@intentius/behold 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/cli.js +148 -24
  2. package/package.json +3 -1
  3. package/web/app.js +5 -2
package/dist/cli.js CHANGED
@@ -164,8 +164,9 @@ function readInfo(cfg) {
164
164
  const envNames = (v) => Array.isArray(v) ? v.map((x) => typeof x === "string" ? x : typeof x?.name === "string" ? x.name : void 0).filter((x) => !!x) : [];
165
165
  const stacks = readStacks(cfg?.stacks);
166
166
  const k8sProfiles = readK8sProfiles(cfg?.k8s);
167
+ const environments = envNames(cfg?.environments);
167
168
  return {
168
- environments: envNames(cfg?.environments),
169
+ environments: environments.length ? environments : Object.keys(k8sProfiles ?? {}),
169
170
  lexicons: arr(cfg?.lexicons),
170
171
  ...k8sProfiles ? { k8sProfiles } : {},
171
172
  ...typeof cfg?.sourceDir === "string" ? { sourceDir: cfg.sourceDir } : {},
@@ -186,6 +187,36 @@ function parseEnvironmentNames(content) {
186
187
  }
187
188
  return [...body.matchAll(/["'`]([^"'`]+)["'`]/g)].map((m) => m[1]);
188
189
  }
190
+ function parseK8sProfileNames(content) {
191
+ const m = content.match(/\bprofiles\s*:\s*\{/);
192
+ if (!m || m.index === void 0) return [];
193
+ const start = m.index + m[0].length;
194
+ let depth = 1;
195
+ let end = start;
196
+ for (; end < content.length && depth > 0; end++) {
197
+ if (content[end] === "{") depth++;
198
+ else if (content[end] === "}") depth--;
199
+ }
200
+ const body = content.slice(start, end - 1);
201
+ const names = [];
202
+ let d = 0;
203
+ let segment = "";
204
+ for (const ch of body) {
205
+ if (ch === "{") {
206
+ if (d === 0) {
207
+ const key = segment.match(/(["'`]?)([\w.-]+)\1\s*:\s*$/);
208
+ if (key) names.push(key[2]);
209
+ segment = "";
210
+ }
211
+ d++;
212
+ } else if (ch === "}") {
213
+ d--;
214
+ } else if (d === 0) {
215
+ segment += ch;
216
+ }
217
+ }
218
+ return names;
219
+ }
189
220
  function parseStringLiteral(content, key) {
190
221
  const m = content.match(new RegExp(`\\b${key}\\s*:\\s*["'\`]([^"'\`]+)["'\`]`));
191
222
  return m?.[1];
@@ -202,8 +233,9 @@ async function detectProject(projectDir) {
202
233
  }
203
234
  const content = readFileSync2(path, "utf8");
204
235
  const sourceDir = parseStringLiteral(content, "sourceDir");
236
+ const environments = parseEnvironmentNames(content);
205
237
  return {
206
- environments: parseEnvironmentNames(content),
238
+ environments: environments.length ? environments : parseK8sProfileNames(content),
207
239
  lexicons: parseStringArray(content, "lexicons"),
208
240
  ...sourceDir ? { sourceDir } : {}
209
241
  };
@@ -1869,7 +1901,7 @@ function projectK8sLogical(ir, env, boundContext) {
1869
1901
  if (k8s.length === 0) return { ir: { nodes: [], edges: [], groups: {} }, byContainer: {} };
1870
1902
  const cluster = boundManagedCluster(ir.nodes, boundContext);
1871
1903
  const CLUSTER_TITLE = cluster ? cluster.id : env ? `cluster ${env}` : "cluster";
1872
- const CLUSTER_SCOPED = "cluster-scoped";
1904
+ const CLUSTER_SCOPED2 = "cluster-scoped";
1873
1905
  const namespaceTitle = (ns) => `namespace ${ns}`;
1874
1906
  const byContainer = {};
1875
1907
  const child = (parent, c) => {
@@ -1885,10 +1917,10 @@ function projectK8sLogical(ir, env, boundContext) {
1885
1917
  child(namespaceTitle(ns), n.id);
1886
1918
  } else {
1887
1919
  clusterScoped = true;
1888
- child(CLUSTER_SCOPED, n.id);
1920
+ child(CLUSTER_SCOPED2, n.id);
1889
1921
  }
1890
1922
  }
1891
- if (clusterScoped) child(CLUSTER_TITLE, CLUSTER_SCOPED);
1923
+ if (clusterScoped) child(CLUSTER_TITLE, CLUSTER_SCOPED2);
1892
1924
  const kept = new Set(headline.map((n) => n.id));
1893
1925
  const edges = ir.edges.filter((e) => kept.has(e.from) && kept.has(e.to));
1894
1926
  return { ir: { nodes: headline, edges, groups: {} }, byContainer };
@@ -2550,6 +2582,22 @@ function edgelessNote(zoom, ir) {
2550
2582
  if (ir.nodes.length === 0 || ir.edges.length > 0) return void 0;
2551
2583
  return "no edges \u2014 nothing in this estate references anything else";
2552
2584
  }
2585
+ var CLUSTER_SCOPED = /* @__PURE__ */ new Set([
2586
+ "K8s::Core::Namespace",
2587
+ "K8s::Rbac::ClusterRole",
2588
+ "K8s::Rbac::ClusterRoleBinding",
2589
+ "K8s::Storage::StorageClass",
2590
+ "K8s::Apiextensions::CustomResourceDefinition"
2591
+ ]);
2592
+ function namespaceMismatchNote(nodes) {
2593
+ const k8s = nodes.filter((n) => n?.lexicon === "k8s");
2594
+ if (k8s.length === 0) return void 0;
2595
+ const pending = k8s.filter((n) => n.attrs?._status === "accent" && !CLUSTER_SCOPED.has(n.kind ?? ""));
2596
+ if (pending.length < 3) return void 0;
2597
+ if (pending.some((n) => typeof n.attrs?.metadata?.namespace === "string" && n.attrs.metadata.namespace)) return void 0;
2598
+ if (!k8s.some((n) => n.attrs?._status === "good")) return void 0;
2599
+ return `${pending.length} pending k8s objects declare no metadata.namespace \u2014 if a controller stamps it at apply time (e.g. Flux's targetNamespace), the live read looked in "default", not where they run`;
2600
+ }
2553
2601
  function notesFor(zoom, ir, compositeEdgesAttached, logicalBefore) {
2554
2602
  const primary = zoom === "logical" && logicalBefore !== void 0 ? logicalKept(logicalBefore, ir.nodes.length) : zoomNote(zoom, ir, compositeEdgesAttached);
2555
2603
  const notes = [primary, edgelessNote(zoom, ir)].filter((n) => n !== void 0);
@@ -3513,6 +3561,44 @@ async function composeEstate(projectDirs, opts = {}) {
3513
3561
  );
3514
3562
  return composeStacks(stacks);
3515
3563
  }
3564
+ var firstLine = (e) => {
3565
+ const msg = e instanceof Error ? e.message : String(e);
3566
+ const m = msg.match(/exited \d+:\s*([\s\S]*)/);
3567
+ return (m ? m[1] : msg).split("\n")[0].trim().slice(0, 160);
3568
+ };
3569
+ async function composeEstateOverlay(projectDirs, opts, classify) {
3570
+ const names = shortStackNames(projectDirs);
3571
+ const unobserved = [];
3572
+ const dropped = [];
3573
+ const stacks = new Array(projectDirs.length);
3574
+ await Promise.all(
3575
+ projectDirs.map(async (dir, i) => {
3576
+ const name = names[i];
3577
+ try {
3578
+ stacks[i] = { name, ir: classify(await graphIr(dir, { ...opts, live: true, overlay: true })) };
3579
+ } catch (err) {
3580
+ const reason = firstLine(err);
3581
+ try {
3582
+ const { env: _env, live: _live, overlay: _overlay, ...srcOpts } = opts;
3583
+ const src = await graphIr(dir, srcOpts);
3584
+ for (const n of src.nodes) n.attrs = { ...n.attrs, _status: "neutral", _unobserved: reason };
3585
+ stacks[i] = { name, ir: src };
3586
+ unobserved.push({ name, reason });
3587
+ } catch (err2) {
3588
+ dropped.push({ name, reason: firstLine(err2) });
3589
+ }
3590
+ }
3591
+ })
3592
+ );
3593
+ const present = stacks.filter((s) => !!s);
3594
+ return {
3595
+ ir: composeStacks(present),
3596
+ observed: present.length - unobserved.length,
3597
+ total: projectDirs.length,
3598
+ unobserved,
3599
+ dropped
3600
+ };
3601
+ }
3516
3602
 
3517
3603
  // src/events.ts
3518
3604
  import { watch, existsSync as existsSync8 } from "node:fs";
@@ -3643,17 +3729,19 @@ function safeJson(value) {
3643
3729
  return JSON.stringify(value).replace(/</g, "\\u003c");
3644
3730
  }
3645
3731
  var LANES_CSS = `
3646
- #behold-lanes { position: fixed; left: 0; right: 0; bottom: 0; background: #0d1117;
3647
- border-top: 1px solid #30363d; padding: 8px 12px 10px; font: 12px ui-sans-serif, system-ui, sans-serif; color: #8b949e; }
3732
+ body { background: var(--bg, #0d1117); color: var(--fg, #e6edf3); padding-bottom: 150px; }
3733
+ #behold-lanes { position: fixed; left: 0; right: 0; bottom: 0; background: var(--bg, #0d1117);
3734
+ border-top: 1px solid var(--line, #30363d); padding: 8px 12px 10px; font: 12px ui-sans-serif, system-ui, sans-serif; color: var(--muted, #8b949e); }
3648
3735
  #behold-lanes .hd { display: flex; gap: 14px; align-items: baseline; margin-bottom: 4px; }
3649
- #behold-lanes .hd .rt { color: #d29922; }
3736
+ #behold-lanes .hd .rt { color: var(--foreign, #d29922); }
3737
+ #behold-lanes .hd a { color: var(--pending, #58a6ff); text-decoration: none; margin-left: auto; }
3650
3738
  #behold-lanes canvas { display: block; width: 100%; cursor: pointer; }
3651
3739
  #behold-diff { position: fixed; right: 12px; bottom: 156px; width: 260px; max-height: 40vh; overflow: auto;
3652
- background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 10px 12px; font: 12px ui-sans-serif, system-ui, sans-serif;
3653
- color: #e6edf3; display: none; }
3654
- #behold-diff h4 { margin: 0 0 6px; font-size: 12px; color: #8b949e; }
3655
- #behold-diff .a { color: #3fb950; } #behold-diff .r { color: #f85149; } #behold-diff .c { color: #d29922; }
3656
- body { padding-bottom: 150px; }`;
3740
+ background: var(--panel, #161b22); border: 1px solid var(--line, #30363d); border-radius: 6px; padding: 10px 12px; font: 12px ui-sans-serif, system-ui, sans-serif;
3741
+ color: var(--fg, #e6edf3); display: none; }
3742
+ #behold-diff h4 { margin: 0 0 6px; font-size: 12px; color: var(--muted, #8b949e); }
3743
+ #behold-diff .a { color: var(--managed, #3fb950); } #behold-diff .r { color: var(--degraded, #f85149); } #behold-diff .c { color: var(--foreign, #d29922); }`;
3744
+ var THEME_BOOT = `<script type="module">import { initTheme, onThemeChange, mountThemePicker } from "/theme.js"; initTheme(); mountThemePicker(document.getElementById("behold-lanes-pickers")); onThemeChange(() => window.dispatchEvent(new Event("behold-theme")));</script>`;
3657
3745
  function laneStripScript(frames) {
3658
3746
  return `<script>
3659
3747
  const LF = ${safeJson(frames)};
@@ -3664,6 +3752,7 @@ const LF = ${safeJson(frames)};
3664
3752
  const rowH = 22, padL = 96, padR = 16, padT = 6;
3665
3753
  const H = padT + subs.length * rowH + 24;
3666
3754
  const t0 = LF[0].t, tN = LF[LF.length - 1].t, span = Math.max(1, tN - t0);
3755
+ const css = (n, fb) => (getComputedStyle(document.documentElement).getPropertyValue(n).trim() || fb);
3667
3756
  const offset = {}; // per-substrate time offset (graph-inert)
3668
3757
  let cur = LF.length - 1, focus = null, pair = null;
3669
3758
 
@@ -3677,7 +3766,7 @@ const LF = ${safeJson(frames)};
3677
3766
  const inNow = now !== undefined, inPrev = prev !== undefined;
3678
3767
  return inNow !== inPrev || (inNow && inPrev && now !== prev);
3679
3768
  }
3680
- const color = s => s === "good" ? "#3fb950" : s === "warn" ? "#d29922" : s === "accent" ? "#58a6ff" : "#6e7681";
3769
+ const color = s => s === "good" ? css("--managed", "#3fb950") : s === "warn" ? css("--foreign", "#d29922") : s === "accent" ? css("--pending", "#58a6ff") : css("--edge", "#6e7681");
3681
3770
 
3682
3771
  function draw() {
3683
3772
  const dpr = window.devicePixelRatio || 1;
@@ -3686,22 +3775,22 @@ const LF = ${safeJson(frames)};
3686
3775
  c.clearRect(0, 0, host.clientWidth, H); c.font = "12px ui-sans-serif, system-ui, sans-serif";
3687
3776
  subs.forEach((s, r) => {
3688
3777
  const y = padT + r * rowH + rowH / 2;
3689
- c.fillStyle = offset[s] ? "#d29922" : "#8b949e"; c.textAlign = "left"; c.fillText(s, 8, y + 4);
3690
- c.strokeStyle = "#21262d"; c.beginPath(); c.moveTo(padL, y); c.lineTo(host.clientWidth - padR, y); c.stroke();
3778
+ c.fillStyle = offset[s] ? css("--foreign", "#d29922") : css("--muted", "#8b949e"); c.textAlign = "left"; c.fillText(s, 8, y + 4);
3779
+ c.strokeStyle = css("--line", "#21262d"); c.beginPath(); c.moveTo(padL, y); c.lineTo(host.clientWidth - padR, y); c.stroke();
3691
3780
  LF.forEach((f, i) => {
3692
3781
  if (!f.byLexicon[s]) return;
3693
3782
  // dot per substrate; brighter when a node in this substrate changed at i
3694
3783
  const changed = Object.keys(f.status).some(id => f.lexicon[id] === s && changedAt(id, i));
3695
3784
  const hi = focus && f.lexicon[focus] === s && changedAt(focus, i);
3696
- c.fillStyle = hi ? "#f0f6fc" : changed ? color(mode(f, s)) : "#30363d";
3785
+ c.fillStyle = hi ? css("--fg", "#f0f6fc") : changed ? color(mode(f, s)) : css("--line", "#30363d");
3697
3786
  c.beginPath(); c.arc(xOf(i, s), y, i === cur ? 5 : hi ? 4.5 : 3.5, 0, 7); c.fill();
3698
3787
  });
3699
3788
  });
3700
- const px = baseX(cur); c.strokeStyle = "#58a6ff"; c.lineWidth = 1.5;
3789
+ const px = baseX(cur); c.strokeStyle = css("--pending", "#58a6ff"); c.lineWidth = 1.5;
3701
3790
  c.beginPath(); c.moveTo(px, padT - 2); c.lineTo(px, padT + subs.length * rowH); c.stroke();
3702
- if (pair != null) { const qx = baseX(pair); c.strokeStyle = "#d29922"; c.setLineDash([3,3]);
3791
+ if (pair != null) { const qx = baseX(pair); c.strokeStyle = css("--foreign", "#d29922"); c.setLineDash([3,3]);
3703
3792
  c.beginPath(); c.moveTo(qx, padT - 2); c.lineTo(qx, padT + subs.length * rowH); c.stroke(); c.setLineDash([]); }
3704
- c.fillStyle = "#e6edf3"; c.textAlign = "center"; c.fillText(LF[cur].name, px, padT + subs.length * rowH + 16);
3793
+ c.fillStyle = css("--fg", "#e6edf3"); c.textAlign = "center"; c.fillText(LF[cur].name, px, padT + subs.length * rowH + 16);
3705
3794
  document.getElementById("behold-lanes-meta").textContent =
3706
3795
  LF.length + " frames \xB7 frame " + (cur + 1) + "/" + LF.length + (focus ? " \xB7 focus " + focus : "");
3707
3796
  document.getElementById("behold-lanes-rt").style.display = anyOffset() ? "inline" : "none";
@@ -3742,6 +3831,7 @@ const LF = ${safeJson(frames)};
3742
3831
  window.addEventListener("keydown", (e) => { if (e.key === "ArrowLeft") go(cur - 1); if (e.key === "ArrowRight") go(cur + 1);
3743
3832
  if (e.key === "Escape") { focus = null; pair = null; showDiff(); draw(); } });
3744
3833
  window.addEventListener("resize", draw);
3834
+ window.addEventListener("behold-theme", draw); // #198: theme flip repaints the strip
3745
3835
 
3746
3836
  // focus cursor: click a graph node \u2192 highlight where it changed (graph \u2192 lanes)
3747
3837
  function wireNodes() { document.querySelectorAll("[data-node-id]").forEach(el => {
@@ -3767,7 +3857,7 @@ function renderLanes(frames, summaries) {
3767
3857
  status: Object.fromEntries(f.ir.nodes.map((n) => [n.id, n.attrs?._status ?? ""])),
3768
3858
  lexicon: Object.fromEntries(f.ir.nodes.map((n) => [n.id, n.lexicon]))
3769
3859
  }));
3770
- const strip = `<style>${LANES_CSS}</style><div id="behold-diff"></div><div id="behold-lanes"><div class="hd"><span>deployment lanes</span><span id="behold-lanes-meta"></span><span class="rt" id="behold-lanes-rt" style="display:none">offset \u2014 graph shows real time</span></div><canvas id="behold-lanes-canvas"></canvas></div>` + laneStripScript(laneFrames);
3860
+ const strip = `<style>${LANES_CSS}</style>` + THEME_BOOT + `<div id="behold-diff"></div><div id="behold-lanes"><div class="hd"><span>deployment lanes</span><span id="behold-lanes-meta"></span><span class="rt" id="behold-lanes-rt" style="display:none">offset \u2014 graph shows real time</span><a href="/">\u2190 graph</a><span id="behold-lanes-pickers"></span></div><canvas id="behold-lanes-canvas"></canvas></div>` + laneStripScript(laneFrames);
3771
3861
  return doc.includes("</body>") ? doc.replace("</body>", `${strip}</body>`) : doc + strip;
3772
3862
  }
3773
3863
 
@@ -3923,7 +4013,7 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
3923
4013
  const all = frames.all();
3924
4014
  if (all.length < 2) {
3925
4015
  return c.html(
3926
- `<!doctype html><meta charset=utf-8><body style="font:14px system-ui;background:#0d1117;color:#8b949e;padding:2rem"><h3 style="color:#e6edf3">deployment lanes</h3><p>${all.length} frame(s) captured \u2014 need at least two to scrub.</p><p>Frames accrue when the estate moves: hit <b style="color:#e6edf3">\u21BB Refresh</b> (captures the current live state), run a <b style="color:#e6edf3">Sync</b>/Adopt, edit the source, or serve with <code>--poll</code> against a moving environment. Then reload.</p><p><a href="/" style="color:#58a6ff;text-decoration:none">\u2190 back to the graph</a></p></body>`
4016
+ `<!doctype html><meta charset=utf-8><script type="module">import { initTheme } from "/theme.js"; initTheme();</script><body style="font:14px system-ui;background:var(--bg,#0d1117);color:var(--muted,#8b949e);padding:2rem"><h3 style="color:var(--fg,#e6edf3)">deployment lanes</h3><p>${all.length} frame(s) captured \u2014 need at least two to scrub.</p><p>Frames accrue when the estate moves: hit <b style="color:var(--fg,#e6edf3)">\u21BB Refresh</b> (captures the current live state), run a <b style="color:var(--fg,#e6edf3)">Sync</b>/Adopt, edit the source, or serve with <code>--poll</code> against a moving environment. Then reload.</p><p><a href="/" style="color:var(--pending,#58a6ff);text-decoration:none">\u2190 back to the graph</a></p></body>`
3927
4017
  );
3928
4018
  }
3929
4019
  return c.html(renderLanes(all, frames.summaries()));
@@ -4034,6 +4124,11 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4034
4124
  ...cfg.projectDirs && cfg.projectDirs.length > 1 ? { projectDirs: cfg.projectDirs } : {},
4035
4125
  recents: listRecents().map((r) => r.dir),
4036
4126
  environments,
4127
+ // #191: the cluster each k8s env is bound to (`k8s.profiles.<env>.
4128
+ // context`) — the SPA's env picker shows it (`home → home-cloud`) so a
4129
+ // wrong-cluster pick is visible BEFORE the read, not after (the failure
4130
+ // chant#1100 exists to prevent, and the one behind #192's red herring).
4131
+ ...k8sProfiles ? { k8sContexts: Object.fromEntries(Object.entries(k8sProfiles).flatMap(([e, p]) => p.context ? [[e, p.context]] : [])) } : {},
4037
4132
  lexicons,
4038
4133
  currentEnv: cfg.env ?? null,
4039
4134
  // v0.1.0 preview: the SPA hides git/PR ops + arbitrary-project affordances.
@@ -4138,6 +4233,9 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4138
4233
  let metaEnv = cfg.env ?? null;
4139
4234
  if (multi) {
4140
4235
  ir = await composeEstate(cfg.projectDirs, opts);
4236
+ ir = addValueMatchEdges(ir);
4237
+ ir = addK8sDeclaredEdges(ir);
4238
+ ir = addClusterAnchorEdges(ir, await boundK8sContext(metaEnv ?? void 0));
4141
4239
  } else if (components) {
4142
4240
  ir = await componentGraphIr(cfg.projectDir, opts);
4143
4241
  const env = opts.env ?? cfg.env;
@@ -4179,7 +4277,8 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4179
4277
  const radial = new URL(c.req.url).searchParams.get("radial") === "1";
4180
4278
  const { svg } = renderGraph(ir, multi ? { boxes: "byStack" } : { radial });
4181
4279
  const srcZoom = components ? "components" : logical ? "logical" : opts.detail === 1 ? "composites" : opts.detail === 3 ? "attributes" : "resources";
4182
- const srcNote = multi ? void 0 : notesFor(srcZoom, ir, srcCompositeEdgesAttached);
4280
+ const estateLensNote = multi && (components || logical) ? `the ${components ? "components" : "logical"} lens doesn't apply to a composed estate yet \u2014 showing the composed entity graph` : void 0;
4281
+ const srcNote = multi ? estateLensNote : notesFor(srcZoom, ir, srcCompositeEdgesAttached);
4183
4282
  return c.json({
4184
4283
  ir,
4185
4284
  svg,
@@ -4278,6 +4377,30 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4278
4377
  }
4279
4378
  const logical = new URL(c.req.url).searchParams.get("logical") === "1";
4280
4379
  try {
4380
+ if (cfg.projectDirs && cfg.projectDirs.length > 1) {
4381
+ const runtime = new URL(c.req.url).searchParams.get("runtime") === "1";
4382
+ const est = await composeEstateOverlay(cfg.projectDirs, { ...tierTargetOpts(query), detail: query.detail, env }, reclassifyOverlay);
4383
+ if (est.dropped.length === est.total) {
4384
+ return c.json({ error: `no project in the estate could be graphed \u2014 ${est.dropped.map((d) => `${d.name}: ${d.reason}`).join("; ")}` }, 500);
4385
+ }
4386
+ let ir2 = pruneRuntimeChildren(est.ir);
4387
+ if ((query.detail ?? 2) < 3) ir2 = pruneImports(ir2);
4388
+ ir2 = addValueMatchEdges(ir2);
4389
+ ir2 = addK8sDeclaredEdges(ir2);
4390
+ ir2 = addClusterAnchorEdges(ir2, await boundK8sContext(env));
4391
+ const { svg: svg2 } = renderGraph(ir2, { boxes: "byStack" });
4392
+ const lensNote = logical || runtime ? `the ${logical ? "logical" : "runtime"} lens doesn't apply to a composed estate yet \u2014 showing the composed entity overlay` : void 0;
4393
+ const coverNote = est.unobserved.length || est.dropped.length ? `live observe covered ${est.observed} of ${est.total} projects \u2014 ` + [
4394
+ ...est.unobserved.map((u) => `${u.name}: ${u.reason} (painted unobserved)`),
4395
+ ...est.dropped.map((d) => `${d.name}: dropped (${d.reason})`)
4396
+ ].join("; ") : void 0;
4397
+ const note2 = [lensNote, coverNote, namespaceMismatchNote(ir2.nodes)].filter(Boolean).join(" \xB7 ");
4398
+ return c.json({
4399
+ ir: ir2,
4400
+ svg: svg2,
4401
+ meta: { projectDir: cfg.projectDir, env, mode: "overlay", estate: est.total, ...note2 ? { note: note2 } : {} }
4402
+ });
4403
+ }
4281
4404
  const opts = { ...query, live: true, overlay: true, env, ...logical ? { detail: 3 } : {} };
4282
4405
  let ir = reclassifyOverlay(await graphIr(cfg.projectDir, opts));
4283
4406
  const boundContext = await boundK8sContext(env);
@@ -4315,8 +4438,9 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
4315
4438
  const { svg } = renderGraph(ir, { boxes: "byContainer", radial: new URL(c.req.url).searchParams.get("radial") === "1" });
4316
4439
  const zoom = new URL(c.req.url).searchParams.get("runtime") === "1" ? "runtime" : query.detail === 1 ? "composites" : query.detail === 3 ? "attributes" : "resources";
4317
4440
  const tierNote = tierMismatchNote(ir, beholdConfig.tiers, query.tier);
4441
+ const nsNote = namespaceMismatchNote(ir.nodes);
4318
4442
  const zoomNotes = notesFor(zoom, ir, compositeEdgesAttached);
4319
- const note = [tierNote, zoomNotes].filter(Boolean).join(" \xB7 ");
4443
+ const note = [tierNote, nsNote, zoomNotes].filter(Boolean).join(" \xB7 ");
4320
4444
  return c.json({ ir, svg, meta: { projectDir: cfg.projectDir, env, mode: "overlay", ...note ? { note } : {} } });
4321
4445
  } catch (err) {
4322
4446
  return errorResponse(c, query, err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/behold",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "description": "behold — a live control plane on chant. See your whole estate (every substrate in one graph), coloured by drift; act through delegated, gated Ops.",
6
6
  "bin": {
@@ -21,6 +21,7 @@
21
21
  "dev": "tsx src/cli.ts",
22
22
  "demo": "npm --prefix example-writes install && tsx src/cli.ts serve example-writes --local --env prod",
23
23
  "demo:k8s": "bash example-k8s/scripts/local/local-up.sh; npm --prefix example-k8s install && tsx src/cli.ts serve example-k8s --local --env local; bash example-k8s/scripts/local/local-down.sh",
24
+ "smoke:ui": "node smoke/ui-smoke.mjs",
24
25
  "tsc": "tsc --noEmit",
25
26
  "test": "vitest run",
26
27
  "build": "esbuild src/cli.ts --bundle --platform=node --format=esm --external:hono --external:@hono/node-server --external:@intentius/pinhole --outfile=dist/cli.js && chmod +x dist/cli.js",
@@ -36,6 +37,7 @@
36
37
  "devDependencies": {
37
38
  "@types/node": "^22.0.0",
38
39
  "esbuild": "^0.28.0",
40
+ "playwright": "^1.62.1",
39
41
  "tsx": "^4.19.0",
40
42
  "typescript": "^5.9.3",
41
43
  "vitest": "^4.1.9"
package/web/app.js CHANGED
@@ -838,13 +838,16 @@ function renderPanelScope() {
838
838
  }, "The declared source graph — no live overlay"),
839
839
  );
840
840
  for (const e of environments) {
841
+ // #191: a k8s env shows the cluster it's bound to (`home → home-cloud`),
842
+ // so a wrong-cluster pick is visible before the read, not after.
843
+ const ctx = projectInfo && projectInfo.k8sContexts && projectInfo.k8sContexts[e];
841
844
  host.appendChild(
842
- panelOpt(e, view.env === e, () => {
845
+ panelOpt(ctx ? `${e} → ${ctx}` : e, view.env === e, () => {
843
846
  view.env = e;
844
847
  resetDialCaches();
845
848
  renderStatusbar();
846
849
  load();
847
- }, `Live overlay for ${e}`),
850
+ }, ctx ? `Live overlay for ${e} — bound to kubeconfig context ${ctx}` : `Live overlay for ${e}`),
848
851
  );
849
852
  }
850
853
  if (!environments.length) host.appendChild(panelMuted("no environments declared"));