diff --git a/frontend/components/canvas/InfraCanvas.tsx b/frontend/components/canvas/InfraCanvas.tsx index 2c8cbc8..221c156 100644 --- a/frontend/components/canvas/InfraCanvas.tsx +++ b/frontend/components/canvas/InfraCanvas.tsx @@ -24,9 +24,9 @@ import { } from '@/types' import { applyRootedDagreLayout, applyNamespaceHierarchyLayout } from '@/lib/layout' import { MAP_NODE_WIDTH, MAP_NODE_HEIGHT } from '@/lib/radialMap' -import { applyStructuredMapLayout } from '@/lib/structuredMap' +import { buildHierarchy, applyHierarchyLayout } from '@/lib/hierarchyMap' import MapNode from './MapNode' -import MapLabel from './MapLabel' +import { MapRoot, MapType } from './MapHierarchyNodes' import { sendCommand } from '@/lib/wsManager' import { buildGroupedGraph, @@ -170,7 +170,8 @@ const nodeTypes: NodeTypes = { namespaceGroup: NamespaceGroupNode, groupNode: GroupNode, mapNode: MapNode, - mapLabel: MapLabel, + mapRoot: MapRoot, + mapType: MapType, } // ─── Main component ─────────────────────────────────────────────────────────── @@ -383,10 +384,17 @@ export default function InfraCanvas({ vm, onBack }: InfraCanvasProps) { ) // The map draws every resource with the compact disc-and-label node; // `buildFlatFlowElements` emits the card nodes used by the tree view. - flowNodes = viewMode === 'map' - ? fn.map((n) => ({ ...n, type: 'mapNode', width: MAP_NODE_WIDTH, height: MAP_NODE_HEIGHT })) - : fn - flowEdges = fe + if (viewMode === 'map') { + const h = buildHierarchy( + fn.map((n) => ({ ...n, type: 'mapNode', width: MAP_NODE_WIDTH, height: MAP_NODE_HEIGHT })) as Node[], + fe as Edge[], + ) + flowNodes = h.nodes + flowEdges = h.edges as Edge[] + } else { + flowNodes = fn + flowEdges = fe + } setGroupsMap(new Map()) } @@ -402,7 +410,7 @@ export default function InfraCanvas({ vm, onBack }: InfraCanvasProps) { // Every node on screen, arranged as a hub-and-spokes map. Deterministic // by design: snapshots arrive every 15s and a re-randomised layout would // make the whole map jump on every refresh. - ln = applyStructuredMapLayout(flowNodes, flowEdges).nodes + ln = applyHierarchyLayout(flowNodes, flowEdges).nodes le = flowEdges.map((e) => ({ ...e, label: undefined, diff --git a/frontend/components/canvas/MapHierarchyNodes.tsx b/frontend/components/canvas/MapHierarchyNodes.tsx new file mode 100644 index 0000000..73eee02 --- /dev/null +++ b/frontend/components/canvas/MapHierarchyNodes.tsx @@ -0,0 +1,110 @@ +'use client' + +import { memo } from 'react' +import { Handle, Position, type NodeProps } from 'reactflow' +import { HIER_ROOT_W, HIER_ROOT_H, HIER_TYPE_W, HIER_TYPE_H } from '@/lib/hierarchyMap' +import NodeSvgIcon from './NodeSvgIcon' + +/** Platform branding. The Kubernetes helm is the one logo worth drawing — it + * is what makes the top of the tree read as "this is Kubernetes" at a glance + * rather than as a generic box labelled with a word. */ +function PlatformMark({ platform, size }: { platform: string; size: number }) { + if (platform === 'Kubernetes') { + return ( + + + + + + + + + + ) + } + if (platform === 'Docker') { + return ( + + + + + + + + + + ) + } + return ( + + + + + + + ) +} + +/** Top-of-tree platform node: Kubernetes / Docker / Host. */ +export const MapRoot = memo(({ data, selected }: NodeProps<{ label: string; count: number }>) => { + return ( +
+ + +
+
{data.label}
+
{data.count} resources
+
+
+ ) +}) +MapRoot.displayName = 'MapRoot' + +/** Resource-type node, e.g. "Pods · 43", hanging under a platform. */ +export const MapType = memo(({ data, selected }: NodeProps<{ label: string; kind: string; count: number }>) => { + return ( +
+ + + + {data.label} + {data.count} +
+ ) +}) +MapType.displayName = 'MapType' diff --git a/frontend/components/canvas/MapLabel.tsx b/frontend/components/canvas/MapLabel.tsx deleted file mode 100644 index b024387..0000000 --- a/frontend/components/canvas/MapLabel.tsx +++ /dev/null @@ -1,46 +0,0 @@ -'use client' - -import { memo } from 'react' -import { type NodeProps } from 'reactflow' - -/** - * Header for a block in the structured map. - * - * The point of the structured layout is that the block *says what it is* — a - * namespace name and how much it holds — so a wall of individual nodes is - * still navigable. Without this the map is just as anonymous as the radial - * version, just rectangular. - */ - -const KIND_STYLE: Record = { - ns: { color: '#a5b4fc', bg: 'rgba(99,102,241,0.10)', border: 'rgba(99,102,241,0.30)' }, - type: { color: '#94a3b8', bg: 'rgba(100,116,139,0.10)', border: 'rgba(100,116,139,0.28)' }, -} - -const MapLabel = memo(({ data }: NodeProps<{ label: string; sub: string; kind: string }>) => { - const s = KIND_STYLE[data.kind] ?? KIND_STYLE.type - return ( -
- {data.label} - {data.sub} -
- ) -}) - -MapLabel.displayName = 'MapLabel' -export default MapLabel diff --git a/frontend/lib/hierarchyMap.ts b/frontend/lib/hierarchyMap.ts new file mode 100644 index 0000000..4687519 --- /dev/null +++ b/frontend/lib/hierarchyMap.ts @@ -0,0 +1,320 @@ +/** + * hierarchyMap.ts + * + * A real containment hierarchy for the map view: + * + * Kubernetes (big node, logo) + * ├── Pods ×43 + * ├── Namespaces ×9 ── cs-agent ── its pods + * │ kube-system ── its pods + * ├── Deployments ×30 + * └── Services ×44 + * + * The previous attempts each got one half of this wrong. Grouping by type + * alone threw away parentage — one flat `Pods ×9` node owned by nothing. A + * free radial layout threw away grouping — 189 identical discs. Bucketing + * into labelled blocks kept the grouping but had no hierarchy *inside* it, so + * a namespace's resources looked like a flat shelf rather than something + * belonging to it. + * + * So: platform roots at the top, resource-type nodes beneath them, individual + * entities beneath those, and a namespace's own members beneath the namespace. + * Containment is expressed by position *and* an edge, so the structure is + * readable at a glance and still navigable by following lines. + * + * Deterministic — snapshots arrive every 15s and the layout must not reshuffle. + */ + +import { Node, Edge } from 'reactflow' +import { MAP_NODE_WIDTH, MAP_NODE_HEIGHT } from './radialMap' + +// ── Sizes ───────────────────────────────────────────────────────────────────── +const ROOT_W = 210 +const ROOT_H = 62 +const TYPE_W = 168 +const TYPE_H = 42 + +const ENT_W = MAP_NODE_WIDTH +const ENT_H = MAP_NODE_HEIGHT + +const GAP_X = 18 +const GAP_Y = 34 +const LEVEL_H = 62 // vertical gap between levels, on top of node height + +/** Types that live inside a namespace and are therefore drawn under it. */ +const NAMESPACE_SCOPED = new Set([ + 'pod', 'deployment', 'statefulset', 'daemonset', 'job', 'cronjob', + 'k8s_service', 'ingress', 'pvc', +]) + +/** Human label for a raw type. */ +const TYPE_LABEL: Record = { + pod: 'Pods', deployment: 'Deployments', statefulset: 'StatefulSets', + daemonset: 'DaemonSets', job: 'Jobs', cronjob: 'CronJobs', + k8s_service: 'Services', ingress: 'Ingresses', pvc: 'PVCs', + namespace: 'Namespaces', node: 'Nodes', cluster: 'Cluster', + container: 'Containers', image: 'Images', image_group: 'Build Cache', + volume: 'Volumes', network: 'Networks', container_runtime: 'Container Runtime', + event: 'Events', service: 'Services', process: 'Processes', host: 'Host', + configmap: 'ConfigMaps', secret: 'Secrets', + storageclass: 'Storage Classes', persistentvolume: 'Persistent Volumes', + ingressclass: 'Ingress Classes', node_role: 'Node Roles', +} + +/** Which platform a type belongs to, for the root row. */ +const PLATFORM: Record = (() => { + const m: Record = {} + const put = (p: string, types: string[]) => types.forEach((t) => { m[t] = p }) + put('Kubernetes', ['cluster', 'node', 'namespace', 'pod', 'deployment', 'statefulset', + 'daemonset', 'job', 'cronjob', 'k8s_service', 'ingress', 'pvc', 'configmap', + 'secret', 'storageclass', 'persistentvolume', 'ingressclass', 'node_role']) + put('Docker', ['container', 'image', 'image_group', 'volume', 'network', 'container_runtime']) + put('Host', ['host', 'process', 'service']) + return m +})() + +export const HIER_ROOT_W = ROOT_W +export const HIER_ROOT_H = ROOT_H +export const HIER_TYPE_W = TYPE_W +export const HIER_TYPE_H = TYPE_H + +type Meta = Record + +function nsOf(data: Meta): string { + const ns = data?.metadata?.namespace + return typeof ns === 'string' ? ns : '' +} + +/** + * Build the hierarchy nodes and the containment edges between them. + * Returns React Flow nodes plus synthetic edges joining parent → child. + */ +export function buildHierarchy( + nodes: Node[], + edges: Edge[], +): { nodes: Node[]; edges: Edge[] } { + const outNodes: Node[] = [] + const outEdges: Edge[] = [] + const tree = new Map() // parent id → child ids + const add = (parent: string, child: string) => { + tree.set(parent, [...(tree.get(parent) ?? []), child]) + } + + const byType = new Map() + for (const n of nodes) { + const t = String((n.data as Meta)?.nodeType ?? n.type ?? '') + byType.set(t, [...(byType.get(t) ?? []), n]) + } + + // 1. Platform roots, in a stable order. + const platforms = [...new Set([...byType.keys()].map((t) => PLATFORM[t]).filter(Boolean))] + const rootId = (p: string) => `__root__${p}` + for (const p of platforms) { + const count = [...byType.keys()].filter((t) => PLATFORM[t] === p) + .reduce((a, t) => a + (byType.get(t)?.length ?? 0), 0) + outNodes.push({ + id: rootId(p), type: 'mapRoot', + position: { x: 0, y: 0 }, + data: { label: p, count }, + width: ROOT_W, height: ROOT_H, + draggable: true, + } as Node) + } + + // 2. Type nodes under each platform. Namespaces get their entities attached + // as real nodes; every other type's entities attach directly. + for (const t of [...byType.keys()].sort()) { + const list = byType.get(t)! + const platform = PLATFORM[t] + const typeId = `__type__${t}` + + if (!outNodes.some((n) => n.id === typeId)) { + outNodes.push({ + id: typeId, type: 'mapType', + position: { x: 0, y: 0 }, + data: { label: TYPE_LABEL[t] ?? t, kind: t, count: list.length }, + width: TYPE_W, height: TYPE_H, + } as Node) + if (platform) add(rootId(platform), typeId) + } + + if (t === 'namespace') { + // Namespace entities become their own visible nodes; their members hang + // beneath them. + for (const ns of list.slice().sort(byLabel)) { + add(typeId, ns.id) + } + continue + } + + if (NAMESPACE_SCOPED.has(t)) { + // Drawn under their namespace instead, so the parentage is real. + // Still linked to the type node so "Pods" reaches its pods. + continue + } + + for (const n of list.slice().sort(byLabel)) add(typeId, n.id) + } + + // 3. Namespaced resources under their namespace. + const nsByName = new Map() + for (const n of byType.get('namespace') ?? []) nsByName.set(nsOf(n.data as Meta) || String((n.data as Meta)?.label ?? ''), n) + // A namespace node may carry the name in label rather than metadata. + for (const n of byType.get('namespace') ?? []) { + const nm = String((n.data as Meta)?.label ?? '') + if (nm && !nsByName.has(nm)) nsByName.set(nm, n) + } + + for (const t of [...byType.keys()].sort()) { + if (!NAMESPACE_SCOPED.has(t)) continue + const typeId = `__type__${t}` + for (const n of (byType.get(t) ?? []).slice().sort(byLabel)) { + const nsName = nsOf(n.data as Meta) + const parent = nsByName.get(nsName) + if (parent) { + add(parent.id, n.id) + // Also reach it from the type node, so "Pods" connects to its pods. + outEdges.push({ id: `he:${typeId}→${n.id}`, source: typeId, target: n.id, kind: 'type-link' } as Edge) + } else { + // Orphan (its namespace is filtered out) — keep it under its type. + add(typeId, n.id) + } + } + } + + // 4. All the real nodes. + outNodes.push(...nodes) + + // 5. Containment edges. + for (const [parent, children] of tree) { + for (const child of children) { + outEdges.push({ id: `hc:${parent}→${child}`, source: parent, target: child, kind: 'contain' } as Edge) + } + } + + return { nodes: outNodes, edges: outEdges } +} + +function byLabel(a: Node, b: Node): number { + const la = String((a.data as Meta)?.label ?? a.id) + const lb = String((b.data as Meta)?.label ?? b.id) + return la < lb ? -1 : la > lb ? 1 : 0 +} + +// ── Layout ──────────────────────────────────────────────────────────────────── + +interface Box { w: number; h: number; x: number; y: number; node: Node } + +/** + * Lay the hierarchy out top-down, centring each node over its children. + * + * Wide fan-outs (a namespace with 40 pods) wrap into a grid instead of one + * absurd row — the alternative is a tree tens of thousands of pixels wide, + * which fit-view answers by zooming to unreadability. + */ +export function applyHierarchyLayout( + nodes: Node[], + edges: Edge[], +): { nodes: Node[]; edges: Edge[] } { + if (nodes.length === 0) return { nodes, edges } + + const byId = new Map(nodes.map((n) => [n.id, n])) + const children = new Map() + const hasParent = new Set() + for (const e of edges) { + if ((e as { kind?: string }).kind !== 'contain') continue + if (!byId.has(e.source) || !byId.has(e.target)) continue + children.set(e.source, [...(children.get(e.source) ?? []), e.target]) + hasParent.add(e.target) + } + const roots = nodes.filter((n) => !hasParent.has(n.id)).map((n) => n.id) + + const placed: Box[] = [] + // Wider blocks mean fewer levels stacked on top of each other; capped so a + // 50-pod namespace stays a readable rectangle rather than a 9000px ribbon. + const MAX_COLS = 8 + /** Roughly how wide a level of the tree should be. */ + const TARGET_BLOCK_W = 1500 + const CELL_W = Math.max(ENT_W, TYPE_W) + GAP_X + const CELL_H = ENT_H + GAP_Y + + // Depth-first, bottom-up: each subtree reports its size, parent is centred + // above it. + const measure = (id: string): { w: number; h: number; place: (x: number, y: number) => void } => { + const node = byId.get(id)! + const isSynthetic = id.startsWith('__') + const selfW = node.width ?? ENT_W + const selfH = node.height ?? ENT_H + const kids = (children.get(id) ?? []).filter((c) => byId.has(c)) + // Sorted so ordering is stable between refreshes. + kids.sort((a, b) => byLabel(byId.get(a)!, byId.get(b)!)) + + if (kids.length === 0) { + return { w: selfW, h: selfH, place: (x, y) => placed.push({ x, y, w: selfW, h: selfH, node }) } + } + + const sub = kids.map(measure) + // Choose the column count from how wide the children actually are, not just + // how many there are. A fixed count put four ~1200px namespace blocks side + // by side and made the whole tree 6000px wide, which fit-view answers by + // zooming to unreadability. Sizing to a target block width keeps the tree + // near the shape of the viewport. + const avgW = sub.reduce((a, s) => a + s.w, 0) / sub.length + const cols = Math.max(1, Math.min(kids.length, MAX_COLS, Math.round(TARGET_BLOCK_W / (avgW + GAP_X)))) + const rows = Math.ceil(kids.length / cols) + + const colW: number[] = new Array(cols).fill(0) + const rowH: number[] = new Array(rows).fill(0) + sub.forEach((s, i) => { + const c = i % cols + const r = Math.floor(i / cols) + colW[c] = Math.max(colW[c], s.w) + rowH[r] = Math.max(rowH[r], s.h) + }) + const gridW = colW.reduce((a, b) => a + b, 0) + GAP_X * (cols - 1) + const gridH = rowH.reduce((a, b) => a + b, 0) + GAP_Y * (rows - 1) + + const w = Math.max(selfW, gridW) + const h = selfH + LEVEL_H + gridH + + return { + w, h, + place: (x, y) => { + placed.push({ x: x + (w - selfW) / 2, y, w: selfW, h: selfH, node }) + const gx = x + (w - gridW) / 2 + sub.forEach((s, i) => { + const c = i % cols + const r = Math.floor(i / cols) + let cx = gx + for (let k = 0; k < c; k++) cx += colW[k] + GAP_X + // Offset by the height of every row above this one — without this + // every row lands on the same y and stacks into one line. + let ry = y + selfH + LEVEL_H + for (let k = 0; k < r; k++) ry += rowH[k] + GAP_Y + s.place(cx, ry + (rowH[r] - s.h) / 2) + }) + void isSynthetic + }, + } + } + + const top = roots.map((id) => measure(id)) + + // Roots in a row, spaced by their own widths. + let cursor = 0 + top.forEach((t) => { + t.place(cursor, 0) + cursor += t.w + GAP_X * 3 + }) + + // Normalise so the origin is at the top-left of content. + const minX = Math.min(...placed.map((b) => b.x)) + const minY = Math.min(...placed.map((b) => b.y)) + + const out = placed.map((b) => ({ + ...b.node, + position: { x: Math.round(b.x - minX), y: Math.round(b.y - minY) }, + })) + + return { nodes: out, edges } +} diff --git a/frontend/lib/structuredMap.ts b/frontend/lib/structuredMap.ts deleted file mode 100644 index 109b6e9..0000000 --- a/frontend/lib/structuredMap.ts +++ /dev/null @@ -1,168 +0,0 @@ -/** - * structuredMap.ts - * - * Structured node layout for the map view. - * - * The first version of the map arranged everything on concentric rings around - * the most-connected node. With one host that produced a legible hub-and-spokes - * diagram; with the 189-node kind cluster it produced a uniform circle of 189 - * identical discs where a Pod, a Service and a PVC were indistinguishable. A - * free radial layout has no structure to read once the graph gets big. - * - * The grouped canvas *did* have structure — namespaces owning resource types — - * but it spent that structure on count cards, so you still had to drill in to - * learn anything. - * - * This keeps both: nodes are bucketed by the thing that owns them (namespace - * for k8s resources, type otherwise), each bucket gets its own labelled block - * laid out as a grid of individual nodes, and edges run between the real nodes. - * So the shape on screen is the shape of the cluster, and every single resource - * in it is visible and named. - * - * Deterministic throughout — the agent pushes a snapshot every 15s, so the - * layout must not reshuffle between refreshes. - */ - -import { Node, Edge } from 'reactflow' -import { MAP_NODE_WIDTH, MAP_NODE_HEIGHT } from './radialMap' - -const CELL_W = MAP_NODE_WIDTH + 16 -const CELL_H = MAP_NODE_HEIGHT + 14 -const PAD = 18 -const HEADER_H = 34 -const BLOCK_GAP = 34 - -/** Owner key: the namespace for namespaced k8s resources, else the type. */ -export function bucketKey(n: Node): string { - const d = n.data as { metadata?: Record; nodeType?: string } - const ns = d?.metadata?.namespace - const type = d?.nodeType ?? n.type ?? 'other' - if (typeof ns === 'string' && ns) return `ns:${ns}` - return `type:${type}` -} - -export function bucketLabel(key: string): string { - const [kind, rest] = key.split(':') - if (kind === 'ns') return rest - return rest.charAt(0).toUpperCase() + rest.slice(1) -} - -/** Plural label used on the block header, e.g. "8 pods". */ -export function bucketPlural(key: string, count: number): string { - const [kind, rest] = key.split(':') - if (kind === 'ns') return `${count} resource${count === 1 ? '' : 's'}` - const s = rest.endsWith('s') ? rest : rest + 's' - return `${count} ${s}` -} - -export function applyStructuredMapLayout( - nodes: Node[], - edges: Edge[], -): { nodes: Node[]; edges: Edge[] } { - if (nodes.length === 0) return { nodes, edges } - - // 1. Bucket by owner, preserving the order they appear in. - const buckets = new Map() - for (const n of nodes) { - const k = bucketKey(n) - buckets.set(k, [...(buckets.get(k) ?? []), n]) - } - - // 2. Sort each bucket by type then name so related resources sit together - // and the block is stable between refreshes. - for (const [, list] of buckets) { - list.sort((a, b) => { - const ta = String((a.data as { nodeType?: string })?.nodeType ?? '') - const tb = String((b.data as { nodeType?: string })?.nodeType ?? '') - if (ta !== tb) return ta < tb ? -1 : 1 - const la = String((a.data as { label?: string })?.label ?? a.id) - const lb = String((b.data as { label?: string })?.label ?? b.id) - return la < lb ? -1 : 1 - }) - } - - // 3. Namespace blocks first and largest-to-smallest, then type blocks. - // Namespaces are what a Kubernetes user navigates by, so they get the top - // band and the widest columns. - const keys = [...buckets.keys()].sort((a, b) => { - const na = a.startsWith('ns:') ? 0 : 1 - const nb = b.startsWith('ns:') ? 0 : 1 - if (na !== nb) return na - nb - return buckets.get(b)!.length - buckets.get(a)!.length - }) - - // 4. Wrap blocks into rows of at most MAX_BLOCK_W, keeping each block's - // height so a row's height is the tallest block in it. - // How wide one block may get, and how wide a row of blocks may get before - // wrapping. These are separate: capping the row at the block width meant one - // block per row, which stacked every namespace into a single tall column and - // wasted all the horizontal space. - const MAX_BLOCK_W = 1180 - const ROW_W = MAX_BLOCK_W * 2 - interface Placed { key: string; list: Node[]; cols: number; rows: number; w: number; h: number } - const placed: Placed[] = [] - - // Widest a single block may be, so one large bucket cannot blow out the row. - const maxCols = Math.max(1, Math.floor((MAX_BLOCK_W - PAD * 2) / CELL_W)) - - for (const key of keys) { - const list = buckets.get(key)! - // Aim for a roughly 16:9 block: cols ≈ sqrt(n * 1.8), then clamp so a - // 155-resource namespace becomes a tall readable column rather than a - // single 3500px-wide strip that pushes everything else off screen. - const cols = Math.min(maxCols, Math.max(1, Math.ceil(Math.sqrt(list.length * 1.8)))) - const rows = Math.ceil(list.length / cols) - const w = Math.max(cols * CELL_W + PAD * 2, 300) - const h = HEADER_H + PAD + rows * CELL_H + PAD - placed.push({ key, list, cols, rows, w, h }) - } - - const out: Node[] = [] - let x = 0 - let y = 0 - let rowH = 0 - - for (const b of placed) { - // Wrap when this block would overflow the row width and the row has content. - if (x > 0 && x + b.w > ROW_W) { - x = 0 - y += rowH + BLOCK_GAP - rowH = 0 - } - - // Header for the block. - out.push({ - id: `__bucket__${b.key}`, - type: 'mapLabel', - position: { x, y }, - data: { - label: bucketLabel(b.key), - sub: bucketPlural(b.key, b.list.length), - kind: b.key.startsWith('ns:') ? 'ns' : 'type', - }, - width: b.w, - height: HEADER_H, - selectable: false, - draggable: false, - zIndex: 1, - } as Node) - - // Nodes in a grid beneath the header. - b.list.forEach((n, i) => { - const col = i % b.cols - const row = Math.floor(i / b.cols) - out.push({ - ...n, - position: { - x: x + PAD + col * CELL_W, - y: y + HEADER_H + PAD + row * CELL_H, - }, - }) - }) - - x += b.w + BLOCK_GAP - rowH = Math.max(rowH, b.h) - } - - return { nodes: out, edges } -}