@intentius/behold 0.17.0 → 0.18.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 +517 -2
- package/LICENSE +190 -0
- package/README.md +22 -2
- package/THIRD_PARTY.md +75 -0
- package/demos.json +78 -15
- package/dist/cli.js +4585 -1467
- 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/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 +24 -7
- package/web/app.js +929 -20
- 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 +39 -1
package/web/app.js
CHANGED
|
@@ -8,7 +8,31 @@
|
|
|
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 { initTheme, mountThemePicker, readableOn, colorForCategory, onThemeChange, getTokens } from "./theme.js";
|
|
11
|
+
import { initTheme, mountThemePicker, readableOn, colorForCategory, onThemeChange, getTokens, getTheme, pinTokensFor } from "./theme.js";
|
|
12
|
+
// #399 M2 / #401 M4 of #397: the colour-by modes' arithmetic and the
|
|
13
|
+
// provenance badge's wording — every decision the behaviour overlay makes,
|
|
14
|
+
// pure and unit-tested beside the file. This module owns the fetches, the SVG
|
|
15
|
+
// and the panels; nothing below states a figure of its own.
|
|
16
|
+
import {
|
|
17
|
+
COLOUR_MODES,
|
|
18
|
+
badgeFor,
|
|
19
|
+
blockOf,
|
|
20
|
+
countFigures,
|
|
21
|
+
countsText,
|
|
22
|
+
domainFor,
|
|
23
|
+
effectiveMode,
|
|
24
|
+
figureOf,
|
|
25
|
+
fillFor,
|
|
26
|
+
fmtCost,
|
|
27
|
+
fmtHeadroom,
|
|
28
|
+
headroomOf,
|
|
29
|
+
modeAvailability,
|
|
30
|
+
rampFor,
|
|
31
|
+
rampGradient,
|
|
32
|
+
scaleEnds,
|
|
33
|
+
totalsFor,
|
|
34
|
+
totalsText,
|
|
35
|
+
} from "./behaviour-scale.js";
|
|
12
36
|
import { addPanelTab, initPanel, setPanelTab, togglePanelCollapsed, isPanelCollapsed } from "./panel.js";
|
|
13
37
|
// #254: the carve walkthrough's stepper — everything it DECIDES is a pure
|
|
14
38
|
// function in there; this file owns the fetches, the graph selection, and the
|
|
@@ -56,6 +80,7 @@ import { hasOperator, renderOperator, APPROVED_SEMANTICS } from "./operator.js";
|
|
|
56
80
|
// which commit, approved by whom — and, when the ledger's run id cannot be
|
|
57
81
|
// followed, the reason in words rather than a guessed link.
|
|
58
82
|
import { releaseRows } from "./release.js";
|
|
83
|
+
import { comparisonRow, comparisonSummary } from "./cross-env.js";
|
|
59
84
|
initTheme();
|
|
60
85
|
initPanel();
|
|
61
86
|
mountThemePicker(document.getElementById("panel-theme"));
|
|
@@ -65,16 +90,115 @@ mountThemePicker(document.getElementById("panel-theme"));
|
|
|
65
90
|
// labels/icons get readable ink (black/white) on the category fill. Re-runs on theme switch,
|
|
66
91
|
// since the categorical hues come from the active theme's palette.
|
|
67
92
|
let lastGraphIr = null;
|
|
93
|
+
|
|
94
|
+
// --- colour by drift, cost or headroom (#399, M2 of #397) -------------------
|
|
95
|
+
//
|
|
96
|
+
// The mode is CLIENT-SIDE state, deliberately. It changes no fetch: the server
|
|
97
|
+
// hands the same overlay whichever mode is picked — `attrs._behaviour` rides
|
|
98
|
+
// every priced card already (src/behaviour.ts explains why the block stays on
|
|
99
|
+
// the node), so a mode switch is a repaint of the SVG that is already on
|
|
100
|
+
// screen, with no round trip and nothing for a slow chant read to hold up. It
|
|
101
|
+
// is therefore NOT in `LENS_PARAMS`/`canonicalKey`: a lens param there means "a
|
|
102
|
+
// different snapshot comes back", and adding one would give a static export
|
|
103
|
+
// three identical bundles of the same graph under three keys.
|
|
104
|
+
//
|
|
105
|
+
// It survives a reload the way the theme does, and for the same reason — the
|
|
106
|
+
// colour you left the graph in is part of how you read it.
|
|
107
|
+
const COLOUR_STORE_KEY = "behold.colourBy";
|
|
108
|
+
let colourMode = "drift";
|
|
109
|
+
try {
|
|
110
|
+
const saved = localStorage.getItem(COLOUR_STORE_KEY);
|
|
111
|
+
if (COLOUR_MODES.includes(saved)) colourMode = saved;
|
|
112
|
+
} catch {
|
|
113
|
+
/* private mode */
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const COLOUR_MODE_TITLE = {
|
|
117
|
+
drift: "Colour each card by what chant observed live — the overlay behold has always drawn",
|
|
118
|
+
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.",
|
|
119
|
+
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.",
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/** `meta.behaviour` off the last render, or null. */
|
|
123
|
+
function behaviourMeta() {
|
|
124
|
+
return (lastMeta && lastMeta.behaviour) || null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The mode actually painted: the pick, unless this graph cannot serve it (no
|
|
128
|
+
* overlay, a refusal, an absence), in which case drift — which never depended
|
|
129
|
+
* on an engine. The PICK is kept either way, so switching back to a live env
|
|
130
|
+
* lands where you left off. */
|
|
131
|
+
function activeColourMode() {
|
|
132
|
+
return effectiveMode(colourMode, lastMeta);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function setColourMode(mode) {
|
|
136
|
+
colourMode = mode;
|
|
137
|
+
try {
|
|
138
|
+
localStorage.setItem(COLOUR_STORE_KEY, mode);
|
|
139
|
+
} catch {
|
|
140
|
+
/* private mode */
|
|
141
|
+
}
|
|
142
|
+
// No load(): the mode changes nothing the server would answer differently,
|
|
143
|
+
// so the SVG already on screen is repainted in place. renderStatusbar() is
|
|
144
|
+
// the one entry point that redraws the strip AND all three panel tabs, which
|
|
145
|
+
// is exactly the surface a mode switch moves.
|
|
146
|
+
recolorNodesByCategory();
|
|
147
|
+
renderStatusbar();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* What each card is painted, in the active mode.
|
|
152
|
+
*
|
|
153
|
+
* Drift keeps the categorical hue #62 gives a card (the drift state stays on
|
|
154
|
+
* the bar and the stroke, where pinhole puts it). The two behaviour modes
|
|
155
|
+
* replace the FILL with the mode's scale, so the drift bar underneath is
|
|
156
|
+
* untouched and both facts stay on the card at once.
|
|
157
|
+
*
|
|
158
|
+
* The scale's domain is taken over every entity in the graph that carries a
|
|
159
|
+
* figure — the same single walk of `ir.nodes` M1's design note promised, with
|
|
160
|
+
* no join against a second index.
|
|
161
|
+
*/
|
|
162
|
+
function colourPlan(graphIr) {
|
|
163
|
+
const mode = activeColourMode();
|
|
164
|
+
const tokens = getTokens();
|
|
165
|
+
const kindOf = new Map(graphIr.nodes.map((n) => [n.id, n.kind || n.lexicon || "node"]));
|
|
166
|
+
if (mode === "drift" || !tokens) {
|
|
167
|
+
return { mode, fillOf: (id) => ({ fill: colorForCategory(kindOf.get(id)), unpriced: false }) };
|
|
168
|
+
}
|
|
169
|
+
const theme = getTheme();
|
|
170
|
+
// The drift overlay's own neutral card ground — the colour a card wears when
|
|
171
|
+
// chant could not read it. #399: an entity with no block draws THIS, never
|
|
172
|
+
// the zero end of the scale, which would say "free" or "saturated" about an
|
|
173
|
+
// entity nothing priced.
|
|
174
|
+
const neutralFill = theme ? pinTokensFor(theme).neutralFill : tokens.neutral;
|
|
175
|
+
const ramp = rampFor(mode, tokens);
|
|
176
|
+
const domain = domainFor(
|
|
177
|
+
mode,
|
|
178
|
+
graphIr.nodes.map((n) => figureOf(mode, blockOf(n))),
|
|
179
|
+
);
|
|
180
|
+
const figures = new Map(graphIr.nodes.map((n) => [n.id, figureOf(mode, blockOf(n))]));
|
|
181
|
+
return { mode, domain, ramp, fillOf: (id) => fillFor(figures.get(id), domain, ramp, neutralFill) };
|
|
182
|
+
}
|
|
183
|
+
|
|
68
184
|
function recolorNodesByCategory(ir) {
|
|
69
185
|
if (ir) lastGraphIr = ir;
|
|
70
186
|
const graphIr = ir || lastGraphIr;
|
|
71
187
|
const svg = document.querySelector("#graph svg");
|
|
72
188
|
if (!svg || !graphIr) return;
|
|
73
189
|
const kindOf = new Map(graphIr.nodes.map((n) => [n.id, n.kind || n.lexicon || "node"]));
|
|
190
|
+
const plan = colourPlan(graphIr);
|
|
74
191
|
for (const g of svg.querySelectorAll("[data-node-id]")) {
|
|
75
|
-
const
|
|
192
|
+
const id = g.getAttribute("data-node-id");
|
|
193
|
+
const kind = kindOf.get(id);
|
|
76
194
|
if (!kind) continue;
|
|
77
|
-
const
|
|
195
|
+
const paint = plan.fillOf(id);
|
|
196
|
+
const cat = paint.fill, ink = readableOn(cat);
|
|
197
|
+
// #399: an unpriced card is marked, not merely coloured — a hex cannot be
|
|
198
|
+
// read back out into "the engine did not price this", and the legend's
|
|
199
|
+
// unpriced count has to agree with what is on the canvas.
|
|
200
|
+
if (plan.mode !== "drift" && paint.unpriced) g.setAttribute("data-unpriced", "1");
|
|
201
|
+
else g.removeAttribute("data-unpriced");
|
|
78
202
|
// Classify each element ONCE by the pinhole token it rode on (data-cat role), then apply
|
|
79
203
|
// the role's colour on every pass. This is what makes it recolour on theme switch: after
|
|
80
204
|
// the first pass the fill is a hex (not a --pin-* var), so we must key off the marker, not
|
|
@@ -158,7 +282,7 @@ if (staticMode) {
|
|
|
158
282
|
|
|
159
283
|
/** Canonical key for a read URL — path + the lens params (whitelisted, sorted)
|
|
160
284
|
* that select a distinct snapshot. MUST match src/export.ts `canonicalKey`. */
|
|
161
|
-
const LENS_PARAMS = ["components", "detail", "env", "logical", "ops", "radial", "runtime", "tier"];
|
|
285
|
+
const LENS_PARAMS = ["collapse", "components", "detail", "env", "logical", "ops", "radial", "runtime", "tier"];
|
|
162
286
|
function canonicalKey(path, params) {
|
|
163
287
|
// Components + logical + ops views ignore detail/radial — drop them so they
|
|
164
288
|
// match the single captured snapshot (MUST match src/export.ts).
|
|
@@ -187,6 +311,32 @@ function apiFetch(url) {
|
|
|
187
311
|
// reaches a declared entity — expected runtime (a Pod its Deployment
|
|
188
312
|
// created), never foreign and never drift. Additive the same way.
|
|
189
313
|
const STATUS_LABEL = { good: "managed", warn: "foreign", accent: "pending", neutral: "unobserved", runtime: "runtime child" };
|
|
314
|
+
// #393 item 8: the same four colours, in the words of whatever the estate's
|
|
315
|
+
// members ARE. The server derives the set from the member kinds and puts it on
|
|
316
|
+
// the overlay meta (`vocabulary`, src/status-vocabulary.ts) — on a choudoufu
|
|
317
|
+
// estate the legend reads bound / unowned / pending / not observed, which is
|
|
318
|
+
// what choudoufu itself calls those states. A mixed estate keeps chant's words
|
|
319
|
+
// and carries a `note` the legend shows as its tooltip. No meta, or a meta from
|
|
320
|
+
// a behold that predates this: chant's words, exactly as before.
|
|
321
|
+
function statusLabels() {
|
|
322
|
+
const v = lastMeta && lastMeta.vocabulary;
|
|
323
|
+
return (v && v.labels) || STATUS_LABEL;
|
|
324
|
+
}
|
|
325
|
+
function statusLabel(status) {
|
|
326
|
+
return statusLabels()[status];
|
|
327
|
+
}
|
|
328
|
+
function vocabularyNote() {
|
|
329
|
+
const v = lastMeta && lastMeta.vocabulary;
|
|
330
|
+
return (v && v.note) || "";
|
|
331
|
+
}
|
|
332
|
+
// #404: whether a plan was read for this overlay, and how many cards it would
|
|
333
|
+
// change. `{read: false}` and `{read: true, drifted: 0}` are DIFFERENT answers
|
|
334
|
+
// — "nobody looked" versus "looked, nothing drifted" — and the legend must not
|
|
335
|
+
// print a count for the first one. Null from a behold that predates this, and
|
|
336
|
+
// every reader below treats null as "not read".
|
|
337
|
+
function driftMeta() {
|
|
338
|
+
return (lastMeta && lastMeta.drift) || null;
|
|
339
|
+
}
|
|
190
340
|
// M1.1 (#57), palette hardened M2 (#54): the component-DAG live-status join
|
|
191
341
|
// paints the same `_status` vocabulary (good/warn/accent/neutral) but with
|
|
192
342
|
// different meaning — a stack-health reading, not "managed" — so the inspect
|
|
@@ -384,6 +534,37 @@ function renderDriftSection(section, r) {
|
|
|
384
534
|
pr("rendered", `${p.renderedAt} · helm ${p.helmVersion} · chant ${p.chantVersion}`);
|
|
385
535
|
}
|
|
386
536
|
|
|
537
|
+
const RESILIENCE_WORD = { survives: "survives", degrades: "degrades", fails: "fails" };
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* The `behaviour` inspect section (#401, M4 of #397) — one entity's whole
|
|
541
|
+
* block, as rows, with the provenance badge beside every figure.
|
|
542
|
+
*
|
|
543
|
+
* Every row here is the engine's claim, echoed. The currency is the engine's
|
|
544
|
+
* (behold converts none), the traffic level is the string the engine named
|
|
545
|
+
* (there is no default one), a headroom axis the engine did not report is
|
|
546
|
+
* simply not a row, and `rightSize` appears only when the engine suggested
|
|
547
|
+
* something. The badge on each figure says who stated it, to what tolerance,
|
|
548
|
+
* and whether it was modeled from list prices or validated against a bill.
|
|
549
|
+
*/
|
|
550
|
+
function renderBehaviourSection(section, b) {
|
|
551
|
+
if (!b) return;
|
|
552
|
+
const meta = behaviourMeta();
|
|
553
|
+
const one = [b];
|
|
554
|
+
const add = section("behaviour");
|
|
555
|
+
add("at", b.at.traffic);
|
|
556
|
+
add("cost", figureWithBadge(fmtCost(b.cost.perHour, b.cost.currency), one, meta));
|
|
557
|
+
const axes = [];
|
|
558
|
+
if (typeof b.headroom.cpu === "number") axes.push(`cpu ${fmtHeadroom(b.headroom.cpu)}`);
|
|
559
|
+
if (typeof b.headroom.latency === "number") axes.push(`latency ${fmtHeadroom(b.headroom.latency)}`);
|
|
560
|
+
const worst = headroomOf(b);
|
|
561
|
+
add("headroom", figureWithBadge(`${axes.join(" · ")} — colours on ${fmtHeadroom(worst)}`, one, meta));
|
|
562
|
+
add("error rate", figureWithBadge(`${(b.errorRate * 100).toFixed(2)}% of requests at ${b.at.traffic}`, one, meta));
|
|
563
|
+
add("resilience", figureWithBadge(`${RESILIENCE_WORD[b.resilience.verdict] || b.resilience.verdict} — ${b.resilience.failure}`, one, meta));
|
|
564
|
+
if (b.resilience.note) add("resilience note", b.resilience.note);
|
|
565
|
+
if (b.rightSize) add("right-size", figureWithBadge(b.rightSize.suggestion + (b.rightSize.reason ? ` — ${b.rightSize.reason}` : ""), one, meta));
|
|
566
|
+
}
|
|
567
|
+
|
|
387
568
|
function inspect(node) {
|
|
388
569
|
const panel = document.getElementById("inspect-body");
|
|
389
570
|
panel.innerHTML = "<h2>inspect</h2>";
|
|
@@ -431,7 +612,7 @@ function inspect(node) {
|
|
|
431
612
|
"status",
|
|
432
613
|
driftLabel ||
|
|
433
614
|
(node.lexicon === "op" ? opStatusLabel(node) : undefined) ||
|
|
434
|
-
(isArtifact ? ARTIFACT_STATUS_LABEL[st] : liveStatus || tickStatus ? COMPONENT_STATUS_LABEL[st] :
|
|
615
|
+
(isArtifact ? ARTIFACT_STATUS_LABEL[st] : liveStatus || tickStatus ? COMPONENT_STATUS_LABEL[st] : statusLabel(st)) ||
|
|
435
616
|
st,
|
|
436
617
|
);
|
|
437
618
|
if (node.attrs && node.attrs._artifact) {
|
|
@@ -508,6 +689,13 @@ function inspect(node) {
|
|
|
508
689
|
});
|
|
509
690
|
}
|
|
510
691
|
|
|
692
|
+
// #401: the behaviour block, field by field, with a badge on every figure.
|
|
693
|
+
// The colour on the card carries one number at a time (whichever mode is
|
|
694
|
+
// picked) and carries it as a hue; this is where the engine's whole
|
|
695
|
+
// statement about this entity is legible — and where a figure without its
|
|
696
|
+
// provenance would be visible as the contract violation it is.
|
|
697
|
+
renderBehaviourSection(section, blockOf(node));
|
|
698
|
+
|
|
511
699
|
// Render diff (#146's deferred half): what `chant helm diff <digest> <env>
|
|
512
700
|
// --live --json` said about this chart's PINNED render. Never rely on the
|
|
513
701
|
// colour alone (#57's accessibility note) — and here that matters twice
|
|
@@ -638,7 +826,19 @@ function inspect(node) {
|
|
|
638
826
|
const attrKeys = Object.keys(node.attrs || {}).filter((k) => !k.startsWith("_") && !(k === "release" && release));
|
|
639
827
|
if (attrKeys.length) {
|
|
640
828
|
const decl = section("declared");
|
|
641
|
-
|
|
829
|
+
// #393 item 10: an UNOWNED choudoufu card leads with the write that adopts
|
|
830
|
+
// the object — the two marker tags choudoufu named (`adopt_tofu_estate` /
|
|
831
|
+
// `adopt_tofu_address`), or its own one-line command if a release ever
|
|
832
|
+
// sends one. behold composes neither (src/choudoufu-live.ts); this puts
|
|
833
|
+
// the line first and makes it copyable, the way the moves panel already
|
|
834
|
+
// does for a `live-mv` line. The paragraph explaining the omission — which
|
|
835
|
+
// used to be the whole section, with the tags under it as JSON — follows.
|
|
836
|
+
const adoptable = node.lexicon === "choudoufu" && typeof node.attrs.adopt === "string" ? "adopt" : null;
|
|
837
|
+
if (adoptable) copyableRow(decl, adoptable, node.attrs.adopt, "Copy the tags that adopt this object");
|
|
838
|
+
for (const k of attrKeys) {
|
|
839
|
+
if (k === adoptable) continue;
|
|
840
|
+
decl(k, fmtValue(node.attrs[k]));
|
|
841
|
+
}
|
|
642
842
|
}
|
|
643
843
|
|
|
644
844
|
// CI projection facet (M1.2, #56/#58): loomster's GitLab CI is the SAME
|
|
@@ -738,6 +938,13 @@ function inspect(node) {
|
|
|
738
938
|
// node is instant (no per-node query). Via apiFetch, so a static export replays
|
|
739
939
|
// the captured snapshot. Cache is per env; cleared on lens change / after an op.
|
|
740
940
|
let bulkDiffCache = null; // { env, nodes: { <id>: { observed, diff, health } } }
|
|
941
|
+
// #404: ONE-SHOT. Set by the "Re-check live with plan" palette row and cleared
|
|
942
|
+
// by the request that carries it, so `plan=1` is exactly as opt-in as the
|
|
943
|
+
// person's keystroke — an ordinary load, a lens change or a reconnect never
|
|
944
|
+
// spawns a plan. The server still serves the change set that read produced
|
|
945
|
+
// from its own cache on the loads that follow, so the drift stays on screen
|
|
946
|
+
// without anyone paying for a second full pass over the account.
|
|
947
|
+
let planDriftOnce = false;
|
|
741
948
|
async function loadNodeDiff(id) {
|
|
742
949
|
if (!bulkDiffCache || bulkDiffCache.env !== view.env) {
|
|
743
950
|
bulkDiffCache = null;
|
|
@@ -802,11 +1009,15 @@ function renderObserved(panel, o, health, healthDetail) {
|
|
|
802
1009
|
// sends the raw condition object gets the tree instead of `[object Object]`.
|
|
803
1010
|
for (const c of conditions) add("condition", valueCell(c), "var(--degraded)");
|
|
804
1011
|
}
|
|
1012
|
+
// #393 item 10: the adoption line is a line to run, here as in the declared
|
|
1013
|
+
// section — first, and with the same copy button.
|
|
1014
|
+
if (typeof o.attributes?.adopt === "string") copyableRow(add, "adopt", o.attributes.adopt, "Copy the tags that adopt this object");
|
|
805
1015
|
// #259: an observed attribute is whatever the substrate reported — a k8s
|
|
806
1016
|
// `spec`, a nested `loadBalancer`, an array of ports. All of it collapsible
|
|
807
1017
|
// now, instead of one flat JSON.stringify line per key.
|
|
808
1018
|
for (const [k, v] of Object.entries(o.attributes || {})) {
|
|
809
1019
|
if (k === "conditions") continue; // rendered above, one line each
|
|
1020
|
+
if (k === "adopt" && typeof v === "string") continue; // led with, above
|
|
810
1021
|
add(k, valueCell(v));
|
|
811
1022
|
}
|
|
812
1023
|
panel.appendChild(dl);
|
|
@@ -814,6 +1025,11 @@ function renderObserved(panel, o, health, healthDetail) {
|
|
|
814
1025
|
|
|
815
1026
|
const DIFF_LABEL = {
|
|
816
1027
|
drifted: "drifted since snapshot",
|
|
1028
|
+
// #404: a choudoufu member has no snapshot to drift FROM — the category is
|
|
1029
|
+
// what a plan says it would do to the live object right now, so it does not
|
|
1030
|
+
// borrow chant's "since snapshot" wording. The rows underneath are the same
|
|
1031
|
+
// `{path, oldValue, newValue}` triples, printed `before → after`.
|
|
1032
|
+
planned: "the plan would change this",
|
|
817
1033
|
missing: "declared, not in cloud",
|
|
818
1034
|
orphan: "in cloud, not declared",
|
|
819
1035
|
disappeared: "gone since snapshot",
|
|
@@ -987,7 +1203,7 @@ function wire(ir) {
|
|
|
987
1203
|
// null on a project that declares no `stacks[]` at all — the picker (and the
|
|
988
1204
|
// status strip's stack tag) then never renders. Every fetch reads this, so
|
|
989
1205
|
// 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 };
|
|
1206
|
+
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
1207
|
|
|
992
1208
|
// #182: `components` is the boot default, but a project that declares no
|
|
993
1209
|
// components renders it as ZERO nodes — the first screen was a blank graph
|
|
@@ -997,6 +1213,20 @@ const view = { env: null, detail: 2, components: true, logical: false, runtime:
|
|
|
997
1213
|
// or ⌘K) still shows the honest empty view + the server's note.
|
|
998
1214
|
let autoZoomFallback = true;
|
|
999
1215
|
|
|
1216
|
+
// #393 item 1: for an estate whose members are NONE of them chant projects, the
|
|
1217
|
+
// fallback above is a round-trip too late and one degree too vague. The
|
|
1218
|
+
// components zoom is a chant projection of a chant project's own component DAG;
|
|
1219
|
+
// a choudoufu estate or a bare Terraform directory has no components to
|
|
1220
|
+
// project, so the first screen was "the components lens doesn't apply to a
|
|
1221
|
+
// composed estate yet" — an apology, every time, before the person had picked
|
|
1222
|
+
// anything. The kinds come from /api/project (`memberKinds`, in composition
|
|
1223
|
+
// order), so the decision is made from what the server already knows rather
|
|
1224
|
+
// than from a zero-node answer. A chant member anywhere in the estate keeps
|
|
1225
|
+
// #182's behaviour exactly, and picking components later still shows the note.
|
|
1226
|
+
function bootZoom(memberKinds) {
|
|
1227
|
+
return memberKinds && memberKinds.length && !memberKinds.includes("chant") ? "resources" : "components";
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1000
1230
|
// v0.1.0 preview lock (set from /api/project in initActions): hides the git/PR
|
|
1001
1231
|
// write ops (Rollback, Sync, Adopt, Run ▾) — the server also 403s them. Local
|
|
1002
1232
|
// deploy (Apply all / dial), Reset, Bring up, Approve, and reads stay on.
|
|
@@ -1034,11 +1264,19 @@ const ZOOM_DETAIL = { composites: 1, resources: 2, attributes: 3, runtime: 3 };
|
|
|
1034
1264
|
/** How many emitted Ops the served estate has (0 = the stop doesn't exist).
|
|
1035
1265
|
* Seeded from /api/project in initPickers(). */
|
|
1036
1266
|
let opsAvailable = 0;
|
|
1037
|
-
/**
|
|
1038
|
-
*
|
|
1039
|
-
*
|
|
1267
|
+
/** #393 item 2: whether ANY served member could have owner-referenced children
|
|
1268
|
+
* — /api/project's `runtimeCapable`, decided from the members' declared
|
|
1269
|
+
* lexicons (src/server.ts's RUNTIME_LEXICONS). Before this the stop needed only
|
|
1270
|
+
* an env, so every choudoufu estate (which declares `live`) offered a
|
|
1271
|
+
* Kubernetes tier that could only ever answer "nothing below the declaration
|
|
1272
|
+
* boundary". */
|
|
1273
|
+
let runtimeCapable = false;
|
|
1274
|
+
/** The zoom stops this project can actually offer — `runtime` needs an env AND
|
|
1275
|
+
* a member whose substrate has an owner chain, `ops` needs emitted op.json
|
|
1276
|
+
* files. Both the panel and ⌘K read this, so a stop never appears in one
|
|
1277
|
+
* surface and not the other. */
|
|
1040
1278
|
function availableZooms() {
|
|
1041
|
-
return ZOOM_OPTS.filter(([, z]) => (z === "runtime" ? Boolean(view.env) : z === "ops" ? opsAvailable > 0 : true));
|
|
1279
|
+
return ZOOM_OPTS.filter(([, z]) => (z === "runtime" ? Boolean(view.env) && runtimeCapable : z === "ops" ? opsAvailable > 0 : true));
|
|
1042
1280
|
}
|
|
1043
1281
|
/** Current zoom value from (components, logical, ops, detail). */
|
|
1044
1282
|
function zoomValue() {
|
|
@@ -1109,6 +1347,28 @@ function renderPanelView() {
|
|
|
1109
1347
|
}),
|
|
1110
1348
|
);
|
|
1111
1349
|
}
|
|
1350
|
+
// #399: the three-state colour switch, beside the zoom — the other thing
|
|
1351
|
+
// this tab is for is "what am I looking at", and colour is half that answer.
|
|
1352
|
+
// Sits below the stops because it is not a granularity: every zoom paints in
|
|
1353
|
+
// whichever of the three you picked.
|
|
1354
|
+
zoom.appendChild(panelHeading("colour by"));
|
|
1355
|
+
for (const mode of COLOUR_MODES) {
|
|
1356
|
+
const gate = mode === "drift" ? { available: true, reason: "" } : modeAvailability(lastMeta, mode);
|
|
1357
|
+
const b = panelOpt(
|
|
1358
|
+
mode,
|
|
1359
|
+
mode === activeColourMode(),
|
|
1360
|
+
() => {
|
|
1361
|
+
if (!gate.available) return;
|
|
1362
|
+
setColourMode(mode);
|
|
1363
|
+
},
|
|
1364
|
+
gate.available ? COLOUR_MODE_TITLE[mode] : gate.reason,
|
|
1365
|
+
);
|
|
1366
|
+
// #401: disabled VISIBLY, with the lexicon's own reason on it. A control
|
|
1367
|
+
// greyed out is where a person asks "why not", so that is where the answer
|
|
1368
|
+
// goes — never hidden, which would leave the question unasked.
|
|
1369
|
+
if (!gate.available) b.disabled = true;
|
|
1370
|
+
zoom.appendChild(b);
|
|
1371
|
+
}
|
|
1112
1372
|
// Radial toggle — entity zooms only, same gate the ⌘K entry has
|
|
1113
1373
|
// (components/logical/ops all lay themselves out: waves / nested arch boxes /
|
|
1114
1374
|
// phase boxes).
|
|
@@ -1270,6 +1530,154 @@ function renderCarveState(host, state) {
|
|
|
1270
1530
|
host.appendChild(panelMuted(state.apply.note));
|
|
1271
1531
|
}
|
|
1272
1532
|
|
|
1533
|
+
/**
|
|
1534
|
+
* One inspect row whose value is a line to run: the line itself, monospaced,
|
|
1535
|
+
* and a copy button beside it — the affordance the moves panel already gives a
|
|
1536
|
+
* `live-mv` line (#371), reused here for the tags that adopt an unowned object
|
|
1537
|
+
* (#393 item 10). `add` is a section's own row adder, so the row sits in the
|
|
1538
|
+
* section it belongs to rather than in a box of its own.
|
|
1539
|
+
*/
|
|
1540
|
+
function copyableRow(add, key, text, title) {
|
|
1541
|
+
const wrap = document.createElement("span");
|
|
1542
|
+
wrap.style.cssText = "display:flex;gap:6px;align-items:baseline;min-width:0";
|
|
1543
|
+
const line = document.createElement("code");
|
|
1544
|
+
line.className = "grow";
|
|
1545
|
+
line.style.cssText = "flex:1;min-width:0;overflow-wrap:anywhere";
|
|
1546
|
+
line.textContent = text;
|
|
1547
|
+
const copy = actButton("copy", () => copyToClipboard(text, copy), title);
|
|
1548
|
+
wrap.append(line, copy);
|
|
1549
|
+
add(key, wrap);
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
/** Copy `text` to the clipboard and say so on the button for a moment. */
|
|
1553
|
+
function copyToClipboard(text, el) {
|
|
1554
|
+
const done = () => {
|
|
1555
|
+
const was = el.textContent;
|
|
1556
|
+
el.textContent = "copied ✓";
|
|
1557
|
+
setTimeout(() => {
|
|
1558
|
+
el.textContent = was;
|
|
1559
|
+
}, 1200);
|
|
1560
|
+
};
|
|
1561
|
+
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done, done);
|
|
1562
|
+
else done();
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
/**
|
|
1566
|
+
* The choudoufu move plan (#371), as the scope panel shows it: one row per
|
|
1567
|
+
* move (`address: from → to`), the line a person runs in the destination
|
|
1568
|
+
* estate's directory with a copy button, and a link to the morph page. The
|
|
1569
|
+
* plan is read from `/api/choudoufu/moves`, which previews with choudoufu's
|
|
1570
|
+
* own `-dry-run` when asked and never runs the write — `apply.note` carries
|
|
1571
|
+
* that sentence here verbatim. A no-op when no served member carries a
|
|
1572
|
+
* `carve.json`.
|
|
1573
|
+
*/
|
|
1574
|
+
function renderChoudoufuMoves(host, info) {
|
|
1575
|
+
if (!info || !info.plans || !info.plans.length || staticMode) return;
|
|
1576
|
+
host.appendChild(panelHeading("moves (choudoufu)"));
|
|
1577
|
+
for (const plan of info.plans) {
|
|
1578
|
+
const head = document.createElement("div");
|
|
1579
|
+
head.className = "count-row";
|
|
1580
|
+
const label = document.createElement("span");
|
|
1581
|
+
label.className = "grow";
|
|
1582
|
+
label.textContent = plan;
|
|
1583
|
+
const morph = document.createElement("a");
|
|
1584
|
+
morph.className = "tag";
|
|
1585
|
+
morph.textContent = "morph ↗";
|
|
1586
|
+
morph.href = "/choudoufu/morph?plan=" + encodeURIComponent(plan);
|
|
1587
|
+
morph.target = "_blank";
|
|
1588
|
+
morph.title = "Every member a box, the moved cards gliding to their destination";
|
|
1589
|
+
head.append(label, morph);
|
|
1590
|
+
host.appendChild(head);
|
|
1591
|
+
const rows = document.createElement("div");
|
|
1592
|
+
host.appendChild(rows);
|
|
1593
|
+
apiFetch("/api/choudoufu/moves?plan=" + encodeURIComponent(plan))
|
|
1594
|
+
.then((r) => r.json())
|
|
1595
|
+
.then((body) => {
|
|
1596
|
+
if (!body || !Array.isArray(body.moves)) {
|
|
1597
|
+
rows.appendChild(panelMuted((body && body.error) || "plan unreadable"));
|
|
1598
|
+
return;
|
|
1599
|
+
}
|
|
1600
|
+
for (const m of body.moves) {
|
|
1601
|
+
const row = document.createElement("div");
|
|
1602
|
+
row.className = "count-row";
|
|
1603
|
+
const name = document.createElement("span");
|
|
1604
|
+
name.className = "grow";
|
|
1605
|
+
name.textContent = m.address + (m.newAddress ? " → " + m.newAddress : "");
|
|
1606
|
+
name.title = "run in " + m.runIn;
|
|
1607
|
+
const tag = document.createElement("span");
|
|
1608
|
+
tag.className = "tag";
|
|
1609
|
+
tag.textContent = m.from + " → " + m.to;
|
|
1610
|
+
const copy = actButton("copy", () => copyToClipboard(m.command, copy), "Copy the live-mv line to the clipboard");
|
|
1611
|
+
row.append(name, tag, copy);
|
|
1612
|
+
rows.appendChild(row);
|
|
1613
|
+
rows.appendChild(panelMuted(m.command));
|
|
1614
|
+
}
|
|
1615
|
+
if (body.apply && body.apply.note) rows.appendChild(panelMuted(body.apply.note));
|
|
1616
|
+
})
|
|
1617
|
+
.catch(() => rows.appendChild(panelMuted("plan unreadable — is the behold server still running?")));
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
/**
|
|
1622
|
+
* The totals, per box and per estate, in the active mode (#399).
|
|
1623
|
+
*
|
|
1624
|
+
* Sits in the Scope tab because Scope is already the tab that says what this
|
|
1625
|
+
* estate IS — its members, its moves, its environments — and a per-box figure
|
|
1626
|
+
* is a fact about a member, not about the picture.
|
|
1627
|
+
*
|
|
1628
|
+
* In `drift` mode there is no row at all. Drift counts states; a sum of states
|
|
1629
|
+
* is not a thing, and the Model tab's legend already carries the counts.
|
|
1630
|
+
*
|
|
1631
|
+
* Where the figure comes from is on the row, every time. `cost` quotes the
|
|
1632
|
+
* server: `meta.behaviour.boxes[<key>]` per box and `.sum` for the estate, both
|
|
1633
|
+
* additions of engine figures — or `meta.behaviour.total`, the engine's own
|
|
1634
|
+
* estate figure, which outranks the sum and is labelled "engine total" so the
|
|
1635
|
+
* two can never be confused. `headroom` has no server-side aggregate, because
|
|
1636
|
+
* no engine states one and adding fractions of capacity would mean nothing, so
|
|
1637
|
+
* the SPA takes the worst and middle card of the scope itself and the row says
|
|
1638
|
+
* "computed".
|
|
1639
|
+
*/
|
|
1640
|
+
function renderBehaviourTotals(host) {
|
|
1641
|
+
const mode = activeColourMode();
|
|
1642
|
+
if (mode === "drift") return;
|
|
1643
|
+
const b = behaviourMeta();
|
|
1644
|
+
const ir = lastGraphIr;
|
|
1645
|
+
if (!b || !ir) return;
|
|
1646
|
+
const byId = new Map(ir.nodes.map((n) => [n.id, n]));
|
|
1647
|
+
const boxes = (ir.groups && ir.groups.byStack) || {};
|
|
1648
|
+
host.appendChild(panelHeading(`${mode} totals`));
|
|
1649
|
+
|
|
1650
|
+
const row = (label, blocks, sum, engineTotal, title) => {
|
|
1651
|
+
const totals = totalsFor(mode, blocks, sum, engineTotal);
|
|
1652
|
+
if (!totals) return;
|
|
1653
|
+
// Stacked, not a name/value row: the panel is 260px and one of these
|
|
1654
|
+
// lines is "0.5669 USD/h · 41 priced · 4 unpriced" — side by side, the
|
|
1655
|
+
// figure takes the width and the box's name ellipses away to nothing,
|
|
1656
|
+
// which is the one part of the row that says WHAT is being totalled.
|
|
1657
|
+
const r = document.createElement("div");
|
|
1658
|
+
r.className = "behaviour-total";
|
|
1659
|
+
const name = document.createElement("div");
|
|
1660
|
+
name.className = "grow";
|
|
1661
|
+
name.style.fontWeight = "600";
|
|
1662
|
+
name.textContent = label;
|
|
1663
|
+
if (title) name.title = title;
|
|
1664
|
+
r.appendChild(name);
|
|
1665
|
+
r.appendChild(figureWithBadge(totalsText(totals), blocks.filter(Boolean), b));
|
|
1666
|
+
host.appendChild(r);
|
|
1667
|
+
};
|
|
1668
|
+
|
|
1669
|
+
for (const [key, ids] of Object.entries(boxes).sort(([a], [c]) => a.localeCompare(c))) {
|
|
1670
|
+
const blocks = ids.map((id) => blockOf(byId.get(id)));
|
|
1671
|
+
row(key, blocks, (b.boxes || {})[key], undefined, `${ids.length} entities in this box`);
|
|
1672
|
+
}
|
|
1673
|
+
const all = ir.nodes.map((n) => blockOf(n));
|
|
1674
|
+
// `total` is the engine's word and only ever holds an engine's own figure;
|
|
1675
|
+
// `sum` is behold's addition. Passing both lets totalsFor apply #398's
|
|
1676
|
+
// precedence in one place rather than at each call site.
|
|
1677
|
+
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");
|
|
1678
|
+
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."));
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1273
1681
|
function renderPanelScope() {
|
|
1274
1682
|
const host = document.getElementById("tab-scope");
|
|
1275
1683
|
if (!host) return;
|
|
@@ -1301,6 +1709,13 @@ function renderPanelScope() {
|
|
|
1301
1709
|
// one — and it renders identically in carve mode and on an ordinary project
|
|
1302
1710
|
// serve, because /api/project publishes one shape for both.
|
|
1303
1711
|
renderCarveState(host, info.carve && info.carve.state);
|
|
1712
|
+
// #399: what the estate costs, or how much room it has left, per member box
|
|
1713
|
+
// and for the whole estate — right under the member list the figures are
|
|
1714
|
+
// about, and above the moves that would change them.
|
|
1715
|
+
renderBehaviourTotals(host);
|
|
1716
|
+
// #371: a choudoufu member's move plan — the handoff lines with copy
|
|
1717
|
+
// buttons, and the morph page. Read-only: behold never runs live-mv.
|
|
1718
|
+
renderChoudoufuMoves(host, info.choudoufu);
|
|
1304
1719
|
// #195: switching — recents first (server-persisted, validated), then a
|
|
1305
1720
|
// free path input. Locked in preview mode (the demo's contract) and
|
|
1306
1721
|
// meaningless in a static export.
|
|
@@ -1362,6 +1777,57 @@ function renderPanelScope() {
|
|
|
1362
1777
|
);
|
|
1363
1778
|
}
|
|
1364
1779
|
if (!environments.length) host.appendChild(panelMuted("no environments declared"));
|
|
1780
|
+
|
|
1781
|
+
// #234's last remainder — "is prod running what staging tested". Only when
|
|
1782
|
+
// an env is picked and there is another to compare against; ledger-only, so
|
|
1783
|
+
// it never adds a cloud read to opening the panel (fetch happens on pick).
|
|
1784
|
+
if (view.env && environments.length > 1 && !staticMode) {
|
|
1785
|
+
if (view.compareTo === view.env) view.compareTo = null; // env re-picked onto the compared one
|
|
1786
|
+
host.appendChild(panelHeading("compare releases"));
|
|
1787
|
+
const sel = document.createElement("select");
|
|
1788
|
+
sel.add(new Option("(off)", "", view.compareTo == null, view.compareTo == null));
|
|
1789
|
+
for (const e of environments) {
|
|
1790
|
+
if (e === view.env) continue;
|
|
1791
|
+
sel.add(new Option(`vs ${e}`, e, false, view.compareTo === e));
|
|
1792
|
+
}
|
|
1793
|
+
sel.title = "Compare this environment's recorded releases with another's — per component, on the comparable identity (inputs for pinned deploys)";
|
|
1794
|
+
const result = document.createElement("div");
|
|
1795
|
+
const render = (payload) => {
|
|
1796
|
+
result.replaceChildren();
|
|
1797
|
+
const s = comparisonSummary(payload);
|
|
1798
|
+
const line = document.createElement("div");
|
|
1799
|
+
line.style.cssText = `font-size:var(--t-caption);color:${COMPONENT_STATUS_VAR[s.tone === "warn" ? "warn" : s.tone === "good" ? "good" : "neutral"]};margin:4px 0`;
|
|
1800
|
+
line.textContent = s.text;
|
|
1801
|
+
result.appendChild(line);
|
|
1802
|
+
for (const c of payload.comparisons || []) {
|
|
1803
|
+
const r = comparisonRow(c);
|
|
1804
|
+
const row = panelDotRow(COMPONENT_STATUS_VAR[r.tone === "warn" ? "warn" : r.tone === "good" ? "good" : "neutral"], r.component, r.verdict, () => selectNode(r.component));
|
|
1805
|
+
row.title = r.detail;
|
|
1806
|
+
result.appendChild(row);
|
|
1807
|
+
}
|
|
1808
|
+
};
|
|
1809
|
+
sel.addEventListener("change", () => {
|
|
1810
|
+
view.compareTo = sel.value || null;
|
|
1811
|
+
result.replaceChildren();
|
|
1812
|
+
if (!view.compareTo) return;
|
|
1813
|
+
result.appendChild(panelMuted("comparing…"));
|
|
1814
|
+
fetch(`/api/components/compare?env=${encodeURIComponent(view.env)}&to=${encodeURIComponent(view.compareTo)}`)
|
|
1815
|
+
.then((r) => r.json())
|
|
1816
|
+
.then((payload) => {
|
|
1817
|
+
if (view.compareTo !== sel.value) return; // re-picked while loading
|
|
1818
|
+
if (payload.error) {
|
|
1819
|
+
result.replaceChildren(panelMuted(payload.error));
|
|
1820
|
+
return;
|
|
1821
|
+
}
|
|
1822
|
+
render(payload);
|
|
1823
|
+
})
|
|
1824
|
+
.catch((err) => result.replaceChildren(panelMuted(`compare failed: ${err && err.message ? err.message : err}`)));
|
|
1825
|
+
});
|
|
1826
|
+
host.appendChild(sel);
|
|
1827
|
+
host.appendChild(result);
|
|
1828
|
+
// Restore an active comparison across panel re-renders.
|
|
1829
|
+
if (view.compareTo) sel.dispatchEvent(new Event("change"));
|
|
1830
|
+
}
|
|
1365
1831
|
if (stacks.length) {
|
|
1366
1832
|
host.appendChild(panelHeading("stack"));
|
|
1367
1833
|
for (const s of stacks) {
|
|
@@ -1410,6 +1876,103 @@ function renderPanelScope() {
|
|
|
1410
1876
|
const DRIFT_STATUS_VAR = { good: "var(--managed)", warn: "var(--foreign)", accent: "var(--pending)", neutral: "var(--muted)", runtime: "var(--runtime)" };
|
|
1411
1877
|
const COMPONENT_STATUS_VAR = { good: "var(--managed)", accent: "var(--pending)", warn: "var(--degraded)", neutral: "var(--muted)" };
|
|
1412
1878
|
|
|
1879
|
+
/**
|
|
1880
|
+
* The provenance badge (#401, M4 of #397) — `{engine} {version} · {tolerance}
|
|
1881
|
+
* · {basis}`, beside a figure rather than once per page.
|
|
1882
|
+
*
|
|
1883
|
+
* #397's one prohibition is a prediction presented as a bill, and this element
|
|
1884
|
+
* is where that is prevented: the word `modeled` is on the badge and the
|
|
1885
|
+
* sentence "modeled, not billed" is on its hover, on every figure that carries
|
|
1886
|
+
* one. `blocks` is the entities the figure was derived from — one for an
|
|
1887
|
+
* inspect row, a whole box or estate for a totals row — because #398 puts
|
|
1888
|
+
* provenance on the ENTITY, so a badge over a set can only say what the set
|
|
1889
|
+
* agrees on (badgeFor says `mixed` where it does not).
|
|
1890
|
+
*/
|
|
1891
|
+
function badgeEl(blocks, meta) {
|
|
1892
|
+
const b = badgeFor(blocks, meta);
|
|
1893
|
+
if (!b) return null;
|
|
1894
|
+
const el = document.createElement("span");
|
|
1895
|
+
el.className = "tag behaviour-badge";
|
|
1896
|
+
el.textContent = b.text;
|
|
1897
|
+
el.title = b.title;
|
|
1898
|
+
return el;
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
/** A figure and its badge on one line — the shape every behaviour figure the
|
|
1902
|
+
* SPA shows takes, so none of them can end up without one. */
|
|
1903
|
+
function figureWithBadge(text, blocks, meta) {
|
|
1904
|
+
const wrap = document.createElement("span");
|
|
1905
|
+
wrap.style.cssText = "display:flex;gap:6px;align-items:baseline;flex-wrap:wrap;min-width:0";
|
|
1906
|
+
const val = document.createElement("span");
|
|
1907
|
+
val.textContent = text;
|
|
1908
|
+
wrap.appendChild(val);
|
|
1909
|
+
const badge = badgeEl(blocks, meta);
|
|
1910
|
+
if (badge) wrap.appendChild(badge);
|
|
1911
|
+
return wrap;
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
/**
|
|
1915
|
+
* One mode's legend (#399: one legend per mode).
|
|
1916
|
+
*
|
|
1917
|
+
* `drift` is not here — it keeps #393's vocabulary legend, the estate's own
|
|
1918
|
+
* words for the four states, which is the whole point of that issue. The two
|
|
1919
|
+
* behaviour modes get a ramp with its two ends labelled, the priced/unpriced
|
|
1920
|
+
* split, the neutral swatch that says what an unpriced card is drawn in, and
|
|
1921
|
+
* the badge for the figures the ramp spans.
|
|
1922
|
+
*/
|
|
1923
|
+
function renderBehaviourLegend(host, mode) {
|
|
1924
|
+
const b = behaviourMeta();
|
|
1925
|
+
const ir = lastGraphIr;
|
|
1926
|
+
if (!b || !ir) return;
|
|
1927
|
+
const blocks = ir.nodes.map((n) => blockOf(n));
|
|
1928
|
+
const values = ir.nodes.map((n) => figureOf(mode, blockOf(n)));
|
|
1929
|
+
const domain = domainFor(mode, values);
|
|
1930
|
+
const tokens = getTokens();
|
|
1931
|
+
const ramp = rampFor(mode, tokens);
|
|
1932
|
+
const currency = (blocks.find((x) => x && x.cost) || { cost: {} }).cost.currency || "";
|
|
1933
|
+
const heading = panelHeading(`${mode} · ${lastMeta.env}`);
|
|
1934
|
+
heading.title = COLOUR_MODE_TITLE[mode];
|
|
1935
|
+
host.appendChild(heading);
|
|
1936
|
+
|
|
1937
|
+
const bar = document.createElement("div");
|
|
1938
|
+
bar.className = "behaviour-ramp";
|
|
1939
|
+
bar.style.cssText = `height:10px;border-radius:5px;border:1px solid var(--line);background:${rampGradient(ramp)}`;
|
|
1940
|
+
host.appendChild(bar);
|
|
1941
|
+
const ends = scaleEnds(mode, domain, currency);
|
|
1942
|
+
if (ends) {
|
|
1943
|
+
const row = document.createElement("div");
|
|
1944
|
+
row.className = "count-row";
|
|
1945
|
+
const lo = document.createElement("span");
|
|
1946
|
+
lo.className = "grow";
|
|
1947
|
+
lo.textContent = ends.low;
|
|
1948
|
+
const hi = document.createElement("span");
|
|
1949
|
+
hi.className = "tag";
|
|
1950
|
+
hi.textContent = ends.high;
|
|
1951
|
+
row.append(lo, hi);
|
|
1952
|
+
host.appendChild(row);
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
const counts = countFigures(mode, blocks);
|
|
1956
|
+
const priced = blocks.filter((x) => x && figureOf(mode, x) !== undefined);
|
|
1957
|
+
const line = document.createElement("div");
|
|
1958
|
+
line.className = "count-row";
|
|
1959
|
+
const txt = document.createElement("span");
|
|
1960
|
+
txt.className = "grow";
|
|
1961
|
+
txt.textContent = countsText(counts);
|
|
1962
|
+
line.appendChild(txt);
|
|
1963
|
+
const badge = badgeEl(priced, b);
|
|
1964
|
+
if (badge) line.appendChild(badge);
|
|
1965
|
+
host.appendChild(line);
|
|
1966
|
+
|
|
1967
|
+
// The neutral, spelled out. Without this row the grey cards read as a third
|
|
1968
|
+
// band of the scale rather than as "nothing priced these".
|
|
1969
|
+
const theme = getTheme();
|
|
1970
|
+
host.appendChild(
|
|
1971
|
+
panelDotRow(theme ? pinTokensFor(theme).neutralFill : "var(--muted)", "unpriced — no behaviour block", String(counts.unpriced)),
|
|
1972
|
+
);
|
|
1973
|
+
if (mode === "headroom") host.appendChild(panelMuted("the lower of the axes the engine reported; a missing axis is absent, not zero"));
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1413
1976
|
function panelDotRow(color, main, tag, onClick) {
|
|
1414
1977
|
const row = document.createElement("div");
|
|
1415
1978
|
row.className = onClick ? "node-row" : "count-row";
|
|
@@ -1454,6 +2017,15 @@ function renderPanelModel() {
|
|
|
1454
2017
|
host.appendChild(panelMuted("no graph loaded yet"));
|
|
1455
2018
|
return;
|
|
1456
2019
|
}
|
|
2020
|
+
// #393 item 7: the note in full, where there is room for it. The strip may be
|
|
2021
|
+
// showing the server's short form (a 260px strip cannot hold five root names,
|
|
2022
|
+
// two skip reasons and five block counts), so the whole sentence has to be
|
|
2023
|
+
// readable somewhere that is not a tooltip — and this tab is where the panel
|
|
2024
|
+
// already explains the picture.
|
|
2025
|
+
if (lastNote && lastNoteShort) {
|
|
2026
|
+
host.appendChild(panelHeading("note"));
|
|
2027
|
+
host.appendChild(panelMuted(lastNote));
|
|
2028
|
+
}
|
|
1457
2029
|
const drift = m.mode === "overlay" || (m.mode === "logical" && !!m.env);
|
|
1458
2030
|
const componentStatus = m.mode === "component-status";
|
|
1459
2031
|
const count = (statuses) => {
|
|
@@ -1475,15 +2047,38 @@ function renderPanelModel() {
|
|
|
1475
2047
|
const s = n.attrs && n.attrs._status;
|
|
1476
2048
|
host.appendChild(panelDotRow(COMPONENT_STATUS_VAR[s] || "var(--muted)", n.id, APPLY_STATUS_TAG[s] || "", () => selectNode(n.id)));
|
|
1477
2049
|
}
|
|
2050
|
+
} else if (drift && activeColourMode() !== "drift") {
|
|
2051
|
+
// #399: one legend per mode. The drift legend below is #393's vocabulary
|
|
2052
|
+
// legend and stays exactly as it was — this branch is the two behaviour
|
|
2053
|
+
// modes, whose legend is a scale rather than a set of states.
|
|
2054
|
+
renderBehaviourLegend(host, activeColourMode());
|
|
1478
2055
|
} else if (drift) {
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
2056
|
+
// #393 item 8: the legend proper. The heading carries the mixed-estate
|
|
2057
|
+
// sentence as its tooltip — the one case where the words on these rows are
|
|
2058
|
+
// not the words the members would use for themselves.
|
|
2059
|
+
const heading = panelHeading(`drift · ${m.env}`);
|
|
2060
|
+
const why = vocabularyNote();
|
|
2061
|
+
if (why) heading.title = why;
|
|
2062
|
+
host.appendChild(heading);
|
|
2063
|
+
const words = statusLabels();
|
|
2064
|
+
const c = count(words);
|
|
2065
|
+
for (const [k, label] of Object.entries(words)) {
|
|
1482
2066
|
// The additive buckets (chant#1168 unobserved, chant#1180 runtime) stay
|
|
1483
2067
|
// hidden until a chant actually emits them — same as the old legend.
|
|
1484
2068
|
if ((k === "neutral" || k === "runtime") && !c[k]) continue;
|
|
1485
2069
|
host.appendChild(panelDotRow(DRIFT_STATUS_VAR[k], label, String(c[k])));
|
|
1486
2070
|
}
|
|
2071
|
+
// #404: attribute drift, on its own row under the ownership counts —
|
|
2072
|
+
// beside them, never mixed into them, because a drifted card is still
|
|
2073
|
+
// bound and is already counted on the row above. The row appears only when
|
|
2074
|
+
// a plan was actually read; where it was not, the legend says so in words
|
|
2075
|
+
// and prints no number, because "0 drifted" would be a claim nobody made.
|
|
2076
|
+
const dm = driftMeta();
|
|
2077
|
+
if (dm && dm.read) {
|
|
2078
|
+
host.appendChild(panelDotRow("var(--degraded)", "drifted (the plan would change it)", String(dm.drifted)));
|
|
2079
|
+
} else if (dm) {
|
|
2080
|
+
host.appendChild(panelMuted("attribute drift not read — the overlay is ownership only. ⌘K → “Re-check live with plan”."));
|
|
2081
|
+
}
|
|
1487
2082
|
// The actionable nodes — foreign (adoptable) and pending (not applied yet).
|
|
1488
2083
|
const attention = ir.nodes.filter((n) => {
|
|
1489
2084
|
const s = n.attrs && n.attrs._status;
|
|
@@ -1493,7 +2088,7 @@ function renderPanelModel() {
|
|
|
1493
2088
|
host.appendChild(panelHeading("needs attention"));
|
|
1494
2089
|
for (const n of attention.slice(0, 40)) {
|
|
1495
2090
|
const s = n.attrs._status;
|
|
1496
|
-
host.appendChild(panelDotRow(DRIFT_STATUS_VAR[s], n.id,
|
|
2091
|
+
host.appendChild(panelDotRow(DRIFT_STATUS_VAR[s], n.id, statusLabel(s), () => selectNode(n.id)));
|
|
1497
2092
|
}
|
|
1498
2093
|
if (attention.length > 40) host.appendChild(panelMuted(`+ ${attention.length - 40} more — click nodes in the graph`));
|
|
1499
2094
|
}
|
|
@@ -1505,6 +2100,45 @@ function renderPanelModel() {
|
|
|
1505
2100
|
host.appendChild(actButton("→ Scope", () => setPanelTab("scope")));
|
|
1506
2101
|
}
|
|
1507
2102
|
}
|
|
2103
|
+
renderBehaviourRefusal(host);
|
|
2104
|
+
renderBehaviourDiagnostics(host);
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
/**
|
|
2108
|
+
* The refusal, where the legend would be (#401).
|
|
2109
|
+
*
|
|
2110
|
+
* The words are the lexicon's, in its own house style — an env-var chain and
|
|
2111
|
+
* the variable it wants — and behold prints them as they came. It does not
|
|
2112
|
+
* paraphrase them, does not soften them, and above all does not substitute a
|
|
2113
|
+
* number: "a missing or unreachable engine means no overlay and a refusal that
|
|
2114
|
+
* names why, never a locally faked number" is #397's rule, and this is the one
|
|
2115
|
+
* surface where a person finds out it fired.
|
|
2116
|
+
*
|
|
2117
|
+
* An ABSENCE prints nothing here. Nothing was configured, so nothing refused —
|
|
2118
|
+
* and a panel that announced every estate with no behavioural engine would be
|
|
2119
|
+
* shouting about a feature nobody asked for. The absent line is on the disabled
|
|
2120
|
+
* modes' tooltips instead, where the question actually gets asked.
|
|
2121
|
+
*/
|
|
2122
|
+
function renderBehaviourRefusal(host) {
|
|
2123
|
+
const b = behaviourMeta();
|
|
2124
|
+
if (!b || !b.refusal) return;
|
|
2125
|
+
host.appendChild(panelHeading("behaviour — refused"));
|
|
2126
|
+
const reason = panelMuted(b.refusal.reason);
|
|
2127
|
+
reason.style.color = "var(--degraded)";
|
|
2128
|
+
host.appendChild(reason);
|
|
2129
|
+
host.appendChild(panelMuted(b.refusal.remedy));
|
|
2130
|
+
host.appendChild(panelMuted("the drift overlay above is unaffected — it never depended on an engine."));
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
/** M1's diagnostics (`meta.behaviour.diagnostics`): every block behold dropped
|
|
2134
|
+
* and every thing it could not reconcile, under the legend where the figures
|
|
2135
|
+
* they are about are. They were reaching the wire and nothing was reading
|
|
2136
|
+
* them. */
|
|
2137
|
+
function renderBehaviourDiagnostics(host) {
|
|
2138
|
+
const b = behaviourMeta();
|
|
2139
|
+
if (!b || !b.diagnostics || !b.diagnostics.length) return;
|
|
2140
|
+
host.appendChild(panelHeading(`behaviour diagnostics · ${b.diagnostics.length}`));
|
|
2141
|
+
for (const d of b.diagnostics) host.appendChild(panelMuted(d));
|
|
1508
2142
|
}
|
|
1509
2143
|
|
|
1510
2144
|
// ---------------------------------------------------------------------------
|
|
@@ -1811,6 +2445,58 @@ function markOperatorCards(ir) {
|
|
|
1811
2445
|
}
|
|
1812
2446
|
}
|
|
1813
2447
|
|
|
2448
|
+
/**
|
|
2449
|
+
* #404: the second signal on a bound choudoufu card — the plan would change an
|
|
2450
|
+
* attribute on a resource the estate demonstrably owns.
|
|
2451
|
+
*
|
|
2452
|
+
* The ownership verdict keeps the card's FILL: bound is still green, and this
|
|
2453
|
+
* only adds a stroke and a corner glyph, the same post-render stamp
|
|
2454
|
+
* `markCarvedCards`/`markOperatorCards` use (pinhole's SVG arrives from the
|
|
2455
|
+
* server as a string and is replaced on every render, so a mark has to be
|
|
2456
|
+
* re-applied here rather than asked for at layout time).
|
|
2457
|
+
*
|
|
2458
|
+
* `~` is OpenTofu's own symbol for update-in-place, so the card and the
|
|
2459
|
+
* terminal read the same. The corner is the bottom-right one the carve and
|
|
2460
|
+
* operator marks also use — a choudoufu card is never carved (that is chant
|
|
2461
|
+
* source) and never the operating loop's home (that is Kubernetes), so on the
|
|
2462
|
+
* estates this can appear on the corner is free; the guard below still skips a
|
|
2463
|
+
* card that already carries one of those, rather than drawing over it.
|
|
2464
|
+
*/
|
|
2465
|
+
function markDriftedCards(ir) {
|
|
2466
|
+
const svg = document.querySelector("#graph svg");
|
|
2467
|
+
if (!svg) return;
|
|
2468
|
+
for (const n of ir.nodes || []) {
|
|
2469
|
+
const mark = n.attrs && n.attrs._planDrift;
|
|
2470
|
+
if (!mark || !mark.attributes || !mark.attributes.length) continue;
|
|
2471
|
+
const g = svg.querySelector('[data-node-id="' + CSS.escape(n.id) + '"]');
|
|
2472
|
+
if (!g || g.querySelector('[data-plan-drift="1"]')) continue;
|
|
2473
|
+
if (g.querySelector('[data-carved="1"]') || g.querySelector('[data-operator="1"]')) continue;
|
|
2474
|
+
g.classList.add("plan-drifted");
|
|
2475
|
+
// Measured, not read off attributes — markCarvedCards()'s note applies
|
|
2476
|
+
// here verbatim (pinhole sizes a card from its content).
|
|
2477
|
+
const rect = g.querySelector("rect");
|
|
2478
|
+
const box = rect && rect.getBBox ? rect.getBBox() : null;
|
|
2479
|
+
const tag = document.createElementNS("http://www.w3.org/2000/svg", "text");
|
|
2480
|
+
tag.setAttribute("data-plan-drift", "1");
|
|
2481
|
+
tag.setAttribute("x", String((box ? box.x + box.width : 150) - 10));
|
|
2482
|
+
tag.setAttribute("y", String((box ? box.y + box.height : 60) - 9));
|
|
2483
|
+
tag.setAttribute("text-anchor", "end");
|
|
2484
|
+
tag.setAttribute("font-size", "11");
|
|
2485
|
+
tag.setAttribute("font-weight", "600");
|
|
2486
|
+
tag.setAttribute("fill", "var(--degraded)");
|
|
2487
|
+
// A COUNT, not the attribute names: a name like `image_tag_mutability` is
|
|
2488
|
+
// wider than the corner and runs into the card's own subtitle (seen in the
|
|
2489
|
+
// browser). The count is bounded whatever the provider calls its fields,
|
|
2490
|
+
// and the names are one hover — or one click, in the pane — away.
|
|
2491
|
+
const count_ = mark.attributes.length;
|
|
2492
|
+
tag.textContent = `~ ${count_} attribute${count_ === 1 ? "" : "s"}`;
|
|
2493
|
+
const title = document.createElementNS("http://www.w3.org/2000/svg", "title");
|
|
2494
|
+
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.`;
|
|
2495
|
+
g.appendChild(title);
|
|
2496
|
+
g.appendChild(tag);
|
|
2497
|
+
}
|
|
2498
|
+
}
|
|
2499
|
+
|
|
1814
2500
|
function renderPanelCarve() {
|
|
1815
2501
|
if (!carveMode()) return;
|
|
1816
2502
|
if (!carveHost) {
|
|
@@ -1866,6 +2552,10 @@ function renderStatusbar() {
|
|
|
1866
2552
|
// Pure state — the strip echoes the axes whose controls live on the floating
|
|
1867
2553
|
// panel and in ⌘K, so the current view stays legible with the panel collapsed.
|
|
1868
2554
|
const parts = [`zoom: ${zoomValue()}`, view.env ? `env: ${view.env}` : "env: (source)"];
|
|
2555
|
+
// #399: which colour the graph is in is state, not a control, so it belongs
|
|
2556
|
+
// on the strip the same way the zoom and the env do. Only when it is not
|
|
2557
|
+
// drift — drift is what the strip has always implied.
|
|
2558
|
+
if (activeColourMode() !== "drift") parts.push(`colour: ${activeColourMode()}`);
|
|
1869
2559
|
if (view.stack) parts.push(`stack: ${view.stack}`);
|
|
1870
2560
|
if (axes.tier) parts.push(`tier: ${axes.tier}`);
|
|
1871
2561
|
if (view.radial && !view.components && !view.logical && !view.ops) parts.push("radial");
|
|
@@ -1878,7 +2568,15 @@ function renderStatusbar() {
|
|
|
1878
2568
|
if (lastNote) {
|
|
1879
2569
|
const note = document.createElement("span");
|
|
1880
2570
|
note.className = "statusbar-note";
|
|
1881
|
-
|
|
2571
|
+
// #393 item 7: the strip is a strip. A Terraform estate's note names every
|
|
2572
|
+
// root, every skipped directory with its reason, and every block class the
|
|
2573
|
+
// zoom left out — ~60 words, in a 260px panel, at every zoom. The server
|
|
2574
|
+
// sends the short form beside the long one (`meta.noteShort`) when it has
|
|
2575
|
+
// one; the full text stays one hover away here and is printed in full on
|
|
2576
|
+
// the panel's Model tab. Nothing is truncated client-side: the SPA does not
|
|
2577
|
+
// write notes, and a note with no short form is already a line.
|
|
2578
|
+
note.textContent = " — " + (lastNoteShort || lastNote);
|
|
2579
|
+
if (lastNoteShort) note.title = lastNote;
|
|
1882
2580
|
el.appendChild(note);
|
|
1883
2581
|
}
|
|
1884
2582
|
}
|
|
@@ -1887,6 +2585,9 @@ function renderStatusbar() {
|
|
|
1887
2585
|
// or null. Set on every load so a level that stops degrading stops explaining
|
|
1888
2586
|
// itself.
|
|
1889
2587
|
let lastNote = null;
|
|
2588
|
+
// #393 item 7: the same note as the strip can hold (`meta.noteShort`), or null
|
|
2589
|
+
// when the long one already fits. Never derived here — see renderStatusbar().
|
|
2590
|
+
let lastNoteShort = null;
|
|
1890
2591
|
|
|
1891
2592
|
// The `meta` of the last rendered graph — the panel's Model tab reads its
|
|
1892
2593
|
// mode/env to pick the status vocabulary (drift vs component live status).
|
|
@@ -2560,6 +3261,7 @@ function render(ir, svg, m) {
|
|
|
2560
3261
|
// #131: set before anything can early-return, so a level that stopped
|
|
2561
3262
|
// degrading stops explaining itself on the very next render.
|
|
2562
3263
|
lastNote = m.note || null;
|
|
3264
|
+
lastNoteShort = m.noteShort || null;
|
|
2563
3265
|
// The panel's Model tab reads both of these via renderStatusbar() →
|
|
2564
3266
|
// renderPanel() below — set them first so it renders THIS graph, not the
|
|
2565
3267
|
// previous one (recolorNodesByCategory also sets lastGraphIr; harmless).
|
|
@@ -2606,9 +3308,18 @@ function render(ir, svg, m) {
|
|
|
2606
3308
|
const s = n.attrs && n.attrs._status;
|
|
2607
3309
|
if (s in c) c[s]++;
|
|
2608
3310
|
}
|
|
2609
|
-
|
|
2610
|
-
|
|
3311
|
+
// #393 item 8: the estate's own words for these four colours — chant's
|
|
3312
|
+
// managed/foreign/pending/unobserved on a chant estate, choudoufu's
|
|
3313
|
+
// bound/unowned/pending/not observed on one whose members are choudoufu.
|
|
3314
|
+
const w = statusLabels();
|
|
3315
|
+
tail = ` · ${c.good} ${w.good} · ${c.warn} ${w.warn} · ${c.accent} ${w.accent}`;
|
|
3316
|
+
if (c.neutral) tail += ` · ${c.neutral} ${w.neutral}`;
|
|
2611
3317
|
if (c.runtime) tail += ` · ${c.runtime} runtime`;
|
|
3318
|
+
// #404: beside the ownership counts, never folded into them — a drifted
|
|
3319
|
+
// card is bound and is already inside `c.good`. Silent when no plan was
|
|
3320
|
+
// read, so the strip never implies the question was asked and answered.
|
|
3321
|
+
const dm = driftMeta();
|
|
3322
|
+
if (dm && dm.read) tail += ` · ${dm.drifted} drifted`;
|
|
2612
3323
|
// Nothing observed live in this env — explain the all-blue rather than let it
|
|
2613
3324
|
// read as a bug (#32).
|
|
2614
3325
|
if (c.good === 0 && c.warn === 0 && c.accent > 0) tail += ` — nothing deployed in ${m.env} yet`;
|
|
@@ -2625,8 +3336,9 @@ function render(ir, svg, m) {
|
|
|
2625
3336
|
// Multi-estate (#31): note the composed project count; the graph draws one box per project.
|
|
2626
3337
|
// #186: the meta line lives in the panel's 272px footer now — the directory
|
|
2627
3338
|
// basename reads better than an absolute path (full path in the tooltip).
|
|
3339
|
+
// #393 item 4: one member is a project, not "1 projects".
|
|
2628
3340
|
const scope = m.estate
|
|
2629
|
-
? `estate of ${m.estate}
|
|
3341
|
+
? `estate of ${m.estate} project${m.estate === 1 ? "" : "s"}`
|
|
2630
3342
|
: String(m.projectDir || "").replace(/\/+$/, "").split("/").pop() || m.projectDir;
|
|
2631
3343
|
// The deploy axes (#59 unify, M2 #54 lenses) — tier/target, kept in sync with
|
|
2632
3344
|
// what this response actually observed (falls back to the launch-time value
|
|
@@ -2675,6 +3387,7 @@ function render(ir, svg, m) {
|
|
|
2675
3387
|
markCarvedCards(); // #254: the SVG is replaced per render — re-stamp the marker
|
|
2676
3388
|
markPlayhead(ir); // #284 item 2: same, for the step the run is sitting on
|
|
2677
3389
|
markOperatorCards(ir); // #234 free rider: same, for the operating loop's home
|
|
3390
|
+
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
|
|
2678
3391
|
applyLayout(); // #228: last, so the hand-placed deltas ride on top of every other pass
|
|
2679
3392
|
renderDial();
|
|
2680
3393
|
}
|
|
@@ -2788,6 +3501,45 @@ function fitGraph() {
|
|
|
2788
3501
|
applyVB();
|
|
2789
3502
|
}
|
|
2790
3503
|
}
|
|
3504
|
+
|
|
3505
|
+
// How much of the viewBox one card should take when ⌘K lands on it (#393): the
|
|
3506
|
+
// window is this many card-widths across, so a 301-card estate arrives readable
|
|
3507
|
+
// rather than at whatever zoom the last gesture left. Never wider than the fit,
|
|
3508
|
+
// which is the whole graph — there is nothing beyond it to show.
|
|
3509
|
+
const REVEAL_CARDS_ACROSS = 9;
|
|
3510
|
+
|
|
3511
|
+
/**
|
|
3512
|
+
* Pan (and zoom) the graph so one card sits in the middle of the pane (#393
|
|
3513
|
+
* item 4). The same viewBox the wheel/drag handlers drive and "⤢ fit" resets —
|
|
3514
|
+
* there was no programmatic pan before this, only the reset.
|
|
3515
|
+
*
|
|
3516
|
+
* The card's rectangle comes from the DOM rather than from the IR: pinhole
|
|
3517
|
+
* paints a card as a `<g data-node-id>` whose first `<rect>` carries absolute
|
|
3518
|
+
* viewBox coordinates (no transform on the estate view, unlike the radial one),
|
|
3519
|
+
* and `getBBox()` covers whichever it is. Returns false when there is no such
|
|
3520
|
+
* card on the canvas — a node the current zoom elided — so the caller can say
|
|
3521
|
+
* so instead of panning to nowhere.
|
|
3522
|
+
*/
|
|
3523
|
+
function revealNode(id) {
|
|
3524
|
+
const svg = currentSvg();
|
|
3525
|
+
if (!svg || !vb || !vbInit) return false;
|
|
3526
|
+
const g = svg.querySelector(`[data-node-id="${CSS.escape(id)}"]`);
|
|
3527
|
+
if (!g) return false;
|
|
3528
|
+
let box;
|
|
3529
|
+
try {
|
|
3530
|
+
box = g.getBBox();
|
|
3531
|
+
} catch {
|
|
3532
|
+
const r = g.querySelector("rect");
|
|
3533
|
+
if (!r) return false;
|
|
3534
|
+
box = { x: +r.getAttribute("x"), y: +r.getAttribute("y"), width: +r.getAttribute("width"), height: +r.getAttribute("height") };
|
|
3535
|
+
}
|
|
3536
|
+
if (!box.width || !box.height) return false;
|
|
3537
|
+
const w = Math.min(vbInit[2], Math.max(box.width * REVEAL_CARDS_ACROSS, vbInit[2] / 60));
|
|
3538
|
+
const h = w * (vbInit[3] / vbInit[2]);
|
|
3539
|
+
vb = [box.x + box.width / 2 - w / 2, box.y + box.height / 2 - h / 2, w, h];
|
|
3540
|
+
applyVB();
|
|
3541
|
+
return true;
|
|
3542
|
+
}
|
|
2791
3543
|
function ensureZoomControls(host) {
|
|
2792
3544
|
let btn = document.getElementById("zoom-toggle");
|
|
2793
3545
|
if (!btn || btn.parentElement !== host) {
|
|
@@ -3516,9 +4268,20 @@ async function load(opts = {}) {
|
|
|
3516
4268
|
// never in your source — so it rides the overlay and means nothing
|
|
3517
4269
|
// without an env.
|
|
3518
4270
|
if (view.runtime) q.set("runtime", "1");
|
|
4271
|
+
// #404: the attribute-drift read, when this load is the one the person
|
|
4272
|
+
// asked for it on. Consumed here — the next load goes without it.
|
|
4273
|
+
if (planDriftOnce) {
|
|
4274
|
+
q.set("plan", "1");
|
|
4275
|
+
planDriftOnce = false;
|
|
4276
|
+
}
|
|
3519
4277
|
}
|
|
3520
4278
|
// Radial layout (entity view only) — curl the wide DAG onto concentric rings.
|
|
3521
4279
|
if (view.radial && !view.components && !view.logical && !view.ops) q.set("radial", "1");
|
|
4280
|
+
// Collapse large boxes (#393): a member box over the server's limit draws
|
|
4281
|
+
// as one card carrying its counts. An estate-box lens — the component DAG
|
|
4282
|
+
// and the ops view have no member boxes to shut — and it rides both the
|
|
4283
|
+
// source graph and the overlay, so picking an env doesn't undo it.
|
|
4284
|
+
if (view.collapse && !view.components && !view.ops) q.set("collapse", "1");
|
|
3522
4285
|
// The CI + resources facets are component-DAG-mode-only details. Load
|
|
3523
4286
|
// both whenever components mode is on, env picked or not — #59 unifies
|
|
3524
4287
|
// the CI facet (#58), the live-status join (#57), and resources (#59) so
|
|
@@ -3574,6 +4337,35 @@ async function load(opts = {}) {
|
|
|
3574
4337
|
}
|
|
3575
4338
|
}
|
|
3576
4339
|
|
|
4340
|
+
/**
|
|
4341
|
+
* #404: re-read the overlay AND ask for the attribute-drift plan.
|
|
4342
|
+
*
|
|
4343
|
+
* A second row rather than a flag on `refresh()` above, for two reasons.
|
|
4344
|
+
*
|
|
4345
|
+
* The costs are different in kind. The ownership read a refresh already makes
|
|
4346
|
+
* (`live-ls` + `live-plan`) is answered out of the account's tagging index —
|
|
4347
|
+
* a handful of calls whatever the estate's size. `plan` refreshes every
|
|
4348
|
+
* resource, which is one provider read per card: on the workbench's
|
|
4349
|
+
* terralith-4 that is 301 reads for one keypress. Putting them on one button
|
|
4350
|
+
* would make the cheap, repeatable "did anything move?" silently cost the
|
|
4351
|
+
* expensive question every time.
|
|
4352
|
+
*
|
|
4353
|
+
* And they are not even the same request. `refresh()` is `POST /api/refresh`,
|
|
4354
|
+
* which re-observes the PRIMARY project and captures a lanes frame; it never
|
|
4355
|
+
* composes the estate. A choudoufu member is served through the estate branch
|
|
4356
|
+
* of `GET /api/overlay`, which is where the drift read lives — so this row
|
|
4357
|
+
* goes through `load()`, the path that actually renders those cards.
|
|
4358
|
+
*/
|
|
4359
|
+
async function refreshWithPlan() {
|
|
4360
|
+
planDriftOnce = true;
|
|
4361
|
+
bulkDiffCache = null; // the pane's rows come from /api/diff — re-fetch them against the new plan
|
|
4362
|
+
nowline("↻ planning — a full read of the account…");
|
|
4363
|
+
await load();
|
|
4364
|
+
const dm = driftMeta();
|
|
4365
|
+
if (dm && dm.read) nowline(dm.drifted ? `↻ plan read — ${dm.drifted} drifted` : "↻ plan read — nothing drifted");
|
|
4366
|
+
else nowline("↻ the plan could not be read — the overlay is ownership only");
|
|
4367
|
+
}
|
|
4368
|
+
|
|
3577
4369
|
// Refresh (#24): re-check live drift now and capture a lanes frame, in one
|
|
3578
4370
|
// round-trip. Renders the returned graph directly (no second pull); the server's
|
|
3579
4371
|
// `frames` event updates the lanes view.
|
|
@@ -3638,6 +4430,15 @@ async function initPickers() {
|
|
|
3638
4430
|
// way it gates `tiers`/`stacks`), so an unbuilt project or a chant < 0.50
|
|
3639
4431
|
// never grows a stop that would refuse.
|
|
3640
4432
|
opsAvailable = info.ops || 0;
|
|
4433
|
+
// #393: whether the runtime stop exists here, and where this estate can
|
|
4434
|
+
// honestly open. Both are read before the first load() below, so a non-chant
|
|
4435
|
+
// estate never paints the components apology on its way to a picture.
|
|
4436
|
+
runtimeCapable = !!info.runtimeCapable;
|
|
4437
|
+
const boot = bootZoom(info.memberKinds);
|
|
4438
|
+
if (boot !== "components") {
|
|
4439
|
+
applyZoom(boot);
|
|
4440
|
+
autoZoomFallback = false;
|
|
4441
|
+
}
|
|
3641
4442
|
// #254: carve mode declares itself here. The Carve tab is mounted at runtime
|
|
3642
4443
|
// (panel.js's addPanelTab), so nothing else grows a dead tab.
|
|
3643
4444
|
carveInfo = info.carve || null;
|
|
@@ -4409,6 +5210,17 @@ function paletteCommands() {
|
|
|
4409
5210
|
|
|
4410
5211
|
// Reads — always available, even in a static export or the preview lock.
|
|
4411
5212
|
if (!staticMode) c.push(["Re-check live (refresh drift)", () => refresh()]);
|
|
5213
|
+
// #404: the expensive sibling, listed beside it and named for what it costs.
|
|
5214
|
+
// Offered only with an env picked — there is no overlay to plan against
|
|
5215
|
+
// without one — and disabled-with-its-reason otherwise, the convention the
|
|
5216
|
+
// colour-mode and carve rows below already follow.
|
|
5217
|
+
if (!staticMode) {
|
|
5218
|
+
c.push(
|
|
5219
|
+
view.env
|
|
5220
|
+
? ["Re-check live with plan (attribute drift) — a full read of the account", () => refreshWithPlan()]
|
|
5221
|
+
: ["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)],
|
|
5222
|
+
);
|
|
5223
|
+
}
|
|
4412
5224
|
c.push(["Fit graph to view", () => fitGraph()]);
|
|
4413
5225
|
c.push(["Export: current graph as SVG", () => exportSvg()]);
|
|
4414
5226
|
const inspectCollapsed = document.getElementById("app").classList.contains("inspect-collapsed");
|
|
@@ -4439,12 +5251,39 @@ function paletteCommands() {
|
|
|
4439
5251
|
for (const [label, v] of availableZooms()) {
|
|
4440
5252
|
c.push([label + (v === zoomValue() ? " ✓" : ""), () => { applyZoom(v); load(); }]);
|
|
4441
5253
|
}
|
|
5254
|
+
// #399: the colour-by modes, with the same ✓ every other lens row carries.
|
|
5255
|
+
// A disabled mode is LISTED with its reason rather than hidden — same rule
|
|
5256
|
+
// #254's blocked carve steps follow, and the same reason: "why can't I do
|
|
5257
|
+
// that" is a question the palette should be able to answer.
|
|
5258
|
+
for (const mode of COLOUR_MODES) {
|
|
5259
|
+
const gate = mode === "drift" ? { available: true, reason: "" } : modeAvailability(lastMeta, mode);
|
|
5260
|
+
const why = gate.available ? "" : ` — ${gate.reason}`;
|
|
5261
|
+
c.push([
|
|
5262
|
+
`colour by: ${mode}${mode === activeColourMode() ? " ✓" : ""}${why}`,
|
|
5263
|
+
() => {
|
|
5264
|
+
if (!gate.available) return showToast(gate.reason, false);
|
|
5265
|
+
setColourMode(mode);
|
|
5266
|
+
},
|
|
5267
|
+
]);
|
|
5268
|
+
}
|
|
4442
5269
|
// Radial toggle — entity zooms only, same gate the removed checkbox had
|
|
4443
5270
|
// (components/logical/ops all lay themselves out: waves / nested arch boxes /
|
|
4444
5271
|
// phase boxes).
|
|
4445
5272
|
if (!view.components && !view.logical && !view.ops) {
|
|
4446
5273
|
c.push([(view.radial ? "Disable" : "Enable") + " radial layout", () => { view.radial = !view.radial; load(); }]);
|
|
4447
5274
|
}
|
|
5275
|
+
// #393: 301 cards in one box read as a strip at any zoom. This shuts every
|
|
5276
|
+
// box over the server's limit down to a card carrying its own counts, so an
|
|
5277
|
+
// estate can be read at the level of its members first.
|
|
5278
|
+
//
|
|
5279
|
+
// Not in a static export: `captureKeys` (src/export.ts) captures the estate
|
|
5280
|
+
// once, expanded, and a bundle that offers a stop it has no snapshot behind
|
|
5281
|
+
// answers the click with "not in this static export". `collapse` is in
|
|
5282
|
+
// `canonicalKey`'s LENS_PARAMS all the same, so the day a bundle does carry
|
|
5283
|
+
// both, the two keys are already distinct.
|
|
5284
|
+
if (!staticMode && !view.components && !view.ops) {
|
|
5285
|
+
c.push([view.collapse ? "Expand all" : "Collapse large boxes", () => { view.collapse = !view.collapse; load(); }]);
|
|
5286
|
+
}
|
|
4448
5287
|
|
|
4449
5288
|
// Env/stack/tier/target selection — replaces the old header pickers.
|
|
4450
5289
|
c.push(["env: (source)" + (!view.env ? " ✓" : ""), () => { view.env = null; resetDialCaches(); load(); }]);
|
|
@@ -4511,9 +5350,73 @@ function paletteCommands() {
|
|
|
4511
5350
|
return c.map(([label, run]) => ({ label, run }));
|
|
4512
5351
|
}
|
|
4513
5352
|
|
|
5353
|
+
// --- ⌘K takes an address (#393 item 4) ---------------------------------------
|
|
5354
|
+
// The audit's finding was that the palette had zoom, env, panel and deploy
|
|
5355
|
+
// commands and nothing that took a node. The SPA already holds the IR it
|
|
5356
|
+
// painted (`lastGraphIr`), so the whole feature is a filter over it.
|
|
5357
|
+
//
|
|
5358
|
+
// Built per keystroke rather than baked into `paletteCommands()` on open: a
|
|
5359
|
+
// 301-card estate would otherwise put 301 rows in front of somebody who typed
|
|
5360
|
+
// nothing, and push every command below them. Nothing at all until two
|
|
5361
|
+
// characters are typed, and at most {@link PAL_NODE_ROWS} rows after that.
|
|
5362
|
+
//
|
|
5363
|
+
// Outside `paletteCommands()` for a second reason: that function returns early
|
|
5364
|
+
// in a static export (no writes there at all), and finding a card is a READ.
|
|
5365
|
+
// Nothing below fetches anything — the IR is the one the page painted and the
|
|
5366
|
+
// pan is a viewBox — so a bundle answers ⌘K exactly as a served project does.
|
|
5367
|
+
|
|
5368
|
+
/** How many node rows the palette will show. A cap, not a ranking: past a
|
|
5369
|
+
* dozen the reader is scrolling a list rather than recognising a name, and the
|
|
5370
|
+
* answer is a longer query. */
|
|
5371
|
+
const PAL_NODE_ROWS = 12;
|
|
5372
|
+
|
|
5373
|
+
/** The address inside a composed id — `terralith-4/aws_iam_role.x` is the id,
|
|
5374
|
+
* `aws_iam_role.x` is what a person types. A member name cannot hold a slash
|
|
5375
|
+
* (composeStacks' `shortStackNames`), so the FIRST one splits it. */
|
|
5376
|
+
function nodeAddress(id) {
|
|
5377
|
+
const slash = id.indexOf("/");
|
|
5378
|
+
return slash >= 0 ? id.slice(slash + 1) : id;
|
|
5379
|
+
}
|
|
5380
|
+
function nodeMember(id) {
|
|
5381
|
+
const slash = id.indexOf("/");
|
|
5382
|
+
return slash >= 0 ? id.slice(0, slash) : "";
|
|
5383
|
+
}
|
|
5384
|
+
|
|
5385
|
+
/** The node rows for a query: matches on the composed id and on the address,
|
|
5386
|
+
* with anything that STARTS with the query first — typing `aws_iam_role.team_00`
|
|
5387
|
+
* should reach `aws_iam_role.team_0007_role` before a card that merely mentions
|
|
5388
|
+
* it. Stable within each half (the IR's own order), so the list does not
|
|
5389
|
+
* reshuffle as a query grows. */
|
|
5390
|
+
function paletteNodes(q) {
|
|
5391
|
+
if (!lastGraphIr || q.length < 2) return [];
|
|
5392
|
+
const prefix = [];
|
|
5393
|
+
const rest = [];
|
|
5394
|
+
for (const n of lastGraphIr.nodes) {
|
|
5395
|
+
const address = nodeAddress(n.id);
|
|
5396
|
+
const id = n.id.toLowerCase();
|
|
5397
|
+
const a = address.toLowerCase();
|
|
5398
|
+
if (a.startsWith(q) || id.startsWith(q)) prefix.push({ n, address });
|
|
5399
|
+
else if (a.includes(q) || id.includes(q)) rest.push({ n, address });
|
|
5400
|
+
if (prefix.length >= PAL_NODE_ROWS) break;
|
|
5401
|
+
}
|
|
5402
|
+
return [...prefix, ...rest].slice(0, PAL_NODE_ROWS).map(({ n, address }) => ({
|
|
5403
|
+
label: `node: ${address}`,
|
|
5404
|
+
// What the address alone does not say: which member it is in, and what it
|
|
5405
|
+
// is. Two members of one estate can declare the same address.
|
|
5406
|
+
sub: [nodeMember(n.id), n.kind, n.lexicon].filter(Boolean).join(" · "),
|
|
5407
|
+
run: () => {
|
|
5408
|
+
selectNode(n.id); // the same path a graph click takes — inspect included
|
|
5409
|
+
if (!revealNode(n.id)) showToast(`${address} is not on this view — it may be elided at this zoom`, false);
|
|
5410
|
+
},
|
|
5411
|
+
}));
|
|
5412
|
+
}
|
|
5413
|
+
|
|
4514
5414
|
function palRender() {
|
|
4515
5415
|
const q = palInput.value.toLowerCase().trim();
|
|
4516
|
-
|
|
5416
|
+
const cmds = q ? palCmds.filter((c) => c.label.toLowerCase().includes(q)) : palCmds;
|
|
5417
|
+
// Nodes first: a query that matches a card is nearly always somebody looking
|
|
5418
|
+
// for that card, and a query that matches no card costs nothing.
|
|
5419
|
+
palCurrent = [...paletteNodes(q), ...cmds];
|
|
4517
5420
|
palSel = Math.max(0, Math.min(palSel, palCurrent.length - 1));
|
|
4518
5421
|
palList.replaceChildren();
|
|
4519
5422
|
if (!palCurrent.length) {
|
|
@@ -4527,6 +5430,12 @@ function palRender() {
|
|
|
4527
5430
|
const d = document.createElement("div");
|
|
4528
5431
|
d.className = "row" + (i === palSel ? " sel" : "");
|
|
4529
5432
|
d.textContent = c.label;
|
|
5433
|
+
if (c.sub) {
|
|
5434
|
+
const sub = document.createElement("div");
|
|
5435
|
+
sub.className = "sub";
|
|
5436
|
+
sub.textContent = c.sub;
|
|
5437
|
+
d.appendChild(sub);
|
|
5438
|
+
}
|
|
4530
5439
|
d.onmousedown = (ev) => {
|
|
4531
5440
|
ev.preventDefault();
|
|
4532
5441
|
closePalette();
|