@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.
- package/dist/cli.js +148 -24
- package/package.json +3 -1
- 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:
|
|
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:
|
|
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
|
|
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(
|
|
1920
|
+
child(CLUSTER_SCOPED2, n.id);
|
|
1889
1921
|
}
|
|
1890
1922
|
}
|
|
1891
|
-
if (clusterScoped) child(CLUSTER_TITLE,
|
|
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
|
-
|
|
3647
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
+
"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"));
|