mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-02 07:00:27 +00:00
af4083175c
* feat(fleet): add read-only dependency map tab Add a fleet-wide Dependencies tab to Fleet view that maps how stacks, services, networks, volumes, and ports relate, with flags for missing dependencies, port conflicts, orphaned resources, and cross-stack shared resources. Read-only; filterable by stack, node, and flag; collapsed by default with a list-view fallback at scale. The graph is derived at request time from Docker and compose metadata, so no new table or persisted state is introduced. A per-node graph endpoint feeds a hub aggregation endpoint that fans out across the fleet and degrades gracefully, surfacing unreachable or unparseable nodes inline while the rest of the map still renders. * fix(fleet): harden dependency map flag detection and remote merge Address review findings on the dependency map: - Port-conflict detection now does pairwise host-scope overlap, so an unrelated bind on the same port and protocol but a different specific host IP is no longer flagged, and the flag lands on the exact scoped port node. - A running service's depends_on target is only considered satisfied when it is actually running, so a crashed (exited) dependency is surfaced while a deliberately stopped stack stays quiet. - Declared external networks and volumes are reported missing when they do not exist on the host instead of being assumed present. - The hub deep-validates each remote node-graph payload before merging, so a reachable-but-malformed remote degrades to a single node error rather than failing the whole fleet map, and the validation failure is logged. - Searching or filtering on a network, volume, or port now also reveals the services that claim it and their stacks.
133 lines
3.9 KiB
TypeScript
133 lines
3.9 KiB
TypeScript
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<string, unknown> {
|
|
dep: DepNode;
|
|
expanded: boolean;
|
|
expandable: boolean;
|
|
childCount: number;
|
|
}
|
|
|
|
export const DEP_NODE_DIMS: Record<DepNodeKind, { w: number; h: number }> = {
|
|
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<string>,
|
|
childCounts: Map<string, number>,
|
|
): { 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 };
|
|
}
|