import dagre from '@dagrejs/dagre'; import type { Edge, Node } from '@xyflow/react'; // Mirrors the payload from GET /api/fleet/dependency-map. The repo defines // types per side rather than sharing a package, matching how the topology and // fleet views re-declare their backend shapes. export type DepNodeKind = 'host' | 'stack' | 'service' | 'network' | 'volume' | 'port'; export type DepFlagKind = 'missing-dependency' | 'port-conflict' | 'orphan' | 'cross-stack-shared'; export type DepEdgeKind = | 'stack-node' | 'stack-service' | 'depends-on' | 'service-network' | 'service-volume' | 'service-port'; export interface DepNode { id: string; kind: DepNodeKind; label: string; nodeId: number; nodeName: string; stack: string | null; managedStatus?: 'managed' | 'unmanaged' | 'system'; state?: string; flags: DepFlagKind[]; } export interface DepEdge { id: string; source: string; target: string; kind: DepEdgeKind; declaredOnly?: boolean; } export interface DepFlag { kind: DepFlagKind; nodeId: number; nodeName: string; subjects: string[]; detail: string; } export interface FleetDependencyMap { nodes: DepNode[]; edges: DepEdge[]; flags: DepFlag[]; nodeErrors: { nodeId: number; nodeName: string; error: string }[]; parseErrors: { nodeId: number; nodeName: string; stack: string; error: string }[]; } export interface DepFlowData extends Record { dep: DepNode; expanded: boolean; expandable: boolean; childCount: number; } export const DEP_NODE_DIMS: Record = { host: { w: 200, h: 54 }, stack: { w: 200, h: 58 }, service: { w: 174, h: 50 }, network: { w: 162, h: 46 }, volume: { w: 162, h: 46 }, port: { w: 150, h: 46 }, }; // ReactFlow inline styles cannot resolve CSS custom properties, so raw oklch // values are used. They approximate the default cyan accent and do not track a // user's chosen accent or light/dark theme. const EDGE_BRAND = 'oklch(0.78 0.11 195)'; const EDGE_MUTED = 'oklch(0.55 0 0)'; function edgeStyle(e: DepEdge): { stroke: string; strokeWidth: number; strokeDasharray?: string } { if (e.declaredOnly) return { stroke: EDGE_MUTED, strokeWidth: 1, strokeDasharray: '4 4' }; if (e.kind === 'stack-node' || e.kind === 'stack-service') return { stroke: EDGE_MUTED, strokeWidth: 1 }; return { stroke: EDGE_BRAND, strokeWidth: 1.5 }; } /** * Positions an already-filtered set of dependency nodes/edges with dagre and * maps them to ReactFlow nodes/edges. Visibility (collapse/expand and filters) * is decided by the caller; this function only lays out what it is given. */ export function layoutDependencyGraph( nodes: DepNode[], edges: DepEdge[], expanded: Set, childCounts: Map, ): { nodes: Node[]; edges: Edge[] } { if (nodes.length === 0) return { nodes: [], edges: [] }; const g = new dagre.graphlib.Graph(); g.setGraph({ rankdir: 'LR', ranksep: 120, nodesep: 22, marginx: 20, marginy: 20 }); g.setDefaultEdgeLabel(() => ({})); for (const n of nodes) { const d = DEP_NODE_DIMS[n.kind]; g.setNode(n.id, { width: d.w, height: d.h }); } const visibleEdges = edges.filter((e) => g.hasNode(e.source) && g.hasNode(e.target)); for (const e of visibleEdges) g.setEdge(e.source, e.target); dagre.layout(g); const flowNodes: Node[] = nodes.map((n) => { const p = g.node(n.id); return { id: n.id, type: 'dep', position: { x: p.x - p.width / 2, y: p.y - p.height / 2 }, data: { dep: n, expanded: expanded.has(n.id), expandable: n.kind === 'stack', childCount: childCounts.get(n.id) ?? 0, } satisfies DepFlowData, draggable: true, }; }); const flowEdges: Edge[] = visibleEdges.map((e) => ({ id: e.id, source: e.source, target: e.target, style: edgeStyle(e), animated: false, })); return { nodes: flowNodes, edges: flowEdges }; }