@intentius/behold 0.24.0 → 0.26.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 +341 -2
- package/demos.json +1 -1
- package/dist/cli.js +19290 -9373
- package/example-choudoufu-estate/chant.workspace.json +1 -1
- package/example-terraform-estate/README.md +2 -2
- package/example-terraform-estate/chant.workspace.json +1 -1
- package/package.json +5 -4
- package/web/app.js +318 -6
- package/web/history-picker.js +85 -0
- package/web/history-picker.test.js +77 -0
- package/web/index.html +57 -0
- package/web/releases.js +121 -0
- package/web/sql-yodeler-lane.js +129 -0
- package/web/terragucci-estate.js +328 -0
- package/web/terragucci-estate.test.js +114 -0
- package/web/terragucci-lifecycle.js +235 -0
- package/web/terragucci-lifecycle.test.js +95 -0
- package/web/terragucci-progress.js +127 -0
- package/web/terragucci-progress.test.js +59 -0
- package/web/terragucci-timeline.js +195 -0
- package/web/terragucci-timeline.test.js +56 -0
- package/web/workspace-gates.js +29 -0
- package/web/yodel-report.js +99 -0
- package/web/yodel-report.test.js +18 -0
|
@@ -90,7 +90,7 @@ install them, so that every user who serves an ordinary chant project does not
|
|
|
90
90
|
carry an HCL parser they will never run:
|
|
91
91
|
|
|
92
92
|
```sh
|
|
93
|
-
npm install --no-save @intentius/chant-lexicon-terraform@^0.
|
|
93
|
+
npm install --no-save @intentius/chant-lexicon-terraform@^0.124.0 @cdktn/hcl2json@^0.24.0
|
|
94
94
|
```
|
|
95
95
|
|
|
96
96
|
Without them the demo refuses before it serves, naming both packages, the
|
|
@@ -101,7 +101,7 @@ the packages `chant.workspace.json` pins, looked up from the workspace's own
|
|
|
101
101
|
`node_modules` at exactly the pinned version. A demo copy has no
|
|
102
102
|
`node_modules`, so `behold demo` links the lexicon behold resolved into the
|
|
103
103
|
copy and sets the copy's pin to that version (#484). It says so as it loads:
|
|
104
|
-
`linked @intentius/chant-lexicon-terraform 0.
|
|
104
|
+
`linked @intentius/chant-lexicon-terraform 0.124.0 into the copy`. Serving
|
|
105
105
|
your own declared workspace is different: install the pinned lexicon in its
|
|
106
106
|
root, the way `chant workspace check` asks.
|
|
107
107
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/behold",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "behold — a live control plane on chant. See your whole estate (every substrate in one graph), coloured by drift; act through delegated, gated Ops.",
|
|
@@ -41,14 +41,14 @@
|
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"@hono/node-server": "^2.0.5",
|
|
44
|
-
"@intentius/chant": "^0.
|
|
45
|
-
"@intentius/chant-k8s-client": "^0.
|
|
44
|
+
"@intentius/chant": "^0.124.17",
|
|
45
|
+
"@intentius/chant-k8s-client": "^0.124.17",
|
|
46
46
|
"@intentius/pinhole": "^0.3.8",
|
|
47
47
|
"ajv": "^8.20.0",
|
|
48
48
|
"hono": "^4.12.34"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
|
-
"@intentius/chant-lexicon-terraform": "^0.
|
|
51
|
+
"@intentius/chant-lexicon-terraform": "^0.124.0",
|
|
52
52
|
"@cdktn/hcl2json": "^0.24.0"
|
|
53
53
|
},
|
|
54
54
|
"peerDependenciesMeta": {
|
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
}
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
|
+
"@intentius/chant-lexicon-sql": "^0.124.13",
|
|
63
64
|
"@types/node": "^26.4.1",
|
|
64
65
|
"esbuild": "^0.28.0",
|
|
65
66
|
"playwright": "^1.62.1",
|
package/web/app.js
CHANGED
|
@@ -13,8 +13,16 @@ import { readViewQuery, writeViewQuery, settleView, settlePlace, refusalLine } f
|
|
|
13
13
|
import { readEmbed, selectMessage, viewFromMessage } from "./embed.js";
|
|
14
14
|
import { gateCards } from "./workspace-gates.js";
|
|
15
15
|
import { cornerOf, estateLine, hrefOf as tgHref, legend as tgLegend, markLine, rootRows as tgRootRows, waveCards } from "./terragucci.js";
|
|
16
|
+
import { laneElement as tgLane, rootOfCard as tgRootOfCard, historyElement as tgHistoryLane } from "./terragucci-timeline.js";
|
|
17
|
+
import { renderLane } from "./terragucci-lifecycle.js";
|
|
18
|
+
import { renderReleases } from "./releases.js";
|
|
19
|
+
import { renderYodelLane } from "./sql-yodeler-lane.js";
|
|
20
|
+
import { initTerragucciEstate } from "./terragucci-estate.js";
|
|
21
|
+
import { auditLines as yrAuditLines, cornerText as yrCorner, datedWords as yrDated, environmentLines as yrEnvLines, joinMarks as yrJoin, legend as yrLegend, markLine as yrMarkLine } from "./yodel-report.js";
|
|
22
|
+
import { cardProgress as tgCardProgress, progressLines as tgProgressLines, progressSection as tgProgressSection, stampProgress as tgStampProgress } from "./terragucci-progress.js";
|
|
16
23
|
import { seconds, whyQuery, whyView } from "./why.js";
|
|
17
24
|
import { readCostLine } from "./read-cost.js";
|
|
25
|
+
import { mountHistoryPicker } from "./history-picker.js";
|
|
18
26
|
import { applyMemberFrame, pendingLine, stillPending } from "./pending.js";
|
|
19
27
|
import { initTheme, setTheme, mountThemePicker, readableOn, colorForCategory, onThemeChange, getTokens, getTheme, pinTokensFor } from "./theme.js";
|
|
20
28
|
// #399 M2 / #401 M4 of #397: the colour-by modes' arithmetic and the
|
|
@@ -903,6 +911,42 @@ function inspect(node) {
|
|
|
903
911
|
panel.appendChild(p);
|
|
904
912
|
}
|
|
905
913
|
}
|
|
914
|
+
// #526: SQL Yodeler's drift on this card, each dated by its file.
|
|
915
|
+
const yrMarks = yrJoined && yrJoined.cards[node.id];
|
|
916
|
+
if (yrMarks && yrMarks.length) {
|
|
917
|
+
const h = document.createElement("h3");
|
|
918
|
+
h.textContent = "SQL Yodeler drift (from files, not live)";
|
|
919
|
+
panel.appendChild(h);
|
|
920
|
+
const now = new Date();
|
|
921
|
+
for (const m of yrMarks) {
|
|
922
|
+
const p = document.createElement("p");
|
|
923
|
+
p.className = "tg-inspect";
|
|
924
|
+
p.textContent = yrMarkLine(m, now);
|
|
925
|
+
p.title = m.changes.map((c) => `${c.path || ""} ${c.kind || ""}: declared ${JSON.stringify(c.declared ?? null)}, live ${JSON.stringify(c.live ?? null)}`).join("\n");
|
|
926
|
+
panel.appendChild(p);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
// #511: the newest apply's progress on this card, dated by the records read.
|
|
930
|
+
const tgProg = tgCardProgress(tgProgress && tgProgress.cards && tgProgress.cards[node.id]);
|
|
931
|
+
if (tgProg) {
|
|
932
|
+
const h = document.createElement("h3");
|
|
933
|
+
h.textContent = "terragucci apply progress (run view, not live)";
|
|
934
|
+
panel.appendChild(h);
|
|
935
|
+
for (const line of tgProgressLines(tgProg, new Date())) {
|
|
936
|
+
const p = document.createElement("p");
|
|
937
|
+
p.className = "tg-inspect";
|
|
938
|
+
p.textContent = line;
|
|
939
|
+
panel.appendChild(p);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
// #506: the audit record's entries for this card's root (the record names
|
|
943
|
+
// roots, never a resource), newest first.
|
|
944
|
+
const tgRoot = tgTimeline && tgRootOfCard(tgAnswer, node.id);
|
|
945
|
+
if (tgRoot) {
|
|
946
|
+
const h = document.createElement("h3");
|
|
947
|
+
h.textContent = "terragucci timeline (audit record)";
|
|
948
|
+
panel.append(h, tgLane(tgTimeline, { root: tgRoot }));
|
|
949
|
+
}
|
|
906
950
|
|
|
907
951
|
// Containment hierarchy: a resource shows its parent chain UP (composite →
|
|
908
952
|
// component); a collapsed composite (detail 1: `attrs.members` is a count)
|
|
@@ -3223,26 +3267,88 @@ function markDriftedCards(ir) {
|
|
|
3223
3267
|
// of this is live. A waiting wave offers its approve line to copy, no button.
|
|
3224
3268
|
let tgAnswer = null;
|
|
3225
3269
|
let tgHost = null;
|
|
3270
|
+
let tgTimeline = null; // #506: /api/terragucci/timeline, the audit record's lane
|
|
3271
|
+
let tgHistory = null; // #522: /api/lifecycle/history, the gate ledgers' history
|
|
3226
3272
|
async function initTerragucci() {
|
|
3227
3273
|
const project = await apiFetch("/api/project").then((r) => r.json()).catch(() => ({}));
|
|
3228
3274
|
if (!project.terragucciReports) return;
|
|
3229
3275
|
await loadTerragucci();
|
|
3276
|
+
// #505: the chant/lifecycle lane, and what moved while this page is open.
|
|
3277
|
+
if (staticMode) return;
|
|
3278
|
+
await loadTerragucciLane();
|
|
3279
|
+
watchTerragucci();
|
|
3280
|
+
}
|
|
3281
|
+
let tgLifecycle = null;
|
|
3282
|
+
let tgPolled = null;
|
|
3283
|
+
async function loadTerragucciLane({ fresh = false } = {}) {
|
|
3284
|
+
tgLifecycle = await apiFetch(`/api/terragucci/lifecycle${fresh ? "?fresh=1" : ""}`)
|
|
3285
|
+
.then((r) => r.json())
|
|
3286
|
+
.catch((e) => ({ error: String(e) }));
|
|
3287
|
+
renderTerragucciPanel();
|
|
3288
|
+
}
|
|
3289
|
+
// The server polls only while this stream is open, and stops when the page goes.
|
|
3290
|
+
function watchTerragucci() {
|
|
3291
|
+
if (typeof EventSource === "undefined") return;
|
|
3292
|
+
const es = new EventSource("/api/terragucci/events");
|
|
3293
|
+
es.addEventListener("reports", () => loadTerragucci({ fresh: true }));
|
|
3294
|
+
// #511: the run view moved while a wave applies.
|
|
3295
|
+
es.addEventListener("progress", (e) => {
|
|
3296
|
+
tgProgress = JSON.parse(e.data);
|
|
3297
|
+
paintTgProgress();
|
|
3298
|
+
});
|
|
3299
|
+
es.addEventListener("lifecycle", (e) => {
|
|
3300
|
+
tgLifecycle = JSON.parse(e.data);
|
|
3301
|
+
renderTerragucciPanel();
|
|
3302
|
+
});
|
|
3303
|
+
es.addEventListener("polled", (e) => {
|
|
3304
|
+
tgPolled = JSON.parse(e.data);
|
|
3305
|
+
renderTerragucciPanel();
|
|
3306
|
+
});
|
|
3307
|
+
}
|
|
3308
|
+
function renderTerragucciLane(host, now) {
|
|
3309
|
+
if (staticMode || (!tgLifecycle && !tgPolled)) return;
|
|
3310
|
+
renderLane(host, tgLifecycle, tgPolled, now, { button, copy: copyToClipboard, link: tgLink });
|
|
3230
3311
|
}
|
|
3231
3312
|
async function loadTerragucci({ fresh = false } = {}) {
|
|
3232
3313
|
const answer = await apiFetch(`/api/terragucci${fresh ? "?fresh=1" : ""}`)
|
|
3233
3314
|
.then((r) => r.json())
|
|
3234
3315
|
.catch((e) => ({ error: String(e), code: "terragucci-report", remedy: "" }));
|
|
3235
3316
|
tgAnswer = answer;
|
|
3317
|
+
// #506: the timeline, whole; the inspect pane filters it by a card's root.
|
|
3318
|
+
tgTimeline = await apiFetch(`/api/terragucci/timeline${fresh ? "?fresh=1" : ""}`)
|
|
3319
|
+
.then((r) => r.json())
|
|
3320
|
+
.catch((e) => ({ error: String(e), code: "terragucci-report", remedy: "" }));
|
|
3321
|
+
// #522: the gate ledgers' history, with or in place of the audit record.
|
|
3322
|
+
tgHistory = await apiFetch(`/api/lifecycle/history${fresh ? "?fresh=1" : ""}`)
|
|
3323
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
3324
|
+
.catch(() => null);
|
|
3236
3325
|
renderTerragucciPanel();
|
|
3237
3326
|
markTerragucciCards();
|
|
3327
|
+
if (!staticMode) await loadTgProgress({ fresh });
|
|
3328
|
+
}
|
|
3329
|
+
// #511: /api/terragucci/progress, the newest apply's run view on the cards.
|
|
3330
|
+
let tgProgress = null;
|
|
3331
|
+
async function loadTgProgress({ fresh = false } = {}) {
|
|
3332
|
+
tgProgress = await apiFetch(`/api/terragucci/progress${fresh ? "?fresh=1" : ""}`)
|
|
3333
|
+
.then((r) => r.json())
|
|
3334
|
+
.catch((e) => ({ error: String(e) }));
|
|
3335
|
+
paintTgProgress();
|
|
3336
|
+
}
|
|
3337
|
+
function paintTgProgress() {
|
|
3338
|
+
tgStampProgress(document.querySelector("#graph svg"), tgProgress, new Date());
|
|
3339
|
+
renderTerragucciPanel();
|
|
3238
3340
|
}
|
|
3239
3341
|
function markTerragucciCards() {
|
|
3240
3342
|
const svg = document.querySelector("#graph svg");
|
|
3343
|
+
tgStampProgress(svg, tgProgress, new Date()); // #511
|
|
3241
3344
|
if (!svg || !tgAnswer || !tgAnswer.cards) return;
|
|
3242
3345
|
const now = new Date();
|
|
3346
|
+
// #505: a pushed read replaces the marks, so the old ones go first.
|
|
3347
|
+
for (const old of svg.querySelectorAll("[data-tg-mark]")) old.remove();
|
|
3348
|
+
for (const g of svg.querySelectorAll(".tg-marked")) g.classList.remove("tg-marked", "tg-drift", "tg-wave", "tg-plan");
|
|
3243
3349
|
for (const [id, marks] of Object.entries(tgAnswer.cards)) {
|
|
3244
3350
|
const g = svg.querySelector('[data-node-id="' + CSS.escape(id) + '"]');
|
|
3245
|
-
if (!g
|
|
3351
|
+
if (!g) continue;
|
|
3246
3352
|
const corner = cornerOf(marks);
|
|
3247
3353
|
g.classList.add("tg-marked", `tg-${corner.tone}`);
|
|
3248
3354
|
const rect = g.querySelector("rect");
|
|
@@ -3257,6 +3363,7 @@ function markTerragucciCards() {
|
|
|
3257
3363
|
tag.setAttribute("fill", corner.tone === "drift" ? "var(--degraded)" : corner.tone === "wave" ? "var(--pending)" : "var(--muted)");
|
|
3258
3364
|
tag.textContent = `${corner.text} · ${markAge(marks)}`;
|
|
3259
3365
|
const title = document.createElementNS("http://www.w3.org/2000/svg", "title");
|
|
3366
|
+
title.setAttribute("data-tg-mark", "title");
|
|
3260
3367
|
title.textContent = marks.map((m) => markLine(m, now)).join("\n") + "\n(from terragucci reports, not live: the Terragucci tab links each run)";
|
|
3261
3368
|
g.append(title, tag);
|
|
3262
3369
|
}
|
|
@@ -3292,6 +3399,7 @@ function renderTerragucciPanel() {
|
|
|
3292
3399
|
fix.className = "tg-legend";
|
|
3293
3400
|
fix.textContent = (a && a.remedy) || "";
|
|
3294
3401
|
host.append(err, fix);
|
|
3402
|
+
renderTerragucciLane(host, new Date());
|
|
3295
3403
|
return;
|
|
3296
3404
|
}
|
|
3297
3405
|
const now = new Date();
|
|
@@ -3308,6 +3416,8 @@ function renderTerragucciPanel() {
|
|
|
3308
3416
|
line.appendChild(tgLink(est.text, est.href));
|
|
3309
3417
|
host.appendChild(line);
|
|
3310
3418
|
}
|
|
3419
|
+
renderTerragucciLane(host, now);
|
|
3420
|
+
if (tgProgress) host.appendChild(tgProgressSection(tgProgress, now, tgLink)); // #511
|
|
3311
3421
|
for (const w of waveCards(a, now)) {
|
|
3312
3422
|
const box = document.createElement("div");
|
|
3313
3423
|
box.className = "ws-gate tg-wave";
|
|
@@ -3369,12 +3479,210 @@ function renderTerragucciPanel() {
|
|
|
3369
3479
|
].filter(Boolean).join(" · ");
|
|
3370
3480
|
host.appendChild(um);
|
|
3371
3481
|
}
|
|
3482
|
+
if (tgTimeline || tgHistory) {
|
|
3483
|
+
const h = document.createElement("div");
|
|
3484
|
+
h.className = "tg-timeline-title";
|
|
3485
|
+
h.textContent = "Timeline";
|
|
3486
|
+
host.append(h);
|
|
3487
|
+
// #522: the audit record, and the ledger's history beside it, each
|
|
3488
|
+
// labelled by its source. Without an audit.jsonl the record's lane is its
|
|
3489
|
+
// one "has no audit.jsonl" sentence and the ledger carries the history.
|
|
3490
|
+
if (tgTimeline) host.append(tgLane(tgTimeline));
|
|
3491
|
+
if (tgHistory) host.append(tgHistoryLane(tgHistory));
|
|
3492
|
+
}
|
|
3372
3493
|
if (!staticMode) {
|
|
3373
|
-
const again = button("Read the reports again", "", () =>
|
|
3494
|
+
const again = button("Read the reports again", "", () => {
|
|
3495
|
+
loadTerragucci({ fresh: true });
|
|
3496
|
+
loadTerragucciLane({ fresh: true }); // #505: and chant/lifecycle
|
|
3497
|
+
});
|
|
3374
3498
|
host.appendChild(again);
|
|
3375
3499
|
}
|
|
3376
3500
|
}
|
|
3377
3501
|
initTerragucci();
|
|
3502
|
+
// #525: each environment's newest release from the release ledger on
|
|
3503
|
+
// chant/lifecycle, with a cell whose schema the environment before it does
|
|
3504
|
+
// not hold marked. Dated and read-only; a static export carries its snapshot.
|
|
3505
|
+
let relHost = null;
|
|
3506
|
+
async function initReleases() {
|
|
3507
|
+
const project = await apiFetch("/api/project").then((r) => r.json()).catch(() => ({}));
|
|
3508
|
+
if (!project.releaseLedger) return;
|
|
3509
|
+
await loadReleases();
|
|
3510
|
+
}
|
|
3511
|
+
async function loadReleases({ fresh = false } = {}) {
|
|
3512
|
+
const a = await apiFetch(`/api/lifecycle/releases${fresh ? "?fresh=1" : ""}`)
|
|
3513
|
+
.then((r) => r.json())
|
|
3514
|
+
.catch((e) => ({ error: String(e) }));
|
|
3515
|
+
if (!relHost) {
|
|
3516
|
+
relHost = addPanelTab("releases", "Releases", "Each environment's newest release from the release ledger on chant/lifecycle, dated. Not live.");
|
|
3517
|
+
if (!relHost) return;
|
|
3518
|
+
relHost.id = "tab-releases";
|
|
3519
|
+
}
|
|
3520
|
+
renderReleases(relHost, a, new Date(), { link: tgLink });
|
|
3521
|
+
if (!staticMode) {
|
|
3522
|
+
const again = button("Read again", "", () => loadReleases({ fresh: true }));
|
|
3523
|
+
again.title = "Read chant/lifecycle again and redraw.";
|
|
3524
|
+
relHost.appendChild(again);
|
|
3525
|
+
}
|
|
3526
|
+
}
|
|
3527
|
+
initReleases();
|
|
3528
|
+
|
|
3529
|
+
|
|
3530
|
+
// --- #526: SQL Yodeler's report and drift, from files ------------------------
|
|
3531
|
+
// /api/yodel-report reads `yodel report --json` and drift JSON the person or
|
|
3532
|
+
// their pipeline wrote, checked against SQL Yodeler's schemas. Drift marks are
|
|
3533
|
+
// dated corner tags on the cards they name; environments and gaps go in the
|
|
3534
|
+
// panel. Nothing is live, and behold never runs yodel.
|
|
3535
|
+
let yrAnswer = null;
|
|
3536
|
+
let yrJoined = null;
|
|
3537
|
+
let yrHost = null;
|
|
3538
|
+
async function initYodelReport() {
|
|
3539
|
+
const project = await apiFetch("/api/project").then((r) => r.json()).catch(() => ({}));
|
|
3540
|
+
if (!project.yodelReport) return;
|
|
3541
|
+
await loadYodelReport();
|
|
3542
|
+
}
|
|
3543
|
+
async function loadYodelReport() {
|
|
3544
|
+
yrAnswer = await apiFetch("/api/yodel-report")
|
|
3545
|
+
.then((r) => r.json())
|
|
3546
|
+
.catch((e) => ({ error: String(e), code: "yodel-report", remedy: "" }));
|
|
3547
|
+
markYodelCards();
|
|
3548
|
+
}
|
|
3549
|
+
function markYodelCards() {
|
|
3550
|
+
if (!yrAnswer) return;
|
|
3551
|
+
const svg = document.querySelector("#graph svg");
|
|
3552
|
+
if (svg) {
|
|
3553
|
+
for (const old of svg.querySelectorAll("[data-yr-mark]")) old.remove();
|
|
3554
|
+
const ids = [...svg.querySelectorAll("[data-node-id]")].map((g) => g.getAttribute("data-node-id"));
|
|
3555
|
+
yrJoined = yrAnswer.error ? null : yrJoin(yrAnswer, ids);
|
|
3556
|
+
const now = new Date();
|
|
3557
|
+
for (const [id, marks] of Object.entries(yrJoined ? yrJoined.cards : {})) {
|
|
3558
|
+
const g = svg.querySelector('[data-node-id="' + CSS.escape(id) + '"]');
|
|
3559
|
+
if (!g) continue;
|
|
3560
|
+
const rect = g.querySelector("rect");
|
|
3561
|
+
const box = rect && rect.getBBox ? rect.getBBox() : null;
|
|
3562
|
+
const tag = document.createElementNS("http://www.w3.org/2000/svg", "text");
|
|
3563
|
+
tag.setAttribute("data-yr-mark", "1");
|
|
3564
|
+
tag.setAttribute("x", String((box ? box.x + box.width : 150) - 10));
|
|
3565
|
+
tag.setAttribute("y", String((box ? box.y : 0) + 14));
|
|
3566
|
+
tag.setAttribute("text-anchor", "end");
|
|
3567
|
+
tag.setAttribute("font-size", "11");
|
|
3568
|
+
tag.setAttribute("font-weight", "600");
|
|
3569
|
+
tag.setAttribute("fill", "var(--degraded)");
|
|
3570
|
+
tag.textContent = yrCorner(marks, now);
|
|
3571
|
+
const title = document.createElementNS("http://www.w3.org/2000/svg", "title");
|
|
3572
|
+
title.setAttribute("data-yr-mark", "title");
|
|
3573
|
+
title.textContent = marks.map((m) => yrMarkLine(m, now)).join("\n") + "\n(from SQL Yodeler drift files, not live)";
|
|
3574
|
+
g.append(title, tag);
|
|
3575
|
+
}
|
|
3576
|
+
}
|
|
3577
|
+
renderYodelPanel();
|
|
3578
|
+
}
|
|
3579
|
+
function renderYodelPanel() {
|
|
3580
|
+
if (!yrHost) {
|
|
3581
|
+
yrHost = addPanelTab("yodel", "SQL Yodeler", "SQL Yodeler's report and drift, read from files: each environment's applied migrations and gaps, dated. Not live.");
|
|
3582
|
+
if (!yrHost) return;
|
|
3583
|
+
yrHost.id = "tab-yodel";
|
|
3584
|
+
}
|
|
3585
|
+
const host = yrHost;
|
|
3586
|
+
host.replaceChildren();
|
|
3587
|
+
const a = yrAnswer;
|
|
3588
|
+
const line = (cls, text, title) => {
|
|
3589
|
+
const d = document.createElement("div");
|
|
3590
|
+
d.className = cls;
|
|
3591
|
+
d.textContent = text;
|
|
3592
|
+
if (title) d.title = title;
|
|
3593
|
+
host.appendChild(d);
|
|
3594
|
+
return d;
|
|
3595
|
+
};
|
|
3596
|
+
if (!a || a.error) {
|
|
3597
|
+
line("tg-refusal", a ? `${a.code}: ${a.error}` : "SQL Yodeler report not read");
|
|
3598
|
+
if (a && a.remedy) line("tg-legend", a.remedy);
|
|
3599
|
+
return;
|
|
3600
|
+
}
|
|
3601
|
+
const now = new Date();
|
|
3602
|
+
const lg = yrLegend(a, now);
|
|
3603
|
+
line("tg-legend", lg.text, lg.title);
|
|
3604
|
+
for (const e of a.environments) {
|
|
3605
|
+
const h = document.createElement("h3");
|
|
3606
|
+
h.textContent = e.op ? `${e.name} (op ${e.op.name}, gate ${e.op.gate})` : e.name;
|
|
3607
|
+
host.appendChild(h);
|
|
3608
|
+
for (const t of yrEnvLines(e, now)) line("tg-inspect", t);
|
|
3609
|
+
line("tg-legend", `from ${yrDated(e.dated, now)}`);
|
|
3610
|
+
drawAudit((a.audit || []).filter((x) => x.environment === e.name));
|
|
3611
|
+
}
|
|
3612
|
+
// #537: audit entries for an environment the report lists no row for.
|
|
3613
|
+
const named = new Set(a.environments.map((e) => e.name));
|
|
3614
|
+
const loose = (a.audit || []).filter((x) => !named.has(x.environment));
|
|
3615
|
+
if (loose.length) {
|
|
3616
|
+
const h = document.createElement("h3");
|
|
3617
|
+
h.textContent = "audit, environments the report lists no row for";
|
|
3618
|
+
host.appendChild(h);
|
|
3619
|
+
drawAudit(loose, true);
|
|
3620
|
+
}
|
|
3621
|
+
function drawAudit(entries, withEnv = false) {
|
|
3622
|
+
if (!entries.length) return;
|
|
3623
|
+
const sub = document.createElement("div");
|
|
3624
|
+
sub.className = "tg-legend";
|
|
3625
|
+
sub.textContent = `audit, ${entries.length} entr${entries.length === 1 ? "y" : "ies"}, oldest first`;
|
|
3626
|
+
host.appendChild(sub);
|
|
3627
|
+
for (const x of entries) {
|
|
3628
|
+
const l = yrAuditLines(x, now);
|
|
3629
|
+
line("tg-inspect", withEnv ? `${x.environment}: ${l.text}` : l.text, l.source);
|
|
3630
|
+
line("tg-legend", l.source);
|
|
3631
|
+
}
|
|
3632
|
+
}
|
|
3633
|
+
for (const d of a.drift) {
|
|
3634
|
+
const h = document.createElement("h3");
|
|
3635
|
+
h.textContent = `${d.environment} drift`;
|
|
3636
|
+
host.appendChild(h);
|
|
3637
|
+
line("tg-inspect", d.drifted ? `${d.changed} changed, ${d.missing} missing` : "no drift in what could be read");
|
|
3638
|
+
for (const u of d.unobserved) line("tg-inspect", `not read: ${u.name}${u.reason ? ` (${u.reason})` : ""}`);
|
|
3639
|
+
line("tg-legend", `from ${yrDated(d.dated, now)}${d.server ? ` · ${d.server}` : ""}`);
|
|
3640
|
+
}
|
|
3641
|
+
const unmatched = (yrJoined && yrJoined.unmatched) || [];
|
|
3642
|
+
if (unmatched.length) {
|
|
3643
|
+
const h = document.createElement("h3");
|
|
3644
|
+
h.textContent = "drift on no card";
|
|
3645
|
+
host.appendChild(h);
|
|
3646
|
+
for (const m of unmatched) line("tg-inspect", `${m.object}: ${yrMarkLine(m, now)}`);
|
|
3647
|
+
}
|
|
3648
|
+
if (a.ignored.length) line("tg-legend", `not read (neither a report nor drift): ${a.ignored.join(", ")}`);
|
|
3649
|
+
if (!staticMode) host.appendChild(button("Read the files again", "", () => loadYodelReport()));
|
|
3650
|
+
}
|
|
3651
|
+
initYodelReport();
|
|
3652
|
+
|
|
3653
|
+
// --- #524: a SQL Yodeler repo's lane over chant/lifecycle -------------------
|
|
3654
|
+
// /api/sql-yodeler/lifecycle: each environment's wave gate and migrations-op
|
|
3655
|
+
// gate, in the order of applies, with the approve line to copy. Below it,
|
|
3656
|
+
// the gate ledgers' history (#522). Read-only and dated; never live.
|
|
3657
|
+
let ylHost = null;
|
|
3658
|
+
async function initYodelLane() {
|
|
3659
|
+
// #537: a static export carries the lane as a snapshot (src/export.ts), so it draws here too.
|
|
3660
|
+
const project = await apiFetch("/api/project").then((r) => r.json()).catch(() => ({}));
|
|
3661
|
+
if (!project.sqlYodeler) return;
|
|
3662
|
+
await loadYodelLane();
|
|
3663
|
+
}
|
|
3664
|
+
async function loadYodelLane({ fresh = false } = {}) {
|
|
3665
|
+
const q = fresh ? "?fresh=1" : "";
|
|
3666
|
+
const [lane, history] = await Promise.all([
|
|
3667
|
+
apiFetch(`/api/sql-yodeler/lifecycle${q}`).then((r) => r.json()).catch((e) => ({ error: String(e) })),
|
|
3668
|
+
apiFetch(`/api/lifecycle/history${q}`).then((r) => (r.ok ? r.json() : null)).catch(() => null),
|
|
3669
|
+
]);
|
|
3670
|
+
if (!ylHost) {
|
|
3671
|
+
ylHost = addPanelTab("yodel-lane", "Pipeline", "SQL Yodeler's gates on chant/lifecycle: each environment's wave and migrations op, in the order of applies, dated. Not live.");
|
|
3672
|
+
if (!ylHost) return;
|
|
3673
|
+
ylHost.id = "tab-yodel-lane";
|
|
3674
|
+
}
|
|
3675
|
+
ylHost.replaceChildren();
|
|
3676
|
+
renderYodelLane(ylHost, lane, new Date(), { button, copy: copyToClipboard, link: tgLink });
|
|
3677
|
+
if (history) ylHost.appendChild(tgHistoryLane(history));
|
|
3678
|
+
if (staticMode) return;
|
|
3679
|
+
const again = button("Read again", "", () => loadYodelLane({ fresh: true }));
|
|
3680
|
+
again.title = "Fetch chant/lifecycle again and redraw.";
|
|
3681
|
+
ylHost.appendChild(again);
|
|
3682
|
+
}
|
|
3683
|
+
initYodelLane();
|
|
3684
|
+
// #509: a control repo draws its projects from estate.json and their run views.
|
|
3685
|
+
initTerragucciEstate({ fetchUrl: apiFetch, copy: copyToClipboard });
|
|
3378
3686
|
|
|
3379
3687
|
function renderPanelCarve() {
|
|
3380
3688
|
if (!carveMode()) return;
|
|
@@ -4281,6 +4589,7 @@ function render(ir, svg, m) {
|
|
|
4281
4589
|
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
|
|
4282
4590
|
markPendingCards(ir); // #422: and the cards whose member has not answered yet
|
|
4283
4591
|
markTerragucciCards(); // #490: and what terragucci's reports found, dated
|
|
4592
|
+
markYodelCards(); // #526: and SQL Yodeler's drift, dated
|
|
4284
4593
|
applyLayout(); // #228: last, so the hand-placed deltas ride on top of every other pass
|
|
4285
4594
|
renderDial();
|
|
4286
4595
|
}
|
|
@@ -6110,6 +6419,7 @@ async function initActions() {
|
|
|
6110
6419
|
pill.title = "An exported, read-only snapshot — no live observe or deploy.";
|
|
6111
6420
|
pill.style.cssText = "align-self:center;font:var(--t-caption)/1.4 var(--font-mono);color:var(--muted);border:1px solid var(--line);border-radius:var(--r-ctl);padding:2px 8px";
|
|
6112
6421
|
bar.appendChild(pill);
|
|
6422
|
+
mountHistoryPicker(bar); // #510: other commits' views, when ../history.json sits beside this one
|
|
6113
6423
|
previewMode = true;
|
|
6114
6424
|
return; // nothing else in the bar is a read
|
|
6115
6425
|
}
|
|
@@ -6122,12 +6432,14 @@ async function initActions() {
|
|
|
6122
6432
|
// them right now (`/api/project`'s executor block).
|
|
6123
6433
|
executorInfo = project.executor || {};
|
|
6124
6434
|
previewMode = staticMode || !!project.previewMode; // static ⇒ read-only, no writes at all
|
|
6125
|
-
|
|
6126
|
-
|
|
6435
|
+
// #517: a SQL Yodeler repo is guarded the same way.
|
|
6436
|
+
const guarded = project.terragucci || project.sqlYodeler;
|
|
6437
|
+
if (guarded) {
|
|
6438
|
+
terragucciRepo = guarded;
|
|
6127
6439
|
previewMode = true;
|
|
6128
6440
|
const pill = document.createElement("span");
|
|
6129
|
-
pill.textContent = "● applied by terragucci";
|
|
6130
|
-
pill.title = `${
|
|
6441
|
+
pill.textContent = project.terragucci ? "● applied by terragucci" : "● migrated by SQL Yodeler";
|
|
6442
|
+
pill.title = `${guarded.error}\n\n${guarded.remedy}`;
|
|
6131
6443
|
pill.style.cssText = "align-self:center;font:var(--t-caption)/1.4 var(--font-mono);color:var(--muted);border:1px solid var(--line);border-radius:var(--r-ctl);padding:2px 8px";
|
|
6132
6444
|
bar.appendChild(pill);
|
|
6133
6445
|
return; // no Deploy, no Run, no Approve: the pipeline is the only way to apply
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// #510: a published view lives at views/<name>/<commit>/, with
|
|
2
|
+
// views/<name>/history.json beside the commits (newest first, {commit, at,
|
|
3
|
+
// project, generated}). Inside one, this picker reads ../history.json and
|
|
4
|
+
// opens another commit's view. It only reads: switching is a navigation, and
|
|
5
|
+
// a bundle served anywhere without a history.json beside it shows no picker.
|
|
6
|
+
|
|
7
|
+
const COMMIT_SHAPE = /^[0-9a-f]{7,64}$/;
|
|
8
|
+
|
|
9
|
+
/** The rows of a history.json document worth offering, in its order. */
|
|
10
|
+
export function historyRows(doc) {
|
|
11
|
+
if (!Array.isArray(doc)) return [];
|
|
12
|
+
return doc.filter((e) => e && typeof e === "object" && typeof e.commit === "string" && COMMIT_SHAPE.test(e.commit));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Which row the page at `pathname` is: the commit named by one of its path segments, or null. */
|
|
16
|
+
export function commitOfPath(pathname, rows) {
|
|
17
|
+
const segs = String(pathname || "").split("/").filter(Boolean);
|
|
18
|
+
for (let i = segs.length - 1; i >= 0; i--) {
|
|
19
|
+
const hit = rows.find((r) => r.commit === segs[i]);
|
|
20
|
+
if (hit) return hit.commit;
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const stamp = (iso) => (typeof iso === "string" && iso.length >= 16 ? iso.slice(0, 16).replace("T", " ") : "");
|
|
26
|
+
|
|
27
|
+
/** One option's words: the commit, and when its view was exported. */
|
|
28
|
+
export function historyLabel(row) {
|
|
29
|
+
const when = stamp(row.generated) || stamp(row.at);
|
|
30
|
+
return `${row.commit.slice(0, 12)}${when ? ` · exported ${when}` : ""}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** One option's tooltip. */
|
|
34
|
+
export function historyTitle(row) {
|
|
35
|
+
return [`commit ${row.commit}`, row.at ? `committed ${row.at}` : "", row.generated ? `exported ${row.generated}` : "", row.project ? `project ${row.project}` : ""].filter(Boolean).join("\n");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Where a pick goes: the sibling commit's view, keeping the lens in the query and the fragment. */
|
|
39
|
+
export function commitHref(commit, search = "", hash = "") {
|
|
40
|
+
return `../${commit}/index.html${search || ""}${hash || ""}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Read ../history.json and put a commit picker into `host`. Resolves to the
|
|
45
|
+
* <select>, or null when there is no history to pick from.
|
|
46
|
+
*/
|
|
47
|
+
export async function mountHistoryPicker(host, env = {}) {
|
|
48
|
+
const fetchFn = env.fetch || ((u, o) => fetch(u, o));
|
|
49
|
+
const loc = env.location || location;
|
|
50
|
+
const doc = env.document || document;
|
|
51
|
+
let rows;
|
|
52
|
+
try {
|
|
53
|
+
const r = await fetchFn("../history.json", { cache: "no-cache" });
|
|
54
|
+
if (!r.ok) return null;
|
|
55
|
+
rows = historyRows(await r.json());
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
if (!rows.length) return null;
|
|
60
|
+
const current = commitOfPath(loc.pathname, rows);
|
|
61
|
+
const select = doc.createElement("select");
|
|
62
|
+
select.title = "Published views, newest first: open the view exported at another commit";
|
|
63
|
+
select.setAttribute("aria-label", "Published view history");
|
|
64
|
+
select.style.cssText = "align-self:center;font:var(--t-caption)/1.4 var(--font-mono);color:var(--fg);background:var(--panel);border:1px solid var(--line);border-radius:var(--r-ctl);padding:1px 4px;margin-left:6px;max-width:16em";
|
|
65
|
+
if (!current) {
|
|
66
|
+
const here = doc.createElement("option");
|
|
67
|
+
here.value = "";
|
|
68
|
+
here.textContent = "this view (not in history)";
|
|
69
|
+
here.selected = true;
|
|
70
|
+
select.appendChild(here);
|
|
71
|
+
}
|
|
72
|
+
for (const row of rows) {
|
|
73
|
+
const o = doc.createElement("option");
|
|
74
|
+
o.value = row.commit;
|
|
75
|
+
o.textContent = historyLabel(row);
|
|
76
|
+
o.title = historyTitle(row);
|
|
77
|
+
if (row.commit === current) o.selected = true;
|
|
78
|
+
select.appendChild(o);
|
|
79
|
+
}
|
|
80
|
+
select.addEventListener("change", () => {
|
|
81
|
+
if (select.value && select.value !== current) loc.assign(commitHref(select.value, loc.search, loc.hash));
|
|
82
|
+
});
|
|
83
|
+
host.appendChild(select);
|
|
84
|
+
return select;
|
|
85
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { commitHref, commitOfPath, historyLabel, historyRows, mountHistoryPicker } from "./history-picker.js";
|
|
3
|
+
|
|
4
|
+
// #510: the picker inside a published commit's view, reading ../history.json.
|
|
5
|
+
const A = "a".repeat(40);
|
|
6
|
+
const B = "b".repeat(40);
|
|
7
|
+
const history = [
|
|
8
|
+
{ commit: A, at: "2026-10-09T08:00:00.000Z", project: "shop", generated: "2026-10-10T12:00:00.000Z" },
|
|
9
|
+
{ commit: B, at: "2026-10-08T08:00:00.000Z", project: "shop", generated: "2026-10-08T09:30:00.000Z" },
|
|
10
|
+
{ commit: "../x", at: "", project: "", generated: "" },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
/** Just enough of a document for the picker. */
|
|
14
|
+
function fakeDocument() {
|
|
15
|
+
const make = (tag) => {
|
|
16
|
+
const el = {
|
|
17
|
+
tag,
|
|
18
|
+
children: [],
|
|
19
|
+
style: {},
|
|
20
|
+
attrs: {},
|
|
21
|
+
listeners: {},
|
|
22
|
+
appendChild: (c) => (el.children.push(c), c),
|
|
23
|
+
setAttribute: (k, v) => (el.attrs[k] = v),
|
|
24
|
+
addEventListener: (k, f) => (el.listeners[k] = f),
|
|
25
|
+
};
|
|
26
|
+
return el;
|
|
27
|
+
};
|
|
28
|
+
return { createElement: make, host: make("div") };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("the history picker (#510)", () => {
|
|
32
|
+
it("offers the rows with a commit, newest first, labeled with the commit and when it was exported", () => {
|
|
33
|
+
const rows = historyRows(history);
|
|
34
|
+
expect(rows.map((r) => r.commit)).toEqual([A, B]);
|
|
35
|
+
expect(historyLabel(rows[0])).toBe(`${A.slice(0, 12)} · exported 2026-10-10 12:00`);
|
|
36
|
+
expect(historyRows({})).toEqual([]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("knows which commit the page is from its path", () => {
|
|
40
|
+
const rows = historyRows(history);
|
|
41
|
+
expect(commitOfPath(`/r/views/behold/${B}/index.html`, rows)).toBe(B);
|
|
42
|
+
expect(commitOfPath(`/r/views/behold/${A}/`, rows)).toBe(A);
|
|
43
|
+
expect(commitOfPath("/somewhere/else/", rows)).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("reads ../history.json, selects this commit, and opens another in place, keeping the query and fragment", async () => {
|
|
47
|
+
const asked = [];
|
|
48
|
+
const went = [];
|
|
49
|
+
const doc = fakeDocument();
|
|
50
|
+
const location = { pathname: `/r/views/behold/${A}/index.html`, search: "?env=prod", hash: "#x", assign: (u) => went.push(u) };
|
|
51
|
+
const fetch = async (u, o) => (asked.push([u, o]), { ok: true, json: async () => history });
|
|
52
|
+
const select = await mountHistoryPicker(doc.host, { fetch, location, document: doc });
|
|
53
|
+
expect(asked).toEqual([["../history.json", { cache: "no-cache" }]]);
|
|
54
|
+
expect(doc.host.children).toEqual([select]);
|
|
55
|
+
expect(select.children.map((o) => [o.value, !!o.selected])).toEqual([[A, true], [B, false]]);
|
|
56
|
+
select.value = B;
|
|
57
|
+
select.listeners.change();
|
|
58
|
+
expect(went).toEqual([`../${B}/index.html?env=prod#x`]);
|
|
59
|
+
expect(commitHref(A)).toBe(`../${A}/index.html`);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("shows nothing when no history.json sits beside the view", async () => {
|
|
63
|
+
const doc = fakeDocument();
|
|
64
|
+
const location = { pathname: "/bundle/index.html", search: "", hash: "", assign: () => {} };
|
|
65
|
+
expect(await mountHistoryPicker(doc.host, { fetch: async () => ({ ok: false }), location, document: doc })).toBeNull();
|
|
66
|
+
expect(await mountHistoryPicker(doc.host, { fetch: async () => Promise.reject(new Error("offline")), location, document: doc })).toBeNull();
|
|
67
|
+
expect(doc.host.children).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("marks a view that is not in the history rather than claiming to be another commit", async () => {
|
|
71
|
+
const doc = fakeDocument();
|
|
72
|
+
const location = { pathname: `/r/views/behold/${"c".repeat(40)}/index.html`, search: "", hash: "", assign: () => {} };
|
|
73
|
+
const select = await mountHistoryPicker(doc.host, { fetch: async () => ({ ok: true, json: async () => history }), location, document: doc });
|
|
74
|
+
expect(select.children[0]).toMatchObject({ value: "", selected: true });
|
|
75
|
+
expect(select.children).toHaveLength(3);
|
|
76
|
+
});
|
|
77
|
+
});
|