@intentius/behold 0.2.2
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/README.md +338 -0
- package/bin/behold.js +15 -0
- package/dist/cli.js +4830 -0
- package/package.json +48 -0
- package/web/app.js +2352 -0
- package/web/index.html +248 -0
- package/web/theme.js +188 -0
- package/web/themes.js +559 -0
package/web/app.js
ADDED
|
@@ -0,0 +1,2352 @@
|
|
|
1
|
+
// behold SPA. Fetches the read-only graph from the server — chant's IR + pinhole's
|
|
2
|
+
// rendered SVG — inlines the SVG and wires click-inspect. The visual is pinhole's
|
|
3
|
+
// mature painter (themes, icons, `_status` drift colouring); behold owns the data,
|
|
4
|
+
// the inspect panel, and (later) the lanes + delegated actions.
|
|
5
|
+
|
|
6
|
+
// Ghostty colour themes (#62): apply the persisted/default theme's tokens as CSS vars
|
|
7
|
+
// before first paint (so the whole graph + chrome recolour from one source), then mount
|
|
8
|
+
// the theme picker into the header's #pickers slot.
|
|
9
|
+
import { initTheme, mountThemePicker, readableOn, colorForCategory, onThemeChange, getTokens } from "./theme.js";
|
|
10
|
+
initTheme();
|
|
11
|
+
mountThemePicker(document.getElementById("pickers"));
|
|
12
|
+
|
|
13
|
+
// Colour node fills by category/kind using the theme's FULL palette (spicypath-style, so the
|
|
14
|
+
// graph shows the theme's many colours), while pinhole's drift stays on the bar/stroke. Node
|
|
15
|
+
// labels/icons get readable ink (black/white) on the category fill. Re-runs on theme switch,
|
|
16
|
+
// since the categorical hues come from the active theme's palette.
|
|
17
|
+
let lastGraphIr = null;
|
|
18
|
+
function recolorNodesByCategory(ir) {
|
|
19
|
+
if (ir) lastGraphIr = ir;
|
|
20
|
+
const graphIr = ir || lastGraphIr;
|
|
21
|
+
const svg = document.querySelector("#graph svg");
|
|
22
|
+
if (!svg || !graphIr) return;
|
|
23
|
+
const kindOf = new Map(graphIr.nodes.map((n) => [n.id, n.kind || n.lexicon || "node"]));
|
|
24
|
+
for (const g of svg.querySelectorAll("[data-node-id]")) {
|
|
25
|
+
const kind = kindOf.get(g.getAttribute("data-node-id"));
|
|
26
|
+
if (!kind) continue;
|
|
27
|
+
const cat = colorForCategory(kind), ink = readableOn(cat);
|
|
28
|
+
// Classify each element ONCE by the pinhole token it rode on (data-cat role), then apply
|
|
29
|
+
// the role's colour on every pass. This is what makes it recolour on theme switch: after
|
|
30
|
+
// the first pass the fill is a hex (not a --pin-* var), so we must key off the marker, not
|
|
31
|
+
// the token. Roles: fill=card background (→ category hue), bg=foreignObject background,
|
|
32
|
+
// inkf/inks=label/icon (→ readable ink). The drift bar (--pin-*Bar) is never classified.
|
|
33
|
+
for (const el of g.querySelectorAll("*")) {
|
|
34
|
+
let role = el.getAttribute("data-cat");
|
|
35
|
+
if (!role) {
|
|
36
|
+
const f = el.getAttribute("fill") || "", s = el.getAttribute("stroke") || "", st = el.getAttribute("style") || "";
|
|
37
|
+
if (/--pin-\w+Fill\b/.test(f)) role = "fill";
|
|
38
|
+
else if (/--pin-\w+Fill\b/.test(st)) role = "bg";
|
|
39
|
+
else if (/--pin-text/.test(f)) role = "inkf";
|
|
40
|
+
else if (/--pin-text/.test(s)) role = "inks";
|
|
41
|
+
if (role) el.setAttribute("data-cat", role);
|
|
42
|
+
}
|
|
43
|
+
if (role === "fill") el.setAttribute("fill", cat);
|
|
44
|
+
else if (role === "bg") el.setAttribute("style", (el.getAttribute("style") || "").replace(/background:[^;]*/, "background:" + cat));
|
|
45
|
+
else if (role === "inkf") el.setAttribute("fill", ink);
|
|
46
|
+
else if (role === "inks") el.setAttribute("stroke", ink);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// Runtime children get their bar coloured here (#144): pinhole's status
|
|
50
|
+
// palette has no `runtime` token, so chant's `_status: runtime` falls through
|
|
51
|
+
// to the neutral bar and a Pod reads exactly like an unobserved node. The bar
|
|
52
|
+
// is the 4px rect pinhole draws second inside each node group.
|
|
53
|
+
const statusOf = new Map(graphIr.nodes.map((n) => [n.id, n.attrs && n.attrs._status]));
|
|
54
|
+
for (const g of svg.querySelectorAll("[data-node-id]")) {
|
|
55
|
+
if (statusOf.get(g.getAttribute("data-node-id")) !== "runtime") continue;
|
|
56
|
+
const bar = g.querySelector('rect[width="4"]');
|
|
57
|
+
if (bar) bar.setAttribute("fill", "var(--runtime)");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
onThemeChange(() => recolorNodesByCategory());
|
|
61
|
+
|
|
62
|
+
// Static-export mode (`behold export`): the SPA runs off a pre-captured bundle
|
|
63
|
+
// with no backend. Detect the flag the export injects, load its manifest, and
|
|
64
|
+
// replay every read from `snapshots/` — the graph, zoom dial, radial, inspect,
|
|
65
|
+
// and env/tier pickers all work; live observe + all writes are off.
|
|
66
|
+
const staticMode = !!window.__BEHOLD_STATIC__;
|
|
67
|
+
let manifest = null;
|
|
68
|
+
if (staticMode) {
|
|
69
|
+
try {
|
|
70
|
+
manifest = await fetch("./manifest.json").then((r) => r.json());
|
|
71
|
+
} catch {
|
|
72
|
+
/* no manifest → apiFetch falls back to a not-captured error */
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Canonical key for a read URL — path + the lens params (whitelisted, sorted)
|
|
77
|
+
* that select a distinct snapshot. MUST match src/export.ts `canonicalKey`. */
|
|
78
|
+
const LENS_PARAMS = ["components", "detail", "env", "logical", "radial", "runtime", "tier"];
|
|
79
|
+
function canonicalKey(path, params) {
|
|
80
|
+
// Components + logical views ignore detail/radial — drop them so they match
|
|
81
|
+
// the single captured snapshot (MUST match src/export.ts).
|
|
82
|
+
const flat = params.get("components") === "1" || params.get("logical") === "1";
|
|
83
|
+
const q = LENS_PARAMS.filter((k) => params.has(k) && !(flat && (k === "detail" || k === "radial")))
|
|
84
|
+
.map((k) => `${k}=${params.get(k)}`)
|
|
85
|
+
.join("&");
|
|
86
|
+
return q ? `${path}?${q}` : path;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Fetch a read endpoint — live `fetch` normally; in static mode, resolve the
|
|
90
|
+
* canonical key against the manifest and load the captured snapshot instead. */
|
|
91
|
+
function apiFetch(url) {
|
|
92
|
+
if (!staticMode) return fetch(url);
|
|
93
|
+
const u = new URL(url, location.origin);
|
|
94
|
+
const key = canonicalKey(u.pathname, u.searchParams);
|
|
95
|
+
const file = manifest && manifest.keyToFile[key];
|
|
96
|
+
if (!file) return Promise.resolve(new Response(JSON.stringify({ error: `not in this static export: ${key}` }), { status: 404, headers: { "content-type": "application/json" } }));
|
|
97
|
+
return fetch("./" + file);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// `neutral` (chant#1168, #1089): a declared node chant could not read live
|
|
101
|
+
// state for — distinct from `accent`/"pending" (provider confirmed it
|
|
102
|
+
// absent). Additive: a chant predating #1168 never emits `neutral` here.
|
|
103
|
+
// `runtime` (chant#1180, #1077): a live, undeclared node whose owner chain
|
|
104
|
+
// reaches a declared entity — expected runtime (a Pod its Deployment
|
|
105
|
+
// created), never foreign and never drift. Additive the same way.
|
|
106
|
+
const STATUS_LABEL = { good: "managed", warn: "foreign", accent: "pending", neutral: "unobserved", runtime: "runtime child" };
|
|
107
|
+
// M1.1 (#57), palette hardened M2 (#54): the component-DAG live-status join
|
|
108
|
+
// paints the same `_status` vocabulary (good/warn/accent/neutral) but with
|
|
109
|
+
// different meaning — a stack-health reading, not "managed" — so the inspect
|
|
110
|
+
// panel picks this label set for a node that carries `_liveStatus` (see
|
|
111
|
+
// joinComponentStatus, src/component-status.ts). `accent` (pinhole's blue
|
|
112
|
+
// paint — there's no separate amber token) reads as "in progress" here,
|
|
113
|
+
// distinct from the entity overlay's "pending" meaning for the same colour.
|
|
114
|
+
const COMPONENT_STATUS_LABEL = { good: "healthy", accent: "in progress", warn: "rollback / failed", neutral: "not deployed" };
|
|
115
|
+
// behold#146: the artifact-presence join (src/helm-artifacts.ts) paints
|
|
116
|
+
// Helm::Chart nodes with the same palette but the 2-way ARTIFACT vocabulary —
|
|
117
|
+
// a release is "installed", never "managed": chant's docs are explicit that
|
|
118
|
+
// artifacts have no declared axis to classify against. `_artifact`'s presence
|
|
119
|
+
// (or an artifact-flavoured `_unobserved`) picks this label set.
|
|
120
|
+
const ARTIFACT_STATUS_LABEL = { good: "installed", warn: "installed, not healthy", accent: "not installed", neutral: "unobserved" };
|
|
121
|
+
|
|
122
|
+
// A declared attribute value may be a cross-resource reference ({$ref:"x.y"}) —
|
|
123
|
+
// the "static infra refs" — rather than a concrete value. Render those readably;
|
|
124
|
+
// concrete values (present once a resource is provisioned) show as-is.
|
|
125
|
+
function fmtValue(v) {
|
|
126
|
+
if (v && typeof v === "object") {
|
|
127
|
+
if (typeof v.$ref === "string") return "→ " + v.$ref;
|
|
128
|
+
return JSON.stringify(v);
|
|
129
|
+
}
|
|
130
|
+
return String(v);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function inspect(node) {
|
|
134
|
+
const panel = document.getElementById("inspect-body");
|
|
135
|
+
panel.innerHTML = "<h2>inspect</h2>";
|
|
136
|
+
panel.dataset.node = node.id; // so async sections can verify this node is still shown
|
|
137
|
+
const section = (title) => {
|
|
138
|
+
const h = document.createElement("h3");
|
|
139
|
+
h.textContent = title;
|
|
140
|
+
h.style.cssText = "font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin:14px 0 6px";
|
|
141
|
+
panel.appendChild(h);
|
|
142
|
+
const dl = document.createElement("dl");
|
|
143
|
+
panel.appendChild(dl);
|
|
144
|
+
return (k, v) => {
|
|
145
|
+
const dt = document.createElement("dt");
|
|
146
|
+
dt.textContent = k;
|
|
147
|
+
const dd = document.createElement("dd");
|
|
148
|
+
dd.textContent = v;
|
|
149
|
+
dl.append(dt, dd);
|
|
150
|
+
};
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const id = section("identity");
|
|
154
|
+
id("id", node.id);
|
|
155
|
+
id("kind", node.kind);
|
|
156
|
+
id("lexicon", node.lexicon);
|
|
157
|
+
const st = node.attrs && node.attrs._status;
|
|
158
|
+
// Component-DAG live status (#57): `_liveStatus` is only ever set by
|
|
159
|
+
// joinComponentStatus, so its presence picks the component-status label set
|
|
160
|
+
// over the entity overlay's managed/foreign/pending.
|
|
161
|
+
const liveStatus = node.attrs && node.attrs._liveStatus;
|
|
162
|
+
// behold#146: a Helm chart's status is artifact presence, not management —
|
|
163
|
+
// the join sets `_artifact` on a match, and every Helm::Chart in an overlay
|
|
164
|
+
// went through it, so the kind alone is the reliable picker.
|
|
165
|
+
const isArtifact = node.kind === "Helm::Chart";
|
|
166
|
+
if (st) id("status", (isArtifact ? ARTIFACT_STATUS_LABEL[st] : liveStatus ? COMPONENT_STATUS_LABEL[st] : STATUS_LABEL[st]) || st);
|
|
167
|
+
if (node.attrs && node.attrs._artifact) {
|
|
168
|
+
const a = node.attrs._artifact;
|
|
169
|
+
if (a.release) id("release", a.release);
|
|
170
|
+
if (a.status) id("release status", a.status);
|
|
171
|
+
if (a.revision) id("revision", a.revision);
|
|
172
|
+
if (a.chart) id("chart", a.chart);
|
|
173
|
+
}
|
|
174
|
+
// chant#1168 (#1089): `_unobserved` carries WHY chant couldn't read this
|
|
175
|
+
// entity's live state — only ever set alongside the entity overlay's
|
|
176
|
+
// `_status: "neutral"` (never the component-status join's own, unrelated
|
|
177
|
+
// `neutral` = "not deployed"), so it's safe to show unconditionally here.
|
|
178
|
+
if (node.attrs && node.attrs._unobserved) id("unobserved reason", node.attrs._unobserved);
|
|
179
|
+
// chant#1180 (#1077): `runtimeOwner` is a first-class IR field (like
|
|
180
|
+
// `ownership`/`physicalId`), not an `attrs` tag — the declared entity this
|
|
181
|
+
// live, undeclared node's owner chain resolves to. Shown immediately from
|
|
182
|
+
// the graph node itself, no diff fetch needed.
|
|
183
|
+
if (node.runtimeOwner) id("runtime owner", node.runtimeOwner);
|
|
184
|
+
if (node.sourceLoc && node.sourceLoc.file) id("source", node.sourceLoc.file);
|
|
185
|
+
|
|
186
|
+
// Containment hierarchy: a resource shows its parent chain UP (composite →
|
|
187
|
+
// component); a collapsed composite (detail 1: `attrs.members` is a count)
|
|
188
|
+
// shows its member list DOWN. The parent chain is on the node itself
|
|
189
|
+
// (compositeInstance/compositeParent + the src/<component>/ path) — no fetch.
|
|
190
|
+
const sp = ((node.sourceLoc && node.sourceLoc.file) || "").split("/");
|
|
191
|
+
const component = sp[0] === "src" && sp[1] === "examples" ? "examples/" + (sp[2] || "") : sp[0] === "src" && sp.length >= 3 ? sp[1] : null;
|
|
192
|
+
const isComposite = node.attrs && typeof node.attrs.members === "number";
|
|
193
|
+
if (!isComposite && (node.compositeInstance || (component && node.kind !== "Component"))) {
|
|
194
|
+
const bt = section("belongs to");
|
|
195
|
+
if (component) bt("component", component);
|
|
196
|
+
if (node.compositeInstance) bt("composite", node.compositeParent ? `${node.compositeInstance} · ${node.compositeParent}` : node.compositeInstance);
|
|
197
|
+
}
|
|
198
|
+
if (isComposite) {
|
|
199
|
+
const h = document.createElement("h3");
|
|
200
|
+
h.textContent = `members · ${node.attrs.members}`;
|
|
201
|
+
h.style.cssText = "font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin:14px 0 6px";
|
|
202
|
+
panel.appendChild(h);
|
|
203
|
+
const loading = document.createElement("p");
|
|
204
|
+
loading.style.color = "var(--muted)";
|
|
205
|
+
loading.textContent = "loading…";
|
|
206
|
+
panel.appendChild(loading);
|
|
207
|
+
const dl = document.createElement("dl");
|
|
208
|
+
panel.appendChild(dl);
|
|
209
|
+
const forId = node.id;
|
|
210
|
+
getCompositeMembers(node.id).then((members) => {
|
|
211
|
+
if (panel.dataset.node !== forId) return; // selection changed while loading
|
|
212
|
+
loading.remove();
|
|
213
|
+
if (!members.length) {
|
|
214
|
+
loading.textContent = "(no members found)";
|
|
215
|
+
panel.insertBefore(loading, dl);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
for (const m of members) {
|
|
219
|
+
const dt = document.createElement("dt");
|
|
220
|
+
dt.textContent = m.kind;
|
|
221
|
+
const dd = document.createElement("dd");
|
|
222
|
+
dd.textContent = m.id;
|
|
223
|
+
dl.append(dt, dd);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Live state: what chant observed in the cloud. Only managed (provisioned)
|
|
229
|
+
// nodes carry it — pending nodes have none because they aren't deployed yet.
|
|
230
|
+
if (node.physicalId || node.ownership) {
|
|
231
|
+
const live = section("live");
|
|
232
|
+
if (node.physicalId) live("physical id", node.physicalId);
|
|
233
|
+
if (node.ownership) live("ownership", node.ownership);
|
|
234
|
+
} else if (liveStatus) {
|
|
235
|
+
// The colour alone doesn't carry chant's verdict or its reasoning — spell
|
|
236
|
+
// both out here (never rely on the node's colour alone, #57 accessibility
|
|
237
|
+
// note): reconciliation is the raw `chant components status` verdict
|
|
238
|
+
// (reconciled/unrecorded/stale/drifted/unknown), detail is chant's own
|
|
239
|
+
// human-readable explanation. M2 (#54, chant 0.18.29): when present, the
|
|
240
|
+
// raw stack — the actual signal the palette painted from — backs it up
|
|
241
|
+
// with the provider-native fact (e.g. loom-db's UPDATE_ROLLBACK_COMPLETE).
|
|
242
|
+
const live = section("live status");
|
|
243
|
+
live("reconciliation", liveStatus.reconciliation);
|
|
244
|
+
if (liveStatus.detail) live("detail", liveStatus.detail);
|
|
245
|
+
if (liveStatus.stack) {
|
|
246
|
+
live("stack", liveStatus.stack.name);
|
|
247
|
+
if (liveStatus.stack.status) live("stack status", liveStatus.stack.status);
|
|
248
|
+
if (liveStatus.stack.healthy !== undefined) live("healthy", String(liveStatus.stack.healthy));
|
|
249
|
+
}
|
|
250
|
+
} else if (st === "accent") {
|
|
251
|
+
const live = section("live");
|
|
252
|
+
live("", "not provisioned yet (pending) — no live state");
|
|
253
|
+
} else if (st === "runtime") {
|
|
254
|
+
// chant#1180 (#1077): a runtime child rarely carries physicalId/ownership
|
|
255
|
+
// today (it's not chant-owned in the marker sense), so make sure
|
|
256
|
+
// something explains the node rather than showing an empty "live" gap.
|
|
257
|
+
const live = section("live");
|
|
258
|
+
live("", `runtime child — owned by ${node.runtimeOwner || "its declared parent"}, not itself declared`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Declared attributes — the source-of-truth values / cross-resource refs.
|
|
262
|
+
const attrKeys = Object.keys(node.attrs || {}).filter((k) => !k.startsWith("_"));
|
|
263
|
+
if (attrKeys.length) {
|
|
264
|
+
const decl = section("declared");
|
|
265
|
+
for (const k of attrKeys) decl(k, fmtValue(node.attrs[k]));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// CI projection facet (M1.2, #56/#58): loomster's GitLab CI is the SAME
|
|
269
|
+
// component DAG projected — waves = stages, components = jobs, `dependsOn` =
|
|
270
|
+
// `needs:`. Read one way it's the deployment, read the other it's the
|
|
271
|
+
// pipeline. A read-only per-node detail hanging off the component by name —
|
|
272
|
+
// no topology change. Only present for component nodes, and only once the
|
|
273
|
+
// facet loaded (component-DAG mode; see loadCi()).
|
|
274
|
+
const job = node.kind === "Component" ? ciByComponent.get(node.id) : undefined;
|
|
275
|
+
if (job) {
|
|
276
|
+
// The heading names the forge the pipeline was actually generated for
|
|
277
|
+
// (#164) — it used to say GitLab whatever the project targeted.
|
|
278
|
+
const ci = section(`CI (${{ gitlab: "GitLab", github: "GitHub Actions", forgejo: "Forgejo" }[ciForge] || ciForge || "pipeline"})`);
|
|
279
|
+
ci("stage", job.stage);
|
|
280
|
+
ci("needs", job.needs.length ? job.needs.join(", ") : "(none)");
|
|
281
|
+
ci("runs", job.script.length ? job.script.join(" && ") : `chant run --components ${node.id}`);
|
|
282
|
+
// The last pipeline run of this session, when one touched this component
|
|
283
|
+
// (#164) — the server joins it as `_ciJob`/`ci` on the node.
|
|
284
|
+
if (node.attrs && node.attrs._ciJob) ci("last run", `${node.attrs._ciJob}: ${node.attrs.ci}`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Resources facet (#59 unify) — a best-effort slice of the DoD's "its
|
|
288
|
+
// stack, and its resources": the AWS resources declared under this
|
|
289
|
+
// component's own source directory (see loadResources(); src/server.ts
|
|
290
|
+
// `/api/resources` documents why this is resources-by-source-location, not
|
|
291
|
+
// a literal CFN stack lookup, and a pre-existing chant gap — verified
|
|
292
|
+
// against loomster/Floci — that leaves physicalId/ownership usually empty
|
|
293
|
+
// even in live mode: kind/id are always the real declared shape; treat
|
|
294
|
+
// physicalId as a bonus when chant happens to supply it, not a given).
|
|
295
|
+
const resources = node.kind === "Component" ? resourcesByComponent[node.id] : undefined;
|
|
296
|
+
if (resources) {
|
|
297
|
+
const res = section("resources");
|
|
298
|
+
if (!resources.length) {
|
|
299
|
+
res("", "(none found under this component's source directory)");
|
|
300
|
+
} else {
|
|
301
|
+
for (const r of resources) {
|
|
302
|
+
res(r.kind, r.physicalId ? `${r.id} (${r.physicalId})` : r.id);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// A foreign node on a live-import substrate can be pulled into typed source:
|
|
308
|
+
// Adopt triggers the ReconcileOp (cloud → code), which opens a reviewable PR.
|
|
309
|
+
// behold never writes source — a human merges. Managed/pending nodes and
|
|
310
|
+
// substrates with no live-import path show nothing.
|
|
311
|
+
if (adoptable(node) && !previewMode) {
|
|
312
|
+
const b = button("Adopt", "", () => runOp(adopt.reconcile.name));
|
|
313
|
+
b.title = `Reconcile ${node.id} into source via ${adopt.reconcile.name} (opens a PR)`;
|
|
314
|
+
const wrap = document.createElement("p");
|
|
315
|
+
wrap.style.marginTop = "12px";
|
|
316
|
+
wrap.appendChild(b);
|
|
317
|
+
panel.appendChild(wrap);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Live state for this node (#27/#30): a node that's already been observed
|
|
321
|
+
// (managed=good or foreign=warn — that's why it's coloured) auto-loads its
|
|
322
|
+
// observed state + drift, so a click shows it without a second click. Cached
|
|
323
|
+
// per node (loadNodeDiff) so re-clicks are instant. A live diff is a build +
|
|
324
|
+
// cloud query, so we only fire it for observed nodes, and never in static.
|
|
325
|
+
//
|
|
326
|
+
// chant#1168 (#1089): the entity overlay's `neutral` means "chant couldn't
|
|
327
|
+
// read this" — worth a diff fetch too, since `/api/diff`'s `unobserved`
|
|
328
|
+
// entries carry the richer reason/detail this panel can show (see
|
|
329
|
+
// renderDiff). Guarded to `!liveStatus` so this doesn't also fire for the
|
|
330
|
+
// component-status join's unrelated `neutral` ("not deployed" — no entity
|
|
331
|
+
// name for `/api/diff` to match against).
|
|
332
|
+
// chant#1180 (#1077): `runtime` is worth a diff fetch too — `/api/diff`'s
|
|
333
|
+
// `observed` map already carries a runtime child (chant's describeResources
|
|
334
|
+
// reports it the same as any other resource it found), and its own
|
|
335
|
+
// `fieldDrift` may apply too on a substrate with per-field ownership.
|
|
336
|
+
const observed = st === "good" || st === "warn" || st === "runtime" || (st === "neutral" && !liveStatus);
|
|
337
|
+
if (view.env && observed) {
|
|
338
|
+
const forId = node.id;
|
|
339
|
+
const loading = document.createElement("p");
|
|
340
|
+
loading.style.cssText = "color:var(--muted);margin-top:12px";
|
|
341
|
+
loading.textContent = "loading live state…";
|
|
342
|
+
panel.appendChild(loading);
|
|
343
|
+
loadNodeDiff(node.id).then((j) => {
|
|
344
|
+
if (panel.dataset.node !== forId) return; // reselected while loading
|
|
345
|
+
loading.remove();
|
|
346
|
+
if (!j) {
|
|
347
|
+
const p = document.createElement("p");
|
|
348
|
+
p.style.cssText = "color:var(--muted);margin-top:12px";
|
|
349
|
+
p.textContent = "live state unavailable";
|
|
350
|
+
panel.appendChild(p);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
renderObserved(panel, j.observed, j.health); // #30 observed state + #26 health
|
|
354
|
+
renderDiff(panel, j.diff); // #27 — drift since snapshot
|
|
355
|
+
renderFieldDrift(panel, j.fieldDrift); // #87 — field-level (per-manager) drift
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Bulk per-node live state — ONE `chant lifecycle diff --live` sliced for every
|
|
361
|
+
// node (/api/diff), fetched once per env and cached, so inspecting an observed
|
|
362
|
+
// node is instant (no per-node query). Via apiFetch, so a static export replays
|
|
363
|
+
// the captured snapshot. Cache is per env; cleared on lens change / after an op.
|
|
364
|
+
let bulkDiffCache = null; // { env, nodes: { <id>: { observed, diff, health } } }
|
|
365
|
+
async function loadNodeDiff(id) {
|
|
366
|
+
if (!bulkDiffCache || bulkDiffCache.env !== view.env) {
|
|
367
|
+
bulkDiffCache = null;
|
|
368
|
+
try {
|
|
369
|
+
const res = await apiFetch(`/api/diff?env=${encodeURIComponent(view.env)}`);
|
|
370
|
+
if (res.ok) bulkDiffCache = await res.json();
|
|
371
|
+
} catch {
|
|
372
|
+
/* leave null → empty below */
|
|
373
|
+
}
|
|
374
|
+
if (!bulkDiffCache || !bulkDiffCache.nodes) bulkDiffCache = { env: view.env, nodes: {} };
|
|
375
|
+
}
|
|
376
|
+
return bulkDiffCache.nodes[id] || null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const HEALTH_COLOR = {
|
|
380
|
+
healthy: "var(--managed)",
|
|
381
|
+
progressing: "var(--pending)",
|
|
382
|
+
degraded: "var(--degraded)",
|
|
383
|
+
unknown: "var(--muted)",
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
// Render a node's observed live state (#30) + health verdict (#26).
|
|
387
|
+
function renderObserved(panel, o, health) {
|
|
388
|
+
if (!o) return; // pending/foreign nodes have no observed record in the diff
|
|
389
|
+
const h = document.createElement("h3");
|
|
390
|
+
h.textContent = "observed";
|
|
391
|
+
h.style.cssText = "font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin:14px 0 6px";
|
|
392
|
+
panel.appendChild(h);
|
|
393
|
+
const dl = document.createElement("dl");
|
|
394
|
+
const add = (k, v, color) => {
|
|
395
|
+
const dt = document.createElement("dt");
|
|
396
|
+
dt.textContent = k;
|
|
397
|
+
const dd = document.createElement("dd");
|
|
398
|
+
dd.textContent = v;
|
|
399
|
+
if (color) dd.style.color = color;
|
|
400
|
+
dl.append(dt, dd);
|
|
401
|
+
};
|
|
402
|
+
// Health first — the "is it well?" verdict, distinct from drift. Absent when
|
|
403
|
+
// the substrate reports no status (not fabricated).
|
|
404
|
+
if (health && health !== "unknown") add("health", health, HEALTH_COLOR[health]);
|
|
405
|
+
if (o.type) add("type", o.type);
|
|
406
|
+
if (o.status) add("status", o.status, HEALTH_COLOR[health] || undefined);
|
|
407
|
+
if (o.physicalId) add("physical id", o.physicalId);
|
|
408
|
+
if (o.ownership) add("ownership", o.ownership);
|
|
409
|
+
if (o.lastUpdated) add("last updated", o.lastUpdated);
|
|
410
|
+
// What the object's own controller says is wrong with it (#86, chant#1401).
|
|
411
|
+
// Placed with health and status rather than among the attributes below,
|
|
412
|
+
// because it is the line that says what to DO: `Unschedulable` is the reason,
|
|
413
|
+
// "0/3 nodes are available: 1 node(s) had untolerated taint" is the answer.
|
|
414
|
+
// chant only sends conditions that are NOT in their happy state, so anything
|
|
415
|
+
// here is worth reading; the field is absent on every substrate that records
|
|
416
|
+
// none.
|
|
417
|
+
const conditions = o.attributes?.conditions;
|
|
418
|
+
if (Array.isArray(conditions)) {
|
|
419
|
+
for (const c of conditions) add("condition", String(c), "var(--degraded)");
|
|
420
|
+
}
|
|
421
|
+
for (const [k, v] of Object.entries(o.attributes || {})) {
|
|
422
|
+
if (k === "conditions") continue; // rendered above, one line each
|
|
423
|
+
add(k, typeof v === "object" ? JSON.stringify(v) : String(v));
|
|
424
|
+
}
|
|
425
|
+
panel.appendChild(dl);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const DIFF_LABEL = {
|
|
429
|
+
drifted: "drifted since snapshot",
|
|
430
|
+
missing: "declared, not in cloud",
|
|
431
|
+
orphan: "in cloud, not declared",
|
|
432
|
+
disappeared: "gone since snapshot",
|
|
433
|
+
newlyObserved: "live — no snapshot baseline",
|
|
434
|
+
unchanged: "in sync",
|
|
435
|
+
// chant#1168 (#1089): its own category — chant couldn't read this entity's
|
|
436
|
+
// live state at all, so this is neither drift nor a confirmed absence.
|
|
437
|
+
unobserved: "chant could not read live state",
|
|
438
|
+
// chant#1180 (#1077): its own category too — expected runtime, never drift
|
|
439
|
+
// and never orphan/adopt.
|
|
440
|
+
runtime: "expected runtime child",
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
// Render a node's live-diff into the inspect panel (#27).
|
|
444
|
+
function renderDiff(panel, diff) {
|
|
445
|
+
const h = document.createElement("h3");
|
|
446
|
+
h.textContent = "drift";
|
|
447
|
+
h.style.cssText = "font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin:14px 0 6px";
|
|
448
|
+
panel.appendChild(h);
|
|
449
|
+
if (!diff) {
|
|
450
|
+
const p = document.createElement("p");
|
|
451
|
+
p.style.color = "var(--muted)";
|
|
452
|
+
p.textContent = "not present in the live diff";
|
|
453
|
+
panel.appendChild(p);
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
const cat = document.createElement("p");
|
|
457
|
+
cat.textContent = DIFF_LABEL[diff.category] || diff.category;
|
|
458
|
+
panel.appendChild(cat);
|
|
459
|
+
// chant#1168 (#1089): show WHY chant couldn't look, instead of the
|
|
460
|
+
// "no field changes" text below — that phrasing implies a comparison ran
|
|
461
|
+
// and found nothing, which is exactly the wrong read for a hole in the
|
|
462
|
+
// observation.
|
|
463
|
+
if (diff.category === "unobserved") {
|
|
464
|
+
const p = document.createElement("p");
|
|
465
|
+
p.style.color = "var(--muted)";
|
|
466
|
+
p.textContent = diff.unobservedReason
|
|
467
|
+
? `reason: ${diff.unobservedReason}${diff.unobservedDetail ? " — " + diff.unobservedDetail : ""}`
|
|
468
|
+
: "chant did not report why";
|
|
469
|
+
panel.appendChild(p);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
// chant#1180 (#1077): never call this "drift" — it's the runtime doing its
|
|
473
|
+
// job (a Pod its Deployment created); deleting it just gets it recreated.
|
|
474
|
+
if (diff.category === "runtime") {
|
|
475
|
+
const p = document.createElement("p");
|
|
476
|
+
p.style.color = "var(--runtime)";
|
|
477
|
+
p.textContent = diff.runtimeOwner
|
|
478
|
+
? `owned by ${diff.runtimeOwner} — created by its controller, not drift`
|
|
479
|
+
: "created by its controller, not drift";
|
|
480
|
+
panel.appendChild(p);
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
if (!diff.changes.length) {
|
|
484
|
+
const p = document.createElement("p");
|
|
485
|
+
p.style.color = "var(--muted)";
|
|
486
|
+
p.textContent =
|
|
487
|
+
diff.category === "newlyObserved"
|
|
488
|
+
? "no field diff yet — take a snapshot (chant lifecycle snapshot) to track changes"
|
|
489
|
+
: "no field changes";
|
|
490
|
+
panel.appendChild(p);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
const dl = document.createElement("dl");
|
|
494
|
+
for (const ch of diff.changes) {
|
|
495
|
+
const dt = document.createElement("dt");
|
|
496
|
+
dt.textContent = ch.path;
|
|
497
|
+
const dd = document.createElement("dd");
|
|
498
|
+
dd.textContent = `${JSON.stringify(ch.oldValue)} → ${JSON.stringify(ch.newValue)}`;
|
|
499
|
+
dl.append(dt, dd);
|
|
500
|
+
}
|
|
501
|
+
panel.appendChild(dl);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Field-level ownership colouring (#87, chant#1076/#1181): per-field drift
|
|
505
|
+
// derived from k8s managed-fields pruning — additive, only present when
|
|
506
|
+
// chant's `lifecycle diff --live --json` carried a `deep` section for this
|
|
507
|
+
// entity's lexicon (src/diff.ts's `nodeFieldDrift`). `null` on a substrate/
|
|
508
|
+
// chant with no deep reader — the whole-object `drift` section above is
|
|
509
|
+
// unaffected, per #87's fallback acceptance criterion.
|
|
510
|
+
//
|
|
511
|
+
// chant's wire contract doesn't carry a field-MANAGER name (which manager
|
|
512
|
+
// currently owns a path) — only whether the path is `changed` (a real
|
|
513
|
+
// deviation from what chant declared, whether purely chant-owned or
|
|
514
|
+
// contested by a foreign manager — chant's own pruning already drops a
|
|
515
|
+
// confidently foreign-owned, undeclared field before this ever runs),
|
|
516
|
+
// `undeclared` (present live, chant's source says nothing about it — the
|
|
517
|
+
// closest available signal to "foreign"), or `absent` (declared, not present
|
|
518
|
+
// live). These three are the closest honest mapping to "chant-owned vs
|
|
519
|
+
// foreign vs contested" the current wire data supports.
|
|
520
|
+
const FIELD_KIND_LABEL = { changed: "drifted", undeclared: "foreign (not declared)", absent: "declared, not present live" };
|
|
521
|
+
const FIELD_KIND_COLOR = { changed: "var(--degraded)", undeclared: "var(--foreign)", absent: "var(--muted)" };
|
|
522
|
+
|
|
523
|
+
function renderFieldDrift(panel, fieldDrift) {
|
|
524
|
+
if (!fieldDrift) return; // no lexicon in this diff ran a deep (field-level) read
|
|
525
|
+
if (!fieldDrift.drifted.length && !fieldDrift.accepted.length) return; // deep ran, nothing to report for this node
|
|
526
|
+
const h = document.createElement("h3");
|
|
527
|
+
h.textContent = "field ownership";
|
|
528
|
+
h.style.cssText = "font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin:14px 0 6px";
|
|
529
|
+
panel.appendChild(h);
|
|
530
|
+
const dl = document.createElement("dl");
|
|
531
|
+
for (const ch of fieldDrift.drifted) {
|
|
532
|
+
const dt = document.createElement("dt");
|
|
533
|
+
dt.textContent = ch.path;
|
|
534
|
+
dt.style.color = FIELD_KIND_COLOR[ch.kind] || "";
|
|
535
|
+
const dd = document.createElement("dd");
|
|
536
|
+
// The owning manager (#87, chant#1189) leads, because it is the part that
|
|
537
|
+
// decides what to do about the field: `hpa-controller` holding
|
|
538
|
+
// `spec.replicas` is a controller doing its job; `kubectl-client-side-apply`
|
|
539
|
+
// holding it is somebody editing around the pipeline. Both are `changed`.
|
|
540
|
+
// Absent on every substrate but k8s, where the line reads as it always did.
|
|
541
|
+
const owned = ch.owner ? `owned by ${ch.owner} — ` : "";
|
|
542
|
+
dd.textContent = `${owned}${FIELD_KIND_LABEL[ch.kind] || ch.kind} — declared: ${JSON.stringify(ch.declared)} · live: ${JSON.stringify(ch.live)}`;
|
|
543
|
+
dl.append(dt, dd);
|
|
544
|
+
}
|
|
545
|
+
for (const ch of fieldDrift.accepted) {
|
|
546
|
+
const dt = document.createElement("dt");
|
|
547
|
+
dt.textContent = ch.path;
|
|
548
|
+
dt.style.color = "var(--muted)";
|
|
549
|
+
const dd = document.createElement("dd");
|
|
550
|
+
dd.textContent = `accepted deviation — baseline: ${JSON.stringify(ch.baseline)} · live: ${JSON.stringify(ch.live)}`;
|
|
551
|
+
dl.append(dt, dd);
|
|
552
|
+
}
|
|
553
|
+
panel.appendChild(dl);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function wire(ir) {
|
|
557
|
+
const byId = new Map(ir.nodes.map((n) => [n.id, n]));
|
|
558
|
+
const host = document.getElementById("graph");
|
|
559
|
+
for (const g of host.querySelectorAll("[data-node-id]")) {
|
|
560
|
+
const node = byId.get(g.getAttribute("data-node-id"));
|
|
561
|
+
if (!node) continue;
|
|
562
|
+
g.style.cursor = "pointer";
|
|
563
|
+
g.addEventListener("click", () => {
|
|
564
|
+
if (panMoved) return; // a drag-pan ended here — don't also select
|
|
565
|
+
host.querySelectorAll(".sel").forEach((n) => n.classList.remove("sel"));
|
|
566
|
+
g.classList.add("sel");
|
|
567
|
+
inspect(node);
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// View state driven by the ⌘K palette's lens commands (#17, moved off the
|
|
573
|
+
// header pickers in #73 — see paletteCommands()). env=null → the declared
|
|
574
|
+
// source graph; env set → the live overlay for that env (needs cloud creds).
|
|
575
|
+
// detail is chant's --detail tier. components (#56) toggles the component-DAG
|
|
576
|
+
// projection (nodes=components, wave-laned, dependsOn edges) in place of the
|
|
577
|
+
// AWS entity graph. With an env picked too, components mode gets its own live
|
|
578
|
+
// status join (#57, per-component AWS reconciliation) instead of the entity
|
|
579
|
+
// overlay — see load()'s endpoint choice. tier/target (M2, #54) are the two
|
|
580
|
+
// new lenses: a picked tier overrides LOOM_TIER, a picked target overrides
|
|
581
|
+
// AWS_ENDPOINT_URL, for every chant shell-out this page's fetches trigger (see
|
|
582
|
+
// lensParams()). stack (#76, follow-up to #71) is a THIRD kind of lens: a
|
|
583
|
+
// multi-stack project's `chant.config.ts` `stacks[]` names an independently-
|
|
584
|
+
// deployed source tree — picking one re-points the graph at that stack's
|
|
585
|
+
// source (chant.ts's `graphPath`), not an env override like tier/target.
|
|
586
|
+
// null on a project that declares no `stacks[]` at all — the picker (and the
|
|
587
|
+
// status strip's stack tag) then never renders. Every fetch reads this, so
|
|
588
|
+
// the `changed` SSE re-pull and a palette lens change go through the same path.
|
|
589
|
+
const view = { env: null, detail: 2, components: true, logical: false, runtime: false, tier: null, target: null, stack: null, radial: false };
|
|
590
|
+
|
|
591
|
+
// v0.1.0 preview lock (set from /api/project in initActions): hides the git/PR
|
|
592
|
+
// write ops (Rollback, Sync, Adopt, Run ▾) — the server also 403s them. Local
|
|
593
|
+
// deploy (Apply all / dial), Reset, Bring up, Approve, and reads stay on.
|
|
594
|
+
let previewMode = false;
|
|
595
|
+
|
|
596
|
+
// The unified "zoom" control (one granularity axis, coarse → fine). Underlying
|
|
597
|
+
// state stays (components, detail); zoom is just the single knob the header
|
|
598
|
+
// exposes, mapping "components" → the wave/component view and composites/
|
|
599
|
+
// resources/attributes → the entity graph at detail 1/2/3. (detail 0 / per-
|
|
600
|
+
// lexicon "stacks" is dropped from the UI — niche; the API still accepts it.)
|
|
601
|
+
// "logical" (#63) is a re-projection, not a granularity stop like the others —
|
|
602
|
+
// a traditional AWS architecture diagram: nested VPC/subnet ⊃ component boxes.
|
|
603
|
+
// region/VPC/subnet boxes with topology nodes only — but it rides the same dial
|
|
604
|
+
// as the coarse "infrastructure overview" the way a traditional AWS diagram sits
|
|
605
|
+
// beside the resource views.
|
|
606
|
+
const ZOOM_OPTS = [
|
|
607
|
+
["zoom: components", "components"],
|
|
608
|
+
["zoom: logical", "logical"],
|
|
609
|
+
["zoom: composites", "composites"],
|
|
610
|
+
["zoom: resources", "resources"],
|
|
611
|
+
["zoom: attributes", "attributes"],
|
|
612
|
+
// Below the declaration boundary (#86): the owner-referenced children the
|
|
613
|
+
// cluster maintains — the Pods under a Deployment. Its own stop rather than a
|
|
614
|
+
// detail level, because it is a different axis: every tier above shows what
|
|
615
|
+
// you declared, and this one shows what your declaration produced.
|
|
616
|
+
["zoom: runtime", "runtime"],
|
|
617
|
+
];
|
|
618
|
+
const ZOOM_DETAIL = { composites: 1, resources: 2, attributes: 3, runtime: 3 };
|
|
619
|
+
/** Current zoom value from (components, logical, detail). */
|
|
620
|
+
function zoomValue() {
|
|
621
|
+
if (view.components) return "components";
|
|
622
|
+
if (view.logical) return "logical";
|
|
623
|
+
if (view.runtime) return "runtime";
|
|
624
|
+
return { 1: "composites", 2: "resources", 3: "attributes" }[view.detail] ?? "resources";
|
|
625
|
+
}
|
|
626
|
+
/** Apply a zoom value back onto (components, logical, detail). */
|
|
627
|
+
function applyZoom(z) {
|
|
628
|
+
view.components = z === "components";
|
|
629
|
+
view.logical = z === "logical";
|
|
630
|
+
view.runtime = z === "runtime";
|
|
631
|
+
if (z !== "components" && z !== "logical") view.detail = ZOOM_DETAIL[z] ?? 2;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// #73 "hide controls, never state": zoom/env/tier/radial used to live in
|
|
635
|
+
// header <select>s; the pickers moved into the ⌘K palette (paletteCommands()
|
|
636
|
+
// below), but the CURRENT value must stay visible without opening it — this
|
|
637
|
+
// is the one place that reads. Called after every render() and once before
|
|
638
|
+
// the first load (initPickers()). stack (#76) joins the strip the same way —
|
|
639
|
+
// only ever truthy on a project that declares `stacks[]` (initPickers seeds
|
|
640
|
+
// `view.stack` from `info.stacks`; a project with none leaves it null), so a
|
|
641
|
+
// single-stack project's strip is unaffected.
|
|
642
|
+
// The zoom picker, back in the header as a control you can see and click.
|
|
643
|
+
//
|
|
644
|
+
// #73 moved the pickers into ⌘K and left the current value on the strip, on the
|
|
645
|
+
// principle "hide controls, never state". That reads well until someone opens
|
|
646
|
+
// behold for the first time: the strip says "zoom: components", it looks like a
|
|
647
|
+
// dropdown because it is a value next to other values, and clicking it does
|
|
648
|
+
// nothing — renderStatusbar() only ever sets textContent. The discoverable path
|
|
649
|
+
// to the single most useful control is a keyboard shortcut hinted by a "⌘K"
|
|
650
|
+
// glyph at the far end of the header.
|
|
651
|
+
//
|
|
652
|
+
// So zoom gets a real <select> and the palette keeps its entry. The other axes
|
|
653
|
+
// stay where #73 put them: zoom is the one you reach for constantly, and one
|
|
654
|
+
// visible control is not the header of selects that issue was about.
|
|
655
|
+
function renderZoomPicker() {
|
|
656
|
+
// #zoom-slot, top-left after the brand — not #pickers on the far right,
|
|
657
|
+
// where it sat beside the theme select and read as chrome rather than as the
|
|
658
|
+
// control. Falls back to #pickers so a static export built from older markup
|
|
659
|
+
// still gets a picker rather than none.
|
|
660
|
+
const slot = document.getElementById("zoom-slot") || document.getElementById("pickers");
|
|
661
|
+
if (!slot) return;
|
|
662
|
+
let sel = document.getElementById("zoom-picker");
|
|
663
|
+
if (!sel) {
|
|
664
|
+
sel = document.createElement("select");
|
|
665
|
+
sel.id = "zoom-picker";
|
|
666
|
+
sel.title = "Zoom — components, logical, composites, resources, attributes, runtime (also ⌘K)";
|
|
667
|
+
sel.addEventListener("change", () => {
|
|
668
|
+
applyZoom(sel.value);
|
|
669
|
+
renderStatusbar();
|
|
670
|
+
load();
|
|
671
|
+
});
|
|
672
|
+
// Before the theme picker, which mounts into the same slot at load.
|
|
673
|
+
slot.insertBefore(sel, slot.firstChild);
|
|
674
|
+
}
|
|
675
|
+
const current = zoomValue();
|
|
676
|
+
// Rebuilt each render because runtime is only meaningful with an env: it
|
|
677
|
+
// descends below the declaration boundary to owner-referenced children,
|
|
678
|
+
// which exist in a cluster and never in your source.
|
|
679
|
+
const opts = ZOOM_OPTS.filter(([, v]) => v !== "runtime" || view.env);
|
|
680
|
+
const want = opts.map(([label, v]) => `${v}:${label}`).join("|") + "@" + current;
|
|
681
|
+
if (sel.dataset.built !== want) {
|
|
682
|
+
sel.innerHTML = "";
|
|
683
|
+
for (const [label, v] of opts) {
|
|
684
|
+
const o = document.createElement("option");
|
|
685
|
+
o.value = v;
|
|
686
|
+
o.textContent = label;
|
|
687
|
+
if (v === current) o.selected = true;
|
|
688
|
+
sel.appendChild(o);
|
|
689
|
+
}
|
|
690
|
+
sel.dataset.built = want;
|
|
691
|
+
}
|
|
692
|
+
sel.value = current;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function renderStatusbar() {
|
|
696
|
+
renderZoomPicker();
|
|
697
|
+
const el = document.getElementById("statusbar");
|
|
698
|
+
if (!el) return;
|
|
699
|
+
// The strip looks clickable whether or not it is, so make it act like it:
|
|
700
|
+
// clicking opens the palette rather than doing nothing. env, stack and tier
|
|
701
|
+
// live only there, and this is the only affordance pointing at them.
|
|
702
|
+
if (!el.dataset.clickable) {
|
|
703
|
+
el.dataset.clickable = "1";
|
|
704
|
+
el.style.cursor = "pointer";
|
|
705
|
+
el.title = "env · stack · tier — click, or ⌘K, to change";
|
|
706
|
+
el.addEventListener("click", () => openPalette());
|
|
707
|
+
}
|
|
708
|
+
// No zoom here any more. #73 put the current zoom on the strip because the
|
|
709
|
+
// control had moved into the palette; with a real picker two slots along,
|
|
710
|
+
// the same value in both places reads as two pickers, one of which does not
|
|
711
|
+
// work — which is exactly how it was reported. The strip keeps the axes that
|
|
712
|
+
// still have no on-screen control.
|
|
713
|
+
const parts = [view.env ? `env: ${view.env}` : "env: (source)"];
|
|
714
|
+
if (view.stack) parts.push(`stack: ${view.stack}`);
|
|
715
|
+
if (axes.tier) parts.push(`tier: ${axes.tier}`);
|
|
716
|
+
if (view.radial && !view.components && !view.logical) parts.push("radial");
|
|
717
|
+
el.textContent = parts.join(" · ");
|
|
718
|
+
// #131: why this level rendered empty, or as the one below it. The server
|
|
719
|
+
// decides (src/zoom-notes.ts) — the SPA never infers it, so the note always
|
|
720
|
+
// describes the graph that actually came back. Appended rather than mixed
|
|
721
|
+
// into `parts` so it can carry its own emphasis without the zoom/env strip
|
|
722
|
+
// changing shape when there is nothing to say.
|
|
723
|
+
if (lastNote) {
|
|
724
|
+
const note = document.createElement("span");
|
|
725
|
+
note.className = "statusbar-note";
|
|
726
|
+
note.textContent = " — " + lastNote;
|
|
727
|
+
el.appendChild(note);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// The note from the last /api/graph or /api/overlay response (`meta.note`),
|
|
732
|
+
// or null. Set on every load so a level that stops degrading stops explaining
|
|
733
|
+
// itself.
|
|
734
|
+
let lastNote = null;
|
|
735
|
+
|
|
736
|
+
// The deploy axes as currently displayed in the header (#59 unify, M2 #54
|
|
737
|
+
// lenses) — seeded once from /api/project (server-derived from the process
|
|
738
|
+
// env at launch; see deployAxes() in src/server.ts), then kept in sync with
|
|
739
|
+
// whatever the last /api/graph response actually observed (its `meta.tier`/
|
|
740
|
+
// `meta.target`, since a picked lens can differ from the launch-time default).
|
|
741
|
+
let axes = { tier: null, target: null };
|
|
742
|
+
|
|
743
|
+
// Query params for the tier/target lenses (M2, #54) — shared by every fetch
|
|
744
|
+
// this page makes, so picking a lens re-parameterizes the component graph,
|
|
745
|
+
// its CI/resources facets, and the reconcile summary all the same way.
|
|
746
|
+
// stack (#76) rides along too — server.ts's `optsFromQuery` parses `?stack=`
|
|
747
|
+
// the same way, but only `graphPath`'s callers (the graph/overlay fetches in
|
|
748
|
+
// load()) actually consult it; the CI/resources/reconcile facets don't take a
|
|
749
|
+
// source path at all, so they ignore an extra `stack=` harmlessly.
|
|
750
|
+
function lensParams(params) {
|
|
751
|
+
if (view.tier) params.set("tier", view.tier);
|
|
752
|
+
if (view.target) params.set("target", view.target);
|
|
753
|
+
if (view.stack) params.set("stack", view.stack);
|
|
754
|
+
return params;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// CI projection facet (M1.2, #58): the component DAG's GitLab CI reading —
|
|
758
|
+
// component name → {jobName, stage, needs, script}, from `/api/ci` (`chant
|
|
759
|
+
// build --components --generate gitlab`). Loaded once per components-mode
|
|
760
|
+
// load() and cached here so inspect() (fired per click, not per fetch) reads
|
|
761
|
+
// it synchronously. Component-DAG mode only — cleared otherwise.
|
|
762
|
+
let ciByComponent = new Map();
|
|
763
|
+
let ciForge = null; // the forge the pipeline was generated for (#164) — heads the inspect section
|
|
764
|
+
|
|
765
|
+
async function loadCi() {
|
|
766
|
+
try {
|
|
767
|
+
const q = lensParams(new URLSearchParams(view.env ? { env: view.env } : {}));
|
|
768
|
+
const res = await apiFetch(`/api/ci?${q}`);
|
|
769
|
+
const j = await res.json();
|
|
770
|
+
if (!res.ok) throw new Error(j.error || res.statusText);
|
|
771
|
+
ciByComponent = new Map((j.jobs || []).map((job) => [job.component, job]));
|
|
772
|
+
ciForge = j.forge || null;
|
|
773
|
+
} catch {
|
|
774
|
+
// Non-fatal: the component DAG still renders without the CI facet (e.g. a
|
|
775
|
+
// served chant predating generate mode) — inspect() just omits the section.
|
|
776
|
+
ciByComponent = new Map();
|
|
777
|
+
ciForge = null;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// Resources facet (#59 unify) — component name -> its AWS resources, from
|
|
782
|
+
// `/api/resources` (src/server.ts documents what this is and isn't: a
|
|
783
|
+
// source-location convention match, not the literal CFN stack — chant's own
|
|
784
|
+
// `groups.byStack` is lexicon-only today, not per-stack). Loaded once per
|
|
785
|
+
// components-mode load(), same caching shape as `ciByComponent`; a click
|
|
786
|
+
// reads it synchronously.
|
|
787
|
+
let resourcesByComponent = {};
|
|
788
|
+
|
|
789
|
+
async function loadResources() {
|
|
790
|
+
try {
|
|
791
|
+
const q = lensParams(new URLSearchParams(view.env ? { env: view.env } : {}));
|
|
792
|
+
const res = await apiFetch(`/api/resources?${q}`);
|
|
793
|
+
const j = await res.json();
|
|
794
|
+
if (!res.ok) throw new Error(j.error || res.statusText);
|
|
795
|
+
resourcesByComponent = j.byComponent || {};
|
|
796
|
+
} catch {
|
|
797
|
+
// Non-fatal, same rationale as loadCi(): the DAG and its other facets
|
|
798
|
+
// still render without the resources facet.
|
|
799
|
+
resourcesByComponent = {};
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// The observe → reconcile → apply dial (M2 #54 observe/reconcile, M3 #54
|
|
804
|
+
// apply): where the selected target sits on the lifecycle progression, per
|
|
805
|
+
// the epic's design. `observe` is always live already — it's the
|
|
806
|
+
// component-status view above (render()'s `componentStatus` branch IS
|
|
807
|
+
// observe); clicking the step just switches into it. `reconcile` is a
|
|
808
|
+
// click-to-fetch summary (`/api/reconcile`, a full build + cloud query — on
|
|
809
|
+
// demand, like #27's live diff), cached until the env/tier/target lens
|
|
810
|
+
// changes. `apply` (M3) is a REAL delegated write: click opens a small
|
|
811
|
+
// component/all picker, confirm triggers `POST /api/apply`
|
|
812
|
+
// (`chant run <target> --components --env <env> --progress-json` — behold
|
|
813
|
+
// triggers, chant executes), and the structured wave/phase progress it
|
|
814
|
+
// streams back (see applyProgressReducer in src/apply.ts, broadcast as the
|
|
815
|
+
// `apply` SSE event) renders live below the dial — the primary surface for
|
|
816
|
+
// an apply, not the raw now-line.
|
|
817
|
+
let reconcileCache = null; // last ReconcileSummary for the current env/tier/target, or null
|
|
818
|
+
let applyProgress = null; // last ApplyProgressState (src/apply.ts) for the current env, or null — hydrated from /api/ops on load, then kept live by the `apply` SSE event
|
|
819
|
+
let applyPicker = false; // whether the inline "apply <component|all> →" prompt is open
|
|
820
|
+
let componentChoices = []; // component names for the apply picker — loaded lazily (independent of whether the graph pane is currently in components mode)
|
|
821
|
+
let componentStatusById = {}; // id -> _status ("good"|"accent"|"warn"|"neutral"), populated alongside componentChoices — lets the picker show which stacks are already applied
|
|
822
|
+
|
|
823
|
+
// How a component's live status reads in the apply picker (and the apply-all
|
|
824
|
+
// summary): a glyph + words, so "which stacks are applied" is legible at a
|
|
825
|
+
// glance rather than a bare name list. Mirrors COMPONENT_STATUS_LABEL's buckets.
|
|
826
|
+
const APPLY_STATUS_TAG = {
|
|
827
|
+
good: "✓ deployed",
|
|
828
|
+
accent: "⋯ in progress",
|
|
829
|
+
warn: "⚠ rolled back / failed",
|
|
830
|
+
neutral: "○ not deployed",
|
|
831
|
+
};
|
|
832
|
+
function applyOptionLabel(name) {
|
|
833
|
+
const tag = APPLY_STATUS_TAG[componentStatusById[name]];
|
|
834
|
+
return tag ? `${name} — ${tag}` : name;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// Reset the dial's per-target caches (reconcile summary, apply picker/progress,
|
|
838
|
+
// component-name list) when the env/tier/target lens changes — all three are
|
|
839
|
+
// scoped to "whatever target is currently picked", so switching targets must
|
|
840
|
+
// not show a stale reconcile count or a finished apply's progress from a
|
|
841
|
+
// DIFFERENT target as if it were current.
|
|
842
|
+
function resetDialCaches() {
|
|
843
|
+
reconcileCache = null;
|
|
844
|
+
applyProgress = null;
|
|
845
|
+
applyPicker = false;
|
|
846
|
+
componentChoices = [];
|
|
847
|
+
componentStatusById = {};
|
|
848
|
+
compositeMembersCache = null;
|
|
849
|
+
bulkDiffCache = null;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// Composite → member list, for the inspect pane. Derived once from the base
|
|
853
|
+
// (attribute-tier) source graph, where every node carries its `compositeInstance`
|
|
854
|
+
// — then a detail-1 composite node lists what it expands to. Cached per lens
|
|
855
|
+
// (reset in resetDialCaches); structural, so no live/env call needed.
|
|
856
|
+
let compositeMembersCache = null;
|
|
857
|
+
async function getCompositeMembers(instanceId) {
|
|
858
|
+
if (!compositeMembersCache) {
|
|
859
|
+
compositeMembersCache = {};
|
|
860
|
+
try {
|
|
861
|
+
const q = lensParams(new URLSearchParams({ detail: "3" }));
|
|
862
|
+
const j = await apiFetch(`/api/graph?${q}`).then((r) => r.json());
|
|
863
|
+
for (const n of (j.ir && j.ir.nodes) || []) {
|
|
864
|
+
if (n.compositeInstance) (compositeMembersCache[n.compositeInstance] ||= []).push({ id: n.id, kind: n.kind });
|
|
865
|
+
}
|
|
866
|
+
} catch {
|
|
867
|
+
/* leave the cache empty — the members section shows "(no members found)" */
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
return compositeMembersCache[instanceId] || [];
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function dialArrow() {
|
|
874
|
+
const s = document.createElement("span");
|
|
875
|
+
s.className = "dial-arrow";
|
|
876
|
+
s.textContent = "→";
|
|
877
|
+
return s;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function renderDial() {
|
|
881
|
+
const host = document.getElementById("dial");
|
|
882
|
+
if (!view.env) {
|
|
883
|
+
// The dial used to vanish entirely here, with nothing saying why — someone
|
|
884
|
+
// who launched without --env saw no observe/reconcile/apply path at all.
|
|
885
|
+
// Say what's missing instead (a static export keeps the old silence: there
|
|
886
|
+
// is genuinely nothing to offer).
|
|
887
|
+
host.innerHTML = "";
|
|
888
|
+
if (staticMode || !environments.length) {
|
|
889
|
+
host.style.display = "none";
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
host.style.display = "flex";
|
|
893
|
+
const hint = document.createElement("span");
|
|
894
|
+
hint.style.cssText = "font-size:11px;color:var(--muted);align-self:center";
|
|
895
|
+
hint.textContent = "observe → reconcile → apply needs an environment — pick one in ⌘K (env: …)";
|
|
896
|
+
host.appendChild(hint);
|
|
897
|
+
// A pipeline run (#163) is env-less — its progress still belongs here.
|
|
898
|
+
if (applyProgress && applyProgress.waves.length) host.appendChild(renderApplyProgress(applyProgress));
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
host.style.display = "flex";
|
|
902
|
+
host.innerHTML = "";
|
|
903
|
+
|
|
904
|
+
const track = document.createElement("div");
|
|
905
|
+
track.className = "dial-track";
|
|
906
|
+
|
|
907
|
+
const observeBtn = button("observe", "dial-step" + (view.components ? " active" : ""), () => {
|
|
908
|
+
view.components = true;
|
|
909
|
+
load();
|
|
910
|
+
});
|
|
911
|
+
observeBtn.title = `Live per-component status for ${view.env} (chant components status --live) — the palette this graph paints when "components" is on.`;
|
|
912
|
+
track.appendChild(observeBtn);
|
|
913
|
+
track.appendChild(dialArrow());
|
|
914
|
+
|
|
915
|
+
// chant#1168 (#1089): `unobserved` is its own count, appended rather than
|
|
916
|
+
// folded into "pending" — reconcileCache.unobserved is 0 against an older
|
|
917
|
+
// chant, so this is a no-op suffix until #1168 ships.
|
|
918
|
+
// chant#1180 (#1077): `runtime` gets the identical treatment — its own
|
|
919
|
+
// count, 0 against a chant predating it.
|
|
920
|
+
const reconcileBtn = button(
|
|
921
|
+
reconcileCache
|
|
922
|
+
? `reconcile · ${reconcileCache.total} pending` +
|
|
923
|
+
(reconcileCache.unobserved ? ` · ${reconcileCache.unobserved} unobserved` : "") +
|
|
924
|
+
(reconcileCache.runtime ? ` · ${reconcileCache.runtime} runtime` : "")
|
|
925
|
+
: "reconcile",
|
|
926
|
+
"dial-step",
|
|
927
|
+
loadReconcile,
|
|
928
|
+
);
|
|
929
|
+
reconcileBtn.title = `Pending change set for ${view.env} (chant lifecycle plan --live, read-only) — click to load.`;
|
|
930
|
+
track.appendChild(reconcileBtn);
|
|
931
|
+
|
|
932
|
+
// Apply is the write step — omitted in a static export (observe + reconcile
|
|
933
|
+
// above are reads and stay).
|
|
934
|
+
const applying = applyProgress && applyProgress.status === "running";
|
|
935
|
+
if (!staticMode) {
|
|
936
|
+
track.appendChild(dialArrow());
|
|
937
|
+
const applyBtn = button(
|
|
938
|
+
applying ? "apply · running…" : "apply",
|
|
939
|
+
"dial-step" + (applyPicker || applying ? " active" : ""),
|
|
940
|
+
() => {
|
|
941
|
+
if (applying) return; // a run is already in flight — its progress is on screen below
|
|
942
|
+
applyPicker = !applyPicker;
|
|
943
|
+
if (applyPicker) loadComponentChoices().then(renderDial);
|
|
944
|
+
renderDial();
|
|
945
|
+
},
|
|
946
|
+
);
|
|
947
|
+
applyBtn.title = `Delegated write: chant run <component|all> --components --env ${view.env} --progress-json — behold triggers, chant executes.`;
|
|
948
|
+
track.appendChild(applyBtn);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
host.appendChild(track);
|
|
952
|
+
if (reconcileCache) host.appendChild(renderReconcileDetail(reconcileCache));
|
|
953
|
+
if (applyPicker && !applying && !staticMode) host.appendChild(renderApplyPicker());
|
|
954
|
+
if (applyProgress && applyProgress.waves.length) host.appendChild(renderApplyProgress(applyProgress));
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// Apply picker (M3): "which component(s)?" prompt for the dial's apply step —
|
|
958
|
+
// mirrors openRollback's inline select+confirm+cancel shape. Defaults to "all
|
|
959
|
+
// components" (chant's own `run --components all` selector); loadComponentChoices()
|
|
960
|
+
// supplies the individual names regardless of the graph pane's current mode.
|
|
961
|
+
function renderApplyPicker() {
|
|
962
|
+
const wrap = document.createElement("div");
|
|
963
|
+
wrap.className = "dial-detail";
|
|
964
|
+
wrap.style.gap = "6px";
|
|
965
|
+
const sel = document.createElement("select");
|
|
966
|
+
sel.style.cssText =
|
|
967
|
+
"background:var(--panel);color:var(--fg);border:1px solid var(--line);border-radius:6px;padding:3px 8px;font-size:12px";
|
|
968
|
+
sel.add(new Option("all components", "all"));
|
|
969
|
+
for (const name of componentChoices) sel.add(new Option(applyOptionLabel(name), name));
|
|
970
|
+
const go = button("Apply →", "", () => {
|
|
971
|
+
const component = sel.value;
|
|
972
|
+
const what = component === "all" ? "ALL components" : component;
|
|
973
|
+
if (!window.confirm(`Apply ${what} to ${view.env}?\nThis is a real write — chant run --components --progress-json.`)) return;
|
|
974
|
+
applyPicker = false;
|
|
975
|
+
runApply(component);
|
|
976
|
+
});
|
|
977
|
+
const cancel = button("✕", "", () => {
|
|
978
|
+
applyPicker = false;
|
|
979
|
+
renderDial();
|
|
980
|
+
});
|
|
981
|
+
wrap.append(sel, go, cancel);
|
|
982
|
+
return wrap;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
async function loadComponentChoices() {
|
|
986
|
+
if (componentChoices.length) return componentChoices;
|
|
987
|
+
try {
|
|
988
|
+
const q = lensParams(new URLSearchParams({ components: "1", ...(view.env ? { env: view.env } : {}) }));
|
|
989
|
+
const res = await apiFetch(`/api/graph?${q}`);
|
|
990
|
+
const j = await res.json();
|
|
991
|
+
const nodes = (j.ir && j.ir.nodes ? j.ir.nodes : []).filter((n) => n.kind === "Component");
|
|
992
|
+
componentChoices = nodes.map((n) => n.id);
|
|
993
|
+
// Per-component live status for the picker labels — only present when an env
|
|
994
|
+
// is picked (the status join needs one); source-only graphs leave it blank.
|
|
995
|
+
componentStatusById = {};
|
|
996
|
+
for (const n of nodes) if (n.attrs && n.attrs._status) componentStatusById[n.id] = n.attrs._status;
|
|
997
|
+
} catch {
|
|
998
|
+
componentChoices = []; // the picker still offers "all components" — just no per-name list
|
|
999
|
+
componentStatusById = {};
|
|
1000
|
+
}
|
|
1001
|
+
return componentChoices;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// "Apply all" from the header (M3): the discoverable equivalent of Sync when a
|
|
1005
|
+
// project has no committed ApplyOp — deploy every component. Uses the current
|
|
1006
|
+
// env (kept live by the picker); confirms since it's a real write. The dial's
|
|
1007
|
+
// structured progress then renders the run.
|
|
1008
|
+
async function confirmApplyAll() {
|
|
1009
|
+
const env = view.env;
|
|
1010
|
+
if (!env) {
|
|
1011
|
+
nowline("✗ apply all: pick an env first (env drives the target)");
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
// Route around Floci #16 (github.com/lex00/floci/issues/16): re-applying an
|
|
1015
|
+
// already-deployed stack collides on its fixed-name resources ("... already
|
|
1016
|
+
// exists") and rolls the stack back — the emulator can't no-op an unchanged
|
|
1017
|
+
// resource on update. So the honest states, from the accurate per-component
|
|
1018
|
+
// live status (the stack-status seam):
|
|
1019
|
+
// all healthy -> nothing to apply; re-apply would only break things.
|
|
1020
|
+
// some undeployed -> apply is fine for a fresh emulator.
|
|
1021
|
+
// some rolled-back -> re-apply won't recover them; a reset is the clean path.
|
|
1022
|
+
let deployed = 0;
|
|
1023
|
+
let total = 0;
|
|
1024
|
+
let rolledBack = 0;
|
|
1025
|
+
try {
|
|
1026
|
+
const j = await apiFetch(`/api/graph?components=1&env=${encodeURIComponent(env)}`).then((r) => r.json());
|
|
1027
|
+
const nodes = (j.ir && j.ir.nodes) || [];
|
|
1028
|
+
total = nodes.length;
|
|
1029
|
+
deployed = nodes.filter((n) => n.attrs && n.attrs._status === "good").length;
|
|
1030
|
+
rolledBack = nodes.filter((n) => n.attrs && n.attrs._status === "warn").length;
|
|
1031
|
+
} catch {
|
|
1032
|
+
/* couldn't check — fall through to the plain confirm */
|
|
1033
|
+
}
|
|
1034
|
+
// The Floci #16 story (re-apply collides on the emulator's fixed-name
|
|
1035
|
+
// resources) only exists where applies actually hit Floci. On a k8s/helm
|
|
1036
|
+
// estate a re-apply is the normal sync gesture, and warning about an
|
|
1037
|
+
// emulator the project doesn't use was wrong twice over — the server's
|
|
1038
|
+
// /api/apply pre-flight is gated the same way.
|
|
1039
|
+
const onFloci = lastSubstrates.some((s) => s.name === "floci" && s.status === "up");
|
|
1040
|
+
if (onFloci && total > 0 && deployed === total) {
|
|
1041
|
+
showToast(`✓ nothing to apply — all ${total} components are already deployed & in sync (re-applying would collide on Floci #16)`, true);
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
const brokenNote = onFloci && rolledBack > 0
|
|
1045
|
+
? `⚠ ${rolledBack} component(s) are rolled back. Re-applying WON'T recover them on the emulator — their fixed-name resources still exist (Floci #16). Use the "Reset" button on the Floci substrate pill — it reboots the emulator and redeploys clean (don't apply after).\n\n`
|
|
1046
|
+
: "";
|
|
1047
|
+
const reapplyNote = onFloci && deployed > 0
|
|
1048
|
+
? `Note: ${deployed} of ${total} are already deployed and will be re-applied — that can fail on the local emulator (Floci #16).\n\n`
|
|
1049
|
+
: "";
|
|
1050
|
+
if (
|
|
1051
|
+
!window.confirm(
|
|
1052
|
+
`${brokenNote}${reapplyNote}Apply ALL components to ${env}?\nReal write — chant run --components all --env ${env} --progress-json.\nLive progress appears in the dial.`,
|
|
1053
|
+
)
|
|
1054
|
+
)
|
|
1055
|
+
return;
|
|
1056
|
+
runApply("all");
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function runApply(component) {
|
|
1060
|
+
const q = new URLSearchParams({ env: view.env, component });
|
|
1061
|
+
fetch(`/api/apply?${q}`, { method: "POST" })
|
|
1062
|
+
.then((r) => r.json())
|
|
1063
|
+
.then((j) => {
|
|
1064
|
+
if (j.error) {
|
|
1065
|
+
showToast(`✗ apply: ${j.error}`, false);
|
|
1066
|
+
nowline("✗ apply: " + j.error);
|
|
1067
|
+
} else {
|
|
1068
|
+
showToast(`▶ applying ${component} → ${view.env} — progress on the dial`, true);
|
|
1069
|
+
nowline(`▶ apply ${component} → ${view.env}`);
|
|
1070
|
+
}
|
|
1071
|
+
renderDial();
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
// Structured wave/phase progress (M3): the primary surface for an apply — an
|
|
1076
|
+
// ordered list of waves, each showing its components' current phase/step and
|
|
1077
|
+
// status, coloured the same way the rest of the SPA colours health (managed=
|
|
1078
|
+
// ok, pending=running, degraded=failed, muted=not-yet-reached). Replaces the
|
|
1079
|
+
// raw-log-tail now-line as the thing you actually watch during a deploy; the
|
|
1080
|
+
// now-line still gets chant's human summary + any non-progress line as a
|
|
1081
|
+
// fallback (src/op-runner.ts's apply() only filters OUT recognized
|
|
1082
|
+
// RunProgressEvent lines from that channel).
|
|
1083
|
+
const APPLY_STATUS_COLOR = { pending: "var(--muted)", running: "var(--pending)", ok: "var(--managed)", failed: "var(--degraded)" };
|
|
1084
|
+
|
|
1085
|
+
function renderApplyProgress(state) {
|
|
1086
|
+
const wrap = document.createElement("div");
|
|
1087
|
+
wrap.style.cssText = "display:flex;flex-direction:column;gap:6px;width:100%;margin-top:4px";
|
|
1088
|
+
const summary = document.createElement("div");
|
|
1089
|
+
summary.style.cssText = `font-size:11px;color:${APPLY_STATUS_COLOR[state.status] || "var(--muted)"}`;
|
|
1090
|
+
// A pipeline run (#163) reuses this whole panel — same shape, different
|
|
1091
|
+
// executor — and says so instead of claiming to be an apply.
|
|
1092
|
+
summary.textContent = `${state.kind || "apply"}: ${state.status}`;
|
|
1093
|
+
wrap.appendChild(summary);
|
|
1094
|
+
for (const w of state.waves) {
|
|
1095
|
+
const row = document.createElement("div");
|
|
1096
|
+
row.style.cssText = "display:flex;align-items:center;gap:8px;flex-wrap:wrap";
|
|
1097
|
+
const label = document.createElement("span");
|
|
1098
|
+
label.style.cssText = `font-size:11px;color:${APPLY_STATUS_COLOR[w.status] || "var(--muted)"};min-width:52px`;
|
|
1099
|
+
label.textContent = `wave ${w.wave}`;
|
|
1100
|
+
row.appendChild(label);
|
|
1101
|
+
for (const cname of w.components) {
|
|
1102
|
+
const c = (state.components || []).find((x) => x.component === cname) || { status: "pending" };
|
|
1103
|
+
const color = APPLY_STATUS_COLOR[c.status] || "var(--muted)";
|
|
1104
|
+
const chip = document.createElement("span");
|
|
1105
|
+
chip.style.cssText = `border:1px solid ${color};color:${color};border-radius:6px;padding:2px 8px;font-size:11px`;
|
|
1106
|
+
const detail = [c.phase, c.step].filter(Boolean).join(" · ");
|
|
1107
|
+
chip.textContent = `${cname}${detail ? " · " + detail : ""} (${c.status})`;
|
|
1108
|
+
if (c.error) chip.title = c.error;
|
|
1109
|
+
row.appendChild(chip);
|
|
1110
|
+
}
|
|
1111
|
+
wrap.appendChild(row);
|
|
1112
|
+
}
|
|
1113
|
+
return wrap;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
function renderReconcileDetail(r) {
|
|
1117
|
+
const wrap = document.createElement("div");
|
|
1118
|
+
wrap.className = "dial-detail";
|
|
1119
|
+
const rows = Object.entries(r.byComponent).sort((a, b) => b[1] - a[1]);
|
|
1120
|
+
// chant#1168 (#1089): `unobserved` is its own category — a plan that's ALL
|
|
1121
|
+
// unobserved (nothing pending, nothing uncorrelated) must not read as
|
|
1122
|
+
// "no pending changes", which would claim everything's confirmed in sync.
|
|
1123
|
+
const hasUnobserved = !!r.unobserved;
|
|
1124
|
+
// chant#1180 (#1077): `runtime` gets the identical treatment.
|
|
1125
|
+
const hasRuntime = !!r.runtime;
|
|
1126
|
+
if (!rows.length && !r.uncorrelated && !hasUnobserved && !hasRuntime) {
|
|
1127
|
+
wrap.textContent = "no pending changes";
|
|
1128
|
+
return wrap;
|
|
1129
|
+
}
|
|
1130
|
+
for (const [component, count] of rows) {
|
|
1131
|
+
const span = document.createElement("span");
|
|
1132
|
+
span.textContent = `${component}: ${count}`;
|
|
1133
|
+
wrap.appendChild(span);
|
|
1134
|
+
}
|
|
1135
|
+
if (r.uncorrelated) {
|
|
1136
|
+
const span = document.createElement("span");
|
|
1137
|
+
span.textContent = `${r.uncorrelated} uncorrelated`;
|
|
1138
|
+
span.title = "Pending changes that couldn't be mapped to a component by source location.";
|
|
1139
|
+
wrap.appendChild(span);
|
|
1140
|
+
}
|
|
1141
|
+
if (hasUnobserved) {
|
|
1142
|
+
const span = document.createElement("span");
|
|
1143
|
+
span.style.color = "var(--muted)";
|
|
1144
|
+
span.textContent = `${r.unobserved} unobserved`;
|
|
1145
|
+
span.title = "Declared entities chant could not read live state for — not a pending change, not confirmed in sync.";
|
|
1146
|
+
wrap.appendChild(span);
|
|
1147
|
+
}
|
|
1148
|
+
if (hasRuntime) {
|
|
1149
|
+
const span = document.createElement("span");
|
|
1150
|
+
span.style.color = "var(--runtime)";
|
|
1151
|
+
span.textContent = `${r.runtime} runtime`;
|
|
1152
|
+
span.title = "Live, undeclared resources whose owner chain reaches a declared entity — expected runtime, not a pending change.";
|
|
1153
|
+
wrap.appendChild(span);
|
|
1154
|
+
}
|
|
1155
|
+
return wrap;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
async function loadReconcile() {
|
|
1159
|
+
try {
|
|
1160
|
+
const q = lensParams(new URLSearchParams({ env: view.env }));
|
|
1161
|
+
const res = await apiFetch(`/api/reconcile?${q}`);
|
|
1162
|
+
const j = await res.json();
|
|
1163
|
+
// #72: the structured {error, code, remedy} src/server.ts's errorResponse
|
|
1164
|
+
// now sends for every classified failure (tier included — `error` already
|
|
1165
|
+
// carries the full tier-scoped message, replacing the old `tierNote`).
|
|
1166
|
+
if (!res.ok) throw new Error(j.error || res.statusText);
|
|
1167
|
+
reconcileCache = j;
|
|
1168
|
+
renderDial();
|
|
1169
|
+
} catch (e) {
|
|
1170
|
+
nowline("✗ reconcile: " + e.message);
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
// Edge hover-highlight (helps trace one edge through an overlapping bundle — see
|
|
1175
|
+
// index.html's `.pin-edge-line` rules). pinhole already paints a fat transparent
|
|
1176
|
+
// hit-path per edge, so the CSS `:hover` does the visual work; this adds two
|
|
1177
|
+
// things CSS can't: raise the hovered edge to the top of the paint order (SVG has
|
|
1178
|
+
// no z-index) so it's not buried, and light up every edge touching a hovered node
|
|
1179
|
+
// (the `.edge-hi` class) so hovering a card traces all its connections.
|
|
1180
|
+
// Human-readable "why does this edge exist" from the IR edge's `viaAttr`
|
|
1181
|
+
// (src/logical.ts tags them). Shown as a native SVG <title> tooltip on hover;
|
|
1182
|
+
// the value also drives a dashed style for data-dependency links (index.html).
|
|
1183
|
+
const EDGE_REASON = {
|
|
1184
|
+
"security-group ingress": "security-group ingress — traffic is allowed here",
|
|
1185
|
+
"data dependency": "data dependency — reads/uses this store",
|
|
1186
|
+
};
|
|
1187
|
+
function wireEdgeHighlight(svgEl, ir) {
|
|
1188
|
+
const edges = [...svgEl.querySelectorAll("g[data-edge-from]")];
|
|
1189
|
+
if (!edges.length) return;
|
|
1190
|
+
// Reason per edge, keyed by from|to (and its reverse, since pinhole may paint
|
|
1191
|
+
// an edge in either direction relative to the IR).
|
|
1192
|
+
const viaOf = new Map();
|
|
1193
|
+
for (const e of ir.edges || []) {
|
|
1194
|
+
if (!e.viaAttr) continue;
|
|
1195
|
+
viaOf.set(`${e.from}|${e.to}`, e.viaAttr);
|
|
1196
|
+
viaOf.set(`${e.to}|${e.from}`, e.viaAttr);
|
|
1197
|
+
}
|
|
1198
|
+
for (const g of edges) {
|
|
1199
|
+
const from = g.getAttribute("data-edge-from");
|
|
1200
|
+
const to = g.getAttribute("data-edge-to");
|
|
1201
|
+
const via = viaOf.get(`${from}|${to}`);
|
|
1202
|
+
if (via) g.setAttribute("data-edge-via", via); // drives the dashed style
|
|
1203
|
+
const title = document.createElementNS(SVGNS, "title");
|
|
1204
|
+
title.textContent = `${from} → ${to}${via ? "\n" + (EDGE_REASON[via] || via) : ""}`;
|
|
1205
|
+
g.insertBefore(title, g.firstChild);
|
|
1206
|
+
}
|
|
1207
|
+
const raise = (g) => g.parentNode && g.parentNode.appendChild(g);
|
|
1208
|
+
// Delegated: raise whichever edge the pointer is over (its hit-path catches it).
|
|
1209
|
+
svgEl.addEventListener("mouseover", (e) => {
|
|
1210
|
+
const g = e.target.closest && e.target.closest("g[data-edge-from]");
|
|
1211
|
+
if (g) raise(g);
|
|
1212
|
+
});
|
|
1213
|
+
// Hovering a node lights (and raises) every edge on it.
|
|
1214
|
+
for (const card of svgEl.querySelectorAll("[data-node-id]")) {
|
|
1215
|
+
const id = card.getAttribute("data-node-id");
|
|
1216
|
+
const touching = edges.filter((g) => g.getAttribute("data-edge-from") === id || g.getAttribute("data-edge-to") === id);
|
|
1217
|
+
if (!touching.length) continue;
|
|
1218
|
+
card.addEventListener("mouseenter", () => touching.forEach((g) => (g.classList.add("edge-hi"), raise(g))));
|
|
1219
|
+
card.addEventListener("mouseleave", () => touching.forEach((g) => g.classList.remove("edge-hi")));
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// GitLab's tanuki, one filled silhouette (the widely-used single-path mark),
|
|
1224
|
+
// scaled into a small badge. Self-contained (no external URL) so it survives the
|
|
1225
|
+
// static export + the CSP.
|
|
1226
|
+
const GITLAB_TANUKI =
|
|
1227
|
+
"M23.6004 9.5927l-.0337-.0862L20.3.9814a.851.851 0 00-.3362-.405.8748.8748 0 00-.9997.0539.8748.8748 0 00-.29.4399l-2.2055 6.748H7.5375l-2.2055-6.748a.8573.8573 0 00-.29-.4412.8748.8748 0 00-.9997-.0537.8585.8585 0 00-.3362.405L.4332 9.5065l-.0325.0862a6.0657 6.0657 0 002.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.0085 1.0085 0 001.2197 0l1.4995-1.1321 2.462-1.8633 5.006-3.7489.0125-.01a6.0682 6.0682 0 002.0094-7.003z";
|
|
1228
|
+
const SVGNS = "http://www.w3.org/2000/svg";
|
|
1229
|
+
|
|
1230
|
+
// Stamp a GitLab tanuki in the top-right corner of each wave lane — a visual cue
|
|
1231
|
+
// that the waves ARE the GitLab CI pipeline's stages (job `stage` = `wave-N`).
|
|
1232
|
+
// Post-processes the inlined SVG: each wave box is a `<rect>` immediately
|
|
1233
|
+
// followed by its `<text>wave-N</text>` title, so the rect gives the corner.
|
|
1234
|
+
function addGitlabWaveBadges(svgEl) {
|
|
1235
|
+
const SIZE = 17;
|
|
1236
|
+
for (const text of svgEl.querySelectorAll("text")) {
|
|
1237
|
+
if (!/^wave-/i.test((text.textContent || "").trim())) continue;
|
|
1238
|
+
const rect = text.previousElementSibling;
|
|
1239
|
+
if (!rect || rect.tagName.toLowerCase() !== "rect") continue;
|
|
1240
|
+
const rx = parseFloat(rect.getAttribute("x"));
|
|
1241
|
+
const ry = parseFloat(rect.getAttribute("y"));
|
|
1242
|
+
const rw = parseFloat(rect.getAttribute("width"));
|
|
1243
|
+
const g = document.createElementNS(SVGNS, "g");
|
|
1244
|
+
g.setAttribute("class", "gitlab-wave-badge");
|
|
1245
|
+
g.setAttribute("transform", `translate(${rx + rw - SIZE - 12}, ${ry + 10}) scale(${SIZE / 24})`);
|
|
1246
|
+
const path = document.createElementNS(SVGNS, "path");
|
|
1247
|
+
path.setAttribute("d", GITLAB_TANUKI);
|
|
1248
|
+
path.setAttribute("fill", "#FC6D26"); // GitLab brand orange
|
|
1249
|
+
const title = document.createElementNS(SVGNS, "title");
|
|
1250
|
+
title.textContent = "This wave is a GitLab CI stage (chant → GitLab pipeline)";
|
|
1251
|
+
g.append(title, path);
|
|
1252
|
+
rect.parentNode.insertBefore(g, text);
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
// Paint a fetched graph: the SVG, the meta line (with a drift summary in overlay
|
|
1257
|
+
// mode), the legend, and click-inspect wiring. Shared by load() and refresh().
|
|
1258
|
+
function render(ir, svg, m) {
|
|
1259
|
+
// #131: set before anything can early-return, so a level that stopped
|
|
1260
|
+
// degrading stops explaining itself on the very next render.
|
|
1261
|
+
lastNote = m.note || null;
|
|
1262
|
+
const overlay = m.mode === "overlay";
|
|
1263
|
+
// Logical/architecture lens (#63): its own mode, but when an env is picked the
|
|
1264
|
+
// projected nodes still carry the drift `_status`, so it reads as a drift view
|
|
1265
|
+
// (same summary + legend as the entity overlay).
|
|
1266
|
+
const logical = m.mode === "logical";
|
|
1267
|
+
const drift = overlay || (logical && !!m.env);
|
|
1268
|
+
// M1.1 (#57): the component DAG's live per-component AWS status — a
|
|
1269
|
+
// different join than the entity overlay (see server's /api/graph), so it
|
|
1270
|
+
// gets its own summary + legend rather than reusing `overlay`'s.
|
|
1271
|
+
const componentStatus = m.mode === "component-status";
|
|
1272
|
+
let tail = ` · ${ir.edges.length} edges`;
|
|
1273
|
+
if (drift) {
|
|
1274
|
+
// Summarise drift so "everything's blue" reads as "N pending". `neutral`
|
|
1275
|
+
// (chant#1168, #1089) is its own bucket — a declared node chant couldn't
|
|
1276
|
+
// read live state for is neither managed, foreign, nor (confirmed)
|
|
1277
|
+
// pending, and folding it into any of those would misreport a hole in
|
|
1278
|
+
// the observation as a verdict. Additive: a chant predating #1168 never
|
|
1279
|
+
// tags a node `neutral` here, so `c.neutral` stays 0 against an older chant.
|
|
1280
|
+
// `runtime` (chant#1180, #1077) is its own bucket too — a live,
|
|
1281
|
+
// undeclared node whose owner chain reaches a declared entity is neither
|
|
1282
|
+
// managed, foreign, nor pending; folding it into any of those would
|
|
1283
|
+
// misreport expected runtime as drift or as a proposed create. Additive
|
|
1284
|
+
// the same way — `c.runtime` stays 0 against a chant predating #1180.
|
|
1285
|
+
const c = { good: 0, warn: 0, accent: 0, neutral: 0, runtime: 0 };
|
|
1286
|
+
for (const n of ir.nodes) {
|
|
1287
|
+
const s = n.attrs && n.attrs._status;
|
|
1288
|
+
if (s in c) c[s]++;
|
|
1289
|
+
}
|
|
1290
|
+
tail = ` · ${c.good} managed · ${c.warn} foreign · ${c.accent} pending`;
|
|
1291
|
+
if (c.neutral) tail += ` · ${c.neutral} unobserved`;
|
|
1292
|
+
if (c.runtime) tail += ` · ${c.runtime} runtime`;
|
|
1293
|
+
// Nothing observed live in this env — explain the all-blue rather than let it
|
|
1294
|
+
// read as a bug (#32).
|
|
1295
|
+
if (c.good === 0 && c.warn === 0 && c.accent > 0) tail += ` — nothing deployed in ${m.env} yet`;
|
|
1296
|
+
} else if (componentStatus) {
|
|
1297
|
+
// M2 (#54): 4 buckets now (good/accent/warn/neutral) — see
|
|
1298
|
+
// COMPONENT_STATUS_LABEL and src/component-status.ts's palette doc comment.
|
|
1299
|
+
const c = { good: 0, accent: 0, warn: 0, neutral: 0 };
|
|
1300
|
+
for (const n of ir.nodes) {
|
|
1301
|
+
const s = n.attrs && n.attrs._status;
|
|
1302
|
+
if (s in c) c[s]++;
|
|
1303
|
+
}
|
|
1304
|
+
tail = ` · ${c.good} healthy · ${c.accent} in progress · ${c.warn} rollback/failed · ${c.neutral} not deployed`;
|
|
1305
|
+
}
|
|
1306
|
+
// Multi-estate (#31): note the composed project count; the graph draws one box per project.
|
|
1307
|
+
const scope = m.estate ? `estate of ${m.estate} projects` : m.projectDir;
|
|
1308
|
+
// The deploy axes (#59 unify, M2 #54 lenses) — tier/target, kept in sync with
|
|
1309
|
+
// what this response actually observed (falls back to the launch-time value
|
|
1310
|
+
// from /api/project when a route doesn't echo them, e.g. /api/overlay).
|
|
1311
|
+
if (m.tier !== undefined) axes.tier = m.tier;
|
|
1312
|
+
if (m.target !== undefined) axes.target = m.target;
|
|
1313
|
+
const axesTail = `${axes.tier ? " · tier " + axes.tier : ""}${axes.target ? " · target " + axes.target : ""}`;
|
|
1314
|
+
document.getElementById("meta").textContent =
|
|
1315
|
+
`${scope}${m.env ? " · env " + m.env : ""}${axesTail}${overlay ? " · overlay" : ""}${logical ? " · logical" : ""}${m.components ? " · components" : ""}${componentStatus ? " · live status" : ""} · ${ir.nodes.length} nodes${tail}`;
|
|
1316
|
+
document.getElementById("legend").style.display = drift ? "flex" : "none";
|
|
1317
|
+
document.getElementById("component-legend").style.display = componentStatus ? "flex" : "none";
|
|
1318
|
+
// Keep the persistent state strip in sync — zoom/env/tier/radial are picked
|
|
1319
|
+
// via the ⌘K palette now (#73), but stay visible here regardless (radial
|
|
1320
|
+
// only applies to the entity zooms; renderStatusbar() drops it for
|
|
1321
|
+
// components/logical, both of which lay themselves out: waves / nested
|
|
1322
|
+
// architecture boxes).
|
|
1323
|
+
renderStatusbar();
|
|
1324
|
+
const g = document.getElementById("graph");
|
|
1325
|
+
// Ghostty theming (#62): strip pinhole's baked-in `:root{--pin-*}` defaults from the
|
|
1326
|
+
// inlined SVG so its var(--pin-*) fills resolve from behold's live documentElement tokens
|
|
1327
|
+
// (theme.js applyTheme), not the frozen dark palette. Without this, every theme renders
|
|
1328
|
+
// green — the SVG's own :root shadows behold's override within the graph subtree.
|
|
1329
|
+
g.innerHTML = svg.replace(/:root\s*\{[^{}]*--pin-[^{}]*\}/g, "");
|
|
1330
|
+
recolorNodesByCategory(ir); // #62: category-hued fills from the theme's full palette
|
|
1331
|
+
const svgEl = g.querySelector("svg");
|
|
1332
|
+
if (svgEl) {
|
|
1333
|
+
// Drop pinhole's fixed pixel size so the viewBox drives sizing; behold then
|
|
1334
|
+
// pans/zooms by mutating the viewBox (setupGraphViewBox + the wheel/drag
|
|
1335
|
+
// handlers). Starts fit-to-pane, then pinch / ⌘+scroll zooms, drag pans.
|
|
1336
|
+
svgEl.removeAttribute("width");
|
|
1337
|
+
svgEl.removeAttribute("height");
|
|
1338
|
+
svgEl.setAttribute("preserveAspectRatio", "xMidYMid meet");
|
|
1339
|
+
setupGraphViewBox(svgEl);
|
|
1340
|
+
wireEdgeHighlight(svgEl, ir);
|
|
1341
|
+
// Mark each wave lane as a GitLab CI stage when the CI projection is loaded
|
|
1342
|
+
// (components view only) — waves ARE the pipeline's stages (`chant build
|
|
1343
|
+
// --components --generate gitlab`, see loadCi / #58).
|
|
1344
|
+
if (view.components && ciByComponent.size) addGitlabWaveBadges(svgEl);
|
|
1345
|
+
}
|
|
1346
|
+
ensureZoomControls(g);
|
|
1347
|
+
ensureBackToInfra(g);
|
|
1348
|
+
wire(ir);
|
|
1349
|
+
if (view.radial && !view.components && !view.logical) addRadialLabels(ir);
|
|
1350
|
+
renderDial();
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
// The radial layout clusters each component into an angular wedge, but nothing
|
|
1354
|
+
// says which wedge is which. Label each: read every node's position from its
|
|
1355
|
+
// `data-node-id` transform (SVG/viewBox coords, so labels pan/zoom with the
|
|
1356
|
+
// graph), group by component, and drop the component name just outside its
|
|
1357
|
+
// wedge at its mean angle. Cue only — pointer-events off so clicks pass through.
|
|
1358
|
+
function radialGroupOf(node) {
|
|
1359
|
+
const p = (node.sourceLoc?.file || "").split("/");
|
|
1360
|
+
if (p[0] === "src" && p[1] === "examples") return "examples";
|
|
1361
|
+
if (p[0] === "src" && p.length >= 3) return p[1];
|
|
1362
|
+
return node.lexicon || "other";
|
|
1363
|
+
}
|
|
1364
|
+
function addRadialLabels(ir) {
|
|
1365
|
+
const svg = document.querySelector("#graph svg");
|
|
1366
|
+
if (!svg) return;
|
|
1367
|
+
const groupOf = new Map(ir.nodes.map((n) => [n.id, radialGroupOf(n)]));
|
|
1368
|
+
const pos = new Map();
|
|
1369
|
+
for (const g of svg.querySelectorAll("[data-node-id]")) {
|
|
1370
|
+
const m = (g.getAttribute("transform") || "").match(/translate\(\s*([-\d.]+)[\s,]+([-\d.]+)/);
|
|
1371
|
+
if (m) pos.set(g.getAttribute("data-node-id"), { x: +m[1], y: +m[2] });
|
|
1372
|
+
}
|
|
1373
|
+
if (pos.size < 2) return;
|
|
1374
|
+
let cx = 0, cy = 0;
|
|
1375
|
+
pos.forEach((p) => { cx += p.x; cy += p.y; });
|
|
1376
|
+
cx /= pos.size; cy /= pos.size;
|
|
1377
|
+
const groups = new Map(); // key -> {sx,sy,n,maxR}
|
|
1378
|
+
pos.forEach((p, id) => {
|
|
1379
|
+
const k = groupOf.get(id);
|
|
1380
|
+
if (!k) return;
|
|
1381
|
+
const dx = p.x - cx, dy = p.y - cy;
|
|
1382
|
+
const g = groups.get(k) || { sx: 0, sy: 0, n: 0, maxR: 0 };
|
|
1383
|
+
g.sx += dx; g.sy += dy; g.n++; g.maxR = Math.max(g.maxR, Math.hypot(dx, dy));
|
|
1384
|
+
groups.set(k, g);
|
|
1385
|
+
});
|
|
1386
|
+
const NS = "http://www.w3.org/2000/svg";
|
|
1387
|
+
const layer = document.createElementNS(NS, "g");
|
|
1388
|
+
layer.setAttribute("id", "radial-labels");
|
|
1389
|
+
layer.setAttribute("pointer-events", "none");
|
|
1390
|
+
const vb = (svg.getAttribute("viewBox") || "0 0 1000 1000").split(/\s+/).map(Number);
|
|
1391
|
+
const fontSize = Math.max(18, Math.round(vb[2] / 60));
|
|
1392
|
+
groups.forEach((g, key) => {
|
|
1393
|
+
const ang = Math.atan2(g.sy, g.sx);
|
|
1394
|
+
const r = g.maxR + fontSize * 2.2;
|
|
1395
|
+
const x = cx + r * Math.cos(ang), y = cy + r * Math.sin(ang);
|
|
1396
|
+
const t = document.createElementNS(NS, "text");
|
|
1397
|
+
t.setAttribute("x", x);
|
|
1398
|
+
t.setAttribute("y", y);
|
|
1399
|
+
t.setAttribute("text-anchor", Math.abs(Math.cos(ang)) < 0.4 ? "middle" : Math.cos(ang) < 0 ? "end" : "start");
|
|
1400
|
+
t.setAttribute("dominant-baseline", "middle");
|
|
1401
|
+
t.setAttribute("fill", "var(--fg)");
|
|
1402
|
+
t.setAttribute("opacity", "0.72");
|
|
1403
|
+
t.setAttribute("font-size", String(fontSize));
|
|
1404
|
+
t.setAttribute("font-weight", "700");
|
|
1405
|
+
t.textContent = key;
|
|
1406
|
+
layer.appendChild(t);
|
|
1407
|
+
});
|
|
1408
|
+
svg.appendChild(layer);
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
// When observe (or the zoom picker) drops you into the components view, float a
|
|
1412
|
+
// "zoom in ⤢" link on the graph itself — the exit where the eye already is, not
|
|
1413
|
+
// buried in the toolbar. Zooms one step finer (components → resources). Shown
|
|
1414
|
+
// only in the components view.
|
|
1415
|
+
function ensureBackToInfra(host) {
|
|
1416
|
+
let link = document.getElementById("back-to-infra");
|
|
1417
|
+
if (!link) {
|
|
1418
|
+
link = document.createElement("button");
|
|
1419
|
+
link.id = "back-to-infra";
|
|
1420
|
+
link.textContent = "zoom in ⤢ resources";
|
|
1421
|
+
link.title = "Zoom finer — from components to the resource graph.";
|
|
1422
|
+
link.addEventListener("click", (e) => {
|
|
1423
|
+
e.stopPropagation();
|
|
1424
|
+
applyZoom("resources");
|
|
1425
|
+
load();
|
|
1426
|
+
});
|
|
1427
|
+
host.appendChild(link);
|
|
1428
|
+
}
|
|
1429
|
+
link.style.display = view.components ? "" : "none";
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
// --- Graph zoom/pan, driven by the SVG viewBox (works for a 7-node DAG or a
|
|
1433
|
+
// 180-node estate alike: fit-to-pane by default, then zoom IN to read). Pinch
|
|
1434
|
+
// or ⌘/Ctrl+scroll zooms at the cursor; drag pans; "⤢ fit" resets. ---
|
|
1435
|
+
let vb = null; // current viewBox [x,y,w,h]
|
|
1436
|
+
let vbInit = null; // the graph's natural viewBox (fit)
|
|
1437
|
+
let panMoved = false; // true once a drag moved — suppresses the node click on release
|
|
1438
|
+
let zoomWired = false;
|
|
1439
|
+
|
|
1440
|
+
function setupGraphViewBox(svg) {
|
|
1441
|
+
const a = (svg.getAttribute("viewBox") || "").split(/\s+/).map(Number);
|
|
1442
|
+
if (a.length === 4 && a.every((n) => !Number.isNaN(n))) {
|
|
1443
|
+
vbInit = a.slice();
|
|
1444
|
+
vb = a.slice();
|
|
1445
|
+
} else {
|
|
1446
|
+
vbInit = vb = null;
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
function currentSvg() {
|
|
1450
|
+
return document.querySelector("#graph svg");
|
|
1451
|
+
}
|
|
1452
|
+
function applyVB() {
|
|
1453
|
+
const s = currentSvg();
|
|
1454
|
+
if (s && vb) s.setAttribute("viewBox", vb.join(" "));
|
|
1455
|
+
}
|
|
1456
|
+
function fitGraph() {
|
|
1457
|
+
if (vbInit) {
|
|
1458
|
+
vb = vbInit.slice();
|
|
1459
|
+
applyVB();
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
function ensureZoomControls(host) {
|
|
1463
|
+
let btn = document.getElementById("zoom-toggle");
|
|
1464
|
+
if (!btn || btn.parentElement !== host) {
|
|
1465
|
+
btn = document.createElement("button");
|
|
1466
|
+
btn.id = "zoom-toggle";
|
|
1467
|
+
btn.textContent = "⤢ fit";
|
|
1468
|
+
btn.title = "Reset to fit. Pinch or ⌘/Ctrl+scroll to zoom at the cursor; drag to pan.";
|
|
1469
|
+
btn.addEventListener("click", (e) => {
|
|
1470
|
+
e.stopPropagation();
|
|
1471
|
+
fitGraph();
|
|
1472
|
+
});
|
|
1473
|
+
host.appendChild(btn);
|
|
1474
|
+
}
|
|
1475
|
+
if (zoomWired) return;
|
|
1476
|
+
zoomWired = true;
|
|
1477
|
+
host.addEventListener(
|
|
1478
|
+
"wheel",
|
|
1479
|
+
(e) => {
|
|
1480
|
+
// Trackpad pinch fires wheel+ctrlKey; ⌘/Ctrl+scroll is the explicit gesture.
|
|
1481
|
+
// Plain scroll is left alone (nothing to scroll when fit).
|
|
1482
|
+
if (!vb || !(e.ctrlKey || e.metaKey)) return;
|
|
1483
|
+
e.preventDefault();
|
|
1484
|
+
const s = currentSvg();
|
|
1485
|
+
if (!s) return;
|
|
1486
|
+
const r = s.getBoundingClientRect();
|
|
1487
|
+
const cx = vb[0] + ((e.clientX - r.left) / r.width) * vb[2];
|
|
1488
|
+
const cy = vb[1] + ((e.clientY - r.top) / r.height) * vb[3];
|
|
1489
|
+
const f = Math.exp(e.deltaY * 0.0025); // scroll up → f<1 → zoom in
|
|
1490
|
+
const minW = vbInit[2] / 60;
|
|
1491
|
+
const maxW = vbInit[2] * 3;
|
|
1492
|
+
const nw = Math.min(maxW, Math.max(minW, vb[2] * f));
|
|
1493
|
+
const nh = nw * (vb[3] / vb[2]);
|
|
1494
|
+
vb[0] = cx - ((cx - vb[0]) * nw) / vb[2];
|
|
1495
|
+
vb[1] = cy - ((cy - vb[1]) * nh) / vb[3];
|
|
1496
|
+
vb[2] = nw;
|
|
1497
|
+
vb[3] = nh;
|
|
1498
|
+
applyVB();
|
|
1499
|
+
},
|
|
1500
|
+
{ passive: false },
|
|
1501
|
+
);
|
|
1502
|
+
let drag = false;
|
|
1503
|
+
let px = 0;
|
|
1504
|
+
let py = 0;
|
|
1505
|
+
host.addEventListener("mousedown", (e) => {
|
|
1506
|
+
if (!vb) return;
|
|
1507
|
+
drag = true;
|
|
1508
|
+
panMoved = false;
|
|
1509
|
+
px = e.clientX;
|
|
1510
|
+
py = e.clientY;
|
|
1511
|
+
});
|
|
1512
|
+
window.addEventListener("mousemove", (e) => {
|
|
1513
|
+
if (!drag || !vb) return;
|
|
1514
|
+
const s = currentSvg();
|
|
1515
|
+
if (!s) return;
|
|
1516
|
+
const r = s.getBoundingClientRect();
|
|
1517
|
+
const dx = e.clientX - px;
|
|
1518
|
+
const dy = e.clientY - py;
|
|
1519
|
+
if (Math.abs(dx) + Math.abs(dy) > 3) {
|
|
1520
|
+
panMoved = true;
|
|
1521
|
+
s.classList.add("grabbing");
|
|
1522
|
+
}
|
|
1523
|
+
vb[0] -= (dx / r.width) * vb[2];
|
|
1524
|
+
vb[1] -= (dy / r.height) * vb[3];
|
|
1525
|
+
px = e.clientX;
|
|
1526
|
+
py = e.clientY;
|
|
1527
|
+
applyVB();
|
|
1528
|
+
});
|
|
1529
|
+
window.addEventListener("mouseup", () => {
|
|
1530
|
+
drag = false;
|
|
1531
|
+
const s = currentSvg();
|
|
1532
|
+
if (s) s.classList.remove("grabbing");
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
// Precondition-failure codes (#72) → a short, human title for the entry/error
|
|
1537
|
+
// screen below. Mirrors every `code` a read route's structured error can
|
|
1538
|
+
// carry (src/server.ts RouteErrorCode: chant.ts's lint/not-installed/eval,
|
|
1539
|
+
// plus "tier" — a picked tier that needs creds this host lacks, M2 #54).
|
|
1540
|
+
const PRECONDITION_TITLE = {
|
|
1541
|
+
lint: "This project doesn't pass chant lint",
|
|
1542
|
+
"not-installed": "This project isn't installed",
|
|
1543
|
+
tier: "This tier needs credentials",
|
|
1544
|
+
eval: "chant couldn't evaluate this project",
|
|
1545
|
+
};
|
|
1546
|
+
|
|
1547
|
+
// A precondition failure — the lint gate, a not-installed/no-typegen project,
|
|
1548
|
+
// or a tier that needs credentials (#72) — gets a readable card with chant's
|
|
1549
|
+
// own message and a suggested remedy, not a blank canvas or a raw stack
|
|
1550
|
+
// trace. Generalizes what used to be a tier-only `tierNote` (the server now
|
|
1551
|
+
// sends the same structured {error, code, remedy} for every classified
|
|
1552
|
+
// failure — src/server.ts errorResponse). Text content only (never
|
|
1553
|
+
// innerHTML) — `error` embeds chant's own stderr verbatim.
|
|
1554
|
+
function renderPreconditionError(body) {
|
|
1555
|
+
const host = document.getElementById("graph");
|
|
1556
|
+
host.innerHTML = "";
|
|
1557
|
+
const card = document.createElement("div");
|
|
1558
|
+
card.className = "precondition-error";
|
|
1559
|
+
const title = document.createElement("div");
|
|
1560
|
+
title.className = "precondition-error-title";
|
|
1561
|
+
title.textContent = PRECONDITION_TITLE[body.code] || "chant couldn't evaluate this project";
|
|
1562
|
+
card.appendChild(title);
|
|
1563
|
+
if (body.error) {
|
|
1564
|
+
const message = document.createElement("div");
|
|
1565
|
+
message.className = "precondition-error-message";
|
|
1566
|
+
message.textContent = body.error;
|
|
1567
|
+
card.appendChild(message);
|
|
1568
|
+
}
|
|
1569
|
+
if (body.remedy) {
|
|
1570
|
+
const remedy = document.createElement("div");
|
|
1571
|
+
remedy.className = "precondition-error-remedy";
|
|
1572
|
+
remedy.textContent = body.remedy;
|
|
1573
|
+
card.appendChild(remedy);
|
|
1574
|
+
}
|
|
1575
|
+
host.appendChild(card);
|
|
1576
|
+
document.getElementById("legend").style.display = "none";
|
|
1577
|
+
document.getElementById("component-legend").style.display = "none";
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
// Fetch the current view (source graph, or the picked env's live overlay).
|
|
1581
|
+
async function load(opts = {}) {
|
|
1582
|
+
const meta = document.getElementById("meta");
|
|
1583
|
+
// A background settle re-pull (post-apply) shouldn't flash the meta/overlay.
|
|
1584
|
+
if (!opts.quiet) {
|
|
1585
|
+
meta.textContent = view.env ? `loading overlay for ${view.env}…` : "loading…";
|
|
1586
|
+
// A view change shells chant live (seconds on a slow box) — cover the UI with
|
|
1587
|
+
// a blocking overlay so clicks can't queue a second pull mid-flight.
|
|
1588
|
+
showLoading(`loading ${zoomValue()}${view.env ? " · " + view.env : ""}…`);
|
|
1589
|
+
}
|
|
1590
|
+
try {
|
|
1591
|
+
const q = lensParams(new URLSearchParams({ detail: String(view.detail) }));
|
|
1592
|
+
let endpoint = "/api/graph";
|
|
1593
|
+
if (view.components) {
|
|
1594
|
+
// M1.1 (#57): the component DAG stays on /api/graph even with an env
|
|
1595
|
+
// picked — the server joins live per-component status onto it there
|
|
1596
|
+
// (component name -> CFN stack), instead of routing to /api/overlay's
|
|
1597
|
+
// cross-substrate entity overlay, which components never use.
|
|
1598
|
+
q.set("components", "1");
|
|
1599
|
+
if (view.env) q.set("env", view.env);
|
|
1600
|
+
} else if (view.logical) {
|
|
1601
|
+
// Logical/architecture lens (#63): the server re-projects at detail 3
|
|
1602
|
+
// regardless of the dial, so detail/radial don't apply. With an env it
|
|
1603
|
+
// projects the live overlay (drift colours preserved), else the source.
|
|
1604
|
+
q.set("logical", "1");
|
|
1605
|
+
if (view.env) {
|
|
1606
|
+
endpoint = "/api/overlay";
|
|
1607
|
+
q.set("env", view.env);
|
|
1608
|
+
}
|
|
1609
|
+
} else if (view.env) {
|
|
1610
|
+
endpoint = "/api/overlay";
|
|
1611
|
+
q.set("env", view.env);
|
|
1612
|
+
// The runtime tier (#86) descends below the declaration boundary. It is
|
|
1613
|
+
// live-only by nature — owner-referenced children exist in the cluster,
|
|
1614
|
+
// never in your source — so it rides the overlay and means nothing
|
|
1615
|
+
// without an env.
|
|
1616
|
+
if (view.runtime) q.set("runtime", "1");
|
|
1617
|
+
}
|
|
1618
|
+
// Radial layout (entity view only) — curl the wide DAG onto concentric rings.
|
|
1619
|
+
if (view.radial && !view.components && !view.logical) q.set("radial", "1");
|
|
1620
|
+
// The CI + resources facets are component-DAG-mode-only details. Load
|
|
1621
|
+
// both whenever components mode is on, env picked or not — #59 unifies
|
|
1622
|
+
// the CI facet (#58), the live-status join (#57), and resources (#59) so
|
|
1623
|
+
// a component node's inspect panel shows all of them at once, not just one.
|
|
1624
|
+
if (view.components) {
|
|
1625
|
+
await Promise.all([loadCi(), loadResources()]);
|
|
1626
|
+
} else {
|
|
1627
|
+
ciByComponent = new Map();
|
|
1628
|
+
resourcesByComponent = {};
|
|
1629
|
+
}
|
|
1630
|
+
const res = await apiFetch(`${endpoint}?${q}`);
|
|
1631
|
+
const body = await res.json();
|
|
1632
|
+
if (!res.ok) {
|
|
1633
|
+
// #72: a classified precondition failure (lint gate, not installed, a
|
|
1634
|
+
// tier that needs credentials) gets the calmer entry/error card instead
|
|
1635
|
+
// of the generic red error box — see renderPreconditionError().
|
|
1636
|
+
if (body.code) {
|
|
1637
|
+
renderPreconditionError(body);
|
|
1638
|
+
meta.textContent = body.code === "tier" ? `tier ${view.tier} unavailable here` : "error";
|
|
1639
|
+
renderDial();
|
|
1640
|
+
return;
|
|
1641
|
+
}
|
|
1642
|
+
throw new Error(body.error || res.statusText);
|
|
1643
|
+
}
|
|
1644
|
+
render(body.ir, body.svg, body.meta);
|
|
1645
|
+
} catch (err) {
|
|
1646
|
+
// A background settle poll must not blow away a good graph on a transient error.
|
|
1647
|
+
if (!opts.quiet) {
|
|
1648
|
+
document.getElementById("graph").innerHTML = `<div class="err">graph failed: ${err.message}</div>`;
|
|
1649
|
+
meta.textContent = "error";
|
|
1650
|
+
}
|
|
1651
|
+
} finally {
|
|
1652
|
+
if (!opts.quiet) hideLoading();
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
// Refresh (#24): re-check live drift now and capture a lanes frame, in one
|
|
1657
|
+
// round-trip. Renders the returned graph directly (no second pull); the server's
|
|
1658
|
+
// `frames` event updates the lanes view.
|
|
1659
|
+
async function refresh() {
|
|
1660
|
+
const meta = document.getElementById("meta");
|
|
1661
|
+
const prev = meta.textContent;
|
|
1662
|
+
meta.textContent = "refreshing…";
|
|
1663
|
+
showLoading("refreshing live state…");
|
|
1664
|
+
try {
|
|
1665
|
+
// Carry the active zoom's runtime flag so a refresh from the resources
|
|
1666
|
+
// tier doesn't come back wearing the runtime tier's Pods (#144).
|
|
1667
|
+
const runtime = zoomValue() === "runtime" ? "&runtime=1" : "";
|
|
1668
|
+
const q = view.env ? `?env=${encodeURIComponent(view.env)}${runtime}` : "";
|
|
1669
|
+
const res = await fetch(`/api/refresh${q}`, { method: "POST" });
|
|
1670
|
+
if (!res.ok) throw new Error((await res.json()).error || res.statusText);
|
|
1671
|
+
const { ir, svg, meta: m, captured } = await res.json();
|
|
1672
|
+
render(ir, svg, m);
|
|
1673
|
+
nowline(captured ? "↻ refreshed — new lanes frame" : "↻ refreshed — no change");
|
|
1674
|
+
} catch (err) {
|
|
1675
|
+
meta.textContent = prev;
|
|
1676
|
+
nowline("✗ refresh: " + err.message);
|
|
1677
|
+
} finally {
|
|
1678
|
+
hideLoading();
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
// The env/tier/target/stack lenses (M2 #54; stack #76) — used to be header
|
|
1683
|
+
// <select>s; #73 moved picking them into the ⌘K palette (paletteCommands()
|
|
1684
|
+
// reads these lists), so they're module-level now instead of local to the
|
|
1685
|
+
// picker-building closure that used to render them. Populated once in
|
|
1686
|
+
// initPickers().
|
|
1687
|
+
let environments = [];
|
|
1688
|
+
let tiers = [];
|
|
1689
|
+
let targets = [];
|
|
1690
|
+
let stacks = [];
|
|
1691
|
+
|
|
1692
|
+
// Fetch the project once, seed view/axes + the lens lists above, then do the
|
|
1693
|
+
// first load. previously also built the header's env/zoom/radial/tier/target
|
|
1694
|
+
// controls; those are now ⌘K palette commands (#73) — renderStatusbar() is
|
|
1695
|
+
// what keeps their CURRENT value visible without opening the palette.
|
|
1696
|
+
async function initPickers() {
|
|
1697
|
+
const info = await apiFetch("/api/project")
|
|
1698
|
+
.then((r) => r.json())
|
|
1699
|
+
.catch(() => ({ environments: [], currentEnv: null }));
|
|
1700
|
+
view.env = info.currentEnv || null;
|
|
1701
|
+
view.tier = info.tier || null;
|
|
1702
|
+
view.target = (info.targets && info.targets[0] && info.targets[0].endpoint) || null;
|
|
1703
|
+
// The stack picker's default (#76, design: "default = the FIRST stack when
|
|
1704
|
+
// the project declares stacks[] and none is selected") — mirrors `target`
|
|
1705
|
+
// above, which defaults to its own first option the same way. `info.stacks`
|
|
1706
|
+
// is only present at all when `chant.config.ts` declares `stacks[]` (see
|
|
1707
|
+
// server.ts's `/api/project`, gated on `info.stacks?.length`), so a project
|
|
1708
|
+
// with none leaves `view.stack` (and the picker + status tag) null.
|
|
1709
|
+
stacks = info.stacks || [];
|
|
1710
|
+
view.stack = stacks[0] || null;
|
|
1711
|
+
axes = { tier: info.tier || null, target: info.target || null };
|
|
1712
|
+
environments = info.environments || [];
|
|
1713
|
+
tiers = info.tiers || [];
|
|
1714
|
+
targets = info.targets || [];
|
|
1715
|
+
renderStatusbar();
|
|
1716
|
+
load();
|
|
1717
|
+
}
|
|
1718
|
+
initPickers();
|
|
1719
|
+
|
|
1720
|
+
// Live updates (#3): re-pull when the server signals the served source changed.
|
|
1721
|
+
// EventSource reconnects on its own if the server restarts.
|
|
1722
|
+
// No backend in a static export → no live event stream; a no-op keeps the
|
|
1723
|
+
// `events.addEventListener(...)` wiring below harmless.
|
|
1724
|
+
const events = staticMode ? { addEventListener() {} } : new EventSource("/api/events");
|
|
1725
|
+
// Post-op settle re-pull: an apply's CLI can exit while the last stacks are still
|
|
1726
|
+
// flipping to *_COMPLETE, so the immediate reload catches a few components mid-
|
|
1727
|
+
// deploy ("all done, 3 still pending"). Quietly re-pull a couple more times so
|
|
1728
|
+
// the graph lands on the final colours without a manual Re-check live.
|
|
1729
|
+
let settleTimers = [];
|
|
1730
|
+
function scheduleSettle() {
|
|
1731
|
+
settleTimers.forEach(clearTimeout);
|
|
1732
|
+
settleTimers = [3000, 8000, 15000].map((ms) => setTimeout(() => load({ quiet: true }), ms));
|
|
1733
|
+
}
|
|
1734
|
+
events.addEventListener("changed", () => {
|
|
1735
|
+
bulkDiffCache = null; // an op ran → per-node live state may have changed
|
|
1736
|
+
load();
|
|
1737
|
+
loadSubstrates(); // a bring-up (or any op) finished → re-detect readiness
|
|
1738
|
+
scheduleSettle();
|
|
1739
|
+
});
|
|
1740
|
+
|
|
1741
|
+
// Substrate readiness strip (M5, #54): is each substrate the project needs
|
|
1742
|
+
// actually up? Poll /api/substrates, render status pills — pure state (#73:
|
|
1743
|
+
// the "Bring up" / "Reset" affordances that used to sit inside each pill moved
|
|
1744
|
+
// into the ⌘K palette; see paletteCommands()'s use of lastSubstrates below).
|
|
1745
|
+
async function loadSubstrates() {
|
|
1746
|
+
try {
|
|
1747
|
+
const { substrates } = await apiFetch("/api/substrates").then((r) => r.json());
|
|
1748
|
+
renderSubstrates(substrates || []);
|
|
1749
|
+
} catch {
|
|
1750
|
+
/* transient — leave whatever's shown */
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
// The last substrates list — read by paletteCommands() to build "Bring up
|
|
1755
|
+
// <label>" / "Reset local emulator" entries with the same gating renderSubstrates()
|
|
1756
|
+
// used to apply to its now-removed inline buttons.
|
|
1757
|
+
let lastSubstrates = [];
|
|
1758
|
+
|
|
1759
|
+
function renderSubstrates(subs) {
|
|
1760
|
+
lastSubstrates = subs;
|
|
1761
|
+
const host = document.getElementById("substrates");
|
|
1762
|
+
if (!subs.length) {
|
|
1763
|
+
host.style.display = "none";
|
|
1764
|
+
return;
|
|
1765
|
+
}
|
|
1766
|
+
host.style.display = "flex";
|
|
1767
|
+
host.innerHTML = "";
|
|
1768
|
+
const lbl = document.createElement("span");
|
|
1769
|
+
lbl.className = "label";
|
|
1770
|
+
lbl.textContent = "substrates:";
|
|
1771
|
+
host.appendChild(lbl);
|
|
1772
|
+
for (const s of subs) {
|
|
1773
|
+
const pill = document.createElement("span");
|
|
1774
|
+
pill.className = `sub ${s.status}`;
|
|
1775
|
+
pill.title = s.bringUp || s.name === "floci" ? `${s.detail} (⌘K for actions)` : s.detail;
|
|
1776
|
+
const dot = document.createElement("span");
|
|
1777
|
+
dot.className = "dot";
|
|
1778
|
+
pill.appendChild(dot);
|
|
1779
|
+
const name = document.createElement("span");
|
|
1780
|
+
name.textContent = s.label;
|
|
1781
|
+
pill.appendChild(name);
|
|
1782
|
+
host.appendChild(pill);
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
function resetLocal() {
|
|
1787
|
+
if (
|
|
1788
|
+
!window.confirm(
|
|
1789
|
+
"Reset the local emulator?\nRuns local-down + local-up: wipes every stack, reboots the emulator, and redeploys all components clean.\nThis is the recovery for rolled-back stacks (Floci #16) — do NOT apply afterward (that re-apply is what collides).\n\nOutput streams in the log below; takes a few minutes.",
|
|
1790
|
+
)
|
|
1791
|
+
)
|
|
1792
|
+
return;
|
|
1793
|
+
nowline("▶ resetting local emulator (local-down + local-up, redeploys clean) …");
|
|
1794
|
+
fetch("/api/local/reset", { method: "POST" })
|
|
1795
|
+
.then((r) => r.json())
|
|
1796
|
+
.then((j) => nowline(j.error ? "✗ " + j.error : `▶ reset: ${j.ran} — reboots + redeploys; watch the log, no apply needed`))
|
|
1797
|
+
.catch((e) => nowline("✗ reset: " + e.message));
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
function bringUpSubstrate(s) {
|
|
1801
|
+
if (
|
|
1802
|
+
!window.confirm(
|
|
1803
|
+
`Bring up ${s.label}?\nRuns: ${s.bringUp.cmd} ${s.bringUp.args.join(" ")}\nOutput streams in the log below — this can take a minute.`,
|
|
1804
|
+
)
|
|
1805
|
+
)
|
|
1806
|
+
return;
|
|
1807
|
+
nowline(`▶ bringing up ${s.label} …`);
|
|
1808
|
+
fetch(`/api/substrates/${encodeURIComponent(s.name)}/up`, { method: "POST" })
|
|
1809
|
+
.then((r) => r.json())
|
|
1810
|
+
.then((j) => nowline(j.error ? "✗ " + j.error : `▶ ${s.label}: ${j.ran}`))
|
|
1811
|
+
.catch((e) => nowline("✗ bring up: " + e.message));
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
loadSubstrates();
|
|
1815
|
+
// Poll readiness so pills update as things come up on their own (Docker
|
|
1816
|
+
// starting, a bring-up provisioning) without needing a `changed` event.
|
|
1817
|
+
setInterval(loadSubstrates, 5000);
|
|
1818
|
+
|
|
1819
|
+
// Delegated writes (#7 Sync / #8 Adopt). behold never mutates — these buttons
|
|
1820
|
+
// trigger the project's committed Ops on the executor; the now-line streams phases.
|
|
1821
|
+
// A blocking load overlay (#slow-box): a live view change shells chant and can
|
|
1822
|
+
// take seconds. Cover the whole app with a scrim + spinner so a stray click
|
|
1823
|
+
// can't fire a second pull while one's in flight. Ref-counted — nested loads
|
|
1824
|
+
// (graph + CI + resources) only lift the scrim when the last finishes.
|
|
1825
|
+
let loadingDepth = 0;
|
|
1826
|
+
function showLoading(msg) {
|
|
1827
|
+
loadingDepth++;
|
|
1828
|
+
const o = document.getElementById("loading-overlay");
|
|
1829
|
+
if (!o) return;
|
|
1830
|
+
document.getElementById("loading-msg").textContent = msg || "loading…";
|
|
1831
|
+
o.hidden = false;
|
|
1832
|
+
}
|
|
1833
|
+
function hideLoading() {
|
|
1834
|
+
loadingDepth = Math.max(0, loadingDepth - 1);
|
|
1835
|
+
if (loadingDepth > 0) return;
|
|
1836
|
+
const o = document.getElementById("loading-overlay");
|
|
1837
|
+
if (o) o.hidden = true;
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
function nowline(line) {
|
|
1841
|
+
const p = document.getElementById("nowline");
|
|
1842
|
+
p.style.display = "block";
|
|
1843
|
+
const d = document.createElement("div");
|
|
1844
|
+
d.textContent = line;
|
|
1845
|
+
p.appendChild(d);
|
|
1846
|
+
p.scrollTop = p.scrollHeight;
|
|
1847
|
+
}
|
|
1848
|
+
events.addEventListener("op", (e) => nowline(e.data));
|
|
1849
|
+
|
|
1850
|
+
// Transient toast for action feedback. The now-line is a bottom log pane that
|
|
1851
|
+
// is display:none until something writes to it and may be scrolled out of
|
|
1852
|
+
// view — an error that only lands there after the palette closed is an error
|
|
1853
|
+
// nobody sees. Errors get BOTH: the toast for now, the now-line for the
|
|
1854
|
+
// record. Auto-dismisses; click to dismiss sooner.
|
|
1855
|
+
function showToast(msg, ok) {
|
|
1856
|
+
let host = document.getElementById("toasts");
|
|
1857
|
+
if (!host) {
|
|
1858
|
+
host = document.createElement("div");
|
|
1859
|
+
host.id = "toasts";
|
|
1860
|
+
host.style.cssText = "position:fixed;top:52px;right:16px;display:flex;flex-direction:column;gap:8px;z-index:60;max-width:420px";
|
|
1861
|
+
document.body.appendChild(host);
|
|
1862
|
+
}
|
|
1863
|
+
const t = document.createElement("div");
|
|
1864
|
+
const color = ok ? "var(--managed)" : "var(--degraded)";
|
|
1865
|
+
t.style.cssText = `background:var(--panel);color:var(--fg);border:1px solid ${color};border-left:4px solid ${color};border-radius:8px;padding:8px 12px;font-size:12px;box-shadow:0 4px 16px rgba(0,0,0,.35);cursor:pointer;white-space:pre-wrap`;
|
|
1866
|
+
t.textContent = msg;
|
|
1867
|
+
t.onclick = () => t.remove();
|
|
1868
|
+
host.appendChild(t);
|
|
1869
|
+
setTimeout(() => t.remove(), ok ? 5000 : 10000);
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
// Structured apply progress (M3, #54): the server broadcasts the full
|
|
1873
|
+
// ApplyProgressState (src/apply.ts) after every recognized RunProgressEvent —
|
|
1874
|
+
// see src/op-runner.ts's apply(). Re-render the dial's progress panel each
|
|
1875
|
+
// time; renderDial() is cheap (rebuilds a small DOM subtree) so no diffing.
|
|
1876
|
+
events.addEventListener("apply", (e) => {
|
|
1877
|
+
try {
|
|
1878
|
+
applyProgress = JSON.parse(e.data);
|
|
1879
|
+
} catch {
|
|
1880
|
+
return;
|
|
1881
|
+
}
|
|
1882
|
+
renderDial();
|
|
1883
|
+
});
|
|
1884
|
+
|
|
1885
|
+
function button(label, cls, onClick) {
|
|
1886
|
+
const b = document.createElement("button");
|
|
1887
|
+
b.textContent = label;
|
|
1888
|
+
if (cls) b.className = cls;
|
|
1889
|
+
b.addEventListener("click", onClick);
|
|
1890
|
+
return b;
|
|
1891
|
+
}
|
|
1892
|
+
function runOp(name) {
|
|
1893
|
+
fetch(`/api/ops/${encodeURIComponent(name)}/run`, { method: "POST" })
|
|
1894
|
+
.then((r) => r.json())
|
|
1895
|
+
.then((j) => {
|
|
1896
|
+
if (j.error) {
|
|
1897
|
+
showToast(`✗ ${name}: ${j.error}`, false);
|
|
1898
|
+
nowline("✗ " + j.error);
|
|
1899
|
+
} else {
|
|
1900
|
+
showToast(`▶ running ${name} — output streams in the log below`, true);
|
|
1901
|
+
}
|
|
1902
|
+
});
|
|
1903
|
+
}
|
|
1904
|
+
function signal(name, gate) {
|
|
1905
|
+
fetch(`/api/ops/${encodeURIComponent(name)}/signal/${encodeURIComponent(gate)}`, { method: "POST" })
|
|
1906
|
+
.then((r) => r.json())
|
|
1907
|
+
.then((j) => {
|
|
1908
|
+
if (j.error) {
|
|
1909
|
+
showToast(`✗ approve ${gate}: ${j.error}`, false);
|
|
1910
|
+
nowline("✗ " + j.error);
|
|
1911
|
+
} else {
|
|
1912
|
+
showToast(`✓ approved ${gate}`, true);
|
|
1913
|
+
}
|
|
1914
|
+
});
|
|
1915
|
+
}
|
|
1916
|
+
// Adopt is a per-node gesture (a *foreign* node → ReconcileOp → PR), so it lives
|
|
1917
|
+
// in the inspect panel, not the global bar. Stash the reconcile op + the
|
|
1918
|
+
// live-import lexicons the server allows so inspect() can gate the button.
|
|
1919
|
+
let adopt = { reconcile: null, lexicons: [] };
|
|
1920
|
+
function adoptable(node) {
|
|
1921
|
+
// `warn` is chant's overlay tag for foreign — "foreign" is only ever the
|
|
1922
|
+
// display label (STATUS_LABEL above), never the attr's value (#145).
|
|
1923
|
+
return (
|
|
1924
|
+
adopt.reconcile &&
|
|
1925
|
+
node.attrs &&
|
|
1926
|
+
node.attrs._status === "warn" &&
|
|
1927
|
+
adopt.lexicons.includes(node.lexicon)
|
|
1928
|
+
);
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
// Rollback (#28): fetch recent source commits, let the user pick one, and trigger
|
|
1932
|
+
// the delegated rollback (opens a PR). Replaces the button with a picker + confirm.
|
|
1933
|
+
async function openRollback(btn) {
|
|
1934
|
+
const commits = await fetch("/api/history")
|
|
1935
|
+
.then((r) => r.json())
|
|
1936
|
+
.then((j) => j.commits)
|
|
1937
|
+
.catch(() => []);
|
|
1938
|
+
if (!commits.length) {
|
|
1939
|
+
nowline("rollback: no git history found");
|
|
1940
|
+
return;
|
|
1941
|
+
}
|
|
1942
|
+
const wrap = document.createElement("span");
|
|
1943
|
+
wrap.style.cssText = "display:flex;gap:6px;align-self:center";
|
|
1944
|
+
const sel = document.createElement("select");
|
|
1945
|
+
sel.style.cssText =
|
|
1946
|
+
"background:var(--panel);color:var(--fg);border:1px solid var(--line);border-radius:6px;padding:4px 8px;font-size:12px;max-width:340px";
|
|
1947
|
+
for (const c of commits) sel.add(new Option(`${c.sha} · ${c.subject} (${c.date})`, c.sha));
|
|
1948
|
+
const go = button("Roll back →", "", () => {
|
|
1949
|
+
const to = sel.value;
|
|
1950
|
+
if (!window.confirm(`Open a rollback PR restoring source to ${to}?\nA human reviews + merges, then Sync applies it.`)) return;
|
|
1951
|
+
fetch(`/api/rollback?to=${encodeURIComponent(to)}`, { method: "POST" })
|
|
1952
|
+
.then((r) => r.json())
|
|
1953
|
+
.then((j) => nowline(j.error ? "✗ " + j.error : `▶ rollback → ${to} (opening PR…)`));
|
|
1954
|
+
wrap.remove();
|
|
1955
|
+
});
|
|
1956
|
+
const cancel = button("✕", "", () => wrap.remove());
|
|
1957
|
+
wrap.append(sel, go, cancel);
|
|
1958
|
+
btn.replaceWith(wrap);
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
// #73: Rollback used to be a permanent toolbar button (openRollback(rb) swapped
|
|
1962
|
+
// it for the picker, then restored it after). Triggered from the ⌘K palette
|
|
1963
|
+
// instead now — there's no permanent button to restore, so this drops a
|
|
1964
|
+
// throwaway one into the (now otherwise action-free) actions bar just to host
|
|
1965
|
+
// the inline picker; openRollback's own wrap.remove() above cleans it up.
|
|
1966
|
+
function paletteRollback() {
|
|
1967
|
+
const bar = document.getElementById("actions");
|
|
1968
|
+
const rb = button("Rollback", "", () => {});
|
|
1969
|
+
bar.appendChild(rb);
|
|
1970
|
+
openRollback(rb);
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
// Deploy/ops state for the ⌘K palette (#73) — populated once in initActions(),
|
|
1974
|
+
// which used to turn each of these straight into a toolbar button. previewMode
|
|
1975
|
+
// (declared above, near view) is what the palette gates on: it hides exactly
|
|
1976
|
+
// the git/PR write actions the buttons hid (Rollback, Sync, Run <op>) — Apply
|
|
1977
|
+
// all, Reset, Bring up, Approve, and every read stay reachable in preview,
|
|
1978
|
+
// same as before.
|
|
1979
|
+
let opsApply = null; // the committed ApplyOp ({name, gate}), or null
|
|
1980
|
+
let opsRunnable = []; // generic Ops (backup/restore/audit/…) for "Run: <name>"
|
|
1981
|
+
let opsInitialEnv = null; // env behold launched with — gates "Apply all"
|
|
1982
|
+
|
|
1983
|
+
async function initActions() {
|
|
1984
|
+
const bar = document.getElementById("actions");
|
|
1985
|
+
if (staticMode) {
|
|
1986
|
+
// A frozen snapshot — no live actions at all. Show when it was captured.
|
|
1987
|
+
const pill = document.createElement("span");
|
|
1988
|
+
pill.textContent = `● static snapshot${manifest && manifest.capturedAt ? " · " + manifest.capturedAt.slice(0, 16).replace("T", " ") : ""}`;
|
|
1989
|
+
pill.title = "An exported, read-only snapshot — no live observe or deploy.";
|
|
1990
|
+
pill.style.cssText = "align-self:center;font-size:11px;color:var(--muted);border:1px solid var(--line);border-radius:6px;padding:2px 8px";
|
|
1991
|
+
bar.appendChild(pill);
|
|
1992
|
+
previewMode = true;
|
|
1993
|
+
return; // nothing else in the bar is a read
|
|
1994
|
+
}
|
|
1995
|
+
// The env behold launched with (reliable regardless of picker-init ordering) —
|
|
1996
|
+
// gates the first-class "Apply all" affordance below; also carries the preview
|
|
1997
|
+
// lock that hides the git/PR ops.
|
|
1998
|
+
const project = await apiFetch("/api/project").then((r) => r.json()).catch(() => ({}));
|
|
1999
|
+
opsInitialEnv = project.currentEnv || null;
|
|
2000
|
+
previewMode = staticMode || !!project.previewMode; // static ⇒ read-only, no writes at all
|
|
2001
|
+
const { ops, adoptLexicons, autoSync, local, applyProgress: apInit } = await apiFetch("/api/ops")
|
|
2002
|
+
.then((r) => r.json())
|
|
2003
|
+
.catch(() => ({ ops: [], adoptLexicons: [] }));
|
|
2004
|
+
// M3 (#54): hydrate the dial's apply progress from the server's last known
|
|
2005
|
+
// state — a page load (or reload) mid-apply picks up the structured view
|
|
2006
|
+
// instead of starting blank; the `apply` SSE listener keeps it live from here.
|
|
2007
|
+
if (apInit && apInit.waves && apInit.waves.length) {
|
|
2008
|
+
applyProgress = apInit;
|
|
2009
|
+
renderDial();
|
|
2010
|
+
}
|
|
2011
|
+
// Local-mode banner (#46) — the emulator(s) behold booted with --local, so it's
|
|
2012
|
+
// obvious deploys/overlay hit them (no cloud creds), not a real account.
|
|
2013
|
+
if (local && local.emulators && local.emulators.length) {
|
|
2014
|
+
const pill = document.createElement("span");
|
|
2015
|
+
const names = local.emulators.map((e) => e.name).join(", ");
|
|
2016
|
+
pill.textContent = `● local · ${names} up`;
|
|
2017
|
+
pill.title =
|
|
2018
|
+
"Emulator(s) booted by --local: " +
|
|
2019
|
+
local.emulators.map((e) => `${e.lexicon} ${e.name} @ ${e.endpoint}`).join("; ") +
|
|
2020
|
+
". Deploys and the overlay observe them — no cloud creds.";
|
|
2021
|
+
pill.style.cssText =
|
|
2022
|
+
"align-self:center;font-size:11px;color:var(--managed);border:1px solid var(--managed);border-radius:6px;padding:2px 8px";
|
|
2023
|
+
bar.appendChild(pill);
|
|
2024
|
+
}
|
|
2025
|
+
// Auto-sync banner (#29) — make an active self-heal loop visible, not silent.
|
|
2026
|
+
if (autoSync && autoSync !== "off") {
|
|
2027
|
+
const pill = document.createElement("span");
|
|
2028
|
+
pill.textContent = `⟳ auto-sync: ${autoSync}`;
|
|
2029
|
+
pill.title = `On polled drift, behold triggers the ${autoSync === "apply" ? "ApplyOp (heal)" : "ReconcileOp (adopt)"}. Gated applies still wait for Approve.`;
|
|
2030
|
+
pill.style.cssText =
|
|
2031
|
+
"align-self:center;font-size:11px;color:var(--pending);border:1px solid var(--pending);border-radius:6px;padding:2px 8px";
|
|
2032
|
+
bar.appendChild(pill);
|
|
2033
|
+
}
|
|
2034
|
+
opsApply = ops.find((o) => o.kind === "apply") ?? null;
|
|
2035
|
+
adopt = { reconcile: ops.find((o) => o.kind === "reconcile") ?? null, lexicons: adoptLexicons ?? [] };
|
|
2036
|
+
// Generic Ops (backup, restore, seed, watch, teardown, …) — "Run: <name>" in
|
|
2037
|
+
// the palette, same set the old "Run ▾" dropdown offered.
|
|
2038
|
+
opsRunnable = ops.filter((o) => o.kind === "op" || o.kind === "audit");
|
|
2039
|
+
|
|
2040
|
+
// The one visible deploy affordance (#73 follow-up). Moving every write into
|
|
2041
|
+
// the ⌘K palette left the header with zero action buttons — behold looked
|
|
2042
|
+
// read-only unless you happened to press ⌘K, and the docs' "click Run/Sync"
|
|
2043
|
+
// still described the old toolbar. The PRIMARY deploy gesture gets a real
|
|
2044
|
+
// button back; everything else stays in the palette. One intent, one word:
|
|
2045
|
+
// the button says Deploy whether the project routes it through a committed
|
|
2046
|
+
// ApplyOp or a raw `chant run --components` — the tooltip carries the
|
|
2047
|
+
// mechanism.
|
|
2048
|
+
if (opsApply && !previewMode) {
|
|
2049
|
+
const deploy = button(`▶ Deploy (${opsApply.name})`, "", () => {
|
|
2050
|
+
if (window.confirm(`Run the committed ApplyOp "${opsApply.name}"?\nDelegated write: behold triggers, chant's executor applies.`)) runOp(opsApply.name);
|
|
2051
|
+
});
|
|
2052
|
+
deploy.title = `chant run ${opsApply.name} — the committed ApplyOp (Build → Plan → Apply). Also in ⌘K as "Deploy: Sync".`;
|
|
2053
|
+
bar.appendChild(deploy);
|
|
2054
|
+
if (opsApply.gate) {
|
|
2055
|
+
const approve = button(`Approve ${opsApply.gate}`, "approve", () => signal(opsApply.name, opsApply.gate));
|
|
2056
|
+
approve.title = `chant run signal ${opsApply.name} ${opsApply.gate} — releases the Op's human gate.`;
|
|
2057
|
+
bar.appendChild(approve);
|
|
2058
|
+
}
|
|
2059
|
+
} else if (opsInitialEnv || view.env) {
|
|
2060
|
+
const deploy = button("▶ Deploy…", "", () => {
|
|
2061
|
+
if (!view.env) {
|
|
2062
|
+
showToast("Deploy needs an environment — pick one in ⌘K (env: …)", false);
|
|
2063
|
+
return;
|
|
2064
|
+
}
|
|
2065
|
+
applyPicker = true;
|
|
2066
|
+
loadComponentChoices().then(renderDial);
|
|
2067
|
+
renderDial();
|
|
2068
|
+
document.getElementById("dial").scrollIntoView({ block: "nearest" });
|
|
2069
|
+
});
|
|
2070
|
+
deploy.title = `chant run <component|all> --components --env ${view.env || opsInitialEnv} --progress-json — opens the component picker on the dial. behold triggers, chant executes.`;
|
|
2071
|
+
bar.appendChild(deploy);
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
// Only complain when there's genuinely nothing to do (never in the preview —
|
|
2075
|
+
// its deploy path is Apply all, not committed Ops).
|
|
2076
|
+
if (ops.length === 0 && !previewMode && !opsInitialEnv) {
|
|
2077
|
+
const hint = document.createElement("span");
|
|
2078
|
+
hint.style.cssText = "color:var(--muted);font-size:11px;align-self:center";
|
|
2079
|
+
hint.textContent = "no Ops — commit an *.op.ts (ApplyOp / ReconcileOp / any deploy Op) to act";
|
|
2080
|
+
hint.title = "behold triggers committed Ops on your executor. Add one to enable Deploy / Adopt / Run.";
|
|
2081
|
+
bar.appendChild(hint);
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
initActions();
|
|
2085
|
+
initInspectPane();
|
|
2086
|
+
|
|
2087
|
+
// Inspect pane chrome (#15): collapse (chevron / edge tab) + drag-to-resize, with
|
|
2088
|
+
// the width and collapsed state persisted so the layout survives reloads.
|
|
2089
|
+
// setInspectCollapsed/toggleInspect are module-level (not local to initInspectPane)
|
|
2090
|
+
// so the ⌘K palette (#73) can drive the same collapse/reopen the chevron does.
|
|
2091
|
+
function setInspectCollapsed(on) {
|
|
2092
|
+
document.getElementById("app").classList.toggle("inspect-collapsed", on);
|
|
2093
|
+
localStorage.setItem("behold.inspectCollapsed", on ? "1" : "0");
|
|
2094
|
+
}
|
|
2095
|
+
function toggleInspect() {
|
|
2096
|
+
setInspectCollapsed(!document.getElementById("app").classList.contains("inspect-collapsed"));
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
function initInspectPane() {
|
|
2100
|
+
const app = document.getElementById("app");
|
|
2101
|
+
const pane = document.getElementById("inspect");
|
|
2102
|
+
const MIN = 240, MAX = 720;
|
|
2103
|
+
// Restore persisted width + collapsed state.
|
|
2104
|
+
const savedW = Number(localStorage.getItem("behold.inspectW"));
|
|
2105
|
+
if (savedW >= MIN && savedW <= MAX) document.documentElement.style.setProperty("--inspect-w", savedW + "px");
|
|
2106
|
+
if (localStorage.getItem("behold.inspectCollapsed") === "1") app.classList.add("inspect-collapsed");
|
|
2107
|
+
|
|
2108
|
+
document.getElementById("inspect-collapse").addEventListener("click", () => setInspectCollapsed(true));
|
|
2109
|
+
document.getElementById("inspect-reopen").addEventListener("click", () => setInspectCollapsed(false));
|
|
2110
|
+
|
|
2111
|
+
// Drag the left edge to resize; width grows as the handle moves left.
|
|
2112
|
+
const handle = document.getElementById("inspect-resize");
|
|
2113
|
+
handle.addEventListener("mousedown", (e) => {
|
|
2114
|
+
e.preventDefault();
|
|
2115
|
+
const startX = e.clientX;
|
|
2116
|
+
const startW = pane.getBoundingClientRect().width;
|
|
2117
|
+
document.body.style.cursor = "col-resize";
|
|
2118
|
+
const onMove = (ev) => {
|
|
2119
|
+
const w = Math.min(MAX, Math.max(MIN, startW + (startX - ev.clientX)));
|
|
2120
|
+
document.documentElement.style.setProperty("--inspect-w", w + "px");
|
|
2121
|
+
};
|
|
2122
|
+
const onUp = () => {
|
|
2123
|
+
document.body.style.cursor = "";
|
|
2124
|
+
window.removeEventListener("mousemove", onMove);
|
|
2125
|
+
window.removeEventListener("mouseup", onUp);
|
|
2126
|
+
const w = parseInt(getComputedStyle(document.documentElement).getPropertyValue("--inspect-w"));
|
|
2127
|
+
if (w) localStorage.setItem("behold.inspectW", String(w));
|
|
2128
|
+
// Graph viewBox is pane-relative; refit so nothing clips after a resize.
|
|
2129
|
+
if (typeof fitGraph === "function") fitGraph();
|
|
2130
|
+
};
|
|
2131
|
+
window.addEventListener("mousemove", onMove);
|
|
2132
|
+
window.addEventListener("mouseup", onUp);
|
|
2133
|
+
});
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
// The opened PR (chant #841 surfaces it as a ReconcileOp outcome). Link it in the
|
|
2137
|
+
// now-line and pin it in the header so the review target is one click away.
|
|
2138
|
+
events.addEventListener("pr", (e) => {
|
|
2139
|
+
const url = e.data;
|
|
2140
|
+
nowline("→ opened PR: " + url);
|
|
2141
|
+
let slot = document.getElementById("pr-link");
|
|
2142
|
+
if (!slot) {
|
|
2143
|
+
slot = document.createElement("a");
|
|
2144
|
+
slot.id = "pr-link";
|
|
2145
|
+
slot.target = "_blank";
|
|
2146
|
+
slot.rel = "noopener";
|
|
2147
|
+
slot.style.cssText = "color:var(--managed);text-decoration:none;font-size:12px;align-self:center";
|
|
2148
|
+
document.getElementById("actions").after(slot);
|
|
2149
|
+
}
|
|
2150
|
+
slot.href = url;
|
|
2151
|
+
slot.textContent = "PR opened →";
|
|
2152
|
+
});
|
|
2153
|
+
|
|
2154
|
+
// Export the current graph as a standalone SVG file — the inlined pinhole SVG
|
|
2155
|
+
// already IS the full rendered graph (see render()), so this is a pure client-
|
|
2156
|
+
// side download, no server round-trip. Works in a static export too.
|
|
2157
|
+
function exportSvg() {
|
|
2158
|
+
const svg = currentSvg();
|
|
2159
|
+
if (!svg) {
|
|
2160
|
+
nowline("✗ export: no graph loaded yet");
|
|
2161
|
+
return;
|
|
2162
|
+
}
|
|
2163
|
+
const blob = new Blob([svg.outerHTML], { type: "image/svg+xml" });
|
|
2164
|
+
const url = URL.createObjectURL(blob);
|
|
2165
|
+
const a = document.createElement("a");
|
|
2166
|
+
a.href = url;
|
|
2167
|
+
a.download = `behold-${zoomValue()}${view.env ? "-" + view.env : ""}.svg`;
|
|
2168
|
+
a.style.display = "none";
|
|
2169
|
+
document.body.appendChild(a);
|
|
2170
|
+
a.click();
|
|
2171
|
+
a.remove();
|
|
2172
|
+
URL.revokeObjectURL(url);
|
|
2173
|
+
nowline(`↓ exported ${a.download}`);
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
// --- Command palette (⌘K / Ctrl+K, #73) -----------------------------------
|
|
2177
|
+
// Ported from spicypath's FG-036 pattern (../spicypath/src/index.html: search
|
|
2178
|
+
// `#palette`, `paletteCommands`, `openPalette`) — same shape (module-level
|
|
2179
|
+
// palCmds/palSel/palCurrent, paletteCommands() rebuilding a fresh {label, run}
|
|
2180
|
+
// list from live state on every open, palRender() filtering + repainting,
|
|
2181
|
+
// openPalette()/closePalette() toggling the `.on` class), retargeted at
|
|
2182
|
+
// behold's own handlers instead of spicypath's. "Hide controls, never state"
|
|
2183
|
+
// (spicypath's own design rule, carried over): every action this moves out of
|
|
2184
|
+
// the toolbar is still reachable here; zoom/env/tier/drift/substrates stay
|
|
2185
|
+
// visible in the header regardless (renderStatusbar(), #substrates pills,
|
|
2186
|
+
// #meta) — see index.html's #statusbar comment.
|
|
2187
|
+
const palette = document.getElementById("palette");
|
|
2188
|
+
const palInput = document.getElementById("pal-input");
|
|
2189
|
+
const palList = document.getElementById("pal-list");
|
|
2190
|
+
let palCmds = [], palSel = 0, palCurrent = [];
|
|
2191
|
+
|
|
2192
|
+
// Builds the live command list fresh on every open, so it always reflects
|
|
2193
|
+
// current state (which env is picked, whether the inspect pane is collapsed,
|
|
2194
|
+
// what previewMode hides) rather than a snapshot from page load.
|
|
2195
|
+
function paletteCommands() {
|
|
2196
|
+
const c = [];
|
|
2197
|
+
|
|
2198
|
+
// Reads — always available, even in a static export or the preview lock.
|
|
2199
|
+
if (!staticMode) c.push(["Re-check live (refresh drift)", () => refresh()]);
|
|
2200
|
+
c.push(["Fit graph to view", () => fitGraph()]);
|
|
2201
|
+
c.push(["Export: current graph as SVG", () => exportSvg()]);
|
|
2202
|
+
const inspectCollapsed = document.getElementById("app").classList.contains("inspect-collapsed");
|
|
2203
|
+
c.push([inspectCollapsed ? "Show inspect panel" : "Hide inspect panel", () => toggleInspect()]);
|
|
2204
|
+
|
|
2205
|
+
// Lens/zoom switches (#56, #63) — replaces the old header zoom picker.
|
|
2206
|
+
for (const [label, v] of ZOOM_OPTS) {
|
|
2207
|
+
c.push([label + (v === zoomValue() ? " ✓" : ""), () => { applyZoom(v); load(); }]);
|
|
2208
|
+
}
|
|
2209
|
+
// Radial toggle — entity zooms only, same gate the removed checkbox had
|
|
2210
|
+
// (components/logical both lay themselves out: waves / nested arch boxes).
|
|
2211
|
+
if (!view.components && !view.logical) {
|
|
2212
|
+
c.push([(view.radial ? "Disable" : "Enable") + " radial layout", () => { view.radial = !view.radial; load(); }]);
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
// Env/stack/tier/target selection — replaces the old header pickers.
|
|
2216
|
+
c.push(["env: (source)" + (!view.env ? " ✓" : ""), () => { view.env = null; resetDialCaches(); load(); }]);
|
|
2217
|
+
for (const e of environments) {
|
|
2218
|
+
c.push([`env: ${e}` + (view.env === e ? " ✓" : ""), () => { view.env = e; resetDialCaches(); load(); }]);
|
|
2219
|
+
}
|
|
2220
|
+
// The stack picker (#76, follow-up to #71) — only ever populated (`stacks`,
|
|
2221
|
+
// seeded in initPickers()) when the served project declares `chant.config.
|
|
2222
|
+
// ts`'s `stacks[]`; empty on every single-stack/sourceDir-only/legacy
|
|
2223
|
+
// project, so this loop (and the status strip's `stack:` tag) is a no-op
|
|
2224
|
+
// there. No "stack: (none)" escape hatch like env's "(source)" above —
|
|
2225
|
+
// the design's locked default is always the first declared stack, never "no
|
|
2226
|
+
// stack selected".
|
|
2227
|
+
for (const s of stacks) {
|
|
2228
|
+
c.push([`stack: ${s}` + (view.stack === s ? " ✓" : ""), () => { view.stack = s; resetDialCaches(); load(); }]);
|
|
2229
|
+
}
|
|
2230
|
+
for (const t of tiers) {
|
|
2231
|
+
c.push([`tier: ${t}` + (view.tier === t ? " ✓" : ""), () => { view.tier = t; resetDialCaches(); load(); }]);
|
|
2232
|
+
}
|
|
2233
|
+
for (const t of targets) {
|
|
2234
|
+
c.push([`target: ${t.endpoint}` + (view.target === t.endpoint ? " ✓" : ""), () => { view.target = t.endpoint; resetDialCaches(); load(); }]);
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
if (staticMode) return c.map(([label, run]) => ({ label, run })); // no writes at all in a static export
|
|
2238
|
+
|
|
2239
|
+
// Deploy / write actions — previewMode hides exactly the git/PR write
|
|
2240
|
+
// affordances the toolbar hid (Rollback, Sync, Run <op>); Apply all, Reset,
|
|
2241
|
+
// Bring up, and Approve stay reachable, mirroring initActions()'s and
|
|
2242
|
+
// renderSubstrates()'s previous button-gating precisely.
|
|
2243
|
+
if (!previewMode) {
|
|
2244
|
+
if (opsApply) {
|
|
2245
|
+
c.push([`Deploy: Sync (${opsApply.name})`, () => runOp(opsApply.name)]);
|
|
2246
|
+
if (opsApply.gate) c.push([`Approve ${opsApply.gate}`, () => signal(opsApply.name, opsApply.gate)]);
|
|
2247
|
+
}
|
|
2248
|
+
c.push(["Rollback to a prior revision…", () => paletteRollback()]);
|
|
2249
|
+
for (const op of opsRunnable) {
|
|
2250
|
+
c.push([`Run: ${op.name}`, () => { if (window.confirm(`Run Op "${op.name}"?`)) runOp(op.name); }]);
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
2253
|
+
// No committed ApplyOp, but there's an env → "Apply all" is the equivalent
|
|
2254
|
+
// deploy action (M3) — stays available in preview (a local write, not git/PR).
|
|
2255
|
+
if (!opsApply && opsInitialEnv) c.push([`Deploy: Apply all → ${opsInitialEnv}`, () => confirmApplyAll()]);
|
|
2256
|
+
|
|
2257
|
+
for (const s of lastSubstrates) {
|
|
2258
|
+
if (s.bringUp) c.push([`Bring up ${s.label}`, () => bringUpSubstrate(s)]);
|
|
2259
|
+
if (s.name === "floci" && s.status === "up") c.push(["Reset local emulator (Floci)", () => resetLocal()]);
|
|
2260
|
+
// Dispatch a GitHub Actions run (#164) — through the operator's own `gh`
|
|
2261
|
+
// login. Offered whenever the pill exists; the server refuses honestly
|
|
2262
|
+
// (no gh, unauthenticated, no matching workflow_dispatch workflow) and
|
|
2263
|
+
// the reason lands as a toast.
|
|
2264
|
+
if (s.name === "github" && !previewMode) {
|
|
2265
|
+
c.push([
|
|
2266
|
+
"Run pipeline: GitHub Actions",
|
|
2267
|
+
() => {
|
|
2268
|
+
if (!window.confirm("Dispatch the GitHub Actions pipeline?\nRuns via YOUR gh login (gh workflow run); behold follows the run on the dial.")) return;
|
|
2269
|
+
fetch("/api/ci/dispatch", { method: "POST" })
|
|
2270
|
+
.then((r) => r.json())
|
|
2271
|
+
.then((j) => {
|
|
2272
|
+
if (j.error) {
|
|
2273
|
+
showToast(`✗ dispatch: ${j.error}`, false);
|
|
2274
|
+
nowline("✗ dispatch: " + j.error);
|
|
2275
|
+
} else {
|
|
2276
|
+
showToast(`▶ dispatched ${j.workflow} @ ${j.ref} (${j.jobs} jobs) — following on the dial`, true);
|
|
2277
|
+
}
|
|
2278
|
+
});
|
|
2279
|
+
},
|
|
2280
|
+
]);
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
return c.map(([label, run]) => ({ label, run }));
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
function palRender() {
|
|
2288
|
+
const q = palInput.value.toLowerCase().trim();
|
|
2289
|
+
palCurrent = q ? palCmds.filter((c) => c.label.toLowerCase().includes(q)) : palCmds;
|
|
2290
|
+
palSel = Math.max(0, Math.min(palSel, palCurrent.length - 1));
|
|
2291
|
+
palList.replaceChildren();
|
|
2292
|
+
if (!palCurrent.length) {
|
|
2293
|
+
const e = document.createElement("div");
|
|
2294
|
+
e.className = "empty";
|
|
2295
|
+
e.textContent = "no matching command";
|
|
2296
|
+
palList.appendChild(e);
|
|
2297
|
+
return;
|
|
2298
|
+
}
|
|
2299
|
+
palCurrent.forEach((c, i) => {
|
|
2300
|
+
const d = document.createElement("div");
|
|
2301
|
+
d.className = "row" + (i === palSel ? " sel" : "");
|
|
2302
|
+
d.textContent = c.label;
|
|
2303
|
+
d.onmousedown = (ev) => {
|
|
2304
|
+
ev.preventDefault();
|
|
2305
|
+
closePalette();
|
|
2306
|
+
c.run();
|
|
2307
|
+
};
|
|
2308
|
+
palList.appendChild(d);
|
|
2309
|
+
});
|
|
2310
|
+
}
|
|
2311
|
+
function openPalette() {
|
|
2312
|
+
palCmds = paletteCommands();
|
|
2313
|
+
palSel = 0;
|
|
2314
|
+
palInput.value = "";
|
|
2315
|
+
palRender();
|
|
2316
|
+
palette.classList.add("on");
|
|
2317
|
+
palInput.focus();
|
|
2318
|
+
}
|
|
2319
|
+
function closePalette() {
|
|
2320
|
+
palette.classList.remove("on");
|
|
2321
|
+
}
|
|
2322
|
+
palInput.oninput = () => { palSel = 0; palRender(); };
|
|
2323
|
+
palInput.onkeydown = (e) => {
|
|
2324
|
+
if (e.key === "ArrowDown") { palSel++; palRender(); e.preventDefault(); }
|
|
2325
|
+
else if (e.key === "ArrowUp") { palSel--; palRender(); e.preventDefault(); }
|
|
2326
|
+
else if (e.key === "Enter") { const c = palCurrent[palSel]; closePalette(); if (c) c.run(); e.preventDefault(); }
|
|
2327
|
+
else if (e.key === "Escape") { closePalette(); e.stopPropagation(); e.preventDefault(); }
|
|
2328
|
+
};
|
|
2329
|
+
palette.onmousedown = (e) => { if (e.target === palette) closePalette(); }; // backdrop click
|
|
2330
|
+
document.getElementById("hintk").addEventListener("click", openPalette);
|
|
2331
|
+
|
|
2332
|
+
// Global ⌘K / Ctrl+K toggle — fires even while focus is inside another input
|
|
2333
|
+
// (e.g. an apply-picker <select>), matching spicypath's own keymap.
|
|
2334
|
+
window.addEventListener("keydown", (e) => {
|
|
2335
|
+
const mod = e.metaKey || e.ctrlKey;
|
|
2336
|
+
if (mod && (e.key === "k" || e.key === "K")) {
|
|
2337
|
+
e.preventDefault();
|
|
2338
|
+
palette.classList.contains("on") ? closePalette() : openPalette();
|
|
2339
|
+
}
|
|
2340
|
+
});
|
|
2341
|
+
|
|
2342
|
+
// Readable text on the accent-filled selected row (spicypath's --on-accent),
|
|
2343
|
+
// across all 552 Ghostty themes — behold has no static equivalent since
|
|
2344
|
+
// --pending's lightness varies per theme, so this reuses theme.js's own
|
|
2345
|
+
// contrast helper (the same one recolorNodesByCategory() uses for node labels)
|
|
2346
|
+
// instead of assuming light-on-dark or dark-on-light.
|
|
2347
|
+
function applyPaletteContrast() {
|
|
2348
|
+
const t = getTokens();
|
|
2349
|
+
if (t) document.documentElement.style.setProperty("--pal-sel-fg", readableOn(t.pending));
|
|
2350
|
+
}
|
|
2351
|
+
applyPaletteContrast();
|
|
2352
|
+
onThemeChange(applyPaletteContrast);
|