mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 11:16:55 +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.
552 lines
24 KiB
TypeScript
552 lines
24 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import {
|
|
ReactFlow,
|
|
Background,
|
|
Controls,
|
|
MiniMap,
|
|
useNodesState,
|
|
useEdgesState,
|
|
Handle,
|
|
Position,
|
|
type Node,
|
|
type Edge,
|
|
type NodeTypes,
|
|
} from '@xyflow/react';
|
|
import '@xyflow/react/dist/style.css';
|
|
import {
|
|
RefreshCw, Search, Server, Layers, Box, Network, Database, Plug,
|
|
ChevronRight, ChevronDown, TriangleAlert, Share2,
|
|
} from 'lucide-react';
|
|
import { apiFetch } from '@/lib/api';
|
|
import { toast } from '@/components/ui/toast-store';
|
|
import { cn } from '@/lib/utils';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { SegmentedControl } from '@/components/ui/segmented-control';
|
|
import {
|
|
layoutDependencyGraph,
|
|
DEP_NODE_DIMS,
|
|
type FleetDependencyMap,
|
|
type DepNode,
|
|
type DepNodeKind,
|
|
type DepFlagKind,
|
|
type DepFlowData,
|
|
} from '@/lib/dependency-map-layout';
|
|
|
|
// Above this many visible graph nodes the graph becomes unreadable; we prompt
|
|
// the operator to filter or switch to the (uncapped) list instead.
|
|
const GRAPH_NODE_CEILING = 300;
|
|
|
|
// ReactFlow MiniMap fills cannot resolve CSS custom properties; these raw oklch
|
|
// values approximate the default accent/severity palette and do not track theme.
|
|
const MINIMAP_BRAND = 'oklch(0.78 0.11 195)';
|
|
const MINIMAP_WARNING = 'oklch(0.75 0.14 75)';
|
|
const MINIMAP_DESTRUCTIVE = 'oklch(0.62 0.21 25)';
|
|
const MINIMAP_MUTED = 'oklch(0.55 0 0)';
|
|
|
|
const KIND_ICON: Record<DepNodeKind, typeof Server> = {
|
|
host: Server,
|
|
stack: Layers,
|
|
service: Box,
|
|
network: Network,
|
|
volume: Database,
|
|
port: Plug,
|
|
};
|
|
|
|
const FLAG_META: { kind: DepFlagKind; label: string; severity: 'warning' | 'destructive' }[] = [
|
|
{ kind: 'missing-dependency', label: 'Missing deps', severity: 'destructive' },
|
|
{ kind: 'port-conflict', label: 'Port conflicts', severity: 'destructive' },
|
|
{ kind: 'orphan', label: 'Orphans', severity: 'warning' },
|
|
{ kind: 'cross-stack-shared', label: 'Shared', severity: 'warning' },
|
|
];
|
|
|
|
const DESTRUCTIVE_FLAGS = new Set<DepFlagKind>(['missing-dependency', 'port-conflict']);
|
|
|
|
function worstSeverity(flags: DepFlagKind[]): 'destructive' | 'warning' | null {
|
|
if (flags.length === 0) return null;
|
|
return flags.some((f) => DESTRUCTIVE_FLAGS.has(f)) ? 'destructive' : 'warning';
|
|
}
|
|
|
|
// ── Node card ─────────────────────────────────────────────────────────────
|
|
|
|
function DependencyNodeCard({ data }: { data: DepFlowData }) {
|
|
const dep = data.dep;
|
|
const Icon = KIND_ICON[dep.kind];
|
|
const severity = worstSeverity(dep.flags);
|
|
const isHost = dep.kind === 'host';
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden',
|
|
isHost && 'ring-1 ring-brand/40',
|
|
severity === 'destructive' && 'ring-1 ring-destructive/50',
|
|
severity === 'warning' && 'ring-1 ring-warning/40',
|
|
data.expandable && 'cursor-pointer',
|
|
)}
|
|
style={{ width: DEP_NODE_DIMS[dep.kind].w }}
|
|
>
|
|
<Handle type="target" position={Position.Left} className="!bg-muted-foreground !w-1.5 !h-1.5 !border-0" />
|
|
<div className="flex items-center gap-2 px-3 py-2">
|
|
<Icon className="h-3.5 w-3.5 text-stat-icon shrink-0" strokeWidth={1.5} />
|
|
<span className="text-xs font-medium text-stat-value truncate" title={dep.label}>{dep.label}</span>
|
|
{data.expandable && (
|
|
data.expanded
|
|
? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground ml-auto shrink-0" strokeWidth={2} />
|
|
: <ChevronRight className="h-3.5 w-3.5 text-muted-foreground ml-auto shrink-0" strokeWidth={2} />
|
|
)}
|
|
</div>
|
|
{(dep.state || severity || (data.expandable && data.childCount > 0)) && (
|
|
<div className="flex items-center gap-1.5 px-3 pb-1.5 font-mono text-[9px] uppercase tracking-[0.18em] text-muted-foreground">
|
|
{dep.state && <span>{dep.state}</span>}
|
|
{data.expandable && data.childCount > 0 && <span>· {data.childCount} svc</span>}
|
|
{severity && (
|
|
<span className={cn('ml-auto inline-flex items-center gap-1', severity === 'destructive' ? 'text-destructive' : 'text-warning')}>
|
|
<TriangleAlert className="h-2.5 w-2.5" strokeWidth={2} />
|
|
{dep.flags.length}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
<Handle type="source" position={Position.Right} className="!bg-muted-foreground !w-1.5 !h-1.5 !border-0" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const nodeTypes: NodeTypes = { dep: DependencyNodeCard };
|
|
|
|
// ── Adjacency helpers ──────────────────────────────────────────────────────
|
|
|
|
interface Adjacency {
|
|
byId: Map<string, DepNode>;
|
|
stackChildren: Map<string, string[]>;
|
|
serviceResources: Map<string, string[]>;
|
|
/** Reverse of serviceResources: resource id -> services that reference it. */
|
|
resourceServices: Map<string, string[]>;
|
|
hostByNodeId: Map<number, string>;
|
|
serviceStack: Map<string, string>;
|
|
}
|
|
|
|
function buildAdjacency(map: FleetDependencyMap): Adjacency {
|
|
const byId = new Map(map.nodes.map((n) => [n.id, n]));
|
|
const stackChildren = new Map<string, string[]>();
|
|
const serviceResources = new Map<string, string[]>();
|
|
const resourceServices = new Map<string, string[]>();
|
|
const serviceStack = new Map<string, string>();
|
|
const hostByNodeId = new Map<number, string>();
|
|
|
|
for (const n of map.nodes) if (n.kind === 'host') hostByNodeId.set(n.nodeId, n.id);
|
|
|
|
for (const e of map.edges) {
|
|
if (e.kind === 'stack-service') {
|
|
const arr = stackChildren.get(e.source) ?? [];
|
|
arr.push(e.target);
|
|
stackChildren.set(e.source, arr);
|
|
serviceStack.set(e.target, e.source);
|
|
} else if (e.kind === 'service-network' || e.kind === 'service-volume' || e.kind === 'service-port') {
|
|
const fwd = serviceResources.get(e.source) ?? [];
|
|
fwd.push(e.target);
|
|
serviceResources.set(e.source, fwd);
|
|
const rev = resourceServices.get(e.target) ?? [];
|
|
rev.push(e.source);
|
|
resourceServices.set(e.target, rev);
|
|
}
|
|
}
|
|
return { byId, stackChildren, serviceResources, resourceServices, hostByNodeId, serviceStack };
|
|
}
|
|
|
|
// ── Tab ─────────────────────────────────────────────────────────────────────
|
|
|
|
type ViewMode = 'graph' | 'list';
|
|
|
|
export function DependencyMapTab() {
|
|
const [data, setData] = useState<FleetDependencyMap | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [view, setView] = useState<ViewMode>('graph');
|
|
const [search, setSearch] = useState('');
|
|
const [nodeFilter, setNodeFilter] = useState<Set<number>>(new Set());
|
|
const [flagFilter, setFlagFilter] = useState<Set<DepFlagKind>>(new Set());
|
|
const [expandedStacks, setExpandedStacks] = useState<Set<string>>(new Set());
|
|
|
|
const fetchMap = useCallback(async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const res = await apiFetch('/fleet/dependency-map', { localOnly: true });
|
|
if (!res.ok) throw new Error(`Request failed (${res.status})`);
|
|
setData(await res.json() as FleetDependencyMap);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : 'Failed to load dependency map';
|
|
setError(message);
|
|
toast.error(`Failed to load dependency map: ${message}`);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => { void fetchMap(); }, [fetchMap]);
|
|
|
|
const adjacency = useMemo(() => (data ? buildAdjacency(data) : null), [data]);
|
|
|
|
const flagCounts = useMemo(() => {
|
|
const counts = new Map<DepFlagKind, number>();
|
|
for (const f of data?.flags ?? []) counts.set(f.kind, (counts.get(f.kind) ?? 0) + 1);
|
|
return counts;
|
|
}, [data]);
|
|
|
|
const presentNodeIds = useMemo(() => {
|
|
const set = new Set<number>();
|
|
for (const n of data?.nodes ?? []) set.add(n.nodeId);
|
|
for (const e of data?.nodeErrors ?? []) set.add(e.nodeId);
|
|
return [...set];
|
|
}, [data]);
|
|
|
|
const nodeNameById = useMemo(() => {
|
|
const m = new Map<number, string>();
|
|
for (const n of data?.nodes ?? []) m.set(n.nodeId, n.nodeName);
|
|
for (const e of data?.nodeErrors ?? []) m.set(e.nodeId, e.nodeName);
|
|
return m;
|
|
}, [data]);
|
|
|
|
const term = search.trim().toLowerCase();
|
|
const searchActive = term !== '';
|
|
const flagActive = flagFilter.size > 0;
|
|
|
|
const passNode = useCallback((n: DepNode) => nodeFilter.size === 0 || nodeFilter.has(n.nodeId), [nodeFilter]);
|
|
const textMatch = useCallback((n: DepNode) =>
|
|
n.label.toLowerCase().includes(term) || (n.stack ?? '').toLowerCase().includes(term) || n.nodeName.toLowerCase().includes(term), [term]);
|
|
const flagMatch = useCallback((n: DepNode) => n.flags.some((f) => flagFilter.has(f)), [flagFilter]);
|
|
const matchesFilters = useCallback((n: DepNode) =>
|
|
(!searchActive || textMatch(n)) && (!flagActive || flagMatch(n)), [searchActive, textMatch, flagActive, flagMatch]);
|
|
|
|
// Resolve the set of nodes to draw (collapse + filters).
|
|
const visible = useMemo(() => {
|
|
if (!data || !adjacency) return { nodes: [] as DepNode[], expanded: new Set<string>() };
|
|
const filterActive = searchActive || flagActive;
|
|
const visibleIds = new Set<string>();
|
|
const expanded = new Set(expandedStacks);
|
|
|
|
const addWithHost = (n: DepNode) => {
|
|
visibleIds.add(n.id);
|
|
const host = adjacency.hostByNodeId.get(n.nodeId);
|
|
if (host) visibleIds.add(host);
|
|
};
|
|
|
|
if (filterActive) {
|
|
const revealService = (svcId: string) => {
|
|
const svcNode = adjacency.byId.get(svcId);
|
|
if (!svcNode || !passNode(svcNode)) return;
|
|
addWithHost(svcNode);
|
|
const parentStack = adjacency.serviceStack.get(svcId);
|
|
if (parentStack) { visibleIds.add(parentStack); expanded.add(parentStack); }
|
|
};
|
|
for (const n of data.nodes) {
|
|
if (!passNode(n) || n.kind === 'host' || n.kind === 'stack') continue;
|
|
if (!matchesFilters(n)) continue;
|
|
addWithHost(n);
|
|
if (n.kind === 'service') {
|
|
const parentStack = adjacency.serviceStack.get(n.id);
|
|
if (parentStack) { visibleIds.add(parentStack); expanded.add(parentStack); }
|
|
for (const r of adjacency.serviceResources.get(n.id) ?? []) visibleIds.add(r);
|
|
} else {
|
|
// A matching resource reveals the services that claim it (and their
|
|
// stacks) so "what claims port 8080?" and "which stacks share this
|
|
// network?" show the connected services, not an isolated node.
|
|
for (const svc of adjacency.resourceServices.get(n.id) ?? []) revealService(svc);
|
|
}
|
|
}
|
|
// Stacks that match directly (e.g. orphan synthetic stacks).
|
|
for (const n of data.nodes) {
|
|
if (n.kind === 'stack' && passNode(n) && matchesFilters(n)) addWithHost(n);
|
|
}
|
|
} else {
|
|
for (const n of data.nodes) {
|
|
if (!passNode(n)) continue;
|
|
if (n.kind === 'host' || n.kind === 'stack') { visibleIds.add(n.id); continue; }
|
|
// Standalone orphan resources surface by default.
|
|
if ((n.kind === 'network' || n.kind === 'volume') && n.flags.includes('orphan')) addWithHost(n);
|
|
}
|
|
for (const sid of expanded) {
|
|
if (!visibleIds.has(sid)) continue;
|
|
for (const svc of adjacency.stackChildren.get(sid) ?? []) {
|
|
const svcNode = adjacency.byId.get(svc);
|
|
if (!svcNode || !passNode(svcNode)) continue;
|
|
visibleIds.add(svc);
|
|
for (const r of adjacency.serviceResources.get(svc) ?? []) visibleIds.add(r);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { nodes: data.nodes.filter((n) => visibleIds.has(n.id)), expanded };
|
|
}, [data, adjacency, searchActive, flagActive, expandedStacks, passNode, matchesFilters]);
|
|
|
|
const childCounts = useMemo(() => {
|
|
const m = new Map<string, number>();
|
|
if (adjacency) for (const [stack, kids] of adjacency.stackChildren) m.set(stack, kids.length);
|
|
return m;
|
|
}, [adjacency]);
|
|
|
|
const overCeiling = visible.nodes.length > GRAPH_NODE_CEILING;
|
|
|
|
const [flowNodes, setFlowNodes, onNodesChange] = useNodesState<Node>([]);
|
|
const [flowEdges, setFlowEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
|
|
|
useEffect(() => {
|
|
if (!data || view !== 'graph' || overCeiling) {
|
|
setFlowNodes([]);
|
|
setFlowEdges([]);
|
|
return;
|
|
}
|
|
const visibleSet = new Set(visible.nodes.map((n) => n.id));
|
|
const edges = data.edges.filter((e) => visibleSet.has(e.source) && visibleSet.has(e.target));
|
|
const { nodes: fn, edges: fe } = layoutDependencyGraph(visible.nodes, edges, visible.expanded, childCounts);
|
|
setFlowNodes(fn);
|
|
setFlowEdges(fe);
|
|
}, [data, view, overCeiling, visible, childCounts, setFlowNodes, setFlowEdges]);
|
|
|
|
const handleNodeClick = useCallback((_e: React.MouseEvent, flowNode: Node) => {
|
|
const dep = (flowNode.data as DepFlowData | undefined)?.dep;
|
|
if (dep?.kind !== 'stack') return;
|
|
setExpandedStacks((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(flowNode.id)) next.delete(flowNode.id); else next.add(flowNode.id);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const miniMapColor = useCallback((n: Node) => {
|
|
const dep = (n.data as DepFlowData | undefined)?.dep;
|
|
const sev = dep ? worstSeverity(dep.flags) : null;
|
|
if (sev === 'destructive') return MINIMAP_DESTRUCTIVE;
|
|
if (sev === 'warning') return MINIMAP_WARNING;
|
|
if (dep?.kind === 'host') return MINIMAP_BRAND;
|
|
return MINIMAP_MUTED;
|
|
}, []);
|
|
|
|
const toggleFlag = (kind: DepFlagKind) => setFlagFilter((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(kind)) next.delete(kind); else next.add(kind);
|
|
return next;
|
|
});
|
|
const toggleNode = (id: number) => setNodeFilter((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) next.delete(id); else next.add(id);
|
|
return next;
|
|
});
|
|
|
|
const listRows = useMemo(() => {
|
|
if (!data) return [];
|
|
return data.nodes
|
|
.filter((n) => n.kind !== 'host')
|
|
.filter(passNode)
|
|
.filter(matchesFilters);
|
|
}, [data, passNode, matchesFilters]);
|
|
|
|
if (loading && !data) {
|
|
return <div className="rounded-lg border border-card-border bg-card p-10 text-center text-sm text-muted-foreground">Loading dependency map…</div>;
|
|
}
|
|
if (error && !data) {
|
|
return (
|
|
<div className="rounded-lg border border-card-border bg-card p-10 text-center">
|
|
<p className="text-sm text-muted-foreground mb-3">{error}</p>
|
|
<Button variant="outline" size="sm" onClick={() => void fetchMap()} className="gap-2">
|
|
<RefreshCw className="w-4 h-4" />Retry
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
if (!data) return null;
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{/* Toolbar */}
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<div className="relative">
|
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
|
<Input
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder="Search stacks, services, resources…"
|
|
className="h-8 w-64 pl-8 text-sm"
|
|
/>
|
|
</div>
|
|
<SegmentedControl
|
|
value={view}
|
|
onChange={setView}
|
|
ariaLabel="View mode"
|
|
options={[
|
|
{ value: 'graph', label: 'Graph', icon: Share2 },
|
|
{ value: 'list', label: 'List', icon: Layers },
|
|
]}
|
|
/>
|
|
<Button variant="outline" size="sm" onClick={() => void fetchMap()} disabled={loading} className="gap-2 ml-auto">
|
|
<RefreshCw className={cn('w-4 h-4', loading && 'animate-spin')} />Refresh
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Flag summary strip */}
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
{FLAG_META.map((f) => {
|
|
const count = flagCounts.get(f.kind) ?? 0;
|
|
const active = flagFilter.has(f.kind);
|
|
return (
|
|
<button
|
|
key={f.kind}
|
|
type="button"
|
|
onClick={() => toggleFlag(f.kind)}
|
|
aria-pressed={active}
|
|
className={cn(
|
|
'inline-flex items-center gap-1.5 rounded-md border px-2 py-1 font-mono text-[10px] uppercase tracking-[0.12em] transition-colors',
|
|
active ? 'border-brand/60 bg-brand/15 text-brand'
|
|
: count > 0 ? 'border-card-border text-foreground hover:bg-muted/40'
|
|
: 'border-card-border text-muted-foreground/60',
|
|
)}
|
|
>
|
|
<span className={cn('h-1.5 w-1.5 rounded-full', f.severity === 'destructive' ? 'bg-destructive' : 'bg-warning')} />
|
|
{f.label}
|
|
<span className="tabular-nums">{count > 99 ? '99+' : count}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Node filter chips (only with more than one node present) */}
|
|
{presentNodeIds.length > 1 && (
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="font-mono text-[9px] uppercase tracking-[0.22em] text-muted-foreground">Node</span>
|
|
{presentNodeIds.map((id) => {
|
|
const active = nodeFilter.has(id);
|
|
return (
|
|
<button
|
|
key={id}
|
|
type="button"
|
|
onClick={() => toggleNode(id)}
|
|
aria-pressed={active}
|
|
className={cn(
|
|
'inline-flex items-center gap-1.5 rounded-md border px-2 py-1 font-mono text-[10px] uppercase tracking-[0.12em] transition-colors',
|
|
active ? 'border-brand/60 bg-brand/15 text-brand' : 'border-card-border text-muted-foreground hover:text-foreground hover:bg-muted/40',
|
|
)}
|
|
>
|
|
<Server className="h-3 w-3" strokeWidth={2} />
|
|
{nodeNameById.get(id) ?? `Node ${id}`}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Unreachable-node banner */}
|
|
{data.nodeErrors.length > 0 && (
|
|
<div className="flex items-start gap-2 rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-sm text-warning">
|
|
<TriangleAlert className="h-4 w-4 shrink-0 mt-0.5" strokeWidth={2} />
|
|
<span>
|
|
{data.nodeErrors.length === 1 ? '1 node could not be reached' : `${data.nodeErrors.length} nodes could not be reached`}: {data.nodeErrors.map((e) => e.nodeName).join(', ')}. The rest of the fleet is shown below.
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{data.parseErrors.length > 0 && (
|
|
<div className="flex items-start gap-2 rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-sm text-warning">
|
|
<TriangleAlert className="h-4 w-4 shrink-0 mt-0.5" strokeWidth={2} />
|
|
<span>
|
|
{data.parseErrors.length === 1 ? '1 stack could not be parsed' : `${data.parseErrors.length} stacks could not be parsed`}: {data.parseErrors.map((e) => `${e.nodeName}/${e.stack}`).join(', ')}. Their declared dependencies are not shown.
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Body */}
|
|
{view === 'graph' ? (
|
|
overCeiling ? (
|
|
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-10 text-center">
|
|
<Network className="h-6 w-6 text-muted-foreground mx-auto mb-3" strokeWidth={1.5} />
|
|
<p className="text-sm text-muted-foreground mb-1">This fleet is large to draw at once ({visible.nodes.length} elements).</p>
|
|
<p className="text-sm text-muted-foreground mb-4">Narrow it with search or filters, or switch to the list.</p>
|
|
<Button variant="outline" size="sm" onClick={() => setView('list')}>Switch to list</Button>
|
|
</div>
|
|
) : flowNodes.length === 0 ? (
|
|
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-10 text-center text-sm text-muted-foreground">
|
|
{searchActive || flagActive ? 'No elements match the current filters.' : 'No stacks to map on this fleet.'}
|
|
</div>
|
|
) : (
|
|
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
|
|
<div className="px-3 py-1.5 text-[11px] text-muted-foreground bg-muted/30 border-b border-card-border">
|
|
Click a stack to expand its services, networks, volumes, and ports.
|
|
</div>
|
|
<div className="h-[560px] w-full">
|
|
<ReactFlow
|
|
nodes={flowNodes}
|
|
edges={flowEdges}
|
|
onNodesChange={onNodesChange}
|
|
onEdgesChange={onEdgesChange}
|
|
onNodeClick={handleNodeClick}
|
|
nodeTypes={nodeTypes}
|
|
fitView
|
|
fitViewOptions={{ padding: 0.2 }}
|
|
proOptions={{ hideAttribution: true }}
|
|
nodesConnectable={false}
|
|
className="bg-background"
|
|
>
|
|
<Background gap={20} size={1} className="opacity-30" />
|
|
<Controls
|
|
className="!bg-card !border-card-border !shadow-card-bevel [&>button]:!bg-card [&>button]:!border-card-border [&>button]:!text-foreground [&>button:hover]:!bg-muted"
|
|
showInteractive={false}
|
|
/>
|
|
<MiniMap className="!bg-card !border-card-border !shadow-card-bevel" nodeColor={miniMapColor} maskColor="oklch(0 0 0 / 0.2)" pannable zoomable />
|
|
</ReactFlow>
|
|
</div>
|
|
</div>
|
|
)
|
|
) : (
|
|
<DependencyList rows={listRows} nodeColumn={presentNodeIds.length > 1} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── List view ───────────────────────────────────────────────────────────────
|
|
|
|
function DependencyList({ rows, nodeColumn }: { rows: DepNode[]; nodeColumn: boolean }) {
|
|
if (rows.length === 0) {
|
|
return <div className="rounded-lg border border-card-border bg-card p-10 text-center text-sm text-muted-foreground">No elements match the current filters.</div>;
|
|
}
|
|
return (
|
|
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-card-border font-mono text-[9px] uppercase tracking-[0.18em] text-muted-foreground">
|
|
{nodeColumn && <th className="text-left px-3 py-2 font-normal">Node</th>}
|
|
<th className="text-left px-3 py-2 font-normal">Stack</th>
|
|
<th className="text-left px-3 py-2 font-normal">Type</th>
|
|
<th className="text-left px-3 py-2 font-normal">Name</th>
|
|
<th className="text-left px-3 py-2 font-normal">State</th>
|
|
<th className="text-left px-3 py-2 font-normal">Flags</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((n) => {
|
|
const sev = worstSeverity(n.flags);
|
|
return (
|
|
<tr key={n.id} className="border-b border-card-border/50 last:border-0">
|
|
{nodeColumn && <td className="px-3 py-1.5 text-muted-foreground">{n.nodeName}</td>}
|
|
<td className="px-3 py-1.5 text-muted-foreground">{n.stack ?? '—'}</td>
|
|
<td className="px-3 py-1.5 font-mono text-[11px] text-muted-foreground">{n.kind}</td>
|
|
<td className="px-3 py-1.5 text-stat-value">{n.label}</td>
|
|
<td className="px-3 py-1.5 text-muted-foreground">{n.state ?? '—'}</td>
|
|
<td className="px-3 py-1.5">
|
|
{n.flags.length === 0 ? <span className="text-muted-foreground">—</span> : (
|
|
<span className={cn('inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-[0.12em]', sev === 'destructive' ? 'text-destructive' : 'text-warning')}>
|
|
<TriangleAlert className="h-3 w-3" strokeWidth={2} />
|
|
{n.flags.join(', ')}
|
|
</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|