@intentius/behold 0.3.0 → 0.4.0
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 +127 -7
- package/package.json +1 -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";
|
|
@@ -4034,6 +4120,11 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
4034
4120
|
...cfg.projectDirs && cfg.projectDirs.length > 1 ? { projectDirs: cfg.projectDirs } : {},
|
|
4035
4121
|
recents: listRecents().map((r) => r.dir),
|
|
4036
4122
|
environments,
|
|
4123
|
+
// #191: the cluster each k8s env is bound to (`k8s.profiles.<env>.
|
|
4124
|
+
// context`) — the SPA's env picker shows it (`home → home-cloud`) so a
|
|
4125
|
+
// wrong-cluster pick is visible BEFORE the read, not after (the failure
|
|
4126
|
+
// chant#1100 exists to prevent, and the one behind #192's red herring).
|
|
4127
|
+
...k8sProfiles ? { k8sContexts: Object.fromEntries(Object.entries(k8sProfiles).flatMap(([e, p]) => p.context ? [[e, p.context]] : [])) } : {},
|
|
4037
4128
|
lexicons,
|
|
4038
4129
|
currentEnv: cfg.env ?? null,
|
|
4039
4130
|
// v0.1.0 preview: the SPA hides git/PR ops + arbitrary-project affordances.
|
|
@@ -4138,6 +4229,9 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
4138
4229
|
let metaEnv = cfg.env ?? null;
|
|
4139
4230
|
if (multi) {
|
|
4140
4231
|
ir = await composeEstate(cfg.projectDirs, opts);
|
|
4232
|
+
ir = addValueMatchEdges(ir);
|
|
4233
|
+
ir = addK8sDeclaredEdges(ir);
|
|
4234
|
+
ir = addClusterAnchorEdges(ir, await boundK8sContext(metaEnv ?? void 0));
|
|
4141
4235
|
} else if (components) {
|
|
4142
4236
|
ir = await componentGraphIr(cfg.projectDir, opts);
|
|
4143
4237
|
const env = opts.env ?? cfg.env;
|
|
@@ -4179,7 +4273,8 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
4179
4273
|
const radial = new URL(c.req.url).searchParams.get("radial") === "1";
|
|
4180
4274
|
const { svg } = renderGraph(ir, multi ? { boxes: "byStack" } : { radial });
|
|
4181
4275
|
const srcZoom = components ? "components" : logical ? "logical" : opts.detail === 1 ? "composites" : opts.detail === 3 ? "attributes" : "resources";
|
|
4182
|
-
const
|
|
4276
|
+
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;
|
|
4277
|
+
const srcNote = multi ? estateLensNote : notesFor(srcZoom, ir, srcCompositeEdgesAttached);
|
|
4183
4278
|
return c.json({
|
|
4184
4279
|
ir,
|
|
4185
4280
|
svg,
|
|
@@ -4278,6 +4373,30 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
4278
4373
|
}
|
|
4279
4374
|
const logical = new URL(c.req.url).searchParams.get("logical") === "1";
|
|
4280
4375
|
try {
|
|
4376
|
+
if (cfg.projectDirs && cfg.projectDirs.length > 1) {
|
|
4377
|
+
const runtime = new URL(c.req.url).searchParams.get("runtime") === "1";
|
|
4378
|
+
const est = await composeEstateOverlay(cfg.projectDirs, { ...tierTargetOpts(query), detail: query.detail, env }, reclassifyOverlay);
|
|
4379
|
+
if (est.dropped.length === est.total) {
|
|
4380
|
+
return c.json({ error: `no project in the estate could be graphed \u2014 ${est.dropped.map((d) => `${d.name}: ${d.reason}`).join("; ")}` }, 500);
|
|
4381
|
+
}
|
|
4382
|
+
let ir2 = pruneRuntimeChildren(est.ir);
|
|
4383
|
+
if ((query.detail ?? 2) < 3) ir2 = pruneImports(ir2);
|
|
4384
|
+
ir2 = addValueMatchEdges(ir2);
|
|
4385
|
+
ir2 = addK8sDeclaredEdges(ir2);
|
|
4386
|
+
ir2 = addClusterAnchorEdges(ir2, await boundK8sContext(env));
|
|
4387
|
+
const { svg: svg2 } = renderGraph(ir2, { boxes: "byStack" });
|
|
4388
|
+
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;
|
|
4389
|
+
const coverNote = est.unobserved.length || est.dropped.length ? `live observe covered ${est.observed} of ${est.total} projects \u2014 ` + [
|
|
4390
|
+
...est.unobserved.map((u) => `${u.name}: ${u.reason} (painted unobserved)`),
|
|
4391
|
+
...est.dropped.map((d) => `${d.name}: dropped (${d.reason})`)
|
|
4392
|
+
].join("; ") : void 0;
|
|
4393
|
+
const note2 = [lensNote, coverNote, namespaceMismatchNote(ir2.nodes)].filter(Boolean).join(" \xB7 ");
|
|
4394
|
+
return c.json({
|
|
4395
|
+
ir: ir2,
|
|
4396
|
+
svg: svg2,
|
|
4397
|
+
meta: { projectDir: cfg.projectDir, env, mode: "overlay", estate: est.total, ...note2 ? { note: note2 } : {} }
|
|
4398
|
+
});
|
|
4399
|
+
}
|
|
4281
4400
|
const opts = { ...query, live: true, overlay: true, env, ...logical ? { detail: 3 } : {} };
|
|
4282
4401
|
let ir = reclassifyOverlay(await graphIr(cfg.projectDir, opts));
|
|
4283
4402
|
const boundContext = await boundK8sContext(env);
|
|
@@ -4315,8 +4434,9 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
|
|
|
4315
4434
|
const { svg } = renderGraph(ir, { boxes: "byContainer", radial: new URL(c.req.url).searchParams.get("radial") === "1" });
|
|
4316
4435
|
const zoom = new URL(c.req.url).searchParams.get("runtime") === "1" ? "runtime" : query.detail === 1 ? "composites" : query.detail === 3 ? "attributes" : "resources";
|
|
4317
4436
|
const tierNote = tierMismatchNote(ir, beholdConfig.tiers, query.tier);
|
|
4437
|
+
const nsNote = namespaceMismatchNote(ir.nodes);
|
|
4318
4438
|
const zoomNotes = notesFor(zoom, ir, compositeEdgesAttached);
|
|
4319
|
-
const note = [tierNote, zoomNotes].filter(Boolean).join(" \xB7 ");
|
|
4439
|
+
const note = [tierNote, nsNote, zoomNotes].filter(Boolean).join(" \xB7 ");
|
|
4320
4440
|
return c.json({ ir, svg, meta: { projectDir: cfg.projectDir, env, mode: "overlay", ...note ? { note } : {} } });
|
|
4321
4441
|
} catch (err) {
|
|
4322
4442
|
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.0",
|
|
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": {
|
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"));
|