@intentius/behold 0.29.0 → 0.29.2
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/README.md +17 -4
- package/dist/cli.js +281 -125
- package/package.json +1 -1
- package/web/app.js +348 -51
- package/web/embed.js +6 -3
- package/web/embed.test.js +15 -1
- package/web/index.html +7 -0
- package/web/kiosk-runs.js +485 -0
- package/web/kiosk-runs.test.js +98 -0
- package/web/kiosk.html +98 -29
- package/web/kiosk.js +177 -83
- package/web/kiosk.test.js +65 -0
- package/web/terragucci-change.js +148 -3
- package/web/terragucci-change.test.js +99 -0
- package/web/view-url.js +24 -2
- package/web/view-url.test.js +23 -0
package/web/kiosk.html
CHANGED
|
@@ -20,48 +20,117 @@
|
|
|
20
20
|
--font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
21
21
|
}
|
|
22
22
|
* { box-sizing: border-box; }
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
23
|
+
/* #569 step 3: one screen. The bar is the narration, the frame is
|
|
24
|
+
behold's own view of the change, the foot is the plan's facts. Sizes
|
|
25
|
+
follow the screen's width, so a TV reads it from across a room. */
|
|
26
|
+
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--fg); font: clamp(13px, 0.82vw, 20px)/1.4 var(--font-ui); overflow: hidden; }
|
|
27
|
+
body { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; }
|
|
28
|
+
/* #569: the pipeline's runs (left), the selected run's jobs, steps and
|
|
29
|
+
log (middle), behold's view of the change (right). */
|
|
30
|
+
#stage { display: grid; grid-template-columns: minmax(15em, 18%) minmax(26em, 34%) minmax(0, 1fr); min-height: 0; }
|
|
31
|
+
#runs-side { overflow-y: auto; border-right: 1px solid var(--rule); background: var(--panel); }
|
|
32
|
+
#runs-main { overflow: hidden; display: flex; flex-direction: column; min-height: 0; border-right: 1px solid var(--rule); }
|
|
33
|
+
.r-side-head { padding: 0.8em 1em 0.5em; }
|
|
34
|
+
.r-side-title { font-weight: 700; font-size: 1.05em; }
|
|
35
|
+
.r-side-note { color: var(--muted); font-size: 0.82em; margin-top: 0.15em; }
|
|
36
|
+
.r-runs { list-style: none; margin: 0; padding: 0 0 1em; }
|
|
37
|
+
.r-group { font: 600 0.72em var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); padding: 0.7em 1em 0.25em; }
|
|
38
|
+
.r-run { display: grid; grid-template-columns: 1.2em 1fr; gap: 0.6em; padding: 0.5em 1em; cursor: pointer; border-left: 3px solid transparent; }
|
|
39
|
+
.r-run:hover { background: color-mix(in srgb, var(--active) 50%, transparent); }
|
|
40
|
+
.r-run.r-selected { background: var(--active); border-left-color: var(--focus); }
|
|
41
|
+
.r-run-title { font-weight: 600; line-height: 1.25; }
|
|
42
|
+
.r-run-place { color: var(--muted); font-size: 0.85em; }
|
|
43
|
+
.r-run-meta { display: flex; flex-wrap: wrap; gap: 0 0.7em; color: var(--muted); font-size: 0.8em; }
|
|
44
|
+
.r-icon { width: 1.15em; height: 1.15em; flex: none; margin-top: 0.15em; }
|
|
45
|
+
.r-ok { color: var(--managed); } .r-bad { color: var(--degraded); } .r-wait { color: var(--foreign); } .r-busy { color: var(--foreign); } .r-idle { color: var(--neutral); }
|
|
46
|
+
.r-word.r-idle { color: var(--muted); }
|
|
47
|
+
@media (prefers-reduced-motion: no-preference) { .r-spin { animation: r-spin 1s linear infinite; } }
|
|
48
|
+
@keyframes r-spin { to { transform: rotate(360deg); } }
|
|
49
|
+
.r-head { padding: 0.8em 1em 0.6em; border-bottom: 1px solid var(--rule); }
|
|
50
|
+
.r-head-title { display: flex; gap: 0.5em; align-items: center; font-weight: 700; font-size: 1.15em; }
|
|
51
|
+
.r-head-title .r-icon { margin: 0; }
|
|
52
|
+
.r-head-sub { color: var(--fg); font-size: 0.88em; margin-top: 0.15em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
53
|
+
.r-head-meta { color: var(--muted); font-size: 0.82em; }
|
|
54
|
+
.r-label { font: 600 0.7em var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); padding: 0.6em 1em 0; }
|
|
55
|
+
.r-jobs { list-style: none; margin: 0; padding: 0.3em 0 0.5em; border-bottom: 1px solid var(--rule); }
|
|
56
|
+
.r-job { display: flex; gap: 0.5em; align-items: center; padding: 0.22em 1em 0.22em calc(1em + var(--depth, 0) * 0.9em); cursor: pointer; }
|
|
57
|
+
.r-job .r-icon { margin: 0; }
|
|
58
|
+
.r-job.r-selected { background: var(--active); }
|
|
59
|
+
.r-job-name { font: 600 0.92em var(--font-mono); }
|
|
60
|
+
.r-job-needs { color: var(--muted); font-size: 0.78em; }
|
|
61
|
+
.r-dur { margin-left: auto; color: var(--muted); font-size: 0.8em; white-space: nowrap; }
|
|
62
|
+
.r-job-skipped { color: var(--muted); font-size: 0.78em; cursor: default; }
|
|
63
|
+
.r-steps { display: flex; flex-direction: column; min-height: 0; flex: 1; overflow-y: auto; padding-bottom: 0.5em; }
|
|
64
|
+
.r-steps-head { display: flex; gap: 0.5em; align-items: center; padding: 0.3em 1em 0.3em; }
|
|
65
|
+
.r-steps-head .r-icon { margin: 0; }
|
|
66
|
+
.r-steps-job { font: 700 0.95em var(--font-mono); }
|
|
67
|
+
.r-steps-word { color: var(--muted); font-size: 0.85em; }
|
|
68
|
+
.r-step-line { display: flex; gap: 0.45em; align-items: center; padding: 0.2em 1em; cursor: pointer; }
|
|
69
|
+
.r-step-line .r-icon { margin: 0; width: 1em; height: 1em; }
|
|
70
|
+
.r-caret { color: var(--muted); width: 0.8em; font-size: 0.8em; }
|
|
71
|
+
.r-step-name { font-size: 0.88em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
72
|
+
.r-open > .r-step-line { background: color-mix(in srgb, var(--active) 60%, transparent); }
|
|
73
|
+
.r-log { margin: 0.2em 0.6em 0.4em 1.6em; padding: 0.5em 0; background: var(--well); border-radius: 6px; font: 0.76em/1.45 var(--font-mono);
|
|
74
|
+
white-space: pre-wrap; overflow-wrap: anywhere; overflow-y: auto; max-height: 52vh; min-height: 6em; }
|
|
75
|
+
.r-log-line { display: grid; grid-template-columns: 3.2em 1fr; gap: 0.8em; padding: 0 0.8em 0 0; }
|
|
76
|
+
.r-ln { color: var(--muted); text-align: right; user-select: none; opacity: 0.7; }
|
|
77
|
+
.r-log-cut { color: var(--muted); padding: 0 0.8em 0.2em 4em; font-style: italic; }
|
|
78
|
+
.r-log-live { color: var(--foreign); padding-left: 4em; }
|
|
79
|
+
@media (prefers-reduced-motion: no-preference) { .r-log-live { animation: r-blink 1s steps(2) infinite; } }
|
|
80
|
+
@keyframes r-blink { 50% { opacity: 0; } }
|
|
81
|
+
.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.7em 1.4em 0.7em; background: var(--panel); border-bottom: 1px solid var(--rule); }
|
|
84
|
+
#bar > .k-empty { grid-column: 1 / -1; }
|
|
85
|
+
.k-kicker { display: flex; gap: 1.2em; font: 600 0.78em var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
|
|
86
|
+
.k-cycle { text-transform: none; letter-spacing: 0; font-weight: 400; }
|
|
87
|
+
.k-title { margin: 0.05em 0 0.25em; font-size: 1.45em; line-height: 1.15; }
|
|
88
|
+
.k-muted { color: var(--muted); margin: 0.25em 0; }
|
|
89
|
+
.k-step { border-left: 4px solid var(--pending); padding: 0.1em 0 0.1em 0.8em; }
|
|
90
|
+
.k-step-word { font: 600 0.75em var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
|
|
91
|
+
.k-label { font-size: 1.02em; margin: 0.1em 0 0; }
|
|
92
|
+
.k-step-waiting { border-left-color: var(--foreign); }
|
|
93
|
+
.k-step-failed, .k-step-stalled { border-left-color: var(--degraded); }
|
|
94
|
+
.k-step-applied { border-left-color: var(--managed); }
|
|
95
|
+
.k-step-reset { border-left-color: var(--neutral); }
|
|
96
|
+
.k-right { display: grid; gap: 0.6em; align-content: start; }
|
|
97
|
+
.k-strip { list-style: none; display: flex; flex-wrap: wrap; gap: 0.35em; padding: 0; margin: 0; }
|
|
98
|
+
.k-stop { font: 0.82em var(--font-mono); padding: 0.25em 0.7em; border-radius: 999px; border: 1px solid var(--rule); color: var(--muted); }
|
|
31
99
|
.k-done { color: var(--fg); border-color: var(--managed); }
|
|
32
100
|
.k-current { color: var(--bg); background: var(--pending); border-color: var(--pending); font-weight: 600; }
|
|
33
101
|
.k-failed { color: var(--bg); background: var(--degraded); border-color: var(--degraded); font-weight: 600; }
|
|
34
|
-
.k-
|
|
35
|
-
.k-
|
|
36
|
-
.k-step-failed, .k-step-stalled { border-color: var(--degraded); }
|
|
37
|
-
.k-step-applied { border-color: var(--managed); }
|
|
38
|
-
.k-next-line { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: baseline; margin: 0.4rem 0 0; font-size: 1.2rem; }
|
|
102
|
+
.k-next { display: flex; flex-wrap: wrap; gap: 0.3em 0.8em; align-items: baseline; font-size: 1.05em; }
|
|
103
|
+
.k-next-word { font: 600 0.72em var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
|
|
39
104
|
.k-action { font-family: var(--font-mono); }
|
|
40
105
|
.k-when { color: var(--foreign); font-family: var(--font-mono); }
|
|
41
|
-
.k-approve {
|
|
42
|
-
.k-
|
|
43
|
-
.k-
|
|
106
|
+
.k-approve { border-left: 4px solid var(--foreign); padding-left: 0.8em; }
|
|
107
|
+
.k-approve p { margin: 0.15em 0; }
|
|
108
|
+
.k-approve-head, .k-approve-then { color: var(--muted); font-size: 0.92em; }
|
|
109
|
+
.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; }
|
|
110
|
+
.k-inline { font-family: var(--font-mono); color: var(--fg); background: var(--well); padding: 0.05em 0.35em; border-radius: 4px; user-select: none; }
|
|
111
|
+
.k-note { color: var(--muted); margin: 0; font-size: 0.92em; }
|
|
44
112
|
.k-unknown { color: var(--foreign); }
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
.k-
|
|
48
|
-
.k-
|
|
49
|
-
.k-
|
|
50
|
-
|
|
51
|
-
.k-tone-wave { border-left-color: var(--foreign); }
|
|
52
|
-
.k-tone-failed { border-left-color: var(--degraded); }
|
|
53
|
-
.k-tone-plan { border-left-color: var(--pending); }
|
|
54
|
-
.k-tone-none { border-left-color: var(--neutral); }
|
|
55
|
-
.k-foot { font-size: 0.8rem; }
|
|
113
|
+
#view { display: block; width: 100%; height: 100%; border: 0; background: var(--bg); }
|
|
114
|
+
#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; }
|
|
115
|
+
.k-legend { color: var(--muted); flex-basis: 100%; }
|
|
116
|
+
.k-fact { color: var(--fg); }
|
|
117
|
+
.k-source { color: var(--muted); margin-left: auto; }
|
|
118
|
+
@media (max-width: 900px) { #bar { grid-template-columns: 1fr; } #stage { grid-template-columns: 1fr; } }
|
|
56
119
|
</style>
|
|
57
120
|
</head>
|
|
58
121
|
<body>
|
|
59
|
-
<
|
|
122
|
+
<header id="bar" aria-live="polite"></header>
|
|
123
|
+
<div id="stage">
|
|
124
|
+
<nav id="runs-side" aria-label="pipeline runs"></nav>
|
|
125
|
+
<section id="runs-main" aria-live="polite"></section>
|
|
126
|
+
<iframe id="view" title="behold's view of the change"></iframe>
|
|
127
|
+
</div>
|
|
128
|
+
<footer id="foot"></footer>
|
|
60
129
|
<script type="module">
|
|
61
130
|
import { initTheme } from "/theme.js";
|
|
62
131
|
import { startKiosk } from "/kiosk.js";
|
|
63
132
|
initTheme();
|
|
64
|
-
startKiosk(document.getElementById("
|
|
133
|
+
startKiosk({ bar: document.getElementById("bar"), frame: document.getElementById("view"), foot: document.getElementById("foot"), side: document.getElementById("runs-side"), main: document.getElementById("runs-main") });
|
|
65
134
|
</script>
|
|
66
135
|
</body>
|
|
67
136
|
</html>
|
package/web/kiosk.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
// #569 step 2: the kiosk.
|
|
1
|
+
// #569 step 2: the kiosk. #569 step 3: a status bar over behold's own view of
|
|
2
|
+
// the change, framed (`/?embed=1&bare=1`), moved to the roots each step is about. A looping terragucci demo (terragucci#1014),
|
|
2
3
|
// narrated from the demo.json its driver writes at every step, beside the
|
|
3
4
|
// newest run: the pull request's plan before the merge, the run view's waves
|
|
4
5
|
// from the merge on, and back to the start when the loop resets.
|
|
@@ -12,7 +13,8 @@
|
|
|
12
13
|
// A wave that failed says which waves applied before it and stay applied.
|
|
13
14
|
// Nothing here is all-or-nothing, and nothing says so.
|
|
14
15
|
|
|
15
|
-
import {
|
|
16
|
+
import { startRuns } from "./kiosk-runs.js";
|
|
17
|
+
import { STATE_WORDS, changingRoots, costLine, readEdges, rootsCount, totalsLine, waveRows, blastRows } from "./terragucci-change.js";
|
|
16
18
|
|
|
17
19
|
/** The scenarios terragucci#1014 numbers. An id not here is shown by the title demo.json carries. */
|
|
18
20
|
export const SCENARIOS = {
|
|
@@ -198,13 +200,92 @@ export function pendingApproval(demo, progress) {
|
|
|
198
200
|
return {
|
|
199
201
|
wave: number,
|
|
200
202
|
digest: w && w.digest ? w.digest : null,
|
|
201
|
-
|
|
203
|
+
// #569 review: only the roots whose plan the approval covers a change in.
|
|
204
|
+
roots: w ? changingRoots(w, [...new Set([...w.roots, ...(w.composed ? w.composed.roots : [])])]) : [],
|
|
202
205
|
...driverApproveLines(number, demo.pull),
|
|
203
206
|
};
|
|
204
207
|
}
|
|
205
208
|
|
|
209
|
+
/**
|
|
210
|
+
* #569: the roots the step is about, for the framed view to bring forward:
|
|
211
|
+
* the pull request's roots while it is planned; from the merge on, the wave
|
|
212
|
+
* that is waiting, applying, failed or refused (its roots and, for a composed
|
|
213
|
+
* run, every root in it; for a failed or refused wave, the roots of the waves
|
|
214
|
+
* before it that applied, which stay applied); nothing in particular once applied or reset, so the
|
|
215
|
+
* whole picture shows. Null brings nothing forward.
|
|
216
|
+
*/
|
|
217
|
+
export function focusRoots(demo, answer, progress) {
|
|
218
|
+
if (!demo) return null;
|
|
219
|
+
const stage = demoStage(demo);
|
|
220
|
+
if (stage === "none" || demo.step === "applied") return null;
|
|
221
|
+
if (stage === "pull-request") {
|
|
222
|
+
const pr = matchingPull(answer, demo);
|
|
223
|
+
return pr && pr.roots && pr.roots.length ? [...pr.roots] : null;
|
|
224
|
+
}
|
|
225
|
+
const run = matchingRun(progress, demo);
|
|
226
|
+
if (!run) {
|
|
227
|
+
const pr = matchingPull(answer, demo);
|
|
228
|
+
return pr && pr.roots && pr.roots.length ? [...pr.roots] : null;
|
|
229
|
+
}
|
|
230
|
+
const order = ["failed", "refused", "waiting", "applying"];
|
|
231
|
+
for (const want of order) {
|
|
232
|
+
const w = progress.states.find((x) => x.state === want);
|
|
233
|
+
if (!w) continue;
|
|
234
|
+
const inRun = progress.reads.filter((r) => r.composed && r.composed.wave === w.number).map((r) => r.root);
|
|
235
|
+
// A wave that failed or refused: the waves before it that applied too, since they stay applied.
|
|
236
|
+
const before = want === "failed" || want === "refused" ? progress.states.filter((x) => x.number < w.number && x.state === "applied").flatMap((x) => changingRoots(x, x.roots)) : [];
|
|
237
|
+
// A root its wave plans no change in is not what the step is about.
|
|
238
|
+
const roots = [...new Set([...changingRoots(w, [...w.roots, ...(w.composed ? w.composed.roots : []), ...inRun]), ...before])];
|
|
239
|
+
if (roots.length) return roots;
|
|
240
|
+
}
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* #569: which run the framed view draws for this step, so the graph stays on
|
|
246
|
+
* the cycle the narration describes while the loop's next runs (the reset's
|
|
247
|
+
* push back to the base) already go:
|
|
248
|
+
*
|
|
249
|
+
* - before the merge, `none`: no run view, the pull request's plan;
|
|
250
|
+
* - from the merge to `applied`, the cycle's commit (demo.json's `commit`:
|
|
251
|
+
* the merge, whose run view the `/terragucci apply` resume rewrites, or
|
|
252
|
+
* scenario 5's push);
|
|
253
|
+
* - at `reset`, and on the base, `base`: nothing in flight.
|
|
254
|
+
*
|
|
255
|
+
* Never the reset's or the base's run while the step is before `reset`.
|
|
256
|
+
*/
|
|
257
|
+
export function runPin(demo) {
|
|
258
|
+
if (!demo) return null;
|
|
259
|
+
if (demo.scenario === "base" || demo.step === "reset") return "base";
|
|
260
|
+
if (demo.step === "pull-request" || demo.step === "planned") return "none";
|
|
261
|
+
if (demo.step === "stalled") return demo.commit ? demo.commit : "none";
|
|
262
|
+
return demo.commit || "none";
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** The framed view's address: behold's own page, bare, at the resources zoom, told who its host is. */
|
|
266
|
+
export function frameSrc(origin) {
|
|
267
|
+
const q = new URLSearchParams({ embed: "1", bare: "1", zoom: "resources", host: origin });
|
|
268
|
+
return `/?${q.toString()}`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** The one-line facts under the view: the pull request's totals, cost and blast radius, as #568 has them. */
|
|
272
|
+
export function factLines(demo, answer) {
|
|
273
|
+
const pr = demo ? matchingPull(answer, demo) : null;
|
|
274
|
+
if (!pr) return [];
|
|
275
|
+
const out = [];
|
|
276
|
+
const totals = totalsLine(pr.totals);
|
|
277
|
+
if (totals) out.push(`pull request #${pr.run.pull_request} changes ${rootsCount(pr.totals_roots) || "across roots"}: ${totals}`);
|
|
278
|
+
const b = blastRows(answer);
|
|
279
|
+
if (b && b.changed.length) out.push(`blast radius: ${b.changed.join(", ")}${b.downstream.length ? `; downstream, nearest first: ${b.downstream.join(", ")}` : ""}`);
|
|
280
|
+
if (b && b.unplanned.length) out.push(`not touched: ${b.unplanned.join(", ")}`);
|
|
281
|
+
const cost = costLine(pr.cost);
|
|
282
|
+
if (cost) out.push(cost);
|
|
283
|
+
return out;
|
|
284
|
+
}
|
|
285
|
+
|
|
206
286
|
// ---------------------------------------------------------------------------
|
|
207
|
-
// The page
|
|
287
|
+
// The page: a status bar over behold's own view in a frame, and a line of
|
|
288
|
+
// facts under it. The bar is the narration; the frame shows the change.
|
|
208
289
|
|
|
209
290
|
const el = (tag, cls, text) => {
|
|
210
291
|
const n = document.createElement(tag);
|
|
@@ -213,101 +294,72 @@ const el = (tag, cls, text) => {
|
|
|
213
294
|
return n;
|
|
214
295
|
};
|
|
215
296
|
|
|
216
|
-
/** Draw
|
|
217
|
-
export function renderKiosk(
|
|
218
|
-
|
|
297
|
+
/** Draw the bar and the foot from what the kiosk holds. Pure over its arguments, but for the DOM. */
|
|
298
|
+
export function renderKiosk(bar, s, now = new Date(), foot = null) {
|
|
299
|
+
bar.replaceChildren();
|
|
300
|
+
if (foot) foot.replaceChildren();
|
|
219
301
|
if (!s.demo || !s.demo.demo) {
|
|
220
|
-
const box = el("
|
|
302
|
+
const box = el("div", "k-empty");
|
|
221
303
|
box.appendChild(el("h1", "k-title", "Waiting for the demo"));
|
|
222
304
|
box.appendChild(el("p", "k-muted", s.demo ? s.demo.error || s.demo.absent : "Reading demo.json…"));
|
|
223
|
-
|
|
305
|
+
bar.appendChild(box);
|
|
224
306
|
return;
|
|
225
307
|
}
|
|
226
308
|
const demo = s.demo.demo;
|
|
227
309
|
const head = scenarioHead(demo);
|
|
228
310
|
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
311
|
+
const left = el("div", "k-left");
|
|
312
|
+
const kick = el("div", "k-kicker");
|
|
313
|
+
kick.append(el("span", null, head.kicker), el("span", "k-cycle", cycleWords(demo, now)));
|
|
314
|
+
left.appendChild(kick);
|
|
315
|
+
left.appendChild(el("h1", "k-title", head.title));
|
|
316
|
+
const said = el("div", `k-step k-step-${demo.step}`);
|
|
317
|
+
said.appendChild(el("span", "k-step-word", STEP_WORDS[demo.step] || demo.step));
|
|
318
|
+
said.appendChild(el("p", "k-label", demo.label));
|
|
319
|
+
left.appendChild(said);
|
|
320
|
+
bar.appendChild(left);
|
|
321
|
+
|
|
322
|
+
const right = el("div", "k-right");
|
|
235
323
|
const strip = el("ol", "k-strip");
|
|
236
|
-
for (const stop of stripStops(demo.step)) {
|
|
237
|
-
|
|
238
|
-
strip.appendChild(li);
|
|
239
|
-
}
|
|
240
|
-
root.appendChild(strip);
|
|
241
|
-
|
|
242
|
-
const step = el("section", `k-step k-step-${demo.step}`);
|
|
243
|
-
step.appendChild(el("div", "k-kicker", STEP_WORDS[demo.step] || demo.step));
|
|
244
|
-
step.appendChild(el("p", "k-label", demo.label));
|
|
245
|
-
root.appendChild(step);
|
|
324
|
+
for (const stop of stripStops(demo.step)) strip.appendChild(el("li", `k-stop k-${stop.state}`, stop.words));
|
|
325
|
+
right.appendChild(strip);
|
|
246
326
|
|
|
247
327
|
const nx = nextWords(demo, now);
|
|
248
|
-
const next = el("
|
|
249
|
-
next.appendChild(el("
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
328
|
+
const next = el("div", "k-next");
|
|
329
|
+
next.appendChild(el("span", "k-next-word", "next, by the driver"));
|
|
330
|
+
next.appendChild(el("span", "k-action", nx.action));
|
|
331
|
+
next.appendChild(el("span", "k-when", nx.when));
|
|
332
|
+
right.appendChild(next);
|
|
333
|
+
|
|
254
334
|
const appr = pendingApproval(demo, s.progress);
|
|
255
335
|
if (appr) {
|
|
256
336
|
const a = el("div", "k-approve");
|
|
257
337
|
a.appendChild(
|
|
258
338
|
el(
|
|
259
339
|
"p",
|
|
260
|
-
"k-
|
|
261
|
-
`The driver approves wave ${appr.wave}${appr.roots.length ? ` (${appr.roots.join(", ")})` : ""} ${
|
|
340
|
+
"k-approve-head",
|
|
341
|
+
`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)}` : ""}:`,
|
|
262
342
|
),
|
|
263
343
|
);
|
|
264
344
|
a.appendChild(el("code", "k-command", appr.approve));
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
a.appendChild(
|
|
268
|
-
|
|
269
|
-
}
|
|
270
|
-
root.appendChild(next);
|
|
271
|
-
|
|
272
|
-
const panel = runPanel(demo, s.answer, s.progress);
|
|
273
|
-
const runBox = el("section", "k-run");
|
|
274
|
-
if (panel.note) {
|
|
275
|
-
runBox.appendChild(el("div", "k-kicker", panel.stage === "pull-request" ? "the pull request's plan" : panel.stage === "apply" ? "the waves" : "the run"));
|
|
276
|
-
runBox.appendChild(el("p", "k-muted", panel.note));
|
|
277
|
-
} else if (panel.stage === "pull-request") {
|
|
278
|
-
runBox.appendChild(el("div", "k-kicker", `the plan of pull request #${panel.pr.run.pull_request}, commit ${panel.pr.run.commit.slice(0, 8)}`));
|
|
279
|
-
const rows = pullRows(s.answer);
|
|
280
|
-
for (const e of rows.reads) runBox.appendChild(el("p", e.unknown.length ? "k-read k-unknown" : "k-read", readLine(e)));
|
|
281
|
-
for (const u of unreviewedLines(s.answer)) runBox.appendChild(el("p", "k-unreviewed", u));
|
|
282
|
-
runBox.appendChild(waveList(rows.waves));
|
|
283
|
-
const totals = totalsLine(panel.pr.totals);
|
|
284
|
-
if (totals) runBox.appendChild(el("p", "k-muted", `totals across roots: ${totals}`));
|
|
285
|
-
const cost = costLine(panel.pr.cost);
|
|
286
|
-
if (cost) runBox.appendChild(el("p", "k-muted", cost));
|
|
287
|
-
const b = blastRows(s.answer);
|
|
288
|
-
if (b && b.downstream.length) runBox.appendChild(el("p", "k-muted", `blast radius, nearest first: ${b.downstream.join(", ")}`));
|
|
289
|
-
if (b && b.unplanned.length) runBox.appendChild(el("p", "k-muted", `not planned by this pull request: ${b.unplanned.join(", ")}`));
|
|
345
|
+
const then = el("p", "k-approve-then");
|
|
346
|
+
then.append(`then comments `, el("code", "k-inline", appr.comment), ` on ${appr.on}, and the waves run again.`);
|
|
347
|
+
a.appendChild(then);
|
|
348
|
+
right.appendChild(a);
|
|
290
349
|
} else {
|
|
291
|
-
|
|
292
|
-
|
|
350
|
+
const panel = runPanel(demo, s.answer, s.progress);
|
|
351
|
+
if (panel.note) right.appendChild(el("p", "k-note", panel.note));
|
|
352
|
+
else if (panel.stage === "pull-request") for (const u of unreviewedLines(s.answer)) right.appendChild(el("p", "k-note k-unknown", u));
|
|
293
353
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
const foot = el("footer", "k-foot k-muted");
|
|
297
|
-
foot.textContent = `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)}` : ""}`;
|
|
298
|
-
root.appendChild(foot);
|
|
299
|
-
}
|
|
354
|
+
bar.appendChild(right);
|
|
300
355
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
for (const f of w.facts) li.appendChild(el("div", "k-wave-fact", f));
|
|
308
|
-
ol.appendChild(li);
|
|
356
|
+
if (foot) {
|
|
357
|
+
foot.appendChild(el("span", "k-legend", "Each box is a Terraform root with its own state; an arrow is one root reading another's outputs, dashed while it reads planned values. A wave applies its roots together, after the waves it reads."));
|
|
358
|
+
for (const f of factLines(demo, s.answer)) foot.appendChild(el("span", "k-fact", f));
|
|
359
|
+
foot.appendChild(
|
|
360
|
+
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`),
|
|
361
|
+
);
|
|
309
362
|
}
|
|
310
|
-
return ol;
|
|
311
363
|
}
|
|
312
364
|
|
|
313
365
|
const clockOf = (iso) => {
|
|
@@ -319,21 +371,57 @@ const clockOf = (iso) => {
|
|
|
319
371
|
export const DEMO_POLL_MS = 3_000;
|
|
320
372
|
|
|
321
373
|
/**
|
|
322
|
-
* Boot the kiosk
|
|
323
|
-
* and the run view when the step, cycle or commit moves
|
|
324
|
-
* says they did, and redraw every second for the
|
|
374
|
+
* Boot the kiosk: the bar, the frame and the foot. Ask demo.json every few
|
|
375
|
+
* seconds, the reports and the run view when the step, cycle or commit moves
|
|
376
|
+
* or the event stream says they did, and redraw every second for the
|
|
377
|
+
* countdown. When the roots the step is about change, move the framed view
|
|
378
|
+
* with a `behold:view` message; the frame takes them only from this origin.
|
|
325
379
|
*/
|
|
326
|
-
export function startKiosk(
|
|
380
|
+
export function startKiosk({ bar, frame, foot, side, main }, { fetchJson = defaultFetch, now = () => new Date(), origin = location.origin } = {}) {
|
|
327
381
|
const s = { demo: null, answer: null, progress: null };
|
|
328
382
|
let lastKey = "";
|
|
329
|
-
|
|
383
|
+
let lastFocus;
|
|
384
|
+
const tellFrame = (force) => {
|
|
385
|
+
const d = s.demo && s.demo.demo;
|
|
386
|
+
if (!d) return; // nothing said yet: the frame keeps its default until the narration is read
|
|
387
|
+
const roots = focusRoots(d, s.answer, s.progress);
|
|
388
|
+
const focus = roots ? roots.join(",") : null;
|
|
389
|
+
const run = runPin(d);
|
|
390
|
+
const key = `${focus}|${run}`;
|
|
391
|
+
if (!force && key === lastFocus) return;
|
|
392
|
+
lastFocus = key;
|
|
393
|
+
if (frame && frame.contentWindow) frame.contentWindow.postMessage({ type: "behold:view", member: null, focus, run }, origin);
|
|
394
|
+
};
|
|
395
|
+
const draw = () => {
|
|
396
|
+
renderKiosk(bar, s, now(), foot);
|
|
397
|
+
tellFrame(false);
|
|
398
|
+
};
|
|
399
|
+
// #569: the pipeline's runs beside the view, CI-page style.
|
|
400
|
+
if (side && main) startRuns({ side, main }, { fetchJson, now, scenarios: SCENARIOS });
|
|
401
|
+
if (frame) {
|
|
402
|
+
frame.src = frameSrc(origin);
|
|
403
|
+
// The frame reads its graph after load; tell it again once it has had the time.
|
|
404
|
+
frame.addEventListener("load", () => {
|
|
405
|
+
for (const ms of [1500, 4000, 8000]) setTimeout(() => tellFrame(true), ms);
|
|
406
|
+
});
|
|
407
|
+
}
|
|
330
408
|
|
|
331
409
|
const reports = async (fresh) => {
|
|
332
410
|
s.answer = await fetchJson(`/api/terragucci${fresh ? "?fresh=1" : ""}`);
|
|
333
411
|
draw();
|
|
334
412
|
};
|
|
413
|
+
// #569: the run view of the cycle's commit, the same one the frame draws.
|
|
414
|
+
const pinnedCommit = () => {
|
|
415
|
+
const pin = runPin(s.demo && s.demo.demo);
|
|
416
|
+
return pin && pin !== "none" && pin !== "base" ? pin : null;
|
|
417
|
+
};
|
|
335
418
|
const progress = async (fresh) => {
|
|
336
|
-
|
|
419
|
+
const pin = pinnedCommit();
|
|
420
|
+
const q = new URLSearchParams();
|
|
421
|
+
if (pin) q.set("commit", pin);
|
|
422
|
+
if (fresh) q.set("fresh", "1");
|
|
423
|
+
const qs = q.toString();
|
|
424
|
+
s.progress = await fetchJson(`/api/terragucci/progress${qs ? `?${qs}` : ""}`);
|
|
337
425
|
draw();
|
|
338
426
|
};
|
|
339
427
|
|
|
@@ -362,8 +450,14 @@ export function startKiosk(root, { fetchJson = defaultFetch, now = () => new Dat
|
|
|
362
450
|
es.addEventListener("reports", () => void reports(false));
|
|
363
451
|
es.addEventListener("progress", (e) => {
|
|
364
452
|
try {
|
|
365
|
-
|
|
366
|
-
|
|
453
|
+
const pushed = JSON.parse(e.data);
|
|
454
|
+
const pin = pinnedCommit();
|
|
455
|
+
const c = pushed && pushed.run && pushed.run.commit;
|
|
456
|
+
// The pushed view is the newest run's: take it only when it is the cycle's.
|
|
457
|
+
if (!pin || (c && (c.startsWith(pin) || pin.startsWith(c)))) {
|
|
458
|
+
s.progress = pushed;
|
|
459
|
+
draw();
|
|
460
|
+
} else void progress(true);
|
|
367
461
|
} catch {
|
|
368
462
|
void progress(false);
|
|
369
463
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { focusRoots, frameSrc, factLines, pendingApproval, runPin } from "./kiosk.js";
|
|
3
|
+
|
|
4
|
+
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 });
|
|
5
|
+
const answer = {
|
|
6
|
+
roots: ["app", "edge", "network", "other"].map((name) => ({ member: "demo", name, path: name })),
|
|
7
|
+
pull_request: { run: { pull_request: "7", commit: "abc12345" }, roots: ["app", "edge", "network"], reads: [], waves: [], totals: { update: 3 }, totals_roots: 3, blast: { roots: ["app", "edge", "network"], downstream: [] } },
|
|
8
|
+
};
|
|
9
|
+
const progress = (states, reads = []) => ({ run: { present: true, commit: "abc12345", updated: "2026-10-15T03:05:00Z" }, states, reads });
|
|
10
|
+
|
|
11
|
+
describe("focusRoots (#569)", () => {
|
|
12
|
+
it("brings the pull request's roots forward while it is planned", () => {
|
|
13
|
+
expect(focusRoots(demo("planned"), answer, null)).toEqual(["app", "edge", "network"]);
|
|
14
|
+
});
|
|
15
|
+
it("brings the wave that waits, applies or failed forward, and nothing once applied", () => {
|
|
16
|
+
const p = progress([
|
|
17
|
+
{ number: 1, state: "applied", roots: ["network", "other"], reads: [] },
|
|
18
|
+
{ number: 2, state: "waiting", roots: ["app"], reads: [1] },
|
|
19
|
+
]);
|
|
20
|
+
expect(focusRoots(demo("waiting"), answer, p)).toEqual(["app"]);
|
|
21
|
+
expect(focusRoots(demo("applied"), answer, p)).toBe(null);
|
|
22
|
+
expect(focusRoots(demo("reset"), answer, p)).toBe(null);
|
|
23
|
+
});
|
|
24
|
+
it("brings a failed wave forward with the waves before it that stay applied", () => {
|
|
25
|
+
const p = progress([
|
|
26
|
+
{ number: 1, state: "applied", roots: ["network", "other"], reads: [] },
|
|
27
|
+
{ number: 2, state: "failed", roots: ["app"], reads: [1] },
|
|
28
|
+
{ number: 3, state: "not-started", roots: ["edge"], reads: [2] },
|
|
29
|
+
]);
|
|
30
|
+
expect(focusRoots(demo("failed"), answer, p)).toEqual(["app", "network", "other"]);
|
|
31
|
+
});
|
|
32
|
+
it("leaves a root its wave plans no change in out (#569 review)", () => {
|
|
33
|
+
const p = progress([{ number: 1, state: "waiting", roots: ["network", "other"], reads: [], digest: "d", totals: { create: 1 }, changed: ["network"] }]);
|
|
34
|
+
expect(focusRoots(demo("waiting"), answer, p)).toEqual(["network"]);
|
|
35
|
+
expect(pendingApproval(demo("waiting", { next: { action: "approve wave-1", at: null } }), p).roots).toEqual(["network"]);
|
|
36
|
+
});
|
|
37
|
+
it("takes every root of a composed run", () => {
|
|
38
|
+
const p = progress([{ number: 1, state: "applying", roots: ["network"], reads: [], composed: { roots: ["network", "app", "edge"] } }]);
|
|
39
|
+
expect(focusRoots(demo("applying"), answer, p)).toEqual(["network", "app", "edge"]);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("frameSrc (#569)", () => {
|
|
44
|
+
it("frames behold's own view, bare, told its host", () => {
|
|
45
|
+
expect(frameSrc("http://localhost:4700")).toBe("/?embed=1&bare=1&zoom=resources&host=http%3A%2F%2Flocalhost%3A4700");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("factLines (#569)", () => {
|
|
50
|
+
it("says the pull request's totals and blast radius", () => {
|
|
51
|
+
expect(factLines(demo("planned"), answer)).toEqual(["pull request #7 changes across 3 roots: 3 update", "blast radius: app, edge, network", "not touched: other"]);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("runPin: the frame stays on the cycle's run (#569)", () => {
|
|
56
|
+
it("draws the pull request's plan, then the cycle's commit up to applied, then the base", () => {
|
|
57
|
+
expect(runPin(demo("pull-request"))).toBe("none");
|
|
58
|
+
expect(runPin(demo("planned"))).toBe("none");
|
|
59
|
+
for (const step of ["merged", "applying", "waiting", "approved", "failed", "resumed", "applied"]) expect(runPin(demo(step))).toBe("abc12345");
|
|
60
|
+
expect(runPin(demo("reset"))).toBe("base");
|
|
61
|
+
expect(runPin(demo("applied", { scenario: "base" }))).toBe("base");
|
|
62
|
+
expect(runPin(demo("stalled", { commit: null }))).toBe("none");
|
|
63
|
+
expect(runPin(null)).toBe(null);
|
|
64
|
+
});
|
|
65
|
+
});
|