@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
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); }
|
|
@@ -158,9 +173,51 @@
|
|
|
158
173
|
#tab-terragucci .tg-cells > span::before { content: attr(data-stage) ": "; color: var(--muted); }
|
|
159
174
|
#tab-terragucci .tg-drift, #tab-terragucci .tg-failed { color: var(--degraded); }
|
|
160
175
|
#tab-terragucci .tg-wave { color: var(--pending); }
|
|
176
|
+
/* #509: a control repo's projects, over the empty graph area. Dated, never live. */
|
|
177
|
+
#tg-estate { grid-row: 1; grid-column: 1; z-index: 2; overflow: auto; background: var(--bg); padding: 10px 14px; display: flex; flex-direction: column; gap: 10px; font-size: var(--t-caption); }
|
|
178
|
+
#tg-estate .tg-estate-head { display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: baseline; }
|
|
179
|
+
#tg-estate .tg-legend, #tg-estate .tg-none, #tg-estate .ws-gate-facts, #tg-estate .ws-gate-note { color: var(--muted); overflow-wrap: anywhere; }
|
|
180
|
+
#tg-estate .tg-refusal, #tg-estate .tg-bad { color: var(--degraded); font-weight: 600; }
|
|
181
|
+
#tg-estate a { color: inherit; }
|
|
182
|
+
#tg-estate .tg-estate-graph { overflow: auto; }
|
|
183
|
+
#tg-estate svg text { fill: var(--fg); font-size: 11px; }
|
|
184
|
+
#tg-estate .tg-project-box { fill: var(--panel); stroke: var(--rule); }
|
|
185
|
+
#tg-estate .tg-project-box.tg-no-run { stroke-dasharray: 4 3; }
|
|
186
|
+
#tg-estate .tg-project-name { font-weight: 600; font-size: 12px; }
|
|
187
|
+
#tg-estate svg .tg-project-counts, #tg-estate svg .tg-project-run.tg-none, #tg-estate svg .tg-wave-label { fill: var(--muted); }
|
|
188
|
+
#tg-estate svg .tg-project-run.tg-bad { fill: var(--degraded); }
|
|
189
|
+
#tg-estate svg .tg-wave-waiting, #tg-estate svg .tg-wave-applying { fill: var(--pending); }
|
|
190
|
+
#tg-estate svg .tg-wave-failed, #tg-estate svg .tg-wave-refused { fill: var(--degraded); }
|
|
191
|
+
#tg-estate .tg-root-node rect { fill: var(--bg); stroke: var(--edge); }
|
|
192
|
+
#tg-estate .tg-root-node text { font-family: var(--font-mono); }
|
|
193
|
+
#tg-estate .tg-edge { fill: none; stroke: var(--edge); stroke-width: 1.3; }
|
|
194
|
+
#tg-estate .tg-edge-cross { stroke-dasharray: 5 4; stroke: var(--pending); }
|
|
195
|
+
#tg-estate .tg-estate-projects { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 10px; }
|
|
196
|
+
#tg-estate .tg-estate-project { border: 1px solid var(--rule); border-radius: var(--r-ctl); padding: 6px 8px; display: flex; flex-direction: column; gap: 4px; }
|
|
197
|
+
#tg-estate .tg-estate-title { font-weight: 600; overflow-wrap: anywhere; }
|
|
198
|
+
#tg-estate .ws-gate { border: 1px solid var(--pending, var(--rule)); border-radius: var(--r-ctl); padding: 4px 6px; }
|
|
199
|
+
#tg-estate .tg-estate-cmd { display: flex; gap: 6px; align-items: center; }
|
|
200
|
+
#tg-estate .tg-command { font-family: var(--font-mono); overflow-wrap: anywhere; }
|
|
161
201
|
#tab-terragucci .tg-plan { color: var(--fg); }
|
|
162
202
|
#tab-terragucci .tg-none, #tab-terragucci .tg-ok { color: var(--muted); }
|
|
163
203
|
#tab-terragucci a { color: inherit; }
|
|
204
|
+
/* #506: the audit record's lane, in the tab and in a card's inspect pane. */
|
|
205
|
+
#tab-terragucci .tg-timeline-title { font-weight: 600; border-top: 1px solid var(--rule); padding-top: 6px; }
|
|
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; }
|
|
214
|
+
.tg-timeline .tg-legend { color: var(--muted); }
|
|
215
|
+
.tg-timeline .tg-refusal { color: var(--degraded); font-weight: 600; }
|
|
216
|
+
.tg-timeline .tg-entry { overflow-wrap: anywhere; }
|
|
217
|
+
.tg-timeline .tg-when { color: var(--muted); font-family: var(--font-mono); }
|
|
218
|
+
.tg-timeline .tg-failed, .tg-timeline .tg-drift { color: var(--degraded); }
|
|
219
|
+
.tg-timeline .tg-wave { color: var(--pending); }
|
|
220
|
+
.tg-timeline a { color: inherit; }
|
|
164
221
|
/* Fit reset + a zoom hint, floating top-right of the graph pane. */
|
|
165
222
|
#zoom-toggle { position: absolute; top: 8px; right: 8px; z-index: 2; background: var(--panel);
|
|
166
223
|
color: var(--muted); border: 1px solid var(--rule); border-radius: var(--r-ctl); padding: 4px 10px;
|
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
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
// #509: a terragucci control repo, across its projects. app.js calls
|
|
2
|
+
// initTerragucciEstate once; when /api/project names a control repo, this reads
|
|
3
|
+
// /api/terragucci/estate and draws it over the (empty) graph area:
|
|
4
|
+
//
|
|
5
|
+
// - one box per project, its roots grouped by wave inside it, a solid arrow
|
|
6
|
+
// for each read within a project and a dashed one for each read between
|
|
7
|
+
// projects (an arrow points from a root to the root that reads its state,
|
|
8
|
+
// as on terragucci's estate page);
|
|
9
|
+
// - on each box, terragucci's counts and the run view it was drawn from,
|
|
10
|
+
// each with its age and source;
|
|
11
|
+
// - below, per project, its waiting waves with the approve line to copy (no
|
|
12
|
+
// button approves), its links, and how to serve its own checkout.
|
|
13
|
+
//
|
|
14
|
+
// A project with no run view is drawn as a box that says so, never dropped.
|
|
15
|
+
// Nothing here is live: every figure is dated by estate.json's `generated` or
|
|
16
|
+
// a run view's `updated`. The layout functions are pure and tested.
|
|
17
|
+
|
|
18
|
+
import { ago, clock } from "./terragucci.js";
|
|
19
|
+
|
|
20
|
+
export const COL_W = 176;
|
|
21
|
+
export const ROW_H = 26;
|
|
22
|
+
export const PAD = 12;
|
|
23
|
+
export const HEAD_H = 64;
|
|
24
|
+
export const WAVE_H = 22;
|
|
25
|
+
export const GAP = 72;
|
|
26
|
+
export const NO_RUN_W = 300;
|
|
27
|
+
|
|
28
|
+
/** The address a key opens at: this server's file route. */
|
|
29
|
+
export function keyHref(answer, key) {
|
|
30
|
+
return `${answer.files || ""}${answer.files && answer.files.includes("?") ? encodeURIComponent(key) : key}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Projects in drawing order: a project whose roots another reads comes before
|
|
35
|
+
* the reader, so a dashed edge runs left to right where it can. Ties keep
|
|
36
|
+
* estate.json's order.
|
|
37
|
+
*/
|
|
38
|
+
export function projectOrder(answer) {
|
|
39
|
+
const names = answer.projects.map((p) => p.project);
|
|
40
|
+
const before = new Map(names.map((n) => [n, new Set()]));
|
|
41
|
+
for (const e of answer.edges || []) if (e.cross && before.has(e.to.project) && before.has(e.from.project)) before.get(e.to.project).add(e.from.project);
|
|
42
|
+
const out = [];
|
|
43
|
+
const seen = new Set();
|
|
44
|
+
const visit = (n, stack) => {
|
|
45
|
+
if (seen.has(n) || stack.has(n)) return;
|
|
46
|
+
stack.add(n);
|
|
47
|
+
for (const m of names) if (before.get(n).has(m)) visit(m, stack);
|
|
48
|
+
stack.delete(n);
|
|
49
|
+
seen.add(n);
|
|
50
|
+
out.push(n);
|
|
51
|
+
};
|
|
52
|
+
for (const n of names) visit(n, new Set());
|
|
53
|
+
return out.map((n) => answer.projects.find((p) => p.project === n));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The waves a project's box has columns for: its run view's waves and any wave a root names, ascending. */
|
|
57
|
+
export function wavesOf(run) {
|
|
58
|
+
const n = new Set([...(run.waves || []).map((w) => w.number), ...(run.roots || []).map((r) => r.wave)]);
|
|
59
|
+
return [...n].sort((a, b) => a - b);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Where everything goes: each project's box, each wave column in it, each
|
|
64
|
+
* root's rect, and each edge's path. Coordinates are SVG user units.
|
|
65
|
+
*/
|
|
66
|
+
export function estateLayout(answer) {
|
|
67
|
+
const boxes = [];
|
|
68
|
+
const at = new Map(); // "project\u0000root" -> rect
|
|
69
|
+
let x = PAD;
|
|
70
|
+
let height = 0;
|
|
71
|
+
for (const p of projectOrder(answer)) {
|
|
72
|
+
const run = p.run;
|
|
73
|
+
const waves = run ? wavesOf(run) : [];
|
|
74
|
+
const rows = run ? Math.max(1, ...waves.map((n) => run.roots.filter((r) => r.wave === n).length)) : 0;
|
|
75
|
+
const w = run && waves.length ? waves.length * COL_W + PAD * 2 : NO_RUN_W;
|
|
76
|
+
const h = run ? HEAD_H + WAVE_H + rows * ROW_H + PAD : HEAD_H + PAD;
|
|
77
|
+
const box = { project: p.project, x, y: PAD, w, h, waves: [] };
|
|
78
|
+
if (run) {
|
|
79
|
+
waves.forEach((n, i) => {
|
|
80
|
+
const wx = x + PAD + i * COL_W;
|
|
81
|
+
const meta = run.waves.find((v) => v.number === n);
|
|
82
|
+
const roots = run.roots
|
|
83
|
+
.filter((r) => r.wave === n)
|
|
84
|
+
.sort((a, b) => (a.root < b.root ? -1 : 1))
|
|
85
|
+
.map((r, j) => {
|
|
86
|
+
const rect = { root: r.root, x: wx, y: PAD + HEAD_H + WAVE_H + j * ROW_H, w: COL_W - 16, h: ROW_H - 6 };
|
|
87
|
+
at.set(`${p.project}\u0000${r.root}`, rect);
|
|
88
|
+
return rect;
|
|
89
|
+
});
|
|
90
|
+
box.waves.push({ number: n, x: wx, y: PAD + HEAD_H, state: meta ? meta.state : "not-started", roots });
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
boxes.push(box);
|
|
94
|
+
x += w + GAP;
|
|
95
|
+
height = Math.max(height, box.y + h);
|
|
96
|
+
}
|
|
97
|
+
const edges = [];
|
|
98
|
+
for (const e of answer.edges || []) {
|
|
99
|
+
const a = at.get(`${e.from.project}\u0000${e.from.root}`);
|
|
100
|
+
const b = at.get(`${e.to.project}\u0000${e.to.root}`);
|
|
101
|
+
if (!a || !b) continue;
|
|
102
|
+
const x1 = a.x + a.w;
|
|
103
|
+
const y1 = a.y + a.h / 2;
|
|
104
|
+
const x2 = b.x;
|
|
105
|
+
const y2 = b.y + b.h / 2;
|
|
106
|
+
const bend = Math.max(36, Math.abs(x2 - x1) / 3);
|
|
107
|
+
edges.push({ from: e.from, to: e.to, cross: !!e.cross, d: `M${x1},${y1} C${x1 + bend},${y1} ${x2 - bend},${y2} ${x2},${y2}` });
|
|
108
|
+
}
|
|
109
|
+
return { width: Math.max(x - GAP + PAD, NO_RUN_W + PAD * 2), height: height + PAD, boxes, edges };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
113
|
+
|
|
114
|
+
/** terragucci's counts for a project, with their age and source. */
|
|
115
|
+
export function countsLine(p, now = new Date()) {
|
|
116
|
+
if (!p.counts) return { text: p.status === "not-in-estate" ? "not in estate.json" : "no counts", title: p.missing || "" };
|
|
117
|
+
const c = p.counts;
|
|
118
|
+
return {
|
|
119
|
+
text: `${plural(c.drifted, "root")} drifted · ${c.failed} failed · ${plural(c.waiting, "wave")} waiting · ${ago(c.dated.at, now)}`,
|
|
120
|
+
title: `from ${c.dated.key}, generated ${clock(c.dated.at, now)} (${ago(c.dated.at, now)}); not live`,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Which run view the box was drawn from, or why it has none. */
|
|
125
|
+
export function runLine(p, now = new Date()) {
|
|
126
|
+
if (p.run) {
|
|
127
|
+
return {
|
|
128
|
+
text: `run view ${p.run.commit.slice(0, 12)} · updated ${ago(p.run.updated, now)}`,
|
|
129
|
+
title: `${p.run.key}, the run view of ${p.run.commit}, named by estate.json's ${p.run.from === "run_view" ? "run_view" : "newest apply"}; a wave last wrote it ${clock(p.run.updated, now)}`,
|
|
130
|
+
tone: "ok",
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
if (p.refused) return { text: "run view refused", title: `${p.refused.error}\n${p.refused.remedy}`, tone: "bad" };
|
|
134
|
+
return { text: "no run view", title: p.missing || "no run view", tone: "none" };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** A waiting wave as a line and its approve command. */
|
|
138
|
+
export function waitingRows(p, now = new Date()) {
|
|
139
|
+
return (p.waiting || []).map((w) => ({
|
|
140
|
+
text: `wave ${w.wave} waiting since ${clock(w.since, now)}, ${ago(w.since, now)} · commit ${w.commit.slice(0, 12)}${w.digest ? ` · plan ${String(w.digest).replace(/^jcs1-sha256:/, "").slice(0, 12)}` : ""}`,
|
|
141
|
+
title: `from ${w.dated.key}, generated ${clock(w.dated.at, now)}`,
|
|
142
|
+
command: w.command,
|
|
143
|
+
digestKnown: !!w.digest,
|
|
144
|
+
report: w.report,
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The line over the picture: whose estate, how old, how checked. */
|
|
149
|
+
export function estateLegend(answer, now = new Date()) {
|
|
150
|
+
const cross = (answer.edges || []).filter((e) => e.cross).length;
|
|
151
|
+
const drawn = answer.projects.filter((p) => p.run).length;
|
|
152
|
+
const checked = (v) => (v.by === "schema" ? `terragucci ${v.terragucci}'s schema` : "a structural check");
|
|
153
|
+
return {
|
|
154
|
+
text: `terragucci estate of ${plural(answer.projects.length, "project")}, not live · estate.json generated ${ago(answer.estate.generated, now)} · ${drawn} drawn from a run view · ${plural(cross, "read")} between projects`,
|
|
155
|
+
title: [`read from ${answer.source} (${answer.gets} GETs)`, `estate.json checked with ${checked(answer.validation.estate)}`, `run views checked with ${checked(answer.validation.run)}`, `config: ${answer.config}`].join("\n"),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// The DOM. Text only, never innerHTML: every string here came from a bucket.
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
const SVG = "http://www.w3.org/2000/svg";
|
|
164
|
+
|
|
165
|
+
function el(doc, tag, cls, text) {
|
|
166
|
+
const e = doc.createElement(tag);
|
|
167
|
+
if (cls) e.className = cls;
|
|
168
|
+
if (text !== undefined) e.textContent = text;
|
|
169
|
+
return e;
|
|
170
|
+
}
|
|
171
|
+
function svgEl(doc, tag, attrs = {}, text) {
|
|
172
|
+
const e = doc.createElementNS(SVG, tag);
|
|
173
|
+
for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, String(v));
|
|
174
|
+
if (text !== undefined) e.textContent = text;
|
|
175
|
+
return e;
|
|
176
|
+
}
|
|
177
|
+
function link(doc, text, href) {
|
|
178
|
+
const a = el(doc, "a", "", text);
|
|
179
|
+
a.href = href;
|
|
180
|
+
a.target = "_blank";
|
|
181
|
+
a.rel = "noopener";
|
|
182
|
+
return a;
|
|
183
|
+
}
|
|
184
|
+
function titled(doc, node, title) {
|
|
185
|
+
if (title) node.appendChild(svgEl(doc, "title", {}, title));
|
|
186
|
+
return node;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The picture: boxes, wave columns, roots and edges. */
|
|
190
|
+
export function renderEstateSvg(answer, now = new Date(), doc = document) {
|
|
191
|
+
const L = estateLayout(answer);
|
|
192
|
+
const svg = svgEl(doc, "svg", { width: L.width, height: L.height, viewBox: `0 0 ${L.width} ${L.height}`, class: "tg-estate-svg", role: "img", "aria-label": "terragucci estate: projects, their roots by wave, and the reads between them" });
|
|
193
|
+
const defs = svgEl(doc, "defs");
|
|
194
|
+
const marker = svgEl(doc, "marker", { id: "tg-estate-arrow", viewBox: "0 0 10 10", refX: 9, refY: 5, markerWidth: 7, markerHeight: 7, orient: "auto-start-reverse" });
|
|
195
|
+
marker.appendChild(svgEl(doc, "path", { d: "M0,0 L10,5 L0,10 z", fill: "var(--edge)" }));
|
|
196
|
+
defs.appendChild(marker);
|
|
197
|
+
svg.appendChild(defs);
|
|
198
|
+
const byName = new Map(answer.projects.map((p) => [p.project, p]));
|
|
199
|
+
for (const b of L.boxes) {
|
|
200
|
+
const p = byName.get(b.project);
|
|
201
|
+
const g = svgEl(doc, "g", { class: "tg-project", "data-project": b.project });
|
|
202
|
+
g.appendChild(svgEl(doc, "rect", { x: b.x, y: b.y, width: b.w, height: b.h, rx: 6, class: `tg-project-box${p.run ? "" : " tg-no-run"}` }));
|
|
203
|
+
const name = svgEl(doc, "text", { x: b.x + PAD, y: b.y + 20, class: "tg-project-name" }, b.project);
|
|
204
|
+
if (p.index) {
|
|
205
|
+
const a = svgEl(doc, "a", { href: keyHref(answer, p.index), target: "_blank" });
|
|
206
|
+
a.appendChild(name);
|
|
207
|
+
g.appendChild(a);
|
|
208
|
+
} else g.appendChild(name);
|
|
209
|
+
const counts = countsLine(p, now);
|
|
210
|
+
g.appendChild(titled(doc, svgEl(doc, "text", { x: b.x + PAD, y: b.y + 38, class: "tg-project-counts" }, counts.text), counts.title));
|
|
211
|
+
const run = runLine(p, now);
|
|
212
|
+
g.appendChild(titled(doc, svgEl(doc, "text", { x: b.x + PAD, y: b.y + 55, class: `tg-project-run tg-${run.tone}` }, run.text), run.title));
|
|
213
|
+
for (const w of b.waves) {
|
|
214
|
+
g.appendChild(svgEl(doc, "text", { x: w.x, y: w.y + 14, class: `tg-wave-label tg-wave-${w.state}`, "data-wave": w.number }, `wave ${w.number} · ${w.state === "not-started" ? "not started" : w.state}`));
|
|
215
|
+
for (const r of w.roots) {
|
|
216
|
+
const node = svgEl(doc, "g", { class: "tg-root-node", "data-root": r.root });
|
|
217
|
+
node.appendChild(svgEl(doc, "rect", { x: r.x, y: r.y, width: r.w, height: r.h, rx: 4 }));
|
|
218
|
+
node.appendChild(titled(doc, svgEl(doc, "text", { x: r.x + 6, y: r.y + r.h / 2 + 4 }, r.root.length > 24 ? `…${r.root.slice(-23)}` : r.root), `${b.project}: ${r.root}, wave ${w.number}`));
|
|
219
|
+
g.appendChild(node);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
svg.appendChild(g);
|
|
223
|
+
}
|
|
224
|
+
for (const e of L.edges) {
|
|
225
|
+
const path = svgEl(doc, "path", { d: e.d, class: `tg-edge${e.cross ? " tg-edge-cross" : ""}`, "marker-end": "url(#tg-estate-arrow)", "data-from": `${e.from.project} ${e.from.root}`, "data-to": `${e.to.project} ${e.to.root}` });
|
|
226
|
+
svg.appendChild(titled(doc, path, `${e.to.project}: ${e.to.root} reads ${e.from.project}: ${e.from.root}`));
|
|
227
|
+
}
|
|
228
|
+
return svg;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Draw the whole view into `host`. `copy(text, buttonEl)` puts a line on the clipboard. */
|
|
232
|
+
export function renderEstate(host, answer, { copy = () => {}, refresh, now = new Date(), doc = document } = {}) {
|
|
233
|
+
host.replaceChildren();
|
|
234
|
+
if (!answer || answer.error) {
|
|
235
|
+
host.append(el(doc, "div", "tg-refusal", answer ? answer.error : "the estate was not read"), el(doc, "div", "tg-legend", (answer && answer.remedy) || ""));
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const lg = estateLegend(answer, now);
|
|
239
|
+
const head = el(doc, "div", "tg-legend", lg.text);
|
|
240
|
+
head.title = lg.title;
|
|
241
|
+
const top = el(doc, "div", "tg-estate-head");
|
|
242
|
+
top.append(head, link(doc, "terragucci's estate page", keyHref(answer, answer.estate.html)));
|
|
243
|
+
if (refresh) {
|
|
244
|
+
const again = el(doc, "button", "", "Read again");
|
|
245
|
+
again.addEventListener("click", refresh);
|
|
246
|
+
top.appendChild(again);
|
|
247
|
+
}
|
|
248
|
+
host.appendChild(top);
|
|
249
|
+
const wrap = el(doc, "div", "tg-estate-graph");
|
|
250
|
+
wrap.appendChild(renderEstateSvg(answer, now, doc));
|
|
251
|
+
host.appendChild(wrap);
|
|
252
|
+
const list = el(doc, "div", "tg-estate-projects");
|
|
253
|
+
const copyRow = (text) => {
|
|
254
|
+
const row = el(doc, "div", "tg-estate-cmd");
|
|
255
|
+
const code = el(doc, "code", "tg-command", text);
|
|
256
|
+
const b = el(doc, "button", "", "Copy");
|
|
257
|
+
b.title = "Copy the line and run it at your shell. behold runs nothing here.";
|
|
258
|
+
b.addEventListener("click", () => copy(text, b));
|
|
259
|
+
row.append(code, b);
|
|
260
|
+
return row;
|
|
261
|
+
};
|
|
262
|
+
for (const p of projectOrder(answer)) {
|
|
263
|
+
const card = el(doc, "section", "tg-estate-project");
|
|
264
|
+
card.dataset.project = p.project;
|
|
265
|
+
const title = el(doc, "div", "tg-estate-title", p.project);
|
|
266
|
+
card.appendChild(title);
|
|
267
|
+
const links = el(doc, "div", "tg-legend");
|
|
268
|
+
const parts = [];
|
|
269
|
+
if (p.index) parts.push(link(doc, "its index", keyHref(answer, p.index)));
|
|
270
|
+
parts.push(link(doc, "estate.html", keyHref(answer, answer.estate.html)));
|
|
271
|
+
if (p.run) parts.push(link(doc, "run view", keyHref(answer, p.run.page)));
|
|
272
|
+
parts.forEach((a, i) => links.append(...(i ? [" · ", a] : [a])));
|
|
273
|
+
card.appendChild(links);
|
|
274
|
+
const counts = countsLine(p, now);
|
|
275
|
+
const c = el(doc, "div", "tg-estate-counts", counts.text);
|
|
276
|
+
c.title = counts.title;
|
|
277
|
+
card.appendChild(c);
|
|
278
|
+
const run = runLine(p, now);
|
|
279
|
+
const r = el(doc, "div", `tg-estate-run tg-${run.tone}`, run.tone === "ok" ? run.text : `${run.text}: ${p.refused ? p.refused.error : p.missing || ""}`);
|
|
280
|
+
r.title = run.title;
|
|
281
|
+
card.appendChild(r);
|
|
282
|
+
for (const w of waitingRows(p, now)) {
|
|
283
|
+
const box = el(doc, "div", "ws-gate tg-wave");
|
|
284
|
+
const t = el(doc, "div", "ws-gate-facts", w.text);
|
|
285
|
+
t.title = w.title;
|
|
286
|
+
box.append(t, copyRow(w.command));
|
|
287
|
+
const note = el(doc, "div", "ws-gate-note", w.digestKnown ? "Run it in the project's own checkout: the approval is a signed git record bound to this plan digest. behold does not approve." : "Run it in the project's own checkout, with the plan digest from the wave's report. behold does not approve.");
|
|
288
|
+
box.appendChild(note);
|
|
289
|
+
if (w.report) {
|
|
290
|
+
const l = el(doc, "div", "ws-gate-note");
|
|
291
|
+
l.appendChild(link(doc, "report", keyHref(answer, w.report)));
|
|
292
|
+
box.appendChild(l);
|
|
293
|
+
}
|
|
294
|
+
card.appendChild(box);
|
|
295
|
+
}
|
|
296
|
+
if (p.checkout) {
|
|
297
|
+
card.appendChild(el(doc, "div", "tg-legend", `checked out at ${p.checkout.path}; serve it to see its roots:`));
|
|
298
|
+
card.appendChild(copyRow(p.checkout.command));
|
|
299
|
+
}
|
|
300
|
+
list.appendChild(card);
|
|
301
|
+
}
|
|
302
|
+
host.appendChild(list);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Once, at boot: when /api/project names a terragucci control repo, read the
|
|
307
|
+
* estate and draw it in a layer over the graph area. `fetchUrl(url)` is
|
|
308
|
+
* app.js's apiFetch, `copy` its copyToClipboard.
|
|
309
|
+
*/
|
|
310
|
+
export async function initTerragucciEstate({ fetchUrl, copy, doc = document } = {}) {
|
|
311
|
+
const project = await fetchUrl("/api/project").then((r) => r.json()).catch(() => ({}));
|
|
312
|
+
if (!project.terragucciControl || !project.terragucciReports) return;
|
|
313
|
+
const graph = doc.getElementById("graph");
|
|
314
|
+
if (!graph || !graph.parentElement) return;
|
|
315
|
+
let host = doc.getElementById("tg-estate");
|
|
316
|
+
if (!host) {
|
|
317
|
+
host = el(doc, "div");
|
|
318
|
+
host.id = "tg-estate";
|
|
319
|
+
graph.parentElement.insertBefore(host, graph.nextSibling);
|
|
320
|
+
}
|
|
321
|
+
const load = async (fresh) => {
|
|
322
|
+
const answer = await fetchUrl(`/api/terragucci/estate${fresh ? "?fresh=1" : ""}`)
|
|
323
|
+
.then((r) => r.json())
|
|
324
|
+
.catch((e) => ({ error: String(e), code: "terragucci-report", remedy: "" }));
|
|
325
|
+
renderEstate(host, answer, { copy, refresh: () => load(true), doc });
|
|
326
|
+
};
|
|
327
|
+
await load(false);
|
|
328
|
+
}
|