@intentius/behold 0.29.7 → 0.29.9
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 +7 -0
- package/dist/cli.js +1221 -765
- package/package.json +1 -1
- package/web/app.js +60 -4
- package/web/index.html +2 -0
- package/web/kiosk-annotations.js +528 -0
- package/web/kiosk-annotations.test.js +83 -0
- package/web/kiosk-approval.js +241 -0
- package/web/kiosk-approval.test.js +122 -0
- package/web/kiosk-popover.js +1 -0
- package/web/kiosk-repo.js +25 -0
- package/web/kiosk-repo.test.js +43 -0
- package/web/kiosk-roots.js +15 -7
- package/web/kiosk-runs.js +360 -25
- package/web/kiosk-runs.test.js +104 -1
- package/web/kiosk-vendor.js +397 -0
- package/web/kiosk-vendor.test.js +281 -0
- package/web/kiosk.html +160 -17
- package/web/kiosk.js +465 -51
- package/web/kiosk.test.js +73 -8
- package/web/theme.js +112 -0
- package/web/theme.test.js +51 -1
- package/web/vendors/choudoufu-128.png +0 -0
- package/web/vendors/choudoufu-64.png +0 -0
- package/web/view-repo.js +44 -0
package/web/kiosk-runs.js
CHANGED
|
@@ -10,7 +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";
|
|
13
|
+
import { rootColour, rootPaint, rootSegments, rootText, rootsOfWave, waveOfJob } from "./kiosk-roots.js";
|
|
14
|
+
import { TOUR, chipWords, docsUrl, jobMarks, runFeatures } from "./kiosk-annotations.js";
|
|
14
15
|
|
|
15
16
|
/** The run, job and step statuses, as a viewer reads them. */
|
|
16
17
|
export const STATUS_WORDS = {
|
|
@@ -184,7 +185,12 @@ function ownSteps(steps) {
|
|
|
184
185
|
|
|
185
186
|
/** How many log lines a job's own steps wrote. */
|
|
186
187
|
function ownLines(steps) {
|
|
187
|
-
return ownSteps(steps).reduce((n, { s }) => n + (s.log
|
|
188
|
+
return ownSteps(steps).reduce((n, { s }) => n + saidLines(s.log), 0);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** behold#571: a log's lines that are not set-up or an artifact's copy, the ones a viewer reads. */
|
|
192
|
+
function saidLines(log) {
|
|
193
|
+
return (log || []).filter((l) => !setupLine(l) && typeof l === "string" && l.trim()).length;
|
|
188
194
|
}
|
|
189
195
|
|
|
190
196
|
/**
|
|
@@ -200,8 +206,8 @@ export function defaultStep(steps) {
|
|
|
200
206
|
const i = steps.findIndex((x) => x.status === s);
|
|
201
207
|
if (i >= 0) return i;
|
|
202
208
|
}
|
|
203
|
-
const own = ownSteps(steps).filter(({ s }) => s.log
|
|
204
|
-
if (own.length) return own.reduce((a, b) => (b.s.log
|
|
209
|
+
const own = ownSteps(steps).filter(({ s }) => saidLines(s.log) > 0);
|
|
210
|
+
if (own.length) return own.reduce((a, b) => (saidLines(b.s.log) > saidLines(a.s.log) ? b : a)).i;
|
|
205
211
|
const any = steps.map((s, i) => ({ s, i })).filter(({ s }) => s.log && s.log.length);
|
|
206
212
|
return any.length ? any[any.length - 1].i : 0;
|
|
207
213
|
}
|
|
@@ -289,6 +295,39 @@ export const SETUP_LINES = [
|
|
|
289
295
|
/^Deleting the contents of '/,
|
|
290
296
|
/^Syncing repository: /,
|
|
291
297
|
/^Working directory is '/,
|
|
298
|
+
// behold#571: an artifact's upload or download, as actions/upload-artifact and download-artifact print it.
|
|
299
|
+
/^With the provided path, there will be \d+ files? uploaded$/,
|
|
300
|
+
/^Starting artifact upload$/,
|
|
301
|
+
/^For more detailed logs during the artifact (?:upload|download) process, enable step-debugging: /,
|
|
302
|
+
/^Artifact name is valid!$/,
|
|
303
|
+
/^Container for artifact "[^"]*" successfully created\. Starting upload of file\(s\)$/,
|
|
304
|
+
/^Total size of all the files uploaded is \d+ bytes$/,
|
|
305
|
+
/^File upload process has finished\. Finalizing the artifact upload$/,
|
|
306
|
+
/^Artifact has been finalized\. All files have been successfully uploaded!$/,
|
|
307
|
+
/^The raw size of all the files that were specified for upload is \d+ bytes$/,
|
|
308
|
+
/^The size of all the files that were uploaded is \d+ bytes\. /,
|
|
309
|
+
/^Note: The size of downloaded zips can differ significantly from the reported size\./,
|
|
310
|
+
/^Artifact \S+ has been successfully uploaded!/,
|
|
311
|
+
/^Uploaded bytes \d+$/,
|
|
312
|
+
/^Beginning upload of artifact content to blob storage$/,
|
|
313
|
+
/^Finished uploading artifact content to blob storage!$/,
|
|
314
|
+
/^SHA256 (?:hash|digest) of (?:uploaded )?artifact (?:zip )?is /,
|
|
315
|
+
/^Finalizing artifact upload$/,
|
|
316
|
+
/^Artifact \S+ successfully finalized\. Artifact ID \d+$/,
|
|
317
|
+
/^Artifact download URL: /,
|
|
318
|
+
/^Starting download for \S+$/,
|
|
319
|
+
/^Directory structure has been setup for the artifact$/,
|
|
320
|
+
/^Total number of files that will be downloaded: \d+$/,
|
|
321
|
+
/^Artifact \S+ was downloaded to /,
|
|
322
|
+
/^Artifact download has finished successfully$/,
|
|
323
|
+
/^Downloading single artifact$/,
|
|
324
|
+
/^Preparing to download the following artifacts:$/,
|
|
325
|
+
/^- \S+ \(ID: \d+, Size: \d+/,
|
|
326
|
+
/^Redirecting to blob download url: /,
|
|
327
|
+
/^Starting download of artifact to: /,
|
|
328
|
+
/^Artifact download completed successfully\.$/,
|
|
329
|
+
/^Total of \d+ artifact\(s\) downloaded$/,
|
|
330
|
+
/^Download artifact has finished successfully$/,
|
|
292
331
|
];
|
|
293
332
|
|
|
294
333
|
/** Whether a log line is set-up noise (SETUP_LINES). */
|
|
@@ -302,8 +341,22 @@ export function setupLine(line) {
|
|
|
302
341
|
* opened (`open`), the row heads the whole log in order. Every line keeps
|
|
303
342
|
* its number in the log. A log with no set-up lines is drawn as it came.
|
|
304
343
|
*/
|
|
305
|
-
export function logRows(lines, { open = false, from = 0 } = {}) {
|
|
306
|
-
|
|
344
|
+
export function logRows(lines, { open = false, from = 0, hide = null } = {}) {
|
|
345
|
+
// behold#571: a tool's page leaves out a line naming another tool, and says how many it left out.
|
|
346
|
+
const every = (lines || []).map((text, i) => ({ n: from + i + 1, text, setup: setupLine(text) }));
|
|
347
|
+
// behold#571: a blank line after a set-up line (the upload's spacing) folds with it.
|
|
348
|
+
let prev = false;
|
|
349
|
+
for (const l of every) {
|
|
350
|
+
if (typeof l.text === "string" && !l.text.trim()) l.setup = prev;
|
|
351
|
+
else prev = l.setup;
|
|
352
|
+
}
|
|
353
|
+
const all = hide ? every.filter((l) => !hide(l.text)) : every;
|
|
354
|
+
const gone = every.length - all.length;
|
|
355
|
+
const rows = logRowsOf(all, open);
|
|
356
|
+
return gone ? [...rows, { hidden: gone }] : rows;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function logRowsOf(all, open) {
|
|
307
360
|
const n = all.filter((l) => l.setup).length;
|
|
308
361
|
if (!n) return all.map(({ n: num, text }) => ({ n: num, text }));
|
|
309
362
|
const head = { setup: n, open };
|
|
@@ -355,11 +408,12 @@ export function statusIcon(status) {
|
|
|
355
408
|
}
|
|
356
409
|
|
|
357
410
|
/** The sidebar: the live run pinned, then the recent ones. `pick(id)` selects one; nothing here writes. */
|
|
358
|
-
export function renderSidebar(host, index, selectedId, { now = new Date(), scenarios = {}, titles = {}, names = {}, pick = () => {}, merged = null } = {}) {
|
|
411
|
+
export function renderSidebar(host, index, selectedId, { now = new Date(), scenarios = {}, titles = {}, names = {}, pick = () => {}, merged = null, replay = false } = {}) {
|
|
359
412
|
host.replaceChildren();
|
|
360
413
|
const head = el("div", "r-side-head");
|
|
361
414
|
head.appendChild(el("div", "r-side-title", "Pipeline runs"));
|
|
362
|
-
|
|
415
|
+
// behold#571: a tool's page replays an earlier cycle's runs, and says so.
|
|
416
|
+
head.appendChild(el("div", "r-side-note", replay ? "Real CI runs of the replayed cycle, as they stood at this point in it. Newest first." : "Real CI runs of the demo repo, copied from its forge as they go. Newest first."));
|
|
363
417
|
host.appendChild(head);
|
|
364
418
|
if (!index || index.error || index.absent || !index.doc) {
|
|
365
419
|
host.appendChild(el("p", "r-note", index ? index.absent || index.error : "Reading the runs…"));
|
|
@@ -367,11 +421,15 @@ export function renderSidebar(host, index, selectedId, { now = new Date(), scena
|
|
|
367
421
|
}
|
|
368
422
|
// The selected run as its page draws it, when its own document is newer than the index.
|
|
369
423
|
const runs = sidebarRuns(index.doc.runs).map((r) => (merged && merged.id === r.id ? merged : r));
|
|
424
|
+
if (!runs.length) {
|
|
425
|
+
host.appendChild(el("p", "r-note", "No runs to show yet."));
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
370
428
|
const live = runs[0];
|
|
371
429
|
const named = scenarioTitles(index.doc.runs, titles);
|
|
372
430
|
const list = el("ol", "r-runs");
|
|
373
431
|
runs.forEach((r, i) => {
|
|
374
|
-
if (i === 0) list.appendChild(el("li", "r-group", LIVE.has(live.status) ? "Live" : "Latest"));
|
|
432
|
+
if (i === 0) list.appendChild(el("li", "r-group", replay ? (LIVE.has(live.status) ? "Running in the replay" : "Latest in the replay") : LIVE.has(live.status) ? "Live" : "Latest"));
|
|
375
433
|
if (i === 1) list.appendChild(el("li", "r-group", "Recent"));
|
|
376
434
|
const li = el("li", `r-run${r.id === selectedId ? " r-selected" : ""}`);
|
|
377
435
|
li.tabIndex = 0;
|
|
@@ -407,7 +465,7 @@ export function renderSidebar(host, index, selectedId, { now = new Date(), scena
|
|
|
407
465
|
* null for the defaults. Returns what it drew, so the caller can keep a log's
|
|
408
466
|
* scroll.
|
|
409
467
|
*/
|
|
410
|
-
export function renderRun(host, answer, ui, { now = new Date(), pickJob = () => {}, toggleStep = () => {}, toggleSetup = () => {}, entry = null, roots = [], waves = [], light = false } = {}) {
|
|
468
|
+
export function renderRun(host, answer, ui, { now = new Date(), pickJob = () => {}, toggleStep = () => {}, toggleSetup = () => {}, jump = () => {}, toggleFeats = () => {}, featsOpen = false, note = null, flash = null, entry = null, roots = [], waves = [], light = false, hideLine = null } = {}) {
|
|
411
469
|
// behold#571: a root's name with its colour's dot, where the line names one.
|
|
412
470
|
const textOf = (cls, text) => {
|
|
413
471
|
if (!roots.length || !rootSegments(text, roots).some((x) => x.root)) return el("span", cls, text);
|
|
@@ -458,7 +516,7 @@ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () =>
|
|
|
458
516
|
for (const r of applies) {
|
|
459
517
|
const name = el("span", "k-root", r);
|
|
460
518
|
name.dataset.kRoot = r;
|
|
461
|
-
name.style.setProperty("--k-root",
|
|
519
|
+
name.style.setProperty("--k-root", rootPaint(r, roots, { light }));
|
|
462
520
|
box.appendChild(name);
|
|
463
521
|
}
|
|
464
522
|
li.appendChild(box);
|
|
@@ -471,6 +529,31 @@ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () =>
|
|
|
471
529
|
if (skipped.length) jobsBox.appendChild(el("li", "r-job r-job-skipped", `${skipped.length} skipped, not for this event: ${skipped.map((j) => j.name).join(", ")}`));
|
|
472
530
|
host.appendChild(jobsBox);
|
|
473
531
|
|
|
532
|
+
// behold#571: the features this run's logs show, by tool, each jumping to its first line.
|
|
533
|
+
// One wrapping list (a tool's name, then its features); folded, it shows whole lines only (fitFeatures).
|
|
534
|
+
const feats = runFeatures(shown, { hide: hideLine });
|
|
535
|
+
if (feats.length) {
|
|
536
|
+
const box = el("div", `r-feats${featsOpen ? " r-feats-open" : ""}`);
|
|
537
|
+
box.appendChild(el("div", "r-label r-feats-label", "Features in this run"));
|
|
538
|
+
const list = el("div", "r-feats-list");
|
|
539
|
+
for (const g of feats) {
|
|
540
|
+
list.appendChild(el("span", `r-feats-product r-ann-${g.product}`, g.product));
|
|
541
|
+
for (const f of g.features) {
|
|
542
|
+
const b = el("button", "r-feat", f.feature);
|
|
543
|
+
b.type = "button";
|
|
544
|
+
b.title = `${f.a.note} (${f.job}, line ${f.n})`;
|
|
545
|
+
b.addEventListener("click", () => jump(f.job, f.step, f.n));
|
|
546
|
+
list.appendChild(b);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
const more = el("button", "r-feats-more", featsOpen ? "Show fewer" : "");
|
|
550
|
+
more.type = "button";
|
|
551
|
+
more.addEventListener("click", () => toggleFeats());
|
|
552
|
+
list.appendChild(more);
|
|
553
|
+
box.appendChild(list);
|
|
554
|
+
host.appendChild(box);
|
|
555
|
+
}
|
|
556
|
+
|
|
474
557
|
if (!job) return { log: null, steps: null, key: null };
|
|
475
558
|
host.appendChild(el("div", "r-label", "Steps of the job, with its log"));
|
|
476
559
|
const stepsBox = el("div", "r-steps");
|
|
@@ -489,6 +572,7 @@ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () =>
|
|
|
489
572
|
}
|
|
490
573
|
const open = ui.open !== null && ui.open !== undefined && ui.job === job.name ? ui.open : defaultStep(steps);
|
|
491
574
|
let logEl = null;
|
|
575
|
+
const marks = jobMarks(steps, { hide: hideLine });
|
|
492
576
|
steps.forEach((s, i) => {
|
|
493
577
|
const row = el("div", `r-step${i === open ? " r-open" : ""}`);
|
|
494
578
|
const line = el("div", "r-step-line");
|
|
@@ -499,9 +583,13 @@ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () =>
|
|
|
499
583
|
const pre = el("pre", "r-log");
|
|
500
584
|
if (s.truncated) pre.appendChild(el("div", "r-log-cut", `… ${s.truncated} earlier lines not kept`));
|
|
501
585
|
const key = logKey(run.id, job.name, i);
|
|
502
|
-
for (const row of logRows(s.log, { open: ui.setup === key, from: s.truncated || 0 })) {
|
|
586
|
+
for (const row of logRows(s.log, { open: ui.setup === key, from: s.truncated || 0, hide: hideLine })) {
|
|
587
|
+
if (row.hidden) {
|
|
588
|
+
pre.appendChild(el("div", "r-log-cut", `${row.hidden} line${row.hidden === 1 ? "" : "s"} of this log left out on this page`));
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
503
591
|
if (row.setup) {
|
|
504
|
-
const fold = el("div", `r-log-setup${row.open ? " r-log-setup-open" : ""}`, `${row.open ? "▾" : "▸"}
|
|
592
|
+
const fold = el("div", `r-log-setup${row.open ? " r-log-setup-open" : ""}`, `${row.open ? "▾" : "▸"} runner set-up and file copies (${row.setup} line${row.setup === 1 ? "" : "s"})`);
|
|
505
593
|
fold.setAttribute("role", "button");
|
|
506
594
|
fold.tabIndex = 0;
|
|
507
595
|
fold.setAttribute("aria-expanded", String(row.open));
|
|
@@ -512,9 +600,20 @@ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () =>
|
|
|
512
600
|
pre.appendChild(fold);
|
|
513
601
|
continue;
|
|
514
602
|
}
|
|
515
|
-
const
|
|
603
|
+
const lineKey = `${key}|${row.n}`;
|
|
604
|
+
const ln = el("div", `r-log-line${row.quiet ? " r-log-quiet" : ""}${flash === lineKey ? " r-flash" : ""}`);
|
|
605
|
+
ln.dataset.n = String(row.n);
|
|
516
606
|
const shown = logLineWords(job.status, row.text);
|
|
517
|
-
|
|
607
|
+
const text = 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);
|
|
608
|
+
ln.append(el("span", "r-ln", String(row.n)), text);
|
|
609
|
+
// behold#571: the line's feature, as a chip on its first line in the job and a dot after.
|
|
610
|
+
const mark = marks.get(`${i}:${row.n}`);
|
|
611
|
+
if (mark) {
|
|
612
|
+
ln.classList.add("r-ann-line");
|
|
613
|
+
ln.dataset.annKey = lineKey;
|
|
614
|
+
text.append(" ", annMark(mark.a, { first: mark.first, tour: mark.first && TOUR.includes(mark.a.id) }));
|
|
615
|
+
if (note && note.key === lineKey) ln.appendChild(annNote(mark.a, note));
|
|
616
|
+
}
|
|
518
617
|
pre.appendChild(ln);
|
|
519
618
|
}
|
|
520
619
|
if (!s.log.length) pre.appendChild(el("div", "r-log-cut", s.status === "running" ? "waiting for output…" : "no output"));
|
|
@@ -528,6 +627,73 @@ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () =>
|
|
|
528
627
|
return { log: logEl, steps: stepsBox, key: logEl ? logKey(run.id, job.name, open) : null, live: LIVE.has(job.status) };
|
|
529
628
|
}
|
|
530
629
|
|
|
630
|
+
/** behold#571: a line's feature mark: an outlined chip (the tool and the feature), or a faint dot on a later line of it. */
|
|
631
|
+
export function annMark(a, { first = true, tour = false } = {}) {
|
|
632
|
+
const b = el("button", first ? `r-ann r-ann-${a.product}${tour ? " r-ann-tour" : ""}` : `r-ann-dot r-ann-${a.product}`, first ? chipWords(a) : "");
|
|
633
|
+
b.type = "button";
|
|
634
|
+
b.dataset.ann = a.id;
|
|
635
|
+
b.setAttribute("aria-label", `${chipWords(a)}: ${a.note}`);
|
|
636
|
+
if (!first) b.title = chipWords(a);
|
|
637
|
+
return b;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** behold#571: a feature's note, opened under its line: the sentence, and the docs in a new tab. */
|
|
641
|
+
export function annNote(a, { auto = false, pinned = false } = {}) {
|
|
642
|
+
const box = el("div", `r-ann-note${auto ? " r-ann-auto" : ""}${pinned ? " r-ann-pinned" : ""}`);
|
|
643
|
+
box.setAttribute("role", "note");
|
|
644
|
+
box.appendChild(el("div", `r-ann-note-head r-ann-${a.product}`, chipWords(a)));
|
|
645
|
+
box.appendChild(el("p", "r-ann-note-text", a.note));
|
|
646
|
+
const link = el("a", "r-ann-more", "Read more");
|
|
647
|
+
link.href = docsUrl(a);
|
|
648
|
+
link.target = "_blank";
|
|
649
|
+
link.rel = "noopener";
|
|
650
|
+
box.appendChild(link);
|
|
651
|
+
return box;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** behold#571: how many whole lines the folded features strip shows. */
|
|
655
|
+
export const FEATURE_LINES = 3;
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* behold#571: fold the features strip to FEATURE_LINES whole lines: what
|
|
659
|
+
* would wrap past them is hidden, never cut through, and a "+N more" button
|
|
660
|
+
* on the last line opens the rest. A tool's name left last on a line with
|
|
661
|
+
* none of its features after it goes too. Measures the drawn list, so it runs
|
|
662
|
+
* after layout and again whenever the list's width or its features change.
|
|
663
|
+
* The button shows only when it hides at least one feature (never "+0 more"),
|
|
664
|
+
* and opened, only when there is something to fold back.
|
|
665
|
+
*/
|
|
666
|
+
export function fitFeatures(list, open, lines = FEATURE_LINES) {
|
|
667
|
+
if (!list) return;
|
|
668
|
+
const more = list.querySelector(".r-feats-more");
|
|
669
|
+
const items = [...list.children].filter((c) => c !== more);
|
|
670
|
+
for (const i of items) i.hidden = false;
|
|
671
|
+
more.hidden = true;
|
|
672
|
+
const tops = [...new Set(items.map((i) => i.offsetTop))].sort((a, b) => a - b);
|
|
673
|
+
if (tops.length <= lines) return;
|
|
674
|
+
const last = tops[lines - 1];
|
|
675
|
+
if (open) {
|
|
676
|
+
// Folded, it would hide a feature: offer to fold it back.
|
|
677
|
+
more.hidden = !items.some((i) => i.offsetTop > last && i.classList.contains("r-feat"));
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
const shown = items.filter((i) => i.offsetTop <= last);
|
|
681
|
+
for (const i of items) if (i.offsetTop > last) i.hidden = true;
|
|
682
|
+
const count = () => items.filter((i) => i.hidden && i.classList.contains("r-feat")).length;
|
|
683
|
+
if (count() > 0) {
|
|
684
|
+
more.hidden = false;
|
|
685
|
+
more.textContent = `+${count()} more`;
|
|
686
|
+
// The button sits on the last line: hide features from its end until it fits there.
|
|
687
|
+
while (more.offsetTop > last && shown.length) {
|
|
688
|
+
shown.pop().hidden = true;
|
|
689
|
+
more.textContent = `+${count()} more`;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
// A tool's name with none of its features shown after it.
|
|
693
|
+
const vis = items.filter((i) => !i.hidden);
|
|
694
|
+
if (vis.length && vis[vis.length - 1].classList.contains("r-feats-product")) vis[vis.length - 1].hidden = true;
|
|
695
|
+
}
|
|
696
|
+
|
|
531
697
|
/** Which log is open: the run, the job and the step. A new key means a different log, which the pane follows from its end. */
|
|
532
698
|
export function logKey(runId, job, step) {
|
|
533
699
|
return `${runId}|${job}|${step}`;
|
|
@@ -560,8 +726,30 @@ export const PICK_HOLD_MS = 90_000;
|
|
|
560
726
|
* is live), and redraw. The live run is selected until a viewer picks
|
|
561
727
|
* another, and again {@link PICK_HOLD_MS} after their last pick.
|
|
562
728
|
*/
|
|
563
|
-
export function startRuns(
|
|
564
|
-
|
|
729
|
+
export function startRuns(
|
|
730
|
+
{ side, main },
|
|
731
|
+
{
|
|
732
|
+
fetchJson,
|
|
733
|
+
now = () => new Date(),
|
|
734
|
+
shown = now,
|
|
735
|
+
scenarios = {},
|
|
736
|
+
titles = () => ({}),
|
|
737
|
+
names = () => ({}),
|
|
738
|
+
onLive = () => {},
|
|
739
|
+
roots = () => [],
|
|
740
|
+
waves = () => [],
|
|
741
|
+
light = () => false,
|
|
742
|
+
source = () => null,
|
|
743
|
+
filter = (index) => index,
|
|
744
|
+
replay = () => false,
|
|
745
|
+
hideLine = null,
|
|
746
|
+
} = {},
|
|
747
|
+
) {
|
|
748
|
+
const s = { index: null, run: null, selected: null, picked: 0, ui: { job: null, open: null, setup: null }, note: null, flash: null, scrollTo: null, pending: null, featsOpen: false };
|
|
749
|
+
// behold#571: the notes a kiosk with nobody at it has opened by itself, and when it last had a pointer.
|
|
750
|
+
const toured = new Set();
|
|
751
|
+
let lastPointer = -Infinity;
|
|
752
|
+
let tourRest = 0;
|
|
565
753
|
let follow = true;
|
|
566
754
|
let lastRunKey = "";
|
|
567
755
|
// The open log and whether the pane follows its end; a viewer who scrolls up stops it until they come back.
|
|
@@ -581,20 +769,52 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
|
|
|
581
769
|
const id = selectedId();
|
|
582
770
|
const entry = s.index && s.index.doc ? s.index.doc.runs.find((r) => r.id === id) || null : null;
|
|
583
771
|
const ans = s.run && (!s.run.doc || s.run.doc.id === id) ? s.run : null;
|
|
772
|
+
// behold#571: a line asked for from elsewhere (the approval panel), once its run is read.
|
|
773
|
+
if (s.pending && ans && ans.doc && ans.doc.id === s.pending.run) {
|
|
774
|
+
const p = s.pending;
|
|
775
|
+
s.pending = null;
|
|
776
|
+
s.ui = { job: p.job, open: p.step, setup: null };
|
|
777
|
+
const k = `${logKey(p.run, p.job, p.step)}|${p.n}`;
|
|
778
|
+
s.scrollTo = k;
|
|
779
|
+
s.flash = { key: k, until: now().getTime() + FLASH_MS };
|
|
780
|
+
s.note = p.note === false ? s.note : { key: k, pinned: true, auto: false };
|
|
781
|
+
}
|
|
782
|
+
const t = now().getTime();
|
|
783
|
+
if (s.flash && t > s.flash.until) s.flash = null;
|
|
784
|
+
if (s.note && s.note.auto && t > s.note.until) {
|
|
785
|
+
s.note = null;
|
|
786
|
+
tourRest = t + TOUR_REST_MS;
|
|
787
|
+
}
|
|
584
788
|
// One merged read of the selected run for both the sidebar and its page.
|
|
585
789
|
const merged = ans && ans.doc ? mergeRun(entry, ans.doc) : null;
|
|
586
|
-
renderSidebar(side, s.index, id, { now:
|
|
790
|
+
renderSidebar(side, s.index, id, { now: shown(), scenarios, titles: titles(), names: names(), pick, merged, replay: replay() });
|
|
587
791
|
// The redraw replaces the scrollers: keep where a viewer left them.
|
|
588
792
|
const old = main.querySelector(".r-log");
|
|
589
793
|
const oldSteps = main.querySelector(".r-steps");
|
|
590
794
|
const prevTop = old ? old.scrollTop : 0;
|
|
591
795
|
const prevStepsTop = oldSteps ? oldSteps.scrollTop : 0;
|
|
592
796
|
const drawn = renderRun(main, ans, s.ui, {
|
|
593
|
-
now:
|
|
797
|
+
now: shown(),
|
|
594
798
|
entry,
|
|
595
799
|
roots: roots(),
|
|
596
800
|
waves: waves(),
|
|
597
801
|
light: light(),
|
|
802
|
+
hideLine,
|
|
803
|
+
note: s.note,
|
|
804
|
+
flash: s.flash ? s.flash.key : null,
|
|
805
|
+
featsOpen: s.featsOpen,
|
|
806
|
+
toggleFeats: () => {
|
|
807
|
+
s.featsOpen = !s.featsOpen;
|
|
808
|
+
hold();
|
|
809
|
+
draw();
|
|
810
|
+
},
|
|
811
|
+
jump: (job, step, n) => {
|
|
812
|
+
const runId = ans && ans.doc ? ans.doc.id : null;
|
|
813
|
+
if (runId === null) return;
|
|
814
|
+
s.pending = { run: runId, job, step, n };
|
|
815
|
+
hold();
|
|
816
|
+
draw();
|
|
817
|
+
},
|
|
598
818
|
pickJob: (name) => {
|
|
599
819
|
s.ui = { job: name, open: null, setup: null };
|
|
600
820
|
hold();
|
|
@@ -611,6 +831,7 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
|
|
|
611
831
|
draw();
|
|
612
832
|
},
|
|
613
833
|
});
|
|
834
|
+
fitFeatures(main.querySelector(".r-feats-list"), s.featsOpen);
|
|
614
835
|
logFollow = followsLog(logShown, drawn.key, logFollow);
|
|
615
836
|
const sameLog = drawn.key !== null && drawn.key === logShown;
|
|
616
837
|
logShown = drawn.key;
|
|
@@ -620,7 +841,17 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
|
|
|
620
841
|
}
|
|
621
842
|
const log = drawn.log;
|
|
622
843
|
const steps = drawn.steps;
|
|
623
|
-
|
|
844
|
+
const target = s.scrollTo && s.scrollTo.startsWith(`${drawn.key}|`) ? log.querySelector(`.r-log-line[data-n="${s.scrollTo.slice(drawn.key.length + 1)}"]`) : null;
|
|
845
|
+
if (target) {
|
|
846
|
+
// A line asked for: in the middle of the log's view, the log in the steps' view; the pane stops following.
|
|
847
|
+
s.scrollTo = null;
|
|
848
|
+
logFollow = false;
|
|
849
|
+
log.scrollTop = Math.max(0, target.offsetTop - log.clientHeight / 3);
|
|
850
|
+
steps.scrollTop = 0;
|
|
851
|
+
const below = log.getBoundingClientRect().bottom - steps.getBoundingClientRect().bottom;
|
|
852
|
+
if (below > 0) steps.scrollTop += below + 8;
|
|
853
|
+
if (typeof target.scrollIntoView === "function" && main.closest && main.closest(".k-runs-open")) target.scrollIntoView({ block: "center" });
|
|
854
|
+
} else if (logFollow) {
|
|
624
855
|
log.scrollTop = log.scrollHeight;
|
|
625
856
|
// The steps list scrolls too: bring the log's end into its view.
|
|
626
857
|
steps.scrollTop = sameLog ? prevStepsTop : 0;
|
|
@@ -639,7 +870,83 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
|
|
|
639
870
|
};
|
|
640
871
|
log.addEventListener("scroll", onScroll, { passive: true });
|
|
641
872
|
steps.addEventListener("scroll", onScroll, { passive: true });
|
|
873
|
+
tour(log);
|
|
642
874
|
};
|
|
875
|
+
/**
|
|
876
|
+
* behold#571: with nobody at the screen (no pointer or touch for a while,
|
|
877
|
+
* and not a touch screen), the tour's chips open their notes one at a
|
|
878
|
+
* time, each for a few seconds, as their line scrolls into the log's view.
|
|
879
|
+
*/
|
|
880
|
+
const tour = (log) => {
|
|
881
|
+
const t = now().getTime();
|
|
882
|
+
if (s.note || t < tourRest || t - lastPointer < IDLE_MS || !tourable()) return;
|
|
883
|
+
const view = log.getBoundingClientRect();
|
|
884
|
+
if (!view.height) return;
|
|
885
|
+
for (const chip of log.querySelectorAll(".r-ann-tour")) {
|
|
886
|
+
const line = chip.closest(".r-ann-line");
|
|
887
|
+
const key = line && line.dataset.annKey;
|
|
888
|
+
if (!key || toured.has(key)) continue;
|
|
889
|
+
const r = line.getBoundingClientRect();
|
|
890
|
+
if (r.top < view.top || r.bottom > view.bottom - view.height / 4) continue;
|
|
891
|
+
toured.add(key);
|
|
892
|
+
s.note = { key, pinned: false, auto: true, until: t + TOUR_NOTE_MS };
|
|
893
|
+
draw();
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
};
|
|
897
|
+
// behold#571: pointing at a chip or a dot opens its note, a click or a tap keeps it open; a click elsewhere closes it.
|
|
898
|
+
const annKeyAt = (target) => {
|
|
899
|
+
const mark = target && target.closest ? target.closest(".r-ann, .r-ann-dot, .r-ann-note") : null;
|
|
900
|
+
const line = mark && mark.closest(".r-ann-line");
|
|
901
|
+
return line ? line.dataset.annKey : null;
|
|
902
|
+
};
|
|
903
|
+
// behold#571: the features strip folds by measuring its drawn lines, so it measures again
|
|
904
|
+
// when the panel's width changes (a resize, the panel opening) and once the fonts load.
|
|
905
|
+
const refitFeatures = () => fitFeatures(main.querySelector && main.querySelector(".r-feats-list"), s.featsOpen);
|
|
906
|
+
if (typeof ResizeObserver !== "undefined" && typeof Element !== "undefined" && main instanceof Element) {
|
|
907
|
+
let width = -1;
|
|
908
|
+
new ResizeObserver((entries) => {
|
|
909
|
+
const w = Math.round(entries[entries.length - 1].contentRect.width);
|
|
910
|
+
if (w === width) return;
|
|
911
|
+
width = w;
|
|
912
|
+
refitFeatures();
|
|
913
|
+
}).observe(main);
|
|
914
|
+
}
|
|
915
|
+
if (typeof document !== "undefined" && document.fonts && document.fonts.ready) document.fonts.ready.then(refitFeatures, () => {});
|
|
916
|
+
if (main.addEventListener) {
|
|
917
|
+
const seen =() => (lastPointer = now().getTime());
|
|
918
|
+
for (const ev of ["pointermove", "pointerdown", "touchstart", "wheel", "keydown"]) document.addEventListener(ev, seen, { passive: true });
|
|
919
|
+
main.addEventListener("pointerover", (e) => {
|
|
920
|
+
if (e.pointerType === "touch") return;
|
|
921
|
+
const key = annKeyAt(e.target);
|
|
922
|
+
if (s.note && (s.note.pinned || s.note.key === key)) return;
|
|
923
|
+
if (!key && !s.note) return;
|
|
924
|
+
s.note = key ? { key, pinned: false, auto: false } : null;
|
|
925
|
+
draw();
|
|
926
|
+
});
|
|
927
|
+
document.addEventListener("click", (e) => {
|
|
928
|
+
// The approval panel's chips and the features strip show their line, which opens its note.
|
|
929
|
+
if (e.target && e.target.closest && e.target.closest("[data-ann-jump], .r-feat")) return;
|
|
930
|
+
const key = annKeyAt(e.target);
|
|
931
|
+
const onMark = e.target && e.target.closest && e.target.closest(".r-ann, .r-ann-dot");
|
|
932
|
+
if (onMark && key) {
|
|
933
|
+
s.note = s.note && s.note.key === key && s.note.pinned ? null : { key, pinned: true, auto: false };
|
|
934
|
+
hold();
|
|
935
|
+
draw();
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
if (s.note && !key) {
|
|
939
|
+
s.note = null;
|
|
940
|
+
draw();
|
|
941
|
+
}
|
|
942
|
+
});
|
|
943
|
+
document.addEventListener("keydown", (e) => {
|
|
944
|
+
if (e.key === "Escape" && s.note) {
|
|
945
|
+
s.note = null;
|
|
946
|
+
draw();
|
|
947
|
+
}
|
|
948
|
+
});
|
|
949
|
+
}
|
|
643
950
|
const hold = () => {
|
|
644
951
|
follow = false;
|
|
645
952
|
s.selected = selectedId() ?? s.selected;
|
|
@@ -653,7 +960,9 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
|
|
|
653
960
|
void readRun();
|
|
654
961
|
};
|
|
655
962
|
const readIndex = async () => {
|
|
656
|
-
s
|
|
963
|
+
// behold#571: a tool's page reads its replay's runs from `source`, and keeps its own repo's alone.
|
|
964
|
+
const src = source();
|
|
965
|
+
s.index = filter(src ? await src.index() : await fetchJson("/api/terragucci/runs"));
|
|
657
966
|
const runs = s.index && s.index.doc ? s.index.doc.runs : [];
|
|
658
967
|
const live = liveRun(runs);
|
|
659
968
|
onLive(live);
|
|
@@ -661,18 +970,44 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
|
|
|
661
970
|
};
|
|
662
971
|
const readRun = async () => {
|
|
663
972
|
const id = selectedId();
|
|
664
|
-
if (id === null)
|
|
973
|
+
if (id === null) {
|
|
974
|
+
if (s.index && s.index.doc) s.run = { doc: null, absent: "No run to show yet." };
|
|
975
|
+
return draw();
|
|
976
|
+
}
|
|
665
977
|
const entry = s.index && s.index.doc ? s.index.doc.runs.find((r) => r.id === id) : null;
|
|
666
978
|
// A run that ended reads once; one still going reads on every tick.
|
|
667
|
-
const
|
|
979
|
+
const src = source();
|
|
980
|
+
const key = `${src ? "replay" : "live"}|${id}|${entry ? entry.status : ""}|${entry ? entry.ended_at : ""}`;
|
|
668
981
|
if (key === lastRunKey && entry && !LIVE.has(entry.status) && s.run && s.run.doc && s.run.doc.id === id) return draw();
|
|
669
|
-
if (s.run && s.run.doc && s.run.doc.id !== id) s.ui = { job: null, open: null, setup: null };
|
|
982
|
+
if (s.run && s.run.doc && s.run.doc.id !== id && !s.pending) s.ui = { job: null, open: null, setup: null };
|
|
670
983
|
lastRunKey = key;
|
|
671
|
-
s.run = await fetchJson(`/api/terragucci/runs/${id}`);
|
|
984
|
+
s.run = src ? await src.run(id) : await fetchJson(`/api/terragucci/runs/${id}`);
|
|
672
985
|
draw();
|
|
673
986
|
};
|
|
674
987
|
void readIndex();
|
|
675
988
|
setInterval(() => void readIndex(), 3_000);
|
|
676
989
|
setInterval(draw, 1_000);
|
|
990
|
+
/** behold#571: show a run's log line from elsewhere (the approval panel's chips): its job, its step open, the line in view and lit, its note open. */
|
|
991
|
+
s.show = (run, job, step, n) => {
|
|
992
|
+
s.selected = run;
|
|
993
|
+
follow = false;
|
|
994
|
+
s.picked = now().getTime();
|
|
995
|
+
s.pending = { run, job, step, n };
|
|
996
|
+
if (s.run && s.run.doc && s.run.doc.id === run) draw();
|
|
997
|
+
else void readRun();
|
|
998
|
+
};
|
|
677
999
|
return s;
|
|
678
1000
|
}
|
|
1001
|
+
|
|
1002
|
+
/** How long a line asked for stays lit. */
|
|
1003
|
+
export const FLASH_MS = 2_500;
|
|
1004
|
+
/** How long a tour's note stays open, and the rest before the next. */
|
|
1005
|
+
export const TOUR_NOTE_MS = 6_000;
|
|
1006
|
+
export const TOUR_REST_MS = 2_000;
|
|
1007
|
+
/** How long without a pointer or a touch before the kiosk counts as unattended. */
|
|
1008
|
+
export const IDLE_MS = 45_000;
|
|
1009
|
+
|
|
1010
|
+
/** Whether this screen takes a tour: not a touch screen, where a finger opens the notes. */
|
|
1011
|
+
function tourable() {
|
|
1012
|
+
return !(typeof matchMedia === "function" && matchMedia("(pointer: coarse)").matches);
|
|
1013
|
+
}
|