@intentius/behold 0.17.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +757 -26
- package/LICENSE +190 -0
- package/README.md +251 -187
- package/THIRD_PARTY.md +75 -0
- package/demos.json +80 -9
- package/dist/cli.js +5479 -1673
- package/example-argo-estate/package-lock.json +6 -6
- package/example-carve/app/package-lock.json +3 -3
- package/example-choudoufu-estate/.behold.json +8 -0
- package/example-choudoufu-estate/README.md +48 -0
- package/example-choudoufu-estate/monolith/behaviour.live.json +495 -0
- package/example-choudoufu-estate/monolith/carve.json +12 -0
- package/example-choudoufu-estate/monolith/main.tf +127 -0
- package/example-choudoufu-estate/scripts/choudoufu-down.sh +8 -0
- package/example-choudoufu-estate/scripts/choudoufu-up.sh +62 -0
- package/example-choudoufu-estate/team-a/behaviour.live.json +179 -0
- package/example-choudoufu-estate/team-a/main.tf +58 -0
- package/example-choudoufu-estate/team-b/behaviour.live.json +179 -0
- package/example-choudoufu-estate/team-b/main.tf +73 -0
- package/example-choudoufu-estate/team-c/behaviour.live.json +179 -0
- package/example-choudoufu-estate/team-c/main.tf +51 -0
- package/example-flux-estate/package-lock.json +6 -6
- package/example-k8s/package-lock.json +6 -6
- package/example-terraform-estate/README.md +109 -0
- package/example-terraform-estate/backends/backend.s3.tf +10 -0
- package/example-terraform-estate/envs/dev/README.md +5 -0
- package/example-terraform-estate/envs/prod/data.tf +18 -0
- package/example-terraform-estate/envs/prod/network.tf +80 -0
- package/example-terraform-estate/envs/prod/outputs.tf +13 -0
- package/example-terraform-estate/envs/prod/provider.tf +10 -0
- package/example-terraform-estate/envs/prod/services.tf +21 -0
- package/example-terraform-estate/envs/prod/storage.tf +31 -0
- package/example-terraform-estate/envs/prod/variables.tf +17 -0
- package/example-terraform-estate/envs/prod/versions.tf +10 -0
- package/example-terraform-estate/modules/service/main.tf +58 -0
- package/example-terraform-estate/modules/service/outputs.tf +7 -0
- package/example-terraform-estate/modules/service/variables.tf +16 -0
- package/example-terraform-estate/modules/service/versions.tf +10 -0
- package/example-terraform-estate/platform/iam.tf +40 -0
- package/example-terraform-estate/platform/kms.tf +16 -0
- package/example-terraform-estate/platform/outputs.tf +7 -0
- package/example-terraform-estate/platform/variables.tf +10 -0
- package/example-terraform-estate/platform/versions.tf +10 -0
- package/example-writes/package-lock.json +3 -3
- package/licenses/cncf-artwork-LICENSE.md +13 -0
- package/licenses/iTerm2-Color-Schemes-LICENSE +31 -0
- package/licenses/kubernetes-icons-LICENSE +201 -0
- package/package.json +25 -7
- package/web/app.js +1309 -41
- package/web/behaviour-scale.js +380 -0
- package/web/behaviour-scale.test.js +307 -0
- package/web/cross-env.js +58 -0
- package/web/cross-env.test.js +61 -0
- package/web/index.html +62 -2
- package/web/pending.js +67 -0
- package/web/pending.test.js +62 -0
- package/web/read-cost.js +49 -0
- package/web/read-cost.test.js +76 -0
- package/web/refresh-queue.js +26 -0
- package/web/refresh-queue.test.js +54 -0
package/web/app.js
CHANGED
|
@@ -8,7 +8,34 @@
|
|
|
8
8
|
// floating control panel's chrome (panel.js — drag/snap/collapse/tabs, persisted
|
|
9
9
|
// position), and the theme picker into the panel's View-tab slot (a stable element
|
|
10
10
|
// renderPanelView never rewrites, so the select mounts once and survives re-renders).
|
|
11
|
-
import {
|
|
11
|
+
import { createRefreshQueue } from "./refresh-queue.js";
|
|
12
|
+
import { readCostLine } from "./read-cost.js";
|
|
13
|
+
import { applyMemberFrame, pendingLine, stillPending } from "./pending.js";
|
|
14
|
+
import { initTheme, mountThemePicker, readableOn, colorForCategory, onThemeChange, getTokens, getTheme, pinTokensFor } from "./theme.js";
|
|
15
|
+
// #399 M2 / #401 M4 of #397: the colour-by modes' arithmetic and the
|
|
16
|
+
// provenance badge's wording — every decision the behaviour overlay makes,
|
|
17
|
+
// pure and unit-tested beside the file. This module owns the fetches, the SVG
|
|
18
|
+
// and the panels; nothing below states a figure of its own.
|
|
19
|
+
import {
|
|
20
|
+
COLOUR_MODES,
|
|
21
|
+
badgeFor,
|
|
22
|
+
blockOf,
|
|
23
|
+
countFigures,
|
|
24
|
+
countsText,
|
|
25
|
+
domainFor,
|
|
26
|
+
effectiveMode,
|
|
27
|
+
figureOf,
|
|
28
|
+
fillFor,
|
|
29
|
+
fmtCost,
|
|
30
|
+
fmtHeadroom,
|
|
31
|
+
headroomOf,
|
|
32
|
+
modeAvailability,
|
|
33
|
+
rampFor,
|
|
34
|
+
rampGradient,
|
|
35
|
+
scaleEnds,
|
|
36
|
+
totalsFor,
|
|
37
|
+
totalsText,
|
|
38
|
+
} from "./behaviour-scale.js";
|
|
12
39
|
import { addPanelTab, initPanel, setPanelTab, togglePanelCollapsed, isPanelCollapsed } from "./panel.js";
|
|
13
40
|
// #254: the carve walkthrough's stepper — everything it DECIDES is a pure
|
|
14
41
|
// function in there; this file owns the fetches, the graph selection, and the
|
|
@@ -56,6 +83,7 @@ import { hasOperator, renderOperator, APPROVED_SEMANTICS } from "./operator.js";
|
|
|
56
83
|
// which commit, approved by whom — and, when the ledger's run id cannot be
|
|
57
84
|
// followed, the reason in words rather than a guessed link.
|
|
58
85
|
import { releaseRows } from "./release.js";
|
|
86
|
+
import { comparisonRow, comparisonSummary } from "./cross-env.js";
|
|
59
87
|
initTheme();
|
|
60
88
|
initPanel();
|
|
61
89
|
mountThemePicker(document.getElementById("panel-theme"));
|
|
@@ -65,16 +93,121 @@ mountThemePicker(document.getElementById("panel-theme"));
|
|
|
65
93
|
// labels/icons get readable ink (black/white) on the category fill. Re-runs on theme switch,
|
|
66
94
|
// since the categorical hues come from the active theme's palette.
|
|
67
95
|
let lastGraphIr = null;
|
|
96
|
+
|
|
97
|
+
// --- colour by drift, cost or headroom (#399, M2 of #397) -------------------
|
|
98
|
+
//
|
|
99
|
+
// The mode is CLIENT-SIDE state, deliberately. It changes no fetch: the server
|
|
100
|
+
// hands the same overlay whichever mode is picked — `attrs._behaviour` rides
|
|
101
|
+
// every priced card already (src/behaviour.ts explains why the block stays on
|
|
102
|
+
// the node), so a mode switch is a repaint of the SVG that is already on
|
|
103
|
+
// screen, with no round trip and nothing for a slow chant read to hold up. It
|
|
104
|
+
// is therefore NOT in `LENS_PARAMS`/`canonicalKey`: a lens param there means "a
|
|
105
|
+
// different snapshot comes back", and adding one would give a static export
|
|
106
|
+
// three identical bundles of the same graph under three keys.
|
|
107
|
+
//
|
|
108
|
+
// It survives a reload the way the theme does, and for the same reason — the
|
|
109
|
+
// colour you left the graph in is part of how you read it.
|
|
110
|
+
const COLOUR_STORE_KEY = "behold.colourBy";
|
|
111
|
+
let colourMode = "drift";
|
|
112
|
+
try {
|
|
113
|
+
const saved = localStorage.getItem(COLOUR_STORE_KEY);
|
|
114
|
+
if (COLOUR_MODES.includes(saved)) colourMode = saved;
|
|
115
|
+
} catch {
|
|
116
|
+
/* private mode */
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const COLOUR_MODE_TITLE = {
|
|
120
|
+
drift: "Colour each card by what chant observed live — the overlay behold has always drawn",
|
|
121
|
+
cost: "Colour each card by the engine's cost per hour, low to high across this estate. An entity nothing priced draws neutral, never the cheap end.",
|
|
122
|
+
headroom: "Colour each card by how far it is from saturation — the LOWER of the axes the engine reported. An entity nothing priced draws neutral.",
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** `meta.behaviour` off the last render, or null. */
|
|
126
|
+
function behaviourMeta() {
|
|
127
|
+
return (lastMeta && lastMeta.behaviour) || null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The mode actually painted: the pick, unless this graph cannot serve it (no
|
|
131
|
+
* overlay, a refusal, an absence), in which case drift — which never depended
|
|
132
|
+
* on an engine. The PICK is kept either way, so switching back to a live env
|
|
133
|
+
* lands where you left off. */
|
|
134
|
+
function activeColourMode() {
|
|
135
|
+
return effectiveMode(colourMode, lastMeta);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function setColourMode(mode) {
|
|
139
|
+
colourMode = mode;
|
|
140
|
+
try {
|
|
141
|
+
localStorage.setItem(COLOUR_STORE_KEY, mode);
|
|
142
|
+
} catch {
|
|
143
|
+
/* private mode */
|
|
144
|
+
}
|
|
145
|
+
// No load(): the mode changes nothing the server would answer differently,
|
|
146
|
+
// so the SVG already on screen is repainted in place. renderStatusbar() is
|
|
147
|
+
// the one entry point that redraws the strip AND all three panel tabs, which
|
|
148
|
+
// is exactly the surface a mode switch moves.
|
|
149
|
+
recolorNodesByCategory();
|
|
150
|
+
renderStatusbar();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* What each card is painted, in the active mode.
|
|
155
|
+
*
|
|
156
|
+
* Drift keeps the categorical hue #62 gives a card (the drift state stays on
|
|
157
|
+
* the bar and the stroke, where pinhole puts it). The two behaviour modes
|
|
158
|
+
* replace the FILL with the mode's scale, so the drift bar underneath is
|
|
159
|
+
* untouched and both facts stay on the card at once.
|
|
160
|
+
*
|
|
161
|
+
* The scale's domain is taken over every entity in the graph that carries a
|
|
162
|
+
* figure — the same single walk of `ir.nodes` M1's design note promised, with
|
|
163
|
+
* no join against a second index.
|
|
164
|
+
*/
|
|
165
|
+
function colourPlan(graphIr) {
|
|
166
|
+
const mode = activeColourMode();
|
|
167
|
+
const tokens = getTokens();
|
|
168
|
+
const kindOf = new Map(graphIr.nodes.map((n) => [n.id, n.kind || n.lexicon || "node"]));
|
|
169
|
+
if (mode === "drift" || !tokens) {
|
|
170
|
+
return { mode, fillOf: (id) => ({ fill: colorForCategory(kindOf.get(id)), unpriced: false }) };
|
|
171
|
+
}
|
|
172
|
+
const theme = getTheme();
|
|
173
|
+
// The drift overlay's own neutral card ground — the colour a card wears when
|
|
174
|
+
// chant could not read it. #399: an entity with no block draws THIS, never
|
|
175
|
+
// the zero end of the scale, which would say "free" or "saturated" about an
|
|
176
|
+
// entity nothing priced.
|
|
177
|
+
const neutralFill = theme ? pinTokensFor(theme).neutralFill : tokens.neutral;
|
|
178
|
+
const ramp = rampFor(mode, tokens);
|
|
179
|
+
const domain = domainFor(
|
|
180
|
+
mode,
|
|
181
|
+
graphIr.nodes.map((n) => figureOf(mode, blockOf(n))),
|
|
182
|
+
);
|
|
183
|
+
const figures = new Map(graphIr.nodes.map((n) => [n.id, figureOf(mode, blockOf(n))]));
|
|
184
|
+
return { mode, domain, ramp, fillOf: (id) => fillFor(figures.get(id), domain, ramp, neutralFill) };
|
|
185
|
+
}
|
|
186
|
+
|
|
68
187
|
function recolorNodesByCategory(ir) {
|
|
69
188
|
if (ir) lastGraphIr = ir;
|
|
70
189
|
const graphIr = ir || lastGraphIr;
|
|
71
190
|
const svg = document.querySelector("#graph svg");
|
|
72
191
|
if (!svg || !graphIr) return;
|
|
73
192
|
const kindOf = new Map(graphIr.nodes.map((n) => [n.id, n.kind || n.lexicon || "node"]));
|
|
193
|
+
const plan = colourPlan(graphIr);
|
|
74
194
|
for (const g of svg.querySelectorAll("[data-node-id]")) {
|
|
75
|
-
const
|
|
195
|
+
const id = g.getAttribute("data-node-id");
|
|
196
|
+
const kind = kindOf.get(id);
|
|
76
197
|
if (!kind) continue;
|
|
77
|
-
const
|
|
198
|
+
const paint = plan.fillOf(id);
|
|
199
|
+
const cat = paint.fill, ink = readableOn(cat);
|
|
200
|
+
// #399: an unpriced card is marked, not merely coloured — a hex cannot be
|
|
201
|
+
// read back out into "the engine did not price this", and the legend's
|
|
202
|
+
// unpriced count has to agree with what is on the canvas.
|
|
203
|
+
if (plan.mode !== "drift" && paint.unpriced) g.setAttribute("data-unpriced", "1");
|
|
204
|
+
else g.removeAttribute("data-unpriced");
|
|
205
|
+
// #429: and the card an overlay never observed because its member has no
|
|
206
|
+
// live half. Only on an overlay: on the source graph NO card has a status
|
|
207
|
+
// and the mark would be on every one of them, saying nothing.
|
|
208
|
+
const onOverlay = !!(lastMeta && lastMeta.env);
|
|
209
|
+
const node = graphIr.nodes.find((n) => n.id === id);
|
|
210
|
+
g.toggleAttribute("data-no-live", onOverlay && !!node && !(node.attrs && node.attrs._status));
|
|
78
211
|
// Classify each element ONCE by the pinhole token it rode on (data-cat role), then apply
|
|
79
212
|
// the role's colour on every pass. This is what makes it recolour on theme switch: after
|
|
80
213
|
// the first pass the fill is a hex (not a --pin-* var), so we must key off the marker, not
|
|
@@ -158,11 +291,11 @@ if (staticMode) {
|
|
|
158
291
|
|
|
159
292
|
/** Canonical key for a read URL — path + the lens params (whitelisted, sorted)
|
|
160
293
|
* that select a distinct snapshot. MUST match src/export.ts `canonicalKey`. */
|
|
161
|
-
const LENS_PARAMS = ["components", "detail", "env", "logical", "ops", "radial", "runtime", "tier"];
|
|
294
|
+
const LENS_PARAMS = ["collapse", "components", "detail", "env", "logical", "ops", "radial", "runtime", "stacks", "tier"];
|
|
162
295
|
function canonicalKey(path, params) {
|
|
163
296
|
// Components + logical + ops views ignore detail/radial — drop them so they
|
|
164
297
|
// match the single captured snapshot (MUST match src/export.ts).
|
|
165
|
-
const flat = params.get("components") === "1" || params.get("logical") === "1" || params.get("ops") === "1";
|
|
298
|
+
const flat = params.get("components") === "1" || params.get("logical") === "1" || params.get("ops") === "1" || params.get("stacks") === "1";
|
|
166
299
|
const q = LENS_PARAMS.filter((k) => params.has(k) && !(flat && (k === "detail" || k === "radial")))
|
|
167
300
|
.map((k) => `${k}=${params.get(k)}`)
|
|
168
301
|
.join("&");
|
|
@@ -187,6 +320,55 @@ function apiFetch(url) {
|
|
|
187
320
|
// reaches a declared entity — expected runtime (a Pod its Deployment
|
|
188
321
|
// created), never foreign and never drift. Additive the same way.
|
|
189
322
|
const STATUS_LABEL = { good: "managed", warn: "foreign", accent: "pending", neutral: "unobserved", runtime: "runtime child" };
|
|
323
|
+
// #393 item 8: the same four colours, in the words of whatever the estate's
|
|
324
|
+
// members ARE. The server derives the set from the member kinds and puts it on
|
|
325
|
+
// the overlay meta (`vocabulary`, src/status-vocabulary.ts) — on a choudoufu
|
|
326
|
+
// estate the legend reads bound / unowned / pending / not observed, which is
|
|
327
|
+
// what choudoufu itself calls those states. A mixed estate keeps chant's words
|
|
328
|
+
// and carries a `note` the legend shows as its tooltip. No meta, or a meta from
|
|
329
|
+
// a behold that predates this: chant's words, exactly as before.
|
|
330
|
+
function statusLabels() {
|
|
331
|
+
const v = lastMeta && lastMeta.vocabulary;
|
|
332
|
+
return (v && v.labels) || STATUS_LABEL;
|
|
333
|
+
}
|
|
334
|
+
function statusLabel(status) {
|
|
335
|
+
return statusLabels()[status];
|
|
336
|
+
}
|
|
337
|
+
function vocabularyNote() {
|
|
338
|
+
const v = lastMeta && lastMeta.vocabulary;
|
|
339
|
+
return (v && v.note) || "";
|
|
340
|
+
}
|
|
341
|
+
// #396 item 5: the inspect pane's LIVE section prints `ownership` raw, and
|
|
342
|
+
// `ownership` is a word two different readers write into the same field.
|
|
343
|
+
// chant's live reader answers it from the resource's own marker — `owned` /
|
|
344
|
+
// `foreign`, which is chant's contract for that field (chant graph-ir.d.ts).
|
|
345
|
+
// src/choudoufu-live.ts has no marker to read: it writes the same two words for
|
|
346
|
+
// choudoufu's `bound[]` and `unowned[]`, so an unowned card read `status:
|
|
347
|
+
// unowned` and, two rows below it, `ownership: foreign` — chant's word for
|
|
348
|
+
// choudoufu's state, the last of the four #393 item 8 did not reach.
|
|
349
|
+
//
|
|
350
|
+
// So this row joins the legend, the counts and the status row in being rendered
|
|
351
|
+
// through the estate's own vocabulary rather than printed. Translated ONLY when
|
|
352
|
+
// the estate's members are all of a kind that has its own words for these
|
|
353
|
+
// colours (`vocabulary.of`): on a chant estate, and on a mixed one — which
|
|
354
|
+
// keeps chant's words on the legend by design and says so in its tooltip — the
|
|
355
|
+
// value is printed exactly as it is today, which is the point. The mapping is
|
|
356
|
+
// value → the colour that value means → that colour's word, so it needs no
|
|
357
|
+
// table of its own beyond the two words chant's field can hold.
|
|
358
|
+
const OWNERSHIP_STATUS = { owned: "good", foreign: "warn" };
|
|
359
|
+
function ownershipLabel(value) {
|
|
360
|
+
const v = lastMeta && lastMeta.vocabulary;
|
|
361
|
+
if (!v || !v.labels || !v.of || v.of === "chant" || v.of === "mixed") return value;
|
|
362
|
+
return v.labels[OWNERSHIP_STATUS[value]] || value;
|
|
363
|
+
}
|
|
364
|
+
// #404: whether a plan was read for this overlay, and how many cards it would
|
|
365
|
+
// change. `{read: false}` and `{read: true, drifted: 0}` are DIFFERENT answers
|
|
366
|
+
// — "nobody looked" versus "looked, nothing drifted" — and the legend must not
|
|
367
|
+
// print a count for the first one. Null from a behold that predates this, and
|
|
368
|
+
// every reader below treats null as "not read".
|
|
369
|
+
function driftMeta() {
|
|
370
|
+
return (lastMeta && lastMeta.drift) || null;
|
|
371
|
+
}
|
|
190
372
|
// M1.1 (#57), palette hardened M2 (#54): the component-DAG live-status join
|
|
191
373
|
// paints the same `_status` vocabulary (good/warn/accent/neutral) but with
|
|
192
374
|
// different meaning — a stack-health reading, not "managed" — so the inspect
|
|
@@ -384,6 +566,37 @@ function renderDriftSection(section, r) {
|
|
|
384
566
|
pr("rendered", `${p.renderedAt} · helm ${p.helmVersion} · chant ${p.chantVersion}`);
|
|
385
567
|
}
|
|
386
568
|
|
|
569
|
+
const RESILIENCE_WORD = { survives: "survives", degrades: "degrades", fails: "fails" };
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* The `behaviour` inspect section (#401, M4 of #397) — one entity's whole
|
|
573
|
+
* block, as rows, with the provenance badge beside every figure.
|
|
574
|
+
*
|
|
575
|
+
* Every row here is the engine's claim, echoed. The currency is the engine's
|
|
576
|
+
* (behold converts none), the traffic level is the string the engine named
|
|
577
|
+
* (there is no default one), a headroom axis the engine did not report is
|
|
578
|
+
* simply not a row, and `rightSize` appears only when the engine suggested
|
|
579
|
+
* something. The badge on each figure says who stated it, to what tolerance,
|
|
580
|
+
* and whether it was modeled from list prices or validated against a bill.
|
|
581
|
+
*/
|
|
582
|
+
function renderBehaviourSection(section, b) {
|
|
583
|
+
if (!b) return;
|
|
584
|
+
const meta = behaviourMeta();
|
|
585
|
+
const one = [b];
|
|
586
|
+
const add = section("behaviour");
|
|
587
|
+
add("at", b.at.traffic);
|
|
588
|
+
add("cost", figureWithBadge(fmtCost(b.cost.perHour, b.cost.currency), one, meta));
|
|
589
|
+
const axes = [];
|
|
590
|
+
if (typeof b.headroom.cpu === "number") axes.push(`cpu ${fmtHeadroom(b.headroom.cpu)}`);
|
|
591
|
+
if (typeof b.headroom.latency === "number") axes.push(`latency ${fmtHeadroom(b.headroom.latency)}`);
|
|
592
|
+
const worst = headroomOf(b);
|
|
593
|
+
add("headroom", figureWithBadge(`${axes.join(" · ")} — colours on ${fmtHeadroom(worst)}`, one, meta));
|
|
594
|
+
add("error rate", figureWithBadge(`${(b.errorRate * 100).toFixed(2)}% of requests at ${b.at.traffic}`, one, meta));
|
|
595
|
+
add("resilience", figureWithBadge(`${RESILIENCE_WORD[b.resilience.verdict] || b.resilience.verdict} — ${b.resilience.failure}`, one, meta));
|
|
596
|
+
if (b.resilience.note) add("resilience note", b.resilience.note);
|
|
597
|
+
if (b.rightSize) add("right-size", figureWithBadge(b.rightSize.suggestion + (b.rightSize.reason ? ` — ${b.rightSize.reason}` : ""), one, meta));
|
|
598
|
+
}
|
|
599
|
+
|
|
387
600
|
function inspect(node) {
|
|
388
601
|
const panel = document.getElementById("inspect-body");
|
|
389
602
|
panel.innerHTML = "<h2>inspect</h2>";
|
|
@@ -431,7 +644,7 @@ function inspect(node) {
|
|
|
431
644
|
"status",
|
|
432
645
|
driftLabel ||
|
|
433
646
|
(node.lexicon === "op" ? opStatusLabel(node) : undefined) ||
|
|
434
|
-
(isArtifact ? ARTIFACT_STATUS_LABEL[st] : liveStatus || tickStatus ? COMPONENT_STATUS_LABEL[st] :
|
|
647
|
+
(isArtifact ? ARTIFACT_STATUS_LABEL[st] : liveStatus || tickStatus ? COMPONENT_STATUS_LABEL[st] : statusLabel(st)) ||
|
|
435
648
|
st,
|
|
436
649
|
);
|
|
437
650
|
if (node.attrs && node.attrs._artifact) {
|
|
@@ -508,6 +721,13 @@ function inspect(node) {
|
|
|
508
721
|
});
|
|
509
722
|
}
|
|
510
723
|
|
|
724
|
+
// #401: the behaviour block, field by field, with a badge on every figure.
|
|
725
|
+
// The colour on the card carries one number at a time (whichever mode is
|
|
726
|
+
// picked) and carries it as a hue; this is where the engine's whole
|
|
727
|
+
// statement about this entity is legible — and where a figure without its
|
|
728
|
+
// provenance would be visible as the contract violation it is.
|
|
729
|
+
renderBehaviourSection(section, blockOf(node));
|
|
730
|
+
|
|
511
731
|
// Render diff (#146's deferred half): what `chant helm diff <digest> <env>
|
|
512
732
|
// --live --json` said about this chart's PINNED render. Never rely on the
|
|
513
733
|
// colour alone (#57's accessibility note) — and here that matters twice
|
|
@@ -520,7 +740,7 @@ function inspect(node) {
|
|
|
520
740
|
if (node.physicalId || node.ownership) {
|
|
521
741
|
const live = section("live");
|
|
522
742
|
if (node.physicalId) live("physical id", node.physicalId);
|
|
523
|
-
if (node.ownership) live("ownership", node.ownership);
|
|
743
|
+
if (node.ownership) live("ownership", ownershipLabel(node.ownership)); // #396 item 5: the estate's own word for it
|
|
524
744
|
} else if (liveStatus) {
|
|
525
745
|
// The colour alone doesn't carry chant's verdict or its reasoning — spell
|
|
526
746
|
// both out here (never rely on the node's colour alone, #57 accessibility
|
|
@@ -638,7 +858,19 @@ function inspect(node) {
|
|
|
638
858
|
const attrKeys = Object.keys(node.attrs || {}).filter((k) => !k.startsWith("_") && !(k === "release" && release));
|
|
639
859
|
if (attrKeys.length) {
|
|
640
860
|
const decl = section("declared");
|
|
641
|
-
|
|
861
|
+
// #393 item 10: an UNOWNED choudoufu card leads with the write that adopts
|
|
862
|
+
// the object — the two marker tags choudoufu named (`adopt_tofu_estate` /
|
|
863
|
+
// `adopt_tofu_address`), or its own one-line command if a release ever
|
|
864
|
+
// sends one. behold composes neither (src/choudoufu-live.ts); this puts
|
|
865
|
+
// the line first and makes it copyable, the way the moves panel already
|
|
866
|
+
// does for a `live-mv` line. The paragraph explaining the omission — which
|
|
867
|
+
// used to be the whole section, with the tags under it as JSON — follows.
|
|
868
|
+
const adoptable = node.lexicon === "choudoufu" && typeof node.attrs.adopt === "string" ? "adopt" : null;
|
|
869
|
+
if (adoptable) copyableRow(decl, adoptable, node.attrs.adopt, "Copy the tags that adopt this object");
|
|
870
|
+
for (const k of attrKeys) {
|
|
871
|
+
if (k === adoptable) continue;
|
|
872
|
+
decl(k, fmtValue(node.attrs[k]));
|
|
873
|
+
}
|
|
642
874
|
}
|
|
643
875
|
|
|
644
876
|
// CI projection facet (M1.2, #56/#58): loomster's GitLab CI is the SAME
|
|
@@ -738,6 +970,13 @@ function inspect(node) {
|
|
|
738
970
|
// node is instant (no per-node query). Via apiFetch, so a static export replays
|
|
739
971
|
// the captured snapshot. Cache is per env; cleared on lens change / after an op.
|
|
740
972
|
let bulkDiffCache = null; // { env, nodes: { <id>: { observed, diff, health } } }
|
|
973
|
+
// #404: ONE-SHOT. Set by the "Re-check live with plan" palette row and cleared
|
|
974
|
+
// by the request that carries it, so `plan=1` is exactly as opt-in as the
|
|
975
|
+
// person's keystroke — an ordinary load, a lens change or a reconnect never
|
|
976
|
+
// spawns a plan. The server still serves the change set that read produced
|
|
977
|
+
// from its own cache on the loads that follow, so the drift stays on screen
|
|
978
|
+
// without anyone paying for a second full pass over the account.
|
|
979
|
+
let planDriftOnce = false;
|
|
741
980
|
async function loadNodeDiff(id) {
|
|
742
981
|
if (!bulkDiffCache || bulkDiffCache.env !== view.env) {
|
|
743
982
|
bulkDiffCache = null;
|
|
@@ -787,7 +1026,7 @@ function renderObserved(panel, o, health, healthDetail) {
|
|
|
787
1026
|
if (o.type) add("type", o.type);
|
|
788
1027
|
if (o.status) add("status", o.status, HEALTH_COLOR[health] || undefined);
|
|
789
1028
|
if (o.physicalId) add("physical id", o.physicalId);
|
|
790
|
-
if (o.ownership) add("ownership", o.ownership);
|
|
1029
|
+
if (o.ownership) add("ownership", ownershipLabel(o.ownership)); // #396 item 5: same, off /api/diff
|
|
791
1030
|
if (o.lastUpdated) add("last updated", o.lastUpdated);
|
|
792
1031
|
// What the object's own controller says is wrong with it (#86, chant#1401).
|
|
793
1032
|
// Placed with health and status rather than among the attributes below,
|
|
@@ -802,11 +1041,15 @@ function renderObserved(panel, o, health, healthDetail) {
|
|
|
802
1041
|
// sends the raw condition object gets the tree instead of `[object Object]`.
|
|
803
1042
|
for (const c of conditions) add("condition", valueCell(c), "var(--degraded)");
|
|
804
1043
|
}
|
|
1044
|
+
// #393 item 10: the adoption line is a line to run, here as in the declared
|
|
1045
|
+
// section — first, and with the same copy button.
|
|
1046
|
+
if (typeof o.attributes?.adopt === "string") copyableRow(add, "adopt", o.attributes.adopt, "Copy the tags that adopt this object");
|
|
805
1047
|
// #259: an observed attribute is whatever the substrate reported — a k8s
|
|
806
1048
|
// `spec`, a nested `loadBalancer`, an array of ports. All of it collapsible
|
|
807
1049
|
// now, instead of one flat JSON.stringify line per key.
|
|
808
1050
|
for (const [k, v] of Object.entries(o.attributes || {})) {
|
|
809
1051
|
if (k === "conditions") continue; // rendered above, one line each
|
|
1052
|
+
if (k === "adopt" && typeof v === "string") continue; // led with, above
|
|
810
1053
|
add(k, valueCell(v));
|
|
811
1054
|
}
|
|
812
1055
|
panel.appendChild(dl);
|
|
@@ -814,6 +1057,11 @@ function renderObserved(panel, o, health, healthDetail) {
|
|
|
814
1057
|
|
|
815
1058
|
const DIFF_LABEL = {
|
|
816
1059
|
drifted: "drifted since snapshot",
|
|
1060
|
+
// #404: a choudoufu member has no snapshot to drift FROM — the category is
|
|
1061
|
+
// what a plan says it would do to the live object right now, so it does not
|
|
1062
|
+
// borrow chant's "since snapshot" wording. The rows underneath are the same
|
|
1063
|
+
// `{path, oldValue, newValue}` triples, printed `before → after`.
|
|
1064
|
+
planned: "the plan would change this",
|
|
817
1065
|
missing: "declared, not in cloud",
|
|
818
1066
|
orphan: "in cloud, not declared",
|
|
819
1067
|
disappeared: "gone since snapshot",
|
|
@@ -987,7 +1235,7 @@ function wire(ir) {
|
|
|
987
1235
|
// null on a project that declares no `stacks[]` at all — the picker (and the
|
|
988
1236
|
// status strip's stack tag) then never renders. Every fetch reads this, so
|
|
989
1237
|
// the `changed` SSE re-pull and a palette lens change go through the same path.
|
|
990
|
-
const view = { env: null, detail: 2, components: true, logical: false, runtime: false, ops: false, tier: null, target: null, stack: null, radial: false };
|
|
1238
|
+
const view = { env: null, detail: 2, components: true, logical: false, runtime: false, ops: false, tier: null, target: null, stack: null, radial: false, collapse: false, compareTo: null };
|
|
991
1239
|
|
|
992
1240
|
// #182: `components` is the boot default, but a project that declares no
|
|
993
1241
|
// components renders it as ZERO nodes — the first screen was a blank graph
|
|
@@ -997,6 +1245,20 @@ const view = { env: null, detail: 2, components: true, logical: false, runtime:
|
|
|
997
1245
|
// or ⌘K) still shows the honest empty view + the server's note.
|
|
998
1246
|
let autoZoomFallback = true;
|
|
999
1247
|
|
|
1248
|
+
// #393 item 1: for an estate whose members are NONE of them chant projects, the
|
|
1249
|
+
// fallback above is a round-trip too late and one degree too vague. The
|
|
1250
|
+
// components zoom is a chant projection of a chant project's own component DAG;
|
|
1251
|
+
// a choudoufu estate or a bare Terraform directory has no components to
|
|
1252
|
+
// project, so the first screen was "the components lens doesn't apply to a
|
|
1253
|
+
// composed estate yet" — an apology, every time, before the person had picked
|
|
1254
|
+
// anything. The kinds come from /api/project (`memberKinds`, in composition
|
|
1255
|
+
// order), so the decision is made from what the server already knows rather
|
|
1256
|
+
// than from a zero-node answer. A chant member anywhere in the estate keeps
|
|
1257
|
+
// #182's behaviour exactly, and picking components later still shows the note.
|
|
1258
|
+
function bootZoom(memberKinds) {
|
|
1259
|
+
return memberKinds && memberKinds.length && !memberKinds.includes("chant") ? "resources" : "components";
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1000
1262
|
// v0.1.0 preview lock (set from /api/project in initActions): hides the git/PR
|
|
1001
1263
|
// write ops (Rollback, Sync, Adopt, Run ▾) — the server also 403s them. Local
|
|
1002
1264
|
// deploy (Apply all / dial), Reset, Bring up, Approve, and reads stay on.
|
|
@@ -1029,22 +1291,47 @@ const ZOOM_OPTS = [
|
|
|
1029
1291
|
// same chant source), so it sits last, and only appears when the served
|
|
1030
1292
|
// estate has actually emitted Ops — `opsAvailable`, from /api/project.
|
|
1031
1293
|
["zoom: ops", "ops"],
|
|
1294
|
+
// The apply order (#426): the cross-stack ordering chant already computes at
|
|
1295
|
+
// synthesis — which lexicon partition must apply before which. A different
|
|
1296
|
+
// axis again, like `ops`: neither coarser nor finer than a resource, and the
|
|
1297
|
+
// only stop whose edges are about TIME rather than reference. Single-project
|
|
1298
|
+
// only and gated on `stacksCapable`, because two members' `aws` stacks are
|
|
1299
|
+
// not one stack.
|
|
1300
|
+
["zoom: apply order", "stacks"],
|
|
1032
1301
|
];
|
|
1033
1302
|
const ZOOM_DETAIL = { composites: 1, resources: 2, attributes: 3, runtime: 3 };
|
|
1034
1303
|
/** How many emitted Ops the served estate has (0 = the stop doesn't exist).
|
|
1035
1304
|
* Seeded from /api/project in initPickers(). */
|
|
1036
1305
|
let opsAvailable = 0;
|
|
1037
|
-
/**
|
|
1038
|
-
*
|
|
1039
|
-
*
|
|
1306
|
+
/** #393 item 2: whether ANY served member could have owner-referenced children
|
|
1307
|
+
* — /api/project's `runtimeCapable`, decided from the members' declared
|
|
1308
|
+
* lexicons (src/server.ts's RUNTIME_LEXICONS). Before this the stop needed only
|
|
1309
|
+
* an env, so every choudoufu estate (which declares `live`) offered a
|
|
1310
|
+
* Kubernetes tier that could only ever answer "nothing below the declaration
|
|
1311
|
+
* boundary". */
|
|
1312
|
+
let runtimeCapable = false;
|
|
1313
|
+
/** #422: the members whose live read has not landed yet, off the progressive
|
|
1314
|
+
* overlay's `meta.pending`. Empty on every non-progressive load, which is every
|
|
1315
|
+
* single-project serve and every static snapshot. */
|
|
1316
|
+
let pendingMembers = [];
|
|
1317
|
+
let pendingTotal = 0;
|
|
1318
|
+
/** #426: whether the apply-order stop is worth offering — /api/project's
|
|
1319
|
+
* `stacksCapable`, which is false for an estate and for a single-lexicon
|
|
1320
|
+
* project, where the answer could only ever be one box. */
|
|
1321
|
+
let stacksCapable = false;
|
|
1322
|
+
/** The zoom stops this project can actually offer — `runtime` needs an env AND
|
|
1323
|
+
* a member whose substrate has an owner chain, `ops` needs emitted op.json
|
|
1324
|
+
* files. Both the panel and ⌘K read this, so a stop never appears in one
|
|
1325
|
+
* surface and not the other. */
|
|
1040
1326
|
function availableZooms() {
|
|
1041
|
-
return ZOOM_OPTS.filter(([, z]) => (z === "runtime" ? Boolean(view.env) : z === "ops" ? opsAvailable > 0 : true));
|
|
1327
|
+
return ZOOM_OPTS.filter(([, z]) => (z === "runtime" ? Boolean(view.env) && runtimeCapable : z === "ops" ? opsAvailable > 0 : z === "stacks" ? stacksCapable : true));
|
|
1042
1328
|
}
|
|
1043
1329
|
/** Current zoom value from (components, logical, ops, detail). */
|
|
1044
1330
|
function zoomValue() {
|
|
1045
1331
|
if (view.components) return "components";
|
|
1046
1332
|
if (view.logical) return "logical";
|
|
1047
1333
|
if (view.ops) return "ops";
|
|
1334
|
+
if (view.stacks) return "stacks";
|
|
1048
1335
|
if (view.runtime) return "runtime";
|
|
1049
1336
|
return { 1: "composites", 2: "resources", 3: "attributes" }[view.detail] ?? "resources";
|
|
1050
1337
|
}
|
|
@@ -1053,8 +1340,9 @@ function applyZoom(z) {
|
|
|
1053
1340
|
view.components = z === "components";
|
|
1054
1341
|
view.logical = z === "logical";
|
|
1055
1342
|
view.ops = z === "ops";
|
|
1343
|
+
view.stacks = z === "stacks";
|
|
1056
1344
|
view.runtime = z === "runtime";
|
|
1057
|
-
if (z !== "components" && z !== "logical" && z !== "ops") view.detail = ZOOM_DETAIL[z] ?? 2;
|
|
1345
|
+
if (z !== "components" && z !== "logical" && z !== "ops" && z !== "stacks") view.detail = ZOOM_DETAIL[z] ?? 2;
|
|
1058
1346
|
}
|
|
1059
1347
|
|
|
1060
1348
|
// --- Floating control panel content ---------------------------------------
|
|
@@ -1109,6 +1397,28 @@ function renderPanelView() {
|
|
|
1109
1397
|
}),
|
|
1110
1398
|
);
|
|
1111
1399
|
}
|
|
1400
|
+
// #399: the three-state colour switch, beside the zoom — the other thing
|
|
1401
|
+
// this tab is for is "what am I looking at", and colour is half that answer.
|
|
1402
|
+
// Sits below the stops because it is not a granularity: every zoom paints in
|
|
1403
|
+
// whichever of the three you picked.
|
|
1404
|
+
zoom.appendChild(panelHeading("colour by"));
|
|
1405
|
+
for (const mode of COLOUR_MODES) {
|
|
1406
|
+
const gate = mode === "drift" ? { available: true, reason: "" } : modeAvailability(lastMeta, mode);
|
|
1407
|
+
const b = panelOpt(
|
|
1408
|
+
mode,
|
|
1409
|
+
mode === activeColourMode(),
|
|
1410
|
+
() => {
|
|
1411
|
+
if (!gate.available) return;
|
|
1412
|
+
setColourMode(mode);
|
|
1413
|
+
},
|
|
1414
|
+
gate.available ? COLOUR_MODE_TITLE[mode] : gate.reason,
|
|
1415
|
+
);
|
|
1416
|
+
// #401: disabled VISIBLY, with the lexicon's own reason on it. A control
|
|
1417
|
+
// greyed out is where a person asks "why not", so that is where the answer
|
|
1418
|
+
// goes — never hidden, which would leave the question unasked.
|
|
1419
|
+
if (!gate.available) b.disabled = true;
|
|
1420
|
+
zoom.appendChild(b);
|
|
1421
|
+
}
|
|
1112
1422
|
// Radial toggle — entity zooms only, same gate the ⌘K entry has
|
|
1113
1423
|
// (components/logical/ops all lay themselves out: waves / nested arch boxes /
|
|
1114
1424
|
// phase boxes).
|
|
@@ -1270,6 +1580,163 @@ function renderCarveState(host, state) {
|
|
|
1270
1580
|
host.appendChild(panelMuted(state.apply.note));
|
|
1271
1581
|
}
|
|
1272
1582
|
|
|
1583
|
+
/**
|
|
1584
|
+
* One inspect row whose value is a line to run: the line itself, monospaced,
|
|
1585
|
+
* and a copy button beside it — the affordance the moves panel already gives a
|
|
1586
|
+
* `live-mv` line (#371), reused here for the tags that adopt an unowned object
|
|
1587
|
+
* (#393 item 10). `add` is a section's own row adder, so the row sits in the
|
|
1588
|
+
* section it belongs to rather than in a box of its own.
|
|
1589
|
+
*/
|
|
1590
|
+
function copyableRow(add, key, text, title) {
|
|
1591
|
+
const wrap = document.createElement("span");
|
|
1592
|
+
// `center`, not `baseline`: an `overflow: hidden` flex item takes its bottom
|
|
1593
|
+
// margin edge as its baseline, so the clipped line below would have dragged
|
|
1594
|
+
// the copy button down with it.
|
|
1595
|
+
wrap.style.cssText = "display:flex;gap:6px;align-items:center;min-width:0";
|
|
1596
|
+
const line = document.createElement("code");
|
|
1597
|
+
line.className = "grow";
|
|
1598
|
+
// #396 item 7c: ONE line, clipped. `tofu-estate=… tofu-address=…` wrapped
|
|
1599
|
+
// onto three lines in the 260px pane and pushed everything the card had to
|
|
1600
|
+
// say off the bottom — for a string nobody reads off the screen anyway,
|
|
1601
|
+
// because the copy button beside it is how it gets used. The whole of it is
|
|
1602
|
+
// on this row's tooltip, on the button's, and in the clipboard.
|
|
1603
|
+
line.style.cssText = "flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis";
|
|
1604
|
+
line.textContent = text;
|
|
1605
|
+
line.title = text;
|
|
1606
|
+
const copy = actButton("copy", () => copyToClipboard(text, copy), title ? `${title}:\n${text}` : text);
|
|
1607
|
+
wrap.append(line, copy);
|
|
1608
|
+
add(key, wrap);
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
/** Copy `text` to the clipboard and say so on the button for a moment. */
|
|
1612
|
+
function copyToClipboard(text, el) {
|
|
1613
|
+
const done = () => {
|
|
1614
|
+
const was = el.textContent;
|
|
1615
|
+
el.textContent = "copied ✓";
|
|
1616
|
+
setTimeout(() => {
|
|
1617
|
+
el.textContent = was;
|
|
1618
|
+
}, 1200);
|
|
1619
|
+
};
|
|
1620
|
+
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done, done);
|
|
1621
|
+
else done();
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
/**
|
|
1625
|
+
* The choudoufu move plan (#371), as the scope panel shows it: one row per
|
|
1626
|
+
* move (`address: from → to`), the line a person runs in the destination
|
|
1627
|
+
* estate's directory with a copy button, and a link to the morph page. The
|
|
1628
|
+
* plan is read from `/api/choudoufu/moves`, which previews with choudoufu's
|
|
1629
|
+
* own `-dry-run` when asked and never runs the write — `apply.note` carries
|
|
1630
|
+
* that sentence here verbatim. A no-op when no served member carries a
|
|
1631
|
+
* `carve.json`.
|
|
1632
|
+
*/
|
|
1633
|
+
function renderChoudoufuMoves(host, info) {
|
|
1634
|
+
if (!info || !info.plans || !info.plans.length || staticMode) return;
|
|
1635
|
+
host.appendChild(panelHeading("moves (choudoufu)"));
|
|
1636
|
+
for (const plan of info.plans) {
|
|
1637
|
+
const head = document.createElement("div");
|
|
1638
|
+
head.className = "count-row";
|
|
1639
|
+
const label = document.createElement("span");
|
|
1640
|
+
label.className = "grow";
|
|
1641
|
+
label.textContent = plan;
|
|
1642
|
+
const morph = document.createElement("a");
|
|
1643
|
+
morph.className = "tag";
|
|
1644
|
+
morph.textContent = "morph ↗";
|
|
1645
|
+
morph.href = "/choudoufu/morph?plan=" + encodeURIComponent(plan);
|
|
1646
|
+
morph.target = "_blank";
|
|
1647
|
+
morph.title = "Every member a box, the moved cards gliding to their destination";
|
|
1648
|
+
head.append(label, morph);
|
|
1649
|
+
host.appendChild(head);
|
|
1650
|
+
const rows = document.createElement("div");
|
|
1651
|
+
host.appendChild(rows);
|
|
1652
|
+
apiFetch("/api/choudoufu/moves?plan=" + encodeURIComponent(plan))
|
|
1653
|
+
.then((r) => r.json())
|
|
1654
|
+
.then((body) => {
|
|
1655
|
+
if (!body || !Array.isArray(body.moves)) {
|
|
1656
|
+
rows.appendChild(panelMuted((body && body.error) || "plan unreadable"));
|
|
1657
|
+
return;
|
|
1658
|
+
}
|
|
1659
|
+
for (const m of body.moves) {
|
|
1660
|
+
const row = document.createElement("div");
|
|
1661
|
+
row.className = "count-row";
|
|
1662
|
+
const name = document.createElement("span");
|
|
1663
|
+
name.className = "grow";
|
|
1664
|
+
name.textContent = m.address + (m.newAddress ? " → " + m.newAddress : "");
|
|
1665
|
+
name.title = "run in " + m.runIn;
|
|
1666
|
+
const tag = document.createElement("span");
|
|
1667
|
+
tag.className = "tag";
|
|
1668
|
+
tag.textContent = m.from + " → " + m.to;
|
|
1669
|
+
const copy = actButton("copy", () => copyToClipboard(m.command, copy), "Copy the live-mv line to the clipboard");
|
|
1670
|
+
row.append(name, tag, copy);
|
|
1671
|
+
rows.appendChild(row);
|
|
1672
|
+
rows.appendChild(panelMuted(m.command));
|
|
1673
|
+
}
|
|
1674
|
+
if (body.apply && body.apply.note) rows.appendChild(panelMuted(body.apply.note));
|
|
1675
|
+
})
|
|
1676
|
+
.catch(() => rows.appendChild(panelMuted("plan unreadable — is the behold server still running?")));
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
/**
|
|
1681
|
+
* The totals, per box and per estate, in the active mode (#399).
|
|
1682
|
+
*
|
|
1683
|
+
* Sits in the Scope tab because Scope is already the tab that says what this
|
|
1684
|
+
* estate IS — its members, its moves, its environments — and a per-box figure
|
|
1685
|
+
* is a fact about a member, not about the picture.
|
|
1686
|
+
*
|
|
1687
|
+
* In `drift` mode there is no row at all. Drift counts states; a sum of states
|
|
1688
|
+
* is not a thing, and the Model tab's legend already carries the counts.
|
|
1689
|
+
*
|
|
1690
|
+
* Where the figure comes from is on the row, every time. `cost` quotes the
|
|
1691
|
+
* server: `meta.behaviour.boxes[<key>]` per box and `.sum` for the estate, both
|
|
1692
|
+
* additions of engine figures — or `meta.behaviour.total`, the engine's own
|
|
1693
|
+
* estate figure, which outranks the sum and is labelled "engine total" so the
|
|
1694
|
+
* two can never be confused. `headroom` has no server-side aggregate, because
|
|
1695
|
+
* no engine states one and adding fractions of capacity would mean nothing, so
|
|
1696
|
+
* the SPA takes the worst and middle card of the scope itself and the row says
|
|
1697
|
+
* "computed".
|
|
1698
|
+
*/
|
|
1699
|
+
function renderBehaviourTotals(host) {
|
|
1700
|
+
const mode = activeColourMode();
|
|
1701
|
+
if (mode === "drift") return;
|
|
1702
|
+
const b = behaviourMeta();
|
|
1703
|
+
const ir = lastGraphIr;
|
|
1704
|
+
if (!b || !ir) return;
|
|
1705
|
+
const byId = new Map(ir.nodes.map((n) => [n.id, n]));
|
|
1706
|
+
const boxes = (ir.groups && ir.groups.byStack) || {};
|
|
1707
|
+
host.appendChild(panelHeading(`${mode} totals`));
|
|
1708
|
+
|
|
1709
|
+
const row = (label, blocks, sum, engineTotal, title) => {
|
|
1710
|
+
const totals = totalsFor(mode, blocks, sum, engineTotal);
|
|
1711
|
+
if (!totals) return;
|
|
1712
|
+
// Stacked, not a name/value row: the panel is 260px and one of these
|
|
1713
|
+
// lines is "0.5669 USD/h · 41 priced · 4 unpriced" — side by side, the
|
|
1714
|
+
// figure takes the width and the box's name ellipses away to nothing,
|
|
1715
|
+
// which is the one part of the row that says WHAT is being totalled.
|
|
1716
|
+
const r = document.createElement("div");
|
|
1717
|
+
r.className = "behaviour-total";
|
|
1718
|
+
const name = document.createElement("div");
|
|
1719
|
+
name.className = "grow";
|
|
1720
|
+
name.style.fontWeight = "600";
|
|
1721
|
+
name.textContent = label;
|
|
1722
|
+
if (title) name.title = title;
|
|
1723
|
+
r.appendChild(name);
|
|
1724
|
+
r.appendChild(figureWithBadge(totalsText(totals), blocks.filter(Boolean), b));
|
|
1725
|
+
host.appendChild(r);
|
|
1726
|
+
};
|
|
1727
|
+
|
|
1728
|
+
for (const [key, ids] of Object.entries(boxes).sort(([a], [c]) => a.localeCompare(c))) {
|
|
1729
|
+
const blocks = ids.map((id) => blockOf(byId.get(id)));
|
|
1730
|
+
row(key, blocks, (b.boxes || {})[key], undefined, `${ids.length} entities in this box`);
|
|
1731
|
+
}
|
|
1732
|
+
const all = ir.nodes.map((n) => blockOf(n));
|
|
1733
|
+
// `total` is the engine's word and only ever holds an engine's own figure;
|
|
1734
|
+
// `sum` is behold's addition. Passing both lets totalsFor apply #398's
|
|
1735
|
+
// precedence in one place rather than at each call site.
|
|
1736
|
+
row(b.total ? "estate · engine total" : "estate", all, b.sum, b.total, b.total ? "the engine stated this estate total itself; behold added nothing" : "behold's sum over the engine's per-entity figures");
|
|
1737
|
+
if (mode === "headroom") host.appendChild(panelMuted("min and median computed by behold over the engine's per-entity figures — no engine states a headroom aggregate."));
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1273
1740
|
function renderPanelScope() {
|
|
1274
1741
|
const host = document.getElementById("tab-scope");
|
|
1275
1742
|
if (!host) return;
|
|
@@ -1301,6 +1768,13 @@ function renderPanelScope() {
|
|
|
1301
1768
|
// one — and it renders identically in carve mode and on an ordinary project
|
|
1302
1769
|
// serve, because /api/project publishes one shape for both.
|
|
1303
1770
|
renderCarveState(host, info.carve && info.carve.state);
|
|
1771
|
+
// #399: what the estate costs, or how much room it has left, per member box
|
|
1772
|
+
// and for the whole estate — right under the member list the figures are
|
|
1773
|
+
// about, and above the moves that would change them.
|
|
1774
|
+
renderBehaviourTotals(host);
|
|
1775
|
+
// #371: a choudoufu member's move plan — the handoff lines with copy
|
|
1776
|
+
// buttons, and the morph page. Read-only: behold never runs live-mv.
|
|
1777
|
+
renderChoudoufuMoves(host, info.choudoufu);
|
|
1304
1778
|
// #195: switching — recents first (server-persisted, validated), then a
|
|
1305
1779
|
// free path input. Locked in preview mode (the demo's contract) and
|
|
1306
1780
|
// meaningless in a static export.
|
|
@@ -1362,6 +1836,57 @@ function renderPanelScope() {
|
|
|
1362
1836
|
);
|
|
1363
1837
|
}
|
|
1364
1838
|
if (!environments.length) host.appendChild(panelMuted("no environments declared"));
|
|
1839
|
+
|
|
1840
|
+
// #234's last remainder — "is prod running what staging tested". Only when
|
|
1841
|
+
// an env is picked and there is another to compare against; ledger-only, so
|
|
1842
|
+
// it never adds a cloud read to opening the panel (fetch happens on pick).
|
|
1843
|
+
if (view.env && environments.length > 1 && !staticMode) {
|
|
1844
|
+
if (view.compareTo === view.env) view.compareTo = null; // env re-picked onto the compared one
|
|
1845
|
+
host.appendChild(panelHeading("compare releases"));
|
|
1846
|
+
const sel = document.createElement("select");
|
|
1847
|
+
sel.add(new Option("(off)", "", view.compareTo == null, view.compareTo == null));
|
|
1848
|
+
for (const e of environments) {
|
|
1849
|
+
if (e === view.env) continue;
|
|
1850
|
+
sel.add(new Option(`vs ${e}`, e, false, view.compareTo === e));
|
|
1851
|
+
}
|
|
1852
|
+
sel.title = "Compare this environment's recorded releases with another's — per component, on the comparable identity (inputs for pinned deploys)";
|
|
1853
|
+
const result = document.createElement("div");
|
|
1854
|
+
const render = (payload) => {
|
|
1855
|
+
result.replaceChildren();
|
|
1856
|
+
const s = comparisonSummary(payload);
|
|
1857
|
+
const line = document.createElement("div");
|
|
1858
|
+
line.style.cssText = `font-size:var(--t-caption);color:${COMPONENT_STATUS_VAR[s.tone === "warn" ? "warn" : s.tone === "good" ? "good" : "neutral"]};margin:4px 0`;
|
|
1859
|
+
line.textContent = s.text;
|
|
1860
|
+
result.appendChild(line);
|
|
1861
|
+
for (const c of payload.comparisons || []) {
|
|
1862
|
+
const r = comparisonRow(c);
|
|
1863
|
+
const row = panelDotRow(COMPONENT_STATUS_VAR[r.tone === "warn" ? "warn" : r.tone === "good" ? "good" : "neutral"], r.component, r.verdict, () => selectNode(r.component));
|
|
1864
|
+
row.title = r.detail;
|
|
1865
|
+
result.appendChild(row);
|
|
1866
|
+
}
|
|
1867
|
+
};
|
|
1868
|
+
sel.addEventListener("change", () => {
|
|
1869
|
+
view.compareTo = sel.value || null;
|
|
1870
|
+
result.replaceChildren();
|
|
1871
|
+
if (!view.compareTo) return;
|
|
1872
|
+
result.appendChild(panelMuted("comparing…"));
|
|
1873
|
+
fetch(`/api/components/compare?env=${encodeURIComponent(view.env)}&to=${encodeURIComponent(view.compareTo)}`)
|
|
1874
|
+
.then((r) => r.json())
|
|
1875
|
+
.then((payload) => {
|
|
1876
|
+
if (view.compareTo !== sel.value) return; // re-picked while loading
|
|
1877
|
+
if (payload.error) {
|
|
1878
|
+
result.replaceChildren(panelMuted(payload.error));
|
|
1879
|
+
return;
|
|
1880
|
+
}
|
|
1881
|
+
render(payload);
|
|
1882
|
+
})
|
|
1883
|
+
.catch((err) => result.replaceChildren(panelMuted(`compare failed: ${err && err.message ? err.message : err}`)));
|
|
1884
|
+
});
|
|
1885
|
+
host.appendChild(sel);
|
|
1886
|
+
host.appendChild(result);
|
|
1887
|
+
// Restore an active comparison across panel re-renders.
|
|
1888
|
+
if (view.compareTo) sel.dispatchEvent(new Event("change"));
|
|
1889
|
+
}
|
|
1365
1890
|
if (stacks.length) {
|
|
1366
1891
|
host.appendChild(panelHeading("stack"));
|
|
1367
1892
|
for (const s of stacks) {
|
|
@@ -1410,6 +1935,103 @@ function renderPanelScope() {
|
|
|
1410
1935
|
const DRIFT_STATUS_VAR = { good: "var(--managed)", warn: "var(--foreign)", accent: "var(--pending)", neutral: "var(--muted)", runtime: "var(--runtime)" };
|
|
1411
1936
|
const COMPONENT_STATUS_VAR = { good: "var(--managed)", accent: "var(--pending)", warn: "var(--degraded)", neutral: "var(--muted)" };
|
|
1412
1937
|
|
|
1938
|
+
/**
|
|
1939
|
+
* The provenance badge (#401, M4 of #397) — `{engine} {version} · {tolerance}
|
|
1940
|
+
* · {basis}`, beside a figure rather than once per page.
|
|
1941
|
+
*
|
|
1942
|
+
* #397's one prohibition is a prediction presented as a bill, and this element
|
|
1943
|
+
* is where that is prevented: the word `modeled` is on the badge and the
|
|
1944
|
+
* sentence "modeled, not billed" is on its hover, on every figure that carries
|
|
1945
|
+
* one. `blocks` is the entities the figure was derived from — one for an
|
|
1946
|
+
* inspect row, a whole box or estate for a totals row — because #398 puts
|
|
1947
|
+
* provenance on the ENTITY, so a badge over a set can only say what the set
|
|
1948
|
+
* agrees on (badgeFor says `mixed` where it does not).
|
|
1949
|
+
*/
|
|
1950
|
+
function badgeEl(blocks, meta) {
|
|
1951
|
+
const b = badgeFor(blocks, meta);
|
|
1952
|
+
if (!b) return null;
|
|
1953
|
+
const el = document.createElement("span");
|
|
1954
|
+
el.className = "tag behaviour-badge";
|
|
1955
|
+
el.textContent = b.text;
|
|
1956
|
+
el.title = b.title;
|
|
1957
|
+
return el;
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
/** A figure and its badge on one line — the shape every behaviour figure the
|
|
1961
|
+
* SPA shows takes, so none of them can end up without one. */
|
|
1962
|
+
function figureWithBadge(text, blocks, meta) {
|
|
1963
|
+
const wrap = document.createElement("span");
|
|
1964
|
+
wrap.style.cssText = "display:flex;gap:6px;align-items:baseline;flex-wrap:wrap;min-width:0";
|
|
1965
|
+
const val = document.createElement("span");
|
|
1966
|
+
val.textContent = text;
|
|
1967
|
+
wrap.appendChild(val);
|
|
1968
|
+
const badge = badgeEl(blocks, meta);
|
|
1969
|
+
if (badge) wrap.appendChild(badge);
|
|
1970
|
+
return wrap;
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
/**
|
|
1974
|
+
* One mode's legend (#399: one legend per mode).
|
|
1975
|
+
*
|
|
1976
|
+
* `drift` is not here — it keeps #393's vocabulary legend, the estate's own
|
|
1977
|
+
* words for the four states, which is the whole point of that issue. The two
|
|
1978
|
+
* behaviour modes get a ramp with its two ends labelled, the priced/unpriced
|
|
1979
|
+
* split, the neutral swatch that says what an unpriced card is drawn in, and
|
|
1980
|
+
* the badge for the figures the ramp spans.
|
|
1981
|
+
*/
|
|
1982
|
+
function renderBehaviourLegend(host, mode) {
|
|
1983
|
+
const b = behaviourMeta();
|
|
1984
|
+
const ir = lastGraphIr;
|
|
1985
|
+
if (!b || !ir) return;
|
|
1986
|
+
const blocks = ir.nodes.map((n) => blockOf(n));
|
|
1987
|
+
const values = ir.nodes.map((n) => figureOf(mode, blockOf(n)));
|
|
1988
|
+
const domain = domainFor(mode, values);
|
|
1989
|
+
const tokens = getTokens();
|
|
1990
|
+
const ramp = rampFor(mode, tokens);
|
|
1991
|
+
const currency = (blocks.find((x) => x && x.cost) || { cost: {} }).cost.currency || "";
|
|
1992
|
+
const heading = panelHeading(`${mode} · ${lastMeta.env}`);
|
|
1993
|
+
heading.title = COLOUR_MODE_TITLE[mode];
|
|
1994
|
+
host.appendChild(heading);
|
|
1995
|
+
|
|
1996
|
+
const bar = document.createElement("div");
|
|
1997
|
+
bar.className = "behaviour-ramp";
|
|
1998
|
+
bar.style.cssText = `height:10px;border-radius:5px;border:1px solid var(--line);background:${rampGradient(ramp)}`;
|
|
1999
|
+
host.appendChild(bar);
|
|
2000
|
+
const ends = scaleEnds(mode, domain, currency);
|
|
2001
|
+
if (ends) {
|
|
2002
|
+
const row = document.createElement("div");
|
|
2003
|
+
row.className = "count-row";
|
|
2004
|
+
const lo = document.createElement("span");
|
|
2005
|
+
lo.className = "grow";
|
|
2006
|
+
lo.textContent = ends.low;
|
|
2007
|
+
const hi = document.createElement("span");
|
|
2008
|
+
hi.className = "tag";
|
|
2009
|
+
hi.textContent = ends.high;
|
|
2010
|
+
row.append(lo, hi);
|
|
2011
|
+
host.appendChild(row);
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
const counts = countFigures(mode, blocks);
|
|
2015
|
+
const priced = blocks.filter((x) => x && figureOf(mode, x) !== undefined);
|
|
2016
|
+
const line = document.createElement("div");
|
|
2017
|
+
line.className = "count-row";
|
|
2018
|
+
const txt = document.createElement("span");
|
|
2019
|
+
txt.className = "grow";
|
|
2020
|
+
txt.textContent = countsText(counts);
|
|
2021
|
+
line.appendChild(txt);
|
|
2022
|
+
const badge = badgeEl(priced, b);
|
|
2023
|
+
if (badge) line.appendChild(badge);
|
|
2024
|
+
host.appendChild(line);
|
|
2025
|
+
|
|
2026
|
+
// The neutral, spelled out. Without this row the grey cards read as a third
|
|
2027
|
+
// band of the scale rather than as "nothing priced these".
|
|
2028
|
+
const theme = getTheme();
|
|
2029
|
+
host.appendChild(
|
|
2030
|
+
panelDotRow(theme ? pinTokensFor(theme).neutralFill : "var(--muted)", "unpriced — no behaviour block", String(counts.unpriced)),
|
|
2031
|
+
);
|
|
2032
|
+
if (mode === "headroom") host.appendChild(panelMuted("the lower of the axes the engine reported; a missing axis is absent, not zero"));
|
|
2033
|
+
}
|
|
2034
|
+
|
|
1413
2035
|
function panelDotRow(color, main, tag, onClick) {
|
|
1414
2036
|
const row = document.createElement("div");
|
|
1415
2037
|
row.className = onClick ? "node-row" : "count-row";
|
|
@@ -1454,6 +2076,15 @@ function renderPanelModel() {
|
|
|
1454
2076
|
host.appendChild(panelMuted("no graph loaded yet"));
|
|
1455
2077
|
return;
|
|
1456
2078
|
}
|
|
2079
|
+
// #393 item 7: the note in full, where there is room for it. The strip may be
|
|
2080
|
+
// showing the server's short form (a 260px strip cannot hold five root names,
|
|
2081
|
+
// two skip reasons and five block counts), so the whole sentence has to be
|
|
2082
|
+
// readable somewhere that is not a tooltip — and this tab is where the panel
|
|
2083
|
+
// already explains the picture.
|
|
2084
|
+
if (lastNote && lastNoteShort) {
|
|
2085
|
+
host.appendChild(panelHeading("note"));
|
|
2086
|
+
host.appendChild(panelMuted(lastNote));
|
|
2087
|
+
}
|
|
1457
2088
|
const drift = m.mode === "overlay" || (m.mode === "logical" && !!m.env);
|
|
1458
2089
|
const componentStatus = m.mode === "component-status";
|
|
1459
2090
|
const count = (statuses) => {
|
|
@@ -1475,15 +2106,55 @@ function renderPanelModel() {
|
|
|
1475
2106
|
const s = n.attrs && n.attrs._status;
|
|
1476
2107
|
host.appendChild(panelDotRow(COMPONENT_STATUS_VAR[s] || "var(--muted)", n.id, APPLY_STATUS_TAG[s] || "", () => selectNode(n.id)));
|
|
1477
2108
|
}
|
|
2109
|
+
} else if (drift && activeColourMode() !== "drift") {
|
|
2110
|
+
// #399: one legend per mode. The drift legend below is #393's vocabulary
|
|
2111
|
+
// legend and stays exactly as it was — this branch is the two behaviour
|
|
2112
|
+
// modes, whose legend is a scale rather than a set of states.
|
|
2113
|
+
renderBehaviourLegend(host, activeColourMode());
|
|
1478
2114
|
} else if (drift) {
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
2115
|
+
// #393 item 8: the legend proper. The heading carries the mixed-estate
|
|
2116
|
+
// sentence as its tooltip — the one case where the words on these rows are
|
|
2117
|
+
// not the words the members would use for themselves.
|
|
2118
|
+
const heading = panelHeading(`drift · ${m.env}`);
|
|
2119
|
+
const why = vocabularyNote();
|
|
2120
|
+
if (why) heading.title = why;
|
|
2121
|
+
host.appendChild(heading);
|
|
2122
|
+
const words = statusLabels();
|
|
2123
|
+
const c = count(words);
|
|
2124
|
+
for (const [k, label] of Object.entries(words)) {
|
|
1482
2125
|
// The additive buckets (chant#1168 unobserved, chant#1180 runtime) stay
|
|
1483
2126
|
// hidden until a chant actually emits them — same as the old legend.
|
|
1484
2127
|
if ((k === "neutral" || k === "runtime") && !c[k]) continue;
|
|
1485
2128
|
host.appendChild(panelDotRow(DRIFT_STATUS_VAR[k], label, String(c[k])));
|
|
1486
2129
|
}
|
|
2130
|
+
// #404: attribute drift, on its own row under the ownership counts —
|
|
2131
|
+
// beside them, never mixed into them, because a drifted card is still
|
|
2132
|
+
// bound and is already counted on the row above. The row appears only when
|
|
2133
|
+
// a plan was actually read; where it was not, the legend says so in words
|
|
2134
|
+
// and prints no number, because "0 drifted" would be a claim nobody made.
|
|
2135
|
+
// #429: a card with NO `_status` at all. pinhole's painter falls back to
|
|
2136
|
+
// `neutral` for an unrecognised value, `undefined` included, so a member
|
|
2137
|
+
// with no live half — a Terraform root, whose `live`/`overlay`/`env` are
|
|
2138
|
+
// stripped in src/terraform-member.ts — paints exactly like a card behold
|
|
2139
|
+
// looked at and could not see. It is in no legend bucket either, so the
|
|
2140
|
+
// colour claimed 37 unobserved cards on an estate whose legend counted 2.
|
|
2141
|
+
//
|
|
2142
|
+
// Named rather than folded into `unobserved`, because they are different
|
|
2143
|
+
// claims: "I looked and could not see" against "there is nothing here to
|
|
2144
|
+
// look at". Same argument #399 makes for an unpriced card, and the same
|
|
2145
|
+
// answer — mark it, do not merely colour it.
|
|
2146
|
+
const noLive = ir.nodes.filter((n) => !(n.attrs && n.attrs._status)).length;
|
|
2147
|
+
if (noLive) {
|
|
2148
|
+
const row = panelDotRow("var(--muted)", "no live half", String(noLive));
|
|
2149
|
+
row.title = "These members declare no live half, so nothing observed them — which is not the same as unobserved, and not the same as absent.";
|
|
2150
|
+
host.appendChild(row);
|
|
2151
|
+
}
|
|
2152
|
+
const dm = driftMeta();
|
|
2153
|
+
if (dm && dm.read) {
|
|
2154
|
+
host.appendChild(panelDotRow("var(--degraded)", "drifted (the plan would change it)", String(dm.drifted)));
|
|
2155
|
+
} else if (dm) {
|
|
2156
|
+
host.appendChild(panelMuted("attribute drift not read — the overlay is ownership only. ⌘K → “Re-check live with plan”."));
|
|
2157
|
+
}
|
|
1487
2158
|
// The actionable nodes — foreign (adoptable) and pending (not applied yet).
|
|
1488
2159
|
const attention = ir.nodes.filter((n) => {
|
|
1489
2160
|
const s = n.attrs && n.attrs._status;
|
|
@@ -1493,7 +2164,7 @@ function renderPanelModel() {
|
|
|
1493
2164
|
host.appendChild(panelHeading("needs attention"));
|
|
1494
2165
|
for (const n of attention.slice(0, 40)) {
|
|
1495
2166
|
const s = n.attrs._status;
|
|
1496
|
-
host.appendChild(panelDotRow(DRIFT_STATUS_VAR[s], n.id,
|
|
2167
|
+
host.appendChild(panelDotRow(DRIFT_STATUS_VAR[s], n.id, statusLabel(s), () => selectNode(n.id)));
|
|
1497
2168
|
}
|
|
1498
2169
|
if (attention.length > 40) host.appendChild(panelMuted(`+ ${attention.length - 40} more — click nodes in the graph`));
|
|
1499
2170
|
}
|
|
@@ -1505,6 +2176,45 @@ function renderPanelModel() {
|
|
|
1505
2176
|
host.appendChild(actButton("→ Scope", () => setPanelTab("scope")));
|
|
1506
2177
|
}
|
|
1507
2178
|
}
|
|
2179
|
+
renderBehaviourRefusal(host);
|
|
2180
|
+
renderBehaviourDiagnostics(host);
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
/**
|
|
2184
|
+
* The refusal, where the legend would be (#401).
|
|
2185
|
+
*
|
|
2186
|
+
* The words are the lexicon's, in its own house style — an env-var chain and
|
|
2187
|
+
* the variable it wants — and behold prints them as they came. It does not
|
|
2188
|
+
* paraphrase them, does not soften them, and above all does not substitute a
|
|
2189
|
+
* number: "a missing or unreachable engine means no overlay and a refusal that
|
|
2190
|
+
* names why, never a locally faked number" is #397's rule, and this is the one
|
|
2191
|
+
* surface where a person finds out it fired.
|
|
2192
|
+
*
|
|
2193
|
+
* An ABSENCE prints nothing here. Nothing was configured, so nothing refused —
|
|
2194
|
+
* and a panel that announced every estate with no behavioural engine would be
|
|
2195
|
+
* shouting about a feature nobody asked for. The absent line is on the disabled
|
|
2196
|
+
* modes' tooltips instead, where the question actually gets asked.
|
|
2197
|
+
*/
|
|
2198
|
+
function renderBehaviourRefusal(host) {
|
|
2199
|
+
const b = behaviourMeta();
|
|
2200
|
+
if (!b || !b.refusal) return;
|
|
2201
|
+
host.appendChild(panelHeading("behaviour — refused"));
|
|
2202
|
+
const reason = panelMuted(b.refusal.reason);
|
|
2203
|
+
reason.style.color = "var(--degraded)";
|
|
2204
|
+
host.appendChild(reason);
|
|
2205
|
+
host.appendChild(panelMuted(b.refusal.remedy));
|
|
2206
|
+
host.appendChild(panelMuted("the drift overlay above is unaffected — it never depended on an engine."));
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
/** M1's diagnostics (`meta.behaviour.diagnostics`): every block behold dropped
|
|
2210
|
+
* and every thing it could not reconcile, under the legend where the figures
|
|
2211
|
+
* they are about are. They were reaching the wire and nothing was reading
|
|
2212
|
+
* them. */
|
|
2213
|
+
function renderBehaviourDiagnostics(host) {
|
|
2214
|
+
const b = behaviourMeta();
|
|
2215
|
+
if (!b || !b.diagnostics || !b.diagnostics.length) return;
|
|
2216
|
+
host.appendChild(panelHeading(`behaviour diagnostics · ${b.diagnostics.length}`));
|
|
2217
|
+
for (const d of b.diagnostics) host.appendChild(panelMuted(d));
|
|
1508
2218
|
}
|
|
1509
2219
|
|
|
1510
2220
|
// ---------------------------------------------------------------------------
|
|
@@ -1811,6 +2521,88 @@ function markOperatorCards(ir) {
|
|
|
1811
2521
|
}
|
|
1812
2522
|
}
|
|
1813
2523
|
|
|
2524
|
+
/**
|
|
2525
|
+
* #404: the second signal on a bound choudoufu card — the plan would change an
|
|
2526
|
+
* attribute on a resource the estate demonstrably owns.
|
|
2527
|
+
*
|
|
2528
|
+
* The ownership verdict keeps the card's FILL: bound is still green, and this
|
|
2529
|
+
* only adds a stroke and a corner glyph, the same post-render stamp
|
|
2530
|
+
* `markCarvedCards`/`markOperatorCards` use (pinhole's SVG arrives from the
|
|
2531
|
+
* server as a string and is replaced on every render, so a mark has to be
|
|
2532
|
+
* re-applied here rather than asked for at layout time).
|
|
2533
|
+
*
|
|
2534
|
+
* `~` is OpenTofu's own symbol for update-in-place, so the card and the
|
|
2535
|
+
* terminal read the same. The corner is the bottom-right one the carve and
|
|
2536
|
+
* operator marks also use — a choudoufu card is never carved (that is chant
|
|
2537
|
+
* source) and never the operating loop's home (that is Kubernetes), so on the
|
|
2538
|
+
* estates this can appear on the corner is free; the guard below still skips a
|
|
2539
|
+
* card that already carries one of those, rather than drawing over it.
|
|
2540
|
+
*/
|
|
2541
|
+
/**
|
|
2542
|
+
* The cards whose member is still being read (#422).
|
|
2543
|
+
*
|
|
2544
|
+
* The same post-render stamp `markCarvedCards`/`markDriftedCards` use, and for
|
|
2545
|
+
* the same reason: pinhole's SVG arrives as a string and is replaced on every
|
|
2546
|
+
* render, so a mark is re-applied here rather than asked for at layout time.
|
|
2547
|
+
*
|
|
2548
|
+
* A dashed outline and a muted corner rather than a fill, because the fill is
|
|
2549
|
+
* the status channel and this card HAS no status yet — painting one would be
|
|
2550
|
+
* the claim the whole milestone exists to avoid. `_pendingRead` rides its own
|
|
2551
|
+
* attr for the same reason: a fifth `_status` value falls back to neutral in
|
|
2552
|
+
* pinhole's painter, which reads as "unobserved", which is a different and
|
|
2553
|
+
* wrong thing.
|
|
2554
|
+
*/
|
|
2555
|
+
function markPendingCards(ir) {
|
|
2556
|
+
const svg = document.querySelector("#graph svg");
|
|
2557
|
+
if (!svg) return;
|
|
2558
|
+
for (const n of (ir && ir.nodes) || []) {
|
|
2559
|
+
const g = svg.querySelector('[data-node-id="' + CSS.escape(n.id) + '"]');
|
|
2560
|
+
if (!g) continue;
|
|
2561
|
+
const pending = !!(n.attrs && n.attrs._pendingRead);
|
|
2562
|
+
g.toggleAttribute("data-pending-read", pending);
|
|
2563
|
+
if (pending && !g.querySelector("title")) {
|
|
2564
|
+
const t = document.createElementNS("http://www.w3.org/2000/svg", "title");
|
|
2565
|
+
t.textContent = "still reading this member — not yet observed";
|
|
2566
|
+
g.appendChild(t);
|
|
2567
|
+
}
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
function markDriftedCards(ir) {
|
|
2572
|
+
const svg = document.querySelector("#graph svg");
|
|
2573
|
+
if (!svg) return;
|
|
2574
|
+
for (const n of ir.nodes || []) {
|
|
2575
|
+
const mark = n.attrs && n.attrs._planDrift;
|
|
2576
|
+
if (!mark || !mark.attributes || !mark.attributes.length) continue;
|
|
2577
|
+
const g = svg.querySelector('[data-node-id="' + CSS.escape(n.id) + '"]');
|
|
2578
|
+
if (!g || g.querySelector('[data-plan-drift="1"]')) continue;
|
|
2579
|
+
if (g.querySelector('[data-carved="1"]') || g.querySelector('[data-operator="1"]')) continue;
|
|
2580
|
+
g.classList.add("plan-drifted");
|
|
2581
|
+
// Measured, not read off attributes — markCarvedCards()'s note applies
|
|
2582
|
+
// here verbatim (pinhole sizes a card from its content).
|
|
2583
|
+
const rect = g.querySelector("rect");
|
|
2584
|
+
const box = rect && rect.getBBox ? rect.getBBox() : null;
|
|
2585
|
+
const tag = document.createElementNS("http://www.w3.org/2000/svg", "text");
|
|
2586
|
+
tag.setAttribute("data-plan-drift", "1");
|
|
2587
|
+
tag.setAttribute("x", String((box ? box.x + box.width : 150) - 10));
|
|
2588
|
+
tag.setAttribute("y", String((box ? box.y + box.height : 60) - 9));
|
|
2589
|
+
tag.setAttribute("text-anchor", "end");
|
|
2590
|
+
tag.setAttribute("font-size", "11");
|
|
2591
|
+
tag.setAttribute("font-weight", "600");
|
|
2592
|
+
tag.setAttribute("fill", "var(--degraded)");
|
|
2593
|
+
// A COUNT, not the attribute names: a name like `image_tag_mutability` is
|
|
2594
|
+
// wider than the corner and runs into the card's own subtitle (seen in the
|
|
2595
|
+
// browser). The count is bounded whatever the provider calls its fields,
|
|
2596
|
+
// and the names are one hover — or one click, in the pane — away.
|
|
2597
|
+
const count_ = mark.attributes.length;
|
|
2598
|
+
tag.textContent = `~ ${count_} attribute${count_ === 1 ? "" : "s"}`;
|
|
2599
|
+
const title = document.createElementNS("http://www.w3.org/2000/svg", "title");
|
|
2600
|
+
title.textContent = `The plan would ${(mark.actions || ["update"]).join("+")} this: ${mark.attributes.join(", ")}. Ownership is unchanged — the card's colour is still its ownership verdict.`;
|
|
2601
|
+
g.appendChild(title);
|
|
2602
|
+
g.appendChild(tag);
|
|
2603
|
+
}
|
|
2604
|
+
}
|
|
2605
|
+
|
|
1814
2606
|
function renderPanelCarve() {
|
|
1815
2607
|
if (!carveMode()) return;
|
|
1816
2608
|
if (!carveHost) {
|
|
@@ -1866,9 +2658,18 @@ function renderStatusbar() {
|
|
|
1866
2658
|
// Pure state — the strip echoes the axes whose controls live on the floating
|
|
1867
2659
|
// panel and in ⌘K, so the current view stays legible with the panel collapsed.
|
|
1868
2660
|
const parts = [`zoom: ${zoomValue()}`, view.env ? `env: ${view.env}` : "env: (source)"];
|
|
2661
|
+
// #399: which colour the graph is in is state, not a control, so it belongs
|
|
2662
|
+
// on the strip the same way the zoom and the env do. Only when it is not
|
|
2663
|
+
// drift — drift is what the strip has always implied.
|
|
2664
|
+
if (activeColourMode() !== "drift") parts.push(`colour: ${activeColourMode()}`);
|
|
1869
2665
|
if (view.stack) parts.push(`stack: ${view.stack}`);
|
|
1870
2666
|
if (axes.tier) parts.push(`tier: ${axes.tier}`);
|
|
1871
2667
|
if (view.radial && !view.components && !view.logical && !view.ops) parts.push("radial");
|
|
2668
|
+
// #422: what the picture is still waiting on, while it is waiting. Ahead of
|
|
2669
|
+
// the zoom note, because a half-read estate is the more urgent fact about
|
|
2670
|
+
// what is on screen.
|
|
2671
|
+
const stillReading = pendingLine(pendingMembers, pendingTotal);
|
|
2672
|
+
if (stillReading) parts.push(stillReading);
|
|
1872
2673
|
el.textContent = parts.join(" · ");
|
|
1873
2674
|
// #131: why this level rendered empty, or as the one below it. The server
|
|
1874
2675
|
// decides (src/zoom-notes.ts) — the SPA never infers it, so the note always
|
|
@@ -1878,7 +2679,15 @@ function renderStatusbar() {
|
|
|
1878
2679
|
if (lastNote) {
|
|
1879
2680
|
const note = document.createElement("span");
|
|
1880
2681
|
note.className = "statusbar-note";
|
|
1881
|
-
|
|
2682
|
+
// #393 item 7: the strip is a strip. A Terraform estate's note names every
|
|
2683
|
+
// root, every skipped directory with its reason, and every block class the
|
|
2684
|
+
// zoom left out — ~60 words, in a 260px panel, at every zoom. The server
|
|
2685
|
+
// sends the short form beside the long one (`meta.noteShort`) when it has
|
|
2686
|
+
// one; the full text stays one hover away here and is printed in full on
|
|
2687
|
+
// the panel's Model tab. Nothing is truncated client-side: the SPA does not
|
|
2688
|
+
// write notes, and a note with no short form is already a line.
|
|
2689
|
+
note.textContent = " — " + (lastNoteShort || lastNote);
|
|
2690
|
+
if (lastNoteShort) note.title = lastNote;
|
|
1882
2691
|
el.appendChild(note);
|
|
1883
2692
|
}
|
|
1884
2693
|
}
|
|
@@ -1887,6 +2696,9 @@ function renderStatusbar() {
|
|
|
1887
2696
|
// or null. Set on every load so a level that stops degrading stops explaining
|
|
1888
2697
|
// itself.
|
|
1889
2698
|
let lastNote = null;
|
|
2699
|
+
// #393 item 7: the same note as the strip can hold (`meta.noteShort`), or null
|
|
2700
|
+
// when the long one already fits. Never derived here — see renderStatusbar().
|
|
2701
|
+
let lastNoteShort = null;
|
|
1890
2702
|
|
|
1891
2703
|
// The `meta` of the last rendered graph — the panel's Model tab reads its
|
|
1892
2704
|
// mode/env to pick the status vocabulary (drift vs component live status).
|
|
@@ -2560,6 +3372,7 @@ function render(ir, svg, m) {
|
|
|
2560
3372
|
// #131: set before anything can early-return, so a level that stopped
|
|
2561
3373
|
// degrading stops explaining itself on the very next render.
|
|
2562
3374
|
lastNote = m.note || null;
|
|
3375
|
+
lastNoteShort = m.noteShort || null;
|
|
2563
3376
|
// The panel's Model tab reads both of these via renderStatusbar() →
|
|
2564
3377
|
// renderPanel() below — set them first so it renders THIS graph, not the
|
|
2565
3378
|
// previous one (recolorNodesByCategory also sets lastGraphIr; harmless).
|
|
@@ -2606,9 +3419,18 @@ function render(ir, svg, m) {
|
|
|
2606
3419
|
const s = n.attrs && n.attrs._status;
|
|
2607
3420
|
if (s in c) c[s]++;
|
|
2608
3421
|
}
|
|
2609
|
-
|
|
2610
|
-
|
|
3422
|
+
// #393 item 8: the estate's own words for these four colours — chant's
|
|
3423
|
+
// managed/foreign/pending/unobserved on a chant estate, choudoufu's
|
|
3424
|
+
// bound/unowned/pending/not observed on one whose members are choudoufu.
|
|
3425
|
+
const w = statusLabels();
|
|
3426
|
+
tail = ` · ${c.good} ${w.good} · ${c.warn} ${w.warn} · ${c.accent} ${w.accent}`;
|
|
3427
|
+
if (c.neutral) tail += ` · ${c.neutral} ${w.neutral}`;
|
|
2611
3428
|
if (c.runtime) tail += ` · ${c.runtime} runtime`;
|
|
3429
|
+
// #404: beside the ownership counts, never folded into them — a drifted
|
|
3430
|
+
// card is bound and is already inside `c.good`. Silent when no plan was
|
|
3431
|
+
// read, so the strip never implies the question was asked and answered.
|
|
3432
|
+
const dm = driftMeta();
|
|
3433
|
+
if (dm && dm.read) tail += ` · ${dm.drifted} drifted`;
|
|
2612
3434
|
// Nothing observed live in this env — explain the all-blue rather than let it
|
|
2613
3435
|
// read as a bug (#32).
|
|
2614
3436
|
if (c.good === 0 && c.warn === 0 && c.accent > 0) tail += ` — nothing deployed in ${m.env} yet`;
|
|
@@ -2625,8 +3447,9 @@ function render(ir, svg, m) {
|
|
|
2625
3447
|
// Multi-estate (#31): note the composed project count; the graph draws one box per project.
|
|
2626
3448
|
// #186: the meta line lives in the panel's 272px footer now — the directory
|
|
2627
3449
|
// basename reads better than an absolute path (full path in the tooltip).
|
|
3450
|
+
// #393 item 4: one member is a project, not "1 projects".
|
|
2628
3451
|
const scope = m.estate
|
|
2629
|
-
? `estate of ${m.estate}
|
|
3452
|
+
? `estate of ${m.estate} project${m.estate === 1 ? "" : "s"}`
|
|
2630
3453
|
: String(m.projectDir || "").replace(/\/+$/, "").split("/").pop() || m.projectDir;
|
|
2631
3454
|
// The deploy axes (#59 unify, M2 #54 lenses) — tier/target, kept in sync with
|
|
2632
3455
|
// what this response actually observed (falls back to the launch-time value
|
|
@@ -2675,6 +3498,8 @@ function render(ir, svg, m) {
|
|
|
2675
3498
|
markCarvedCards(); // #254: the SVG is replaced per render — re-stamp the marker
|
|
2676
3499
|
markPlayhead(ir); // #284 item 2: same, for the step the run is sitting on
|
|
2677
3500
|
markOperatorCards(ir); // #234 free rider: same, for the operating loop's home
|
|
3501
|
+
markDriftedCards(ir); // #404: same, for a bound card the plan would change — after the carve/operator stamps, so it can see and yield to them
|
|
3502
|
+
markPendingCards(ir); // #422: and the cards whose member has not answered yet
|
|
2678
3503
|
applyLayout(); // #228: last, so the hand-placed deltas ride on top of every other pass
|
|
2679
3504
|
renderDial();
|
|
2680
3505
|
}
|
|
@@ -2770,7 +3595,13 @@ function setupGraphViewBox(svg) {
|
|
|
2770
3595
|
const a = (svg.getAttribute("viewBox") || "").split(/\s+/).map(Number);
|
|
2771
3596
|
if (a.length === 4 && a.every((n) => !Number.isNaN(n))) {
|
|
2772
3597
|
vbInit = a.slice();
|
|
2773
|
-
|
|
3598
|
+
// #396 item 2: land on the fit that keeps the panel's footprint clear, not
|
|
3599
|
+
// on the raw box — a re-render (a zoom change, an env change, the SSE
|
|
3600
|
+
// re-pull) goes through here and never through fitGraph, so the first
|
|
3601
|
+
// column would slide back under the panel at every one of them.
|
|
3602
|
+
vb = fittedViewBox() || a.slice();
|
|
3603
|
+
vbAtFit = true;
|
|
3604
|
+
applyVB();
|
|
2774
3605
|
} else {
|
|
2775
3606
|
vbInit = vb = null;
|
|
2776
3607
|
}
|
|
@@ -2781,12 +3612,219 @@ function currentSvg() {
|
|
|
2781
3612
|
function applyVB() {
|
|
2782
3613
|
const s = currentSvg();
|
|
2783
3614
|
if (s && vb) s.setAttribute("viewBox", vb.join(" "));
|
|
3615
|
+
scaleBoxLabels(); // #396 item 7b: box titles/badges keep a legible size at every zoom
|
|
3616
|
+
}
|
|
3617
|
+
|
|
3618
|
+
// #396 item 2: the panel is `position: fixed` and floats OVER the graph pane
|
|
3619
|
+
// (index.html), so "fit the pane" put the leftmost column of cards underneath
|
|
3620
|
+
// it — a whole column of the terralith, half waterpark's `github` box — at
|
|
3621
|
+
// boot and after every re-render. The panel is furniture the reader cannot see
|
|
3622
|
+
// through, so the fit's target is the pane MINUS whatever the panel is
|
|
3623
|
+
// currently covering.
|
|
3624
|
+
//
|
|
3625
|
+
// Read at fit time, never remembered: the panel drags, docks to either side and
|
|
3626
|
+
// collapses to its tab bar, and a fit computed against where it used to be is
|
|
3627
|
+
// the same bug with a different offset. Collapsed or hidden → no footprint, and
|
|
3628
|
+
// the fit is the whole pane exactly as before.
|
|
3629
|
+
//
|
|
3630
|
+
// Horizontal only. The panel is a tall palette that docks left or right; a
|
|
3631
|
+
// top/bottom dock still leaves the full width, and insetting vertically for it
|
|
3632
|
+
// would shrink the graph for a strip the cards mostly clear anyway.
|
|
3633
|
+
const FIT_PANEL_MAX = 0.45; // the panel can never claim more than this of the pane
|
|
3634
|
+
|
|
3635
|
+
function panelInsets(pane) {
|
|
3636
|
+
const none = { left: 0, right: 0 };
|
|
3637
|
+
const p = document.getElementById("panel");
|
|
3638
|
+
if (!p || p.classList.contains("collapsed")) return none;
|
|
3639
|
+
const cs = getComputedStyle(p);
|
|
3640
|
+
if (cs.display === "none" || cs.visibility === "hidden" || Number(cs.opacity) === 0) return none;
|
|
3641
|
+
const r = p.getBoundingClientRect();
|
|
3642
|
+
if (!r.width || !r.height) return none;
|
|
3643
|
+
// Nothing to inset for a panel that has been dragged clear of the graph pane.
|
|
3644
|
+
if (r.bottom <= pane.top || r.top >= pane.bottom || r.right <= pane.left || r.left >= pane.right) return none;
|
|
3645
|
+
const cap = pane.width * FIT_PANEL_MAX;
|
|
3646
|
+
// Whichever side it is nearer to is the side it eats.
|
|
3647
|
+
return (r.left + r.right) / 2 < (pane.left + pane.right) / 2
|
|
3648
|
+
? { left: Math.min(cap, Math.max(0, r.right - pane.left)), right: 0 }
|
|
3649
|
+
: { left: 0, right: Math.min(cap, Math.max(0, pane.right - r.left)) };
|
|
3650
|
+
}
|
|
3651
|
+
|
|
3652
|
+
/**
|
|
3653
|
+
* The viewBox that fits the whole graph into the pane's UNCOVERED part.
|
|
3654
|
+
*
|
|
3655
|
+
* The returned box is given the pane's own aspect ratio, so `xMidYMid meet`
|
|
3656
|
+
* letterboxes nothing and the pane's pixels map to viewBox units at one known
|
|
3657
|
+
* scale: the graph is then placed by arithmetic — centred inside the band
|
|
3658
|
+
* between the insets, centred vertically — instead of wherever `meet` would
|
|
3659
|
+
* have dropped it. With no insets the result is pixel-identical to the old
|
|
3660
|
+
* `vb = vbInit`, which is why nothing without a panel over it moves.
|
|
3661
|
+
*/
|
|
3662
|
+
function fittedViewBox() {
|
|
3663
|
+
if (!vbInit) return null;
|
|
3664
|
+
const host = document.getElementById("graph");
|
|
3665
|
+
if (!host) return null;
|
|
3666
|
+
const pane = host.getBoundingClientRect();
|
|
3667
|
+
const [x0, y0, w0, h0] = vbInit;
|
|
3668
|
+
if (!(pane.width > 0 && pane.height > 0 && w0 > 0 && h0 > 0)) return null;
|
|
3669
|
+
const { left, right } = panelInsets(pane);
|
|
3670
|
+
const band = Math.max(1, pane.width - left - right);
|
|
3671
|
+
const s = Math.min(band / w0, pane.height / h0);
|
|
3672
|
+
const vw = pane.width / s;
|
|
3673
|
+
const vh = pane.height / s;
|
|
3674
|
+
return [x0 - left / s - (band / s - w0) / 2, y0 - (vh - h0) / 2, vw, vh];
|
|
2784
3675
|
}
|
|
3676
|
+
|
|
2785
3677
|
function fitGraph() {
|
|
2786
|
-
if (vbInit)
|
|
2787
|
-
|
|
2788
|
-
|
|
3678
|
+
if (!vbInit) return;
|
|
3679
|
+
vb = fittedViewBox() || vbInit.slice();
|
|
3680
|
+
vbAtFit = true;
|
|
3681
|
+
applyVB();
|
|
3682
|
+
}
|
|
3683
|
+
|
|
3684
|
+
// #396 item 2: the fit now depends on the pane — its size, and what the panel
|
|
3685
|
+
// covers of it — where `vbInit` alone depended on neither, so a graph sitting
|
|
3686
|
+
// at fit has to be re-fitted when the pane moves under it. It moves more often
|
|
3687
|
+
// than a window resize: the statusbar's note wraps onto a second line, the
|
|
3688
|
+
// inspect pane is dragged wider, a panel tab's content grows. Only while the
|
|
3689
|
+
// view IS the fit: a pan or a zoom takes ownership of the viewBox, and nothing
|
|
3690
|
+
// here may pull it back out from under the reader's hands.
|
|
3691
|
+
let vbAtFit = false;
|
|
3692
|
+
function watchPaneForFit(host) {
|
|
3693
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
3694
|
+
new ResizeObserver(() => {
|
|
3695
|
+
if (vbAtFit) fitGraph();
|
|
3696
|
+
}).observe(host);
|
|
3697
|
+
}
|
|
3698
|
+
|
|
3699
|
+
// --- #396 item 7b: box titles and badges that survive the fit ---------------
|
|
3700
|
+
//
|
|
3701
|
+
// pinhole draws a group box's title at a flat `font-size="12"` and its badge at
|
|
3702
|
+
// `11`, in viewBox UNITS. That reads as 12px on a graph whose fit is 1:1 and as
|
|
3703
|
+
// 7px on terralith-4, whose box is 8000 units wide — the one label that says
|
|
3704
|
+
// which member 301 cards belong to, and the one that counts them, are the two
|
|
3705
|
+
// things on the canvas a person cannot read at the zoom they arrive at.
|
|
3706
|
+
//
|
|
3707
|
+
// So the label is given a floor in SCREEN pixels: its size in units is raised
|
|
3708
|
+
// by the inverse of the current scale until it renders at its natural size, and
|
|
3709
|
+
// never below it, so anything already legible is untouched (zoomed IN, the
|
|
3710
|
+
// attribute is left at 12 and grows with the graph, exactly as before). The
|
|
3711
|
+
// anchor never moves — x, y and `text-anchor` are pinhole's — so a title stays
|
|
3712
|
+
// welded to its box's top-left corner and a badge to its top-right through
|
|
3713
|
+
// every pan and zoom; only the type gets bigger.
|
|
3714
|
+
//
|
|
3715
|
+
// The floor yields to the box: a label may not grow past a fraction of the
|
|
3716
|
+
// box's height, nor past its width. A sub-box too small to hold readable type
|
|
3717
|
+
// at this zoom says so by staying small, rather than by writing across its
|
|
3718
|
+
// neighbours.
|
|
3719
|
+
const BOX_LABEL_GUTTER = 34; // units below a box's top edge that the title row occupies
|
|
3720
|
+
const BOX_LABEL_MAX_H = 8; // a label may be at most box height / this
|
|
3721
|
+
const BOX_LABEL_EM = 0.62; // rough advance width per char, as a fraction of the size
|
|
3722
|
+
|
|
3723
|
+
let boxLabelCache = { svg: null, labels: [] };
|
|
3724
|
+
let boxLabelScale = null;
|
|
3725
|
+
|
|
3726
|
+
/** The `<text>` elements pinhole put in each group box's title gutter, with the
|
|
3727
|
+
* box they belong to and the size it drew them at. Matched by geometry rather
|
|
3728
|
+
* than by sibling order: a box's optional identity mark sits between the title
|
|
3729
|
+
* and the badge, and #228's layout pass lifts the rect and its title into a
|
|
3730
|
+
* wrapper `<g>` after the render, so neither order nor a common parent holds.
|
|
3731
|
+
* What geometry alone would let through — a card's own text, an edge chip's —
|
|
3732
|
+
* is excluded by the ancestor it sits under. */
|
|
3733
|
+
function boxLabelsOf(svg) {
|
|
3734
|
+
if (boxLabelCache.svg === svg) return boxLabelCache.labels;
|
|
3735
|
+
const labels = [];
|
|
3736
|
+
const boxes = [];
|
|
3737
|
+
for (const rect of svg.querySelectorAll("rect[data-group-id]")) {
|
|
3738
|
+
const b = {
|
|
3739
|
+
x: parseFloat(rect.getAttribute("x")),
|
|
3740
|
+
y: parseFloat(rect.getAttribute("y")),
|
|
3741
|
+
w: parseFloat(rect.getAttribute("width")),
|
|
3742
|
+
h: parseFloat(rect.getAttribute("height")),
|
|
3743
|
+
};
|
|
3744
|
+
if ([b.x, b.y, b.w, b.h].every((n) => Number.isFinite(n))) boxes.push(b);
|
|
3745
|
+
}
|
|
3746
|
+
if (boxes.length) {
|
|
3747
|
+
for (const t of svg.querySelectorAll("text")) {
|
|
3748
|
+
const x = parseFloat(t.getAttribute("x"));
|
|
3749
|
+
const y = parseFloat(t.getAttribute("y"));
|
|
3750
|
+
// pinhole's own size, stashed the first time so a re-index can never
|
|
3751
|
+
// read a size this pass already raised as if it were the natural one.
|
|
3752
|
+
const base = parseFloat(t.dataset.pinFont ?? t.getAttribute("font-size"));
|
|
3753
|
+
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(base)) continue;
|
|
3754
|
+
// Not a card's own text (inside its `g[data-node-id]`), not an edge
|
|
3755
|
+
// chip's (inside the edge's `g`), not a radial wedge label behold adds
|
|
3756
|
+
// itself. What is left at a box's title row IS the box's title row.
|
|
3757
|
+
if (t.closest("[data-node-id], [data-edge-from], [data-edge-to], #radial-labels")) continue;
|
|
3758
|
+
const box = boxes.find((b) => y > b.y && y <= b.y + BOX_LABEL_GUTTER && x >= b.x - 2 && x <= b.x + b.w + 2);
|
|
3759
|
+
if (!box) continue;
|
|
3760
|
+
t.dataset.pinFont = String(base);
|
|
3761
|
+
labels.push({ el: t, box, base });
|
|
3762
|
+
}
|
|
2789
3763
|
}
|
|
3764
|
+
boxLabelCache = { svg, labels };
|
|
3765
|
+
boxLabelScale = null;
|
|
3766
|
+
return labels;
|
|
3767
|
+
}
|
|
3768
|
+
|
|
3769
|
+
/** Re-size every box label for the current zoom. Cheap and idempotent: the
|
|
3770
|
+
* scale is remembered, so the pan handler's stream of `applyVB` calls (which
|
|
3771
|
+
* move the viewBox without changing its width) touches no attribute at all. */
|
|
3772
|
+
function scaleBoxLabels() {
|
|
3773
|
+
const svg = currentSvg();
|
|
3774
|
+
if (!svg || !vb) return;
|
|
3775
|
+
const labels = boxLabelsOf(svg);
|
|
3776
|
+
if (!labels.length) return;
|
|
3777
|
+
const pane = svg.getBoundingClientRect();
|
|
3778
|
+
if (!(pane.width > 0 && pane.height > 0)) return;
|
|
3779
|
+
const scale = Math.min(pane.width / vb[2], pane.height / vb[3]);
|
|
3780
|
+
if (!(scale > 0) || scale === boxLabelScale) return;
|
|
3781
|
+
boxLabelScale = scale;
|
|
3782
|
+
for (const l of labels) {
|
|
3783
|
+
const chars = Math.max(4, (l.el.textContent || "").length);
|
|
3784
|
+
const cap = Math.max(l.base, Math.min(l.box.h / BOX_LABEL_MAX_H, l.box.w / (chars * BOX_LABEL_EM)));
|
|
3785
|
+
const size = Math.max(l.base, Math.min(l.base / scale, cap));
|
|
3786
|
+
l.el.setAttribute("font-size", String(Math.round(size * 10) / 10));
|
|
3787
|
+
}
|
|
3788
|
+
}
|
|
3789
|
+
|
|
3790
|
+
// How much of the viewBox one card should take when ⌘K lands on it (#393): the
|
|
3791
|
+
// window is this many card-widths across, so a 301-card estate arrives readable
|
|
3792
|
+
// rather than at whatever zoom the last gesture left. Never wider than the fit,
|
|
3793
|
+
// which is the whole graph — there is nothing beyond it to show.
|
|
3794
|
+
const REVEAL_CARDS_ACROSS = 9;
|
|
3795
|
+
|
|
3796
|
+
/**
|
|
3797
|
+
* Pan (and zoom) the graph so one card sits in the middle of the pane (#393
|
|
3798
|
+
* item 4). The same viewBox the wheel/drag handlers drive and "⤢ fit" resets —
|
|
3799
|
+
* there was no programmatic pan before this, only the reset.
|
|
3800
|
+
*
|
|
3801
|
+
* The card's rectangle comes from the DOM rather than from the IR: pinhole
|
|
3802
|
+
* paints a card as a `<g data-node-id>` whose first `<rect>` carries absolute
|
|
3803
|
+
* viewBox coordinates (no transform on the estate view, unlike the radial one),
|
|
3804
|
+
* and `getBBox()` covers whichever it is. Returns false when there is no such
|
|
3805
|
+
* card on the canvas — a node the current zoom elided — so the caller can say
|
|
3806
|
+
* so instead of panning to nowhere.
|
|
3807
|
+
*/
|
|
3808
|
+
function revealNode(id) {
|
|
3809
|
+
const svg = currentSvg();
|
|
3810
|
+
if (!svg || !vb || !vbInit) return false;
|
|
3811
|
+
const g = svg.querySelector(`[data-node-id="${CSS.escape(id)}"]`);
|
|
3812
|
+
if (!g) return false;
|
|
3813
|
+
let box;
|
|
3814
|
+
try {
|
|
3815
|
+
box = g.getBBox();
|
|
3816
|
+
} catch {
|
|
3817
|
+
const r = g.querySelector("rect");
|
|
3818
|
+
if (!r) return false;
|
|
3819
|
+
box = { x: +r.getAttribute("x"), y: +r.getAttribute("y"), width: +r.getAttribute("width"), height: +r.getAttribute("height") };
|
|
3820
|
+
}
|
|
3821
|
+
if (!box.width || !box.height) return false;
|
|
3822
|
+
const w = Math.min(vbInit[2], Math.max(box.width * REVEAL_CARDS_ACROSS, vbInit[2] / 60));
|
|
3823
|
+
const h = w * (vbInit[3] / vbInit[2]);
|
|
3824
|
+
vb = [box.x + box.width / 2 - w / 2, box.y + box.height / 2 - h / 2, w, h];
|
|
3825
|
+
vbAtFit = false;
|
|
3826
|
+
applyVB();
|
|
3827
|
+
return true;
|
|
2790
3828
|
}
|
|
2791
3829
|
function ensureZoomControls(host) {
|
|
2792
3830
|
let btn = document.getElementById("zoom-toggle");
|
|
@@ -2803,6 +3841,7 @@ function ensureZoomControls(host) {
|
|
|
2803
3841
|
}
|
|
2804
3842
|
if (zoomWired) return;
|
|
2805
3843
|
zoomWired = true;
|
|
3844
|
+
watchPaneForFit(host);
|
|
2806
3845
|
host.addEventListener(
|
|
2807
3846
|
"wheel",
|
|
2808
3847
|
(e) => {
|
|
@@ -2824,6 +3863,7 @@ function ensureZoomControls(host) {
|
|
|
2824
3863
|
vb[1] = cy - ((cy - vb[1]) * nh) / vb[3];
|
|
2825
3864
|
vb[2] = nw;
|
|
2826
3865
|
vb[3] = nh;
|
|
3866
|
+
vbAtFit = false;
|
|
2827
3867
|
applyVB();
|
|
2828
3868
|
},
|
|
2829
3869
|
{ passive: false },
|
|
@@ -2856,6 +3896,7 @@ function ensureZoomControls(host) {
|
|
|
2856
3896
|
}
|
|
2857
3897
|
vb[0] -= (dx / r.width) * vb[2];
|
|
2858
3898
|
vb[1] -= (dy / r.height) * vb[3];
|
|
3899
|
+
vbAtFit = false;
|
|
2859
3900
|
px = e.clientX;
|
|
2860
3901
|
py = e.clientY;
|
|
2861
3902
|
applyVB();
|
|
@@ -3459,7 +4500,8 @@ function renderPreconditionError(body) {
|
|
|
3459
4500
|
}
|
|
3460
4501
|
|
|
3461
4502
|
// Fetch the current view (source graph, or the picked env's live overlay).
|
|
3462
|
-
|
|
4503
|
+
const load = createRefreshQueue(loadOnce);
|
|
4504
|
+
async function loadOnce(opts = {}, isCurrent = () => true) {
|
|
3463
4505
|
const meta = document.getElementById("meta");
|
|
3464
4506
|
// A background settle re-pull (post-apply) shouldn't flash the meta/overlay.
|
|
3465
4507
|
if (!opts.quiet) {
|
|
@@ -3478,6 +4520,14 @@ async function load(opts = {}) {
|
|
|
3478
4520
|
// cross-substrate entity overlay, which components never use.
|
|
3479
4521
|
q.set("components", "1");
|
|
3480
4522
|
if (view.env) q.set("env", view.env);
|
|
4523
|
+
} else if (view.stacks) {
|
|
4524
|
+
// The apply-order lens (#426): chant's cross-stack ordering, built fresh
|
|
4525
|
+
// for the same reason the ops lens is — it reads none of the entity
|
|
4526
|
+
// knobs. No env (it is computed at synthesis, with no substrate), no
|
|
4527
|
+
// detail, no radial. Tier rides along because a project's source can
|
|
4528
|
+
// branch on one, which can change which lexicons it partitions into.
|
|
4529
|
+
q = new URLSearchParams({ stacks: "1" });
|
|
4530
|
+
if (view.tier) q.set("tier", view.tier);
|
|
3481
4531
|
} else if (view.ops) {
|
|
3482
4532
|
// The ops lens (#284): the declared Ops, read from each Op's emitted
|
|
3483
4533
|
// op.json. Built fresh rather than layered onto the entity lenses,
|
|
@@ -3511,14 +4561,30 @@ async function load(opts = {}) {
|
|
|
3511
4561
|
} else if (view.env) {
|
|
3512
4562
|
endpoint = "/api/overlay";
|
|
3513
4563
|
q.set("env", view.env);
|
|
4564
|
+
// #422: draw the estate from source at once and recolour each member as
|
|
4565
|
+
// its own live read lands, instead of a blank canvas until the slowest
|
|
4566
|
+
// one finishes. The server only honours it for a composed estate, and
|
|
4567
|
+
// never in a static snapshot, where there is no stream to follow.
|
|
4568
|
+
if (!staticMode) q.set("progressive", "1");
|
|
3514
4569
|
// The runtime tier (#86) descends below the declaration boundary. It is
|
|
3515
4570
|
// live-only by nature — owner-referenced children exist in the cluster,
|
|
3516
4571
|
// never in your source — so it rides the overlay and means nothing
|
|
3517
4572
|
// without an env.
|
|
3518
4573
|
if (view.runtime) q.set("runtime", "1");
|
|
4574
|
+
// #404: the attribute-drift read, when this load is the one the person
|
|
4575
|
+
// asked for it on. Consumed here — the next load goes without it.
|
|
4576
|
+
if (planDriftOnce) {
|
|
4577
|
+
q.set("plan", "1");
|
|
4578
|
+
planDriftOnce = false;
|
|
4579
|
+
}
|
|
3519
4580
|
}
|
|
3520
4581
|
// Radial layout (entity view only) — curl the wide DAG onto concentric rings.
|
|
3521
4582
|
if (view.radial && !view.components && !view.logical && !view.ops) q.set("radial", "1");
|
|
4583
|
+
// Collapse large boxes (#393): a member box over the server's limit draws
|
|
4584
|
+
// as one card carrying its counts. An estate-box lens — the component DAG
|
|
4585
|
+
// and the ops view have no member boxes to shut — and it rides both the
|
|
4586
|
+
// source graph and the overlay, so picking an env doesn't undo it.
|
|
4587
|
+
if (view.collapse && !view.components && !view.ops) q.set("collapse", "1");
|
|
3522
4588
|
// The CI + resources facets are component-DAG-mode-only details. Load
|
|
3523
4589
|
// both whenever components mode is on, env picked or not — #59 unifies
|
|
3524
4590
|
// the CI facet (#58), the live-status join (#57), and resources (#59) so
|
|
@@ -3531,6 +4597,7 @@ async function load(opts = {}) {
|
|
|
3531
4597
|
}
|
|
3532
4598
|
const res = await apiFetch(`${endpoint}?${q}`);
|
|
3533
4599
|
const body = await res.json();
|
|
4600
|
+
if (!isCurrent()) return; // a newer view/event is pending; do not paint the old result
|
|
3534
4601
|
if (!res.ok) {
|
|
3535
4602
|
// #72: a classified precondition failure (lint gate, not installed, a
|
|
3536
4603
|
// tier that needs credentials) gets the calmer entry/error card instead
|
|
@@ -3551,13 +4618,19 @@ async function load(opts = {}) {
|
|
|
3551
4618
|
autoZoomFallback = false;
|
|
3552
4619
|
applyZoom("resources");
|
|
3553
4620
|
renderStatusbar();
|
|
3554
|
-
return
|
|
4621
|
+
return loadOnce(opts, isCurrent);
|
|
3555
4622
|
}
|
|
3556
4623
|
autoZoomFallback = false;
|
|
4624
|
+
// #422: a progressive answer names the members it is still reading, and
|
|
4625
|
+
// the `member` stream above clears them one at a time. Any other answer
|
|
4626
|
+
// resets it, so a switch away from a progressive load never leaves a stale
|
|
4627
|
+
// "still reading" claim on screen.
|
|
4628
|
+
pendingMembers = body.meta && body.meta.mode === "progressive" ? [...(body.meta.pending || [])] : [];
|
|
4629
|
+
pendingTotal = pendingMembers.length;
|
|
3557
4630
|
render(body.ir, body.svg, body.meta);
|
|
3558
4631
|
} catch (err) {
|
|
3559
4632
|
// A background settle poll must not blow away a good graph on a transient error.
|
|
3560
|
-
if (!opts.quiet) {
|
|
4633
|
+
if (!opts.quiet && isCurrent()) {
|
|
3561
4634
|
// Text, never innerHTML — `err.message` embeds chant's own stderr, which
|
|
3562
4635
|
// is not ours to interpolate as markup (the sibling precondition card has
|
|
3563
4636
|
// said so since #72; this branch had been left behind).
|
|
@@ -3574,6 +4647,35 @@ async function load(opts = {}) {
|
|
|
3574
4647
|
}
|
|
3575
4648
|
}
|
|
3576
4649
|
|
|
4650
|
+
/**
|
|
4651
|
+
* #404: re-read the overlay AND ask for the attribute-drift plan.
|
|
4652
|
+
*
|
|
4653
|
+
* A second row rather than a flag on `refresh()` above, for two reasons.
|
|
4654
|
+
*
|
|
4655
|
+
* The costs are different in kind. The ownership read a refresh already makes
|
|
4656
|
+
* (`live-ls` + `live-plan`) is answered out of the account's tagging index —
|
|
4657
|
+
* a handful of calls whatever the estate's size. `plan` refreshes every
|
|
4658
|
+
* resource, which is one provider read per card: on the workbench's
|
|
4659
|
+
* terralith-4 that is 301 reads for one keypress. Putting them on one button
|
|
4660
|
+
* would make the cheap, repeatable "did anything move?" silently cost the
|
|
4661
|
+
* expensive question every time.
|
|
4662
|
+
*
|
|
4663
|
+
* And they are not even the same request. `refresh()` is `POST /api/refresh`,
|
|
4664
|
+
* which re-observes the PRIMARY project and captures a lanes frame; it never
|
|
4665
|
+
* composes the estate. A choudoufu member is served through the estate branch
|
|
4666
|
+
* of `GET /api/overlay`, which is where the drift read lives — so this row
|
|
4667
|
+
* goes through `load()`, the path that actually renders those cards.
|
|
4668
|
+
*/
|
|
4669
|
+
async function refreshWithPlan() {
|
|
4670
|
+
planDriftOnce = true;
|
|
4671
|
+
bulkDiffCache = null; // the pane's rows come from /api/diff — re-fetch them against the new plan
|
|
4672
|
+
nowline("↻ planning — a full read of the account…");
|
|
4673
|
+
await load();
|
|
4674
|
+
const dm = driftMeta();
|
|
4675
|
+
if (dm && dm.read) nowline(dm.drifted ? `↻ plan read — ${dm.drifted} drifted` : "↻ plan read — nothing drifted");
|
|
4676
|
+
else nowline("↻ the plan could not be read — the overlay is ownership only");
|
|
4677
|
+
}
|
|
4678
|
+
|
|
3577
4679
|
// Refresh (#24): re-check live drift now and capture a lanes frame, in one
|
|
3578
4680
|
// round-trip. Renders the returned graph directly (no second pull); the server's
|
|
3579
4681
|
// `frames` event updates the lanes view.
|
|
@@ -3638,6 +4740,16 @@ async function initPickers() {
|
|
|
3638
4740
|
// way it gates `tiers`/`stacks`), so an unbuilt project or a chant < 0.50
|
|
3639
4741
|
// never grows a stop that would refuse.
|
|
3640
4742
|
opsAvailable = info.ops || 0;
|
|
4743
|
+
// #393: whether the runtime stop exists here, and where this estate can
|
|
4744
|
+
// honestly open. Both are read before the first load() below, so a non-chant
|
|
4745
|
+
// estate never paints the components apology on its way to a picture.
|
|
4746
|
+
runtimeCapable = !!info.runtimeCapable;
|
|
4747
|
+
stacksCapable = !!info.stacksCapable;
|
|
4748
|
+
const boot = bootZoom(info.memberKinds);
|
|
4749
|
+
if (boot !== "components") {
|
|
4750
|
+
applyZoom(boot);
|
|
4751
|
+
autoZoomFallback = false;
|
|
4752
|
+
}
|
|
3641
4753
|
// #254: carve mode declares itself here. The Carve tab is mounted at runtime
|
|
3642
4754
|
// (panel.js's addPanelTab), so nothing else grows a dead tab.
|
|
3643
4755
|
carveInfo = info.carve || null;
|
|
@@ -3659,20 +4771,39 @@ initPickers();
|
|
|
3659
4771
|
// No backend in a static export → no live event stream; a no-op keeps the
|
|
3660
4772
|
// `events.addEventListener(...)` wiring below harmless.
|
|
3661
4773
|
const events = staticMode ? { addEventListener() {} } : new EventSource("/api/events");
|
|
3662
|
-
//
|
|
3663
|
-
//
|
|
3664
|
-
//
|
|
3665
|
-
//
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
4774
|
+
// One notification requests one refresh. Further notifications during a slow
|
|
4775
|
+
// read collapse to one follow-up; no wall-clock timers multiply estate scans.
|
|
4776
|
+
// #422: one frame per member as its live read settles. Payload-carrying, like
|
|
4777
|
+
// `apply` and `run` — the node set is already on screen, so this is id ->
|
|
4778
|
+
// status and the client repaints in place. Nothing is re-fetched: a refetch
|
|
4779
|
+
// here would re-run the whole estate read this stream exists to narrate.
|
|
4780
|
+
events.addEventListener("member", (e) => {
|
|
4781
|
+
let frame;
|
|
4782
|
+
try {
|
|
4783
|
+
frame = JSON.parse(e.data);
|
|
4784
|
+
} catch {
|
|
4785
|
+
return;
|
|
4786
|
+
}
|
|
4787
|
+
// The live pass itself failed. Say so on the now-line and stop claiming the
|
|
4788
|
+
// remaining members are still arriving, because nothing more is coming.
|
|
4789
|
+
if (frame.error) {
|
|
4790
|
+
pendingMembers = [];
|
|
4791
|
+
nowline(`the estate read failed — ${frame.error}`);
|
|
4792
|
+
renderStatusbar();
|
|
4793
|
+
return;
|
|
4794
|
+
}
|
|
4795
|
+
if (!lastGraphIr) return;
|
|
4796
|
+
const moved = applyMemberFrame(lastGraphIr, frame);
|
|
4797
|
+
pendingMembers = stillPending(pendingMembers, frame);
|
|
4798
|
+
if (moved.length) recolorNodesByCategory(lastGraphIr);
|
|
4799
|
+
markPendingCards(lastGraphIr);
|
|
4800
|
+
renderStatusbar();
|
|
4801
|
+
});
|
|
4802
|
+
|
|
3671
4803
|
events.addEventListener("changed", () => {
|
|
3672
4804
|
bulkDiffCache = null; // an op ran → per-node live state may have changed
|
|
3673
4805
|
load();
|
|
3674
4806
|
loadSubstrates(); // a bring-up (or any op) finished → re-detect readiness
|
|
3675
|
-
scheduleSettle();
|
|
3676
4807
|
});
|
|
3677
4808
|
|
|
3678
4809
|
// Substrate readiness (M5, #54): is each substrate the project needs actually
|
|
@@ -3809,11 +4940,19 @@ setInterval(() => {
|
|
|
3809
4940
|
// can't fire a second pull while one's in flight. Ref-counted — nested loads
|
|
3810
4941
|
// (graph + CI + resources) only lift the scrim when the last finishes.
|
|
3811
4942
|
let loadingDepth = 0;
|
|
4943
|
+
// #421: what the LAST read cost, shown while this one runs. The person watching
|
|
4944
|
+
// the scrim wants to know whether to wait or go away, and behold has never told
|
|
4945
|
+
// them. Fetched after a load settles rather than during one — asking the server
|
|
4946
|
+
// how slow it is while it is being slow helps nobody — so the line always
|
|
4947
|
+
// describes the previous read and never guesses at this one.
|
|
4948
|
+
let lastReadCost = null;
|
|
3812
4949
|
function showLoading(msg) {
|
|
3813
4950
|
loadingDepth++;
|
|
3814
4951
|
const o = document.getElementById("loading-overlay");
|
|
3815
4952
|
if (!o) return;
|
|
3816
4953
|
document.getElementById("loading-msg").textContent = msg || "loading…";
|
|
4954
|
+
const cost = document.getElementById("loading-cost");
|
|
4955
|
+
if (cost) cost.textContent = lastReadCost || "";
|
|
3817
4956
|
o.hidden = false;
|
|
3818
4957
|
}
|
|
3819
4958
|
function hideLoading() {
|
|
@@ -3821,6 +4960,27 @@ function hideLoading() {
|
|
|
3821
4960
|
if (loadingDepth > 0) return;
|
|
3822
4961
|
const o = document.getElementById("loading-overlay");
|
|
3823
4962
|
if (o) o.hidden = true;
|
|
4963
|
+
void refreshReadCost();
|
|
4964
|
+
}
|
|
4965
|
+
|
|
4966
|
+
// One cheap call on counters, never on the read path. `?reads=1` is what makes
|
|
4967
|
+
// that true: the bare route also runs the full doctor diagnosis, whose substrate
|
|
4968
|
+
// probes spawn docker/gh/helm/k3d with no timeout, and this runs after every
|
|
4969
|
+
// load settles. A cost line is never worth breaking a load over, so every
|
|
4970
|
+
// failure here leaves the previous line standing.
|
|
4971
|
+
//
|
|
4972
|
+
// A static export has no backend to ask and no reads to report, so it does not
|
|
4973
|
+
// ask. Without this guard the bundle logs a 404 on every page load and relies on
|
|
4974
|
+
// the fetch failing to stay silent, which is an outcome rather than a rule.
|
|
4975
|
+
async function refreshReadCost() {
|
|
4976
|
+
if (staticMode) return;
|
|
4977
|
+
try {
|
|
4978
|
+
const res = await fetch("/api/doctor?reads=1");
|
|
4979
|
+
if (!res.ok) return;
|
|
4980
|
+
lastReadCost = readCostLine((await res.json()).reads);
|
|
4981
|
+
} catch {
|
|
4982
|
+
/* no line rather than a wrong one */
|
|
4983
|
+
}
|
|
3824
4984
|
}
|
|
3825
4985
|
|
|
3826
4986
|
// #259: the op stream is chant's own stdout, and an Op that reports as JSON
|
|
@@ -4409,6 +5569,17 @@ function paletteCommands() {
|
|
|
4409
5569
|
|
|
4410
5570
|
// Reads — always available, even in a static export or the preview lock.
|
|
4411
5571
|
if (!staticMode) c.push(["Re-check live (refresh drift)", () => refresh()]);
|
|
5572
|
+
// #404: the expensive sibling, listed beside it and named for what it costs.
|
|
5573
|
+
// Offered only with an env picked — there is no overlay to plan against
|
|
5574
|
+
// without one — and disabled-with-its-reason otherwise, the convention the
|
|
5575
|
+
// colour-mode and carve rows below already follow.
|
|
5576
|
+
if (!staticMode) {
|
|
5577
|
+
c.push(
|
|
5578
|
+
view.env
|
|
5579
|
+
? ["Re-check live with plan (attribute drift) — a full read of the account", () => refreshWithPlan()]
|
|
5580
|
+
: ["Re-check live with plan (attribute drift) — pick an environment first", () => showToast("attribute drift is read from a plan, which needs an environment to plan against", false)],
|
|
5581
|
+
);
|
|
5582
|
+
}
|
|
4412
5583
|
c.push(["Fit graph to view", () => fitGraph()]);
|
|
4413
5584
|
c.push(["Export: current graph as SVG", () => exportSvg()]);
|
|
4414
5585
|
const inspectCollapsed = document.getElementById("app").classList.contains("inspect-collapsed");
|
|
@@ -4439,12 +5610,39 @@ function paletteCommands() {
|
|
|
4439
5610
|
for (const [label, v] of availableZooms()) {
|
|
4440
5611
|
c.push([label + (v === zoomValue() ? " ✓" : ""), () => { applyZoom(v); load(); }]);
|
|
4441
5612
|
}
|
|
5613
|
+
// #399: the colour-by modes, with the same ✓ every other lens row carries.
|
|
5614
|
+
// A disabled mode is LISTED with its reason rather than hidden — same rule
|
|
5615
|
+
// #254's blocked carve steps follow, and the same reason: "why can't I do
|
|
5616
|
+
// that" is a question the palette should be able to answer.
|
|
5617
|
+
for (const mode of COLOUR_MODES) {
|
|
5618
|
+
const gate = mode === "drift" ? { available: true, reason: "" } : modeAvailability(lastMeta, mode);
|
|
5619
|
+
const why = gate.available ? "" : ` — ${gate.reason}`;
|
|
5620
|
+
c.push([
|
|
5621
|
+
`colour by: ${mode}${mode === activeColourMode() ? " ✓" : ""}${why}`,
|
|
5622
|
+
() => {
|
|
5623
|
+
if (!gate.available) return showToast(gate.reason, false);
|
|
5624
|
+
setColourMode(mode);
|
|
5625
|
+
},
|
|
5626
|
+
]);
|
|
5627
|
+
}
|
|
4442
5628
|
// Radial toggle — entity zooms only, same gate the removed checkbox had
|
|
4443
5629
|
// (components/logical/ops all lay themselves out: waves / nested arch boxes /
|
|
4444
5630
|
// phase boxes).
|
|
4445
5631
|
if (!view.components && !view.logical && !view.ops) {
|
|
4446
5632
|
c.push([(view.radial ? "Disable" : "Enable") + " radial layout", () => { view.radial = !view.radial; load(); }]);
|
|
4447
5633
|
}
|
|
5634
|
+
// #393: 301 cards in one box read as a strip at any zoom. This shuts every
|
|
5635
|
+
// box over the server's limit down to a card carrying its own counts, so an
|
|
5636
|
+
// estate can be read at the level of its members first.
|
|
5637
|
+
//
|
|
5638
|
+
// Not in a static export: `captureKeys` (src/export.ts) captures the estate
|
|
5639
|
+
// once, expanded, and a bundle that offers a stop it has no snapshot behind
|
|
5640
|
+
// answers the click with "not in this static export". `collapse` is in
|
|
5641
|
+
// `canonicalKey`'s LENS_PARAMS all the same, so the day a bundle does carry
|
|
5642
|
+
// both, the two keys are already distinct.
|
|
5643
|
+
if (!staticMode && !view.components && !view.ops) {
|
|
5644
|
+
c.push([view.collapse ? "Expand all" : "Collapse large boxes", () => { view.collapse = !view.collapse; load(); }]);
|
|
5645
|
+
}
|
|
4448
5646
|
|
|
4449
5647
|
// Env/stack/tier/target selection — replaces the old header pickers.
|
|
4450
5648
|
c.push(["env: (source)" + (!view.env ? " ✓" : ""), () => { view.env = null; resetDialCaches(); load(); }]);
|
|
@@ -4511,9 +5709,73 @@ function paletteCommands() {
|
|
|
4511
5709
|
return c.map(([label, run]) => ({ label, run }));
|
|
4512
5710
|
}
|
|
4513
5711
|
|
|
5712
|
+
// --- ⌘K takes an address (#393 item 4) ---------------------------------------
|
|
5713
|
+
// The audit's finding was that the palette had zoom, env, panel and deploy
|
|
5714
|
+
// commands and nothing that took a node. The SPA already holds the IR it
|
|
5715
|
+
// painted (`lastGraphIr`), so the whole feature is a filter over it.
|
|
5716
|
+
//
|
|
5717
|
+
// Built per keystroke rather than baked into `paletteCommands()` on open: a
|
|
5718
|
+
// 301-card estate would otherwise put 301 rows in front of somebody who typed
|
|
5719
|
+
// nothing, and push every command below them. Nothing at all until two
|
|
5720
|
+
// characters are typed, and at most {@link PAL_NODE_ROWS} rows after that.
|
|
5721
|
+
//
|
|
5722
|
+
// Outside `paletteCommands()` for a second reason: that function returns early
|
|
5723
|
+
// in a static export (no writes there at all), and finding a card is a READ.
|
|
5724
|
+
// Nothing below fetches anything — the IR is the one the page painted and the
|
|
5725
|
+
// pan is a viewBox — so a bundle answers ⌘K exactly as a served project does.
|
|
5726
|
+
|
|
5727
|
+
/** How many node rows the palette will show. A cap, not a ranking: past a
|
|
5728
|
+
* dozen the reader is scrolling a list rather than recognising a name, and the
|
|
5729
|
+
* answer is a longer query. */
|
|
5730
|
+
const PAL_NODE_ROWS = 12;
|
|
5731
|
+
|
|
5732
|
+
/** The address inside a composed id — `terralith-4/aws_iam_role.x` is the id,
|
|
5733
|
+
* `aws_iam_role.x` is what a person types. A member name cannot hold a slash
|
|
5734
|
+
* (composeStacks' `shortStackNames`), so the FIRST one splits it. */
|
|
5735
|
+
function nodeAddress(id) {
|
|
5736
|
+
const slash = id.indexOf("/");
|
|
5737
|
+
return slash >= 0 ? id.slice(slash + 1) : id;
|
|
5738
|
+
}
|
|
5739
|
+
function nodeMember(id) {
|
|
5740
|
+
const slash = id.indexOf("/");
|
|
5741
|
+
return slash >= 0 ? id.slice(0, slash) : "";
|
|
5742
|
+
}
|
|
5743
|
+
|
|
5744
|
+
/** The node rows for a query: matches on the composed id and on the address,
|
|
5745
|
+
* with anything that STARTS with the query first — typing `aws_iam_role.team_00`
|
|
5746
|
+
* should reach `aws_iam_role.team_0007_role` before a card that merely mentions
|
|
5747
|
+
* it. Stable within each half (the IR's own order), so the list does not
|
|
5748
|
+
* reshuffle as a query grows. */
|
|
5749
|
+
function paletteNodes(q) {
|
|
5750
|
+
if (!lastGraphIr || q.length < 2) return [];
|
|
5751
|
+
const prefix = [];
|
|
5752
|
+
const rest = [];
|
|
5753
|
+
for (const n of lastGraphIr.nodes) {
|
|
5754
|
+
const address = nodeAddress(n.id);
|
|
5755
|
+
const id = n.id.toLowerCase();
|
|
5756
|
+
const a = address.toLowerCase();
|
|
5757
|
+
if (a.startsWith(q) || id.startsWith(q)) prefix.push({ n, address });
|
|
5758
|
+
else if (a.includes(q) || id.includes(q)) rest.push({ n, address });
|
|
5759
|
+
if (prefix.length >= PAL_NODE_ROWS) break;
|
|
5760
|
+
}
|
|
5761
|
+
return [...prefix, ...rest].slice(0, PAL_NODE_ROWS).map(({ n, address }) => ({
|
|
5762
|
+
label: `node: ${address}`,
|
|
5763
|
+
// What the address alone does not say: which member it is in, and what it
|
|
5764
|
+
// is. Two members of one estate can declare the same address.
|
|
5765
|
+
sub: [nodeMember(n.id), n.kind, n.lexicon].filter(Boolean).join(" · "),
|
|
5766
|
+
run: () => {
|
|
5767
|
+
selectNode(n.id); // the same path a graph click takes — inspect included
|
|
5768
|
+
if (!revealNode(n.id)) showToast(`${address} is not on this view — it may be elided at this zoom`, false);
|
|
5769
|
+
},
|
|
5770
|
+
}));
|
|
5771
|
+
}
|
|
5772
|
+
|
|
4514
5773
|
function palRender() {
|
|
4515
5774
|
const q = palInput.value.toLowerCase().trim();
|
|
4516
|
-
|
|
5775
|
+
const cmds = q ? palCmds.filter((c) => c.label.toLowerCase().includes(q)) : palCmds;
|
|
5776
|
+
// Nodes first: a query that matches a card is nearly always somebody looking
|
|
5777
|
+
// for that card, and a query that matches no card costs nothing.
|
|
5778
|
+
palCurrent = [...paletteNodes(q), ...cmds];
|
|
4517
5779
|
palSel = Math.max(0, Math.min(palSel, palCurrent.length - 1));
|
|
4518
5780
|
palList.replaceChildren();
|
|
4519
5781
|
if (!palCurrent.length) {
|
|
@@ -4527,6 +5789,12 @@ function palRender() {
|
|
|
4527
5789
|
const d = document.createElement("div");
|
|
4528
5790
|
d.className = "row" + (i === palSel ? " sel" : "");
|
|
4529
5791
|
d.textContent = c.label;
|
|
5792
|
+
if (c.sub) {
|
|
5793
|
+
const sub = document.createElement("div");
|
|
5794
|
+
sub.className = "sub";
|
|
5795
|
+
sub.textContent = c.sub;
|
|
5796
|
+
d.appendChild(sub);
|
|
5797
|
+
}
|
|
4530
5798
|
d.onmousedown = (ev) => {
|
|
4531
5799
|
ev.preventDefault();
|
|
4532
5800
|
closePalette();
|