@intentius/behold 0.29.5 → 0.29.7
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 +6 -1
- package/dist/cli.js +48 -6
- package/package.json +1 -1
- package/web/app.js +268 -2
- package/web/embed.js +20 -0
- package/web/embed.test.js +10 -1
- package/web/index.html +22 -0
- package/web/kiosk-popover.js +234 -0
- package/web/kiosk-popover.test.js +180 -0
- package/web/kiosk-roots.js +191 -0
- package/web/kiosk-roots.test.js +95 -0
- package/web/kiosk-runs.js +139 -18
- package/web/kiosk-runs.test.js +53 -1
- package/web/kiosk.html +21 -12
- package/web/kiosk.js +145 -38
- package/web/kiosk.test.js +80 -1
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { ROOT_HUES, cssString, hoverCss, hoverFromMessage, hoverLinks, hoverMessage, isLightBg, rootColour, rootOrder, rootSegments, rootsOfWave, waveOfJob } from "./kiosk-roots.js";
|
|
3
|
+
|
|
4
|
+
// behold#571: the demo repo's roots and the wave jobs of its terragucci.components.json.
|
|
5
|
+
const ROOTS = ["network", "other", "app", "edge"];
|
|
6
|
+
const WAVES = [
|
|
7
|
+
{ job: "apply-wave-1", wave: 1, roots: ["network", "other"] },
|
|
8
|
+
{ job: "apply-wave-2", wave: 2, roots: ["app"] },
|
|
9
|
+
{ job: "apply-wave-3", wave: 3, roots: ["edge"] },
|
|
10
|
+
];
|
|
11
|
+
const names = (text) => rootSegments(text, ROOTS).filter((s) => s.root).map((s) => s.root);
|
|
12
|
+
|
|
13
|
+
describe("a colour per root (behold#571)", () => {
|
|
14
|
+
it("follows the sorted root list, so it is the same on every cycle and in any order", () => {
|
|
15
|
+
expect(rootOrder(ROOTS)).toEqual(["app", "edge", "network", "other"]);
|
|
16
|
+
const a = Object.fromEntries(ROOTS.map((r) => [r, rootColour(r, ROOTS)]));
|
|
17
|
+
const b = Object.fromEntries(ROOTS.map((r) => [r, rootColour(r, [...ROOTS].reverse())]));
|
|
18
|
+
expect(a).toEqual(b);
|
|
19
|
+
expect(a.app).toBe(ROOT_HUES.dark[0]);
|
|
20
|
+
expect(new Set(Object.values(a)).size).toBe(4);
|
|
21
|
+
expect(rootColour("app", ROOTS, { light: true })).toBe(ROOT_HUES.light[0]);
|
|
22
|
+
expect(rootColour("nope", ROOTS)).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("uses no state colour: not the green, amber, red, blue or teal of the theme", () => {
|
|
26
|
+
const state = ["#a6e3a1", "#f9e2af", "#f38ba8", "#89b4fa", "#94e2d5", "#6d7187"];
|
|
27
|
+
for (const hue of [...ROOT_HUES.dark, ...ROOT_HUES.light]) expect(state).not.toContain(hue.toLowerCase());
|
|
28
|
+
expect(new Set(ROOT_HUES.dark).size).toBe(ROOT_HUES.dark.length);
|
|
29
|
+
expect(new Set(ROOT_HUES.light).size).toBe(ROOT_HUES.light.length);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("tells a light background from a dark one", () => {
|
|
33
|
+
expect(isLightBg("#ffffff")).toBe(true);
|
|
34
|
+
expect(isLightBg(" #1e1e2e")).toBe(false);
|
|
35
|
+
expect(isLightBg("")).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("root names in a line (behold#571)", () => {
|
|
40
|
+
it("finds whole-word names, possessives and names in brackets, and keeps the line's text", () => {
|
|
41
|
+
const line = "app reads subnet_id from network, known only after network applies: the review does not show the value app gets.";
|
|
42
|
+
expect(names(line)).toEqual(["app", "network", "network", "app"]);
|
|
43
|
+
expect(rootSegments(line, ROOTS).map((s) => s.text).join("")).toBe(line);
|
|
44
|
+
expect(names("approves wave 2 (app) with network's key")).toEqual(["app", "network"]);
|
|
45
|
+
expect(names("app: Plan: 0 to add, 1 to change, 0 to destroy.")).toEqual(["app"]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("leaves names inside addresses, hosts, URLs, paths, hashes and longer words alone", () => {
|
|
49
|
+
expect(names("terraform_data.app_x and data.terraform_remote_state.network")).toEqual([]);
|
|
50
|
+
expect(names("upstream http://app:9080 and app:8070")).toEqual([]);
|
|
51
|
+
expect(names("report copied to demo/forgejo:3000/terragucci-admin/demo/2026/10/ef95/tf-apply-wave-1")).toEqual([]);
|
|
52
|
+
expect(names("set digest jcs1-sha256:0accc105ba06 and commit a1b2c3d4e5")).toEqual([]);
|
|
53
|
+
expect(names("apply-wave-2 appliance networks edge-proxy network.tfstate")).toEqual([]);
|
|
54
|
+
expect(names("key=network")).toEqual([]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("says nothing without roots", () => {
|
|
58
|
+
expect(rootSegments("network", [])).toEqual([{ text: "network" }]);
|
|
59
|
+
expect(rootSegments("", ROOTS)).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("hover links between the panes (behold#571)", () => {
|
|
64
|
+
it("ties a wave's job to its roots, from the declared jobs or the job's name", () => {
|
|
65
|
+
expect(waveOfJob("apply-wave-2", WAVES)).toBe(2);
|
|
66
|
+
expect(waveOfJob("apply-wave-7", [])).toBe(7);
|
|
67
|
+
expect(waveOfJob("check", WAVES)).toBeNull();
|
|
68
|
+
expect(rootsOfWave(1, WAVES)).toEqual(["network", "other"]);
|
|
69
|
+
expect(rootsOfWave(9, WAVES)).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("lights a root's mentions and its wave, and a wave's job and its roots", () => {
|
|
73
|
+
expect(hoverLinks({ root: "other" }, WAVES)).toEqual({ roots: ["other"], waves: [1] });
|
|
74
|
+
expect(hoverLinks({ wave: 1 }, WAVES)).toEqual({ roots: ["network", "other"], waves: [1] });
|
|
75
|
+
expect(hoverLinks({ root: "lonely" }, WAVES)).toEqual({ roots: ["lonely"], waves: [] });
|
|
76
|
+
expect(hoverLinks(null, WAVES)).toEqual({ roots: [], waves: [] });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("carries a hover between the frame and the kiosk as a message, and takes only that message", () => {
|
|
80
|
+
expect(hoverMessage({ root: "app" })).toEqual({ type: "behold:hover", root: "app", wave: null });
|
|
81
|
+
expect(hoverMessage(null)).toEqual({ type: "behold:hover", root: null, wave: null });
|
|
82
|
+
expect(hoverFromMessage(hoverMessage({ wave: 2 }))).toEqual({ wave: 2 });
|
|
83
|
+
expect(hoverFromMessage(hoverMessage({ root: "app" }))).toEqual({ root: "app" });
|
|
84
|
+
expect(hoverFromMessage(hoverMessage(null))).toBeNull();
|
|
85
|
+
expect(hoverFromMessage({ type: "behold:view" })).toBeUndefined();
|
|
86
|
+
expect(hoverFromMessage("behold:hover")).toBeUndefined();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("writes one rule for what a hover lights, quoting names", () => {
|
|
90
|
+
const css = hoverCss(hoverLinks({ wave: 2 }, WAVES), { root: (r) => `.k-root[data-k-root=${cssString(r)}]`, wave: (w) => `.r-job[data-k-wave="${w}"]` }, "outline: 1px solid");
|
|
91
|
+
expect(css).toBe('.k-root[data-k-root="app"],\n.r-job[data-k-wave="2"] { outline: 1px solid }');
|
|
92
|
+
expect(hoverCss({ roots: [], waves: [] }, { root: () => "x", wave: () => "y" }, "z")).toBe("");
|
|
93
|
+
expect(cssString('a"b')).toBe('"a\\"b"');
|
|
94
|
+
});
|
|
95
|
+
});
|
package/web/kiosk-runs.js
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
// A job held at a terragucci gate is `waiting`: it waits for an approval, and
|
|
11
11
|
// is never drawn as a failure. Step names are shown as the driver wrote them.
|
|
12
12
|
|
|
13
|
+
import { rootColour, rootSegments, rootText, rootsOfWave, waveOfJob } from "./kiosk-roots.js";
|
|
14
|
+
|
|
13
15
|
/** The run, job and step statuses, as a viewer reads them. */
|
|
14
16
|
export const STATUS_WORDS = {
|
|
15
17
|
queued: "queued",
|
|
@@ -227,12 +229,88 @@ export function agoWords(at, now = new Date()) {
|
|
|
227
229
|
return `${Math.floor(s / 86400)} d ago`;
|
|
228
230
|
}
|
|
229
231
|
|
|
230
|
-
/**
|
|
231
|
-
|
|
232
|
-
|
|
232
|
+
/**
|
|
233
|
+
* behold#571: whether a scenario's short label reads as code to someone new
|
|
234
|
+
* to the demo: a lettered label ("A, read through .output", "B on waves"), a
|
|
235
|
+
* code token in it, or the bare id ("a-output").
|
|
236
|
+
*/
|
|
237
|
+
export function codeish(label) {
|
|
238
|
+
return typeof label === "string" && (/^[A-Z]\b/.test(label) || /\.[A-Za-z]|[_`]/.test(label) || /^[a-z0-9]+(?:-[a-z0-9]+)+$/.test(label));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const MERGE_TITLE = /^Merge pull request '(.+)' \(#\d+\)/;
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* behold#571: each scenario's title in plain words, by id: from the merge
|
|
245
|
+
* runs' titles (the pull request is titled after its scenario), and from
|
|
246
|
+
* `known` (the titles demo.json has carried, which win).
|
|
247
|
+
*/
|
|
248
|
+
export function scenarioTitles(runs, known = {}) {
|
|
249
|
+
const out = {};
|
|
250
|
+
for (const r of [...(runs || [])].reverse()) {
|
|
251
|
+
const m = r && r.scenario && typeof r.title === "string" ? MERGE_TITLE.exec(r.title) : null;
|
|
252
|
+
if (m) out[r.scenario] = m[1];
|
|
253
|
+
}
|
|
254
|
+
for (const [id, title] of Object.entries(known || {})) if (typeof title === "string" && title) out[id] = title;
|
|
255
|
+
return out;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The scenario and the cycle under a run: "a failure mid-apply · cycle 90".
|
|
260
|
+
* The scenario's name as demo.json last carried it (`names`, terragucci#1050)
|
|
261
|
+
* comes first, then the kiosk's SCENARIOS label; a label that reads as code
|
|
262
|
+
* gives way to the scenario's title when one is known (behold#571).
|
|
263
|
+
*/
|
|
264
|
+
export function placeWords(run, scenarios, titles = {}, names = {}) {
|
|
265
|
+
const own = (m) => !!(run.scenario && m && Object.prototype.hasOwnProperty.call(m, run.scenario));
|
|
266
|
+
const short = own(names) && names[run.scenario] ? names[run.scenario] : own(scenarios) ? scenarios[run.scenario].name : run.scenario;
|
|
267
|
+
const title = own(titles) ? titles[run.scenario] : null;
|
|
268
|
+
const sc = title && (!short || codeish(short)) ? title : short;
|
|
233
269
|
return [sc, run.cycle !== null && run.cycle !== undefined ? `cycle ${run.cycle}` : null].filter(Boolean).join(" · ");
|
|
234
270
|
}
|
|
235
271
|
|
|
272
|
+
/**
|
|
273
|
+
* behold#571: a runner's or an action's set-up line, not terragucci's own:
|
|
274
|
+
* git's hints, the checkout's `[command]` lines and groups, the runner's
|
|
275
|
+
* docker and fetch lines.
|
|
276
|
+
*/
|
|
277
|
+
export const SETUP_LINES = [
|
|
278
|
+
/^hint:/,
|
|
279
|
+
/^\[command\]/,
|
|
280
|
+
/^##\[(?:group|endgroup)\]/,
|
|
281
|
+
/^Initialized empty Git repository\b/,
|
|
282
|
+
/^::(?:add|remove)-matcher\b/,
|
|
283
|
+
/^\s*❓\s*add-matcher\b/u,
|
|
284
|
+
/^\s*🐳\s+docker\b/u,
|
|
285
|
+
/^\s*☁️\s+git fetch\b/u,
|
|
286
|
+
/^git version \d/,
|
|
287
|
+
/^Temporarily overriding HOME=/,
|
|
288
|
+
/^Adding repository directory to the temporary git global config\b/,
|
|
289
|
+
/^Deleting the contents of '/,
|
|
290
|
+
/^Syncing repository: /,
|
|
291
|
+
/^Working directory is '/,
|
|
292
|
+
];
|
|
293
|
+
|
|
294
|
+
/** Whether a log line is set-up noise (SETUP_LINES). */
|
|
295
|
+
export function setupLine(line) {
|
|
296
|
+
return typeof line === "string" && SETUP_LINES.some((re) => re.test(line));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* behold#571: a step's log as the kiosk draws it. The set-up lines fold into
|
|
301
|
+
* one row at the top, `{ setup: n }`, so terragucci's own lines come first;
|
|
302
|
+
* opened (`open`), the row heads the whole log in order. Every line keeps
|
|
303
|
+
* its number in the log. A log with no set-up lines is drawn as it came.
|
|
304
|
+
*/
|
|
305
|
+
export function logRows(lines, { open = false, from = 0 } = {}) {
|
|
306
|
+
const all = (lines || []).map((text, i) => ({ n: from + i + 1, text, setup: setupLine(text) }));
|
|
307
|
+
const n = all.filter((l) => l.setup).length;
|
|
308
|
+
if (!n) return all.map(({ n: num, text }) => ({ n: num, text }));
|
|
309
|
+
const head = { setup: n, open };
|
|
310
|
+
const body = open ? all : all.filter((l) => !l.setup);
|
|
311
|
+
return [head, ...body.map(({ n: num, text, setup }) => (open && setup ? { n: num, text, quiet: true } : { n: num, text }))];
|
|
312
|
+
}
|
|
313
|
+
|
|
236
314
|
// ---------------------------------------------------------------------------
|
|
237
315
|
// Drawing.
|
|
238
316
|
|
|
@@ -277,7 +355,7 @@ export function statusIcon(status) {
|
|
|
277
355
|
}
|
|
278
356
|
|
|
279
357
|
/** The sidebar: the live run pinned, then the recent ones. `pick(id)` selects one; nothing here writes. */
|
|
280
|
-
export function renderSidebar(host, index, selectedId, { now = new Date(), scenarios = {}, pick = () => {}, merged = null } = {}) {
|
|
358
|
+
export function renderSidebar(host, index, selectedId, { now = new Date(), scenarios = {}, titles = {}, names = {}, pick = () => {}, merged = null } = {}) {
|
|
281
359
|
host.replaceChildren();
|
|
282
360
|
const head = el("div", "r-side-head");
|
|
283
361
|
head.appendChild(el("div", "r-side-title", "Pipeline runs"));
|
|
@@ -290,6 +368,7 @@ export function renderSidebar(host, index, selectedId, { now = new Date(), scena
|
|
|
290
368
|
// The selected run as its page draws it, when its own document is newer than the index.
|
|
291
369
|
const runs = sidebarRuns(index.doc.runs).map((r) => (merged && merged.id === r.id ? merged : r));
|
|
292
370
|
const live = runs[0];
|
|
371
|
+
const named = scenarioTitles(index.doc.runs, titles);
|
|
293
372
|
const list = el("ol", "r-runs");
|
|
294
373
|
runs.forEach((r, i) => {
|
|
295
374
|
if (i === 0) list.appendChild(el("li", "r-group", LIVE.has(live.status) ? "Live" : "Latest"));
|
|
@@ -300,7 +379,7 @@ export function renderSidebar(host, index, selectedId, { now = new Date(), scena
|
|
|
300
379
|
li.appendChild(statusIcon(r.status));
|
|
301
380
|
const body = el("div", "r-run-body");
|
|
302
381
|
body.appendChild(el("div", "r-run-title", triggerWords(r)));
|
|
303
|
-
const place = placeWords(r, scenarios);
|
|
382
|
+
const place = placeWords(r, scenarios, named, names);
|
|
304
383
|
if (place) body.appendChild(el("div", "r-run-place", place));
|
|
305
384
|
const meta = el("div", "r-run-meta");
|
|
306
385
|
meta.append(
|
|
@@ -328,7 +407,14 @@ export function renderSidebar(host, index, selectedId, { now = new Date(), scena
|
|
|
328
407
|
* null for the defaults. Returns what it drew, so the caller can keep a log's
|
|
329
408
|
* scroll.
|
|
330
409
|
*/
|
|
331
|
-
export function renderRun(host, answer, ui, { now = new Date(), pickJob = () => {}, toggleStep = () => {}, entry = null } = {}) {
|
|
410
|
+
export function renderRun(host, answer, ui, { now = new Date(), pickJob = () => {}, toggleStep = () => {}, toggleSetup = () => {}, entry = null, roots = [], waves = [], light = false } = {}) {
|
|
411
|
+
// behold#571: a root's name with its colour's dot, where the line names one.
|
|
412
|
+
const textOf = (cls, text) => {
|
|
413
|
+
if (!roots.length || !rootSegments(text, roots).some((x) => x.root)) return el("span", cls, text);
|
|
414
|
+
const n = el("span", cls);
|
|
415
|
+
n.appendChild(rootText(document, text, roots, { light }));
|
|
416
|
+
return n;
|
|
417
|
+
};
|
|
332
418
|
host.replaceChildren();
|
|
333
419
|
if (!answer || answer.error || answer.absent || !answer.doc) {
|
|
334
420
|
host.appendChild(el("p", "r-note", answer ? answer.absent || answer.error : "Reading the run…"));
|
|
@@ -363,6 +449,20 @@ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () =>
|
|
|
363
449
|
li.style.setProperty("--depth", String(j.depth));
|
|
364
450
|
li.appendChild(statusIcon(j.status));
|
|
365
451
|
li.appendChild(el("span", "r-job-name", j.name));
|
|
452
|
+
// behold#571: a wave's job row names the roots it applies, each with its dot, and lights its wave box on hover.
|
|
453
|
+
const wave = waveOfJob(j.name, waves);
|
|
454
|
+
const applies = wave !== null ? rootsOfWave(wave, waves).filter((r) => rootColour(r, roots, { light })) : [];
|
|
455
|
+
if (wave !== null && waves.length) li.dataset.kWave = String(wave);
|
|
456
|
+
if (applies.length) {
|
|
457
|
+
const box = el("span", "r-job-roots");
|
|
458
|
+
for (const r of applies) {
|
|
459
|
+
const name = el("span", "k-root", r);
|
|
460
|
+
name.dataset.kRoot = r;
|
|
461
|
+
name.style.setProperty("--k-root", rootColour(r, roots, { light }));
|
|
462
|
+
box.appendChild(name);
|
|
463
|
+
}
|
|
464
|
+
li.appendChild(box);
|
|
465
|
+
}
|
|
366
466
|
if (j.needs && j.needs.length) li.appendChild(el("span", "r-job-needs", `after ${j.needs.join(", ")}`));
|
|
367
467
|
li.appendChild(el("span", "r-dur", j.status === "waiting" ? "held at the gate" : durationWords(j.started_at, j.ended_at, now) || STATUS_WORDS[j.status] || ""));
|
|
368
468
|
li.addEventListener("click", () => pickJob(j.name));
|
|
@@ -398,12 +498,25 @@ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () =>
|
|
|
398
498
|
if (i === open) {
|
|
399
499
|
const pre = el("pre", "r-log");
|
|
400
500
|
if (s.truncated) pre.appendChild(el("div", "r-log-cut", `… ${s.truncated} earlier lines not kept`));
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
501
|
+
const key = logKey(run.id, job.name, i);
|
|
502
|
+
for (const row of logRows(s.log, { open: ui.setup === key, from: s.truncated || 0 })) {
|
|
503
|
+
if (row.setup) {
|
|
504
|
+
const fold = el("div", `r-log-setup${row.open ? " r-log-setup-open" : ""}`, `${row.open ? "▾" : "▸"} setup (${row.setup} line${row.setup === 1 ? "" : "s"})`);
|
|
505
|
+
fold.setAttribute("role", "button");
|
|
506
|
+
fold.tabIndex = 0;
|
|
507
|
+
fold.setAttribute("aria-expanded", String(row.open));
|
|
508
|
+
fold.addEventListener("click", () => toggleSetup(key));
|
|
509
|
+
fold.addEventListener("keydown", (e) => {
|
|
510
|
+
if (e.key === "Enter" || e.key === " ") toggleSetup(key);
|
|
511
|
+
});
|
|
512
|
+
pre.appendChild(fold);
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
const ln = el("div", `r-log-line${row.quiet ? " r-log-quiet" : ""}`);
|
|
516
|
+
const shown = logLineWords(job.status, row.text);
|
|
517
|
+
ln.append(el("span", "r-ln", String(row.n)), row.quiet ? el("span", shown.held ? "r-lt r-wait" : "r-lt", shown.text) : textOf(shown.held ? "r-lt r-wait" : "r-lt", shown.text));
|
|
405
518
|
pre.appendChild(ln);
|
|
406
|
-
}
|
|
519
|
+
}
|
|
407
520
|
if (!s.log.length) pre.appendChild(el("div", "r-log-cut", s.status === "running" ? "waiting for output…" : "no output"));
|
|
408
521
|
if (s.status === "running") pre.appendChild(el("div", "r-log-live", "▍"));
|
|
409
522
|
row.appendChild(pre);
|
|
@@ -447,8 +560,8 @@ export const PICK_HOLD_MS = 90_000;
|
|
|
447
560
|
* is live), and redraw. The live run is selected until a viewer picks
|
|
448
561
|
* another, and again {@link PICK_HOLD_MS} after their last pick.
|
|
449
562
|
*/
|
|
450
|
-
export function startRuns({ side, main }, { fetchJson, now = () => new Date(), scenarios = {}, onLive = () => {} } = {}) {
|
|
451
|
-
const s = { index: null, run: null, selected: null, picked: 0, ui: { job: null, open: null } };
|
|
563
|
+
export function startRuns({ side, main }, { fetchJson, now = () => new Date(), scenarios = {}, titles = () => ({}), names = () => ({}), onLive = () => {}, roots = () => [], waves = () => [], light = () => false } = {}) {
|
|
564
|
+
const s = { index: null, run: null, selected: null, picked: 0, ui: { job: null, open: null, setup: null } };
|
|
452
565
|
let follow = true;
|
|
453
566
|
let lastRunKey = "";
|
|
454
567
|
// The open log and whether the pane follows its end; a viewer who scrolls up stops it until they come back.
|
|
@@ -470,7 +583,7 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
|
|
|
470
583
|
const ans = s.run && (!s.run.doc || s.run.doc.id === id) ? s.run : null;
|
|
471
584
|
// One merged read of the selected run for both the sidebar and its page.
|
|
472
585
|
const merged = ans && ans.doc ? mergeRun(entry, ans.doc) : null;
|
|
473
|
-
renderSidebar(side, s.index, id, { now: now(), scenarios, pick, merged });
|
|
586
|
+
renderSidebar(side, s.index, id, { now: now(), scenarios, titles: titles(), names: names(), pick, merged });
|
|
474
587
|
// The redraw replaces the scrollers: keep where a viewer left them.
|
|
475
588
|
const old = main.querySelector(".r-log");
|
|
476
589
|
const oldSteps = main.querySelector(".r-steps");
|
|
@@ -479,13 +592,21 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
|
|
|
479
592
|
const drawn = renderRun(main, ans, s.ui, {
|
|
480
593
|
now: now(),
|
|
481
594
|
entry,
|
|
595
|
+
roots: roots(),
|
|
596
|
+
waves: waves(),
|
|
597
|
+
light: light(),
|
|
482
598
|
pickJob: (name) => {
|
|
483
|
-
s.ui = { job: name, open: null };
|
|
599
|
+
s.ui = { job: name, open: null, setup: null };
|
|
484
600
|
hold();
|
|
485
601
|
draw();
|
|
486
602
|
},
|
|
487
603
|
toggleStep: (job, i) => {
|
|
488
|
-
s.ui = { job, open: i };
|
|
604
|
+
s.ui = { job, open: i, setup: null };
|
|
605
|
+
hold();
|
|
606
|
+
draw();
|
|
607
|
+
},
|
|
608
|
+
toggleSetup: (key) => {
|
|
609
|
+
s.ui = { ...s.ui, setup: s.ui.setup === key ? null : key };
|
|
489
610
|
hold();
|
|
490
611
|
draw();
|
|
491
612
|
},
|
|
@@ -528,7 +649,7 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
|
|
|
528
649
|
s.selected = id;
|
|
529
650
|
follow = false;
|
|
530
651
|
s.picked = now().getTime();
|
|
531
|
-
s.ui = { job: null, open: null };
|
|
652
|
+
s.ui = { job: null, open: null, setup: null };
|
|
532
653
|
void readRun();
|
|
533
654
|
};
|
|
534
655
|
const readIndex = async () => {
|
|
@@ -545,7 +666,7 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
|
|
|
545
666
|
// A run that ended reads once; one still going reads on every tick.
|
|
546
667
|
const key = `${id}|${entry ? entry.status : ""}|${entry ? entry.ended_at : ""}`;
|
|
547
668
|
if (key === lastRunKey && entry && !LIVE.has(entry.status) && s.run && s.run.doc && s.run.doc.id === id) return draw();
|
|
548
|
-
if (s.run && s.run.doc && s.run.doc.id !== id) s.ui = { job: null, open: null };
|
|
669
|
+
if (s.run && s.run.doc && s.run.doc.id !== id) s.ui = { job: null, open: null, setup: null };
|
|
549
670
|
lastRunKey = key;
|
|
550
671
|
s.run = await fetchJson(`/api/terragucci/runs/${id}`);
|
|
551
672
|
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, logLineWords, 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"));
|
|
@@ -131,3 +131,55 @@ describe("the log pane follows its end, like a CI page's log", () => {
|
|
|
131
131
|
expect(nearEnd({ scrollTop: 0, clientHeight: 400, scrollHeight: 1000 }, slack)).toBe(false);
|
|
132
132
|
});
|
|
133
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
|
@@ -58,6 +58,9 @@
|
|
|
58
58
|
.r-job.r-selected { background: var(--active); }
|
|
59
59
|
.r-job-name { font: 600 0.92em var(--font-mono); }
|
|
60
60
|
.r-job-needs { color: var(--muted); font-size: 0.78em; }
|
|
61
|
+
/* behold#571: each root's colour, as a dot by its name; never a text or fill colour, which mean a state. */
|
|
62
|
+
.k-root::before { content: ""; display: inline-block; width: 0.6em; height: 0.6em; border-radius: 50%; background: var(--k-root); margin: 0 0.28em 0.1em 0.05em; vertical-align: middle; }
|
|
63
|
+
.r-job-roots { display: inline-flex; gap: 0.75em; font-size: 0.82em; color: var(--fg); }
|
|
61
64
|
.r-dur { margin-left: auto; color: var(--muted); font-size: 0.8em; white-space: nowrap; }
|
|
62
65
|
.r-job-skipped { color: var(--muted); font-size: 0.78em; cursor: default; }
|
|
63
66
|
.r-steps { display: flex; flex-direction: column; min-height: 0; flex: 1; overflow-y: auto; padding-bottom: 0.5em; }
|
|
@@ -74,30 +77,36 @@
|
|
|
74
77
|
white-space: pre-wrap; overflow-wrap: anywhere; overflow-y: auto; max-height: 52vh; min-height: 6em; }
|
|
75
78
|
.r-log-line { display: grid; grid-template-columns: 3.2em 1fr; gap: 0.8em; padding: 0 0.8em 0 0; }
|
|
76
79
|
.r-ln { color: var(--muted); text-align: right; user-select: none; opacity: 0.7; }
|
|
80
|
+
/* behold#571: the runner's set-up lines, folded into one row so terragucci's own lines come first. */
|
|
81
|
+
.r-log-setup { color: var(--muted); padding: 0 0.8em 0.2em 4em; cursor: pointer; user-select: none; }
|
|
82
|
+
.r-log-setup:hover { color: var(--fg); }
|
|
83
|
+
.r-log-quiet .r-lt { color: var(--muted); }
|
|
77
84
|
.r-log-cut { color: var(--muted); padding: 0 0.8em 0.2em 4em; font-style: italic; }
|
|
78
85
|
.r-log-live { color: var(--foreign); padding-left: 4em; }
|
|
79
86
|
@media (prefers-reduced-motion: no-preference) { .r-log-live { animation: r-blink 1s steps(2) infinite; } }
|
|
80
87
|
@keyframes r-blink { 50% { opacity: 0; } }
|
|
81
88
|
.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.
|
|
89
|
+
#bar { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); gap: 0.45em 1.6em; align-items: start;
|
|
90
|
+
padding: 0.55em 1.4em 0.6em; background: var(--panel); border-bottom: 1px solid var(--rule); }
|
|
84
91
|
#bar > .k-empty { grid-column: 1 / -1; }
|
|
85
|
-
|
|
86
|
-
.k-
|
|
87
|
-
.k-
|
|
92
|
+
/* behold#571: the problem the demo solves, always on, across the bar's top. */
|
|
93
|
+
.k-intro { grid-column: 1 / -1; margin: 0; font-size: 1em; line-height: 1.35; color: var(--fg); }
|
|
94
|
+
.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; }
|
|
95
|
+
.k-cycle { color: var(--muted); font: 0.78em var(--font-mono); white-space: nowrap; }
|
|
96
|
+
.k-title { margin: 0 0 0.2em; font-size: 1.35em; line-height: 1.2; }
|
|
88
97
|
/* 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.
|
|
98
|
+
.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
99
|
.k-about-label { font: 600 0.74em var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
|
|
91
100
|
.k-about-text { margin: 0; color: var(--fg); }
|
|
92
101
|
.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; }
|
|
102
|
+
.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; }
|
|
103
|
+
.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
104
|
.k-step-waiting { border-left-color: var(--foreign); }
|
|
97
105
|
.k-step-failed, .k-step-stalled { border-left-color: var(--degraded); }
|
|
98
106
|
.k-step-applied { border-left-color: var(--managed); }
|
|
99
107
|
.k-step-reset { border-left-color: var(--neutral); }
|
|
100
|
-
.k-right { display: grid; gap: 0.
|
|
108
|
+
.k-right { display: grid; gap: 0.45em; align-content: start; }
|
|
109
|
+
.k-where { display: flex; flex-wrap: wrap; gap: 0.35em 1em; align-items: center; }
|
|
101
110
|
.k-strip { list-style: none; display: flex; flex-wrap: wrap; gap: 0.35em; padding: 0; margin: 0; }
|
|
102
111
|
.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
112
|
.k-done { color: var(--fg); border-color: var(--managed); }
|
|
@@ -109,7 +118,8 @@
|
|
|
109
118
|
.k-when { color: var(--foreign); font-family: var(--font-mono); }
|
|
110
119
|
.k-approve { border-left: 4px solid var(--foreign); padding-left: 0.8em; }
|
|
111
120
|
.k-approve p { margin: 0.15em 0; }
|
|
112
|
-
.k-approve-
|
|
121
|
+
.k-approve-gloss { color: var(--fg); font-size: 0.92em; }
|
|
122
|
+
.k-approve-then { color: var(--muted); font-size: 0.92em; }
|
|
113
123
|
.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
124
|
.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
125
|
.k-note { color: var(--muted); margin: 0; font-size: 0.92em; }
|
|
@@ -129,7 +139,6 @@
|
|
|
129
139
|
#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
140
|
.k-legend { color: var(--muted); flex-basis: 100%; }
|
|
131
141
|
.k-fact { color: var(--fg); }
|
|
132
|
-
.k-source { color: var(--muted); margin-left: auto; }
|
|
133
142
|
@media (max-width: 900px) { #bar { grid-template-columns: 1fr; } #stage { grid-template-columns: 1fr; } }
|
|
134
143
|
</style>
|
|
135
144
|
</head>
|