From 245423a9c52ed05e220a929a83eaab4d0d8d3dcf Mon Sep 17 00:00:00 2001 From: WildEchoWanderer <116340711+WildEchoWanderer@users.noreply.github.com> Date: Tue, 18 Nov 2025 20:07:39 +0100 Subject: [PATCH 01/27] Implement SABnzbd service definition add sabnzbd --- .../server/services/definitions/sabnzbd.rs | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 backend/src/server/services/definitions/sabnzbd.rs diff --git a/backend/src/server/services/definitions/sabnzbd.rs b/backend/src/server/services/definitions/sabnzbd.rs new file mode 100644 index 00000000..2a069dfa --- /dev/null +++ b/backend/src/server/services/definitions/sabnzbd.rs @@ -0,0 +1,35 @@ +use crate::server::hosts::r#impl::ports::PortBase; +use crate::server::services::definitions::{ServiceDefinitionFactory, create_service}; +use crate::server::services::r#impl::categories::ServiceCategory; +use crate::server::services::r#impl::definitions::ServiceDefinition; +use crate::server::services::r#impl::patterns::Pattern; + +#[derive(Default, Clone, Eq, PartialEq, Hash)] +pub struct SABnzbd; + +impl ServiceDefinition for SABnzdb { + fn name(&self) -> &'static str { + "SABnzdb" + } + fn description(&self) -> &'static str { + "A NZB Files Downloader." + } + fn category(&self) -> ServiceCategory { + ServiceCategory::Media + } + + fn discovery_pattern(&self) -> Pattern<'_> { + Pattern::Endpoint( + PortBase::new_tcp(7777), + "/Content/manifest.json", + "SABnzdb", + None, + ) + } + + fn logo_url(&self) -> &'static str { + "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sabnzbd.svg" + } +} + +inventory::submit!(ServiceDefinitionFactory::new(create_service::)); From 981b34105cceca51b92a0036ecc2d173c15b1749 Mon Sep 17 00:00:00 2001 From: Maya Date: Tue, 18 Nov 2025 16:02:18 -0500 Subject: [PATCH 02/27] feat: basic CRUD operations --- backend/src/server/shared/services/factory.rs | 1 + backend/src/server/shared/storage/factory.rs | 4 +- backend/src/server/shared/storage/generic.rs | 4 +- backend/src/server/shared/storage/tests.rs | 278 +++++++++++++++--- backend/src/server/shared/storage/traits.rs | 10 +- backend/src/server/topology/handlers.rs | 26 +- backend/src/server/topology/service/main.rs | 18 +- backend/src/server/topology/types/base.rs | 13 +- backend/src/server/topology/types/handlers.rs | 21 ++ backend/src/server/topology/types/mod.rs | 2 + backend/src/server/topology/types/storage.rs | 116 ++++++++ 11 files changed, 436 insertions(+), 57 deletions(-) create mode 100644 backend/src/server/topology/types/handlers.rs create mode 100644 backend/src/server/topology/types/storage.rs diff --git a/backend/src/server/shared/services/factory.rs b/backend/src/server/shared/services/factory.rs index 6dd7f23c..eb1c3df6 100644 --- a/backend/src/server/shared/services/factory.rs +++ b/backend/src/server/shared/services/factory.rs @@ -72,6 +72,7 @@ impl ServiceFactory { subnet_service.clone(), group_service.clone(), service_service.clone(), + storage.topology.clone(), )); let network_service = Arc::new(NetworkService::new( diff --git a/backend/src/server/shared/storage/factory.rs b/backend/src/server/shared/storage/factory.rs index 5af2d39e..1ebfb150 100644 --- a/backend/src/server/shared/storage/factory.rs +++ b/backend/src/server/shared/storage/factory.rs @@ -9,7 +9,7 @@ use crate::server::{ discovery::r#impl::base::Discovery, groups::r#impl::base::Group, hosts::r#impl::base::Host, networks::r#impl::Network, organizations::r#impl::base::Organization, services::r#impl::base::Service, shared::storage::generic::GenericPostgresStorage, - subnets::r#impl::base::Subnet, users::r#impl::base::User, + subnets::r#impl::base::Subnet, topology::types::base::Topology, users::r#impl::base::User, }; pub struct StorageFactory { @@ -24,6 +24,7 @@ pub struct StorageFactory { pub services: Arc>, pub organizations: Arc>, pub discovery: Arc>, + pub topology: Arc>, } pub async fn create_session_store( @@ -62,6 +63,7 @@ impl StorageFactory { daemons: Arc::new(GenericPostgresStorage::new(pool.clone())), subnets: Arc::new(GenericPostgresStorage::new(pool.clone())), services: Arc::new(GenericPostgresStorage::new(pool.clone())), + topology: Arc::new(GenericPostgresStorage::new(pool.clone())), }) } } diff --git a/backend/src/server/shared/storage/generic.rs b/backend/src/server/shared/storage/generic.rs index 8968579b..9e6c1c46 100644 --- a/backend/src/server/shared/storage/generic.rs +++ b/backend/src/server/shared/storage/generic.rs @@ -64,7 +64,6 @@ where SqlValue::U16(v) => query.bind(Into::::into(*v)), SqlValue::I32(v) => query.bind(v), SqlValue::Bool(v) => query.bind(v), - SqlValue::Json(v) => query.bind(v), SqlValue::Timestamp(v) => query.bind(v), SqlValue::OptionTimestamp(v) => query.bind(v), SqlValue::UuidArray(v) => query.bind(serde_json::to_value(v)?), @@ -90,6 +89,9 @@ where SqlValue::OptionBillingPlan(v) => query.bind(serde_json::to_value(v)?), SqlValue::OptionBillingPlanStatus(v) => query.bind(serde_json::to_string(v)?), SqlValue::EdgeStyle(v) => query.bind(v.to_string()), + SqlValue::Nodes(v) => query.bind(serde_json::to_value(v)?), + SqlValue::Edges(v) => query.bind(serde_json::to_value(v)?), + SqlValue::TopologyOptions(v) => query.bind(serde_json::to_value(v)?), }; Ok(value) diff --git a/backend/src/server/shared/storage/tests.rs b/backend/src/server/shared/storage/tests.rs index 3b140c7e..b7a517ef 100644 --- a/backend/src/server/shared/storage/tests.rs +++ b/backend/src/server/shared/storage/tests.rs @@ -1,3 +1,144 @@ +use crate::server::{ + api_keys::r#impl::base::ApiKey, daemons::r#impl::base::Daemon, + discovery::r#impl::base::Discovery, groups::r#impl::base::Group, hosts::r#impl::base::Host, + networks::r#impl::Network, organizations::r#impl::base::Organization, + services::r#impl::base::Service, shared::storage::traits::StorableEntity, + subnets::r#impl::base::Subnet, users::r#impl::base::User, +}; +use sqlx::postgres::PgRow; +use std::collections::HashMap; + +// Type alias for the deserialization function +#[allow(dead_code)] +type DeserializeFn = Box Result<(), anyhow::Error> + Send + Sync>; + +// Mapping from table name to deserialization function +#[allow(dead_code)] +fn get_entity_deserializers() -> HashMap<&'static str, DeserializeFn> { + let mut map: HashMap<&'static str, DeserializeFn> = HashMap::new(); + + map.insert( + ApiKey::table_name(), + Box::new(|row| { + ApiKey::from_row(row)?; + Ok(()) + }), + ); + + map.insert( + Daemon::table_name(), + Box::new(|row| { + Daemon::from_row(row)?; + Ok(()) + }), + ); + + map.insert( + Discovery::table_name(), + Box::new(|row| { + Discovery::from_row(row)?; + Ok(()) + }), + ); + + map.insert( + Group::table_name(), + Box::new(|row| { + Group::from_row(row)?; + Ok(()) + }), + ); + + map.insert( + Host::table_name(), + Box::new(|row| { + Host::from_row(row)?; + Ok(()) + }), + ); + + map.insert( + Network::table_name(), + Box::new(|row| { + Network::from_row(row)?; + Ok(()) + }), + ); + + map.insert( + Organization::table_name(), + Box::new(|row| { + Organization::from_row(row)?; + Ok(()) + }), + ); + + map.insert( + Service::table_name(), + Box::new(|row| { + Service::from_row(row)?; + Ok(()) + }), + ); + + map.insert( + Subnet::table_name(), + Box::new(|row| { + Subnet::from_row(row)?; + Ok(()) + }), + ); + + map.insert( + User::table_name(), + Box::new(|row| { + User::from_row(row)?; + Ok(()) + }), + ); + + map +} + +#[tokio::test] +pub async fn test_all_tables_have_entity_mapping() { + use crate::tests::setup_test_db; + + let (pool, _database_url, _container) = setup_test_db().await; + + // Get all tables from information_schema + let tables: Vec = sqlx::query_scalar( + "SELECT table_name FROM information_schema.tables + WHERE table_schema = 'public' + AND table_type = 'BASE TABLE' + AND table_name != '_sqlx_migrations'", + ) + .fetch_all(&pool) + .await + .expect("Failed to fetch table names"); + + let deserializers = get_entity_deserializers(); + + println!("Verifying entity mappings for all tables..."); + + let mut missing_mappings = Vec::new(); + for table in &tables { + if !deserializers.contains_key(table.as_str()) { + missing_mappings.push(table.clone()); + } + } + + if !missing_mappings.is_empty() { + panic!( + "The following tables are missing entity mappings in get_entity_deserializers():\n - {}\n\ + Please add them to the registry.", + missing_mappings.join("\n - ") + ); + } + + println!("✓ All {} tables have entity mappings", tables.len()); +} + #[tokio::test] pub async fn test_database_schema_backward_compatibility() { use crate::tests::SERVER_DB_FIXTURE; @@ -13,7 +154,6 @@ pub async fn test_database_schema_backward_compatibility() { let (pool, database_url, _container) = setup_test_db().await; - // Parse connection details let url = url::Url::parse(&database_url).unwrap(); let host = url.host_str().unwrap(); let port = url.port().unwrap(); @@ -21,7 +161,6 @@ pub async fn test_database_schema_backward_compatibility() { pool.close().await; - // Use psql which understands all pg_dump output including meta-commands let output = Command::new("psql") .arg("-h") .arg(host) @@ -47,49 +186,18 @@ pub async fn test_database_schema_backward_compatibility() { let pool = sqlx::PgPool::connect(&database_url).await.unwrap(); - // Verify tables - assert!( - sqlx::query("SELECT * FROM hosts") - .fetch_all(&pool) - .await - .is_ok() - ); - assert!( - sqlx::query("SELECT * FROM services") - .fetch_all(&pool) - .await - .is_ok() - ); - assert!( - sqlx::query("SELECT * FROM subnets") - .fetch_all(&pool) - .await - .is_ok() - ); - assert!( - sqlx::query("SELECT * FROM groups") - .fetch_all(&pool) - .await - .is_ok() - ); - assert!( - sqlx::query("SELECT * FROM daemons") - .fetch_all(&pool) - .await - .is_ok() - ); - assert!( - sqlx::query("SELECT * FROM networks") - .fetch_all(&pool) - .await - .is_ok() - ); - assert!( - sqlx::query("SELECT * FROM users") - .fetch_all(&pool) - .await - .is_ok() - ); + // Verify tables exist using the deserializers map + let deserializers = get_entity_deserializers(); + for table_name in deserializers.keys() { + assert!( + sqlx::query(&format!("SELECT * FROM {}", table_name)) + .fetch_all(&pool) + .await + .is_ok(), + "Failed to read table: {}", + table_name + ); + } println!("Successfully read all tables from latest release database"); @@ -103,3 +211,83 @@ pub async fn test_database_schema_backward_compatibility() { panic!("No database fixture found at {}", SERVER_DB_FIXTURE); } } + +#[tokio::test] +pub async fn test_struct_deserialization_backward_compatibility() { + use crate::tests::SERVER_DB_FIXTURE; + use crate::tests::setup_test_db; + use std::path::Path; + + let db_path = Path::new(SERVER_DB_FIXTURE); + + if db_path.exists() { + use std::process::Command; + + println!("Testing struct deserialization from migrated old schema"); + + let (pool, database_url, _container) = setup_test_db().await; + + let url = url::Url::parse(&database_url).unwrap(); + let host = url.host_str().unwrap(); + let port = url.port().unwrap(); + let database = url.path().trim_start_matches('/'); + + pool.close().await; + + // Restore old database + let output = Command::new("psql") + .arg("-h") + .arg(host) + .arg("-p") + .arg(port.to_string()) + .arg("-U") + .arg("postgres") + .arg("-d") + .arg(database) + .arg("-f") + .arg(db_path) + .env("PGPASSWORD", "password") + .output() + .expect("Failed to execute psql"); + + assert!( + output.status.success(), + "Failed to restore database:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + + let pool = sqlx::PgPool::connect(&database_url).await.unwrap(); + + // Apply current migrations + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("Failed to apply current schema"); + + println!("Testing deserialization of all entity types..."); + + let deserializers = get_entity_deserializers(); + + for (table_name, deserialize_fn) in deserializers.iter() { + let rows = sqlx::query(&format!("SELECT * FROM {}", table_name)) + .fetch_all(&pool) + .await + .expect(&format!("Failed to fetch {}", table_name)); + + for row in rows.iter() { + deserialize_fn(row) + .expect(&format!("Failed to deserialize row from {}", table_name)); + } + + println!( + "✓ Successfully deserialized {} rows from {}", + rows.len(), + table_name + ); + } + + println!("All entity types deserialized successfully from migrated schema"); + } else { + panic!("No database fixture found at {}", SERVER_DB_FIXTURE); + } +} diff --git a/backend/src/server/shared/storage/traits.rs b/backend/src/server/shared/storage/traits.rs index 2ee617e8..5e746585 100644 --- a/backend/src/server/shared/storage/traits.rs +++ b/backend/src/server/shared/storage/traits.rs @@ -21,7 +21,11 @@ use crate::server::{ }, shared::{storage::filter::EntityFilter, types::entities::EntitySource}, subnets::r#impl::types::SubnetType, - topology::types::edges::EdgeStyle, + topology::types::{ + api::TopologyOptions, + edges::{Edge, EdgeStyle}, + nodes::Node, + }, users::r#impl::permissions::UserOrgPermissions, }; @@ -69,7 +73,6 @@ pub enum SqlValue { I32(i32), U16(u16), Bool(bool), - Json(serde_json::Value), Email(EmailAddress), Timestamp(DateTime), OptionTimestamp(Option>), @@ -94,4 +97,7 @@ pub enum SqlValue { OptionBillingPlanStatus(Option), EdgeStyle(EdgeStyle), DaemonMode(DaemonMode), + Nodes(Vec), + Edges(Vec), + TopologyOptions(TopologyOptions), } diff --git a/backend/src/server/topology/handlers.rs b/backend/src/server/topology/handlers.rs index e5cb9979..c8c4e2e0 100644 --- a/backend/src/server/topology/handlers.rs +++ b/backend/src/server/topology/handlers.rs @@ -1,17 +1,33 @@ use crate::server::{ auth::middleware::AuthenticatedUser, config::AppState, - shared::types::api::{ApiResponse, ApiResult}, - topology::types::api::TopologyOptions, + shared::{ + handlers::traits::{ + create_handler, delete_handler, get_all_handler, get_by_id_handler, update_handler, + }, + types::api::{ApiResponse, ApiResult}, + }, + topology::types::{api::TopologyOptions, base::Topology}, +}; +use axum::{ + Router, + extract::State, + response::Json, + routing::{delete, get, post, put}, }; -use axum::{Router, extract::State, response::Json, routing::post}; use std::sync::Arc; pub fn create_router() -> Router> { - Router::new().route("/", post(get_topology)) + Router::new() + .route("/", post(create_handler::)) + .route("/", get(get_all_handler::)) + .route("/{id}", put(update_handler::)) + .route("/{id}", delete(delete_handler::)) + .route("/{id}", get(get_by_id_handler::)) + .route("/generate", post(generate_topology)) } -async fn get_topology( +async fn generate_topology( State(state): State>, _user: AuthenticatedUser, Json(request): Json, diff --git a/backend/src/server/topology/service/main.rs b/backend/src/server/topology/service/main.rs index 7e0764b2..7019f74e 100644 --- a/backend/src/server/topology/service/main.rs +++ b/backend/src/server/topology/service/main.rs @@ -1,6 +1,7 @@ use std::{collections::HashMap, sync::Arc}; use anyhow::Error; +use async_trait::async_trait; use petgraph::{Graph, graph::NodeIndex}; use uuid::Uuid; @@ -8,7 +9,10 @@ use crate::server::{ groups::service::GroupService, hosts::service::HostService, services::{r#impl::base::Service, service::ServiceService}, - shared::{services::traits::CrudService, storage::filter::EntityFilter}, + shared::{ + services::traits::CrudService, + storage::{filter::EntityFilter, generic::GenericPostgresStorage}, + }, subnets::service::SubnetService, topology::{ service::{ @@ -16,29 +20,39 @@ use crate::server::{ optimizer::main::TopologyOptimizer, planner::subnet_layout_planner::SubnetLayoutPlanner, }, - types::{api::TopologyOptions, edges::Edge, nodes::Node}, + types::{api::TopologyOptions, base::Topology, edges::Edge, nodes::Node}, }, }; pub struct TopologyService { + storage: Arc>, host_service: Arc, subnet_service: Arc, group_service: Arc, service_service: Arc, } +#[async_trait] +impl CrudService for TopologyService { + fn storage(&self) -> &Arc> { + &self.storage + } +} + impl TopologyService { pub fn new( host_service: Arc, subnet_service: Arc, group_service: Arc, service_service: Arc, + storage: Arc>, ) -> Self { Self { host_service, subnet_service, group_service, service_service, + storage, } } diff --git a/backend/src/server/topology/types/base.rs b/backend/src/server/topology/types/base.rs index a02562d7..51f1187b 100644 --- a/backend/src/server/topology/types/base.rs +++ b/backend/src/server/topology/types/base.rs @@ -3,7 +3,7 @@ use crate::server::topology::types::edges::Edge; use crate::server::topology::types::nodes::Node; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use std::hash::Hash; +use std::{fmt::Display, hash::Hash}; use uuid::Uuid; use validator::Validate; @@ -12,6 +12,7 @@ pub struct TopologyBase { #[validate(length(min = 0, max = 100))] pub name: String, // "Home LAN", "VPN Network", etc. pub options: TopologyOptions, + pub network_id: Uuid, pub nodes: Vec, pub edges: Vec, } @@ -24,3 +25,13 @@ pub struct Topology { #[serde(flatten)] pub base: TopologyBase, } + +impl Display for Topology { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Topology {{ id: {}, name: {} }}", + self.id, self.base.name + ) + } +} diff --git a/backend/src/server/topology/types/handlers.rs b/backend/src/server/topology/types/handlers.rs new file mode 100644 index 00000000..43130bb0 --- /dev/null +++ b/backend/src/server/topology/types/handlers.rs @@ -0,0 +1,21 @@ +use crate::server::shared::storage::traits::StorableEntity; +use crate::server::{ + shared::handlers::traits::CrudHandlers, + topology::{service::main::TopologyService, types::base::Topology}, +}; + +impl CrudHandlers for Topology { + type Service = TopologyService; + + fn get_service(state: &crate::server::config::AppState) -> &Self::Service { + &state.services.topology_service + } + + fn entity_name() -> &'static str { + Self::table_name() + } + + fn validate(&self) -> Result<(), String> { + Ok(()) + } +} diff --git a/backend/src/server/topology/types/mod.rs b/backend/src/server/topology/types/mod.rs index cd328ad7..758c56bf 100644 --- a/backend/src/server/topology/types/mod.rs +++ b/backend/src/server/topology/types/mod.rs @@ -1,5 +1,7 @@ pub mod api; pub mod base; pub mod edges; +pub mod handlers; pub mod layout; pub mod nodes; +pub mod storage; diff --git a/backend/src/server/topology/types/storage.rs b/backend/src/server/topology/types/storage.rs new file mode 100644 index 00000000..26902f41 --- /dev/null +++ b/backend/src/server/topology/types/storage.rs @@ -0,0 +1,116 @@ +use chrono::{DateTime, Utc}; +use sqlx::Row; +use sqlx::postgres::PgRow; +use uuid::Uuid; + +use crate::server::{ + shared::storage::traits::{SqlValue, StorableEntity}, + topology::types::{ + api::TopologyOptions, + base::{Topology, TopologyBase}, + edges::Edge, + nodes::Node, + }, +}; + +impl StorableEntity for Topology { + type BaseData = TopologyBase; + + fn table_name() -> &'static str { + "topologies" + } + + fn get_base(&self) -> Self::BaseData { + self.base.clone() + } + + fn new(base: Self::BaseData) -> Self { + let now = chrono::Utc::now(); + + Self { + id: Uuid::new_v4(), + created_at: now, + updated_at: now, + base, + } + } + + fn id(&self) -> Uuid { + self.id + } + + fn created_at(&self) -> DateTime { + self.created_at + } + + fn updated_at(&self) -> DateTime { + self.updated_at + } + + fn set_updated_at(&mut self, time: DateTime) { + self.updated_at = time; + } + + fn to_params(&self) -> Result<(Vec<&'static str>, Vec), anyhow::Error> { + let Self { + id, + created_at, + updated_at, + base: + Self::BaseData { + name, + network_id, + nodes, + edges, + options, + }, + } = self.clone(); + + Ok(( + vec![ + "id", + "created_at", + "updated_at", + "name", + "network_id", + "nodes", + "edges", + "options", + ], + vec![ + SqlValue::Uuid(id), + SqlValue::Timestamp(created_at), + SqlValue::Timestamp(updated_at), + SqlValue::String(name), + SqlValue::Uuid(network_id), + SqlValue::Nodes(nodes), + SqlValue::Edges(edges), + SqlValue::TopologyOptions(options), + ], + )) + } + + fn from_row(row: &PgRow) -> Result { + // Parse JSON fields safely + let nodes: Vec = serde_json::from_str(&row.get::("nodes")) + .map_err(|e| anyhow::anyhow!("Failed to deserialize nodes: {}", e))?; + let edges: Vec = serde_json::from_str(&row.get::("edges")) + .map_err(|e| anyhow::anyhow!("Failed to deserialize edges: {}", e))?; + let options: TopologyOptions = + serde_json::from_value(row.get::("options")) + .map_err(|e| anyhow::anyhow!("Failed to deserialize options: {}", e))?; + + Ok(Topology { + id: row.get("id"), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + base: TopologyBase { + name: row.get("name"), + network_id: row.get("network_id"), + nodes, + edges, + options, + }, + }) + } +} From f90c1bcb7fd09eb4c06d1843503321e425034884 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 19 Nov 2025 09:43:42 -0500 Subject: [PATCH 03/27] Update backend/src/server/services/definitions/sabnzbd.rs --- backend/src/server/services/definitions/sabnzbd.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/server/services/definitions/sabnzbd.rs b/backend/src/server/services/definitions/sabnzbd.rs index 2a069dfa..482806c1 100644 --- a/backend/src/server/services/definitions/sabnzbd.rs +++ b/backend/src/server/services/definitions/sabnzbd.rs @@ -7,7 +7,7 @@ use crate::server::services::r#impl::patterns::Pattern; #[derive(Default, Clone, Eq, PartialEq, Hash)] pub struct SABnzbd; -impl ServiceDefinition for SABnzdb { +impl ServiceDefinition for SABnzbd { fn name(&self) -> &'static str { "SABnzdb" } From 7d82c9e2d6a68ab891406e42e5ca4e67a894a385 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 19 Nov 2025 09:43:52 -0500 Subject: [PATCH 04/27] Update backend/src/server/services/definitions/sabnzbd.rs --- backend/src/server/services/definitions/sabnzbd.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/server/services/definitions/sabnzbd.rs b/backend/src/server/services/definitions/sabnzbd.rs index 482806c1..23bffdc8 100644 --- a/backend/src/server/services/definitions/sabnzbd.rs +++ b/backend/src/server/services/definitions/sabnzbd.rs @@ -9,7 +9,7 @@ pub struct SABnzbd; impl ServiceDefinition for SABnzbd { fn name(&self) -> &'static str { - "SABnzdb" + "SABnzbd" } fn description(&self) -> &'static str { "A NZB Files Downloader." From 0a74e905df39541525ac8a789d02098160405adf Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 19 Nov 2025 09:43:59 -0500 Subject: [PATCH 05/27] Update backend/src/server/services/definitions/sabnzbd.rs --- backend/src/server/services/definitions/sabnzbd.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/server/services/definitions/sabnzbd.rs b/backend/src/server/services/definitions/sabnzbd.rs index 23bffdc8..3fbbfaf7 100644 --- a/backend/src/server/services/definitions/sabnzbd.rs +++ b/backend/src/server/services/definitions/sabnzbd.rs @@ -22,7 +22,7 @@ impl ServiceDefinition for SABnzbd { Pattern::Endpoint( PortBase::new_tcp(7777), "/Content/manifest.json", - "SABnzdb", + "SABnzbd", None, ) } From 8b3e7516d984db1ad4f9ed7872db114fb04397a0 Mon Sep 17 00:00:00 2001 From: Maya Date: Wed, 19 Nov 2025 12:08:01 -0500 Subject: [PATCH 06/27] Wiring frontend to topology backend --- .../20251118225043_save-topology.sql | 25 ++ backend/src/bin/server.rs | 23 +- backend/src/server/api_keys/handlers.rs | 61 +-- backend/src/server/api_keys/service.rs | 58 ++- backend/src/server/auth/handlers.rs | 8 +- backend/src/server/auth/middleware.rs | 68 +++- backend/src/server/auth/service.rs | 60 ++- backend/src/server/billing/handlers.rs | 8 +- backend/src/server/billing/service.rs | 36 +- backend/src/server/daemons/handlers.rs | 109 +++--- backend/src/server/daemons/service.rs | 65 +++- backend/src/server/discovery/handlers.rs | 10 +- backend/src/server/discovery/service.rs | 191 +++++++--- backend/src/server/groups/impl/base.rs | 4 +- backend/src/server/groups/service.rs | 31 +- backend/src/server/hosts/handlers.rs | 53 +-- backend/src/server/hosts/service.rs | 347 ++++++++++++------ backend/src/server/hosts/tests.rs | 37 +- backend/src/server/networks/handlers.rs | 2 +- backend/src/server/networks/service.rs | 40 +- backend/src/server/organizations/handlers.rs | 26 +- .../src/server/organizations/impl/invites.rs | 8 +- backend/src/server/organizations/service.rs | 97 ++++- backend/src/server/services/impl/base.rs | 2 +- backend/src/server/services/service.rs | 273 ++++++++++---- backend/src/server/services/tests.rs | 35 +- backend/src/server/shared/entities.rs | 22 +- backend/src/server/shared/events/bus.rs | 132 +++++++ backend/src/server/shared/events/mod.rs | 2 + backend/src/server/shared/events/types.rs | 38 ++ backend/src/server/shared/handlers/factory.rs | 44 ++- backend/src/server/shared/handlers/traits.rs | 48 +-- backend/src/server/shared/mod.rs | 1 + backend/src/server/shared/services/factory.rs | 45 ++- backend/src/server/shared/services/traits.rs | 115 ++++-- backend/src/server/shared/storage/factory.rs | 4 +- backend/src/server/shared/storage/generic.rs | 10 +- backend/src/server/shared/storage/traits.rs | 27 +- backend/src/server/subnets/handlers.rs | 62 +++- backend/src/server/subnets/impl/base.rs | 2 +- backend/src/server/subnets/service.rs | 82 ++++- backend/src/server/topology/handlers.rs | 139 ++++++- .../src/server/topology/service/context.rs | 5 +- backend/src/server/topology/service/main.rs | 62 +++- backend/src/server/topology/service/mod.rs | 1 + .../service/planner/subnet_layout_planner.rs | 4 +- .../src/server/topology/service/subscriber.rs | 97 +++++ backend/src/server/topology/types/api.rs | 16 +- backend/src/server/topology/types/base.rs | 120 +++++- backend/src/server/topology/types/storage.rs | 83 ++++- backend/src/server/users/handlers.rs | 2 +- backend/src/server/users/service.rs | 109 +++--- .../api_keys/components/ApiKeyTab.svelte | 19 +- ui/src/lib/features/api_keys/store.ts | 4 +- .../daemons/components/DaemonTab.svelte | 18 +- .../tabs/DiscoveryScheduledTab.svelte | 18 +- .../groups/components/GroupTab.svelte | 18 +- ui/src/lib/features/groups/store.ts | 4 +- .../features/hosts/components/HostTab.svelte | 18 +- ui/src/lib/features/hosts/store.ts | 4 +- .../networks/components/NetworksTab.svelte | 18 +- ui/src/lib/features/networks/store.ts | 8 +- ui/src/lib/features/services/store.ts | 4 +- .../subnets/components/SubnetTab.svelte | 18 +- ui/src/lib/features/subnets/store.ts | 4 +- .../topology/components/ExportButton.svelte | 3 +- .../components/RefreshConflictsModal.svelte | 145 ++++++++ .../components/TopologyDetailsForm.svelte | 36 ++ .../topology/components/TopologyModal.svelte | 76 ++++ .../topology/components/TopologyTab.svelte | 287 ++++++++++++++- .../panel/inspectors/InspectorNode.svelte | 4 +- .../edges/InspectorEdgeGroup.svelte | 23 +- .../InspectorEdgeHostVirtualization.svelte | 15 +- .../edges/InspectorEdgeInterface.svelte | 12 +- .../InspectorEdgeServiceVirtualization.svelte | 31 +- .../nodes/InspectorInterfaceNode.svelte | 18 +- .../nodes/InspectorSubnetNode.svelte | 5 +- .../panel/options/OptionsContent.svelte | 51 +-- .../{ => visualization}/CustomEdge.svelte | 15 +- .../{ => visualization}/InterfaceNode.svelte | 58 ++- .../{ => visualization}/SubnetNode.svelte | 13 +- .../{ => visualization}/TopologyViewer.svelte | 12 +- ui/src/lib/features/topology/store.ts | 174 +++++++-- ui/src/lib/features/topology/types/base.ts | 92 +++-- .../features/users/components/UserTab.svelte | 30 +- .../shared/components/data/GithubStars.svelte | 2 + .../shared/components/layout/TabHeader.svelte | 21 +- 87 files changed, 3191 insertions(+), 1036 deletions(-) create mode 100644 backend/migrations/20251118225043_save-topology.sql create mode 100644 backend/src/server/shared/events/bus.rs create mode 100644 backend/src/server/shared/events/mod.rs create mode 100644 backend/src/server/shared/events/types.rs create mode 100644 backend/src/server/topology/service/subscriber.rs create mode 100644 ui/src/lib/features/topology/components/RefreshConflictsModal.svelte create mode 100644 ui/src/lib/features/topology/components/TopologyDetailsForm.svelte create mode 100644 ui/src/lib/features/topology/components/TopologyModal.svelte rename ui/src/lib/features/topology/components/panel/inspectors/{edges => }/nodes/InspectorInterfaceNode.svelte (80%) rename ui/src/lib/features/topology/components/panel/inspectors/{edges => }/nodes/InspectorSubnetNode.svelte (79%) rename ui/src/lib/features/topology/components/{ => visualization}/CustomEdge.svelte (94%) rename ui/src/lib/features/topology/components/{ => visualization}/InterfaceNode.svelte (73%) rename ui/src/lib/features/topology/components/{ => visualization}/SubnetNode.svelte (94%) rename ui/src/lib/features/topology/components/{ => visualization}/TopologyViewer.svelte (95%) diff --git a/backend/migrations/20251118225043_save-topology.sql b/backend/migrations/20251118225043_save-topology.sql new file mode 100644 index 00000000..691e399d --- /dev/null +++ b/backend/migrations/20251118225043_save-topology.sql @@ -0,0 +1,25 @@ +CREATE TABLE topologies ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + network_id UUID NOT NULL REFERENCES networks(id) ON DELETE CASCADE, + name TEXT NOT NULL, + edges JSONB NOT NULL, + nodes JSONB NOT NULL, + options JSONB NOT NULL, + hosts JSONB NOT NULL, + subnets JSONB NOT NULL, + services JSONB NOT NULL, + groups JSONB NOT NULL, + is_stale BOOLEAN, + last_refreshed TIMESTAMPTZ NOT NULL DEFAULT NOW(), + is_locked BOOLEAN, + locked_at TIMESTAMPTZ, + locked_by UUID, + removed_hosts UUID[], + removed_services UUID[], + removed_subnets UUID[], + removed_groups UUID[], + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_topologies_network ON topologies(network_id); \ No newline at end of file diff --git a/backend/src/bin/server.rs b/backend/src/bin/server.rs index 5581d6e1..db87a9ac 100644 --- a/backend/src/bin/server.rs +++ b/backend/src/bin/server.rs @@ -6,6 +6,7 @@ use axum::{ }; use clap::Parser; use netvisor::server::{ + auth::middleware::AuthenticatedEntity, billing::types::base::{BillingPlan, BillingRate, Price}, config::{AppState, CliArgs, ServerConfig}, organizations::r#impl::base::{Organization, OrganizationBase}, @@ -305,17 +306,23 @@ async fn main() -> anyhow::Result<()> { // First load - populate user and org if all_users.is_empty() { let organization = organization_service - .create(Organization::new(OrganizationBase { - stripe_customer_id: None, - plan: None, - plan_status: None, - name: "My Organization".to_string(), - is_onboarded: false, - })) + .create( + Organization::new(OrganizationBase { + stripe_customer_id: None, + plan: None, + plan_status: None, + name: "My Organization".to_string(), + is_onboarded: false, + }), + AuthenticatedEntity::System, + ) .await?; user_service - .create_user(User::new(UserBase::new_seed(organization.id))) + .create( + User::new(UserBase::new_seed(organization.id)), + AuthenticatedEntity::System, + ) .await?; } else { tracing::debug!("Server already has data, skipping seed data"); diff --git a/backend/src/server/api_keys/handlers.rs b/backend/src/server/api_keys/handlers.rs index e78b90b1..ddb21cc3 100644 --- a/backend/src/server/api_keys/handlers.rs +++ b/backend/src/server/api_keys/handlers.rs @@ -39,14 +39,17 @@ pub async fn create_handler( ); let service = ApiKey::get_service(&state); - let api_key = service.create(api_key).await.map_err(|e| { - tracing::error!( - error = %e, - user_id = %user.user_id, - "Failed to create API key" - ); - ApiError::internal_error(&e.to_string()) - })?; + let api_key = service + .create(api_key, user.clone().into()) + .await + .map_err(|e| { + tracing::error!( + error = %e, + user_id = %user.user_id, + "Failed to create API key" + ); + ApiError::internal_error(&e.to_string()) + })?; tracing::info!( api_key_id = %api_key.id, @@ -73,15 +76,18 @@ pub async fn rotate_key_handler( ); let service = ApiKey::get_service(&state); - let key = service.rotate_key(api_key_id).await.map_err(|e| { - tracing::error!( - api_key_id = %api_key_id, - user_id = %user.user_id, - error = %e, - "Failed to rotate API key" - ); - ApiError::internal_error(&e.to_string()) - })?; + let key = service + .rotate_key(api_key_id, user.clone().into()) + .await + .map_err(|e| { + tracing::error!( + api_key_id = %api_key_id, + user_id = %user.user_id, + error = %e, + "Failed to rotate API key" + ); + ApiError::internal_error(&e.to_string()) + })?; tracing::info!( api_key_id = %api_key_id, @@ -131,15 +137,18 @@ pub async fn update_handler( // Preserve the key - don't allow it to be changed via update request.base.key = existing.base.key; - let updated = service.update(&mut request).await.map_err(|e| { - tracing::error!( - api_key_id = %id, - user_id = %user.user_id, - error = %e, - "Failed to update API key" - ); - ApiError::internal_error(&e.to_string()) - })?; + let updated = service + .update(&mut request, user.clone().into()) + .await + .map_err(|e| { + tracing::error!( + api_key_id = %id, + user_id = %user.user_id, + error = %e, + "Failed to update API key" + ); + ApiError::internal_error(&e.to_string()) + })?; tracing::info!( api_key_id = %id, diff --git a/backend/src/server/api_keys/service.rs b/backend/src/server/api_keys/service.rs index d6792067..98ef9245 100644 --- a/backend/src/server/api_keys/service.rs +++ b/backend/src/server/api_keys/service.rs @@ -1,11 +1,18 @@ use anyhow::{Result, anyhow}; use async_trait::async_trait; +use chrono::Utc; use std::sync::Arc; use uuid::Uuid; use crate::server::{ api_keys::r#impl::base::{ApiKey, ApiKeyBase}, + auth::middleware::AuthenticatedEntity, shared::{ + entities::Entity, + events::{ + bus::EventBus, + types::{EntityEvent, EntityOperation}, + }, services::traits::CrudService, storage::{ generic::GenericPostgresStorage, @@ -16,6 +23,7 @@ use crate::server::{ pub struct ApiKeyService { storage: Arc>, + event_bus: Arc, } #[async_trait] @@ -23,18 +31,22 @@ impl CrudService for ApiKeyService { fn storage(&self) -> &Arc> { &self.storage } -} -impl ApiKeyService { - pub fn new(storage: Arc>) -> Self { - Self { storage } + fn event_bus(&self) -> &Arc { + &self.event_bus } - pub fn generate_api_key(&self) -> String { - Uuid::new_v4().simple().to_string() + fn entity_type() -> Entity { + Entity::ApiKey + } + fn get_network_id(&self, entity: &ApiKey) -> Option { + Some(entity.base.network_id) + } + fn get_organization_id(&self, _entity: &ApiKey) -> Option { + None } - pub async fn create(&self, api_key: ApiKey) -> Result { + async fn create(&self, api_key: ApiKey, authentication: AuthenticatedEntity) -> Result { let key = self.generate_api_key(); tracing::debug!( @@ -54,6 +66,20 @@ impl ApiKeyService { let created = self.storage.create(&api_key).await?; + self.event_bus() + .publish(EntityEvent { + id: Uuid::new_v4(), + entity_type: Self::entity_type(), + entity_id: created.id(), + network_id: self.get_network_id(&created), + organization_id: self.get_organization_id(&created), + operation: EntityOperation::Created, + timestamp: Utc::now(), + metadata: serde_json::json!({}), + authentication, + }) + .await?; + tracing::info!( api_key_id = %created.id, api_key_name = %created.base.name, @@ -63,8 +89,22 @@ impl ApiKeyService { Ok(created) } +} + +impl ApiKeyService { + pub fn new(storage: Arc>, event_bus: Arc) -> Self { + Self { storage, event_bus } + } + + pub fn generate_api_key(&self) -> String { + Uuid::new_v4().simple().to_string() + } - pub async fn rotate_key(&self, api_key_id: Uuid) -> Result { + pub async fn rotate_key( + &self, + api_key_id: Uuid, + authentication: AuthenticatedEntity, + ) -> Result { tracing::info!( api_key_id = %api_key_id, "Rotating API key" @@ -75,7 +115,7 @@ impl ApiKeyService { api_key.base.key = new_key.clone(); - self.update(&mut api_key).await?; + let _updated = self.update(&mut api_key, authentication).await?; tracing::info!( api_key_id = %api_key_id, diff --git a/backend/src/server/auth/handlers.rs b/backend/src/server/auth/handlers.rs index 72ca1d19..1c08df9d 100644 --- a/backend/src/server/auth/handlers.rs +++ b/backend/src/server/auth/handlers.rs @@ -5,6 +5,7 @@ use crate::server::{ ForgotPasswordRequest, LoginRequest, OidcAuthorizeParams, OidcCallbackParams, RegisterRequest, ResetPasswordRequest, UpdateEmailPasswordRequest, }, + middleware::AuthenticatedUser, oidc::OidcPendingAuth, service::hash_password, }, @@ -123,6 +124,7 @@ async fn get_current_user( async fn update_password_auth( State(state): State>, session: Session, + auth_user: AuthenticatedUser, Json(request): Json, ) -> ApiResult>> { let user_id: Uuid = session @@ -146,7 +148,11 @@ async fn update_password_auth( user.base.email = email } - state.services.user_service.update(&mut user).await?; + state + .services + .user_service + .update(&mut user, auth_user.into()) + .await?; Ok(Json(ApiResponse::success(user))) } diff --git a/backend/src/server/auth/middleware.rs b/backend/src/server/auth/middleware.rs index 71349163..d8962349 100644 --- a/backend/src/server/auth/middleware.rs +++ b/backend/src/server/auth/middleware.rs @@ -11,6 +11,8 @@ use axum::{ response::{IntoResponse, Response}, }; use chrono::Utc; +use serde::Deserialize; +use serde::Serialize; use tower_sessions::Session; use uuid::Uuid; @@ -23,7 +25,7 @@ impl IntoResponse for AuthError { } /// Represents either an authenticated user or daemon -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum AuthenticatedEntity { User { user_id: Uuid, @@ -31,7 +33,11 @@ pub enum AuthenticatedEntity { permissions: UserOrgPermissions, network_ids: Vec, }, - Daemon(Uuid), // network_id + Daemon { + network_id: Uuid, + api_key_id: Uuid, + }, // network_id + System, } impl AuthenticatedEntity { @@ -46,15 +52,23 @@ impl AuthenticatedEntity { pub fn entity_id(&self) -> String { match self { AuthenticatedEntity::User { user_id, .. } => user_id.to_string(), - AuthenticatedEntity::Daemon(network_id) => format!("Daemon for network {}", network_id), + AuthenticatedEntity::Daemon { + network_id, + api_key_id, + } => format!( + "Daemon for network {} using API key {}", + network_id, api_key_id + ), + AuthenticatedEntity::System => "System".to_string(), } } /// Get network_ids that daemon / user have access to pub fn network_ids(&self) -> Vec { match self { - AuthenticatedEntity::Daemon(id) => vec![*id], + AuthenticatedEntity::Daemon { network_id, .. } => vec![*network_id], AuthenticatedEntity::User { network_ids, .. } => network_ids.clone(), + AuthenticatedEntity::System => vec![], } } @@ -65,7 +79,7 @@ impl AuthenticatedEntity { /// Check if this is a daemon pub fn is_daemon(&self) -> bool { - matches!(self, AuthenticatedEntity::Daemon(_)) + matches!(self, AuthenticatedEntity::Daemon { .. }) } } @@ -94,7 +108,7 @@ where { let network_id = api_key.base.network_id; let service = app_state.services.api_key_service.clone(); - + let api_key_id = api_key.id; // Check expiration if let Some(expires_at) = api_key.base.expires_at && chrono::Utc::now() > expires_at @@ -102,7 +116,9 @@ where // Update enabled asynchronously (don't block auth) api_key.base.is_enabled = false; tokio::spawn(async move { - let _ = service.update(&mut api_key).await; + let _ = service + .update(&mut api_key, AuthenticatedEntity::System) + .await; }); return Err(AuthError(ApiError::unauthorized( "API key has expired".to_string(), @@ -118,10 +134,15 @@ where // Update last used asynchronously (don't block auth) api_key.base.last_used = Some(Utc::now()); tokio::spawn(async move { - let _ = service.update(&mut api_key).await; + let _ = service + .update(&mut api_key, AuthenticatedEntity::System) + .await; }); - return Ok(AuthenticatedEntity::Daemon(network_id)); + return Ok(AuthenticatedEntity::Daemon { + network_id, + api_key_id, + }); } // Invalid API key return Err(AuthError(ApiError::unauthorized( @@ -169,6 +190,7 @@ where } /// Extractor that only accepts authenticated users (rejects daemons) +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct AuthenticatedUser { pub user_id: Uuid, pub organization_id: Uuid, @@ -208,7 +230,7 @@ where permissions, network_ids, }), - AuthenticatedEntity::Daemon(_) => Err(AuthError(ApiError::unauthorized( + _ => Err(AuthError(ApiError::unauthorized( "User authentication required".to_string(), ))), } @@ -216,11 +238,18 @@ where } /// Extractor that only accepts authenticated daemons (rejects users) -pub struct AuthenticatedDaemon(pub Uuid); +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AuthenticatedDaemon { + pub network_id: Uuid, + pub api_key_id: Uuid, +} impl From for AuthenticatedEntity { fn from(value: AuthenticatedDaemon) -> Self { - AuthenticatedEntity::Daemon(value.0) + AuthenticatedEntity::Daemon { + network_id: value.network_id, + api_key_id: value.api_key_id, + } } } @@ -234,8 +263,14 @@ where let entity = AuthenticatedEntity::from_request_parts(parts, state).await?; match entity { - AuthenticatedEntity::Daemon(network_id) => Ok(AuthenticatedDaemon(network_id)), - AuthenticatedEntity::User { .. } => Err(AuthError(ApiError::unauthorized( + AuthenticatedEntity::Daemon { + network_id, + api_key_id, + } => Ok(AuthenticatedDaemon { + network_id, + api_key_id, + }), + _ => Err(AuthError(ApiError::unauthorized( "Daemon authentication required".to_string(), ))), } @@ -270,13 +305,16 @@ where entity: user.into(), }) } - AuthenticatedEntity::Daemon(network_id) => { + AuthenticatedEntity::Daemon { network_id, .. } => { // Daemons only have access to their single network Ok(MemberOrDaemon { network_ids: vec![network_id], entity, }) } + _ => Err(AuthError(ApiError::forbidden( + "Member or Daemon permission required", + ))), } } } diff --git a/backend/src/server/auth/service.rs b/backend/src/server/auth/service.rs index edcfe786..57e96313 100644 --- a/backend/src/server/auth/service.rs +++ b/backend/src/server/auth/service.rs @@ -1,5 +1,8 @@ use crate::server::{ - auth::r#impl::api::{LoginRequest, RegisterRequest}, + auth::{ + r#impl::api::{LoginRequest, RegisterRequest}, + middleware::AuthenticatedEntity, + }, email::service::EmailService, organizations::{ r#impl::base::{Organization, OrganizationBase}, @@ -10,7 +13,10 @@ use crate::server::{ storage::{filter::EntityFilter, traits::StorableEntity}, }, users::{ - r#impl::{base::User, permissions::UserOrgPermissions}, + r#impl::{ + base::{User, UserBase}, + permissions::UserOrgPermissions, + }, service::UserService, }, }; @@ -141,22 +147,27 @@ impl AuthService { seed_user.base.oidc_linked_at = Some(chrono::Utc::now()); } - self.user_service.update(&mut seed_user).await + self.user_service + .update(&mut seed_user, AuthenticatedEntity::System) + .await } else { // If being invited, use provied org ID, otherwise create a new one - let org_id = if let Some(org_id) = org_id { + let organization_id = if let Some(org_id) = org_id { org_id } else { // Create new organization for this user let organization = self .organization_service - .create(Organization::new(OrganizationBase { - stripe_customer_id: None, - name: "My Organization".to_string(), - plan: None, - plan_status: None, - is_onboarded: false, - })) + .create( + Organization::new(OrganizationBase { + stripe_customer_id: None, + name: "My Organization".to_string(), + plan: None, + plan_status: None, + is_onboarded: false, + }), + AuthenticatedEntity::System, + ) .await?; organization.id }; @@ -167,11 +178,28 @@ impl AuthService { // Create user based on auth method if let Some(hash) = password_hash { self.user_service - .create_user_with_password(email, hash, org_id, permissions) + .create( + User::new(UserBase::new_password( + email, + hash, + organization_id, + permissions, + )), + AuthenticatedEntity::System, + ) .await - } else if let Some(subject) = oidc_subject { + } else if let Some(oidc_subject) = oidc_subject { self.user_service - .create_user_with_oidc(email, subject, oidc_provider, org_id, permissions) + .create( + User::new(UserBase::new_oidc( + email, + oidc_subject, + oidc_provider, + organization_id, + permissions, + )), + AuthenticatedEntity::System, + ) .await } else { Err(anyhow!("Must provide either password or OIDC credentials")) @@ -318,7 +346,9 @@ impl AuthService { // Update password let hashed_password = hash_password(new_password)?; user.set_password(hashed_password); - self.user_service.update(&mut user).await?; + self.user_service + .update(&mut user, AuthenticatedEntity::System) + .await?; Ok(user.clone()) } diff --git a/backend/src/server/billing/handlers.rs b/backend/src/server/billing/handlers.rs index d95b8b5d..02b4938f 100644 --- a/backend/src/server/billing/handlers.rs +++ b/backend/src/server/billing/handlers.rs @@ -54,7 +54,13 @@ async fn create_checkout_session( } let session = billing_service - .create_checkout_session(user.organization_id, request.plan, success_url, cancel_url) + .create_checkout_session( + user.organization_id, + request.plan, + success_url, + cancel_url, + user.into(), + ) .await?; Ok(Json(ApiResponse::success(session.url.unwrap()))) diff --git a/backend/src/server/billing/service.rs b/backend/src/server/billing/service.rs index 15b6f12c..8ea1994f 100644 --- a/backend/src/server/billing/service.rs +++ b/backend/src/server/billing/service.rs @@ -1,3 +1,4 @@ +use crate::server::auth::middleware::AuthenticatedEntity; use crate::server::billing::types::base::BillingPlan; use crate::server::networks::service::NetworkService; use crate::server::organizations::service::OrganizationService; @@ -157,9 +158,12 @@ impl BillingService { plan: BillingPlan, success_url: String, cancel_url: String, + authentication: AuthenticatedEntity, ) -> Result { // Get or create Stripe customer - let customer_id = self.get_or_create_customer(organization_id).await?; + let customer_id = self + .get_or_create_customer(organization_id, authentication) + .await?; tracing::info!( organization_id = %organization_id, @@ -227,7 +231,11 @@ impl BillingService { } /// Get existing customer or create new one - async fn get_or_create_customer(&self, organization_id: Uuid) -> Result { + async fn get_or_create_customer( + &self, + organization_id: Uuid, + authentication: AuthenticatedEntity, + ) -> Result { // Check if org already has stripe_customer_id let mut organization = self .organization_service @@ -264,7 +272,9 @@ impl BillingService { organization.base.stripe_customer_id = Some(customer.id.to_string()); - self.organization_service.update(&mut organization).await?; + self.organization_service + .update(&mut organization, authentication) + .await?; Ok(customer.id) } @@ -356,7 +366,9 @@ impl BillingService { for network in networks { if !keep_ids.contains(&network.id) { - self.network_service.delete(&network.id).await?; + self.network_service + .delete(&network.id, AuthenticatedEntity::System) + .await?; tracing::info!( organization_id = %org_id, network_id = %network.id, @@ -375,7 +387,9 @@ impl BillingService { for user in &mut users { if user.base.permissions != UserOrgPermissions::Owner { user.base.permissions = UserOrgPermissions::None; - self.user_service.update(user).await?; + self.user_service + .update(user, AuthenticatedEntity::System) + .await?; } } } @@ -387,7 +401,9 @@ impl BillingService { for user in &mut users { if user.base.permissions != UserOrgPermissions::Owner { user.base.permissions = UserOrgPermissions::Visualizer; - self.user_service.update(user).await?; + self.user_service + .update(user, AuthenticatedEntity::System) + .await?; } } } @@ -398,7 +414,9 @@ impl BillingService { organization.base.plan_status = Some(sub.status); organization.base.plan = Some(plan); - self.organization_service.update(&mut organization).await?; + self.organization_service + .update(&mut organization, AuthenticatedEntity::System) + .await?; tracing::info!( "Updated organization {} subscription status to {}", @@ -427,7 +445,9 @@ impl BillingService { organization.base.plan_status = Some(SubscriptionStatus::Canceled); - self.organization_service.update(&mut organization).await?; + self.organization_service + .update(&mut organization, AuthenticatedEntity::System) + .await?; tracing::info!( organization_id = %org_id, diff --git a/backend/src/server/daemons/handlers.rs b/backend/src/server/daemons/handlers.rs index 72e814be..99a01802 100644 --- a/backend/src/server/daemons/handlers.rs +++ b/backend/src/server/daemons/handlers.rs @@ -1,5 +1,5 @@ use crate::server::{ - auth::middleware::AuthenticatedDaemon, + auth::middleware::{AuthenticatedDaemon, AuthenticatedEntity}, config::AppState, daemons::r#impl::{ api::{ @@ -50,7 +50,7 @@ const DAILY_MIDNIGHT_CRON: &str = "0 0 0 * * *"; /// Register a new daemon async fn register_daemon( State(state): State>, - _daemon: AuthenticatedDaemon, + auth_daemon: AuthenticatedDaemon, Json(request): Json, ) -> ApiResult>> { let service = &state.services.daemon_service; @@ -63,7 +63,7 @@ async fn register_daemon( let (host, _) = state .services .host_service - .create_host_with_services(dummy_host, Vec::new()) + .create_host_with_services(dummy_host, Vec::new(), auth_daemon.clone().into()) .await?; let mut daemon = Daemon::new(DaemonBase { @@ -79,69 +79,82 @@ async fn register_daemon( daemon.id = request.daemon_id; let registered_daemon = service - .create(daemon) + .create(daemon, auth_daemon.into()) .await .map_err(|e| ApiError::internal_error(&format!("Failed to register daemon: {}", e)))?; let discovery_service = state.services.discovery_service.clone(); let self_report_discovery = discovery_service - .create_discovery(Discovery::new(DiscoveryBase { - run_type: RunType::Scheduled { - cron_schedule: DAILY_MIDNIGHT_CRON.to_string(), - last_run: None, - enabled: true, - }, - discovery_type: DiscoveryType::SelfReport { host_id: host.id }, - name: format!("Self Report @ {}", request.daemon_ip), - daemon_id: request.daemon_id, - network_id: request.network_id, - })) + .create_discovery( + Discovery::new(DiscoveryBase { + run_type: RunType::Scheduled { + cron_schedule: DAILY_MIDNIGHT_CRON.to_string(), + last_run: None, + enabled: true, + }, + discovery_type: DiscoveryType::SelfReport { host_id: host.id }, + name: format!("Self Report @ {}", request.daemon_ip), + daemon_id: request.daemon_id, + network_id: request.network_id, + }), + AuthenticatedEntity::System, + ) .await?; discovery_service - .start_session(self_report_discovery) + .start_session(self_report_discovery, AuthenticatedEntity::System) .await?; if request.capabilities.has_docker_socket { let docker_discovery = discovery_service - .create_discovery(Discovery::new(DiscoveryBase { + .create_discovery( + Discovery::new(DiscoveryBase { + run_type: RunType::Scheduled { + cron_schedule: DAILY_MIDNIGHT_CRON.to_string(), + last_run: None, + enabled: true, + }, + discovery_type: DiscoveryType::Docker { + host_id: host.id, + host_naming_fallback: HostNamingFallback::BestService, + }, + name: format!("Docker @ {}", request.daemon_ip), + daemon_id: request.daemon_id, + network_id: request.network_id, + }), + AuthenticatedEntity::System, + ) + .await?; + + discovery_service + .start_session(docker_discovery, AuthenticatedEntity::System) + .await?; + } + + let network_discovery = discovery_service + .create_discovery( + Discovery::new(DiscoveryBase { run_type: RunType::Scheduled { cron_schedule: DAILY_MIDNIGHT_CRON.to_string(), last_run: None, enabled: true, }, - discovery_type: DiscoveryType::Docker { - host_id: host.id, + discovery_type: DiscoveryType::Network { + subnet_ids: None, host_naming_fallback: HostNamingFallback::BestService, }, - name: format!("Docker @ {}", request.daemon_ip), + name: format!("Network Scan @ {}", request.daemon_ip), daemon_id: request.daemon_id, network_id: request.network_id, - })) - .await?; - - discovery_service.start_session(docker_discovery).await?; - } - - let network_discovery = discovery_service - .create_discovery(Discovery::new(DiscoveryBase { - run_type: RunType::Scheduled { - cron_schedule: DAILY_MIDNIGHT_CRON.to_string(), - last_run: None, - enabled: true, - }, - discovery_type: DiscoveryType::Network { - subnet_ids: None, - host_naming_fallback: HostNamingFallback::BestService, - }, - name: format!("Network Scan @ {}", request.daemon_ip), - daemon_id: request.daemon_id, - network_id: request.network_id, - })) + }), + AuthenticatedEntity::System, + ) .await?; - discovery_service.start_session(network_discovery).await?; + discovery_service + .start_session(network_discovery, AuthenticatedEntity::System) + .await?; Ok(Json(ApiResponse::success(DaemonRegistrationResponse { daemon: registered_daemon, @@ -151,7 +164,7 @@ async fn register_daemon( async fn update_capabilities( State(state): State>, - _daemon: AuthenticatedDaemon, + auth_daemon: AuthenticatedDaemon, Path(id): Path, Json(updated_capabilities): Json, ) -> ApiResult>> { @@ -170,7 +183,7 @@ async fn update_capabilities( daemon.base.capabilities = updated_capabilities; - service.update(&mut daemon).await?; + service.update(&mut daemon, auth_daemon.into()).await?; Ok(Json(ApiResponse::success(()))) } @@ -178,7 +191,7 @@ async fn update_capabilities( /// Receive heartbeat from daemon async fn receive_heartbeat( State(state): State>, - _daemon: AuthenticatedDaemon, + auth_daemon: AuthenticatedDaemon, Path(id): Path, ) -> ApiResult>> { let service = &state.services.daemon_service; @@ -192,7 +205,7 @@ async fn receive_heartbeat( daemon.base.last_seen = Utc::now(); service - .update(&mut daemon) + .update(&mut daemon, auth_daemon.into()) .await .map_err(|e| ApiError::internal_error(&format!("Failed to update heartbeat: {}", e)))?; @@ -201,7 +214,7 @@ async fn receive_heartbeat( async fn receive_work_request( State(state): State>, - _daemon: AuthenticatedDaemon, + auth_daemon: AuthenticatedDaemon, Path(id): Path, Json(daemon_id): Json, ) -> ApiResult, bool)>>> { @@ -216,7 +229,7 @@ async fn receive_work_request( daemon.base.last_seen = Utc::now(); service - .update(&mut daemon) + .update(&mut daemon, auth_daemon.into()) .await .map_err(|e| ApiError::internal_error(&format!("Failed to update heartbeat: {}", e)))?; diff --git a/backend/src/server/daemons/service.rs b/backend/src/server/daemons/service.rs index ae38b304..ac4c1889 100644 --- a/backend/src/server/daemons/service.rs +++ b/backend/src/server/daemons/service.rs @@ -1,6 +1,7 @@ use crate::{ daemon::runtime::types::InitializeDaemonRequest, server::{ + auth::middleware::AuthenticatedEntity, daemons::r#impl::{ api::{DaemonDiscoveryRequest, DaemonDiscoveryResponse}, base::Daemon, @@ -8,19 +9,27 @@ use crate::{ hosts::r#impl::ports::PortBase, services::r#impl::endpoints::{ApplicationProtocol, Endpoint}, shared::{ - services::traits::CrudService, storage::generic::GenericPostgresStorage, + entities::Entity, + events::{ + bus::EventBus, + types::{EntityEvent, EntityOperation}, + }, + services::traits::CrudService, + storage::generic::GenericPostgresStorage, types::api::ApiResponse, }, }, }; use anyhow::{Error, Result}; use async_trait::async_trait; +use chrono::Utc; use std::sync::Arc; use uuid::Uuid; pub struct DaemonService { daemon_storage: Arc>, client: reqwest::Client, + event_bus: Arc, } #[async_trait] @@ -28,13 +37,31 @@ impl CrudService for DaemonService { fn storage(&self) -> &Arc> { &self.daemon_storage } + + fn event_bus(&self) -> &Arc { + &self.event_bus + } + + fn entity_type() -> Entity { + Entity::Daemon + } + fn get_network_id(&self, entity: &Daemon) -> Option { + Some(entity.base.network_id) + } + fn get_organization_id(&self, _entity: &Daemon) -> Option { + None + } } impl DaemonService { - pub fn new(daemon_storage: Arc>) -> Self { + pub fn new( + daemon_storage: Arc>, + event_bus: Arc, + ) -> Self { Self { daemon_storage, client: reqwest::Client::new(), + event_bus, } } @@ -43,6 +70,7 @@ impl DaemonService { &self, daemon_id: &Uuid, request: DaemonDiscoveryRequest, + authentication: AuthenticatedEntity, ) -> Result<(), Error> { let daemon = self .get_by_id(daemon_id) @@ -80,6 +108,22 @@ impl DaemonService { ); } + let daemon_ref = &daemon; + + self.event_bus() + .publish(EntityEvent { + id: Uuid::new_v4(), + entity_type: Self::entity_type(), + entity_id: *daemon_id, + network_id: self.get_network_id(daemon_ref), + organization_id: self.get_organization_id(daemon_ref), + operation: EntityOperation::Custom("discovery_request_sent"), + timestamp: Utc::now(), + metadata: serde_json::json!({}), + authentication, + }) + .await?; + tracing::info!( "Discovery request sent to daemon {} for session {}", daemon.id, @@ -92,6 +136,7 @@ impl DaemonService { &self, daemon: &Daemon, session_id: Uuid, + authentication: AuthenticatedEntity, ) -> Result<(), anyhow::Error> { let endpoint = Endpoint { ip: Some(daemon.base.ip), @@ -115,6 +160,22 @@ impl DaemonService { ); } + self.event_bus() + .publish(EntityEvent { + id: Uuid::new_v4(), + entity_type: Self::entity_type(), + entity_id: daemon.id, + network_id: self.get_network_id(daemon), + organization_id: self.get_organization_id(daemon), + operation: EntityOperation::Custom("discovery_cancellation_sent"), + timestamp: Utc::now(), + metadata: serde_json::json!({ + "session_id": session_id + }), + authentication, + }) + .await?; + Ok(()) } diff --git a/backend/src/server/discovery/handlers.rs b/backend/src/server/discovery/handlers.rs index 7e170de5..2c3971a1 100644 --- a/backend/src/server/discovery/handlers.rs +++ b/backend/src/server/discovery/handlers.rs @@ -59,7 +59,7 @@ async fn receive_discovery_update( /// Endpoint to start a discovery session async fn start_session( State(state): State>, - RequireMember(_user): RequireMember, + RequireMember(user): RequireMember, Json(discovery_id): Json, ) -> ApiResult>> { let mut discovery = state @@ -85,13 +85,13 @@ async fn start_session( let update = state .services .discovery_service - .start_session(discovery.clone()) + .start_session(discovery.clone(), user.clone().into()) .await?; state .services .discovery_service - .update_discovery(discovery) + .update_discovery(discovery, user.into()) .await?; Ok(Json(ApiResponse::success(update))) @@ -139,13 +139,13 @@ async fn get_active_sessions( /// Cancel an active discovery session async fn cancel_discovery( State(state): State>, - RequireMember(_user): RequireMember, + RequireMember(user): RequireMember, Path(session_id): Path, ) -> ApiResult>> { state .services .discovery_service - .cancel_session(session_id) + .cancel_session(session_id, user.into()) .await?; tracing::info!("Discovery session was {} cancelled", session_id); diff --git a/backend/src/server/discovery/service.rs b/backend/src/server/discovery/service.rs index 9424b7e8..0322a84b 100644 --- a/backend/src/server/discovery/service.rs +++ b/backend/src/server/discovery/service.rs @@ -1,5 +1,9 @@ +use crate::server::auth::middleware::AuthenticatedEntity; use crate::server::daemons::r#impl::base::DaemonMode; use crate::server::discovery::r#impl::types::RunType; +use crate::server::shared::entities::Entity; +use crate::server::shared::events::bus::EventBus; +use crate::server::shared::events::types::{EntityEvent, EntityOperation}; use crate::server::shared::services::traits::CrudService; use crate::server::shared::storage::filter::EntityFilter; use crate::server::shared::storage::generic::GenericPostgresStorage; @@ -31,6 +35,7 @@ pub struct DiscoveryService { daemon_pull_cancellations: RwLock>, // daemon_id -> boolean mapping for pull mode cancellations of current session on daemon update_tx: broadcast::Sender, scheduler: Option>>, + event_bus: Arc, } #[async_trait] @@ -38,12 +43,27 @@ impl CrudService for DiscoveryService { fn storage(&self) -> &Arc> { &self.discovery_storage } + + fn event_bus(&self) -> &Arc { + &self.event_bus + } + + fn entity_type() -> Entity { + Entity::Discovery + } + fn get_network_id(&self, entity: &Discovery) -> Option { + Some(entity.base.network_id) + } + fn get_organization_id(&self, _entity: &Discovery) -> Option { + None + } } impl DiscoveryService { pub async fn new( discovery_storage: Arc>, daemon_service: Arc, + event_bus: Arc, ) -> Result> { let (tx, _rx) = broadcast::channel(100); // Buffer 100 messages let scheduler = JobScheduler::new().await?; @@ -56,11 +76,57 @@ impl DiscoveryService { daemon_pull_cancellations: RwLock::new(HashMap::new()), update_tx: tx, scheduler: Some(Arc::new(RwLock::new(scheduler))), + event_bus, })) } + /// Expose stream to handler + pub fn subscribe(&self) -> broadcast::Receiver { + self.update_tx.subscribe() + } + + /// Get session state + pub async fn get_session(&self, session_id: &Uuid) -> Option { + self.sessions.read().await.get(session_id).cloned() + } + + /// Get session state + pub async fn get_all_sessions(&self, network_ids: &[Uuid]) -> Vec { + let all_sessions = self.sessions.read().await; + all_sessions + .values() + .filter(|v| network_ids.contains(&v.network_id)) + .cloned() + .collect() + } + + pub async fn get_sessions_for_daemon(&self, daemon_id: &Uuid) -> Vec { + let daemon_session_ids = self.daemon_sessions.read().await; + let session_ids = daemon_session_ids + .get(daemon_id) + .cloned() + .unwrap_or_default(); + + let all_sessions = self.sessions.read().await; + + all_sessions + .iter() + .filter(|(session_id, _)| session_ids.contains(session_id)) + .map(|(_, session)| session.clone()) + .collect() + } + + pub async fn pull_cancellation_for_daemon(&self, daemon_id: &Uuid) -> bool { + let mut daemon_cancellation_ids = self.daemon_pull_cancellations.write().await; + daemon_cancellation_ids.remove(daemon_id).unwrap_or(false) + } + /// Create a new scheduled discovery - pub async fn create_discovery(self: &Arc, discovery: Discovery) -> Result { + pub async fn create_discovery( + self: &Arc, + discovery: Discovery, + authentication: AuthenticatedEntity, + ) -> Result { let mut created_discovery = if discovery.id == Uuid::nil() { self.discovery_storage .create(&Discovery::new(discovery.base)) @@ -89,6 +155,20 @@ impl DiscoveryService { return Ok(disabled_discovery); } + self.event_bus() + .publish(EntityEvent { + id: Uuid::new_v4(), + entity_type: Self::entity_type(), + entity_id: created_discovery.id(), + network_id: self.get_network_id(&created_discovery), + organization_id: self.get_organization_id(&created_discovery), + operation: EntityOperation::Created, + timestamp: Utc::now(), + metadata: serde_json::json!({}), + authentication, + }) + .await?; + tracing::info!( "Created discovery {}: {}", created_discovery.base.name, @@ -101,6 +181,7 @@ impl DiscoveryService { pub async fn update_discovery( self: &Arc, mut discovery: Discovery, + authentication: AuthenticatedEntity, ) -> Result { discovery.updated_at = Utc::now(); @@ -129,6 +210,20 @@ impl DiscoveryService { return Ok(disabled_discovery); } + self.event_bus() + .publish(EntityEvent { + id: Uuid::new_v4(), + entity_type: Self::entity_type(), + entity_id: updated.id(), + network_id: self.get_network_id(&updated), + organization_id: self.get_organization_id(&updated), + operation: EntityOperation::Updated, + timestamp: Utc::now(), + metadata: serde_json::json!({}), + authentication, + }) + .await?; + tracing::info!( "Updated and rescheduled discovery {}: {}", updated.base.name, @@ -144,7 +239,11 @@ impl DiscoveryService { } /// Delete group - pub async fn delete_discovery(self: &Arc, id: &Uuid) -> Result<(), Error> { + pub async fn delete_discovery( + self: &Arc, + id: &Uuid, + authentication: AuthenticatedEntity, + ) -> Result<(), Error> { let discovery = self .get_by_id(id) .await? @@ -159,6 +258,21 @@ impl DiscoveryService { } self.discovery_storage.delete(id).await?; + + self.event_bus() + .publish(EntityEvent { + id: Uuid::new_v4(), + entity_type: Self::entity_type(), + entity_id: discovery.id(), + network_id: self.get_network_id(&discovery), + organization_id: self.get_organization_id(&discovery), + operation: EntityOperation::Deleted, + timestamp: Utc::now(), + metadata: serde_json::json!({}), + authentication, + }) + .await?; + tracing::info!( "Deleted discovery {}: {}", discovery.base.name, @@ -254,7 +368,10 @@ impl DiscoveryService { Box::pin(async move { tracing::info!("Running scheduled discovery {}", &discovery.id); - match service.start_session(discovery.clone()).await { + match service + .start_session(discovery.clone(), AuthenticatedEntity::System) + .await + { Ok(_) => { // Update last_run if let RunType::Scheduled { @@ -285,51 +402,11 @@ impl DiscoveryService { Ok(job_id) } - /// Expose stream to handler - pub fn subscribe(&self) -> broadcast::Receiver { - self.update_tx.subscribe() - } - - /// Get session state - pub async fn get_session(&self, session_id: &Uuid) -> Option { - self.sessions.read().await.get(session_id).cloned() - } - - /// Get session state - pub async fn get_all_sessions(&self, network_ids: &[Uuid]) -> Vec { - let all_sessions = self.sessions.read().await; - all_sessions - .values() - .filter(|v| network_ids.contains(&v.network_id)) - .cloned() - .collect() - } - - pub async fn get_sessions_for_daemon(&self, daemon_id: &Uuid) -> Vec { - let daemon_session_ids = self.daemon_sessions.read().await; - let session_ids = daemon_session_ids - .get(daemon_id) - .cloned() - .unwrap_or_default(); - - let all_sessions = self.sessions.read().await; - - all_sessions - .iter() - .filter(|(session_id, _)| session_ids.contains(session_id)) - .map(|(_, session)| session.clone()) - .collect() - } - - pub async fn pull_cancellation_for_daemon(&self, daemon_id: &Uuid) -> bool { - let mut daemon_cancellation_ids = self.daemon_pull_cancellations.write().await; - daemon_cancellation_ids.remove(daemon_id).unwrap_or(false) - } - /// Create a new discovery session pub async fn start_session( &self, discovery: Discovery, + authentication: AuthenticatedEntity, ) -> Result { let session_id = Uuid::new_v4(); @@ -382,6 +459,7 @@ impl DiscoveryService { discovery_type: discovery.base.discovery_type, session_id, }, + authentication, ) .await?; } @@ -452,6 +530,22 @@ impl DiscoveryService { e ); } else { + self.event_bus() + .publish(EntityEvent { + id: Uuid::new_v4(), + entity_type: Self::entity_type(), + entity_id: historical_discovery.id(), + network_id: self.get_network_id(&historical_discovery), + organization_id: self.get_organization_id(&historical_discovery), + operation: EntityOperation::Created, + timestamp: Utc::now(), + metadata: serde_json::json!({ + "type": "historical" + }), + authentication: AuthenticatedEntity::System, + }) + .await?; + tracing::debug!( "Created historical discovery record {} for session {}", historical_discovery.id, @@ -507,6 +601,7 @@ impl DiscoveryService { discovery_type, session_id, }, + AuthenticatedEntity::System, ) .await?; } @@ -515,7 +610,11 @@ impl DiscoveryService { Ok(()) } - pub async fn cancel_session(&self, session_id: Uuid) -> Result<(), Error> { + pub async fn cancel_session( + &self, + session_id: Uuid, + authentication: AuthenticatedEntity, + ) -> Result<(), Error> { // Get the session let session = match self.get_session(&session_id).await { Some(session) => session, @@ -576,7 +675,7 @@ impl DiscoveryService { match daemon.base.mode { DaemonMode::Push => { self.daemon_service - .send_discovery_cancellation(&daemon, session_id) + .send_discovery_cancellation(&daemon, session_id, authentication) .await .map_err(|e| { anyhow!( diff --git a/backend/src/server/groups/impl/base.rs b/backend/src/server/groups/impl/base.rs index 79996a85..b67a4cf4 100644 --- a/backend/src/server/groups/impl/base.rs +++ b/backend/src/server/groups/impl/base.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use validator::Validate; -#[derive(Debug, Clone, Serialize, Validate, Deserialize)] +#[derive(Debug, Clone, Serialize, Validate, Deserialize, PartialEq, Eq, Hash)] pub struct GroupBase { #[validate(length(min = 0, max = 100))] pub name: String, @@ -26,7 +26,7 @@ pub struct GroupBase { pub edge_style: EdgeStyle, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] pub struct Group { pub id: Uuid, pub created_at: DateTime, diff --git a/backend/src/server/groups/service.rs b/backend/src/server/groups/service.rs index dee4c31d..72a9d5f3 100644 --- a/backend/src/server/groups/service.rs +++ b/backend/src/server/groups/service.rs @@ -1,13 +1,18 @@ use async_trait::async_trait; use std::sync::Arc; +use uuid::Uuid; use crate::server::{ groups::r#impl::base::Group, - shared::{services::traits::CrudService, storage::generic::GenericPostgresStorage}, + shared::{ + entities::Entity, events::bus::EventBus, services::traits::CrudService, + storage::generic::GenericPostgresStorage, + }, }; pub struct GroupService { group_storage: Arc>, + event_bus: Arc, } #[async_trait] @@ -15,10 +20,30 @@ impl CrudService for GroupService { fn storage(&self) -> &Arc> { &self.group_storage } + + fn event_bus(&self) -> &Arc { + &self.event_bus + } + + fn entity_type() -> Entity { + Entity::Group + } + fn get_network_id(&self, entity: &Group) -> Option { + Some(entity.base.network_id) + } + fn get_organization_id(&self, _entity: &Group) -> Option { + None + } } impl GroupService { - pub fn new(group_storage: Arc>) -> Self { - Self { group_storage } + pub fn new( + group_storage: Arc>, + event_bus: Arc, + ) -> Self { + Self { + group_storage, + event_bus, + } } } diff --git a/backend/src/server/hosts/handlers.rs b/backend/src/server/hosts/handlers.rs index ee606c75..e610a368 100644 --- a/backend/src/server/hosts/handlers.rs +++ b/backend/src/server/hosts/handlers.rs @@ -17,7 +17,6 @@ use axum::{ routing::{post, put}, }; use futures::future::try_join_all; -use itertools::{Either, Itertools}; use std::sync::Arc; use uuid::Uuid; use validator::Validate; @@ -37,7 +36,7 @@ pub fn create_router() -> Router> { async fn create_host( State(state): State>, - MemberOrDaemon { .. }: MemberOrDaemon, + MemberOrDaemon { entity, .. }: MemberOrDaemon, Json(request): Json, ) -> ApiResult>> { let host_service = &state.services.host_service; @@ -55,7 +54,7 @@ async fn create_host( } let (host, services) = host_service - .create_host_with_services(request.host, request.services.unwrap_or_default()) + .create_host_with_services(request.host, request.services.unwrap_or_default(), entity) .await?; Ok(Json(ApiResponse::success(HostWithServicesRequest { @@ -66,7 +65,7 @@ async fn create_host( async fn update_host( State(state): State>, - RequireMember(_user): RequireMember, + RequireMember(user): RequireMember, Json(mut request): Json, ) -> ApiResult>> { let host_service = &state.services.host_service; @@ -74,34 +73,40 @@ async fn update_host( // If services is None, don't update services if let Some(services) = request.services { - let (create_futures, update_futures): (Vec<_>, Vec<_>) = - services.into_iter().partition_map(|s| { - if s.id == Uuid::nil() { - let service = Service::new(s.base); - Either::Left(service_service.create_service(service)) - } else { - Either::Right(service_service.update_service(s)) - } - }); - + let mut created_service_ids = Vec::new(); + let mut updated_service_ids = Vec::new(); + let mut create_futures = Vec::new(); + + for mut s in services { + let user = user.clone(); + if s.id == Uuid::nil() { + let service = Service::new(s.base); + create_futures.push(service_service.create(service, user.into())); + } else { + // Execute updates sequentially + let updated = service_service.update(&mut s, user.into()).await?; + updated_service_ids.push(updated.id); + } + } + + // Execute creates concurrently let created_services = try_join_all(create_futures).await?; - let updated_services = try_join_all(update_futures).await?; + created_service_ids.extend(created_services.iter().map(|s| s.id)); - request.host.base.services = created_services - .iter() - .chain(updated_services.iter()) - .map(|s| s.id) + request.host.base.services = created_service_ids + .into_iter() + .chain(updated_service_ids) .collect(); } - let updated_host = host_service.update_host(request.host).await?; + let updated_host = host_service.update(&mut request.host, user.into()).await?; Ok(Json(ApiResponse::success(updated_host))) } async fn consolidate_hosts( State(state): State>, - RequireMember(_user): RequireMember, + RequireMember(user): RequireMember, Path((destination_host_id, other_host_id)): Path<(Uuid, Uuid)>, ) -> ApiResult>> { let host_service = &state.services.host_service; @@ -126,7 +131,7 @@ async fn consolidate_hosts( })?; let updated_host = host_service - .consolidate_hosts(destination_host, other_host) + .consolidate_hosts(destination_host, other_host, user.into()) .await?; Ok(Json(ApiResponse::success(updated_host))) @@ -134,7 +139,7 @@ async fn consolidate_hosts( pub async fn delete_handler( State(state): State>, - RequireMember(_user): RequireMember, + RequireMember(user): RequireMember, Path(id): Path, ) -> ApiResult>> { let service = Host::get_service(&state); @@ -156,7 +161,7 @@ pub async fn delete_handler( .ok_or_else(|| ApiError::not_found(format!("Host '{}' not found", id)))?; service - .delete(&id) + .delete(&id, user.into()) .await .map_err(|e| ApiError::internal_error(&e.to_string()))?; diff --git a/backend/src/server/hosts/service.rs b/backend/src/server/hosts/service.rs index 04f189fd..68ebbc24 100644 --- a/backend/src/server/hosts/service.rs +++ b/backend/src/server/hosts/service.rs @@ -1,17 +1,27 @@ use crate::server::{ + auth::middleware::AuthenticatedEntity, daemons::service::DaemonService, hosts::r#impl::base::Host, services::{r#impl::base::Service, service::ServiceService}, shared::{ + entities::Entity, + events::{ + bus::EventBus, + types::{EntityEvent, EntityOperation}, + }, services::traits::CrudService, - storage::{filter::EntityFilter, generic::GenericPostgresStorage, traits::Storage}, + storage::{ + filter::EntityFilter, + generic::GenericPostgresStorage, + traits::{StorableEntity, Storage}, + }, types::entities::{EntitySource, EntitySourceDiscriminants}, }, }; use anyhow::{Error, Result, anyhow}; use async_trait::async_trait; +use chrono::Utc; use futures::future::{join_all, try_join_all}; -use itertools::{Either, Itertools}; use std::{collections::HashMap, sync::Arc}; use strum::IntoDiscriminant; use tokio::sync::Mutex; @@ -22,6 +32,7 @@ pub struct HostService { service_service: Arc, daemon_service: Arc, host_locks: Arc>>>>, + event_bus: Arc, } #[async_trait] @@ -29,6 +40,129 @@ impl CrudService for HostService { fn storage(&self) -> &Arc> { &self.storage } + + fn event_bus(&self) -> &Arc { + &self.event_bus + } + + fn entity_type() -> Entity { + Entity::Host + } + fn get_network_id(&self, entity: &Host) -> Option { + Some(entity.base.network_id) + } + fn get_organization_id(&self, _entity: &Host) -> Option { + None + } + + /// Create a new host + async fn create(&self, host: Host, authentication: AuthenticatedEntity) -> Result { + // Manually created and needs actual UUID + let host = if host.id == Uuid::nil() { + Host::new(host.base.clone()) + } else { + host + }; + + let lock = self.get_host_lock(&host.id).await; + let _guard = lock.lock().await; + + tracing::trace!("Creating host {:?}", host); + + let filter = EntityFilter::unfiltered().network_ids(&[host.base.network_id]); + let all_hosts = self.storage.get_all(filter).await?; + + let host_from_storage = match all_hosts.into_iter().find(|h| host.eq(h)) { + // If both are from discovery, or if they have the same ID, upsert data + Some(existing_host) + if (host.base.source.discriminant() == EntitySourceDiscriminants::Discovery + && existing_host.base.source.discriminant() + == EntitySourceDiscriminants::Discovery) + || host.id == existing_host.id => + { + tracing::warn!( + "Duplicate host for {}: {} found, {}: {} - upserting discovery data...", + host.base.name, + host.id, + existing_host.base.name, + existing_host.id + ); + + self.upsert_host(existing_host, host, authentication) + .await? + } + _ => { + let created = self.storage.create(&host).await?; + + self.event_bus() + .publish(EntityEvent { + id: Uuid::new_v4(), + entity_type: Self::entity_type(), + entity_id: created.id(), + network_id: self.get_network_id(&created), + organization_id: self.get_organization_id(&created), + operation: EntityOperation::Created, + timestamp: Utc::now(), + metadata: serde_json::json!({}), + authentication, + }) + .await?; + + tracing::info!( + name = %host.base.name, + id = %host.id, + "Created host" + ); + tracing::trace!("Result: {:?}", host); + host + } + }; + + Ok(host_from_storage) + } + + async fn update( + &self, + host: &mut Host, + authentication: AuthenticatedEntity, + ) -> Result { + let lock = self.get_host_lock(&host.id).await; + let _guard = lock.lock().await; + + tracing::trace!("Updating host {:?}", host); + + let current_host = self + .get_by_id(&host.id) + .await? + .ok_or_else(|| anyhow!("Host '{}' not found", host.id))?; + + self.update_host_services(¤t_host, host, authentication.clone()) + .await?; + + let updated = self.storage.update(host).await?; + + self.event_bus() + .publish(EntityEvent { + id: Uuid::new_v4(), + entity_type: Self::entity_type(), + entity_id: updated.id(), + network_id: self.get_network_id(&updated), + organization_id: self.get_organization_id(&updated), + operation: EntityOperation::Updated, + timestamp: Utc::now(), + metadata: serde_json::json!({}), + authentication, + }) + .await?; + + tracing::info!( + id = %host.id, + "Updated host" + ); + tracing::trace!("Result: {:?}", host); + + Ok(updated) + } } impl HostService { @@ -36,12 +170,14 @@ impl HostService { storage: Arc>, service_service: Arc, daemon_service: Arc, + event_bus: Arc, ) -> Self { Self { storage, service_service, daemon_service, host_locks: Arc::new(Mutex::new(HashMap::new())), + event_bus, } } @@ -57,9 +193,10 @@ impl HostService { &self, host: Host, services: Vec, + authentication: AuthenticatedEntity, ) -> Result<(Host, Vec)> { // Create host first (handles duplicates via upsert_host) - let mut created_host = self.create_host(host.clone()).await?; + let mut created_host = self.create(host.clone(), authentication.clone()).await?; // Create services, handling case where created_host was upserted instead of created anew (ie during discovery), which means that host ID + interfaces/port IDs // are different from what's mapped to the service and they need to be updated @@ -72,7 +209,7 @@ impl HostService { let create_service_futures: Vec<_> = transferred_services .into_iter() - .map(|s| self.service_service.create_service(s)) + .map(|s| self.service_service.create(s, authentication.clone())) .collect(); let created_services = try_join_all(create_service_futures).await?; @@ -99,75 +236,13 @@ impl HostService { Ok((host_with_final_services, created_services)) } - /// Create a new host - pub async fn create_host(&self, host: Host) -> Result { - // Manually created and needs actual UUID - let host = if host.id == Uuid::nil() { - Host::new(host.base.clone()) - } else { - host - }; - - let lock = self.get_host_lock(&host.id).await; - let _guard = lock.lock().await; - - tracing::trace!("Creating host {:?}", host); - - let filter = EntityFilter::unfiltered().network_ids(&[host.base.network_id]); - let all_hosts = self.storage.get_all(filter).await?; - - let host_from_storage = match all_hosts.into_iter().find(|h| host.eq(h)) { - // If both are from discovery, or if they have the same ID, upsert data - Some(existing_host) - if (host.base.source.discriminant() == EntitySourceDiscriminants::Discovery - && existing_host.base.source.discriminant() - == EntitySourceDiscriminants::Discovery) - || host.id == existing_host.id => - { - tracing::warn!( - "Duplicate host for {}: {} found, {}: {} - upserting discovery data...", - host.base.name, - host.id, - existing_host.base.name, - existing_host.id - ); - - self.upsert_host(existing_host, host).await? - } - _ => { - self.storage.create(&host).await?; - tracing::info!("Created host {}: {}", host.base.name, host.id); - tracing::trace!("Result: {:?}", host); - host - } - }; - - Ok(host_from_storage) - } - - pub async fn update_host(&self, mut host: Host) -> Result { - let lock = self.get_host_lock(&host.id).await; - let _guard = lock.lock().await; - - tracing::trace!("Updating host {:?}", host); - - let current_host = self - .get_by_id(&host.id) - .await? - .ok_or_else(|| anyhow!("Host '{}' not found", host.id))?; - - self.update_host_services(¤t_host, &host).await?; - - self.storage.update(&mut host).await?; - - tracing::info!("Updated host {:?}: {:?}", host.base.name, host.id); - tracing::trace!("Result: {:?}", host); - - Ok(host) - } - /// Merge new discovery data with existing host - async fn upsert_host(&self, mut existing_host: Host, new_host_data: Host) -> Result { + async fn upsert_host( + &self, + mut existing_host: Host, + new_host_data: Host, + authentication: AuthenticatedEntity, + ) -> Result { let mut interface_updates = 0; let mut port_updates = 0; let mut hostname_update = false; @@ -262,6 +337,20 @@ impl HostService { } if !data.is_empty() { + self.event_bus() + .publish(EntityEvent { + id: Uuid::new_v4(), + entity_type: Self::entity_type(), + entity_id: existing_host.id(), + network_id: self.get_network_id(&existing_host), + organization_id: self.get_organization_id(&existing_host), + operation: EntityOperation::Updated, + timestamp: Utc::now(), + metadata: serde_json::json!({}), + authentication, + }) + .await?; + tracing::info!( host_id = %existing_host.id, host_name = %existing_host.base.name, @@ -284,6 +373,7 @@ impl HostService { &self, destination_host: Host, other_host: Host, + authentication: AuthenticatedEntity, ) -> Result { if destination_host.id == other_host.id { return Err(anyhow!("Can't consolidate a host with itself")); @@ -313,12 +403,18 @@ impl HostService { if let Some(mut other_host_daemon) = other_host_daemon { other_host_daemon.base.host_id = destination_host.id; - self.daemon_service.update(&mut other_host_daemon).await?; + self.daemon_service + .update(&mut other_host_daemon, authentication.clone()) + .await?; } // Add bindings, interfaces, sources from old host to new let updated_host = self - .upsert_host(destination_host.clone(), other_host.clone()) + .upsert_host( + destination_host.clone(), + other_host.clone(), + authentication.clone(), + ) .await?; // Update host_id, network_id, and interface/port binding IDs to what's available on new host @@ -337,33 +433,49 @@ impl HostService { let prepped_for_transfer_services: Vec = join_all(service_transfer_futures).await; - let ((upsert_futures, delete_futures), update_futures): ((Vec<_>, Vec<_>), Vec<_>) = - prepped_for_transfer_services + // First, execute updates sequentially + for prepped_service in &prepped_for_transfer_services { + if !destination_host_services .iter() - .partition_map(|prepped_service| { - // If there's an existing service on the host, upsert the transferred service so to avoid duplicates - // If not, just update the transferred service - if let Some(existing_service) = destination_host_services - .iter() - .find(|s| *s == prepped_service) - { - Either::Left(( + .any(|s| s == prepped_service) + { + let mut owned_service = prepped_service.clone(); + self.service_service + .update(&mut owned_service, authentication.clone()) + .await?; + } + } + + // Then collect upsert/delete futures for concurrent execution + let (upsert_futures, delete_futures): (Vec<_>, Vec<_>) = prepped_for_transfer_services + .iter() + .filter_map(|prepped_service| { + destination_host_services + .iter() + .find(|s| *s == prepped_service) + .map(|existing_service| { + ( + self.service_service.upsert_service( + existing_service.clone(), + prepped_service.clone(), + authentication.clone(), + ), self.service_service - .upsert_service(existing_service.clone(), prepped_service.clone()), - self.service_service.delete_service(&prepped_service.id), - )) - } else { - Either::Right(self.service_service.update_service(prepped_service.clone())) - } - }); - - // Save the updated services to DB - let _upserted_services = try_join_all(upsert_futures).await?; - let _deleted_services = try_join_all(delete_futures).await?; - let _updated_services = try_join_all(update_futures).await?; + .delete(&prepped_service.id, authentication.clone()), + ) + }) + }) + .unzip(); + + // Execute upsert/delete concurrently + let (_, _) = tokio::join!( + futures::future::join_all(upsert_futures), + futures::future::join_all(delete_futures), + ); // Delete host, ignore services because they are just being moved to other host - self.delete_host(&other_host.id, false).await?; + self.delete_host(&other_host.id, false, authentication) + .await?; tracing::info!( source_host_id = %other_host.id, source_host_name = %other_host.base.name, @@ -376,7 +488,12 @@ impl HostService { Ok(updated_host) } - async fn update_host_services(&self, current_host: &Host, updates: &Host) -> Result<(), Error> { + async fn update_host_services( + &self, + current_host: &Host, + updates: &Host, + authentication: AuthenticatedEntity, + ) -> Result<(), Error> { let host_filter = EntityFilter::unfiltered().host_id(¤t_host.id); let services = self.service_service.get_all(host_filter).await?; @@ -394,7 +511,7 @@ impl HostService { let delete_service_futures = delete_services .iter() - .map(|s| self.service_service.delete_service(&s.id)); + .map(|s| self.service_service.delete(&s.id, authentication.clone())); try_join_all(delete_service_futures).await?; @@ -402,11 +519,12 @@ impl HostService { let service_service = self.service_service.clone(); let current_host = current_host.clone(); let updates = updates.clone(); + let authentication = authentication.clone(); async move { - let updated = service_service + let mut updated = service_service .reassign_service_interface_bindings(service, ¤t_host, &updates) .await; - service_service.update_service(updated).await + service_service.update(&mut updated, authentication).await } }); @@ -429,7 +547,12 @@ impl HostService { Ok(()) } - pub async fn delete_host(&self, id: &Uuid, delete_services: bool) -> Result<()> { + pub async fn delete_host( + &self, + id: &Uuid, + delete_services: bool, + authentication: AuthenticatedEntity, + ) -> Result<()> { let host_filter = EntityFilter::unfiltered().host_id(id); if self.daemon_service.get_one(host_filter).await?.is_some() { return Err(anyhow!( @@ -447,11 +570,29 @@ impl HostService { if delete_services { for service_id in &host.base.services { - let _ = self.service_service.delete_service(service_id).await; + let _ = self + .service_service + .delete(service_id, authentication.clone()) + .await; } } self.storage.delete(id).await?; + + self.event_bus() + .publish(EntityEvent { + id: Uuid::new_v4(), + entity_type: Self::entity_type(), + entity_id: host.id(), + network_id: self.get_network_id(&host), + organization_id: self.get_organization_id(&host), + operation: EntityOperation::Deleted, + timestamp: Utc::now(), + metadata: serde_json::json!({}), + authentication, + }) + .await?; + tracing::info!( host_id = %host.id, host_name = %host.base.name, diff --git a/backend/src/server/hosts/tests.rs b/backend/src/server/hosts/tests.rs index 7ecb8191..7ff7e68f 100644 --- a/backend/src/server/hosts/tests.rs +++ b/backend/src/server/hosts/tests.rs @@ -2,6 +2,7 @@ use serial_test::serial; use crate::{ server::{ + auth::middleware::AuthenticatedEntity, services::r#impl::bindings::Binding, shared::{ services::traits::CrudService, @@ -19,12 +20,12 @@ async fn test_host_deduplication_on_create() { let organization = services .organization_service - .create(organization()) + .create(organization(), AuthenticatedEntity::System) .await .unwrap(); let network = services .network_service - .create(network(&organization.id)) + .create(network(&organization.id), AuthenticatedEntity::System) .await .unwrap(); @@ -39,7 +40,7 @@ async fn test_host_deduplication_on_create() { }; let (created1, _) = services .host_service - .create_host_with_services(host1.clone(), vec![]) + .create_host_with_services(host1.clone(), vec![], AuthenticatedEntity::System) .await .unwrap(); @@ -50,7 +51,7 @@ async fn test_host_deduplication_on_create() { }; let (created2, _) = services .host_service - .create_host_with_services(host2.clone(), vec![]) + .create_host_with_services(host2.clone(), vec![], AuthenticatedEntity::System) .await .unwrap(); @@ -69,12 +70,12 @@ async fn test_host_upsert_merges_new_data() { let organization = services .organization_service - .create(organization()) + .create(organization(), AuthenticatedEntity::System) .await .unwrap(); let network = services .network_service - .create(network(&organization.id)) + .create(network(&organization.id), AuthenticatedEntity::System) .await .unwrap(); @@ -86,14 +87,14 @@ async fn test_host_upsert_merges_new_data() { let subnet1 = subnet(&network.id); services .subnet_service - .create(subnet1.clone()) + .create(subnet1.clone(), AuthenticatedEntity::System) .await .unwrap(); host1.base.interfaces = vec![interface(&subnet1.id)]; let (created, _) = services .host_service - .create_host_with_services(host1.clone(), vec![]) + .create_host_with_services(host1.clone(), vec![], AuthenticatedEntity::System) .await .unwrap(); @@ -105,14 +106,14 @@ async fn test_host_upsert_merges_new_data() { let subnet2 = subnet(&network.id); services .subnet_service - .create(subnet2.clone()) + .create(subnet2.clone(), AuthenticatedEntity::System) .await .unwrap(); host2.base.interfaces = vec![interface(&subnet1.id), interface(&subnet2.id)]; let (upserted, _) = services .host_service - .create_host_with_services(host2.clone(), vec![]) + .create_host_with_services(host2.clone(), vec![], AuthenticatedEntity::System) .await .unwrap(); @@ -133,19 +134,19 @@ async fn test_host_consolidation() { let organization = services .organization_service - .create(organization()) + .create(organization(), AuthenticatedEntity::System) .await .unwrap(); let network = services .network_service - .create(network(&organization.id)) + .create(network(&organization.id), AuthenticatedEntity::System) .await .unwrap(); let subnet_obj = subnet(&network.id); services .subnet_service - .create(subnet_obj.clone()) + .create(subnet_obj.clone(), AuthenticatedEntity::System) .await .unwrap(); @@ -154,7 +155,7 @@ async fn test_host_consolidation() { let (created1, _) = services .host_service - .create_host_with_services(host1.clone(), vec![]) + .create_host_with_services(host1.clone(), vec![], AuthenticatedEntity::System) .await .unwrap(); @@ -169,7 +170,7 @@ async fn test_host_consolidation() { let (created2, created_svcs) = services .host_service - .create_host_with_services(host2.clone(), vec![svc]) + .create_host_with_services(host2.clone(), vec![svc], AuthenticatedEntity::System) .await .unwrap(); @@ -178,7 +179,11 @@ async fn test_host_consolidation() { // Consolidate host2 into host1 let consolidated = services .host_service - .consolidate_hosts(created1.clone(), created2.clone()) + .consolidate_hosts( + created1.clone(), + created2.clone(), + AuthenticatedEntity::System, + ) .await .unwrap(); diff --git a/backend/src/server/networks/handlers.rs b/backend/src/server/networks/handlers.rs index 1bc60a0f..85989d0d 100644 --- a/backend/src/server/networks/handlers.rs +++ b/backend/src/server/networks/handlers.rs @@ -67,7 +67,7 @@ pub async fn create_handler( let service = Network::get_service(&state); let created = service - .create(request) + .create(request, user.into()) .await .map_err(|e| ApiError::internal_error(&e.to_string()))?; diff --git a/backend/src/server/networks/service.rs b/backend/src/server/networks/service.rs index df1a7318..3b7c8920 100644 --- a/backend/src/server/networks/service.rs +++ b/backend/src/server/networks/service.rs @@ -1,7 +1,10 @@ use crate::server::{ + auth::middleware::AuthenticatedEntity, hosts::service::HostService, networks::r#impl::Network, shared::{ + entities::Entity, + events::bus::EventBus, services::traits::CrudService, storage::{ generic::GenericPostgresStorage, @@ -22,6 +25,7 @@ pub struct NetworkService { network_storage: Arc>, host_service: Arc, subnet_service: Arc, + event_bus: Arc, } #[async_trait] @@ -29,6 +33,20 @@ impl CrudService for NetworkService { fn storage(&self) -> &Arc> { &self.network_storage } + + fn event_bus(&self) -> &Arc { + &self.event_bus + } + + fn entity_type() -> Entity { + Entity::Network + } + fn get_network_id(&self, _entity: &Network) -> Option { + None + } + fn get_organization_id(&self, entity: &Network) -> Option { + Some(entity.id) + } } impl NetworkService { @@ -36,15 +54,21 @@ impl NetworkService { network_storage: Arc>, host_service: Arc, subnet_service: Arc, + event_bus: Arc, ) -> Self { Self { network_storage, host_service, subnet_service, + event_bus, } } - pub async fn seed_default_data(&self, network_id: Uuid) -> Result<()> { + pub async fn seed_default_data( + &self, + network_id: Uuid, + authenticated: AuthenticatedEntity, + ) -> Result<()> { tracing::info!("Seeding default data..."); let wan_subnet = create_wan_subnet(network_id); @@ -53,16 +77,20 @@ impl NetworkService { let (web_host, web_service) = create_internet_connectivity_host(&wan_subnet, network_id); let (remote_host, client_service) = create_remote_host(&remote_subnet, network_id); - self.subnet_service.create(wan_subnet).await?; - self.subnet_service.create(remote_subnet).await?; + self.subnet_service + .create(wan_subnet, authenticated.clone()) + .await?; + self.subnet_service + .create(remote_subnet, authenticated.clone()) + .await?; self.host_service - .create_host_with_services(dns_host, vec![dns_service]) + .create_host_with_services(dns_host, vec![dns_service], authenticated.clone()) .await?; self.host_service - .create_host_with_services(web_host, vec![web_service]) + .create_host_with_services(web_host, vec![web_service], authenticated.clone()) .await?; self.host_service - .create_host_with_services(remote_host, vec![client_service]) + .create_host_with_services(remote_host, vec![client_service], authenticated.clone()) .await?; tracing::info!("Default data seeded successfully"); diff --git a/backend/src/server/organizations/handlers.rs b/backend/src/server/organizations/handlers.rs index bb50a483..5e354da0 100644 --- a/backend/src/server/organizations/handlers.rs +++ b/backend/src/server/organizations/handlers.rs @@ -1,3 +1,4 @@ +use crate::server::auth::middleware::AuthenticatedEntity; use crate::server::auth::middleware::{ AuthenticatedUser, InviteUsersFeature, RequireFeature, RequireMember, }; @@ -77,6 +78,7 @@ async fn create_invite( user.organization_id, user.user_id, state.config.public_url.clone(), + user.into(), ) .await .map_err(|e| ApiError::internal_error(&e.to_string()))?; @@ -88,12 +90,12 @@ async fn create_invite( async fn get_invite( State(state): State>, RequireMember(_user): RequireMember, - Path(token): Path, + Path(id): Path, ) -> ApiResult>> { let invite = state .services .organization_service - .get_invite(&token) + .get_invite(id) .await .map_err(|e| ApiError::bad_request(&e.to_string()))?; @@ -122,13 +124,13 @@ async fn get_invites( async fn revoke_invite( State(state): State>, RequireMember(user): RequireMember, - Path(token): Path, + Path(id): Path, ) -> ApiResult>> { // Get the invite to verify ownership let invite = state .services .organization_service - .get_invite(&token) + .get_invite(id) .await .map_err(|e| ApiError::bad_request(&e.to_string()))?; @@ -148,7 +150,7 @@ async fn revoke_invite( state .services .organization_service - .revoke_invite(&token) + .revoke_invite(id, user.into()) .await .map_err(|e| ApiError::internal_error(&e.to_string()))?; @@ -159,10 +161,10 @@ async fn revoke_invite( async fn accept_invite_link( State(state): State>, session: Session, - Path(token): Path, + Path(id): Path, ) -> Result { // Validate the invite and get organization_id - let invite = match state.services.organization_service.get_invite(&token).await { + let invite = match state.services.organization_service.get_invite(id).await { Ok(invite) => invite, Err(e) => { tracing::warn!("Invalid invite token: {}", e); @@ -202,7 +204,7 @@ async fn accept_invite_link( ))); } - if let Err(e) = session.insert("pending_invite_token", token.clone()).await { + if let Err(e) = session.insert("pending_invite_id", id).await { tracing::error!("Failed to save invite token to session: {}", e); return Err(Redirect::to(&format!( "/?error={}", @@ -261,7 +263,7 @@ async fn accept_invite_link( state .services .user_service - .update(&mut user) + .update(&mut user, AuthenticatedEntity::System) .await .map_err(|_| { Redirect::to(&format!( @@ -295,7 +297,7 @@ pub async fn process_pending_invite( _ => return Ok(None), // No pending invite }; - let invite_token = match session.get::("pending_invite_token").await { + let invite_id = match session.get::("pending_invite_id").await { Ok(Some(token)) => token, _ => return Ok(None), // No token stored }; @@ -317,7 +319,7 @@ pub async fn process_pending_invite( if let Err(e) = state .services .organization_service - .use_invite(&invite_token) + .use_invite(invite_id) .await { tracing::error!("Failed to mark invite as used: {}", e); @@ -325,7 +327,7 @@ pub async fn process_pending_invite( // Clear session data let _ = session.remove::("pending_org_invite").await; - let _ = session.remove::("pending_invite_token").await; + let _ = session.remove::("pending_invite_id").await; let _ = session.remove::("pending_invite_permissions").await; Ok(Some((pending_org_id, permissions))) diff --git a/backend/src/server/organizations/impl/invites.rs b/backend/src/server/organizations/impl/invites.rs index 88f1d7dd..8e435cd2 100644 --- a/backend/src/server/organizations/impl/invites.rs +++ b/backend/src/server/organizations/impl/invites.rs @@ -6,7 +6,7 @@ use crate::server::users::r#impl::permissions::UserOrgPermissions; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OrganizationInvite { - pub token: String, + pub id: Uuid, pub organization_id: Uuid, pub permissions: UserOrgPermissions, pub url: String, @@ -25,7 +25,7 @@ impl OrganizationInvite { ) -> Self { let now = Utc::now(); Self { - token: Self::generate_token(), + id: Uuid::new_v4(), organization_id, permissions, created_by, @@ -45,8 +45,4 @@ impl OrganizationInvite { true } - - fn generate_token() -> String { - nanoid::nanoid!(32) - } } diff --git a/backend/src/server/organizations/service.rs b/backend/src/server/organizations/service.rs index 19581198..ec36d396 100644 --- a/backend/src/server/organizations/service.rs +++ b/backend/src/server/organizations/service.rs @@ -1,4 +1,8 @@ +use crate::server::auth::middleware::AuthenticatedEntity; use crate::server::organizations::r#impl::invites::OrganizationInvite; +use crate::server::shared::entities::Entity; +use crate::server::shared::events::bus::EventBus; +use crate::server::shared::events::types::{EntityEvent, EntityOperation}; use crate::server::{ organizations::r#impl::{api::CreateInviteRequest, base::Organization}, shared::{services::traits::CrudService, storage::generic::GenericPostgresStorage}, @@ -13,7 +17,8 @@ use uuid::Uuid; pub struct OrganizationService { storage: Arc>, - invites: Arc>>, + invites: Arc>>, + event_bus: Arc, } #[async_trait] @@ -21,21 +26,39 @@ impl CrudService for OrganizationService { fn storage(&self) -> &Arc> { &self.storage } + + fn event_bus(&self) -> &Arc { + &self.event_bus + } + + fn entity_type() -> Entity { + Entity::Organization + } + fn get_network_id(&self, _entity: &Organization) -> Option { + None + } + fn get_organization_id(&self, entity: &Organization) -> Option { + Some(entity.id) + } } impl OrganizationService { - pub fn new(storage: Arc>) -> Self { + pub fn new( + storage: Arc>, + event_bus: Arc, + ) -> Self { Self { storage, invites: Arc::new(RwLock::new(HashMap::new())), + event_bus, } } - pub async fn get_invite(&self, token: &str) -> Result { + pub async fn get_invite(&self, id: Uuid) -> Result { let invites = self.invites.read().await; let invite = invites - .get(token) + .get(&id) .ok_or_else(|| anyhow!("Invalid or expired invite link"))?; if !invite.is_valid() { @@ -45,11 +68,11 @@ impl OrganizationService { Ok(invite.clone()) } - pub async fn use_invite(&self, token: &str) -> Result { + pub async fn use_invite(&self, id: Uuid) -> Result { let mut invites = self.invites.write().await; let invite = invites - .get_mut(token) + .get_mut(&id) .ok_or_else(|| anyhow!("Invalid or expired invite link"))?; if !invite.is_valid() { @@ -58,7 +81,23 @@ impl OrganizationService { let organization_id = invite.organization_id; - invites.remove(token); + let invite = invites + .remove(&id) + .ok_or_else(|| anyhow!("Invite not found"))?; + + self.event_bus() + .publish(EntityEvent { + id: Uuid::new_v4(), + entity_type: Entity::Invite, + entity_id: invite.id, + network_id: None, + organization_id: Some(invite.organization_id), + operation: EntityOperation::Deleted, + timestamp: Utc::now(), + metadata: serde_json::json!({}), + authentication: AuthenticatedEntity::System, + }) + .await?; Ok(organization_id) } @@ -81,6 +120,7 @@ impl OrganizationService { organization_id: Uuid, user_id: Uuid, url: String, + authentication: AuthenticatedEntity, ) -> Result { let expiration_hours = request.expiration_hours.unwrap_or(168); // Default 7 days @@ -93,22 +133,51 @@ impl OrganizationService { ); // Store invite - self.invites - .write() - .await - .insert(invite.token.clone(), invite.clone()); + self.invites.write().await.insert(invite.id, invite.clone()); + + self.event_bus() + .publish(EntityEvent { + id: Uuid::new_v4(), + entity_type: Entity::Invite, + entity_id: invite.id, + network_id: None, + organization_id: Some(invite.organization_id), + operation: EntityOperation::Created, + timestamp: Utc::now(), + metadata: serde_json::json!({}), + authentication, + }) + .await?; Ok(invite) } /// Revoke a specific invite - pub async fn revoke_invite(&self, token: &str) -> Result<(), Error> { + pub async fn revoke_invite( + &self, + id: Uuid, + authentication: AuthenticatedEntity, + ) -> Result<(), Error> { let mut invites = self.invites.write().await; - invites - .remove(token) + let invite = invites + .remove(&id) .ok_or_else(|| anyhow!("Invite not found"))?; + self.event_bus() + .publish(EntityEvent { + id: Uuid::new_v4(), + entity_type: Entity::Invite, + entity_id: invite.id, + network_id: None, + organization_id: Some(invite.organization_id), + operation: EntityOperation::Deleted, + timestamp: Utc::now(), + metadata: serde_json::json!({}), + authentication, + }) + .await?; + Ok(()) } diff --git a/backend/src/server/services/impl/base.rs b/backend/src/server/services/impl/base.rs index 62855cce..ce2dfa5c 100644 --- a/backend/src/server/services/impl/base.rs +++ b/backend/src/server/services/impl/base.rs @@ -47,7 +47,7 @@ impl Default for ServiceBase { } } -#[derive(Debug, Clone, Validate, Serialize, Deserialize)] +#[derive(Debug, Clone, Validate, Serialize, Deserialize, Eq)] pub struct Service { pub id: Uuid, pub created_at: DateTime, diff --git a/backend/src/server/services/service.rs b/backend/src/server/services/service.rs index ae465a6a..1aef7974 100644 --- a/backend/src/server/services/service.rs +++ b/backend/src/server/services/service.rs @@ -1,4 +1,5 @@ use crate::server::{ + auth::middleware::AuthenticatedEntity, groups::{ r#impl::{base::Group, types::GroupType}, service::GroupService, @@ -9,14 +10,24 @@ use crate::server::{ }, services::r#impl::{base::Service, bindings::Binding, patterns::MatchDetails}, shared::{ + entities::Entity, + events::{ + bus::EventBus, + types::{EntityEvent, EntityOperation}, + }, services::traits::CrudService, - storage::{filter::EntityFilter, generic::GenericPostgresStorage, traits::Storage}, + storage::{ + filter::EntityFilter, + generic::GenericPostgresStorage, + traits::{StorableEntity, Storage}, + }, types::entities::{EntitySource, EntitySourceDiscriminants}, }, }; use anyhow::anyhow; use anyhow::{Error, Result}; use async_trait::async_trait; +use chrono::Utc; use futures::lock::Mutex; use std::{ collections::HashMap, @@ -31,6 +42,7 @@ pub struct ServiceService { group_service: Arc, group_update_lock: Arc>, service_locks: Arc>>>>, + event_bus: Arc, } #[async_trait] @@ -38,35 +50,32 @@ impl CrudService for ServiceService { fn storage(&self) -> &Arc> { &self.storage } -} -impl ServiceService { - pub fn new( - storage: Arc>, - group_service: Arc, - ) -> Self { - Self { - storage, - group_service, - host_service: OnceLock::new(), - group_update_lock: Arc::new(Mutex::new(())), - service_locks: Arc::new(Mutex::new(HashMap::new())), - } + fn event_bus(&self) -> &Arc { + &self.event_bus } - async fn get_service_lock(&self, service_id: &Uuid) -> Arc> { - let mut locks = self.service_locks.lock().await; - locks - .entry(*service_id) - .or_insert_with(|| Arc::new(Mutex::new(()))) - .clone() + fn entity_type() -> Entity { + Entity::Service } - - pub fn set_host_service(&self, host_service: Arc) -> Result<(), Arc> { - self.host_service.set(host_service) + fn get_network_id(&self, entity: &Service) -> Option { + Some(entity.base.network_id) + } + fn get_organization_id(&self, _entity: &Service) -> Option { + None } - pub async fn create_service(&self, service: Service) -> Result { + async fn create( + &self, + service: Service, + authentication: AuthenticatedEntity, + ) -> Result { + let service = if service.id == Uuid::nil() { + Service::new(service.base) + } else { + service + }; + let lock = self.get_service_lock(&service.id).await; let _guard = lock.lock().await; @@ -90,10 +99,26 @@ impl ServiceService { service, existing_service, ); - self.upsert_service(existing_service, service).await? + self.upsert_service(existing_service, service, authentication) + .await? } _ => { - self.storage.create(&service).await?; + let created = self.storage.create(&service).await?; + + self.event_bus() + .publish(EntityEvent { + id: Uuid::new_v4(), + entity_type: Entity::Service, + entity_id: created.id, + network_id: self.get_network_id(&created), + organization_id: self.get_organization_id(&created), + operation: EntityOperation::Created, + timestamp: Utc::now(), + metadata: serde_json::json!({}), + authentication, + }) + .await?; + tracing::info!( service_id = %service.id, service_name = %service.base.name, @@ -109,10 +134,121 @@ impl ServiceService { Ok(service_from_storage) } + async fn update( + &self, + service: &mut Service, + authentication: AuthenticatedEntity, + ) -> Result { + let lock = self.get_service_lock(&service.id).await; + let _guard = lock.lock().await; + + tracing::trace!("Updating service: {:?}", service); + + let current_service = self + .get_by_id(&service.id) + .await? + .ok_or_else(|| anyhow!("Could not find service"))?; + + self.update_group_service_bindings(¤t_service, Some(service), authentication.clone()) + .await?; + + let updated = self.storage.update(service).await?; + + self.event_bus() + .publish(EntityEvent { + id: Uuid::new_v4(), + entity_type: Entity::Service, + entity_id: updated.id, + network_id: self.get_network_id(&updated), + organization_id: self.get_organization_id(&updated), + operation: EntityOperation::Updated, + timestamp: Utc::now(), + metadata: serde_json::json!({}), + authentication, + }) + .await?; + + tracing::info!( + service_id = %service.id, + service_name = %service.base.name, + host_id = %service.base.host_id, + "Service updated" + ); + tracing::trace!("Result: {:?}", service); + Ok(updated) + } + + async fn delete(&self, id: &Uuid, authentication: AuthenticatedEntity) -> Result<()> { + let lock = self.get_service_lock(id).await; + let _guard = lock.lock().await; + + let service = self + .get_by_id(id) + .await? + .ok_or_else(|| anyhow::anyhow!("Service {} not found", id))?; + + self.update_group_service_bindings(&service, None, authentication.clone()) + .await?; + + self.storage.delete(id).await?; + + self.event_bus() + .publish(EntityEvent { + id: Uuid::new_v4(), + entity_type: Entity::Service, + entity_id: service.id, + network_id: self.get_network_id(&service), + organization_id: self.get_organization_id(&service), + operation: EntityOperation::Deleted, + timestamp: Utc::now(), + metadata: serde_json::json!({}), + authentication, + }) + .await?; + + tracing::info!( + "Deleted service {}: {} for host {}", + service.base.name, + service.id, + service.base.host_id + ); + Ok(()) + } +} + +impl ServiceService { + pub fn new( + storage: Arc>, + group_service: Arc, + event_bus: Arc, + ) -> Self { + Self { + storage, + group_service, + host_service: OnceLock::new(), + group_update_lock: Arc::new(Mutex::new(())), + service_locks: Arc::new(Mutex::new(HashMap::new())), + event_bus, + } + } + + async fn get_service_lock(&self, service_id: &Uuid) -> Arc> { + let mut locks = self.service_locks.lock().await; + locks + .entry(*service_id) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone() + } + + pub fn set_host_service(&self, host_service: Arc) -> Result<(), Arc> { + self.host_service.set(host_service) + } + pub async fn upsert_service( &self, mut existing_service: Service, new_service_data: Service, + authentication: AuthenticatedEntity, ) -> Result { let mut binding_updates = 0; @@ -200,6 +336,20 @@ impl ServiceService { }; if !data.is_empty() { + self.event_bus() + .publish(EntityEvent { + id: Uuid::new_v4(), + entity_type: Entity::Service, + entity_id: existing_service.id, + network_id: self.get_network_id(&existing_service), + organization_id: self.get_organization_id(&existing_service), + operation: EntityOperation::Updated, + timestamp: Utc::now(), + metadata: serde_json::json!({}), + authentication, + }) + .await?; + tracing::info!( service_id = %existing_service.id, service_name = %existing_service.base.name, @@ -217,35 +367,11 @@ impl ServiceService { Ok(existing_service) } - pub async fn update_service(&self, mut service: Service) -> Result { - let lock = self.get_service_lock(&service.id).await; - let _guard = lock.lock().await; - - tracing::trace!("Updating service: {:?}", service); - - let current_service = self - .get_by_id(&service.id) - .await? - .ok_or_else(|| anyhow!("Could not find service"))?; - - self.update_group_service_bindings(¤t_service, Some(&service)) - .await?; - - self.storage.update(&mut service).await?; - tracing::info!( - service_id = %service.id, - service_name = %service.base.name, - host_id = %service.base.host_id, - "Service updated" - ); - tracing::trace!("Result: {:?}", service); - Ok(service) - } - async fn update_group_service_bindings( &self, current_service: &Service, updates: Option<&Service>, + authenticated: AuthenticatedEntity, ) -> Result<(), Error> { tracing::trace!( "Updating group bindings referencing {:?}, with changes {:?}", @@ -297,13 +423,19 @@ impl ServiceService { }) .collect(); - // Execute updates sequentially - for mut group in groups_to_update { - self.group_service.update(&mut group).await?; + if !groups_to_update.is_empty() { + // Execute updates sequentially + for mut group in groups_to_update { + self.group_service + .update(&mut group, authenticated.clone()) + .await?; + } + tracing::info!( + service = %current_service, + "Updated group bindings" + ); } - tracing::info!("Updated group bindings referencing {}", current_service); - Ok(()) } @@ -397,10 +529,10 @@ impl ServiceService { mutable_service.base.network_id = updated_host.base.network_id; tracing::info!( - "Reassigned service {} bindings for from host {} to host {}", - mutable_service, - original_host, - updated_host + service = %mutable_service, + origin_host = %original_host, + destination_host = %updated_host, + "Reassigned service bindings", ); tracing::trace!( @@ -412,25 +544,4 @@ impl ServiceService { mutable_service } - - pub async fn delete_service(&self, id: &Uuid) -> Result<()> { - let lock = self.get_service_lock(id).await; - let _guard = lock.lock().await; - - let service = self - .get_by_id(id) - .await? - .ok_or_else(|| anyhow::anyhow!("Service {} not found", id))?; - - self.update_group_service_bindings(&service, None).await?; - - self.storage.delete(id).await?; - tracing::info!( - "Deleted service {}: {} for host {}", - service.base.name, - service.id, - service.base.host_id - ); - Ok(()) - } } diff --git a/backend/src/server/services/tests.rs b/backend/src/server/services/tests.rs index 4628a647..0d67baad 100644 --- a/backend/src/server/services/tests.rs +++ b/backend/src/server/services/tests.rs @@ -2,6 +2,7 @@ use serial_test::serial; use crate::{ server::{ + auth::middleware::AuthenticatedEntity, groups::r#impl::types::GroupType, services::r#impl::{bindings::Binding, patterns::MatchDetails}, shared::{ @@ -19,19 +20,19 @@ async fn test_service_deduplication_on_create() { let organization = services .organization_service - .create(organization()) + .create(organization(), AuthenticatedEntity::System) .await .unwrap(); let network = services .network_service - .create(network(&organization.id)) + .create(network(&organization.id), AuthenticatedEntity::System) .await .unwrap(); let subnet_obj = subnet(&network.id); services .subnet_service - .create(subnet_obj.clone()) + .create(subnet_obj.clone(), AuthenticatedEntity::System) .await .unwrap(); @@ -53,7 +54,11 @@ async fn test_service_deduplication_on_create() { let (created_host, created1) = services .host_service - .create_host_with_services(host_obj.clone(), vec![svc1.clone()]) + .create_host_with_services( + host_obj.clone(), + vec![svc1.clone()], + AuthenticatedEntity::System, + ) .await .unwrap(); @@ -72,7 +77,7 @@ async fn test_service_deduplication_on_create() { let created2 = services .service_service - .create_service(svc2.clone()) + .create(svc2.clone(), AuthenticatedEntity::System) .await .unwrap(); @@ -92,19 +97,19 @@ async fn test_service_deletion_cleans_up_relationships() { let organization = services .organization_service - .create(organization()) + .create(organization(), AuthenticatedEntity::System) .await .unwrap(); let network = services .network_service - .create(network(&organization.id)) + .create(network(&organization.id), AuthenticatedEntity::System) .await .unwrap(); let subnet_obj = subnet(&network.id); let created_subnet = services .subnet_service - .create(subnet_obj.clone()) + .create(subnet_obj.clone(), AuthenticatedEntity::System) .await .unwrap(); @@ -121,7 +126,11 @@ async fn test_service_deletion_cleans_up_relationships() { services .host_service - .create_host_with_services(host_obj.clone(), vec![svc.clone()]) + .create_host_with_services( + host_obj.clone(), + vec![svc.clone()], + AuthenticatedEntity::System, + ) .await .unwrap(); @@ -136,12 +145,16 @@ async fn test_service_deletion_cleans_up_relationships() { group_obj.base.group_type = GroupType::RequestPath { service_bindings: vec![created_svc.base.bindings[0].id()], }; - let created_group = services.group_service.create(group_obj).await.unwrap(); + let created_group = services + .group_service + .create(group_obj, AuthenticatedEntity::System) + .await + .unwrap(); // Delete service services .service_service - .delete_service(&created_svc.id) + .delete(&created_svc.id, AuthenticatedEntity::System) .await .unwrap(); diff --git a/backend/src/server/shared/entities.rs b/backend/src/server/shared/entities.rs index ee24550b..e3360d93 100644 --- a/backend/src/server/shared/entities.rs +++ b/backend/src/server/shared/entities.rs @@ -1,10 +1,26 @@ +use serde::{Deserialize, Serialize}; use strum_macros::{Display, EnumDiscriminants, EnumIter, IntoStaticStr}; use crate::server::shared::types::metadata::{EntityMetadataProvider, HasId}; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumDiscriminants, EnumIter, IntoStaticStr)] +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + EnumDiscriminants, + EnumIter, + IntoStaticStr, + Serialize, + Deserialize, + Display, +)] #[strum_discriminants(derive(Display))] pub enum Entity { + Organization, + Invite, Network, ApiKey, User, @@ -38,11 +54,13 @@ impl HasId for Entity { impl EntityMetadataProvider for Entity { fn color(&self) -> &'static str { match self { + Entity::Organization => "blue", Entity::Network => "gray", Entity::Daemon => "green", Entity::Discovery => "green", Entity::ApiKey => "yellow", Entity::User => "blue", + Entity::Invite => "green", Entity::Host => "blue", Entity::Service => "purple", @@ -66,8 +84,10 @@ impl EntityMetadataProvider for Entity { fn icon(&self) -> &'static str { match self { + Entity::Organization => "Building", Entity::Network => "Globe", Entity::User => "User", + Entity::Invite => "UserPlus", Entity::ApiKey => "Key", Entity::Daemon => "SatelliteDish", Entity::Discovery => "Radar", diff --git a/backend/src/server/shared/events/bus.rs b/backend/src/server/shared/events/bus.rs new file mode 100644 index 00000000..e7e7c35f --- /dev/null +++ b/backend/src/server/shared/events/bus.rs @@ -0,0 +1,132 @@ +use std::{collections::HashMap, sync::Arc}; + +use tokio::sync::RwLock; + +use anyhow::Result; +use async_trait::async_trait; +use tokio::sync::broadcast; +use uuid::Uuid; + +use crate::server::shared::{ + entities::Entity, + events::types::{EntityEvent, EntityOperation}, +}; + +// Trait for event subscribers +#[async_trait] +pub trait EventSubscriber: Send + Sync { + /// Return the types of events this subscriber cares about + fn event_filter(&self) -> EventFilter; + + /// Handle an event + async fn handle_event(&self, event: &EntityEvent) -> Result<()>; + + /// Optional: subscriber name for debugging + fn name(&self) -> &str; +} + +#[derive(Debug, Clone)] +pub struct EventFilter { + pub entity_operations: Option>>>, + pub network_ids: Option>, +} + +impl EventFilter { + pub fn all() -> Self { + Self { + entity_operations: None, + network_ids: None, + } + } + + pub fn matches(&self, event: &EntityEvent) -> bool { + if let Some(networks) = &self.network_ids + && let Some(network_id) = event.network_id + && !networks.contains(&network_id) + { + return false; + } + + if let Some(entity_operations) = &self.entity_operations { + if let Some(operations) = entity_operations.get(&event.entity_type) { + if operations.is_none() { + return true; + } else if let Some(operations) = operations + && operations.contains(&event.operation) + { + return true; + } + } + return false; + } + + true + } +} + +pub struct EventBus { + sender: broadcast::Sender, + subscribers: Arc>>>, +} + +impl Default for EventBus { + fn default() -> Self { + Self::new() + } +} + +impl EventBus { + pub fn new() -> Self { + let (sender, _) = broadcast::channel(1000); // Buffer size + + Self { + sender, + subscribers: Arc::new(RwLock::new(Vec::new())), + } + } + + /// Register a subscriber + pub async fn register_subscriber(&self, subscriber: Arc) { + let mut subscribers = self.subscribers.write().await; + subscribers.push(subscriber.clone()); + tracing::info!( + subscriber = %subscriber.name(), + "Registered event subscriber", + ); + } + + /// Publish an event to all subscribers + pub async fn publish(&self, event: EntityEvent) -> Result<()> { + tracing::debug!( + operation = %event.operation, + entity_type = %event.entity_type, + entity_id = %event.entity_id, + "Publishing event", + ); + + // Send to broadcast channel (non-blocking) + let _ = self.sender.send(event.clone()); + + // Also notify direct subscribers (blocking, with error handling) + let subscribers = self.subscribers.read().await; + + for subscriber in subscribers.iter() { + if subscriber.event_filter().matches(&event) + && let Err(e) = subscriber.handle_event(&event).await + { + tracing::error!( + subscriber = subscriber.name(), + error = %e, + "Subscriber failed to handle event", + ); + } + } + + Ok(()) + } + + /// Get a receiver for raw event stream (useful for SSE) + pub fn subscribe_channel(&self) -> broadcast::Receiver { + self.sender.subscribe() + } +} diff --git a/backend/src/server/shared/events/mod.rs b/backend/src/server/shared/events/mod.rs new file mode 100644 index 00000000..2fe40530 --- /dev/null +++ b/backend/src/server/shared/events/mod.rs @@ -0,0 +1,2 @@ +pub mod bus; +pub mod types; diff --git a/backend/src/server/shared/events/types.rs b/backend/src/server/shared/events/types.rs new file mode 100644 index 00000000..30d5b89d --- /dev/null +++ b/backend/src/server/shared/events/types.rs @@ -0,0 +1,38 @@ +use crate::server::{auth::middleware::AuthenticatedEntity, shared::entities::Entity}; +use chrono::{DateTime, Utc}; +use serde::Serialize; +use std::fmt::Display; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq, strum::Display)] +pub enum EntityOperation { + Created, + Updated, + Deleted, + Custom(&'static str), +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct EntityEvent { + pub id: Uuid, + pub entity_type: Entity, + pub entity_id: Uuid, + pub network_id: Option, // Some entities might belong to an org, not a network + pub organization_id: Option, // Some entities might belong to a network, not an org + pub operation: EntityOperation, + pub timestamp: DateTime, + pub authentication: AuthenticatedEntity, + + // Optional: store change details + pub metadata: serde_json::Value, +} + +impl Display for EntityEvent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Event: {{ id: {}, entity_type: {}, entity_id: {} }}", + self.id, self.entity_type, self.entity_id + ) + } +} diff --git a/backend/src/server/shared/handlers/factory.rs b/backend/src/server/shared/handlers/factory.rs index 95e444c7..e19de7fe 100644 --- a/backend/src/server/shared/handlers/factory.rs +++ b/backend/src/server/shared/handlers/factory.rs @@ -1,5 +1,5 @@ use crate::server::api_keys::r#impl::base::{ApiKey, ApiKeyBase}; -use crate::server::auth::middleware::{AuthenticatedUser, RequireOwner}; +use crate::server::auth::middleware::{AuthenticatedEntity, AuthenticatedUser, RequireOwner}; use crate::server::billing::types::base::BillingPlan; use crate::server::billing::types::features::Feature; use crate::server::config::PublicConfigResponse; @@ -16,6 +16,7 @@ use crate::server::shared::storage::traits::StorableEntity; use crate::server::shared::types::api::{ApiError, ApiResult}; use crate::server::shared::types::metadata::{MetadataProvider, MetadataRegistry}; use crate::server::subnets::r#impl::types::SubnetType; +use crate::server::topology::types::base::{Topology, TopologyBase}; use crate::server::topology::types::edges::EdgeType; use crate::server::users::r#impl::permissions::UserOrgPermissions; use crate::server::{ @@ -140,18 +141,34 @@ pub async fn onboarding( org.base.name = request.organization_name; org.base.is_onboarded = true; - let updated_org = state.services.organization_service.update(&mut org).await?; + let updated_org = state + .services + .organization_service + .update(&mut org, user.clone().into()) + .await?; let mut network = Network::new(NetworkBase::new(user.organization_id)); network.base.name = request.network_name; - let network = state.services.network_service.create(network).await?; + let network = state + .services + .network_service + .create(network, user.clone().into()) + .await?; + + let topology = Topology::new(TopologyBase::new("My Topology".to_string(), network.id)); + + state + .services + .topology_service + .create(topology, user.clone().into()) + .await?; if request.populate_seed_data { state .services .network_service - .seed_default_data(network.id) + .seed_default_data(network.id, user.into()) .await?; } @@ -159,14 +176,17 @@ pub async fn onboarding( let api_key = state .services .api_key_service - .create(ApiKey::new(ApiKeyBase { - key: "".to_string(), - name: "Integrated Daemon API Key".to_string(), - last_used: None, - expires_at: None, - network_id: network.id, - is_enabled: true, - })) + .create( + ApiKey::new(ApiKeyBase { + key: "".to_string(), + name: "Integrated Daemon API Key".to_string(), + last_used: None, + expires_at: None, + network_id: network.id, + is_enabled: true, + }), + AuthenticatedEntity::System, + ) .await?; state diff --git a/backend/src/server/shared/handlers/traits.rs b/backend/src/server/shared/handlers/traits.rs index 9d4557d7..707e4a93 100644 --- a/backend/src/server/shared/handlers/traits.rs +++ b/backend/src/server/shared/handlers/traits.rs @@ -81,15 +81,18 @@ where ); let service = T::get_service(&state); - let created = service.create(request).await.map_err(|e| { - tracing::error!( - entity_type = T::table_name(), - user_id = %user.user_id, - error = %e, - "Failed to create entity" - ); - ApiError::internal_error(&e.to_string()) - })?; + let created = service + .create(request, user.clone().into()) + .await + .map_err(|e| { + tracing::error!( + entity_type = T::table_name(), + user_id = %user.user_id, + error = %e, + "Failed to create entity" + ); + ApiError::internal_error(&e.to_string()) + })?; tracing::info!( entity_type = T::table_name(), @@ -229,16 +232,19 @@ where ApiError::not_found(format!("{} '{}' not found", T::entity_name(), id)) })?; - let updated = service.update(&mut request).await.map_err(|e| { - tracing::error!( - entity_type = T::table_name(), - entity_id = %id, - user_id = %user.user_id, - error = %e, - "Failed to update entity" - ); - ApiError::internal_error(&e.to_string()) - })?; + let updated = service + .update(&mut request, user.clone().into()) + .await + .map_err(|e| { + tracing::error!( + entity_type = T::table_name(), + entity_id = %id, + user_id = %user.user_id, + error = %e, + "Failed to update entity" + ); + ApiError::internal_error(&e.to_string()) + })?; tracing::info!( entity_type = T::table_name(), @@ -252,7 +258,7 @@ where pub async fn delete_handler( State(state): State>, - RequireMember(_user): RequireMember, + RequireMember(user): RequireMember, Path(id): Path, ) -> ApiResult>> where @@ -289,7 +295,7 @@ where "Delete request received" ); - service.delete(&id).await.map_err(|e| { + service.delete(&id, user.into()).await.map_err(|e| { tracing::error!( entity_type = T::table_name(), entity_id = %id, diff --git a/backend/src/server/shared/mod.rs b/backend/src/server/shared/mod.rs index c2637fc0..cb3e9382 100644 --- a/backend/src/server/shared/mod.rs +++ b/backend/src/server/shared/mod.rs @@ -1,4 +1,5 @@ pub mod entities; +pub mod events; pub mod handlers; pub mod services; pub mod storage; diff --git a/backend/src/server/shared/services/factory.rs b/backend/src/server/shared/services/factory.rs index eb1c3df6..3783e46e 100644 --- a/backend/src/server/shared/services/factory.rs +++ b/backend/src/server/shared/services/factory.rs @@ -11,7 +11,7 @@ use crate::server::{ networks::service::NetworkService, organizations::service::OrganizationService, services::service::ServiceService, - shared::storage::factory::StorageFactory, + shared::{events::bus::EventBus, storage::factory::StorageFactory}, subnets::service::SubnetService, topology::service::main::TopologyService, users::service::UserService, @@ -35,34 +35,52 @@ pub struct ServiceFactory { pub oidc_service: Option>, pub billing_service: Option>, pub email_service: Option>, + pub event_bus: Arc, } impl ServiceFactory { pub async fn new(storage: &StorageFactory, config: Option) -> Result { - let api_key_service = Arc::new(ApiKeyService::new(storage.api_keys.clone())); - let daemon_service = Arc::new(DaemonService::new(storage.daemons.clone())); - let group_service = Arc::new(GroupService::new(storage.groups.clone())); - let organization_service = - Arc::new(OrganizationService::new(storage.organizations.clone())); + let event_bus = Arc::new(EventBus::new()); + + let api_key_service = Arc::new(ApiKeyService::new( + storage.api_keys.clone(), + event_bus.clone(), + )); + let daemon_service = Arc::new(DaemonService::new( + storage.daemons.clone(), + event_bus.clone(), + )); + let group_service = Arc::new(GroupService::new(storage.groups.clone(), event_bus.clone())); + let organization_service = Arc::new(OrganizationService::new( + storage.organizations.clone(), + event_bus.clone(), + )); // Already implements Arc internally due to scheduler + sessions - let discovery_service = - DiscoveryService::new(storage.discovery.clone(), daemon_service.clone()).await?; + let discovery_service = DiscoveryService::new( + storage.discovery.clone(), + daemon_service.clone(), + event_bus.clone(), + ) + .await?; let service_service = Arc::new(ServiceService::new( storage.services.clone(), group_service.clone(), + event_bus.clone(), )); let host_service = Arc::new(HostService::new( storage.hosts.clone(), service_service.clone(), daemon_service.clone(), + event_bus.clone(), )); let subnet_service = Arc::new(SubnetService::new( storage.subnets.clone(), host_service.clone(), + event_bus.clone(), )); let _ = service_service.set_host_service(host_service.clone()); @@ -72,15 +90,17 @@ impl ServiceFactory { subnet_service.clone(), group_service.clone(), service_service.clone(), - storage.topology.clone(), + storage.topologies.clone(), + event_bus.clone(), )); let network_service = Arc::new(NetworkService::new( storage.networks.clone(), host_service.clone(), subnet_service.clone(), + event_bus.clone(), )); - let user_service = Arc::new(UserService::new(storage.users.clone())); + let user_service = Arc::new(UserService::new(storage.users.clone(), event_bus.clone())); let billing_service = config.clone().and_then(|c| { if let Some(strip_secret) = c.stripe_secret @@ -141,6 +161,10 @@ impl ServiceFactory { None }); + event_bus + .register_subscriber(topology_service.clone()) + .await; + Ok(Self { user_service, auth_service, @@ -157,6 +181,7 @@ impl ServiceFactory { oidc_service, billing_service, email_service, + event_bus, }) } } diff --git a/backend/src/server/shared/services/traits.rs b/backend/src/server/shared/services/traits.rs index 2690070f..d08f0d40 100644 --- a/backend/src/server/shared/services/traits.rs +++ b/backend/src/server/shared/services/traits.rs @@ -1,11 +1,22 @@ use async_trait::async_trait; +use chrono::Utc; use std::{fmt::Display, sync::Arc}; use uuid::Uuid; -use crate::server::shared::storage::{ - filter::EntityFilter, - generic::GenericPostgresStorage, - traits::{StorableEntity, Storage}, +use crate::server::{ + auth::middleware::AuthenticatedEntity, + shared::{ + entities::Entity, + events::{ + bus::EventBus, + types::{EntityEvent, EntityOperation}, + }, + storage::{ + filter::EntityFilter, + generic::GenericPostgresStorage, + traits::{StorableEntity, Storage}, + }, + }, }; /// Helper trait for services that use generic storage @@ -18,6 +29,13 @@ where /// Get reference to the storage fn storage(&self) -> &Arc>; + /// Event bus and helpers + fn event_bus(&self) -> &Arc; + + fn entity_type() -> Entity; + fn get_network_id(&self, entity: &T) -> Option; + fn get_organization_id(&self, entity: &T) -> Option; + /// Get entity by ID async fn get_by_id(&self, id: &Uuid) -> Result, anyhow::Error> { self.storage().get_by_id(id).await @@ -34,21 +52,34 @@ where } /// Delete entity by ID - async fn delete(&self, id: &Uuid) -> Result<(), anyhow::Error> { - // ADD logging before deletion + async fn delete( + &self, + id: &Uuid, + authentication: AuthenticatedEntity, + ) -> Result<(), anyhow::Error> { if let Some(entity) = self.get_by_id(id).await? { - tracing::info!( - entity_type = T::table_name(), - entity_id = %id, - entity_name = %entity, - "Deleting entity" - ); self.storage().delete(id).await?; - tracing::debug!( + + self.event_bus() + .publish(EntityEvent { + id: Uuid::new_v4(), + entity_type: Self::entity_type(), + entity_id: *id, + network_id: self.get_network_id(&entity), + organization_id: self.get_organization_id(&entity), + operation: EntityOperation::Deleted, + timestamp: Utc::now(), + metadata: serde_json::json!({}), + authentication, + }) + .await?; + + tracing::info!( entity_type = T::table_name(), entity_id = %id, - "Entity deleted successfully" + "Entity deleted" ); + Ok(()) } else { Err(anyhow::anyhow!( @@ -60,27 +91,36 @@ where } /// Create entity - async fn create(&self, entity: T) -> Result { + async fn create( + &self, + entity: T, + authentication: AuthenticatedEntity, + ) -> Result { let entity = if entity.id() == Uuid::nil() { T::new(entity.get_base()) } else { entity }; - // ADD logging before creation - tracing::debug!( - entity_type = T::table_name(), - entity_id = %entity.id(), - entity_name = %entity, - "Creating entity" - ); - let created = self.storage().create(&entity).await?; + self.event_bus() + .publish(EntityEvent { + id: Uuid::new_v4(), + entity_type: Self::entity_type(), + entity_id: created.id(), + network_id: self.get_network_id(&created), + organization_id: self.get_organization_id(&created), + operation: EntityOperation::Created, + timestamp: Utc::now(), + metadata: serde_json::json!({}), + authentication, + }) + .await?; + tracing::info!( entity_type = T::table_name(), entity_id = %created.id(), - entity_name = %created, "Entity created" ); @@ -88,16 +128,27 @@ where } /// Update entity - async fn update(&self, entity: &mut T) -> Result { - tracing::debug!( - entity_type = T::table_name(), - entity_id = %entity.id(), - entity_name = %entity, - "Updating entity" - ); - + async fn update( + &self, + entity: &mut T, + authentication: AuthenticatedEntity, + ) -> Result { let updated = self.storage().update(entity).await?; + self.event_bus() + .publish(EntityEvent { + id: Uuid::new_v4(), + entity_type: Self::entity_type(), + entity_id: updated.id(), + network_id: self.get_network_id(&updated), + organization_id: self.get_organization_id(&updated), + operation: EntityOperation::Updated, + timestamp: Utc::now(), + metadata: serde_json::json!({}), + authentication, + }) + .await?; + tracing::info!( entity_type = T::table_name(), entity_id = %updated.id(), diff --git a/backend/src/server/shared/storage/factory.rs b/backend/src/server/shared/storage/factory.rs index 1ebfb150..1571c8a0 100644 --- a/backend/src/server/shared/storage/factory.rs +++ b/backend/src/server/shared/storage/factory.rs @@ -24,7 +24,7 @@ pub struct StorageFactory { pub services: Arc>, pub organizations: Arc>, pub discovery: Arc>, - pub topology: Arc>, + pub topologies: Arc>, } pub async fn create_session_store( @@ -63,7 +63,7 @@ impl StorageFactory { daemons: Arc::new(GenericPostgresStorage::new(pool.clone())), subnets: Arc::new(GenericPostgresStorage::new(pool.clone())), services: Arc::new(GenericPostgresStorage::new(pool.clone())), - topology: Arc::new(GenericPostgresStorage::new(pool.clone())), + topologies: Arc::new(GenericPostgresStorage::new(pool.clone())), }) } } diff --git a/backend/src/server/shared/storage/generic.rs b/backend/src/server/shared/storage/generic.rs index 9e6c1c46..192aac8a 100644 --- a/backend/src/server/shared/storage/generic.rs +++ b/backend/src/server/shared/storage/generic.rs @@ -66,7 +66,11 @@ where SqlValue::Bool(v) => query.bind(v), SqlValue::Timestamp(v) => query.bind(v), SqlValue::OptionTimestamp(v) => query.bind(v), - SqlValue::UuidArray(v) => query.bind(serde_json::to_value(v)?), + SqlValue::UuidArray(v) => { + // Create a reference that lives as long as 'q + let slice: &'q [Uuid] = v; + query.bind(slice) + } SqlValue::OptionalString(v) => query.bind(v), SqlValue::EntitySource(v) => query.bind(serde_json::to_value(v)?), SqlValue::IpCidr(v) => query.bind(serde_json::to_string(v)?), @@ -92,6 +96,10 @@ where SqlValue::Nodes(v) => query.bind(serde_json::to_value(v)?), SqlValue::Edges(v) => query.bind(serde_json::to_value(v)?), SqlValue::TopologyOptions(v) => query.bind(serde_json::to_value(v)?), + SqlValue::Hosts(v) => query.bind(serde_json::to_value(v)?), + SqlValue::Subnets(v) => query.bind(serde_json::to_value(v)?), + SqlValue::Services(v) => query.bind(serde_json::to_value(v)?), + SqlValue::Groups(v) => query.bind(serde_json::to_value(v)?), }; Ok(value) diff --git a/backend/src/server/shared/storage/traits.rs b/backend/src/server/shared/storage/traits.rs index 5e746585..24f05dc8 100644 --- a/backend/src/server/shared/storage/traits.rs +++ b/backend/src/server/shared/storage/traits.rs @@ -1,20 +1,16 @@ use std::net::IpAddr; -use async_trait::async_trait; -use chrono::{DateTime, Utc}; -use cidr::IpCidr; -use email_address::EmailAddress; -use sqlx::postgres::PgRow; -use stripe_billing::SubscriptionStatus; -use uuid::Uuid; - +use crate::server::groups::r#impl::base::Group; +use crate::server::services::r#impl::base::Service; +use crate::server::subnets::r#impl::base::Subnet; use crate::server::{ billing::types::base::BillingPlan, daemons::r#impl::{api::DaemonCapabilities, base::DaemonMode}, discovery::r#impl::types::{DiscoveryType, RunType}, groups::r#impl::types::GroupType, hosts::r#impl::{ - interfaces::Interface, ports::Port, targets::HostTarget, virtualization::HostVirtualization, + base::Host, interfaces::Interface, ports::Port, targets::HostTarget, + virtualization::HostVirtualization, }, services::r#impl::{ bindings::Binding, definitions::ServiceDefinition, virtualization::ServiceVirtualization, @@ -22,12 +18,19 @@ use crate::server::{ shared::{storage::filter::EntityFilter, types::entities::EntitySource}, subnets::r#impl::types::SubnetType, topology::types::{ - api::TopologyOptions, + base::TopologyOptions, edges::{Edge, EdgeStyle}, nodes::Node, }, users::r#impl::permissions::UserOrgPermissions, }; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use cidr::IpCidr; +use email_address::EmailAddress; +use sqlx::postgres::PgRow; +use stripe_billing::SubscriptionStatus; +use uuid::Uuid; #[async_trait] pub trait Storage: Send + Sync { @@ -100,4 +103,8 @@ pub enum SqlValue { Nodes(Vec), Edges(Vec), TopologyOptions(TopologyOptions), + Hosts(Vec), + Subnets(Vec), + Services(Vec), + Groups(Vec), } diff --git a/backend/src/server/subnets/handlers.rs b/backend/src/server/subnets/handlers.rs index c21aa380..562dc374 100644 --- a/backend/src/server/subnets/handlers.rs +++ b/backend/src/server/subnets/handlers.rs @@ -53,7 +53,7 @@ pub async fn create_handler( ); let service = Subnet::get_service(&state); - let created = service.create(request).await.map_err(|e| { + let created = service.create(request, entity.clone()).await.map_err(|e| { tracing::error!( error = %e, entity_id = %entity.entity_id(), @@ -76,11 +76,33 @@ async fn get_all_subnets( State(state): State>, entity: AuthenticatedEntity, ) -> ApiResult>>> { - tracing::debug!( - entity_id = %entity.entity_id(), - network_count = %entity.network_ids().len(), - "Get all subnets request received" - ); + match &entity { + AuthenticatedEntity::User { + user_id, + network_ids, + .. + } => { + tracing::debug!( + entity_type = "subnet", + user_id = %user_id, + network_count = %network_ids.len(), + "Get all request received" + ); + } + AuthenticatedEntity::Daemon { .. } => { + tracing::debug!( + entity_type = "subnet", + daemon_id = %entity.entity_id(), + network_count = 1, + "Get all request received" + ); + } + AuthenticatedEntity::System => { + return Err(ApiError::internal_error( + "System should not authenticate for requests to /subnets/", + )); + } + } let service = &state.services.subnet_service; let filter = EntityFilter::unfiltered().network_ids(&entity.network_ids()); @@ -94,11 +116,29 @@ async fn get_all_subnets( ApiError::internal_error(&e.to_string()) })?; - tracing::debug!( - entity_id = %entity.entity_id(), - subnet_count = %subnets.len(), - "Subnets fetched successfully" - ); + match &entity { + AuthenticatedEntity::User { user_id, .. } => { + tracing::debug!( + user_id = %user_id, + entity_type = "subnet", + subnet_count = %subnets.len(), + "Entities fetched successfully" + ); + } + AuthenticatedEntity::Daemon { .. } => { + tracing::debug!( + entity_type = "subnet", + daemon_id = %entity.entity_id(), + subnet_count = %subnets.len(), + "Entities fetched successfully" + ); + } + AuthenticatedEntity::System => { + return Err(ApiError::internal_error( + "System should not authenticate for requests to /subnets/", + )); + } + } Ok(Json(ApiResponse::success(subnets))) } diff --git a/backend/src/server/subnets/impl/base.rs b/backend/src/server/subnets/impl/base.rs index 9908b011..7dc22b16 100644 --- a/backend/src/server/subnets/impl/base.rs +++ b/backend/src/server/subnets/impl/base.rs @@ -42,7 +42,7 @@ impl Default for SubnetBase { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Eq)] pub struct Subnet { pub id: Uuid, pub created_at: DateTime, diff --git a/backend/src/server/subnets/service.rs b/backend/src/server/subnets/service.rs index 9d8d9223..7a1e7646 100644 --- a/backend/src/server/subnets/service.rs +++ b/backend/src/server/subnets/service.rs @@ -1,7 +1,13 @@ use crate::server::{ + auth::middleware::AuthenticatedEntity, discovery::r#impl::types::DiscoveryType, hosts::service::HostService, shared::{ + entities::Entity, + events::{ + bus::EventBus, + types::{EntityEvent, EntityOperation}, + }, services::traits::CrudService, storage::{ filter::EntityFilter, @@ -14,13 +20,14 @@ use crate::server::{ }; use anyhow::Result; use async_trait::async_trait; -use futures::future::try_join_all; +use chrono::Utc; use std::sync::Arc; use uuid::Uuid; pub struct SubnetService { storage: Arc>, host_service: Arc, + event_bus: Arc, } #[async_trait] @@ -29,7 +36,25 @@ impl CrudService for SubnetService { &self.storage } - async fn create(&self, subnet: Subnet) -> Result { + fn event_bus(&self) -> &Arc { + &self.event_bus + } + + fn entity_type() -> Entity { + Entity::Subnet + } + fn get_network_id(&self, entity: &Subnet) -> Option { + Some(entity.base.network_id) + } + fn get_organization_id(&self, _entity: &Subnet) -> Option { + None + } + + async fn create( + &self, + subnet: Subnet, + authentication: AuthenticatedEntity, + ) -> Result { let filter = EntityFilter::unfiltered().network_ids(&[subnet.base.network_id]); let all_subnets = self.storage.get_all(filter).await?; @@ -102,7 +127,22 @@ impl CrudService for SubnetService { } // If there's no existing subnet, create a new one _ => { - self.storage.create(&subnet).await?; + let created = self.storage.create(&subnet).await?; + + self.event_bus() + .publish(EntityEvent { + id: Uuid::new_v4(), + entity_type: Entity::Subnet, + entity_id: created.id, + network_id: self.get_network_id(&created), + organization_id: self.get_organization_id(&created), + operation: EntityOperation::Created, + timestamp: Utc::now(), + metadata: serde_json::json!({}), + authentication, + }) + .await?; + tracing::info!( subnet_id = %subnet.id, subnet_name = %subnet.base.name, @@ -116,7 +156,7 @@ impl CrudService for SubnetService { Ok(subnet_from_storage) } - async fn delete(&self, id: &Uuid) -> Result<()> { + async fn delete(&self, id: &Uuid, authentication: AuthenticatedEntity) -> Result<()> { let subnet = self .get_by_id(id) .await? @@ -132,7 +172,8 @@ impl CrudService for SubnetService { let filter = EntityFilter::unfiltered().network_ids(&[subnet.base.network_id]); let hosts = self.host_service.get_all(filter).await?; - let update_futures = hosts.into_iter().filter_map(|mut host| { + let mut updated_count = 0; + for mut host in hosts { let has_subnet = host.base.interfaces.iter().any(|i| &i.base.subnet_id == id); if has_subnet { host.base.interfaces = host @@ -142,24 +183,39 @@ impl CrudService for SubnetService { .filter(|i| &i.base.subnet_id != id) .cloned() .collect(); - return Some(self.host_service.update_host(host)); + self.host_service + .update(&mut host, authentication.clone()) + .await?; + updated_count += 1; } - None - }); - - let updated_hosts = try_join_all(update_futures).await?; + } tracing::debug!( subnet_id = %subnet.id, - affected_hosts = %updated_hosts.len(), + affected_hosts = %updated_count, "Cleaned up host interfaces referencing subnet" ); self.storage.delete(id).await?; + + self.event_bus() + .publish(EntityEvent { + id: Uuid::new_v4(), + entity_type: Entity::Subnet, + entity_id: subnet.id, + network_id: self.get_network_id(&subnet), + organization_id: self.get_organization_id(&subnet), + operation: EntityOperation::Deleted, + timestamp: Utc::now(), + metadata: serde_json::json!({}), + authentication, + }) + .await?; + tracing::info!( subnet_id = %subnet.id, subnet_name = %subnet.base.name, - affected_hosts = %updated_hosts.len(), + affected_hosts = %updated_count, "Subnet deleted" ); Ok(()) @@ -170,10 +226,12 @@ impl SubnetService { pub fn new( storage: Arc>, host_service: Arc, + event_bus: Arc, ) -> Self { Self { storage, host_service, + event_bus, } } } diff --git a/backend/src/server/topology/handlers.rs b/backend/src/server/topology/handlers.rs index c8c4e2e0..bf20be24 100644 --- a/backend/src/server/topology/handlers.rs +++ b/backend/src/server/topology/handlers.rs @@ -1,13 +1,15 @@ use crate::server::{ - auth::middleware::AuthenticatedUser, + auth::middleware::RequireMember, config::AppState, shared::{ handlers::traits::{ - create_handler, delete_handler, get_all_handler, get_by_id_handler, update_handler, + CrudHandlers, delete_handler, get_all_handler, get_by_id_handler, update_handler, }, - types::api::{ApiResponse, ApiResult}, + services::traits::CrudService, + storage::traits::StorableEntity, + types::api::{ApiError, ApiResponse, ApiResult}, }, - topology::types::{api::TopologyOptions, base::Topology}, + topology::types::base::Topology, }; use axum::{ Router, @@ -19,23 +21,132 @@ use std::sync::Arc; pub fn create_router() -> Router> { Router::new() - .route("/", post(create_handler::)) + .route("/", post(create_handler)) .route("/", get(get_all_handler::)) .route("/{id}", put(update_handler::)) .route("/{id}", delete(delete_handler::)) .route("/{id}", get(get_by_id_handler::)) - .route("/generate", post(generate_topology)) + .route("/{id}/refresh", post(refresh)) + .route("/{id}/lock", post(lock)) + .route("/{id}/unlock", post(unlock)) } -async fn generate_topology( +pub async fn create_handler( State(state): State>, - _user: AuthenticatedUser, - Json(request): Json, -) -> ApiResult>> { - let service = &state.services.topology_service; - let graph = service.build_graph(request).await?; + RequireMember(user): RequireMember, + Json(mut topology): Json, +) -> ApiResult>> { + if let Err(err) = topology.validate() { + tracing::warn!( + entity_type = Topology::table_name(), + user_id = %user.user_id, + error = %err, + "Entity validation failed" + ); + return Err(ApiError::bad_request(&format!( + "{} validation failed: {}", + Topology::entity_name(), + err + ))); + } - let json = serde_json::to_value(&graph)?; + tracing::debug!( + entity_type = Topology::table_name(), + user_id = %user.user_id, + "Create request received" + ); - Ok(Json(ApiResponse::success(json))) + let service = Topology::get_service(&state); + + let (hosts, services, subnets, groups) = service + .get_entity_data(topology.base.network_id, topology.base.options.clone()) + .await?; + + let (nodes, edges) = + service.build_graph(&topology.base.options, &hosts, &subnets, &services, &groups); + + topology.base.hosts = hosts; + topology.base.services = services; + topology.base.subnets = subnets; + topology.base.groups = groups; + topology.base.edges = edges; + topology.base.nodes = nodes; + topology.refresh(); + + let created = service + .create(topology, user.clone().into()) + .await + .map_err(|e| { + tracing::error!( + entity_type = Topology::table_name(), + user_id = %user.user_id, + error = %e, + "Failed to create entity" + ); + ApiError::internal_error(&e.to_string()) + })?; + + tracing::info!( + entity_type = Topology::table_name(), + entity_id = %created.id(), + user_id = %user.user_id, + "Entity created via API" + ); + + Ok(Json(ApiResponse::success(created))) +} + +async fn refresh( + State(state): State>, + RequireMember(user): RequireMember, + Json(mut topology): Json, +) -> ApiResult>> { + let service = Topology::get_service(&state); + + let (hosts, services, subnets, groups) = service + .get_entity_data(topology.base.network_id, topology.base.options.clone()) + .await?; + + let (nodes, edges) = + service.build_graph(&topology.base.options, &hosts, &subnets, &services, &groups); + + topology.base.hosts = hosts; + topology.base.services = services; + topology.base.subnets = subnets; + topology.base.groups = groups; + topology.base.edges = edges; + topology.base.nodes = nodes; + topology.refresh(); + + let updated = service.update(&mut topology, user.into()).await?; + + Ok(Json(ApiResponse::success(updated))) +} + +async fn lock( + State(state): State>, + RequireMember(user): RequireMember, + Json(mut topology): Json, +) -> ApiResult>> { + let service = Topology::get_service(&state); + + topology.lock(user.user_id); + + let updated = service.update(&mut topology, user.into()).await?; + + Ok(Json(ApiResponse::success(updated))) +} + +async fn unlock( + State(state): State>, + RequireMember(user): RequireMember, + Json(mut topology): Json, +) -> ApiResult>> { + let service = Topology::get_service(&state); + + topology.unlock(); + + let updated = service.update(&mut topology, user.into()).await?; + + Ok(Json(ApiResponse::success(updated))) } diff --git a/backend/src/server/topology/service/context.rs b/backend/src/server/topology/service/context.rs index 473aa2f9..bd717f38 100644 --- a/backend/src/server/topology/service/context.rs +++ b/backend/src/server/topology/service/context.rs @@ -8,7 +8,7 @@ use crate::server::{ }, subnets::r#impl::base::Subnet, topology::types::{ - api::TopologyOptions, + base::TopologyOptions, edges::Edge, nodes::{Node, NodeType}, }, @@ -140,9 +140,10 @@ impl<'a> TopologyContext<'a> { if let Some(host) = self.hosts.iter().find(|h| h.id == s.base.host_id) { return (self .options + .request .left_zone_service_categories .contains(&s.base.service_definition.category()) - || (self.options.show_gateway_in_left_zone + || (self.options.request.show_gateway_in_left_zone && s.base.service_definition.is_gateway())) && subnet.has_interface_with_service(host, s); } diff --git a/backend/src/server/topology/service/main.rs b/backend/src/server/topology/service/main.rs index 7019f74e..7df242bd 100644 --- a/backend/src/server/topology/service/main.rs +++ b/backend/src/server/topology/service/main.rs @@ -6,21 +6,27 @@ use petgraph::{Graph, graph::NodeIndex}; use uuid::Uuid; use crate::server::{ - groups::service::GroupService, - hosts::service::HostService, + groups::{r#impl::base::Group, service::GroupService}, + hosts::{r#impl::base::Host, service::HostService}, services::{r#impl::base::Service, service::ServiceService}, shared::{ + entities::Entity, + events::bus::EventBus, services::traits::CrudService, storage::{filter::EntityFilter, generic::GenericPostgresStorage}, }, - subnets::service::SubnetService, + subnets::{r#impl::base::Subnet, service::SubnetService}, topology::{ service::{ context::TopologyContext, edge_builder::EdgeBuilder, optimizer::main::TopologyOptimizer, planner::subnet_layout_planner::SubnetLayoutPlanner, }, - types::{api::TopologyOptions, base::Topology, edges::Edge, nodes::Node}, + types::{ + base::{Topology, TopologyOptions}, + edges::Edge, + nodes::Node, + }, }, }; @@ -30,6 +36,7 @@ pub struct TopologyService { subnet_service: Arc, group_service: Arc, service_service: Arc, + event_bus: Arc, } #[async_trait] @@ -37,6 +44,20 @@ impl CrudService for TopologyService { fn storage(&self) -> &Arc> { &self.storage } + + fn event_bus(&self) -> &Arc { + &self.event_bus + } + + fn entity_type() -> Entity { + Entity::Topology + } + fn get_network_id(&self, entity: &Topology) -> Option { + Some(entity.base.network_id) + } + fn get_organization_id(&self, _entity: &Topology) -> Option { + None + } } impl TopologyService { @@ -46,6 +67,7 @@ impl TopologyService { group_service: Arc, service_service: Arc, storage: Arc>, + event_bus: Arc, ) -> Self { Self { host_service, @@ -53,11 +75,16 @@ impl TopologyService { group_service, service_service, storage, + event_bus, } } - pub async fn build_graph(&self, options: TopologyOptions) -> Result, Error> { - let network_filter = EntityFilter::unfiltered().network_ids(&options.network_ids); + pub async fn get_entity_data( + &self, + network_id: Uuid, + options: TopologyOptions, + ) -> Result<(Vec, Vec, Vec, Vec), Error> { + let network_filter = EntityFilter::unfiltered().network_ids(&[network_id]); // Fetch all data let hosts = self.host_service.get_all(network_filter.clone()).await?; let subnets = self.subnet_service.get_all(network_filter.clone()).await?; @@ -69,13 +96,25 @@ impl TopologyService { .into_iter() .filter(|s| { !options + .request .hide_service_categories .contains(&s.base.service_definition.category()) }) .collect(); + Ok((hosts, services, subnets, groups)) + } + + pub fn build_graph( + &self, + options: &TopologyOptions, + hosts: &[Host], + subnets: &[Subnet], + services: &[Service], + groups: &[Group], + ) -> (Vec, Vec) { // Create context to avoid parameter passing - let ctx = TopologyContext::new(&hosts, &subnets, &services, &groups, &options); + let ctx = TopologyContext::new(hosts, subnets, services, groups, options); // Create all edges (needed for anchor analysis) let mut all_edges = Vec::new(); @@ -87,7 +126,7 @@ impl TopologyService { let (container_edges, docker_bridge_host_subnet_id_to_group_on) = EdgeBuilder::create_containerized_service_edges( &ctx, - options.group_docker_bridges_by_host, + options.request.group_docker_bridges_by_host, ); all_edges.extend(container_edges); @@ -97,7 +136,7 @@ impl TopologyService { let (subnet_layouts, child_nodes) = layout_planner.create_subnet_child_nodes( &ctx, &mut all_edges, - options.group_docker_bridges_by_host, + options.request.group_docker_bridges_by_host, docker_bridge_host_subnet_id_to_group_on, ); @@ -123,6 +162,9 @@ impl TopologyService { // Add edges to graph EdgeBuilder::add_edges_to_graph(&mut graph, &node_indices, optimized_edges); - Ok(graph) + ( + graph.node_weights().cloned().collect(), + graph.edge_weights().cloned().collect(), + ) } } diff --git a/backend/src/server/topology/service/mod.rs b/backend/src/server/topology/service/mod.rs index 6531308a..1ada9dc5 100644 --- a/backend/src/server/topology/service/mod.rs +++ b/backend/src/server/topology/service/mod.rs @@ -3,3 +3,4 @@ pub mod edge_builder; pub mod main; pub mod optimizer; pub mod planner; +pub mod subscriber; diff --git a/backend/src/server/topology/service/planner/subnet_layout_planner.rs b/backend/src/server/topology/service/planner/subnet_layout_planner.rs index c1786c1e..3b2384f1 100644 --- a/backend/src/server/topology/service/planner/subnet_layout_planner.rs +++ b/backend/src/server/topology/service/planner/subnet_layout_planner.rs @@ -108,7 +108,7 @@ impl SubnetLayoutPlanner { .collect(); let hide_docker_bridge_vm_header = *subnet_type == SubnetType::DockerBridge - && ctx.options.hide_vm_title_on_docker_container; + && ctx.options.request.hide_vm_title_on_docker_container; if !hide_docker_bridge_vm_header { // If they have at least one interface on a common subnet @@ -264,7 +264,7 @@ impl SubnetLayoutPlanner { &interface_bound_services, interface.id, header_text.is_some(), - ctx.options.hide_ports, + ctx.options.request.hide_ports, ), header: header_text, interface_id: Some(interface.id), diff --git a/backend/src/server/topology/service/subscriber.rs b/backend/src/server/topology/service/subscriber.rs new file mode 100644 index 00000000..14e0186f --- /dev/null +++ b/backend/src/server/topology/service/subscriber.rs @@ -0,0 +1,97 @@ +use std::collections::HashMap; + +use anyhow::Error; +use async_trait::async_trait; + +use crate::server::{ + auth::middleware::AuthenticatedEntity, + shared::{ + entities::Entity, + events::{ + bus::{EventFilter, EventSubscriber}, + types::{EntityEvent, EntityOperation}, + }, + services::traits::CrudService, + storage::filter::EntityFilter, + }, + topology::service::main::TopologyService, +}; + +#[async_trait] +impl EventSubscriber for TopologyService { + fn event_filter(&self) -> EventFilter { + EventFilter { + entity_operations: Some(HashMap::from([ + (Entity::Host, None), + (Entity::Service, None), + (Entity::Subnet, None), + (Entity::Group, None), + ])), + network_ids: None, // All networks + } + } + + async fn handle_event(&self, event: &EntityEvent) -> Result<(), Error> { + if let Some(network_id) = event.network_id { + tracing::debug!( + "Topology validation subscriber handling {} event for {}", + event.operation, + event.entity_type + ); + let network_filter = EntityFilter::unfiltered().network_ids(&[network_id]); + + let topologies = self.get_all(network_filter).await?; + + for mut topology in topologies { + if !topology.base.is_locked { + // Track removed entities for staleness alerts + if event.operation == EntityOperation::Deleted { + match event.entity_type { + Entity::Host => { + topology.base.removed_hosts = { + topology.base.removed_hosts.push(event.entity_id); + topology.base.removed_hosts + } + } + Entity::Service => { + topology.base.removed_services = { + topology.base.removed_services.push(event.entity_id); + topology.base.removed_services + } + } + Entity::Subnet => { + topology.base.removed_subnets = { + topology.base.removed_subnets.push(event.entity_id); + topology.base.removed_subnets + } + } + Entity::Group => { + topology.base.removed_groups = { + topology.base.removed_groups.push(event.entity_id); + topology.base.removed_groups + } + } + _ => (), + } + } + + topology.base.is_stale = true; + self.update(&mut topology, AuthenticatedEntity::System) + .await?; + } + } + } else { + tracing::warn!( + entity_type = %event.entity_type, + operation = %event.operation, + "Topology validation subscriber received event with no network_id", + ); + } + + Ok(()) + } + + fn name(&self) -> &str { + "topology_validation" + } +} diff --git a/backend/src/server/topology/types/api.rs b/backend/src/server/topology/types/api.rs index d1d6040e..e6763fde 100644 --- a/backend/src/server/topology/types/api.rs +++ b/backend/src/server/topology/types/api.rs @@ -1,15 +1,9 @@ +use crate::server::topology::types::edges::Edge; +use crate::server::topology::types::nodes::Node; use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use crate::server::services::r#impl::categories::ServiceCategory; #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, Hash)] -pub struct TopologyOptions { - pub network_ids: Vec, - pub group_docker_bridges_by_host: bool, - pub hide_vm_title_on_docker_container: bool, - pub hide_ports: bool, - pub left_zone_service_categories: Vec, - pub hide_service_categories: Vec, - pub show_gateway_in_left_zone: bool, +pub struct RefreshDataResponse { + pub nodes: Vec, + pub edges: Vec, } diff --git a/backend/src/server/topology/types/base.rs b/backend/src/server/topology/types/base.rs index 51f1187b..9931bfb3 100644 --- a/backend/src/server/topology/types/base.rs +++ b/backend/src/server/topology/types/base.rs @@ -1,5 +1,10 @@ -use crate::server::topology::types::api::TopologyOptions; +use crate::server::groups::r#impl::base::Group; +use crate::server::hosts::r#impl::base::Host; +use crate::server::services::r#impl::base::Service; +use crate::server::services::r#impl::categories::ServiceCategory; +use crate::server::subnets::r#impl::base::Subnet; use crate::server::topology::types::edges::Edge; +use crate::server::topology::types::edges::EdgeType; use crate::server::topology::types::nodes::Node; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -7,14 +12,52 @@ use std::{fmt::Display, hash::Hash}; use uuid::Uuid; use validator::Validate; -#[derive(Debug, Clone, Validate, Serialize, Deserialize, Eq, PartialEq, Hash)] +#[derive(Debug, Clone, Validate, Serialize, Deserialize, Eq, PartialEq)] pub struct TopologyBase { #[validate(length(min = 0, max = 100))] - pub name: String, // "Home LAN", "VPN Network", etc. + pub name: String, pub options: TopologyOptions, pub network_id: Uuid, pub nodes: Vec, pub edges: Vec, + pub hosts: Vec, + pub subnets: Vec, + pub services: Vec, + pub groups: Vec, + pub is_stale: bool, + pub last_refreshed: DateTime, + pub is_locked: bool, + pub locked_at: Option>, + pub locked_by: Option, + pub removed_hosts: Vec, + pub removed_subnets: Vec, + pub removed_services: Vec, + pub removed_groups: Vec, +} + +impl TopologyBase { + pub fn new(name: String, network_id: Uuid) -> Self { + Self { + name, + network_id, + options: TopologyOptions::default(), + nodes: vec![], + edges: vec![], + hosts: vec![], + subnets: vec![], + services: vec![], + groups: vec![], + is_stale: false, + last_refreshed: Utc::now(), + is_locked: false, + locked_at: None, + locked_by: None, + removed_hosts: vec![], + removed_subnets: vec![], + removed_services: vec![], + removed_groups: vec![], + } + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -26,6 +69,29 @@ pub struct Topology { pub base: TopologyBase, } +impl Topology { + pub fn lock(&mut self, locked_by: Uuid) { + self.base.is_locked = true; + self.base.locked_at = Some(Utc::now()); + self.base.locked_by = Some(locked_by) + } + + pub fn unlock(&mut self) { + self.base.is_locked = false; + self.base.locked_at = None; + self.base.locked_by = None; + } + + pub fn refresh(&mut self) { + self.base.removed_groups = vec![]; + self.base.removed_hosts = vec![]; + self.base.removed_services = vec![]; + self.base.removed_subnets = vec![]; + self.base.is_stale = false; + self.base.last_refreshed = Utc::now() + } +} + impl Display for Topology { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( @@ -35,3 +101,51 @@ impl Display for Topology { ) } } + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, Hash)] +pub struct TopologyOptions { + pub local: TopologyLocalOptions, + pub request: TopologyRequestOptions, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] +pub struct TopologyLocalOptions { + pub left_zone_title: String, + pub no_fade_edges: bool, + pub hide_resize_handles: bool, + pub hide_edge_types: Vec, +} + +impl Default for TopologyLocalOptions { + fn default() -> Self { + Self { + left_zone_title: "Infrastructure".to_string(), + no_fade_edges: false, + hide_resize_handles: false, + hide_edge_types: Vec::new(), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] +pub struct TopologyRequestOptions { + pub group_docker_bridges_by_host: bool, + pub hide_vm_title_on_docker_container: bool, + pub hide_ports: bool, + pub left_zone_service_categories: Vec, + pub hide_service_categories: Vec, + pub show_gateway_in_left_zone: bool, +} + +impl Default for TopologyRequestOptions { + fn default() -> Self { + Self { + group_docker_bridges_by_host: false, + hide_vm_title_on_docker_container: false, + hide_ports: false, + left_zone_service_categories: vec![ServiceCategory::DNS, ServiceCategory::ReverseProxy], + hide_service_categories: Vec::new(), + show_gateway_in_left_zone: true, + } + } +} diff --git a/backend/src/server/topology/types/storage.rs b/backend/src/server/topology/types/storage.rs index 26902f41..3b218bff 100644 --- a/backend/src/server/topology/types/storage.rs +++ b/backend/src/server/topology/types/storage.rs @@ -1,17 +1,19 @@ -use chrono::{DateTime, Utc}; -use sqlx::Row; -use sqlx::postgres::PgRow; -use uuid::Uuid; - +use crate::server::groups::r#impl::base::Group; +use crate::server::services::r#impl::base::Service; +use crate::server::subnets::r#impl::base::Subnet; use crate::server::{ + hosts::r#impl::base::Host, shared::storage::traits::{SqlValue, StorableEntity}, topology::types::{ - api::TopologyOptions, - base::{Topology, TopologyBase}, + base::{Topology, TopologyBase, TopologyOptions}, edges::Edge, nodes::Node, }, }; +use chrono::{DateTime, Utc}; +use sqlx::Row; +use sqlx::postgres::PgRow; +use uuid::Uuid; impl StorableEntity for Topology { type BaseData = TopologyBase; @@ -63,6 +65,19 @@ impl StorableEntity for Topology { nodes, edges, options, + hosts, + services, + subnets, + groups, + is_stale, + last_refreshed, + is_locked, + locked_at, + locked_by, + removed_hosts, + removed_services, + removed_subnets, + removed_groups, }, } = self.clone(); @@ -76,6 +91,19 @@ impl StorableEntity for Topology { "nodes", "edges", "options", + "hosts", + "subnets", + "groups", + "services", + "is_stale", + "last_refreshed", + "is_locked", + "locked_at", + "locked_by", + "removed_hosts", + "removed_services", + "removed_subnets", + "removed_groups", ], vec![ SqlValue::Uuid(id), @@ -86,20 +114,44 @@ impl StorableEntity for Topology { SqlValue::Nodes(nodes), SqlValue::Edges(edges), SqlValue::TopologyOptions(options), + SqlValue::Hosts(hosts), + SqlValue::Subnets(subnets), + SqlValue::Groups(groups), + SqlValue::Services(services), + SqlValue::Bool(is_stale), + SqlValue::Timestamp(last_refreshed), + SqlValue::Bool(is_locked), + SqlValue::OptionTimestamp(locked_at), + SqlValue::OptionalUuid(locked_by), + SqlValue::UuidArray(removed_hosts), + SqlValue::UuidArray(removed_services), + SqlValue::UuidArray(removed_subnets), + SqlValue::UuidArray(removed_groups), ], )) } fn from_row(row: &PgRow) -> Result { // Parse JSON fields safely - let nodes: Vec = serde_json::from_str(&row.get::("nodes")) + let nodes: Vec = serde_json::from_value(row.get::("nodes")) .map_err(|e| anyhow::anyhow!("Failed to deserialize nodes: {}", e))?; - let edges: Vec = serde_json::from_str(&row.get::("edges")) + let edges: Vec = serde_json::from_value(row.get::("edges")) .map_err(|e| anyhow::anyhow!("Failed to deserialize edges: {}", e))?; let options: TopologyOptions = serde_json::from_value(row.get::("options")) .map_err(|e| anyhow::anyhow!("Failed to deserialize options: {}", e))?; + let hosts: Vec = serde_json::from_value(row.get::("hosts")) + .map_err(|e| anyhow::anyhow!("Failed to deserialize hosts: {}", e))?; + let subnets: Vec = + serde_json::from_value(row.get::("subnets")) + .map_err(|e| anyhow::anyhow!("Failed to deserialize subnets: {}", e))?; + let services: Vec = + serde_json::from_value(row.get::("services")) + .map_err(|e| anyhow::anyhow!("Failed to deserialize services: {}", e))?; + let groups: Vec = serde_json::from_value(row.get::("groups")) + .map_err(|e| anyhow::anyhow!("Failed to deserialize groups: {}", e))?; + Ok(Topology { id: row.get("id"), created_at: row.get("created_at"), @@ -107,8 +159,21 @@ impl StorableEntity for Topology { base: TopologyBase { name: row.get("name"), network_id: row.get("network_id"), + is_stale: row.get("is_stale"), + last_refreshed: row.get("last_refreshed"), + is_locked: row.get("is_locked"), + locked_at: row.get("locked_at"), + locked_by: row.get("locked_by"), + removed_groups: row.get("removed_groups"), + removed_hosts: row.get("removed_hosts"), + removed_services: row.get("removed_services"), + removed_subnets: row.get("removed_subnets"), nodes, edges, + hosts, + subnets, + services, + groups, options, }, }) diff --git a/backend/src/server/users/handlers.rs b/backend/src/server/users/handlers.rs index 63291d38..8b5e708c 100644 --- a/backend/src/server/users/handlers.rs +++ b/backend/src/server/users/handlers.rs @@ -110,7 +110,7 @@ pub async fn update_user( } let updated = service - .update(&mut request) + .update(&mut request, user.into()) .await .map_err(|e| ApiError::internal_error(&e.to_string()))?; diff --git a/backend/src/server/users/service.rs b/backend/src/server/users/service.rs index 471cdd6f..9b834e0f 100644 --- a/backend/src/server/users/service.rs +++ b/backend/src/server/users/service.rs @@ -1,5 +1,11 @@ use crate::server::{ + auth::middleware::AuthenticatedEntity, shared::{ + entities::Entity, + events::{ + bus::EventBus, + types::{EntityEvent, EntityOperation}, + }, services::traits::CrudService, storage::{ filter::EntityFilter, @@ -7,20 +13,18 @@ use crate::server::{ traits::{StorableEntity, Storage}, }, }, - users::r#impl::{ - base::{User, UserBase}, - permissions::UserOrgPermissions, - }, + users::r#impl::{base::User, permissions::UserOrgPermissions}, }; use anyhow::Error; use anyhow::Result; use async_trait::async_trait; -use email_address::EmailAddress; +use chrono::Utc; use std::sync::Arc; use uuid::Uuid; pub struct UserService { user_storage: Arc>, + event_bus: Arc, } #[async_trait] @@ -28,28 +32,23 @@ impl CrudService for UserService { fn storage(&self) -> &Arc> { &self.user_storage } -} -impl UserService { - pub fn new(user_storage: Arc>) -> Self { - Self { user_storage } + fn event_bus(&self) -> &Arc { + &self.event_bus } - pub async fn get_user_by_oidc(&self, oidc_subject: &str) -> Result> { - let oidc_filter = EntityFilter::unfiltered().oidc_subject(oidc_subject.to_string()); - self.user_storage.get_one(oidc_filter).await + fn entity_type() -> Entity { + Entity::User } - - pub async fn get_organization_owners(&self, organization_id: &Uuid) -> Result> { - let filter: EntityFilter = EntityFilter::unfiltered() - .organization_id(organization_id) - .user_permissions(&UserOrgPermissions::Owner); - - self.user_storage.get_all(filter).await + fn get_network_id(&self, _entity: &User) -> Option { + None + } + fn get_organization_id(&self, entity: &User) -> Option { + Some(entity.base.organization_id) } /// Create a new user - pub async fn create_user(&self, user: User) -> Result { + async fn create(&self, user: User, authentication: AuthenticatedEntity) -> Result { let existing_user = self .user_storage .get_one(EntityFilter::unfiltered().email(&user.base.email)) @@ -61,45 +60,45 @@ impl UserService { )); } - self.user_storage.create(&User::new(user.base)).await + let created = self.user_storage.create(&User::new(user.base)).await?; + + self.event_bus() + .publish(EntityEvent { + id: Uuid::new_v4(), + entity_type: Entity::User, + entity_id: created.id, + network_id: self.get_network_id(&created), + organization_id: self.get_organization_id(&created), + operation: EntityOperation::Created, + timestamp: Utc::now(), + metadata: serde_json::json!({}), + authentication, + }) + .await?; + + Ok(created) } +} - /// Create new user with OIDC (no password) - pub async fn create_user_with_oidc( - &self, - email: EmailAddress, - oidc_subject: String, - oidc_provider: Option, - organization_id: Uuid, - permissions: UserOrgPermissions, - ) -> Result { - let user = User::new(UserBase::new_oidc( - email, - oidc_subject, - oidc_provider, - organization_id, - permissions, - )); - - self.create_user(user).await +impl UserService { + pub fn new(user_storage: Arc>, event_bus: Arc) -> Self { + Self { + user_storage, + event_bus, + } } - /// Create new user with password (no OIDC) - pub async fn create_user_with_password( - &self, - email: EmailAddress, - password_hash: String, - organization_id: Uuid, - permissions: UserOrgPermissions, - ) -> Result { - let user = User::new(UserBase::new_password( - email, - password_hash, - organization_id, - permissions, - )); - - self.create_user(user).await + pub async fn get_user_by_oidc(&self, oidc_subject: &str) -> Result> { + let oidc_filter = EntityFilter::unfiltered().oidc_subject(oidc_subject.to_string()); + self.user_storage.get_one(oidc_filter).await + } + + pub async fn get_organization_owners(&self, organization_id: &Uuid) -> Result> { + let filter: EntityFilter = EntityFilter::unfiltered() + .organization_id(organization_id) + .user_permissions(&UserOrgPermissions::Owner); + + self.user_storage.get_all(filter).await } /// Link OIDC to existing user diff --git a/ui/src/lib/features/api_keys/components/ApiKeyTab.svelte b/ui/src/lib/features/api_keys/components/ApiKeyTab.svelte index d0408959..d27954a2 100644 --- a/ui/src/lib/features/api_keys/components/ApiKeyTab.svelte +++ b/ui/src/lib/features/api_keys/components/ApiKeyTab.svelte @@ -11,6 +11,7 @@ import type { ApiKey } from '../types/base'; import { apiKeys, deleteApiKey, getApiKeys, updateApiKey } from '../store'; import ApiKeyCard from './ApiKeyCard.svelte'; + import { Plus } from 'lucide-svelte'; const loading = loadData([getApiKeys, getDaemons]); @@ -69,17 +70,13 @@
- - + + + + + {#if $loading} diff --git a/ui/src/lib/features/api_keys/store.ts b/ui/src/lib/features/api_keys/store.ts index 1e5da681..867e79ed 100644 --- a/ui/src/lib/features/api_keys/store.ts +++ b/ui/src/lib/features/api_keys/store.ts @@ -2,7 +2,7 @@ import { derived, get, writable, type Readable } from 'svelte/store'; import { api } from '../../shared/utils/api'; import type { ApiKey } from './types/base'; import { utcTimeZoneSentinel, uuidv4Sentinel } from '$lib/shared/utils/formatting'; -import { currentNetwork } from '../networks/store'; +import { networks } from '../networks/store'; export const apiKeys = writable([]); @@ -70,7 +70,7 @@ export function createEmptyApiKeyFormData(): ApiKey { updated_at: utcTimeZoneSentinel, expires_at: null, last_used: null, - network_id: get(currentNetwork).id, + network_id: get(networks)[0].id || '', key: '', is_enabled: true }; diff --git a/ui/src/lib/features/daemons/components/DaemonTab.svelte b/ui/src/lib/features/daemons/components/DaemonTab.svelte index 9fe731d2..c2e6d33b 100644 --- a/ui/src/lib/features/daemons/components/DaemonTab.svelte +++ b/ui/src/lib/features/daemons/components/DaemonTab.svelte @@ -11,6 +11,7 @@ import { getHosts } from '$lib/features/hosts/store'; import type { FieldConfig } from '$lib/shared/components/data/types'; import DataControls from '$lib/shared/components/data/DataControls.svelte'; + import { Plus } from 'lucide-svelte'; const loading = loadData([getNetworks, getDaemons, getHosts]); @@ -58,16 +59,13 @@
- + + + + + {#if $loading} diff --git a/ui/src/lib/features/discovery/components/tabs/DiscoveryScheduledTab.svelte b/ui/src/lib/features/discovery/components/tabs/DiscoveryScheduledTab.svelte index b98b1a76..408d4f42 100644 --- a/ui/src/lib/features/discovery/components/tabs/DiscoveryScheduledTab.svelte +++ b/ui/src/lib/features/discovery/components/tabs/DiscoveryScheduledTab.svelte @@ -20,6 +20,7 @@ import { getHosts, hosts } from '$lib/features/hosts/store'; import DiscoveryRunCard from '../cards/DiscoveryScheduledCard.svelte'; import type { FieldConfig } from '$lib/shared/components/data/types'; + import { Plus } from 'lucide-svelte'; const loading = loadData([getDiscoveries, getDaemons, getSubnets, getHosts]); @@ -85,16 +86,13 @@
- + + + + + {#if $loading} diff --git a/ui/src/lib/features/groups/components/GroupTab.svelte b/ui/src/lib/features/groups/components/GroupTab.svelte index db50dbdb..c47b248c 100644 --- a/ui/src/lib/features/groups/components/GroupTab.svelte +++ b/ui/src/lib/features/groups/components/GroupTab.svelte @@ -11,6 +11,7 @@ import DataControls from '$lib/shared/components/data/DataControls.svelte'; import type { FieldConfig } from '$lib/shared/components/data/types'; import { networks } from '$lib/features/networks/store'; + import { Plus } from 'lucide-svelte'; const loading = loadData([getServices, getGroups]); @@ -103,16 +104,13 @@
- + + + + + {#if $loading} diff --git a/ui/src/lib/features/groups/store.ts b/ui/src/lib/features/groups/store.ts index bee9fae8..7878bf1f 100644 --- a/ui/src/lib/features/groups/store.ts +++ b/ui/src/lib/features/groups/store.ts @@ -3,8 +3,8 @@ import { api } from '../../shared/utils/api'; import type { Group } from '$lib/features/groups/types/base'; import { utcTimeZoneSentinel, uuidv4Sentinel } from '$lib/shared/utils/formatting'; import { getServices } from '../services/store'; -import { currentNetwork } from '../networks/store'; import { entities } from '$lib/shared/stores/metadata'; +import { networks } from '../networks/store'; export const groups = writable([]); @@ -69,7 +69,7 @@ export function createEmptyGroupFormData(): Group { source: { type: 'Manual' }, - network_id: get(currentNetwork).id, + network_id: get(networks)[0].id || '', color: entities.getColorHelper('Group').string, edge_style: 'Straight' }; diff --git a/ui/src/lib/features/hosts/components/HostTab.svelte b/ui/src/lib/features/hosts/components/HostTab.svelte index e4c4d401..cc5f1a21 100644 --- a/ui/src/lib/features/hosts/components/HostTab.svelte +++ b/ui/src/lib/features/hosts/components/HostTab.svelte @@ -16,6 +16,7 @@ import type { FieldConfig } from '$lib/shared/components/data/types'; import { networks } from '$lib/features/networks/store'; import { get } from 'svelte/store'; + import { Plus } from 'lucide-svelte'; const loading = loadData([getHosts, getGroups, getServices, getSubnets, getDaemons]); @@ -170,16 +171,13 @@
- + + + + + {#if $loading} diff --git a/ui/src/lib/features/hosts/store.ts b/ui/src/lib/features/hosts/store.ts index d545e9ad..c1083b41 100644 --- a/ui/src/lib/features/hosts/store.ts +++ b/ui/src/lib/features/hosts/store.ts @@ -5,7 +5,7 @@ import { pushSuccess } from '$lib/shared/stores/feedback'; import { utcTimeZoneSentinel, uuidv4Sentinel } from '$lib/shared/utils/formatting'; import { isContainerSubnet } from '../subnets/store'; import { getBindingFromId, getBindingDisplayName } from '../services/store'; -import { currentNetwork } from '../networks/store'; +import { networks } from '../networks/store'; export const hosts = writable([]); export const polling = writable(false); @@ -78,7 +78,7 @@ export function createEmptyHostFormData(): Host { type: 'Manual' }, virtualization: null, - network_id: get(currentNetwork).id, + network_id: get(networks)[0].id || '', hidden: false }; } diff --git a/ui/src/lib/features/networks/components/NetworksTab.svelte b/ui/src/lib/features/networks/components/NetworksTab.svelte index 91892ee7..8cd5eede 100644 --- a/ui/src/lib/features/networks/components/NetworksTab.svelte +++ b/ui/src/lib/features/networks/components/NetworksTab.svelte @@ -19,6 +19,7 @@ import NetworkEditModal from './NetworkEditModal.svelte'; import DataControls from '$lib/shared/components/data/DataControls.svelte'; import type { FieldConfig } from '$lib/shared/components/data/types'; + import { Plus } from 'lucide-svelte'; const loading = loadData([getNetworks, getHosts, getDaemons, getSubnets, getGroups]); @@ -81,16 +82,13 @@
- + + + + + {#if $loading} diff --git a/ui/src/lib/features/networks/store.ts b/ui/src/lib/features/networks/store.ts index 448e596b..ede93ff5 100644 --- a/ui/src/lib/features/networks/store.ts +++ b/ui/src/lib/features/networks/store.ts @@ -5,20 +5,14 @@ import { currentUser } from '../auth/store'; import { utcTimeZoneSentinel, uuidv4Sentinel } from '$lib/shared/utils/formatting'; export const networks = writable([]); -export const currentNetwork = writable(); export async function getNetworks() { const user = get(currentUser); if (user) { - const result = await api.request(`/networks`, networks, (networks) => networks, { + await api.request(`/networks`, networks, (networks) => networks, { method: 'GET' }); - - if (result && result.success && result.data) { - const current = get(networks).find((n) => n.is_default) || get(networks)[0]; - currentNetwork.set(current); - } } } diff --git a/ui/src/lib/features/services/store.ts b/ui/src/lib/features/services/store.ts index 4ffa15d6..3a090037 100644 --- a/ui/src/lib/features/services/store.ts +++ b/ui/src/lib/features/services/store.ts @@ -5,8 +5,8 @@ import { formatPort, utcTimeZoneSentinel, uuidv4Sentinel } from '$lib/shared/uti import { formatInterface, getInterfaceFromId, getPortFromId, hosts } from '../hosts/store'; import { ALL_INTERFACES, type Host } from '../hosts/types/base'; import { groups } from '../groups/store'; -import { currentNetwork } from '../networks/store'; import type { Subnet } from '../subnets/types/base'; +import { networks } from '../networks/store'; export const services = writable([]); @@ -48,7 +48,7 @@ export function createDefaultService( id: uuidv4Sentinel, created_at: utcTimeZoneSentinel, updated_at: utcTimeZoneSentinel, - network_id: get(currentNetwork).id, + network_id: get(networks)[0].id || '', host_id, is_gateway: false, service_definition: serviceType, diff --git a/ui/src/lib/features/subnets/components/SubnetTab.svelte b/ui/src/lib/features/subnets/components/SubnetTab.svelte index e9d94375..f7f00612 100644 --- a/ui/src/lib/features/subnets/components/SubnetTab.svelte +++ b/ui/src/lib/features/subnets/components/SubnetTab.svelte @@ -12,6 +12,7 @@ import DataControls from '$lib/shared/components/data/DataControls.svelte'; import type { FieldConfig } from '$lib/shared/components/data/types'; import { networks } from '$lib/features/networks/store'; + import { Plus } from 'lucide-svelte'; let showSubnetEditor = false; let editingSubnet: Subnet | null = null; @@ -105,16 +106,13 @@
- + + + + + {#if $loading} diff --git a/ui/src/lib/features/subnets/store.ts b/ui/src/lib/features/subnets/store.ts index e24c1b6b..d8bc4238 100644 --- a/ui/src/lib/features/subnets/store.ts +++ b/ui/src/lib/features/subnets/store.ts @@ -2,9 +2,9 @@ import { derived, get, writable, type Readable } from 'svelte/store'; import { api } from '../../shared/utils/api'; import { utcTimeZoneSentinel, uuidv4Sentinel } from '$lib/shared/utils/formatting'; import type { Subnet } from './types/base'; -import { currentNetwork } from '../networks/store'; import type { Interface } from '../hosts/types/base'; import { hosts } from '../hosts/store'; +import { networks } from '../networks/store'; export const subnets = writable([]); @@ -57,7 +57,7 @@ export function createEmptySubnetFormData(): Subnet { created_at: utcTimeZoneSentinel, updated_at: utcTimeZoneSentinel, name: '', - network_id: get(currentNetwork).id, + network_id: get(networks)[0].id || '', cidr: '', description: '', subnet_type: 'Unknown', diff --git a/ui/src/lib/features/topology/components/ExportButton.svelte b/ui/src/lib/features/topology/components/ExportButton.svelte index 5e5c4b9e..a0085b6c 100644 --- a/ui/src/lib/features/topology/components/ExportButton.svelte +++ b/ui/src/lib/features/topology/components/ExportButton.svelte @@ -192,6 +192,5 @@ diff --git a/ui/src/lib/features/topology/components/RefreshConflictsModal.svelte b/ui/src/lib/features/topology/components/RefreshConflictsModal.svelte new file mode 100644 index 00000000..a0ccff99 --- /dev/null +++ b/ui/src/lib/features/topology/components/RefreshConflictsModal.svelte @@ -0,0 +1,145 @@ + + + + + + + +
+ +
+ +
+

+ {totalRemoved} + {totalRemoved === 1 ? 'entity' : 'entities'} will be removed +

+

+ These entities no longer exist in the network and will be removed from this diagram if you + refresh. +

+
+
+ + +
+ {#if removedHosts.length > 0} +
+

+ Hosts ({removedHosts.length}) +

+
    + {#each removedHosts as host (host.id)} +
  • • {host.name}
  • + {/each} +
+
+ {/if} + + {#if removedServices.length > 0} +
+

+ Services ({removedServices.length}) +

+
    + {#each removedServices as service (service.id)} +
  • • {service.name}
  • + {/each} +
+
+ {/if} + + {#if removedSubnets.length > 0} +
+

+ Subnets ({removedSubnets.length}) +

+
    + {#each removedSubnets as subnet (subnet.id)} +
  • • {subnet.name}
  • + {/each} +
+
+ {/if} + + {#if removedGroups.length > 0} +
+

+ Groups ({removedGroups.length}) +

+
    + {#each removedGroups as group (group.id)} +
  • • {group.name}
  • + {/each} +
+
+ {/if} +
+ + +
+

+ Tip: If you want to preserve this network state as a historical record, click + "Lock Instead" to freeze this topology without refreshing. +

+
+
+ + +
+ +
+ + +
+
+
+
diff --git a/ui/src/lib/features/topology/components/TopologyDetailsForm.svelte b/ui/src/lib/features/topology/components/TopologyDetailsForm.svelte new file mode 100644 index 00000000..3d0d7f6b --- /dev/null +++ b/ui/src/lib/features/topology/components/TopologyDetailsForm.svelte @@ -0,0 +1,36 @@ + + +
+ + + +
diff --git a/ui/src/lib/features/topology/components/TopologyModal.svelte b/ui/src/lib/features/topology/components/TopologyModal.svelte new file mode 100644 index 00000000..3456e873 --- /dev/null +++ b/ui/src/lib/features/topology/components/TopologyModal.svelte @@ -0,0 +1,76 @@ + + + + + + + +
+ +
+
diff --git a/ui/src/lib/features/topology/components/TopologyTab.svelte b/ui/src/lib/features/topology/components/TopologyTab.svelte index 5d511b82..df1cd6f1 100644 --- a/ui/src/lib/features/topology/components/TopologyTab.svelte +++ b/ui/src/lib/features/topology/components/TopologyTab.svelte @@ -1,32 +1,283 @@
- + + +
+ + {#if $topology && topologyState} +
+ {#if topologyState.type === 'locked'} +
+ + + Locked {$topology.locked_at + ? new Date($topology.locked_at).toLocaleDateString() + : ''} + +
+ {:else if topologyState.type === 'fresh'} +
+ + Up to date +
+ {:else if topologyState.type === 'stale_safe'} +
+ + Refresh available +
+ {:else if topologyState.type === 'stale_conflicts'} +
+ + Conflicts detected +
+ {/if} +
+ {/if} + + + + + + + + {#if $topology && !$topology.is_locked} + + {/if} + + {#if $topology} + {#if $topology.is_locked} + + {:else} + + {/if} + {/if} + + + + +
+
+
+ + + {#if $topology && topologyState} + {#if topologyState.type === 'locked'} +
+
+ +
+

+ Topology Locked + {#if lockedByUser} + by {lockedByUser.email} + {/if} +

+

+ This topology is frozen at its current network state. Click "Unlock" to enable + refreshing. +

+
+
+
+ {:else if topologyState.type === 'stale_safe'} + + {:else if topologyState.type === 'stale_conflicts'} +
+
+ +
+

Conflicts Detected

+

+ Some entities in this diagram no longer exist. Click "Refresh" to review changes + before updating. +

+
+
+
+ {/if} + {/if} + {#if $loading} {:else} @@ -37,3 +288,15 @@ {/if}
+ + + +{#if $topology} + (isRefreshConflictsOpen = false)} + /> +{/if} diff --git a/ui/src/lib/features/topology/components/panel/inspectors/InspectorNode.svelte b/ui/src/lib/features/topology/components/panel/inspectors/InspectorNode.svelte index e4e9a392..ae241f1d 100644 --- a/ui/src/lib/features/topology/components/panel/inspectors/InspectorNode.svelte +++ b/ui/src/lib/features/topology/components/panel/inspectors/InspectorNode.svelte @@ -1,7 +1,7 @@
- {#if $group && localGroup} + {#if group && localGroup} Group
- +
Edge Style @@ -58,7 +59,7 @@
Services - {#each $group.service_bindings as binding (binding)} + {#each group.service_bindings as binding (binding)} {@const bindingService = get(getServiceForBinding(binding))} {@const bindingHost = bindingService ? getHostFromId(bindingService.id) : null} {#if bindingService && bindingHost} diff --git a/ui/src/lib/features/topology/components/panel/inspectors/edges/InspectorEdgeHostVirtualization.svelte b/ui/src/lib/features/topology/components/panel/inspectors/edges/InspectorEdgeHostVirtualization.svelte index 6ed9ca58..661f43ac 100644 --- a/ui/src/lib/features/topology/components/panel/inspectors/edges/InspectorEdgeHostVirtualization.svelte +++ b/ui/src/lib/features/topology/components/panel/inspectors/edges/InspectorEdgeHostVirtualization.svelte @@ -1,33 +1,32 @@
- {#if $vmService} + {#if vmService} VM Service
{/if} - {#if $hypervisorHost} + {#if hypervisorHost} Hypervisor Host
- +
{/if}
diff --git a/ui/src/lib/features/topology/components/panel/inspectors/edges/InspectorEdgeInterface.svelte b/ui/src/lib/features/topology/components/panel/inspectors/edges/InspectorEdgeInterface.svelte index ee80111c..5253788a 100644 --- a/ui/src/lib/features/topology/components/panel/inspectors/edges/InspectorEdgeInterface.svelte +++ b/ui/src/lib/features/topology/components/panel/inspectors/edges/InspectorEdgeInterface.svelte @@ -1,20 +1,16 @@
diff --git a/ui/src/lib/features/topology/components/panel/inspectors/edges/InspectorEdgeServiceVirtualization.svelte b/ui/src/lib/features/topology/components/panel/inspectors/edges/InspectorEdgeServiceVirtualization.svelte index 68e36a4d..5ecec3dc 100644 --- a/ui/src/lib/features/topology/components/panel/inspectors/edges/InspectorEdgeServiceVirtualization.svelte +++ b/ui/src/lib/features/topology/components/panel/inspectors/edges/InspectorEdgeServiceVirtualization.svelte @@ -1,12 +1,11 @@
- {#if $containerizingHost} + {#if containerizingHost} Docker Host
- +
{/if} - {#if $containerizingService} + {#if containerizingService} Docker Service
diff --git a/ui/src/lib/features/topology/components/panel/inspectors/edges/nodes/InspectorInterfaceNode.svelte b/ui/src/lib/features/topology/components/panel/inspectors/nodes/InspectorInterfaceNode.svelte similarity index 80% rename from ui/src/lib/features/topology/components/panel/inspectors/edges/nodes/InspectorInterfaceNode.svelte rename to ui/src/lib/features/topology/components/panel/inspectors/nodes/InspectorInterfaceNode.svelte index da7f0a80..9fe1095c 100644 --- a/ui/src/lib/features/topology/components/panel/inspectors/edges/nodes/InspectorInterfaceNode.svelte +++ b/ui/src/lib/features/topology/components/panel/inspectors/nodes/InspectorInterfaceNode.svelte @@ -1,36 +1,36 @@ diff --git a/ui/src/lib/features/topology/components/panel/inspectors/edges/nodes/InspectorSubnetNode.svelte b/ui/src/lib/features/topology/components/panel/inspectors/nodes/InspectorSubnetNode.svelte similarity index 79% rename from ui/src/lib/features/topology/components/panel/inspectors/edges/nodes/InspectorSubnetNode.svelte rename to ui/src/lib/features/topology/components/panel/inspectors/nodes/InspectorSubnetNode.svelte index 75771679..0e4a63ec 100644 --- a/ui/src/lib/features/topology/components/panel/inspectors/edges/nodes/InspectorSubnetNode.svelte +++ b/ui/src/lib/features/topology/components/panel/inspectors/nodes/InspectorSubnetNode.svelte @@ -1,13 +1,12 @@
diff --git a/ui/src/lib/features/topology/components/panel/options/OptionsContent.svelte b/ui/src/lib/features/topology/components/panel/options/OptionsContent.svelte index 0fad857e..2ca26ff3 100644 --- a/ui/src/lib/features/topology/components/panel/options/OptionsContent.svelte +++ b/ui/src/lib/features/topology/components/panel/options/OptionsContent.svelte @@ -1,6 +1,5 @@ -{#if nodeData} +{#if nodeRenderData}
- {#if nodeData.headerText} + {#if nodeRenderData.headerText}
- {nodeData.headerText} + {nodeRenderData.headerText}
{/if} @@ -111,13 +107,13 @@ class="flex flex-col items-center justify-around px-3 py-2" style="flex: 1 1 0; min-height: 0;" > - {#if nodeData.showServices} + {#if nodeRenderData.showServices}
- {#each nodeData.services as service (service.id)} + {#each nodeRenderData.services as service (service.id)} {@const ServiceIcon = serviceDefinitions.getIconComponent(service.service_definition)}
- {#if !$topologyOptions.request_options.hide_ports && service.bindings.filter((b) => b.type == 'Port').length > 0} + {#if !$topologyOptions.request.hide_ports && service.bindings.filter((b) => b.type == 'Port').length > 0} {service.bindings .map((b) => { if ( - (b.interface_id == nodeData.interface_id || b.interface_id == null) && + (b.interface_id == nodeRenderData.interface_id || b.interface_id == null) && b.type == 'Port' ) { const port = get(getPortFromId(b.port_id)); @@ -160,18 +156,18 @@
- {nodeData.bodyText} + {nodeRenderData.bodyText}
{/if}
- {#if nodeData.footerText} + {#if nodeRenderData.footerText}
- {nodeData.footerText} + {nodeRenderData.footerText}
{/if} diff --git a/ui/src/lib/features/topology/components/SubnetNode.svelte b/ui/src/lib/features/topology/components/visualization/SubnetNode.svelte similarity index 94% rename from ui/src/lib/features/topology/components/SubnetNode.svelte rename to ui/src/lib/features/topology/components/visualization/SubnetNode.svelte index 23eee2d5..10db588e 100644 --- a/ui/src/lib/features/topology/components/SubnetNode.svelte +++ b/ui/src/lib/features/topology/components/visualization/SubnetNode.svelte @@ -2,20 +2,19 @@ import { Handle, NodeResizeControl, Position, useViewport, type NodeProps } from '@xyflow/svelte'; import { createColorHelper, twColorToRgba } from '$lib/shared/utils/styling'; import { subnetTypes } from '$lib/shared/stores/metadata'; - import { getSubnetFromId, isContainerSubnet } from '$lib/features/subnets/store'; - import { topologyOptions } from '../store'; - import type { SubnetRenderData } from '../types/base'; + import { isContainerSubnet } from '$lib/features/subnets/store'; + import { topology, topologyOptions } from '../../store'; + import type { SubnetRenderData } from '../../types/base'; import { get } from 'svelte/store'; let { id, data, selected, width, height }: NodeProps = $props(); - let leftZoneTitle = $derived($topologyOptions.left_zone_title); + let leftZoneTitle = $derived($topologyOptions.local.left_zone_title); let infra_width = $derived((data.infra_width as number) || 0); let nodeStyle = $derived(`width: ${width}px; height: ${height}px;`); let hasInfra = $derived(infra_width > 0); - let subnetStore = getSubnetFromId(id); - let subnet = $derived($subnetStore); + let subnet = $derived($topology.subnets.find((s) => s.id == id)); const viewport = useViewport(); let resizeHandleZoomLevel = $derived(viewport.current.zoom > 0.5); @@ -89,7 +88,7 @@ {/if}
- {#if resizeHandleZoomLevel && !$topologyOptions.hide_resize_handles} + {#if resizeHandleZoomLevel && !$topologyOptions.local.hide_resize_handles} edge.edge_type != 'HostVirtualization' - ) - .map(([, , edge]: [number, number, TopologyEdge], index: number): Edge => { + .filter((edge) => edge.edge_type != 'HostVirtualization') + .map((edge: TopologyEdge, index: number) => { const edgeType = edge.edge_type as string; const edgeMetadata = edgeTypes.getMetadata(edgeType); const edgeColorHelper = edgeTypes.getColorHelper(edgeType); diff --git a/ui/src/lib/features/topology/store.ts b/ui/src/lib/features/topology/store.ts index 29538b57..8f5b2714 100644 --- a/ui/src/lib/features/topology/store.ts +++ b/ui/src/lib/features/topology/store.ts @@ -1,10 +1,15 @@ import { get, writable } from 'svelte/store'; import { api } from '../../shared/utils/api'; import { type Edge, type Node } from '@xyflow/svelte'; -import { EdgeHandle, type TopologyResponse, type TopologyOptions } from './types/base'; +import { EdgeHandle, type Topology, type TopologyOptions } from './types/base'; import { networks } from '../networks/store'; import deepmerge from 'deepmerge'; import { browser } from '$app/environment'; +import { utcTimeZoneSentinel, uuidv4Sentinel } from '$lib/shared/utils/formatting'; + +export const topologies = writable([]); +export const topology = writable(); +export const selectedNetwork = writable(''); export const selectedNode = writable(null); export const selectedEdge = writable(null); @@ -14,66 +19,70 @@ const EXPANDED_STORAGE_KEY = 'netvisor_topology_options_expanded_state'; // Default options const defaultOptions: TopologyOptions = { - left_zone_title: 'Infrastructure', - hide_edge_types: [], - no_fade_edges: false, - hide_resize_handles: false, - request_options: { + local: { + left_zone_title: 'Infrastructure', + hide_edge_types: [], + no_fade_edges: false, + hide_resize_handles: false + }, + request: { group_docker_bridges_by_host: true, hide_ports: false, hide_vm_title_on_docker_container: false, show_gateway_in_left_zone: true, left_zone_service_categories: ['DNS', 'ReverseProxy'], - hide_service_categories: [], - network_ids: [] + hide_service_categories: [] } }; -export const topology = writable(); export const topologyOptions = writable(loadOptionsFromStorage()); export const optionsPanelExpanded = writable(loadExpandedFromStorage()); // Initialize network_ids with the first network when networks are loaded -let networksInitialized = false; +// let networksInitialized = false; +let topologyInitialized = false; +let lastTopologyId = ''; if (browser) { - networks.subscribe(($networks) => { - if (!networksInitialized && $networks.length > 0) { - networksInitialized = true; - topologyOptions.update((opts) => { - // Only set default if network_ids is empty - if (opts.request_options.network_ids.length === 0 && $networks[0]) { - opts.request_options.network_ids = [$networks[0].id]; - } - return opts; - }); + topologies.subscribe(($topologies) => { + if (!topologyInitialized && $topologies.length > 0) { + topology.set($topologies[0]); + lastTopologyId = $topologies[0].id; + topologyInitialized = true; } }); - let lastRequestOptions = JSON.stringify(get(topologyOptions).request_options); + // let lastOptions = JSON.stringify(get(topologyOptions)); - // Subscribe to options changes and save to localStorage + // Subscribe to options changes and save to localStorage + update topology object if (typeof window !== 'undefined') { topologyOptions.subscribe((options) => { saveOptionsToStorage(options); }); + topology.subscribe((topology) => { + if (topology && lastTopologyId != topology.id) { + lastTopologyId = topology.id; + topologyOptions.set(topology.options); + } + }); + optionsPanelExpanded.subscribe((expanded) => { saveExpandedToStorage(expanded); }); - topologyOptions.subscribe(($options) => { - const current = JSON.stringify($options.request_options); - if (current !== lastRequestOptions) { - lastRequestOptions = current; - if (networksInitialized) getTopology(); - } - }); + // topologyOptions.subscribe(($options) => { + // const current = JSON.stringify($options.request); + // if (current !== lastOptions) { + // lastOptions = current; + // if (networksInitialized && topologyInitialized) refreshTopology(get(topology)); + // } + // }); } } export function resetTopologyOptions(): void { - networksInitialized = false; + // networksInitialized = false; topologyOptions.set(structuredClone(defaultOptions)); if (browser) { localStorage.removeItem(OPTIONS_STORAGE_KEY); @@ -139,14 +148,83 @@ function saveExpandedToStorage(expanded: boolean): void { } } -export async function getTopology() { - const options = get(topologyOptions); - return await api.request('/topology', topology, (topology) => topology, { - method: 'POST', - body: JSON.stringify(options.request_options) +export async function refreshTopology(data: Topology) { + return await api.request( + `/topology/${data.id}/refresh`, + topology, + (topology) => topology, + { + method: 'POST', + body: JSON.stringify(data) + } + ); +} + +export async function lockTopology(data: Topology) { + return await api.request( + `/topology/${data.id}/lock`, + topology, + (topology) => topology, + { + method: 'POST', + body: JSON.stringify(data) + } + ); +} + +export async function unlockTopology(data: Topology) { + return await api.request( + `/topology/${data.id}/unlock`, + topology, + (topology) => topology, + { + method: 'POST', + body: JSON.stringify(data) + } + ); +} + +export async function getTopologies() { + return await api.request('/topology', topologies, (topologies) => topologies, { + method: 'GET' }); } +export async function createTopology(data: Topology) { + const result = await api.request( + `/topology`, + topologies, + (newTopology, current) => [...current, newTopology], + { method: 'POST', body: JSON.stringify(data) } + ); + + if (result && result.data && result.success) { + topology.set(result.data); + } + + return result; +} + +export async function deleteTopology(id: string) { + await api.request( + `/topology/${id}`, + topologies, + (_, current) => current.filter((t) => t.id != id), + { method: 'DELETE' } + ); +} + +export async function updateTopology(data: Topology) { + const result = await api.request( + `/topology/${data.id}`, + topologies, + (updatedTopology, current) => current.map((t) => (t.id === data.id ? updatedTopology : t)), + { method: 'PUT', body: JSON.stringify(data) } + ); + + return result; +} + // Cycle through anchor positions in logical order export function getNextHandle(currentHandle: EdgeHandle): EdgeHandle { const cycle = [EdgeHandle.Top, EdgeHandle.Right, EdgeHandle.Bottom, EdgeHandle.Left]; @@ -154,3 +232,29 @@ export function getNextHandle(currentHandle: EdgeHandle): EdgeHandle { const nextIndex = (currentIndex + 1) % cycle.length; return cycle[nextIndex]; } + +export function createEmptyTopologyFormData(): Topology { + return { + id: uuidv4Sentinel, + created_at: utcTimeZoneSentinel, + updated_at: utcTimeZoneSentinel, + name: '', + network_id: get(networks)[0]?.id || '', + edges: [], + nodes: [], + options: structuredClone(defaultOptions), + hosts: [], + services: [], + subnets: [], + groups: [], + is_stale: false, + last_refreshed: utcTimeZoneSentinel, + is_locked: false, + removed_groups: [], + removed_hosts: [], + removed_services: [], + removed_subnets: [], + locked_at: null, + locked_by: null + }; +} diff --git a/ui/src/lib/features/topology/types/base.ts b/ui/src/lib/features/topology/types/base.ts index 0a7e6f1e..ca81d796 100644 --- a/ui/src/lib/features/topology/types/base.ts +++ b/ui/src/lib/features/topology/types/base.ts @@ -1,7 +1,34 @@ +import type { Group } from '$lib/features/groups/types/base'; +import type { Host } from '$lib/features/hosts/types/base'; import type { Service } from '$lib/features/services/types/base'; +import type { Subnet } from '$lib/features/subnets/types/base'; import type { ColorStyle } from '$lib/shared/utils/styling'; import type { IconComponent } from '$lib/shared/utils/types'; +export interface Topology { + edges: TopologyEdge[]; + nodes: Node[]; + options: TopologyOptions; + name: string; + id: string; + created_at: string; + updated_at: string; + network_id: string; + hosts: Host[]; + subnets: Subnet[]; + groups: Group[]; + services: Service[]; + is_stale: boolean; + last_refreshed: string; + is_locked: boolean; + locked_at: string | null; + locked_by: string | null; + removed_hosts: string[]; + removed_services: string[]; + removed_subnets: string[]; + removed_groups: string[]; +} + export interface NodeBase { id: string; node_type: string; @@ -10,17 +37,22 @@ export interface NodeBase { header: string | null; } -type NodeType = - | { - node_type: 'InterfaceNode'; - subnet_id: string; - host_id: string; - interface_id: string; - is_infra: boolean; - } - | { node_type: 'SubnetNode'; infra_width: number }; +type NodeType = InterfaceNode | SubnetNode; + +export interface InterfaceNode extends Record { + node_type: 'InterfaceNode'; + subnet_id: string; + host_id: string; + interface_id: string; + is_infra: boolean; +} + +export interface SubnetNode extends Record { + node_type: 'SubnetNode'; + infra_width: number; +} -type TopologyNode = NodeBase & NodeType & Record; +type Node = NodeBase & NodeType & Record; export interface NodeRenderData { headerText: string | null; @@ -39,7 +71,7 @@ export interface SubnetRenderData { colorHelper: ColorStyle; } -interface TopologyEdgeBase extends Record { +interface EdgeBase extends Record { source: string; label: string; target: string; @@ -49,11 +81,11 @@ interface TopologyEdgeBase extends Record { } export type TopologyEdge = - | (TopologyEdgeBase & RequestPathEdge) - | (TopologyEdgeBase & HubAndSpokeEdge) - | (TopologyEdgeBase & InterfaceEdge) - | (TopologyEdgeBase & ServiceVirtualizationEdge) - | (TopologyEdgeBase & HostVirtualizationEdge); + | (EdgeBase & RequestPathEdge) + | (EdgeBase & HubAndSpokeEdge) + | (EdgeBase & InterfaceEdge) + | (EdgeBase & ServiceVirtualizationEdge) + | (EdgeBase & HostVirtualizationEdge); export interface RequestPathEdge { edge_type: 'RequestPath'; @@ -85,13 +117,6 @@ export interface HostVirtualizationEdge { vm_service_id: string; } -export interface TopologyResponse { - edge_property: string; - edges: Array<[number, number, TopologyEdge]>; - node_holes: unknown[]; - nodes: TopologyNode[]; -} - export enum EdgeHandle { Top = 'Top', Right = 'Right', @@ -99,20 +124,23 @@ export enum EdgeHandle { Left = 'Left' } +export interface TopologyOptions { + local: TopologyLocalOptions; + request: TopologyRequestOptions; +} + +export interface TopologyLocalOptions { + left_zone_title: string; + no_fade_edges: boolean; + hide_resize_handles: boolean; + hide_edge_types: string[]; +} + export interface TopologyRequestOptions { group_docker_bridges_by_host: boolean; hide_vm_title_on_docker_container: boolean; hide_ports: boolean; - network_ids: string[]; show_gateway_in_left_zone: boolean; left_zone_service_categories: string[]; hide_service_categories: string[]; } - -export interface TopologyOptions { - left_zone_title: string; - no_fade_edges: boolean; - hide_resize_handles: boolean; - hide_edge_types: string[]; - request_options: TopologyRequestOptions; -} diff --git a/ui/src/lib/features/users/components/UserTab.svelte b/ui/src/lib/features/users/components/UserTab.svelte index 9fb9fd6d..5fc1435d 100644 --- a/ui/src/lib/features/users/components/UserTab.svelte +++ b/ui/src/lib/features/users/components/UserTab.svelte @@ -43,22 +43,11 @@ showInviteModal = false; } - // Make headerButtons reactive - let headerButtons = $derived.by(() => { - if (!$organization || !$organization.plan) return []; - + // Check if user can invite + let canInviteUsers = $derived.by(() => { + if (!$organization || !$organization.plan) return false; let features = billingPlans.getMetadata($organization.plan.type).features; - let canInviteUsers = features.share_views || features.team_members; - - return canInviteUsers - ? [ - { - cta: 'Invite User', - onClick: handleCreateInvite, - IconComponent: UserPlus - } - ] - : []; + return features.share_views || features.team_members; }); // Only define fields for users (invites won't be filtered/sorted) @@ -101,7 +90,16 @@
- + + + {#if canInviteUsers} + + {/if} + + {#if $loading} diff --git a/ui/src/lib/shared/components/data/GithubStars.svelte b/ui/src/lib/shared/components/data/GithubStars.svelte index 52c838ed..eb4f4e71 100644 --- a/ui/src/lib/shared/components/data/GithubStars.svelte +++ b/ui/src/lib/shared/components/data/GithubStars.svelte @@ -20,6 +20,8 @@ method: 'GET' }); + console.log(response); + if (response) { error = false; } else { diff --git a/ui/src/lib/shared/components/layout/TabHeader.svelte b/ui/src/lib/shared/components/layout/TabHeader.svelte index e57d276c..f3b86b12 100644 --- a/ui/src/lib/shared/components/layout/TabHeader.svelte +++ b/ui/src/lib/shared/components/layout/TabHeader.svelte @@ -1,16 +1,6 @@
@@ -19,15 +9,6 @@

{subtitle}

- {#each buttons as button (button)} - {#if button.ButtonComponent} - - {:else} - - {/if} - {/each} +
From dc3024d2c0667cd579837b9e5dec973c9584accd Mon Sep 17 00:00:00 2001 From: Maya Date: Wed, 19 Nov 2025 15:51:23 -0500 Subject: [PATCH 07/27] Wiring state management to frontend --- .../20251118225043_save-topology.sql | 12 +- .../src/daemon/discovery/service/network.rs | 2 +- backend/src/daemon/utils/base.rs | 20 +- backend/src/server/hosts/impl/storage.rs | 5 +- backend/src/server/shared/storage/generic.rs | 6 +- docker-compose.test.yml | 83 -------- ui/src/app.css | 22 ++- .../components/HostConsolidationModal.svelte | 77 ++++---- .../topology/components/ExportButton.svelte | 4 +- .../components/RefreshConflictsModal.svelte | 131 +++++------- .../topology/components/StateBadge.svelte | 37 ++++ .../topology/components/TopologyTab.svelte | 187 +++++------------- .../visualization/TopologyViewer.svelte | 6 +- ui/src/lib/features/topology/state.ts | 70 +++++++ ui/src/lib/features/topology/store.ts | 24 +-- .../shared/components/data/EntityList.svelte | 21 ++ .../components/feedback/InlineDanger.svelte | 18 ++ 17 files changed, 341 insertions(+), 384 deletions(-) delete mode 100644 docker-compose.test.yml create mode 100644 ui/src/lib/features/topology/components/StateBadge.svelte create mode 100644 ui/src/lib/features/topology/state.ts create mode 100644 ui/src/lib/shared/components/data/EntityList.svelte create mode 100644 ui/src/lib/shared/components/feedback/InlineDanger.svelte diff --git a/backend/migrations/20251118225043_save-topology.sql b/backend/migrations/20251118225043_save-topology.sql index 691e399d..2eb7f977 100644 --- a/backend/migrations/20251118225043_save-topology.sql +++ b/backend/migrations/20251118225043_save-topology.sql @@ -22,4 +22,14 @@ CREATE TABLE topologies ( updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -CREATE INDEX IF NOT EXISTS idx_topologies_network ON topologies(network_id); \ No newline at end of file +CREATE INDEX IF NOT EXISTS idx_topologies_network ON topologies(network_id); + +-- Migration to change hosts.services from JSONB to UUID[] +-- Converts JSONB array to UUID array, handles NULL and non-array cases + +ALTER TABLE hosts + ALTER COLUMN services TYPE UUID[] + USING CASE + WHEN services IS NULL THEN NULL + ELSE translate(services::text, '[]"', '{}')::UUID[] + END; \ No newline at end of file diff --git a/backend/src/daemon/discovery/service/network.rs b/backend/src/daemon/discovery/service/network.rs index 9c9f476e..8e3dc1bc 100644 --- a/backend/src/daemon/discovery/service/network.rs +++ b/backend/src/daemon/discovery/service/network.rs @@ -314,7 +314,7 @@ impl DiscoveryRunner { total_ips = %total_ips, scanned = %scanned, discovered = %successful_discoveries.len(), - "📊 Scan complete" + "Scan complete" ); Ok(successful_discoveries) diff --git a/backend/src/daemon/utils/base.rs b/backend/src/daemon/utils/base.rs index 44276133..d3d6d9be 100644 --- a/backend/src/daemon/utils/base.rs +++ b/backend/src/daemon/utils/base.rs @@ -226,22 +226,20 @@ pub trait DaemonUtils { } else { // Use automatic tracing::info!( - "Using automatic concurrent_scans={} with port_batch={} per host \ - (FD limit: {}, available: {}, FDs per host: {})", - optimal_concurrent, - port_batch_bounded, - fd_limit, - available, - fds_per_host + concurrent_scans = %optimal_concurrent, + port_batch = %port_batch_bounded, + fd_limit = %fd_limit, + fd_available = %available, + fds_per_host = %fds_per_host, + "Using automatic concurrent_scans", ); optimal_concurrent }; - if result == 1 { + if result < 5 { tracing::warn!( - "Very low concurrency (1 host). File descriptor limit is {}. \ - Consider increasing for better performance.", - fd_limit + fd_limit = %fd_limit, + "Very low concurrency. Consider increasing for better performance.", ); } diff --git a/backend/src/server/hosts/impl/storage.rs b/backend/src/server/hosts/impl/storage.rs index 8fc5e608..eae64632 100644 --- a/backend/src/server/hosts/impl/storage.rs +++ b/backend/src/server/hosts/impl/storage.rs @@ -114,9 +114,6 @@ impl StorableEntity for Host { fn from_row(row: &PgRow) -> Result { // Parse JSON fields safely - let services: Vec = - serde_json::from_value(row.get::("services")) - .map_err(|e| anyhow::anyhow!("Failed to deserialize services: {}", e))?; let interfaces: Vec = serde_json::from_value(row.get::("interfaces")) .map_err(|e| anyhow::anyhow!("Failed to deserialize interfaces: {}", e))?; @@ -143,7 +140,7 @@ impl StorableEntity for Host { hostname: row.get("hostname"), target, hidden: row.get("hidden"), - services, + services: row.get("services"), ports, virtualization, interfaces, diff --git a/backend/src/server/shared/storage/generic.rs b/backend/src/server/shared/storage/generic.rs index 192aac8a..a0cac496 100644 --- a/backend/src/server/shared/storage/generic.rs +++ b/backend/src/server/shared/storage/generic.rs @@ -66,11 +66,7 @@ where SqlValue::Bool(v) => query.bind(v), SqlValue::Timestamp(v) => query.bind(v), SqlValue::OptionTimestamp(v) => query.bind(v), - SqlValue::UuidArray(v) => { - // Create a reference that lives as long as 'q - let slice: &'q [Uuid] = v; - query.bind(slice) - } + SqlValue::UuidArray(v) => query.bind(v.clone()), SqlValue::OptionalString(v) => query.bind(v), SqlValue::EntitySource(v) => query.bind(serde_json::to_value(v)?), SqlValue::IpCidr(v) => query.bind(serde_json::to_string(v)?), diff --git a/docker-compose.test.yml b/docker-compose.test.yml deleted file mode 100644 index 2cedb273..00000000 --- a/docker-compose.test.yml +++ /dev/null @@ -1,83 +0,0 @@ -x-netvisor-env: &netvisor-env - NETVISOR_LOG_LEVEL: ${NETVISOR_LOG_LEVEL:-info} - NETVISOR_SERVER_PORT: ${NETVISOR_SERVER_PORT:-60072} - NETVISOR_DAEMON_PORT: ${NETVISOR_DAEMON_PORT:-60073} - -services: - daemon: - image: mayanayza/netvisor-daemon:v0.9.2-dev - container_name: netvisor-daemon - network_mode: host - privileged: true - restart: unless-stopped - ports: - - "${NETVISOR_DAEMON_PORT:-60073}:${NETVISOR_DAEMON_PORT:-60073}" - environment: - <<: *netvisor-env - NETVISOR_SERVER_URL: ${NETVISOR_SERVER_URL:-http://127.0.0.1:60072} - NETVISOR_PORT: ${NETVISOR_DAEMON_PORT:-60073} - NETVISOR_BIND_ADDRESS: ${NETVISOR_BIND_ADDRESS:-0.0.0.0} - NETVISOR_NAME: ${NETVISOR_NAME:-netvisor-daemon} - NETVISOR_HEARTBEAT_INTERVAL: ${NETVISOR_HEARTBEAT_INTERVAL:-30} - NETVISOR_MODE: ${NETVISOR_MODE:-Push} - healthcheck: - test: ["CMD-SHELL", "curl -f http://localhost:${NETVISOR_DAEMON_PORT:-60073}/api/health || exit 1"] - interval: 5s - timeout: 3s - retries: 15 - volumes: - - daemon-config:/root/.config/daemon - # Comment out the line below to disable docker discovery - - /var/run/docker.sock:/var/run/docker.sock:ro - - postgres: - image: postgres:17-alpine - environment: - POSTGRES_DB: netvisor - POSTGRES_USER: postgres - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-password} - volumes: - - postgres_data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] - interval: 10s - timeout: 5s - retries: 5 - restart: unless-stopped - networks: - - netvisor - - server: - image: mayanayza/netvisor-server:v0.9.2-dev - ports: - - "${NETVISOR_SERVER_PORT:-60072}:${NETVISOR_SERVER_PORT:-60072}" - environment: - <<: *netvisor-env - NETVISOR_DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-password}@postgres:5432/netvisor - NETVISOR_WEB_EXTERNAL_PATH: /app/static - NETVISOR_PUBLIC_URL: ${NETVISOR_PUBLIC_URL:-http://localhost:${NETVISOR_SERVER_PORT:-60072}} - # How server reaches integrated daemon - # 172.17.0.1 is Docker's default bridge gateway. If your's is different, make sure to change it. - NETVISOR_INTEGRATED_DAEMON_URL: http://172.17.0.1:${NETVISOR_DAEMON_PORT:-60073} - volumes: - - ./data:/data - depends_on: - postgres: - condition: service_healthy - daemon: - condition: service_healthy - restart: unless-stopped - networks: - - netvisor - -volumes: - postgres_data: - daemon-config: - -networks: - netvisor: - driver: bridge - ipam: - config: - - subnet: 172.31.0.0/16 - gateway: 172.31.0.1 diff --git a/ui/src/app.css b/ui/src/app.css index f6996931..c1a93608 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -58,6 +58,26 @@ @apply disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:border-green-800/50 disabled:hover:bg-green-950/40 disabled:hover:text-green-400; } + /* Warning action button - mirrors btn-danger pattern for yellow/warning states */ + .btn-warning { + @apply bg-yellow-950/40 hover:bg-yellow-950/60; + @apply border border-yellow-800/50 hover:border-yellow-700; + @apply rounded-md px-4 py-2 font-medium text-yellow-400 hover:text-yellow-300; + @apply transition-colors duration-150; + @apply inline-flex items-center justify-center gap-2; + @apply disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:border-yellow-800/50 disabled:hover:bg-yellow-950/40 disabled:hover:text-yellow-400; + } + + /* Info action button - mirrors btn-primary pattern for blue/info states */ + .btn-info { + @apply bg-blue-950/40 hover:bg-blue-950/60; + @apply border border-blue-800/50 hover:border-blue-700; + @apply rounded-md px-4 py-2 font-medium text-blue-400 hover:text-blue-300; + @apply transition-colors duration-150; + @apply inline-flex items-center justify-center gap-2; + @apply disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:border-blue-800/50 disabled:hover:bg-blue-950/40 disabled:hover:text-blue-400; + } + /* Icon buttons - minimal style for icon-only actions */ .btn-icon { @apply rounded p-2 text-gray-500 hover:bg-gray-800 hover:text-gray-200; @@ -84,7 +104,7 @@ /* Icon button - primary variant (for emphasized icon actions) */ .btn-icon-primary { - @apply rounded p-2 text-gray-500 hover:bg-purple-950/30 hover:text-purple-400; + @apply rounded p-2 text-gray-500 hover:bg-blue-950/30 hover:text-blue-400; @apply transition-colors duration-150; @apply inline-flex items-center justify-center; @apply disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-gray-500; diff --git a/ui/src/lib/features/hosts/components/HostConsolidationModal.svelte b/ui/src/lib/features/hosts/components/HostConsolidationModal.svelte index 0579dabb..1553e374 100644 --- a/ui/src/lib/features/hosts/components/HostConsolidationModal.svelte +++ b/ui/src/lib/features/hosts/components/HostConsolidationModal.svelte @@ -1,5 +1,4 @@ - diff --git a/ui/src/lib/features/topology/components/RefreshConflictsModal.svelte b/ui/src/lib/features/topology/components/RefreshConflictsModal.svelte index a0ccff99..9f0bf363 100644 --- a/ui/src/lib/features/topology/components/RefreshConflictsModal.svelte +++ b/ui/src/lib/features/topology/components/RefreshConflictsModal.svelte @@ -2,6 +2,9 @@ import GenericModal from '$lib/shared/components/layout/GenericModal.svelte'; import { AlertTriangle, Lock, RefreshCcw } from 'lucide-svelte'; import type { Topology } from '../types/base'; + import InlineDanger from '$lib/shared/components/feedback/InlineDanger.svelte'; + import InlineInfo from '$lib/shared/components/feedback/InlineInfo.svelte'; + import EntityList from '$lib/shared/components/data/EntityList.svelte'; export let isOpen: boolean; export let topology: Topology; @@ -28,6 +31,41 @@ $: totalRemoved = removedHosts.length + removedServices.length + removedSubnets.length + removedGroups.length; + + // Build single list with category headers + $: allRemovedEntities = (() => { + const items = []; + + if (removedHosts.length > 0) { + items.push({ + id: 'hosts-header', + name: `Hosts: ${removedHosts.map((h) => h.name).join(', ')}` + }); + } + + if (removedServices.length > 0) { + items.push({ + id: 'services-header', + name: `Services: ${removedServices.map((s) => s.name).join(', ')}` + }); + } + + if (removedSubnets.length > 0) { + items.push({ + id: 'subnets-header', + name: `Subnets: ${removedSubnets.map((s) => s.name).join(', ')}` + }); + } + + if (removedGroups.length > 0) { + items.push({ + id: 'groups-header', + name: `Groups: ${removedGroups.map((g) => g.name).join(', ')}` + }); + } + + return items; + })(); @@ -37,94 +75,19 @@
-
- -
-

- {totalRemoved} - {totalRemoved === 1 ? 'entity' : 'entities'} will be removed -

-

- These entities no longer exist in the network and will be removed from this diagram if you - refresh. -

-
-
+ -
- {#if removedHosts.length > 0} -
-

- Hosts ({removedHosts.length}) -

-
    - {#each removedHosts as host (host.id)} -
  • • {host.name}
  • - {/each} -
-
- {/if} - - {#if removedServices.length > 0} -
-

- Services ({removedServices.length}) -

-
    - {#each removedServices as service (service.id)} -
  • • {service.name}
  • - {/each} -
-
- {/if} - - {#if removedSubnets.length > 0} -
-

- Subnets ({removedSubnets.length}) -

-
    - {#each removedSubnets as subnet (subnet.id)} -
  • • {subnet.name}
  • - {/each} -
-
- {/if} - - {#if removedGroups.length > 0} -
-

- Groups ({removedGroups.length}) -

-
    - {#each removedGroups as group (group.id)} -
  • • {group.name}
  • - {/each} -
-
- {/if} -
+ -
-

- Tip: If you want to preserve this network state as a historical record, click - "Lock Instead" to freeze this topology without refreshing. -

-
+
diff --git a/ui/src/lib/features/topology/components/StateBadge.svelte b/ui/src/lib/features/topology/components/StateBadge.svelte new file mode 100644 index 00000000..26ac8e7b --- /dev/null +++ b/ui/src/lib/features/topology/components/StateBadge.svelte @@ -0,0 +1,37 @@ + + +{#if onClick} + +{:else} +
+ + {label} +
+{/if} diff --git a/ui/src/lib/features/topology/components/TopologyTab.svelte b/ui/src/lib/features/topology/components/TopologyTab.svelte index df1cd6f1..8b77b23d 100644 --- a/ui/src/lib/features/topology/components/TopologyTab.svelte +++ b/ui/src/lib/features/topology/components/TopologyTab.svelte @@ -4,7 +4,7 @@ import TopologyViewer from './visualization/TopologyViewer.svelte'; import TopologyOptionsPanel from './panel/TopologyOptionsPanel.svelte'; import { loadData } from '$lib/shared/utils/dataLoader'; - import { Edit, Lock, LockOpen, Plus, RefreshCcw, Trash2, AlertTriangle } from 'lucide-svelte'; + import { Edit, Lock, Plus, Trash2 } from 'lucide-svelte'; import { getHosts } from '$lib/features/hosts/store'; import { getServices } from '$lib/features/services/store'; import { getSubnets } from '$lib/features/subnets/store'; @@ -21,10 +21,13 @@ unlockTopology } from '../store'; import type { Topology } from '../types/base'; - import InlineWarning from '$lib/shared/components/feedback/InlineWarning.svelte'; import TopologyModal from './TopologyModal.svelte'; - import RefreshConflictsModal from './RefreshConflictsModal.svelte'; import { users } from '$lib/features/users/store'; + import { getTopologyState } from '../state'; + import StateBadge from './StateBadge.svelte'; + import InlineDanger from '$lib/shared/components/feedback/InlineDanger.svelte'; + import InlineInfo from '$lib/shared/components/feedback/InlineInfo.svelte'; + import RefreshConflictsModal from './RefreshConflictsModal.svelte'; let isCreateEditOpen = false; let editingTopology: Topology | null = null; @@ -108,29 +111,18 @@ } // Compute topology state - $: topologyState = $topology ? getTopologyState($topology) : null; + $: stateConfig = $topology ? getTopologyState($topology) : null; $: lockedByUser = $topology?.locked_by ? $users.find((u) => u.id === $topology.locked_by) : null; - function getTopologyState(topo: Topology) { - if (topo.is_locked) { - return { type: 'locked', color: 'blue', icon: Lock }; - } + // Determine primary action handler + function handlePrimaryAction() { + if (!stateConfig || !stateConfig.primaryAction) return; - if (!topo.is_stale) { - return { type: 'fresh', color: 'green', icon: RefreshCcw }; + if (stateConfig.primaryAction === 'refresh') { + handleRefresh(); + } else if (stateConfig.primaryAction === 'unlock') { + handleUnlock(); } - - const hasConflicts = - topo.removed_hosts.length > 0 || - topo.removed_services.length > 0 || - topo.removed_subnets.length > 0 || - topo.removed_groups.length > 0; - - if (hasConflicts) { - return { type: 'stale_conflicts', color: 'red', icon: AlertTriangle }; - } - - return { type: 'stale_safe', color: 'yellow', icon: RefreshCcw }; } const loading = loadData([getHosts, getServices, getSubnets, getGroups, getTopologies]); @@ -142,50 +134,29 @@
- - {#if $topology && topologyState} + + + + + {#if $topology && stateConfig} + {#if stateConfig.secondaryAction === 'lock'} + + {/if} + {/if} + + + {#if $topology && stateConfig}
- {#if topologyState.type === 'locked'} -
- - - Locked {$topology.locked_at - ? new Date($topology.locked_at).toLocaleDateString() - : ''} - -
- {:else if topologyState.type === 'fresh'} -
- - Up to date -
- {:else if topologyState.type === 'stale_safe'} -
- - Refresh available -
- {:else if topologyState.type === 'stale_conflicts'} -
- - Conflicts detected -
- {/if} +
{/if} @@ -199,92 +170,42 @@ {/each} - - - - {#if $topology && !$topology.is_locked} - - {/if} - - {#if $topology} - {#if $topology.is_locked} - - {:else} - - {/if} - {/if} - - - -
- - {#if $topology && topologyState} - {#if topologyState.type === 'locked'} -
-
- -
-

- Topology Locked - {#if lockedByUser} - by {lockedByUser.email} - {/if} -

-

- This topology is frozen at its current network state. Click "Unlock" to enable - refreshing. -

-
-
-
- {:else if topologyState.type === 'stale_safe'} - + {#if $topology && stateConfig} + {#if stateConfig.type === 'locked'} + + {:else if stateConfig.type === 'stale_conflicts'} + - {:else if topologyState.type === 'stale_conflicts'} -
-
- -
-

Conflicts Detected

-

- Some entities in this diagram no longer exist. Click "Refresh" to review changes - before updating. -

-
-
-
{/if} {/if} {#if $loading} - {:else} + {:else if $topology}
+ {:else} +
No topology selected. Create one to get started.
{/if}
diff --git a/ui/src/lib/features/topology/components/visualization/TopologyViewer.svelte b/ui/src/lib/features/topology/components/visualization/TopologyViewer.svelte index 58dedb76..c4fb9491 100644 --- a/ui/src/lib/features/topology/components/visualization/TopologyViewer.svelte +++ b/ui/src/lib/features/topology/components/visualization/TopologyViewer.svelte @@ -19,7 +19,6 @@ import InterfaceNode from './InterfaceNode.svelte'; import CustomEdge from './CustomEdge.svelte'; import { type TopologyEdge } from '../../types/base'; - import { onMount } from 'svelte'; import { updateConnectedNodes, toggleEdgeHover, getEdgeDisplayState } from '../../interactions'; // Define node types @@ -42,10 +41,6 @@ // Store pending edges until nodes are ready let pendingEdges: Edge[] = []; - onMount(async () => { - await loadTopologyData(); - }); - $effect(() => { if ($topology?.edges || $topology?.nodes) { void loadTopologyData(); @@ -220,6 +215,7 @@ diff --git a/ui/src/lib/features/topology/state.ts b/ui/src/lib/features/topology/state.ts new file mode 100644 index 00000000..22bd6169 --- /dev/null +++ b/ui/src/lib/features/topology/state.ts @@ -0,0 +1,70 @@ +import { Lock, RefreshCcw, AlertTriangle } from 'lucide-svelte'; +import type { Topology } from './types/base'; +import type { IconComponent } from '$lib/shared/utils/types'; + +export type TopologyStateType = 'locked' | 'fresh' | 'stale_safe' | 'stale_conflicts'; + +export interface TopologyStateConfig { + type: TopologyStateType; + icon: IconComponent; + color: 'blue' | 'green' | 'yellow' | 'red'; + getLabel: (topology: Topology) => string; + primaryAction: 'refresh' | 'unlock' | null; + secondaryAction: 'lock' | null; +} + +export function getTopologyState(topology: Topology): TopologyStateConfig { + // Locked state + if (topology.is_locked) { + const lockedDate = topology.locked_at ? new Date(topology.locked_at).toLocaleDateString() : ''; + return { + type: 'locked', + icon: Lock, + color: 'blue', + getLabel: () => `Locked ${lockedDate}`.trim(), + primaryAction: 'unlock', + secondaryAction: null + }; + } + + // Fresh state + if (!topology.is_stale) { + return { + type: 'fresh', + icon: RefreshCcw, + color: 'green', + getLabel: () => 'Up to date', + primaryAction: null, + secondaryAction: 'lock' + }; + } + + // Check for conflicts + const hasConflicts = + topology.removed_hosts.length > 0 || + topology.removed_services.length > 0 || + topology.removed_subnets.length > 0 || + topology.removed_groups.length > 0; + + // Stale with conflicts + if (hasConflicts) { + return { + type: 'stale_conflicts', + icon: AlertTriangle, + color: 'red', + getLabel: () => 'Conflicts detected', + primaryAction: 'refresh', + secondaryAction: 'lock' + }; + } + + // Stale without conflicts + return { + type: 'stale_safe', + icon: RefreshCcw, + color: 'yellow', + getLabel: () => 'Refresh available', + primaryAction: 'refresh', + secondaryAction: 'lock' + }; +} diff --git a/ui/src/lib/features/topology/store.ts b/ui/src/lib/features/topology/store.ts index 8f5b2714..9f608fe5 100644 --- a/ui/src/lib/features/topology/store.ts +++ b/ui/src/lib/features/topology/store.ts @@ -38,8 +38,6 @@ const defaultOptions: TopologyOptions = { export const topologyOptions = writable(loadOptionsFromStorage()); export const optionsPanelExpanded = writable(loadExpandedFromStorage()); -// Initialize network_ids with the first network when networks are loaded -// let networksInitialized = false; let topologyInitialized = false; let lastTopologyId = ''; @@ -52,8 +50,6 @@ if (browser) { } }); - // let lastOptions = JSON.stringify(get(topologyOptions)); - // Subscribe to options changes and save to localStorage + update topology object if (typeof window !== 'undefined') { topologyOptions.subscribe((options) => { @@ -70,14 +66,6 @@ if (browser) { optionsPanelExpanded.subscribe((expanded) => { saveExpandedToStorage(expanded); }); - - // topologyOptions.subscribe(($options) => { - // const current = JSON.stringify($options.request); - // if (current !== lastOptions) { - // lastOptions = current; - // if (networksInitialized && topologyInitialized) refreshTopology(get(topology)); - // } - // }); } } @@ -185,9 +173,15 @@ export async function unlockTopology(data: Topology) { } export async function getTopologies() { - return await api.request('/topology', topologies, (topologies) => topologies, { - method: 'GET' - }); + const result = await api.request( + '/topology', + topologies, + (topologies) => topologies, + { + method: 'GET' + } + ); + return result; } export async function createTopology(data: Topology) { diff --git a/ui/src/lib/shared/components/data/EntityList.svelte b/ui/src/lib/shared/components/data/EntityList.svelte new file mode 100644 index 00000000..944cbf9c --- /dev/null +++ b/ui/src/lib/shared/components/data/EntityList.svelte @@ -0,0 +1,21 @@ + + +{#if items.length > 0} +
+ {#if title.length > 0} +

+ {title} ({items.length}) +

+ {/if} +
    + {#each items as item (item.id)} +
  • + • {item.name} +
  • + {/each} +
+
+{/if} diff --git a/ui/src/lib/shared/components/feedback/InlineDanger.svelte b/ui/src/lib/shared/components/feedback/InlineDanger.svelte new file mode 100644 index 00000000..c0ebe149 --- /dev/null +++ b/ui/src/lib/shared/components/feedback/InlineDanger.svelte @@ -0,0 +1,18 @@ + + +
+
+ +
+

{title}

+ {#if body} +

{body}

+ {/if} +
+
+
From dd3ab17a8b713dfc7c2686c37ed49f7cef43a452 Mon Sep 17 00:00:00 2001 From: Maya Date: Wed, 19 Nov 2025 17:49:59 -0500 Subject: [PATCH 08/27] feat: topology state management done --- .../20251118225043_save-topology.sql | 1 + backend/src/server/topology/handlers.rs | 33 ++++- backend/src/server/topology/service/main.rs | 9 ++ .../src/server/topology/service/subscriber.rs | 3 + backend/src/server/topology/types/api.rs | 28 +++- backend/src/server/topology/types/base.rs | 2 + backend/src/server/topology/types/storage.rs | 4 + .../daemons/components/DaemonCard.svelte | 2 +- .../cards/DiscoverySessionCard.svelte | 2 +- .../tabs/DiscoveryScheduledTab.svelte | 2 +- .../tabs/DiscoverySessionTab.svelte | 2 +- .../discovery/{SSEStore.ts => sse.ts} | 123 ++++++++---------- .../components/TopologyDetailsForm.svelte | 21 +++ .../topology/components/TopologyTab.svelte | 30 +++-- ui/src/lib/features/topology/sse.ts | 61 +++++++++ ui/src/lib/features/topology/store.ts | 39 ++++-- ui/src/lib/features/topology/types/base.ts | 1 + .../forms/selection/ListSelectItem.svelte | 2 +- .../selection/display/TopologyDisplay.svelte | 33 +++++ ui/src/lib/shared/utils/sse.ts | 36 +++++ ui/src/routes/+page.svelte | 9 +- 21 files changed, 335 insertions(+), 108 deletions(-) rename ui/src/lib/features/discovery/{SSEStore.ts => sse.ts} (63%) create mode 100644 ui/src/lib/features/topology/sse.ts create mode 100644 ui/src/lib/shared/components/forms/selection/display/TopologyDisplay.svelte diff --git a/backend/migrations/20251118225043_save-topology.sql b/backend/migrations/20251118225043_save-topology.sql index 2eb7f977..c04a9e96 100644 --- a/backend/migrations/20251118225043_save-topology.sql +++ b/backend/migrations/20251118225043_save-topology.sql @@ -18,6 +18,7 @@ CREATE TABLE topologies ( removed_services UUID[], removed_subnets UUID[], removed_groups UUID[], + parent_id UUID, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); diff --git a/backend/src/server/topology/handlers.rs b/backend/src/server/topology/handlers.rs index bf20be24..81807c02 100644 --- a/backend/src/server/topology/handlers.rs +++ b/backend/src/server/topology/handlers.rs @@ -1,5 +1,5 @@ use crate::server::{ - auth::middleware::RequireMember, + auth::middleware::{AuthenticatedUser, RequireMember}, config::AppState, shared::{ handlers::traits::{ @@ -14,10 +14,14 @@ use crate::server::{ use axum::{ Router, extract::State, - response::Json, + response::{ + Json, Sse, + sse::{Event, KeepAlive}, + }, routing::{delete, get, post, put}, }; -use std::sync::Arc; +use futures::{Stream, stream}; +use std::{convert::Infallible, sync::Arc}; pub fn create_router() -> Router> { Router::new() @@ -29,6 +33,7 @@ pub fn create_router() -> Router> { .route("/{id}/refresh", post(refresh)) .route("/{id}/lock", post(lock)) .route("/{id}/unlock", post(unlock)) + .route("/stream", get(staleness_stream)) } pub async fn create_handler( @@ -150,3 +155,25 @@ async fn unlock( Ok(Json(ApiResponse::success(updated))) } + +async fn staleness_stream( + State(state): State>, + _user: AuthenticatedUser, +) -> Sse>> { + let rx = state + .services + .topology_service + .subscribe_staleness_changes(); + + let stream = stream::unfold(rx, |mut rx| async move { + match rx.recv().await { + Ok(update) => { + let json = serde_json::to_string(&update).ok()?; + Some((Ok(Event::default().data(json)), rx)) + } + Err(_) => None, + } + }); + + Sse::new(stream).keep_alive(KeepAlive::default()) +} diff --git a/backend/src/server/topology/service/main.rs b/backend/src/server/topology/service/main.rs index 7df242bd..238aad15 100644 --- a/backend/src/server/topology/service/main.rs +++ b/backend/src/server/topology/service/main.rs @@ -3,6 +3,7 @@ use std::{collections::HashMap, sync::Arc}; use anyhow::Error; use async_trait::async_trait; use petgraph::{Graph, graph::NodeIndex}; +use tokio::sync::broadcast; use uuid::Uuid; use crate::server::{ @@ -23,6 +24,7 @@ use crate::server::{ planner::subnet_layout_planner::SubnetLayoutPlanner, }, types::{ + api::TopologyStalenessUpdate, base::{Topology, TopologyOptions}, edges::Edge, nodes::Node, @@ -37,6 +39,7 @@ pub struct TopologyService { group_service: Arc, service_service: Arc, event_bus: Arc, + pub staleness_tx: broadcast::Sender, } #[async_trait] @@ -69,6 +72,7 @@ impl TopologyService { storage: Arc>, event_bus: Arc, ) -> Self { + let (staleness_tx, _) = broadcast::channel(100); Self { host_service, subnet_service, @@ -76,9 +80,14 @@ impl TopologyService { service_service, storage, event_bus, + staleness_tx, } } + pub fn subscribe_staleness_changes(&self) -> broadcast::Receiver { + self.staleness_tx.subscribe() + } + pub async fn get_entity_data( &self, network_id: Uuid, diff --git a/backend/src/server/topology/service/subscriber.rs b/backend/src/server/topology/service/subscriber.rs index 14e0186f..473dfb01 100644 --- a/backend/src/server/topology/service/subscriber.rs +++ b/backend/src/server/topology/service/subscriber.rs @@ -76,6 +76,9 @@ impl EventSubscriber for TopologyService { } topology.base.is_stale = true; + + self.staleness_tx.send(topology.clone().into())?; + self.update(&mut topology, AuthenticatedEntity::System) .await?; } diff --git a/backend/src/server/topology/types/api.rs b/backend/src/server/topology/types/api.rs index e6763fde..ab424d18 100644 --- a/backend/src/server/topology/types/api.rs +++ b/backend/src/server/topology/types/api.rs @@ -1,9 +1,27 @@ -use crate::server::topology::types::edges::Edge; -use crate::server::topology::types::nodes::Node; use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::server::topology::types::base::Topology; #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, Hash)] -pub struct RefreshDataResponse { - pub nodes: Vec, - pub edges: Vec, +pub struct TopologyStalenessUpdate { + topology_id: Uuid, + is_stale: bool, + removed_hosts: Vec, + removed_services: Vec, + removed_subnets: Vec, + removed_groups: Vec, +} + +impl From for TopologyStalenessUpdate { + fn from(value: Topology) -> Self { + Self { + removed_groups: value.base.removed_groups, + removed_hosts: value.base.removed_hosts, + removed_services: value.base.removed_services, + removed_subnets: value.base.removed_subnets, + is_stale: true, + topology_id: value.id, + } + } } diff --git a/backend/src/server/topology/types/base.rs b/backend/src/server/topology/types/base.rs index 9931bfb3..c63ae786 100644 --- a/backend/src/server/topology/types/base.rs +++ b/backend/src/server/topology/types/base.rs @@ -33,6 +33,7 @@ pub struct TopologyBase { pub removed_subnets: Vec, pub removed_services: Vec, pub removed_groups: Vec, + pub parent_id: Option, } impl TopologyBase { @@ -56,6 +57,7 @@ impl TopologyBase { removed_subnets: vec![], removed_services: vec![], removed_groups: vec![], + parent_id: None, } } } diff --git a/backend/src/server/topology/types/storage.rs b/backend/src/server/topology/types/storage.rs index 3b218bff..8fc56a2f 100644 --- a/backend/src/server/topology/types/storage.rs +++ b/backend/src/server/topology/types/storage.rs @@ -78,6 +78,7 @@ impl StorableEntity for Topology { removed_services, removed_subnets, removed_groups, + parent_id, }, } = self.clone(); @@ -104,6 +105,7 @@ impl StorableEntity for Topology { "removed_services", "removed_subnets", "removed_groups", + "parent_id", ], vec![ SqlValue::Uuid(id), @@ -127,6 +129,7 @@ impl StorableEntity for Topology { SqlValue::UuidArray(removed_services), SqlValue::UuidArray(removed_subnets), SqlValue::UuidArray(removed_groups), + SqlValue::OptionalUuid(parent_id), ], )) } @@ -168,6 +171,7 @@ impl StorableEntity for Topology { removed_hosts: row.get("removed_hosts"), removed_services: row.get("removed_services"), removed_subnets: row.get("removed_subnets"), + parent_id: row.get("parent_id"), nodes, edges, hosts, diff --git a/ui/src/lib/features/daemons/components/DaemonCard.svelte b/ui/src/lib/features/daemons/components/DaemonCard.svelte index d0fdb967..d9fd18da 100644 --- a/ui/src/lib/features/daemons/components/DaemonCard.svelte +++ b/ui/src/lib/features/daemons/components/DaemonCard.svelte @@ -2,7 +2,7 @@ import GenericCard from '$lib/shared/components/data/GenericCard.svelte'; import type { Daemon } from '$lib/features/daemons/types/base'; import { getDaemonIsRunningDiscovery } from '$lib/features/daemons/store'; - import { sessions } from '$lib/features/discovery/SSEStore'; + import { sessions } from '$lib/features/discovery/sse'; import { entities } from '$lib/shared/stores/metadata'; import { networks } from '$lib/features/networks/store'; import { formatTimestamp } from '$lib/shared/utils/formatting'; diff --git a/ui/src/lib/features/discovery/components/cards/DiscoverySessionCard.svelte b/ui/src/lib/features/discovery/components/cards/DiscoverySessionCard.svelte index 4cf70f83..56269f4c 100644 --- a/ui/src/lib/features/discovery/components/cards/DiscoverySessionCard.svelte +++ b/ui/src/lib/features/discovery/components/cards/DiscoverySessionCard.svelte @@ -1,6 +1,6 @@ + + + + diff --git a/ui/src/lib/shared/utils/sse.ts b/ui/src/lib/shared/utils/sse.ts index 71fb3748..89465b7a 100644 --- a/ui/src/lib/shared/utils/sse.ts +++ b/ui/src/lib/shared/utils/sse.ts @@ -70,3 +70,39 @@ export class SSEClient { return this.eventSource?.readyState === EventSource.OPEN; } } + +/** + * Base SSE manager class that handles connection lifecycle + * Extend this for specific SSE use cases + */ +export abstract class BaseSSEManager { + protected client: SSEClient | null = null; + + /** + * Create the SSE configuration for this manager + * Must be implemented by subclasses + */ + protected abstract createConfig(): SSEConfig; + + connect() { + // Don't create a new client if already connected + if (this.isConnected()) { + return; + } + + const config = this.createConfig(); + this.client = new SSEClient(config); + this.client.connect(); + } + + disconnect() { + if (this.client) { + this.client.disconnect(); + this.client = null; + } + } + + isConnected(): boolean { + return this.client?.isConnected() ?? false; + } +} diff --git a/ui/src/routes/+page.svelte b/ui/src/routes/+page.svelte index 3d946356..bf622773 100644 --- a/ui/src/routes/+page.svelte +++ b/ui/src/routes/+page.svelte @@ -9,10 +9,11 @@ import { getServices, services } from '$lib/features/services/store'; import { watchStores } from '$lib/shared/utils/storeWatcher'; import { getNetworks } from '$lib/features/networks/store'; - import { startDiscoverySSE } from '$lib/features/discovery/SSEStore'; + import { discoverySSEManager } from '$lib/features/discovery/sse'; import { isAuthenticated, isCheckingAuth } from '$lib/features/auth/store'; import type { Component } from 'svelte'; import { getMetadata } from '$lib/shared/stores/metadata'; + import { topologySSEManager } from '$lib/features/topology/sse'; // Read hash immediately during script initialization, before onMount const initialHash = typeof window !== 'undefined' ? window.location.hash.substring(1) : ''; @@ -62,7 +63,8 @@ }) ].flatMap((w) => w); - startDiscoverySSE(); + topologySSEManager.connect(); + discoverySSEManager.connect(); appInitialized = true; } @@ -87,6 +89,9 @@ unsub(); }); + topologySSEManager.disconnect(); + discoverySSEManager.disconnect(); + if (typeof window !== 'undefined') { window.removeEventListener('hashchange', handleHashChange); } From bf0b9daa92a7551f5318ece685d6f2c40f4e0a82 Mon Sep 17 00:00:00 2001 From: vhsdream Date: Thu, 20 Nov 2025 12:59:29 -0500 Subject: [PATCH 09/27] Only use http endpoint for NextCloud service detection --- .../server/services/definitions/next_cloud.rs | 20 ++++++------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/backend/src/server/services/definitions/next_cloud.rs b/backend/src/server/services/definitions/next_cloud.rs index dcd25557..35c0ed5c 100644 --- a/backend/src/server/services/definitions/next_cloud.rs +++ b/backend/src/server/services/definitions/next_cloud.rs @@ -19,20 +19,12 @@ impl ServiceDefinition for NextCloud { } fn discovery_pattern(&self) -> Pattern<'_> { - Pattern::AnyOf(vec![ - Pattern::Endpoint( - PortBase::Http, - "/core/css/server.css", - "Nextcloud GmbH", - None, - ), - Pattern::Endpoint( - PortBase::Https, - "/core/css/server.css", - "Nextcloud GmbH", - None, - ), - ]) + Pattern::Endpoint( + PortBase::Http, + "/core/css/server.css", + "Nextcloud GmbH", + None, + ) } fn logo_url(&self) -> &'static str { From 152562de94df99e422363910824684e0b137018f Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 20 Nov 2025 14:27:57 -0500 Subject: [PATCH 10/27] Change SABnzbd port to default 8080 --- backend/src/server/services/definitions/sabnzbd.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/server/services/definitions/sabnzbd.rs b/backend/src/server/services/definitions/sabnzbd.rs index 3fbbfaf7..1159601f 100644 --- a/backend/src/server/services/definitions/sabnzbd.rs +++ b/backend/src/server/services/definitions/sabnzbd.rs @@ -20,7 +20,7 @@ impl ServiceDefinition for SABnzbd { fn discovery_pattern(&self) -> Pattern<'_> { Pattern::Endpoint( - PortBase::new_tcp(7777), + PortBase::Http8080, "/Content/manifest.json", "SABnzbd", None, From b5aa2d30d242974505237014f5ecdd2efb879d89 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 20 Nov 2025 15:02:10 -0500 Subject: [PATCH 11/27] Add sabnzbd module to definitions --- backend/src/server/services/definitions/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/server/services/definitions/mod.rs b/backend/src/server/services/definitions/mod.rs index b0eeb59f..0ba50c56 100644 --- a/backend/src/server/services/definitions/mod.rs +++ b/backend/src/server/services/definitions/mod.rs @@ -136,6 +136,7 @@ pub mod jellystat; pub mod komga; pub mod overseerr; pub mod plex; +pub mod sabnzbd; pub mod slskd; pub mod tautulli; From 339e3d2b2cf320f5ea1add1240e1803dd7f72b65 Mon Sep 17 00:00:00 2001 From: Maya Date: Thu, 20 Nov 2025 23:08:59 -0500 Subject: [PATCH 12/27] feat: topology saving working end to end --- backend/Cargo.lock | 25 ++ backend/Cargo.toml | 2 +- .../src/daemon/discovery/service/network.rs | 8 +- backend/src/daemon/discovery/types/base.rs | 2 +- backend/src/server/api_keys/impl/base.rs | 12 +- backend/src/server/api_keys/service.rs | 43 +-- backend/src/server/auth/handlers.rs | 115 +++++-- backend/src/server/auth/middleware.rs | 16 +- backend/src/server/auth/oidc.rs | 184 ++++++++--- backend/src/server/auth/service.rs | 227 ++++++++++--- backend/src/server/billing/service.rs | 4 +- backend/src/server/billing/types/base.rs | 33 +- backend/src/server/daemons/handlers.rs | 21 +- backend/src/server/daemons/impl/api.rs | 4 +- backend/src/server/daemons/impl/base.rs | 16 +- backend/src/server/daemons/service.rs | 96 ++++-- backend/src/server/discovery/impl/base.rs | 15 +- backend/src/server/discovery/impl/types.rs | 12 +- backend/src/server/discovery/service.rs | 143 ++++----- backend/src/server/groups/impl/base.rs | 20 ++ backend/src/server/groups/impl/types.rs | 6 +- backend/src/server/groups/service.rs | 63 +++- backend/src/server/hosts/impl/base.rs | 15 + backend/src/server/hosts/impl/ports.rs | 10 +- .../src/server/hosts/impl/virtualization.rs | 6 +- backend/src/server/hosts/mod.rs | 1 + backend/src/server/hosts/service.rs | 102 +++--- backend/src/server/hosts/subscriber.rs | 104 ++++++ backend/src/server/logging/mod.rs | 2 + backend/src/server/logging/service.rs | 14 + backend/src/server/logging/subscriber.rs | 34 ++ backend/src/server/mod.rs | 1 + backend/src/server/networks/impl.rs | 15 +- backend/src/server/networks/service.rs | 20 +- backend/src/server/organizations/handlers.rs | 8 +- backend/src/server/organizations/impl/base.rs | 17 +- .../src/server/organizations/impl/invites.rs | 17 +- .../src/server/organizations/impl/storage.rs | 10 +- backend/src/server/organizations/service.rs | 68 ++-- backend/src/server/services/impl/base.rs | 13 + .../src/server/services/impl/categories.rs | 24 +- .../server/services/impl/virtualization.rs | 6 +- backend/src/server/services/service.rs | 92 +++--- backend/src/server/shared/concepts.rs | 60 ++++ backend/src/server/shared/entities.rs | 235 +++++++++----- backend/src/server/shared/events/bus.rs | 219 +++++++++++-- backend/src/server/shared/events/types.rs | 134 +++++++- backend/src/server/shared/handlers/factory.rs | 8 +- backend/src/server/shared/handlers/traits.rs | 52 +-- backend/src/server/shared/mod.rs | 1 + backend/src/server/shared/services/factory.rs | 29 +- backend/src/server/shared/services/traits.rs | 74 +++-- backend/src/server/shared/storage/generic.rs | 6 +- backend/src/server/shared/types/metadata.rs | 1 + backend/src/server/subnets/handlers.rs | 16 +- backend/src/server/subnets/impl/base.rs | 7 + backend/src/server/subnets/impl/types.rs | 35 +- backend/src/server/subnets/service.rs | 117 ++----- backend/src/server/topology/handlers.rs | 59 +++- .../src/server/topology/service/context.rs | 16 +- .../server/topology/service/edge_builder.rs | 13 +- backend/src/server/topology/service/main.rs | 131 +++++--- .../service/planner/subnet_layout_planner.rs | 4 +- .../src/server/topology/service/subscriber.rs | 194 +++++++---- backend/src/server/topology/types/api.rs | 6 +- backend/src/server/topology/types/base.rs | 29 +- backend/src/server/topology/types/edges.rs | 20 +- backend/src/server/users/impl/base.rs | 15 +- backend/src/server/users/impl/permissions.rs | 18 +- backend/src/server/users/service.rs | 73 +---- .../api_keys/components/ApiKeyCard.svelte | 6 +- .../daemons/components/DaemonCard.svelte | 6 +- .../groups/components/GroupCard.svelte | 26 +- .../GroupEditModal/EdgeStyleForm.svelte | 4 +- .../features/hosts/components/HostCard.svelte | 302 ++++++++++-------- .../HostEditModal/HostEditor.svelte | 4 +- .../features/hosts/components/HostTab.svelte | 30 +- .../networks/components/NetworkCard.svelte | 4 +- .../services/components/ServiceCard.svelte | 14 +- .../subnets/components/SubnetCard.svelte | 4 +- .../topology/components/ExportButton.svelte | 4 +- .../components/RefreshConflictsModal.svelte | 8 +- .../topology/components/StateBadge.svelte | 29 +- .../topology/components/TopologyTab.svelte | 73 +++-- .../panel/TopologyOptionsPanel.svelte | 8 +- .../edges/InspectorEdgeGroup.svelte | 14 +- .../visualization/CustomEdge.svelte | 27 +- .../visualization/InterfaceNode.svelte | 41 ++- .../visualization/SubnetNode.svelte | 33 +- .../visualization/TopologyViewer.svelte | 96 ++++-- ui/src/lib/features/topology/interactions.ts | 15 +- ui/src/lib/features/topology/sse.ts | 92 +++--- ui/src/lib/features/topology/state.ts | 77 +++-- ui/src/lib/features/topology/store.ts | 30 +- ui/src/lib/features/topology/types/base.ts | 5 +- .../users/components/InviteCard.svelte | 2 +- .../components/data/DataControls.svelte | 7 +- .../shared/components/data/GenericCard.svelte | 28 +- .../feedback/BaseInlineFeedback.svelte | 61 ++++ .../components/feedback/InlineDanger.svelte | 32 +- .../components/feedback/InlineInfo.svelte | 32 +- .../components/feedback/InlineWarning.svelte | 32 +- .../selection/display/ServiceDisplay.svelte | 4 +- .../selection/display/TopologyDisplay.svelte | 15 +- ...VirtualizationManagerServiceDisplay.svelte | 4 +- .../shared/components/layout/Sidebar.svelte | 48 +-- ui/src/lib/shared/stores/metadata.ts | 2 + ui/src/lib/shared/utils/dataLoader.ts | 2 +- ui/src/routes/+page.svelte | 16 +- 109 files changed, 3025 insertions(+), 1430 deletions(-) create mode 100644 backend/src/server/hosts/subscriber.rs create mode 100644 backend/src/server/logging/mod.rs create mode 100644 backend/src/server/logging/service.rs create mode 100644 backend/src/server/logging/subscriber.rs create mode 100644 backend/src/server/shared/concepts.rs create mode 100644 ui/src/lib/shared/components/feedback/BaseInlineFeedback.svelte diff --git a/backend/Cargo.lock b/backend/Cargo.lock index d4ccb85c..ebf0c98f 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -482,6 +482,7 @@ dependencies = [ "bytes", "cookie", "futures-util", + "headers", "http", "http-body", "http-body-util", @@ -1884,6 +1885,30 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64 0.22.1", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + [[package]] name = "heck" version = "0.5.0" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 4a9d2fb5..1af1d853 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -131,7 +131,7 @@ argon2 = "0.5.3" password-hash = "0.5.0" lazy_static = "1.5.0" rand_core = "0.9.3" -axum-extra = {version = "0.10.3", features = ["cookie"]} +axum-extra = { version = "0.10.3", features = ["cookie", "typed-header"] } time = "0.3.44" tower-sessions-sqlx-store = { version = "0.15", features = ["postgres"] } secrecy = "0.10.3" diff --git a/backend/src/daemon/discovery/service/network.rs b/backend/src/daemon/discovery/service/network.rs index 8e3dc1bc..921d3f16 100644 --- a/backend/src/daemon/discovery/service/network.rs +++ b/backend/src/daemon/discovery/service/network.rs @@ -356,10 +356,10 @@ impl DiscoveryRunner { Ok((open_ports, endpoint_responses)) => { if !open_ports.is_empty() || !endpoint_responses.is_empty() { tracing::info!( - "Processing host {} with {} open ports and {} endpoint responses", - ip, - open_ports.len(), - endpoint_responses.len() + ip = %ip, + open_port_count = %open_ports.len(), + endpoint_response_count = %endpoint_responses.len(), + "Processing host", ); // Check cancellation before processing diff --git a/backend/src/daemon/discovery/types/base.rs b/backend/src/daemon/discovery/types/base.rs index 0cd393e3..a513e007 100644 --- a/backend/src/daemon/discovery/types/base.rs +++ b/backend/src/daemon/discovery/types/base.rs @@ -4,7 +4,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -#[derive(Debug, Clone, Serialize, Deserialize, Copy)] +#[derive(Debug, Clone, Serialize, Deserialize, Copy, PartialEq, Eq, Hash)] pub enum DiscoveryPhase { Pending, // Initial state, set by server; all subsequent states until Finished are set by Daemon Starting, diff --git a/backend/src/server/api_keys/impl/base.rs b/backend/src/server/api_keys/impl/base.rs index 5eb805ea..0fa01868 100644 --- a/backend/src/server/api_keys/impl/base.rs +++ b/backend/src/server/api_keys/impl/base.rs @@ -4,7 +4,9 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize, Serializer}; use uuid::Uuid; -#[derive(Debug, Clone, Serialize, Deserialize)] +use crate::server::shared::entities::ChangeTriggersTopologyStaleness; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] pub struct ApiKeyBase { #[serde(serialize_with = "serialize_api_key_status")] pub key: String, @@ -22,7 +24,7 @@ where serializer.serialize_str("***REDACTED***") } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] pub struct ApiKey { pub id: Uuid, pub updated_at: DateTime, @@ -36,3 +38,9 @@ impl Display for ApiKey { write!(f, "{}: {}", self.base.name, self.id) } } + +impl ChangeTriggersTopologyStaleness for ApiKey { + fn triggers_staleness(&self, _other: Option) -> bool { + false + } +} diff --git a/backend/src/server/api_keys/service.rs b/backend/src/server/api_keys/service.rs index 98ef9245..d8fc7518 100644 --- a/backend/src/server/api_keys/service.rs +++ b/backend/src/server/api_keys/service.rs @@ -8,12 +8,12 @@ use crate::server::{ api_keys::r#impl::base::{ApiKey, ApiKeyBase}, auth::middleware::AuthenticatedEntity, shared::{ - entities::Entity, + entities::ChangeTriggersTopologyStaleness, events::{ bus::EventBus, types::{EntityEvent, EntityOperation}, }, - services::traits::CrudService, + services::traits::{CrudService, EventBusService}, storage::{ generic::GenericPostgresStorage, traits::{StorableEntity, Storage}, @@ -26,25 +26,24 @@ pub struct ApiKeyService { event_bus: Arc, } -#[async_trait] -impl CrudService for ApiKeyService { - fn storage(&self) -> &Arc> { - &self.storage - } - +impl EventBusService for ApiKeyService { fn event_bus(&self) -> &Arc { &self.event_bus } - fn entity_type() -> Entity { - Entity::ApiKey - } fn get_network_id(&self, entity: &ApiKey) -> Option { Some(entity.base.network_id) } fn get_organization_id(&self, _entity: &ApiKey) -> Option { None } +} + +#[async_trait] +impl CrudService for ApiKeyService { + fn storage(&self) -> &Arc> { + &self.storage + } async fn create(&self, api_key: ApiKey, authentication: AuthenticatedEntity) -> Result { let key = self.generate_api_key(); @@ -65,28 +64,24 @@ impl CrudService for ApiKeyService { }); let created = self.storage.create(&api_key).await?; + let trigger_stale = created.triggers_staleness(None); self.event_bus() - .publish(EntityEvent { + .publish_entity(EntityEvent { id: Uuid::new_v4(), - entity_type: Self::entity_type(), + entity_type: created.clone().into(), entity_id: created.id(), network_id: self.get_network_id(&created), organization_id: self.get_organization_id(&created), operation: EntityOperation::Created, timestamp: Utc::now(), - metadata: serde_json::json!({}), + metadata: serde_json::json!({ + "trigger_stale": trigger_stale + }), authentication, }) .await?; - tracing::info!( - api_key_id = %created.id, - api_key_name = %created.base.name, - network_id = %created.base.network_id, - "API key created" - ); - Ok(created) } } @@ -117,12 +112,6 @@ impl ApiKeyService { let _updated = self.update(&mut api_key, authentication).await?; - tracing::info!( - api_key_id = %api_key_id, - api_key_name = %api_key.base.name, - "API key rotated successfully" - ); - Ok(new_key) } else { tracing::warn!( diff --git a/backend/src/server/auth/handlers.rs b/backend/src/server/auth/handlers.rs index 1c08df9d..1b89ec68 100644 --- a/backend/src/server/auth/handlers.rs +++ b/backend/src/server/auth/handlers.rs @@ -7,7 +7,6 @@ use crate::server::{ }, middleware::AuthenticatedUser, oidc::OidcPendingAuth, - service::hash_password, }, config::AppState, organizations::handlers::process_pending_invite, @@ -19,11 +18,12 @@ use crate::server::{ }; use axum::{ Router, - extract::{Query, State}, + extract::{ConnectInfo, Query, State}, response::{Json, Redirect}, routing::{get, post}, }; -use std::sync::Arc; +use axum_extra::{TypedHeader, headers::UserAgent}; +use std::{net::SocketAddr, sync::Arc}; use tower_sessions::Session; use url::Url; use uuid::Uuid; @@ -45,6 +45,8 @@ pub fn create_router() -> Router> { async fn register( State(state): State>, + ConnectInfo(addr): ConnectInfo, + user_agent: Option>, session: Session, Json(request): Json, ) -> ApiResult>> { @@ -52,6 +54,9 @@ async fn register( return Err(ApiError::forbidden("User registration is disabled")); } + let ip = addr.ip(); + let user_agent = user_agent.map(|u| u.to_string()); + let (org_id, permissions) = match process_pending_invite(&state, &session).await { Ok(Some((org_id, permissions))) => (Some(org_id), Some(permissions)), Ok(_) => (None, None), @@ -66,7 +71,7 @@ async fn register( let user = state .services .auth_service - .register(request, org_id, permissions) + .register(request, org_id, permissions, ip, user_agent) .await?; session @@ -79,10 +84,19 @@ async fn register( async fn login( State(state): State>, + ConnectInfo(addr): ConnectInfo, + user_agent: Option>, session: Session, Json(request): Json, ) -> ApiResult>> { - let user = state.services.auth_service.login(request).await?; + let ip = addr.ip(); + let user_agent = user_agent.map(|u| u.to_string()); + + let user = state + .services + .auth_service + .login(request, ip, user_agent) + .await?; session .insert("user_id", user.id) @@ -92,7 +106,23 @@ async fn login( Ok(Json(ApiResponse::success(user))) } -async fn logout(session: Session) -> ApiResult>> { +async fn logout( + State(state): State>, + ConnectInfo(addr): ConnectInfo, + user_agent: Option>, + session: Session, +) -> ApiResult>> { + if let Ok(Some(user_id)) = session.get::("user_id").await { + let ip = addr.ip(); + let user_agent = user_agent.map(|u| u.to_string()); + + state + .services + .auth_service + .logout(user_id, ip, user_agent) + .await?; + } + session .delete() .await @@ -124,6 +154,8 @@ async fn get_current_user( async fn update_password_auth( State(state): State>, session: Session, + ConnectInfo(addr): ConnectInfo, + user_agent: Option>, auth_user: AuthenticatedUser, Json(request): Json, ) -> ApiResult>> { @@ -133,25 +165,20 @@ async fn update_password_auth( .map_err(|e| ApiError::internal_error(&format!("Failed to read session: {}", e)))? .ok_or_else(|| ApiError::unauthorized("Not authenticated".to_string()))?; - let mut user = state - .services - .user_service - .get_by_id(&user_id) - .await? - .ok_or_else(|| ApiError::not_found("User not found".to_string()))?; - - if let Some(password) = request.password { - user.set_password(hash_password(&password)?); - } - - if let Some(email) = request.email { - user.base.email = email - } + let ip = addr.ip(); + let user_agent = user_agent.map(|u| u.to_string()); - state + let user = state .services - .user_service - .update(&mut user, auth_user.into()) + .auth_service + .update_password( + user_id, + request.password, + request.email, + ip, + user_agent, + auth_user, + ) .await?; Ok(Json(ApiResponse::success(user))) @@ -159,12 +186,22 @@ async fn update_password_auth( async fn forgot_password( State(state): State>, + ConnectInfo(addr): ConnectInfo, + user_agent: Option>, Json(request): Json, ) -> ApiResult>> { + let ip = addr.ip(); + let user_agent = user_agent.map(|u| u.to_string()); + state .services .auth_service - .initiate_password_reset(&request.email, state.config.public_url.clone()) + .initiate_password_reset( + &request.email, + state.config.public_url.clone(), + ip, + user_agent, + ) .await?; Ok(Json(ApiResponse::success(()))) @@ -172,13 +209,18 @@ async fn forgot_password( async fn reset_password( State(state): State>, + ConnectInfo(addr): ConnectInfo, + user_agent: Option>, session: Session, Json(request): Json, ) -> ApiResult>> { + let ip = addr.ip(); + let user_agent = user_agent.map(|u| u.to_string()); + let user = state .services .auth_service - .complete_password_reset(&request.token, &request.password) + .complete_password_reset(&request.token, &request.password, ip, user_agent) .await?; session @@ -230,8 +272,13 @@ async fn oidc_authorize( async fn oidc_callback( State(state): State>, session: Session, + ConnectInfo(addr): ConnectInfo, + user_agent: Option>, Query(params): Query, ) -> Result { + let ip = addr.ip(); + let user_agent = user_agent.map(|u| u.to_string()); + let oidc_service = match state.services.oidc_service.as_ref() { Some(service) => service, None => { @@ -304,7 +351,7 @@ async fn oidc_callback( })?; match oidc_service - .link_to_user(&user_id, ¶ms.code, pending_auth) + .link_to_user(&user_id, ¶ms.code, pending_auth, ip, user_agent) .await { Ok(_) => { @@ -341,7 +388,14 @@ async fn oidc_callback( }; match oidc_service - .login_or_register(¶ms.code, pending_auth, org_id, permissions) + .login_or_register( + ¶ms.code, + pending_auth, + org_id, + permissions, + ip, + user_agent, + ) .await { Ok(user) => { @@ -376,7 +430,12 @@ async fn oidc_callback( async fn unlink_oidc_account( State(state): State>, session: Session, + ConnectInfo(addr): ConnectInfo, + user_agent: Option>, ) -> ApiResult>> { + let ip = addr.ip(); + let user_agent = user_agent.map(|u| u.to_string()); + let oidc_service = state .services .oidc_service @@ -390,7 +449,7 @@ async fn unlink_oidc_account( .ok_or_else(|| ApiError::unauthorized("Not authenticated".to_string()))?; let updated_user = oidc_service - .unlink_from_user(&user_id) + .unlink_from_user(&user_id, ip, user_agent) .await .map_err(|e| ApiError::internal_error(&format!("Failed to unlink OIDC: {}", e)))?; diff --git a/backend/src/server/auth/middleware.rs b/backend/src/server/auth/middleware.rs index d8962349..ae5f983b 100644 --- a/backend/src/server/auth/middleware.rs +++ b/backend/src/server/auth/middleware.rs @@ -3,7 +3,7 @@ use crate::server::{ config::AppState, organizations::r#impl::base::Organization, shared::{services::traits::CrudService, storage::filter::EntityFilter, types::api::ApiError}, - users::r#impl::permissions::UserOrgPermissions, + users::r#impl::{base::User, permissions::UserOrgPermissions}, }; use axum::{ extract::FromRequestParts, @@ -38,6 +38,7 @@ pub enum AuthenticatedEntity { api_key_id: Uuid, }, // network_id System, + Anonymous, } impl AuthenticatedEntity { @@ -60,6 +61,7 @@ impl AuthenticatedEntity { network_id, api_key_id ), AuthenticatedEntity::System => "System".to_string(), + AuthenticatedEntity::Anonymous => "Anonymous".to_string(), } } @@ -69,6 +71,7 @@ impl AuthenticatedEntity { AuthenticatedEntity::Daemon { network_id, .. } => vec![*network_id], AuthenticatedEntity::User { network_ids, .. } => network_ids.clone(), AuthenticatedEntity::System => vec![], + AuthenticatedEntity::Anonymous => vec![], } } @@ -83,6 +86,17 @@ impl AuthenticatedEntity { } } +impl From for AuthenticatedEntity { + fn from(value: User) -> Self { + AuthenticatedEntity::User { + user_id: value.id, + organization_id: value.base.organization_id, + permissions: value.base.permissions, + network_ids: vec![], + } + } +} + // Generic authenticated entity extractor - accepts both users and daemons impl FromRequestParts for AuthenticatedEntity where diff --git a/backend/src/server/auth/oidc.rs b/backend/src/server/auth/oidc.rs index 84ed3b43..0a5a1bb4 100644 --- a/backend/src/server/auth/oidc.rs +++ b/backend/src/server/auth/oidc.rs @@ -1,4 +1,5 @@ use anyhow::{Error, Result, anyhow}; +use chrono::Utc; use email_address::EmailAddress; use openidconnect::{ AuthenticationFlow, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce, @@ -7,12 +8,22 @@ use openidconnect::{ reqwest::Client as ReqwestClient, }; use serde::{Deserialize, Serialize}; -use std::{str::FromStr, sync::Arc}; +use std::{net::IpAddr, str::FromStr, sync::Arc}; use uuid::Uuid; use crate::server::{ - auth::service::AuthService, - users::r#impl::{base::User, permissions::UserOrgPermissions}, + auth::{middleware::AuthenticatedEntity, service::AuthService}, + shared::{ + events::{ + bus::EventBus, + types::{AuthEvent, AuthOperation}, + }, + services::traits::CrudService, + }, + users::{ + r#impl::{base::User, permissions::UserOrgPermissions}, + service::UserService, + }, }; #[derive(Debug, Serialize, Deserialize)] @@ -32,31 +43,19 @@ pub struct OidcPendingAuth { #[derive(Clone)] pub struct OidcService { - issuer_url: String, - client_id: String, - client_secret: String, - redirect_url: String, - provider_name: String, - auth_service: Arc, + pub issuer_url: String, + pub client_id: String, + pub client_secret: String, + pub redirect_url: String, + pub provider_name: String, + pub auth_service: Arc, + pub user_service: Arc, + pub event_bus: Arc, } impl OidcService { - pub fn new( - issuer_url: String, - client_id: String, - client_secret: String, - redirect_url: String, - provider_name: String, - auth_service: Arc, - ) -> Self { - Self { - issuer_url, - client_id, - client_secret, - redirect_url, - provider_name, - auth_service, - } + pub fn new(params: OidcService) -> Self { + params } /// Generate authorization URL for user to visit @@ -155,6 +154,8 @@ impl OidcService { user_id: &Uuid, code: &str, pending_auth: OidcPendingAuth, + ip: IpAddr, + user_agent: Option, ) -> Result { let user_info = self.exchange_code(code, pending_auth).await?; @@ -174,11 +175,36 @@ impl OidcService { return Ok(existing_user); } - // Link OIDC to current user - self.auth_service + let mut user = self .user_service - .link_oidc(user_id, user_info.subject, self.provider_name.clone()) - .await + .get_by_id(user_id) + .await? + .ok_or_else(|| anyhow::anyhow!("User not found"))?; + + user.base.oidc_provider = Some(self.provider_name.clone()); + user.base.oidc_subject = Some(user_info.subject); + user.base.oidc_linked_at = Some(chrono::Utc::now()); + + let authentication: AuthenticatedEntity = user.clone().into(); + + self.event_bus + .publish_auth(AuthEvent { + id: Uuid::new_v4(), + user_id: Some(user.id), + organization_id: Some(user.base.organization_id), + timestamp: Utc::now(), + operation: AuthOperation::OidcLinked, + ip_address: ip, + user_agent, + metadata: serde_json::json!({ + "method": "oidc", + "provider": self.provider_name + }), + authentication: authentication.clone(), + }) + .await?; + + self.user_service.update(&mut user, authentication).await } /// Login or register user via OIDC @@ -188,16 +214,35 @@ impl OidcService { pending_auth: OidcPendingAuth, org_id: Option, permissions: Option, + ip: IpAddr, + user_agent: Option, ) -> Result { let user_info = self.exchange_code(code, pending_auth).await?; - // Check if user exists with this OIDC account + // Check if user exists with this OIDC account, login if so if let Some(user) = self .auth_service .user_service .get_user_by_oidc(&user_info.subject) .await? { + self.event_bus + .publish_auth(AuthEvent { + id: Uuid::new_v4(), + user_id: Some(user.id), + organization_id: Some(user.base.organization_id), + timestamp: Utc::now(), + operation: AuthOperation::LoginSuccess, + ip_address: ip, + user_agent, + metadata: serde_json::json!({ + "method": "oidc", + "provider": self.provider_name + }), + authentication: user.clone().into(), + }) + .await?; + return Ok(user); } @@ -212,20 +257,83 @@ impl OidcService { Ok::(EmailAddress::new_unchecked(fallback_email_str)) })?; - // Register new user via OIDC - self.auth_service - .register_with_oidc( + // Register new user + let user = self + .auth_service + .provision_user( email, - user_info.subject, - self.provider_name.clone(), + None, + Some(user_info.subject), + Some(self.provider_name.clone()), org_id, permissions, ) - .await + .await?; + + self.event_bus + .publish_auth(AuthEvent { + id: Uuid::new_v4(), + user_id: Some(user.id), + organization_id: Some(user.base.organization_id), + timestamp: Utc::now(), + operation: AuthOperation::Register, + ip_address: ip, + user_agent, + metadata: serde_json::json!({ + "method": "oidc", + "provider": self.provider_name + }), + authentication: user.clone().into(), + }) + .await?; + + Ok(user) } /// Unlink OIDC from user - pub async fn unlink_from_user(&self, user_id: &Uuid) -> Result { - self.auth_service.user_service.unlink_oidc(user_id).await + pub async fn unlink_from_user( + &self, + user_id: &Uuid, + ip: IpAddr, + user_agent: Option, + ) -> Result { + let mut user = self + .user_service + .get_by_id(user_id) + .await? + .ok_or_else(|| anyhow::anyhow!("User not found"))?; + + // Require password before unlinking + if user.base.password_hash.is_none() { + return Err(anyhow::anyhow!( + "Cannot unlink OIDC - no password set. Set a password first." + )); + } + + user.base.oidc_provider = None; + user.base.oidc_subject = None; + user.base.oidc_linked_at = None; + user.updated_at = chrono::Utc::now(); + + let authentication: AuthenticatedEntity = user.clone().into(); + + self.event_bus + .publish_auth(AuthEvent { + id: Uuid::new_v4(), + user_id: Some(user.id), + organization_id: Some(user.base.organization_id), + timestamp: Utc::now(), + operation: AuthOperation::OidcUnlinked, + ip_address: ip, + user_agent, + metadata: serde_json::json!({ + "method": "oidc", + "provider": self.provider_name + }), + authentication: authentication.clone(), + }) + .await?; + + self.user_service.update(&mut user, authentication).await } } diff --git a/backend/src/server/auth/service.rs b/backend/src/server/auth/service.rs index 57e96313..2bc1ce2b 100644 --- a/backend/src/server/auth/service.rs +++ b/backend/src/server/auth/service.rs @@ -1,7 +1,7 @@ use crate::server::{ auth::{ r#impl::api::{LoginRequest, RegisterRequest}, - middleware::AuthenticatedEntity, + middleware::{AuthenticatedEntity, AuthenticatedUser}, }, email::service::EmailService, organizations::{ @@ -9,6 +9,10 @@ use crate::server::{ service::OrganizationService, }, shared::{ + events::{ + bus::EventBus, + types::{AuthEvent, AuthOperation}, + }, services::traits::CrudService, storage::{filter::EntityFilter, traits::StorableEntity}, }, @@ -25,8 +29,9 @@ use argon2::{ Argon2, password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng}, }; +use chrono::Utc; use email_address::EmailAddress; -use std::{collections::HashMap, sync::Arc, time::Instant}; +use std::{collections::HashMap, net::IpAddr, sync::Arc, time::Instant}; use tokio::sync::RwLock; use uuid::Uuid; use validator::Validate; @@ -37,6 +42,7 @@ pub struct AuthService { email_service: Option>, login_attempts: Arc>>, password_reset_tokens: Arc>>, + event_bus: Arc, } impl AuthService { @@ -47,6 +53,7 @@ impl AuthService { user_service: Arc, organization_service: Arc, email_service: Option>, + event_bus: Arc, ) -> Self { Self { user_service, @@ -54,6 +61,7 @@ impl AuthService { email_service, login_attempts: Arc::new(RwLock::new(HashMap::new())), password_reset_tokens: Arc::new(RwLock::new(HashMap::new())), + event_bus, } } @@ -63,6 +71,8 @@ impl AuthService { request: RegisterRequest, org_id: Option, permissions: Option, + ip: IpAddr, + user_agent: Option, ) -> Result { request .validate() @@ -79,40 +89,38 @@ impl AuthService { } // Provision user with password - self.provision_user( - request.email, - Some(hash_password(&request.password)?), - None, - None, - org_id, - permissions, - ) - .await - } + let user = self + .provision_user( + request.email, + Some(hash_password(&request.password)?), + None, + None, + org_id, + permissions, + ) + .await?; - /// Register a new user with OIDC - pub async fn register_with_oidc( - &self, - email: EmailAddress, - oidc_subject: String, - oidc_provider: String, - org_id: Option, - permissions: Option, - ) -> Result { - // Provision user with OIDC - self.provision_user( - email, - None, - Some(oidc_subject), - Some(oidc_provider), - org_id, - permissions, - ) - .await + self.event_bus + .publish_auth(AuthEvent { + id: Uuid::new_v4(), + user_id: Some(user.id), + organization_id: Some(user.base.organization_id), + timestamp: Utc::now(), + operation: AuthOperation::Register, + ip_address: ip, + user_agent, + metadata: serde_json::json!({ + "method": "password", + }), + authentication: user.clone().into(), + }) + .await?; + + Ok(user) } /// Core user provisioning logic - handles both password and OIDC registration - async fn provision_user( + pub async fn provision_user( &self, email: EmailAddress, password_hash: Option, @@ -208,7 +216,12 @@ impl AuthService { } /// Login with username and password - pub async fn login(&self, request: LoginRequest) -> Result { + pub async fn login( + &self, + request: LoginRequest, + ip: IpAddr, + user_agent: Option, + ) -> Result { request .validate() .map_err(|e| anyhow!("Validation failed: {}", e))?; @@ -224,11 +237,45 @@ impl AuthService { Ok(user) => { // Success - clear attempts self.login_attempts.write().await.remove(&request.email); - tracing::info!("User {} logged in successfully", user.id); + + self.event_bus + .publish_auth(AuthEvent { + id: Uuid::new_v4(), + user_id: Some(user.id), + organization_id: Some(user.base.organization_id), + timestamp: Utc::now(), + operation: AuthOperation::LoginSuccess, + ip_address: ip, + user_agent, + metadata: serde_json::json!({ + "method": "password", + }), + authentication: user.clone().into(), + }) + .await?; + Ok(user) } Err(e) => { // Failure - increment attempts + + self.event_bus + .publish_auth(AuthEvent { + id: Uuid::new_v4(), + user_id: None, + organization_id: None, + timestamp: Utc::now(), + operation: AuthOperation::LoginFailed, + ip_address: ip, + user_agent, + metadata: serde_json::json!({ + "method": "password", + "email": request.email + }), + authentication: AuthenticatedEntity::Anonymous, + }) + .await?; + let mut attempts = self.login_attempts.write().await; let entry = attempts .entry(request.email.clone()) @@ -265,6 +312,7 @@ impl AuthService { .user_service .get_all(EntityFilter::unfiltered()) .await?; + let user = all_users .iter() .find(|u| u.base.email == request.email) @@ -283,8 +331,56 @@ impl AuthService { Ok(user.clone()) } + pub async fn update_password( + &self, + user_id: Uuid, + password: Option, + email: Option, + ip: IpAddr, + user_agent: Option, + authentication: AuthenticatedUser, + ) -> Result { + let mut user = self + .user_service + .get_by_id(&user_id) + .await? + .ok_or_else(|| anyhow::anyhow!("User not found".to_string()))?; + + if let Some(password) = password { + user.set_password(hash_password(&password)?); + } + + if let Some(email) = email { + user.base.email = email + } + + self.event_bus + .publish_auth(AuthEvent { + id: Uuid::new_v4(), + user_id: Some(user.id), + organization_id: Some(user.base.organization_id), + timestamp: Utc::now(), + operation: AuthOperation::PasswordChanged, + ip_address: ip, + user_agent, + metadata: serde_json::json!({}), + authentication: authentication.clone().into(), + }) + .await?; + + self.user_service + .update(&mut user, authentication.into()) + .await + } + /// Initiate password reset process - generates a token - pub async fn initiate_password_reset(&self, email: &EmailAddress, url: String) -> Result<()> { + pub async fn initiate_password_reset( + &self, + email: &EmailAddress, + url: String, + ip: IpAddr, + user_agent: Option, + ) -> Result<()> { let email_service = self .email_service .as_ref() @@ -306,6 +402,20 @@ impl AuthService { } }; + self.event_bus + .publish_auth(AuthEvent { + id: Uuid::new_v4(), + user_id: Some(user.id), + organization_id: Some(user.base.organization_id), + timestamp: Utc::now(), + operation: AuthOperation::PasswordResetRequested, + ip_address: ip, + user_agent, + metadata: serde_json::json!({}), + authentication: AuthenticatedEntity::Anonymous, + }) + .await?; + let token = Uuid::new_v4().to_string(); let mut tokens = self.password_reset_tokens.write().await; tokens.insert(token.clone(), (user.id, Instant::now())); @@ -325,7 +435,13 @@ impl AuthService { } /// Reset password using token - pub async fn complete_password_reset(&self, token: &str, new_password: &str) -> Result { + pub async fn complete_password_reset( + &self, + token: &str, + new_password: &str, + ip: IpAddr, + user_agent: Option, + ) -> Result { let mut tokens = self.password_reset_tokens.write().await; let (user_id, created_at) = tokens .remove(token) @@ -343,6 +459,20 @@ impl AuthService { .await? .ok_or_else(|| anyhow!("User not found"))?; + self.event_bus + .publish_auth(AuthEvent { + id: Uuid::new_v4(), + user_id: Some(user.id), + organization_id: Some(user.base.organization_id), + timestamp: Utc::now(), + operation: AuthOperation::PasswordResetCompleted, + ip_address: ip, + user_agent, + metadata: serde_json::json!({}), + authentication: user.clone().into(), + }) + .await?; + // Update password let hashed_password = hash_password(new_password)?; user.set_password(hashed_password); @@ -353,6 +483,31 @@ impl AuthService { Ok(user.clone()) } + pub async fn logout( + &self, + user_id: Uuid, + ip: IpAddr, + user_agent: Option, + ) -> Result<()> { + if let Ok(Some(user)) = self.user_service.get_by_id(&user_id).await { + self.event_bus + .publish_auth(AuthEvent { + id: Uuid::new_v4(), + user_id: Some(user.id), + organization_id: Some(user.base.organization_id), + timestamp: Utc::now(), + operation: AuthOperation::LoggedOut, + ip_address: ip, + user_agent, + metadata: serde_json::json!({}), + authentication: user.into(), + }) + .await?; + } + + Ok(()) + } + /// Cleanup old login attempts (called periodically from background task) pub async fn cleanup_old_login_attempts(&self) { let mut attempts = self.login_attempts.write().await; diff --git a/backend/src/server/billing/service.rs b/backend/src/server/billing/service.rs index 8ea1994f..1bb47208 100644 --- a/backend/src/server/billing/service.rs +++ b/backend/src/server/billing/service.rs @@ -411,7 +411,7 @@ impl BillingService { BillingPlan::Community { .. } => {} } - organization.base.plan_status = Some(sub.status); + organization.base.plan_status = Some(sub.status.to_string()); organization.base.plan = Some(plan); self.organization_service @@ -443,7 +443,7 @@ impl BillingService { .revoke_org_invites(&organization.id) .await?; - organization.base.plan_status = Some(SubscriptionStatus::Canceled); + organization.base.plan_status = Some(SubscriptionStatus::Canceled.to_string()); self.organization_service .update(&mut organization, AuthenticatedEntity::System) diff --git a/backend/src/server/billing/types/base.rs b/backend/src/server/billing/types/base.rs index e7f8022d..c18605d1 100644 --- a/backend/src/server/billing/types/base.rs +++ b/backend/src/server/billing/types/base.rs @@ -1,12 +1,21 @@ +use crate::server::shared::types::metadata::{EntityMetadataProvider, HasId, TypeMetadataProvider}; use serde::{Deserialize, Serialize}; use std::fmt::Display; +use std::hash::Hash; use stripe_product::price::CreatePriceRecurringInterval; use strum::{Display, EnumDiscriminants, EnumIter, IntoStaticStr}; -use crate::server::shared::types::metadata::{EntityMetadataProvider, HasId, TypeMetadataProvider}; - #[derive( - Debug, Clone, Copy, Serialize, Deserialize, Display, IntoStaticStr, EnumIter, EnumDiscriminants, + Debug, + Clone, + Copy, + Serialize, + Deserialize, + Display, + IntoStaticStr, + EnumIter, + EnumDiscriminants, + Eq, )] #[serde(tag = "type")] pub enum BillingPlan { @@ -22,6 +31,13 @@ impl PartialEq for BillingPlan { } } +impl Hash for BillingPlan { + fn hash(&self, state: &mut H) { + self.price().hash(state); + self.trial_days().hash(state); + } +} + impl Default for BillingPlan { fn default() -> Self { BillingPlan::Community { @@ -34,12 +50,19 @@ impl Default for BillingPlan { } } -#[derive(Debug, Clone, Serialize, Deserialize, Default, Copy)] +#[derive(Debug, Clone, Serialize, Deserialize, Default, Copy, Eq)] pub struct Price { pub cents: i64, pub rate: BillingRate, } +impl Hash for Price { + fn hash(&self, state: &mut H) { + self.cents.hash(state); + self.rate.hash(state); + } +} + impl PartialEq for Price { fn eq(&self, other: &Self) -> bool { self.cents == other.cents && self.rate == other.rate @@ -61,7 +84,7 @@ impl Display for Price { } } -#[derive(Debug, Clone, Serialize, Deserialize, Display, Default, Copy, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, Display, Default, Copy, PartialEq, Eq, Hash)] pub enum BillingRate { #[default] Month, diff --git a/backend/src/server/daemons/handlers.rs b/backend/src/server/daemons/handlers.rs index 99a01802..19c85d33 100644 --- a/backend/src/server/daemons/handlers.rs +++ b/backend/src/server/daemons/handlers.rs @@ -229,7 +229,7 @@ async fn receive_work_request( daemon.base.last_seen = Utc::now(); service - .update(&mut daemon, auth_daemon.into()) + .update(&mut daemon, auth_daemon.clone().into()) .await .map_err(|e| ApiError::internal_error(&format!("Failed to update heartbeat: {}", e)))?; @@ -238,14 +238,23 @@ async fn receive_work_request( .discovery_service .get_sessions_for_daemon(&daemon_id) .await; - let cancel = state + let (cancel, session_id_to_cancel) = state .services .discovery_service .pull_cancellation_for_daemon(&daemon_id) .await; - Ok(Json(ApiResponse::success(( - sessions.first().cloned(), - cancel, - )))) + let next_session = sessions.first().cloned(); + + service + .receive_work_request( + daemon, + cancel, + session_id_to_cancel, + next_session.clone(), + auth_daemon.into(), + ) + .await?; + + Ok(Json(ApiResponse::success((next_session, cancel)))) } diff --git a/backend/src/server/daemons/impl/api.rs b/backend/src/server/daemons/impl/api.rs index c149ea1b..38dccec4 100644 --- a/backend/src/server/daemons/impl/api.rs +++ b/backend/src/server/daemons/impl/api.rs @@ -14,7 +14,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; /// Daemon registration request from daemon to server -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] pub struct DaemonCapabilities { #[serde(default)] pub has_docker_socket: bool, @@ -73,7 +73,7 @@ pub struct DaemonDiscoveryResponse { } /// Progress update from daemon to server during discovery -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] pub struct DiscoveryUpdatePayload { pub session_id: Uuid, pub daemon_id: Uuid, diff --git a/backend/src/server/daemons/impl/base.rs b/backend/src/server/daemons/impl/base.rs index 72a1374e..507032c3 100644 --- a/backend/src/server/daemons/impl/base.rs +++ b/backend/src/server/daemons/impl/base.rs @@ -6,9 +6,11 @@ use serde::{Deserialize, Serialize}; use strum::Display; use uuid::Uuid; -use crate::server::daemons::r#impl::api::DaemonCapabilities; +use crate::server::{ + daemons::r#impl::api::DaemonCapabilities, shared::entities::ChangeTriggersTopologyStaleness, +}; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] pub struct DaemonBase { pub host_id: Uuid, pub network_id: Uuid, @@ -20,7 +22,7 @@ pub struct DaemonBase { pub mode: DaemonMode, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] pub struct Daemon { pub id: Uuid, pub updated_at: DateTime, @@ -36,10 +38,16 @@ impl Display for Daemon { } #[derive( - Debug, Display, Copy, Clone, Serialize, Deserialize, Default, PartialEq, Eq, ValueEnum, + Debug, Display, Copy, Clone, Serialize, Deserialize, Default, PartialEq, Eq, ValueEnum, Hash, )] pub enum DaemonMode { #[default] Push, Pull, } + +impl ChangeTriggersTopologyStaleness for Daemon { + fn triggers_staleness(&self, _other: Option) -> bool { + false + } +} diff --git a/backend/src/server/daemons/service.rs b/backend/src/server/daemons/service.rs index ac4c1889..e51ed59c 100644 --- a/backend/src/server/daemons/service.rs +++ b/backend/src/server/daemons/service.rs @@ -3,18 +3,17 @@ use crate::{ server::{ auth::middleware::AuthenticatedEntity, daemons::r#impl::{ - api::{DaemonDiscoveryRequest, DaemonDiscoveryResponse}, + api::{DaemonDiscoveryRequest, DaemonDiscoveryResponse, DiscoveryUpdatePayload}, base::Daemon, }, hosts::r#impl::ports::PortBase, services::r#impl::endpoints::{ApplicationProtocol, Endpoint}, shared::{ - entities::Entity, events::{ bus::EventBus, types::{EntityEvent, EntityOperation}, }, - services::traits::CrudService, + services::traits::{CrudService, EventBusService}, storage::generic::GenericPostgresStorage, types::api::ApiResponse, }, @@ -32,19 +31,11 @@ pub struct DaemonService { event_bus: Arc, } -#[async_trait] -impl CrudService for DaemonService { - fn storage(&self) -> &Arc> { - &self.daemon_storage - } - +impl EventBusService for DaemonService { fn event_bus(&self) -> &Arc { &self.event_bus } - fn entity_type() -> Entity { - Entity::Daemon - } fn get_network_id(&self, entity: &Daemon) -> Option { Some(entity.base.network_id) } @@ -53,6 +44,13 @@ impl CrudService for DaemonService { } } +#[async_trait] +impl CrudService for DaemonService { + fn storage(&self) -> &Arc> { + &self.daemon_storage + } +} + impl DaemonService { pub fn new( daemon_storage: Arc>, @@ -111,30 +109,27 @@ impl DaemonService { let daemon_ref = &daemon; self.event_bus() - .publish(EntityEvent { + .publish_entity(EntityEvent { id: Uuid::new_v4(), - entity_type: Self::entity_type(), entity_id: *daemon_id, network_id: self.get_network_id(daemon_ref), organization_id: self.get_organization_id(daemon_ref), - operation: EntityOperation::Custom("discovery_request_sent"), + entity_type: daemon.into(), + operation: EntityOperation::DiscoveryStarted, timestamp: Utc::now(), - metadata: serde_json::json!({}), + metadata: serde_json::json!({ + "session_id": request.session_id + }), authentication, }) .await?; - tracing::info!( - "Discovery request sent to daemon {} for session {}", - daemon.id, - request.session_id - ); Ok(()) } pub async fn send_discovery_cancellation( &self, - daemon: &Daemon, + daemon: Daemon, session_id: Uuid, authentication: AuthenticatedEntity, ) -> Result<(), anyhow::Error> { @@ -161,13 +156,13 @@ impl DaemonService { } self.event_bus() - .publish(EntityEvent { + .publish_entity(EntityEvent { id: Uuid::new_v4(), - entity_type: Self::entity_type(), entity_id: daemon.id, - network_id: self.get_network_id(daemon), - organization_id: self.get_organization_id(daemon), - operation: EntityOperation::Custom("discovery_cancellation_sent"), + network_id: self.get_network_id(&daemon), + organization_id: self.get_organization_id(&daemon), + entity_type: daemon.into(), + operation: EntityOperation::DiscoveryCancelled, timestamp: Utc::now(), metadata: serde_json::json!({ "session_id": session_id @@ -179,6 +174,53 @@ impl DaemonService { Ok(()) } + pub async fn receive_work_request( + &self, + daemon: Daemon, + cancel: bool, + cancellation_session_id: Uuid, + next_session: Option, + authentication: AuthenticatedEntity, + ) -> Result<(), Error> { + if cancel { + self.event_bus() + .publish_entity(EntityEvent { + id: Uuid::new_v4(), + entity_id: daemon.id, + network_id: self.get_network_id(&daemon), + organization_id: self.get_organization_id(&daemon), + entity_type: daemon.clone().into(), + operation: EntityOperation::DiscoveryCancelled, + timestamp: Utc::now(), + metadata: serde_json::json!({ + "session_id": cancellation_session_id + }), + authentication: authentication.clone(), + }) + .await?; + } + + if let Some(session) = next_session { + self.event_bus() + .publish_entity(EntityEvent { + id: Uuid::new_v4(), + entity_id: daemon.id, + network_id: self.get_network_id(&daemon), + organization_id: self.get_organization_id(&daemon), + entity_type: daemon.into(), + operation: EntityOperation::DiscoveryStarted, + timestamp: Utc::now(), + metadata: serde_json::json!({ + "session_id": session.session_id + }), + authentication, + }) + .await?; + } + + Ok(()) + } + pub async fn initialize_local_daemon( &self, daemon_url: String, diff --git a/backend/src/server/discovery/impl/base.rs b/backend/src/server/discovery/impl/base.rs index 782e16cf..c540210e 100644 --- a/backend/src/server/discovery/impl/base.rs +++ b/backend/src/server/discovery/impl/base.rs @@ -4,9 +4,12 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::server::discovery::r#impl::types::{DiscoveryType, RunType}; +use crate::server::{ + discovery::r#impl::types::{DiscoveryType, RunType}, + shared::entities::ChangeTriggersTopologyStaleness, +}; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Hash, PartialEq, Eq)] pub struct DiscoveryBase { pub discovery_type: DiscoveryType, pub run_type: RunType, @@ -15,7 +18,7 @@ pub struct DiscoveryBase { pub network_id: Uuid, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] pub struct Discovery { pub id: Uuid, pub created_at: DateTime, @@ -41,3 +44,9 @@ impl Display for Discovery { write!(f, "Discovery {}: {}", self.base.name, self.id) } } + +impl ChangeTriggersTopologyStaleness for Discovery { + fn triggers_staleness(&self, _other: Option) -> bool { + false + } +} diff --git a/backend/src/server/discovery/impl/types.rs b/backend/src/server/discovery/impl/types.rs index 342e5fb5..bb3d38e1 100644 --- a/backend/src/server/discovery/impl/types.rs +++ b/backend/src/server/discovery/impl/types.rs @@ -4,12 +4,10 @@ use serde::Serialize; use strum::{Display, EnumDiscriminants, EnumIter, IntoStaticStr}; use uuid::Uuid; +use crate::server::shared::entities::EntityDiscriminants; use crate::server::{ daemons::r#impl::api::DiscoveryUpdatePayload, - shared::{ - entities::Entity, - types::metadata::{EntityMetadataProvider, HasId, TypeMetadataProvider}, - }, + shared::types::metadata::{EntityMetadataProvider, HasId, TypeMetadataProvider}, }; #[derive( @@ -50,7 +48,7 @@ pub enum HostNamingFallback { BestService, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] #[serde(tag = "type")] pub enum RunType { Scheduled { @@ -74,11 +72,11 @@ impl HasId for DiscoveryType { impl EntityMetadataProvider for DiscoveryType { fn color(&self) -> &'static str { - Entity::Discovery.color() + EntityDiscriminants::Discovery.color() } fn icon(&self) -> &'static str { - Entity::Discovery.icon() + EntityDiscriminants::Discovery.icon() } } diff --git a/backend/src/server/discovery/service.rs b/backend/src/server/discovery/service.rs index 0322a84b..3703d5ab 100644 --- a/backend/src/server/discovery/service.rs +++ b/backend/src/server/discovery/service.rs @@ -1,10 +1,10 @@ use crate::server::auth::middleware::AuthenticatedEntity; use crate::server::daemons::r#impl::base::DaemonMode; use crate::server::discovery::r#impl::types::RunType; -use crate::server::shared::entities::Entity; +use crate::server::shared::entities::ChangeTriggersTopologyStaleness; use crate::server::shared::events::bus::EventBus; use crate::server::shared::events::types::{EntityEvent, EntityOperation}; -use crate::server::shared::services::traits::CrudService; +use crate::server::shared::services::traits::{CrudService, EventBusService}; use crate::server::shared::storage::filter::EntityFilter; use crate::server::shared::storage::generic::GenericPostgresStorage; use crate::server::shared::storage::traits::{StorableEntity, Storage}; @@ -32,25 +32,17 @@ pub struct DiscoveryService { daemon_service: Arc, sessions: RwLock>, // session_id -> session state mapping daemon_sessions: RwLock>>, // daemon_id -> session_id mapping - daemon_pull_cancellations: RwLock>, // daemon_id -> boolean mapping for pull mode cancellations of current session on daemon + daemon_pull_cancellations: RwLock>, // daemon_id -> (boolean, session_id) mapping for pull mode cancellations of current session on daemon update_tx: broadcast::Sender, scheduler: Option>>, event_bus: Arc, } -#[async_trait] -impl CrudService for DiscoveryService { - fn storage(&self) -> &Arc> { - &self.discovery_storage - } - +impl EventBusService for DiscoveryService { fn event_bus(&self) -> &Arc { &self.event_bus } - fn entity_type() -> Entity { - Entity::Discovery - } fn get_network_id(&self, entity: &Discovery) -> Option { Some(entity.base.network_id) } @@ -59,6 +51,13 @@ impl CrudService for DiscoveryService { } } +#[async_trait] +impl CrudService for DiscoveryService { + fn storage(&self) -> &Arc> { + &self.discovery_storage + } +} + impl DiscoveryService { pub async fn new( discovery_storage: Arc>, @@ -116,9 +115,11 @@ impl DiscoveryService { .collect() } - pub async fn pull_cancellation_for_daemon(&self, daemon_id: &Uuid) -> bool { + pub async fn pull_cancellation_for_daemon(&self, daemon_id: &Uuid) -> (bool, Uuid) { let mut daemon_cancellation_ids = self.daemon_pull_cancellations.write().await; - daemon_cancellation_ids.remove(daemon_id).unwrap_or(false) + daemon_cancellation_ids + .remove(daemon_id) + .unwrap_or((false, Uuid::nil())) } /// Create a new scheduled discovery @@ -155,25 +156,24 @@ impl DiscoveryService { return Ok(disabled_discovery); } + let trigger_stale = created_discovery.triggers_staleness(None); + self.event_bus() - .publish(EntityEvent { + .publish_entity(EntityEvent { id: Uuid::new_v4(), - entity_type: Self::entity_type(), entity_id: created_discovery.id(), network_id: self.get_network_id(&created_discovery), organization_id: self.get_organization_id(&created_discovery), + entity_type: created_discovery.clone().into(), operation: EntityOperation::Created, timestamp: Utc::now(), - metadata: serde_json::json!({}), + metadata: serde_json::json!({ + "trigger_stale": trigger_stale + }), authentication, }) .await?; - tracing::info!( - "Created discovery {}: {}", - created_discovery.base.name, - created_discovery.id - ); Ok(created_discovery) } @@ -183,10 +183,13 @@ impl DiscoveryService { mut discovery: Discovery, authentication: AuthenticatedEntity, ) -> Result { - discovery.updated_at = Utc::now(); + let current = self + .get_by_id(&discovery.id) + .await? + .ok_or_else(|| anyhow::anyhow!("Could not find discovery {}", discovery))?; // If it's a scheduled discovery, need to reschedule - if matches!(discovery.base.run_type, RunType::Scheduled { .. }) { + let updated = if matches!(discovery.base.run_type, RunType::Scheduled { .. }) { // Remove old schedule first if let Some(scheduler) = &self.scheduler { let _ = scheduler.write().await.remove(&discovery.id).await; @@ -206,36 +209,35 @@ impl DiscoveryService { disabled_discovery.id, e ); - - return Ok(disabled_discovery); } - self.event_bus() - .publish(EntityEvent { - id: Uuid::new_v4(), - entity_type: Self::entity_type(), - entity_id: updated.id(), - network_id: self.get_network_id(&updated), - organization_id: self.get_organization_id(&updated), - operation: EntityOperation::Updated, - timestamp: Utc::now(), - metadata: serde_json::json!({}), - authentication, - }) - .await?; - - tracing::info!( - "Updated and rescheduled discovery {}: {}", - updated.base.name, - updated.id - ); - Ok(updated) + updated } else { // For non-scheduled, just update let updated = self.discovery_storage.update(&mut discovery).await?; tracing::info!("Updated discovery {}: {}", updated.base.name, updated.id); - Ok(updated) - } + updated + }; + + let trigger_stale = updated.triggers_staleness(Some(current)); + + self.event_bus() + .publish_entity(EntityEvent { + id: Uuid::new_v4(), + entity_id: updated.id(), + network_id: self.get_network_id(&updated), + organization_id: self.get_organization_id(&updated), + entity_type: updated.clone().into(), + operation: EntityOperation::Updated, + timestamp: Utc::now(), + metadata: serde_json::json!({ + "trigger_stale": trigger_stale + }), + authentication, + }) + .await?; + + Ok(updated) } /// Delete group @@ -259,25 +261,23 @@ impl DiscoveryService { self.discovery_storage.delete(id).await?; + let trigger_stale = discovery.triggers_staleness(None); + self.event_bus() - .publish(EntityEvent { + .publish_entity(EntityEvent { id: Uuid::new_v4(), - entity_type: Self::entity_type(), entity_id: discovery.id(), network_id: self.get_network_id(&discovery), organization_id: self.get_organization_id(&discovery), + entity_type: discovery.into(), operation: EntityOperation::Deleted, timestamp: Utc::now(), - metadata: serde_json::json!({}), + metadata: serde_json::json!({ + "trigger_stale": trigger_stale + }), authentication, }) .await?; - - tracing::info!( - "Deleted discovery {}: {}", - discovery.base.name, - discovery.id - ); Ok(()) } @@ -466,11 +466,6 @@ impl DiscoveryService { let _ = self.update_tx.send(session_payload.clone()); - tracing::info!( - "Created discovery session {} for daemon {}", - session_id, - discovery.base.daemon_id - ); Ok(session_payload) } @@ -486,11 +481,11 @@ impl DiscoveryService { let daemon_id = session.daemon_id; tracing::debug!( - "Updated session {}: {} ({}/{})", - update.session_id, - update.phase, - update.processed, - update.total_to_process + session_id = %update.session_id, + phase = %update.phase, + processed = %update.processed, + total_to_process = %update.total_to_process, + "Updated session", ); let _ = self.update_tx.send(update.clone()); @@ -531,12 +526,12 @@ impl DiscoveryService { ); } else { self.event_bus() - .publish(EntityEvent { + .publish_entity(EntityEvent { id: Uuid::new_v4(), - entity_type: Self::entity_type(), entity_id: historical_discovery.id(), network_id: self.get_network_id(&historical_discovery), organization_id: self.get_organization_id(&historical_discovery), + entity_type: historical_discovery.into(), operation: EntityOperation::Created, timestamp: Utc::now(), metadata: serde_json::json!({ @@ -545,12 +540,6 @@ impl DiscoveryService { authentication: AuthenticatedEntity::System, }) .await?; - - tracing::debug!( - "Created historical discovery record {} for session {}", - historical_discovery.id, - session.session_id - ); } // Get next session info BEFORE trying to send request @@ -675,7 +664,7 @@ impl DiscoveryService { match daemon.base.mode { DaemonMode::Push => { self.daemon_service - .send_discovery_cancellation(&daemon, session_id, authentication) + .send_discovery_cancellation(daemon, session_id, authentication) .await .map_err(|e| { anyhow!( @@ -699,7 +688,7 @@ impl DiscoveryService { .write() .await .entry(daemon_id) - .insert_entry(true); + .insert_entry((true, session_id)); tracing::info!( "Marked session {} for cancellation on next pull by daemon {}", diff --git a/backend/src/server/groups/impl/base.rs b/backend/src/server/groups/impl/base.rs index b67a4cf4..ae106f9e 100644 --- a/backend/src/server/groups/impl/base.rs +++ b/backend/src/server/groups/impl/base.rs @@ -1,5 +1,6 @@ use std::fmt::Display; +use crate::server::shared::entities::ChangeTriggersTopologyStaleness; use crate::server::shared::types::entities::EntitySource; use crate::server::topology::types::edges::EdgeStyle; use crate::server::{ @@ -40,3 +41,22 @@ impl Display for Group { write!(f, "Group {}: {}", self.base.name, self.id) } } + +impl Group { + pub fn bindings(&self) -> Vec { + match &self.base.group_type { + GroupType::HubAndSpoke { service_bindings } => service_bindings.to_vec(), + GroupType::RequestPath { service_bindings } => service_bindings.to_vec(), + } + } +} + +impl ChangeTriggersTopologyStaleness for Group { + fn triggers_staleness(&self, other: Option) -> bool { + if let Some(other_group) = other { + self.bindings() != other_group.bindings() + } else { + true + } + } +} diff --git a/backend/src/server/groups/impl/types.rs b/backend/src/server/groups/impl/types.rs index f210b83b..e5617f2d 100644 --- a/backend/src/server/groups/impl/types.rs +++ b/backend/src/server/groups/impl/types.rs @@ -1,4 +1,4 @@ -use crate::server::shared::entities::Entity; +use crate::server::shared::entities::EntityDiscriminants; use crate::server::shared::types::metadata::{EntityMetadataProvider, HasId, TypeMetadataProvider}; use serde::{Deserialize, Serialize}; use strum_macros::{EnumDiscriminants, EnumIter, IntoStaticStr}; @@ -37,8 +37,8 @@ impl HasId for GroupTypeDiscriminants { impl EntityMetadataProvider for GroupTypeDiscriminants { fn color(&self) -> &'static str { match self { - GroupTypeDiscriminants::RequestPath => Entity::Group.color(), - GroupTypeDiscriminants::HubAndSpoke => Entity::Group.color(), + GroupTypeDiscriminants::RequestPath => EntityDiscriminants::Group.color(), + GroupTypeDiscriminants::HubAndSpoke => EntityDiscriminants::Group.color(), } } diff --git a/backend/src/server/groups/service.rs b/backend/src/server/groups/service.rs index 72a9d5f3..e2f4d532 100644 --- a/backend/src/server/groups/service.rs +++ b/backend/src/server/groups/service.rs @@ -1,12 +1,22 @@ use async_trait::async_trait; +use chrono::Utc; use std::sync::Arc; use uuid::Uuid; use crate::server::{ + auth::middleware::AuthenticatedEntity, groups::r#impl::base::Group, shared::{ - entities::Entity, events::bus::EventBus, services::traits::CrudService, - storage::generic::GenericPostgresStorage, + entities::ChangeTriggersTopologyStaleness, + events::{ + bus::EventBus, + types::{EntityEvent, EntityOperation}, + }, + services::traits::{CrudService, EventBusService}, + storage::{ + generic::GenericPostgresStorage, + traits::{StorableEntity, Storage}, + }, }, }; @@ -15,19 +25,11 @@ pub struct GroupService { event_bus: Arc, } -#[async_trait] -impl CrudService for GroupService { - fn storage(&self) -> &Arc> { - &self.group_storage - } - +impl EventBusService for GroupService { fn event_bus(&self) -> &Arc { &self.event_bus } - fn entity_type() -> Entity { - Entity::Group - } fn get_network_id(&self, entity: &Group) -> Option { Some(entity.base.network_id) } @@ -36,6 +38,45 @@ impl CrudService for GroupService { } } +#[async_trait] +impl CrudService for GroupService { + fn storage(&self) -> &Arc> { + &self.group_storage + } + + async fn update( + &self, + updates: &mut Group, + authentication: AuthenticatedEntity, + ) -> Result { + let current = self + .get_by_id(&updates.id) + .await? + .ok_or_else(|| anyhow::anyhow!("Could not find group to update"))?; + + let updated = self.storage().update(updates).await?; + let trigger_stale = updated.triggers_staleness(Some(current)); + + self.event_bus() + .publish_entity(EntityEvent { + id: Uuid::new_v4(), + entity_id: updated.id(), + network_id: self.get_network_id(&updated), + organization_id: self.get_organization_id(&updated), + entity_type: updated.clone().into(), + operation: EntityOperation::Updated, + timestamp: Utc::now(), + metadata: serde_json::json!({ + "trigger_stale": trigger_stale + }), + authentication, + }) + .await?; + + Ok(updated) + } +} + impl GroupService { pub fn new( group_storage: Arc>, diff --git a/backend/src/server/hosts/impl/base.rs b/backend/src/server/hosts/impl/base.rs index 6bee7fc0..d3593b0d 100644 --- a/backend/src/server/hosts/impl/base.rs +++ b/backend/src/server/hosts/impl/base.rs @@ -1,4 +1,5 @@ use crate::server::hosts::r#impl::virtualization::HostVirtualization; +use crate::server::shared::entities::ChangeTriggersTopologyStaleness; use crate::server::shared::types::api::deserialize_empty_string_as_none; use crate::server::shared::types::entities::EntitySource; use crate::server::subnets::r#impl::base::Subnet; @@ -169,3 +170,17 @@ impl Host { self.base.services.push(service_id); } } + +impl ChangeTriggersTopologyStaleness for Host { + fn triggers_staleness(&self, other: Option) -> bool { + if let Some(other_host) = other { + self.base.services != other_host.base.services + || self.base.hostname != other_host.base.hostname + || self.base.interfaces != other_host.base.interfaces + || self.base.virtualization != other_host.base.virtualization + || self.base.hidden != other_host.base.hidden + } else { + true + } + } +} diff --git a/backend/src/server/hosts/impl/ports.rs b/backend/src/server/hosts/impl/ports.rs index d2e62838..ec950ddd 100644 --- a/backend/src/server/hosts/impl/ports.rs +++ b/backend/src/server/hosts/impl/ports.rs @@ -6,10 +6,8 @@ use strum_macros::{Display, EnumDiscriminants, EnumIter, IntoStaticStr}; use uuid::Uuid; use validator::Validate; -use crate::server::shared::{ - entities::Entity, - types::metadata::{EntityMetadataProvider, HasId, TypeMetadataProvider}, -}; +use crate::server::shared::entities::EntityDiscriminants; +use crate::server::shared::types::metadata::{EntityMetadataProvider, HasId, TypeMetadataProvider}; #[derive( Copy, @@ -472,10 +470,10 @@ impl HasId for PortBase { impl EntityMetadataProvider for PortBase { fn color(&self) -> &'static str { - Entity::Port.color() + EntityDiscriminants::Port.color() } fn icon(&self) -> &'static str { - Entity::Port.icon() + EntityDiscriminants::Port.icon() } } diff --git a/backend/src/server/hosts/impl/virtualization.rs b/backend/src/server/hosts/impl/virtualization.rs index 8b48c709..b1e7f27f 100644 --- a/backend/src/server/hosts/impl/virtualization.rs +++ b/backend/src/server/hosts/impl/virtualization.rs @@ -5,7 +5,7 @@ use uuid::Uuid; use validator::Validate; use crate::server::shared::{ - entities::Entity, + concepts::Concept, types::metadata::{EntityMetadataProvider, HasId, TypeMetadataProvider}, }; @@ -30,10 +30,10 @@ impl HasId for HostVirtualization { impl EntityMetadataProvider for HostVirtualization { fn color(&self) -> &'static str { - Entity::Virtualization.color() + Concept::Virtualization.color() } fn icon(&self) -> &'static str { - Entity::Virtualization.icon() + Concept::Virtualization.icon() } } diff --git a/backend/src/server/hosts/mod.rs b/backend/src/server/hosts/mod.rs index d86b9b5e..4b18718d 100644 --- a/backend/src/server/hosts/mod.rs +++ b/backend/src/server/hosts/mod.rs @@ -1,5 +1,6 @@ pub mod handlers; pub mod r#impl; pub mod service; +pub mod subscriber; #[cfg(test)] pub mod tests; diff --git a/backend/src/server/hosts/service.rs b/backend/src/server/hosts/service.rs index 68ebbc24..983c9308 100644 --- a/backend/src/server/hosts/service.rs +++ b/backend/src/server/hosts/service.rs @@ -4,12 +4,12 @@ use crate::server::{ hosts::r#impl::base::Host, services::{r#impl::base::Service, service::ServiceService}, shared::{ - entities::Entity, + entities::ChangeTriggersTopologyStaleness, events::{ bus::EventBus, types::{EntityEvent, EntityOperation}, }, - services::traits::CrudService, + services::traits::{CrudService, EventBusService}, storage::{ filter::EntityFilter, generic::GenericPostgresStorage, @@ -35,25 +35,24 @@ pub struct HostService { event_bus: Arc, } -#[async_trait] -impl CrudService for HostService { - fn storage(&self) -> &Arc> { - &self.storage - } - +impl EventBusService for HostService { fn event_bus(&self) -> &Arc { &self.event_bus } - fn entity_type() -> Entity { - Entity::Host - } fn get_network_id(&self, entity: &Host) -> Option { Some(entity.base.network_id) } fn get_organization_id(&self, _entity: &Host) -> Option { None } +} + +#[async_trait] +impl CrudService for HostService { + fn storage(&self) -> &Arc> { + &self.storage + } /// Create a new host async fn create(&self, host: Host, authentication: AuthenticatedEntity) -> Result { @@ -93,27 +92,24 @@ impl CrudService for HostService { } _ => { let created = self.storage.create(&host).await?; + let trigger_stale = created.triggers_staleness(None); self.event_bus() - .publish(EntityEvent { + .publish_entity(EntityEvent { id: Uuid::new_v4(), - entity_type: Self::entity_type(), entity_id: created.id(), network_id: self.get_network_id(&created), organization_id: self.get_organization_id(&created), + entity_type: created.into(), operation: EntityOperation::Created, timestamp: Utc::now(), - metadata: serde_json::json!({}), + metadata: serde_json::json!({ + "trigger_stale": trigger_stale + }), authentication, }) .await?; - tracing::info!( - name = %host.base.name, - id = %host.id, - "Created host" - ); - tracing::trace!("Result: {:?}", host); host } }; @@ -123,44 +119,39 @@ impl CrudService for HostService { async fn update( &self, - host: &mut Host, + updates: &mut Host, authentication: AuthenticatedEntity, ) -> Result { - let lock = self.get_host_lock(&host.id).await; + let lock = self.get_host_lock(&updates.id).await; let _guard = lock.lock().await; - tracing::trace!("Updating host {:?}", host); - let current_host = self - .get_by_id(&host.id) + .get_by_id(&updates.id) .await? - .ok_or_else(|| anyhow!("Host '{}' not found", host.id))?; + .ok_or_else(|| anyhow!("Host '{}' not found", updates.id))?; - self.update_host_services(¤t_host, host, authentication.clone()) + self.update_host_services(¤t_host, updates, authentication.clone()) .await?; - let updated = self.storage.update(host).await?; + let updated = self.storage.update(updates).await?; + let trigger_stale = updated.triggers_staleness(Some(current_host)); self.event_bus() - .publish(EntityEvent { + .publish_entity(EntityEvent { id: Uuid::new_v4(), - entity_type: Self::entity_type(), entity_id: updated.id(), network_id: self.get_network_id(&updated), organization_id: self.get_organization_id(&updated), + entity_type: updated.clone().into(), operation: EntityOperation::Updated, timestamp: Utc::now(), - metadata: serde_json::json!({}), + metadata: serde_json::json!({ + "trigger_stale": trigger_stale + }), authentication, }) .await?; - tracing::info!( - id = %host.id, - "Updated host" - ); - tracing::trace!("Result: {:?}", host); - Ok(updated) } } @@ -248,6 +239,8 @@ impl HostService { let mut hostname_update = false; let mut description_update = false; + let host_before_updates = existing_host.clone(); + tracing::trace!( "Upserting new host data {:?} to host {:?}", new_host_data, @@ -337,27 +330,23 @@ impl HostService { } if !data.is_empty() { + let trigger_stale = existing_host.triggers_staleness(Some(host_before_updates)); + self.event_bus() - .publish(EntityEvent { + .publish_entity(EntityEvent { id: Uuid::new_v4(), - entity_type: Self::entity_type(), entity_id: existing_host.id(), network_id: self.get_network_id(&existing_host), organization_id: self.get_organization_id(&existing_host), + entity_type: existing_host.clone().into(), operation: EntityOperation::Updated, timestamp: Utc::now(), - metadata: serde_json::json!({}), + metadata: serde_json::json!({ + "trigger_stale": trigger_stale + }), authentication, }) .await?; - - tracing::info!( - host_id = %existing_host.id, - host_name = %existing_host.base.name, - updates = %data.join(", "), - "Upserted discovery data to host" - ); - tracing::trace!("Result: {:?}", existing_host); } else { tracing::debug!( "No new data to upsert from host {} to {}", @@ -579,27 +568,24 @@ impl HostService { self.storage.delete(id).await?; + let trigger_stale = host.triggers_staleness(None); + self.event_bus() - .publish(EntityEvent { + .publish_entity(EntityEvent { id: Uuid::new_v4(), - entity_type: Self::entity_type(), entity_id: host.id(), network_id: self.get_network_id(&host), organization_id: self.get_organization_id(&host), + entity_type: host.into(), operation: EntityOperation::Deleted, timestamp: Utc::now(), - metadata: serde_json::json!({}), + metadata: serde_json::json!({ + "trigger_stale": trigger_stale + }), authentication, }) .await?; - tracing::info!( - host_id = %host.id, - host_name = %host.base.name, - service_count = %host.base.services.len(), - deleted_services = %delete_services, - "Host deleted" - ); Ok(()) } } diff --git a/backend/src/server/hosts/subscriber.rs b/backend/src/server/hosts/subscriber.rs new file mode 100644 index 00000000..34284b6f --- /dev/null +++ b/backend/src/server/hosts/subscriber.rs @@ -0,0 +1,104 @@ +use std::collections::HashMap; + +use anyhow::Error; +use async_trait::async_trait; + +use crate::server::{ + auth::middleware::AuthenticatedEntity, + hosts::service::HostService, + shared::{ + entities::EntityDiscriminants, + events::{ + bus::{EventFilter, EventSubscriber}, + types::{EntityOperation, Event}, + }, + services::traits::CrudService, + storage::filter::EntityFilter, + }, +}; + +#[async_trait] +impl EventSubscriber for HostService { + fn event_filter(&self) -> EventFilter { + EventFilter::entity_only(HashMap::from([( + EntityDiscriminants::Subnet, + Some(vec![EntityOperation::Deleted]), + )])) + } + + async fn handle_events(&self, events: Vec) -> Result<(), Error> { + if events.is_empty() { + return Ok(()); + } + + // Collect all deleted subnet IDs and affected network IDs + let mut deleted_subnets = std::collections::HashSet::new(); + let mut network_ids = std::collections::HashSet::new(); + + for event in events { + if let Event::Entity(entity_event) = event { + deleted_subnets.insert(entity_event.entity_id); + if let Some(network_id) = entity_event.network_id { + network_ids.insert(network_id); + } + + tracing::debug!( + entity_type = %entity_event.entity_type, + entity_operation = %entity_event.operation, + subnet_id = %entity_event.entity_id, + "Host subscriber handling subnet deletion event", + ); + } + } + + // Process all affected networks + for network_id in network_ids { + let filter = EntityFilter::unfiltered().network_ids(&[network_id]); + let hosts = self.get_all(filter).await?; + + let mut updated_count = 0; + + for mut host in hosts { + // Check if host has interfaces referencing any deleted subnet + let has_deleted_subnet = host + .base + .interfaces + .iter() + .any(|i| deleted_subnets.contains(&i.base.subnet_id)); + + if has_deleted_subnet { + // Remove interfaces for all deleted subnets in this batch + host.base.interfaces = host + .base + .interfaces + .iter() + .filter(|i| !deleted_subnets.contains(&i.base.subnet_id)) + .cloned() + .collect(); + + self.update(&mut host, AuthenticatedEntity::System).await?; + updated_count += 1; + } + } + + if updated_count > 0 { + tracing::info!( + deleted_subnets = deleted_subnets.len(), + affected_hosts = updated_count, + network_id = %network_id, + "Cleaned up host interfaces referencing deleted subnets" + ); + } + } + + Ok(()) + } + + fn debounce_window_ms(&self) -> u64 { + 50 // Small window to batch multiple subnet deletions + } + + fn name(&self) -> &str { + "subnet_deleted_interface_removal" + } +} diff --git a/backend/src/server/logging/mod.rs b/backend/src/server/logging/mod.rs new file mode 100644 index 00000000..97c7fa9b --- /dev/null +++ b/backend/src/server/logging/mod.rs @@ -0,0 +1,2 @@ +pub mod service; +pub mod subscriber; diff --git a/backend/src/server/logging/service.rs b/backend/src/server/logging/service.rs new file mode 100644 index 00000000..3270e68c --- /dev/null +++ b/backend/src/server/logging/service.rs @@ -0,0 +1,14 @@ +#[derive(Clone)] +pub struct LoggingService {} + +impl LoggingService { + pub fn new() -> Self { + Self {} + } +} + +impl Default for LoggingService { + fn default() -> Self { + Self::new() + } +} diff --git a/backend/src/server/logging/subscriber.rs b/backend/src/server/logging/subscriber.rs new file mode 100644 index 00000000..4cdb9908 --- /dev/null +++ b/backend/src/server/logging/subscriber.rs @@ -0,0 +1,34 @@ +use anyhow::Error; +use async_trait::async_trait; + +use crate::server::{ + logging::service::LoggingService, + shared::events::{ + bus::{EventFilter, EventSubscriber}, + types::Event, + }, +}; + +#[async_trait] +impl EventSubscriber for LoggingService { + fn event_filter(&self) -> EventFilter { + EventFilter::all() + } + + async fn handle_events(&self, events: Vec) -> Result<(), Error> { + // Log each event individually + for event in events { + event.log(); + } + + Ok(()) + } + + fn debounce_window_ms(&self) -> u64 { + 0 // No batching for logging - we want immediate logs + } + + fn name(&self) -> &str { + "logging" + } +} diff --git a/backend/src/server/mod.rs b/backend/src/server/mod.rs index b3dace28..5cb92c2c 100644 --- a/backend/src/server/mod.rs +++ b/backend/src/server/mod.rs @@ -8,6 +8,7 @@ pub mod email; pub mod github; pub mod groups; pub mod hosts; +pub mod logging; pub mod networks; pub mod organizations; pub mod services; diff --git a/backend/src/server/networks/impl.rs b/backend/src/server/networks/impl.rs index b583ffbf..dbf73eeb 100644 --- a/backend/src/server/networks/impl.rs +++ b/backend/src/server/networks/impl.rs @@ -1,6 +1,9 @@ use std::fmt::Display; -use crate::server::{networks::service::NetworkService, shared::handlers::traits::CrudHandlers}; +use crate::server::{ + networks::service::NetworkService, + shared::{entities::ChangeTriggersTopologyStaleness, handlers::traits::CrudHandlers}, +}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::Row; @@ -10,7 +13,7 @@ use validator::Validate; use crate::server::shared::storage::traits::{SqlValue, StorableEntity}; -#[derive(Debug, Clone, Serialize, Deserialize, Validate)] +#[derive(Debug, Clone, Serialize, Deserialize, Validate, PartialEq, Eq, Hash)] pub struct NetworkBase { #[validate(length(min = 0, max = 100))] pub name: String, @@ -28,7 +31,7 @@ impl NetworkBase { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] pub struct Network { pub id: Uuid, pub created_at: DateTime, @@ -51,6 +54,12 @@ impl CrudHandlers for Network { } } +impl ChangeTriggersTopologyStaleness for Network { + fn triggers_staleness(&self, _other: Option) -> bool { + false + } +} + impl StorableEntity for Network { type BaseData = NetworkBase; diff --git a/backend/src/server/networks/service.rs b/backend/src/server/networks/service.rs index 3b7c8920..1146cf4f 100644 --- a/backend/src/server/networks/service.rs +++ b/backend/src/server/networks/service.rs @@ -3,9 +3,8 @@ use crate::server::{ hosts::service::HostService, networks::r#impl::Network, shared::{ - entities::Entity, events::bus::EventBus, - services::traits::CrudService, + services::traits::{CrudService, EventBusService}, storage::{ generic::GenericPostgresStorage, seed_data::{ @@ -28,19 +27,11 @@ pub struct NetworkService { event_bus: Arc, } -#[async_trait] -impl CrudService for NetworkService { - fn storage(&self) -> &Arc> { - &self.network_storage - } - +impl EventBusService for NetworkService { fn event_bus(&self) -> &Arc { &self.event_bus } - fn entity_type() -> Entity { - Entity::Network - } fn get_network_id(&self, _entity: &Network) -> Option { None } @@ -49,6 +40,13 @@ impl CrudService for NetworkService { } } +#[async_trait] +impl CrudService for NetworkService { + fn storage(&self) -> &Arc> { + &self.network_storage + } +} + impl NetworkService { pub fn new( network_storage: Arc>, diff --git a/backend/src/server/organizations/handlers.rs b/backend/src/server/organizations/handlers.rs index 5e354da0..7b066f7c 100644 --- a/backend/src/server/organizations/handlers.rs +++ b/backend/src/server/organizations/handlers.rs @@ -5,7 +5,7 @@ use crate::server::auth::middleware::{ use crate::server::config::AppState; use crate::server::organizations::r#impl::api::CreateInviteRequest; use crate::server::organizations::r#impl::base::Organization; -use crate::server::organizations::r#impl::invites::OrganizationInvite; +use crate::server::organizations::r#impl::invites::Invite; use crate::server::shared::handlers::traits::{CrudHandlers, update_handler}; use crate::server::shared::services::traits::CrudService; use crate::server::shared::types::api::ApiError; @@ -56,7 +56,7 @@ async fn create_invite( RequireMember(user): RequireMember, RequireFeature { plan, .. }: RequireFeature, Json(request): Json, -) -> ApiResult>> { +) -> ApiResult>> { // We know they have either team_members or share_views enabled if !plan.features().team_members && request.permissions > UserOrgPermissions::Visualizer { return Err(ApiError::forbidden( @@ -91,7 +91,7 @@ async fn get_invite( State(state): State>, RequireMember(_user): RequireMember, Path(id): Path, -) -> ApiResult>> { +) -> ApiResult>> { let invite = state .services .organization_service @@ -106,7 +106,7 @@ async fn get_invite( async fn get_invites( State(state): State>, RequireMember(user): RequireMember, -) -> ApiResult>>> { +) -> ApiResult>>> { // Show user invites that they created or created for users with permissions lower than them let invites = state .services diff --git a/backend/src/server/organizations/impl/base.rs b/backend/src/server/organizations/impl/base.rs index b6d77970..c21da5dd 100644 --- a/backend/src/server/organizations/impl/base.rs +++ b/backend/src/server/organizations/impl/base.rs @@ -1,23 +1,24 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::fmt::Display; -use stripe_billing::SubscriptionStatus; use uuid::Uuid; use validator::Validate; -use crate::server::billing::types::base::BillingPlan; +use crate::server::{ + billing::types::base::BillingPlan, shared::entities::ChangeTriggersTopologyStaleness, +}; -#[derive(Debug, Clone, Serialize, Validate, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Validate, Deserialize, Default, PartialEq, Eq, Hash)] pub struct OrganizationBase { pub stripe_customer_id: Option, #[validate(length(min = 0, max = 100))] pub name: String, pub plan: Option, - pub plan_status: Option, + pub plan_status: Option, pub is_onboarded: bool, } -#[derive(Debug, Clone, Validate, Serialize, Deserialize)] +#[derive(Debug, Clone, Validate, Serialize, Deserialize, PartialEq, Eq, Hash)] pub struct Organization { pub id: Uuid, pub created_at: DateTime, @@ -32,3 +33,9 @@ impl Display for Organization { write!(f, "{:?}: {:?}", self.base.name, self.id) } } + +impl ChangeTriggersTopologyStaleness for Organization { + fn triggers_staleness(&self, _other: Option) -> bool { + false + } +} diff --git a/backend/src/server/organizations/impl/invites.rs b/backend/src/server/organizations/impl/invites.rs index 8e435cd2..68ae04f4 100644 --- a/backend/src/server/organizations/impl/invites.rs +++ b/backend/src/server/organizations/impl/invites.rs @@ -2,10 +2,13 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::server::users::r#impl::permissions::UserOrgPermissions; +use crate::server::{ + shared::entities::ChangeTriggersTopologyStaleness, + users::r#impl::permissions::UserOrgPermissions, +}; -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OrganizationInvite { +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct Invite { pub id: Uuid, pub organization_id: Uuid, pub permissions: UserOrgPermissions, @@ -15,7 +18,7 @@ pub struct OrganizationInvite { pub expires_at: DateTime, } -impl OrganizationInvite { +impl Invite { pub fn new( organization_id: Uuid, url: String, @@ -46,3 +49,9 @@ impl OrganizationInvite { true } } + +impl ChangeTriggersTopologyStaleness for Invite { + fn triggers_staleness(&self, _other: Option) -> bool { + false + } +} diff --git a/backend/src/server/organizations/impl/storage.rs b/backend/src/server/organizations/impl/storage.rs index 4e1674dd..cf9ccde8 100644 --- a/backend/src/server/organizations/impl/storage.rs +++ b/backend/src/server/organizations/impl/storage.rs @@ -1,7 +1,6 @@ use chrono::{DateTime, Utc}; use sqlx::Row; use sqlx::postgres::PgRow; -use stripe_billing::SubscriptionStatus; use uuid::Uuid; use crate::server::{ @@ -81,7 +80,7 @@ impl StorableEntity for Organization { SqlValue::String(name), SqlValue::OptionalString(stripe_customer_id), SqlValue::OptionBillingPlan(plan), - SqlValue::OptionBillingPlanStatus(plan_status), + SqlValue::OptionalString(plan_status), SqlValue::Bool(is_onboarded), ], )) @@ -93,11 +92,6 @@ impl StorableEntity for Organization { .unwrap_or(None) .and_then(|v| serde_json::from_value(v).ok()); - let plan_status: Option = row - .try_get::, _>("plan") - .unwrap_or(None) - .and_then(|v| serde_json::from_str(&v).ok()); - Ok(Organization { id: row.get("id"), created_at: row.get("created_at"), @@ -106,7 +100,7 @@ impl StorableEntity for Organization { name: row.get("name"), stripe_customer_id: row.get("stripe_customer_id"), plan, - plan_status, + plan_status: row.get("plan_status"), is_onboarded: row.get("is_onboarded"), }, }) diff --git a/backend/src/server/organizations/service.rs b/backend/src/server/organizations/service.rs index ec36d396..da03a7ee 100644 --- a/backend/src/server/organizations/service.rs +++ b/backend/src/server/organizations/service.rs @@ -1,8 +1,9 @@ use crate::server::auth::middleware::AuthenticatedEntity; -use crate::server::organizations::r#impl::invites::OrganizationInvite; -use crate::server::shared::entities::Entity; +use crate::server::organizations::r#impl::invites::Invite; +use crate::server::shared::entities::ChangeTriggersTopologyStaleness; use crate::server::shared::events::bus::EventBus; use crate::server::shared::events::types::{EntityEvent, EntityOperation}; +use crate::server::shared::services::traits::EventBusService; use crate::server::{ organizations::r#impl::{api::CreateInviteRequest, base::Organization}, shared::{services::traits::CrudService, storage::generic::GenericPostgresStorage}, @@ -17,23 +18,15 @@ use uuid::Uuid; pub struct OrganizationService { storage: Arc>, - invites: Arc>>, + invites: Arc>>, event_bus: Arc, } -#[async_trait] -impl CrudService for OrganizationService { - fn storage(&self) -> &Arc> { - &self.storage - } - +impl EventBusService for OrganizationService { fn event_bus(&self) -> &Arc { &self.event_bus } - fn entity_type() -> Entity { - Entity::Organization - } fn get_network_id(&self, _entity: &Organization) -> Option { None } @@ -42,6 +35,13 @@ impl CrudService for OrganizationService { } } +#[async_trait] +impl CrudService for OrganizationService { + fn storage(&self) -> &Arc> { + &self.storage + } +} + impl OrganizationService { pub fn new( storage: Arc>, @@ -54,7 +54,7 @@ impl OrganizationService { } } - pub async fn get_invite(&self, id: Uuid) -> Result { + pub async fn get_invite(&self, id: Uuid) -> Result { let invites = self.invites.read().await; let invite = invites @@ -85,16 +85,20 @@ impl OrganizationService { .remove(&id) .ok_or_else(|| anyhow!("Invite not found"))?; + let trigger_stale = invite.triggers_staleness(None); + self.event_bus() - .publish(EntityEvent { + .publish_entity(EntityEvent { id: Uuid::new_v4(), - entity_type: Entity::Invite, entity_id: invite.id, - network_id: None, organization_id: Some(invite.organization_id), + entity_type: invite.into(), + network_id: None, operation: EntityOperation::Deleted, timestamp: Utc::now(), - metadata: serde_json::json!({}), + metadata: serde_json::json!({ + "trigger_stale": trigger_stale + }), authentication: AuthenticatedEntity::System, }) .await?; @@ -121,10 +125,10 @@ impl OrganizationService { user_id: Uuid, url: String, authentication: AuthenticatedEntity, - ) -> Result { + ) -> Result { let expiration_hours = request.expiration_hours.unwrap_or(168); // Default 7 days - let invite = OrganizationInvite::new( + let invite = Invite::new( organization_id, url, user_id, @@ -135,16 +139,20 @@ impl OrganizationService { // Store invite self.invites.write().await.insert(invite.id, invite.clone()); + let trigger_stale = invite.triggers_staleness(None); + self.event_bus() - .publish(EntityEvent { + .publish_entity(EntityEvent { id: Uuid::new_v4(), - entity_type: Entity::Invite, entity_id: invite.id, - network_id: None, organization_id: Some(invite.organization_id), + entity_type: invite.clone().into(), + network_id: None, operation: EntityOperation::Created, timestamp: Utc::now(), - metadata: serde_json::json!({}), + metadata: serde_json::json!({ + "trigger_stale": trigger_stale + }), authentication, }) .await?; @@ -164,16 +172,20 @@ impl OrganizationService { .remove(&id) .ok_or_else(|| anyhow!("Invite not found"))?; + let trigger_stale = invite.triggers_staleness(None); + self.event_bus() - .publish(EntityEvent { + .publish_entity(EntityEvent { id: Uuid::new_v4(), - entity_type: Entity::Invite, entity_id: invite.id, - network_id: None, organization_id: Some(invite.organization_id), + entity_type: invite.into(), + network_id: None, operation: EntityOperation::Deleted, timestamp: Utc::now(), - metadata: serde_json::json!({}), + metadata: serde_json::json!({ + "trigger_stale": trigger_stale + }), authentication, }) .await?; @@ -191,7 +203,7 @@ impl OrganizationService { } /// List all active invites for an organization - pub async fn list_invites(&self, organization_id: &Uuid) -> Vec { + pub async fn list_invites(&self, organization_id: &Uuid) -> Vec { let invites = self.invites.read().await; invites diff --git a/backend/src/server/services/impl/base.rs b/backend/src/server/services/impl/base.rs index ce2dfa5c..e135d1cc 100644 --- a/backend/src/server/services/impl/base.rs +++ b/backend/src/server/services/impl/base.rs @@ -10,6 +10,7 @@ use crate::server::services::r#impl::patterns::{MatchConfidence, MatchReason, Ma use crate::server::services::r#impl::virtualization::{ DockerVirtualization, ServiceVirtualization, }; +use crate::server::shared::entities::ChangeTriggersTopologyStaleness; use crate::server::shared::storage::traits::StorableEntity; use crate::server::shared::types::entities::{DiscoveryMetadata, EntitySource}; use crate::server::subnets::r#impl::base::Subnet; @@ -47,6 +48,18 @@ impl Default for ServiceBase { } } +impl ChangeTriggersTopologyStaleness for Service { + fn triggers_staleness(&self, other: Option) -> bool { + if let Some(other_service) = other { + self.base.bindings != other_service.base.bindings + || self.base.host_id != other_service.base.host_id + || self.base.virtualization != other_service.base.virtualization + } else { + true + } + } +} + #[derive(Debug, Clone, Validate, Serialize, Deserialize, Eq)] pub struct Service { pub id: Uuid, diff --git a/backend/src/server/services/impl/categories.rs b/backend/src/server/services/impl/categories.rs index ae991932..a8f8c4fd 100644 --- a/backend/src/server/services/impl/categories.rs +++ b/backend/src/server/services/impl/categories.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use strum_macros::{Display, EnumDiscriminants, EnumIter, IntoStaticStr}; use crate::server::shared::{ - entities::Entity, + concepts::Concept, types::metadata::{EntityMetadataProvider, HasId}, }; @@ -81,21 +81,21 @@ impl EntityMetadataProvider for ServiceCategory { ServiceCategory::Storage => "HardDrive", ServiceCategory::Media => "PlayCircle", ServiceCategory::HomeAutomation => "Home", - ServiceCategory::Virtualization => Entity::Virtualization.icon(), + ServiceCategory::Virtualization => Concept::Virtualization.icon(), ServiceCategory::FileSharing => "Folder", // Network Services - ServiceCategory::DNS => Entity::Dns.icon(), - ServiceCategory::VPN => Entity::Vpn.icon(), + ServiceCategory::DNS => Concept::Dns.icon(), + ServiceCategory::VPN => Concept::Vpn.icon(), ServiceCategory::Monitoring => "Activity", ServiceCategory::AdBlock => "ShieldCheck", ServiceCategory::Backup => "DatabaseBackup", - ServiceCategory::ReverseProxy => Entity::ReverseProxy.icon(), + ServiceCategory::ReverseProxy => Concept::ReverseProxy.icon(), // End devices ServiceCategory::Workstation => "Monitor", ServiceCategory::Mobile => "Smartphone", - ServiceCategory::IoT => Entity::IoT.icon(), + ServiceCategory::IoT => Concept::IoT.icon(), ServiceCategory::Printer => "Printer", // Application @@ -126,21 +126,21 @@ impl EntityMetadataProvider for ServiceCategory { ServiceCategory::Storage => "green", ServiceCategory::Media => "blue", ServiceCategory::HomeAutomation => "blue", - ServiceCategory::Virtualization => Entity::Virtualization.color(), + ServiceCategory::Virtualization => Concept::Virtualization.color(), ServiceCategory::Backup => "gray", ServiceCategory::FileSharing => "blue", // Network Services - ServiceCategory::DNS => Entity::Dns.color(), - ServiceCategory::VPN => Entity::Vpn.color(), + ServiceCategory::DNS => Concept::Dns.color(), + ServiceCategory::VPN => Concept::Vpn.color(), ServiceCategory::Monitoring => "orange", - ServiceCategory::AdBlock => Entity::Dns.color(), - ServiceCategory::ReverseProxy => Entity::ReverseProxy.color(), + ServiceCategory::AdBlock => Concept::Dns.color(), + ServiceCategory::ReverseProxy => Concept::ReverseProxy.color(), // End devices ServiceCategory::Workstation => "green", ServiceCategory::Mobile => "blue", - ServiceCategory::IoT => Entity::IoT.color(), + ServiceCategory::IoT => Concept::IoT.color(), ServiceCategory::Printer => "gray", // Application diff --git a/backend/src/server/services/impl/virtualization.rs b/backend/src/server/services/impl/virtualization.rs index f1b19a0f..692addb2 100644 --- a/backend/src/server/services/impl/virtualization.rs +++ b/backend/src/server/services/impl/virtualization.rs @@ -5,7 +5,7 @@ use uuid::Uuid; use validator::Validate; use crate::server::shared::{ - entities::Entity, + concepts::Concept, types::metadata::{EntityMetadataProvider, HasId, TypeMetadataProvider}, }; @@ -32,10 +32,10 @@ impl HasId for ServiceVirtualization { impl EntityMetadataProvider for ServiceVirtualization { fn color(&self) -> &'static str { - Entity::Virtualization.color() + Concept::Virtualization.color() } fn icon(&self) -> &'static str { - Entity::Virtualization.icon() + Concept::Virtualization.icon() } } diff --git a/backend/src/server/services/service.rs b/backend/src/server/services/service.rs index 1aef7974..b50e8ca0 100644 --- a/backend/src/server/services/service.rs +++ b/backend/src/server/services/service.rs @@ -10,12 +10,12 @@ use crate::server::{ }, services::r#impl::{base::Service, bindings::Binding, patterns::MatchDetails}, shared::{ - entities::Entity, + entities::ChangeTriggersTopologyStaleness, events::{ bus::EventBus, types::{EntityEvent, EntityOperation}, }, - services::traits::CrudService, + services::traits::{CrudService, EventBusService}, storage::{ filter::EntityFilter, generic::GenericPostgresStorage, @@ -45,25 +45,24 @@ pub struct ServiceService { event_bus: Arc, } -#[async_trait] -impl CrudService for ServiceService { - fn storage(&self) -> &Arc> { - &self.storage - } - +impl EventBusService for ServiceService { fn event_bus(&self) -> &Arc { &self.event_bus } - fn entity_type() -> Entity { - Entity::Service - } fn get_network_id(&self, entity: &Service) -> Option { Some(entity.base.network_id) } fn get_organization_id(&self, _entity: &Service) -> Option { None } +} + +#[async_trait] +impl CrudService for ServiceService { + fn storage(&self) -> &Arc> { + &self.storage + } async fn create( &self, @@ -105,28 +104,24 @@ impl CrudService for ServiceService { _ => { let created = self.storage.create(&service).await?; + let trigger_stale = created.triggers_staleness(None); + self.event_bus() - .publish(EntityEvent { + .publish_entity(EntityEvent { id: Uuid::new_v4(), - entity_type: Entity::Service, entity_id: created.id, network_id: self.get_network_id(&created), organization_id: self.get_organization_id(&created), + entity_type: created.into(), operation: EntityOperation::Created, timestamp: Utc::now(), - metadata: serde_json::json!({}), + metadata: serde_json::json!({ + "trigger_stale": trigger_stale + }), authentication, }) .await?; - tracing::info!( - service_id = %service.id, - service_name = %service.base.name, - host_id = %service.base.host_id, - binding_count = %service.base.bindings.len(), - "Service created" - ); - tracing::trace!("Result: {:?}", service); service } }; @@ -153,28 +148,24 @@ impl CrudService for ServiceService { .await?; let updated = self.storage.update(service).await?; + let trigger_stale = updated.triggers_staleness(Some(current_service)); self.event_bus() - .publish(EntityEvent { + .publish_entity(EntityEvent { id: Uuid::new_v4(), - entity_type: Entity::Service, entity_id: updated.id, network_id: self.get_network_id(&updated), organization_id: self.get_organization_id(&updated), + entity_type: updated.clone().into(), operation: EntityOperation::Updated, timestamp: Utc::now(), - metadata: serde_json::json!({}), + metadata: serde_json::json!({ + "trigger_stale": trigger_stale + }), authentication, }) .await?; - tracing::info!( - service_id = %service.id, - service_name = %service.base.name, - host_id = %service.base.host_id, - "Service updated" - ); - tracing::trace!("Result: {:?}", service); Ok(updated) } @@ -192,26 +183,23 @@ impl CrudService for ServiceService { self.storage.delete(id).await?; + let trigger_stale = service.triggers_staleness(None); + self.event_bus() - .publish(EntityEvent { + .publish_entity(EntityEvent { id: Uuid::new_v4(), - entity_type: Entity::Service, entity_id: service.id, network_id: self.get_network_id(&service), organization_id: self.get_organization_id(&service), + entity_type: service.into(), operation: EntityOperation::Deleted, timestamp: Utc::now(), - metadata: serde_json::json!({}), + metadata: serde_json::json!({ + "trigger_stale": trigger_stale + }), authentication, }) .await?; - - tracing::info!( - "Deleted service {}: {} for host {}", - service.base.name, - service.id, - service.base.host_id - ); Ok(()) } } @@ -252,6 +240,8 @@ impl ServiceService { ) -> Result { let mut binding_updates = 0; + let service_before_updates = existing_service.clone(); + let lock = self.get_service_lock(&existing_service.id).await; let _guard = lock.lock().await; @@ -336,27 +326,23 @@ impl ServiceService { }; if !data.is_empty() { + let trigger_stale = existing_service.triggers_staleness(Some(service_before_updates)); + self.event_bus() - .publish(EntityEvent { + .publish_entity(EntityEvent { id: Uuid::new_v4(), - entity_type: Entity::Service, entity_id: existing_service.id, network_id: self.get_network_id(&existing_service), organization_id: self.get_organization_id(&existing_service), + entity_type: existing_service.clone().into(), operation: EntityOperation::Updated, timestamp: Utc::now(), - metadata: serde_json::json!({}), + metadata: serde_json::json!({ + "trigger_stale": trigger_stale + }), authentication, }) .await?; - - tracing::info!( - service_id = %existing_service.id, - service_name = %existing_service.base.name, - updates = %data.join(", "), - "Upserted service with new data" - ); - tracing::debug!("Result {:?}", existing_service); } else { tracing::debug!( "Service upsert - no changes needed for {}", diff --git a/backend/src/server/shared/concepts.rs b/backend/src/server/shared/concepts.rs new file mode 100644 index 00000000..910f793a --- /dev/null +++ b/backend/src/server/shared/concepts.rs @@ -0,0 +1,60 @@ +use serde::{Deserialize, Serialize}; +use strum_macros::{Display, EnumDiscriminants, EnumIter, IntoStaticStr}; + +use crate::server::shared::types::metadata::{EntityMetadataProvider, HasId}; + +#[derive( + Debug, + Clone, + PartialEq, + Eq, + Hash, + EnumDiscriminants, + EnumIter, + IntoStaticStr, + Serialize, + Deserialize, + Display, +)] +#[strum_discriminants(derive(Display, Hash, EnumIter, IntoStaticStr))] +pub enum Concept { + Dns, + Vpn, + Gateway, + ReverseProxy, + IoT, + Storage, + Virtualization, +} + +impl HasId for Concept { + fn id(&self) -> &'static str { + self.into() + } +} + +impl EntityMetadataProvider for Concept { + fn color(&self) -> &'static str { + match self { + Concept::Dns => "emerald", + Concept::Vpn => "green", + Concept::Gateway => "teal", + Concept::ReverseProxy => "cyan", + Concept::IoT => "yellow", + Concept::Storage => "green", + Concept::Virtualization => "indigo", + } + } + + fn icon(&self) -> &'static str { + match self { + Concept::Dns => "Search", + Concept::Vpn => "VenetianMask", + Concept::Gateway => "Router", + Concept::ReverseProxy => "Split", + Concept::IoT => "Cpu", + Concept::Storage => "HardDrive", + Concept::Virtualization => "MonitorCog", + } + } +} diff --git a/backend/src/server/shared/entities.rs b/backend/src/server/shared/entities.rs index e3360d93..4eb4ff45 100644 --- a/backend/src/server/shared/entities.rs +++ b/backend/src/server/shared/entities.rs @@ -1,110 +1,189 @@ +use crate::server::groups::r#impl::base::Group; +use crate::server::hosts::r#impl::interfaces::Interface; +use crate::server::hosts::r#impl::ports::Port; +use crate::server::organizations::r#impl::invites::Invite; +use crate::server::services::r#impl::base::Service; +use crate::server::subnets::r#impl::base::Subnet; +use crate::server::topology::types::base::Topology; use serde::{Deserialize, Serialize}; use strum_macros::{Display, EnumDiscriminants, EnumIter, IntoStaticStr}; -use crate::server::shared::types::metadata::{EntityMetadataProvider, HasId}; +use crate::server::{ + api_keys::r#impl::base::ApiKey, + daemons::r#impl::base::Daemon, + discovery::r#impl::base::Discovery, + hosts::r#impl::base::Host, + networks::r#impl::Network, + organizations::r#impl::base::Organization, + shared::types::metadata::{EntityMetadataProvider, HasId}, + users::r#impl::base::User, +}; + +// Trait use to determine whether a given property change on an entity should trigger a rebuild of topology +pub trait ChangeTriggersTopologyStaleness { + fn triggers_staleness(&self, _other: Option) -> bool; +} #[derive( Debug, Clone, - Copy, PartialEq, Eq, Hash, EnumDiscriminants, - EnumIter, IntoStaticStr, Serialize, Deserialize, Display, )] -#[strum_discriminants(derive(Display))] +#[strum_discriminants(derive(Display, Hash, EnumIter, IntoStaticStr))] pub enum Entity { - Organization, - Invite, - Network, - ApiKey, - User, - Discovery, - Daemon, - - Host, - Service, - Port, - Interface, - - Subnet, - Group, - Topology, - - Dns, - Vpn, - Gateway, - ReverseProxy, - IoT, - Storage, - Virtualization, -} - -impl HasId for Entity { + Organization(Organization), + Invite(Invite), + Network(Network), + ApiKey(ApiKey), + User(User), + Discovery(Discovery), + Daemon(Daemon), + + Host(Host), + Service(Service), + Port(Port), + Interface(Interface), + + Subnet(Subnet), + Group(Group), + Topology(Topology), +} + +impl HasId for EntityDiscriminants { fn id(&self) -> &'static str { self.into() } } -impl EntityMetadataProvider for Entity { +impl EntityMetadataProvider for EntityDiscriminants { fn color(&self) -> &'static str { match self { - Entity::Organization => "blue", - Entity::Network => "gray", - Entity::Daemon => "green", - Entity::Discovery => "green", - Entity::ApiKey => "yellow", - Entity::User => "blue", - Entity::Invite => "green", - - Entity::Host => "blue", - Entity::Service => "purple", - Entity::Interface => "cyan", - Entity::Port => "cyan", - - Entity::Dns => "emerald", - Entity::Vpn => "green", - Entity::Gateway => "teal", - Entity::ReverseProxy => "cyan", - - Entity::Subnet => "orange", - Entity::Group => "rose", - Entity::Topology => "pink", - - Entity::IoT => "yellow", - Entity::Storage => "green", - Entity::Virtualization => "indigo", + EntityDiscriminants::Organization => "blue", + EntityDiscriminants::Network => "gray", + EntityDiscriminants::Daemon => "green", + EntityDiscriminants::Discovery => "green", + EntityDiscriminants::ApiKey => "yellow", + EntityDiscriminants::User => "blue", + EntityDiscriminants::Invite => "green", + + EntityDiscriminants::Host => "blue", + EntityDiscriminants::Service => "purple", + EntityDiscriminants::Interface => "cyan", + EntityDiscriminants::Port => "cyan", + + EntityDiscriminants::Subnet => "orange", + EntityDiscriminants::Group => "rose", + EntityDiscriminants::Topology => "pink", } } fn icon(&self) -> &'static str { match self { - Entity::Organization => "Building", - Entity::Network => "Globe", - Entity::User => "User", - Entity::Invite => "UserPlus", - Entity::ApiKey => "Key", - Entity::Daemon => "SatelliteDish", - Entity::Discovery => "Radar", - Entity::Host => "Server", - Entity::Service => "Layers", - Entity::Interface => "Binary", - Entity::Dns => "Search", - Entity::Vpn => "VenetianMask", - Entity::Port => "EthernetPort", - Entity::Gateway => "Router", - Entity::ReverseProxy => "Split", - Entity::Subnet => "Network", - Entity::Group => "Group", - Entity::Topology => "ChartNetwork", - Entity::IoT => "Cpu", - Entity::Storage => "HardDrive", - Entity::Virtualization => "MonitorCog", + EntityDiscriminants::Organization => "Building", + EntityDiscriminants::Network => "Globe", + EntityDiscriminants::User => "User", + EntityDiscriminants::Invite => "UserPlus", + EntityDiscriminants::ApiKey => "Key", + EntityDiscriminants::Daemon => "SatelliteDish", + EntityDiscriminants::Discovery => "Radar", + EntityDiscriminants::Host => "Server", + EntityDiscriminants::Service => "Layers", + EntityDiscriminants::Interface => "Binary", + EntityDiscriminants::Port => "EthernetPort", + EntityDiscriminants::Subnet => "Network", + EntityDiscriminants::Group => "Group", + EntityDiscriminants::Topology => "ChartNetwork", } } } + +impl From for Entity { + fn from(value: Organization) -> Self { + Self::Organization(value) + } +} + +impl From for Entity { + fn from(value: Invite) -> Self { + Self::Invite(value) + } +} + +impl From for Entity { + fn from(value: Network) -> Self { + Self::Network(value) + } +} + +impl From for Entity { + fn from(value: ApiKey) -> Self { + Self::ApiKey(value) + } +} + +impl From for Entity { + fn from(value: User) -> Self { + Self::User(value) + } +} + +impl From for Entity { + fn from(value: Discovery) -> Self { + Self::Discovery(value) + } +} + +impl From for Entity { + fn from(value: Daemon) -> Self { + Self::Daemon(value) + } +} + +impl From for Entity { + fn from(value: Host) -> Self { + Self::Host(value) + } +} + +impl From for Entity { + fn from(value: Service) -> Self { + Self::Service(value) + } +} + +impl From for Entity { + fn from(value: Port) -> Self { + Self::Port(value) + } +} + +impl From for Entity { + fn from(value: Interface) -> Self { + Self::Interface(value) + } +} + +impl From for Entity { + fn from(value: Subnet) -> Self { + Self::Subnet(value) + } +} + +impl From for Entity { + fn from(value: Group) -> Self { + Self::Group(value) + } +} + +impl From for Entity { + fn from(value: Topology) -> Self { + Self::Topology(value) + } +} diff --git a/backend/src/server/shared/events/bus.rs b/backend/src/server/shared/events/bus.rs index e7e7c35f..52bc6429 100644 --- a/backend/src/server/shared/events/bus.rs +++ b/backend/src/server/shared/events/bus.rs @@ -1,5 +1,6 @@ -use std::{collections::HashMap, sync::Arc}; +use std::{collections::HashMap, sync::Arc, time::Duration}; +use strum::IntoDiscriminant; use tokio::sync::RwLock; use anyhow::Result; @@ -8,8 +9,8 @@ use tokio::sync::broadcast; use uuid::Uuid; use crate::server::shared::{ - entities::Entity, - events::types::{EntityEvent, EntityOperation}, + entities::EntityDiscriminants, + events::types::{AuthEvent, AuthOperation, EntityEvent, EntityOperation, Event}, }; // Trait for event subscribers @@ -18,8 +19,13 @@ pub trait EventSubscriber: Send + Sync { /// Return the types of events this subscriber cares about fn event_filter(&self) -> EventFilter; - /// Handle an event - async fn handle_event(&self, event: &EntityEvent) -> Result<()>; + /// Handle a batch of events (vec will have 1 element if debounce_window_ms = 0) + async fn handle_events(&self, events: Vec) -> Result<()>; + + /// Optional: debounce window in milliseconds (default: 0 = no batching) + fn debounce_window_ms(&self) -> u64 { + 0 + } /// Optional: subscriber name for debugging fn name(&self) -> &str; @@ -27,7 +33,9 @@ pub trait EventSubscriber: Send + Sync { #[derive(Debug, Clone)] pub struct EventFilter { - pub entity_operations: Option>>>, + // None = match all values (ignore as a filter) + pub entity_operations: Option>>>, + pub auth_operations: Option>, pub network_ids: Option>, } @@ -35,11 +43,38 @@ impl EventFilter { pub fn all() -> Self { Self { entity_operations: None, + auth_operations: None, + network_ids: None, + } + } + + pub fn entity_only( + entity_operations: HashMap>>, + ) -> Self { + Self { + entity_operations: Some(entity_operations), + auth_operations: None, + network_ids: None, + } + } + + pub fn auth_only(auth_operations: Vec) -> Self { + Self { + entity_operations: None, + auth_operations: Some(auth_operations), network_ids: None, } } - pub fn matches(&self, event: &EntityEvent) -> bool { + pub fn matches(&self, event: &Event) -> bool { + match event { + Event::Entity(entity_event) => self.matches_entity(entity_event), + Event::Auth(auth_event) => self.matches_auth(auth_event), + } + } + + fn matches_entity(&self, event: &EntityEvent) -> bool { + // Check network filter if let Some(networks) = &self.network_ids && let Some(network_id) = event.network_id && !networks.contains(&network_id) @@ -47,8 +82,9 @@ impl EventFilter { return false; } + // Check entity operation filter if let Some(entity_operations) = &self.entity_operations { - if let Some(operations) = entity_operations.get(&event.entity_type) { + if let Some(operations) = entity_operations.get(&event.entity_type.discriminant()) { if operations.is_none() { return true; } else if let Some(operations) = operations @@ -62,11 +98,116 @@ impl EventFilter { true } + + fn matches_auth(&self, event: &AuthEvent) -> bool { + // Check network filter (using organization_id for auth events) + if let Some(networks) = &self.network_ids + && let Some(org_id) = event.organization_id + && !networks.contains(&org_id) + { + return false; + } + + // Check auth operation filter + if let Some(auth_operations) = &self.auth_operations { + return auth_operations.contains(&event.operation); + } + + true + } +} + +/// Internal: Manages batching state for a subscriber +struct SubscriberState { + subscriber: Arc, + pending_events: Arc>>, +} + +impl SubscriberState { + fn new(subscriber: Arc) -> Self { + let debounce_ms = subscriber.debounce_window_ms(); + let pending = Arc::new(RwLock::new(Vec::new())); + + if debounce_ms > 0 { + // Spawn background flush task for subscribers with batching + let pending_clone = pending.clone(); + let subscriber_clone = subscriber.clone(); + let debounce_window = Duration::from_millis(debounce_ms); + + tokio::spawn(async move { + let mut interval = tokio::time::interval(debounce_window); + loop { + interval.tick().await; + Self::flush_batch(&subscriber_clone, &pending_clone).await; + } + }); + } + + Self { + subscriber, + pending_events: pending, + } + } + + async fn flush_batch(subscriber: &Arc, pending: &Arc>>) { + let events: Vec = { + let mut p = pending.write().await; + if p.is_empty() { + return; + } + + // Deduplicate events (requires PartialEq on Event) + let mut unique_events = Vec::new(); + for event in p.drain(..) { + if !unique_events.contains(&event) { + unique_events.push(event); + } + } + unique_events + }; + + if events.is_empty() { + return; + } + + tracing::debug!( + subscriber = %subscriber.name(), + event_count = events.len(), + "Subscriber processing event batch" + ); + + if let Err(e) = subscriber.handle_events(events).await { + tracing::error!( + subscriber = %subscriber.name(), + error = %e, + "Subscriber failed to handle batched events", + ); + } + } + + async fn add_event(&self, event: Event) { + let debounce_window = self.subscriber.debounce_window_ms(); + + if debounce_window == 0 { + // No batching - handle immediately + if let Err(e) = self.subscriber.handle_events(vec![event]).await { + tracing::error!( + subscriber = %self.subscriber.name(), + error = %e, + "Subscriber failed to handle event", + ); + } + } else { + // Add to batch + let mut pending = self.pending_events.write().await; + pending.push(event); + } + } } pub struct EventBus { - sender: broadcast::Sender, - subscribers: Arc>>>, + sender: broadcast::Sender, + subscribers: Arc>>, } impl Default for EventBus { @@ -77,7 +218,7 @@ impl Default for EventBus { impl EventBus { pub fn new() -> Self { - let (sender, _) = broadcast::channel(1000); // Buffer size + let (sender, _) = broadcast::channel(1000); Self { sender, @@ -87,38 +228,56 @@ impl EventBus { /// Register a subscriber pub async fn register_subscriber(&self, subscriber: Arc) { + let state = SubscriberState::new(subscriber.clone()); let mut subscribers = self.subscribers.write().await; - subscribers.push(subscriber.clone()); + subscribers.push(state); + tracing::info!( subscriber = %subscriber.name(), + debounce_ms = subscriber.debounce_window_ms(), "Registered event subscriber", ); } + /// Publish an entity event + pub async fn publish_entity(&self, event: EntityEvent) -> Result<()> { + self.publish(Event::Entity(Box::new(event))).await + } + + /// Publish an auth event + pub async fn publish_auth(&self, event: AuthEvent) -> Result<()> { + self.publish(Event::Auth(event)).await + } + /// Publish an event to all subscribers - pub async fn publish(&self, event: EntityEvent) -> Result<()> { - tracing::debug!( - operation = %event.operation, - entity_type = %event.entity_type, - entity_id = %event.entity_id, - "Publishing event", - ); + async fn publish(&self, event: Event) -> Result<()> { + match &event { + Event::Entity(e) => { + tracing::debug!( + operation = %e.operation, + entity_type = %e.entity_type, + entity_id = %e.entity_id, + "Publishing entity event", + ); + } + Event::Auth(e) => { + tracing::debug!( + operation = ?e.operation, + user_id = ?e.user_id, + "Publishing auth event", + ); + } + } // Send to broadcast channel (non-blocking) let _ = self.sender.send(event.clone()); - // Also notify direct subscribers (blocking, with error handling) + // Notify subscribers let subscribers = self.subscribers.read().await; - for subscriber in subscribers.iter() { - if subscriber.event_filter().matches(&event) - && let Err(e) = subscriber.handle_event(&event).await - { - tracing::error!( - subscriber = subscriber.name(), - error = %e, - "Subscriber failed to handle event", - ); + for state in subscribers.iter() { + if state.subscriber.event_filter().matches(&event) { + state.add_event(event.clone()).await; } } @@ -126,7 +285,7 @@ impl EventBus { } /// Get a receiver for raw event stream (useful for SSE) - pub fn subscribe_channel(&self) -> broadcast::Receiver { + pub fn subscribe_channel(&self) -> broadcast::Receiver { self.sender.subscribe() } } diff --git a/backend/src/server/shared/events/types.rs b/backend/src/server/shared/events/types.rs index 30d5b89d..77f19821 100644 --- a/backend/src/server/shared/events/types.rs +++ b/backend/src/server/shared/events/types.rs @@ -1,18 +1,135 @@ use crate::server::{auth::middleware::AuthenticatedEntity, shared::entities::Entity}; use chrono::{DateTime, Utc}; use serde::Serialize; -use std::fmt::Display; +use std::{fmt::Display, net::IpAddr}; use uuid::Uuid; +#[derive(Debug, Clone, Serialize)] +pub enum Event { + Entity(Box), + Auth(AuthEvent), +} + +impl Event { + pub fn id(&self) -> Uuid { + match self { + Event::Auth(a) => a.id, + Event::Entity(e) => e.id, + } + } + + pub fn log(&self) { + match self { + Event::Entity(event) => { + let network_id_str = event + .network_id + .map(|n| n.to_string()) + .unwrap_or("N/A".to_string()); + let org_id_str = event + .organization_id + .map(|n| n.to_string()) + .unwrap_or("N/A".to_string()); + + tracing::info!( + entity_type = %event.entity_type, + entity_id = %event.entity_id, + network_id = %network_id_str, + organization_id = %org_id_str, + operation = %event.operation, + "Entity Event Logged" + ); + } + Event::Auth(event) => { + let user_id_str = event + .user_id + .map(|n| n.to_string()) + .unwrap_or("N/A".to_string()); + let user_agent_str = event + .user_agent + .as_ref() + .map(|u| u.to_owned()) + .unwrap_or("unknown".to_string()); + let org_id_str = event + .organization_id + .map(|n| n.to_string()) + .unwrap_or("N/A".to_string()); + + tracing::info!( + ip = %event.ip_address, + organization_id = %org_id_str, + user_id = %user_id_str, + user_agent = %user_agent_str, + operation = %event.operation, + "Auth Event Logged" + ); + } + } + } +} + +impl PartialEq for Event { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Event::Auth(a1), Event::Auth(a2)) => a1 == a2, + (Event::Entity(e1), Event::Entity(e2)) => e1 == e2, + _ => false, + } + } +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq, strum::Display)] +pub enum AuthOperation { + Register, + LoginSuccess, + LoginFailed, + PasswordResetRequested, + PasswordResetCompleted, + PasswordChanged, + EmailVerified, + SessionExpired, + OidcLinked, + OidcUnlinked, + LoggedOut, +} + +#[derive(Debug, Clone, Serialize)] +pub struct AuthEvent { + pub id: Uuid, + pub user_id: Option, // None for failed login with unknown user + pub organization_id: Option, + pub operation: AuthOperation, + pub timestamp: DateTime, + pub ip_address: IpAddr, + pub user_agent: Option, + pub metadata: serde_json::Value, + pub authentication: AuthenticatedEntity, +} + +impl PartialEq for AuthEvent { + fn eq(&self, other: &Self) -> bool { + self.user_id == other.user_id + && self.organization_id == other.organization_id + && self.operation == other.operation + && self.ip_address == other.ip_address + && self.user_agent == other.user_agent + && self.metadata == other.metadata + && self.authentication == other.authentication + } +} + #[derive(Debug, Clone, Serialize, PartialEq, Eq, strum::Display)] pub enum EntityOperation { + Get, + GetAll, Created, Updated, Deleted, + DiscoveryStarted, + DiscoveryCancelled, Custom(&'static str), } -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Eq)] pub struct EntityEvent { pub id: Uuid, pub entity_type: Entity, @@ -22,11 +139,20 @@ pub struct EntityEvent { pub operation: EntityOperation, pub timestamp: DateTime, pub authentication: AuthenticatedEntity, - - // Optional: store change details pub metadata: serde_json::Value, } +impl PartialEq for EntityEvent { + fn eq(&self, other: &Self) -> bool { + self.entity_id == other.entity_id + && self.network_id == other.network_id + && self.organization_id == other.organization_id + && self.operation == other.operation + && self.authentication == other.authentication + && self.metadata == other.metadata + } +} + impl Display for EntityEvent { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( diff --git a/backend/src/server/shared/handlers/factory.rs b/backend/src/server/shared/handlers/factory.rs index e19de7fe..66138598 100644 --- a/backend/src/server/shared/handlers/factory.rs +++ b/backend/src/server/shared/handlers/factory.rs @@ -10,7 +10,8 @@ use crate::server::hosts::r#impl::ports::PortBase; use crate::server::networks::r#impl::{Network, NetworkBase}; use crate::server::organizations::r#impl::base::Organization; use crate::server::services::definitions::ServiceDefinitionRegistry; -use crate::server::shared::entities::Entity; +use crate::server::shared::concepts::Concept; +use crate::server::shared::entities::EntityDiscriminants; use crate::server::shared::services::traits::CrudService; use crate::server::shared::storage::traits::StorableEntity; use crate::server::shared::types::api::{ApiError, ApiResult}; @@ -68,7 +69,10 @@ async fn get_metadata_registry(_user: AuthenticatedUser) -> Json Deserialize<'de> where - Self: Display, + Self: Display + ChangeTriggersTopologyStaleness, + Entity: From, { /// Get the service from AppState (must implement CrudService) type Service: CrudService + Send + Sync; @@ -42,7 +44,8 @@ where /// Create a standard CRUD router pub fn create_crud_router() -> Router> where - T: CrudHandlers + 'static, + T: CrudHandlers + 'static + ChangeTriggersTopologyStaleness, + Entity: From, { Router::new() .route("/", post(create_handler::)) @@ -58,7 +61,8 @@ pub async fn create_handler( Json(request): Json, ) -> ApiResult>> where - T: CrudHandlers + 'static, + T: CrudHandlers + 'static + ChangeTriggersTopologyStaleness, + Entity: From, { if let Err(err) = request.validate() { tracing::warn!( @@ -94,13 +98,6 @@ where ApiError::internal_error(&e.to_string()) })?; - tracing::info!( - entity_type = T::table_name(), - entity_id = %created.id(), - user_id = %user.user_id, - "Entity created via API" - ); - Ok(Json(ApiResponse::success(created))) } @@ -109,7 +106,8 @@ pub async fn get_all_handler( user: AuthenticatedUser, ) -> ApiResult>>> where - T: CrudHandlers + 'static, + T: CrudHandlers + 'static + ChangeTriggersTopologyStaleness, + Entity: From, { tracing::debug!( entity_type = T::table_name(), @@ -131,13 +129,6 @@ where ApiError::internal_error(&e.to_string()) })?; - tracing::debug!( - entity_type = T::table_name(), - user_id = %user.user_id, - count = %entities.len(), - "Entities fetched successfully" - ); - Ok(Json(ApiResponse::success(entities))) } @@ -147,7 +138,8 @@ pub async fn get_by_id_handler( Path(id): Path, ) -> ApiResult>> where - T: CrudHandlers + 'static, + T: CrudHandlers + 'static + ChangeTriggersTopologyStaleness, + Entity: From, { tracing::debug!( entity_type = T::table_name(), @@ -180,13 +172,6 @@ where ApiError::not_found(format!("{} '{}' not found", T::entity_name(), id)) })?; - tracing::debug!( - entity_type = T::table_name(), - entity_id = %id, - user_id = %user.user_id, - "Entity fetched successfully" - ); - Ok(Json(ApiResponse::success(entity))) } @@ -197,7 +182,8 @@ pub async fn update_handler( Json(mut request): Json, ) -> ApiResult>> where - T: CrudHandlers + 'static, + T: CrudHandlers + 'static + ChangeTriggersTopologyStaleness, + Entity: From, { tracing::debug!( entity_type = T::table_name(), @@ -246,13 +232,6 @@ where ApiError::internal_error(&e.to_string()) })?; - tracing::info!( - entity_type = T::table_name(), - entity_id = %id, - user_id = %user.user_id, - "Entity updated via API" - ); - Ok(Json(ApiResponse::success(updated))) } @@ -262,7 +241,8 @@ pub async fn delete_handler( Path(id): Path, ) -> ApiResult>> where - T: CrudHandlers + 'static, + T: CrudHandlers + 'static + ChangeTriggersTopologyStaleness, + Entity: From, { let service = T::get_service(&state); @@ -288,7 +268,7 @@ where ApiError::not_found(format!("{} '{}' not found", T::entity_name(), id)) })?; - tracing::info!( + tracing::debug!( entity_type = T::table_name(), entity_id = %id, entity_name = %entity, diff --git a/backend/src/server/shared/mod.rs b/backend/src/server/shared/mod.rs index cb3e9382..7c2f76ae 100644 --- a/backend/src/server/shared/mod.rs +++ b/backend/src/server/shared/mod.rs @@ -1,3 +1,4 @@ +pub mod concepts; pub mod entities; pub mod events; pub mod handlers; diff --git a/backend/src/server/shared/services/factory.rs b/backend/src/server/shared/services/factory.rs index 3783e46e..842d7b9f 100644 --- a/backend/src/server/shared/services/factory.rs +++ b/backend/src/server/shared/services/factory.rs @@ -8,6 +8,7 @@ use crate::server::{ email::service::EmailService, groups::service::GroupService, hosts::service::HostService, + logging::service::LoggingService, networks::service::NetworkService, organizations::service::OrganizationService, services::service::ServiceService, @@ -36,12 +37,15 @@ pub struct ServiceFactory { pub billing_service: Option>, pub email_service: Option>, pub event_bus: Arc, + pub logging_service: Arc, } impl ServiceFactory { pub async fn new(storage: &StorageFactory, config: Option) -> Result { let event_bus = Arc::new(EventBus::new()); + let logging_service = Arc::new(LoggingService::new()); + let api_key_service = Arc::new(ApiKeyService::new( storage.api_keys.clone(), event_bus.clone(), @@ -79,7 +83,6 @@ impl ServiceFactory { let subnet_service = Arc::new(SubnetService::new( storage.subnets.clone(), - host_service.clone(), event_bus.clone(), )); @@ -133,6 +136,7 @@ impl ServiceFactory { user_service.clone(), organization_service.clone(), email_service.clone(), + event_bus.clone(), )); let oidc_service = config.and_then(|c| { @@ -149,14 +153,16 @@ impl ServiceFactory { &c.oidc_client_secret, &c.oidc_provider_name, ) { - return Some(Arc::new(OidcService::new( - issuer_url.to_owned(), - client_id.to_owned(), - client_secret.to_owned(), - redirect_url.to_owned(), - provider_name.to_owned(), - auth_service.clone(), - ))); + return Some(Arc::new(OidcService::new(OidcService { + issuer_url: issuer_url.to_owned(), + client_id: client_id.to_owned(), + client_secret: client_secret.to_owned(), + redirect_url: redirect_url.to_owned(), + provider_name: provider_name.to_owned(), + auth_service: auth_service.clone(), + user_service: user_service.clone(), + event_bus: event_bus.clone(), + }))); } None }); @@ -165,6 +171,10 @@ impl ServiceFactory { .register_subscriber(topology_service.clone()) .await; + event_bus.register_subscriber(logging_service.clone()).await; + + event_bus.register_subscriber(host_service.clone()).await; + Ok(Self { user_service, auth_service, @@ -182,6 +192,7 @@ impl ServiceFactory { billing_service, email_service, event_bus, + logging_service, }) } } diff --git a/backend/src/server/shared/services/traits.rs b/backend/src/server/shared/services/traits.rs index d08f0d40..8b00be9e 100644 --- a/backend/src/server/shared/services/traits.rs +++ b/backend/src/server/shared/services/traits.rs @@ -1,3 +1,4 @@ +use anyhow::anyhow; use async_trait::async_trait; use chrono::Utc; use std::{fmt::Display, sync::Arc}; @@ -6,7 +7,7 @@ use uuid::Uuid; use crate::server::{ auth::middleware::AuthenticatedEntity, shared::{ - entities::Entity, + entities::{ChangeTriggersTopologyStaleness, Entity}, events::{ bus::EventBus, types::{EntityEvent, EntityOperation}, @@ -19,23 +20,24 @@ use crate::server::{ }, }; +pub trait EventBusService> { + /// Event bus and helpers + fn event_bus(&self) -> &Arc; + + fn get_network_id(&self, entity: &T) -> Option; + fn get_organization_id(&self, entity: &T) -> Option; +} + /// Helper trait for services that use generic storage /// Provides default implementations for common CRUD operations #[async_trait] -pub trait CrudService +pub trait CrudService>: EventBusService where - T: Display, + T: Display + ChangeTriggersTopologyStaleness, { /// Get reference to the storage fn storage(&self) -> &Arc>; - /// Event bus and helpers - fn event_bus(&self) -> &Arc; - - fn entity_type() -> Entity; - fn get_network_id(&self, entity: &T) -> Option; - fn get_organization_id(&self, entity: &T) -> Option; - /// Get entity by ID async fn get_by_id(&self, id: &Uuid) -> Result, anyhow::Error> { self.storage().get_by_id(id).await @@ -60,26 +62,24 @@ where if let Some(entity) = self.get_by_id(id).await? { self.storage().delete(id).await?; + let trigger_stale = entity.triggers_staleness(None); + self.event_bus() - .publish(EntityEvent { + .publish_entity(EntityEvent { id: Uuid::new_v4(), - entity_type: Self::entity_type(), entity_id: *id, network_id: self.get_network_id(&entity), organization_id: self.get_organization_id(&entity), + entity_type: entity.into(), operation: EntityOperation::Deleted, timestamp: Utc::now(), - metadata: serde_json::json!({}), + metadata: serde_json::json!({ + "trigger_stale": trigger_stale + }), authentication, }) .await?; - tracing::info!( - entity_type = T::table_name(), - entity_id = %id, - "Entity deleted" - ); - Ok(()) } else { Err(anyhow::anyhow!( @@ -103,27 +103,24 @@ where }; let created = self.storage().create(&entity).await?; + let trigger_stale = created.triggers_staleness(None); self.event_bus() - .publish(EntityEvent { + .publish_entity(EntityEvent { id: Uuid::new_v4(), - entity_type: Self::entity_type(), entity_id: created.id(), network_id: self.get_network_id(&created), organization_id: self.get_organization_id(&created), + entity_type: created.clone().into(), operation: EntityOperation::Created, timestamp: Utc::now(), - metadata: serde_json::json!({}), + metadata: serde_json::json!({ + "trigger_stale": trigger_stale + }), authentication, }) .await?; - tracing::info!( - entity_type = T::table_name(), - entity_id = %created.id(), - "Entity created" - ); - Ok(created) } @@ -133,29 +130,30 @@ where entity: &mut T, authentication: AuthenticatedEntity, ) -> Result { + let current = self + .get_by_id(&entity.id()) + .await? + .ok_or_else(|| anyhow!("Could not find {}", entity))?; let updated = self.storage().update(entity).await?; + let trigger_stale = updated.triggers_staleness(Some(current)); + self.event_bus() - .publish(EntityEvent { + .publish_entity(EntityEvent { id: Uuid::new_v4(), - entity_type: Self::entity_type(), entity_id: updated.id(), network_id: self.get_network_id(&updated), organization_id: self.get_organization_id(&updated), + entity_type: updated.clone().into(), operation: EntityOperation::Updated, timestamp: Utc::now(), - metadata: serde_json::json!({}), + metadata: serde_json::json!({ + "trigger_stale": trigger_stale + }), authentication, }) .await?; - tracing::info!( - entity_type = T::table_name(), - entity_id = %updated.id(), - entity_name = %updated, - "Entity updated" - ); - Ok(updated) } } diff --git a/backend/src/server/shared/storage/generic.rs b/backend/src/server/shared/storage/generic.rs index a0cac496..ee9d5c14 100644 --- a/backend/src/server/shared/storage/generic.rs +++ b/backend/src/server/shared/storage/generic.rs @@ -117,7 +117,7 @@ where } query.execute(&self.pool).await?; - tracing::info!("Created {}: {}", T::table_name(), entity); + tracing::debug!("Created {}: {}", T::table_name(), entity); Ok(entity.clone()) } @@ -173,7 +173,7 @@ where query = Self::bind_value(query, value)?; } - tracing::info!("Updated {}", entity); + tracing::debug!("Updated {}", entity); query.execute(&self.pool).await?; Ok(entity.clone()) @@ -184,7 +184,7 @@ where sqlx::query(&query_str).bind(id).execute(&self.pool).await?; - tracing::info!("Deleted {} with id: {}", T::table_name(), id); + tracing::debug!("Deleted {} with id: {}", T::table_name(), id); Ok(()) } diff --git a/backend/src/server/shared/types/metadata.rs b/backend/src/server/shared/types/metadata.rs index 01c50247..ba2ba464 100644 --- a/backend/src/server/shared/types/metadata.rs +++ b/backend/src/server/shared/types/metadata.rs @@ -12,6 +12,7 @@ pub struct MetadataRegistry { pub billing_plans: Vec, pub features: Vec, pub permissions: Vec, + pub concepts: Vec, } #[derive(Serialize, Debug, Clone)] diff --git a/backend/src/server/subnets/handlers.rs b/backend/src/server/subnets/handlers.rs index 562dc374..d5f603b4 100644 --- a/backend/src/server/subnets/handlers.rs +++ b/backend/src/server/subnets/handlers.rs @@ -77,15 +77,10 @@ async fn get_all_subnets( entity: AuthenticatedEntity, ) -> ApiResult>>> { match &entity { - AuthenticatedEntity::User { - user_id, - network_ids, - .. - } => { + AuthenticatedEntity::User { user_id, .. } => { tracing::debug!( entity_type = "subnet", user_id = %user_id, - network_count = %network_ids.len(), "Get all request received" ); } @@ -93,13 +88,12 @@ async fn get_all_subnets( tracing::debug!( entity_type = "subnet", daemon_id = %entity.entity_id(), - network_count = 1, "Get all request received" ); } - AuthenticatedEntity::System => { + _ => { return Err(ApiError::internal_error( - "System should not authenticate for requests to /subnets/", + "Invalid authentication for request to /subnets/", )); } } @@ -133,9 +127,9 @@ async fn get_all_subnets( "Entities fetched successfully" ); } - AuthenticatedEntity::System => { + _ => { return Err(ApiError::internal_error( - "System should not authenticate for requests to /subnets/", + "Invalid authentication for request to /subnets/", )); } } diff --git a/backend/src/server/subnets/impl/base.rs b/backend/src/server/subnets/impl/base.rs index 7dc22b16..c0a33a2f 100644 --- a/backend/src/server/subnets/impl/base.rs +++ b/backend/src/server/subnets/impl/base.rs @@ -2,6 +2,7 @@ use std::fmt::Display; use std::net::Ipv4Addr; use crate::server::discovery::r#impl::types::DiscoveryType; +use crate::server::shared::entities::ChangeTriggersTopologyStaleness; use crate::server::shared::storage::traits::StorableEntity; use crate::server::shared::types::api::deserialize_empty_string_as_none; use crate::server::shared::types::entities::{DiscoveryMetadata, EntitySource}; @@ -137,3 +138,9 @@ impl Display for Subnet { write!(f, "Subnet {}: {}", self.base.name, self.id) } } + +impl ChangeTriggersTopologyStaleness for Subnet { + fn triggers_staleness(&self, _other: Option) -> bool { + false + } +} diff --git a/backend/src/server/subnets/impl/types.rs b/backend/src/server/subnets/impl/types.rs index f9a6afab..bb26e390 100644 --- a/backend/src/server/subnets/impl/types.rs +++ b/backend/src/server/subnets/impl/types.rs @@ -2,7 +2,8 @@ use serde::{Deserialize, Serialize}; use strum::{Display, EnumDiscriminants, EnumIter, IntoStaticStr}; use crate::server::shared::{ - entities::Entity, + concepts::Concept, + entities::EntityDiscriminants, types::metadata::{EntityMetadataProvider, HasId, TypeMetadataProvider}, }; @@ -131,20 +132,20 @@ impl EntityMetadataProvider for SubnetType { fn color(&self) -> &'static str { match self { SubnetType::Internet => "blue", - SubnetType::Remote => Entity::Subnet.color(), + SubnetType::Remote => EntityDiscriminants::Subnet.color(), - SubnetType::Gateway => Entity::Gateway.color(), - SubnetType::VpnTunnel => Entity::Vpn.color(), + SubnetType::Gateway => Concept::Gateway.color(), + SubnetType::VpnTunnel => Concept::Vpn.color(), SubnetType::Dmz => "rose", - SubnetType::Lan => Entity::Subnet.color(), - SubnetType::IoT => Entity::IoT.color(), + SubnetType::Lan => EntityDiscriminants::Subnet.color(), + SubnetType::IoT => Concept::IoT.color(), SubnetType::Guest => "green", SubnetType::WiFi => "teal", SubnetType::Management => "gray", - SubnetType::DockerBridge => Entity::Virtualization.color(), - SubnetType::Storage => Entity::Storage.color(), + SubnetType::DockerBridge => Concept::Virtualization.color(), + SubnetType::Storage => Concept::Storage.color(), SubnetType::Unknown => "gray", SubnetType::None => "gray", @@ -153,23 +154,23 @@ impl EntityMetadataProvider for SubnetType { fn icon(&self) -> &'static str { match self { SubnetType::Internet => "Globe", - SubnetType::Remote => Entity::Subnet.icon(), + SubnetType::Remote => EntityDiscriminants::Subnet.icon(), - SubnetType::Gateway => Entity::Gateway.icon(), - SubnetType::VpnTunnel => Entity::Vpn.icon(), - SubnetType::Dmz => Entity::Subnet.icon(), + SubnetType::Gateway => Concept::Gateway.icon(), + SubnetType::VpnTunnel => Concept::Vpn.icon(), + SubnetType::Dmz => EntityDiscriminants::Subnet.icon(), - SubnetType::Lan => Entity::Subnet.icon(), - SubnetType::IoT => Entity::IoT.icon(), + SubnetType::Lan => EntityDiscriminants::Subnet.icon(), + SubnetType::IoT => Concept::IoT.icon(), SubnetType::Guest => "User", SubnetType::WiFi => "WiFi", SubnetType::Management => "ServerCog", SubnetType::DockerBridge => "Box", - SubnetType::Storage => Entity::Storage.icon(), + SubnetType::Storage => Concept::Storage.icon(), - SubnetType::Unknown => Entity::Subnet.icon(), - SubnetType::None => Entity::Subnet.icon(), + SubnetType::Unknown => EntityDiscriminants::Subnet.icon(), + SubnetType::None => EntityDiscriminants::Subnet.icon(), } } } diff --git a/backend/src/server/subnets/service.rs b/backend/src/server/subnets/service.rs index 7a1e7646..6275b43c 100644 --- a/backend/src/server/subnets/service.rs +++ b/backend/src/server/subnets/service.rs @@ -1,14 +1,13 @@ use crate::server::{ auth::middleware::AuthenticatedEntity, discovery::r#impl::types::DiscoveryType, - hosts::service::HostService, shared::{ - entities::Entity, + entities::ChangeTriggersTopologyStaleness, events::{ bus::EventBus, types::{EntityEvent, EntityOperation}, }, - services::traits::CrudService, + services::traits::{CrudService, EventBusService}, storage::{ filter::EntityFilter, generic::GenericPostgresStorage, @@ -26,29 +25,27 @@ use uuid::Uuid; pub struct SubnetService { storage: Arc>, - host_service: Arc, event_bus: Arc, } -#[async_trait] -impl CrudService for SubnetService { - fn storage(&self) -> &Arc> { - &self.storage - } - +impl EventBusService for SubnetService { fn event_bus(&self) -> &Arc { &self.event_bus } - fn entity_type() -> Entity { - Entity::Subnet - } fn get_network_id(&self, entity: &Subnet) -> Option { Some(entity.base.network_id) } fn get_organization_id(&self, _entity: &Subnet) -> Option { None } +} + +#[async_trait] +impl CrudService for SubnetService { + fn storage(&self) -> &Arc> { + &self.storage + } async fn create( &self, @@ -129,109 +126,33 @@ impl CrudService for SubnetService { _ => { let created = self.storage.create(&subnet).await?; + let trigger_stale = created.triggers_staleness(None); + self.event_bus() - .publish(EntityEvent { + .publish_entity(EntityEvent { id: Uuid::new_v4(), - entity_type: Entity::Subnet, entity_id: created.id, network_id: self.get_network_id(&created), organization_id: self.get_organization_id(&created), + entity_type: created.into(), operation: EntityOperation::Created, timestamp: Utc::now(), - metadata: serde_json::json!({}), + metadata: serde_json::json!({ + "trigger_stale": trigger_stale + }), authentication, }) .await?; - tracing::info!( - subnet_id = %subnet.id, - subnet_name = %subnet.base.name, - subnet_cidr = %subnet.base.cidr, - network_id = %subnet.base.network_id, - "Subnet created" - ); subnet } }; Ok(subnet_from_storage) } - - async fn delete(&self, id: &Uuid, authentication: AuthenticatedEntity) -> Result<()> { - let subnet = self - .get_by_id(id) - .await? - .ok_or_else(|| anyhow::anyhow!("Subnet not found"))?; - - tracing::info!( - subnet_id = %subnet.id, - subnet_name = %subnet.base.name, - subnet_cidr = %subnet.base.cidr, - "Deleting subnet" - ); - - let filter = EntityFilter::unfiltered().network_ids(&[subnet.base.network_id]); - - let hosts = self.host_service.get_all(filter).await?; - let mut updated_count = 0; - for mut host in hosts { - let has_subnet = host.base.interfaces.iter().any(|i| &i.base.subnet_id == id); - if has_subnet { - host.base.interfaces = host - .base - .interfaces - .iter() - .filter(|i| &i.base.subnet_id != id) - .cloned() - .collect(); - self.host_service - .update(&mut host, authentication.clone()) - .await?; - updated_count += 1; - } - } - - tracing::debug!( - subnet_id = %subnet.id, - affected_hosts = %updated_count, - "Cleaned up host interfaces referencing subnet" - ); - - self.storage.delete(id).await?; - - self.event_bus() - .publish(EntityEvent { - id: Uuid::new_v4(), - entity_type: Entity::Subnet, - entity_id: subnet.id, - network_id: self.get_network_id(&subnet), - organization_id: self.get_organization_id(&subnet), - operation: EntityOperation::Deleted, - timestamp: Utc::now(), - metadata: serde_json::json!({}), - authentication, - }) - .await?; - - tracing::info!( - subnet_id = %subnet.id, - subnet_name = %subnet.base.name, - affected_hosts = %updated_count, - "Subnet deleted" - ); - Ok(()) - } } impl SubnetService { - pub fn new( - storage: Arc>, - host_service: Arc, - event_bus: Arc, - ) -> Self { - Self { - storage, - host_service, - event_bus, - } + pub fn new(storage: Arc>, event_bus: Arc) -> Self { + Self { storage, event_bus } } } diff --git a/backend/src/server/topology/handlers.rs b/backend/src/server/topology/handlers.rs index 81807c02..71172047 100644 --- a/backend/src/server/topology/handlers.rs +++ b/backend/src/server/topology/handlers.rs @@ -9,7 +9,7 @@ use crate::server::{ storage::traits::StorableEntity, types::api::{ApiError, ApiResponse, ApiResult}, }, - topology::types::base::Topology, + topology::{service::main::BuildGraphParams, types::base::Topology}, }; use axum::{ Router, @@ -31,6 +31,7 @@ pub fn create_router() -> Router> { .route("/{id}", delete(delete_handler::)) .route("/{id}", get(get_by_id_handler::)) .route("/{id}/refresh", post(refresh)) + .route("/{id}/rebuild", post(rebuild)) .route("/{id}/lock", post(lock)) .route("/{id}/unlock", post(unlock)) .route("/stream", get(staleness_stream)) @@ -63,12 +64,18 @@ pub async fn create_handler( let service = Topology::get_service(&state); - let (hosts, services, subnets, groups) = service - .get_entity_data(topology.base.network_id, topology.base.options.clone()) - .await?; - - let (nodes, edges) = - service.build_graph(&topology.base.options, &hosts, &subnets, &services, &groups); + let (hosts, services, subnets, groups) = + service.get_entity_data(topology.base.network_id).await?; + + let (nodes, edges) = service.build_graph(BuildGraphParams { + options: &topology.base.options, + hosts: &hosts, + subnets: &subnets, + services: &services, + groups: &groups, + old_edges: &[], + old_nodes: &[], + }); topology.base.hosts = hosts; topology.base.services = services; @@ -101,6 +108,7 @@ pub async fn create_handler( Ok(Json(ApiResponse::success(created))) } +/// Refresh entity data. Only used when cosmetic properties (ie group color/line routing, entity names) are changed async fn refresh( State(state): State>, RequireMember(user): RequireMember, @@ -108,12 +116,39 @@ async fn refresh( ) -> ApiResult>> { let service = Topology::get_service(&state); - let (hosts, services, subnets, groups) = service - .get_entity_data(topology.base.network_id, topology.base.options.clone()) - .await?; + let (hosts, services, subnets, groups) = + service.get_entity_data(topology.base.network_id).await?; + + topology.base.hosts = hosts; + topology.base.services = services; + topology.base.subnets = subnets; + topology.base.groups = groups; + + let updated = service.update(&mut topology, user.into()).await?; - let (nodes, edges) = - service.build_graph(&topology.base.options, &hosts, &subnets, &services, &groups); + Ok(Json(ApiResponse::success(updated))) +} + +/// Recalculate node and edges and refresh entity data +async fn rebuild( + State(state): State>, + RequireMember(user): RequireMember, + Json(mut topology): Json, +) -> ApiResult>> { + let service = Topology::get_service(&state); + + let (hosts, services, subnets, groups) = + service.get_entity_data(topology.base.network_id).await?; + + let (nodes, edges) = service.build_graph(BuildGraphParams { + options: &topology.base.options, + hosts: &hosts, + subnets: &subnets, + services: &services, + groups: &groups, + old_nodes: &topology.base.nodes, + old_edges: &topology.base.edges, + }); topology.base.hosts = hosts; topology.base.services = services; diff --git a/backend/src/server/topology/service/context.rs b/backend/src/server/topology/service/context.rs index bd717f38..cd4c5607 100644 --- a/backend/src/server/topology/service/context.rs +++ b/backend/src/server/topology/service/context.rs @@ -19,7 +19,7 @@ use crate::server::{ pub struct TopologyContext<'a> { pub hosts: &'a [Host], pub subnets: &'a [Subnet], - pub services: &'a [Service], + services: &'a [Service], pub groups: &'a [Group], pub options: &'a TopologyOptions, } @@ -45,6 +45,20 @@ impl<'a> TopologyContext<'a> { // Data Access Methods // ============================================================================ + pub fn services(&self) -> Vec { + self.services + .iter() + .filter(|s| { + !self + .options + .request + .hide_service_categories + .contains(&s.base.service_definition.category()) + }) + .cloned() + .collect() + } + pub fn get_subnet_by_id(&self, subnet_id: Uuid) -> Option<&'a Subnet> { self.subnets.iter().find(|s| s.id == subnet_id) } diff --git a/backend/src/server/topology/service/edge_builder.rs b/backend/src/server/topology/service/edge_builder.rs index e5e19290..42fa22eb 100644 --- a/backend/src/server/topology/service/edge_builder.rs +++ b/backend/src/server/topology/service/edge_builder.rs @@ -65,7 +65,7 @@ impl EdgeBuilder { let mut docker_service_to_containerized_service_ids: HashMap> = HashMap::new(); - ctx.services.iter().for_each(|s| { + ctx.services().iter().for_each(|s| { if let Some(ServiceVirtualization::Docker(docker_virtualization)) = &s.base.virtualization { @@ -79,7 +79,7 @@ impl EdgeBuilder { }); let edges = ctx - .services + .services() .iter() .filter(|s| { docker_service_to_containerized_service_ids @@ -140,6 +140,7 @@ impl EdgeBuilder { if ctx.interface_will_have_node(&origin_interface.id) { return vec![Edge { + id: Uuid::new_v4(), source: origin_interface.id, target: *first_subnet_id, edge_type: EdgeType::ServiceVirtualization { @@ -186,6 +187,7 @@ impl EdgeBuilder { && ctx.interface_will_have_node(&container_binding_interface_id) { return Some(Edge { + id: Uuid::new_v4(), source: origin_interface.id, target: container_binding_interface_id, edge_type: EdgeType::ServiceVirtualization { @@ -277,6 +279,7 @@ impl EdgeBuilder { )?; return Some(Edge { + id: Uuid::new_v4(), source: *proxmox_service_interface_id, target: i.id, edge_type: EdgeType::HostVirtualization { @@ -342,6 +345,7 @@ impl EdgeBuilder { )?; Some(Edge { + id: Uuid::new_v4(), source: origin_interface.id, target: interface.id, edge_type: EdgeType::Interface { host_id: host.id }, @@ -411,14 +415,14 @@ impl EdgeBuilder { target_binding_id: Uuid, group: &Group, ) -> Option { - let source_interface = ctx.services.iter().find_map(|s| { + let source_interface = ctx.services().iter().find_map(|s| { if let Some(source_binding) = s.get_binding(source_binding_id) { return Some(source_binding.interface_id()); } None }); - let target_interface = ctx.services.iter().find_map(|s| { + let target_interface = ctx.services().iter().find_map(|s| { if let Some(target_binding) = s.get_binding(target_binding_id) { return Some(target_binding.interface_id()); } @@ -453,6 +457,7 @@ impl EdgeBuilder { }; return Some(Edge { + id: Uuid::new_v4(), source: source_interface, target: target_interface, edge_type: match group.base.group_type { diff --git a/backend/src/server/topology/service/main.rs b/backend/src/server/topology/service/main.rs index 238aad15..863ba044 100644 --- a/backend/src/server/topology/service/main.rs +++ b/backend/src/server/topology/service/main.rs @@ -2,7 +2,7 @@ use std::{collections::HashMap, sync::Arc}; use anyhow::Error; use async_trait::async_trait; -use petgraph::{Graph, graph::NodeIndex}; +use petgraph::{Graph, graph::NodeIndex, visit::EdgeRef}; use tokio::sync::broadcast; use uuid::Uuid; @@ -11,9 +11,8 @@ use crate::server::{ hosts::{r#impl::base::Host, service::HostService}, services::{r#impl::base::Service, service::ServiceService}, shared::{ - entities::Entity, events::bus::EventBus, - services::traits::CrudService, + services::traits::{CrudService, EventBusService}, storage::{filter::EntityFilter, generic::GenericPostgresStorage}, }, subnets::{r#impl::base::Subnet, service::SubnetService}, @@ -24,9 +23,8 @@ use crate::server::{ planner::subnet_layout_planner::SubnetLayoutPlanner, }, types::{ - api::TopologyStalenessUpdate, base::{Topology, TopologyOptions}, - edges::Edge, + edges::{Edge, EdgeHandle}, nodes::Node, }, }, @@ -39,22 +37,14 @@ pub struct TopologyService { group_service: Arc, service_service: Arc, event_bus: Arc, - pub staleness_tx: broadcast::Sender, + pub staleness_tx: broadcast::Sender, } -#[async_trait] -impl CrudService for TopologyService { - fn storage(&self) -> &Arc> { - &self.storage - } - +impl EventBusService for TopologyService { fn event_bus(&self) -> &Arc { &self.event_bus } - fn entity_type() -> Entity { - Entity::Topology - } fn get_network_id(&self, entity: &Topology) -> Option { Some(entity.base.network_id) } @@ -63,6 +53,23 @@ impl CrudService for TopologyService { } } +#[async_trait] +impl CrudService for TopologyService { + fn storage(&self) -> &Arc> { + &self.storage + } +} + +pub struct BuildGraphParams<'a> { + pub options: &'a TopologyOptions, + pub hosts: &'a [Host], + pub subnets: &'a [Subnet], + pub services: &'a [Service], + pub groups: &'a [Group], + pub old_nodes: &'a [Node], + pub old_edges: &'a [Edge], +} + impl TopologyService { pub fn new( host_service: Arc, @@ -84,44 +91,35 @@ impl TopologyService { } } - pub fn subscribe_staleness_changes(&self) -> broadcast::Receiver { + pub fn subscribe_staleness_changes(&self) -> broadcast::Receiver { self.staleness_tx.subscribe() } pub async fn get_entity_data( &self, network_id: Uuid, - options: TopologyOptions, ) -> Result<(Vec, Vec, Vec, Vec), Error> { let network_filter = EntityFilter::unfiltered().network_ids(&[network_id]); // Fetch all data let hosts = self.host_service.get_all(network_filter.clone()).await?; let subnets = self.subnet_service.get_all(network_filter.clone()).await?; let groups = self.group_service.get_all(network_filter.clone()).await?; - let services: Vec = self - .service_service - .get_all(network_filter.clone()) - .await? - .into_iter() - .filter(|s| { - !options - .request - .hide_service_categories - .contains(&s.base.service_definition.category()) - }) - .collect(); + let services = self.service_service.get_all(network_filter.clone()).await?; Ok((hosts, services, subnets, groups)) } - pub fn build_graph( - &self, - options: &TopologyOptions, - hosts: &[Host], - subnets: &[Subnet], - services: &[Service], - groups: &[Group], - ) -> (Vec, Vec) { + pub fn build_graph(&self, params: BuildGraphParams) -> (Vec, Vec) { + let BuildGraphParams { + hosts, + subnets, + services, + groups, + old_edges, + old_nodes, + options, + } = params; + // Create context to avoid parameter passing let ctx = TopologyContext::new(hosts, subnets, services, groups, options); @@ -171,6 +169,65 @@ impl TopologyService { // Add edges to graph EdgeBuilder::add_edges_to_graph(&mut graph, &node_indices, optimized_edges); + // Build previous graph to compare and deterine if user edits should be persisted + // If nodes have changed edges, assume they have moved and user edits are no longer applicable + let mut old_graph: Graph = Graph::new(); + let old_node_indices: HashMap = old_nodes + .iter() + .map(|node| { + let node_id = node.id; + let node_idx = old_graph.add_node(node.clone()); + (node_id, node_idx) + }) + .collect(); + + EdgeBuilder::add_edges_to_graph(&mut old_graph, &old_node_indices, old_edges.to_vec()); + + // Create a map of old edges by their source/target for quick lookup + let mut old_edges_map: HashMap<(Uuid, Uuid), &Edge> = HashMap::new(); + for edge_ref in old_graph.edge_references() { + let edge = edge_ref.weight(); + old_edges_map.insert((edge.source, edge.target), edge); + } + + // Preserve handles for nodes with unchanged edge count + // First, collect all the edges that need updating + let mut edges_to_update: Vec<(petgraph::prelude::EdgeIndex, EdgeHandle, EdgeHandle)> = + Vec::new(); + + for node in graph.node_weights() { + if let Some(old_idx) = old_node_indices.get(&node.id) + && let Some(new_idx) = node_indices.get(&node.id) + { + let old_edge_count = old_graph.edges(*old_idx).count(); + let new_edge_count = graph.edges(*new_idx).count(); + + if old_edge_count == new_edge_count { + // Collect edges that match + for edge_ref in graph.edges(*new_idx) { + let new_edge = edge_ref.weight(); + if let Some(old_edge) = + old_edges_map.get(&(new_edge.source, new_edge.target)) + { + edges_to_update.push(( + edge_ref.id(), + old_edge.source_handle, + old_edge.target_handle, + )); + } + } + } + } + } + + // Now apply the updates + for (edge_idx, source_handle, target_handle) in edges_to_update { + if let Some(edge) = graph.edge_weight_mut(edge_idx) { + edge.source_handle = source_handle; + edge.target_handle = target_handle; + } + } + ( graph.node_weights().cloned().collect(), graph.edge_weights().cloned().collect(), diff --git a/backend/src/server/topology/service/planner/subnet_layout_planner.rs b/backend/src/server/topology/service/planner/subnet_layout_planner.rs index 3b2384f1..3cbc9c91 100644 --- a/backend/src/server/topology/service/planner/subnet_layout_planner.rs +++ b/backend/src/server/topology/service/planner/subnet_layout_planner.rs @@ -230,9 +230,9 @@ impl SubnetLayoutPlanner { for interface in &host.base.interfaces { let subnet = ctx.get_subnet_by_id(interface.base.subnet_id); let subnet_type = subnet.map(|s| s.base.subnet_type).unwrap_or_default(); + let services = ctx.services(); - let interface_bound_services: Vec<&Service> = ctx - .services + let interface_bound_services: Vec<&Service> = services .iter() .filter(|s| { // Services with a binding to the interface diff --git a/backend/src/server/topology/service/subscriber.rs b/backend/src/server/topology/service/subscriber.rs index 473dfb01..5fa9d2e2 100644 --- a/backend/src/server/topology/service/subscriber.rs +++ b/backend/src/server/topology/service/subscriber.rs @@ -2,98 +2,174 @@ use std::collections::HashMap; use anyhow::Error; use async_trait::async_trait; +use uuid::Uuid; use crate::server::{ auth::middleware::AuthenticatedEntity, shared::{ - entities::Entity, + entities::{Entity, EntityDiscriminants}, events::{ bus::{EventFilter, EventSubscriber}, - types::{EntityEvent, EntityOperation}, + types::{EntityOperation, Event}, }, services::traits::CrudService, - storage::filter::EntityFilter, + storage::filter::EntityFilter as StorageFilter, }, topology::service::main::TopologyService, }; +#[derive(Default)] +struct TopologyChanges { + updated_hosts: bool, + updated_services: bool, + updated_subnets: bool, + updated_groups: bool, + removed_hosts: std::collections::HashSet, + removed_services: std::collections::HashSet, + removed_subnets: std::collections::HashSet, + removed_groups: std::collections::HashSet, + should_mark_stale: bool, +} + #[async_trait] impl EventSubscriber for TopologyService { fn event_filter(&self) -> EventFilter { - EventFilter { - entity_operations: Some(HashMap::from([ - (Entity::Host, None), - (Entity::Service, None), - (Entity::Subnet, None), - (Entity::Group, None), - ])), - network_ids: None, // All networks - } + EventFilter::entity_only(HashMap::from([ + (EntityDiscriminants::Host, None), + (EntityDiscriminants::Service, None), + (EntityDiscriminants::Subnet, None), + (EntityDiscriminants::Group, None), + ])) } - async fn handle_event(&self, event: &EntityEvent) -> Result<(), Error> { - if let Some(network_id) = event.network_id { - tracing::debug!( - "Topology validation subscriber handling {} event for {}", - event.operation, - event.entity_type - ); - let network_filter = EntityFilter::unfiltered().network_ids(&[network_id]); + async fn handle_events(&self, events: Vec) -> Result<(), Error> { + if events.is_empty() { + return Ok(()); + } + + // Collect all affected network IDs + let mut network_ids = std::collections::HashSet::new(); + // Group events by network_id -> topology changes + let mut topology_updates: HashMap = HashMap::new(); + + for event in events { + if let Event::Entity(entity_event) = event + && let Some(network_id) = entity_event.network_id + { + network_ids.insert(network_id); + + let changes = topology_updates.entry(network_id).or_default(); + + // Track removed entities + if entity_event.operation == EntityOperation::Deleted { + match entity_event.entity_type { + Entity::Host(_) => changes.removed_hosts.insert(entity_event.entity_id), + Entity::Service(_) => { + changes.removed_services.insert(entity_event.entity_id) + } + Entity::Subnet(_) => changes.removed_subnets.insert(entity_event.entity_id), + Entity::Group(_) => changes.removed_groups.insert(entity_event.entity_id), + _ => false, + }; + } + + // Check if any event triggers staleness + let trigger_stale = entity_event + .metadata + .get("trigger_stale") + .and_then(|v| serde_json::from_value::(v.clone()).ok()) + .unwrap_or(false); + + if trigger_stale { + // User will be prompted to update entities + changes.should_mark_stale = true; + } else { + // It's safe to automatically update entities + match entity_event.entity_type { + Entity::Host(_) => changes.updated_hosts = true, + Entity::Service(_) => changes.updated_services = true, + Entity::Subnet(_) => changes.updated_subnets = true, + Entity::Group(_) => changes.updated_groups = true, + _ => (), + }; + } + } + } + + // Apply changes to all topologies in affected networks + for network_id in network_ids { + let network_filter = StorageFilter::unfiltered().network_ids(&[network_id]); let topologies = self.get_all(network_filter).await?; - for mut topology in topologies { - if !topology.base.is_locked { - // Track removed entities for staleness alerts - if event.operation == EntityOperation::Deleted { - match event.entity_type { - Entity::Host => { - topology.base.removed_hosts = { - topology.base.removed_hosts.push(event.entity_id); - topology.base.removed_hosts - } - } - Entity::Service => { - topology.base.removed_services = { - topology.base.removed_services.push(event.entity_id); - topology.base.removed_services - } - } - Entity::Subnet => { - topology.base.removed_subnets = { - topology.base.removed_subnets.push(event.entity_id); - topology.base.removed_subnets - } - } - Entity::Group => { - topology.base.removed_groups = { - topology.base.removed_groups.push(event.entity_id); - topology.base.removed_groups - } - } - _ => (), + if let Some(changes) = topology_updates.get(&network_id) { + for mut topology in topologies { + // Apply removed entities + for host_id in &changes.removed_hosts { + if !topology.base.removed_hosts.contains(host_id) { + topology.base.removed_hosts.push(*host_id); + } + } + for service_id in &changes.removed_services { + if !topology.base.removed_services.contains(service_id) { + topology.base.removed_services.push(*service_id); + } + } + for subnet_id in &changes.removed_subnets { + if !topology.base.removed_subnets.contains(subnet_id) { + topology.base.removed_subnets.push(*subnet_id); + } + } + for group_id in &changes.removed_groups { + if !topology.base.removed_groups.contains(group_id) { + topology.base.removed_groups.push(*group_id); } } - topology.base.is_stale = true; + // Mark stale if needed + if changes.should_mark_stale { + topology.base.is_stale = true; + } + + let (hosts, services, subnets, groups) = + self.get_entity_data(topology.base.network_id).await?; - self.staleness_tx.send(topology.clone().into())?; + if changes.updated_hosts { + topology.base.hosts = hosts + } - self.update(&mut topology, AuthenticatedEntity::System) + if changes.updated_services { + topology.base.services = services + } + + if changes.updated_subnets { + topology.base.subnets = subnets + } + + if changes.updated_groups { + topology.base.groups = groups; + } + + // Update topology in database + let updated = self + .update(&mut topology, AuthenticatedEntity::System) .await?; + + // Send the UPDATED topology to SSE + let _ = self.staleness_tx.send(updated).inspect_err(|e| { + tracing::debug!("Staleness notification skipped (no receivers): {}", e) + }); } } - } else { - tracing::warn!( - entity_type = %event.entity_type, - operation = %event.operation, - "Topology validation subscriber received event with no network_id", - ); } Ok(()) } + fn debounce_window_ms(&self) -> u64 { + 200 // Batch events within 200ms window + } + fn name(&self) -> &str { "topology_validation" } diff --git a/backend/src/server/topology/types/api.rs b/backend/src/server/topology/types/api.rs index ab424d18..9223bb68 100644 --- a/backend/src/server/topology/types/api.rs +++ b/backend/src/server/topology/types/api.rs @@ -4,7 +4,7 @@ use uuid::Uuid; use crate::server::topology::types::base::Topology; #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, Hash)] -pub struct TopologyStalenessUpdate { +pub struct TopologyUpdate { topology_id: Uuid, is_stale: bool, removed_hosts: Vec, @@ -13,14 +13,14 @@ pub struct TopologyStalenessUpdate { removed_groups: Vec, } -impl From for TopologyStalenessUpdate { +impl From for TopologyUpdate { fn from(value: Topology) -> Self { Self { removed_groups: value.base.removed_groups, removed_hosts: value.base.removed_hosts, removed_services: value.base.removed_services, removed_subnets: value.base.removed_subnets, - is_stale: true, + is_stale: value.base.is_stale, topology_id: value.id, } } diff --git a/backend/src/server/topology/types/base.rs b/backend/src/server/topology/types/base.rs index c63ae786..c79343b4 100644 --- a/backend/src/server/topology/types/base.rs +++ b/backend/src/server/topology/types/base.rs @@ -2,6 +2,7 @@ use crate::server::groups::r#impl::base::Group; use crate::server::hosts::r#impl::base::Host; use crate::server::services::r#impl::base::Service; use crate::server::services::r#impl::categories::ServiceCategory; +use crate::server::shared::entities::ChangeTriggersTopologyStaleness; use crate::server::subnets::r#impl::base::Subnet; use crate::server::topology::types::edges::Edge; use crate::server::topology::types::edges::EdgeType; @@ -12,7 +13,16 @@ use std::{fmt::Display, hash::Hash}; use uuid::Uuid; use validator::Validate; -#[derive(Debug, Clone, Validate, Serialize, Deserialize, Eq, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct Topology { + pub id: Uuid, + pub created_at: DateTime, + pub updated_at: DateTime, + #[serde(flatten)] + pub base: TopologyBase, +} + +#[derive(Debug, Clone, Validate, Serialize, Deserialize, Eq, PartialEq, Hash)] pub struct TopologyBase { #[validate(length(min = 0, max = 100))] pub name: String, @@ -48,7 +58,7 @@ impl TopologyBase { subnets: vec![], services: vec![], groups: vec![], - is_stale: false, + is_stale: true, last_refreshed: Utc::now(), is_locked: false, locked_at: None, @@ -62,13 +72,14 @@ impl TopologyBase { } } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Topology { - pub id: Uuid, - pub created_at: DateTime, - pub updated_at: DateTime, - #[serde(flatten)] - pub base: TopologyBase, +impl ChangeTriggersTopologyStaleness for Topology { + fn triggers_staleness(&self, other: Option) -> bool { + if let Some(other_topology) = other { + self.base.options.request != other_topology.base.options.request + } else { + false + } + } } impl Topology { diff --git a/backend/src/server/topology/types/edges.rs b/backend/src/server/topology/types/edges.rs index f05343bb..651c8888 100644 --- a/backend/src/server/topology/types/edges.rs +++ b/backend/src/server/topology/types/edges.rs @@ -1,7 +1,8 @@ use crate::server::{ groups::r#impl::types::GroupTypeDiscriminants, shared::{ - entities::Entity, + concepts::Concept, + entities::EntityDiscriminants, types::metadata::{EntityMetadataProvider, HasId, TypeMetadataProvider}, }, subnets::r#impl::base::Subnet, @@ -13,6 +14,7 @@ use uuid::Uuid; #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] pub struct Edge { + pub id: Uuid, pub source: Uuid, pub target: Uuid, #[serde(flatten)] @@ -263,11 +265,11 @@ impl HasId for EdgeType { impl EntityMetadataProvider for EdgeType { fn color(&self) -> &'static str { match self { - EdgeType::RequestPath { .. } => Entity::Group.color(), - EdgeType::HubAndSpoke { .. } => Entity::Group.color(), - EdgeType::Interface { .. } => Entity::Host.color(), - EdgeType::HostVirtualization { .. } => Entity::Virtualization.color(), - EdgeType::ServiceVirtualization { .. } => Entity::Virtualization.color(), + EdgeType::RequestPath { .. } => EntityDiscriminants::Group.color(), + EdgeType::HubAndSpoke { .. } => EntityDiscriminants::Group.color(), + EdgeType::Interface { .. } => EntityDiscriminants::Host.color(), + EdgeType::HostVirtualization { .. } => Concept::Virtualization.color(), + EdgeType::ServiceVirtualization { .. } => Concept::Virtualization.color(), } } @@ -275,9 +277,9 @@ impl EntityMetadataProvider for EdgeType { match self { EdgeType::RequestPath { .. } => GroupTypeDiscriminants::RequestPath.icon(), EdgeType::HubAndSpoke { .. } => GroupTypeDiscriminants::HubAndSpoke.icon(), - EdgeType::Interface { .. } => Entity::Host.icon(), - EdgeType::HostVirtualization { .. } => Entity::Virtualization.icon(), - EdgeType::ServiceVirtualization { .. } => Entity::Virtualization.icon(), + EdgeType::Interface { .. } => EntityDiscriminants::Host.icon(), + EdgeType::HostVirtualization { .. } => Concept::Virtualization.icon(), + EdgeType::ServiceVirtualization { .. } => Concept::Virtualization.icon(), } } } diff --git a/backend/src/server/users/impl/base.rs b/backend/src/server/users/impl/base.rs index 3cff19c0..f2331950 100644 --- a/backend/src/server/users/impl/base.rs +++ b/backend/src/server/users/impl/base.rs @@ -2,7 +2,10 @@ use std::fmt::Display; use std::str::FromStr; use crate::server::{ - shared::storage::traits::{SqlValue, StorableEntity}, + shared::{ + entities::ChangeTriggersTopologyStaleness, + storage::traits::{SqlValue, StorableEntity}, + }, users::r#impl::permissions::UserOrgPermissions, }; use anyhow::{Error, Result}; @@ -14,7 +17,7 @@ use sqlx::postgres::PgRow; use uuid::Uuid; use validator::Validate; -#[derive(Debug, Clone, Serialize, Deserialize, Validate)] +#[derive(Debug, Clone, Serialize, Deserialize, Validate, PartialEq, Eq, Hash)] pub struct UserBase { pub email: EmailAddress, pub organization_id: Uuid, @@ -93,7 +96,7 @@ impl UserBase { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] pub struct User { pub id: Uuid, pub created_at: DateTime, @@ -115,6 +118,12 @@ impl Display for User { } } +impl ChangeTriggersTopologyStaleness for User { + fn triggers_staleness(&self, _other: Option) -> bool { + false + } +} + impl StorableEntity for User { type BaseData = UserBase; diff --git a/backend/src/server/users/impl/permissions.rs b/backend/src/server/users/impl/permissions.rs index 734669dd..2766a6bb 100644 --- a/backend/src/server/users/impl/permissions.rs +++ b/backend/src/server/users/impl/permissions.rs @@ -3,12 +3,22 @@ use std::{cmp::Ordering, str::FromStr}; use strum::{Display, EnumIter, IntoEnumIterator, IntoStaticStr}; use crate::server::shared::{ - entities::Entity, + entities::EntityDiscriminants, types::metadata::{EntityMetadataProvider, HasId, TypeMetadataProvider}, }; #[derive( - Debug, Clone, Copy, Serialize, Deserialize, Display, PartialEq, Eq, EnumIter, IntoStaticStr, + Debug, + Clone, + Copy, + Serialize, + Deserialize, + Display, + PartialEq, + Eq, + EnumIter, + IntoStaticStr, + Hash, )] pub enum UserOrgPermissions { Owner, @@ -75,11 +85,11 @@ impl HasId for UserOrgPermissions { impl EntityMetadataProvider for UserOrgPermissions { fn color(&self) -> &'static str { - Entity::User.color() + EntityDiscriminants::User.color() } fn icon(&self) -> &'static str { - Entity::User.icon() + EntityDiscriminants::User.icon() } } diff --git a/backend/src/server/users/service.rs b/backend/src/server/users/service.rs index 9b834e0f..36ab6f40 100644 --- a/backend/src/server/users/service.rs +++ b/backend/src/server/users/service.rs @@ -1,12 +1,12 @@ use crate::server::{ auth::middleware::AuthenticatedEntity, shared::{ - entities::Entity, + entities::ChangeTriggersTopologyStaleness, events::{ bus::EventBus, types::{EntityEvent, EntityOperation}, }, - services::traits::CrudService, + services::traits::{CrudService, EventBusService}, storage::{ filter::EntityFilter, generic::GenericPostgresStorage, @@ -27,25 +27,24 @@ pub struct UserService { event_bus: Arc, } -#[async_trait] -impl CrudService for UserService { - fn storage(&self) -> &Arc> { - &self.user_storage - } - +impl EventBusService for UserService { fn event_bus(&self) -> &Arc { &self.event_bus } - fn entity_type() -> Entity { - Entity::User - } fn get_network_id(&self, _entity: &User) -> Option { None } fn get_organization_id(&self, entity: &User) -> Option { Some(entity.base.organization_id) } +} + +#[async_trait] +impl CrudService for UserService { + fn storage(&self) -> &Arc> { + &self.user_storage + } /// Create a new user async fn create(&self, user: User, authentication: AuthenticatedEntity) -> Result { @@ -61,17 +60,20 @@ impl CrudService for UserService { } let created = self.user_storage.create(&User::new(user.base)).await?; + let trigger_stale = created.triggers_staleness(None); self.event_bus() - .publish(EntityEvent { + .publish_entity(EntityEvent { id: Uuid::new_v4(), - entity_type: Entity::User, entity_id: created.id, network_id: self.get_network_id(&created), organization_id: self.get_organization_id(&created), + entity_type: created.clone().into(), operation: EntityOperation::Created, timestamp: Utc::now(), - metadata: serde_json::json!({}), + metadata: serde_json::json!({ + "trigger_stale": trigger_stale + }), authentication, }) .await?; @@ -100,47 +102,4 @@ impl UserService { self.user_storage.get_all(filter).await } - - /// Link OIDC to existing user - pub async fn link_oidc( - &self, - user_id: &Uuid, - oidc_subject: String, - oidc_provider: String, - ) -> Result { - let mut user = self - .get_by_id(user_id) - .await? - .ok_or_else(|| anyhow::anyhow!("User not found"))?; - - user.base.oidc_provider = Some(oidc_provider); - user.base.oidc_subject = Some(oidc_subject); - user.base.oidc_linked_at = Some(chrono::Utc::now()); - - self.user_storage.update(&mut user).await?; - Ok(user) - } - - /// Unlink OIDC from user (requires password to be set) - pub async fn unlink_oidc(&self, user_id: &Uuid) -> Result { - let mut user = self - .get_by_id(user_id) - .await? - .ok_or_else(|| anyhow::anyhow!("User not found"))?; - - // Require password before unlinking - if user.base.password_hash.is_none() { - return Err(anyhow::anyhow!( - "Cannot unlink OIDC - no password set. Set a password first." - )); - } - - user.base.oidc_provider = None; - user.base.oidc_subject = None; - user.base.oidc_linked_at = None; - user.updated_at = chrono::Utc::now(); - - self.user_storage.update(&mut user).await?; - Ok(user) - } } diff --git a/ui/src/lib/features/api_keys/components/ApiKeyCard.svelte b/ui/src/lib/features/api_keys/components/ApiKeyCard.svelte index 99b126f4..b8d8d0e9 100644 --- a/ui/src/lib/features/api_keys/components/ApiKeyCard.svelte +++ b/ui/src/lib/features/api_keys/components/ApiKeyCard.svelte @@ -44,15 +44,15 @@ ], actions: [ { - label: 'Delete Api Key', + label: 'Delete', icon: Trash2, class: 'btn-icon-danger', onClick: () => onDelete(apiKey) }, { - label: 'Edit Api Key', + label: 'Edit', icon: Edit, - class: 'btn-icon-danger', + class: 'btn-icon', onClick: () => onEdit(apiKey) } ] diff --git a/ui/src/lib/features/daemons/components/DaemonCard.svelte b/ui/src/lib/features/daemons/components/DaemonCard.svelte index d9fd18da..f51f80e5 100644 --- a/ui/src/lib/features/daemons/components/DaemonCard.svelte +++ b/ui/src/lib/features/daemons/components/DaemonCard.svelte @@ -3,7 +3,7 @@ import type { Daemon } from '$lib/features/daemons/types/base'; import { getDaemonIsRunningDiscovery } from '$lib/features/daemons/store'; import { sessions } from '$lib/features/discovery/sse'; - import { entities } from '$lib/shared/stores/metadata'; + import { concepts, entities } from '$lib/shared/stores/metadata'; import { networks } from '$lib/features/networks/store'; import { formatTimestamp } from '$lib/shared/utils/formatting'; import { getHostFromId } from '$lib/features/hosts/store'; @@ -52,7 +52,7 @@ ? { id: daemon.id, label: 'True', - color: entities.getColorHelper('Virtualization').string + color: concepts.getColorHelper('Virtualization').string } : { id: daemon.id, @@ -86,7 +86,7 @@ ], actions: [ { - label: 'Delete Daemon', + label: 'Delete', icon: Trash2, class: 'btn-icon-danger', onClick: () => onDelete(daemon), diff --git a/ui/src/lib/features/groups/components/GroupCard.svelte b/ui/src/lib/features/groups/components/GroupCard.svelte index 3de8d834..72228f28 100644 --- a/ui/src/lib/features/groups/components/GroupCard.svelte +++ b/ui/src/lib/features/groups/components/GroupCard.svelte @@ -36,6 +36,28 @@ ], emptyText: 'No type specified' }, + { + label: 'Color', + value: [ + { + id: 'color', + label: group.color.charAt(0).toUpperCase() + group.color.slice(1), + color: group.color + } + ], + emptyText: 'No type specified' + }, + { + label: 'Edge Style', + value: [ + { + id: 'type', + label: group.edge_style, + color: 'gray' + } + ], + emptyText: 'No type specified' + }, { label: 'Services', value: groupServiceLabels.map(({ id, label }, i) => { @@ -51,13 +73,13 @@ actions: [ { - label: 'Delete Group', + label: 'Delete', icon: Trash2, class: 'btn-icon-danger', onClick: () => onDelete(group) }, { - label: 'Edit Group', + label: 'Edit', icon: Edit, onClick: () => onEdit(group) } diff --git a/ui/src/lib/features/groups/components/GroupEditModal/EdgeStyleForm.svelte b/ui/src/lib/features/groups/components/GroupEditModal/EdgeStyleForm.svelte index b2f6ee6b..c9ea6fc2 100644 --- a/ui/src/lib/features/groups/components/GroupEditModal/EdgeStyleForm.svelte +++ b/ui/src/lib/features/groups/components/GroupEditModal/EdgeStyleForm.svelte @@ -5,6 +5,7 @@ export let formData: Group; export let collapsed: boolean = false; + export let editable: boolean = true; const edgeStyleOptions: Array<{ value: 'Straight' | 'SmoothStep' | 'Step' | 'Bezier' | 'SimpleBezier'; @@ -51,8 +52,9 @@ diff --git a/ui/src/lib/features/topology/components/RefreshConflictsModal.svelte b/ui/src/lib/features/topology/components/RefreshConflictsModal.svelte index 9f0bf363..6401a3c0 100644 --- a/ui/src/lib/features/topology/components/RefreshConflictsModal.svelte +++ b/ui/src/lib/features/topology/components/RefreshConflictsModal.svelte @@ -77,7 +77,7 @@ @@ -86,7 +86,7 @@
@@ -96,11 +96,11 @@
diff --git a/ui/src/lib/features/topology/components/StateBadge.svelte b/ui/src/lib/features/topology/components/StateBadge.svelte index 26ac8e7b..5da21e7f 100644 --- a/ui/src/lib/features/topology/components/StateBadge.svelte +++ b/ui/src/lib/features/topology/components/StateBadge.svelte @@ -4,34 +4,17 @@ let { Icon, label, - color, + cls, onClick = null }: { Icon: IconComponent; label: string; - color: 'blue' | 'green' | 'yellow' | 'red'; + cls: string; onClick?: (() => void) | null; } = $props(); - - // Map colors to existing button classes - const buttonClassMap = { - blue: 'btn-info', - green: 'btn-success', - yellow: 'btn-warning', - red: 'btn-danger' - }; - - const buttonClass = $derived(buttonClassMap[color]); -{#if onClick} - -{:else} -
- - {label} -
-{/if} + diff --git a/ui/src/lib/features/topology/components/TopologyTab.svelte b/ui/src/lib/features/topology/components/TopologyTab.svelte index 0771fcbc..353823da 100644 --- a/ui/src/lib/features/topology/components/TopologyTab.svelte +++ b/ui/src/lib/features/topology/components/TopologyTab.svelte @@ -16,7 +16,7 @@ topology, getTopologies, deleteTopology, - refreshTopology, + rebuildTopology, lockTopology, unlockTopology } from '../store'; @@ -30,6 +30,7 @@ import RefreshConflictsModal from './RefreshConflictsModal.svelte'; import RichSelect from '$lib/shared/components/forms/selection/RichSelect.svelte'; import { TopologyDisplay } from '$lib/shared/components/forms/selection/display/TopologyDisplay.svelte'; + import InlineWarning from '$lib/shared/components/feedback/InlineWarning.svelte'; let isCreateEditOpen = false; let editingTopology: Topology | null = null; @@ -86,12 +87,20 @@ isRefreshConflictsOpen = true; } else { // Safe to refresh directly - await refreshTopology($topology); + await rebuildTopology($topology); } } + async function handleReset() { + if (!$topology) return; + let resetTopology = { ...$topology }; + resetTopology.nodes = []; + resetTopology.edges = []; + await rebuildTopology(resetTopology); + } + async function handleConfirmRefresh() { - await refreshTopology($topology); + await rebuildTopology($topology); isRefreshConflictsOpen = false; } @@ -110,20 +119,16 @@ await unlockTopology($topology); } - // Compute topology state - $: stateConfig = $topology ? getTopologyState($topology) : null; - $: lockedByUser = $topology?.locked_by ? $users.find((u) => u.id === $topology.locked_by) : null; - - // Determine primary action handler - function handlePrimaryAction() { - if (!stateConfig || !stateConfig.primaryAction) return; + $: stateConfig = $topology + ? getTopologyState($topology, { + onRefresh: handleRefresh, + onUnlock: handleUnlock, + onReset: handleReset, + onLock: handleLock + }) + : null; - if (stateConfig.primaryAction === 'refresh') { - handleRefresh(); - } else if (stateConfig.primaryAction === 'unlock') { - handleUnlock(); - } - } + $: lockedByUser = $topology?.locked_by ? $users.find((u) => u.id === $topology.locked_by) : null; const loading = loadData([getHosts, getServices, getSubnets, getGroups, getTopologies]); @@ -136,16 +141,10 @@
- - - {#if $topology && stateConfig} - {#if stateConfig.secondaryAction === 'lock'} - - {/if} + {#if $topology && !$topology.is_locked} + {/if} @@ -153,15 +152,15 @@
{/if} {#if $topologies && $topology} -
+
{/if} + + @@ -187,15 +190,23 @@ {#if $topology && stateConfig} {#if stateConfig.type === 'locked'} {:else if stateConfig.type === 'stale_conflicts'} + {:else if stateConfig.type === 'stale_safe'} + {/if} {/if} diff --git a/ui/src/lib/features/topology/components/panel/TopologyOptionsPanel.svelte b/ui/src/lib/features/topology/components/panel/TopologyOptionsPanel.svelte index d8bef326..b5beaf2c 100644 --- a/ui/src/lib/features/topology/components/panel/TopologyOptionsPanel.svelte +++ b/ui/src/lib/features/topology/components/panel/TopologyOptionsPanel.svelte @@ -61,9 +61,13 @@ {:else if activeTab === 'inspector'} {#if $selectedNode} - + {#key $selectedNode.id} + + {/key} {:else if $selectedEdge} - + {#key $selectedEdge.id} + + {/key} {:else}
Click on a node or edge to inspect it diff --git a/ui/src/lib/features/topology/components/panel/inspectors/edges/InspectorEdgeGroup.svelte b/ui/src/lib/features/topology/components/panel/inspectors/edges/InspectorEdgeGroup.svelte index 119092e4..71f6b027 100644 --- a/ui/src/lib/features/topology/components/panel/inspectors/edges/InspectorEdgeGroup.svelte +++ b/ui/src/lib/features/topology/components/panel/inspectors/edges/InspectorEdgeGroup.svelte @@ -11,6 +11,8 @@ import { createColorHelper } from '$lib/shared/utils/styling'; import type { Group } from '$lib/features/groups/types/base'; import { topology } from '$lib/features/topology/store'; + import { getTopologyStateInfo } from '$lib/features/topology/state'; + import InlineWarning from '$lib/shared/components/feedback/InlineWarning.svelte'; let { groupId, @@ -30,6 +32,8 @@ } }); + let liveEditsEnabled = $derived(getTopologyStateInfo($topology).type == 'fresh'); + // Auto-save when styling changes $effect(() => { if ( @@ -54,8 +58,14 @@
Edge Style -
- + {#if getTopologyStateInfo($topology).type != 'fresh'} + + {/if} +
+
Services diff --git a/ui/src/lib/features/topology/components/visualization/CustomEdge.svelte b/ui/src/lib/features/topology/components/visualization/CustomEdge.svelte index 5d9f6b2d..20cb4d63 100644 --- a/ui/src/lib/features/topology/components/visualization/CustomEdge.svelte +++ b/ui/src/lib/features/topology/components/visualization/CustomEdge.svelte @@ -6,7 +6,8 @@ EdgeLabel, getBezierPath, getStraightPath, - type Edge + type Edge, + EdgeReconnectAnchor } from '@xyflow/svelte'; import { selectedEdge, selectedNode, topology, topologyOptions } from '../../store'; import { edgeTypes } from '$lib/shared/stores/metadata'; @@ -228,9 +229,31 @@ function onDragEnd() { isDragging = false; } + + let reconnecting = $state(false); -{#if !hideEdge} +{#if isSelected} + + +{/if} + +{#if !hideEdge && !reconnecting} {#if useMultiColorDash} diff --git a/ui/src/lib/features/topology/components/visualization/InterfaceNode.svelte b/ui/src/lib/features/topology/components/visualization/InterfaceNode.svelte index 0873ab38..2f7d449d 100644 --- a/ui/src/lib/features/topology/components/visualization/InterfaceNode.svelte +++ b/ui/src/lib/features/topology/components/visualization/InterfaceNode.svelte @@ -1,7 +1,7 @@ {#if nodeRenderData} @@ -174,12 +193,12 @@
{/if} - - - - + + + + - - - - + + + + diff --git a/ui/src/lib/features/topology/components/visualization/SubnetNode.svelte b/ui/src/lib/features/topology/components/visualization/SubnetNode.svelte index 10db588e..922bd43a 100644 --- a/ui/src/lib/features/topology/components/visualization/SubnetNode.svelte +++ b/ui/src/lib/features/topology/components/visualization/SubnetNode.svelte @@ -1,9 +1,17 @@ {#if subnetRenderData} @@ -91,6 +116,7 @@ {#if resizeHandleZoomLevel && !$topologyOptions.local.hide_resize_handles} { @@ -83,20 +91,22 @@ try { if ($topology?.nodes && $topology?.edges) { // Create nodes FIRST - const allNodes: Node[] = $topology.nodes.map((node): Node => { - return { - id: node.id, - type: node.node_type, - position: { x: node.position.x, y: node.position.y }, - width: node.size.x, - height: node.size.y, - expandParent: true, - deletable: false, - parentId: node.node_type == 'InterfaceNode' ? node.subnet_id : undefined, - extent: node.node_type == 'InterfaceNode' ? 'parent' : undefined, - data: node - }; - }); + const allNodes: Node[] = $topology.nodes.map((node) => ({ + id: node.id, + type: node.node_type, + position: { x: node.position.x, y: node.position.y }, + width: node.size.x, + height: node.size.y, + expandParent: true, + deletable: false, + parentId: node.node_type == 'InterfaceNode' ? node.subnet_id : undefined, + extent: node.node_type == 'InterfaceNode' ? 'parent' : undefined, + data: node + })); + + // Save current edge animated states before clearing + const currentEdges = get(edges); + const animatedStates = new Map(currentEdges.map((edge) => [edge.id, edge.animated])); // Clear edges FIRST edges.set([]); @@ -132,6 +142,8 @@ color: edgeColorHelper.rgb } as EdgeMarkerType); + const edgeId = `edge-${index}`; + return { id: `edge-${index}`, source: edge.source, @@ -143,7 +155,7 @@ type: 'custom', label: edge.label, data: { ...edge, edgeIndex: index }, - animated: false, + animated: animatedStates.get(edgeId) ?? false, interactionWidth: 50 }; }); @@ -155,6 +167,44 @@ } } + async function onNodeDragEnd({ + targetNode + }: { + targetNode: Node | null; + nodes: Node[]; + event: MouseEvent | TouchEvent; + }) { + let movedNode = $topology.nodes.find((node) => node.id == targetNode?.id); + if (movedNode && targetNode && targetNode.position) { + movedNode.position.x = targetNode?.position.x; + movedNode.position.y = targetNode?.position.y; + await updateTopology($topology); + } + } + + async function onReconnect(edge: Edge, newConnection: Connection) { + const edgeData = edge.data as TopologyEdge; + + if ($selectedEdge && edge.id === $selectedEdge.id) { + let topologyEdge = $topology.edges.find((e) => e.id == edgeData.id); + if ( + topologyEdge && + newConnection.source == topologyEdge.source && + newConnection.target == topologyEdge.target && + newConnection.sourceHandle && + newConnection.targetHandle + ) { + topologyEdge.source_handle = newConnection.sourceHandle as EdgeHandle; + topologyEdge.target_handle = newConnection.targetHandle as EdgeHandle; + $topology = { + ...$topology, + edges: [...$topology.edges] + }; + await updateTopology($topology); + } + } + } + function onNodeClick({ node }: { node: Node; event: MouseEvent | TouchEvent }) { selectedNode.set(node); selectedEdge.set(null); @@ -202,12 +252,14 @@ onnodeclick={onNodeClick} onedgepointerenter={hoveredEdge} onedgepointerleave={hoveredEdge} - fitView + onnodedragstop={onNodeDragEnd} + onreconnect={onReconnect} + fitView={true} minZoom={0.1} noPanClass="nopan" snapGrid={[25, 25]} nodesDraggable={true} - nodesConnectable={false} + nodesConnectable={true} elementsSelectable={true} > diff --git a/ui/src/lib/features/topology/interactions.ts b/ui/src/lib/features/topology/interactions.ts index b8b26e9c..b92a6345 100644 --- a/ui/src/lib/features/topology/interactions.ts +++ b/ui/src/lib/features/topology/interactions.ts @@ -2,7 +2,7 @@ import { writable, get } from 'svelte/store'; import type { Edge } from '@xyflow/svelte'; import type { Node } from '@xyflow/svelte'; import { edgeTypes, subnetTypes } from '$lib/shared/stores/metadata'; -import type { TopologyEdge } from './types/base'; +import type { TopologyEdge, TopologyNode } from './types/base'; import { getInterfacesOnSubnet, getSubnetFromId } from '../subnets/store'; import { getHostFromInterfaceId } from '../hosts/store'; @@ -43,13 +43,24 @@ function getVirtualizedContainerNodes(dockerHostInterfaceId: string): Set(); // If a node is selected if (selectedNode) { connected.add(selectedNode.id); + const nodeData = selectedNode.data as TopologyNode; + + if (nodeData.node_type == 'SubnetNode') { + allNodes.forEach((n) => { + const nd = n.data as TopologyNode; + if (nd.node_type == 'InterfaceNode' && nd.subnet_id == nodeData.id) { + connected.add(nd.id); + } + }); + } for (const edge of allEdges) { const edgeData = edge.data as TopologyEdge; diff --git a/ui/src/lib/features/topology/sse.ts b/ui/src/lib/features/topology/sse.ts index 21fa8cd5..f506702f 100644 --- a/ui/src/lib/features/topology/sse.ts +++ b/ui/src/lib/features/topology/sse.ts @@ -1,52 +1,44 @@ import { BaseSSEManager, type SSEConfig } from '$lib/shared/utils/sse'; import { topologies, topology } from './store'; import { get } from 'svelte/store'; +import type { Topology } from './types/base'; -export interface TopologyStalenessUpdate { - topology_id: string; - is_stale: boolean; - removed_hosts: string[]; - removed_services: string[]; - removed_subnets: string[]; - removed_groups: string[]; -} +class TopologySSEManager extends BaseSSEManager { + private stalenessTimers: Map> = new Map(); + private readonly DEBOUNCE_MS = 300; + + // Call this when a refresh completes to cancel pending staleness updates + public cancelPendingStaleness(topologyId: string) { + const existingTimer = this.stalenessTimers.get(topologyId); + if (existingTimer) { + clearTimeout(existingTimer); + this.stalenessTimers.delete(topologyId); + } + } -class TopologySSEManager extends BaseSSEManager { - protected createConfig(): SSEConfig { + protected createConfig(): SSEConfig { return { url: '/api/topology/stream', onMessage: (update) => { - console.log('Received topology staleness update', update); - - // Update the topologies array - topologies.update((topos) => { - return topos.map((topo) => { - if (topo.id === update.topology_id) { - return { - ...topo, - is_stale: update.is_stale, - removed_hosts: update.removed_hosts, - removed_services: update.removed_services, - removed_subnets: update.removed_subnets, - removed_groups: update.removed_groups - }; - } - return topo; - }); - }); - - // ALSO update the currently selected topology if it matches - const currentTopology = get(topology); - if (currentTopology && currentTopology.id === update.topology_id) { - topology.update((topo) => ({ - ...topo, - is_stale: update.is_stale, - removed_hosts: update.removed_hosts, - removed_services: update.removed_services, - removed_subnets: update.removed_subnets, - removed_groups: update.removed_groups - })); + // If the update says it's NOT stale, apply immediately (it's a refresh) + if (!update.is_stale) { + this.cancelPendingStaleness(update.id); + this.applyUpdate(update); + return; } + + // For staleness updates, debounce them + const existingTimer = this.stalenessTimers.get(update.id); + if (existingTimer) { + clearTimeout(existingTimer); + } + + const timer = setTimeout(() => { + this.applyUpdate(update); + this.stalenessTimers.delete(update.id); + }, this.DEBOUNCE_MS); + + this.stalenessTimers.set(update.id, timer); }, onError: (error) => { console.error('Topology SSE error:', error); @@ -56,6 +48,26 @@ class TopologySSEManager extends BaseSSEManager { } }; } + + private applyUpdate(update: Topology) { + // Update the topologies array + topologies.update((topos) => { + return topos.map((topo) => { + if (topo.id === update.id) { + return { + ...update + }; + } + return topo; + }); + }); + + // ALSO update the currently selected topology if it matches + const currentTopology = get(topology); + if (currentTopology && currentTopology.id === update.id) { + topology.set(update); + } + } } export const topologySSEManager = new TopologySSEManager(); diff --git a/ui/src/lib/features/topology/state.ts b/ui/src/lib/features/topology/state.ts index 22bd6169..ff0f41ab 100644 --- a/ui/src/lib/features/topology/state.ts +++ b/ui/src/lib/features/topology/state.ts @@ -1,19 +1,29 @@ -import { Lock, RefreshCcw, AlertTriangle } from 'lucide-svelte'; +import { Lock, RefreshCcw } from 'lucide-svelte'; import type { Topology } from './types/base'; import type { IconComponent } from '$lib/shared/utils/types'; export type TopologyStateType = 'locked' | 'fresh' | 'stale_safe' | 'stale_conflicts'; -export interface TopologyStateConfig { +export interface TopologyStateInfo { type: TopologyStateType; icon: IconComponent; + hoverIcon?: IconComponent; color: 'blue' | 'green' | 'yellow' | 'red'; - getLabel: (topology: Topology) => string; - primaryAction: 'refresh' | 'unlock' | null; - secondaryAction: 'lock' | null; + class: string; + label: string; + buttonText: string; + hoverLabel?: string; } -export function getTopologyState(topology: Topology): TopologyStateConfig { +export interface TopologyStateConfig extends TopologyStateInfo { + action: (() => void) | null; +} + +/** + * Determine the state info for a topology (without actions) + * This can be used in displays, lists, etc. + */ +export function getTopologyStateInfo(topology: Topology): TopologyStateInfo { // Locked state if (topology.is_locked) { const lockedDate = topology.locked_at ? new Date(topology.locked_at).toLocaleDateString() : ''; @@ -21,9 +31,9 @@ export function getTopologyState(topology: Topology): TopologyStateConfig { type: 'locked', icon: Lock, color: 'blue', - getLabel: () => `Locked ${lockedDate}`.trim(), - primaryAction: 'unlock', - secondaryAction: null + class: 'btn-info', + buttonText: `Locked ${lockedDate}`.trim(), + label: 'Locked' }; } @@ -32,10 +42,10 @@ export function getTopologyState(topology: Topology): TopologyStateConfig { return { type: 'fresh', icon: RefreshCcw, + class: 'btn-secondary', color: 'green', - getLabel: () => 'Up to date', - primaryAction: null, - secondaryAction: 'lock' + buttonText: 'Rebuild', + label: 'Up to date' }; } @@ -50,11 +60,11 @@ export function getTopologyState(topology: Topology): TopologyStateConfig { if (hasConflicts) { return { type: 'stale_conflicts', - icon: AlertTriangle, + icon: RefreshCcw, color: 'red', - getLabel: () => 'Conflicts detected', - primaryAction: 'refresh', - secondaryAction: 'lock' + class: 'btn-danger', + buttonText: 'Rebuild', + label: 'Conflicts' }; } @@ -63,8 +73,37 @@ export function getTopologyState(topology: Topology): TopologyStateConfig { type: 'stale_safe', icon: RefreshCcw, color: 'yellow', - getLabel: () => 'Refresh available', - primaryAction: 'refresh', - secondaryAction: 'lock' + class: 'btn-warning', + buttonText: 'Rebuild', + label: 'Stale' + }; +} + +/** + * Get full topology state config with actions + * This is used in the main topology page where actions are needed + */ +export function getTopologyState( + topology: Topology, + handlers: { + onRefresh: () => void; + onUnlock: () => void; + onReset: () => void; + onLock: () => void; + } +): TopologyStateConfig { + const stateInfo = getTopologyStateInfo(topology); + + // Map state types to actions + const actionMap: Record void) | null> = { + locked: handlers.onUnlock, + fresh: handlers.onReset, + stale_safe: handlers.onRefresh, + stale_conflicts: handlers.onRefresh + }; + + return { + ...stateInfo, + action: actionMap[stateInfo.type] }; } diff --git a/ui/src/lib/features/topology/store.ts b/ui/src/lib/features/topology/store.ts index 39f8259b..3061db96 100644 --- a/ui/src/lib/features/topology/store.ts +++ b/ui/src/lib/features/topology/store.ts @@ -147,8 +147,34 @@ export async function refreshTopology(data: Topology) { } ); - if (result && result.success && result.data && get(topology)?.id === data.id) { - topology.set(result.data); + if (result && result.success && result.data) { + if (get(topology)?.id === data.id) { + topology.set(result.data); + } + } + + return result; +} + +export async function rebuildTopology(data: Topology) { + const result = await api.request( + `/topology/${data.id}/rebuild`, + topologies, + (updated, current) => current.map((t) => (t.id == updated.id ? updated : t)), + { + method: 'POST', + body: JSON.stringify(data) + } + ); + + if (result && result.success && result.data) { + // Cancel any pending staleness updates since we just refreshed + const { topologySSEManager } = await import('./sse'); + topologySSEManager.cancelPendingStaleness(data.id); + + if (get(topology)?.id === data.id) { + topology.set(result.data); + } } return result; diff --git a/ui/src/lib/features/topology/types/base.ts b/ui/src/lib/features/topology/types/base.ts index 916622a0..61cfa1fe 100644 --- a/ui/src/lib/features/topology/types/base.ts +++ b/ui/src/lib/features/topology/types/base.ts @@ -7,7 +7,7 @@ import type { IconComponent } from '$lib/shared/utils/types'; export interface Topology { edges: TopologyEdge[]; - nodes: Node[]; + nodes: TopologyNode[]; options: TopologyOptions; name: string; id: string; @@ -53,7 +53,7 @@ export interface SubnetNode extends Record { infra_width: number; } -type Node = NodeBase & NodeType & Record; +export type TopologyNode = NodeBase & NodeType & Record; export interface NodeRenderData { headerText: string | null; @@ -73,6 +73,7 @@ export interface SubnetRenderData { } interface EdgeBase extends Record { + id: string; source: string; label: string; target: string; diff --git a/ui/src/lib/features/users/components/InviteCard.svelte b/ui/src/lib/features/users/components/InviteCard.svelte index e326dd31..505a13c9 100644 --- a/ui/src/lib/features/users/components/InviteCard.svelte +++ b/ui/src/lib/features/users/components/InviteCard.svelte @@ -48,7 +48,7 @@ ...(canManage ? [ { - label: 'Revoke Invite', + label: 'Revoke', icon: UserX, class: 'btn-icon-danger', onClick: () => handleRevokeInvite() diff --git a/ui/src/lib/shared/components/data/DataControls.svelte b/ui/src/lib/shared/components/data/DataControls.svelte index 47ec2468..fed48dd9 100644 --- a/ui/src/lib/shared/components/data/DataControls.svelte +++ b/ui/src/lib/shared/components/data/DataControls.svelte @@ -17,11 +17,14 @@ items = $bindable([]), fields = $bindable([]), storageKey = null, + getItemKey = (item: T) => item, children }: { items: T[]; fields: FieldConfig[]; storageKey?: string | null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getItemKey?: (item: T) => any; children: Snippet<[T, 'card' | 'list']>; // Snippet that takes two arguments (the item and viewMode) } = $props(); @@ -644,7 +647,7 @@ ? 'space-y-2' : 'grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3'} > - {#each groupItems as item (item)} + {#each groupItems as item (getItemKey(item))} {@render children(item, viewMode)} {/each}
@@ -658,7 +661,7 @@ ? 'space-y-2' : 'grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3'} > - {#each processedItems as item (item)} + {#each processedItems as item (getItemKey(item))} {@render children(item, viewMode)} {/each}
diff --git a/ui/src/lib/shared/components/data/GenericCard.svelte b/ui/src/lib/shared/components/data/GenericCard.svelte index 4d3acaaa..05f37c17 100644 --- a/ui/src/lib/shared/components/data/GenericCard.svelte +++ b/ui/src/lib/shared/components/data/GenericCard.svelte @@ -185,6 +185,8 @@
{/if} + + {#if actions.length > 0}
- {#each actions as action (action.label)} + {#each actions as action, index (action.label)} + {@const isLeftEdge = index === 0} + {@const isRightEdge = index === actions.length - 1} {/each}
diff --git a/ui/src/lib/shared/components/feedback/BaseInlineFeedback.svelte b/ui/src/lib/shared/components/feedback/BaseInlineFeedback.svelte new file mode 100644 index 00000000..78ab4d76 --- /dev/null +++ b/ui/src/lib/shared/components/feedback/BaseInlineFeedback.svelte @@ -0,0 +1,61 @@ + + +{#if !dismissed} +
+
+ +
+

{title}

+ {#if body} +

{body}

+ {/if} +
+ {#if dismissableKey} + + {/if} +
+
+{/if} diff --git a/ui/src/lib/shared/components/feedback/InlineDanger.svelte b/ui/src/lib/shared/components/feedback/InlineDanger.svelte index c0ebe149..1f67f211 100644 --- a/ui/src/lib/shared/components/feedback/InlineDanger.svelte +++ b/ui/src/lib/shared/components/feedback/InlineDanger.svelte @@ -1,18 +1,24 @@ -
-
- -
-

{title}

- {#if body} -

{body}

- {/if} -
-
-
+ diff --git a/ui/src/lib/shared/components/feedback/InlineInfo.svelte b/ui/src/lib/shared/components/feedback/InlineInfo.svelte index 81084526..dfd7a279 100644 --- a/ui/src/lib/shared/components/feedback/InlineInfo.svelte +++ b/ui/src/lib/shared/components/feedback/InlineInfo.svelte @@ -1,18 +1,24 @@ -
-
- -
-

{title}

- {#if body} -

{body}

- {/if} -
-
-
+ diff --git a/ui/src/lib/shared/components/feedback/InlineWarning.svelte b/ui/src/lib/shared/components/feedback/InlineWarning.svelte index be23e5fb..eb2397f1 100644 --- a/ui/src/lib/shared/components/feedback/InlineWarning.svelte +++ b/ui/src/lib/shared/components/feedback/InlineWarning.svelte @@ -1,18 +1,24 @@ -
-
- -
-

{title}

- {#if body} -

{body}

- {/if} -
-
-
+ diff --git a/ui/src/lib/shared/components/forms/selection/display/ServiceDisplay.svelte b/ui/src/lib/shared/components/forms/selection/display/ServiceDisplay.svelte index 85f6f12e..1e7e19e5 100644 --- a/ui/src/lib/shared/components/forms/selection/display/ServiceDisplay.svelte +++ b/ui/src/lib/shared/components/forms/selection/display/ServiceDisplay.svelte @@ -1,5 +1,5 @@ -{#if !loading && !error && $stars !== null} +{#if !loading && !error && $stars != null && $stars != undefined} Date: Tue, 18 Nov 2025 16:14:53 -0500 Subject: [PATCH 17/27] fix: better determination of server URL in frontend to fix reverse proxies --- backend/src/server/billing/handlers.rs | 7 +--- backend/src/server/billing/types/api.rs | 1 + ui/src/lib/features/billing/store.ts | 8 ++-- .../components/CreateDaemonModal.svelte | 8 ++-- ui/src/lib/shared/utils/api.ts | 39 ++++--------------- 5 files changed, 20 insertions(+), 43 deletions(-) diff --git a/backend/src/server/billing/handlers.rs b/backend/src/server/billing/handlers.rs index 02b4938f..525c8ec0 100644 --- a/backend/src/server/billing/handlers.rs +++ b/backend/src/server/billing/handlers.rs @@ -40,11 +40,8 @@ async fn create_checkout_session( Json(request): Json, ) -> ApiResult>> { // Build success/cancel URLs - let success_url = format!( - "{}?session_id={{CHECKOUT_SESSION_ID}}", - state.config.public_url.clone() - ); - let cancel_url = format!("{}/billing", state.config.public_url.clone()); + let success_url = format!("{}?session_id={{CHECKOUT_SESSION_ID}}", request.url); + let cancel_url = format!("{}/billing", request.url); if let Some(billing_service) = state.services.billing_service.clone() { let current_plans = billing_service.get_plans(); diff --git a/backend/src/server/billing/types/api.rs b/backend/src/server/billing/types/api.rs index 5a732bc0..728e7982 100644 --- a/backend/src/server/billing/types/api.rs +++ b/backend/src/server/billing/types/api.rs @@ -5,4 +5,5 @@ use serde::Serialize; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreateCheckoutRequest { pub plan: BillingPlan, + pub url: String, } diff --git a/ui/src/lib/features/billing/store.ts b/ui/src/lib/features/billing/store.ts index 0094da91..5137b60b 100644 --- a/ui/src/lib/features/billing/store.ts +++ b/ui/src/lib/features/billing/store.ts @@ -1,4 +1,4 @@ -import { api, getUiUrl } from '$lib/shared/utils/api'; +import { api } from '$lib/shared/utils/api'; import { writable } from 'svelte/store'; import type { BillingPlan } from './types'; import { pushError } from '$lib/shared/stores/feedback'; @@ -24,7 +24,7 @@ export async function getCurrentBillingPlans(): Promise { export async function checkout(plan: BillingPlan): Promise { const result = await api.request(`/billing/checkout`, null, null, { method: 'POST', - body: JSON.stringify({ plan, url: getUiUrl() }) + body: JSON.stringify({ plan, url: window.location.origin }) }); if (result && result.success && result.data) { @@ -36,8 +36,8 @@ export async function checkout(plan: BillingPlan): Promise { export async function openCustomerPortal(): Promise { const result = await api.request(`/billing/portal`, null, null, { - method: 'POST', - body: JSON.stringify(getUiUrl()) + method: 'GET', + body: JSON.stringify(window.location.origin) }); if (result && result.success && result.data) { diff --git a/ui/src/lib/features/daemons/components/CreateDaemonModal.svelte b/ui/src/lib/features/daemons/components/CreateDaemonModal.svelte index 37ff0f01..8ab95451 100644 --- a/ui/src/lib/features/daemons/components/CreateDaemonModal.svelte +++ b/ui/src/lib/features/daemons/components/CreateDaemonModal.svelte @@ -12,10 +12,10 @@ import SelectNetwork from '$lib/features/networks/components/SelectNetwork.svelte'; import { RotateCcwKey } from 'lucide-svelte'; import { createEmptyApiKeyFormData, createNewApiKey } from '$lib/features/api_keys/store'; - import { getServerPort, getServerProtocol, getServerTarget } from '$lib/shared/utils/api'; import SelectInput from '$lib/shared/components/forms/input/SelectInput.svelte'; import { field } from 'svelte-forms'; import { required } from 'svelte-forms/validators'; + import { config } from '$lib/shared/stores/config'; export let isOpen = false; export let onClose: () => void; @@ -26,6 +26,8 @@ let selectedNetworkId = daemon ? daemon.network_id : $networks[0].id; let isNewDaemon = daemon === null; + let serverUrl = $config.public_url; + let daemonModeField = field('daemonMode', daemon ? daemon.mode : 'Push', [required()], { checkOnInit: false }); @@ -48,7 +50,7 @@ } const installCommand = `curl -sSL https://raw.githubusercontent.com/mayanayza/netvisor/refs/heads/main/install.sh | bash`; - $: runCommand = `netvisor-daemon --server-url ${getServerProtocol()}://${getServerTarget()}:${getServerPort()} ${!daemon ? `--network-id ${selectedNetworkId}` : ''} ${key ? `--daemon-api-key ${key} --mode ${$daemonModeField.value.toLowerCase()}` : ''}`; + $: runCommand = `netvisor-daemon --server-url ${serverUrl} ${!daemon ? `--network-id ${selectedNetworkId}` : ''} ${key ? `--daemon-api-key ${key} --mode ${$daemonModeField.value.toLowerCase()}` : ''}`; let dockerCompose = ''; $: if (key) { @@ -75,7 +77,7 @@ .split('\n') .map((line) => { if (line.includes('NETVISOR_SERVER_URL=')) { - return ` - NETVISOR_SERVER_URL=${getServerProtocol()}://${getServerTarget()}:${getServerPort()}`; + return ` - NETVISOR_SERVER_URL=${serverUrl}`; } if (line.includes('NETVISOR_MODE=')) { return ` - NETVISOR_MODE=${daemonMode}`; diff --git a/ui/src/lib/shared/utils/api.ts b/ui/src/lib/shared/utils/api.ts index a6d50355..347efef4 100644 --- a/ui/src/lib/shared/utils/api.ts +++ b/ui/src/lib/shared/utils/api.ts @@ -2,39 +2,16 @@ import type { Writable } from 'svelte/store'; import { pushError } from '../stores/feedback'; import { env } from '$env/dynamic/public'; -export function getServerTarget(): string { - const baseUrl = window.location.origin; - const parsedUrl = new URL(baseUrl); - - return env.PUBLIC_SERVER_HOSTNAME && env.PUBLIC_SERVER_HOSTNAME !== 'default' - ? env.PUBLIC_SERVER_HOSTNAME - : parsedUrl.hostname; -} - -export function getServerPort(): string { - const baseUrl = window.location.origin; - const parsedUrl = new URL(baseUrl); - - if (parsedUrl.hostname == 'localhost') return '60072'; - - return env.PUBLIC_SERVER_HOSTNAME === 'default' - ? parsedUrl.port || '60072' - : env.PUBLIC_SERVER_PORT || parsedUrl.port || '60072'; -} - -export function getServerProtocol(): string { - const baseUrl = window.location.origin; - const parsedUrl = new URL(baseUrl); - - return parsedUrl.protocol === 'https:' ? 'https' : 'http'; -} - export function getServerUrl() { - return `${getServerProtocol()}://${getServerTarget()}:${getServerPort()}`; -} + // If API is on a different host/port, use explicit config + if (env.PUBLIC_SERVER_HOSTNAME && env.PUBLIC_SERVER_HOSTNAME !== 'default') { + const protocol = env.PUBLIC_SERVER_PROTOCOL || 'http'; + const port = env.PUBLIC_SERVER_PORT ? `:${env.PUBLIC_SERVER_PORT}` : ''; + return `${protocol}://${env.PUBLIC_SERVER_HOSTNAME}${port}`; + } -export function getUiUrl() { - return window.location.href; + // Otherwise, use the exact same origin as the browser + return window.location.origin; } interface ApiResponse { From 3c9dccf0c2b520c023f6c75576ebc06bae15f7b0 Mon Sep 17 00:00:00 2001 From: Maya Date: Tue, 18 Nov 2025 16:18:24 -0500 Subject: [PATCH 18/27] fix: better determination of server URL in frontend to fix reverse proxies --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index aa29da19..be630b35 100644 --- a/Makefile +++ b/Makefile @@ -46,10 +46,10 @@ dump-db: dev-server: @export DATABASE_URL="postgresql://postgres:password@localhost:5432/netvisor" && \ - cd backend && cargo run --bin server -- --log-level debug + cd backend && cargo run --bin server -- --log-level debug --public-url http://localhost:60072 dev-daemon: - cd backend && cargo run --bin daemon -- --server-target http://127.0.0.1 --server-port 60072 --log-level debug + cd backend && cargo run --bin daemon -- --server-url http://127.0.0.1:60072 --log-level debug dev-ui: cd ui && npm run dev From 5b28ebd56613b2fbbe13aa9f6d144c89bf311a9c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 19 Nov 2025 18:11:14 +0000 Subject: [PATCH 19/27] chore: update test fixtures for release v0.10.1 --- backend/src/tests/daemon_config.json | 8 +- backend/src/tests/netvisor.sql | 137 +-- ui/static/services.json | 1528 +++++++++++++------------- 3 files changed, 836 insertions(+), 837 deletions(-) diff --git a/backend/src/tests/daemon_config.json b/backend/src/tests/daemon_config.json index 5c12953e..af351284 100644 --- a/backend/src/tests/daemon_config.json +++ b/backend/src/tests/daemon_config.json @@ -1,6 +1,6 @@ { "server_url": "http://server:60072", - "network_id": "f8138211-9e6c-46d2-9ca1-62604249128d", + "network_id": "61b521cb-415f-4067-9a9d-eccfc3d3c38b", "server_target": null, "server_port": null, "daemon_port": 60073, @@ -9,10 +9,10 @@ "heartbeat_interval": 30, "bind_address": "0.0.0.0", "concurrent_scans": 15, - "id": "5d3c8080-8a4a-4d94-b7a5-06fbe7ffb931", + "id": "887ae705-1262-4e8c-af47-08fe31eba9d9", "last_heartbeat": null, - "host_id": "d005a6c3-d60f-4a83-b79f-300aa6d643ae", - "daemon_api_key": "c1a9c96bd11a40a0934c577a8da34b14", + "host_id": "9ea137d0-3ee2-40d2-9d75-bf12697689a5", + "daemon_api_key": "144edd5bff8d4bf19325f95a25ab3dcb", "docker_proxy": null, "mode": "Push" } \ No newline at end of file diff --git a/backend/src/tests/netvisor.sql b/backend/src/tests/netvisor.sql index 323510c4..11eae5fd 100644 --- a/backend/src/tests/netvisor.sql +++ b/backend/src/tests/netvisor.sql @@ -2,7 +2,7 @@ -- PostgreSQL database dump -- -\restrict WHNbxOKuN3Xlq6Oeb2cV5rt9mfq4SE7l1IP3Gl7fDrORCuqg9zY4YJlSgoW5Qge +\restrict eBSnQ02bTJJqBWf84vAslrtSFRhVu7kiaRFTHpaqJGK3AZClXEDcKsrVWzI35Hs -- Dumped from database version 17.7 -- Dumped by pg_dump version 17.7 @@ -233,7 +233,7 @@ CREATE TABLE public.organizations ( id uuid DEFAULT gen_random_uuid() NOT NULL, name text NOT NULL, stripe_customer_id text, - plan jsonb, + plan jsonb NOT NULL, plan_status text, created_at timestamp with time zone DEFAULT now() NOT NULL, updated_at timestamp with time zone DEFAULT now() NOT NULL, @@ -250,6 +250,13 @@ ALTER TABLE public.organizations OWNER TO postgres; COMMENT ON TABLE public.organizations IS 'Organizations that own networks and have Stripe subscriptions'; +-- +-- Name: COLUMN organizations.plan; Type: COMMENT; Schema: public; Owner: postgres +-- + +COMMENT ON COLUMN public.organizations.plan IS 'The current billing plan for the organization (e.g., Community, Pro)'; + + -- -- Name: services; Type: TABLE; Schema: public; Owner: postgres -- @@ -341,23 +348,24 @@ ALTER TABLE tower_sessions.session OWNER TO postgres; -- COPY public._sqlx_migrations (version, description, installed_on, success, checksum, execution_time) FROM stdin; -20251006215000 users 2025-11-18 00:03:41.533365+00 t \\x4f13ce14ff67ef0b7145987c7b22b588745bf9fbb7b673450c26a0f2f9a36ef8ca980e456c8d77cfb1b2d7a4577a64d7 2287292 -20251006215100 networks 2025-11-18 00:03:41.536119+00 t \\xeaa5a07a262709f64f0c59f31e25519580c79e2d1a523ce72736848946a34b17dd9adc7498eaf90551af6b7ec6d4e0e3 4380750 -20251006215151 create hosts 2025-11-18 00:03:41.540708+00 t \\x6ec7487074c0724932d21df4cf1ed66645313cf62c159a7179e39cbc261bcb81a24f7933a0e3cf58504f2a90fc5c1962 1721583 -20251006215155 create subnets 2025-11-18 00:03:41.542588+00 t \\xefb5b25742bd5f4489b67351d9f2494a95f307428c911fd8c5f475bfb03926347bdc269bbd048d2ddb06336945b27926 2197958 -20251006215201 create groups 2025-11-18 00:03:41.544945+00 t \\x0a7032bf4d33a0baf020e905da865cde240e2a09dda2f62aa535b2c5d4b26b20be30a3286f1b5192bd94cd4a5dbb5bcd 2130958 -20251006215204 create daemons 2025-11-18 00:03:41.547259+00 t \\xcfea93403b1f9cf9aac374711d4ac72d8a223e3c38a1d2a06d9edb5f94e8a557debac3668271f8176368eadc5105349f 2193834 -20251006215212 create services 2025-11-18 00:03:41.549628+00 t \\xd5b07f82fc7c9da2782a364d46078d7d16b5c08df70cfbf02edcfe9b1b24ab6024ad159292aeea455f15cfd1f4740c1d 2080709 -20251029193448 user-auth 2025-11-18 00:03:41.551886+00 t \\xfde8161a8db89d51eeade7517d90a41d560f19645620f2298f78f116219a09728b18e91251ae31e46a47f6942d5a9032 2749084 -20251030044828 daemon api 2025-11-18 00:03:41.554795+00 t \\x181eb3541f51ef5b038b2064660370775d1b364547a214a20dde9c9d4bb95a1c273cd4525ef29e61fa65a3eb4fee0400 627916 -20251030170438 host-hide 2025-11-18 00:03:41.555586+00 t \\x87c6fda7f8456bf610a78e8e98803158caa0e12857c5bab466a5bb0004d41b449004a68e728ca13f17e051f662a15454 533833 -20251102224919 create discovery 2025-11-18 00:03:41.556284+00 t \\xb32a04abb891aba48f92a059fae7341442355ca8e4af5d109e28e2a4f79ee8e11b2a8f40453b7f6725c2dd6487f26573 7678625 -20251106235621 normalize-daemon-cols 2025-11-18 00:03:41.56415+00 t \\x5b137118d506e2708097c432358bf909265b3cf3bacd662b02e2c81ba589a9e0100631c7801cffd9c57bb10a6674fb3b 747333 -20251107034459 api keys 2025-11-18 00:03:41.565061+00 t \\x3133ec043c0c6e25b6e55f7da84cae52b2a72488116938a2c669c8512c2efe72a74029912bcba1f2a2a0a8b59ef01dde 4344708 -20251107222650 oidc-auth 2025-11-18 00:03:41.569609+00 t \\xd349750e0298718cbcd98eaff6e152b3fb45c3d9d62d06eedeb26c75452e9ce1af65c3e52c9f2de4bd532939c2f31096 11108500 -20251110181948 orgs-billing 2025-11-18 00:09:01.489572+00 t \\x5bbea7a2dfc9d00213bd66b473289ddd66694eff8a4f3eaab937c985b64c5f8c3ad2d64e960afbb03f335ac6766687aa 24461292 -20251113223656 group-enhancements 2025-11-18 00:09:01.514844+00 t \\xbe0699486d85df2bd3edc1f0bf4f1f096d5b6c5070361702c4d203ec2bb640811be88bb1979cfe51b40805ad84d1de65 1368459 -20251117032720 daemon-mode 2025-11-18 00:09:01.516576+00 t \\xdd0d899c24b73d70e9970e54b2c748d6b6b55c856ca0f8590fe990da49cc46c700b1ce13f57ff65abd6711f4bd8a6481 1186917 +20251006215000 users 2025-11-19 18:09:54.036441+00 t \\x4f13ce14ff67ef0b7145987c7b22b588745bf9fbb7b673450c26a0f2f9a36ef8ca980e456c8d77cfb1b2d7a4577a64d7 3457532 +20251006215100 networks 2025-11-19 18:09:54.040861+00 t \\xeaa5a07a262709f64f0c59f31e25519580c79e2d1a523ce72736848946a34b17dd9adc7498eaf90551af6b7ec6d4e0e3 4459430 +20251006215151 create hosts 2025-11-19 18:09:54.045676+00 t \\x6ec7487074c0724932d21df4cf1ed66645313cf62c159a7179e39cbc261bcb81a24f7933a0e3cf58504f2a90fc5c1962 3753176 +20251006215155 create subnets 2025-11-19 18:09:54.049755+00 t \\xefb5b25742bd5f4489b67351d9f2494a95f307428c911fd8c5f475bfb03926347bdc269bbd048d2ddb06336945b27926 3588988 +20251006215201 create groups 2025-11-19 18:09:54.053697+00 t \\x0a7032bf4d33a0baf020e905da865cde240e2a09dda2f62aa535b2c5d4b26b20be30a3286f1b5192bd94cd4a5dbb5bcd 3718311 +20251006215204 create daemons 2025-11-19 18:09:54.057761+00 t \\xcfea93403b1f9cf9aac374711d4ac72d8a223e3c38a1d2a06d9edb5f94e8a557debac3668271f8176368eadc5105349f 4217908 +20251006215212 create services 2025-11-19 18:09:54.062345+00 t \\xd5b07f82fc7c9da2782a364d46078d7d16b5c08df70cfbf02edcfe9b1b24ab6024ad159292aeea455f15cfd1f4740c1d 4890819 +20251029193448 user-auth 2025-11-19 18:09:54.067551+00 t \\xfde8161a8db89d51eeade7517d90a41d560f19645620f2298f78f116219a09728b18e91251ae31e46a47f6942d5a9032 4327893 +20251030044828 daemon api 2025-11-19 18:09:54.072173+00 t \\x181eb3541f51ef5b038b2064660370775d1b364547a214a20dde9c9d4bb95a1c273cd4525ef29e61fa65a3eb4fee0400 1501956 +20251030170438 host-hide 2025-11-19 18:09:54.073966+00 t \\x87c6fda7f8456bf610a78e8e98803158caa0e12857c5bab466a5bb0004d41b449004a68e728ca13f17e051f662a15454 1093561 +20251102224919 create discovery 2025-11-19 18:09:54.075355+00 t \\xb32a04abb891aba48f92a059fae7341442355ca8e4af5d109e28e2a4f79ee8e11b2a8f40453b7f6725c2dd6487f26573 9345210 +20251106235621 normalize-daemon-cols 2025-11-19 18:09:54.084974+00 t \\x5b137118d506e2708097c432358bf909265b3cf3bacd662b02e2c81ba589a9e0100631c7801cffd9c57bb10a6674fb3b 1723481 +20251107034459 api keys 2025-11-19 18:09:54.087073+00 t \\x3133ec043c0c6e25b6e55f7da84cae52b2a72488116938a2c669c8512c2efe72a74029912bcba1f2a2a0a8b59ef01dde 7741403 +20251107222650 oidc-auth 2025-11-19 18:09:54.095103+00 t \\xd349750e0298718cbcd98eaff6e152b3fb45c3d9d62d06eedeb26c75452e9ce1af65c3e52c9f2de4bd532939c2f31096 21897441 +20251110181948 orgs-billing 2025-11-19 18:09:54.117385+00 t \\x5bbea7a2dfc9d00213bd66b473289ddd66694eff8a4f3eaab937c985b64c5f8c3ad2d64e960afbb03f335ac6766687aa 10576408 +20251113223656 group-enhancements 2025-11-19 18:09:54.12827+00 t \\xbe0699486d85df2bd3edc1f0bf4f1f096d5b6c5070361702c4d203ec2bb640811be88bb1979cfe51b40805ad84d1de65 1012308 +20251117032720 daemon-mode 2025-11-19 18:09:54.129679+00 t \\xdd0d899c24b73d70e9970e54b2c748d6b6b55c856ca0f8590fe990da49cc46c700b1ce13f57ff65abd6711f4bd8a6481 1067802 +20251118143058 set-default-plan 2025-11-19 18:09:54.131027+00 t \\xd19142607aef84aac7cfb97d60d29bda764d26f513f2c72306734c03cec2651d23eee3ce6cacfd36ca52dbddc462f917 1149295 \. @@ -366,8 +374,7 @@ COPY public._sqlx_migrations (version, description, installed_on, success, check -- COPY public.api_keys (id, key, network_id, name, created_at, updated_at, last_used, expires_at, is_enabled) FROM stdin; -b02deb3a-fa39-47dc-a093-7d648565e8cc c1a9c96bd11a40a0934c577a8da34b14 f8138211-9e6c-46d2-9ca1-62604249128d Integrated Daemon API Key 2025-11-18 14:26:00.512315+00 2025-11-18 14:26:40.140836+00 2025-11-18 14:26:40.140504+00 \N t -659b14e2-0305-487e-abb7-f80105bd6a80 c7f2d4cc94864724a4e8af8568d67848 9d87811c-0b4c-4449-8ae8-1c7a6f252c59 Integrated Daemon API Key 2025-11-18 00:03:41.615323+00 2025-11-18 00:04:12.73107+00 2025-11-18 00:04:12.731028+00 \N t +2ab8b86f-73b1-4f5f-95ae-3534a4562f46 144edd5bff8d4bf19325f95a25ab3dcb 61b521cb-415f-4067-9a9d-eccfc3d3c38b Integrated Daemon API Key 2025-11-19 18:09:57.843792+00 2025-11-19 18:10:50.668673+00 2025-11-19 18:10:50.668351+00 \N t \. @@ -376,8 +383,7 @@ b02deb3a-fa39-47dc-a093-7d648565e8cc c1a9c96bd11a40a0934c577a8da34b14 f8138211-9 -- COPY public.daemons (id, network_id, host_id, ip, port, created_at, last_seen, capabilities, updated_at, mode) FROM stdin; -72b8c7bf-3145-47a4-bfac-5b353f2837db 9d87811c-0b4c-4449-8ae8-1c7a6f252c59 cd775c2a-1eef-4616-b5e6-6d7aadb6328b "192.168.65.3" 60073 2025-11-18 00:03:41.638193+00 2025-11-18 00:03:41.638193+00 {"has_docker_socket": true, "interfaced_subnet_ids": ["95317b60-ba99-4056-b1c0-022f58b60097"]} 2025-11-18 00:03:41.649809+00 "Push" -5d3c8080-8a4a-4d94-b7a5-06fbe7ffb931 f8138211-9e6c-46d2-9ca1-62604249128d d005a6c3-d60f-4a83-b79f-300aa6d643ae "172.25.0.4" 60073 2025-11-18 14:26:00.555154+00 2025-11-18 14:26:00.555147+00 {"has_docker_socket": false, "interfaced_subnet_ids": ["8d4a6cd2-c42d-452d-a7da-ac2b57ad24e8"]} 2025-11-18 14:26:00.566705+00 "Push" +887ae705-1262-4e8c-af47-08fe31eba9d9 61b521cb-415f-4067-9a9d-eccfc3d3c38b 9ea137d0-3ee2-40d2-9d75-bf12697689a5 "172.25.0.4" 60073 2025-11-19 18:09:57.893368+00 2025-11-19 18:09:57.893367+00 {"has_docker_socket": false, "interfaced_subnet_ids": ["d3c4eb6c-bf62-4299-8f8f-4c1a0c44a340"]} 2025-11-19 18:09:57.944998+00 "Push" \. @@ -386,15 +392,10 @@ COPY public.daemons (id, network_id, host_id, ip, port, created_at, last_seen, c -- COPY public.discovery (id, network_id, daemon_id, run_type, discovery_type, name, created_at, updated_at) FROM stdin; -21161f47-f513-4757-aa98-af80fa2eae49 9d87811c-0b4c-4449-8ae8-1c7a6f252c59 72b8c7bf-3145-47a4-bfac-5b353f2837db {"type": "Scheduled", "enabled": true, "last_run": null, "cron_schedule": "0 0 0 * * *"} {"type": "SelfReport", "host_id": "cd775c2a-1eef-4616-b5e6-6d7aadb6328b"} Self Report @ 192.168.65.3 2025-11-18 00:03:41.638987+00 2025-11-18 00:03:41.638987+00 -b1f281ce-e91a-4bf5-bf9f-740877fb5ea7 9d87811c-0b4c-4449-8ae8-1c7a6f252c59 72b8c7bf-3145-47a4-bfac-5b353f2837db {"type": "Scheduled", "enabled": true, "last_run": null, "cron_schedule": "0 0 0 * * *"} {"type": "Docker", "host_id": "cd775c2a-1eef-4616-b5e6-6d7aadb6328b", "host_naming_fallback": "BestService"} Docker @ 192.168.65.3 2025-11-18 00:03:41.642217+00 2025-11-18 00:03:41.642217+00 -010868e2-c688-4c66-9c6c-e9d5b69968b2 9d87811c-0b4c-4449-8ae8-1c7a6f252c59 72b8c7bf-3145-47a4-bfac-5b353f2837db {"type": "Scheduled", "enabled": true, "last_run": null, "cron_schedule": "0 0 0 * * *"} {"type": "Network", "subnet_ids": null, "host_naming_fallback": "BestService"} Network Scan @ 192.168.65.3 2025-11-18 00:03:41.643365+00 2025-11-18 00:03:41.643365+00 -c1967cbb-ba19-4198-95a6-31adcb87b87d 9d87811c-0b4c-4449-8ae8-1c7a6f252c59 72b8c7bf-3145-47a4-bfac-5b353f2837db {"type": "Historical", "results": {"error": null, "phase": "Complete", "daemon_id": "72b8c7bf-3145-47a4-bfac-5b353f2837db", "processed": 1, "network_id": "9d87811c-0b4c-4449-8ae8-1c7a6f252c59", "session_id": "58aca635-89d7-4bc5-8846-7729419d1e35", "started_at": "2025-11-18T00:03:41.642173263Z", "finished_at": "2025-11-18T00:03:41.656417180Z", "discovery_type": {"type": "SelfReport", "host_id": "cd775c2a-1eef-4616-b5e6-6d7aadb6328b"}, "total_to_process": 1}} {"type": "SelfReport", "host_id": "cd775c2a-1eef-4616-b5e6-6d7aadb6328b"} Discovery Run 2025-11-18 00:03:41.642173+00 2025-11-18 00:03:41.656642+00 -c8967910-41f2-41a8-b4a1-6d60b9aaa8b6 9d87811c-0b4c-4449-8ae8-1c7a6f252c59 72b8c7bf-3145-47a4-bfac-5b353f2837db {"type": "Historical", "results": {"error": null, "phase": "Complete", "daemon_id": "72b8c7bf-3145-47a4-bfac-5b353f2837db", "processed": 3, "network_id": "9d87811c-0b4c-4449-8ae8-1c7a6f252c59", "session_id": "56fef2fa-3dbf-4ab8-9f80-22c78a0736ed", "started_at": "2025-11-18T00:03:41.703719388Z", "finished_at": "2025-11-18T00:03:52.139966587Z", "discovery_type": {"type": "Docker", "host_id": "cd775c2a-1eef-4616-b5e6-6d7aadb6328b", "host_naming_fallback": "BestService"}, "total_to_process": 3}} {"type": "Docker", "host_id": "cd775c2a-1eef-4616-b5e6-6d7aadb6328b", "host_naming_fallback": "BestService"} Discovery Run 2025-11-18 00:03:41.703719+00 2025-11-18 00:03:52.140304+00 -e02a20a2-984e-45da-868e-5eb93743d56c f8138211-9e6c-46d2-9ca1-62604249128d 5d3c8080-8a4a-4d94-b7a5-06fbe7ffb931 {"type": "Scheduled", "enabled": true, "last_run": null, "cron_schedule": "0 0 0 * * *"} {"type": "SelfReport", "host_id": "d005a6c3-d60f-4a83-b79f-300aa6d643ae"} Self Report @ 172.25.0.4 2025-11-18 14:26:00.556502+00 2025-11-18 14:26:00.556502+00 -9b20bcb9-15e0-446d-9c48-e97b39eb4693 f8138211-9e6c-46d2-9ca1-62604249128d 5d3c8080-8a4a-4d94-b7a5-06fbe7ffb931 {"type": "Scheduled", "enabled": true, "last_run": null, "cron_schedule": "0 0 0 * * *"} {"type": "Network", "subnet_ids": null, "host_naming_fallback": "BestService"} Network Scan @ 172.25.0.4 2025-11-18 14:26:00.560596+00 2025-11-18 14:26:00.560596+00 -8096b2ba-24f5-423f-81da-97c8ecf06739 f8138211-9e6c-46d2-9ca1-62604249128d 5d3c8080-8a4a-4d94-b7a5-06fbe7ffb931 {"type": "Historical", "results": {"error": null, "phase": "Complete", "daemon_id": "5d3c8080-8a4a-4d94-b7a5-06fbe7ffb931", "processed": 1, "network_id": "f8138211-9e6c-46d2-9ca1-62604249128d", "session_id": "76f82119-fc71-4493-a3f9-fd624305188f", "started_at": "2025-11-18T14:26:00.560339464Z", "finished_at": "2025-11-18T14:26:00.628734173Z", "discovery_type": {"type": "SelfReport", "host_id": "d005a6c3-d60f-4a83-b79f-300aa6d643ae"}, "total_to_process": 1}} {"type": "SelfReport", "host_id": "d005a6c3-d60f-4a83-b79f-300aa6d643ae"} Discovery Run 2025-11-18 14:26:00.560339+00 2025-11-18 14:26:00.629791+00 -4f12c637-e426-4540-ad09-d22fd6938299 f8138211-9e6c-46d2-9ca1-62604249128d 5d3c8080-8a4a-4d94-b7a5-06fbe7ffb931 {"type": "Historical", "results": {"error": null, "phase": "Complete", "daemon_id": "5d3c8080-8a4a-4d94-b7a5-06fbe7ffb931", "processed": 12, "network_id": "f8138211-9e6c-46d2-9ca1-62604249128d", "session_id": "cd943f43-89d5-4bae-9cd4-49601bee23af", "started_at": "2025-11-18T14:26:00.633439173Z", "finished_at": "2025-11-18T14:26:40.139847011Z", "discovery_type": {"type": "Network", "subnet_ids": null, "host_naming_fallback": "BestService"}, "total_to_process": 16}} {"type": "Network", "subnet_ids": null, "host_naming_fallback": "BestService"} Discovery Run 2025-11-18 14:26:00.633439+00 2025-11-18 14:26:40.140809+00 +faf4cc0f-ed0b-4416-81f8-9b71a5cd1d11 61b521cb-415f-4067-9a9d-eccfc3d3c38b 887ae705-1262-4e8c-af47-08fe31eba9d9 {"type": "Scheduled", "enabled": true, "last_run": null, "cron_schedule": "0 0 0 * * *"} {"type": "SelfReport", "host_id": "9ea137d0-3ee2-40d2-9d75-bf12697689a5"} Self Report @ 172.25.0.4 2025-11-19 18:09:57.895007+00 2025-11-19 18:09:57.895007+00 +654adb92-da61-47cf-aa91-f7ec10733bc9 61b521cb-415f-4067-9a9d-eccfc3d3c38b 887ae705-1262-4e8c-af47-08fe31eba9d9 {"type": "Scheduled", "enabled": true, "last_run": null, "cron_schedule": "0 0 0 * * *"} {"type": "Network", "subnet_ids": null, "host_naming_fallback": "BestService"} Network Scan @ 172.25.0.4 2025-11-19 18:09:57.900776+00 2025-11-19 18:09:57.900776+00 +4e534407-db5c-4255-9518-cc9de43d8036 61b521cb-415f-4067-9a9d-eccfc3d3c38b 887ae705-1262-4e8c-af47-08fe31eba9d9 {"type": "Historical", "results": {"error": null, "phase": "Complete", "daemon_id": "887ae705-1262-4e8c-af47-08fe31eba9d9", "processed": 1, "network_id": "61b521cb-415f-4067-9a9d-eccfc3d3c38b", "session_id": "ece32baf-d4b7-424b-a015-596d51edc068", "started_at": "2025-11-19T18:09:57.900482908Z", "finished_at": "2025-11-19T18:09:57.996170361Z", "discovery_type": {"type": "SelfReport", "host_id": "9ea137d0-3ee2-40d2-9d75-bf12697689a5"}, "total_to_process": 1}} {"type": "SelfReport", "host_id": "9ea137d0-3ee2-40d2-9d75-bf12697689a5"} Discovery Run 2025-11-19 18:09:57.900482+00 2025-11-19 18:09:57.997215+00 +9b22049b-2d1f-479b-8b94-65afc2b570aa 61b521cb-415f-4067-9a9d-eccfc3d3c38b 887ae705-1262-4e8c-af47-08fe31eba9d9 {"type": "Historical", "results": {"error": null, "phase": "Complete", "daemon_id": "887ae705-1262-4e8c-af47-08fe31eba9d9", "processed": 12, "network_id": "61b521cb-415f-4067-9a9d-eccfc3d3c38b", "session_id": "93c02bc4-3e17-4cf4-bf86-19f526d44ec2", "started_at": "2025-11-19T18:09:58.006390394Z", "finished_at": "2025-11-19T18:10:50.667475089Z", "discovery_type": {"type": "Network", "subnet_ids": null, "host_naming_fallback": "BestService"}, "total_to_process": 16}} {"type": "Network", "subnet_ids": null, "host_naming_fallback": "BestService"} Discovery Run 2025-11-19 18:09:58.00639+00 2025-11-19 18:10:50.668607+00 \. @@ -403,7 +404,6 @@ e02a20a2-984e-45da-868e-5eb93743d56c f8138211-9e6c-46d2-9ca1-62604249128d 5d3c80 -- COPY public.groups (id, network_id, name, description, group_type, created_at, updated_at, source, color, edge_style) FROM stdin; -77c63594-39d3-4200-b93b-7e255c2b27d6 9d87811c-0b4c-4449-8ae8-1c7a6f252c59 1 \N {"group_type": "RequestPath", "service_bindings": ["2d9b16c2-0a35-43b6-91a6-b88d8b717937"]} 2025-11-18 00:04:01.727115+00 2025-11-18 00:04:01.727115+00 {"type": "Manual"} rose "SmoothStep" \. @@ -412,19 +412,13 @@ COPY public.groups (id, network_id, name, description, group_type, created_at, u -- COPY public.hosts (id, network_id, name, hostname, description, target, interfaces, services, ports, source, virtualization, created_at, updated_at, hidden) FROM stdin; -c9ece604-d614-4855-bc26-c4adbc7cf5bf 9d87811c-0b4c-4449-8ae8-1c7a6f252c59 Cloudflare DNS \N \N {"type": "ServiceBinding", "config": "9ff40468-36bc-4f93-8517-c7be0f47e0d9"} [{"id": "7c37bde3-d3e8-4d84-9026-d00dc7b2b8e2", "name": "Internet", "subnet_id": "9621c20e-1123-4782-89e2-36c48039de6e", "ip_address": "1.1.1.1", "mac_address": null}] ["1b8a9a8d-ccc1-4a2b-8d67-98db36ed72ab"] [{"id": "7914a771-e556-4658-b0ba-5a4f61263b26", "type": "DnsUdp", "number": 53, "protocol": "Udp"}] {"type": "System"} null 2025-11-18 00:03:41.59899+00 2025-11-18 00:03:41.608766+00 f -239a541f-cfbb-47a3-b42b-b53257f68cfb 9d87811c-0b4c-4449-8ae8-1c7a6f252c59 Google.com \N \N {"type": "ServiceBinding", "config": "ef985e15-c481-498c-9131-bacf5ea38df2"} [{"id": "ac3aeb2c-2db5-4b40-986f-fd29a97ee267", "name": "Internet", "subnet_id": "9621c20e-1123-4782-89e2-36c48039de6e", "ip_address": "203.0.113.165", "mac_address": null}] ["cd4f3225-1ec6-4da8-8214-c56a1ce54191"] [{"id": "477c6152-a568-43fe-b706-37d11457bb44", "type": "Https", "number": 443, "protocol": "Tcp"}] {"type": "System"} null 2025-11-18 00:03:41.598996+00 2025-11-18 00:03:41.61193+00 f -9e6bee31-f7e3-4f95-9f99-f5cd039bc410 9d87811c-0b4c-4449-8ae8-1c7a6f252c59 Mobile Device \N A mobile device connecting from a remote network {"type": "ServiceBinding", "config": "0195888d-f68f-4dd8-baaf-a4f87e97df30"} [{"id": "c6658ccd-5394-455f-bb5e-dc501badccda", "name": "Remote Network", "subnet_id": "a3bbf0bd-086e-4291-89b5-eb6680c1b137", "ip_address": "203.0.113.138", "mac_address": null}] ["1d285bea-899e-42c0-8618-d23123d83df1"] [{"id": "d0fef08e-360c-4fc3-b868-d77ea31984a9", "type": "Custom", "number": 0, "protocol": "Tcp"}] {"type": "System"} null 2025-11-18 00:03:41.598998+00 2025-11-18 00:03:41.614899+00 f -cd775c2a-1eef-4616-b5e6-6d7aadb6328b 9d87811c-0b4c-4449-8ae8-1c7a6f252c59 192.168.65.3 docker-desktop NetVisor daemon {"type": "None"} [{"id": "cad5fe90-5fdb-4efe-bd10-d19ee5bc2dab", "name": "eth0", "subnet_id": "95317b60-ba99-4056-b1c0-022f58b60097", "ip_address": "192.168.65.3", "mac_address": "F2:3C:5F:F8:BF:4D"}, {"id": "d90a76c6-436c-4804-a0f1-52bec852c9bf", "name": "services1", "subnet_id": "95317b60-ba99-4056-b1c0-022f58b60097", "ip_address": "192.168.65.6", "mac_address": "42:1C:14:00:64:E5"}, {"id": "c34553fe-22bc-459b-8694-179b28da2e24", "name": "netvisor_netvisor", "subnet_id": "566426f1-7294-49a7-9a75-0aca331793e6", "ip_address": "172.31.0.2", "mac_address": "C6:FC:0F:89:DF:82"}, {"id": "0885358a-1f09-4081-a1a7-21e1e51df73f", "name": "docker0", "subnet_id": "9465b234-7aab-4d47-9971-730fbbcdfec1", "ip_address": "172.17.0.1", "mac_address": "BE:4A:1B:D3:A1:88"}, {"id": "87e5150e-d064-4b10-b156-18a549d75cae", "name": "br-9628b29e0ec1", "subnet_id": "566426f1-7294-49a7-9a75-0aca331793e6", "ip_address": "172.31.0.1", "mac_address": "86:80:5F:5A:4B:40"}, {"id": "7e534ddd-8f5b-4a0f-88cd-cb10686e11c9", "name": "netvisor_netvisor", "subnet_id": "566426f1-7294-49a7-9a75-0aca331793e6", "ip_address": "172.31.0.3", "mac_address": "5A:25:22:3A:5D:66"}] ["d859eeaa-0abb-4caf-a568-af0de36f9269", "efdbc2bb-0252-4821-a149-a48c8b88b7d2", "a9ca87b3-1d69-42d5-bd47-4c1347ffe849", "61a792c1-a2fb-43a0-86d7-1a80430baa7b", "7b8e7e62-b29b-4277-9b25-59275ac15e16", "98a10ed4-21c3-446f-8eb7-a3508a054eda", "4b3f8709-1fc2-423b-90e2-f885f0d06f01"] [{"id": "5ec2312e-b06f-4029-af10-d73da37ab02e", "type": "Custom", "number": 60073, "protocol": "Tcp"}, {"id": "11e41ce5-1d63-44d7-8bf4-ff8cc44bdccb", "type": "Custom", "number": 60072, "protocol": "Tcp"}] {"type": "Discovery", "metadata": [{"date": "2025-11-18T00:03:58.385568049Z", "type": "Network", "daemon_id": "72b8c7bf-3145-47a4-bfac-5b353f2837db", "subnet_ids": null, "host_naming_fallback": "BestService"}, {"date": "2025-11-18T00:03:49.219180836Z", "type": "Docker", "host_id": "cd775c2a-1eef-4616-b5e6-6d7aadb6328b", "daemon_id": "72b8c7bf-3145-47a4-bfac-5b353f2837db", "host_naming_fallback": "BestService"}, {"date": "2025-11-18T00:03:46.130610084Z", "type": "Docker", "host_id": "cd775c2a-1eef-4616-b5e6-6d7aadb6328b", "daemon_id": "72b8c7bf-3145-47a4-bfac-5b353f2837db", "host_naming_fallback": "BestService"}, {"date": "2025-11-18T00:03:41.770284513Z", "type": "Docker", "host_id": "cd775c2a-1eef-4616-b5e6-6d7aadb6328b", "daemon_id": "72b8c7bf-3145-47a4-bfac-5b353f2837db", "host_naming_fallback": "BestService"}, {"date": "2025-11-18T00:03:41.704378680Z", "type": "Docker", "host_id": "cd775c2a-1eef-4616-b5e6-6d7aadb6328b", "daemon_id": "72b8c7bf-3145-47a4-bfac-5b353f2837db", "host_naming_fallback": "BestService"}, {"date": "2025-11-18T00:03:41.650803597Z", "type": "SelfReport", "host_id": "cd775c2a-1eef-4616-b5e6-6d7aadb6328b", "daemon_id": "72b8c7bf-3145-47a4-bfac-5b353f2837db"}]} null 2025-11-18 00:03:41.626372+00 2025-11-18 00:03:58.396925+00 f -0e42e14b-ccd1-4296-9fa3-725ed5f16a75 9d87811c-0b4c-4449-8ae8-1c7a6f252c59 NetVisor Server API \N \N {"type": "None"} [{"id": "e7e4e3b4-4172-4708-b1f2-4812884af864", "name": null, "subnet_id": "95317b60-ba99-4056-b1c0-022f58b60097", "ip_address": "192.168.65.254", "mac_address": "5A:94:EF:E4:0C:DD"}] ["98887096-6410-4ba3-862e-d9d3680b91ff"] [{"id": "ffd342cd-8cee-4ec0-8c14-57611ab7080e", "type": "Custom", "number": 60072, "protocol": "Tcp"}] {"type": "Discovery", "metadata": [{"date": "2025-11-18T00:03:55.627426506Z", "type": "Network", "daemon_id": "72b8c7bf-3145-47a4-bfac-5b353f2837db", "subnet_ids": null, "host_naming_fallback": "BestService"}]} null 2025-11-18 00:03:55.627426+00 2025-11-18 00:03:58.393008+00 f -360a3ada-1cc1-4069-b0b9-2952ff7cc948 9d87811c-0b4c-4449-8ae8-1c7a6f252c59 192.168.65.7 \N \N {"type": "None"} [{"id": "a6f1f589-74fc-4ca5-aa38-abdd814ee58b", "name": null, "subnet_id": "95317b60-ba99-4056-b1c0-022f58b60097", "ip_address": "192.168.65.7", "mac_address": "2A:AE:EB:45:20:28"}] ["8b32c144-df3d-45bd-b030-27ccadeeb5d7"] [{"id": "ac32745a-0e2c-4b75-a999-2238a43b7153", "type": "DnsTcp", "number": 53, "protocol": "Tcp"}, {"id": "9996f83d-170f-410a-9c5f-fd8ed346a465", "type": "DnsUdp", "number": 53, "protocol": "Udp"}] {"type": "Discovery", "metadata": [{"date": "2025-11-18T00:03:52.542268504Z", "type": "Network", "daemon_id": "72b8c7bf-3145-47a4-bfac-5b353f2837db", "subnet_ids": null, "host_naming_fallback": "BestService"}]} null 2025-11-18 00:03:52.54227+00 2025-11-18 00:03:55.467866+00 f -dc6e1a47-876d-4279-add5-de9b17fdcf13 f8138211-9e6c-46d2-9ca1-62604249128d Cloudflare DNS \N \N {"type": "ServiceBinding", "config": "1abae944-cc9e-49d7-be25-0a9f5f8e9d08"} [{"id": "a64a5b31-6820-488c-9f7d-437c8523066e", "name": "Internet", "subnet_id": "1fda39c9-50b9-4220-8f0b-7a7883a8fb51", "ip_address": "1.1.1.1", "mac_address": null}] ["01fe1823-f840-447f-8f06-34ba42a05d27"] [{"id": "53d836b0-c2f2-4c83-a108-02f48561ed26", "type": "DnsUdp", "number": 53, "protocol": "Udp"}] {"type": "System"} null 2025-11-18 14:26:00.502211+00 2025-11-18 14:26:00.508648+00 f -d12d91f9-5f8f-4c75-8e21-bf81d29f2dfc f8138211-9e6c-46d2-9ca1-62604249128d Google.com \N \N {"type": "ServiceBinding", "config": "46380992-59be-4690-a32a-26ddcdeddf89"} [{"id": "d33fb825-8d1c-4388-bb7e-ff12e653da83", "name": "Internet", "subnet_id": "1fda39c9-50b9-4220-8f0b-7a7883a8fb51", "ip_address": "203.0.113.24", "mac_address": null}] ["7d25d600-9a2c-49a2-9241-a347bfbd70e4"] [{"id": "cf4317a1-c1fe-4421-88b7-f5d373c79367", "type": "Https", "number": 443, "protocol": "Tcp"}] {"type": "System"} null 2025-11-18 14:26:00.502219+00 2025-11-18 14:26:00.510571+00 f -f54bd391-f764-45e1-a0af-fb9cb1faf866 f8138211-9e6c-46d2-9ca1-62604249128d Mobile Device \N A mobile device connecting from a remote network {"type": "ServiceBinding", "config": "285eb44f-6ac2-4b50-a58e-1af4a2c95bad"} [{"id": "a2f723dd-776e-4853-a0ba-4271af4cae68", "name": "Remote Network", "subnet_id": "38163127-d618-4d0c-93a9-fb16f41b1cb1", "ip_address": "203.0.113.74", "mac_address": null}] ["f0c6a60d-41cf-4b5c-9016-00c3b67bf7e1"] [{"id": "a47ed054-371e-49fd-b421-926b07e93c7f", "type": "Custom", "number": 0, "protocol": "Tcp"}] {"type": "System"} null 2025-11-18 14:26:00.502221+00 2025-11-18 14:26:00.512006+00 f -80cd569a-0d22-44fa-a5fb-3f689f462fbe f8138211-9e6c-46d2-9ca1-62604249128d netvisor-server-1.netvisor_netvisor-dev netvisor-server-1.netvisor_netvisor-dev \N {"type": "Hostname"} [{"id": "28cd9c3b-eefa-4d3b-9abb-c83527bd6d00", "name": null, "subnet_id": "8d4a6cd2-c42d-452d-a7da-ac2b57ad24e8", "ip_address": "172.25.0.3", "mac_address": "1A:39:06:9C:50:68"}] ["8ea2a0e4-e14e-427f-9bd6-7209f1b1d7ef"] [{"id": "7f18db01-2acc-4e50-9645-31f26b732fc9", "type": "Custom", "number": 60072, "protocol": "Tcp"}] {"type": "Discovery", "metadata": [{"date": "2025-11-18T14:26:02.766466174Z", "type": "Network", "daemon_id": "5d3c8080-8a4a-4d94-b7a5-06fbe7ffb931", "subnet_ids": null, "host_naming_fallback": "BestService"}]} null 2025-11-18 14:26:02.766468+00 2025-11-18 14:26:12.135338+00 f -8c9d200d-3ccf-4bde-aee3-268c699be46a f8138211-9e6c-46d2-9ca1-62604249128d netvisor-postgres-dev-1.netvisor_netvisor-dev netvisor-postgres-dev-1.netvisor_netvisor-dev \N {"type": "Hostname"} [{"id": "9e364443-7c47-44d0-b477-7b10971c7185", "name": null, "subnet_id": "8d4a6cd2-c42d-452d-a7da-ac2b57ad24e8", "ip_address": "172.25.0.6", "mac_address": "2E:68:FB:10:CF:4D"}] ["87135afc-e88d-4388-8c27-702d1414de24"] [{"id": "98733a1c-485d-484d-ad3c-4301878bff8e", "type": "PostgreSQL", "number": 5432, "protocol": "Tcp"}] {"type": "Discovery", "metadata": [{"date": "2025-11-18T14:26:12.174800928Z", "type": "Network", "daemon_id": "5d3c8080-8a4a-4d94-b7a5-06fbe7ffb931", "subnet_ids": null, "host_naming_fallback": "BestService"}]} null 2025-11-18 14:26:12.174803+00 2025-11-18 14:26:21.476783+00 f -d005a6c3-d60f-4a83-b79f-300aa6d643ae f8138211-9e6c-46d2-9ca1-62604249128d 172.25.0.4 6401219c8c5f NetVisor daemon {"type": "None"} [{"id": "e3d7824f-dea6-4edb-ae7e-04a3a7528550", "name": "eth0", "subnet_id": "8d4a6cd2-c42d-452d-a7da-ac2b57ad24e8", "ip_address": "172.25.0.4", "mac_address": "52:36:01:D8:03:80"}] ["05f5e3b9-8b58-428d-b139-d6337d25bc4f", "569b37fe-3cdb-42b0-a699-e35359f375dd"] [{"id": "4c9506eb-20b9-4460-90e7-feecb44c9b3e", "type": "Custom", "number": 60073, "protocol": "Tcp"}] {"type": "Discovery", "metadata": [{"date": "2025-11-18T14:26:02.731833091Z", "type": "Network", "daemon_id": "5d3c8080-8a4a-4d94-b7a5-06fbe7ffb931", "subnet_ids": null, "host_naming_fallback": "BestService"}, {"date": "2025-11-18T14:26:00.623436548Z", "type": "SelfReport", "host_id": "d005a6c3-d60f-4a83-b79f-300aa6d643ae", "daemon_id": "5d3c8080-8a4a-4d94-b7a5-06fbe7ffb931"}]} null 2025-11-18 14:26:00.523292+00 2025-11-18 14:26:02.740772+00 f -af9a8f3c-880a-4e83-801b-56c749585e4b f8138211-9e6c-46d2-9ca1-62604249128d NetVisor Server API \N \N {"type": "None"} [{"id": "0e96bb5a-83aa-4926-8102-f401d56b2f1f", "name": null, "subnet_id": "8d4a6cd2-c42d-452d-a7da-ac2b57ad24e8", "ip_address": "172.25.0.1", "mac_address": "C6:6F:8F:F3:9B:5C"}] ["6d83bd6c-c7ef-4d6f-b788-16e694e921e7", "ae27b532-bd3c-4136-927f-172907a49460"] [{"id": "1880b249-24b8-4aa5-a4e0-094e403a507e", "type": "Custom", "number": 60072, "protocol": "Tcp"}, {"id": "ca07f035-f53c-4804-9b0d-6e4edacfecc0", "type": "Custom", "number": 8123, "protocol": "Tcp"}] {"type": "Discovery", "metadata": [{"date": "2025-11-18T14:26:29.600738047Z", "type": "Network", "daemon_id": "5d3c8080-8a4a-4d94-b7a5-06fbe7ffb931", "subnet_ids": null, "host_naming_fallback": "BestService"}]} null 2025-11-18 14:26:29.600741+00 2025-11-18 14:26:40.137651+00 f +d0bb5250-767e-4dc7-88ae-c9095b54203e 61b521cb-415f-4067-9a9d-eccfc3d3c38b Cloudflare DNS \N \N {"type": "ServiceBinding", "config": "40ee608a-deef-4d9a-beb4-3caaa606fce1"} [{"id": "142b853f-a36a-4c4a-8a46-27281ddee5bf", "name": "Internet", "subnet_id": "6eb9bbef-c490-4b4e-adf0-adbb0b48f20f", "ip_address": "1.1.1.1", "mac_address": null}] ["11aad073-4c5e-4959-9174-d410f226156f"] [{"id": "f868e848-5015-4dbc-995e-1a169ee68809", "type": "DnsUdp", "number": 53, "protocol": "Udp"}] {"type": "System"} null 2025-11-19 18:09:57.826214+00 2025-11-19 18:09:57.834746+00 f +783bcaf9-0f86-4c93-a9a5-73aed8152e53 61b521cb-415f-4067-9a9d-eccfc3d3c38b Google.com \N \N {"type": "ServiceBinding", "config": "c61d2d7f-bfc4-4ecb-b96d-8274777ed603"} [{"id": "000217e1-59e6-461d-a4f0-0040cf9dd2df", "name": "Internet", "subnet_id": "6eb9bbef-c490-4b4e-adf0-adbb0b48f20f", "ip_address": "203.0.113.182", "mac_address": null}] ["f7294413-4733-427b-a7f8-6150d7743d36"] [{"id": "bebb25b3-49bb-45cc-b577-2173ad35695b", "type": "Https", "number": 443, "protocol": "Tcp"}] {"type": "System"} null 2025-11-19 18:09:57.826223+00 2025-11-19 18:09:57.839541+00 f +01c2d4f7-4dbf-4e17-84e8-ac898b65ac8f 61b521cb-415f-4067-9a9d-eccfc3d3c38b Mobile Device \N A mobile device connecting from a remote network {"type": "ServiceBinding", "config": "715adaf4-b3e5-4005-8b13-3ac9c1b208fd"} [{"id": "2daaaf54-66ba-41c4-8f86-ccf3fdbe4f8e", "name": "Remote Network", "subnet_id": "1bf05386-4748-4352-8260-85b9c9daa773", "ip_address": "203.0.113.32", "mac_address": null}] ["b2f10d32-a6db-4f96-92ae-f0eddbadc99d"] [{"id": "b369f9a4-b9cb-4961-8f4f-626c5db1fe1d", "type": "Custom", "number": 0, "protocol": "Tcp"}] {"type": "System"} null 2025-11-19 18:09:57.826231+00 2025-11-19 18:09:57.843025+00 f +19be1245-29c0-49a1-9826-744962d53b13 61b521cb-415f-4067-9a9d-eccfc3d3c38b netvisor-server-1.netvisor_netvisor-dev netvisor-server-1.netvisor_netvisor-dev \N {"type": "Hostname"} [{"id": "461342db-91ec-4258-9cfa-d62310c5afc0", "name": null, "subnet_id": "d3c4eb6c-bf62-4299-8f8f-4c1a0c44a340", "ip_address": "172.25.0.3", "mac_address": "F2:50:68:08:25:7D"}] ["9ac07a7c-26ee-4556-9470-3fb819a8cb6a"] [{"id": "adb2d1d2-acd4-418d-8fde-b32b67b6c20a", "type": "Custom", "number": 60072, "protocol": "Tcp"}] {"type": "Discovery", "metadata": [{"date": "2025-11-19T18:10:00.150306693Z", "type": "Network", "daemon_id": "887ae705-1262-4e8c-af47-08fe31eba9d9", "subnet_ids": null, "host_naming_fallback": "BestService"}]} null 2025-11-19 18:10:00.150308+00 2025-11-19 18:10:14.228034+00 f +26c2861f-c3a3-45c7-8e72-287795e7be96 61b521cb-415f-4067-9a9d-eccfc3d3c38b netvisor-postgres-dev-1.netvisor_netvisor-dev netvisor-postgres-dev-1.netvisor_netvisor-dev \N {"type": "Hostname"} [{"id": "90702814-e01d-430e-9e67-35ec7cb0f2c3", "name": null, "subnet_id": "d3c4eb6c-bf62-4299-8f8f-4c1a0c44a340", "ip_address": "172.25.0.6", "mac_address": "DA:90:29:34:E2:75"}] ["23706bbb-bd9f-4f3e-bde8-ec40c04d017c"] [{"id": "1d2b2ec9-b302-4433-a6ba-9f1353904c86", "type": "PostgreSQL", "number": 5432, "protocol": "Tcp"}] {"type": "Discovery", "metadata": [{"date": "2025-11-19T18:10:14.381228463Z", "type": "Network", "daemon_id": "887ae705-1262-4e8c-af47-08fe31eba9d9", "subnet_ids": null, "host_naming_fallback": "BestService"}]} null 2025-11-19 18:10:14.381229+00 2025-11-19 18:10:28.589679+00 f +9ea137d0-3ee2-40d2-9d75-bf12697689a5 61b521cb-415f-4067-9a9d-eccfc3d3c38b 172.25.0.4 e4c4a864ff5f NetVisor daemon {"type": "None"} [{"id": "b964011d-d5bf-45fa-9a64-3a2d0a7f2d49", "name": "eth0", "subnet_id": "d3c4eb6c-bf62-4299-8f8f-4c1a0c44a340", "ip_address": "172.25.0.4", "mac_address": "E2:0D:DC:D7:11:0C"}] ["7a9926dc-e2a3-4286-9a7c-4f20a2f35b9d", "6b03ba8d-1716-46b2-8a48-10963a8db3bd"] [{"id": "6794d133-6c59-459b-8c90-e169929212f6", "type": "Custom", "number": 60073, "protocol": "Tcp"}] {"type": "Discovery", "metadata": [{"date": "2025-11-19T18:10:00.071231303Z", "type": "Network", "daemon_id": "887ae705-1262-4e8c-af47-08fe31eba9d9", "subnet_ids": null, "host_naming_fallback": "BestService"}, {"date": "2025-11-19T18:09:57.946606781Z", "type": "SelfReport", "host_id": "9ea137d0-3ee2-40d2-9d75-bf12697689a5", "daemon_id": "887ae705-1262-4e8c-af47-08fe31eba9d9"}]} null 2025-11-19 18:09:57.850784+00 2025-11-19 18:10:00.0784+00 f +0b9266bf-ecb7-49a0-8407-ce6c7a6ee3ce 61b521cb-415f-4067-9a9d-eccfc3d3c38b runnervmg1sw1 runnervmg1sw1 \N {"type": "Hostname"} [{"id": "d7557d1a-9d49-4bd2-8388-d8737e54b522", "name": null, "subnet_id": "d3c4eb6c-bf62-4299-8f8f-4c1a0c44a340", "ip_address": "172.25.0.1", "mac_address": "3E:0C:A5:C4:F8:9E"}] ["af77c50b-bc33-4faf-8d23-c93ec0773b2d", "998b4a83-1a2b-4ced-9324-416ca32c8c2c"] [{"id": "00152464-ff69-46d7-bfd4-16c0de01dfc5", "type": "Custom", "number": 8123, "protocol": "Tcp"}, {"id": "3fc07f84-a587-4d9d-86b8-79a9de33ac31", "type": "Custom", "number": 60072, "protocol": "Tcp"}, {"id": "c1357b5d-12d4-402e-9646-9d24d230dd10", "type": "Ssh", "number": 22, "protocol": "Tcp"}] {"type": "Discovery", "metadata": [{"date": "2025-11-19T18:10:36.737214837Z", "type": "Network", "daemon_id": "887ae705-1262-4e8c-af47-08fe31eba9d9", "subnet_ids": null, "host_naming_fallback": "BestService"}]} null 2025-11-19 18:10:36.737217+00 2025-11-19 18:10:50.665517+00 f \. @@ -433,8 +427,7 @@ af9a8f3c-880a-4e83-801b-56c749585e4b f8138211-9e6c-46d2-9ca1-62604249128d NetVis -- COPY public.networks (id, name, created_at, updated_at, is_default, organization_id) FROM stdin; -9d87811c-0b4c-4449-8ae8-1c7a6f252c59 My Network 2025-11-18 00:03:41.598158+00 2025-11-18 00:03:41.598158+00 t 11b279b2-2caa-4b66-a3dc-f3d5d33b5c04 -f8138211-9e6c-46d2-9ca1-62604249128d My Network 2025-11-18 14:26:00.501569+00 2025-11-18 14:26:00.501569+00 f c11ae96a-f7e4-404c-8873-39fa9e2b6985 +61b521cb-415f-4067-9a9d-eccfc3d3c38b My Network 2025-11-19 18:09:57.82488+00 2025-11-19 18:09:57.82488+00 f c379f097-a967-4d9a-8050-020258ac5899 \. @@ -443,8 +436,7 @@ f8138211-9e6c-46d2-9ca1-62604249128d My Network 2025-11-18 14:26:00.501569+00 20 -- COPY public.organizations (id, name, stripe_customer_id, plan, plan_status, created_at, updated_at, is_onboarded) FROM stdin; -11b279b2-2caa-4b66-a3dc-f3d5d33b5c04 My Organization \N \N \N 2025-11-18 00:09:01.489572+00 2025-11-18 00:09:01.489572+00 t -c11ae96a-f7e4-404c-8873-39fa9e2b6985 My Organization \N {"type": "Community", "price": {"rate": "Month", "cents": 0}, "trial_days": 0} null 2025-11-18 14:26:00.482537+00 2025-11-18 14:26:00.501041+00 t +c379f097-a967-4d9a-8050-020258ac5899 My Organization \N {"type": "Community", "price": {"rate": "Month", "cents": 0}, "trial_days": 0} null 2025-11-19 18:09:54.184605+00 2025-11-19 18:09:57.823371+00 t \. @@ -453,23 +445,14 @@ c11ae96a-f7e4-404c-8873-39fa9e2b6985 My Organization \N {"type": "Community", "p -- COPY public.services (id, network_id, created_at, updated_at, name, host_id, bindings, service_definition, virtualization, source) FROM stdin; -1b8a9a8d-ccc1-4a2b-8d67-98db36ed72ab 9d87811c-0b4c-4449-8ae8-1c7a6f252c59 2025-11-18 00:03:41.598994+00 2025-11-18 00:03:41.60805+00 Cloudflare DNS c9ece604-d614-4855-bc26-c4adbc7cf5bf [{"id": "9ff40468-36bc-4f93-8517-c7be0f47e0d9", "type": "Port", "port_id": "7914a771-e556-4658-b0ba-5a4f61263b26", "interface_id": "7c37bde3-d3e8-4d84-9026-d00dc7b2b8e2"}] "Dns Server" null {"type": "System"} -cd4f3225-1ec6-4da8-8214-c56a1ce54191 9d87811c-0b4c-4449-8ae8-1c7a6f252c59 2025-11-18 00:03:41.598997+00 2025-11-18 00:03:41.611535+00 Google.com 239a541f-cfbb-47a3-b42b-b53257f68cfb [{"id": "ef985e15-c481-498c-9131-bacf5ea38df2", "type": "Port", "port_id": "477c6152-a568-43fe-b706-37d11457bb44", "interface_id": "ac3aeb2c-2db5-4b40-986f-fd29a97ee267"}] "Web Service" null {"type": "System"} -1d285bea-899e-42c0-8618-d23123d83df1 9d87811c-0b4c-4449-8ae8-1c7a6f252c59 2025-11-18 00:03:41.598998+00 2025-11-18 00:03:41.614494+00 Mobile Device 9e6bee31-f7e3-4f95-9f99-f5cd039bc410 [{"id": "0195888d-f68f-4dd8-baaf-a4f87e97df30", "type": "Port", "port_id": "d0fef08e-360c-4fc3-b868-d77ea31984a9", "interface_id": "c6658ccd-5394-455f-bb5e-dc501badccda"}] "Client" null {"type": "System"} -efdbc2bb-0252-4821-a149-a48c8b88b7d2 9d87811c-0b4c-4449-8ae8-1c7a6f252c59 2025-11-18 00:03:41.704376+00 2025-11-18 00:03:58.395747+00 Docker cd775c2a-1eef-4616-b5e6-6d7aadb6328b [] "Docker" null {"type": "DiscoveryWithMatch", "details": {"reason": {"data": "Docker daemon self-report", "type": "reason"}, "confidence": "Certain"}, "metadata": [{"date": "2025-11-18T00:03:41.704376180Z", "type": "SelfReport", "host_id": "cd775c2a-1eef-4616-b5e6-6d7aadb6328b", "daemon_id": "72b8c7bf-3145-47a4-bfac-5b353f2837db"}]} -87135afc-e88d-4388-8c27-702d1414de24 f8138211-9e6c-46d2-9ca1-62604249128d 2025-11-18 14:26:21.463212+00 2025-11-18 14:26:21.463212+00 PostgreSQL 8c9d200d-3ccf-4bde-aee3-268c699be46a [{"id": "289edc10-afdc-48da-a016-b22c46885247", "type": "Port", "port_id": "98733a1c-485d-484d-ad3c-4301878bff8e", "interface_id": "9e364443-7c47-44d0-b477-7b10971c7185"}] "PostgreSQL" null {"type": "DiscoveryWithMatch", "details": {"reason": {"data": ["Generic service", [{"data": "Port 5432/tcp is open but is used in other service match patterns", "type": "reason"}]], "type": "container"}, "confidence": "NotApplicable"}, "metadata": [{"date": "2025-11-18T14:26:21.463198377Z", "type": "Network", "daemon_id": "5d3c8080-8a4a-4d94-b7a5-06fbe7ffb931", "subnet_ids": null, "host_naming_fallback": "BestService"}]} -ae27b532-bd3c-4136-927f-172907a49460 f8138211-9e6c-46d2-9ca1-62604249128d 2025-11-18 14:26:36.345949+00 2025-11-18 14:26:36.345949+00 Home Assistant af9a8f3c-880a-4e83-801b-56c749585e4b [{"id": "4106d134-1be0-4943-95b6-c995cc99d882", "type": "Port", "port_id": "ca07f035-f53c-4804-9b0d-6e4edacfecc0", "interface_id": "0e96bb5a-83aa-4926-8102-f401d56b2f1f"}] "Home Assistant" null {"type": "DiscoveryWithMatch", "details": {"reason": {"data": "Response for 172.25.0.1:8123/ contained \\"home assistant\\" in body", "type": "reason"}, "confidence": "High"}, "metadata": [{"date": "2025-11-18T14:26:36.345945926Z", "type": "Network", "daemon_id": "5d3c8080-8a4a-4d94-b7a5-06fbe7ffb931", "subnet_ids": null, "host_naming_fallback": "BestService"}]} -6d83bd6c-c7ef-4d6f-b788-16e694e921e7 f8138211-9e6c-46d2-9ca1-62604249128d 2025-11-18 14:26:36.34579+00 2025-11-18 14:26:36.34579+00 NetVisor Server API af9a8f3c-880a-4e83-801b-56c749585e4b [{"id": "3479aecf-6f5a-4286-ac77-cbf5e0586566", "type": "Port", "port_id": "1880b249-24b8-4aa5-a4e0-094e403a507e", "interface_id": "0e96bb5a-83aa-4926-8102-f401d56b2f1f"}] "NetVisor Server API" null {"type": "DiscoveryWithMatch", "details": {"reason": {"data": "Response for 172.25.0.1:60072/api/health contained \\"netvisor\\" in body", "type": "reason"}, "confidence": "High"}, "metadata": [{"date": "2025-11-18T14:26:36.345769134Z", "type": "Network", "daemon_id": "5d3c8080-8a4a-4d94-b7a5-06fbe7ffb931", "subnet_ids": null, "host_naming_fallback": "BestService"}]} -8b32c144-df3d-45bd-b030-27ccadeeb5d7 9d87811c-0b4c-4449-8ae8-1c7a6f252c59 2025-11-18 00:03:55.462023+00 2025-11-18 00:03:55.467431+00 Dns Server 360a3ada-1cc1-4069-b0b9-2952ff7cc948 [{"id": "cff09680-f2a4-4161-8efd-233c9af9e44b", "type": "Port", "port_id": "ac32745a-0e2c-4b75-a999-2238a43b7153", "interface_id": "a6f1f589-74fc-4ca5-aa38-abdd814ee58b"}, {"id": "2d9b16c2-0a35-43b6-91a6-b88d8b717937", "type": "Port", "port_id": "9996f83d-170f-410a-9c5f-fd8ed346a465", "interface_id": "a6f1f589-74fc-4ca5-aa38-abdd814ee58b"}] "Dns Server" null {"type": "DiscoveryWithMatch", "details": {"reason": {"data": ["Generic service", [{"data": ["Any of", [{"data": "Port 53/tcp is open but is used in other service match patterns", "type": "reason"}, {"data": "Port 53/udp is open but is used in other service match patterns", "type": "reason"}]], "type": "container"}]], "type": "container"}, "confidence": "NotApplicable"}, "metadata": [{"date": "2025-11-18T00:03:55.462020756Z", "type": "Network", "daemon_id": "72b8c7bf-3145-47a4-bfac-5b353f2837db", "subnet_ids": null, "host_naming_fallback": "BestService"}]} -61a792c1-a2fb-43a0-86d7-1a80430baa7b 9d87811c-0b4c-4449-8ae8-1c7a6f252c59 2025-11-18 00:03:49.137073+00 2025-11-18 00:03:58.395495+00 netvisor-postgres-1 cd775c2a-1eef-4616-b5e6-6d7aadb6328b [{"id": "66c7acf9-9f4c-45d0-aad0-c68cb87c312e", "type": "Interface", "interface_id": "c34553fe-22bc-459b-8694-179b28da2e24"}] "Docker Container" {"type": "Docker", "details": {"service_id": "efdbc2bb-0252-4821-a149-a48c8b88b7d2", "container_id": "b5a97aaaf5b85baf3e0623093510c92fbd520605cb4b75b2cb40f4263666b28c", "container_name": "netvisor-postgres-1"}} {"type": "DiscoveryWithMatch", "details": {"reason": {"data": ["Generic service", [{"data": ["All of", [{"data": "Service is running in docker container", "type": "reason"}, {"data": "No other services with this container's ID have been matched", "type": "reason"}]], "type": "container"}]], "type": "container"}, "confidence": "NotApplicable"}, "metadata": [{"date": "2025-11-18T00:03:49.137060461Z", "type": "Docker", "host_id": "cd775c2a-1eef-4616-b5e6-6d7aadb6328b", "daemon_id": "72b8c7bf-3145-47a4-bfac-5b353f2837db", "host_naming_fallback": "BestService"}]} -d859eeaa-0abb-4caf-a568-af0de36f9269 9d87811c-0b4c-4449-8ae8-1c7a6f252c59 2025-11-18 00:03:41.651094+00 2025-11-18 00:03:58.395595+00 NetVisor Daemon API cd775c2a-1eef-4616-b5e6-6d7aadb6328b [{"id": "8782f860-52ac-4c58-ba9d-35846e8a10c8", "type": "Port", "port_id": "5ec2312e-b06f-4029-af10-d73da37ab02e", "interface_id": "cad5fe90-5fdb-4efe-bd10-d19ee5bc2dab"}, {"id": "0a5d082b-a1f0-4093-be37-668afc2d4120", "type": "Port", "port_id": "5ec2312e-b06f-4029-af10-d73da37ab02e", "interface_id": "d90a76c6-436c-4804-a0f1-52bec852c9bf"}] "NetVisor Daemon API" {"type": "Docker", "details": {"service_id": "efdbc2bb-0252-4821-a149-a48c8b88b7d2", "container_id": "f3d95ff23b8db35bee96dd1bab7691f6e693976952b0ce5bc58c3880be89c42a", "container_name": "netvisor-daemon"}} {"type": "DiscoveryWithMatch", "details": {"reason": {"data": "NetVisor Daemon self-report", "type": "reason"}, "confidence": "Certain"}, "metadata": [{"date": "2025-11-18T00:03:55.627230506Z", "type": "Network", "daemon_id": "72b8c7bf-3145-47a4-bfac-5b353f2837db", "subnet_ids": null, "host_naming_fallback": "BestService"}, {"date": "2025-11-18T00:03:42.968719541Z", "type": "Docker", "host_id": "cd775c2a-1eef-4616-b5e6-6d7aadb6328b", "daemon_id": "72b8c7bf-3145-47a4-bfac-5b353f2837db", "host_naming_fallback": "BestService"}, {"date": "2025-11-18T00:03:41.650812847Z", "type": "SelfReport", "host_id": "cd775c2a-1eef-4616-b5e6-6d7aadb6328b", "daemon_id": "72b8c7bf-3145-47a4-bfac-5b353f2837db"}]} -98887096-6410-4ba3-862e-d9d3680b91ff 9d87811c-0b4c-4449-8ae8-1c7a6f252c59 2025-11-18 00:03:56.456093+00 2025-11-18 00:03:58.392315+00 NetVisor Server API 0e42e14b-ccd1-4296-9fa3-725ed5f16a75 [{"id": "1b6fa161-acce-47bf-a00a-b0c83de52a50", "type": "Port", "port_id": "ffd342cd-8cee-4ec0-8c14-57611ab7080e", "interface_id": "e7e4e3b4-4172-4708-b1f2-4812884af864"}] "NetVisor Server API" null {"type": "DiscoveryWithMatch", "details": {"reason": {"data": "Response from http://192.168.65.254:60072/api/health contained \\"netvisor\\"", "type": "reason"}, "confidence": "High"}, "metadata": [{"date": "2025-11-18T00:03:56.456091964Z", "type": "Network", "daemon_id": "72b8c7bf-3145-47a4-bfac-5b353f2837db", "subnet_ids": null, "host_naming_fallback": "BestService"}]} -7b8e7e62-b29b-4277-9b25-59275ac15e16 9d87811c-0b4c-4449-8ae8-1c7a6f252c59 2025-11-18 00:03:50.142542+00 2025-11-18 00:03:58.395453+00 NetVisor Server API cd775c2a-1eef-4616-b5e6-6d7aadb6328b [{"id": "2ecb978d-7054-449d-9711-47fb51b3903b", "type": "Port", "port_id": "11e41ce5-1d63-44d7-8bf4-ff8cc44bdccb", "interface_id": "7e534ddd-8f5b-4a0f-88cd-cb10686e11c9"}, {"id": "d82f809b-fdad-4657-a8e0-7a43755427fb", "type": "Port", "port_id": "11e41ce5-1d63-44d7-8bf4-ff8cc44bdccb", "interface_id": "cad5fe90-5fdb-4efe-bd10-d19ee5bc2dab"}, {"id": "4842f8c8-7f63-47ea-a22b-8f8e23f11b69", "type": "Port", "port_id": "11e41ce5-1d63-44d7-8bf4-ff8cc44bdccb", "interface_id": "d90a76c6-436c-4804-a0f1-52bec852c9bf"}] "NetVisor Server API" {"type": "Docker", "details": {"service_id": "efdbc2bb-0252-4821-a149-a48c8b88b7d2", "container_id": "bf9432c679cef9caf2b4ff808c3c72fc88135c091f5adcb7f6ad19fa584ed34b", "container_name": "netvisor-server-1"}} {"type": "DiscoveryWithMatch", "details": {"reason": {"data": "Response from http://0.0.0.0:60072/api/health contained \\"netvisor\\"", "type": "reason"}, "confidence": "High"}, "metadata": [{"date": "2025-11-18T00:03:55.627224256Z", "type": "Network", "daemon_id": "72b8c7bf-3145-47a4-bfac-5b353f2837db", "subnet_ids": null, "host_naming_fallback": "BestService"}, {"date": "2025-11-18T00:03:50.142540711Z", "type": "Docker", "host_id": "cd775c2a-1eef-4616-b5e6-6d7aadb6328b", "daemon_id": "72b8c7bf-3145-47a4-bfac-5b353f2837db", "host_naming_fallback": "BestService"}]} -01fe1823-f840-447f-8f06-34ba42a05d27 f8138211-9e6c-46d2-9ca1-62604249128d 2025-11-18 14:26:00.502215+00 2025-11-18 14:26:00.502215+00 Cloudflare DNS dc6e1a47-876d-4279-add5-de9b17fdcf13 [{"id": "1abae944-cc9e-49d7-be25-0a9f5f8e9d08", "type": "Port", "port_id": "53d836b0-c2f2-4c83-a108-02f48561ed26", "interface_id": "a64a5b31-6820-488c-9f7d-437c8523066e"}] "Dns Server" null {"type": "System"} -7d25d600-9a2c-49a2-9241-a347bfbd70e4 f8138211-9e6c-46d2-9ca1-62604249128d 2025-11-18 14:26:00.502219+00 2025-11-18 14:26:00.502219+00 Google.com d12d91f9-5f8f-4c75-8e21-bf81d29f2dfc [{"id": "46380992-59be-4690-a32a-26ddcdeddf89", "type": "Port", "port_id": "cf4317a1-c1fe-4421-88b7-f5d373c79367", "interface_id": "d33fb825-8d1c-4388-bb7e-ff12e653da83"}] "Web Service" null {"type": "System"} -f0c6a60d-41cf-4b5c-9016-00c3b67bf7e1 f8138211-9e6c-46d2-9ca1-62604249128d 2025-11-18 14:26:00.502222+00 2025-11-18 14:26:00.502222+00 Mobile Device f54bd391-f764-45e1-a0af-fb9cb1faf866 [{"id": "285eb44f-6ac2-4b50-a58e-1af4a2c95bad", "type": "Port", "port_id": "a47ed054-371e-49fd-b421-926b07e93c7f", "interface_id": "a2f723dd-776e-4853-a0ba-4271af4cae68"}] "Client" null {"type": "System"} -05f5e3b9-8b58-428d-b139-d6337d25bc4f f8138211-9e6c-46d2-9ca1-62604249128d 2025-11-18 14:26:00.623447+00 2025-11-18 14:26:02.739815+00 NetVisor Daemon API d005a6c3-d60f-4a83-b79f-300aa6d643ae [{"id": "20776608-9584-46a3-9bfd-1c41970801a0", "type": "Port", "port_id": "4c9506eb-20b9-4460-90e7-feecb44c9b3e", "interface_id": "e3d7824f-dea6-4edb-ae7e-04a3a7528550"}] "NetVisor Daemon API" null {"type": "DiscoveryWithMatch", "details": {"reason": {"data": "NetVisor Daemon self-report", "type": "reason"}, "confidence": "Certain"}, "metadata": [{"date": "2025-11-18T14:26:02.732627549Z", "type": "Network", "daemon_id": "5d3c8080-8a4a-4d94-b7a5-06fbe7ffb931", "subnet_ids": null, "host_naming_fallback": "BestService"}, {"date": "2025-11-18T14:26:00.623446340Z", "type": "SelfReport", "host_id": "d005a6c3-d60f-4a83-b79f-300aa6d643ae", "daemon_id": "5d3c8080-8a4a-4d94-b7a5-06fbe7ffb931"}]} -8ea2a0e4-e14e-427f-9bd6-7209f1b1d7ef f8138211-9e6c-46d2-9ca1-62604249128d 2025-11-18 14:26:08.43878+00 2025-11-18 14:26:08.43878+00 NetVisor Server API 80cd569a-0d22-44fa-a5fb-3f689f462fbe [{"id": "5fdfb535-724b-4826-bf5b-96ef190cc4f9", "type": "Port", "port_id": "7f18db01-2acc-4e50-9645-31f26b732fc9", "interface_id": "28cd9c3b-eefa-4d3b-9abb-c83527bd6d00"}] "NetVisor Server API" null {"type": "DiscoveryWithMatch", "details": {"reason": {"data": "Response for 172.25.0.3:60072/api/health contained \\"netvisor\\" in body", "type": "reason"}, "confidence": "High"}, "metadata": [{"date": "2025-11-18T14:26:08.438766385Z", "type": "Network", "daemon_id": "5d3c8080-8a4a-4d94-b7a5-06fbe7ffb931", "subnet_ids": null, "host_naming_fallback": "BestService"}]} +11aad073-4c5e-4959-9174-d410f226156f 61b521cb-415f-4067-9a9d-eccfc3d3c38b 2025-11-19 18:09:57.826216+00 2025-11-19 18:09:57.826216+00 Cloudflare DNS d0bb5250-767e-4dc7-88ae-c9095b54203e [{"id": "40ee608a-deef-4d9a-beb4-3caaa606fce1", "type": "Port", "port_id": "f868e848-5015-4dbc-995e-1a169ee68809", "interface_id": "142b853f-a36a-4c4a-8a46-27281ddee5bf"}] "Dns Server" null {"type": "System"} +f7294413-4733-427b-a7f8-6150d7743d36 61b521cb-415f-4067-9a9d-eccfc3d3c38b 2025-11-19 18:09:57.826225+00 2025-11-19 18:09:57.826225+00 Google.com 783bcaf9-0f86-4c93-a9a5-73aed8152e53 [{"id": "c61d2d7f-bfc4-4ecb-b96d-8274777ed603", "type": "Port", "port_id": "bebb25b3-49bb-45cc-b577-2173ad35695b", "interface_id": "000217e1-59e6-461d-a4f0-0040cf9dd2df"}] "Web Service" null {"type": "System"} +b2f10d32-a6db-4f96-92ae-f0eddbadc99d 61b521cb-415f-4067-9a9d-eccfc3d3c38b 2025-11-19 18:09:57.826232+00 2025-11-19 18:09:57.826232+00 Mobile Device 01c2d4f7-4dbf-4e17-84e8-ac898b65ac8f [{"id": "715adaf4-b3e5-4005-8b13-3ac9c1b208fd", "type": "Port", "port_id": "b369f9a4-b9cb-4961-8f4f-626c5db1fe1d", "interface_id": "2daaaf54-66ba-41c4-8f86-ccf3fdbe4f8e"}] "Client" null {"type": "System"} +7a9926dc-e2a3-4286-9a7c-4f20a2f35b9d 61b521cb-415f-4067-9a9d-eccfc3d3c38b 2025-11-19 18:09:57.946623+00 2025-11-19 18:10:00.077424+00 NetVisor Daemon API 9ea137d0-3ee2-40d2-9d75-bf12697689a5 [{"id": "ea1a68e9-801b-489e-9571-b26390060e96", "type": "Port", "port_id": "6794d133-6c59-459b-8c90-e169929212f6", "interface_id": "b964011d-d5bf-45fa-9a64-3a2d0a7f2d49"}] "NetVisor Daemon API" null {"type": "DiscoveryWithMatch", "details": {"reason": {"data": "NetVisor Daemon self-report", "type": "reason"}, "confidence": "Certain"}, "metadata": [{"date": "2025-11-19T18:10:00.071843802Z", "type": "Network", "daemon_id": "887ae705-1262-4e8c-af47-08fe31eba9d9", "subnet_ids": null, "host_naming_fallback": "BestService"}, {"date": "2025-11-19T18:09:57.946622481Z", "type": "SelfReport", "host_id": "9ea137d0-3ee2-40d2-9d75-bf12697689a5", "daemon_id": "887ae705-1262-4e8c-af47-08fe31eba9d9"}]} +9ac07a7c-26ee-4556-9470-3fb819a8cb6a 61b521cb-415f-4067-9a9d-eccfc3d3c38b 2025-11-19 18:10:07.94262+00 2025-11-19 18:10:07.94262+00 NetVisor Server API 19be1245-29c0-49a1-9826-744962d53b13 [{"id": "2a5bfbde-f79c-41a0-acb3-4787da50de51", "type": "Port", "port_id": "adb2d1d2-acd4-418d-8fde-b32b67b6c20a", "interface_id": "461342db-91ec-4258-9cfa-d62310c5afc0"}] "NetVisor Server API" null {"type": "DiscoveryWithMatch", "details": {"reason": {"data": "Response for 172.25.0.3:60072/api/health contained \\"netvisor\\" in body", "type": "reason"}, "confidence": "High"}, "metadata": [{"date": "2025-11-19T18:10:07.942613021Z", "type": "Network", "daemon_id": "887ae705-1262-4e8c-af47-08fe31eba9d9", "subnet_ids": null, "host_naming_fallback": "BestService"}]} +23706bbb-bd9f-4f3e-bde8-ec40c04d017c 61b521cb-415f-4067-9a9d-eccfc3d3c38b 2025-11-19 18:10:28.580996+00 2025-11-19 18:10:28.580996+00 PostgreSQL 26c2861f-c3a3-45c7-8e72-287795e7be96 [{"id": "bde0065c-40a1-4ad2-b310-0bcca565fa68", "type": "Port", "port_id": "1d2b2ec9-b302-4433-a6ba-9f1353904c86", "interface_id": "90702814-e01d-430e-9e67-35ec7cb0f2c3"}] "PostgreSQL" null {"type": "DiscoveryWithMatch", "details": {"reason": {"data": ["Generic service", [{"data": "Port 5432/tcp is open but is used in other service match patterns", "type": "reason"}]], "type": "container"}, "confidence": "NotApplicable"}, "metadata": [{"date": "2025-11-19T18:10:28.580986289Z", "type": "Network", "daemon_id": "887ae705-1262-4e8c-af47-08fe31eba9d9", "subnet_ids": null, "host_naming_fallback": "BestService"}]} +af77c50b-bc33-4faf-8d23-c93ec0773b2d 61b521cb-415f-4067-9a9d-eccfc3d3c38b 2025-11-19 18:10:42.330668+00 2025-11-19 18:10:42.330668+00 Home Assistant 0b9266bf-ecb7-49a0-8407-ce6c7a6ee3ce [{"id": "f3ac1b5d-ec99-41e7-aa76-e8d32f65bbb3", "type": "Port", "port_id": "00152464-ff69-46d7-bfd4-16c0de01dfc5", "interface_id": "d7557d1a-9d49-4bd2-8388-d8737e54b522"}] "Home Assistant" null {"type": "DiscoveryWithMatch", "details": {"reason": {"data": "Response for 172.25.0.1:8123/ contained \\"home assistant\\" in body", "type": "reason"}, "confidence": "High"}, "metadata": [{"date": "2025-11-19T18:10:42.330656434Z", "type": "Network", "daemon_id": "887ae705-1262-4e8c-af47-08fe31eba9d9", "subnet_ids": null, "host_naming_fallback": "BestService"}]} +998b4a83-1a2b-4ced-9324-416ca32c8c2c 61b521cb-415f-4067-9a9d-eccfc3d3c38b 2025-11-19 18:10:44.405631+00 2025-11-19 18:10:44.405631+00 NetVisor Server API 0b9266bf-ecb7-49a0-8407-ce6c7a6ee3ce [{"id": "1d8605ec-586e-4423-8b7c-020b816d7129", "type": "Port", "port_id": "3fc07f84-a587-4d9d-86b8-79a9de33ac31", "interface_id": "d7557d1a-9d49-4bd2-8388-d8737e54b522"}] "NetVisor Server API" null {"type": "DiscoveryWithMatch", "details": {"reason": {"data": "Response for 172.25.0.1:60072/api/health contained \\"netvisor\\" in body", "type": "reason"}, "confidence": "High"}, "metadata": [{"date": "2025-11-19T18:10:44.405621740Z", "type": "Network", "daemon_id": "887ae705-1262-4e8c-af47-08fe31eba9d9", "subnet_ids": null, "host_naming_fallback": "BestService"}]} \. @@ -478,14 +461,9 @@ f0c6a60d-41cf-4b5c-9016-00c3b67bf7e1 f8138211-9e6c-46d2-9ca1-62604249128d 2025-1 -- COPY public.subnets (id, network_id, created_at, updated_at, cidr, name, description, subnet_type, source) FROM stdin; -9621c20e-1123-4782-89e2-36c48039de6e 9d87811c-0b4c-4449-8ae8-1c7a6f252c59 2025-11-18 00:03:41.598976+00 2025-11-18 00:03:41.598976+00 "0.0.0.0/0" Internet This subnet uses the 0.0.0.0/0 CIDR as an organizational container for services running on the internet (e.g., public DNS servers, cloud services, etc.). "Internet" {"type": "System"} -a3bbf0bd-086e-4291-89b5-eb6680c1b137 9d87811c-0b4c-4449-8ae8-1c7a6f252c59 2025-11-18 00:03:41.598978+00 2025-11-18 00:03:41.598978+00 "0.0.0.0/0" Remote Network This subnet uses the 0.0.0.0/0 CIDR as an organizational container for hosts on remote networks (e.g., mobile connections, friend's networks, public WiFi, etc.). "Remote" {"type": "System"} -95317b60-ba99-4056-b1c0-022f58b60097 9d87811c-0b4c-4449-8ae8-1c7a6f252c59 2025-11-18 00:03:41.642827+00 2025-11-18 00:03:41.642827+00 "192.168.65.0/24" 192.168.65.0/24 \N "Lan" {"type": "Discovery", "metadata": [{"date": "2025-11-18T00:03:41.642823722Z", "type": "SelfReport", "host_id": "cd775c2a-1eef-4616-b5e6-6d7aadb6328b", "daemon_id": "72b8c7bf-3145-47a4-bfac-5b353f2837db"}]} -566426f1-7294-49a7-9a75-0aca331793e6 9d87811c-0b4c-4449-8ae8-1c7a6f252c59 2025-11-18 00:03:41.719234+00 2025-11-18 00:03:41.719234+00 "172.31.0.0/16" netvisor_netvisor \N "DockerBridge" {"type": "Discovery", "metadata": [{"date": "2025-11-18T00:03:41.719234305Z", "type": "Docker", "host_id": "cd775c2a-1eef-4616-b5e6-6d7aadb6328b", "daemon_id": "72b8c7bf-3145-47a4-bfac-5b353f2837db", "host_naming_fallback": "BestService"}]} -9465b234-7aab-4d47-9971-730fbbcdfec1 9d87811c-0b4c-4449-8ae8-1c7a6f252c59 2025-11-18 00:03:41.717119+00 2025-11-18 00:03:41.717119+00 "172.17.0.0/16" 172.17.0.0/16 \N "DockerBridge" {"type": "Discovery", "metadata": [{"date": "2025-11-18T00:03:41.717119472Z", "type": "Docker", "host_id": "cd775c2a-1eef-4616-b5e6-6d7aadb6328b", "daemon_id": "72b8c7bf-3145-47a4-bfac-5b353f2837db", "host_naming_fallback": "BestService"}]} -1fda39c9-50b9-4220-8f0b-7a7883a8fb51 f8138211-9e6c-46d2-9ca1-62604249128d 2025-11-18 14:26:00.50216+00 2025-11-18 14:26:00.50216+00 "0.0.0.0/0" Internet This subnet uses the 0.0.0.0/0 CIDR as an organizational container for services running on the internet (e.g., public DNS servers, cloud services, etc.). "Internet" {"type": "System"} -38163127-d618-4d0c-93a9-fb16f41b1cb1 f8138211-9e6c-46d2-9ca1-62604249128d 2025-11-18 14:26:00.502162+00 2025-11-18 14:26:00.502162+00 "0.0.0.0/0" Remote Network This subnet uses the 0.0.0.0/0 CIDR as an organizational container for hosts on remote networks (e.g., mobile connections, friend's networks, public WiFi, etc.). "Remote" {"type": "System"} -8d4a6cd2-c42d-452d-a7da-ac2b57ad24e8 f8138211-9e6c-46d2-9ca1-62604249128d 2025-11-18 14:26:00.560783+00 2025-11-18 14:26:00.560783+00 "172.25.0.0/28" 172.25.0.0/28 \N "Lan" {"type": "Discovery", "metadata": [{"date": "2025-11-18T14:26:00.560777923Z", "type": "SelfReport", "host_id": "d005a6c3-d60f-4a83-b79f-300aa6d643ae", "daemon_id": "5d3c8080-8a4a-4d94-b7a5-06fbe7ffb931"}]} +6eb9bbef-c490-4b4e-adf0-adbb0b48f20f 61b521cb-415f-4067-9a9d-eccfc3d3c38b 2025-11-19 18:09:57.826162+00 2025-11-19 18:09:57.826162+00 "0.0.0.0/0" Internet This subnet uses the 0.0.0.0/0 CIDR as an organizational container for services running on the internet (e.g., public DNS servers, cloud services, etc.). "Internet" {"type": "System"} +1bf05386-4748-4352-8260-85b9c9daa773 61b521cb-415f-4067-9a9d-eccfc3d3c38b 2025-11-19 18:09:57.826166+00 2025-11-19 18:09:57.826166+00 "0.0.0.0/0" Remote Network This subnet uses the 0.0.0.0/0 CIDR as an organizational container for hosts on remote networks (e.g., mobile connections, friend's networks, public WiFi, etc.). "Remote" {"type": "System"} +d3c4eb6c-bf62-4299-8f8f-4c1a0c44a340 61b521cb-415f-4067-9a9d-eccfc3d3c38b 2025-11-19 18:09:57.900631+00 2025-11-19 18:09:57.900631+00 "172.25.0.0/28" 172.25.0.0/28 \N "Lan" {"type": "Discovery", "metadata": [{"date": "2025-11-19T18:09:57.900630135Z", "type": "SelfReport", "host_id": "9ea137d0-3ee2-40d2-9d75-bf12697689a5", "daemon_id": "887ae705-1262-4e8c-af47-08fe31eba9d9"}]} \. @@ -494,8 +472,7 @@ a3bbf0bd-086e-4291-89b5-eb6680c1b137 9d87811c-0b4c-4449-8ae8-1c7a6f252c59 2025-1 -- COPY public.users (id, created_at, updated_at, password_hash, oidc_provider, oidc_subject, oidc_linked_at, email, organization_id, permissions) FROM stdin; -cb5d5936-0bf9-41cc-8325-84e2a7078602 2025-11-18 00:03:41.597545+00 2025-11-18 00:03:54.725277+00 $argon2id$v=19$m=19456,t=2,p=1$HHFkG/7KixxQYg+ac8FW3g$Pnx9D/f5YidfUMInxQVChixxvynOcJ8vD6somtboToo \N \N \N test@test.com 11b279b2-2caa-4b66-a3dc-f3d5d33b5c04 Owner -0a0ee0a8-4f09-415b-95b9-6031c683b7f5 2025-11-18 14:26:00.485506+00 2025-11-18 14:26:00.485506+00 $argon2id$v=19$m=19456,t=2,p=1$1t+V45SLHSRsK3JBXDvJBQ$fS5a4aPKedFONNqsmwOa6FL2RFeNIuMuw4pCpRsRMig \N \N \N user@example.com c11ae96a-f7e4-404c-8873-39fa9e2b6985 Owner +0453c0db-81ca-47cc-b498-6bbf0aee617d 2025-11-19 18:09:54.186464+00 2025-11-19 18:09:57.812318+00 $argon2id$v=19$m=19456,t=2,p=1$WrqgKWj4pAwpZKyJ57YxZQ$Q/TPXWvkgUjasHZhsxryNLxJ/DkaxUZ8rC+CjATFpbY \N \N \N user@example.com c379f097-a967-4d9a-8050-020258ac5899 Owner \. @@ -504,9 +481,7 @@ cb5d5936-0bf9-41cc-8325-84e2a7078602 2025-11-18 00:03:41.597545+00 2025-11-18 00 -- COPY tower_sessions.session (id, data, expiry_date) FROM stdin; -UAyDo6o9xnmc_RDUQK8B5g \\x93c410e601af40d410fd9c79c63daaa3830c5081a7757365725f6964d92463623564353933362d306266392d343163632d383332352d38346532613730373836303299cd07e9cd0160000336ce2b4eed70000000 2025-12-18 00:03:54.726592+00 -sjpIBZTNC1NEIciKv8CsYg \\x93c41062acc0bf8ac82144530bcd9405483ab281a7757365725f6964d92463623564353933362d306266392d343163632d383332352d38346532613730373836303299cd07e9cd0160000b21ce0ff51540000000 2025-12-18 00:11:33.26772+00 -EEkmjUGE-RRPWxtDhc6r1g \\x93c410d6abce85431b5b4f14f984418d26491081a7757365725f6964d92430613065653061382d346630392d343135622d393562392d36303331633638336237663599cd07e9cd01600e1a00ce1d155f29000000 2025-12-18 14:26:00.487939+00 +1rdFiyAMLLw9RDDiSvWODA \\x93c4100c8ef54ae230443dbc2c0c208b45b7d681a7757365725f6964d92430343533633064622d383163612d343763632d623439382d36626266306165653631376499cd07e9cd0161120939ce30825520000000 2025-12-19 18:09:57.813847+00 \. @@ -818,5 +793,5 @@ ALTER TABLE ONLY public.users -- PostgreSQL database dump complete -- -\unrestrict WHNbxOKuN3Xlq6Oeb2cV5rt9mfq4SE7l1IP3Gl7fDrORCuqg9zY4YJlSgoW5Qge +\unrestrict eBSnQ02bTJJqBWf84vAslrtSFRhVu7kiaRFTHpaqJGK3AZClXEDcKsrVWzI35Hs diff --git a/ui/static/services.json b/ui/static/services.json index d2f91a65..a89cf23e 100644 --- a/ui/static/services.json +++ b/ui/static/services.json @@ -1,45 +1,21 @@ [ { - "description": "Free and Open-Source CalDAV and CardDAV Server", - "discovery_pattern": "Endpoint response body from :5232/.web/ contains Radicale Web Interface", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/radicale.svg", - "name": "Radicale" - }, - { - "description": "A single pane of glass for managing clustered & non-clustered Proxmox nodes", - "discovery_pattern": "Endpoint response body from :8443/ contains pdm-ui_bundle.js", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/proxmox.svg", - "name": "Proxmox Datacenter Manager" - }, - { - "description": "Sonos wireless speaker system", - "discovery_pattern": "All of: (MAC Address belongs to Sonos, Inc., Any of: (445/tcp is open, 3445/tcp is open, 1400/tcp is open, 1410/tcp is open, 1843/tcp is open, 3400/tcp is open, 3401/tcp is open, 3500/tcp is open))", - "logo_url": "https://simpleicons.org/icons/sonos.svg", - "name": "Sonos Speaker" - }, - { - "description": "A generic Dhcp server", - "discovery_pattern": "67/udp is open", - "logo_url": "", - "name": "Dhcp Server" - }, - { - "description": "Error tracking platform", - "discovery_pattern": "Endpoint response body from :9000/api/0/ contains sentry", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sentry.svg", - "name": "Sentry" + "description": "Docker", + "discovery_pattern": "No match pattern provided", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/docker.svg", + "name": "Docker" }, { - "description": "Torrent cleanup tool for Sonarr and Radarr", - "discovery_pattern": "11011/tcp is open", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/cleanuperr.svg", - "name": "Cleanuparr" + "description": "NetVisor Server API for network management", + "discovery_pattern": "Endpoint response body from :60072/api/health contains netvisor", + "logo_url": "/logos/netvisor-logo.png", + "name": "NetVisor Server API" }, { - "description": "Open source software application for managing requests for your media library.", - "discovery_pattern": "All of: (Endpoint response body from :3000/ contains Jellystat, Endpoint response body from :3000/ contains Jellyfin stats for the masses)", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/jellystat.svg", - "name": "Jellystat" + "description": "NetVisor Daemon API for network scanning", + "discovery_pattern": "Endpoint response body from :60073/api/health contains netvisor", + "logo_url": "/logos/netvisor-logo.png", + "name": "NetVisor Daemon API" }, { "description": "User invitation and management system for Jellyfin, Plex, Emby etc", @@ -48,268 +24,274 @@ "name": "Wizarr" }, { - "description": "A generic Dns server", - "discovery_pattern": "Any of: (53/tcp is open, 53/udp is open)", - "logo_url": "", - "name": "Dns Server" + "description": "A TV collection manager for Usenet and BitTorrent users.", + "discovery_pattern": "Endpoint response body from :8989/Content/manifest.json contains Sonarr", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sonarr.svg", + "name": "Sonarr" }, { - "description": "Publishing platform", - "discovery_pattern": "Endpoint response body from :2368/ contains ghost", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/ghost.png", - "name": "Ghost" + "description": "A movie collection manager for Usenet and BitTorrent users.", + "discovery_pattern": "Endpoint response body from :7878/Content/manifest.json contains Radarr", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/radarr.svg", + "name": "Radarr" }, { - "description": "Open-source firewall and router platform", - "discovery_pattern": "All of: (22/tcp is open, Endpoint response body from :80/ contains pfsense)", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/pfsense.svg", - "name": "pfSense" + "description": "Cross-platform open-source BitTorrent client", + "discovery_pattern": "Any of: (Endpoint response body from :8080/ contains qBittorrent logo, Endpoint response body from :8090/ contains qBittorrent logo)", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/qbittorrent.svg", + "name": "qBittorrent" }, { - "description": "Monitor temperatures, fan speeds, and power in real time.", - "discovery_pattern": "Endpoint response body from :11987/ contains CoolerControl", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/cooler-control.svg", - "name": "CoolerControl" + "description": "The Ultimate Indexer Manager.", + "discovery_pattern": "Endpoint response body from :3232/Content/Images/Icons/manifest.json contains Prowlarr", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/prowlarr.svg", + "name": "Prowlarr" }, { - "description": "NoSQL document database", - "discovery_pattern": "27017/tcp is open", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/mongodb.svg", - "name": "MongoDB" + "description": "A Simple OIDC provider that uses passkeys for authentication", + "discovery_pattern": "Endpoint response body from :1411/app.webmanifest contains Pocket ID", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/pocket-id-light.svg", + "name": "Pocket ID" }, { - "description": "CI/CD server", - "discovery_pattern": "Endpoint response body from :8085/ contains bamboo", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/atlassian-bamboo.svg", - "name": "Bamboo" + "description": "A tiny dashboard for Network UPS Tools", + "discovery_pattern": "Endpoint response body from :3000/api/v1/info contains peanut", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/peanut.svg", + "name": "PeaNUT" }, { - "description": "Container orchestration platform", - "discovery_pattern": "All of: (6443/tcp is open, Any of: (10250/tcp is open, 10259/tcp is open, 10257/tcp is open, 10256/tcp is open))", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/kubernetes.svg", - "name": "Kubernetes" + "description": "Community-supported document management system", + "discovery_pattern": "Endpoint response body from :8000/ contains Paperless-ngx project", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/paperless-ngx.svg", + "name": "Paperless-NGX" }, { - "description": "FTP server", - "discovery_pattern": "All of: (21/tcp is open, 14147/tcp is open)", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/filezilla.svg", - "name": "FileZilla Server" + "description": "Open, extensible, user-friendly interface for AI", + "discovery_pattern": "Endpoint response body from :8080/manifest.json contains Open WebUI", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/open-webui-light.svg", + "name": "Open WebUI" }, { - "description": "Cloudflare tunnel daemon", - "discovery_pattern": "Endpoint response body from :80/metrics contains cloudflared", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/cloudflare.svg", - "name": "Cloudflared" + "description": "An easy way to get up and running with LLMs.", + "discovery_pattern": "Endpoint response body from :11434/ contains Ollama is running", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/ollama-dark.svg", + "name": "Ollama" }, { - "description": "Infrastructure monitoring", - "discovery_pattern": "Endpoint response body from :5665/v1 contains icinga", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/icinga.svg", - "name": "Icinga" + "description": "Network UPS Tools", + "discovery_pattern": "3493/tcp is open", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/nut.svg", + "name": "NUT" }, { - "description": "Continuous file synchronization service", - "discovery_pattern": "All of: (Endpoint response body from :80/ contains Syncthing, 22000/tcp is open)", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/syncthing.svg", - "name": "Syncthing" + "description": "PXE Boot Server", + "discovery_pattern": "Endpoint response body from :61208/ contains Netbootxyz", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/netbootxyz.svg", + "name": "Netbootxyz" }, { - "description": "Eero device providing routing and gateway services", - "discovery_pattern": "All of: (MAC Address belongs to eero Inc, Host IP is a gateway in daemon's routing tables, or ends in .1 or .254.)", - "logo_url": "https://www.vectorlogo.zone/logos/eero/eero-icon.svg", - "name": "Eero Gateway" + "description": "An open-source, self-hosted note-taking service.", + "discovery_pattern": "Endpoint response body from :5230/explore contains Memos", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/memos.png", + "name": "Memos" }, { - "description": "Open source software application for managing requests for your media library.", - "discovery_pattern": "Endpoint response body from :5055/ contains Jellyseerr", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/jellyseerr.svg", - "name": "Jellyseerr" + "description": "A self-hosted recipe manager and meal planner", + "discovery_pattern": "All of: (Endpoint response body from :9000/ contains Mealie, Endpoint response body from :9000/ contains recipe)", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/mealie.svg", + "name": "Mealie" }, { - "description": "Web conferencing system", - "discovery_pattern": "Endpoint response status is between 200 and 300, and response body from :80/bigbluebutton/api contains ", - "logo_url": "https://simpleicons.org/icons/bigbluebutton.svg", - "name": "BigBlueButton" + "description": "Self-hosted YouTube downloader", + "discovery_pattern": "Endpoint response body from :8081/manifest.webmanifest contains MeTube", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/metube.svg", + "name": "MeTube" }, { - "description": "Open-source virtualization management platform", - "discovery_pattern": "Any of: (Endpoint response body from :8006/ contains proxmox, 8006/tcp is open)", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/proxmox.svg", - "name": "Proxmox VE" + "description": "Vehicle Maintenance Records and Fuel Mileage Tracker", + "discovery_pattern": "Endpoint response body from :8080/ contains Garage - LubeLogger", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/lubelogger.png", + "name": "Lubelogger" }, { - "description": "Message broker", - "discovery_pattern": "Endpoint response body from :15672/ contains rabbitmq", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/rabbitmq.svg", - "name": "RabbitMQ" + "description": "A music collection manager for Usenet and BitTorrent users.", + "discovery_pattern": "Endpoint response body from :8686/Content/manifest.json contains Lidarr", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/lidarr.svg", + "name": "Lidarr" }, { - "description": "NetVisor Server API for network management", - "discovery_pattern": "Endpoint response body from :60072/api/health contains netvisor", - "logo_url": "/logos/netvisor-logo.png", - "name": "NetVisor Server API" + "description": "The Bookmark Everything App", + "discovery_pattern": "Endpoint response body from :3000/manifest.json contains Karakeep", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/karakeep.svg", + "name": "Karakeep" }, { - "description": "Self-hosted Bitwarden-compatible server, written in Rust", - "discovery_pattern": "Endpoint response body from :8000/manifest.json contains Vaultwarden Web", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/vaultwarden.svg", - "name": "Vaultwarden" + "description": "A simple, self-hosted app for your checklists and notes", + "discovery_pattern": "Endpoint response body from :3000/site.webmanifest contains jotty", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/jotty.svg", + "name": "Jotty" }, { - "description": "Self-hosted GitHub", - "discovery_pattern": "No match pattern provided", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/github.svg", - "name": "GitHub" + "description": "Finds missing media and upgrades your existing content.", + "discovery_pattern": "9705/tcp is open", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/huntarr.png", + "name": "Huntarr" }, { - "description": "Berkeley Internet Name Domain DNS server", - "discovery_pattern": "All of: (53/udp is open, 8053/tcp is open)", - "logo_url": "", - "name": "Bind9" + "description": "Web-based self-hosted groceries & household management solution", + "discovery_pattern": "Any of: (Endpoint response body from :80/ contains grocy.css, Endpoint response body from :443/ contains grocy.css)", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/grocy.svg", + "name": "Grocy" }, { - "description": "Infrastructure monitoring", - "discovery_pattern": "Endpoint response body from :80/nagios contains nagios", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/nagios.svg", - "name": "Nagios" + "description": "A free, self-hostable news aggregator", + "discovery_pattern": "Endpoint response body from :80/themes/manifest.json contains FreshRSS feed aggregator", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/freshrss.svg", + "name": "FreshRSS" }, { - "description": "The Bookmark Everything App", - "discovery_pattern": "Endpoint response body from :3000/manifest.json contains Karakeep", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/karakeep.svg", - "name": "Karakeep" + "description": "Torrent cleanup tool for Sonarr and Radarr", + "discovery_pattern": "11011/tcp is open", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/cleanuperr.svg", + "name": "Cleanuparr" }, { - "description": "A modern client-server application for the Soulseek file-sharing network", - "discovery_pattern": "All of: (Endpoint response body from :5030/ contains slskd, Endpoint response body from :5030/api/v0/session/enabled contains true)", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/slskd.svg", - "name": "Slskd" + "description": "Web UI and orchestrator for Restic", + "discovery_pattern": "Endpoint response body from :9898/ contains BackRest", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/backrest-light.svg", + "name": "BackRest" }, { - "description": "Web-based Nginx proxy management interface", - "discovery_pattern": "Endpoint response body from :80 contains nginx proxy manager", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/nginx-proxy-manager.svg", - "name": "Nginx Proxy Manager" + "description": "The modern autodl-irssi replacement.", + "discovery_pattern": "7474/tcp is open", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/autobrr.svg", + "name": "Autobrr" }, { - "description": "Ansible automation platform", - "discovery_pattern": "Endpoint response status is between 200 and 300, and response body from :80/api/v2/ contains awx", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/ansible.svg", - "name": "AWX" + "description": "A local-first personal finance app", + "discovery_pattern": "Endpoint response body from :5006/manifest.webmanifest contains @actual-app/web", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/actual-budget.svg", + "name": "Actual Budget" }, { - "description": "A generic gateway", - "discovery_pattern": "All of: (Host IP is a gateway in daemon's routing tables, or ends in .1 or .254., A custom match pattern evaluated at runtime)", + "description": "A generic printing service", + "discovery_pattern": "Any of: (631/tcp is open, 515/tcp is open, 515/udp is open)", "logo_url": "", - "name": "Gateway" - }, - { - "description": "Network-wide ad blocking DNS service", - "discovery_pattern": "All of: (Any of: (53/udp is open, 53/tcp is open), Endpoint response body from :80/admin contains pi-hole)", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/pi-hole.svg", - "name": "Pi-Hole" + "name": "Print Server" }, { - "description": "API gateway", - "discovery_pattern": "Endpoint response status is between 200 and 300, and response body from :8080/hello contains tyk", - "logo_url": "https://www.vectorlogo.zone/logos/tyk/tyk-icon.svg", - "name": "Tyk" + "description": "An HP Printer", + "discovery_pattern": "All of: (Any of: (Endpoint response body from :80 contains LaserJet, Endpoint response body from :80 contains DeskJet, Endpoint response body from :80 contains OfficeJet, Endpoint response body from :8080 contains LaserJet, Endpoint response body from :8080 contains DeskJet, Endpoint response body from :8080 contains OfficeJet), Any of: (631/tcp is open, 515/tcp is open, 515/udp is open))", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/hp.svg", + "name": "Hp Printer" }, { - "description": "Analytics and monitoring visualization platform", - "discovery_pattern": "Endpoint response body from :80/ contains grafana.com", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/grafana.svg", - "name": "Grafana" + "description": "Common Unix Printing System", + "discovery_pattern": "All of: (631/tcp is open, Endpoint response body from :80/ contains CUPS)", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/cups.svg", + "name": "CUPS" }, { - "description": "Time-series monitoring and alerting system", - "discovery_pattern": "Any of: (Endpoint response body from :80/metrics contains Prometheus, Endpoint response body from :80/graph contains Prometheus)", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/prometheus.svg", - "name": "Prometheus" + "description": "ESP device firmware", + "discovery_pattern": "No match pattern provided", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/tasmota.svg", + "name": "Tasmota" }, { - "description": "Cross-platform backup client with encryption", - "discovery_pattern": "Endpoint response body from :8200/ngax/index.html contains Duplicati", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/duplicati.svg", - "name": "Duplicati" + "description": "Sonos wireless speaker system", + "discovery_pattern": "All of: (MAC Address belongs to Sonos, Inc., Any of: (445/tcp is open, 3445/tcp is open, 1400/tcp is open, 1410/tcp is open, 1843/tcp is open, 3400/tcp is open, 3401/tcp is open, 3500/tcp is open))", + "logo_url": "https://simpleicons.org/icons/sonos.svg", + "name": "Sonos Speaker" }, { - "description": "Session initiation protocol", - "discovery_pattern": "Any of: (5060/tcp is open, 5061/tcp is open)", - "logo_url": "", - "name": "SIP Server" + "description": "Roku streaming device or TV", + "discovery_pattern": "All of: (MAC Address belongs to Roku, Inc, 8060/tcp is open)", + "logo_url": "https://simpleicons.org/icons/roku.svg", + "name": "Roku Media Player" }, { - "description": "Data analytics platform", - "discovery_pattern": "Endpoint response body from :8000/ contains splunk", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/splunk.svg", - "name": "Splunk" + "description": "Ring video doorbell or security camera", + "discovery_pattern": "All of: (MAC Address belongs to Amazon Technologies Inc., Any of: (8557/tcp is open, 9998/tcp is open, 19302/tcp is open, 9999/tcp is open))", + "logo_url": "https://simpleicons.org/icons/ring.svg", + "name": "Ring Doorbell" }, { - "description": "A generic web service", + "description": "Google Nest smart thermostat", + "discovery_pattern": "All of: (Any of: (MAC Address belongs to Nest Labs Inc., MAC Address belongs to Google, Inc.), 9543/tcp is open)", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/google-home.svg", + "name": "Nest Thermostat" + }, + { + "description": "Google Nest smoke and CO detector", + "discovery_pattern": "All of: (Any of: (MAC Address belongs to Nest Labs Inc., MAC Address belongs to Google, Inc.), 11095/tcp is open)", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/google-home.svg", + "name": "Nest Protect" + }, + { + "description": "A generic IoT Service", "discovery_pattern": "No match pattern provided", "logo_url": "", - "name": "Web Service" + "name": "IoT" }, { - "description": "Encrypted, incremental and deduplicated backups for Proxmox VMs, LXCs, and hosts", - "discovery_pattern": "Any of: (Endpoint response body from :8007/ contains proxmox-backup-gui, 8007/tcp is open)", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/proxmox.svg", - "name": "Proxmox Backup Server" + "description": "Google Home smart speaker or display", + "discovery_pattern": "All of: (Any of: (MAC Address belongs to Nest Labs Inc., MAC Address belongs to Google, Inc.), All of: (8008/tcp is open, 8009/tcp is open))", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/google-home.svg", + "name": "Google Home" }, { - "description": "Identity and access management", - "discovery_pattern": "Endpoint response body from :8080/ contains /keycloak/", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/keycloak.svg", - "name": "Keycloak" + "description": "Google Chromecast streaming device", + "discovery_pattern": "All of: (MAC Address belongs to Google, Inc., 8008/tcp is open, 8009/tcp is open)", + "logo_url": "https://simpleicons.org/icons/googlecast.svg", + "name": "Chromecast" }, { - "description": "Simple HTTP-based pub-sub notification service", - "discovery_pattern": "Any of: (Endpoint response body from :80/ contains ntfy web, Endpoint response body from :2856/ contains ntfy web)", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/ntfy.svg", - "name": "Ntfy" + "description": "Camera with RTSP Streaming", + "discovery_pattern": "554/tcp is open", + "logo_url": "", + "name": "RTSP Camera" }, { - "description": "Authoritative DNS server with API", - "discovery_pattern": "All of: (53/udp is open, 53/tcp is open, 8081/tcp is open)", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/powerdns.svg", - "name": "PowerDNS" + "description": "Amazon Echo smart speaker", + "discovery_pattern": "All of: (MAC Address belongs to Amazon Technologies Inc., 40317/tcp is open)", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/alexa.svg", + "name": "Amazon Echo" }, { - "description": "Open source software application for managing requests for your media library.", - "discovery_pattern": "All of: (Endpoint response body from :5055/site.webmanifest contains Overseerr, Not (Endpoint response body from :5055/ contains Jellyseerr))", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/overseerr.svg", - "name": "Overseerr" + "description": "A generic client device that initiates connections to services", + "discovery_pattern": "No match pattern provided", + "logo_url": "", + "name": "Client" }, { - "description": "Wireguard dashboard for visualizing and managing wireguard clients and server", - "discovery_pattern": "All of: (10086/tcp is open, Not (Subnet is type VpnTunnel))", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/wireguard.svg", - "name": "WGDashboard" + "description": "Desktop computer for productivity work", + "discovery_pattern": "All of: (3389/tcp is open, 445/tcp is open)", + "logo_url": "", + "name": "Workstation" }, { - "description": "An open-source, self-hosted note-taking service.", - "discovery_pattern": "Endpoint response body from :5230/explore contains Memos", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/memos.png", - "name": "Memos" + "description": "Session initiation protocol", + "discovery_pattern": "Any of: (5060/tcp is open, 5061/tcp is open)", + "logo_url": "", + "name": "SIP Server" }, { - "description": "Microsoft relational database", - "discovery_pattern": "1433/tcp is open", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/microsoft-sql-server-light.svg", - "name": "Microsoft SQL Server" + "description": "Video conferencing", + "discovery_pattern": "Endpoint response body from :8443/ contains jitsilogo.png", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/jitsi-meet.svg", + "name": "Jitsi Meet" }, { - "description": "Enterprise monitoring solution", - "discovery_pattern": "Endpoint response body from :80/zabbix contains zabbix", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/zabbix.svg", - "name": "Zabbix" + "description": "PBX web interface", + "discovery_pattern": "All of: (Endpoint response body from :80/ contains freepbx, 5060/tcp is open)", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/freepbx.svg", + "name": "FreePBX" }, { - "description": "PXE Boot Server", - "discovery_pattern": "Endpoint response body from :61208/ contains Netbootxyz", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/netbootxyz.svg", - "name": "Netbootxyz" + "description": "Web conferencing system", + "discovery_pattern": "Endpoint response status is between 200 and 300, and response body from :80/bigbluebutton/api contains ", + "logo_url": "https://simpleicons.org/icons/bigbluebutton.svg", + "name": "BigBlueButton" }, { "description": "PBX and VoIP server", @@ -318,184 +300,214 @@ "name": "Asterisk" }, { - "description": "Enterprise relational database", - "discovery_pattern": "1521/tcp is open", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/oracle.svg", - "name": "Oracle Database" + "description": "Distributed tracing system", + "discovery_pattern": "Endpoint response body from :9411/api/v2/services contains ", + "logo_url": "", + "name": "Zipkin" }, { - "description": "Fortinet security appliance", - "discovery_pattern": "Endpoint response body from :80/login contains fortinet", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/fortinet.svg", - "name": "Fortinet" + "description": "Enterprise monitoring solution", + "discovery_pattern": "Endpoint response body from :80/zabbix contains zabbix", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/zabbix.svg", + "name": "Zabbix" }, { - "description": "A TV collection manager for Usenet and BitTorrent users.", - "discovery_pattern": "Endpoint response body from :8989/Content/manifest.json contains Sonarr", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sonarr.svg", - "name": "Sonarr" + "description": "Security platform", + "discovery_pattern": "Endpoint response body from :55000/ contains wazuh", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/wazuh.svg", + "name": "Wazuh" }, { - "description": "A media server for your comics, mangas, BDs, magazines and eBooks.", - "discovery_pattern": "Endpoint response body from :25600/ contains Komga", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/komga.svg", - "name": "Komga" + "description": "Self-hosted uptime monitoring tool", + "discovery_pattern": "Endpoint response body from :80/ contains Uptime Kuma", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/uptime-kuma.svg", + "name": "UptimeKuma" }, { - "description": "Network-wide ad and tracker blocking", - "discovery_pattern": "All of: (All of: (53/udp is open, 53/tcp is open), Endpoint response body from :80/ contains AdGuard Home)", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/adguard-home.svg", - "name": "Adguard Home" + "description": "Data analytics platform", + "discovery_pattern": "Endpoint response body from :8000/ contains splunk", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/splunk.svg", + "name": "Splunk" }, { - "description": "Distributed tracing system", - "discovery_pattern": "Endpoint response body from :9411/api/v2/services contains ", - "logo_url": "", - "name": "Zipkin" + "description": "Error tracking platform", + "discovery_pattern": "Endpoint response body from :9000/api/0/ contains sentry", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sentry.svg", + "name": "Sentry" }, { - "description": "Object storage", - "discovery_pattern": "Endpoint response status is between 200 and 300, and response body from :9000/minio/health/live contains ", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/minio.svg", - "name": "MinIO" + "description": "Monitoring framework", + "discovery_pattern": "Endpoint response body from :4567/health contains sensu", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sensu.svg", + "name": "Sensu" }, { - "description": "Git repository management", - "discovery_pattern": "Endpoint response body from :7990/ contains bitbucket", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/bitbucket.svg", - "name": "Bitbucket Server" + "description": "Proxmox node/cluster/VM/LXC monitor", + "discovery_pattern": "Endpoint response body from :7655/ contains Pulse", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/pulse.svg", + "name": "Pulse" }, { - "description": "Discussion platform", - "discovery_pattern": "Endpoint response body from :80/srv/status contains discourse", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/discourse.svg", - "name": "Discourse" + "description": "Time-series monitoring and alerting system", + "discovery_pattern": "Any of: (Endpoint response body from :80/metrics contains Prometheus, Endpoint response body from :80/graph contains Prometheus)", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/prometheus.svg", + "name": "Prometheus" }, { - "description": "Generic SMB file server", - "discovery_pattern": "445/tcp is open", - "logo_url": "", - "name": "Samba" + "description": "Real-time performance monitoring", + "discovery_pattern": "Endpoint response body from :19999/api/v1/info contains netdata", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/netdata.svg", + "name": "Netdata" }, { - "description": "A movie collection manager for Usenet and BitTorrent users.", - "discovery_pattern": "Endpoint response body from :7878/Content/manifest.json contains Radarr", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/radarr.svg", - "name": "Radarr" + "description": "Infrastructure monitoring", + "discovery_pattern": "Endpoint response body from :80/nagios contains nagios", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/nagios.svg", + "name": "Nagios" }, { - "description": "Crowdsourced protection against malicious IPs", - "discovery_pattern": "Endpoint response status is between 401 and 401, and response body from :8080/v1/allowlists contains cookie token is empty", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/crowdsec.svg", - "name": "CrowdSec" + "description": "Distributed tracing system", + "discovery_pattern": "Endpoint response body from :16686/ contains jaeger", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/jaeger.svg", + "name": "Jaeger" }, { - "description": "Camera with RTSP Streaming", - "discovery_pattern": "554/tcp is open", - "logo_url": "", - "name": "RTSP Camera" + "description": "Infrastructure monitoring", + "discovery_pattern": "Endpoint response body from :5665/v1 contains icinga", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/icinga.svg", + "name": "Icinga" }, { - "description": "Video conferencing", - "discovery_pattern": "Endpoint response body from :8443/ contains jitsilogo.png", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/jitsi-meet.svg", - "name": "Jitsi Meet" + "description": "Security Information and Event Management (SIEM) solution and log analytics platform", + "discovery_pattern": "All of: (Endpoint response from has header content-security-policy with value graylog, Endpoint response body from :9000/ contains Graylog)", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/graylog.svg", + "name": "Graylog" }, { - "description": "Project management platform", - "discovery_pattern": "Endpoint response status is between 200 and 300, and response body from :8080/rest/api/2/serverInfo contains jira", - "logo_url": "", - "name": "Jira" + "description": "Analytics and monitoring visualization platform", + "discovery_pattern": "Endpoint response body from :80/ contains grafana.com", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/grafana.svg", + "name": "Grafana" }, { - "description": "Ring video doorbell or security camera", - "discovery_pattern": "All of: (MAC Address belongs to Amazon Technologies Inc., Any of: (8557/tcp is open, 9998/tcp is open, 19302/tcp is open, 9999/tcp is open))", - "logo_url": "https://simpleicons.org/icons/ring.svg", - "name": "Ring Doorbell" + "description": "An open-source system cross-platform monitoring tool.", + "discovery_pattern": "Endpoint response body from :61208/ contains Glances", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/glances.svg", + "name": "Glances" }, { - "description": "Synology DiskStation Manager NAS system", - "discovery_pattern": "All of: (Endpoint response body from :80/ contains synology, 21/tcp is open)", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/synology.svg", - "name": "Synology DSM" + "description": "Automated developer-oriented status page", + "discovery_pattern": "Endpoint response body from :8080/manifest.json contains Gatus", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/gatus.svg", + "name": "Gatus" }, { - "description": "File hosting platform", - "discovery_pattern": "Endpoint response body from :8000/api2/ping contains seafile", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/seafile.svg", - "name": "Seafile" + "description": "Application performance monitoring", + "discovery_pattern": "Endpoint response body from :8200/ contains apm", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/elastic.svg", + "name": "Elastic APM" }, { - "description": "Community-supported document management system", - "discovery_pattern": "Endpoint response body from :8000/ contains Paperless-ngx project", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/paperless-ngx.svg", - "name": "Paperless-NGX" + "description": "Monitor temperatures, fan speeds, and power in real time.", + "discovery_pattern": "Endpoint response body from :11987/ contains CoolerControl", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/cooler-control.svg", + "name": "CoolerControl" }, { - "description": "Network UPS Tools", - "discovery_pattern": "3493/tcp is open", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/nut.svg", - "name": "NUT" + "description": "A single pane of glass for managing clustered & non-clustered Proxmox nodes", + "discovery_pattern": "Endpoint response body from :8443/ contains pdm-ui_bundle.js", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/proxmox.svg", + "name": "Proxmox Datacenter Manager" }, { - "description": "Z-Wave controller server", - "discovery_pattern": "Endpoint response body from :8091/health contains ", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/z-wave-js-ui.svg", - "name": "Z-Wave JS" + "description": "A highly customizable link sharing platform", + "discovery_pattern": "All of: (Endpoint response from has header set-cookie with value linkstack_session, Endpoint response body from :8080/ contains LinkStack)", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/linkstack.svg", + "name": "LinkStack" }, { - "description": "Home automation system", - "discovery_pattern": "Endpoint response body from :8080/json.htm contains domoticz", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/domoticz.png", - "name": "Domoticz" + "description": "A self-hosted startpage and real-time status page", + "discovery_pattern": "Endpoint response body from :8123/ contains Jump", + "logo_url": "", + "name": "Jump" }, { - "description": "A generic network storage devices", - "discovery_pattern": "2049/tcp is open", - "logo_url": "", - "name": "Nas Device" + "description": "A self-hosted dashboard for your homelab", + "discovery_pattern": "Endpoint response body from :3000/site.webmanifest contains Homepage", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/webp/homepage.webp", + "name": "Homepage" }, { - "description": "Eero device providing mesh network services", - "discovery_pattern": "All of: (MAC Address belongs to eero Inc, Not (Host IP is a gateway in daemon's routing tables, or ends in .1 or .254.))", - "logo_url": "https://www.vectorlogo.zone/logos/eero/eero-icon.svg", - "name": "Eero Repeater" + "description": "A sleek, modern dashboard", + "discovery_pattern": "7575/tcp is open", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg", + "name": "Homarr" }, { - "description": "Automation server for CI/CD", - "discovery_pattern": "Endpoint response body from :8080/ contains jenkins.io", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/jenkins.svg", - "name": "Jenkins" + "description": "A self-hosted dashboard that puts all your feeds in one place", + "discovery_pattern": "Endpoint response body from :8080/manifest.json contains Glance", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/glance.svg", + "name": "Glance" }, { - "description": "A self-hosted recipe manager and meal planner", - "discovery_pattern": "All of: (Endpoint response body from :9000/ contains Mealie, Endpoint response body from :9000/ contains recipe)", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/mealie.svg", - "name": "Mealie" + "description": "Self-hosted Bitwarden-compatible server, written in Rust", + "discovery_pattern": "Endpoint response body from :8000/manifest.json contains Vaultwarden Web", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/vaultwarden.svg", + "name": "Vaultwarden" }, { - "description": "Load balancer and proxy", - "discovery_pattern": "Endpoint response body from :8404/stats contains haproxy", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/haproxy.svg", - "name": "HAProxy" + "description": "Secrets management", + "discovery_pattern": "Endpoint response body from :8200/v1/sys/health contains vault", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/hashicorp-vault.svg", + "name": "Vault" }, { - "description": "NetVisor Daemon API for network scanning", - "discovery_pattern": "Endpoint response body from :60073/api/health contains netvisor", - "logo_url": "/logos/netvisor-logo.png", - "name": "NetVisor Daemon API" + "description": "Generic LDAP directory service", + "discovery_pattern": "389/tcp is open", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/openldap.svg", + "name": "Open LDAP" }, { - "description": "Fast and secure backup program", - "discovery_pattern": "All of: (8000/tcp is open, Endpoint response body from :80/ contains restic)", - "logo_url": "", - "name": "Restic" + "description": "Identity and access management", + "discovery_pattern": "Endpoint response body from :8080/ contains /keycloak/", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/keycloak.svg", + "name": "Keycloak" }, { - "description": "A generic wireless access point for WiFi connectivity", + "description": "Identity management system", + "discovery_pattern": "Endpoint response status is between 200 and 300, and response body from :80/ipa/ui contains ", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/freeipa.svg", + "name": "FreeIPA" + }, + { + "description": "Password manager", + "discovery_pattern": "Endpoint response body from :80/api/config contains bitwarden", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/bitwarden.svg", + "name": "Bitwarden" + }, + { + "description": "A self-hosted, open source identity provider", + "discovery_pattern": "Any of: (Endpoint response body from :9000/ contains window.authentik, Endpoint response body from :9443/ contains window.authentik)", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/authentik.svg", + "name": "Authentik" + }, + { + "description": "Microsoft directory service", + "discovery_pattern": "All of: (389/tcp is open, 445/tcp is open, 88/tcp is open)", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/microsoft.svg", + "name": "Active Directory" + }, + { + "description": "Content management system", + "discovery_pattern": "Endpoint response body from :80/ contains wp-content", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/wordpress.svg", + "name": "WordPress" + }, + { + "description": "A generic web service", "discovery_pattern": "No match pattern provided", "logo_url": "", - "name": "Access Point" + "name": "Web Service" }, { "description": "Java servlet container", @@ -504,34 +516,34 @@ "name": "Tomcat" }, { - "description": "Recursive DNS resolver with control interface", - "discovery_pattern": "All of: (53/udp is open, 8953/tcp is open)", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/unbound.svg", - "name": "Unbound DNS" + "description": "Publishing platform", + "discovery_pattern": "Endpoint response body from :2368/ contains ghost", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/ghost.png", + "name": "Ghost" }, { - "description": "A sleek, modern dashboard", - "discovery_pattern": "7575/tcp is open", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg", - "name": "Homarr" + "description": "CI/CD server", + "discovery_pattern": "Endpoint response body from :8111/ contains teamcity", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/teamcity-light.svg", + "name": "TeamCity" }, { - "description": "Open-source relational database", - "discovery_pattern": "3306/tcp is open", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/mysql.svg", - "name": "MySQL" + "description": "Multi-cloud CD platform", + "discovery_pattern": "No match pattern provided", + "logo_url": "https://simpleicons.org/icons/spinnaker.svg", + "name": "Spinnaker" }, { - "description": "Generic network file system", - "discovery_pattern": "2049/tcp is open", - "logo_url": "", - "name": "NFS" + "description": "Cloud-native messaging system", + "discovery_pattern": "Endpoint response body from :8222/varz contains ", + "logo_url": "https://simpleicons.org/icons/natsdotio.svg", + "name": "NATS" }, { - "description": "CI/CD server", - "discovery_pattern": "Endpoint response body from :8111/ contains teamcity", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/teamcity-light.svg", - "name": "TeamCity" + "description": "Automation server for CI/CD", + "discovery_pattern": "Endpoint response body from :8080/ contains jenkins.io", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/jenkins.svg", + "name": "Jenkins" }, { "description": "DevOps platform", @@ -540,298 +552,298 @@ "name": "GitLab" }, { - "description": "Google Nest smart thermostat", - "discovery_pattern": "All of: (Any of: (MAC Address belongs to Nest Labs Inc., MAC Address belongs to Google, Inc.), 9543/tcp is open)", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/google-home.svg", - "name": "Nest Thermostat" + "description": "Self-hosted GitHub", + "discovery_pattern": "No match pattern provided", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/github.svg", + "name": "GitHub" }, { - "description": "Generic network security appliance", - "discovery_pattern": "No match pattern provided", - "logo_url": "", - "name": "Firewall" + "description": "Container-native CI platform", + "discovery_pattern": "All of: (Endpoint response body from :80/ contains drone, Endpoint response body from :80/api/user contains )", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/drone.png", + "name": "Drone" }, { - "description": "Self-hosted uptime monitoring tool", - "discovery_pattern": "Endpoint response body from :80/ contains Uptime Kuma", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/uptime-kuma.svg", - "name": "UptimeKuma" + "description": "Git repository management", + "discovery_pattern": "Endpoint response body from :7990/ contains bitbucket", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/bitbucket.svg", + "name": "Bitbucket Server" }, { - "description": "Event streaming platform", - "discovery_pattern": "9092/tcp is open", - "logo_url": "https://simpleicons.org/icons/apachekafka.svg", - "name": "Kafka" + "description": "CI/CD server", + "discovery_pattern": "Endpoint response body from :8085/ contains bamboo", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/atlassian-bamboo.svg", + "name": "Bamboo" }, { - "description": "Amazon Echo smart speaker", - "discovery_pattern": "All of: (MAC Address belongs to Amazon Technologies Inc., 40317/tcp is open)", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/alexa.svg", - "name": "Amazon Echo" + "description": "GitOps continuous delivery", + "discovery_pattern": "Endpoint response body from :8080/api/version contains argocd", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/argo-cd.svg", + "name": "ArgoCD" }, { - "description": "Message broker", - "discovery_pattern": "Endpoint response body from :8161/admin contains activemq", - "logo_url": "https://www.vectorlogo.zone/logos/apache_activemq/apache_activemq-icon.svg", - "name": "ActiveMQ" + "description": "Ansible automation platform", + "discovery_pattern": "Endpoint response status is between 200 and 300, and response body from :80/api/v2/ contains awx", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/ansible.svg", + "name": "AWX" }, { - "description": "An easy way to get up and running with LLMs.", - "discovery_pattern": "Endpoint response body from :11434/ contains Ollama is running", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/ollama-dark.svg", - "name": "Ollama" + "description": "Team communication platform", + "discovery_pattern": "Endpoint response body from :3000/api/info contains rocket", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/rocket-chat.svg", + "name": "Rocket.Chat" }, { - "description": "Self-hosted cloud storage and collaboration platform", - "discovery_pattern": "Any of: (Endpoint response body from :80/core/css/server.css contains Nextcloud GmbH, Endpoint response body from :443/core/css/server.css contains Nextcloud GmbH)", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/nextcloud.svg", - "name": "NextCloud" + "description": "Free and Open-Source CalDAV and CardDAV Server", + "discovery_pattern": "Endpoint response body from :5232/.web/ contains Radicale Web Interface", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/radicale.svg", + "name": "Radicale" }, { - "description": "Lightweight & versatile reverse proxy, web & file server", - "discovery_pattern": "Endpoint response body from :2019/reverse_proxy/upstreams contains num_requests", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/caddy.svg", - "name": "Caddy" + "description": "Team messaging platform", + "discovery_pattern": "Endpoint response body from :8065/api/v4/system/ping contains ", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/mattermost.svg", + "name": "Mattermost" }, { - "description": "Open-source network attached storage system", - "discovery_pattern": "All of: (445/tcp is open, Endpoint response body from :80/ contains TrueNAS)", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/truenas.svg", - "name": "TrueNAS" + "description": "Project management platform", + "discovery_pattern": "Endpoint response status is between 200 and 300, and response body from :8080/rest/api/2/serverInfo contains jira", + "logo_url": "", + "name": "Jira" }, { - "description": "Generic network switch for local area networking", - "discovery_pattern": "All of: (Not (Host IP is a gateway in daemon's routing tables, or ends in .1 or .254.), All of: (80/tcp is open, 23/tcp is open))", - "logo_url": "", - "name": "Switch" + "description": "Discussion platform", + "discovery_pattern": "Endpoint response body from :80/srv/status contains discourse", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/discourse.svg", + "name": "Discourse" }, { - "description": "Automated developer-oriented status page", - "discovery_pattern": "Endpoint response body from :8080/manifest.json contains Gatus", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/gatus.svg", - "name": "Gatus" + "description": "Team collaboration wiki", + "discovery_pattern": "Endpoint response body from :8090/ contains confluence", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/confluence.svg", + "name": "Confluence" }, { - "description": "Open, extensible, user-friendly interface for AI", - "discovery_pattern": "Endpoint response body from :8080/manifest.json contains Open WebUI", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/open-webui-light.svg", - "name": "Open WebUI" + "description": "Message broker", + "discovery_pattern": "Endpoint response body from :15672/ contains rabbitmq", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/rabbitmq.svg", + "name": "RabbitMQ" }, { - "description": "Philips Hue Bridge for lighting control", - "discovery_pattern": "All of: (MAC Address belongs to Philips Lighting BV, Endpoint response body from :80/ contains hue)", - "logo_url": "https://simpleicons.org/icons/philipshue.svg", - "name": "Philips Hue Bridge" + "description": "Simple HTTP-based pub-sub notification service", + "discovery_pattern": "Any of: (Endpoint response body from :80/ contains ntfy web, Endpoint response body from :2856/ contains ntfy web)", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/ntfy.svg", + "name": "Ntfy" }, { - "description": "Finds missing media and upgrades your existing content.", - "discovery_pattern": "9705/tcp is open", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/huntarr.png", - "name": "Huntarr" + "description": "Generic MQTT broker", + "discovery_pattern": "Any of: (1883/tcp is open, 8883/tcp is open)", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/mqtt.svg", + "name": "MQTT" }, { - "description": "PBX web interface", - "discovery_pattern": "All of: (Endpoint response body from :80/ contains freepbx, 5060/tcp is open)", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/freepbx.svg", - "name": "FreePBX" + "description": "Event streaming platform", + "discovery_pattern": "9092/tcp is open", + "logo_url": "https://simpleicons.org/icons/apachekafka.svg", + "name": "Kafka" }, { - "description": "NoSQL document database", - "discovery_pattern": "Endpoint response body from :5984/ contains couchdb", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/couchdb.svg", - "name": "CouchDB" + "description": "Message broker", + "discovery_pattern": "Endpoint response body from :8161/admin contains activemq", + "logo_url": "https://www.vectorlogo.zone/logos/apache_activemq/apache_activemq-icon.svg", + "name": "ActiveMQ" }, { - "description": "Monitor, view analytics, and receive notifications about your Plex Media Server.", - "discovery_pattern": "Endpoint response body from :8181/ contains Tautulli", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/tautulli.svg", - "name": "Tautulli" + "description": "In-memory data store and cache", + "discovery_pattern": "6379/tcp is open", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/redis.svg", + "name": "Redis" }, { - "description": "A tiny dashboard for Network UPS Tools", - "discovery_pattern": "Endpoint response body from :3000/api/v1/info contains peanut", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/peanut.svg", - "name": "PeaNUT" + "description": "Open-source relational database", + "discovery_pattern": "5432/tcp is open", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/postgresql.svg", + "name": "PostgreSQL" }, { - "description": "Fios device providing mesh networking services", - "discovery_pattern": "All of: (Endpoint response body from :80/#/login/ contains fios, Not (Host IP is a gateway in daemon's routing tables, or ends in .1 or .254.))", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/fios.svg", - "name": "Fios Extender" + "description": "Enterprise relational database", + "discovery_pattern": "1521/tcp is open", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/oracle.svg", + "name": "Oracle Database" }, { - "description": "Google Nest smoke and CO detector", - "discovery_pattern": "All of: (Any of: (MAC Address belongs to Nest Labs Inc., MAC Address belongs to Google, Inc.), 11095/tcp is open)", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/google-home.svg", - "name": "Nest Protect" + "description": "Open-source relational database", + "discovery_pattern": "3306/tcp is open", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/mysql.svg", + "name": "MySQL" }, { - "description": "Docker", - "discovery_pattern": "No match pattern provided", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/docker.svg", - "name": "Docker" + "description": "Microsoft relational database", + "discovery_pattern": "1433/tcp is open", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/microsoft-sql-server-light.svg", + "name": "Microsoft SQL Server" }, { - "description": "A music collection manager for Usenet and BitTorrent users.", - "discovery_pattern": "Endpoint response body from :8686/Content/manifest.json contains Lidarr", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/lidarr.svg", - "name": "Lidarr" + "description": "NoSQL document database", + "discovery_pattern": "27017/tcp is open", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/mongodb.svg", + "name": "MongoDB" }, { - "description": "Google Chromecast streaming device", - "discovery_pattern": "All of: (MAC Address belongs to Google, Inc., 8008/tcp is open, 8009/tcp is open)", - "logo_url": "https://simpleicons.org/icons/googlecast.svg", - "name": "Chromecast" + "description": "MySQL-compatible relational database", + "discovery_pattern": "No match pattern provided", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/mariadb.svg", + "name": "MariaDB" }, { - "description": "Desktop computer for productivity work", - "discovery_pattern": "All of: (3389/tcp is open, 445/tcp is open)", - "logo_url": "", - "name": "Workstation" + "description": "NoSQL document database", + "discovery_pattern": "Endpoint response body from :5984/ contains couchdb", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/couchdb.svg", + "name": "CouchDB" }, { - "description": "Proxmox node/cluster/VM/LXC monitor", - "discovery_pattern": "Endpoint response body from :7655/ contains Pulse", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/pulse.svg", - "name": "Pulse" + "description": "Distributed NoSQL database", + "discovery_pattern": "9042/tcp is open", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/apache-cassandra.svg", + "name": "Cassandra" }, { - "description": "A self-hosted, open source identity provider", - "discovery_pattern": "Any of: (Endpoint response body from :9000/ contains window.authentik, Endpoint response body from :9443/ contains window.authentik)", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/authentik.svg", - "name": "Authentik" + "description": "Kubernetes management", + "discovery_pattern": "Endpoint response body from :80/v3 contains rancher", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/rancher.svg", + "name": "Rancher" }, { - "description": "Media server for streaming personal content", - "discovery_pattern": "Any of: (Endpoint response body from :32400/web/index.html contains Plex, Endpoint response status is between 401 and 401, and response from :32400 has header X-Plex-Protocol with value 1.0)", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/plex.svg", - "name": "Plex Media Server" + "description": "Open-source virtualization management platform", + "discovery_pattern": "Any of: (Endpoint response body from :8006/ contains proxmox, 8006/tcp is open)", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/proxmox.svg", + "name": "Proxmox VE" }, { - "description": "Backup and replication", - "discovery_pattern": "9392/tcp is open", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/veeam.svg", - "name": "Veeam" + "description": "Container management web interface", + "discovery_pattern": "Any of: (Endpoint response body from :9443/#!/auth contains portainer.io, Endpoint response body from :9000/ contains portainer.io)", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/portainer.svg", + "name": "Portainer" }, { - "description": "Roku streaming device or TV", - "discovery_pattern": "All of: (MAC Address belongs to Roku, Inc, 8060/tcp is open)", - "logo_url": "https://simpleicons.org/icons/roku.svg", - "name": "Roku Media Player" + "description": "Enterprise Kubernetes", + "discovery_pattern": "Endpoint response body from :6443/healthz contains openshift", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/openshift.svg", + "name": "OpenShift" }, { - "description": "Home automation platform", - "discovery_pattern": "Endpoint response body from :8080/rest/ contains openhab", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/openhab.svg", - "name": "openHAB" + "description": "Workload orchestration", + "discovery_pattern": "Endpoint response body from :4646/v1/status/leader contains ", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/nomad.svg", + "name": "Nomad" }, { - "description": "Distributed tracing system", - "discovery_pattern": "Endpoint response body from :16686/ contains jaeger", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/jaeger.svg", - "name": "Jaeger" + "description": "Container orchestration platform", + "discovery_pattern": "All of: (6443/tcp is open, Any of: (10250/tcp is open, 10259/tcp is open, 10257/tcp is open, 10256/tcp is open))", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/kubernetes.svg", + "name": "Kubernetes" }, { - "description": "Team messaging platform", - "discovery_pattern": "Endpoint response body from :8065/api/v4/system/ping contains ", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/mattermost.svg", - "name": "Mattermost" + "description": "Docker native clustering and orchestration", + "discovery_pattern": "All of: (2377/tcp is open, 7946/tcp is open)", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/docker.svg", + "name": "Docker Swarm" }, { - "description": "Secrets management", - "discovery_pattern": "Endpoint response body from :8200/v1/sys/health contains vault", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/hashicorp-vault.svg", - "name": "Vault" + "description": "A generic docker container", + "discovery_pattern": "All of: (Service is running in a docker container, A custom match pattern evaluated at runtime)", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/docker.svg", + "name": "Docker Container" }, { - "description": "Generic LDAP directory service", - "discovery_pattern": "389/tcp is open", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/openldap.svg", - "name": "Open LDAP" + "description": "Z-Wave controller server", + "discovery_pattern": "Endpoint response body from :8091/health contains ", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/z-wave-js-ui.svg", + "name": "Z-Wave JS" }, { - "description": "ESP device firmware", - "discovery_pattern": "No match pattern provided", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/tasmota.svg", - "name": "Tasmota" + "description": "Zigbee to MQTT bridge", + "discovery_pattern": "Endpoint response body from :8080/ contains Zigbee2MQTT WindFront", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/zigbee2mqtt.svg", + "name": "Zigbee2MQTT" }, { - "description": "A highly customizable link sharing platform", - "discovery_pattern": "All of: (Endpoint response from has header set-cookie with value linkstack_session, Endpoint response body from :8080/ contains LinkStack)", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/linkstack.svg", - "name": "LinkStack" + "description": "Philips Hue Bridge for lighting control", + "discovery_pattern": "All of: (MAC Address belongs to Philips Lighting BV, Endpoint response body from :80/ contains hue)", + "logo_url": "https://simpleicons.org/icons/philipshue.svg", + "name": "Philips Hue Bridge" }, { - "description": "Cloud-native messaging system", - "discovery_pattern": "Endpoint response body from :8222/varz contains ", - "logo_url": "https://simpleicons.org/icons/natsdotio.svg", - "name": "NATS" + "description": "Home automation platform", + "discovery_pattern": "Endpoint response body from :8080/rest/ contains openhab", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/openhab.svg", + "name": "openHAB" }, { - "description": "Real-time performance monitoring", - "discovery_pattern": "Endpoint response body from :19999/api/v1/info contains netdata", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/netdata.svg", - "name": "Netdata" + "description": "Open-source home automation platform", + "discovery_pattern": "Endpoint response body from :8123/ contains home assistant", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/home-assistant.svg", + "name": "Home Assistant" }, { - "description": "MySQL-compatible relational database", - "discovery_pattern": "No match pattern provided", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/mariadb.svg", - "name": "MariaDB" + "description": "ESP device management", + "discovery_pattern": "6052/tcp is open", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/esphome.svg", + "name": "ESPHome" }, { - "description": "Kubernetes management", - "discovery_pattern": "Endpoint response body from :80/v3 contains rancher", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/rancher.svg", - "name": "Rancher" + "description": "Home automation system", + "discovery_pattern": "Endpoint response body from :8080/json.htm contains domoticz", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/domoticz.png", + "name": "Domoticz" }, { - "description": "TP-Link EAP wireless access point", - "discovery_pattern": "All of: (MAC Address belongs to TP-LINK TECHNOLOGIES CO.,LTD, Endpoint response body from :80/ contains tp-link)", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/tp-link.svg", - "name": "TP-Link EAP" + "description": "Monitor, view analytics, and receive notifications about your Plex Media Server.", + "discovery_pattern": "Endpoint response body from :8181/ contains Tautulli", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/tautulli.svg", + "name": "Tautulli" }, { - "description": "Enterprise Kubernetes", - "discovery_pattern": "Endpoint response body from :6443/healthz contains openshift", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/openshift.svg", - "name": "OpenShift" + "description": "A modern client-server application for the Soulseek file-sharing network", + "discovery_pattern": "All of: (Endpoint response body from :5030/ contains slskd, Endpoint response body from :5030/api/v0/session/enabled contains true)", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/slskd.svg", + "name": "Slskd" }, { - "description": "Docker native clustering and orchestration", - "discovery_pattern": "All of: (2377/tcp is open, 7946/tcp is open)", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/docker.svg", - "name": "Docker Swarm" + "description": "Media server for streaming personal content", + "discovery_pattern": "Any of: (Endpoint response body from :32400/web/index.html contains Plex, Endpoint response status is between 401 and 401, and response from :32400 has header X-Plex-Protocol with value 1.0)", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/plex.svg", + "name": "Plex Media Server" }, { - "description": "Google Nest Wifi repeater", - "discovery_pattern": "All of: (Any of: (MAC Address belongs to Nest Labs Inc., MAC Address belongs to Google, Inc.), Not (Host IP is a gateway in daemon's routing tables, or ends in .1 or .254.), Endpoint response body from :80/ contains Nest Wifi)", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/google-home.svg", - "name": "Google Nest repeater" + "description": "Open source software application for managing requests for your media library.", + "discovery_pattern": "All of: (Endpoint response body from :5055/site.webmanifest contains Overseerr, Not (Endpoint response body from :5055/ contains Jellyseerr))", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/overseerr.svg", + "name": "Overseerr" }, { - "description": "Vehicle Maintenance Records and Fuel Mileage Tracker", - "discovery_pattern": "Endpoint response body from :8080/ contains Garage - LubeLogger", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/lubelogger.png", - "name": "Lubelogger" + "description": "A media server for your comics, mangas, BDs, magazines and eBooks.", + "discovery_pattern": "Endpoint response body from :25600/ contains Komga", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/komga.svg", + "name": "Komga" }, { - "description": "The Ultimate Indexer Manager.", - "discovery_pattern": "Endpoint response body from :3232/Content/Images/Icons/manifest.json contains Prowlarr", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/prowlarr.svg", - "name": "Prowlarr" + "description": "Open source software application for managing requests for your media library.", + "discovery_pattern": "All of: (Endpoint response body from :3000/ contains Jellystat, Endpoint response body from :3000/ contains Jellyfin stats for the masses)", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/jellystat.svg", + "name": "Jellystat" }, { - "description": "Web-based self-hosted groceries & household management solution", - "discovery_pattern": "Any of: (Endpoint response body from :80/ contains grocy.css, Endpoint response body from :443/ contains grocy.css)", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/grocy.svg", - "name": "Grocy" + "description": "Open source software application for managing requests for your media library.", + "discovery_pattern": "Endpoint response body from :5055/ contains Jellyseerr", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/jellyseerr.svg", + "name": "Jellyseerr" }, { - "description": "GitOps continuous delivery", - "discovery_pattern": "Endpoint response body from :8080/api/version contains argocd", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/argo-cd.svg", - "name": "ArgoCD" + "description": "Free media server for personal streaming", + "discovery_pattern": "Endpoint response body from :80/System/Info/Public contains Jellyfin", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/jellyfin.svg", + "name": "Jellyfin" }, { "description": "Self-hosted photo and video management solution", @@ -840,124 +852,166 @@ "name": "Immich" }, { - "description": "Google Nest Wifi router", - "discovery_pattern": "All of: (Any of: (MAC Address belongs to Nest Labs Inc., MAC Address belongs to Google, Inc.), Host IP is a gateway in daemon's routing tables, or ends in .1 or .254., Endpoint response body from :80/ contains Nest Wifi)", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/google-home.svg", - "name": "Google Nest router" + "description": "Personal media server with streaming capabilities", + "discovery_pattern": "Endpoint response body from :8096/emby/System/Info/Public contains Emby", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/emby.svg", + "name": "Emby" }, { - "description": "Open-source relational database", - "discovery_pattern": "5432/tcp is open", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/postgresql.svg", - "name": "PostgreSQL" + "description": "Self-hosted audiobook and podcast server.", + "discovery_pattern": "13378/tcp is open", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/audiobookshelf.svg", + "name": "AudioBookShelf" }, { - "description": "PfSense package for DNS/IP blocking", - "discovery_pattern": "All of: (All of: (53/tcp is open, 53/udp is open), Endpoint response body from :80/pfblockerng contains pfblockerng)", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/pfsense.svg", - "name": "pfBlockerNG" + "description": "Backup and replication", + "discovery_pattern": "9392/tcp is open", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/veeam.svg", + "name": "Veeam" }, { - "description": "Google Home smart speaker or display", - "discovery_pattern": "All of: (Any of: (MAC Address belongs to Nest Labs Inc., MAC Address belongs to Google, Inc.), All of: (8008/tcp is open, 8009/tcp is open))", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/google-home.svg", - "name": "Google Home" + "description": "Fast and secure backup program", + "discovery_pattern": "All of: (8000/tcp is open, Endpoint response body from :80/ contains restic)", + "logo_url": "", + "name": "Restic" }, { - "description": "Identity management system", - "discovery_pattern": "Endpoint response status is between 200 and 300, and response body from :80/ipa/ui contains ", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/freeipa.svg", - "name": "FreeIPA" + "description": "Encrypted, incremental and deduplicated backups for Proxmox VMs, LXCs, and hosts", + "discovery_pattern": "Any of: (Endpoint response body from :8007/ contains proxmox-backup-gui, 8007/tcp is open)", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/proxmox.svg", + "name": "Proxmox Backup Server" }, { - "description": "Zigbee to MQTT bridge", - "discovery_pattern": "Endpoint response body from :8080/ contains Zigbee2MQTT WindFront", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/zigbee2mqtt.svg", - "name": "Zigbee2MQTT" + "description": "Cross-platform backup client with encryption", + "discovery_pattern": "Endpoint response body from :8200/ngax/index.html contains Duplicati", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/duplicati.svg", + "name": "Duplicati" }, { - "description": "Microsoft directory service", - "discovery_pattern": "All of: (389/tcp is open, 445/tcp is open, 88/tcp is open)", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/microsoft.svg", - "name": "Active Directory" + "description": "Network backup solution", + "discovery_pattern": "9101/tcp is open", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/bacula.png", + "name": "Bacula" }, { - "description": "Team collaboration wiki", - "discovery_pattern": "Endpoint response body from :8090/ contains confluence", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/confluence.svg", - "name": "Confluence" + "description": "Open-source network attached storage system", + "discovery_pattern": "All of: (445/tcp is open, Endpoint response body from :80/ contains TrueNAS)", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/truenas.svg", + "name": "TrueNAS" }, { - "description": "Personal media server with streaming capabilities", - "discovery_pattern": "Endpoint response body from :8096/emby/System/Info/Public contains Emby", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/emby.svg", - "name": "Emby" + "description": "Synology DiskStation Manager NAS system", + "discovery_pattern": "All of: (Endpoint response body from :80/ contains synology, 21/tcp is open)", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/synology.svg", + "name": "Synology DSM" }, { - "description": "The modern autodl-irssi replacement.", - "discovery_pattern": "7474/tcp is open", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/autobrr.svg", - "name": "Autobrr" + "description": "Continuous file synchronization service", + "discovery_pattern": "All of: (Endpoint response body from :80/ contains Syncthing, 22000/tcp is open)", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/syncthing.svg", + "name": "Syncthing" }, { - "description": "Web UI and orchestrator for Restic", - "discovery_pattern": "Endpoint response body from :9898/ contains BackRest", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/backrest-light.svg", - "name": "BackRest" + "description": "File hosting platform", + "discovery_pattern": "Endpoint response body from :8000/api2/ping contains seafile", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/seafile.svg", + "name": "Seafile" }, { - "description": "A generic docker container", - "discovery_pattern": "All of: (Service is running in a docker container, A custom match pattern evaluated at runtime)", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/docker.svg", - "name": "Docker Container" + "description": "Generic SMB file server", + "discovery_pattern": "445/tcp is open", + "logo_url": "", + "name": "Samba" }, { - "description": "Container-native CI platform", - "discovery_pattern": "All of: (Endpoint response body from :80/ contains drone, Endpoint response body from :80/api/user contains )", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/drone.png", - "name": "Drone" + "description": "QNAP network attached storage system", + "discovery_pattern": "All of: (21/tcp is open, Any of: (Endpoint response body from :80/ contains QNAP, Endpoint response body from :8080/ contains QNAP))", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/qnap.svg", + "name": "QNAP NAS" }, { - "description": "Team communication platform", - "discovery_pattern": "Endpoint response body from :3000/api/info contains rocket", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/rocket-chat.svg", - "name": "Rocket.Chat" + "description": "File sync and share", + "discovery_pattern": "Endpoint response body from :80/status.php contains owncloud", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/owncloud.svg", + "name": "ownCloud" }, { - "description": "A generic printing service", - "discovery_pattern": "Any of: (631/tcp is open, 515/tcp is open, 515/udp is open)", + "description": "Debian-based NAS solution", + "discovery_pattern": "All of: (445/tcp is open, Endpoint response body from :80/ contains openmediavault)", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/openmediavault.svg", + "name": "OpenMediaVault" + }, + { + "description": "Generic network file system", + "discovery_pattern": "2049/tcp is open", "logo_url": "", - "name": "Print Server" + "name": "NFS" }, { - "description": "A self-hosted dashboard for your homelab", - "discovery_pattern": "Endpoint response body from :3000/site.webmanifest contains Homepage", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/webp/homepage.webp", - "name": "Homepage" + "description": "Self-hosted cloud storage and collaboration platform", + "discovery_pattern": "Any of: (Endpoint response body from :80/core/css/server.css contains Nextcloud GmbH, Endpoint response body from :443/core/css/server.css contains Nextcloud GmbH)", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/nextcloud.svg", + "name": "NextCloud" }, { - "description": "Password manager", - "discovery_pattern": "Endpoint response body from :80/api/config contains bitwarden", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/bitwarden.svg", - "name": "Bitwarden" + "description": "A generic network storage devices", + "discovery_pattern": "2049/tcp is open", + "logo_url": "", + "name": "Nas Device" + }, + { + "description": "Object storage", + "discovery_pattern": "Endpoint response status is between 200 and 300, and response body from :9000/minio/health/live contains ", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/minio.svg", + "name": "MinIO" + }, + { + "description": "FTP server", + "discovery_pattern": "All of: (21/tcp is open, 14147/tcp is open)", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/filezilla.svg", + "name": "FileZilla Server" + }, + { + "description": "Generic FTP file sharing service", + "discovery_pattern": "21/tcp is open", + "logo_url": "", + "name": "FTP Server" + }, + { + "description": "Distributed storage", + "discovery_pattern": "Endpoint response body from :8080/ contains ceph dashboard", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/ceph.svg", + "name": "Ceph" + }, + { + "description": "Network-wide ad blocking DNS service", + "discovery_pattern": "All of: (Any of: (53/udp is open, 53/tcp is open), Endpoint response body from :80/admin contains pi-hole)", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/pi-hole.svg", + "name": "Pi-Hole" }, { - "description": "Container management web interface", - "discovery_pattern": "Any of: (Endpoint response body from :9443/#!/auth contains portainer.io, Endpoint response body from :9000/ contains portainer.io)", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/portainer.svg", - "name": "Portainer" + "description": "Network-wide ad and tracker blocking", + "discovery_pattern": "All of: (All of: (53/udp is open, 53/tcp is open), Endpoint response body from :80/ contains AdGuard Home)", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/adguard-home.svg", + "name": "Adguard Home" }, { - "description": "An HP Printer", - "discovery_pattern": "All of: (Any of: (Endpoint response body from :80 contains LaserJet, Endpoint response body from :80 contains DeskJet, Endpoint response body from :80 contains OfficeJet, Endpoint response body from :8080 contains LaserJet, Endpoint response body from :8080 contains DeskJet, Endpoint response body from :8080 contains OfficeJet), Any of: (631/tcp is open, 515/tcp is open, 515/udp is open))", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/hp.svg", - "name": "Hp Printer" + "description": "API gateway", + "discovery_pattern": "Endpoint response status is between 200 and 300, and response body from :8080/hello contains tyk", + "logo_url": "https://www.vectorlogo.zone/logos/tyk/tyk-icon.svg", + "name": "Tyk" }, { - "description": "Security Information and Event Management (SIEM) solution and log analytics platform", - "discovery_pattern": "All of: (Endpoint response from has header content-security-policy with value graylog, Endpoint response body from :9000/ contains Graylog)", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/graylog.svg", - "name": "Graylog" + "description": "Modern reverse proxy and load balancer", + "discovery_pattern": "Endpoint response body from :80/dashboard contains traefik", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/traefik.svg", + "name": "Traefik" + }, + { + "description": "Web-based Nginx proxy management interface", + "discovery_pattern": "Endpoint response body from :80 contains nginx proxy manager", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/nginx-proxy-manager.svg", + "name": "Nginx Proxy Manager" }, { "description": "API gateway", @@ -966,88 +1020,88 @@ "name": "Kong" }, { - "description": "A Simple OIDC provider that uses passkeys for authentication", - "discovery_pattern": "Endpoint response body from :1411/app.webmanifest contains Pocket ID", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/pocket-id-light.svg", - "name": "Pocket ID" + "description": "Load balancer and proxy", + "discovery_pattern": "Endpoint response body from :8404/stats contains haproxy", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/haproxy.svg", + "name": "HAProxy" }, { - "description": "ESP device management", - "discovery_pattern": "6052/tcp is open", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/esphome.svg", - "name": "ESPHome" + "description": "Lightweight & versatile reverse proxy, web & file server", + "discovery_pattern": "Endpoint response body from :2019/reverse_proxy/upstreams contains num_requests", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/caddy.svg", + "name": "Caddy" }, { - "description": "Application performance monitoring", - "discovery_pattern": "Endpoint response body from :8200/ contains apm", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/elastic.svg", - "name": "Elastic APM" + "description": "Wireguard dashboard for visualizing and managing wireguard clients and server", + "discovery_pattern": "All of: (10086/tcp is open, Not (Subnet is type VpnTunnel))", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/wireguard.svg", + "name": "WGDashboard" }, { - "description": "Network backup solution", - "discovery_pattern": "9101/tcp is open", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/bacula.png", - "name": "Bacula" + "description": "Cloudflare tunnel daemon", + "discovery_pattern": "Endpoint response body from :80/metrics contains cloudflared", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/cloudflare.svg", + "name": "Cloudflared" }, { - "description": "Fios device providing routing and gateway services", - "discovery_pattern": "All of: (Endpoint response body from :80/#/login/ contains fios, Host IP is a gateway in daemon's routing tables, or ends in .1 or .254.)", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/fios.svg", - "name": "Fios Gateway" + "description": "Recursive DNS resolver with control interface", + "discovery_pattern": "All of: (53/udp is open, 8953/tcp is open)", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/unbound.svg", + "name": "Unbound DNS" }, { - "description": "An open-source system cross-platform monitoring tool.", - "discovery_pattern": "Endpoint response body from :61208/ contains Glances", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/glances.svg", - "name": "Glances" + "description": "Authoritative DNS server with API", + "discovery_pattern": "All of: (53/udp is open, 53/tcp is open, 8081/tcp is open)", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/powerdns.svg", + "name": "PowerDNS" }, { - "description": "Content management system", - "discovery_pattern": "Endpoint response body from :80/ contains wp-content", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/wordpress.svg", - "name": "WordPress" + "description": "A generic Dns server", + "discovery_pattern": "Any of: (53/tcp is open, 53/udp is open)", + "logo_url": "", + "name": "Dns Server" }, { - "description": "Generic MQTT broker", - "discovery_pattern": "Any of: (1883/tcp is open, 8883/tcp is open)", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/mqtt.svg", - "name": "MQTT" + "description": "Berkeley Internet Name Domain DNS server", + "discovery_pattern": "All of: (53/udp is open, 8053/tcp is open)", + "logo_url": "", + "name": "Bind9" }, { - "description": "In-memory data store and cache", - "discovery_pattern": "6379/tcp is open", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/redis.svg", - "name": "Redis" + "description": "Open-source firewall and router platform", + "discovery_pattern": "All of: (22/tcp is open, Endpoint response body from :80/ contains pfsense)", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/pfsense.svg", + "name": "pfSense" }, { - "description": "Debian-based NAS solution", - "discovery_pattern": "All of: (445/tcp is open, Endpoint response body from :80/ contains openmediavault)", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/openmediavault.svg", - "name": "OpenMediaVault" + "description": "PfSense package for DNS/IP blocking", + "discovery_pattern": "All of: (All of: (53/tcp is open, 53/udp is open), Endpoint response body from :80/pfblockerng contains pfblockerng)", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/pfsense.svg", + "name": "pfBlockerNG" }, { - "description": "Generic FTP file sharing service", - "discovery_pattern": "21/tcp is open", - "logo_url": "", - "name": "FTP Server" + "description": "Open-source firewall and routing platform", + "discovery_pattern": "All of: (Any of: (Endpoint response body from :80/ contains opnsense, Endpoint response body from :443/ contains opnsense), Any of: (53/tcp is open, 53/udp is open, 22/tcp is open, 123/udp is open, 67/udp is open))", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/opnsense.svg", + "name": "OPNsense" }, { - "description": "Open-source home automation platform", - "discovery_pattern": "Endpoint response body from :8123/ contains home assistant", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/home-assistant.svg", - "name": "Home Assistant" + "description": "Fortinet security appliance", + "discovery_pattern": "Endpoint response body from :80/login contains fortinet", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/fortinet.svg", + "name": "Fortinet" }, { - "description": "Distributed storage", - "discovery_pattern": "Endpoint response body from :8080/ contains ceph dashboard", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/ceph.svg", - "name": "Ceph" + "description": "Generic network security appliance", + "discovery_pattern": "No match pattern provided", + "logo_url": "", + "name": "Firewall" }, { - "description": "Self-hosted audiobook and podcast server.", - "discovery_pattern": "13378/tcp is open", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/audiobookshelf.svg", - "name": "AudioBookShelf" + "description": "Crowdsourced protection against malicious IPs", + "discovery_pattern": "Endpoint response status is between 401 and 401, and response body from :8080/v1/allowlists contains cookie token is empty", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/crowdsec.svg", + "name": "CrowdSec" }, { "description": "Ubiquiti UniFi wireless access point", @@ -1056,99 +1110,69 @@ "name": "Unifi Access Point" }, { - "description": "QNAP network attached storage system", - "discovery_pattern": "All of: (21/tcp is open, Any of: (Endpoint response body from :80/ contains QNAP, Endpoint response body from :8080/ contains QNAP))", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/qnap.svg", - "name": "QNAP NAS" - }, - { - "description": "Modern reverse proxy and load balancer", - "discovery_pattern": "Endpoint response body from :80/dashboard contains traefik", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/traefik.svg", - "name": "Traefik" + "description": "TP-Link EAP wireless access point", + "discovery_pattern": "All of: (MAC Address belongs to TP-LINK TECHNOLOGIES CO.,LTD, Endpoint response body from :80/ contains tp-link)", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/tp-link.svg", + "name": "TP-Link EAP" }, { - "description": "A generic IoT Service", - "discovery_pattern": "No match pattern provided", - "logo_url": "", - "name": "IoT" + "description": "Google Nest Wifi router", + "discovery_pattern": "All of: (Any of: (MAC Address belongs to Nest Labs Inc., MAC Address belongs to Google, Inc.), Host IP is a gateway in daemon's routing tables, or ends in .1 or .254., Endpoint response body from :80/ contains Nest Wifi)", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/google-home.svg", + "name": "Google Nest router" }, { - "description": "Security platform", - "discovery_pattern": "Endpoint response body from :55000/ contains wazuh", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/wazuh.svg", - "name": "Wazuh" + "description": "Google Nest Wifi repeater", + "discovery_pattern": "All of: (Any of: (MAC Address belongs to Nest Labs Inc., MAC Address belongs to Google, Inc.), Not (Host IP is a gateway in daemon's routing tables, or ends in .1 or .254.), Endpoint response body from :80/ contains Nest Wifi)", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/google-home.svg", + "name": "Google Nest repeater" }, { - "description": "Free media server for personal streaming", - "discovery_pattern": "Endpoint response body from :80/System/Info/Public contains Jellyfin", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/jellyfin.svg", - "name": "Jellyfin" + "description": "Fios device providing routing and gateway services", + "discovery_pattern": "All of: (Endpoint response body from :80/#/login/ contains fios, Host IP is a gateway in daemon's routing tables, or ends in .1 or .254.)", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/fios.svg", + "name": "Fios Gateway" }, { - "description": "A generic client device that initiates connections to services", - "discovery_pattern": "No match pattern provided", - "logo_url": "", - "name": "Client" + "description": "Fios device providing mesh networking services", + "discovery_pattern": "All of: (Endpoint response body from :80/#/login/ contains fios, Not (Host IP is a gateway in daemon's routing tables, or ends in .1 or .254.))", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/fios.svg", + "name": "Fios Extender" }, { - "description": "File sync and share", - "discovery_pattern": "Endpoint response body from :80/status.php contains owncloud", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/owncloud.svg", - "name": "ownCloud" + "description": "Eero device providing mesh network services", + "discovery_pattern": "All of: (MAC Address belongs to eero Inc, Not (Host IP is a gateway in daemon's routing tables, or ends in .1 or .254.))", + "logo_url": "https://www.vectorlogo.zone/logos/eero/eero-icon.svg", + "name": "Eero Repeater" }, { - "description": "Cross-platform open-source BitTorrent client", - "discovery_pattern": "Any of: (Endpoint response body from :8080/ contains qBittorrent logo, Endpoint response body from :8090/ contains qBittorrent logo)", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/qbittorrent.svg", - "name": "qBittorrent" + "description": "Eero device providing routing and gateway services", + "discovery_pattern": "All of: (MAC Address belongs to eero Inc, Host IP is a gateway in daemon's routing tables, or ends in .1 or .254.)", + "logo_url": "https://www.vectorlogo.zone/logos/eero/eero-icon.svg", + "name": "Eero Gateway" }, { - "description": "Multi-cloud CD platform", + "description": "A generic wireless access point for WiFi connectivity", "discovery_pattern": "No match pattern provided", - "logo_url": "https://simpleicons.org/icons/spinnaker.svg", - "name": "Spinnaker" - }, - { - "description": "Workload orchestration", - "discovery_pattern": "Endpoint response body from :4646/v1/status/leader contains ", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/nomad.svg", - "name": "Nomad" - }, - { - "description": "Open-source firewall and routing platform", - "discovery_pattern": "All of: (Any of: (Endpoint response body from :80/ contains opnsense, Endpoint response body from :443/ contains opnsense), Any of: (53/tcp is open, 53/udp is open, 22/tcp is open, 123/udp is open, 67/udp is open))", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/opnsense.svg", - "name": "OPNsense" - }, - { - "description": "Monitoring framework", - "discovery_pattern": "Endpoint response body from :4567/health contains sensu", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sensu.svg", - "name": "Sensu" - }, - { - "description": "A free, self-hostable news aggregator", - "discovery_pattern": "Endpoint response body from :80/themes/manifest.json contains FreshRSS feed aggregator", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/freshrss.svg", - "name": "FreshRSS" + "logo_url": "", + "name": "Access Point" }, { - "description": "A self-hosted startpage and real-time status page", - "discovery_pattern": "Endpoint response body from :8123/ contains Jump", + "description": "Generic network switch for local area networking", + "discovery_pattern": "All of: (Not (Host IP is a gateway in daemon's routing tables, or ends in .1 or .254.), All of: (80/tcp is open, 23/tcp is open))", "logo_url": "", - "name": "Jump" + "name": "Switch" }, { - "description": "Common Unix Printing System", - "discovery_pattern": "All of: (631/tcp is open, Endpoint response body from :80/ contains CUPS)", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/cups.svg", - "name": "CUPS" + "description": "A generic gateway", + "discovery_pattern": "All of: (Host IP is a gateway in daemon's routing tables, or ends in .1 or .254., A custom match pattern evaluated at runtime)", + "logo_url": "", + "name": "Gateway" }, { - "description": "Distributed NoSQL database", - "discovery_pattern": "9042/tcp is open", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/apache-cassandra.svg", - "name": "Cassandra" + "description": "A generic Dhcp server", + "discovery_pattern": "67/udp is open", + "logo_url": "", + "name": "Dhcp Server" } ] \ No newline at end of file From e50310eb984044acc6a5d1d340996b75e23c501c Mon Sep 17 00:00:00 2001 From: Maya Date: Wed, 19 Nov 2025 15:53:37 -0500 Subject: [PATCH 20/27] fix: remove http from legacy server target URL parsing --- backend/src/daemon/shared/config.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/daemon/shared/config.rs b/backend/src/daemon/shared/config.rs index 69f5e9fe..9e4b1851 100644 --- a/backend/src/daemon/shared/config.rs +++ b/backend/src/daemon/shared/config.rs @@ -323,7 +323,7 @@ impl ConfigStore { if let Some(server_port) = config.server_port && let Some(server_target) = &config.server_target { - Ok(format!("http://{}:{}", server_target, server_port)) + Ok(format!("{}:{}", server_target, server_port)) } else if let Some(server_url) = config.server_url.clone() { Ok(server_url) } else { From 4e22d8f84c975b63e4cb3aaabb37eb9247940e06 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 19 Nov 2025 21:10:38 +0000 Subject: [PATCH 21/27] chore: update test fixtures for release v0.10.1 --- backend/src/tests/daemon_config.json | 8 +-- backend/src/tests/netvisor.sql | 98 ++++++++++++++-------------- 2 files changed, 54 insertions(+), 52 deletions(-) diff --git a/backend/src/tests/daemon_config.json b/backend/src/tests/daemon_config.json index af351284..e95b6012 100644 --- a/backend/src/tests/daemon_config.json +++ b/backend/src/tests/daemon_config.json @@ -1,6 +1,6 @@ { "server_url": "http://server:60072", - "network_id": "61b521cb-415f-4067-9a9d-eccfc3d3c38b", + "network_id": "a1be6f02-adbd-47b0-9705-f7cd5e5725c9", "server_target": null, "server_port": null, "daemon_port": 60073, @@ -9,10 +9,10 @@ "heartbeat_interval": 30, "bind_address": "0.0.0.0", "concurrent_scans": 15, - "id": "887ae705-1262-4e8c-af47-08fe31eba9d9", + "id": "cb822b21-406f-4ec8-8a2d-293086c05e79", "last_heartbeat": null, - "host_id": "9ea137d0-3ee2-40d2-9d75-bf12697689a5", - "daemon_api_key": "144edd5bff8d4bf19325f95a25ab3dcb", + "host_id": "7071d28a-2b9b-402f-ad86-d6a4a9265a4b", + "daemon_api_key": "c13021e952d14a84a32c88bd6c9f5a35", "docker_proxy": null, "mode": "Push" } \ No newline at end of file diff --git a/backend/src/tests/netvisor.sql b/backend/src/tests/netvisor.sql index 11eae5fd..7a80b88e 100644 --- a/backend/src/tests/netvisor.sql +++ b/backend/src/tests/netvisor.sql @@ -2,7 +2,7 @@ -- PostgreSQL database dump -- -\restrict eBSnQ02bTJJqBWf84vAslrtSFRhVu7kiaRFTHpaqJGK3AZClXEDcKsrVWzI35Hs +\restrict MyKRMO5qap9sn56g55pvnii2xqIH9zo3armcSKlGtj5TsP9o5IJXJbz4ZIjUXeN -- Dumped from database version 17.7 -- Dumped by pg_dump version 17.7 @@ -348,24 +348,24 @@ ALTER TABLE tower_sessions.session OWNER TO postgres; -- COPY public._sqlx_migrations (version, description, installed_on, success, checksum, execution_time) FROM stdin; -20251006215000 users 2025-11-19 18:09:54.036441+00 t \\x4f13ce14ff67ef0b7145987c7b22b588745bf9fbb7b673450c26a0f2f9a36ef8ca980e456c8d77cfb1b2d7a4577a64d7 3457532 -20251006215100 networks 2025-11-19 18:09:54.040861+00 t \\xeaa5a07a262709f64f0c59f31e25519580c79e2d1a523ce72736848946a34b17dd9adc7498eaf90551af6b7ec6d4e0e3 4459430 -20251006215151 create hosts 2025-11-19 18:09:54.045676+00 t \\x6ec7487074c0724932d21df4cf1ed66645313cf62c159a7179e39cbc261bcb81a24f7933a0e3cf58504f2a90fc5c1962 3753176 -20251006215155 create subnets 2025-11-19 18:09:54.049755+00 t \\xefb5b25742bd5f4489b67351d9f2494a95f307428c911fd8c5f475bfb03926347bdc269bbd048d2ddb06336945b27926 3588988 -20251006215201 create groups 2025-11-19 18:09:54.053697+00 t \\x0a7032bf4d33a0baf020e905da865cde240e2a09dda2f62aa535b2c5d4b26b20be30a3286f1b5192bd94cd4a5dbb5bcd 3718311 -20251006215204 create daemons 2025-11-19 18:09:54.057761+00 t \\xcfea93403b1f9cf9aac374711d4ac72d8a223e3c38a1d2a06d9edb5f94e8a557debac3668271f8176368eadc5105349f 4217908 -20251006215212 create services 2025-11-19 18:09:54.062345+00 t \\xd5b07f82fc7c9da2782a364d46078d7d16b5c08df70cfbf02edcfe9b1b24ab6024ad159292aeea455f15cfd1f4740c1d 4890819 -20251029193448 user-auth 2025-11-19 18:09:54.067551+00 t \\xfde8161a8db89d51eeade7517d90a41d560f19645620f2298f78f116219a09728b18e91251ae31e46a47f6942d5a9032 4327893 -20251030044828 daemon api 2025-11-19 18:09:54.072173+00 t \\x181eb3541f51ef5b038b2064660370775d1b364547a214a20dde9c9d4bb95a1c273cd4525ef29e61fa65a3eb4fee0400 1501956 -20251030170438 host-hide 2025-11-19 18:09:54.073966+00 t \\x87c6fda7f8456bf610a78e8e98803158caa0e12857c5bab466a5bb0004d41b449004a68e728ca13f17e051f662a15454 1093561 -20251102224919 create discovery 2025-11-19 18:09:54.075355+00 t \\xb32a04abb891aba48f92a059fae7341442355ca8e4af5d109e28e2a4f79ee8e11b2a8f40453b7f6725c2dd6487f26573 9345210 -20251106235621 normalize-daemon-cols 2025-11-19 18:09:54.084974+00 t \\x5b137118d506e2708097c432358bf909265b3cf3bacd662b02e2c81ba589a9e0100631c7801cffd9c57bb10a6674fb3b 1723481 -20251107034459 api keys 2025-11-19 18:09:54.087073+00 t \\x3133ec043c0c6e25b6e55f7da84cae52b2a72488116938a2c669c8512c2efe72a74029912bcba1f2a2a0a8b59ef01dde 7741403 -20251107222650 oidc-auth 2025-11-19 18:09:54.095103+00 t \\xd349750e0298718cbcd98eaff6e152b3fb45c3d9d62d06eedeb26c75452e9ce1af65c3e52c9f2de4bd532939c2f31096 21897441 -20251110181948 orgs-billing 2025-11-19 18:09:54.117385+00 t \\x5bbea7a2dfc9d00213bd66b473289ddd66694eff8a4f3eaab937c985b64c5f8c3ad2d64e960afbb03f335ac6766687aa 10576408 -20251113223656 group-enhancements 2025-11-19 18:09:54.12827+00 t \\xbe0699486d85df2bd3edc1f0bf4f1f096d5b6c5070361702c4d203ec2bb640811be88bb1979cfe51b40805ad84d1de65 1012308 -20251117032720 daemon-mode 2025-11-19 18:09:54.129679+00 t \\xdd0d899c24b73d70e9970e54b2c748d6b6b55c856ca0f8590fe990da49cc46c700b1ce13f57ff65abd6711f4bd8a6481 1067802 -20251118143058 set-default-plan 2025-11-19 18:09:54.131027+00 t \\xd19142607aef84aac7cfb97d60d29bda764d26f513f2c72306734c03cec2651d23eee3ce6cacfd36ca52dbddc462f917 1149295 +20251006215000 users 2025-11-19 21:09:03.62449+00 t \\x4f13ce14ff67ef0b7145987c7b22b588745bf9fbb7b673450c26a0f2f9a36ef8ca980e456c8d77cfb1b2d7a4577a64d7 3555540 +20251006215100 networks 2025-11-19 21:09:03.628727+00 t \\xeaa5a07a262709f64f0c59f31e25519580c79e2d1a523ce72736848946a34b17dd9adc7498eaf90551af6b7ec6d4e0e3 3908426 +20251006215151 create hosts 2025-11-19 21:09:03.632989+00 t \\x6ec7487074c0724932d21df4cf1ed66645313cf62c159a7179e39cbc261bcb81a24f7933a0e3cf58504f2a90fc5c1962 3816616 +20251006215155 create subnets 2025-11-19 21:09:03.637143+00 t \\xefb5b25742bd5f4489b67351d9f2494a95f307428c911fd8c5f475bfb03926347bdc269bbd048d2ddb06336945b27926 6252800 +20251006215201 create groups 2025-11-19 21:09:03.644086+00 t \\x0a7032bf4d33a0baf020e905da865cde240e2a09dda2f62aa535b2c5d4b26b20be30a3286f1b5192bd94cd4a5dbb5bcd 4164262 +20251006215204 create daemons 2025-11-19 21:09:03.648606+00 t \\xcfea93403b1f9cf9aac374711d4ac72d8a223e3c38a1d2a06d9edb5f94e8a557debac3668271f8176368eadc5105349f 4117065 +20251006215212 create services 2025-11-19 21:09:03.653265+00 t \\xd5b07f82fc7c9da2782a364d46078d7d16b5c08df70cfbf02edcfe9b1b24ab6024ad159292aeea455f15cfd1f4740c1d 5088433 +20251029193448 user-auth 2025-11-19 21:09:03.658773+00 t \\xfde8161a8db89d51eeade7517d90a41d560f19645620f2298f78f116219a09728b18e91251ae31e46a47f6942d5a9032 3951156 +20251030044828 daemon api 2025-11-19 21:09:03.663058+00 t \\x181eb3541f51ef5b038b2064660370775d1b364547a214a20dde9c9d4bb95a1c273cd4525ef29e61fa65a3eb4fee0400 1497559 +20251030170438 host-hide 2025-11-19 21:09:03.665314+00 t \\x87c6fda7f8456bf610a78e8e98803158caa0e12857c5bab466a5bb0004d41b449004a68e728ca13f17e051f662a15454 1134632 +20251102224919 create discovery 2025-11-19 21:09:03.666822+00 t \\xb32a04abb891aba48f92a059fae7341442355ca8e4af5d109e28e2a4f79ee8e11b2a8f40453b7f6725c2dd6487f26573 9270729 +20251106235621 normalize-daemon-cols 2025-11-19 21:09:03.676635+00 t \\x5b137118d506e2708097c432358bf909265b3cf3bacd662b02e2c81ba589a9e0100631c7801cffd9c57bb10a6674fb3b 1862668 +20251107034459 api keys 2025-11-19 21:09:03.679003+00 t \\x3133ec043c0c6e25b6e55f7da84cae52b2a72488116938a2c669c8512c2efe72a74029912bcba1f2a2a0a8b59ef01dde 7231062 +20251107222650 oidc-auth 2025-11-19 21:09:03.686747+00 t \\xd349750e0298718cbcd98eaff6e152b3fb45c3d9d62d06eedeb26c75452e9ce1af65c3e52c9f2de4bd532939c2f31096 20484609 +20251110181948 orgs-billing 2025-11-19 21:09:03.707883+00 t \\x5bbea7a2dfc9d00213bd66b473289ddd66694eff8a4f3eaab937c985b64c5f8c3ad2d64e960afbb03f335ac6766687aa 10096106 +20251113223656 group-enhancements 2025-11-19 21:09:03.718355+00 t \\xbe0699486d85df2bd3edc1f0bf4f1f096d5b6c5070361702c4d203ec2bb640811be88bb1979cfe51b40805ad84d1de65 1127076 +20251117032720 daemon-mode 2025-11-19 21:09:03.71988+00 t \\xdd0d899c24b73d70e9970e54b2c748d6b6b55c856ca0f8590fe990da49cc46c700b1ce13f57ff65abd6711f4bd8a6481 1150241 +20251118143058 set-default-plan 2025-11-19 21:09:03.721374+00 t \\xd19142607aef84aac7cfb97d60d29bda764d26f513f2c72306734c03cec2651d23eee3ce6cacfd36ca52dbddc462f917 1186940 \. @@ -374,7 +374,7 @@ COPY public._sqlx_migrations (version, description, installed_on, success, check -- COPY public.api_keys (id, key, network_id, name, created_at, updated_at, last_used, expires_at, is_enabled) FROM stdin; -2ab8b86f-73b1-4f5f-95ae-3534a4562f46 144edd5bff8d4bf19325f95a25ab3dcb 61b521cb-415f-4067-9a9d-eccfc3d3c38b Integrated Daemon API Key 2025-11-19 18:09:57.843792+00 2025-11-19 18:10:50.668673+00 2025-11-19 18:10:50.668351+00 \N t +fb5ddc38-f4e7-4236-9cb8-9b52d2f1c966 c13021e952d14a84a32c88bd6c9f5a35 a1be6f02-adbd-47b0-9705-f7cd5e5725c9 Integrated Daemon API Key 2025-11-19 21:09:06.782132+00 2025-11-19 21:10:14.075217+00 2025-11-19 21:10:14.074884+00 \N t \. @@ -383,7 +383,7 @@ COPY public.api_keys (id, key, network_id, name, created_at, updated_at, last_us -- COPY public.daemons (id, network_id, host_id, ip, port, created_at, last_seen, capabilities, updated_at, mode) FROM stdin; -887ae705-1262-4e8c-af47-08fe31eba9d9 61b521cb-415f-4067-9a9d-eccfc3d3c38b 9ea137d0-3ee2-40d2-9d75-bf12697689a5 "172.25.0.4" 60073 2025-11-19 18:09:57.893368+00 2025-11-19 18:09:57.893367+00 {"has_docker_socket": false, "interfaced_subnet_ids": ["d3c4eb6c-bf62-4299-8f8f-4c1a0c44a340"]} 2025-11-19 18:09:57.944998+00 "Push" +cb822b21-406f-4ec8-8a2d-293086c05e79 a1be6f02-adbd-47b0-9705-f7cd5e5725c9 7071d28a-2b9b-402f-ad86-d6a4a9265a4b "172.25.0.4" 60073 2025-11-19 21:09:06.834694+00 2025-11-19 21:09:06.834693+00 {"has_docker_socket": false, "interfaced_subnet_ids": ["4681595e-cadc-497a-a83a-1c647ab02251"]} 2025-11-19 21:09:06.85269+00 "Push" \. @@ -392,10 +392,10 @@ COPY public.daemons (id, network_id, host_id, ip, port, created_at, last_seen, c -- COPY public.discovery (id, network_id, daemon_id, run_type, discovery_type, name, created_at, updated_at) FROM stdin; -faf4cc0f-ed0b-4416-81f8-9b71a5cd1d11 61b521cb-415f-4067-9a9d-eccfc3d3c38b 887ae705-1262-4e8c-af47-08fe31eba9d9 {"type": "Scheduled", "enabled": true, "last_run": null, "cron_schedule": "0 0 0 * * *"} {"type": "SelfReport", "host_id": "9ea137d0-3ee2-40d2-9d75-bf12697689a5"} Self Report @ 172.25.0.4 2025-11-19 18:09:57.895007+00 2025-11-19 18:09:57.895007+00 -654adb92-da61-47cf-aa91-f7ec10733bc9 61b521cb-415f-4067-9a9d-eccfc3d3c38b 887ae705-1262-4e8c-af47-08fe31eba9d9 {"type": "Scheduled", "enabled": true, "last_run": null, "cron_schedule": "0 0 0 * * *"} {"type": "Network", "subnet_ids": null, "host_naming_fallback": "BestService"} Network Scan @ 172.25.0.4 2025-11-19 18:09:57.900776+00 2025-11-19 18:09:57.900776+00 -4e534407-db5c-4255-9518-cc9de43d8036 61b521cb-415f-4067-9a9d-eccfc3d3c38b 887ae705-1262-4e8c-af47-08fe31eba9d9 {"type": "Historical", "results": {"error": null, "phase": "Complete", "daemon_id": "887ae705-1262-4e8c-af47-08fe31eba9d9", "processed": 1, "network_id": "61b521cb-415f-4067-9a9d-eccfc3d3c38b", "session_id": "ece32baf-d4b7-424b-a015-596d51edc068", "started_at": "2025-11-19T18:09:57.900482908Z", "finished_at": "2025-11-19T18:09:57.996170361Z", "discovery_type": {"type": "SelfReport", "host_id": "9ea137d0-3ee2-40d2-9d75-bf12697689a5"}, "total_to_process": 1}} {"type": "SelfReport", "host_id": "9ea137d0-3ee2-40d2-9d75-bf12697689a5"} Discovery Run 2025-11-19 18:09:57.900482+00 2025-11-19 18:09:57.997215+00 -9b22049b-2d1f-479b-8b94-65afc2b570aa 61b521cb-415f-4067-9a9d-eccfc3d3c38b 887ae705-1262-4e8c-af47-08fe31eba9d9 {"type": "Historical", "results": {"error": null, "phase": "Complete", "daemon_id": "887ae705-1262-4e8c-af47-08fe31eba9d9", "processed": 12, "network_id": "61b521cb-415f-4067-9a9d-eccfc3d3c38b", "session_id": "93c02bc4-3e17-4cf4-bf86-19f526d44ec2", "started_at": "2025-11-19T18:09:58.006390394Z", "finished_at": "2025-11-19T18:10:50.667475089Z", "discovery_type": {"type": "Network", "subnet_ids": null, "host_naming_fallback": "BestService"}, "total_to_process": 16}} {"type": "Network", "subnet_ids": null, "host_naming_fallback": "BestService"} Discovery Run 2025-11-19 18:09:58.00639+00 2025-11-19 18:10:50.668607+00 +19c79f28-4ae2-4dd5-bc7c-197e8bf14ac6 a1be6f02-adbd-47b0-9705-f7cd5e5725c9 cb822b21-406f-4ec8-8a2d-293086c05e79 {"type": "Scheduled", "enabled": true, "last_run": null, "cron_schedule": "0 0 0 * * *"} {"type": "SelfReport", "host_id": "7071d28a-2b9b-402f-ad86-d6a4a9265a4b"} Self Report @ 172.25.0.4 2025-11-19 21:09:06.836608+00 2025-11-19 21:09:06.836608+00 +1312c192-6db4-4795-b4f3-152bdf6e0dc3 a1be6f02-adbd-47b0-9705-f7cd5e5725c9 cb822b21-406f-4ec8-8a2d-293086c05e79 {"type": "Scheduled", "enabled": true, "last_run": null, "cron_schedule": "0 0 0 * * *"} {"type": "Network", "subnet_ids": null, "host_naming_fallback": "BestService"} Network Scan @ 172.25.0.4 2025-11-19 21:09:06.84288+00 2025-11-19 21:09:06.84288+00 +7df967b7-b4fc-436b-9730-51d93c3e4fcf a1be6f02-adbd-47b0-9705-f7cd5e5725c9 cb822b21-406f-4ec8-8a2d-293086c05e79 {"type": "Historical", "results": {"error": null, "phase": "Complete", "daemon_id": "cb822b21-406f-4ec8-8a2d-293086c05e79", "processed": 1, "network_id": "a1be6f02-adbd-47b0-9705-f7cd5e5725c9", "session_id": "92e6da9b-7538-4cf7-8b53-ec64d65729f5", "started_at": "2025-11-19T21:09:06.842573117Z", "finished_at": "2025-11-19T21:09:06.938837010Z", "discovery_type": {"type": "SelfReport", "host_id": "7071d28a-2b9b-402f-ad86-d6a4a9265a4b"}, "total_to_process": 1}} {"type": "SelfReport", "host_id": "7071d28a-2b9b-402f-ad86-d6a4a9265a4b"} Discovery Run 2025-11-19 21:09:06.842573+00 2025-11-19 21:09:06.940525+00 +52954fef-718e-4e65-ba67-ac7fcdc68026 a1be6f02-adbd-47b0-9705-f7cd5e5725c9 cb822b21-406f-4ec8-8a2d-293086c05e79 {"type": "Historical", "results": {"error": null, "phase": "Complete", "daemon_id": "cb822b21-406f-4ec8-8a2d-293086c05e79", "processed": 11, "network_id": "a1be6f02-adbd-47b0-9705-f7cd5e5725c9", "session_id": "6d5db346-ed1c-440d-8525-49dad64274c5", "started_at": "2025-11-19T21:09:06.949909313Z", "finished_at": "2025-11-19T21:10:14.074109263Z", "discovery_type": {"type": "Network", "subnet_ids": null, "host_naming_fallback": "BestService"}, "total_to_process": 16}} {"type": "Network", "subnet_ids": null, "host_naming_fallback": "BestService"} Discovery Run 2025-11-19 21:09:06.949909+00 2025-11-19 21:10:14.075136+00 \. @@ -412,13 +412,14 @@ COPY public.groups (id, network_id, name, description, group_type, created_at, u -- COPY public.hosts (id, network_id, name, hostname, description, target, interfaces, services, ports, source, virtualization, created_at, updated_at, hidden) FROM stdin; -d0bb5250-767e-4dc7-88ae-c9095b54203e 61b521cb-415f-4067-9a9d-eccfc3d3c38b Cloudflare DNS \N \N {"type": "ServiceBinding", "config": "40ee608a-deef-4d9a-beb4-3caaa606fce1"} [{"id": "142b853f-a36a-4c4a-8a46-27281ddee5bf", "name": "Internet", "subnet_id": "6eb9bbef-c490-4b4e-adf0-adbb0b48f20f", "ip_address": "1.1.1.1", "mac_address": null}] ["11aad073-4c5e-4959-9174-d410f226156f"] [{"id": "f868e848-5015-4dbc-995e-1a169ee68809", "type": "DnsUdp", "number": 53, "protocol": "Udp"}] {"type": "System"} null 2025-11-19 18:09:57.826214+00 2025-11-19 18:09:57.834746+00 f -783bcaf9-0f86-4c93-a9a5-73aed8152e53 61b521cb-415f-4067-9a9d-eccfc3d3c38b Google.com \N \N {"type": "ServiceBinding", "config": "c61d2d7f-bfc4-4ecb-b96d-8274777ed603"} [{"id": "000217e1-59e6-461d-a4f0-0040cf9dd2df", "name": "Internet", "subnet_id": "6eb9bbef-c490-4b4e-adf0-adbb0b48f20f", "ip_address": "203.0.113.182", "mac_address": null}] ["f7294413-4733-427b-a7f8-6150d7743d36"] [{"id": "bebb25b3-49bb-45cc-b577-2173ad35695b", "type": "Https", "number": 443, "protocol": "Tcp"}] {"type": "System"} null 2025-11-19 18:09:57.826223+00 2025-11-19 18:09:57.839541+00 f -01c2d4f7-4dbf-4e17-84e8-ac898b65ac8f 61b521cb-415f-4067-9a9d-eccfc3d3c38b Mobile Device \N A mobile device connecting from a remote network {"type": "ServiceBinding", "config": "715adaf4-b3e5-4005-8b13-3ac9c1b208fd"} [{"id": "2daaaf54-66ba-41c4-8f86-ccf3fdbe4f8e", "name": "Remote Network", "subnet_id": "1bf05386-4748-4352-8260-85b9c9daa773", "ip_address": "203.0.113.32", "mac_address": null}] ["b2f10d32-a6db-4f96-92ae-f0eddbadc99d"] [{"id": "b369f9a4-b9cb-4961-8f4f-626c5db1fe1d", "type": "Custom", "number": 0, "protocol": "Tcp"}] {"type": "System"} null 2025-11-19 18:09:57.826231+00 2025-11-19 18:09:57.843025+00 f -19be1245-29c0-49a1-9826-744962d53b13 61b521cb-415f-4067-9a9d-eccfc3d3c38b netvisor-server-1.netvisor_netvisor-dev netvisor-server-1.netvisor_netvisor-dev \N {"type": "Hostname"} [{"id": "461342db-91ec-4258-9cfa-d62310c5afc0", "name": null, "subnet_id": "d3c4eb6c-bf62-4299-8f8f-4c1a0c44a340", "ip_address": "172.25.0.3", "mac_address": "F2:50:68:08:25:7D"}] ["9ac07a7c-26ee-4556-9470-3fb819a8cb6a"] [{"id": "adb2d1d2-acd4-418d-8fde-b32b67b6c20a", "type": "Custom", "number": 60072, "protocol": "Tcp"}] {"type": "Discovery", "metadata": [{"date": "2025-11-19T18:10:00.150306693Z", "type": "Network", "daemon_id": "887ae705-1262-4e8c-af47-08fe31eba9d9", "subnet_ids": null, "host_naming_fallback": "BestService"}]} null 2025-11-19 18:10:00.150308+00 2025-11-19 18:10:14.228034+00 f -26c2861f-c3a3-45c7-8e72-287795e7be96 61b521cb-415f-4067-9a9d-eccfc3d3c38b netvisor-postgres-dev-1.netvisor_netvisor-dev netvisor-postgres-dev-1.netvisor_netvisor-dev \N {"type": "Hostname"} [{"id": "90702814-e01d-430e-9e67-35ec7cb0f2c3", "name": null, "subnet_id": "d3c4eb6c-bf62-4299-8f8f-4c1a0c44a340", "ip_address": "172.25.0.6", "mac_address": "DA:90:29:34:E2:75"}] ["23706bbb-bd9f-4f3e-bde8-ec40c04d017c"] [{"id": "1d2b2ec9-b302-4433-a6ba-9f1353904c86", "type": "PostgreSQL", "number": 5432, "protocol": "Tcp"}] {"type": "Discovery", "metadata": [{"date": "2025-11-19T18:10:14.381228463Z", "type": "Network", "daemon_id": "887ae705-1262-4e8c-af47-08fe31eba9d9", "subnet_ids": null, "host_naming_fallback": "BestService"}]} null 2025-11-19 18:10:14.381229+00 2025-11-19 18:10:28.589679+00 f -9ea137d0-3ee2-40d2-9d75-bf12697689a5 61b521cb-415f-4067-9a9d-eccfc3d3c38b 172.25.0.4 e4c4a864ff5f NetVisor daemon {"type": "None"} [{"id": "b964011d-d5bf-45fa-9a64-3a2d0a7f2d49", "name": "eth0", "subnet_id": "d3c4eb6c-bf62-4299-8f8f-4c1a0c44a340", "ip_address": "172.25.0.4", "mac_address": "E2:0D:DC:D7:11:0C"}] ["7a9926dc-e2a3-4286-9a7c-4f20a2f35b9d", "6b03ba8d-1716-46b2-8a48-10963a8db3bd"] [{"id": "6794d133-6c59-459b-8c90-e169929212f6", "type": "Custom", "number": 60073, "protocol": "Tcp"}] {"type": "Discovery", "metadata": [{"date": "2025-11-19T18:10:00.071231303Z", "type": "Network", "daemon_id": "887ae705-1262-4e8c-af47-08fe31eba9d9", "subnet_ids": null, "host_naming_fallback": "BestService"}, {"date": "2025-11-19T18:09:57.946606781Z", "type": "SelfReport", "host_id": "9ea137d0-3ee2-40d2-9d75-bf12697689a5", "daemon_id": "887ae705-1262-4e8c-af47-08fe31eba9d9"}]} null 2025-11-19 18:09:57.850784+00 2025-11-19 18:10:00.0784+00 f -0b9266bf-ecb7-49a0-8407-ce6c7a6ee3ce 61b521cb-415f-4067-9a9d-eccfc3d3c38b runnervmg1sw1 runnervmg1sw1 \N {"type": "Hostname"} [{"id": "d7557d1a-9d49-4bd2-8388-d8737e54b522", "name": null, "subnet_id": "d3c4eb6c-bf62-4299-8f8f-4c1a0c44a340", "ip_address": "172.25.0.1", "mac_address": "3E:0C:A5:C4:F8:9E"}] ["af77c50b-bc33-4faf-8d23-c93ec0773b2d", "998b4a83-1a2b-4ced-9324-416ca32c8c2c"] [{"id": "00152464-ff69-46d7-bfd4-16c0de01dfc5", "type": "Custom", "number": 8123, "protocol": "Tcp"}, {"id": "3fc07f84-a587-4d9d-86b8-79a9de33ac31", "type": "Custom", "number": 60072, "protocol": "Tcp"}, {"id": "c1357b5d-12d4-402e-9646-9d24d230dd10", "type": "Ssh", "number": 22, "protocol": "Tcp"}] {"type": "Discovery", "metadata": [{"date": "2025-11-19T18:10:36.737214837Z", "type": "Network", "daemon_id": "887ae705-1262-4e8c-af47-08fe31eba9d9", "subnet_ids": null, "host_naming_fallback": "BestService"}]} null 2025-11-19 18:10:36.737217+00 2025-11-19 18:10:50.665517+00 f +2e7ca6ba-343e-46bf-b70f-7fb3e0b421dc a1be6f02-adbd-47b0-9705-f7cd5e5725c9 Cloudflare DNS \N \N {"type": "ServiceBinding", "config": "b4da9cfe-0e9d-4428-b0a0-c6bcf3901a45"} [{"id": "1b67d353-09f5-4c3d-a3ea-ccb3fec9df97", "name": "Internet", "subnet_id": "c2ac5b0a-65e2-478c-8c1f-7308986e63f4", "ip_address": "1.1.1.1", "mac_address": null}] ["0acb2c51-b646-49f2-9f27-a102b7df07c1"] [{"id": "ec05fe6c-733d-4237-98fc-105297c723e3", "type": "DnsUdp", "number": 53, "protocol": "Udp"}] {"type": "System"} null 2025-11-19 21:09:06.76319+00 2025-11-19 21:09:06.772253+00 f +6c87b2ec-6cf3-42a5-9d99-df1cae523800 a1be6f02-adbd-47b0-9705-f7cd5e5725c9 Google.com \N \N {"type": "ServiceBinding", "config": "f2e65830-b346-4325-843a-5d9d8b9e0acc"} [{"id": "26c46456-73bd-4112-8662-586fd3302492", "name": "Internet", "subnet_id": "c2ac5b0a-65e2-478c-8c1f-7308986e63f4", "ip_address": "203.0.113.73", "mac_address": null}] ["6e97e905-7fe0-40e2-b496-be8fa0366566"] [{"id": "2371c794-2b9f-414b-83f8-ce1415923854", "type": "Https", "number": 443, "protocol": "Tcp"}] {"type": "System"} null 2025-11-19 21:09:06.763197+00 2025-11-19 21:09:06.777361+00 f +d8f0cb6a-c469-49a3-ae2a-adcecde00143 a1be6f02-adbd-47b0-9705-f7cd5e5725c9 Mobile Device \N A mobile device connecting from a remote network {"type": "ServiceBinding", "config": "fc48e41c-cf81-45eb-aacf-a88b96c77e21"} [{"id": "7a0fd0d3-aec3-4e0f-977e-b0dbd7bd7ecf", "name": "Remote Network", "subnet_id": "04f685ce-3e1d-4c68-8d17-c9cf2d2169c7", "ip_address": "203.0.113.29", "mac_address": null}] ["1587038c-6ec3-4c24-91d4-19975ff5e5d8"] [{"id": "76d38981-a2d7-4a34-95dc-b74326b6300c", "type": "Custom", "number": 0, "protocol": "Tcp"}] {"type": "System"} null 2025-11-19 21:09:06.763202+00 2025-11-19 21:09:06.781135+00 f +7071d28a-2b9b-402f-ad86-d6a4a9265a4b a1be6f02-adbd-47b0-9705-f7cd5e5725c9 172.25.0.4 3c94e804101f NetVisor daemon {"type": "None"} [{"id": "530b4938-6e8e-4711-a6c1-9c7c5df56e98", "name": "eth0", "subnet_id": "4681595e-cadc-497a-a83a-1c647ab02251", "ip_address": "172.25.0.4", "mac_address": "46:C4:3E:B6:8B:7A"}] ["2cbb1e05-ecf3-4e23-a498-30c9da55fdfa", "6aa3f722-9b35-4f37-a0d3-8887a625d729"] [{"id": "ec053ab0-d2bb-4057-a0ef-69be6144c628", "type": "Custom", "number": 60073, "protocol": "Tcp"}] {"type": "Discovery", "metadata": [{"date": "2025-11-19T21:09:23.471315339Z", "type": "Network", "daemon_id": "cb822b21-406f-4ec8-8a2d-293086c05e79", "subnet_ids": null, "host_naming_fallback": "BestService"}, {"date": "2025-11-19T21:09:06.854514719Z", "type": "SelfReport", "host_id": "7071d28a-2b9b-402f-ad86-d6a4a9265a4b", "daemon_id": "cb822b21-406f-4ec8-8a2d-293086c05e79"}]} null 2025-11-19 21:09:06.789924+00 2025-11-19 21:09:23.481533+00 f +543badba-6922-4ea4-98c5-21208b514387 a1be6f02-adbd-47b0-9705-f7cd5e5725c9 netvisor-postgres-dev-1.netvisor_netvisor-dev netvisor-postgres-dev-1.netvisor_netvisor-dev \N {"type": "Hostname"} [{"id": "577f31fd-15a9-4a05-8298-84a8468745d2", "name": null, "subnet_id": "4681595e-cadc-497a-a83a-1c647ab02251", "ip_address": "172.25.0.6", "mac_address": "D6:27:FB:87:17:40"}] ["6f54be6a-a301-4eda-a58a-0ceac0ab3e94"] [{"id": "cf7c722a-436a-42bf-be1f-91cae1fd5217", "type": "PostgreSQL", "number": 5432, "protocol": "Tcp"}] {"type": "Discovery", "metadata": [{"date": "2025-11-19T21:09:23.473082669Z", "type": "Network", "daemon_id": "cb822b21-406f-4ec8-8a2d-293086c05e79", "subnet_ids": null, "host_naming_fallback": "BestService"}]} null 2025-11-19 21:09:23.473083+00 2025-11-19 21:09:51.788838+00 f +7506acfb-0415-4a4d-9363-ae0116d8b175 a1be6f02-adbd-47b0-9705-f7cd5e5725c9 netvisor-server-1.netvisor_netvisor-dev netvisor-server-1.netvisor_netvisor-dev \N {"type": "Hostname"} [{"id": "293732f5-baaa-45c0-aa79-3a740fe2e264", "name": null, "subnet_id": "4681595e-cadc-497a-a83a-1c647ab02251", "ip_address": "172.25.0.3", "mac_address": "8E:A6:A8:1A:45:CA"}] ["b74e2272-e484-4418-b674-46eaecf34371"] [{"id": "8af0eed6-31de-4365-843f-832ca8abd93a", "type": "Custom", "number": 60072, "protocol": "Tcp"}] {"type": "Discovery", "metadata": [{"date": "2025-11-19T21:09:09.028627353Z", "type": "Network", "daemon_id": "cb822b21-406f-4ec8-8a2d-293086c05e79", "subnet_ids": null, "host_naming_fallback": "BestService"}]} null 2025-11-19 21:09:09.02863+00 2025-11-19 21:09:23.253872+00 f +0f630d59-fd47-47e6-964b-eb8389fe09c6 a1be6f02-adbd-47b0-9705-f7cd5e5725c9 homeassistant-discovery.netvisor_netvisor-dev homeassistant-discovery.netvisor_netvisor-dev \N {"type": "Hostname"} [{"id": "097c6486-7e89-4f9d-a9ec-74dec118e021", "name": null, "subnet_id": "4681595e-cadc-497a-a83a-1c647ab02251", "ip_address": "172.25.0.5", "mac_address": "EE:ED:7C:AD:81:C9"}] ["08875b2e-8288-4baa-8b3e-c6f505615ae5"] [{"id": "6622c748-f93b-418b-9039-a80d0a53a9c6", "type": "Custom", "number": 8123, "protocol": "Tcp"}] {"type": "Discovery", "metadata": [{"date": "2025-11-19T21:09:37.636535299Z", "type": "Network", "daemon_id": "cb822b21-406f-4ec8-8a2d-293086c05e79", "subnet_ids": null, "host_naming_fallback": "BestService"}]} null 2025-11-19 21:09:37.636536+00 2025-11-19 21:09:51.897038+00 f +c8b1a2ad-fe1f-4af1-bf31-54c611e3d74e a1be6f02-adbd-47b0-9705-f7cd5e5725c9 runnervmg1sw1 runnervmg1sw1 \N {"type": "Hostname"} [{"id": "36f836f6-f247-40d1-bb71-c5030c8c1886", "name": null, "subnet_id": "4681595e-cadc-497a-a83a-1c647ab02251", "ip_address": "172.25.0.1", "mac_address": "A2:64:C5:C2:2E:5A"}] ["3d74baf2-41c2-4579-909a-b254f56ea389", "7a9d926a-b894-463d-a015-e43b8458cc79"] [{"id": "a73f14b4-5171-4e95-92bb-2e15d6e400d1", "type": "Custom", "number": 8123, "protocol": "Tcp"}, {"id": "99ac1f5d-332b-4dff-8a31-8f54844dc78b", "type": "Custom", "number": 60072, "protocol": "Tcp"}, {"id": "cbbc8d11-8797-4bd5-8453-9480fdbd49f3", "type": "Ssh", "number": 22, "protocol": "Tcp"}] {"type": "Discovery", "metadata": [{"date": "2025-11-19T21:09:59.940443170Z", "type": "Network", "daemon_id": "cb822b21-406f-4ec8-8a2d-293086c05e79", "subnet_ids": null, "host_naming_fallback": "BestService"}]} null 2025-11-19 21:09:59.940445+00 2025-11-19 21:10:14.072064+00 f \. @@ -427,7 +428,7 @@ d0bb5250-767e-4dc7-88ae-c9095b54203e 61b521cb-415f-4067-9a9d-eccfc3d3c38b Cloudf -- COPY public.networks (id, name, created_at, updated_at, is_default, organization_id) FROM stdin; -61b521cb-415f-4067-9a9d-eccfc3d3c38b My Network 2025-11-19 18:09:57.82488+00 2025-11-19 18:09:57.82488+00 f c379f097-a967-4d9a-8050-020258ac5899 +a1be6f02-adbd-47b0-9705-f7cd5e5725c9 My Network 2025-11-19 21:09:06.761859+00 2025-11-19 21:09:06.761859+00 f 25d71b1c-d081-461f-ac3a-b778e5dd2fee \. @@ -436,7 +437,7 @@ COPY public.networks (id, name, created_at, updated_at, is_default, organization -- COPY public.organizations (id, name, stripe_customer_id, plan, plan_status, created_at, updated_at, is_onboarded) FROM stdin; -c379f097-a967-4d9a-8050-020258ac5899 My Organization \N {"type": "Community", "price": {"rate": "Month", "cents": 0}, "trial_days": 0} null 2025-11-19 18:09:54.184605+00 2025-11-19 18:09:57.823371+00 t +25d71b1c-d081-461f-ac3a-b778e5dd2fee My Organization \N {"type": "Community", "price": {"rate": "Month", "cents": 0}, "trial_days": 0} null 2025-11-19 21:09:03.777406+00 2025-11-19 21:09:06.760308+00 t \. @@ -445,14 +446,15 @@ c379f097-a967-4d9a-8050-020258ac5899 My Organization \N {"type": "Community", "p -- COPY public.services (id, network_id, created_at, updated_at, name, host_id, bindings, service_definition, virtualization, source) FROM stdin; -11aad073-4c5e-4959-9174-d410f226156f 61b521cb-415f-4067-9a9d-eccfc3d3c38b 2025-11-19 18:09:57.826216+00 2025-11-19 18:09:57.826216+00 Cloudflare DNS d0bb5250-767e-4dc7-88ae-c9095b54203e [{"id": "40ee608a-deef-4d9a-beb4-3caaa606fce1", "type": "Port", "port_id": "f868e848-5015-4dbc-995e-1a169ee68809", "interface_id": "142b853f-a36a-4c4a-8a46-27281ddee5bf"}] "Dns Server" null {"type": "System"} -f7294413-4733-427b-a7f8-6150d7743d36 61b521cb-415f-4067-9a9d-eccfc3d3c38b 2025-11-19 18:09:57.826225+00 2025-11-19 18:09:57.826225+00 Google.com 783bcaf9-0f86-4c93-a9a5-73aed8152e53 [{"id": "c61d2d7f-bfc4-4ecb-b96d-8274777ed603", "type": "Port", "port_id": "bebb25b3-49bb-45cc-b577-2173ad35695b", "interface_id": "000217e1-59e6-461d-a4f0-0040cf9dd2df"}] "Web Service" null {"type": "System"} -b2f10d32-a6db-4f96-92ae-f0eddbadc99d 61b521cb-415f-4067-9a9d-eccfc3d3c38b 2025-11-19 18:09:57.826232+00 2025-11-19 18:09:57.826232+00 Mobile Device 01c2d4f7-4dbf-4e17-84e8-ac898b65ac8f [{"id": "715adaf4-b3e5-4005-8b13-3ac9c1b208fd", "type": "Port", "port_id": "b369f9a4-b9cb-4961-8f4f-626c5db1fe1d", "interface_id": "2daaaf54-66ba-41c4-8f86-ccf3fdbe4f8e"}] "Client" null {"type": "System"} -7a9926dc-e2a3-4286-9a7c-4f20a2f35b9d 61b521cb-415f-4067-9a9d-eccfc3d3c38b 2025-11-19 18:09:57.946623+00 2025-11-19 18:10:00.077424+00 NetVisor Daemon API 9ea137d0-3ee2-40d2-9d75-bf12697689a5 [{"id": "ea1a68e9-801b-489e-9571-b26390060e96", "type": "Port", "port_id": "6794d133-6c59-459b-8c90-e169929212f6", "interface_id": "b964011d-d5bf-45fa-9a64-3a2d0a7f2d49"}] "NetVisor Daemon API" null {"type": "DiscoveryWithMatch", "details": {"reason": {"data": "NetVisor Daemon self-report", "type": "reason"}, "confidence": "Certain"}, "metadata": [{"date": "2025-11-19T18:10:00.071843802Z", "type": "Network", "daemon_id": "887ae705-1262-4e8c-af47-08fe31eba9d9", "subnet_ids": null, "host_naming_fallback": "BestService"}, {"date": "2025-11-19T18:09:57.946622481Z", "type": "SelfReport", "host_id": "9ea137d0-3ee2-40d2-9d75-bf12697689a5", "daemon_id": "887ae705-1262-4e8c-af47-08fe31eba9d9"}]} -9ac07a7c-26ee-4556-9470-3fb819a8cb6a 61b521cb-415f-4067-9a9d-eccfc3d3c38b 2025-11-19 18:10:07.94262+00 2025-11-19 18:10:07.94262+00 NetVisor Server API 19be1245-29c0-49a1-9826-744962d53b13 [{"id": "2a5bfbde-f79c-41a0-acb3-4787da50de51", "type": "Port", "port_id": "adb2d1d2-acd4-418d-8fde-b32b67b6c20a", "interface_id": "461342db-91ec-4258-9cfa-d62310c5afc0"}] "NetVisor Server API" null {"type": "DiscoveryWithMatch", "details": {"reason": {"data": "Response for 172.25.0.3:60072/api/health contained \\"netvisor\\" in body", "type": "reason"}, "confidence": "High"}, "metadata": [{"date": "2025-11-19T18:10:07.942613021Z", "type": "Network", "daemon_id": "887ae705-1262-4e8c-af47-08fe31eba9d9", "subnet_ids": null, "host_naming_fallback": "BestService"}]} -23706bbb-bd9f-4f3e-bde8-ec40c04d017c 61b521cb-415f-4067-9a9d-eccfc3d3c38b 2025-11-19 18:10:28.580996+00 2025-11-19 18:10:28.580996+00 PostgreSQL 26c2861f-c3a3-45c7-8e72-287795e7be96 [{"id": "bde0065c-40a1-4ad2-b310-0bcca565fa68", "type": "Port", "port_id": "1d2b2ec9-b302-4433-a6ba-9f1353904c86", "interface_id": "90702814-e01d-430e-9e67-35ec7cb0f2c3"}] "PostgreSQL" null {"type": "DiscoveryWithMatch", "details": {"reason": {"data": ["Generic service", [{"data": "Port 5432/tcp is open but is used in other service match patterns", "type": "reason"}]], "type": "container"}, "confidence": "NotApplicable"}, "metadata": [{"date": "2025-11-19T18:10:28.580986289Z", "type": "Network", "daemon_id": "887ae705-1262-4e8c-af47-08fe31eba9d9", "subnet_ids": null, "host_naming_fallback": "BestService"}]} -af77c50b-bc33-4faf-8d23-c93ec0773b2d 61b521cb-415f-4067-9a9d-eccfc3d3c38b 2025-11-19 18:10:42.330668+00 2025-11-19 18:10:42.330668+00 Home Assistant 0b9266bf-ecb7-49a0-8407-ce6c7a6ee3ce [{"id": "f3ac1b5d-ec99-41e7-aa76-e8d32f65bbb3", "type": "Port", "port_id": "00152464-ff69-46d7-bfd4-16c0de01dfc5", "interface_id": "d7557d1a-9d49-4bd2-8388-d8737e54b522"}] "Home Assistant" null {"type": "DiscoveryWithMatch", "details": {"reason": {"data": "Response for 172.25.0.1:8123/ contained \\"home assistant\\" in body", "type": "reason"}, "confidence": "High"}, "metadata": [{"date": "2025-11-19T18:10:42.330656434Z", "type": "Network", "daemon_id": "887ae705-1262-4e8c-af47-08fe31eba9d9", "subnet_ids": null, "host_naming_fallback": "BestService"}]} -998b4a83-1a2b-4ced-9324-416ca32c8c2c 61b521cb-415f-4067-9a9d-eccfc3d3c38b 2025-11-19 18:10:44.405631+00 2025-11-19 18:10:44.405631+00 NetVisor Server API 0b9266bf-ecb7-49a0-8407-ce6c7a6ee3ce [{"id": "1d8605ec-586e-4423-8b7c-020b816d7129", "type": "Port", "port_id": "3fc07f84-a587-4d9d-86b8-79a9de33ac31", "interface_id": "d7557d1a-9d49-4bd2-8388-d8737e54b522"}] "NetVisor Server API" null {"type": "DiscoveryWithMatch", "details": {"reason": {"data": "Response for 172.25.0.1:60072/api/health contained \\"netvisor\\" in body", "type": "reason"}, "confidence": "High"}, "metadata": [{"date": "2025-11-19T18:10:44.405621740Z", "type": "Network", "daemon_id": "887ae705-1262-4e8c-af47-08fe31eba9d9", "subnet_ids": null, "host_naming_fallback": "BestService"}]} +0acb2c51-b646-49f2-9f27-a102b7df07c1 a1be6f02-adbd-47b0-9705-f7cd5e5725c9 2025-11-19 21:09:06.763192+00 2025-11-19 21:09:06.763192+00 Cloudflare DNS 2e7ca6ba-343e-46bf-b70f-7fb3e0b421dc [{"id": "b4da9cfe-0e9d-4428-b0a0-c6bcf3901a45", "type": "Port", "port_id": "ec05fe6c-733d-4237-98fc-105297c723e3", "interface_id": "1b67d353-09f5-4c3d-a3ea-ccb3fec9df97"}] "Dns Server" null {"type": "System"} +6e97e905-7fe0-40e2-b496-be8fa0366566 a1be6f02-adbd-47b0-9705-f7cd5e5725c9 2025-11-19 21:09:06.763198+00 2025-11-19 21:09:06.763198+00 Google.com 6c87b2ec-6cf3-42a5-9d99-df1cae523800 [{"id": "f2e65830-b346-4325-843a-5d9d8b9e0acc", "type": "Port", "port_id": "2371c794-2b9f-414b-83f8-ce1415923854", "interface_id": "26c46456-73bd-4112-8662-586fd3302492"}] "Web Service" null {"type": "System"} +1587038c-6ec3-4c24-91d4-19975ff5e5d8 a1be6f02-adbd-47b0-9705-f7cd5e5725c9 2025-11-19 21:09:06.763203+00 2025-11-19 21:09:06.763203+00 Mobile Device d8f0cb6a-c469-49a3-ae2a-adcecde00143 [{"id": "fc48e41c-cf81-45eb-aacf-a88b96c77e21", "type": "Port", "port_id": "76d38981-a2d7-4a34-95dc-b74326b6300c", "interface_id": "7a0fd0d3-aec3-4e0f-977e-b0dbd7bd7ecf"}] "Client" null {"type": "System"} +b74e2272-e484-4418-b674-46eaecf34371 a1be6f02-adbd-47b0-9705-f7cd5e5725c9 2025-11-19 21:09:16.881428+00 2025-11-19 21:09:16.881428+00 NetVisor Server API 7506acfb-0415-4a4d-9363-ae0116d8b175 [{"id": "ea2e7c12-599d-4cc8-b7f7-b7e763b75c09", "type": "Port", "port_id": "8af0eed6-31de-4365-843f-832ca8abd93a", "interface_id": "293732f5-baaa-45c0-aa79-3a740fe2e264"}] "NetVisor Server API" null {"type": "DiscoveryWithMatch", "details": {"reason": {"data": "Response for 172.25.0.3:60072/api/health contained \\"netvisor\\" in body", "type": "reason"}, "confidence": "High"}, "metadata": [{"date": "2025-11-19T21:09:16.881410337Z", "type": "Network", "daemon_id": "cb822b21-406f-4ec8-8a2d-293086c05e79", "subnet_ids": null, "host_naming_fallback": "BestService"}]} +2cbb1e05-ecf3-4e23-a498-30c9da55fdfa a1be6f02-adbd-47b0-9705-f7cd5e5725c9 2025-11-19 21:09:06.85453+00 2025-11-19 21:09:23.479594+00 NetVisor Daemon API 7071d28a-2b9b-402f-ad86-d6a4a9265a4b [{"id": "bb6337ce-014e-4fa3-a937-b5bbb82eea0f", "type": "Port", "port_id": "ec053ab0-d2bb-4057-a0ef-69be6144c628", "interface_id": "530b4938-6e8e-4711-a6c1-9c7c5df56e98"}] "NetVisor Daemon API" null {"type": "DiscoveryWithMatch", "details": {"reason": {"data": "NetVisor Daemon self-report", "type": "reason"}, "confidence": "Certain"}, "metadata": [{"date": "2025-11-19T21:09:23.472006385Z", "type": "Network", "daemon_id": "cb822b21-406f-4ec8-8a2d-293086c05e79", "subnet_ids": null, "host_naming_fallback": "BestService"}, {"date": "2025-11-19T21:09:06.854529778Z", "type": "SelfReport", "host_id": "7071d28a-2b9b-402f-ad86-d6a4a9265a4b", "daemon_id": "cb822b21-406f-4ec8-8a2d-293086c05e79"}]} +6f54be6a-a301-4eda-a58a-0ceac0ab3e94 a1be6f02-adbd-47b0-9705-f7cd5e5725c9 2025-11-19 21:09:37.635718+00 2025-11-19 21:09:37.635718+00 PostgreSQL 543badba-6922-4ea4-98c5-21208b514387 [{"id": "a21c6a3e-a686-48fe-8e92-c920091105d3", "type": "Port", "port_id": "cf7c722a-436a-42bf-be1f-91cae1fd5217", "interface_id": "577f31fd-15a9-4a05-8298-84a8468745d2"}] "PostgreSQL" null {"type": "DiscoveryWithMatch", "details": {"reason": {"data": ["Generic service", [{"data": "Port 5432/tcp is open but is used in other service match patterns", "type": "reason"}]], "type": "container"}, "confidence": "NotApplicable"}, "metadata": [{"date": "2025-11-19T21:09:37.635710874Z", "type": "Network", "daemon_id": "cb822b21-406f-4ec8-8a2d-293086c05e79", "subnet_ids": null, "host_naming_fallback": "BestService"}]} +08875b2e-8288-4baa-8b3e-c6f505615ae5 a1be6f02-adbd-47b0-9705-f7cd5e5725c9 2025-11-19 21:09:43.317768+00 2025-11-19 21:09:43.317768+00 Home Assistant 0f630d59-fd47-47e6-964b-eb8389fe09c6 [{"id": "940dd28c-cfd9-49ad-b8c7-9c8acc33bf33", "type": "Port", "port_id": "6622c748-f93b-418b-9039-a80d0a53a9c6", "interface_id": "097c6486-7e89-4f9d-a9ec-74dec118e021"}] "Home Assistant" null {"type": "DiscoveryWithMatch", "details": {"reason": {"data": "Response for 172.25.0.5:8123/ contained \\"home assistant\\" in body", "type": "reason"}, "confidence": "High"}, "metadata": [{"date": "2025-11-19T21:09:43.317758108Z", "type": "Network", "daemon_id": "cb822b21-406f-4ec8-8a2d-293086c05e79", "subnet_ids": null, "host_naming_fallback": "BestService"}]} +7a9d926a-b894-463d-a015-e43b8458cc79 a1be6f02-adbd-47b0-9705-f7cd5e5725c9 2025-11-19 21:10:07.729116+00 2025-11-19 21:10:07.729116+00 NetVisor Server API c8b1a2ad-fe1f-4af1-bf31-54c611e3d74e [{"id": "c1f9c4d8-9c3c-47da-abbf-0197b6ec3a95", "type": "Port", "port_id": "99ac1f5d-332b-4dff-8a31-8f54844dc78b", "interface_id": "36f836f6-f247-40d1-bb71-c5030c8c1886"}] "NetVisor Server API" null {"type": "DiscoveryWithMatch", "details": {"reason": {"data": "Response for 172.25.0.1:60072/api/health contained \\"netvisor\\" in body", "type": "reason"}, "confidence": "High"}, "metadata": [{"date": "2025-11-19T21:10:07.729106741Z", "type": "Network", "daemon_id": "cb822b21-406f-4ec8-8a2d-293086c05e79", "subnet_ids": null, "host_naming_fallback": "BestService"}]} +3d74baf2-41c2-4579-909a-b254f56ea389 a1be6f02-adbd-47b0-9705-f7cd5e5725c9 2025-11-19 21:10:05.582832+00 2025-11-19 21:10:05.582832+00 Home Assistant c8b1a2ad-fe1f-4af1-bf31-54c611e3d74e [{"id": "102be572-b653-4d4d-ab91-bafc20e88b63", "type": "Port", "port_id": "a73f14b4-5171-4e95-92bb-2e15d6e400d1", "interface_id": "36f836f6-f247-40d1-bb71-c5030c8c1886"}] "Home Assistant" null {"type": "DiscoveryWithMatch", "details": {"reason": {"data": "Response for 172.25.0.1:8123/ contained \\"home assistant\\" in body", "type": "reason"}, "confidence": "High"}, "metadata": [{"date": "2025-11-19T21:10:05.582822345Z", "type": "Network", "daemon_id": "cb822b21-406f-4ec8-8a2d-293086c05e79", "subnet_ids": null, "host_naming_fallback": "BestService"}]} \. @@ -461,9 +463,9 @@ af77c50b-bc33-4faf-8d23-c93ec0773b2d 61b521cb-415f-4067-9a9d-eccfc3d3c38b 2025-1 -- COPY public.subnets (id, network_id, created_at, updated_at, cidr, name, description, subnet_type, source) FROM stdin; -6eb9bbef-c490-4b4e-adf0-adbb0b48f20f 61b521cb-415f-4067-9a9d-eccfc3d3c38b 2025-11-19 18:09:57.826162+00 2025-11-19 18:09:57.826162+00 "0.0.0.0/0" Internet This subnet uses the 0.0.0.0/0 CIDR as an organizational container for services running on the internet (e.g., public DNS servers, cloud services, etc.). "Internet" {"type": "System"} -1bf05386-4748-4352-8260-85b9c9daa773 61b521cb-415f-4067-9a9d-eccfc3d3c38b 2025-11-19 18:09:57.826166+00 2025-11-19 18:09:57.826166+00 "0.0.0.0/0" Remote Network This subnet uses the 0.0.0.0/0 CIDR as an organizational container for hosts on remote networks (e.g., mobile connections, friend's networks, public WiFi, etc.). "Remote" {"type": "System"} -d3c4eb6c-bf62-4299-8f8f-4c1a0c44a340 61b521cb-415f-4067-9a9d-eccfc3d3c38b 2025-11-19 18:09:57.900631+00 2025-11-19 18:09:57.900631+00 "172.25.0.0/28" 172.25.0.0/28 \N "Lan" {"type": "Discovery", "metadata": [{"date": "2025-11-19T18:09:57.900630135Z", "type": "SelfReport", "host_id": "9ea137d0-3ee2-40d2-9d75-bf12697689a5", "daemon_id": "887ae705-1262-4e8c-af47-08fe31eba9d9"}]} +c2ac5b0a-65e2-478c-8c1f-7308986e63f4 a1be6f02-adbd-47b0-9705-f7cd5e5725c9 2025-11-19 21:09:06.763099+00 2025-11-19 21:09:06.763099+00 "0.0.0.0/0" Internet This subnet uses the 0.0.0.0/0 CIDR as an organizational container for services running on the internet (e.g., public DNS servers, cloud services, etc.). "Internet" {"type": "System"} +04f685ce-3e1d-4c68-8d17-c9cf2d2169c7 a1be6f02-adbd-47b0-9705-f7cd5e5725c9 2025-11-19 21:09:06.763104+00 2025-11-19 21:09:06.763104+00 "0.0.0.0/0" Remote Network This subnet uses the 0.0.0.0/0 CIDR as an organizational container for hosts on remote networks (e.g., mobile connections, friend's networks, public WiFi, etc.). "Remote" {"type": "System"} +4681595e-cadc-497a-a83a-1c647ab02251 a1be6f02-adbd-47b0-9705-f7cd5e5725c9 2025-11-19 21:09:06.842707+00 2025-11-19 21:09:06.842707+00 "172.25.0.0/28" 172.25.0.0/28 \N "Lan" {"type": "Discovery", "metadata": [{"date": "2025-11-19T21:09:06.842706565Z", "type": "SelfReport", "host_id": "7071d28a-2b9b-402f-ad86-d6a4a9265a4b", "daemon_id": "cb822b21-406f-4ec8-8a2d-293086c05e79"}]} \. @@ -472,7 +474,7 @@ d3c4eb6c-bf62-4299-8f8f-4c1a0c44a340 61b521cb-415f-4067-9a9d-eccfc3d3c38b 2025-1 -- COPY public.users (id, created_at, updated_at, password_hash, oidc_provider, oidc_subject, oidc_linked_at, email, organization_id, permissions) FROM stdin; -0453c0db-81ca-47cc-b498-6bbf0aee617d 2025-11-19 18:09:54.186464+00 2025-11-19 18:09:57.812318+00 $argon2id$v=19$m=19456,t=2,p=1$WrqgKWj4pAwpZKyJ57YxZQ$Q/TPXWvkgUjasHZhsxryNLxJ/DkaxUZ8rC+CjATFpbY \N \N \N user@example.com c379f097-a967-4d9a-8050-020258ac5899 Owner +d6b2831f-c70a-413b-bdcd-6668a84f3ebd 2025-11-19 21:09:03.779243+00 2025-11-19 21:09:06.747737+00 $argon2id$v=19$m=19456,t=2,p=1$kQlWmFddRyF8zN4G2E9x/Q$0gbsFGzfHnVCbpkujAlf2sjXwWo6kJQEavvwxHDfgAQ \N \N \N user@example.com 25d71b1c-d081-461f-ac3a-b778e5dd2fee Owner \. @@ -481,7 +483,7 @@ COPY public.users (id, created_at, updated_at, password_hash, oidc_provider, oid -- COPY tower_sessions.session (id, data, expiry_date) FROM stdin; -1rdFiyAMLLw9RDDiSvWODA \\x93c4100c8ef54ae230443dbc2c0c208b45b7d681a7757365725f6964d92430343533633064622d383163612d343763632d623439382d36626266306165653631376499cd07e9cd0161120939ce30825520000000 2025-12-19 18:09:57.813847+00 +d7QvXa8gZ0k6TWMhRR7vrg \\x93c410aeef1e4521634d3a496720af5d2fb47781a7757365725f6964d92464366232383331662d633730612d343133622d626463642d36363638613834663365626499cd07e9cd0161150906ce2cacac90000000 2025-12-19 21:09:06.749513+00 \. @@ -793,5 +795,5 @@ ALTER TABLE ONLY public.users -- PostgreSQL database dump complete -- -\unrestrict eBSnQ02bTJJqBWf84vAslrtSFRhVu7kiaRFTHpaqJGK3AZClXEDcKsrVWzI35Hs +\unrestrict MyKRMO5qap9sn56g55pvnii2xqIH9zo3armcSKlGtj5TsP9o5IJXJbz4ZIjUXeN From 9029f6e48a8f35d5944cd0a467306860aca71bd0 Mon Sep 17 00:00:00 2001 From: Maya Date: Wed, 19 Nov 2025 18:24:22 -0500 Subject: [PATCH 22/27] fix: brower cache headers expire/removed --- backend/Cargo.toml | 2 +- backend/src/bin/server.rs | 9 ++++++++- backend/src/server/shared/handlers/factory.rs | 17 ++++++++++++++--- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 1af1d853..4b38dd81 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -36,7 +36,7 @@ incremental = true # Enable incremental compilation # === Web Server Framework === axum = "0.8.6" tower = "0.4.13" -tower-http = { version = "0.5", features = ["fs", "cors", "trace"] } +tower-http = { version = "0.5", features = ["fs", "cors", "trace", "set-header"] } tokio = { version = "1.0", features = ["rt-multi-thread", "macros", "net", "time", "fs", "signal", "process"] } # === Database === diff --git a/backend/src/bin/server.rs b/backend/src/bin/server.rs index db87a9ac..25ad8900 100644 --- a/backend/src/bin/server.rs +++ b/backend/src/bin/server.rs @@ -22,6 +22,7 @@ use tower::ServiceBuilder; use tower_http::{ cors::CorsLayer, services::{ServeDir, ServeFile}, + set_header::SetResponseHeaderLayer, trace::TraceLayer, }; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; @@ -246,6 +247,11 @@ async fn main() -> anyhow::Result<()> { CorsLayer::permissive() }; + let cache_headers = SetResponseHeaderLayer::if_not_present( + header::CACHE_CONTROL, + HeaderValue::from_static("no-store, no-cache, must-revalidate, private"), + ); + let app_cache = Arc::new(AppCache::new()); // Create main app @@ -253,7 +259,8 @@ async fn main() -> anyhow::Result<()> { ServiceBuilder::new() .layer(TraceLayer::new_for_http()) .layer(cors) - .layer(Extension(app_cache)), + .layer(Extension(app_cache)) + .layer(cache_headers), ); let listener = tokio::net::TcpListener::bind(&listen_addr).await?; diff --git a/backend/src/server/shared/handlers/factory.rs b/backend/src/server/shared/handlers/factory.rs index 66138598..a1a9a66a 100644 --- a/backend/src/server/shared/handlers/factory.rs +++ b/backend/src/server/shared/handlers/factory.rs @@ -31,11 +31,14 @@ use crate::server::{ }; use anyhow::anyhow; use axum::extract::State; +use axum::http::HeaderValue; use axum::routing::post; use axum::{Json, Router, routing::get}; +use reqwest::header; use serde::{Deserialize, Serialize}; use std::sync::Arc; use strum::{IntoDiscriminant, IntoEnumIterator}; +use tower_http::set_header::SetResponseHeaderLayer; pub fn create_router() -> Router> { Router::new() @@ -52,10 +55,18 @@ pub fn create_router() -> Router> { .nest("/api/auth", auth_handlers::create_router()) .nest("/api/organizations", organization_handlers::create_router()) .route("/api/health", get(get_health)) - .route("/api/metadata", get(get_metadata_registry)) - .route("/api/config", get(get_public_config)) - .route("/api/github-stars", get(get_stars)) .route("/api/onboarding", post(onboarding)) + // Group cacheable routes together + .merge( + Router::new() + .route("/api/metadata", get(get_metadata_registry)) + .route("/api/config", get(get_public_config)) + .route("/api/github-stars", get(get_stars)) + .layer(SetResponseHeaderLayer::if_not_present( + header::CACHE_CONTROL, + HeaderValue::from_static("max-age=3600, must-revalidate"), + )), + ) } async fn get_metadata_registry(_user: AuthenticatedUser) -> Json> { From affc8e5ddb2cd76457d5f7d0074c1c2d1075d363 Mon Sep 17 00:00:00 2001 From: Maya Date: Fri, 21 Nov 2025 14:33:11 -0500 Subject: [PATCH 23/27] feat: bulk delete --- backend/src/server/api_keys/handlers.rs | 5 +- backend/src/server/daemons/handlers.rs | 4 +- backend/src/server/discovery/handlers.rs | 4 +- backend/src/server/groups/handlers.rs | 4 +- backend/src/server/hosts/handlers.rs | 5 +- backend/src/server/networks/handlers.rs | 3 +- backend/src/server/services/handlers.rs | 4 +- backend/src/server/shared/handlers/traits.rs | 47 +++++++ backend/src/server/shared/services/traits.rs | 37 ++++++ backend/src/server/shared/storage/generic.rs | 25 ++++ backend/src/server/shared/storage/traits.rs | 1 + backend/src/server/subnets/handlers.rs | 3 +- backend/src/server/users/handlers.rs | 7 +- .../api_keys/components/ApiKeyCard.svelte | 4 +- .../api_keys/components/ApiKeyTab.svelte | 25 +++- ui/src/lib/features/api_keys/store.ts | 11 ++ .../daemons/components/DaemonCard.svelte | 4 +- .../daemons/components/DaemonTab.svelte | 36 ++++- ui/src/lib/features/daemons/store.ts | 11 ++ .../cards/DiscoveryHistoryCard.svelte | 4 +- .../cards/DiscoveryScheduledCard.svelte | 4 +- .../cards/DiscoverySessionCard.svelte | 2 +- .../tabs/DiscoveryHistoryTab.svelte | 24 +++- .../tabs/DiscoveryScheduledTab.svelte | 18 ++- .../tabs/DiscoverySessionTab.svelte | 1 + ui/src/lib/features/discovery/store.ts | 11 ++ .../groups/components/GroupCard.svelte | 4 +- .../groups/components/GroupTab.svelte | 32 ++++- ui/src/lib/features/groups/store.ts | 11 ++ .../features/hosts/components/HostCard.svelte | 8 +- .../features/hosts/components/HostTab.svelte | 48 +++++-- ui/src/lib/features/hosts/store.ts | 11 ++ .../networks/components/NetworkCard.svelte | 4 +- .../networks/components/NetworksTab.svelte | 18 ++- ui/src/lib/features/networks/store.ts | 11 ++ ui/src/lib/features/organizations/types.ts | 1 + .../services/components/ServiceCard.svelte | 4 +- .../services/components/ServiceTab.svelte | 18 ++- ui/src/lib/features/services/store.ts | 11 ++ .../subnets/components/SubnetCard.svelte | 4 +- .../subnets/components/SubnetTab.svelte | 32 ++++- ui/src/lib/features/subnets/store.ts | 11 ++ .../users/components/InviteCard.svelte | 2 +- .../features/users/components/UserCard.svelte | 14 +- .../features/users/components/UserTab.svelte | 32 ++++- ui/src/lib/features/users/store.ts | 11 ++ ui/src/lib/features/users/types.ts | 10 +- .../components/data/DataControls.svelte | 125 +++++++++++++++--- .../shared/components/data/GenericCard.svelte | 85 ++++++++---- 49 files changed, 700 insertions(+), 111 deletions(-) diff --git a/backend/src/server/api_keys/handlers.rs b/backend/src/server/api_keys/handlers.rs index ddb21cc3..b8bff851 100644 --- a/backend/src/server/api_keys/handlers.rs +++ b/backend/src/server/api_keys/handlers.rs @@ -3,7 +3,9 @@ use crate::server::{ auth::middleware::RequireMember, config::AppState, shared::{ - handlers::traits::{CrudHandlers, delete_handler, get_all_handler, get_by_id_handler}, + handlers::traits::{ + CrudHandlers, bulk_delete_handler, delete_handler, get_all_handler, get_by_id_handler, + }, services::traits::CrudService, types::api::{ApiError, ApiResponse, ApiResult}, }, @@ -24,6 +26,7 @@ pub fn create_router() -> Router> { .route("/{id}", put(update_handler)) .route("/{id}", delete(delete_handler::)) .route("/{id}", get(get_by_id_handler::)) + .route("/bulk-delete", post(bulk_delete_handler::)) } pub async fn create_handler( diff --git a/backend/src/server/daemons/handlers.rs b/backend/src/server/daemons/handlers.rs index 19c85d33..f25f5dd3 100644 --- a/backend/src/server/daemons/handlers.rs +++ b/backend/src/server/daemons/handlers.rs @@ -15,7 +15,8 @@ use crate::server::{ hosts::r#impl::base::{Host, HostBase}, shared::{ handlers::traits::{ - create_handler, delete_handler, get_all_handler, get_by_id_handler, update_handler, + bulk_delete_handler, create_handler, delete_handler, get_all_handler, + get_by_id_handler, update_handler, }, services::traits::CrudService, storage::traits::StorableEntity, @@ -39,6 +40,7 @@ pub fn create_router() -> Router> { .route("/{id}", put(update_handler::)) .route("/{id}", delete(delete_handler::)) .route("/{id}", get(get_by_id_handler::)) + .route("/bulk-delete", post(bulk_delete_handler::)) .route("/register", post(register_daemon)) .route("/{id}/heartbeat", post(receive_heartbeat)) .route("/{id}/update-capabilities", post(update_capabilities)) diff --git a/backend/src/server/discovery/handlers.rs b/backend/src/server/discovery/handlers.rs index 2c3971a1..5a7eb17f 100644 --- a/backend/src/server/discovery/handlers.rs +++ b/backend/src/server/discovery/handlers.rs @@ -5,7 +5,8 @@ use crate::server::{ discovery::r#impl::{base::Discovery, types::RunType}, shared::{ handlers::traits::{ - create_handler, delete_handler, get_all_handler, get_by_id_handler, update_handler, + bulk_delete_handler, create_handler, delete_handler, get_all_handler, + get_by_id_handler, update_handler, }, services::traits::CrudService, types::api::{ApiError, ApiResponse, ApiResult}, @@ -32,6 +33,7 @@ pub fn create_router() -> Router> { .route("/", get(get_all_handler::)) .route("/{id}", put(update_handler::)) .route("/{id}", delete(delete_handler::)) + .route("/bulk-delete", post(bulk_delete_handler::)) .route("/{id}", get(get_by_id_handler::)) .route("/start-session", post(start_session)) .route("/active-sessions", get(get_active_sessions)) diff --git a/backend/src/server/groups/handlers.rs b/backend/src/server/groups/handlers.rs index 65cffc2f..99670429 100644 --- a/backend/src/server/groups/handlers.rs +++ b/backend/src/server/groups/handlers.rs @@ -4,7 +4,8 @@ use axum::routing::{delete, get, post, put}; use crate::server::config::AppState; use crate::server::groups::r#impl::base::Group; use crate::server::shared::handlers::traits::{ - create_handler, delete_handler, get_all_handler, get_by_id_handler, update_handler, + bulk_delete_handler, create_handler, delete_handler, get_all_handler, get_by_id_handler, + update_handler, }; use std::sync::Arc; @@ -15,4 +16,5 @@ pub fn create_router() -> Router> { .route("/{id}", put(update_handler::)) .route("/{id}", delete(delete_handler::)) .route("/{id}", get(get_by_id_handler::)) + .route("/bulk-delete", post(bulk_delete_handler::)) } diff --git a/backend/src/server/hosts/handlers.rs b/backend/src/server/hosts/handlers.rs index e610a368..cae682ec 100644 --- a/backend/src/server/hosts/handlers.rs +++ b/backend/src/server/hosts/handlers.rs @@ -1,5 +1,7 @@ use crate::server::auth::middleware::{MemberOrDaemon, RequireMember}; -use crate::server::shared::handlers::traits::{CrudHandlers, get_all_handler, get_by_id_handler}; +use crate::server::shared::handlers::traits::{ + CrudHandlers, bulk_delete_handler, get_all_handler, get_by_id_handler, +}; use crate::server::shared::services::traits::CrudService; use crate::server::shared::storage::filter::EntityFilter; use crate::server::shared::storage::traits::StorableEntity; @@ -28,6 +30,7 @@ pub fn create_router() -> Router> { .route("/{id}", get(get_by_id_handler::)) .route("/", post(create_host)) .route("/{id}", put(update_host)) + .route("/bulk-delete", post(bulk_delete_handler::)) .route( "/{destination_host}/consolidate/{other_host}", put(consolidate_hosts), diff --git a/backend/src/server/networks/handlers.rs b/backend/src/server/networks/handlers.rs index 85989d0d..d217e178 100644 --- a/backend/src/server/networks/handlers.rs +++ b/backend/src/server/networks/handlers.rs @@ -1,6 +1,6 @@ use crate::server::auth::middleware::{AuthenticatedUser, RequireMember}; use crate::server::shared::handlers::traits::{ - CrudHandlers, delete_handler, get_by_id_handler, update_handler, + CrudHandlers, bulk_delete_handler, delete_handler, get_by_id_handler, update_handler, }; use crate::server::shared::types::api::ApiError; use crate::server::{ @@ -28,6 +28,7 @@ pub fn create_router() -> Router> { .route("/{id}", put(update_handler::)) .route("/{id}", delete(delete_handler::)) .route("/{id}", get(get_by_id_handler::)) + .route("/bulk-delete", post(bulk_delete_handler::)) } pub async fn create_handler( diff --git a/backend/src/server/services/handlers.rs b/backend/src/server/services/handlers.rs index bda27107..818820fc 100644 --- a/backend/src/server/services/handlers.rs +++ b/backend/src/server/services/handlers.rs @@ -1,5 +1,6 @@ use crate::server::shared::handlers::traits::{ - create_handler, delete_handler, get_all_handler, get_by_id_handler, update_handler, + bulk_delete_handler, create_handler, delete_handler, get_all_handler, get_by_id_handler, + update_handler, }; use crate::server::{config::AppState, services::r#impl::base::Service}; use axum::Router; @@ -13,4 +14,5 @@ pub fn create_router() -> Router> { .route("/{id}", put(update_handler::)) .route("/{id}", delete(delete_handler::)) .route("/{id}", get(get_by_id_handler::)) + .route("/bulk-delete", post(bulk_delete_handler::)) } diff --git a/backend/src/server/shared/handlers/traits.rs b/backend/src/server/shared/handlers/traits.rs index 10ebdf8e..8f1979df 100644 --- a/backend/src/server/shared/handlers/traits.rs +++ b/backend/src/server/shared/handlers/traits.rs @@ -53,6 +53,7 @@ where .route("/{id}", put(update_handler::)) .route("/{id}", delete(delete_handler::)) .route("/{id}", get(get_by_id_handler::)) + .route("/bulk-delete", post(bulk_delete_handler::)) } pub async fn create_handler( @@ -287,3 +288,49 @@ where Ok(Json(ApiResponse::success(()))) } + +pub async fn bulk_delete_handler( + State(state): State>, + RequireMember(user): RequireMember, + Json(ids): Json>, +) -> ApiResult>> +where + T: CrudHandlers + 'static, + Entity: From, +{ + if ids.is_empty() { + return Err(ApiError::bad_request("No IDs provided for bulk delete")); + } + + tracing::debug!( + entity_type = T::table_name(), + user_id = %user.user_id, + count = ids.len(), + "Bulk delete request received" + ); + + let service = T::get_service(&state); + let deleted_count = service + .delete_many(&ids, user.clone().into()) + .await + .map_err(|e| { + tracing::error!( + entity_type = T::table_name(), + user_id = %user.user_id, + error = %e, + "Failed to bulk delete entities" + ); + ApiError::internal_error(&e.to_string()) + })?; + + Ok(Json(ApiResponse::success(BulkDeleteResponse { + deleted_count, + requested_count: ids.len(), + }))) +} + +#[derive(Serialize)] +pub struct BulkDeleteResponse { + pub deleted_count: usize, + pub requested_count: usize, +} diff --git a/backend/src/server/shared/services/traits.rs b/backend/src/server/shared/services/traits.rs index 8b00be9e..30e61760 100644 --- a/backend/src/server/shared/services/traits.rs +++ b/backend/src/server/shared/services/traits.rs @@ -156,4 +156,41 @@ where Ok(updated) } + + async fn delete_many( + &self, + ids: &[Uuid], + authentication: AuthenticatedEntity, + ) -> Result { + if ids.is_empty() { + return Ok(0); + } + + // Log which entities are being deleted + for id in ids { + if let Some(entity) = self.get_by_id(id).await? { + let trigger_stale = entity.triggers_staleness(None); + + self.event_bus() + .publish_entity(EntityEvent { + id: Uuid::new_v4(), + entity_id: *id, + network_id: self.get_network_id(&entity), + organization_id: self.get_organization_id(&entity), + entity_type: entity.into(), + operation: EntityOperation::Deleted, + timestamp: Utc::now(), + metadata: serde_json::json!({ + "trigger_stale": trigger_stale + }), + authentication: authentication.clone(), + }) + .await?; + } + } + + let deleted_count = self.storage().delete_many(ids).await?; + + Ok(deleted_count) + } } diff --git a/backend/src/server/shared/storage/generic.rs b/backend/src/server/shared/storage/generic.rs index ee9d5c14..e842a612 100644 --- a/backend/src/server/shared/storage/generic.rs +++ b/backend/src/server/shared/storage/generic.rs @@ -188,4 +188,29 @@ where Ok(()) } + + async fn delete_many(&self, ids: &[Uuid]) -> Result { + if ids.is_empty() { + return Ok(0); + } + + let query_str = format!("DELETE FROM {} WHERE id = ANY($1)", T::table_name()); + + let result = sqlx::query(&query_str) + .bind(ids) + .execute(&self.pool) + .await?; + + let deleted_count = result.rows_affected() as usize; + + tracing::debug!( + "Bulk deleted {} {}s (requested: {}, deleted: {})", + deleted_count, + T::table_name(), + ids.len(), + deleted_count + ); + + Ok(deleted_count) + } } diff --git a/backend/src/server/shared/storage/traits.rs b/backend/src/server/shared/storage/traits.rs index 24f05dc8..9cac8d01 100644 --- a/backend/src/server/shared/storage/traits.rs +++ b/backend/src/server/shared/storage/traits.rs @@ -40,6 +40,7 @@ pub trait Storage: Send + Sync { async fn get_one(&self, filter: EntityFilter) -> Result, anyhow::Error>; async fn update(&self, entity: &mut T) -> Result; async fn delete(&self, id: &Uuid) -> Result<(), anyhow::Error>; + async fn delete_many(&self, ids: &[Uuid]) -> Result; } pub trait StorableEntity: Sized + Clone + Send + Sync + 'static { diff --git a/backend/src/server/subnets/handlers.rs b/backend/src/server/subnets/handlers.rs index d5f603b4..ee10ff99 100644 --- a/backend/src/server/subnets/handlers.rs +++ b/backend/src/server/subnets/handlers.rs @@ -1,6 +1,6 @@ use crate::server::auth::middleware::{AuthenticatedEntity, MemberOrDaemon}; use crate::server::shared::handlers::traits::{ - CrudHandlers, delete_handler, get_by_id_handler, update_handler, + CrudHandlers, bulk_delete_handler, delete_handler, get_by_id_handler, update_handler, }; use crate::server::shared::types::api::ApiError; use crate::server::{ @@ -23,6 +23,7 @@ pub fn create_router() -> Router> { .route("/{id}", put(update_handler::)) .route("/{id}", delete(delete_handler::)) .route("/{id}", get(get_by_id_handler::)) + .route("/bulk-delete", post(bulk_delete_handler::)) } pub async fn create_handler( diff --git a/backend/src/server/users/handlers.rs b/backend/src/server/users/handlers.rs index 8b5e708c..6d6d9824 100644 --- a/backend/src/server/users/handlers.rs +++ b/backend/src/server/users/handlers.rs @@ -1,5 +1,7 @@ use crate::server::auth::middleware::{AuthenticatedUser, RequireAdmin, RequireMember}; -use crate::server::shared::handlers::traits::{CrudHandlers, delete_handler, get_by_id_handler}; +use crate::server::shared::handlers::traits::{ + CrudHandlers, bulk_delete_handler, delete_handler, get_by_id_handler, +}; use crate::server::shared::storage::filter::EntityFilter; use crate::server::shared::types::api::ApiError; use crate::server::users::r#impl::base::User; @@ -13,7 +15,7 @@ use crate::server::{ }; use anyhow::anyhow; use axum::extract::Path; -use axum::routing::{delete, get, put}; +use axum::routing::{delete, get, post, put}; use axum::{Router, extract::State, response::Json}; use std::sync::Arc; use uuid::Uuid; @@ -24,6 +26,7 @@ pub fn create_router() -> Router> { .route("/{id}", put(update_user)) .route("/{id}", delete(delete_user)) .route("/{id}", get(get_by_id_handler::)) + .route("/bulk-delete", post(bulk_delete_handler::)) } pub async fn get_all_users( diff --git a/ui/src/lib/features/api_keys/components/ApiKeyCard.svelte b/ui/src/lib/features/api_keys/components/ApiKeyCard.svelte index b8d8d0e9..8fc5404a 100644 --- a/ui/src/lib/features/api_keys/components/ApiKeyCard.svelte +++ b/ui/src/lib/features/api_keys/components/ApiKeyCard.svelte @@ -10,6 +10,8 @@ export let onDelete: (apiKey: ApiKey) => void = () => {}; export let onEdit: (apiKey: ApiKey) => void = () => {}; export let viewMode: 'card' | 'list'; + export let selected: boolean; + export let onSelectionChange: (selected: boolean) => void = () => {}; // Build card data $: cardData = { @@ -59,4 +61,4 @@ }; - + diff --git a/ui/src/lib/features/api_keys/components/ApiKeyTab.svelte b/ui/src/lib/features/api_keys/components/ApiKeyTab.svelte index d27954a2..7b564a8c 100644 --- a/ui/src/lib/features/api_keys/components/ApiKeyTab.svelte +++ b/ui/src/lib/features/api_keys/components/ApiKeyTab.svelte @@ -9,7 +9,7 @@ import DataControls from '$lib/shared/components/data/DataControls.svelte'; import CreateApiKeyModal from './ApiKeyModal.svelte'; import type { ApiKey } from '../types/base'; - import { apiKeys, deleteApiKey, getApiKeys, updateApiKey } from '../store'; + import { apiKeys, bulkDeleteApiKeys, deleteApiKey, getApiKeys, updateApiKey } from '../store'; import ApiKeyCard from './ApiKeyCard.svelte'; import { Plus } from 'lucide-svelte'; @@ -45,6 +45,12 @@ editingApiKey = apiKey; } + async function handleBulkDelete(ids: string[]) { + if (confirm(`Are you sure you want to delete ${ids.length} Api Keys?`)) { + await bulkDeleteApiKeys(ids); + } + } + const apiKeyFields: FieldConfig[] = [ { key: 'name', @@ -89,11 +95,24 @@ cta="Create your first API Key" /> {:else} - - {#snippet children(item: ApiKey, viewMode: 'card' | 'list')} + item.id} + > + {#snippet children( + item: ApiKey, + viewMode: 'card' | 'list', + isSelected: boolean, + onSelectionChange: (selected: boolean) => void + )} diff --git a/ui/src/lib/features/api_keys/store.ts b/ui/src/lib/features/api_keys/store.ts index 867e79ed..8f406be5 100644 --- a/ui/src/lib/features/api_keys/store.ts +++ b/ui/src/lib/features/api_keys/store.ts @@ -23,6 +23,17 @@ export async function deleteApiKey(id: string) { return result; } +export async function bulkDeleteApiKeys(ids: string[]) { + const result = await api.request( + `/auth/keys/bulk-delete`, + apiKeys, + (_, current) => current.filter((k) => !ids.includes(k.id)), + { method: 'POST', body: JSON.stringify(ids) } + ); + + return result; +} + export async function updateApiKey(apiKey: ApiKey) { const result = await api.request( `/auth/keys/${apiKey.id}`, diff --git a/ui/src/lib/features/daemons/components/DaemonCard.svelte b/ui/src/lib/features/daemons/components/DaemonCard.svelte index f51f80e5..36c89e31 100644 --- a/ui/src/lib/features/daemons/components/DaemonCard.svelte +++ b/ui/src/lib/features/daemons/components/DaemonCard.svelte @@ -13,6 +13,8 @@ export let daemon: Daemon; export let onDelete: (daemon: Daemon) => void = () => {}; export let viewMode: 'card' | 'list'; + export let selected: boolean; + export let onSelectionChange: (selected: boolean) => void = () => {}; $: hostStore = getHostFromId(daemon.host_id); $: host = $hostStore; @@ -96,4 +98,4 @@ }; - + diff --git a/ui/src/lib/features/daemons/components/DaemonTab.svelte b/ui/src/lib/features/daemons/components/DaemonTab.svelte index c2e6d33b..1050f071 100644 --- a/ui/src/lib/features/daemons/components/DaemonTab.svelte +++ b/ui/src/lib/features/daemons/components/DaemonTab.svelte @@ -2,7 +2,12 @@ import TabHeader from '$lib/shared/components/layout/TabHeader.svelte'; import Loading from '$lib/shared/components/feedback/Loading.svelte'; import EmptyState from '$lib/shared/components/layout/EmptyState.svelte'; - import { daemons, deleteDaemon, getDaemons } from '$lib/features/daemons/store'; + import { + bulkDeleteDaemons, + daemons, + deleteDaemon, + getDaemons + } from '$lib/features/daemons/store'; import type { Daemon } from '$lib/features/daemons/types/base'; import { loadData } from '$lib/shared/utils/dataLoader'; import { getNetworks, networks } from '$lib/features/networks/store'; @@ -34,6 +39,12 @@ daemon = null; } + async function handleBulkDelete(ids: string[]) { + if (confirm(`Are you sure you want to delete ${ids.length} Daemons?`)) { + await bulkDeleteDaemons(ids); + } + } + const daemonFields: FieldConfig[] = [ { key: 'name', @@ -79,9 +90,26 @@ cta="Create your first daemon" /> {:else} - - {#snippet children(item: Daemon, viewMode: 'card' | 'list')} - + item.id} + > + {#snippet children( + item: Daemon, + viewMode: 'card' | 'list', + isSelected: boolean, + onSelectionChange: (selected: boolean) => void + )} + {/snippet} {/if} diff --git a/ui/src/lib/features/daemons/store.ts b/ui/src/lib/features/daemons/store.ts index 596393cd..83a5ba45 100644 --- a/ui/src/lib/features/daemons/store.ts +++ b/ui/src/lib/features/daemons/store.ts @@ -18,6 +18,17 @@ export async function deleteDaemon(id: string) { ); } +export async function bulkDeleteDaemons(ids: string[]) { + const result = await api.request( + `/daemons/bulk-delete`, + daemons, + (_, current) => current.filter((k) => !ids.includes(k.id)), + { method: 'POST', body: JSON.stringify(ids) } + ); + + return result; +} + export function getDaemonIsRunningDiscovery( daemon_id: string | null, sessions: DiscoveryUpdatePayload[] diff --git a/ui/src/lib/features/discovery/components/cards/DiscoveryHistoryCard.svelte b/ui/src/lib/features/discovery/components/cards/DiscoveryHistoryCard.svelte index f04b4289..a714ad78 100644 --- a/ui/src/lib/features/discovery/components/cards/DiscoveryHistoryCard.svelte +++ b/ui/src/lib/features/discovery/components/cards/DiscoveryHistoryCard.svelte @@ -9,6 +9,8 @@ export let viewMode: 'card' | 'list'; export let discovery: Discovery; export let onView: (discovery: Discovery) => void = () => {}; + export let selected: boolean; + export let onSelectionChange: (selected: boolean) => void = () => {}; $: results = discovery.run_type.type == 'Historical' ? discovery.run_type.results : null; @@ -57,4 +59,4 @@ }; - + diff --git a/ui/src/lib/features/discovery/components/cards/DiscoveryScheduledCard.svelte b/ui/src/lib/features/discovery/components/cards/DiscoveryScheduledCard.svelte index 98b46ff0..a0d9fc93 100644 --- a/ui/src/lib/features/discovery/components/cards/DiscoveryScheduledCard.svelte +++ b/ui/src/lib/features/discovery/components/cards/DiscoveryScheduledCard.svelte @@ -12,6 +12,8 @@ export let onEdit: (discovery: Discovery) => void = () => {}; export let onDelete: (discovery: Discovery) => void = () => {}; export let onRun: (discovery: Discovery) => void = () => {}; + export let selected: boolean; + export let onSelectionChange: (selected: boolean) => void = () => {}; $: cardData = { title: discovery.name, @@ -65,4 +67,4 @@ }; - + diff --git a/ui/src/lib/features/discovery/components/cards/DiscoverySessionCard.svelte b/ui/src/lib/features/discovery/components/cards/DiscoverySessionCard.svelte index 56269f4c..1931672b 100644 --- a/ui/src/lib/features/discovery/components/cards/DiscoverySessionCard.svelte +++ b/ui/src/lib/features/discovery/components/cards/DiscoverySessionCard.svelte @@ -88,4 +88,4 @@
{/snippet} - + diff --git a/ui/src/lib/features/discovery/components/tabs/DiscoveryHistoryTab.svelte b/ui/src/lib/features/discovery/components/tabs/DiscoveryHistoryTab.svelte index 73a52453..1d8576a5 100644 --- a/ui/src/lib/features/discovery/components/tabs/DiscoveryHistoryTab.svelte +++ b/ui/src/lib/features/discovery/components/tabs/DiscoveryHistoryTab.svelte @@ -4,6 +4,7 @@ import DataControls from '$lib/shared/components/data/DataControls.svelte'; import type { Discovery } from '../../types/base'; import { + bulkDeleteDiscoveries, createDiscovery, discoveries, discoveryFields, @@ -51,6 +52,12 @@ editingDiscovery = null; } + async function handleBulkDelete(ids: string[]) { + if (confirm(`Are you sure you want to delete ${ids.length} Historical Discoveries?`)) { + await bulkDeleteDiscoveries(ids); + } + } + let fields: FieldConfig[]; $: fields = [ @@ -110,10 +117,23 @@ d.run_type.type == 'Historical')} {fields} + onBulkDelete={handleBulkDelete} storageKey="netvisor-discovery-historical-table-state" + getItemId={(item) => item.id} > - {#snippet children(item: Discovery, viewMode: 'card' | 'list')} - + {#snippet children( + item: Discovery, + viewMode: 'card' | 'list', + isSelected: boolean, + onSelectionChange: (selected: boolean) => void + )} + {/snippet} {/if} diff --git a/ui/src/lib/features/discovery/components/tabs/DiscoveryScheduledTab.svelte b/ui/src/lib/features/discovery/components/tabs/DiscoveryScheduledTab.svelte index 1414030e..f34b226b 100644 --- a/ui/src/lib/features/discovery/components/tabs/DiscoveryScheduledTab.svelte +++ b/ui/src/lib/features/discovery/components/tabs/DiscoveryScheduledTab.svelte @@ -5,6 +5,7 @@ import { initiateDiscovery } from '../../sse'; import type { Discovery } from '../../types/base'; import { + bulkDeleteDiscoveries, createDiscovery, deleteDiscovery, discoveries, @@ -68,6 +69,12 @@ editingDiscovery = null; } + async function handleBulkDelete(ids: string[]) { + if (confirm(`Are you sure you want to delete ${ids.length} Scheduled Discoveries?`)) { + await bulkDeleteDiscoveries(ids); + } + } + let fields: FieldConfig[]; $: fields = [ @@ -110,11 +117,20 @@ (d) => d.run_type.type == 'AdHoc' || d.run_type.type == 'Scheduled' )} {fields} + onBulkDelete={handleBulkDelete} storageKey="netvisor-discovery-scheduled-table-state" + getItemId={(item) => item.id} > - {#snippet children(item: Discovery, viewMode: 'card' | 'list')} + {#snippet children( + item: Discovery, + viewMode: 'card' | 'list', + isSelected: boolean, + onSelectionChange: (selected: boolean) => void + )} item.session_id} > {#snippet children(item: DiscoveryUpdatePayload, viewMode: 'card' | 'list')} diff --git a/ui/src/lib/features/discovery/store.ts b/ui/src/lib/features/discovery/store.ts index a467a0fc..1e33b34e 100644 --- a/ui/src/lib/features/discovery/store.ts +++ b/ui/src/lib/features/discovery/store.ts @@ -40,6 +40,17 @@ export async function deleteDiscovery(id: string) { ); } +export async function bulkDeleteDiscoveries(ids: string[]) { + const result = await api.request( + `/discovery/bulk-delete`, + discoveries, + (_, current) => current.filter((k) => !ids.includes(k.id)), + { method: 'POST', body: JSON.stringify(ids) } + ); + + return result; +} + export function createEmptyDiscoveryFormData(): Discovery { return { id: uuidv4Sentinel, diff --git a/ui/src/lib/features/groups/components/GroupCard.svelte b/ui/src/lib/features/groups/components/GroupCard.svelte index 72228f28..a0e83d2b 100644 --- a/ui/src/lib/features/groups/components/GroupCard.svelte +++ b/ui/src/lib/features/groups/components/GroupCard.svelte @@ -9,6 +9,8 @@ export let onEdit: (group: Group) => void = () => {}; export let onDelete: (group: Group) => void = () => {}; export let viewMode: 'card' | 'list'; + export let selected: boolean; + export let onSelectionChange: (selected: boolean) => void = () => {}; $: groupServicesStore = getServicesForGroup(group.id); $: groupServices = $groupServicesStore; @@ -87,4 +89,4 @@ }; - + diff --git a/ui/src/lib/features/groups/components/GroupTab.svelte b/ui/src/lib/features/groups/components/GroupTab.svelte index c47b248c..22c785f9 100644 --- a/ui/src/lib/features/groups/components/GroupTab.svelte +++ b/ui/src/lib/features/groups/components/GroupTab.svelte @@ -1,6 +1,13 @@ - + diff --git a/ui/src/lib/features/hosts/components/HostTab.svelte b/ui/src/lib/features/hosts/components/HostTab.svelte index 3bd5b0cf..c769b6fb 100644 --- a/ui/src/lib/features/hosts/components/HostTab.svelte +++ b/ui/src/lib/features/hosts/components/HostTab.svelte @@ -7,7 +7,15 @@ import { getDaemons } from '$lib/features/daemons/store'; import HostEditor from './HostEditModal/HostEditor.svelte'; import HostConsolidationModal from './HostConsolidationModal.svelte'; - import { consolidateHosts, createHost, deleteHost, getHosts, hosts, updateHost } from '../store'; + import { + bulkDeleteHosts, + consolidateHosts, + createHost, + deleteHost, + getHosts, + hosts, + updateHost + } from '../store'; import { getGroups, groups } from '$lib/features/groups/store'; import { loadData } from '$lib/shared/utils/dataLoader'; import { getServiceById, getServices, services } from '$lib/features/services/store'; @@ -157,6 +165,12 @@ } } + async function handleBulkDelete(ids: string[]) { + if (confirm(`Are you sure you want to delete ${ids.length} Hosts?`)) { + await bulkDeleteHosts(ids); + } + } + async function handleHostHide(host: Host) { host.hidden = !host.hidden; await updateHost({ host, services: null }); @@ -193,21 +207,27 @@ host.id} storageKey="netvisor-hosts-table-state" + onBulkDelete={handleBulkDelete} + getItemId={(item) => item.id} > - {#snippet children(item: Host, viewMode: 'card' | 'list')} - {#key item.id} - - {/key} + {#snippet children( + item: Host, + viewMode: 'card' | 'list', + isSelected: boolean, + onSelectionChange: (selected: boolean) => void + )} + {/snippet} {/if} diff --git a/ui/src/lib/features/hosts/store.ts b/ui/src/lib/features/hosts/store.ts index c1083b41..63823014 100644 --- a/ui/src/lib/features/hosts/store.ts +++ b/ui/src/lib/features/hosts/store.ts @@ -45,6 +45,17 @@ export async function deleteHost(id: string) { ); } +export async function bulkDeleteHosts(ids: string[]) { + const result = await api.request( + `/hosts/bulk-delete`, + hosts, + (_, current) => current.filter((k) => !ids.includes(k.id)), + { method: 'POST', body: JSON.stringify(ids) } + ); + + return result; +} + export async function consolidateHosts(destination_host_id: string, other_host_id: string) { const other_host_name = get(getHostFromId(other_host_id))?.name; diff --git a/ui/src/lib/features/networks/components/NetworkCard.svelte b/ui/src/lib/features/networks/components/NetworkCard.svelte index c3822c94..7399b5d1 100644 --- a/ui/src/lib/features/networks/components/NetworkCard.svelte +++ b/ui/src/lib/features/networks/components/NetworkCard.svelte @@ -12,6 +12,8 @@ export let onDelete: (network: Network) => void = () => {}; export let onEdit: (network: Network) => void = () => {}; export let viewMode: 'card' | 'list'; + export let selected: boolean; + export let onSelectionChange: (selected: boolean) => void = () => {}; $: networkHosts = $hosts.filter((h) => h.network_id == network.id); $: networkDaemons = $daemons.filter((d) => d.network_id == network.id); @@ -82,4 +84,4 @@ }; - + diff --git a/ui/src/lib/features/networks/components/NetworksTab.svelte b/ui/src/lib/features/networks/components/NetworksTab.svelte index 8cd5eede..5209a278 100644 --- a/ui/src/lib/features/networks/components/NetworksTab.svelte +++ b/ui/src/lib/features/networks/components/NetworksTab.svelte @@ -4,6 +4,7 @@ import EmptyState from '$lib/shared/components/layout/EmptyState.svelte'; import { loadData } from '$lib/shared/utils/dataLoader'; import { + bulkDeleteNetworks, createNetwork, deleteNetwork, getNetworks, @@ -46,6 +47,12 @@ showCreateNetworkModal = true; } + async function handleBulkDelete(ids: string[]) { + if (confirm(`Are you sure you want to delete ${ids.length} Networks?`)) { + await bulkDeleteNetworks(ids); + } + } + async function handleNetworkCreate(data: Network) { const result = await createNetwork(data); if (result?.success) { @@ -105,12 +112,21 @@ item.id} > - {#snippet children(item: Network, viewMode: 'card' | 'list')} + {#snippet children( + item: Network, + viewMode: 'card' | 'list', + isSelected: boolean, + onSelectionChange: (selected: boolean) => void + )} diff --git a/ui/src/lib/features/networks/store.ts b/ui/src/lib/features/networks/store.ts index ede93ff5..b4eb9cfb 100644 --- a/ui/src/lib/features/networks/store.ts +++ b/ui/src/lib/features/networks/store.ts @@ -49,6 +49,17 @@ export async function deleteNetwork(id: string) { return result; } +export async function bulkDeleteNetworks(ids: string[]) { + const result = await api.request( + `/networks/bulk-delete`, + networks, + (_, current) => current.filter((k) => !ids.includes(k.id)), + { method: 'POST', body: JSON.stringify(ids) } + ); + + return result; +} + export function createEmptyNetworkFormData(): Network { return { id: uuidv4Sentinel, diff --git a/ui/src/lib/features/organizations/types.ts b/ui/src/lib/features/organizations/types.ts index f814b404..8df7757b 100644 --- a/ui/src/lib/features/organizations/types.ts +++ b/ui/src/lib/features/organizations/types.ts @@ -63,6 +63,7 @@ export interface CreateInviteRequest { } export interface OrganizationInvite { + id: string; token: string; permissions: UserOrgPermissions; url: string; diff --git a/ui/src/lib/features/services/components/ServiceCard.svelte b/ui/src/lib/features/services/components/ServiceCard.svelte index 928bcd83..3f87eea9 100644 --- a/ui/src/lib/features/services/components/ServiceCard.svelte +++ b/ui/src/lib/features/services/components/ServiceCard.svelte @@ -15,6 +15,8 @@ export let onDelete: (service: Service) => void = () => {}; export let onEdit: (service: Service) => void = () => {}; export let viewMode: 'card' | 'list'; + export let selected: boolean; + export let onSelectionChange: (selected: boolean) => void = () => {}; $: ports = host.ports.filter((p) => service.bindings @@ -101,4 +103,4 @@ }; - + diff --git a/ui/src/lib/features/services/components/ServiceTab.svelte b/ui/src/lib/features/services/components/ServiceTab.svelte index 75347922..165ddfa1 100644 --- a/ui/src/lib/features/services/components/ServiceTab.svelte +++ b/ui/src/lib/features/services/components/ServiceTab.svelte @@ -4,6 +4,7 @@ import EmptyState from '$lib/shared/components/layout/EmptyState.svelte'; import { loadData } from '$lib/shared/utils/dataLoader'; import { + bulkDeleteServices, deleteService, getServices, services, @@ -56,6 +57,12 @@ } } + async function handleBulkDelete(ids: string[]) { + if (confirm(`Are you sure you want to delete ${ids.length} Services?`)) { + await bulkDeleteServices(ids); + } + } + // Define field configuration for the DataTableControls const serviceFields: FieldConfig[] = [ { @@ -139,13 +146,22 @@ items={$services} fields={serviceFields} storageKey="netvisor-services-table-state" + onBulkDelete={handleBulkDelete} + getItemId={(item) => item.id} > - {#snippet children(item: Service, viewMode: 'card' | 'list')} + {#snippet children( + item: Service, + viewMode: 'card' | 'list', + isSelected: boolean, + onSelectionChange: (selected: boolean) => void + )} {@const host = serviceHosts.get(item.id)} {#if host} ( + `/services/bulk-delete`, + services, + (_, current) => current.filter((k) => !ids.includes(k.id)), + { method: 'POST', body: JSON.stringify(ids) } + ); + + return result; +} + // Update a service export async function updateService(data: Service) { console.log(1); diff --git a/ui/src/lib/features/subnets/components/SubnetCard.svelte b/ui/src/lib/features/subnets/components/SubnetCard.svelte index 5c63f7b2..106ac8d4 100644 --- a/ui/src/lib/features/subnets/components/SubnetCard.svelte +++ b/ui/src/lib/features/subnets/components/SubnetCard.svelte @@ -11,6 +11,8 @@ export let onEdit: (subnet: Subnet) => void = () => {}; export let onDelete: (subnet: Subnet) => void = () => {}; export let viewMode: 'card' | 'list'; + export let selected: boolean; + export let onSelectionChange: (selected: boolean) => void = () => {}; $: allServices = getServicesForSubnet(subnet); $: serviceLabelsStore = formatServiceLabels($allServices.map((s) => s.id)); @@ -92,4 +94,4 @@ }; - + diff --git a/ui/src/lib/features/subnets/components/SubnetTab.svelte b/ui/src/lib/features/subnets/components/SubnetTab.svelte index f7f00612..a7c72bf8 100644 --- a/ui/src/lib/features/subnets/components/SubnetTab.svelte +++ b/ui/src/lib/features/subnets/components/SubnetTab.svelte @@ -1,5 +1,12 @@ - + diff --git a/ui/src/lib/features/users/components/UserCard.svelte b/ui/src/lib/features/users/components/UserCard.svelte index 3b0b41a6..d704595b 100644 --- a/ui/src/lib/features/users/components/UserCard.svelte +++ b/ui/src/lib/features/users/components/UserCard.svelte @@ -7,7 +7,17 @@ import { currentUser } from '$lib/features/auth/store'; import { deleteUser } from '../store'; - let { user, viewMode }: { user: User; viewMode: 'card' | 'list' } = $props(); + let { + user, + viewMode, + selected, + onSelectionChange + }: { + user: User; + viewMode: 'card' | 'list'; + selected: boolean; + onSelectionChange: (selected: boolean) => void; + } = $props(); // Force Svelte to track metadata reactivity $effect(() => { @@ -72,4 +82,4 @@ }); - + diff --git a/ui/src/lib/features/users/components/UserTab.svelte b/ui/src/lib/features/users/components/UserTab.svelte index 5fc1435d..c3dfb0fa 100644 --- a/ui/src/lib/features/users/components/UserTab.svelte +++ b/ui/src/lib/features/users/components/UserTab.svelte @@ -1,5 +1,5 @@
+ + {#if selectable} +
+ e.stopPropagation()} + class="h-5 w-5 cursor-pointer rounded border-gray-600 bg-gray-700 text-blue-600 focus:ring-2 focus:ring-blue-500" + /> +
+ {/if} +
{#if Icon} {/if} -
- {#if link} - - {title} - - {:else} -

- {title} -

- {/if} +
+
+
+ {#if link} + + {title} + + {:else} +

+ {title} +

+ {/if} +
+ {#if status} +
+ +
+ {/if} +
{#if subtitle}

{subtitle}

{/if} - {#if status && viewMode == 'list'} -
- -
- {/if}
- {#if status && viewMode === 'card'} - - {/if}
From 9176260bb78ca3f8df683d3930ea8334327b6d29 Mon Sep 17 00:00:00 2001 From: Maya Date: Fri, 21 Nov 2025 17:57:02 -0500 Subject: [PATCH 24/27] feat: savable topology works end to end --- backend/src/server/api_keys/handlers.rs | 22 +-- backend/src/server/auth/middleware.rs | 13 ++ backend/src/server/logging/subscriber.rs | 1 + backend/src/server/shared/events/types.rs | 42 +++++ backend/src/server/topology/handlers.rs | 41 +++-- .../src/server/topology/service/context.rs | 16 +- .../server/topology/service/edge_builder.rs | 8 +- backend/src/server/topology/service/main.rs | 27 ++- .../service/planner/subnet_layout_planner.rs | 2 +- .../src/server/topology/service/subscriber.rs | 51 ++++-- backend/src/server/topology/types/base.rs | 6 +- .../components/TopologyDetailsForm.svelte | 7 +- .../topology/components/TopologyTab.svelte | 97 ++++++---- .../edges/InspectorEdgeGroup.svelte | 2 +- .../InspectorEdgeHostVirtualization.svelte | 6 +- .../edges/InspectorEdgeInterface.svelte | 2 +- .../InspectorEdgeServiceVirtualization.svelte | 6 +- .../nodes/InspectorInterfaceNode.svelte | 2 +- .../nodes/InspectorSubnetNode.svelte | 2 +- .../visualization/CustomEdge.svelte | 1 + .../visualization/InterfaceNode.svelte | 20 +-- .../visualization/SubnetNode.svelte | 2 +- .../visualization/TopologyViewer.svelte | 6 +- ui/src/lib/features/topology/sse.ts | 55 ++++-- ui/src/lib/features/topology/state.ts | 3 +- ui/src/lib/features/topology/store.ts | 166 +++++++++--------- ui/src/lib/shared/utils/formatting.ts | 1 - 27 files changed, 371 insertions(+), 236 deletions(-) diff --git a/backend/src/server/api_keys/handlers.rs b/backend/src/server/api_keys/handlers.rs index ddb21cc3..018dee37 100644 --- a/backend/src/server/api_keys/handlers.rs +++ b/backend/src/server/api_keys/handlers.rs @@ -51,13 +51,6 @@ pub async fn create_handler( ApiError::internal_error(&e.to_string()) })?; - tracing::info!( - api_key_id = %api_key.id, - api_key_name = %api_key.base.name, - user_id = %user.user_id, - "API key created via API (key shown to user)" - ); - Ok(Json(ApiResponse::success(ApiKeyResponse { key: api_key.base.key.clone(), api_key, @@ -69,7 +62,7 @@ pub async fn rotate_key_handler( RequireMember(user): RequireMember, Path(api_key_id): Path, ) -> ApiResult>> { - tracing::info!( + tracing::debug!( api_key_id = %api_key_id, user_id = %user.user_id, "API key rotation request received" @@ -89,12 +82,6 @@ pub async fn rotate_key_handler( ApiError::internal_error(&e.to_string()) })?; - tracing::info!( - api_key_id = %api_key_id, - user_id = %user.user_id, - "API key rotated via API (new key shown to user)" - ); - Ok(Json(ApiResponse::success(key))) } @@ -150,12 +137,5 @@ pub async fn update_handler( ApiError::internal_error(&e.to_string()) })?; - tracing::info!( - api_key_id = %id, - api_key_name = %updated.base.name, - user_id = %user.user_id, - "API key updated via API" - ); - Ok(Json(ApiResponse::success(updated))) } diff --git a/backend/src/server/auth/middleware.rs b/backend/src/server/auth/middleware.rs index ae5f983b..5ec0a7cd 100644 --- a/backend/src/server/auth/middleware.rs +++ b/backend/src/server/auth/middleware.rs @@ -1,3 +1,5 @@ +use std::fmt::Display; + use crate::server::{ billing::types::base::BillingPlan, config::AppState, @@ -41,6 +43,17 @@ pub enum AuthenticatedEntity { Anonymous, } +impl Display for AuthenticatedEntity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AuthenticatedEntity::Anonymous => write!(f, "Anonymous"), + AuthenticatedEntity::System => write!(f, "System"), + AuthenticatedEntity::Daemon { .. } => write!(f, "Daemon"), + AuthenticatedEntity::User { .. } => write!(f, "User"), + } + } +} + impl AuthenticatedEntity { /// Get the user_id if this is a User, otherwise None pub fn user_id(&self) -> Option { diff --git a/backend/src/server/logging/subscriber.rs b/backend/src/server/logging/subscriber.rs index 4cdb9908..d47c8730 100644 --- a/backend/src/server/logging/subscriber.rs +++ b/backend/src/server/logging/subscriber.rs @@ -19,6 +19,7 @@ impl EventSubscriber for LoggingService { // Log each event individually for event in events { event.log(); + tracing::debug!("{}", event); } Ok(()) diff --git a/backend/src/server/shared/events/types.rs b/backend/src/server/shared/events/types.rs index 77f19821..723fb691 100644 --- a/backend/src/server/shared/events/types.rs +++ b/backend/src/server/shared/events/types.rs @@ -2,6 +2,7 @@ use crate::server::{auth::middleware::AuthenticatedEntity, shared::entities::Ent use chrono::{DateTime, Utc}; use serde::Serialize; use std::{fmt::Display, net::IpAddr}; +use strum::IntoDiscriminant; use uuid::Uuid; #[derive(Debug, Clone, Serialize)] @@ -67,6 +68,47 @@ impl Event { } } +impl Display for Event { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Event::Auth(a) => write!( + f, + "{{ id: {}, user_id: {}, organization_id: {}, operation: {}, timestamp: {}, ip_address: {}, user_agent: {}, metadata: {}, authentication: {} }}", + a.id, + a.user_id + .map(|u| u.to_string()) + .unwrap_or("None".to_string()), + a.organization_id + .map(|u| u.to_string()) + .unwrap_or("None".to_string()), + a.operation, + a.timestamp, + a.ip_address, + a.user_agent.clone().unwrap_or("Unknown".to_string()), + a.metadata, + a.authentication + ), + Event::Entity(e) => write!( + f, + "{{ id: {}, entity_type: {}, entity_id: {}, network_id: {}, organization_id: {}, operation: {}, timestamp: {}, metadata: {}, authentication: {} }}", + e.id, + e.entity_type.discriminant(), + e.entity_id, + e.network_id + .map(|u| u.to_string()) + .unwrap_or("None".to_string()), + e.organization_id + .map(|u| u.to_string()) + .unwrap_or("None".to_string()), + e.operation, + e.timestamp, + e.metadata, + e.authentication + ), + } + } +} + impl PartialEq for Event { fn eq(&self, other: &Self) -> bool { match (self, other) { diff --git a/backend/src/server/topology/handlers.rs b/backend/src/server/topology/handlers.rs index 71172047..af9f210c 100644 --- a/backend/src/server/topology/handlers.rs +++ b/backend/src/server/topology/handlers.rs @@ -64,8 +64,11 @@ pub async fn create_handler( let service = Topology::get_service(&state); - let (hosts, services, subnets, groups) = - service.get_entity_data(topology.base.network_id).await?; + let (hosts, subnets, groups) = service.get_entity_data(topology.base.network_id).await?; + + let services = service + .get_service_data(topology.base.network_id, &topology.base.options) + .await?; let (nodes, edges) = service.build_graph(BuildGraphParams { options: &topology.base.options, @@ -83,7 +86,7 @@ pub async fn create_handler( topology.base.groups = groups; topology.base.edges = edges; topology.base.nodes = nodes; - topology.refresh(); + topology.clear_stale(); let created = service .create(topology, user.clone().into()) @@ -113,20 +116,25 @@ async fn refresh( State(state): State>, RequireMember(user): RequireMember, Json(mut topology): Json, -) -> ApiResult>> { +) -> ApiResult>> { let service = Topology::get_service(&state); - let (hosts, services, subnets, groups) = - service.get_entity_data(topology.base.network_id).await?; + let (hosts, subnets, groups) = service.get_entity_data(topology.base.network_id).await?; + + let services = service + .get_service_data(topology.base.network_id, &topology.base.options) + .await?; topology.base.hosts = hosts; topology.base.services = services; topology.base.subnets = subnets; topology.base.groups = groups; - let updated = service.update(&mut topology, user.into()).await?; + service.update(&mut topology, user.into()).await?; - Ok(Json(ApiResponse::success(updated))) + // Return will be handled through event subscriber which triggers SSE + + Ok(Json(ApiResponse::success(()))) } /// Recalculate node and edges and refresh entity data @@ -134,11 +142,14 @@ async fn rebuild( State(state): State>, RequireMember(user): RequireMember, Json(mut topology): Json, -) -> ApiResult>> { +) -> ApiResult>> { let service = Topology::get_service(&state); - let (hosts, services, subnets, groups) = - service.get_entity_data(topology.base.network_id).await?; + let (hosts, subnets, groups) = service.get_entity_data(topology.base.network_id).await?; + + let services = service + .get_service_data(topology.base.network_id, &topology.base.options) + .await?; let (nodes, edges) = service.build_graph(BuildGraphParams { options: &topology.base.options, @@ -156,11 +167,13 @@ async fn rebuild( topology.base.groups = groups; topology.base.edges = edges; topology.base.nodes = nodes; - topology.refresh(); + topology.clear_stale(); - let updated = service.update(&mut topology, user.into()).await?; + service.update(&mut topology, user.into()).await?; - Ok(Json(ApiResponse::success(updated))) + // Return will be handled through event subscriber which triggers SSE + + Ok(Json(ApiResponse::success(()))) } async fn lock( diff --git a/backend/src/server/topology/service/context.rs b/backend/src/server/topology/service/context.rs index cd4c5607..bd717f38 100644 --- a/backend/src/server/topology/service/context.rs +++ b/backend/src/server/topology/service/context.rs @@ -19,7 +19,7 @@ use crate::server::{ pub struct TopologyContext<'a> { pub hosts: &'a [Host], pub subnets: &'a [Subnet], - services: &'a [Service], + pub services: &'a [Service], pub groups: &'a [Group], pub options: &'a TopologyOptions, } @@ -45,20 +45,6 @@ impl<'a> TopologyContext<'a> { // Data Access Methods // ============================================================================ - pub fn services(&self) -> Vec { - self.services - .iter() - .filter(|s| { - !self - .options - .request - .hide_service_categories - .contains(&s.base.service_definition.category()) - }) - .cloned() - .collect() - } - pub fn get_subnet_by_id(&self, subnet_id: Uuid) -> Option<&'a Subnet> { self.subnets.iter().find(|s| s.id == subnet_id) } diff --git a/backend/src/server/topology/service/edge_builder.rs b/backend/src/server/topology/service/edge_builder.rs index 42fa22eb..ef97188e 100644 --- a/backend/src/server/topology/service/edge_builder.rs +++ b/backend/src/server/topology/service/edge_builder.rs @@ -65,7 +65,7 @@ impl EdgeBuilder { let mut docker_service_to_containerized_service_ids: HashMap> = HashMap::new(); - ctx.services().iter().for_each(|s| { + ctx.services.iter().for_each(|s| { if let Some(ServiceVirtualization::Docker(docker_virtualization)) = &s.base.virtualization { @@ -79,7 +79,7 @@ impl EdgeBuilder { }); let edges = ctx - .services() + .services .iter() .filter(|s| { docker_service_to_containerized_service_ids @@ -415,14 +415,14 @@ impl EdgeBuilder { target_binding_id: Uuid, group: &Group, ) -> Option { - let source_interface = ctx.services().iter().find_map(|s| { + let source_interface = ctx.services.iter().find_map(|s| { if let Some(source_binding) = s.get_binding(source_binding_id) { return Some(source_binding.interface_id()); } None }); - let target_interface = ctx.services().iter().find_map(|s| { + let target_interface = ctx.services.iter().find_map(|s| { if let Some(target_binding) = s.get_binding(target_binding_id) { return Some(target_binding.interface_id()); } diff --git a/backend/src/server/topology/service/main.rs b/backend/src/server/topology/service/main.rs index 863ba044..2c0b5eae 100644 --- a/backend/src/server/topology/service/main.rs +++ b/backend/src/server/topology/service/main.rs @@ -98,15 +98,36 @@ impl TopologyService { pub async fn get_entity_data( &self, network_id: Uuid, - ) -> Result<(Vec, Vec, Vec, Vec), Error> { + ) -> Result<(Vec, Vec, Vec), Error> { let network_filter = EntityFilter::unfiltered().network_ids(&[network_id]); // Fetch all data let hosts = self.host_service.get_all(network_filter.clone()).await?; let subnets = self.subnet_service.get_all(network_filter.clone()).await?; let groups = self.group_service.get_all(network_filter.clone()).await?; - let services = self.service_service.get_all(network_filter.clone()).await?; - Ok((hosts, services, subnets, groups)) + Ok((hosts, subnets, groups)) + } + + pub async fn get_service_data( + &self, + network_id: Uuid, + options: &TopologyOptions, + ) -> Result, Error> { + let network_filter = EntityFilter::unfiltered().network_ids(&[network_id]); + + Ok(self + .service_service + .get_all(network_filter.clone()) + .await? + .iter() + .filter(|s| { + !options + .request + .hide_service_categories + .contains(&s.base.service_definition.category()) + }) + .cloned() + .collect()) } pub fn build_graph(&self, params: BuildGraphParams) -> (Vec, Vec) { diff --git a/backend/src/server/topology/service/planner/subnet_layout_planner.rs b/backend/src/server/topology/service/planner/subnet_layout_planner.rs index 3cbc9c91..a4172437 100644 --- a/backend/src/server/topology/service/planner/subnet_layout_planner.rs +++ b/backend/src/server/topology/service/planner/subnet_layout_planner.rs @@ -230,7 +230,7 @@ impl SubnetLayoutPlanner { for interface in &host.base.interfaces { let subnet = ctx.get_subnet_by_id(interface.base.subnet_id); let subnet_type = subnet.map(|s| s.base.subnet_type).unwrap_or_default(); - let services = ctx.services(); + let services = ctx.services; let interface_bound_services: Vec<&Service> = services .iter() diff --git a/backend/src/server/topology/service/subscriber.rs b/backend/src/server/topology/service/subscriber.rs index 5fa9d2e2..8f19a47c 100644 --- a/backend/src/server/topology/service/subscriber.rs +++ b/backend/src/server/topology/service/subscriber.rs @@ -39,6 +39,10 @@ impl EventSubscriber for TopologyService { (EntityDiscriminants::Service, None), (EntityDiscriminants::Subnet, None), (EntityDiscriminants::Group, None), + ( + EntityDiscriminants::Topology, + Some(vec![EntityOperation::Updated]), + ), ])) } @@ -57,6 +61,31 @@ impl EventSubscriber for TopologyService { if let Event::Entity(entity_event) = event && let Some(network_id) = entity_event.network_id { + // Check if any event triggers staleness + let trigger_stale = entity_event + .metadata + .get("trigger_stale") + .and_then(|v| serde_json::from_value::(v.clone()).ok()) + .unwrap_or(false); + + // Topology updates from changes to options should be applied immediately and not processed alongside + // other changes, otherwise another call to topology_service.update will be made which will trigger + // an infinite loop + if let Entity::Topology(mut topology) = entity_event.entity_type { + if trigger_stale { + topology.base.is_stale = true; + } + + topology.base.services = self + .get_service_data(network_id, &topology.base.options) + .await?; + + let _ = self.staleness_tx.send(topology).inspect_err(|e| { + tracing::debug!("Staleness notification skipped (no receivers): {}", e) + }); + continue; + } + network_ids.insert(network_id); let changes = topology_updates.entry(network_id).or_default(); @@ -74,13 +103,6 @@ impl EventSubscriber for TopologyService { }; } - // Check if any event triggers staleness - let trigger_stale = entity_event - .metadata - .get("trigger_stale") - .and_then(|v| serde_json::from_value::(v.clone()).ok()) - .unwrap_or(false); - if trigger_stale { // User will be prompted to update entities changes.should_mark_stale = true; @@ -102,8 +124,14 @@ impl EventSubscriber for TopologyService { let network_filter = StorageFilter::unfiltered().network_ids(&[network_id]); let topologies = self.get_all(network_filter).await?; + let (hosts, subnets, groups) = self.get_entity_data(network_id).await?; + if let Some(changes) = topology_updates.get(&network_id) { for mut topology in topologies { + let services = self + .get_service_data(network_id, &topology.base.options) + .await?; + // Apply removed entities for host_id in &changes.removed_hosts { if !topology.base.removed_hosts.contains(host_id) { @@ -131,11 +159,8 @@ impl EventSubscriber for TopologyService { topology.base.is_stale = true; } - let (hosts, services, subnets, groups) = - self.get_entity_data(topology.base.network_id).await?; - if changes.updated_hosts { - topology.base.hosts = hosts + topology.base.hosts = hosts.clone() } if changes.updated_services { @@ -143,11 +168,11 @@ impl EventSubscriber for TopologyService { } if changes.updated_subnets { - topology.base.subnets = subnets + topology.base.subnets = subnets.clone() } if changes.updated_groups { - topology.base.groups = groups; + topology.base.groups = groups.clone(); } // Update topology in database diff --git a/backend/src/server/topology/types/base.rs b/backend/src/server/topology/types/base.rs index c79343b4..0b2b79ff 100644 --- a/backend/src/server/topology/types/base.rs +++ b/backend/src/server/topology/types/base.rs @@ -5,7 +5,7 @@ use crate::server::services::r#impl::categories::ServiceCategory; use crate::server::shared::entities::ChangeTriggersTopologyStaleness; use crate::server::subnets::r#impl::base::Subnet; use crate::server::topology::types::edges::Edge; -use crate::server::topology::types::edges::EdgeType; +use crate::server::topology::types::edges::EdgeTypeDiscriminants; use crate::server::topology::types::nodes::Node; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -95,7 +95,7 @@ impl Topology { self.base.locked_by = None; } - pub fn refresh(&mut self) { + pub fn clear_stale(&mut self) { self.base.removed_groups = vec![]; self.base.removed_hosts = vec![]; self.base.removed_services = vec![]; @@ -126,7 +126,7 @@ pub struct TopologyLocalOptions { pub left_zone_title: String, pub no_fade_edges: bool, pub hide_resize_handles: bool, - pub hide_edge_types: Vec, + pub hide_edge_types: Vec, } impl Default for TopologyLocalOptions { diff --git a/ui/src/lib/features/topology/components/TopologyDetailsForm.svelte b/ui/src/lib/features/topology/components/TopologyDetailsForm.svelte index 92500792..14f30084 100644 --- a/ui/src/lib/features/topology/components/TopologyDetailsForm.svelte +++ b/ui/src/lib/features/topology/components/TopologyDetailsForm.svelte @@ -29,6 +29,11 @@ // Track network_id separately to force reactivity let selectedNetworkId = formData.network_id; $: formData.network_id = selectedNetworkId; + + // Make options reactive to store changes using reactive statement + $: topologyOptions = $topologies.filter( + (t) => t.id !== formData.id && t.network_id == selectedNetworkId + );
@@ -42,7 +47,7 @@ disabled={isEditing} selectedValue={$parentId.value} onSelect={onParentSelect} - options={$topologies} + options={topologyOptions} />
diff --git a/ui/src/lib/features/topology/components/TopologyTab.svelte b/ui/src/lib/features/topology/components/TopologyTab.svelte index 353823da..1f28e562 100644 --- a/ui/src/lib/features/topology/components/TopologyTab.svelte +++ b/ui/src/lib/features/topology/components/TopologyTab.svelte @@ -31,11 +31,12 @@ import RichSelect from '$lib/shared/components/forms/selection/RichSelect.svelte'; import { TopologyDisplay } from '$lib/shared/components/forms/selection/display/TopologyDisplay.svelte'; import InlineWarning from '$lib/shared/components/feedback/InlineWarning.svelte'; + import { formatTimestamp } from '$lib/shared/utils/formatting'; - let isCreateEditOpen = false; - let editingTopology: Topology | null = null; + let isCreateEditOpen = $state(false); + let editingTopology: Topology | null = $state(null); - let isRefreshConflictsOpen = false; + let isRefreshConflictsOpen = $state(false); function handleCreateTopology() { isCreateEditOpen = true; @@ -119,16 +120,20 @@ await unlockTopology($topology); } - $: stateConfig = $topology - ? getTopologyState($topology, { - onRefresh: handleRefresh, - onUnlock: handleUnlock, - onReset: handleReset, - onLock: handleLock - }) - : null; + let stateConfig = $derived( + $topology + ? getTopologyState($topology, { + onRefresh: handleRefresh, + onUnlock: handleUnlock, + onReset: handleReset, + onLock: handleLock + }) + : null + ); - $: lockedByUser = $topology?.locked_by ? $users.find((u) => u.id === $topology.locked_by) : null; + let lockedByUser = $derived( + $topology?.locked_by ? $users.find((u) => u.id === $topology.locked_by) : null + ); const loading = loadData([getHosts, getServices, getSubnets, getGroups, getTopologies]); @@ -138,24 +143,46 @@ -
- +
+ {#if $topology} + + {/if} {#if $topology && !$topology.is_locked} - +
+
+ +
+
{/if} {#if $topology && stateConfig} -
- +
+
+ +
+ {#if $topology.is_locked && $topology.locked_at} +
+ {formatTimestamp($topology.locked_at)} + by {$users.find((u) => u.id == $topology.locked_by)?.email} +
+ {:else} + {formatTimestamp($topology.last_refreshed)} + {/if}
{/if} @@ -171,17 +198,21 @@
{/if} - + {#if $topology} + + {/if} - - + {#if $topology} + + {/if}
@@ -218,7 +249,9 @@
{:else} -
No topology selected. Create one to get started.
+
+ No topology selected. Create one to get started. +
{/if}
diff --git a/ui/src/lib/features/topology/components/panel/inspectors/edges/InspectorEdgeGroup.svelte b/ui/src/lib/features/topology/components/panel/inspectors/edges/InspectorEdgeGroup.svelte index 71f6b027..8498db23 100644 --- a/ui/src/lib/features/topology/components/panel/inspectors/edges/InspectorEdgeGroup.svelte +++ b/ui/src/lib/features/topology/components/panel/inspectors/edges/InspectorEdgeGroup.svelte @@ -20,7 +20,7 @@ targetBindingId }: { groupId: string; sourceBindingId: string; targetBindingId: string } = $props(); - let group = $derived($topology.groups.find((g) => g.id == groupId) || null); + let group = $derived($topology ? $topology.groups.find((g) => g.id == groupId) : null); // Local copy of group for editing let localGroup = $state(null); diff --git a/ui/src/lib/features/topology/components/panel/inspectors/edges/InspectorEdgeHostVirtualization.svelte b/ui/src/lib/features/topology/components/panel/inspectors/edges/InspectorEdgeHostVirtualization.svelte index 661f43ac..37ae2ddb 100644 --- a/ui/src/lib/features/topology/components/panel/inspectors/edges/InspectorEdgeHostVirtualization.svelte +++ b/ui/src/lib/features/topology/components/panel/inspectors/edges/InspectorEdgeHostVirtualization.svelte @@ -7,8 +7,10 @@ let { edge, vmServiceId }: { edge: Edge; vmServiceId: string } = $props(); - let vmService = $derived($topology.services.find((s) => s.id == vmServiceId) || null); - let hypervisorHost = $derived($topology.hosts.find((h) => h.id == edge.target) || null); + let vmService = $derived($topology ? $topology.services.find((s) => s.id == vmServiceId) : null); + let hypervisorHost = $derived( + $topology ? $topology.hosts.find((h) => h.id == edge.target) : null + );
diff --git a/ui/src/lib/features/topology/components/panel/inspectors/edges/InspectorEdgeInterface.svelte b/ui/src/lib/features/topology/components/panel/inspectors/edges/InspectorEdgeInterface.svelte index 5253788a..28bef32d 100644 --- a/ui/src/lib/features/topology/components/panel/inspectors/edges/InspectorEdgeInterface.svelte +++ b/ui/src/lib/features/topology/components/panel/inspectors/edges/InspectorEdgeInterface.svelte @@ -7,7 +7,7 @@ let { edge, hostId }: { edge: Edge; hostId: string } = $props(); - let host = $derived($topology.hosts.find((h) => h.id == hostId)); + let host = $derived($topology ? $topology.hosts.find((h) => h.id == hostId) : null); let sourceInterface = $derived(host?.interfaces.find((i) => i.id == edge.source)); let targetInterface = $derived(host?.interfaces.find((i) => i.id == edge.target)); diff --git a/ui/src/lib/features/topology/components/panel/inspectors/edges/InspectorEdgeServiceVirtualization.svelte b/ui/src/lib/features/topology/components/panel/inspectors/edges/InspectorEdgeServiceVirtualization.svelte index 5ecec3dc..b9cf5deb 100644 --- a/ui/src/lib/features/topology/components/panel/inspectors/edges/InspectorEdgeServiceVirtualization.svelte +++ b/ui/src/lib/features/topology/components/panel/inspectors/edges/InspectorEdgeServiceVirtualization.svelte @@ -14,12 +14,14 @@ let { edge, containerizingServiceId }: { edge: Edge; containerizingServiceId: string } = $props(); let containerizingService = $derived( - $topology.services.find((s) => s.id == containerizingServiceId) || null + $topology ? $topology.services.find((s) => s.id == containerizingServiceId) : null ); let containerizingHost = $derived( containerizingService - ? $topology.hosts.find((h) => h.id == containerizingService.host_id) + ? $topology + ? $topology.hosts.find((h) => h.id == containerizingService.host_id) + : null : null ); diff --git a/ui/src/lib/features/topology/components/panel/inspectors/nodes/InspectorInterfaceNode.svelte b/ui/src/lib/features/topology/components/panel/inspectors/nodes/InspectorInterfaceNode.svelte index 9fe1095c..f5a9d982 100644 --- a/ui/src/lib/features/topology/components/panel/inspectors/nodes/InspectorInterfaceNode.svelte +++ b/ui/src/lib/features/topology/components/panel/inspectors/nodes/InspectorInterfaceNode.svelte @@ -11,7 +11,7 @@ let nodeData = node.data as InterfaceNode; - let host = $derived($topology.hosts.find((h) => h.id == nodeData.host_id)); + let host = $derived($topology ? $topology.hosts.find((h) => h.id == nodeData.host_id) : null); // Get the interface for this node let thisInterface = $derived( diff --git a/ui/src/lib/features/topology/components/panel/inspectors/nodes/InspectorSubnetNode.svelte b/ui/src/lib/features/topology/components/panel/inspectors/nodes/InspectorSubnetNode.svelte index 0e4a63ec..034d69e2 100644 --- a/ui/src/lib/features/topology/components/panel/inspectors/nodes/InspectorSubnetNode.svelte +++ b/ui/src/lib/features/topology/components/panel/inspectors/nodes/InspectorSubnetNode.svelte @@ -6,7 +6,7 @@ let { node }: { node: Node } = $props(); - let subnet = $derived($topology.subnets.find((s) => s.id == node.id)); + let subnet = $derived($topology ? $topology.subnets.find((s) => s.id == node.id) : null);
diff --git a/ui/src/lib/features/topology/components/visualization/CustomEdge.svelte b/ui/src/lib/features/topology/components/visualization/CustomEdge.svelte index 20cb4d63..62c4afca 100644 --- a/ui/src/lib/features/topology/components/visualization/CustomEdge.svelte +++ b/ui/src/lib/features/topology/components/visualization/CustomEdge.svelte @@ -37,6 +37,7 @@ // Get group reactively - updates when groups store changes let group = $derived.by(() => { + if (!$topology?.groups) return null; if (edgeTypeMetadata.is_group_edge && 'group_id' in edgeData) { return $topology.groups.find((g) => g.id == edgeData.group_id) || null; } diff --git a/ui/src/lib/features/topology/components/visualization/InterfaceNode.svelte b/ui/src/lib/features/topology/components/visualization/InterfaceNode.svelte index 2f7d449d..56d867ec 100644 --- a/ui/src/lib/features/topology/components/visualization/InterfaceNode.svelte +++ b/ui/src/lib/features/topology/components/visualization/InterfaceNode.svelte @@ -16,9 +16,13 @@ height = height ? height : 0; width = width ? width : 0; - let host = $derived($topology.hosts.find((h) => h.id == nodeData.host_id)); + let host = $derived( + $topology ? $topology.hosts.find((h) => h.id == nodeData.host_id) : undefined + ); - let servicesForHost = $derived($topology.services.filter((s) => s.host_id == nodeData.host_id)); + let servicesForHost = $derived( + $topology ? $topology.services.filter((s) => s.host_id == nodeData.host_id) : [] + ); // Compute nodeRenderData reactively let nodeRenderData: NodeRenderData | null = $derived( @@ -27,14 +31,10 @@ const iface = host.interfaces.find((i) => i.id === data.interface_id); const servicesOnInterface = servicesForHost - ? servicesForHost.filter( - (s) => - s.bindings.some( - (b) => b.interface_id == null || (iface && b.interface_id == iface.id) - ) && - !$topologyOptions.request.hide_service_categories.includes( - serviceDefinitions.getCategory(s.service_definition) - ) + ? servicesForHost.filter((s) => + s.bindings.some( + (b) => b.interface_id == null || (iface && b.interface_id == iface.id) + ) ) : []; diff --git a/ui/src/lib/features/topology/components/visualization/SubnetNode.svelte b/ui/src/lib/features/topology/components/visualization/SubnetNode.svelte index 922bd43a..0630017c 100644 --- a/ui/src/lib/features/topology/components/visualization/SubnetNode.svelte +++ b/ui/src/lib/features/topology/components/visualization/SubnetNode.svelte @@ -22,7 +22,7 @@ let nodeStyle = $derived(`width: ${width}px; height: ${height}px;`); let hasInfra = $derived(infra_width > 0); - let subnet = $derived($topology.subnets.find((s) => s.id == id)); + let subnet = $derived($topology ? $topology.subnets.find((s) => s.id == id) : undefined); const viewport = useViewport(); let resizeHandleZoomLevel = $derived(viewport.current.zoom > 0.5); diff --git a/ui/src/lib/features/topology/components/visualization/TopologyViewer.svelte b/ui/src/lib/features/topology/components/visualization/TopologyViewer.svelte index 18aa758a..1fdf11eb 100644 --- a/ui/src/lib/features/topology/components/visualization/TopologyViewer.svelte +++ b/ui/src/lib/features/topology/components/visualization/TopologyViewer.svelte @@ -49,7 +49,7 @@ let pendingEdges: Edge[] = []; $effect(() => { - if ($topology?.edges || $topology?.nodes) { + if ($topology && ($topology.edges || $topology.nodes)) { void loadTopologyData(); } }); @@ -59,7 +59,7 @@ void $selectedNode; void $selectedEdge; - if ($topology.edges || $topology.nodes) { + if ($topology && ($topology.edges || $topology.nodes)) { const currentEdges = get(edges); const currentNodes = get(nodes); updateConnectedNodes($selectedNode, $selectedEdge, currentEdges, currentNodes); @@ -89,7 +89,7 @@ async function loadTopologyData() { try { - if ($topology?.nodes && $topology?.edges) { + if ($topology && ($topology.edges || $topology.nodes)) { // Create nodes FIRST const allNodes: Node[] = $topology.nodes.map((node) => ({ id: node.id, diff --git a/ui/src/lib/features/topology/sse.ts b/ui/src/lib/features/topology/sse.ts index f506702f..25e0c0dd 100644 --- a/ui/src/lib/features/topology/sse.ts +++ b/ui/src/lib/features/topology/sse.ts @@ -7,23 +7,13 @@ class TopologySSEManager extends BaseSSEManager { private stalenessTimers: Map> = new Map(); private readonly DEBOUNCE_MS = 300; - // Call this when a refresh completes to cancel pending staleness updates - public cancelPendingStaleness(topologyId: string) { - const existingTimer = this.stalenessTimers.get(topologyId); - if (existingTimer) { - clearTimeout(existingTimer); - this.stalenessTimers.delete(topologyId); - } - } - protected createConfig(): SSEConfig { return { url: '/api/topology/stream', onMessage: (update) => { - // If the update says it's NOT stale, apply immediately (it's a refresh) + // If the update says it's NOT stale, apply immediately (it's a full refresh) if (!update.is_stale) { - this.cancelPendingStaleness(update.id); - this.applyUpdate(update); + this.applyFullUpdate(update); return; } @@ -34,7 +24,14 @@ class TopologySSEManager extends BaseSSEManager { } const timer = setTimeout(() => { - this.applyUpdate(update); + this.applyPartialUpdate(update.id, { + removed_groups: update.removed_groups, + removed_hosts: update.removed_hosts, + removed_services: update.removed_services, + removed_subnets: update.removed_subnets, + is_stale: update.is_stale, + options: update.options + }); this.stalenessTimers.delete(update.id); }, this.DEBOUNCE_MS); @@ -49,25 +46,45 @@ class TopologySSEManager extends BaseSSEManager { }; } - private applyUpdate(update: Topology) { - // Update the topologies array + private applyFullUpdate(update: Topology) { topologies.update((topos) => { return topos.map((topo) => { if (topo.id === update.id) { - return { - ...update - }; + return update; } return topo; }); }); - // ALSO update the currently selected topology if it matches const currentTopology = get(topology); if (currentTopology && currentTopology.id === update.id) { topology.set(update); } } + + private applyPartialUpdate(topologyId: string, updates: Partial) { + topologies.update((topos) => { + return topos.map((topo) => { + if (topo.id === topologyId) { + return { + ...topo, + ...updates + }; + } + return topo; + }); + }); + + const currentTopology = get(topology); + if (currentTopology && currentTopology.id === topologyId) { + topology.update((topo) => { + return { + ...topo, + ...updates + }; + }); + } + } } export const topologySSEManager = new TopologySSEManager(); diff --git a/ui/src/lib/features/topology/state.ts b/ui/src/lib/features/topology/state.ts index ff0f41ab..00282852 100644 --- a/ui/src/lib/features/topology/state.ts +++ b/ui/src/lib/features/topology/state.ts @@ -26,13 +26,12 @@ export interface TopologyStateConfig extends TopologyStateInfo { export function getTopologyStateInfo(topology: Topology): TopologyStateInfo { // Locked state if (topology.is_locked) { - const lockedDate = topology.locked_at ? new Date(topology.locked_at).toLocaleDateString() : ''; return { type: 'locked', icon: Lock, color: 'blue', class: 'btn-info', - buttonText: `Locked ${lockedDate}`.trim(), + buttonText: 'Locked', label: 'Locked' }; } diff --git a/ui/src/lib/features/topology/store.ts b/ui/src/lib/features/topology/store.ts index 3061db96..13058966 100644 --- a/ui/src/lib/features/topology/store.ts +++ b/ui/src/lib/features/topology/store.ts @@ -1,12 +1,16 @@ import { get, writable } from 'svelte/store'; import { api } from '../../shared/utils/api'; import { type Edge, type Node } from '@xyflow/svelte'; -import { EdgeHandle, type Topology, type TopologyOptions } from './types/base'; +import { type Topology, type TopologyOptions } from './types/base'; import { networks } from '../networks/store'; import deepmerge from 'deepmerge'; import { browser } from '$app/environment'; import { utcTimeZoneSentinel, uuidv4Sentinel } from '$lib/shared/utils/formatting'; +let initialized = false; +let topologyInitialized = false; +let lastTopologyId = ''; + export const topologies = writable([]); export const topology = writable(); export const selectedNetwork = writable(''); @@ -38,37 +42,65 @@ const defaultOptions: TopologyOptions = { export const topologyOptions = writable(loadOptionsFromStorage()); export const optionsPanelExpanded = writable(loadExpandedFromStorage()); -let topologyInitialized = false; -let lastTopologyId = ''; +function initializeSubscriptions() { + if (initialized) { + return; + } -if (browser) { - topologies.subscribe(($topologies) => { - if (!topologyInitialized && $topologies.length > 0) { - topology.set($topologies[0]); - lastTopologyId = $topologies[0].id; - topologyInitialized = true; - } - }); + initialized = true; - // Subscribe to options changes and save to localStorage + update topology object - if (typeof window !== 'undefined') { - topologyOptions.subscribe((options) => { - saveOptionsToStorage(options); - }); - - topology.subscribe((topology) => { - if (topology && lastTopologyId != topology.id) { - lastTopologyId = topology.id; - topologyOptions.set(topology.options); + if (browser) { + topologies.subscribe(($topologies) => { + if (!topologyInitialized && $topologies.length > 0) { + const currentTopology = $topologies[0]; + topology.set(currentTopology); + topologyOptions.set(currentTopology.options); + lastTopologyId = currentTopology.id; + topologyInitialized = true; } }); - optionsPanelExpanded.subscribe((expanded) => { - saveExpandedToStorage(expanded); - }); + if (typeof window !== 'undefined') { + let optionsUpdateTimeout: ReturnType | null = null; + + topologyOptions.subscribe(async (options) => { + saveOptionsToStorage(options); + + // Clear any pending timeout + if (optionsUpdateTimeout) { + clearTimeout(optionsUpdateTimeout); + } + + // Debounce the API call + optionsUpdateTimeout = setTimeout(async () => { + const currentTopology = get(topology); + if (currentTopology) { + const updatedTopology = { + ...currentTopology, + options: options + }; + await updateTopology(updatedTopology); + } + }, 500); + }); + + topology.subscribe((topology) => { + if (topology && lastTopologyId != topology.id) { + lastTopologyId = topology.id; + topologyOptions.set(topology.options); + } + }); + + optionsPanelExpanded.subscribe((expanded) => { + saveExpandedToStorage(expanded); + }); + } } } +// Initialize immediately +initializeSubscriptions(); + export function resetTopologyOptions(): void { // networksInitialized = false; topologyOptions.set(structuredClone(defaultOptions)); @@ -137,7 +169,8 @@ function saveExpandedToStorage(expanded: boolean): void { } export async function refreshTopology(data: Topology) { - const result = await api.request( + // Updated topology returns through SSE + await api.request( `/topology/${data.id}/refresh`, topologies, (updated, current) => current.map((t) => (t.id == updated.id ? updated : t)), @@ -146,38 +179,6 @@ export async function refreshTopology(data: Topology) { body: JSON.stringify(data) } ); - - if (result && result.success && result.data) { - if (get(topology)?.id === data.id) { - topology.set(result.data); - } - } - - return result; -} - -export async function rebuildTopology(data: Topology) { - const result = await api.request( - `/topology/${data.id}/rebuild`, - topologies, - (updated, current) => current.map((t) => (t.id == updated.id ? updated : t)), - { - method: 'POST', - body: JSON.stringify(data) - } - ); - - if (result && result.success && result.data) { - // Cancel any pending staleness updates since we just refreshed - const { topologySSEManager } = await import('./sse'); - topologySSEManager.cancelPendingStaleness(data.id); - - if (get(topology)?.id === data.id) { - topology.set(result.data); - } - } - - return result; } export async function lockTopology(data: Topology) { @@ -217,15 +218,25 @@ export async function unlockTopology(data: Topology) { } export async function getTopologies() { - const result = await api.request( - '/topology', - topologies, - (topologies) => topologies, - { - method: 'GET' - } - ); - return result; + await api.request('/topology', topologies, (topologies) => topologies, { + method: 'GET' + }); +} + +export async function rebuildTopology(data: Topology) { + // Updated topology returns through SSE + await api.request(`/topology/${data.id}/rebuild`, null, null, { + method: 'POST', + body: JSON.stringify(data) + }); +} + +export async function updateTopology(data: Topology) { + // Updated topology returns through SSE + await api.request(`/topology/${data.id}`, null, null, { + method: 'PUT', + body: JSON.stringify(data) + }); } export async function createTopology(data: Topology) { @@ -244,31 +255,16 @@ export async function createTopology(data: Topology) { } export async function deleteTopology(id: string) { - await api.request( + const result = await api.request( `/topology/${id}`, topologies, (_, current) => current.filter((t) => t.id != id), { method: 'DELETE' } ); -} - -export async function updateTopology(data: Topology) { - const result = await api.request( - `/topology/${data.id}`, - topologies, - (updatedTopology, current) => current.map((t) => (t.id === data.id ? updatedTopology : t)), - { method: 'PUT', body: JSON.stringify(data) } - ); - return result; -} - -// Cycle through anchor positions in logical order -export function getNextHandle(currentHandle: EdgeHandle): EdgeHandle { - const cycle = [EdgeHandle.Top, EdgeHandle.Right, EdgeHandle.Bottom, EdgeHandle.Left]; - const currentIndex = cycle.indexOf(currentHandle); - const nextIndex = (currentIndex + 1) % cycle.length; - return cycle[nextIndex]; + if (result && result.data && result.success && get(topologies).length > 0) { + topology.set(get(topologies)[0]); + } } export function createEmptyTopologyFormData(): Topology { diff --git a/ui/src/lib/shared/utils/formatting.ts b/ui/src/lib/shared/utils/formatting.ts index 1b63b678..6812079a 100644 --- a/ui/src/lib/shared/utils/formatting.ts +++ b/ui/src/lib/shared/utils/formatting.ts @@ -33,7 +33,6 @@ export function formatTimestamp(timestamp: string): string { day: 'numeric', hour: '2-digit', minute: '2-digit', - second: '2-digit', hour12: false }); } catch { From 2f50168184603809e14ca81a21e0cd79ffc7b2ff Mon Sep 17 00:00:00 2001 From: Maya Date: Sat, 22 Nov 2025 00:31:22 -0500 Subject: [PATCH 25/27] add connection info to server --- backend/src/bin/server.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/bin/server.rs b/backend/src/bin/server.rs index db87a9ac..1e10953d 100644 --- a/backend/src/bin/server.rs +++ b/backend/src/bin/server.rs @@ -1,4 +1,4 @@ -use std::{sync::Arc, time::Duration}; +use std::{net::SocketAddr, sync::Arc, time::Duration}; use axum::{ Extension, Router, @@ -267,7 +267,7 @@ async fn main() -> anyhow::Result<()> { // Spawn server in background tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); + axum::serve(listener, app.into_make_service_with_connect_info::()).await.unwrap(); }); // Start cron for discovery scheduler From cd598d42f62f0576406b180c272a507555a600a8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 22 Nov 2025 05:45:09 +0000 Subject: [PATCH 26/27] chore: update test fixtures for release v0.10.2 --- backend/src/tests/daemon_config.json | 8 +- backend/src/tests/netvisor.sql | 169 +++++++++++++++++++-------- ui/static/services.json | 78 +++++++++---- 3 files changed, 176 insertions(+), 79 deletions(-) diff --git a/backend/src/tests/daemon_config.json b/backend/src/tests/daemon_config.json index 56ba7771..5061c2ea 100644 --- a/backend/src/tests/daemon_config.json +++ b/backend/src/tests/daemon_config.json @@ -1,6 +1,6 @@ { "server_url": "http://server:60072", - "network_id": "f94407b3-bad9-4338-bbfc-7ad5cb0c039a", + "network_id": "169c1636-c0f2-4883-950c-6d3a40053110", "server_target": null, "server_port": null, "daemon_port": 60073, @@ -9,10 +9,10 @@ "heartbeat_interval": 30, "bind_address": "0.0.0.0", "concurrent_scans": 15, - "id": "62c6aac2-3f4c-41e7-a741-d0be2d8c0db0", + "id": "1dd07be9-cbe2-446c-877d-6df902e372fb", "last_heartbeat": null, - "host_id": "7196b058-3317-4da1-a13e-09e60d5cc77c", - "daemon_api_key": "0e065bb45698437d8f85d3c11cde6626", + "host_id": "d69fac63-ee47-40a2-a09a-773d45109cc4", + "daemon_api_key": "ca1f36a8fe5c4958b608375891f41661", "docker_proxy": null, "mode": "Push" } \ No newline at end of file diff --git a/backend/src/tests/netvisor.sql b/backend/src/tests/netvisor.sql index 989135fc..bf1fe9a5 100644 --- a/backend/src/tests/netvisor.sql +++ b/backend/src/tests/netvisor.sql @@ -2,7 +2,7 @@ -- PostgreSQL database dump -- -\restrict lAZKgSAeM5hq62C1gqi2XwedG103NUJLKDgNJYiEQyRVHgLbbzWsShfqwVIjEzP +\restrict iT1L5Xfu7uX8R7DBaYH8wyZJM8XMSgKaqFOpXGfCFJRoXlLxXBHbY4EydMBB7jt -- Dumped from database version 17.7 -- Dumped by pg_dump version 17.7 @@ -20,6 +20,7 @@ SET client_min_messages = warning; SET row_security = off; ALTER TABLE IF EXISTS ONLY public.users DROP CONSTRAINT IF EXISTS users_organization_id_fkey; +ALTER TABLE IF EXISTS ONLY public.topologies DROP CONSTRAINT IF EXISTS topologies_network_id_fkey; ALTER TABLE IF EXISTS ONLY public.subnets DROP CONSTRAINT IF EXISTS subnets_network_id_fkey; ALTER TABLE IF EXISTS ONLY public.services DROP CONSTRAINT IF EXISTS services_network_id_fkey; ALTER TABLE IF EXISTS ONLY public.services DROP CONSTRAINT IF EXISTS services_host_id_fkey; @@ -33,6 +34,7 @@ ALTER TABLE IF EXISTS ONLY public.api_keys DROP CONSTRAINT IF EXISTS api_keys_ne DROP INDEX IF EXISTS public.idx_users_organization; DROP INDEX IF EXISTS public.idx_users_oidc_provider_subject; DROP INDEX IF EXISTS public.idx_users_email_lower; +DROP INDEX IF EXISTS public.idx_topologies_network; DROP INDEX IF EXISTS public.idx_subnets_network; DROP INDEX IF EXISTS public.idx_services_network; DROP INDEX IF EXISTS public.idx_services_host_id; @@ -48,6 +50,7 @@ DROP INDEX IF EXISTS public.idx_api_keys_network; DROP INDEX IF EXISTS public.idx_api_keys_key; ALTER TABLE IF EXISTS ONLY tower_sessions.session DROP CONSTRAINT IF EXISTS session_pkey; ALTER TABLE IF EXISTS ONLY public.users DROP CONSTRAINT IF EXISTS users_pkey; +ALTER TABLE IF EXISTS ONLY public.topologies DROP CONSTRAINT IF EXISTS topologies_pkey; ALTER TABLE IF EXISTS ONLY public.subnets DROP CONSTRAINT IF EXISTS subnets_pkey; ALTER TABLE IF EXISTS ONLY public.services DROP CONSTRAINT IF EXISTS services_pkey; ALTER TABLE IF EXISTS ONLY public.organizations DROP CONSTRAINT IF EXISTS organizations_pkey; @@ -61,6 +64,7 @@ ALTER TABLE IF EXISTS ONLY public.api_keys DROP CONSTRAINT IF EXISTS api_keys_ke ALTER TABLE IF EXISTS ONLY public._sqlx_migrations DROP CONSTRAINT IF EXISTS _sqlx_migrations_pkey; DROP TABLE IF EXISTS tower_sessions.session; DROP TABLE IF EXISTS public.users; +DROP TABLE IF EXISTS public.topologies; DROP TABLE IF EXISTS public.subnets; DROP TABLE IF EXISTS public.services; DROP TABLE IF EXISTS public.organizations; @@ -190,7 +194,7 @@ CREATE TABLE public.hosts ( description text, target jsonb NOT NULL, interfaces jsonb, - services jsonb, + services uuid[], ports jsonb, source jsonb NOT NULL, virtualization jsonb, @@ -296,6 +300,38 @@ CREATE TABLE public.subnets ( ALTER TABLE public.subnets OWNER TO postgres; +-- +-- Name: topologies; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public.topologies ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + network_id uuid NOT NULL, + name text NOT NULL, + edges jsonb NOT NULL, + nodes jsonb NOT NULL, + options jsonb NOT NULL, + hosts jsonb NOT NULL, + subnets jsonb NOT NULL, + services jsonb NOT NULL, + groups jsonb NOT NULL, + is_stale boolean, + last_refreshed timestamp with time zone DEFAULT now() NOT NULL, + is_locked boolean, + locked_at timestamp with time zone, + locked_by uuid, + removed_hosts uuid[], + removed_services uuid[], + removed_subnets uuid[], + removed_groups uuid[], + parent_id uuid, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL +); + + +ALTER TABLE public.topologies OWNER TO postgres; + -- -- Name: users; Type: TABLE; Schema: public; Owner: postgres -- @@ -348,24 +384,25 @@ ALTER TABLE tower_sessions.session OWNER TO postgres; -- COPY public._sqlx_migrations (version, description, installed_on, success, checksum, execution_time) FROM stdin; -20251006215000 users 2025-11-19 23:32:54.300542+00 t \\x4f13ce14ff67ef0b7145987c7b22b588745bf9fbb7b673450c26a0f2f9a36ef8ca980e456c8d77cfb1b2d7a4577a64d7 3390316 -20251006215100 networks 2025-11-19 23:32:54.30461+00 t \\xeaa5a07a262709f64f0c59f31e25519580c79e2d1a523ce72736848946a34b17dd9adc7498eaf90551af6b7ec6d4e0e3 3713737 -20251006215151 create hosts 2025-11-19 23:32:54.308648+00 t \\x6ec7487074c0724932d21df4cf1ed66645313cf62c159a7179e39cbc261bcb81a24f7933a0e3cf58504f2a90fc5c1962 3762878 -20251006215155 create subnets 2025-11-19 23:32:54.312737+00 t \\xefb5b25742bd5f4489b67351d9f2494a95f307428c911fd8c5f475bfb03926347bdc269bbd048d2ddb06336945b27926 3564590 -20251006215201 create groups 2025-11-19 23:32:54.316652+00 t \\x0a7032bf4d33a0baf020e905da865cde240e2a09dda2f62aa535b2c5d4b26b20be30a3286f1b5192bd94cd4a5dbb5bcd 3708206 -20251006215204 create daemons 2025-11-19 23:32:54.320694+00 t \\xcfea93403b1f9cf9aac374711d4ac72d8a223e3c38a1d2a06d9edb5f94e8a557debac3668271f8176368eadc5105349f 4278937 -20251006215212 create services 2025-11-19 23:32:54.32532+00 t \\xd5b07f82fc7c9da2782a364d46078d7d16b5c08df70cfbf02edcfe9b1b24ab6024ad159292aeea455f15cfd1f4740c1d 4883509 -20251029193448 user-auth 2025-11-19 23:32:54.330535+00 t \\xfde8161a8db89d51eeade7517d90a41d560f19645620f2298f78f116219a09728b18e91251ae31e46a47f6942d5a9032 3470214 -20251030044828 daemon api 2025-11-19 23:32:54.334316+00 t \\x181eb3541f51ef5b038b2064660370775d1b364547a214a20dde9c9d4bb95a1c273cd4525ef29e61fa65a3eb4fee0400 1676323 -20251030170438 host-hide 2025-11-19 23:32:54.336288+00 t \\x87c6fda7f8456bf610a78e8e98803158caa0e12857c5bab466a5bb0004d41b449004a68e728ca13f17e051f662a15454 1076640 -20251102224919 create discovery 2025-11-19 23:32:54.337666+00 t \\xb32a04abb891aba48f92a059fae7341442355ca8e4af5d109e28e2a4f79ee8e11b2a8f40453b7f6725c2dd6487f26573 9268893 -20251106235621 normalize-daemon-cols 2025-11-19 23:32:54.347274+00 t \\x5b137118d506e2708097c432358bf909265b3cf3bacd662b02e2c81ba589a9e0100631c7801cffd9c57bb10a6674fb3b 2522124 -20251107034459 api keys 2025-11-19 23:32:54.350094+00 t \\x3133ec043c0c6e25b6e55f7da84cae52b2a72488116938a2c669c8512c2efe72a74029912bcba1f2a2a0a8b59ef01dde 10725899 -20251107222650 oidc-auth 2025-11-19 23:32:54.36118+00 t \\xd349750e0298718cbcd98eaff6e152b3fb45c3d9d62d06eedeb26c75452e9ce1af65c3e52c9f2de4bd532939c2f31096 20024548 -20251110181948 orgs-billing 2025-11-19 23:32:54.381546+00 t \\x5bbea7a2dfc9d00213bd66b473289ddd66694eff8a4f3eaab937c985b64c5f8c3ad2d64e960afbb03f335ac6766687aa 10027372 -20251113223656 group-enhancements 2025-11-19 23:32:54.391931+00 t \\xbe0699486d85df2bd3edc1f0bf4f1f096d5b6c5070361702c4d203ec2bb640811be88bb1979cfe51b40805ad84d1de65 993434 -20251117032720 daemon-mode 2025-11-19 23:32:54.393217+00 t \\xdd0d899c24b73d70e9970e54b2c748d6b6b55c856ca0f8590fe990da49cc46c700b1ce13f57ff65abd6711f4bd8a6481 1093611 -20251118143058 set-default-plan 2025-11-19 23:32:54.394615+00 t \\xd19142607aef84aac7cfb97d60d29bda764d26f513f2c72306734c03cec2651d23eee3ce6cacfd36ca52dbddc462f917 1264388 +20251006215000 users 2025-11-22 05:43:48.457668+00 t \\x4f13ce14ff67ef0b7145987c7b22b588745bf9fbb7b673450c26a0f2f9a36ef8ca980e456c8d77cfb1b2d7a4577a64d7 4725095 +20251006215100 networks 2025-11-22 05:43:48.463753+00 t \\xeaa5a07a262709f64f0c59f31e25519580c79e2d1a523ce72736848946a34b17dd9adc7498eaf90551af6b7ec6d4e0e3 4870373 +20251006215151 create hosts 2025-11-22 05:43:48.468989+00 t \\x6ec7487074c0724932d21df4cf1ed66645313cf62c159a7179e39cbc261bcb81a24f7933a0e3cf58504f2a90fc5c1962 4550465 +20251006215155 create subnets 2025-11-22 05:43:48.47397+00 t \\xefb5b25742bd5f4489b67351d9f2494a95f307428c911fd8c5f475bfb03926347bdc269bbd048d2ddb06336945b27926 4713851 +20251006215201 create groups 2025-11-22 05:43:48.479135+00 t \\x0a7032bf4d33a0baf020e905da865cde240e2a09dda2f62aa535b2c5d4b26b20be30a3286f1b5192bd94cd4a5dbb5bcd 6298983 +20251006215204 create daemons 2025-11-22 05:43:48.485844+00 t \\xcfea93403b1f9cf9aac374711d4ac72d8a223e3c38a1d2a06d9edb5f94e8a557debac3668271f8176368eadc5105349f 4807546 +20251006215212 create services 2025-11-22 05:43:48.491035+00 t \\xd5b07f82fc7c9da2782a364d46078d7d16b5c08df70cfbf02edcfe9b1b24ab6024ad159292aeea455f15cfd1f4740c1d 5889097 +20251029193448 user-auth 2025-11-22 05:43:48.497286+00 t \\xfde8161a8db89d51eeade7517d90a41d560f19645620f2298f78f116219a09728b18e91251ae31e46a47f6942d5a9032 7823826 +20251030044828 daemon api 2025-11-22 05:43:48.505447+00 t \\x181eb3541f51ef5b038b2064660370775d1b364547a214a20dde9c9d4bb95a1c273cd4525ef29e61fa65a3eb4fee0400 1668178 +20251030170438 host-hide 2025-11-22 05:43:48.507434+00 t \\x87c6fda7f8456bf610a78e8e98803158caa0e12857c5bab466a5bb0004d41b449004a68e728ca13f17e051f662a15454 1203209 +20251102224919 create discovery 2025-11-22 05:43:48.509024+00 t \\xb32a04abb891aba48f92a059fae7341442355ca8e4af5d109e28e2a4f79ee8e11b2a8f40453b7f6725c2dd6487f26573 11800605 +20251106235621 normalize-daemon-cols 2025-11-22 05:43:48.521173+00 t \\x5b137118d506e2708097c432358bf909265b3cf3bacd662b02e2c81ba589a9e0100631c7801cffd9c57bb10a6674fb3b 1865677 +20251107034459 api keys 2025-11-22 05:43:48.523447+00 t \\x3133ec043c0c6e25b6e55f7da84cae52b2a72488116938a2c669c8512c2efe72a74029912bcba1f2a2a0a8b59ef01dde 8372329 +20251107222650 oidc-auth 2025-11-22 05:43:48.532248+00 t \\xd349750e0298718cbcd98eaff6e152b3fb45c3d9d62d06eedeb26c75452e9ce1af65c3e52c9f2de4bd532939c2f31096 28884420 +20251110181948 orgs-billing 2025-11-22 05:43:48.561771+00 t \\x5bbea7a2dfc9d00213bd66b473289ddd66694eff8a4f3eaab937c985b64c5f8c3ad2d64e960afbb03f335ac6766687aa 11642928 +20251113223656 group-enhancements 2025-11-22 05:43:48.573825+00 t \\xbe0699486d85df2bd3edc1f0bf4f1f096d5b6c5070361702c4d203ec2bb640811be88bb1979cfe51b40805ad84d1de65 1138748 +20251117032720 daemon-mode 2025-11-22 05:43:48.575551+00 t \\xdd0d899c24b73d70e9970e54b2c748d6b6b55c856ca0f8590fe990da49cc46c700b1ce13f57ff65abd6711f4bd8a6481 1219840 +20251118143058 set-default-plan 2025-11-22 05:43:48.577211+00 t \\xd19142607aef84aac7cfb97d60d29bda764d26f513f2c72306734c03cec2651d23eee3ce6cacfd36ca52dbddc462f917 1264734 +20251118225043 save-topology 2025-11-22 05:43:48.578761+00 t \\x011a594740c69d8d0f8b0149d49d1b53cfbf948b7866ebd84403394139cb66a44277803462846b06e762577adc3e61a3 9338796 \. @@ -374,7 +411,7 @@ COPY public._sqlx_migrations (version, description, installed_on, success, check -- COPY public.api_keys (id, key, network_id, name, created_at, updated_at, last_used, expires_at, is_enabled) FROM stdin; -d0e5e6b5-e66b-48bd-8c0c-26b5247ac930 0e065bb45698437d8f85d3c11cde6626 f94407b3-bad9-4338-bbfc-7ad5cb0c039a Integrated Daemon API Key 2025-11-19 23:32:57.127527+00 2025-11-19 23:34:04.51201+00 2025-11-19 23:34:04.511636+00 \N t +a5f847c2-c702-4407-87ef-b6518527fffe ca1f36a8fe5c4958b608375891f41661 169c1636-c0f2-4883-950c-6d3a40053110 Integrated Daemon API Key 2025-11-22 05:43:51.674319+00 2025-11-22 05:44:45.056692+00 2025-11-22 05:44:45.055948+00 \N t \. @@ -383,7 +420,7 @@ d0e5e6b5-e66b-48bd-8c0c-26b5247ac930 0e065bb45698437d8f85d3c11cde6626 f94407b3-b -- COPY public.daemons (id, network_id, host_id, ip, port, created_at, last_seen, capabilities, updated_at, mode) FROM stdin; -62c6aac2-3f4c-41e7-a741-d0be2d8c0db0 f94407b3-bad9-4338-bbfc-7ad5cb0c039a 7196b058-3317-4da1-a13e-09e60d5cc77c "172.25.0.4" 60073 2025-11-19 23:32:57.179357+00 2025-11-19 23:32:57.179355+00 {"has_docker_socket": false, "interfaced_subnet_ids": ["f9f59dd7-2e67-4e82-aa8b-3d9d5d2e5933"]} 2025-11-19 23:32:57.197292+00 "Push" +1dd07be9-cbe2-446c-877d-6df902e372fb 169c1636-c0f2-4883-950c-6d3a40053110 d69fac63-ee47-40a2-a09a-773d45109cc4 "172.25.0.4" 60073 2025-11-22 05:43:51.726152+00 2025-11-22 05:43:51.726151+00 {"has_docker_socket": false, "interfaced_subnet_ids": ["a54ff32d-28fe-49c8-b699-225be914a185"]} 2025-11-22 05:43:51.770662+00 "Push" \. @@ -392,10 +429,10 @@ COPY public.daemons (id, network_id, host_id, ip, port, created_at, last_seen, c -- COPY public.discovery (id, network_id, daemon_id, run_type, discovery_type, name, created_at, updated_at) FROM stdin; -d8d9b387-25f8-4d1f-8b7e-406ffefbbc83 f94407b3-bad9-4338-bbfc-7ad5cb0c039a 62c6aac2-3f4c-41e7-a741-d0be2d8c0db0 {"type": "Scheduled", "enabled": true, "last_run": null, "cron_schedule": "0 0 0 * * *"} {"type": "SelfReport", "host_id": "7196b058-3317-4da1-a13e-09e60d5cc77c"} Self Report @ 172.25.0.4 2025-11-19 23:32:57.180718+00 2025-11-19 23:32:57.180718+00 -aa979531-b8a5-4328-9eb1-1f89eabee4bf f94407b3-bad9-4338-bbfc-7ad5cb0c039a 62c6aac2-3f4c-41e7-a741-d0be2d8c0db0 {"type": "Scheduled", "enabled": true, "last_run": null, "cron_schedule": "0 0 0 * * *"} {"type": "Network", "subnet_ids": null, "host_naming_fallback": "BestService"} Network Scan @ 172.25.0.4 2025-11-19 23:32:57.187198+00 2025-11-19 23:32:57.187198+00 -dfb94381-7591-43b8-80a9-f955d78adffc f94407b3-bad9-4338-bbfc-7ad5cb0c039a 62c6aac2-3f4c-41e7-a741-d0be2d8c0db0 {"type": "Historical", "results": {"error": null, "phase": "Complete", "daemon_id": "62c6aac2-3f4c-41e7-a741-d0be2d8c0db0", "processed": 1, "network_id": "f94407b3-bad9-4338-bbfc-7ad5cb0c039a", "session_id": "e64dc3d6-7b23-4440-9b9d-1255129bb1d4", "started_at": "2025-11-19T23:32:57.186849674Z", "finished_at": "2025-11-19T23:32:57.243744495Z", "discovery_type": {"type": "SelfReport", "host_id": "7196b058-3317-4da1-a13e-09e60d5cc77c"}, "total_to_process": 1}} {"type": "SelfReport", "host_id": "7196b058-3317-4da1-a13e-09e60d5cc77c"} Discovery Run 2025-11-19 23:32:57.186849+00 2025-11-19 23:32:57.245121+00 -12a9c028-f621-49cb-9502-ac54d1469ade f94407b3-bad9-4338-bbfc-7ad5cb0c039a 62c6aac2-3f4c-41e7-a741-d0be2d8c0db0 {"type": "Historical", "results": {"error": null, "phase": "Complete", "daemon_id": "62c6aac2-3f4c-41e7-a741-d0be2d8c0db0", "processed": 11, "network_id": "f94407b3-bad9-4338-bbfc-7ad5cb0c039a", "session_id": "807e651e-0783-421d-9f47-359d5e1db5f0", "started_at": "2025-11-19T23:32:57.252659275Z", "finished_at": "2025-11-19T23:34:04.510764904Z", "discovery_type": {"type": "Network", "subnet_ids": null, "host_naming_fallback": "BestService"}, "total_to_process": 16}} {"type": "Network", "subnet_ids": null, "host_naming_fallback": "BestService"} Discovery Run 2025-11-19 23:32:57.252659+00 2025-11-19 23:34:04.511941+00 +10f87675-c882-4b8d-b8eb-8f2f3ed03351 169c1636-c0f2-4883-950c-6d3a40053110 1dd07be9-cbe2-446c-877d-6df902e372fb {"type": "Scheduled", "enabled": true, "last_run": null, "cron_schedule": "0 0 0 * * *"} {"type": "SelfReport", "host_id": "d69fac63-ee47-40a2-a09a-773d45109cc4"} Self Report @ 172.25.0.4 2025-11-22 05:43:51.727754+00 2025-11-22 05:43:51.727754+00 +8df26956-2c78-4300-9638-e5fded47dcde 169c1636-c0f2-4883-950c-6d3a40053110 1dd07be9-cbe2-446c-877d-6df902e372fb {"type": "Scheduled", "enabled": true, "last_run": null, "cron_schedule": "0 0 0 * * *"} {"type": "Network", "subnet_ids": null, "host_naming_fallback": "BestService"} Network Scan @ 172.25.0.4 2025-11-22 05:43:51.73562+00 2025-11-22 05:43:51.73562+00 +773ad504-5482-43f5-8b52-e30b303f0bbd 169c1636-c0f2-4883-950c-6d3a40053110 1dd07be9-cbe2-446c-877d-6df902e372fb {"type": "Historical", "results": {"error": null, "phase": "Complete", "daemon_id": "1dd07be9-cbe2-446c-877d-6df902e372fb", "processed": 1, "network_id": "169c1636-c0f2-4883-950c-6d3a40053110", "session_id": "9cfbdcf5-cb3d-48a5-8dbb-d639953c06d2", "started_at": "2025-11-22T05:43:51.735152452Z", "finished_at": "2025-11-22T05:43:51.784747423Z", "discovery_type": {"type": "SelfReport", "host_id": "d69fac63-ee47-40a2-a09a-773d45109cc4"}, "total_to_process": 1}} {"type": "SelfReport", "host_id": "d69fac63-ee47-40a2-a09a-773d45109cc4"} Discovery Run 2025-11-22 05:43:51.735152+00 2025-11-22 05:43:51.786976+00 +8f1b33fa-d696-4b1a-a02e-28a604b2542d 169c1636-c0f2-4883-950c-6d3a40053110 1dd07be9-cbe2-446c-877d-6df902e372fb {"type": "Historical", "results": {"error": null, "phase": "Complete", "daemon_id": "1dd07be9-cbe2-446c-877d-6df902e372fb", "processed": 13, "network_id": "169c1636-c0f2-4883-950c-6d3a40053110", "session_id": "53554442-a32e-4750-9f1c-289fbb9a8d9d", "started_at": "2025-11-22T05:43:51.795758416Z", "finished_at": "2025-11-22T05:44:45.055039140Z", "discovery_type": {"type": "Network", "subnet_ids": null, "host_naming_fallback": "BestService"}, "total_to_process": 16}} {"type": "Network", "subnet_ids": null, "host_naming_fallback": "BestService"} Discovery Run 2025-11-22 05:43:51.795758+00 2025-11-22 05:44:45.056201+00 \. @@ -412,14 +449,13 @@ COPY public.groups (id, network_id, name, description, group_type, created_at, u -- COPY public.hosts (id, network_id, name, hostname, description, target, interfaces, services, ports, source, virtualization, created_at, updated_at, hidden) FROM stdin; -c1584db9-398b-4eaf-99f1-601481685fcf f94407b3-bad9-4338-bbfc-7ad5cb0c039a Cloudflare DNS \N \N {"type": "ServiceBinding", "config": "7f6f10ee-a546-4b63-885b-dc4ba3852ac2"} [{"id": "0da9276e-38f0-4cd2-9ec2-d829d3a47fc7", "name": "Internet", "subnet_id": "af6fe772-b27b-4aa9-bea2-9cdff1d452f4", "ip_address": "1.1.1.1", "mac_address": null}] ["beefc1ab-f7d3-475c-bacb-55c1e2497548"] [{"id": "3dbee162-0264-4bdd-870b-c55537e2fd4d", "type": "DnsUdp", "number": 53, "protocol": "Udp"}] {"type": "System"} null 2025-11-19 23:32:57.109555+00 2025-11-19 23:32:57.118236+00 f -1ed19ab2-8571-4866-8294-e878d2b72490 f94407b3-bad9-4338-bbfc-7ad5cb0c039a Google.com \N \N {"type": "ServiceBinding", "config": "e7f3eb32-56a8-4e89-8a8f-be208de56fe3"} [{"id": "f58135c7-8cd8-483d-9598-19a21aa387af", "name": "Internet", "subnet_id": "af6fe772-b27b-4aa9-bea2-9cdff1d452f4", "ip_address": "203.0.113.221", "mac_address": null}] ["0b16f609-0a4b-4c34-ba35-7361238d04c2"] [{"id": "0fdf3e3e-9427-4f9c-bc51-e72d497d8b21", "type": "Https", "number": 443, "protocol": "Tcp"}] {"type": "System"} null 2025-11-19 23:32:57.109561+00 2025-11-19 23:32:57.123063+00 f -c966e55e-1d55-45ba-9834-4913a01d97c3 f94407b3-bad9-4338-bbfc-7ad5cb0c039a Mobile Device \N A mobile device connecting from a remote network {"type": "ServiceBinding", "config": "fe66aebf-7c08-4412-a17e-5343518e3b47"} [{"id": "58c8bff4-cff6-4fa9-9c69-c2152a8237f8", "name": "Remote Network", "subnet_id": "97a944fb-691b-4109-bcc0-c559d282133c", "ip_address": "203.0.113.8", "mac_address": null}] ["8dd44f7b-c5e9-4b5b-bda0-2ec8a24a5d51"] [{"id": "bb49dd89-3d8f-4f06-bcd1-7f58eacb5dcc", "type": "Custom", "number": 0, "protocol": "Tcp"}] {"type": "System"} null 2025-11-19 23:32:57.109566+00 2025-11-19 23:32:57.126768+00 f -37f494eb-eec7-48f6-a466-1ddf7b965300 f94407b3-bad9-4338-bbfc-7ad5cb0c039a homeassistant-discovery.netvisor_netvisor-dev homeassistant-discovery.netvisor_netvisor-dev \N {"type": "Hostname"} [{"id": "c2c0c2b9-b6ef-4324-860e-eb4023dcd0c2", "name": null, "subnet_id": "f9f59dd7-2e67-4e82-aa8b-3d9d5d2e5933", "ip_address": "172.25.0.5", "mac_address": "32:5B:68:7A:33:86"}] ["ab847e05-4db2-43b5-bc80-c8c4b2e280bd"] [{"id": "5d713bf4-24d7-4c5e-a33b-f2825d427dd0", "type": "Custom", "number": 8123, "protocol": "Tcp"}] {"type": "Discovery", "metadata": [{"date": "2025-11-19T23:33:14.173425530Z", "type": "Network", "daemon_id": "62c6aac2-3f4c-41e7-a741-d0be2d8c0db0", "subnet_ids": null, "host_naming_fallback": "BestService"}]} null 2025-11-19 23:33:14.173428+00 2025-11-19 23:33:28.843367+00 f -9b3dcca7-c0b2-4b50-9bdc-5050ccf00d0d f94407b3-bad9-4338-bbfc-7ad5cb0c039a netvisor-postgres-dev-1.netvisor_netvisor-dev netvisor-postgres-dev-1.netvisor_netvisor-dev \N {"type": "Hostname"} [{"id": "1b4c4739-c0e6-4a0b-a1fd-c43cbde68610", "name": null, "subnet_id": "f9f59dd7-2e67-4e82-aa8b-3d9d5d2e5933", "ip_address": "172.25.0.6", "mac_address": "C6:67:1C:A0:F1:40"}] ["2f06a2aa-5ff7-4724-9c12-afe70799ddc4"] [{"id": "6464217a-c686-402d-9a7d-782e5373e1e5", "type": "PostgreSQL", "number": 5432, "protocol": "Tcp"}] {"type": "Discovery", "metadata": [{"date": "2025-11-19T23:33:28.964846546Z", "type": "Network", "daemon_id": "62c6aac2-3f4c-41e7-a741-d0be2d8c0db0", "subnet_ids": null, "host_naming_fallback": "BestService"}]} null 2025-11-19 23:33:28.964848+00 2025-11-19 23:33:43.605204+00 f -452b8825-df24-4cff-a34a-9a665be361da f94407b3-bad9-4338-bbfc-7ad5cb0c039a netvisor-server-1.netvisor_netvisor-dev netvisor-server-1.netvisor_netvisor-dev \N {"type": "Hostname"} [{"id": "20473b35-d764-4bf7-b04d-f5c150cf0809", "name": null, "subnet_id": "f9f59dd7-2e67-4e82-aa8b-3d9d5d2e5933", "ip_address": "172.25.0.3", "mac_address": "3E:1C:F4:AD:D6:76"}] ["be06478c-8e68-486e-b3d9-4556f302cbf2"] [{"id": "7dc1169e-8843-4967-8adb-0acfb2097542", "type": "Custom", "number": 60072, "protocol": "Tcp"}] {"type": "Discovery", "metadata": [{"date": "2025-11-19T23:32:59.483076353Z", "type": "Network", "daemon_id": "62c6aac2-3f4c-41e7-a741-d0be2d8c0db0", "subnet_ids": null, "host_naming_fallback": "BestService"}]} null 2025-11-19 23:32:59.483079+00 2025-11-19 23:33:28.836141+00 f -7196b058-3317-4da1-a13e-09e60d5cc77c f94407b3-bad9-4338-bbfc-7ad5cb0c039a 172.25.0.4 51afe52ff83c NetVisor daemon {"type": "None"} [{"id": "d64dd9a2-5754-4155-8a4d-b8e92d2b05ea", "name": "eth0", "subnet_id": "f9f59dd7-2e67-4e82-aa8b-3d9d5d2e5933", "ip_address": "172.25.0.4", "mac_address": "AA:D9:C9:5D:EA:7C"}] ["0a899c93-e5bb-4946-a7d3-2f6515fa6a84", "f9349ce3-029a-4bb6-80be-11a655edebd9"] [{"id": "6f00174f-9d7b-425b-a485-97ef386b13e4", "type": "Custom", "number": 60073, "protocol": "Tcp"}] {"type": "Discovery", "metadata": [{"date": "2025-11-19T23:33:28.828039811Z", "type": "Network", "daemon_id": "62c6aac2-3f4c-41e7-a741-d0be2d8c0db0", "subnet_ids": null, "host_naming_fallback": "BestService"}, {"date": "2025-11-19T23:32:57.198827308Z", "type": "SelfReport", "host_id": "7196b058-3317-4da1-a13e-09e60d5cc77c", "daemon_id": "62c6aac2-3f4c-41e7-a741-d0be2d8c0db0"}]} null 2025-11-19 23:32:57.134954+00 2025-11-19 23:33:28.964742+00 f -f42af7f7-70df-4d6b-bffa-50036f9ca2f6 f94407b3-bad9-4338-bbfc-7ad5cb0c039a runnervmg1sw1 runnervmg1sw1 \N {"type": "Hostname"} [{"id": "b88b0983-8764-4cba-8f05-7beca2a6c8f3", "name": null, "subnet_id": "f9f59dd7-2e67-4e82-aa8b-3d9d5d2e5933", "ip_address": "172.25.0.1", "mac_address": "5E:BC:E8:72:DC:DF"}] ["51dd2367-719d-487f-b8f4-13e080201e06", "36e28abf-480f-4bef-979e-f0f4787a3ea7"] [{"id": "baec9eb9-9de4-42f3-afcd-90394a238f66", "type": "Custom", "number": 60072, "protocol": "Tcp"}, {"id": "175145cf-b572-48e2-bb7f-dbb6794a41e7", "type": "Custom", "number": 8123, "protocol": "Tcp"}, {"id": "18a94eec-8ffb-48ce-b1df-91823c9416b5", "type": "Ssh", "number": 22, "protocol": "Tcp"}] {"type": "Discovery", "metadata": [{"date": "2025-11-19T23:33:49.758874400Z", "type": "Network", "daemon_id": "62c6aac2-3f4c-41e7-a741-d0be2d8c0db0", "subnet_ids": null, "host_naming_fallback": "BestService"}]} null 2025-11-19 23:33:49.758877+00 2025-11-19 23:34:04.508326+00 f +cc22b055-17cc-4b32-b485-0297aefbe854 169c1636-c0f2-4883-950c-6d3a40053110 Cloudflare DNS \N \N {"type": "ServiceBinding", "config": "637ed9c5-6538-4b29-8c02-17c20c6c4eea"} [{"id": "9cbf8a2a-3789-4af3-896b-3796556ad10f", "name": "Internet", "subnet_id": "eab7b1b7-69ac-41c3-bf72-2a3dfcd899a5", "ip_address": "1.1.1.1", "mac_address": null}] {9819289a-fb18-4d18-bd8a-14f5e5c41223} [{"id": "8884a744-70f2-49a1-bf64-f82a5144469e", "type": "DnsUdp", "number": 53, "protocol": "Udp"}] {"type": "System"} null 2025-11-22 05:43:51.654082+00 2025-11-22 05:43:51.66396+00 f +fb413bb0-3f0b-4995-8021-fb71331c650d 169c1636-c0f2-4883-950c-6d3a40053110 Google.com \N \N {"type": "ServiceBinding", "config": "84a39a44-f283-47a0-b197-14bc9c4e0b21"} [{"id": "abf254c9-2111-4718-b14a-1ff5060d518c", "name": "Internet", "subnet_id": "eab7b1b7-69ac-41c3-bf72-2a3dfcd899a5", "ip_address": "203.0.113.66", "mac_address": null}] {a15873fd-ab98-4f5f-8714-741b74d8edf1} [{"id": "dbc87c22-f23f-43ce-a43a-db8acd111c68", "type": "Https", "number": 443, "protocol": "Tcp"}] {"type": "System"} null 2025-11-22 05:43:51.654089+00 2025-11-22 05:43:51.669393+00 f +80578e78-ccd8-460a-a681-71e9fdfbd729 169c1636-c0f2-4883-950c-6d3a40053110 Mobile Device \N A mobile device connecting from a remote network {"type": "ServiceBinding", "config": "61700bf0-a593-4d73-90f8-ef795c9f9b0b"} [{"id": "aa9e7988-50b4-445a-85b8-4ebb21e912d8", "name": "Remote Network", "subnet_id": "7fd63fdb-9b7e-4711-9fa7-7acfbcc1453f", "ip_address": "203.0.113.20", "mac_address": null}] {41cc388e-6eac-45b5-a492-360fcca36c53} [{"id": "12388c3a-670f-4385-ab63-6e2d6c48d968", "type": "Custom", "number": 0, "protocol": "Tcp"}] {"type": "System"} null 2025-11-22 05:43:51.654094+00 2025-11-22 05:43:51.67345+00 f +d38f346e-d180-423e-aa51-08d1ecf79c6c 169c1636-c0f2-4883-950c-6d3a40053110 netvisor-postgres-dev-1.netvisor_netvisor-dev netvisor-postgres-dev-1.netvisor_netvisor-dev \N {"type": "Hostname"} [{"id": "459ef547-3027-4b2c-9fd0-4bf5aba88ec2", "name": null, "subnet_id": "a54ff32d-28fe-49c8-b699-225be914a185", "ip_address": "172.25.0.6", "mac_address": "1E:99:71:B6:32:8E"}] {9684901c-4ccf-4ea8-9617-59e6ceccba40} [{"id": "67c96777-50f7-4444-9e9b-e2a399d0bc51", "type": "PostgreSQL", "number": 5432, "protocol": "Tcp"}] {"type": "Discovery", "metadata": [{"date": "2025-11-22T05:44:09.072864461Z", "type": "Network", "daemon_id": "1dd07be9-cbe2-446c-877d-6df902e372fb", "subnet_ids": null, "host_naming_fallback": "BestService"}]} null 2025-11-22 05:44:09.072867+00 2025-11-22 05:44:24.066996+00 f +d69fac63-ee47-40a2-a09a-773d45109cc4 169c1636-c0f2-4883-950c-6d3a40053110 172.25.0.4 9c153bc0e8ec NetVisor daemon {"type": "None"} [{"id": "4acc6696-ac08-46d4-81e8-d8e57df434f1", "name": "eth0", "subnet_id": "a54ff32d-28fe-49c8-b699-225be914a185", "ip_address": "172.25.0.4", "mac_address": "E6:6B:9A:05:11:B8"}] {9a18439a-8621-4039-ad8a-4795e29ee17f} [{"id": "60af8fbd-f774-4aba-ac11-52ec112c1fa8", "type": "Custom", "number": 60073, "protocol": "Tcp"}] {"type": "Discovery", "metadata": [{"date": "2025-11-22T05:43:51.772709379Z", "type": "SelfReport", "host_id": "d69fac63-ee47-40a2-a09a-773d45109cc4", "daemon_id": "1dd07be9-cbe2-446c-877d-6df902e372fb"}]} null 2025-11-22 05:43:51.681919+00 2025-11-22 05:43:51.782439+00 f +581da0e8-37c8-41a8-8e18-065fe49d4e5c 169c1636-c0f2-4883-950c-6d3a40053110 netvisor-server-1.netvisor_netvisor-dev netvisor-server-1.netvisor_netvisor-dev \N {"type": "Hostname"} [{"id": "d0928414-0bfe-42b7-9986-8739466663a6", "name": null, "subnet_id": "a54ff32d-28fe-49c8-b699-225be914a185", "ip_address": "172.25.0.3", "mac_address": "A2:F2:C9:5A:E2:5A"}] {38c64825-56d2-4de4-a88b-4c0e9f0afb6a} [{"id": "25675680-50a4-4671-9527-0465f3ec0a5a", "type": "Custom", "number": 60072, "protocol": "Tcp"}] {"type": "Discovery", "metadata": [{"date": "2025-11-22T05:43:54.028085368Z", "type": "Network", "daemon_id": "1dd07be9-cbe2-446c-877d-6df902e372fb", "subnet_ids": null, "host_naming_fallback": "BestService"}]} null 2025-11-22 05:43:54.028088+00 2025-11-22 05:44:08.96515+00 f +71a8ccad-cec6-4772-87ec-ed2a3c4a696c 169c1636-c0f2-4883-950c-6d3a40053110 runnervmg1sw1 runnervmg1sw1 \N {"type": "Hostname"} [{"id": "3b8d6a32-3795-4966-b370-d5142fe1d905", "name": null, "subnet_id": "a54ff32d-28fe-49c8-b699-225be914a185", "ip_address": "172.25.0.1", "mac_address": "02:61:5D:F5:91:4C"}] {afbd2a53-af6c-43ec-be62-1d0dba17baad,3149002a-62d1-4990-81f3-db22dc820d8a} [{"id": "3b0c68d3-e04e-43bb-8769-7aac77cfbfc4", "type": "Custom", "number": 8123, "protocol": "Tcp"}, {"id": "796485cb-db71-40ec-972d-3de1fd1383a9", "type": "Custom", "number": 60072, "protocol": "Tcp"}, {"id": "8409d0b4-4a4e-4247-90b9-2183a9cebebc", "type": "Ssh", "number": 22, "protocol": "Tcp"}] {"type": "Discovery", "metadata": [{"date": "2025-11-22T05:44:30.205810658Z", "type": "Network", "daemon_id": "1dd07be9-cbe2-446c-877d-6df902e372fb", "subnet_ids": null, "host_naming_fallback": "BestService"}]} null 2025-11-22 05:44:30.205813+00 2025-11-22 05:44:45.0524+00 f \. @@ -428,7 +464,7 @@ f42af7f7-70df-4d6b-bffa-50036f9ca2f6 f94407b3-bad9-4338-bbfc-7ad5cb0c039a runner -- COPY public.networks (id, name, created_at, updated_at, is_default, organization_id) FROM stdin; -f94407b3-bad9-4338-bbfc-7ad5cb0c039a My Network 2025-11-19 23:32:57.108153+00 2025-11-19 23:32:57.108153+00 f 3ad46102-4f4d-416b-a29b-02929af141f9 +169c1636-c0f2-4883-950c-6d3a40053110 My Network 2025-11-22 05:43:51.650161+00 2025-11-22 05:43:51.650161+00 f 8148c683-bf5c-49be-83f3-63511dfa27aa \. @@ -437,7 +473,7 @@ f94407b3-bad9-4338-bbfc-7ad5cb0c039a My Network 2025-11-19 23:32:57.108153+00 20 -- COPY public.organizations (id, name, stripe_customer_id, plan, plan_status, created_at, updated_at, is_onboarded) FROM stdin; -3ad46102-4f4d-416b-a29b-02929af141f9 My Organization \N {"type": "Community", "price": {"rate": "Month", "cents": 0}, "trial_days": 0} null 2025-11-19 23:32:54.45019+00 2025-11-19 23:32:57.106667+00 t +8148c683-bf5c-49be-83f3-63511dfa27aa My Organization \N {"type": "Community", "price": {"rate": "Month", "cents": 0}, "trial_days": 0} \N 2025-11-22 05:43:48.645519+00 2025-11-22 05:43:51.648848+00 t \. @@ -446,15 +482,14 @@ COPY public.organizations (id, name, stripe_customer_id, plan, plan_status, crea -- COPY public.services (id, network_id, created_at, updated_at, name, host_id, bindings, service_definition, virtualization, source) FROM stdin; -beefc1ab-f7d3-475c-bacb-55c1e2497548 f94407b3-bad9-4338-bbfc-7ad5cb0c039a 2025-11-19 23:32:57.109557+00 2025-11-19 23:32:57.109557+00 Cloudflare DNS c1584db9-398b-4eaf-99f1-601481685fcf [{"id": "7f6f10ee-a546-4b63-885b-dc4ba3852ac2", "type": "Port", "port_id": "3dbee162-0264-4bdd-870b-c55537e2fd4d", "interface_id": "0da9276e-38f0-4cd2-9ec2-d829d3a47fc7"}] "Dns Server" null {"type": "System"} -0b16f609-0a4b-4c34-ba35-7361238d04c2 f94407b3-bad9-4338-bbfc-7ad5cb0c039a 2025-11-19 23:32:57.109562+00 2025-11-19 23:32:57.109562+00 Google.com 1ed19ab2-8571-4866-8294-e878d2b72490 [{"id": "e7f3eb32-56a8-4e89-8a8f-be208de56fe3", "type": "Port", "port_id": "0fdf3e3e-9427-4f9c-bc51-e72d497d8b21", "interface_id": "f58135c7-8cd8-483d-9598-19a21aa387af"}] "Web Service" null {"type": "System"} -8dd44f7b-c5e9-4b5b-bda0-2ec8a24a5d51 f94407b3-bad9-4338-bbfc-7ad5cb0c039a 2025-11-19 23:32:57.109568+00 2025-11-19 23:32:57.109568+00 Mobile Device c966e55e-1d55-45ba-9834-4913a01d97c3 [{"id": "fe66aebf-7c08-4412-a17e-5343518e3b47", "type": "Port", "port_id": "bb49dd89-3d8f-4f06-bcd1-7f58eacb5dcc", "interface_id": "58c8bff4-cff6-4fa9-9c69-c2152a8237f8"}] "Client" null {"type": "System"} -be06478c-8e68-486e-b3d9-4556f302cbf2 f94407b3-bad9-4338-bbfc-7ad5cb0c039a 2025-11-19 23:33:03.967735+00 2025-11-19 23:33:03.967735+00 NetVisor Server API 452b8825-df24-4cff-a34a-9a665be361da [{"id": "a8855bb6-c323-4257-aec8-98f0ac1d624c", "type": "Port", "port_id": "7dc1169e-8843-4967-8adb-0acfb2097542", "interface_id": "20473b35-d764-4bf7-b04d-f5c150cf0809"}] "NetVisor Server API" null {"type": "DiscoveryWithMatch", "details": {"reason": {"data": "Response for 172.25.0.3:60072/api/health contained \\"netvisor\\" in body", "type": "reason"}, "confidence": "High"}, "metadata": [{"date": "2025-11-19T23:33:03.967725548Z", "type": "Network", "daemon_id": "62c6aac2-3f4c-41e7-a741-d0be2d8c0db0", "subnet_ids": null, "host_naming_fallback": "BestService"}]} -ab847e05-4db2-43b5-bc80-c8c4b2e280bd f94407b3-bad9-4338-bbfc-7ad5cb0c039a 2025-11-19 23:33:28.82684+00 2025-11-19 23:33:28.82684+00 Home Assistant 37f494eb-eec7-48f6-a466-1ddf7b965300 [{"id": "2ccdbf0a-afc0-4b9d-a003-72073427374e", "type": "Port", "port_id": "5d713bf4-24d7-4c5e-a33b-f2825d427dd0", "interface_id": "c2c0c2b9-b6ef-4324-860e-eb4023dcd0c2"}] "Home Assistant" null {"type": "DiscoveryWithMatch", "details": {"reason": {"data": "Response for 172.25.0.5:8123/ contained \\"home assistant\\" in body", "type": "reason"}, "confidence": "High"}, "metadata": [{"date": "2025-11-19T23:33:28.826830967Z", "type": "Network", "daemon_id": "62c6aac2-3f4c-41e7-a741-d0be2d8c0db0", "subnet_ids": null, "host_naming_fallback": "BestService"}]} -0a899c93-e5bb-4946-a7d3-2f6515fa6a84 f94407b3-bad9-4338-bbfc-7ad5cb0c039a 2025-11-19 23:32:57.198842+00 2025-11-19 23:33:28.963604+00 NetVisor Daemon API 7196b058-3317-4da1-a13e-09e60d5cc77c [{"id": "41807ed9-b8c3-4913-b87f-ae278b41796a", "type": "Port", "port_id": "6f00174f-9d7b-425b-a485-97ef386b13e4", "interface_id": "d64dd9a2-5754-4155-8a4d-b8e92d2b05ea"}] "NetVisor Daemon API" null {"type": "DiscoveryWithMatch", "details": {"reason": {"data": "NetVisor Daemon self-report", "type": "reason"}, "confidence": "Certain"}, "metadata": [{"date": "2025-11-19T23:33:28.828505906Z", "type": "Network", "daemon_id": "62c6aac2-3f4c-41e7-a741-d0be2d8c0db0", "subnet_ids": null, "host_naming_fallback": "BestService"}, {"date": "2025-11-19T23:32:57.198841074Z", "type": "SelfReport", "host_id": "7196b058-3317-4da1-a13e-09e60d5cc77c", "daemon_id": "62c6aac2-3f4c-41e7-a741-d0be2d8c0db0"}]} -2f06a2aa-5ff7-4724-9c12-afe70799ddc4 f94407b3-bad9-4338-bbfc-7ad5cb0c039a 2025-11-19 23:33:43.596871+00 2025-11-19 23:33:43.596871+00 PostgreSQL 9b3dcca7-c0b2-4b50-9bdc-5050ccf00d0d [{"id": "60d12f22-c4fc-4a87-8bf1-d182ea0d0c75", "type": "Port", "port_id": "6464217a-c686-402d-9a7d-782e5373e1e5", "interface_id": "1b4c4739-c0e6-4a0b-a1fd-c43cbde68610"}] "PostgreSQL" null {"type": "DiscoveryWithMatch", "details": {"reason": {"data": ["Generic service", [{"data": "Port 5432/tcp is open but is used in other service match patterns", "type": "reason"}]], "type": "container"}, "confidence": "NotApplicable"}, "metadata": [{"date": "2025-11-19T23:33:43.596861453Z", "type": "Network", "daemon_id": "62c6aac2-3f4c-41e7-a741-d0be2d8c0db0", "subnet_ids": null, "host_naming_fallback": "BestService"}]} -36e28abf-480f-4bef-979e-f0f4787a3ea7 f94407b3-bad9-4338-bbfc-7ad5cb0c039a 2025-11-19 23:34:04.500524+00 2025-11-19 23:34:04.500524+00 Home Assistant f42af7f7-70df-4d6b-bffa-50036f9ca2f6 [{"id": "aea0def9-a8cd-4cdd-a76e-a7c16958ed4b", "type": "Port", "port_id": "175145cf-b572-48e2-bb7f-dbb6794a41e7", "interface_id": "b88b0983-8764-4cba-8f05-7beca2a6c8f3"}] "Home Assistant" null {"type": "DiscoveryWithMatch", "details": {"reason": {"data": "Response for 172.25.0.1:8123/ contained \\"home assistant\\" in body", "type": "reason"}, "confidence": "High"}, "metadata": [{"date": "2025-11-19T23:34:04.500514477Z", "type": "Network", "daemon_id": "62c6aac2-3f4c-41e7-a741-d0be2d8c0db0", "subnet_ids": null, "host_naming_fallback": "BestService"}]} -51dd2367-719d-487f-b8f4-13e080201e06 f94407b3-bad9-4338-bbfc-7ad5cb0c039a 2025-11-19 23:33:54.154381+00 2025-11-19 23:33:54.154381+00 NetVisor Server API f42af7f7-70df-4d6b-bffa-50036f9ca2f6 [{"id": "a04f4c13-f58b-4052-9b23-ca717a445a70", "type": "Port", "port_id": "baec9eb9-9de4-42f3-afcd-90394a238f66", "interface_id": "b88b0983-8764-4cba-8f05-7beca2a6c8f3"}] "NetVisor Server API" null {"type": "DiscoveryWithMatch", "details": {"reason": {"data": "Response for 172.25.0.1:60072/api/health contained \\"netvisor\\" in body", "type": "reason"}, "confidence": "High"}, "metadata": [{"date": "2025-11-19T23:33:54.154371621Z", "type": "Network", "daemon_id": "62c6aac2-3f4c-41e7-a741-d0be2d8c0db0", "subnet_ids": null, "host_naming_fallback": "BestService"}]} +9819289a-fb18-4d18-bd8a-14f5e5c41223 169c1636-c0f2-4883-950c-6d3a40053110 2025-11-22 05:43:51.654084+00 2025-11-22 05:43:51.654084+00 Cloudflare DNS cc22b055-17cc-4b32-b485-0297aefbe854 [{"id": "637ed9c5-6538-4b29-8c02-17c20c6c4eea", "type": "Port", "port_id": "8884a744-70f2-49a1-bf64-f82a5144469e", "interface_id": "9cbf8a2a-3789-4af3-896b-3796556ad10f"}] "Dns Server" null {"type": "System"} +a15873fd-ab98-4f5f-8714-741b74d8edf1 169c1636-c0f2-4883-950c-6d3a40053110 2025-11-22 05:43:51.65409+00 2025-11-22 05:43:51.65409+00 Google.com fb413bb0-3f0b-4995-8021-fb71331c650d [{"id": "84a39a44-f283-47a0-b197-14bc9c4e0b21", "type": "Port", "port_id": "dbc87c22-f23f-43ce-a43a-db8acd111c68", "interface_id": "abf254c9-2111-4718-b14a-1ff5060d518c"}] "Web Service" null {"type": "System"} +41cc388e-6eac-45b5-a492-360fcca36c53 169c1636-c0f2-4883-950c-6d3a40053110 2025-11-22 05:43:51.654095+00 2025-11-22 05:43:51.654095+00 Mobile Device 80578e78-ccd8-460a-a681-71e9fdfbd729 [{"id": "61700bf0-a593-4d73-90f8-ef795c9f9b0b", "type": "Port", "port_id": "12388c3a-670f-4385-ab63-6e2d6c48d968", "interface_id": "aa9e7988-50b4-445a-85b8-4ebb21e912d8"}] "Client" null {"type": "System"} +9a18439a-8621-4039-ad8a-4795e29ee17f 169c1636-c0f2-4883-950c-6d3a40053110 2025-11-22 05:43:51.772735+00 2025-11-22 05:43:51.772735+00 NetVisor Daemon API d69fac63-ee47-40a2-a09a-773d45109cc4 [{"id": "151fb392-edd0-4c0b-9a00-a25e9f92d6bc", "type": "Port", "port_id": "60af8fbd-f774-4aba-ac11-52ec112c1fa8", "interface_id": "4acc6696-ac08-46d4-81e8-d8e57df434f1"}] "NetVisor Daemon API" null {"type": "DiscoveryWithMatch", "details": {"reason": {"data": "NetVisor Daemon self-report", "type": "reason"}, "confidence": "Certain"}, "metadata": [{"date": "2025-11-22T05:43:51.772734477Z", "type": "SelfReport", "host_id": "d69fac63-ee47-40a2-a09a-773d45109cc4", "daemon_id": "1dd07be9-cbe2-446c-877d-6df902e372fb"}]} +38c64825-56d2-4de4-a88b-4c0e9f0afb6a 169c1636-c0f2-4883-950c-6d3a40053110 2025-11-22 05:44:08.945444+00 2025-11-22 05:44:08.945444+00 NetVisor Server API 581da0e8-37c8-41a8-8e18-065fe49d4e5c [{"id": "562962ec-dc4e-47f6-9ba7-8b84f14190dc", "type": "Port", "port_id": "25675680-50a4-4671-9527-0465f3ec0a5a", "interface_id": "d0928414-0bfe-42b7-9986-8739466663a6"}] "NetVisor Server API" null {"type": "DiscoveryWithMatch", "details": {"reason": {"data": "Response for 172.25.0.3:60072/api/health contained \\"netvisor\\" in body", "type": "reason"}, "confidence": "High"}, "metadata": [{"date": "2025-11-22T05:44:08.945437540Z", "type": "Network", "daemon_id": "1dd07be9-cbe2-446c-877d-6df902e372fb", "subnet_ids": null, "host_naming_fallback": "BestService"}]} +9684901c-4ccf-4ea8-9617-59e6ceccba40 169c1636-c0f2-4883-950c-6d3a40053110 2025-11-22 05:44:24.056871+00 2025-11-22 05:44:24.056871+00 PostgreSQL d38f346e-d180-423e-aa51-08d1ecf79c6c [{"id": "1aa0f9f8-f0b7-40c6-84d4-e6580e9ec166", "type": "Port", "port_id": "67c96777-50f7-4444-9e9b-e2a399d0bc51", "interface_id": "459ef547-3027-4b2c-9fd0-4bf5aba88ec2"}] "PostgreSQL" null {"type": "DiscoveryWithMatch", "details": {"reason": {"data": ["Generic service", [{"data": "Port 5432/tcp is open but is used in other service match patterns", "type": "reason"}]], "type": "container"}, "confidence": "NotApplicable"}, "metadata": [{"date": "2025-11-22T05:44:24.056862405Z", "type": "Network", "daemon_id": "1dd07be9-cbe2-446c-877d-6df902e372fb", "subnet_ids": null, "host_naming_fallback": "BestService"}]} +afbd2a53-af6c-43ec-be62-1d0dba17baad 169c1636-c0f2-4883-950c-6d3a40053110 2025-11-22 05:44:33.206717+00 2025-11-22 05:44:33.206717+00 Home Assistant 71a8ccad-cec6-4772-87ec-ed2a3c4a696c [{"id": "a9832b55-53b1-4b11-90b8-698370a00a39", "type": "Port", "port_id": "3b0c68d3-e04e-43bb-8769-7aac77cfbfc4", "interface_id": "3b8d6a32-3795-4966-b370-d5142fe1d905"}] "Home Assistant" null {"type": "DiscoveryWithMatch", "details": {"reason": {"data": "Response for 172.25.0.1:8123/ contained \\"home assistant\\" in body", "type": "reason"}, "confidence": "High"}, "metadata": [{"date": "2025-11-22T05:44:33.206707395Z", "type": "Network", "daemon_id": "1dd07be9-cbe2-446c-877d-6df902e372fb", "subnet_ids": null, "host_naming_fallback": "BestService"}]} +3149002a-62d1-4990-81f3-db22dc820d8a 169c1636-c0f2-4883-950c-6d3a40053110 2025-11-22 05:44:45.043523+00 2025-11-22 05:44:45.043523+00 NetVisor Server API 71a8ccad-cec6-4772-87ec-ed2a3c4a696c [{"id": "0e159ad3-d0ba-453a-b6f5-c7d2ab1b7f06", "type": "Port", "port_id": "796485cb-db71-40ec-972d-3de1fd1383a9", "interface_id": "3b8d6a32-3795-4966-b370-d5142fe1d905"}] "NetVisor Server API" null {"type": "DiscoveryWithMatch", "details": {"reason": {"data": "Response for 172.25.0.1:60072/api/health contained \\"netvisor\\" in body", "type": "reason"}, "confidence": "High"}, "metadata": [{"date": "2025-11-22T05:44:45.043513742Z", "type": "Network", "daemon_id": "1dd07be9-cbe2-446c-877d-6df902e372fb", "subnet_ids": null, "host_naming_fallback": "BestService"}]} \. @@ -463,9 +498,18 @@ ab847e05-4db2-43b5-bc80-c8c4b2e280bd f94407b3-bad9-4338-bbfc-7ad5cb0c039a 2025-1 -- COPY public.subnets (id, network_id, created_at, updated_at, cidr, name, description, subnet_type, source) FROM stdin; -af6fe772-b27b-4aa9-bea2-9cdff1d452f4 f94407b3-bad9-4338-bbfc-7ad5cb0c039a 2025-11-19 23:32:57.109487+00 2025-11-19 23:32:57.109487+00 "0.0.0.0/0" Internet This subnet uses the 0.0.0.0/0 CIDR as an organizational container for services running on the internet (e.g., public DNS servers, cloud services, etc.). "Internet" {"type": "System"} -97a944fb-691b-4109-bcc0-c559d282133c f94407b3-bad9-4338-bbfc-7ad5cb0c039a 2025-11-19 23:32:57.109492+00 2025-11-19 23:32:57.109492+00 "0.0.0.0/0" Remote Network This subnet uses the 0.0.0.0/0 CIDR as an organizational container for hosts on remote networks (e.g., mobile connections, friend's networks, public WiFi, etc.). "Remote" {"type": "System"} -f9f59dd7-2e67-4e82-aa8b-3d9d5d2e5933 f94407b3-bad9-4338-bbfc-7ad5cb0c039a 2025-11-19 23:32:57.187036+00 2025-11-19 23:32:57.187036+00 "172.25.0.0/28" 172.25.0.0/28 \N "Lan" {"type": "Discovery", "metadata": [{"date": "2025-11-19T23:32:57.187034588Z", "type": "SelfReport", "host_id": "7196b058-3317-4da1-a13e-09e60d5cc77c", "daemon_id": "62c6aac2-3f4c-41e7-a741-d0be2d8c0db0"}]} +eab7b1b7-69ac-41c3-bf72-2a3dfcd899a5 169c1636-c0f2-4883-950c-6d3a40053110 2025-11-22 05:43:51.654028+00 2025-11-22 05:43:51.654028+00 "0.0.0.0/0" Internet This subnet uses the 0.0.0.0/0 CIDR as an organizational container for services running on the internet (e.g., public DNS servers, cloud services, etc.). "Internet" {"type": "System"} +7fd63fdb-9b7e-4711-9fa7-7acfbcc1453f 169c1636-c0f2-4883-950c-6d3a40053110 2025-11-22 05:43:51.654032+00 2025-11-22 05:43:51.654032+00 "0.0.0.0/0" Remote Network This subnet uses the 0.0.0.0/0 CIDR as an organizational container for hosts on remote networks (e.g., mobile connections, friend's networks, public WiFi, etc.). "Remote" {"type": "System"} +a54ff32d-28fe-49c8-b699-225be914a185 169c1636-c0f2-4883-950c-6d3a40053110 2025-11-22 05:43:51.735331+00 2025-11-22 05:43:51.735331+00 "172.25.0.0/28" 172.25.0.0/28 \N "Lan" {"type": "Discovery", "metadata": [{"date": "2025-11-22T05:43:51.735330925Z", "type": "SelfReport", "host_id": "d69fac63-ee47-40a2-a09a-773d45109cc4", "daemon_id": "1dd07be9-cbe2-446c-877d-6df902e372fb"}]} +\. + + +-- +-- Data for Name: topologies; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +COPY public.topologies (id, network_id, name, edges, nodes, options, hosts, subnets, services, groups, is_stale, last_refreshed, is_locked, locked_at, locked_by, removed_hosts, removed_services, removed_subnets, removed_groups, parent_id, created_at, updated_at) FROM stdin; +37e49910-6ad8-4590-9989-5f19882aabd0 169c1636-c0f2-4883-950c-6d3a40053110 My Topology [] [] {"local": {"no_fade_edges": false, "hide_edge_types": [], "left_zone_title": "Infrastructure", "hide_resize_handles": false}, "request": {"hide_ports": false, "hide_service_categories": [], "show_gateway_in_left_zone": true, "group_docker_bridges_by_host": false, "left_zone_service_categories": ["DNS", "ReverseProxy"], "hide_vm_title_on_docker_container": false}} [] [{"id": "eab7b1b7-69ac-41c3-bf72-2a3dfcd899a5", "cidr": "0.0.0.0/0", "name": "Internet", "source": {"type": "System"}, "created_at": "2025-11-22T05:43:51.654028Z", "network_id": "169c1636-c0f2-4883-950c-6d3a40053110", "updated_at": "2025-11-22T05:43:51.654028Z", "description": "This subnet uses the 0.0.0.0/0 CIDR as an organizational container for services running on the internet (e.g., public DNS servers, cloud services, etc.).", "subnet_type": "Internet"}, {"id": "7fd63fdb-9b7e-4711-9fa7-7acfbcc1453f", "cidr": "0.0.0.0/0", "name": "Remote Network", "source": {"type": "System"}, "created_at": "2025-11-22T05:43:51.654032Z", "network_id": "169c1636-c0f2-4883-950c-6d3a40053110", "updated_at": "2025-11-22T05:43:51.654032Z", "description": "This subnet uses the 0.0.0.0/0 CIDR as an organizational container for hosts on remote networks (e.g., mobile connections, friend's networks, public WiFi, etc.).", "subnet_type": "Remote"}, {"id": "a54ff32d-28fe-49c8-b699-225be914a185", "cidr": "172.25.0.0/28", "name": "172.25.0.0/28", "source": {"type": "Discovery", "metadata": [{"date": "2025-11-22T05:43:51.735330925Z", "type": "SelfReport", "host_id": "d69fac63-ee47-40a2-a09a-773d45109cc4", "daemon_id": "1dd07be9-cbe2-446c-877d-6df902e372fb"}]}, "created_at": "2025-11-22T05:43:51.735331Z", "network_id": "169c1636-c0f2-4883-950c-6d3a40053110", "updated_at": "2025-11-22T05:43:51.735331Z", "description": null, "subnet_type": "Lan"}] [] [] t 2025-11-22 05:43:51.651516+00 f \N \N {} {} {} {} \N 2025-11-22 05:43:51.651517+00 2025-11-22 05:44:24.245248+00 \. @@ -474,7 +518,7 @@ f9f59dd7-2e67-4e82-aa8b-3d9d5d2e5933 f94407b3-bad9-4338-bbfc-7ad5cb0c039a 2025-1 -- COPY public.users (id, created_at, updated_at, password_hash, oidc_provider, oidc_subject, oidc_linked_at, email, organization_id, permissions) FROM stdin; -239a134c-0972-4dfc-a10f-3444b123274d 2025-11-19 23:32:54.452081+00 2025-11-19 23:32:57.09543+00 $argon2id$v=19$m=19456,t=2,p=1$dshwmj4/NJa9HtNMU5+97g$DvuKqyxJjID2O6Va7x75k1/Zm2j2sT89f3mI4xYhzBM \N \N \N user@example.com 3ad46102-4f4d-416b-a29b-02929af141f9 Owner +c0dfd3a6-71a4-40be-9694-4bd8b12eeaf9 2025-11-22 05:43:48.647634+00 2025-11-22 05:43:51.636455+00 $argon2id$v=19$m=19456,t=2,p=1$ULnH/etDLj/4aaRNUn+fOg$Ajdiy/fFuyioHESksEL/8kw2qntnq7ZunASWZa/w+BE \N \N \N user@example.com 8148c683-bf5c-49be-83f3-63511dfa27aa Owner \. @@ -483,7 +527,7 @@ COPY public.users (id, created_at, updated_at, password_hash, oidc_provider, oid -- COPY tower_sessions.session (id, data, expiry_date) FROM stdin; -WzPPyVr6bc0V9Jym1f3GSA \\x93c41048c6fdd5a69cf415cd6dfa5ac9cf335b81a7757365725f6964d92432333961313334632d303937322d346466632d613130662d33343434623132333237346499cd07e9cd0161172039ce05c6c1d3000000 2025-12-19 23:32:57.096911+00 +ZxIARZNWIDiyDqEB6JRfwg \\x93c410c25f94e801a10eb2382056934500126781a7757365725f6964d92463306466643361362d373161342d343062652d393639342d34626438623132656561663999cd07e9cd0164052b33ce260943cb000000 2025-12-22 05:43:51.638141+00 \. @@ -575,6 +619,14 @@ ALTER TABLE ONLY public.subnets ADD CONSTRAINT subnets_pkey PRIMARY KEY (id); +-- +-- Name: topologies topologies_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.topologies + ADD CONSTRAINT topologies_pkey PRIMARY KEY (id); + + -- -- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres -- @@ -682,6 +734,13 @@ CREATE INDEX idx_services_network ON public.services USING btree (network_id); CREATE INDEX idx_subnets_network ON public.subnets USING btree (network_id); +-- +-- Name: idx_topologies_network; Type: INDEX; Schema: public; Owner: postgres +-- + +CREATE INDEX idx_topologies_network ON public.topologies USING btree (network_id); + + -- -- Name: idx_users_email_lower; Type: INDEX; Schema: public; Owner: postgres -- @@ -783,6 +842,14 @@ ALTER TABLE ONLY public.subnets ADD CONSTRAINT subnets_network_id_fkey FOREIGN KEY (network_id) REFERENCES public.networks(id) ON DELETE CASCADE; +-- +-- Name: topologies topologies_network_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.topologies + ADD CONSTRAINT topologies_network_id_fkey FOREIGN KEY (network_id) REFERENCES public.networks(id) ON DELETE CASCADE; + + -- -- Name: users users_organization_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres -- @@ -795,5 +862,5 @@ ALTER TABLE ONLY public.users -- PostgreSQL database dump complete -- -\unrestrict lAZKgSAeM5hq62C1gqi2XwedG103NUJLKDgNJYiEQyRVHgLbbzWsShfqwVIjEzP +\unrestrict iT1L5Xfu7uX8R7DBaYH8wyZJM8XMSgKaqFOpXGfCFJRoXlLxXBHbY4EydMBB7jt diff --git a/ui/static/services.json b/ui/static/services.json index cde1cbd7..088cd288 100644 --- a/ui/static/services.json +++ b/ui/static/services.json @@ -5,12 +5,6 @@ "logo_url": "/logos/netvisor-logo.png", "name": "NetVisor Server API" }, - { - "description": "NetVisor Daemon API for network scanning", - "discovery_pattern": "Endpoint response body from :60073/api/health contains netvisor", - "logo_url": "/logos/netvisor-logo.png", - "name": "NetVisor Daemon API" - }, { "description": "User invitation and management system for Jellyfin, Plex, Emby etc", "discovery_pattern": "Endpoint response body from :5690/static/manifest.json contains Wizarr", @@ -55,7 +49,7 @@ }, { "description": "Community-supported document management system", - "discovery_pattern": "Endpoint response body from :8000/ contains Paperless-ngx project", + "discovery_pattern": "Endpoint response body from :8000/static/frontend/en-US/manifest.webmanifest contains Paperless-ngx", "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/paperless-ngx.svg", "name": "Paperless-NGX" }, @@ -407,6 +401,12 @@ "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/cooler-control.svg", "name": "CoolerControl" }, + { + "description": "APC Network-Connected UPS", + "discovery_pattern": "Endpoint response body from :80/ contains Schneider Electric", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/apc.svg", + "name": "APC" + }, { "description": "A single pane of glass for managing clustered & non-clustered Proxmox nodes", "discovery_pattern": "Endpoint response body from :8443/ contains pdm-ui_bundle.js", @@ -684,10 +684,16 @@ "name": "MongoDB" }, { - "description": "ESP device management", - "discovery_pattern": "6052/tcp is open", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/esphome.svg", - "name": "ESPHome" + "description": "MySQL-compatible relational database", + "discovery_pattern": "No match pattern provided", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/mariadb.svg", + "name": "MariaDB" + }, + { + "description": "Time series database", + "discovery_pattern": "8086/tcp is open", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/influxdb.svg", + "name": "InfluxDB" }, { "description": "NoSQL document database", @@ -720,10 +726,10 @@ "name": "Portainer" }, { - "description": "A modern client-server application for the Soulseek file-sharing network", - "discovery_pattern": "All of: (Endpoint response body from :5030/ contains slskd, Endpoint response body from :5030/api/v0/session/enabled contains true)", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/slskd.svg", - "name": "Slskd" + "description": "Enterprise Kubernetes", + "discovery_pattern": "Endpoint response body from :6443/healthz contains openshift", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/openshift.svg", + "name": "OpenShift" }, { "description": "Workload orchestration", @@ -743,6 +749,12 @@ "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/docker.svg", "name": "Docker Swarm" }, + { + "description": "Docker", + "discovery_pattern": "No match pattern provided", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/docker.svg", + "name": "Docker" + }, { "description": "A generic docker container", "discovery_pattern": "All of: (Service is running in a docker container, A custom match pattern evaluated at runtime)", @@ -803,6 +815,12 @@ "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/slskd.svg", "name": "Slskd" }, + { + "description": "A NZB Files Downloader.", + "discovery_pattern": "Endpoint response body from :8080/Content/manifest.json contains SABnzbd", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sabnzbd.svg", + "name": "SABnzbd" + }, { "description": "Media server for streaming personal content", "discovery_pattern": "Any of: (Endpoint response body from :32400/web/index.html contains Plex, Endpoint response status is between 401 and 401, and response from :32400 has header X-Plex-Protocol with value 1.0)", @@ -846,10 +864,16 @@ "name": "Immich" }, { - "description": "Synology DiskStation Manager NAS system", - "discovery_pattern": "All of: (Endpoint response body from :80/ contains synology, 21/tcp is open)", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/synology.svg", - "name": "Synology DSM" + "description": "Personal media server with streaming capabilities", + "discovery_pattern": "Endpoint response body from :8096/emby/System/Info/Public contains Emby", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/emby.svg", + "name": "Emby" + }, + { + "description": "A companion application to Sonarr and Radarr that manages and downloads subtitles", + "discovery_pattern": "6767/tcp is open", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/bazarr.svg", + "name": "Bazarr" }, { "description": "Self-hosted audiobook and podcast server.", @@ -943,7 +967,7 @@ }, { "description": "Self-hosted cloud storage and collaboration platform", - "discovery_pattern": "Any of: (Endpoint response body from :80/core/css/server.css contains Nextcloud GmbH, Endpoint response body from :443/core/css/server.css contains Nextcloud GmbH)", + "discovery_pattern": "Endpoint response body from :80/core/css/server.css contains Nextcloud GmbH", "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/nextcloud.svg", "name": "NextCloud" }, @@ -1097,6 +1121,12 @@ "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/crowdsec.svg", "name": "CrowdSec" }, + { + "description": "Ubiquiti UniFi network controller", + "discovery_pattern": "Endpoint response body from :8443/manage contains UniFi", + "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/unifi.svg", + "name": "UniFi Controller" + }, { "description": "Ubiquiti UniFi wireless access point", "discovery_pattern": "All of: (MAC Address belongs to Ubiquiti Networks Inc, Endpoint response body from :80/ contains Unifi)", @@ -1170,9 +1200,9 @@ "name": "Dhcp Server" }, { - "description": "Docker", - "discovery_pattern": "No match pattern provided", - "logo_url": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/docker.svg", - "name": "Docker" + "description": "NetVisor Daemon API for network scanning", + "discovery_pattern": "Endpoint response body from :60073/api/health contains netvisor", + "logo_url": "/logos/netvisor-logo.png", + "name": "NetVisor Daemon API" } ] \ No newline at end of file From 478978e430d7161bc3311620420a65b80c7bbb8a Mon Sep 17 00:00:00 2001 From: Maya Date: Sat, 22 Nov 2025 11:01:40 -0500 Subject: [PATCH 27/27] fix linting --- backend/src/bin/server.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/src/bin/server.rs b/backend/src/bin/server.rs index 556e9ab7..c0b27a06 100644 --- a/backend/src/bin/server.rs +++ b/backend/src/bin/server.rs @@ -274,7 +274,12 @@ async fn main() -> anyhow::Result<()> { // Spawn server in background tokio::spawn(async move { - axum::serve(listener, app.into_make_service_with_connect_info::()).await.unwrap(); + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .await + .unwrap(); }); // Start cron for discovery scheduler