@intentius/behold 0.25.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 +246 -7
- package/demos.json +1 -1
- package/dist/cli.js +9943 -1641
- 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 +220 -7
- package/web/index.html +22 -0
- package/web/releases.js +121 -0
- package/web/sql-yodeler-lane.js +129 -0
- package/web/terragucci-lifecycle.js +103 -11
- package/web/terragucci-lifecycle.test.js +36 -1
- package/web/terragucci-timeline.js +77 -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,9 +13,12 @@ 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 } from "./terragucci-timeline.js";
|
|
16
|
+
import { laneElement as tgLane, rootOfCard as tgRootOfCard, historyElement as tgHistoryLane } from "./terragucci-timeline.js";
|
|
17
17
|
import { renderLane } from "./terragucci-lifecycle.js";
|
|
18
|
+
import { renderReleases } from "./releases.js";
|
|
19
|
+
import { renderYodelLane } from "./sql-yodeler-lane.js";
|
|
18
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";
|
|
19
22
|
import { cardProgress as tgCardProgress, progressLines as tgProgressLines, progressSection as tgProgressSection, stampProgress as tgStampProgress } from "./terragucci-progress.js";
|
|
20
23
|
import { seconds, whyQuery, whyView } from "./why.js";
|
|
21
24
|
import { readCostLine } from "./read-cost.js";
|
|
@@ -908,6 +911,21 @@ function inspect(node) {
|
|
|
908
911
|
panel.appendChild(p);
|
|
909
912
|
}
|
|
910
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
|
+
}
|
|
911
929
|
// #511: the newest apply's progress on this card, dated by the records read.
|
|
912
930
|
const tgProg = tgCardProgress(tgProgress && tgProgress.cards && tgProgress.cards[node.id]);
|
|
913
931
|
if (tgProg) {
|
|
@@ -3250,6 +3268,7 @@ function markDriftedCards(ir) {
|
|
|
3250
3268
|
let tgAnswer = null;
|
|
3251
3269
|
let tgHost = null;
|
|
3252
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
|
|
3253
3272
|
async function initTerragucci() {
|
|
3254
3273
|
const project = await apiFetch("/api/project").then((r) => r.json()).catch(() => ({}));
|
|
3255
3274
|
if (!project.terragucciReports) return;
|
|
@@ -3299,6 +3318,10 @@ async function loadTerragucci({ fresh = false } = {}) {
|
|
|
3299
3318
|
tgTimeline = await apiFetch(`/api/terragucci/timeline${fresh ? "?fresh=1" : ""}`)
|
|
3300
3319
|
.then((r) => r.json())
|
|
3301
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);
|
|
3302
3325
|
renderTerragucciPanel();
|
|
3303
3326
|
markTerragucciCards();
|
|
3304
3327
|
if (!staticMode) await loadTgProgress({ fresh });
|
|
@@ -3456,11 +3479,16 @@ function renderTerragucciPanel() {
|
|
|
3456
3479
|
].filter(Boolean).join(" · ");
|
|
3457
3480
|
host.appendChild(um);
|
|
3458
3481
|
}
|
|
3459
|
-
if (tgTimeline) {
|
|
3482
|
+
if (tgTimeline || tgHistory) {
|
|
3460
3483
|
const h = document.createElement("div");
|
|
3461
3484
|
h.className = "tg-timeline-title";
|
|
3462
3485
|
h.textContent = "Timeline";
|
|
3463
|
-
host.append(h
|
|
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));
|
|
3464
3492
|
}
|
|
3465
3493
|
if (!staticMode) {
|
|
3466
3494
|
const again = button("Read the reports again", "", () => {
|
|
@@ -3471,6 +3499,188 @@ function renderTerragucciPanel() {
|
|
|
3471
3499
|
}
|
|
3472
3500
|
}
|
|
3473
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();
|
|
3474
3684
|
// #509: a control repo draws its projects from estate.json and their run views.
|
|
3475
3685
|
initTerragucciEstate({ fetchUrl: apiFetch, copy: copyToClipboard });
|
|
3476
3686
|
|
|
@@ -4379,6 +4589,7 @@ function render(ir, svg, m) {
|
|
|
4379
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
|
|
4380
4590
|
markPendingCards(ir); // #422: and the cards whose member has not answered yet
|
|
4381
4591
|
markTerragucciCards(); // #490: and what terragucci's reports found, dated
|
|
4592
|
+
markYodelCards(); // #526: and SQL Yodeler's drift, dated
|
|
4382
4593
|
applyLayout(); // #228: last, so the hand-placed deltas ride on top of every other pass
|
|
4383
4594
|
renderDial();
|
|
4384
4595
|
}
|
|
@@ -6221,12 +6432,14 @@ async function initActions() {
|
|
|
6221
6432
|
// them right now (`/api/project`'s executor block).
|
|
6222
6433
|
executorInfo = project.executor || {};
|
|
6223
6434
|
previewMode = staticMode || !!project.previewMode; // static ⇒ read-only, no writes at all
|
|
6224
|
-
|
|
6225
|
-
|
|
6435
|
+
// #517: a SQL Yodeler repo is guarded the same way.
|
|
6436
|
+
const guarded = project.terragucci || project.sqlYodeler;
|
|
6437
|
+
if (guarded) {
|
|
6438
|
+
terragucciRepo = guarded;
|
|
6226
6439
|
previewMode = true;
|
|
6227
6440
|
const pill = document.createElement("span");
|
|
6228
|
-
pill.textContent = "● applied by terragucci";
|
|
6229
|
-
pill.title = `${
|
|
6441
|
+
pill.textContent = project.terragucci ? "● applied by terragucci" : "● migrated by SQL Yodeler";
|
|
6442
|
+
pill.title = `${guarded.error}\n\n${guarded.remedy}`;
|
|
6230
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";
|
|
6231
6444
|
bar.appendChild(pill);
|
|
6232
6445
|
return; // no Deploy, no Run, no Approve: the pipeline is the only way to apply
|
package/web/index.html
CHANGED
|
@@ -142,6 +142,21 @@
|
|
|
142
142
|
#ws-gates .ws-gate-title { font-weight: 600; }
|
|
143
143
|
#ws-gates .ws-gate-facts, #ws-gates .ws-gate-note { color: var(--muted); font-size: var(--t-caption); }
|
|
144
144
|
#ws-gates .ws-gate button { margin-top: 4px; }
|
|
145
|
+
/* #526: SQL Yodeler's report and drift, from files, dated. Never a fill. */
|
|
146
|
+
#panel-body #tab-yodel.active { display: flex; flex-direction: column; gap: 4px; font-size: var(--t-caption); }
|
|
147
|
+
#tab-yodel h3 { margin: 8px 0 0; font-size: var(--t-caption); }
|
|
148
|
+
#tab-yodel .tg-legend { color: var(--muted); overflow-wrap: anywhere; }
|
|
149
|
+
#tab-yodel .tg-inspect { overflow-wrap: anywhere; }
|
|
150
|
+
#tab-yodel .tg-refusal { color: var(--degraded); font-weight: 600; }
|
|
151
|
+
/* #524: SQL Yodeler's gates on chant/lifecycle, dated. A Copy button is the only control. */
|
|
152
|
+
#panel-body #tab-yodel-lane.active { display: flex; flex-direction: column; gap: 6px; font-size: var(--t-caption); }
|
|
153
|
+
#tab-yodel-lane h3 { margin: 8px 0 0; font-size: var(--t-caption); }
|
|
154
|
+
#tab-yodel-lane .tg-legend { color: var(--muted); overflow-wrap: anywhere; }
|
|
155
|
+
#tab-yodel-lane .tg-refusal { color: var(--degraded); font-weight: 600; }
|
|
156
|
+
#tab-yodel-lane .ws-gate { border: 1px solid var(--rule); border-radius: 6px; padding: 6px 8px; }
|
|
157
|
+
#tab-yodel-lane .ws-gate-title { font-weight: 600; }
|
|
158
|
+
#tab-yodel-lane .ws-gate-facts, #tab-yodel-lane .ws-gate-note { color: var(--muted); overflow-wrap: anywhere; }
|
|
159
|
+
#tab-yodel-lane .tg-command { display: block; font-family: var(--font-mono); overflow-wrap: anywhere; margin: 4px 0; }
|
|
145
160
|
/* #490: terragucci's reports, dated. Never a fill. */
|
|
146
161
|
#panel-body #tab-terragucci.active { display: flex; flex-direction: column; gap: 8px; font-size: var(--t-caption); }
|
|
147
162
|
#tab-terragucci .tg-legend, #tab-terragucci .tg-estate { color: var(--muted); }
|
|
@@ -189,6 +204,13 @@
|
|
|
189
204
|
/* #506: the audit record's lane, in the tab and in a card's inspect pane. */
|
|
190
205
|
#tab-terragucci .tg-timeline-title { font-weight: 600; border-top: 1px solid var(--rule); padding-top: 6px; }
|
|
191
206
|
.tg-timeline { display: flex; flex-direction: column; gap: 3px; font-size: var(--t-caption); }
|
|
207
|
+
.rel-strip { border-collapse: collapse; margin: 6px 0; font-size: 12px; }
|
|
208
|
+
.rel-strip th, .rel-strip td { padding: 3px 6px; text-align: left; vertical-align: top; border-bottom: 1px solid var(--neutral); }
|
|
209
|
+
.rel-strip .rel-component { font-family: var(--font-mono); overflow-wrap: anywhere; }
|
|
210
|
+
.rel-strip .rel-cell { font-family: var(--font-mono); }
|
|
211
|
+
.rel-strip .rel-empty { color: var(--muted); }
|
|
212
|
+
.rel-strip .rel-differs { box-shadow: inset 2px 0 0 var(--degraded); }
|
|
213
|
+
.rel-strip .rel-mark { color: var(--degraded); font-weight: 600; font-family: inherit; }
|
|
192
214
|
.tg-timeline .tg-legend { color: var(--muted); }
|
|
193
215
|
.tg-timeline .tg-refusal { color: var(--degraded); font-weight: 600; }
|
|
194
216
|
.tg-timeline .tg-entry { overflow-wrap: anywhere; }
|
package/web/releases.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// #525: the Releases strip. Pure words from /api/lifecycle/releases, and one
|
|
2
|
+
// render. One column per environment (wave order when yodel-waves.json gives
|
|
3
|
+
// one), one row per component or tenant. Every cell says when its release was
|
|
4
|
+
// recorded, by whom, and which commit of chant/lifecycle it was read from. A
|
|
5
|
+
// cell whose digest the environment before it does not hold is marked
|
|
6
|
+
// "differs from <env>" with both digests. Nothing here deploys or promotes.
|
|
7
|
+
|
|
8
|
+
import { ago, clock } from "./terragucci.js";
|
|
9
|
+
|
|
10
|
+
const short = (s) => (s ? String(s).replace(/^(jcs1-)?sha256:/, "").slice(0, 12) : "");
|
|
11
|
+
|
|
12
|
+
/** The strip's line: what was read, how, and when. */
|
|
13
|
+
export function releasesLegend(a, now = new Date()) {
|
|
14
|
+
if (!a || a.error) return { text: `release ledger not read${a && a.error ? `: ${a.error}` : ""}`, title: "", tone: "refusal" };
|
|
15
|
+
const when = `read ${ago(a.read.at, now)}`;
|
|
16
|
+
if (!a.found) {
|
|
17
|
+
const text = a.via === "none" ? `release ledger unknown: behold could not fetch chant/lifecycle · ${when}` : `no release recorded yet · ${when}`;
|
|
18
|
+
return { text, title: a.note, tone: a.via === "none" ? "refusal" : "quiet" };
|
|
19
|
+
}
|
|
20
|
+
const how = a.via === "fetch" ? "fetched from origin" : "the checkout's origin/chant/lifecycle, as of its last git fetch (behold's fetch failed)";
|
|
21
|
+
const order = a.order === "waves" ? "columns in yodel-waves.json's wave order" : "columns by name";
|
|
22
|
+
const bad = Object.entries(a.malformed || {}).map(([env, n]) => `${env}: ${n} malformed line${n === 1 ? "" : "s"} skipped`);
|
|
23
|
+
return {
|
|
24
|
+
text: `release ledger on chant/lifecycle · ${when}, ${how} · ${order}`,
|
|
25
|
+
title: [a.note, a.fetch && a.fetch.error ? `fetch: ${a.fetch.error}` : "", ...bad].filter(Boolean).join("\n"),
|
|
26
|
+
tone: a.via === "fetch" ? "quiet" : "stale",
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** One cell's words: digest, age, who, and a difference mark when there is one. */
|
|
31
|
+
export function releaseCellWords(cell, now = new Date()) {
|
|
32
|
+
if (!cell) return { text: "no release", title: "", differs: null };
|
|
33
|
+
const who = `${cell.actor}${cell.approver ? `, approved by ${cell.approver}` : ""}`;
|
|
34
|
+
const src = cell.source && cell.source.commit ? `chant/lifecycle ${short(cell.source.commit)}${cell.source.committed ? `, committed ${clock(cell.source.committed, now)}` : ""}` : "chant/lifecycle";
|
|
35
|
+
let differs = null;
|
|
36
|
+
if (cell.differs) {
|
|
37
|
+
const theirs = cell.differs.digest ? short(cell.differs.digest) : "nothing";
|
|
38
|
+
differs = {
|
|
39
|
+
text: `differs from ${cell.differs.from}`,
|
|
40
|
+
title: `${cell.env} holds ${short(cell.digest)}; ${cell.differs.from} holds ${theirs}.${cell.differs.everHad ? "" : ` ${cell.differs.from}'s ledger never recorded ${short(cell.digest)}.`}`,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
text: `${short(cell.digest)} · ${ago(cell.timestamp, now)}`,
|
|
45
|
+
title: [
|
|
46
|
+
`${cell.component} in ${cell.env}`,
|
|
47
|
+
`digest ${cell.digest}`,
|
|
48
|
+
`recorded ${clock(cell.timestamp, now)}, ${ago(cell.timestamp, now)}, by ${who}`,
|
|
49
|
+
`commit ${short(cell.gitSha)}, run ${cell.runId}`,
|
|
50
|
+
`from ${src} (${cell.source ? cell.source.path : ""})`,
|
|
51
|
+
differs ? differs.title : "",
|
|
52
|
+
]
|
|
53
|
+
.filter(Boolean)
|
|
54
|
+
.join("\n"),
|
|
55
|
+
differs,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The grid: header row of environments, then a row per component with a cell per environment. */
|
|
60
|
+
export function releaseGrid(a, now = new Date()) {
|
|
61
|
+
if (!a || !a.found) return { environments: [], rows: [] };
|
|
62
|
+
const at = new Map(a.cells.map((c) => [`${c.component}\u0000${c.env}`, c]));
|
|
63
|
+
return {
|
|
64
|
+
environments: a.environments,
|
|
65
|
+
rows: a.components.map((component) => ({
|
|
66
|
+
component,
|
|
67
|
+
cells: a.environments.map((env) => {
|
|
68
|
+
const c = at.get(`${component}\u0000${env}`);
|
|
69
|
+
return { env, ...releaseCellWords(c, now), runUrl: c && c.runUrl ? c.runUrl : null, empty: !c };
|
|
70
|
+
}),
|
|
71
|
+
})),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Render into `host`. `ui.link(text, href)` is app.js's link. */
|
|
76
|
+
export function renderReleases(host, a, now, ui) {
|
|
77
|
+
host.replaceChildren();
|
|
78
|
+
const lg = releasesLegend(a, now);
|
|
79
|
+
const head = document.createElement("div");
|
|
80
|
+
head.className = lg.tone === "refusal" ? "tg-refusal" : "tg-legend";
|
|
81
|
+
head.textContent = lg.text;
|
|
82
|
+
head.title = lg.title;
|
|
83
|
+
host.appendChild(head);
|
|
84
|
+
const g = releaseGrid(a, now);
|
|
85
|
+
if (!g.rows.length) return;
|
|
86
|
+
const table = document.createElement("table");
|
|
87
|
+
table.className = "rel-strip";
|
|
88
|
+
const tr = document.createElement("tr");
|
|
89
|
+
tr.appendChild(document.createElement("th"));
|
|
90
|
+
for (const env of g.environments) {
|
|
91
|
+
const th = document.createElement("th");
|
|
92
|
+
th.textContent = env;
|
|
93
|
+
tr.appendChild(th);
|
|
94
|
+
}
|
|
95
|
+
table.appendChild(tr);
|
|
96
|
+
for (const row of g.rows) {
|
|
97
|
+
const r = document.createElement("tr");
|
|
98
|
+
const name = document.createElement("th");
|
|
99
|
+
name.className = "rel-component";
|
|
100
|
+
name.textContent = row.component;
|
|
101
|
+
r.appendChild(name);
|
|
102
|
+
for (const c of row.cells) {
|
|
103
|
+
const td = document.createElement("td");
|
|
104
|
+
td.className = c.empty ? "rel-cell rel-empty" : c.differs ? "rel-cell rel-differs" : "rel-cell";
|
|
105
|
+
td.title = c.title;
|
|
106
|
+
const t = document.createElement("div");
|
|
107
|
+
t.textContent = c.text;
|
|
108
|
+
td.appendChild(t);
|
|
109
|
+
if (c.differs) {
|
|
110
|
+
const d = document.createElement("div");
|
|
111
|
+
d.className = "rel-mark";
|
|
112
|
+
d.textContent = c.differs.text;
|
|
113
|
+
td.appendChild(d);
|
|
114
|
+
}
|
|
115
|
+
if (c.runUrl && ui && ui.link) td.appendChild(ui.link("run", c.runUrl));
|
|
116
|
+
r.appendChild(td);
|
|
117
|
+
}
|
|
118
|
+
table.appendChild(r);
|
|
119
|
+
}
|
|
120
|
+
host.appendChild(table);
|
|
121
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// #524: a SQL Yodeler repo's lane, from /api/sql-yodeler/lifecycle. One row
|
|
2
|
+
// per environment in the order of applies (yodel-waves.json), each with its
|
|
3
|
+
// wave's gate and its migrations op's gate: the state, the plan digest, who
|
|
4
|
+
// approved and how, and the chant/lifecycle commit it was read from. A
|
|
5
|
+
// waiting gate offers `npx yodel approve <env> --plan <digest>` to copy;
|
|
6
|
+
// nothing here approves.
|
|
7
|
+
|
|
8
|
+
import { ago, clock } from "./terragucci.js";
|
|
9
|
+
import { holderWords, laneLegend, modeWords, sourceWords } from "./terragucci-lifecycle.js";
|
|
10
|
+
|
|
11
|
+
const short = (s) => (s ? String(s).replace(/^(jcs1-)?sha256:/, "").slice(0, 12) : "");
|
|
12
|
+
|
|
13
|
+
const STATE_WORDS = {
|
|
14
|
+
waiting: "waits for an approval",
|
|
15
|
+
expired: "asked for an approval that expired",
|
|
16
|
+
approved: "approved, not applied yet",
|
|
17
|
+
applied: "applied",
|
|
18
|
+
failed: "apply failed",
|
|
19
|
+
refused: "refused: an approval stands for another plan",
|
|
20
|
+
"refused-by-policy": "refused by policy, no override counts",
|
|
21
|
+
held: "held on another gate",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** The line over the lane: what was read, how, when, and where the order came from. */
|
|
25
|
+
export function yodelLegend(answer, now = new Date()) {
|
|
26
|
+
const lg = laneLegend(answer, now);
|
|
27
|
+
if (!answer || answer.error) return lg;
|
|
28
|
+
const order = answer.order && answer.order.source === "yodel-waves.json" ? "environments in yodel-waves.json's order" : answer.order && answer.order.source === "ops" ? "environments from ops/migrate-<env>.op.ts (no yodel-waves.json)" : "no environments declared";
|
|
29
|
+
const member = answer.member && answer.member.members.length ? ` · workspace member ${answer.member.members.join(" / ")}, read under ${answer.member.prefix}` : "";
|
|
30
|
+
return { ...lg, text: `${lg.text} · ${order}${member}`, title: [lg.title, answer.order && answer.order.error ? `yodel-waves.json: ${answer.order.error}` : "", answer.member && answer.member.error ? answer.member.error : ""].filter(Boolean).join("\n") };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** One gate as a card: title, facts, source, and the line to copy when it waits. Pure. */
|
|
34
|
+
export function yodelGateCard(env, g, now = new Date()) {
|
|
35
|
+
const what = g.kind === "wave" ? `wave ${g.wave} (${g.gate})` : `${g.op} (${g.gate})`;
|
|
36
|
+
const facts = [];
|
|
37
|
+
if (g.pending) facts.push(`asked ${clock(g.pending.timestamp, now)}, ${ago(g.pending.timestamp, now)}`);
|
|
38
|
+
if (g.approval) {
|
|
39
|
+
const how = [g.approval.signed ? "signed" : "unsigned", g.approval.via ? `via ${g.approval.via}` : "", g.approval.pr !== undefined ? `PR #${g.approval.pr}` : "", g.approval.relayedBy ? `relayed by ${g.approval.relayedBy}` : ""].filter(Boolean).join(", ");
|
|
40
|
+
facts.push(`approved by ${g.approval.by} (${how}) ${clock(g.approval.at, now)}, ${ago(g.approval.at, now)}`);
|
|
41
|
+
}
|
|
42
|
+
if (g.applied) facts.push(`applied ${clock(g.applied.at, now)}, ${ago(g.applied.at, now)}${g.applied.result ? "" : " (outcome not recorded)"}`);
|
|
43
|
+
if (g.digest) facts.push(`plan ${short(g.digest)}`);
|
|
44
|
+
for (const u of g.uncounted || []) facts.push(`${u.approval.by}'s approval does not count: ${u.reason}`);
|
|
45
|
+
if (g.heldOn) facts.push(`held on ${g.heldOn.op}/${g.heldOn.gate}${holderWords(g.heldOn) ? `, ${holderWords(g.heldOn)}` : ""}`);
|
|
46
|
+
if (modeWords(g)) facts.push(modeWords(g));
|
|
47
|
+
if ((g.headPlans || []).length) {
|
|
48
|
+
const h = g.headPlans[0];
|
|
49
|
+
facts.push(`pull request head ${h.head.slice(0, 12)} recorded this plan ${clock(h.timestamp, now)}, ${ago(h.timestamp, now)} (${h.path}): a writer's approving review of that head, merged, approves it${g.headPlans.length > 1 ? `; ${g.headPlans.length - 1} more head${g.headPlans.length > 2 ? "s" : ""} recorded it too` : ""}`);
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
env,
|
|
53
|
+
kind: g.kind,
|
|
54
|
+
state: g.state,
|
|
55
|
+
tone: g.state === "waiting" || g.state === "held" ? "wave" : g.state === "failed" || g.state === "refused" || g.state === "refused-by-policy" ? "failed" : g.state === "expired" ? "none" : "ok",
|
|
56
|
+
title: `${env}: ${what} ${STATE_WORDS[g.state] || g.state}`,
|
|
57
|
+
facts: facts.join(" · "),
|
|
58
|
+
source: sourceWords(g.source, now),
|
|
59
|
+
...(g.command ? { command: g.command } : {}),
|
|
60
|
+
...(g.pending && g.pending.url ? { job: g.pending.url } : {}),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Every environment's rows, in the order of applies. An environment with no gate on the ledger says so. Pure. */
|
|
65
|
+
export function yodelRows(answer, now = new Date()) {
|
|
66
|
+
if (!answer || answer.error || !answer.found) return [];
|
|
67
|
+
const rows = [];
|
|
68
|
+
for (const e of answer.environments || []) {
|
|
69
|
+
const head = e.wave ? `${e.env}: wave ${e.wave.k}${e.wave.gate ? `, gate ${e.wave.gate}` : ""}${e.wave.approval ? `, approval ${e.wave.approval}` : ""}${e.wave.targets.length > 1 || e.wave.targets[0] !== e.env ? `, ${e.wave.targets.join(", ")}` : ""}` : `${e.env}: in no wave`;
|
|
70
|
+
rows.push({ env: e.env, head, cards: e.gates.map((g) => yodelGateCard(e.env, g, now)) });
|
|
71
|
+
}
|
|
72
|
+
if ((answer.unplaced || []).length) rows.push({ env: "", head: "waves yodel-waves.json does not name", cards: answer.unplaced.map((g) => yodelGateCard(`wave ${g.wave}`, g, now)) });
|
|
73
|
+
return rows;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const APPROVE_NOTE = "An approval is a git record bound to this plan digest: run the line at your shell. behold does not approve.";
|
|
77
|
+
|
|
78
|
+
/** The lane as DOM. `ui` lends app.js's `button`, `copy` and `link`; a Copy button is the only control. */
|
|
79
|
+
export function renderYodelLane(host, answer, now, ui) {
|
|
80
|
+
const lg = yodelLegend(answer, now);
|
|
81
|
+
const head = document.createElement("div");
|
|
82
|
+
head.className = lg.tone === "refusal" ? "tg-refusal" : "tg-legend";
|
|
83
|
+
head.textContent = lg.text;
|
|
84
|
+
head.title = lg.title || "";
|
|
85
|
+
host.appendChild(head);
|
|
86
|
+
for (const r of yodelRows(answer, now)) {
|
|
87
|
+
const h = document.createElement("h3");
|
|
88
|
+
h.textContent = r.head;
|
|
89
|
+
host.appendChild(h);
|
|
90
|
+
if (!r.cards.length) {
|
|
91
|
+
const none = document.createElement("div");
|
|
92
|
+
none.className = "tg-legend";
|
|
93
|
+
none.textContent = "no gate on chant/lifecycle yet";
|
|
94
|
+
host.appendChild(none);
|
|
95
|
+
}
|
|
96
|
+
for (const c of r.cards) {
|
|
97
|
+
const box = document.createElement("div");
|
|
98
|
+
box.className = `ws-gate tg-lane-gate tg-${c.tone}`;
|
|
99
|
+
const title = document.createElement("div");
|
|
100
|
+
title.className = "ws-gate-title";
|
|
101
|
+
title.textContent = c.title;
|
|
102
|
+
const facts = document.createElement("div");
|
|
103
|
+
facts.className = "ws-gate-facts";
|
|
104
|
+
facts.textContent = c.facts;
|
|
105
|
+
const src = document.createElement("div");
|
|
106
|
+
src.className = "ws-gate-note";
|
|
107
|
+
src.textContent = `from ${c.source}`;
|
|
108
|
+
box.append(title, facts, src);
|
|
109
|
+
if (c.command) {
|
|
110
|
+
const cmd = document.createElement("code");
|
|
111
|
+
cmd.className = "tg-command";
|
|
112
|
+
cmd.textContent = c.command;
|
|
113
|
+
const copy = ui.button("Copy", "", () => ui.copy(c.command, copy));
|
|
114
|
+
copy.title = "Copy the line. Run it at your shell: the approval is yours.";
|
|
115
|
+
const note = document.createElement("div");
|
|
116
|
+
note.className = "ws-gate-note";
|
|
117
|
+
note.textContent = APPROVE_NOTE;
|
|
118
|
+
box.append(cmd, copy, note);
|
|
119
|
+
}
|
|
120
|
+
if (c.job) {
|
|
121
|
+
const links = document.createElement("div");
|
|
122
|
+
links.className = "ws-gate-note";
|
|
123
|
+
links.appendChild(ui.link("job", c.job));
|
|
124
|
+
box.appendChild(links);
|
|
125
|
+
}
|
|
126
|
+
host.appendChild(box);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|