diff --git a/CHANGELOG.md b/CHANGELOG.md index a92da3c8..63725eef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +* **topology:** Replace N+1 Docker API calls with container-centric approach (2 calls instead of N+1). Resolves performance issues on hosts with many networks. +* **topology:** Add dagre auto-layout algorithm for hierarchical DAG visualization, replacing the static two-row layout that caused spaghetti edges at scale. +* **topology:** Add "Show system networks" toggle to display bridge, host, and none networks (off by default). +* **topology:** Enrich container nodes with running state indicator, stack badge, and base image name. +* **topology:** Click a running container node to open its log viewer directly from the topology graph. + ### Fixed * **fleet:** fix false "Update available" on remote nodes whose `api_url` has a trailing slash, causing `fetchRemoteMeta` to construct a double-slash URL that fails silently diff --git a/backend/src/index.ts b/backend/src/index.ts index ac743f58..01d30d60 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -5194,27 +5194,11 @@ app.post('/api/system/networks/delete', async (req: Request, res: Response) => { app.get('/api/system/networks/topology', async (req: Request, res: Response) => { try { + const includeSystem = req.query.includeSystem === 'true'; const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); const dockerController = DockerController.getInstance(req.nodeId); - const { networks } = await dockerController.getClassifiedResources(knownStacks); - const userNetworks = networks.filter(n => n.managedStatus !== 'system'); - - const inspected = await Promise.all( - userNetworks.map(async (net) => { - try { - const detail = await dockerController.inspectNetwork(net.Id); - const containers = Object.entries(detail.Containers || {}).map(([id, c]) => { - const info = c as { Name: string; IPv4Address: string }; - return { id, name: info.Name, ip: info.IPv4Address }; - }); - return { ...net, containers }; - } catch { - return { ...net, containers: [] }; - } - }) - ); - - res.json(inspected); + const topology = await dockerController.getTopologyData(knownStacks, includeSystem); + res.json(topology); } catch (error: unknown) { const msg = error instanceof Error ? error.message : 'Failed to fetch network topology'; console.error('Failed to fetch network topology:', error); diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index 4b99c7ab..5d4c8472 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -55,6 +55,25 @@ export interface ClassifiedNetwork { managedStatus: 'managed' | 'unmanaged' | 'system'; } +export interface TopologyContainer { + id: string; + name: string; + ip: string; + state: string; + image: string; + stack: string | null; +} + +export interface TopologyNetwork { + Id: string; + Name: string; + Driver: string; + Scope: string; + managedBy: string | null; + managedStatus: 'managed' | 'unmanaged' | 'system'; + containers: TopologyContainer[]; +} + export type NetworkDriver = 'bridge' | 'overlay' | 'macvlan' | 'host' | 'none'; export interface CreateNetworkOptions { @@ -67,6 +86,7 @@ export interface CreateNetworkOptions { } class DockerController { + private static readonly SYSTEM_NETWORKS = new Set(['bridge', 'host', 'none']); private docker: Docker; private nodeId: number; @@ -184,7 +204,6 @@ class DockerController { volumes: ClassifiedVolume[]; networks: ClassifiedNetwork[]; }> { - const SYSTEM_NETWORKS = new Set(['bridge', 'host', 'none']); const knownSet = new Set(knownStackNames); const [rawImages, rawVolumeData, rawNetworks, allContainers, projectToStack] = await Promise.all([ @@ -239,7 +258,7 @@ class DockerController { }); const networks: ClassifiedNetwork[] = this.validateApiData(rawNetworks).map((net: any) => { - if (SYSTEM_NETWORKS.has(net.Name)) { + if (DockerController.SYSTEM_NETWORKS.has(net.Name)) { return { Id: net.Id, Name: net.Name, Driver: net.Driver, Scope: net.Scope, managedBy: null, managedStatus: 'system' as const }; } const stack = DockerController.resolveProjectLabel(net.Labels?.['com.docker.compose.project'], knownSet, projectToStack); @@ -397,6 +416,94 @@ class DockerController { return this.validateApiData(containers); } + /** + * Builds topology data with 2 Docker API calls instead of N+1. + * Fetches all networks + all containers in parallel, then maps + * container-to-network relationships in memory using NetworkSettings. + */ + public async getTopologyData( + knownStackNames: string[], + includeSystem: boolean, + ): Promise { + const knownSet = new Set(knownStackNames); + + const [rawNetworks, rawContainers, projectToStack] = await Promise.all([ + this.docker.listNetworks(), + this.docker.listContainers({ all: true }), + DockerController.resolveProjectNameMap(knownStackNames), + ]); + + const absDirToStack = DockerController.buildAbsDirMap(knownStackNames); + const resolvedBase = path.resolve(COMPOSE_DIR); + + const networks = this.validateApiData(rawNetworks); + const containers = this.validateApiData(rawContainers); + + // Build network map, optionally filtering system networks + const networkMap = new Map(); + for (const net of networks) { + const isSystem = DockerController.SYSTEM_NETWORKS.has(net.Name); + if (isSystem && !includeSystem) continue; + + const stack = isSystem + ? null + : DockerController.resolveProjectLabel( + net.Labels?.['com.docker.compose.project'], + knownSet, + projectToStack, + ); + const managedStatus: TopologyNetwork['managedStatus'] = isSystem + ? 'system' + : stack ? 'managed' : 'unmanaged'; + + networkMap.set(net.Id, { + Id: net.Id, + Name: net.Name, + Driver: net.Driver ?? 'bridge', + Scope: net.Scope ?? 'local', + managedBy: stack, + managedStatus, + containers: [], + }); + } + + // Map containers to their networks via NetworkSettings. + // Stack resolution is deferred until a network match is found to avoid + // wasted work for containers not attached to any tracked network. + for (const c of containers) { + const netSettings: Record = + c.NetworkSettings?.Networks ?? {}; + + let containerStack: string | null | undefined; + let stackResolved = false; + + for (const [, netInfo] of Object.entries(netSettings)) { + const netId = netInfo.NetworkID; + if (!netId) continue; + const topology = networkMap.get(netId); + if (!topology) continue; + + if (!stackResolved) { + containerStack = DockerController.resolveContainerStack( + c.Labels, projectToStack, knownSet, absDirToStack, resolvedBase, + ); + stackResolved = true; + } + + topology.containers.push({ + id: c.Id, + name: (c.Names?.[0] ?? '').replace(/^\//, ''), + ip: netInfo.IPAddress ?? '', + state: c.State ?? 'unknown', + image: c.Image ?? '', + stack: containerStack ?? null, + }); + } + } + + return Array.from(networkMap.values()); + } + /** Resolves a Docker Compose project label to a known Sencho stack name, or null. */ private static resolveProjectLabel( project: string | undefined, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f0a822e4..0e2f898c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { + "@dagrejs/dagre": "^3.0.0", "@monaco-editor/react": "^4.7.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", @@ -307,6 +308,21 @@ "node": ">=6.9.0" } }, + "node_modules/@dagrejs/dagre": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-3.0.0.tgz", + "integrity": "sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q==", + "license": "MIT", + "dependencies": { + "@dagrejs/graphlib": "4.0.1" + } + }, + "node_modules/@dagrejs/graphlib": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-4.0.1.tgz", + "integrity": "sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==", + "license": "MIT" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index b8d89004..41768c1b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,7 @@ "preview": "vite preview" }, "dependencies": { + "@dagrejs/dagre": "^3.0.0", "@monaco-editor/react": "^4.7.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index f526b7d0..cfbd138d 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -51,6 +51,8 @@ import ScheduledOperationsView from './ScheduledOperationsView'; import AutoUpdatePoliciesView from './AutoUpdatePoliciesView'; import { SENCHO_NAVIGATE_EVENT } from './NodeManager'; import type { SenchoNavigateDetail } from './NodeManager'; +import { SENCHO_OPEN_LOGS_EVENT } from '@/lib/events'; +import type { SenchoOpenLogsDetail } from '@/lib/events'; import { useNodes } from '@/context/NodeContext'; import type { Node } from '@/context/NodeContext'; import { useAuth } from '@/context/AuthContext'; @@ -1270,6 +1272,18 @@ export default function EditorLayout() { setLogContainer(null); }; + // Listen for topology click-to-logs events (ref avoids stale closure) + const openLogViewerRef = useRef(openLogViewer); + openLogViewerRef.current = openLogViewer; + useEffect(() => { + const handler = (e: Event) => { + const { containerId, containerName } = (e as CustomEvent).detail; + openLogViewerRef.current(containerId, containerName); + }; + window.addEventListener(SENCHO_OPEN_LOGS_EVENT, handler); + return () => window.removeEventListener(SENCHO_OPEN_LOGS_EVENT, handler); + }, []); + // Safe container list with fallback const safeContainers = containers || []; // Safe content strings with fallback diff --git a/frontend/src/components/NetworkTopologyView.tsx b/frontend/src/components/NetworkTopologyView.tsx index 4016fca1..e50a7dc3 100644 --- a/frontend/src/components/NetworkTopologyView.tsx +++ b/frontend/src/components/NetworkTopologyView.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { ReactFlow, Background, @@ -13,39 +13,75 @@ import { Position, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; +import dagre from '@dagrejs/dagre'; import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; import { Container, Network, Loader2 } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; +import { Switch } from '@/components/ui/switch'; +import { Label } from '@/components/ui/label'; import { cn } from '@/lib/utils'; // ── Types ───────────────────────────────────────────────────────────────────── +interface TopologyContainer { + id: string; + name: string; + ip: string; + state: string; + image: string; + stack: string | null; +} + interface TopologyNetwork { Id: string; Name: string; Driver: string; managedStatus: 'managed' | 'unmanaged' | 'system'; - containers: Array<{ id: string; name: string; ip: string }>; + containers: TopologyContainer[]; } -interface ContainerNode { - id: string; - name: string; - networks: string[]; - ipAddresses: Record; +// ── Helpers ────────────────────────────────────────────────────────────────── + +function stateColor(state: string): string { + switch (state) { + case 'running': return 'bg-success'; + case 'restarting': + case 'paused': + case 'created': return 'bg-warning'; + default: return 'bg-destructive'; + } } // ── Custom Nodes ────────────────────────────────────────────────────────────── -function ContainerNodeComponent({ data }: { data: { label: string; networks: string[]; ipAddresses: Record } }) { +interface ContainerNodeData { + label: string; + containerId: string; + networks: string[]; + ipAddresses: Record; + state: string; + image: string; + stack: string | null; +} + +function ContainerNodeComponent({ data }: { data: ContainerNodeData }) { return ( -
+
-
+
+ {data.label}
+ {data.stack && ( + {data.stack} + )} {data.networks.length > 0 && (
{data.networks.map(netName => ( @@ -58,6 +94,9 @@ function ContainerNodeComponent({ data }: { data: { label: string; networks: str ))}
)} + + {data.image} +
); @@ -99,81 +138,123 @@ const EDGE_COLORS = [ 'oklch(0.70 0.10 340)', // pink ]; -// ── Layout Helper ───────────────────────────────────────────────────────────── +// ── Layout Helper (dagre) ────────────────────────────────────���─────────────── function layoutGraph( networksList: TopologyNetwork[], ): { nodes: Node[]; edges: Edge[] } { - const flowNodes: Node[] = []; - const flowEdges: Edge[] = []; - const containerDataMap = new Map(); + const g = new dagre.graphlib.Graph(); + g.setGraph({ rankdir: 'TB', ranksep: 120, nodesep: 60 }); + g.setDefaultEdgeLabel(() => ({})); - networksList.forEach(net => { - net.containers.forEach(c => { - if (!containerDataMap.has(c.id)) { - containerDataMap.set(c.id, { id: c.id, name: c.name, networks: [], ipAddresses: {} }); + // Deduplicate containers across networks + const containerMap = new Map; + state: string; + image: string; + stack: string | null; + }>(); + for (const net of networksList) { + for (const c of net.containers) { + if (!containerMap.has(c.id)) { + containerMap.set(c.id, { + name: c.name, networks: [], ipAddresses: {}, + state: c.state, image: c.image, stack: c.stack, + }); } - const cd = containerDataMap.get(c.id)!; - cd.networks.push(net.Name); - cd.ipAddresses[net.Name] = c.ip; - }); + const entry = containerMap.get(c.id)!; + entry.networks.push(net.Name); + entry.ipAddresses[net.Name] = c.ip; + } + } + + // Add nodes to dagre graph + for (const net of networksList) { + g.setNode(`net-${net.Id}`, { width: 160, height: 60 }); + } + for (const [id] of containerMap) { + g.setNode(`ctr-${id}`, { width: 200, height: 100 }); + } + + // Add edges and collect for React Flow + const seenEdges = new Set(); + const edgeList: { netId: string; ctrId: string; color: string }[] = []; + networksList.forEach((net, ni) => { + const color = EDGE_COLORS[ni % EDGE_COLORS.length]; + for (const c of net.containers) { + const edgeKey = `${net.Id}-${c.id}`; + if (!seenEdges.has(edgeKey)) { + seenEdges.add(edgeKey); + g.setEdge(`net-${net.Id}`, `ctr-${c.id}`); + edgeList.push({ netId: net.Id, ctrId: c.id, color }); + } + } }); - const networkSpacing = 240; - const containerSpacing = 220; + dagre.layout(g); - networksList.forEach((net, i) => { + // Convert dagre positions (center-based) to React Flow positions (top-left) + const flowNodes: Node[] = []; + for (const net of networksList) { + const pos = g.node(`net-${net.Id}`); flowNodes.push({ id: `net-${net.Id}`, type: 'network', - position: { x: i * networkSpacing, y: 0 }, + position: { x: pos.x - pos.width / 2, y: pos.y - pos.height / 2 }, data: { label: net.Name, driver: net.Driver, status: net.managedStatus }, draggable: true, }); - }); - - const allContainers = Array.from(containerDataMap.values()); - allContainers.forEach((container, i) => { + } + for (const [id, ctr] of containerMap) { + const pos = g.node(`ctr-${id}`); flowNodes.push({ - id: `ctr-${container.id}`, + id: `ctr-${id}`, type: 'container', - position: { x: i * containerSpacing, y: 180 }, + position: { x: pos.x - pos.width / 2, y: pos.y - pos.height / 2 }, data: { - label: container.name, - networks: container.networks, - ipAddresses: container.ipAddresses, + label: ctr.name, + containerId: id, + networks: ctr.networks, + ipAddresses: ctr.ipAddresses, + state: ctr.state, + image: ctr.image, + stack: ctr.stack, }, draggable: true, }); - }); + } - networksList.forEach((net, ni) => { - const color = EDGE_COLORS[ni % EDGE_COLORS.length]; - net.containers.forEach(c => { - flowEdges.push({ - id: `edge-${net.Id}-${c.id}`, - source: `net-${net.Id}`, - target: `ctr-${c.id}`, - animated: true, - style: { stroke: color, strokeWidth: 1.5 }, - }); - }); - }); + const flowEdges: Edge[] = edgeList.map(({ netId, ctrId, color }) => ({ + id: `edge-${netId}-${ctrId}`, + source: `net-${netId}`, + target: `ctr-${ctrId}`, + animated: true, + style: { stroke: color, strokeWidth: 1.5 }, + })); return { nodes: flowNodes, edges: flowEdges }; } // ── Main Component ──────────────────────────────────────────────────────────── -export default function NetworkTopologyView() { +interface NetworkTopologyViewProps { + onContainerClick?: (containerId: string, containerName: string) => void; +} + +export default function NetworkTopologyView({ onContainerClick }: NetworkTopologyViewProps) { const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [loading, setLoading] = useState(true); + const [includeSystem, setIncludeSystem] = useState(false); + const onContainerClickRef = useRef(onContainerClick); + onContainerClickRef.current = onContainerClick; const fetchTopology = useCallback(async () => { setLoading(true); try { - const res = await apiFetch('/system/networks/topology'); + const res = await apiFetch(`/system/networks/topology?includeSystem=${includeSystem}`); if (!res.ok) throw new Error('Failed to fetch topology'); const inspected = await res.json(); @@ -186,10 +267,16 @@ export default function NetworkTopologyView() { } finally { setLoading(false); } - }, [setNodes, setEdges]); + }, [setNodes, setEdges, includeSystem]); useEffect(() => { fetchTopology(); }, [fetchTopology]); + const handleNodeClick = useCallback((_event: React.MouseEvent, node: Node) => { + if (node.type === 'container' && (!node.data.state || node.data.state === 'running')) { + onContainerClickRef.current?.(node.data.containerId as string, node.data.label as string); + } + }, []); + if (loading) { return (
@@ -203,36 +290,51 @@ export default function NetworkTopologyView() { return (
-

No user-created networks found.

-

Create a network or deploy stacks with custom networks to see the topology.

+

+ {includeSystem ? 'No networks found.' : 'No user-created networks found.'} +

+

+ {includeSystem + ? 'No Docker networks are available on this node.' + : 'Create a network or deploy stacks with custom networks to see the topology.'} +

); } return ( -
- - - - { - if (node.type === 'network') return 'oklch(0.75 0.08 192)'; - return 'oklch(0.50 0 0)'; - }} - maskColor="oklch(0 0 0 / 0.2)" - /> - +
+
+ + +
+
+ + + + { + if (node.type === 'network') return 'oklch(0.75 0.08 192)'; + return 'oklch(0.50 0 0)'; + }} + maskColor="oklch(0 0 0 / 0.2)" + /> + +
); } diff --git a/frontend/src/components/ResourcesView.tsx b/frontend/src/components/ResourcesView.tsx index bea89b79..0580304d 100644 --- a/frontend/src/components/ResourcesView.tsx +++ b/frontend/src/components/ResourcesView.tsx @@ -24,6 +24,8 @@ import { PaidGate } from './PaidGate'; import { CapabilityGate } from './CapabilityGate'; import { formatBytes } from '@/lib/utils'; import { cn } from '@/lib/utils'; +import { SENCHO_OPEN_LOGS_EVENT } from '@/lib/events'; +import type { SenchoOpenLogsDetail } from '@/lib/events'; import { lazy, Suspense } from 'react'; const NetworkTopologyView = lazy(() => import('./NetworkTopologyView')); @@ -830,7 +832,13 @@ export default function ResourcesView() { Loading topology...
}> - + { + window.dispatchEvent(new CustomEvent(SENCHO_OPEN_LOGS_EVENT, { + detail: { containerId: id, containerName: name }, + })); + }} + /> diff --git a/frontend/src/lib/events.ts b/frontend/src/lib/events.ts new file mode 100644 index 00000000..c8534d8b --- /dev/null +++ b/frontend/src/lib/events.ts @@ -0,0 +1,8 @@ +/** Cross-component custom event constants and typed detail interfaces. */ + +export const SENCHO_OPEN_LOGS_EVENT = 'sencho-open-logs'; + +export interface SenchoOpenLogsDetail { + containerId: string; + containerName: string; +}