import { useState, useEffect, useCallback, useRef } from 'react'; import { ReactFlow, Background, Controls, MiniMap, useNodesState, useEdgesState, type Node, type Edge, type NodeTypes, Handle, 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, RefreshCw } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { TogglePill } from '@/components/ui/toggle-pill'; import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; 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: TopologyContainer[]; } // ── 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 ────────────────────────────────────────────────────────────── 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 => (
{netName} {data.ipAddresses[netName]?.replace(/\/\d+$/, '') || ''}
))}
)} {data.image}
); } function NetworkNodeComponent({ data }: { data: { label: string; driver: string; status: string } }) { const statusColor = data.status === 'managed' ? 'text-success' : data.status === 'system' ? 'text-muted-foreground' : 'text-warning'; return (
{data.label}
{data.driver}
); } const nodeTypes: NodeTypes = { container: ContainerNodeComponent, network: NetworkNodeComponent, }; // React Flow's inline style objects cannot resolve CSS custom properties, // so raw oklch values are used here as a necessary escape hatch. const BRAND_COLOR = 'oklch(0.78 0.11 195)'; const EDGE_COLORS = [ BRAND_COLOR, 'oklch(0.70 0.10 150)', // green 'oklch(0.70 0.10 280)', // purple 'oklch(0.70 0.10 30)', // orange 'oklch(0.70 0.10 220)', // blue 'oklch(0.70 0.10 340)', // pink ]; // ── Layout Helper (dagre) ────────────────────────────────────���─────────────── function layoutGraph( networksList: TopologyNetwork[], ): { nodes: Node[]; edges: Edge[] } { const g = new dagre.graphlib.Graph(); g.setGraph({ rankdir: 'TB', ranksep: 120, nodesep: 60 }); g.setDefaultEdgeLabel(() => ({})); // 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 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 }); } } }); dagre.layout(g); // 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: pos.x - pos.width / 2, y: pos.y - pos.height / 2 }, data: { label: net.Name, driver: net.Driver, status: net.managedStatus }, draggable: true, }); } for (const [id, ctr] of containerMap) { const pos = g.node(`ctr-${id}`); flowNodes.push({ id: `ctr-${id}`, type: 'container', position: { x: pos.x - pos.width / 2, y: pos.y - pos.height / 2 }, data: { label: ctr.name, containerId: id, networks: ctr.networks, ipAddresses: ctr.ipAddresses, state: ctr.state, image: ctr.image, stack: ctr.stack, }, draggable: true, }); } 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 ──────────────────────────────────────────────────────────── 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?includeSystem=${includeSystem}`); if (!res.ok) throw new Error('Failed to fetch topology'); const inspected = await res.json(); const { nodes: layoutNodes, edges: layoutEdges } = layoutGraph(inspected); setNodes(layoutNodes); setEdges(layoutEdges); } catch (error) { const err = error as Record; toast.error(String(err?.message || err?.error || 'Something went wrong.')); } finally { setLoading(false); } }, [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 (
Loading network topology...
); } if (nodes.length === 0) { return (

{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 BRAND_COLOR; return 'oklch(0.50 0 0)'; }} maskColor="oklch(0 0 0 / 0.2)" />
); }