@intentius/behold 0.27.0 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -0
- package/dist/cli.js +702 -285
- package/package.json +4 -4
- package/web/app.js +158 -34
- package/web/edge-kind.js +31 -0
- package/web/edge-kind.test.js +31 -1
- package/web/kiosk.html +67 -0
- package/web/kiosk.js +385 -0
- package/web/operator.js +1 -0
- package/web/run-playhead.js +1 -0
- package/web/terragucci-change.js +423 -0
package/web/kiosk.js
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
// #569 step 2: the kiosk. A looping terragucci demo (terragucci#1014),
|
|
2
|
+
// narrated from the demo.json its driver writes at every step, beside the
|
|
3
|
+
// newest run: the pull request's plan before the merge, the run view's waves
|
|
4
|
+
// from the merge on, and back to the start when the loop resets.
|
|
5
|
+
//
|
|
6
|
+
// The page only reads, all of it GET: /api/terragucci/demo (the narration),
|
|
7
|
+
// /api/terragucci (the pull request's plan), /api/terragucci/progress (the
|
|
8
|
+
// run view) and /api/terragucci/events (what moved). It has no control that
|
|
9
|
+
// writes and no copy button: the waiting wave's approve line is what the
|
|
10
|
+
// driver runs, shown so a viewer sees it.
|
|
11
|
+
//
|
|
12
|
+
// A wave that failed says which waves applied before it and stay applied.
|
|
13
|
+
// Nothing here is all-or-nothing, and nothing says so.
|
|
14
|
+
|
|
15
|
+
import { STATE_WORDS, costLine, readEdges, readLine, totalsLine, waveRows, blastRows } from "./terragucci-change.js";
|
|
16
|
+
|
|
17
|
+
/** The scenarios terragucci#1014 numbers. An id not here is shown by the title demo.json carries. */
|
|
18
|
+
export const SCENARIOS = {
|
|
19
|
+
base: { n: null, name: "the base" },
|
|
20
|
+
"a-known": { n: 1, name: "A, a known value" },
|
|
21
|
+
"a-output": { n: 2, name: "A, read through .output" },
|
|
22
|
+
"b-waves": { n: 3, name: "B on waves" },
|
|
23
|
+
failure: { n: 4, name: "a failure mid-apply" },
|
|
24
|
+
"stale-plan": { n: 5, name: "a stale plan" },
|
|
25
|
+
"b-composed": { n: 6, name: "B composed" },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** The steps of one cycle as the strip shows them, in order. */
|
|
29
|
+
export const STRIP = [
|
|
30
|
+
{ id: "pull-request", words: "pull request", steps: ["pull-request"] },
|
|
31
|
+
{ id: "planned", words: "planned", steps: ["planned"] },
|
|
32
|
+
{ id: "merged", words: "merged", steps: ["merged"] },
|
|
33
|
+
{ id: "waves", words: "waves", steps: ["applying", "waiting", "approved", "failed", "resumed"] },
|
|
34
|
+
{ id: "applied", words: "applied", steps: ["applied"] },
|
|
35
|
+
{ id: "reset", words: "reset", steps: ["reset"] },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
export const STEP_WORDS = {
|
|
39
|
+
"pull-request": "pull request open",
|
|
40
|
+
planned: "planned",
|
|
41
|
+
merged: "merged",
|
|
42
|
+
applying: "applying",
|
|
43
|
+
waiting: "waiting for an approval",
|
|
44
|
+
approved: "approved",
|
|
45
|
+
failed: "failed",
|
|
46
|
+
resumed: "running again",
|
|
47
|
+
applied: "applied",
|
|
48
|
+
reset: "reset",
|
|
49
|
+
stalled: "stalled",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** "Scenario 3 of 6 · B on waves", or the driver's title alone for an id behold doesn't know. */
|
|
53
|
+
export function scenarioHead(demo) {
|
|
54
|
+
const known = Object.prototype.hasOwnProperty.call(SCENARIOS, demo.scenario) ? SCENARIOS[demo.scenario] : null;
|
|
55
|
+
if (!known) return { kicker: `scenario ${demo.scenario}`, title: demo.title };
|
|
56
|
+
if (known.n === null) return { kicker: demo.title || "The base", title: demo.title || "The base" };
|
|
57
|
+
return { kicker: `scenario ${known.n} · ${known.name}`, title: demo.title };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The strip: each stop, and whether it is done, current or ahead. A stalled cycle marks no stop current. */
|
|
61
|
+
export function stripStops(step) {
|
|
62
|
+
const at = STRIP.findIndex((s) => s.steps.includes(step));
|
|
63
|
+
return STRIP.map((s, i) => ({ id: s.id, words: s.words, state: at < 0 ? "ahead" : i < at ? "done" : i === at ? (step === "failed" ? "failed" : "current") : "ahead" }));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Seconds from now until `at`, never below 0; null when there is no time. */
|
|
67
|
+
export function secondsUntil(at, now = new Date()) {
|
|
68
|
+
if (!at) return null;
|
|
69
|
+
const t = Date.parse(at);
|
|
70
|
+
if (Number.isNaN(t)) return null;
|
|
71
|
+
return Math.max(0, Math.ceil((t - now.getTime()) / 1000));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** "in 14 s", "in 1 m 05 s", "now". */
|
|
75
|
+
export function countdownWords(secs) {
|
|
76
|
+
if (secs === null) return null;
|
|
77
|
+
if (secs <= 0) return "now";
|
|
78
|
+
if (secs < 60) return `in ${secs} s`;
|
|
79
|
+
return `in ${Math.floor(secs / 60)} m ${String(secs % 60).padStart(2, "0")} s`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The driver's next action, as the kiosk says it. */
|
|
83
|
+
export function nextWords(demo, now = new Date()) {
|
|
84
|
+
if (!demo.next) return { action: "nothing until the driver resets", when: "the driver stalled; it resets and starts the next cycle", secs: null };
|
|
85
|
+
const secs = secondsUntil(demo.next.at, now);
|
|
86
|
+
return {
|
|
87
|
+
action: demo.next.action,
|
|
88
|
+
when: secs === null ? "when the pipeline gets there" : countdownWords(secs),
|
|
89
|
+
secs,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** "cycle 12 · started 4 m ago". */
|
|
94
|
+
export function cycleWords(demo, now = new Date()) {
|
|
95
|
+
const s = Math.max(0, Math.round((now.getTime() - Date.parse(demo.started_at)) / 1000));
|
|
96
|
+
const since = s < 60 ? `${s} s ago` : `${Math.floor(s / 60)} m ${String(s % 60).padStart(2, "0")} s ago`;
|
|
97
|
+
return `cycle ${demo.cycle} · started ${since}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Which run the step is about: the pull request's plan, the merge's waves, or neither (a reset). */
|
|
101
|
+
export function demoStage(demo) {
|
|
102
|
+
if (demo.step === "pull-request" || demo.step === "planned") return "pull-request";
|
|
103
|
+
if (demo.step === "reset" || demo.scenario === "base") return "none";
|
|
104
|
+
if (demo.step === "stalled") return demo.pull === null ? "none" : "either";
|
|
105
|
+
return "apply";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const pullOf = (answer) => (answer && !answer.error && answer.pull_request ? answer.pull_request : null);
|
|
109
|
+
const runOf = (progress) => (progress && !progress.error && progress.run && progress.run.present && progress.states && progress.states.length ? progress.run : null);
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The newest pull request's plan, when it is this step's: the pull request
|
|
113
|
+
* demo.json names. A forge never reuses a number, so an older cycle's plan is
|
|
114
|
+
* never shown as this one's.
|
|
115
|
+
*/
|
|
116
|
+
export function matchingPull(answer, demo) {
|
|
117
|
+
const pr = pullOf(answer);
|
|
118
|
+
if (!pr || demo.pull === null) return null;
|
|
119
|
+
return String(pr.run.pull_request) === String(demo.pull) ? pr : null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The newest run view, when it is this cycle's: on the commit demo.json names
|
|
124
|
+
* (the merge, or scenario 5's push), or written since the cycle started.
|
|
125
|
+
*/
|
|
126
|
+
export function matchingRun(progress, demo) {
|
|
127
|
+
const run = runOf(progress);
|
|
128
|
+
if (!run) return null;
|
|
129
|
+
const sameCommit = !!demo.commit && !!run.commit && (run.commit.startsWith(demo.commit) || demo.commit.startsWith(run.commit));
|
|
130
|
+
const thisCycle = !!run.updated && Date.parse(run.updated) >= Date.parse(demo.started_at);
|
|
131
|
+
return sameCommit || thisCycle ? run : null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** What the run panel shows for this step, and the words when it has nothing yet. */
|
|
135
|
+
export function runPanel(demo, answer, progress) {
|
|
136
|
+
const stage = demoStage(demo);
|
|
137
|
+
if (stage === "none") return { stage: "none", note: demo.scenario === "base" ? "Nothing in flight: the base is applied." : "Nothing in flight: the states are back on the base." };
|
|
138
|
+
const pr = stage !== "apply" ? matchingPull(answer, demo) : null;
|
|
139
|
+
const run = stage !== "pull-request" ? matchingRun(progress, demo) : null;
|
|
140
|
+
if (run) return { stage: "apply", run };
|
|
141
|
+
if (pr) return { stage: "pull-request", pr };
|
|
142
|
+
if (stage === "pull-request") {
|
|
143
|
+
return { stage: "pull-request", note: demo.pull === null ? "The pull request isn't open yet." : `The plan of pull request #${demo.pull} isn't in the reports yet: the pipeline is planning it.` };
|
|
144
|
+
}
|
|
145
|
+
return { stage: "apply", note: demo.commit ? `The run view of ${demo.commit.slice(0, 8)} isn't in the reports yet: the pipeline writes it as the waves start.` : "No run yet." };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The pull request's waves and reads, when the panel shows its plan. */
|
|
149
|
+
export function pullRows(answer) {
|
|
150
|
+
const only = { ...answer, roots: answer.roots || [] };
|
|
151
|
+
return { waves: waveRows(only, null), reads: readEdges(only, null) };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The run view's waves, when the panel shows them. The pull request's plan is left out so the stage is the run's. */
|
|
155
|
+
export function runRows(progress) {
|
|
156
|
+
return waveRows({ pull_request: null }, progress);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Plain sentences for each read of an output known only after its upstream
|
|
161
|
+
* applies: the review didn't show the value the reader gets.
|
|
162
|
+
*/
|
|
163
|
+
export function unreviewedLines(answer) {
|
|
164
|
+
const pr = pullOf(answer);
|
|
165
|
+
if (!pr) return [];
|
|
166
|
+
return pr.reads
|
|
167
|
+
.filter((r) => r.outputs === "planned" && r.unknown && r.unknown.length)
|
|
168
|
+
.map((r) => `${r.root} reads ${r.unknown.join(", ")} from ${r.upstream}, known only after ${r.upstream} applies: the review does not show the value ${r.root} gets.`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The approval the driver gives at a waiting wave, as terragucci's
|
|
173
|
+
* stack/demo.sh gives it: `approve_wave` runs chant's approve, sealed with
|
|
174
|
+
* the driver's ssh key, then `resume_pull` posts `/terragucci apply` on the
|
|
175
|
+
* merged pull request. The actor and the key are placeholders: the kiosk
|
|
176
|
+
* never names a path or a secret.
|
|
177
|
+
*/
|
|
178
|
+
export function driverApproveLines(wave, pull) {
|
|
179
|
+
return {
|
|
180
|
+
approve: `chant approve tf-apply wave-${wave} --actor <driver> --sign <driver key>`,
|
|
181
|
+
comment: "/terragucci apply",
|
|
182
|
+
on: pull !== null && pull !== undefined ? `pull request #${pull}` : "the merged pull request",
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The waiting wave the driver approves next: the wave and its roots and
|
|
188
|
+
* digest from the run view, and the lines the driver runs. Null unless
|
|
189
|
+
* demo.json is at `waiting` and names it.
|
|
190
|
+
*/
|
|
191
|
+
export function pendingApproval(demo, progress) {
|
|
192
|
+
if (demo.step !== "waiting" || !demo.next) return null;
|
|
193
|
+
const m = /\bapprove wave-(\d+)\b/.exec(demo.next.action);
|
|
194
|
+
if (!m) return null;
|
|
195
|
+
const number = Number(m[1]);
|
|
196
|
+
const run = matchingRun(progress, demo);
|
|
197
|
+
const w = run ? progress.states.find((x) => x.number === number) : null;
|
|
198
|
+
return {
|
|
199
|
+
wave: number,
|
|
200
|
+
digest: w && w.digest ? w.digest : null,
|
|
201
|
+
roots: w ? w.roots : [],
|
|
202
|
+
...driverApproveLines(number, demo.pull),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// The page.
|
|
208
|
+
|
|
209
|
+
const el = (tag, cls, text) => {
|
|
210
|
+
const n = document.createElement(tag);
|
|
211
|
+
if (cls) n.className = cls;
|
|
212
|
+
if (text !== undefined && text !== null) n.textContent = text;
|
|
213
|
+
return n;
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
/** Draw everything from what the kiosk holds. Pure over its arguments, but for the DOM. */
|
|
217
|
+
export function renderKiosk(root, s, now = new Date()) {
|
|
218
|
+
root.replaceChildren();
|
|
219
|
+
if (!s.demo || !s.demo.demo) {
|
|
220
|
+
const box = el("section", "k-empty");
|
|
221
|
+
box.appendChild(el("h1", "k-title", "Waiting for the demo"));
|
|
222
|
+
box.appendChild(el("p", "k-muted", s.demo ? s.demo.error || s.demo.absent : "Reading demo.json…"));
|
|
223
|
+
root.appendChild(box);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const demo = s.demo.demo;
|
|
227
|
+
const head = scenarioHead(demo);
|
|
228
|
+
|
|
229
|
+
const top = el("header", "k-head");
|
|
230
|
+
top.appendChild(el("div", "k-kicker", head.kicker));
|
|
231
|
+
top.appendChild(el("h1", "k-title", head.title));
|
|
232
|
+
top.appendChild(el("div", "k-muted", cycleWords(demo, now)));
|
|
233
|
+
root.appendChild(top);
|
|
234
|
+
|
|
235
|
+
const strip = el("ol", "k-strip");
|
|
236
|
+
for (const stop of stripStops(demo.step)) {
|
|
237
|
+
const li = el("li", `k-stop k-${stop.state}`, stop.words);
|
|
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);
|
|
246
|
+
|
|
247
|
+
const nx = nextWords(demo, now);
|
|
248
|
+
const next = el("section", "k-next");
|
|
249
|
+
next.appendChild(el("div", "k-kicker", "next, by the driver"));
|
|
250
|
+
const line = el("p", "k-next-line");
|
|
251
|
+
line.appendChild(el("span", "k-action", nx.action));
|
|
252
|
+
line.appendChild(el("span", "k-when", nx.when));
|
|
253
|
+
next.appendChild(line);
|
|
254
|
+
const appr = pendingApproval(demo, s.progress);
|
|
255
|
+
if (appr) {
|
|
256
|
+
const a = el("div", "k-approve");
|
|
257
|
+
a.appendChild(
|
|
258
|
+
el(
|
|
259
|
+
"p",
|
|
260
|
+
"k-muted",
|
|
261
|
+
`The driver approves wave ${appr.wave}${appr.roots.length ? ` (${appr.roots.join(", ")})` : ""} ${nx.secs === null ? "next" : nx.when}, signed with its own key. It runs:`,
|
|
262
|
+
),
|
|
263
|
+
);
|
|
264
|
+
a.appendChild(el("code", "k-command", appr.approve));
|
|
265
|
+
a.appendChild(el("p", "k-muted", appr.digest ? `The plan it approves: ${appr.digest}` : "The plan it approves: the run view names no digest for this wave yet."));
|
|
266
|
+
a.appendChild(el("p", "k-muted", `Then it comments on ${appr.on}, so the waves run again:`));
|
|
267
|
+
a.appendChild(el("code", "k-command", appr.comment));
|
|
268
|
+
next.appendChild(a);
|
|
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(", ")}`));
|
|
290
|
+
} else {
|
|
291
|
+
runBox.appendChild(el("div", "k-kicker", `the waves of ${panel.run.commit.slice(0, 8)}`));
|
|
292
|
+
runBox.appendChild(waveList(runRows(s.progress)));
|
|
293
|
+
}
|
|
294
|
+
root.appendChild(runBox);
|
|
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
|
+
}
|
|
300
|
+
|
|
301
|
+
function waveList(rows) {
|
|
302
|
+
const ol = el("ol", "k-waves");
|
|
303
|
+
for (const w of rows) {
|
|
304
|
+
const li = el("li", `k-wave k-tone-${w.tone}`);
|
|
305
|
+
li.dataset.state = w.state;
|
|
306
|
+
li.appendChild(el("div", "k-wave-title", w.text));
|
|
307
|
+
for (const f of w.facts) li.appendChild(el("div", "k-wave-fact", f));
|
|
308
|
+
ol.appendChild(li);
|
|
309
|
+
}
|
|
310
|
+
return ol;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const clockOf = (iso) => {
|
|
314
|
+
const d = new Date(iso);
|
|
315
|
+
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`;
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
/** How often the kiosk asks for demo.json. The server shares one read between kiosks. */
|
|
319
|
+
export const DEMO_POLL_MS = 3_000;
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Boot the kiosk into `root`: ask demo.json every few seconds, the reports
|
|
323
|
+
* and the run view when the step, cycle or commit moves or the event stream
|
|
324
|
+
* says they did, and redraw every second for the countdown.
|
|
325
|
+
*/
|
|
326
|
+
export function startKiosk(root, { fetchJson = defaultFetch, now = () => new Date() } = {}) {
|
|
327
|
+
const s = { demo: null, answer: null, progress: null };
|
|
328
|
+
let lastKey = "";
|
|
329
|
+
const draw = () => renderKiosk(root, s, now());
|
|
330
|
+
|
|
331
|
+
const reports = async (fresh) => {
|
|
332
|
+
s.answer = await fetchJson(`/api/terragucci${fresh ? "?fresh=1" : ""}`);
|
|
333
|
+
draw();
|
|
334
|
+
};
|
|
335
|
+
const progress = async (fresh) => {
|
|
336
|
+
s.progress = await fetchJson(`/api/terragucci/progress${fresh ? "?fresh=1" : ""}`);
|
|
337
|
+
draw();
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const demo = async () => {
|
|
341
|
+
s.demo = await fetchJson("/api/terragucci/demo");
|
|
342
|
+
const d = s.demo && s.demo.demo;
|
|
343
|
+
const key = d ? `${d.cycle}|${d.step}|${d.commit}|${d.pull}` : "";
|
|
344
|
+
if (key !== lastKey) {
|
|
345
|
+
lastKey = key;
|
|
346
|
+
// The step moved: the run it is about may be newer than what behold read.
|
|
347
|
+
const stage = d ? demoStage(d) : "none";
|
|
348
|
+
if (stage === "pull-request" || stage === "either") void reports(true);
|
|
349
|
+
if (stage === "apply" || stage === "either") void progress(true);
|
|
350
|
+
}
|
|
351
|
+
draw();
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
void demo();
|
|
355
|
+
void reports(false);
|
|
356
|
+
void progress(false);
|
|
357
|
+
setInterval(() => void demo(), DEMO_POLL_MS);
|
|
358
|
+
setInterval(draw, 1000);
|
|
359
|
+
|
|
360
|
+
if (typeof EventSource !== "undefined") {
|
|
361
|
+
const es = new EventSource("/api/terragucci/events");
|
|
362
|
+
es.addEventListener("reports", () => void reports(false));
|
|
363
|
+
es.addEventListener("progress", (e) => {
|
|
364
|
+
try {
|
|
365
|
+
s.progress = JSON.parse(e.data);
|
|
366
|
+
draw();
|
|
367
|
+
} catch {
|
|
368
|
+
void progress(false);
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
return s;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function defaultFetch(url) {
|
|
376
|
+
try {
|
|
377
|
+
const r = await fetch(url, { headers: { accept: "application/json" } });
|
|
378
|
+
const body = await r.json();
|
|
379
|
+
return r.ok ? body : { error: body.error || `${url} answered ${r.status}`, ...body };
|
|
380
|
+
} catch (e) {
|
|
381
|
+
return { error: `${url}: ${e instanceof Error ? e.message : String(e)}` };
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export { STATE_WORDS };
|
package/web/operator.js
CHANGED
|
@@ -30,6 +30,7 @@ import { waitingFor } from "./run-playhead.js";
|
|
|
30
30
|
const NO_APPROVE = {
|
|
31
31
|
host: "approve in the host that framed this graph",
|
|
32
32
|
preview: "approving is off in preview",
|
|
33
|
+
public: "a public view: approving is off",
|
|
33
34
|
static: "a snapshot: approve where behold runs",
|
|
34
35
|
};
|
|
35
36
|
|
package/web/run-playhead.js
CHANGED
|
@@ -202,5 +202,6 @@ export function renderGateCard(gate, actions = {}) {
|
|
|
202
202
|
const NO_APPROVE = {
|
|
203
203
|
host: "approve in the host that framed this graph",
|
|
204
204
|
preview: "approving is off in preview",
|
|
205
|
+
public: "a public view: approving is off",
|
|
205
206
|
static: "a snapshot: approve where behold runs",
|
|
206
207
|
};
|