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 {