@intentius/behold 0.29.4 → 0.29.6
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/dist/cli.js +6 -1
- package/package.json +1 -1
- package/web/app.js +4 -2
- package/web/embed.js +20 -0
- package/web/embed.test.js +10 -1
- package/web/kiosk-runs.js +129 -17
- package/web/kiosk-runs.test.js +67 -1
- package/web/kiosk.html +18 -12
- package/web/kiosk.js +60 -28
- package/web/kiosk.test.js +80 -1
package/dist/cli.js
CHANGED
|
@@ -24215,6 +24215,7 @@ var UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
|
|
|
24215
24215
|
var isRecord17 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
24216
24216
|
var str13 = (v) => typeof v === "string";
|
|
24217
24217
|
var int = (v) => typeof v === "number" && Number.isInteger(v);
|
|
24218
|
+
var positive = (v) => int(v) && v > 0 ? v : null;
|
|
24218
24219
|
function demoProblem(doc) {
|
|
24219
24220
|
if (!isRecord17(doc)) return "it is not an object";
|
|
24220
24221
|
if (!int(doc.cycle)) return "cycle is not an integer";
|
|
@@ -24269,7 +24270,11 @@ async function readDemo(source2, now = /* @__PURE__ */ new Date()) {
|
|
|
24269
24270
|
next: d.next ? { action: d.next.action, at: d.next.at } : null,
|
|
24270
24271
|
pull: d.pull,
|
|
24271
24272
|
commit: d.commit,
|
|
24272
|
-
about: d.about ? { changes: d.about.changes, watch: d.about.watch, shows: d.about.shows } : null
|
|
24273
|
+
about: d.about ? { changes: d.about.changes, watch: d.about.watch, shows: d.about.shows } : null,
|
|
24274
|
+
intro: str13(d.intro) && d.intro.trim() ? d.intro.trim() : null,
|
|
24275
|
+
name: str13(d.name) && d.name.trim() ? d.name.trim() : null,
|
|
24276
|
+
scenarios: positive(d.scenarios),
|
|
24277
|
+
index: positive(d.index)
|
|
24273
24278
|
}
|
|
24274
24279
|
};
|
|
24275
24280
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/behold",
|
|
3
|
-
"version": "0.29.
|
|
3
|
+
"version": "0.29.6",
|
|
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.",
|
package/web/app.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// renderPanelView never rewrites, so the select mounts once and survives re-renders).
|
|
11
11
|
import { createRefreshQueue } from "./refresh-queue.js";
|
|
12
12
|
import { readViewQuery, writeViewQuery, settleView, settlePlace, refusalLine } from "./view-url.js";
|
|
13
|
-
import { readEmbed, selectMessage, viewFromMessage } from "./embed.js";
|
|
13
|
+
import { quietCardLines, 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
16
|
import { laneElement as tgLane, rootOfCard as tgRootOfCard, historyElement as tgHistoryLane } from "./terragucci-timeline.js";
|
|
@@ -3502,7 +3502,7 @@ function drawTgReads() {
|
|
|
3502
3502
|
}
|
|
3503
3503
|
for (const t of svg.querySelectorAll("[data-tg-hid]")) {
|
|
3504
3504
|
t.removeAttribute("data-tg-hid");
|
|
3505
|
-
t.removeAttribute("visibility");
|
|
3505
|
+
if (!t.hasAttribute("data-kiosk-quiet")) t.removeAttribute("visibility");
|
|
3506
3506
|
}
|
|
3507
3507
|
for (const g of svg.querySelectorAll("[data-tg-dim]")) {
|
|
3508
3508
|
g.removeAttribute("data-tg-dim");
|
|
@@ -5329,6 +5329,8 @@ function render(ir, svg, m) {
|
|
|
5329
5329
|
svgEl.removeAttribute("width");
|
|
5330
5330
|
svgEl.removeAttribute("height");
|
|
5331
5331
|
svgEl.setAttribute("preserveAspectRatio", "xMidYMid meet");
|
|
5332
|
+
// behold#571: the kiosk's frame drops the cards' kind and meta lines.
|
|
5333
|
+
if (embedOpts.bare) quietCardLines(svgEl);
|
|
5332
5334
|
setupGraphViewBox(svgEl);
|
|
5333
5335
|
wireEdgeHighlight(svgEl, ir);
|
|
5334
5336
|
// Mark each wave lane as a GitLab CI stage when the CI projection is loaded
|
package/web/embed.js
CHANGED
|
@@ -79,3 +79,23 @@ export function viewFromMessage(event, host) {
|
|
|
79
79
|
}
|
|
80
80
|
return want;
|
|
81
81
|
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* behold#571: a card's line the kiosk's frame leaves out, for a viewer new to
|
|
85
|
+
* Terraform: the kind line ("Component · chant", "terraform_data ·
|
|
86
|
+
* terraform"), and the `archetype:`, `wave:` and `root:` meta lines (the wave
|
|
87
|
+
* or the root is the box the card sits in). behold's own page keeps them.
|
|
88
|
+
*/
|
|
89
|
+
export function kioskQuietLine(text) {
|
|
90
|
+
const t = typeof text === "string" ? text.trim() : "";
|
|
91
|
+
return /^Component\b/.test(t) || /^[\w.-]+ · [\w.-]+$/.test(t) || /^archetype:\s*\S/.test(t) || /^wave:\s*\d+$/.test(t) || /^root:\s*\S+$/.test(t);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Hide the kioskQuietLine lines of every card in a drawn graph; marks them so a redraw of the marks leaves them hidden. */
|
|
95
|
+
export function quietCardLines(svg) {
|
|
96
|
+
for (const t of svg.querySelectorAll("[data-node-id] text")) {
|
|
97
|
+
if (!kioskQuietLine(t.textContent)) continue;
|
|
98
|
+
t.setAttribute("visibility", "hidden");
|
|
99
|
+
t.setAttribute("data-kiosk-quiet", "1");
|
|
100
|
+
}
|
|
101
|
+
}
|
package/web/embed.test.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { readEmbed, selectMessage, viewFromMessage, THEME_ALIASES } from "./embed.js";
|
|
2
|
+
import { kioskQuietLine, readEmbed, selectMessage, viewFromMessage, THEME_ALIASES } from "./embed.js";
|
|
3
3
|
|
|
4
4
|
describe("readEmbed (#476)", () => {
|
|
5
5
|
it("reads embed, an exact host origin and a theme", () => {
|
|
@@ -63,3 +63,12 @@ describe("viewFromMessage focus (#569)", () => {
|
|
|
63
63
|
expect(viewFromMessage({ origin: host, data: { type: "behold:view", focus: "" } }, host)).toEqual({ focus: null });
|
|
64
64
|
});
|
|
65
65
|
});
|
|
66
|
+
|
|
67
|
+
describe("kioskQuietLine (behold#571)", () => {
|
|
68
|
+
it("leaves out a card's kind and meta lines in the kiosk's frame", () => {
|
|
69
|
+
for (const t of ["Component · chant", "terraform_data · terraform", "archetype: infra", "wave: 2", " root: app "]) expect(kioskQuietLine(t)).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
it("keeps the names, the states and the reads", () => {
|
|
72
|
+
for (const t of ["network", "terraform_data.http_proxy_port", "waiting", "reads network · planned · 1 unknown", "wave 2 · planned", "", null]) expect(kioskQuietLine(t)).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
});
|
package/web/kiosk-runs.js
CHANGED
|
@@ -159,6 +159,22 @@ export function defaultJob(jobs) {
|
|
|
159
159
|
return started[0] || jobs.find((j) => j.status !== "skipped") || jobs[0] || null;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
/** Forgejo's runner line ending a step that exited 3, the code terragucci holds a wave with. */
|
|
163
|
+
const RUNNER_EXIT_3 = /^\s*⚙️\s*\[runner\]: exitcode '3': failure\s*$/u;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* A log line as the kiosk shows it under a job: in a job held at the gate,
|
|
167
|
+
* the runner's `exitcode '3': failure` reads as what it is, a hold waiting for
|
|
168
|
+
* an approval, never a failure. `{ text, held }`; any other line, and any line
|
|
169
|
+
* of a job that is not held, is kept as it came.
|
|
170
|
+
*/
|
|
171
|
+
export function logLineWords(jobStatus, line) {
|
|
172
|
+
if (jobStatus === "waiting" && typeof line === "string" && RUNNER_EXIT_3.test(line)) {
|
|
173
|
+
return { text: "[runner]: exit code 3: held at the gate, waiting for an approval", held: true };
|
|
174
|
+
}
|
|
175
|
+
return { text: line, held: false };
|
|
176
|
+
}
|
|
177
|
+
|
|
162
178
|
/** The workflow's own steps of a job: not the runner's set-up or trailer, a post step, or an action's (named by its address). */
|
|
163
179
|
function ownSteps(steps) {
|
|
164
180
|
return steps.map((s, i) => ({ s, i })).filter(({ s, i }) => i > 0 && i < steps.length - 1 && !isPostStep(s.name) && !looksLikeAction(s.name));
|
|
@@ -211,12 +227,88 @@ export function agoWords(at, now = new Date()) {
|
|
|
211
227
|
return `${Math.floor(s / 86400)} d ago`;
|
|
212
228
|
}
|
|
213
229
|
|
|
214
|
-
/**
|
|
215
|
-
|
|
216
|
-
|
|
230
|
+
/**
|
|
231
|
+
* behold#571: whether a scenario's short label reads as code to someone new
|
|
232
|
+
* to the demo: a lettered label ("A, read through .output", "B on waves"), a
|
|
233
|
+
* code token in it, or the bare id ("a-output").
|
|
234
|
+
*/
|
|
235
|
+
export function codeish(label) {
|
|
236
|
+
return typeof label === "string" && (/^[A-Z]\b/.test(label) || /\.[A-Za-z]|[_`]/.test(label) || /^[a-z0-9]+(?:-[a-z0-9]+)+$/.test(label));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const MERGE_TITLE = /^Merge pull request '(.+)' \(#\d+\)/;
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* behold#571: each scenario's title in plain words, by id: from the merge
|
|
243
|
+
* runs' titles (the pull request is titled after its scenario), and from
|
|
244
|
+
* `known` (the titles demo.json has carried, which win).
|
|
245
|
+
*/
|
|
246
|
+
export function scenarioTitles(runs, known = {}) {
|
|
247
|
+
const out = {};
|
|
248
|
+
for (const r of [...(runs || [])].reverse()) {
|
|
249
|
+
const m = r && r.scenario && typeof r.title === "string" ? MERGE_TITLE.exec(r.title) : null;
|
|
250
|
+
if (m) out[r.scenario] = m[1];
|
|
251
|
+
}
|
|
252
|
+
for (const [id, title] of Object.entries(known || {})) if (typeof title === "string" && title) out[id] = title;
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The scenario and the cycle under a run: "a failure mid-apply · cycle 90".
|
|
258
|
+
* The scenario's name as demo.json last carried it (`names`, terragucci#1050)
|
|
259
|
+
* comes first, then the kiosk's SCENARIOS label; a label that reads as code
|
|
260
|
+
* gives way to the scenario's title when one is known (behold#571).
|
|
261
|
+
*/
|
|
262
|
+
export function placeWords(run, scenarios, titles = {}, names = {}) {
|
|
263
|
+
const own = (m) => !!(run.scenario && m && Object.prototype.hasOwnProperty.call(m, run.scenario));
|
|
264
|
+
const short = own(names) && names[run.scenario] ? names[run.scenario] : own(scenarios) ? scenarios[run.scenario].name : run.scenario;
|
|
265
|
+
const title = own(titles) ? titles[run.scenario] : null;
|
|
266
|
+
const sc = title && (!short || codeish(short)) ? title : short;
|
|
217
267
|
return [sc, run.cycle !== null && run.cycle !== undefined ? `cycle ${run.cycle}` : null].filter(Boolean).join(" · ");
|
|
218
268
|
}
|
|
219
269
|
|
|
270
|
+
/**
|
|
271
|
+
* behold#571: a runner's or an action's set-up line, not terragucci's own:
|
|
272
|
+
* git's hints, the checkout's `[command]` lines and groups, the runner's
|
|
273
|
+
* docker and fetch lines.
|
|
274
|
+
*/
|
|
275
|
+
export const SETUP_LINES = [
|
|
276
|
+
/^hint:/,
|
|
277
|
+
/^\[command\]/,
|
|
278
|
+
/^##\[(?:group|endgroup)\]/,
|
|
279
|
+
/^Initialized empty Git repository\b/,
|
|
280
|
+
/^::(?:add|remove)-matcher\b/,
|
|
281
|
+
/^\s*❓\s*add-matcher\b/u,
|
|
282
|
+
/^\s*🐳\s+docker\b/u,
|
|
283
|
+
/^\s*☁️\s+git fetch\b/u,
|
|
284
|
+
/^git version \d/,
|
|
285
|
+
/^Temporarily overriding HOME=/,
|
|
286
|
+
/^Adding repository directory to the temporary git global config\b/,
|
|
287
|
+
/^Deleting the contents of '/,
|
|
288
|
+
/^Syncing repository: /,
|
|
289
|
+
/^Working directory is '/,
|
|
290
|
+
];
|
|
291
|
+
|
|
292
|
+
/** Whether a log line is set-up noise (SETUP_LINES). */
|
|
293
|
+
export function setupLine(line) {
|
|
294
|
+
return typeof line === "string" && SETUP_LINES.some((re) => re.test(line));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* behold#571: a step's log as the kiosk draws it. The set-up lines fold into
|
|
299
|
+
* one row at the top, `{ setup: n }`, so terragucci's own lines come first;
|
|
300
|
+
* opened (`open`), the row heads the whole log in order. Every line keeps
|
|
301
|
+
* its number in the log. A log with no set-up lines is drawn as it came.
|
|
302
|
+
*/
|
|
303
|
+
export function logRows(lines, { open = false, from = 0 } = {}) {
|
|
304
|
+
const all = (lines || []).map((text, i) => ({ n: from + i + 1, text, setup: setupLine(text) }));
|
|
305
|
+
const n = all.filter((l) => l.setup).length;
|
|
306
|
+
if (!n) return all.map(({ n: num, text }) => ({ n: num, text }));
|
|
307
|
+
const head = { setup: n, open };
|
|
308
|
+
const body = open ? all : all.filter((l) => !l.setup);
|
|
309
|
+
return [head, ...body.map(({ n: num, text, setup }) => (open && setup ? { n: num, text, quiet: true } : { n: num, text }))];
|
|
310
|
+
}
|
|
311
|
+
|
|
220
312
|
// ---------------------------------------------------------------------------
|
|
221
313
|
// Drawing.
|
|
222
314
|
|
|
@@ -261,7 +353,7 @@ export function statusIcon(status) {
|
|
|
261
353
|
}
|
|
262
354
|
|
|
263
355
|
/** The sidebar: the live run pinned, then the recent ones. `pick(id)` selects one; nothing here writes. */
|
|
264
|
-
export function renderSidebar(host, index, selectedId, { now = new Date(), scenarios = {}, pick = () => {}, merged = null } = {}) {
|
|
356
|
+
export function renderSidebar(host, index, selectedId, { now = new Date(), scenarios = {}, titles = {}, names = {}, pick = () => {}, merged = null } = {}) {
|
|
265
357
|
host.replaceChildren();
|
|
266
358
|
const head = el("div", "r-side-head");
|
|
267
359
|
head.appendChild(el("div", "r-side-title", "Pipeline runs"));
|
|
@@ -274,6 +366,7 @@ export function renderSidebar(host, index, selectedId, { now = new Date(), scena
|
|
|
274
366
|
// The selected run as its page draws it, when its own document is newer than the index.
|
|
275
367
|
const runs = sidebarRuns(index.doc.runs).map((r) => (merged && merged.id === r.id ? merged : r));
|
|
276
368
|
const live = runs[0];
|
|
369
|
+
const named = scenarioTitles(index.doc.runs, titles);
|
|
277
370
|
const list = el("ol", "r-runs");
|
|
278
371
|
runs.forEach((r, i) => {
|
|
279
372
|
if (i === 0) list.appendChild(el("li", "r-group", LIVE.has(live.status) ? "Live" : "Latest"));
|
|
@@ -284,7 +377,7 @@ export function renderSidebar(host, index, selectedId, { now = new Date(), scena
|
|
|
284
377
|
li.appendChild(statusIcon(r.status));
|
|
285
378
|
const body = el("div", "r-run-body");
|
|
286
379
|
body.appendChild(el("div", "r-run-title", triggerWords(r)));
|
|
287
|
-
const place = placeWords(r, scenarios);
|
|
380
|
+
const place = placeWords(r, scenarios, named, names);
|
|
288
381
|
if (place) body.appendChild(el("div", "r-run-place", place));
|
|
289
382
|
const meta = el("div", "r-run-meta");
|
|
290
383
|
meta.append(
|
|
@@ -312,7 +405,7 @@ export function renderSidebar(host, index, selectedId, { now = new Date(), scena
|
|
|
312
405
|
* null for the defaults. Returns what it drew, so the caller can keep a log's
|
|
313
406
|
* scroll.
|
|
314
407
|
*/
|
|
315
|
-
export function renderRun(host, answer, ui, { now = new Date(), pickJob = () => {}, toggleStep = () => {}, entry = null } = {}) {
|
|
408
|
+
export function renderRun(host, answer, ui, { now = new Date(), pickJob = () => {}, toggleStep = () => {}, toggleSetup = () => {}, entry = null } = {}) {
|
|
316
409
|
host.replaceChildren();
|
|
317
410
|
if (!answer || answer.error || answer.absent || !answer.doc) {
|
|
318
411
|
host.appendChild(el("p", "r-note", answer ? answer.absent || answer.error : "Reading the run…"));
|
|
@@ -382,11 +475,25 @@ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () =>
|
|
|
382
475
|
if (i === open) {
|
|
383
476
|
const pre = el("pre", "r-log");
|
|
384
477
|
if (s.truncated) pre.appendChild(el("div", "r-log-cut", `… ${s.truncated} earlier lines not kept`));
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
478
|
+
const key = logKey(run.id, job.name, i);
|
|
479
|
+
for (const row of logRows(s.log, { open: ui.setup === key, from: s.truncated || 0 })) {
|
|
480
|
+
if (row.setup) {
|
|
481
|
+
const fold = el("div", `r-log-setup${row.open ? " r-log-setup-open" : ""}`, `${row.open ? "▾" : "▸"} setup (${row.setup} line${row.setup === 1 ? "" : "s"})`);
|
|
482
|
+
fold.setAttribute("role", "button");
|
|
483
|
+
fold.tabIndex = 0;
|
|
484
|
+
fold.setAttribute("aria-expanded", String(row.open));
|
|
485
|
+
fold.addEventListener("click", () => toggleSetup(key));
|
|
486
|
+
fold.addEventListener("keydown", (e) => {
|
|
487
|
+
if (e.key === "Enter" || e.key === " ") toggleSetup(key);
|
|
488
|
+
});
|
|
489
|
+
pre.appendChild(fold);
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
const ln = el("div", `r-log-line${row.quiet ? " r-log-quiet" : ""}`);
|
|
493
|
+
const shown = logLineWords(job.status, row.text);
|
|
494
|
+
ln.append(el("span", "r-ln", String(row.n)), el("span", shown.held ? "r-lt r-wait" : "r-lt", shown.text));
|
|
388
495
|
pre.appendChild(ln);
|
|
389
|
-
}
|
|
496
|
+
}
|
|
390
497
|
if (!s.log.length) pre.appendChild(el("div", "r-log-cut", s.status === "running" ? "waiting for output…" : "no output"));
|
|
391
498
|
if (s.status === "running") pre.appendChild(el("div", "r-log-live", "▍"));
|
|
392
499
|
row.appendChild(pre);
|
|
@@ -430,8 +537,8 @@ export const PICK_HOLD_MS = 90_000;
|
|
|
430
537
|
* is live), and redraw. The live run is selected until a viewer picks
|
|
431
538
|
* another, and again {@link PICK_HOLD_MS} after their last pick.
|
|
432
539
|
*/
|
|
433
|
-
export function startRuns({ side, main }, { fetchJson, now = () => new Date(), scenarios = {}, onLive = () => {} } = {}) {
|
|
434
|
-
const s = { index: null, run: null, selected: null, picked: 0, ui: { job: null, open: null } };
|
|
540
|
+
export function startRuns({ side, main }, { fetchJson, now = () => new Date(), scenarios = {}, titles = () => ({}), names = () => ({}), onLive = () => {} } = {}) {
|
|
541
|
+
const s = { index: null, run: null, selected: null, picked: 0, ui: { job: null, open: null, setup: null } };
|
|
435
542
|
let follow = true;
|
|
436
543
|
let lastRunKey = "";
|
|
437
544
|
// The open log and whether the pane follows its end; a viewer who scrolls up stops it until they come back.
|
|
@@ -453,7 +560,7 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
|
|
|
453
560
|
const ans = s.run && (!s.run.doc || s.run.doc.id === id) ? s.run : null;
|
|
454
561
|
// One merged read of the selected run for both the sidebar and its page.
|
|
455
562
|
const merged = ans && ans.doc ? mergeRun(entry, ans.doc) : null;
|
|
456
|
-
renderSidebar(side, s.index, id, { now: now(), scenarios, pick, merged });
|
|
563
|
+
renderSidebar(side, s.index, id, { now: now(), scenarios, titles: titles(), names: names(), pick, merged });
|
|
457
564
|
// The redraw replaces the scrollers: keep where a viewer left them.
|
|
458
565
|
const old = main.querySelector(".r-log");
|
|
459
566
|
const oldSteps = main.querySelector(".r-steps");
|
|
@@ -463,12 +570,17 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
|
|
|
463
570
|
now: now(),
|
|
464
571
|
entry,
|
|
465
572
|
pickJob: (name) => {
|
|
466
|
-
s.ui = { job: name, open: null };
|
|
573
|
+
s.ui = { job: name, open: null, setup: null };
|
|
467
574
|
hold();
|
|
468
575
|
draw();
|
|
469
576
|
},
|
|
470
577
|
toggleStep: (job, i) => {
|
|
471
|
-
s.ui = { job, open: i };
|
|
578
|
+
s.ui = { job, open: i, setup: null };
|
|
579
|
+
hold();
|
|
580
|
+
draw();
|
|
581
|
+
},
|
|
582
|
+
toggleSetup: (key) => {
|
|
583
|
+
s.ui = { ...s.ui, setup: s.ui.setup === key ? null : key };
|
|
472
584
|
hold();
|
|
473
585
|
draw();
|
|
474
586
|
},
|
|
@@ -511,7 +623,7 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
|
|
|
511
623
|
s.selected = id;
|
|
512
624
|
follow = false;
|
|
513
625
|
s.picked = now().getTime();
|
|
514
|
-
s.ui = { job: null, open: null };
|
|
626
|
+
s.ui = { job: null, open: null, setup: null };
|
|
515
627
|
void readRun();
|
|
516
628
|
};
|
|
517
629
|
const readIndex = async () => {
|
|
@@ -528,7 +640,7 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
|
|
|
528
640
|
// A run that ended reads once; one still going reads on every tick.
|
|
529
641
|
const key = `${id}|${entry ? entry.status : ""}|${entry ? entry.ended_at : ""}`;
|
|
530
642
|
if (key === lastRunKey && entry && !LIVE.has(entry.status) && s.run && s.run.doc && s.run.doc.id === id) return draw();
|
|
531
|
-
if (s.run && s.run.doc && s.run.doc.id !== id) s.ui = { job: null, open: null };
|
|
643
|
+
if (s.run && s.run.doc && s.run.doc.id !== id) s.ui = { job: null, open: null, setup: null };
|
|
532
644
|
lastRunKey = key;
|
|
533
645
|
s.run = await fetchJson(`/api/terragucci/runs/${id}`);
|
|
534
646
|
draw();
|
package/web/kiosk-runs.test.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { describe, expect, it } from "vitest";
|
|
4
|
-
import { FOLLOW_LINES, followsLog, logKey, nearEnd, mergeRun, newerOf, agoWords, defaultJob, defaultStep, durationWords, jobOrder, liveRun, placeWords, sidebarRuns, splitSkipped, STATUS_TONE, triggerWords } from "./kiosk-runs.js";
|
|
4
|
+
import { FOLLOW_LINES, followsLog, logKey, nearEnd, mergeRun, newerOf, agoWords, defaultJob, defaultStep, durationWords, jobOrder, liveRun, codeish, logLineWords, logRows, placeWords, scenarioTitles, setupLine, sidebarRuns, splitSkipped, STATUS_TONE, triggerWords } from "./kiosk-runs.js";
|
|
5
5
|
|
|
6
6
|
const FIX = join(import.meta.dirname, "..", "src", "__fixtures__", "terragucci-demo", "runs");
|
|
7
7
|
const run = (id) => JSON.parse(readFileSync(join(FIX, `${id}.json`), "utf8"));
|
|
@@ -21,6 +21,20 @@ describe("the runs, as a CI page lists them (#569)", () => {
|
|
|
21
21
|
expect(STATUS_TONE.waiting).toBe("wait");
|
|
22
22
|
expect(STATUS_TONE.waiting).not.toBe(STATUS_TONE.failure);
|
|
23
23
|
});
|
|
24
|
+
it("shows the runner's exit 3 under a job held at the gate as the hold, not a failure", () => {
|
|
25
|
+
const line = "⚙️ [runner]: exitcode '3': failure";
|
|
26
|
+
expect(logLineWords("waiting", line)).toEqual({ text: "[runner]: exit code 3: held at the gate, waiting for an approval", held: true });
|
|
27
|
+
// The fixture's held job ends its log with that line.
|
|
28
|
+
const held = run(444).jobs.find((j) => j.status === "waiting");
|
|
29
|
+
const last = held.steps.find((s) => s.status === "waiting").log.at(-1);
|
|
30
|
+
expect(logLineWords(held.status, last).held).toBe(true);
|
|
31
|
+
// A real failure keeps its line, whatever the code.
|
|
32
|
+
expect(logLineWords("failure", line)).toEqual({ text: line, held: false });
|
|
33
|
+
expect(logLineWords("failure", "⚙️ [runner]: exitcode '1': failure")).toEqual({ text: "⚙️ [runner]: exitcode '1': failure", held: false });
|
|
34
|
+
// A held job's other lines, and another code, are kept as they came.
|
|
35
|
+
expect(logLineWords("waiting", "⚙️ [runner]: exitcode '1': failure").held).toBe(false);
|
|
36
|
+
expect(logLineWords("waiting", "wave 2 of 3: approval ledger (the default)").held).toBe(false);
|
|
37
|
+
});
|
|
24
38
|
it("follows the newest run still going, pinned first", () => {
|
|
25
39
|
const runs = index().runs;
|
|
26
40
|
expect(liveRun(runs).id).toBe(448);
|
|
@@ -117,3 +131,55 @@ describe("the log pane follows its end, like a CI page's log", () => {
|
|
|
117
131
|
expect(nearEnd({ scrollTop: 0, clientHeight: 400, scrollHeight: 1000 }, slack)).toBe(false);
|
|
118
132
|
});
|
|
119
133
|
});
|
|
134
|
+
|
|
135
|
+
describe("the runs for a viewer new to the demo (behold#571)", () => {
|
|
136
|
+
const SC = { "a-output": { name: "A, read through .output" }, "b-waves": { name: "B on waves" }, failure: { name: "a failure mid-apply" } };
|
|
137
|
+
it("prefers the scenario's title when its short label reads as code", () => {
|
|
138
|
+
expect(codeish("A, read through .output")).toBe(true);
|
|
139
|
+
expect(codeish("B on waves")).toBe(true);
|
|
140
|
+
expect(codeish("a-output")).toBe(true);
|
|
141
|
+
expect(codeish("a failure mid-apply")).toBe(false);
|
|
142
|
+
expect(codeish("the base")).toBe(false);
|
|
143
|
+
const titles = { "a-output": "The same change, read once it is built", failure: "One root fails partway" };
|
|
144
|
+
expect(placeWords({ scenario: "a-output", cycle: 90 }, SC, titles)).toBe("The same change, read once it is built · cycle 90");
|
|
145
|
+
expect(placeWords({ scenario: "failure", cycle: 92 }, SC, titles)).toBe("a failure mid-apply · cycle 92");
|
|
146
|
+
expect(placeWords({ scenario: "b-waves", cycle: 91 }, SC, titles)).toBe("B on waves · cycle 91");
|
|
147
|
+
expect(placeWords({ scenario: "c-new", cycle: 93 }, SC, { "c-new": "A new one" })).toBe("A new one · cycle 93");
|
|
148
|
+
expect(placeWords({ scenario: "a-output", cycle: 90 }, SC)).toBe("A, read through .output · cycle 90");
|
|
149
|
+
});
|
|
150
|
+
it("takes demo.json's name for a scenario before the table's label (terragucci#1050)", () => {
|
|
151
|
+
const names = { "b-waves": "a new subnet", failure: "a failure halfway" };
|
|
152
|
+
expect(placeWords({ scenario: "b-waves", cycle: 12 }, SC, {}, names)).toBe("a new subnet · cycle 12");
|
|
153
|
+
expect(placeWords({ scenario: "failure", cycle: 12 }, SC, { failure: "One root fails" }, names)).toBe("a failure halfway · cycle 12");
|
|
154
|
+
expect(placeWords({ scenario: "a-output", cycle: 12 }, SC, {}, names)).toBe("A, read through .output · cycle 12");
|
|
155
|
+
});
|
|
156
|
+
it("learns titles from the merge runs, and demo.json's win", () => {
|
|
157
|
+
const runs = index().runs;
|
|
158
|
+
const t = scenarioTitles(runs);
|
|
159
|
+
expect(t["a-known"]).toBe("A port change, known when it plans");
|
|
160
|
+
expect(scenarioTitles(runs, { "a-known": "A plain title" })["a-known"]).toBe("A plain title");
|
|
161
|
+
expect(scenarioTitles([], {})).toEqual({});
|
|
162
|
+
});
|
|
163
|
+
it("knows the runner's set-up lines from terragucci's own", () => {
|
|
164
|
+
for (const l of ["hint: Using 'master' as the name", "[command]/usr/bin/git init /workspace/demo", "##[group]Initializing the repository", "##[endgroup]", "Initialized empty Git repository in /workspace/demo/.git/"]) expect(setupLine(l)).toBe(true);
|
|
165
|
+
for (const l of ["wave 2 of 3: planning app", "app: Plan: 0 to add, 1 to change, 0 to destroy.", " terragucci approve wave-2 --plan jcs1-sha256:0a", "⚙️ [runner]: exitcode '3': failure"]) expect(setupLine(l)).toBe(false);
|
|
166
|
+
});
|
|
167
|
+
it("folds the set-up lines into one row at the top, and opens it in order", () => {
|
|
168
|
+
const log = ["##[group]Init", "[command]/usr/bin/git init", "wave 1 of 3: planning network", "hint: x", "##[endgroup]", "wave 1 of 3: applied"];
|
|
169
|
+
expect(logRows(log)).toEqual([{ setup: 4, open: false }, { n: 3, text: "wave 1 of 3: planning network" }, { n: 6, text: "wave 1 of 3: applied" }]);
|
|
170
|
+
const open = logRows(log, { open: true });
|
|
171
|
+
expect(open[0]).toEqual({ setup: 4, open: true });
|
|
172
|
+
expect(open.slice(1).map((r) => r.n)).toEqual([1, 2, 3, 4, 5, 6]);
|
|
173
|
+
expect(open.filter((r) => r.quiet).map((r) => r.n)).toEqual([1, 2, 4, 5]);
|
|
174
|
+
expect(logRows(["a", "b"], { from: 10 })).toEqual([{ n: 11, text: "a" }, { n: 12, text: "b" }]);
|
|
175
|
+
});
|
|
176
|
+
it("folds the checkout's set-up in a real held job's log, and leaves terragucci's step as it came", () => {
|
|
177
|
+
const job = run(444).jobs.find((j) => j.status === "waiting");
|
|
178
|
+
const checkout = job.steps.find((s) => s.name.includes("checkout") && !s.name.startsWith("Post"));
|
|
179
|
+
const rows = logRows(checkout.log);
|
|
180
|
+
expect(rows[0].setup).toBeGreaterThan(40);
|
|
181
|
+
expect(rows.slice(1).some((r) => /^hint:|^\[command\]|^##\[/.test(r.text))).toBe(false);
|
|
182
|
+
const own = job.steps.find((s) => /^Apply wave/.test(s.name));
|
|
183
|
+
expect(logRows(own.log)).toEqual(own.log.map((text, i) => ({ n: i + 1, text })));
|
|
184
|
+
});
|
|
185
|
+
});
|
package/web/kiosk.html
CHANGED
|
@@ -74,30 +74,36 @@
|
|
|
74
74
|
white-space: pre-wrap; overflow-wrap: anywhere; overflow-y: auto; max-height: 52vh; min-height: 6em; }
|
|
75
75
|
.r-log-line { display: grid; grid-template-columns: 3.2em 1fr; gap: 0.8em; padding: 0 0.8em 0 0; }
|
|
76
76
|
.r-ln { color: var(--muted); text-align: right; user-select: none; opacity: 0.7; }
|
|
77
|
+
/* behold#571: the runner's set-up lines, folded into one row so terragucci's own lines come first. */
|
|
78
|
+
.r-log-setup { color: var(--muted); padding: 0 0.8em 0.2em 4em; cursor: pointer; user-select: none; }
|
|
79
|
+
.r-log-setup:hover { color: var(--fg); }
|
|
80
|
+
.r-log-quiet .r-lt { color: var(--muted); }
|
|
77
81
|
.r-log-cut { color: var(--muted); padding: 0 0.8em 0.2em 4em; font-style: italic; }
|
|
78
82
|
.r-log-live { color: var(--foreign); padding-left: 4em; }
|
|
79
83
|
@media (prefers-reduced-motion: no-preference) { .r-log-live { animation: r-blink 1s steps(2) infinite; } }
|
|
80
84
|
@keyframes r-blink { 50% { opacity: 0; } }
|
|
81
85
|
.r-note { color: var(--muted); padding: 0 1em; font-size: 0.88em; }
|
|
82
|
-
#bar { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); gap: 1.6em; align-items: start;
|
|
83
|
-
padding: 0.
|
|
86
|
+
#bar { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); gap: 0.45em 1.6em; align-items: start;
|
|
87
|
+
padding: 0.55em 1.4em 0.6em; background: var(--panel); border-bottom: 1px solid var(--rule); }
|
|
84
88
|
#bar > .k-empty { grid-column: 1 / -1; }
|
|
85
|
-
|
|
86
|
-
.k-
|
|
87
|
-
.k-
|
|
89
|
+
/* behold#571: the problem the demo solves, always on, across the bar's top. */
|
|
90
|
+
.k-intro { grid-column: 1 / -1; margin: 0; font-size: 1em; line-height: 1.35; color: var(--fg); }
|
|
91
|
+
.k-kicker { font: 600 0.58em var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin-right: 0.9em; vertical-align: 0.2em; white-space: nowrap; }
|
|
92
|
+
.k-cycle { color: var(--muted); font: 0.78em var(--font-mono); white-space: nowrap; }
|
|
93
|
+
.k-title { margin: 0 0 0.2em; font-size: 1.35em; line-height: 1.2; }
|
|
88
94
|
/* behold#571: the scenario's card, three labelled rows under the title. */
|
|
89
|
-
.k-about { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 0.
|
|
95
|
+
.k-about { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 0.08em 0.9em; align-items: baseline; margin: 0 0 0.35em; font-size: 0.88em; line-height: 1.25; }
|
|
90
96
|
.k-about-label { font: 600 0.74em var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
|
|
91
97
|
.k-about-text { margin: 0; color: var(--fg); }
|
|
92
98
|
.k-muted { color: var(--muted); margin: 0.25em 0; }
|
|
93
|
-
.k-step { border-left: 4px solid var(--pending); padding: 0.1em 0 0.1em 0.8em; }
|
|
94
|
-
.k-step-word { font: 600 0.
|
|
95
|
-
.k-label { font-size: 1.02em; margin: 0.1em 0 0; }
|
|
99
|
+
.k-step { border-left: 4px solid var(--pending); padding: 0.1em 0 0.1em 0.8em; margin: 0; font-size: 0.95em; line-height: 1.3; }
|
|
100
|
+
.k-step-word { font: 600 0.72em var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin-right: 0.7em; }
|
|
96
101
|
.k-step-waiting { border-left-color: var(--foreign); }
|
|
97
102
|
.k-step-failed, .k-step-stalled { border-left-color: var(--degraded); }
|
|
98
103
|
.k-step-applied { border-left-color: var(--managed); }
|
|
99
104
|
.k-step-reset { border-left-color: var(--neutral); }
|
|
100
|
-
.k-right { display: grid; gap: 0.
|
|
105
|
+
.k-right { display: grid; gap: 0.45em; align-content: start; }
|
|
106
|
+
.k-where { display: flex; flex-wrap: wrap; gap: 0.35em 1em; align-items: center; }
|
|
101
107
|
.k-strip { list-style: none; display: flex; flex-wrap: wrap; gap: 0.35em; padding: 0; margin: 0; }
|
|
102
108
|
.k-stop { font: 0.82em var(--font-mono); padding: 0.25em 0.7em; border-radius: 999px; border: 1px solid var(--rule); color: var(--muted); }
|
|
103
109
|
.k-done { color: var(--fg); border-color: var(--managed); }
|
|
@@ -109,7 +115,8 @@
|
|
|
109
115
|
.k-when { color: var(--foreign); font-family: var(--font-mono); }
|
|
110
116
|
.k-approve { border-left: 4px solid var(--foreign); padding-left: 0.8em; }
|
|
111
117
|
.k-approve p { margin: 0.15em 0; }
|
|
112
|
-
.k-approve-
|
|
118
|
+
.k-approve-gloss { color: var(--fg); font-size: 0.92em; }
|
|
119
|
+
.k-approve-then { color: var(--muted); font-size: 0.92em; }
|
|
113
120
|
.k-command { display: block; margin: 0.25em 0; padding: 0.45em 0.7em; background: var(--well); border-radius: 6px; font: 0.88em var(--font-mono); overflow-wrap: anywhere; user-select: none; }
|
|
114
121
|
.k-inline { font-family: var(--font-mono); color: var(--fg); background: var(--well); padding: 0.05em 0.35em; border-radius: 4px; user-select: none; }
|
|
115
122
|
.k-note { color: var(--muted); margin: 0; font-size: 0.92em; }
|
|
@@ -129,7 +136,6 @@
|
|
|
129
136
|
#foot { display: flex; flex-wrap: wrap; gap: 0.2em 1.6em; padding: 0.55em 1.6em; border-top: 1px solid var(--rule); background: var(--panel); font-size: 0.85em; }
|
|
130
137
|
.k-legend { color: var(--muted); flex-basis: 100%; }
|
|
131
138
|
.k-fact { color: var(--fg); }
|
|
132
|
-
.k-source { color: var(--muted); margin-left: auto; }
|
|
133
139
|
@media (max-width: 900px) { #bar { grid-template-columns: 1fr; } #stage { grid-template-columns: 1fr; } }
|
|
134
140
|
</style>
|
|
135
141
|
</head>
|
package/web/kiosk.js
CHANGED
|
@@ -51,12 +51,38 @@ export const STEP_WORDS = {
|
|
|
51
51
|
stalled: "stalled",
|
|
52
52
|
};
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
const counted = (n) => Number.isInteger(n) && n > 0;
|
|
55
|
+
|
|
56
|
+
/** demo.json's `name`, the scenario's short label in plain words (terragucci#1050); null without one. */
|
|
57
|
+
export function scenarioName(demo) {
|
|
58
|
+
const n = demo && demo.name;
|
|
59
|
+
return typeof n === "string" && n.trim() ? n.trim() : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The kicker and the title over the card. behold#571: "Scenario 2 of 5" when
|
|
64
|
+
* demo.json counts the loop (`index` of `scenarios`), "Scenario 2" with the
|
|
65
|
+
* index alone, with the title in plain words. Without an index, today's
|
|
66
|
+
* kicker: "scenario 3 · B on waves" from SCENARIOS, demo.json's `name` in
|
|
67
|
+
* place of SCENARIOS' label when it has one; the driver's title alone for an
|
|
68
|
+
* id behold doesn't know.
|
|
69
|
+
*/
|
|
55
70
|
export function scenarioHead(demo) {
|
|
56
71
|
const known = Object.prototype.hasOwnProperty.call(SCENARIOS, demo.scenario) ? SCENARIOS[demo.scenario] : null;
|
|
57
|
-
|
|
58
|
-
if (
|
|
59
|
-
|
|
72
|
+
const name = scenarioName(demo);
|
|
73
|
+
if (counted(demo.index)) {
|
|
74
|
+
const of = counted(demo.scenarios) && demo.index <= demo.scenarios ? ` of ${demo.scenarios}` : "";
|
|
75
|
+
return { kicker: `Scenario ${demo.index}${of}`, title: demo.title || name || (known ? known.name : demo.scenario) };
|
|
76
|
+
}
|
|
77
|
+
if (!known) return { kicker: name ? `scenario · ${name}` : `scenario ${demo.scenario}`, title: demo.title };
|
|
78
|
+
if (known.n === null) return { kicker: demo.title || name || "The base", title: demo.title || name || "The base" };
|
|
79
|
+
return { kicker: `scenario ${known.n} · ${name || known.name}`, title: demo.title };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** behold#571: demo.json's `intro`, one paragraph on the problem the demo solves; null without one. */
|
|
83
|
+
export function introText(demo) {
|
|
84
|
+
const t = demo && demo.intro;
|
|
85
|
+
return typeof t === "string" && t.trim() ? t.trim() : null;
|
|
60
86
|
}
|
|
61
87
|
|
|
62
88
|
/** The card's rows under the title, in order, with the labels the kiosk draws (behold#571). */
|
|
@@ -226,6 +252,16 @@ export function pendingApproval(demo, progress) {
|
|
|
226
252
|
};
|
|
227
253
|
}
|
|
228
254
|
|
|
255
|
+
/**
|
|
256
|
+
* behold#571: the approval in plain words, before the command, for a viewer
|
|
257
|
+
* who has never approved a pipeline: who the driver is, what it signs. The
|
|
258
|
+
* plan's digest is on the wave's box in the view.
|
|
259
|
+
*/
|
|
260
|
+
export function approveGloss(appr) {
|
|
261
|
+
const roots = appr.roots.length ? ` (${appr.roots.join(", ")})` : "";
|
|
262
|
+
return `The driver stands in for a person: it approves wave ${appr.wave}${roots} with its own signing key.`;
|
|
263
|
+
}
|
|
264
|
+
|
|
229
265
|
/**
|
|
230
266
|
* #569: the roots the step is about, for the framed view to bring forward:
|
|
231
267
|
* the pull request's roots while it is planned; from the merge on, the wave
|
|
@@ -435,27 +471,31 @@ export function renderKiosk(bar, s, now = new Date(), foot = null) {
|
|
|
435
471
|
const demo = s.demo.demo;
|
|
436
472
|
const head = scenarioHead(demo);
|
|
437
473
|
|
|
474
|
+
const intro = introText(demo);
|
|
475
|
+
if (intro) bar.appendChild(el("p", "k-intro", intro));
|
|
476
|
+
|
|
477
|
+
// behold#571: the kicker on the title's line, so the bar has room for the intro.
|
|
438
478
|
const left = el("div", "k-left");
|
|
439
|
-
const
|
|
440
|
-
|
|
441
|
-
left.appendChild(
|
|
442
|
-
left.appendChild(el("h1", "k-title", head.title));
|
|
479
|
+
const title = el("h1", "k-title");
|
|
480
|
+
title.append(el("span", "k-kicker", head.kicker), el("span", "k-title-text", head.title));
|
|
481
|
+
left.appendChild(title);
|
|
443
482
|
const rows = aboutRows(demo);
|
|
444
483
|
if (rows.length) {
|
|
445
484
|
const card = el("dl", "k-about");
|
|
446
485
|
for (const r of rows) card.append(el("dt", "k-about-label", r.label), el("dd", "k-about-text", r.text));
|
|
447
486
|
left.appendChild(card);
|
|
448
487
|
}
|
|
449
|
-
const said = el("
|
|
450
|
-
said.
|
|
451
|
-
said.appendChild(el("p", "k-label", demo.label));
|
|
488
|
+
const said = el("p", `k-step k-step-${demo.step}`);
|
|
489
|
+
said.append(el("span", "k-step-word", STEP_WORDS[demo.step] || demo.step), el("span", "k-label", demo.label));
|
|
452
490
|
left.appendChild(said);
|
|
453
491
|
bar.appendChild(left);
|
|
454
492
|
|
|
455
493
|
const right = el("div", "k-right");
|
|
456
494
|
const strip = el("ol", "k-strip");
|
|
457
495
|
for (const stop of stripStops(demo.step)) strip.appendChild(el("li", `k-stop k-${stop.state}`, stop.words));
|
|
458
|
-
|
|
496
|
+
const where = el("div", "k-where");
|
|
497
|
+
where.append(strip, el("span", "k-cycle", cycleWords(demo, now)));
|
|
498
|
+
right.appendChild(where);
|
|
459
499
|
|
|
460
500
|
const nx = nextWords(demo, now);
|
|
461
501
|
const next = el("div", "k-next");
|
|
@@ -467,13 +507,7 @@ export function renderKiosk(bar, s, now = new Date(), foot = null) {
|
|
|
467
507
|
const appr = pendingApproval(demo, s.progress);
|
|
468
508
|
if (appr) {
|
|
469
509
|
const a = el("div", "k-approve");
|
|
470
|
-
a.appendChild(
|
|
471
|
-
el(
|
|
472
|
-
"p",
|
|
473
|
-
"k-approve-head",
|
|
474
|
-
`The driver approves wave ${appr.wave}${appr.roots.length ? ` (${appr.roots.join(", ")})` : ""}, signed with its own key${appr.digest ? `, for the plan ${appr.digest.replace(/^jcs1-sha256:/, "").slice(0, 12)}` : ""}:`,
|
|
475
|
-
),
|
|
476
|
-
);
|
|
510
|
+
a.appendChild(el("p", "k-approve-gloss", approveGloss(appr)));
|
|
477
511
|
a.appendChild(el("code", "k-command", appr.approve));
|
|
478
512
|
const then = el("p", "k-approve-then");
|
|
479
513
|
then.append(`then comments `, el("code", "k-inline", appr.comment), ` on ${appr.on}, and the waves run again.`);
|
|
@@ -488,18 +522,11 @@ export function renderKiosk(bar, s, now = new Date(), foot = null) {
|
|
|
488
522
|
|
|
489
523
|
if (foot) {
|
|
490
524
|
foot.appendChild(el("span", "k-legend", legendWords(s.view || "resources")));
|
|
525
|
+
// behold#571: no line on where the narration was read from: a viewer new to the demo reads the facts.
|
|
491
526
|
for (const f of factLines(demo, s.answer)) foot.appendChild(el("span", "k-fact", f));
|
|
492
|
-
foot.appendChild(
|
|
493
|
-
el("span", "k-source", `narration read ${clockOf(s.demo.read)} from ${s.demo.source}${demo.pull !== null ? ` · pull request #${demo.pull}` : ""}${demo.commit ? ` · commit ${demo.commit.slice(0, 8)}` : ""} · nothing here writes`),
|
|
494
|
-
);
|
|
495
527
|
}
|
|
496
528
|
}
|
|
497
529
|
|
|
498
|
-
const clockOf = (iso) => {
|
|
499
|
-
const d = new Date(iso);
|
|
500
|
-
return Number.isNaN(d.getTime()) ? iso : `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}:${String(d.getUTCSeconds()).padStart(2, "0")} UTC`;
|
|
501
|
-
};
|
|
502
|
-
|
|
503
530
|
/** How often the kiosk asks for demo.json. The server shares one read between kiosks. */
|
|
504
531
|
export const DEMO_POLL_MS = 3_000;
|
|
505
532
|
|
|
@@ -539,7 +566,10 @@ export function startKiosk({ bar, frame, foot, side, main, views }, { fetchJson
|
|
|
539
566
|
tellFrame(false);
|
|
540
567
|
};
|
|
541
568
|
// #569: the pipeline's runs beside the view, CI-page style.
|
|
542
|
-
|
|
569
|
+
// behold#571: the titles and names demo.json has carried, by scenario, for the runs' subtitles.
|
|
570
|
+
const titles = {};
|
|
571
|
+
const names = {};
|
|
572
|
+
if (side && main) startRuns({ side, main }, { fetchJson, now, scenarios: SCENARIOS, titles: () => titles, names: () => names });
|
|
543
573
|
// #571: whether the repo offers the components zoom (a terragucci.components.json), read before the frame opens so it opens on the step's view.
|
|
544
574
|
const openFrame = () => {
|
|
545
575
|
if (!frame) return;
|
|
@@ -577,6 +607,8 @@ export function startKiosk({ bar, frame, foot, side, main, views }, { fetchJson
|
|
|
577
607
|
const demo = async () => {
|
|
578
608
|
s.demo = await fetchJson("/api/terragucci/demo");
|
|
579
609
|
const d = s.demo && s.demo.demo;
|
|
610
|
+
if (d && d.scenario && typeof d.title === "string" && d.title) titles[d.scenario] = d.title;
|
|
611
|
+
if (d && d.scenario && scenarioName(d)) names[d.scenario] = scenarioName(d);
|
|
580
612
|
const key = d ? `${d.cycle}|${d.step}|${d.commit}|${d.pull}` : "";
|
|
581
613
|
if (key !== lastKey) {
|
|
582
614
|
lastKey = key;
|
package/web/kiosk.test.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { STEP_VIEW, STEP_WORDS, aboutRows, focusRoots, frameSrc, factLines, kioskView, legendWords, matchingPull, pendingApproval, renderKiosk, renderViewToggle, runPanel, runPin, stepView, viewZoom } from "./kiosk.js";
|
|
2
|
+
import { STEP_VIEW, STEP_WORDS, aboutRows, approveGloss, focusRoots, introText, scenarioHead, frameSrc, factLines, kioskView, legendWords, matchingPull, pendingApproval, renderKiosk, renderViewToggle, runPanel, runPin, stepView, viewZoom } from "./kiosk.js";
|
|
3
3
|
import { PICK_HOLD_MS } from "./kiosk-runs.js";
|
|
4
4
|
|
|
5
5
|
const demo = (step, extra = {}) => ({ cycle: 1, scenario: "a-output", title: "t", step, label: "", started_at: "2026-10-15T03:00:00Z", next: null, pull: 7, commit: "abc12345", ...extra });
|
|
@@ -220,3 +220,82 @@ describe("which view the frame shows (#571)", () => {
|
|
|
220
220
|
}
|
|
221
221
|
});
|
|
222
222
|
});
|
|
223
|
+
|
|
224
|
+
describe("the kiosk for a viewer new to the demo (behold#571)", () => {
|
|
225
|
+
const intro = "One change can touch many Terraform states; this demo shows how terragucci applies them in order.";
|
|
226
|
+
const fakeDocument = () => {
|
|
227
|
+
const make = (tag) => {
|
|
228
|
+
const n = { tagName: tag, className: "", textContent: "", children: [] };
|
|
229
|
+
n.appendChild = (c) => (n.children.push(c), c);
|
|
230
|
+
n.append = (...cs) => cs.forEach((c) => n.children.push(typeof c === "string" ? { tagName: "#text", textContent: c, children: [] } : c));
|
|
231
|
+
n.replaceChildren = () => (n.children = []);
|
|
232
|
+
return n;
|
|
233
|
+
};
|
|
234
|
+
return { createElement: make };
|
|
235
|
+
};
|
|
236
|
+
const find = (n, cls) => (n.className.split(" ").includes(cls) ? [n] : []).concat(...(n.children || []).map((c) => (c.className !== undefined ? find(c, cls) : [])));
|
|
237
|
+
const draw = (d) => {
|
|
238
|
+
const was = globalThis.document;
|
|
239
|
+
globalThis.document = fakeDocument();
|
|
240
|
+
try {
|
|
241
|
+
const bar = document.createElement("header");
|
|
242
|
+
const foot = document.createElement("footer");
|
|
243
|
+
renderKiosk(bar, { demo: { source: "s3://demo-reports/demo", read: "2026-10-15T03:00:00Z", demo: d }, answer: null, progress: null }, new Date("2026-10-15T03:01:00Z"), foot);
|
|
244
|
+
return { bar, foot };
|
|
245
|
+
} finally {
|
|
246
|
+
globalThis.document = was;
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
it("shows the intro first in the bar, and none without one", () => {
|
|
251
|
+
const { bar } = draw(demo("planned", { intro }));
|
|
252
|
+
expect(bar.children[0].className).toBe("k-intro");
|
|
253
|
+
expect(bar.children[0].textContent).toBe(intro);
|
|
254
|
+
expect(find(draw(demo("planned")).bar, "k-intro")).toEqual([]);
|
|
255
|
+
expect(find(draw(demo("planned", { intro: null })).bar, "k-intro")).toEqual([]);
|
|
256
|
+
expect(introText(demo("planned", { intro: " " }))).toBe(null);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("says which scenario of how many, with the title and no short label", () => {
|
|
260
|
+
expect(scenarioHead(demo("planned", { index: 2, scenarios: 5, title: "The same change, read once it is built" }))).toEqual({ kicker: "Scenario 2 of 5", title: "The same change, read once it is built" });
|
|
261
|
+
const { bar } = draw(demo("planned", { index: 2, scenarios: 5, title: "The same change, read once it is built" }));
|
|
262
|
+
expect(find(bar, "k-kicker")[0].textContent).toBe("Scenario 2 of 5");
|
|
263
|
+
expect(find(bar, "k-title-text")[0].textContent).toBe("The same change, read once it is built");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("leaves the count out when scenarios is missing, 0 or less than the index", () => {
|
|
267
|
+
expect(scenarioHead(demo("planned", { index: 2, title: "t" })).kicker).toBe("Scenario 2");
|
|
268
|
+
expect(scenarioHead(demo("planned", { index: 2, scenarios: 0, title: "t" })).kicker).toBe("Scenario 2");
|
|
269
|
+
expect(scenarioHead(demo("planned", { index: 6, scenarios: 5, title: "t" })).kicker).toBe("Scenario 6");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("falls back to the short label without an index, demo.json's name before the table's", () => {
|
|
273
|
+
expect(scenarioHead(demo("planned", { title: "t" }))).toEqual({ kicker: "scenario 2 · A, read through .output", title: "t" });
|
|
274
|
+
expect(scenarioHead(demo("planned", { index: 0, scenarios: 5 })).kicker).toBe("scenario 2 · A, read through .output");
|
|
275
|
+
expect(scenarioHead(demo("planned", { index: null, scenarios: 0, name: "the same change, read late" })).kicker).toBe("scenario 2 · the same change, read late");
|
|
276
|
+
expect(scenarioHead(demo("reset", { scenario: "base", title: "The base", index: null, scenarios: 0, name: null }))).toEqual({ kicker: "The base", title: "The base" });
|
|
277
|
+
expect(scenarioHead(demo("planned", { scenario: "new-one", title: "A new one" }))).toEqual({ kicker: "scenario new-one", title: "A new one" });
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("has no line on where the narration was read from", () => {
|
|
281
|
+
const { foot } = draw(demo("planned", { intro }));
|
|
282
|
+
expect(JSON.stringify(foot)).not.toMatch(/narration read|s3:\/\/|nothing here writes/);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("says who the driver is before the command it runs", () => {
|
|
286
|
+
expect(approveGloss({ wave: 2, roots: ["app"], digest: "jcs1-sha256:0accc105ba06" })).toBe("The driver stands in for a person: it approves wave 2 (app) with its own signing key.");
|
|
287
|
+
expect(approveGloss({ wave: 3, roots: [], digest: null })).toBe("The driver stands in for a person: it approves wave 3 with its own signing key.");
|
|
288
|
+
const p = progress([{ number: 2, state: "waiting", roots: ["app"], reads: [], digest: "jcs1-sha256:0accc105ba06" }]);
|
|
289
|
+
const was = globalThis.document;
|
|
290
|
+
globalThis.document = fakeDocument();
|
|
291
|
+
try {
|
|
292
|
+
const bar = document.createElement("header");
|
|
293
|
+
renderKiosk(bar, { demo: { source: "fx", read: "2026-10-15T03:00:00Z", demo: demo("waiting", { next: { action: "approve wave-2", at: null } }) }, answer: null, progress: p }, new Date("2026-10-15T03:01:00Z"));
|
|
294
|
+
const card = find(bar, "k-approve")[0];
|
|
295
|
+
expect(card.children.map((c) => c.className)).toEqual(["k-approve-gloss", "k-command", "k-approve-then"]);
|
|
296
|
+
expect(card.children[1].textContent).toBe("chant approve tf-apply wave-2 --actor <driver> --sign <driver key>");
|
|
297
|
+
} finally {
|
|
298
|
+
globalThis.document = was;
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
});
|