diff --git a/src/overlay.test.ts b/src/overlay.test.ts index e96ed61..31ac006 100644 --- a/src/overlay.test.ts +++ b/src/overlay.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { overlayStatus, reclassifyOverlay, pruneImports, attachRuntimeContainment } from "./overlay.ts"; +import { overlayStatus, reclassifyOverlay, pruneImports, attachRuntimeContainment, pruneRuntimeChildren } from "./overlay.ts"; describe("overlayStatus", () => { it("maps chant's _status vocabulary to overlay statuses", () => { @@ -134,3 +134,49 @@ describe("pruneImports", () => { expect(pruneImports(noImports).nodes).toHaveLength(1); }); }); + +// #86 — the runtime layer becomes a TIER, which means it has to be possible to +// not descend. Before this, owner-referenced children were attached at every +// zoom level, so a composites view carried its Pods. +describe("pruneRuntimeChildren (#86)", () => { + const mixed = () => ({ + nodes: [ + { id: "web", kind: "K8s::Apps::Deployment", attrs: { _status: "good" } }, + { id: "web-abc", kind: "K8s::Core::Pod", attrs: { _status: "runtime" }, runtimeOwner: "web" }, + { id: "web-def", kind: "K8s::Core::Pod", attrs: { _status: "runtime" }, runtimeOwner: "web" }, + { id: "svc", kind: "K8s::Core::Service", attrs: { _status: "good" } }, + ], + edges: [ + { from: "svc", to: "web" }, + { from: "web-abc", to: "web" }, + ], + groups: {}, + }); + + it("drops runtime children, keeping every declared node", () => { + const out = pruneRuntimeChildren(mixed() as unknown as Parameters[0]); + expect(out.nodes.map((n) => n.id).sort()).toEqual(["svc", "web"]); + }); + + it("drops edges touching a removed node, so nothing dangles", () => { + const out = pruneRuntimeChildren(mixed() as unknown as Parameters[0]); + expect(out.edges).toEqual([{ from: "svc", to: "web" }]); + }); + + it("is a no-op on a graph with no owner chain — AWS and Azure are untouched", () => { + const aws = { + nodes: [{ id: "vpc", kind: "AWS::EC2::VPC", attrs: { _status: "good" } }], + edges: [{ from: "vpc", to: "vpc" }], + groups: {}, + }; + const before = JSON.stringify(aws); + expect(JSON.stringify(pruneRuntimeChildren(aws as unknown as Parameters[0]))).toBe(before); + }); + + it("is the inverse of attaching them — the two tiers show different graphs", () => { + const attached = attachRuntimeContainment(mixed() as unknown as Parameters[0]); + const pruned = pruneRuntimeChildren(mixed() as unknown as Parameters[0]); + expect(attached.nodes.length).toBe(4); + expect(pruned.nodes.length).toBe(2); + }); +}); diff --git a/src/overlay.ts b/src/overlay.ts index 7822f42..ba03f14 100644 --- a/src/overlay.ts +++ b/src/overlay.ts @@ -164,6 +164,29 @@ export function reclassifyOverlay(ir: T): T * criterion that the tier is gracefully unavailable, never broken or blank. * Mutates + returns `ir`. Pure w.r.t. I/O. */ +/** + * Drop live-only runtime children from an overlay (#86). + * + * chant returns a Pod its Deployment's controller created as a node carrying + * `runtimeOwner` (chant#1180). Those nodes were attached at every zoom level, + * so a project's `composites` view carried its Pods — which is noise at that + * altitude, and meant the runtime layer could be seen but never dialled away. + * + * Making it a TIER means the other tiers stop where your source stops, which is + * what "descend below the declaration boundary" implies: you have to be able to + * not descend. Edges touching a dropped node go with it, since an edge to a node + * that is not in the graph renders as a dangling stub. + */ +export function pruneRuntimeChildren }>( + ir: T, +): T { + const runtime = new Set(ir.nodes.filter((n) => n.runtimeOwner).map((n) => n.id)); + if (runtime.size === 0) return ir; + ir.nodes = ir.nodes.filter((n) => !runtime.has(n.id)); + ir.edges = ir.edges.filter((e) => !runtime.has(e.from) && !runtime.has(e.to)); + return ir; +} + export function attachRuntimeContainment } }>( ir: T, ): T { diff --git a/src/server.ts b/src/server.ts index 94625bd..3a807bc 100644 --- a/src/server.ts +++ b/src/server.ts @@ -34,7 +34,7 @@ import { type ChantFailure, } from "./chant.ts"; import { joinComponentStatus, componentStatusColor } from "./component-status.ts"; -import { reclassifyOverlay, pruneImports, attachRuntimeContainment } from "./overlay.ts"; +import { reclassifyOverlay, pruneImports, attachRuntimeContainment, pruneRuntimeChildren } from "./overlay.ts"; import { addValueMatchEdges } from "./value-match.ts"; import { addClusterAnchorEdges } from "./cluster-anchor.ts"; import { projectTopology } from "./logical.ts"; @@ -728,7 +728,13 @@ export function createApp( // under its declared owner in `groups.byContainer` — a no-op on a // substrate with no owner chain. `renderGraph`'s `boxes: "byContainer"` // opt-in below draws it as a titled boundary box. - ir = attachRuntimeContainment(ir); + // The runtime tier (#86): `?runtime=1` descends below the declaration + // boundary and nests each owner-referenced child under its declared + // parent. Every other tier stops where your source stops — a Pod is noise + // in a composites view, and a layer you cannot dial away is not a tier. + ir = new URL(c.req.url).searchParams.get("runtime") === "1" + ? attachRuntimeContainment(ir) + : pruneRuntimeChildren(ir); // Logical/architecture lens (#63): re-project the live overlay into nested // region/VPC/subnet ⊃ component boxes, keeping each surviving node's drift // colour. Short-circuits the detail-tier pruning/composite plumbing below. diff --git a/web/app.js b/web/app.js index 4c37a64..a7eab2b 100644 --- a/web/app.js +++ b/web/app.js @@ -65,7 +65,7 @@ if (staticMode) { /** Canonical key for a read URL — path + the lens params (whitelisted, sorted) * that select a distinct snapshot. MUST match src/export.ts `canonicalKey`. */ -const LENS_PARAMS = ["components", "detail", "env", "logical", "radial", "tier"]; +const LENS_PARAMS = ["components", "detail", "env", "logical", "radial", "runtime", "tier"]; function canonicalKey(path, params) { // Components + logical views ignore detail/radial — drop them so they match // the single captured snapshot (MUST match src/export.ts). @@ -542,7 +542,7 @@ function wire(ir) { // null on a project that declares no `stacks[]` at all — the picker (and the // status strip's stack tag) then never renders. Every fetch reads this, so // the `changed` SSE re-pull and a palette lens change go through the same path. -const view = { env: null, detail: 2, components: true, logical: false, tier: null, target: null, stack: null, radial: false }; +const view = { env: null, detail: 2, components: true, logical: false, runtime: false, tier: null, target: null, stack: null, radial: false }; // v0.1.0 preview lock (set from /api/project in initActions): hides the git/PR // write ops (Rollback, Sync, Adopt, Run ▾) — the server also 403s them. Local @@ -565,18 +565,25 @@ const ZOOM_OPTS = [ ["zoom: composites", "composites"], ["zoom: resources", "resources"], ["zoom: attributes", "attributes"], + // Below the declaration boundary (#86): the owner-referenced children the + // cluster maintains — the Pods under a Deployment. Its own stop rather than a + // detail level, because it is a different axis: every tier above shows what + // you declared, and this one shows what your declaration produced. + ["zoom: runtime", "runtime"], ]; -const ZOOM_DETAIL = { composites: 1, resources: 2, attributes: 3 }; +const ZOOM_DETAIL = { composites: 1, resources: 2, attributes: 3, runtime: 3 }; /** Current zoom value from (components, logical, detail). */ function zoomValue() { if (view.components) return "components"; if (view.logical) return "logical"; + if (view.runtime) return "runtime"; return { 1: "composites", 2: "resources", 3: "attributes" }[view.detail] ?? "resources"; } /** Apply a zoom value back onto (components, logical, detail). */ function applyZoom(z) { view.components = z === "components"; view.logical = z === "logical"; + view.runtime = z === "runtime"; if (z !== "components" && z !== "logical") view.detail = ZOOM_DETAIL[z] ?? 2; } @@ -1445,6 +1452,11 @@ async function load(opts = {}) { } else if (view.env) { endpoint = "/api/overlay"; q.set("env", view.env); + // The runtime tier (#86) descends below the declaration boundary. It is + // live-only by nature — owner-referenced children exist in the cluster, + // never in your source — so it rides the overlay and means nothing + // without an env. + if (view.runtime) q.set("runtime", "1"); } // Radial layout (entity view only) — curl the wide DAG onto concentric rings. if (view.radial && !view.components && !view.logical) q.set("radial", "1");