+ )
+})
+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 }
-}