@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.js
CHANGED
|
@@ -13,8 +13,12 @@
|
|
|
13
13
|
// A wave that failed says which waves applied before it and stay applied.
|
|
14
14
|
// Nothing here is all-or-nothing, and nothing says so.
|
|
15
15
|
|
|
16
|
-
import { PICK_HOLD_MS, startRuns } from "./kiosk-runs.js";
|
|
16
|
+
import { PICK_HOLD_MS, annMark, mergeRun, startRuns } from "./kiosk-runs.js";
|
|
17
|
+
import { PANEL_STEPS, PANEL_TITLE, approvalModel, cycleRuns, rowFacts, shortDigest } from "./kiosk-approval.js";
|
|
18
|
+
import { getThemeName, mountThemePicker, onThemeChange } from "./theme.js";
|
|
17
19
|
import { cssString, hoverCss, hoverFromMessage, hoverLinks, hoverMessage, isLightBg, rootSegments, rootText } from "./kiosk-roots.js";
|
|
20
|
+
import { creditLines, filterIndex, keepRun, liveEstimate, liveFor, namesOther, notRunRepo, notRunWords, replayAt, replayBanner, replayIndex, rootWord, runAsOf, scrubDemo, servesRepo, vendorOf } from "./kiosk-vendor.js";
|
|
21
|
+
import { repoParam, repoUrl } from "./kiosk-repo.js";
|
|
18
22
|
import { STATE_WORDS, changingRoots, costLine, pinsCommit, planFitsPin, pullPin, readEdges, rootsCount, totalsLine, waveRows, blastRows } from "./terragucci-change.js";
|
|
19
23
|
|
|
20
24
|
/** The scenarios terragucci#1014 numbers. An id not here is shown by the title demo.json carries. */
|
|
@@ -68,7 +72,12 @@ export function scenarioName(demo) {
|
|
|
68
72
|
* place of SCENARIOS' label when it has one; the driver's title alone for an
|
|
69
73
|
* id behold doesn't know.
|
|
70
74
|
*/
|
|
71
|
-
export function scenarioHead(demo) {
|
|
75
|
+
export function scenarioHead(demo, { vendor = null } = {}) {
|
|
76
|
+
// behold#571: a tool's page numbers nothing, so nothing hints at the loop's other scenarios.
|
|
77
|
+
if (vendor) {
|
|
78
|
+
const name = scenarioName(demo);
|
|
79
|
+
return { kicker: name ? `Scenario · ${name}` : "Scenario", title: demo.title || name || demo.scenario };
|
|
80
|
+
}
|
|
72
81
|
const known = Object.prototype.hasOwnProperty.call(SCENARIOS, demo.scenario) ? SCENARIOS[demo.scenario] : null;
|
|
73
82
|
const name = scenarioName(demo);
|
|
74
83
|
if (counted(demo.index)) {
|
|
@@ -336,8 +345,10 @@ export function runPin(demo) {
|
|
|
336
345
|
}
|
|
337
346
|
|
|
338
347
|
/** The framed view's address: behold's own page, bare, at a zoom (the resources zoom unless told), told who its host is. */
|
|
339
|
-
export function frameSrc(origin, zoom = "resources") {
|
|
348
|
+
export function frameSrc(origin, zoom = "resources", repo = null, theme = null) {
|
|
340
349
|
const q = new URLSearchParams({ embed: "1", bare: "1", zoom, host: origin });
|
|
350
|
+
if (repo) q.set("repo", repo); // behold#571: the checkout the frame draws, on a behold that serves several
|
|
351
|
+
if (theme) q.set("theme", theme); // behold#571: the kiosk's theme, which the frame follows
|
|
341
352
|
return `/?${q.toString()}`;
|
|
342
353
|
}
|
|
343
354
|
|
|
@@ -399,11 +410,12 @@ export function kioskView(demo, { components = false, pick = null, now = Date.no
|
|
|
399
410
|
}
|
|
400
411
|
|
|
401
412
|
/** The line under the view saying how to read it, for the view shown. */
|
|
402
|
-
export function legendWords(view) {
|
|
413
|
+
export function legendWords(view, tool = null) {
|
|
414
|
+
const root = rootWord(tool);
|
|
403
415
|
if (view === "components") {
|
|
404
|
-
return
|
|
416
|
+
return `Each card is ${root} with its own state, in a box per wave titled with the pipeline job that applies it; 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.`;
|
|
405
417
|
}
|
|
406
|
-
return
|
|
418
|
+
return `Each box is ${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.`;
|
|
407
419
|
}
|
|
408
420
|
|
|
409
421
|
/** The behold zoom for a view. */
|
|
@@ -475,40 +487,75 @@ const elRoots = (tag, cls, text, s) => {
|
|
|
475
487
|
export function renderKiosk(bar, s, now = new Date(), foot = null) {
|
|
476
488
|
bar.replaceChildren();
|
|
477
489
|
if (foot) foot.replaceChildren();
|
|
490
|
+
drawBar(bar, s, now, foot);
|
|
491
|
+
// behold#571: a tool's page ends with who makes the tool, and that they don't endorse the demo.
|
|
492
|
+
if (foot && vendorOf(s.vendor)) drawCredit(foot, s.vendor);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function drawBar(bar, s, now, foot) {
|
|
496
|
+
const vendor = vendorOf(s.vendor);
|
|
497
|
+
if (vendor && s.mode === "none") {
|
|
498
|
+
// behold#571: the tool's scenario has no cycle yet: its name, its link, and a calm line.
|
|
499
|
+
const words = notRunWords(vendor, { graph: !!s.notRunGraph });
|
|
500
|
+
const box = el("div", "k-empty");
|
|
501
|
+
const title = el("h1", "k-title");
|
|
502
|
+
title.append(brandEl(vendor), el("span", "k-title-text", words.title));
|
|
503
|
+
box.append(title, el("p", "k-muted", words.text));
|
|
504
|
+
bar.appendChild(box);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
478
507
|
if (!s.demo || !s.demo.demo) {
|
|
479
508
|
const box = el("div", "k-empty");
|
|
480
|
-
|
|
509
|
+
const title = el("h1", "k-title");
|
|
510
|
+
if (vendor) title.appendChild(brandEl(vendor));
|
|
511
|
+
title.appendChild(el("span", "k-title-text", "Waiting for the demo"));
|
|
512
|
+
box.appendChild(title);
|
|
481
513
|
box.appendChild(el("p", "k-muted", s.demo ? s.demo.error || s.demo.absent : "Reading demo.json…"));
|
|
482
514
|
bar.appendChild(box);
|
|
483
515
|
return;
|
|
484
516
|
}
|
|
485
517
|
const demo = s.demo.demo;
|
|
486
|
-
const head = scenarioHead(demo);
|
|
518
|
+
const head = scenarioHead(demo, { vendor: s.vendor || null });
|
|
487
519
|
|
|
520
|
+
// behold#571: a tool's page says plainly when it replays an earlier run, and when it can't draw the repo's graph.
|
|
521
|
+
if (s.replay && s.replay.banner) bar.appendChild(el("p", "k-replay", s.replay.banner));
|
|
522
|
+
if (vendor && s.noReports && (s.mode === "live" || s.mode === "replay")) bar.appendChild(el("p", "k-nograph-note", NO_GRAPH_WORDS));
|
|
488
523
|
const intro = introText(demo);
|
|
489
|
-
if (intro) bar.appendChild(el("p", "k-intro", intro));
|
|
490
524
|
|
|
491
|
-
// behold#571: the
|
|
525
|
+
// behold#571: the left column in fixed boxes: the title, the card (the intro between scenarios), the step.
|
|
492
526
|
const left = el("div", "k-left");
|
|
527
|
+
const titleBox = box("title", "k-box-title");
|
|
493
528
|
const title = el("h1", "k-title");
|
|
529
|
+
if (vendor) title.appendChild(brandEl(vendor));
|
|
494
530
|
title.append(el("span", "k-kicker", head.kicker), el("span", "k-title-text", head.title));
|
|
495
|
-
|
|
531
|
+
if (title.setAttribute) title.setAttribute("title", `${head.kicker} · ${head.title || ""}`);
|
|
532
|
+
titleBox.appendChild(title);
|
|
533
|
+
left.appendChild(titleBox);
|
|
534
|
+
const cardBox = box("card");
|
|
496
535
|
const rows = aboutRows(demo);
|
|
497
|
-
if (
|
|
536
|
+
if (demo.step === "reset" && intro) {
|
|
537
|
+
// Between scenarios nothing else happens: the card's box says what the demo is about.
|
|
538
|
+
const p = el("p", "k-intro");
|
|
539
|
+
p.append(el("span", "k-intro-head", INTRO_HEAD), intro);
|
|
540
|
+
cardBox.appendChild(p);
|
|
541
|
+
} else if (rows.length) {
|
|
498
542
|
const card = el("dl", "k-about");
|
|
499
543
|
for (const r of rows) card.append(el("dt", "k-about-label", r.label), elRoots("dd", "k-about-text", r.text, s));
|
|
500
|
-
|
|
544
|
+
cardBox.appendChild(card);
|
|
501
545
|
}
|
|
546
|
+
left.appendChild(cardBox);
|
|
502
547
|
const said = el("p", `k-step k-step-${demo.step}`);
|
|
503
548
|
said.append(el("span", "k-step-word", STEP_WORDS[demo.step] || demo.step), elRoots("span", "k-label", demo.label, s));
|
|
504
|
-
|
|
549
|
+
const stepBox = box("step");
|
|
550
|
+
stepBox.appendChild(said);
|
|
551
|
+
left.appendChild(stepBox);
|
|
505
552
|
bar.appendChild(left);
|
|
506
553
|
|
|
507
554
|
const right = el("div", "k-right");
|
|
508
555
|
const strip = el("ol", "k-strip");
|
|
509
556
|
for (const stop of stripStops(demo.step)) strip.appendChild(el("li", `k-stop k-${stop.state}`, stop.words));
|
|
510
557
|
const where = el("div", "k-where");
|
|
511
|
-
where.
|
|
558
|
+
where.appendChild(strip);
|
|
512
559
|
right.appendChild(where);
|
|
513
560
|
|
|
514
561
|
const nx = nextWords(demo, now);
|
|
@@ -516,28 +563,120 @@ export function renderKiosk(bar, s, now = new Date(), foot = null) {
|
|
|
516
563
|
next.appendChild(el("span", "k-next-word", "next, by the driver"));
|
|
517
564
|
next.appendChild(elRoots("span", "k-action", nx.action, s));
|
|
518
565
|
next.appendChild(el("span", "k-when", nx.when));
|
|
566
|
+
next.appendChild(el("span", "k-cycle", cycleWords(demo, now)));
|
|
519
567
|
right.appendChild(next);
|
|
520
568
|
|
|
569
|
+
// behold#571: the hold's box: how the hold ends, from the records; else the driver's approval, or the run's note.
|
|
570
|
+
const holdBox = box("hold");
|
|
521
571
|
const appr = pendingApproval(demo, s.progress);
|
|
522
|
-
if (
|
|
572
|
+
if (s.approval) {
|
|
573
|
+
holdBox.appendChild(approvalPanel(s.approval, s));
|
|
574
|
+
} else if (appr) {
|
|
523
575
|
const a = el("div", "k-approve");
|
|
524
576
|
a.appendChild(elRoots("p", "k-approve-gloss", approveGloss(appr), s));
|
|
525
577
|
a.appendChild(el("code", "k-command", appr.approve));
|
|
526
578
|
const then = el("p", "k-approve-then");
|
|
527
579
|
then.append(`then comments `, el("code", "k-inline", appr.comment), ` on ${appr.on}, and the waves run again.`);
|
|
528
580
|
a.appendChild(then);
|
|
529
|
-
|
|
530
|
-
} else {
|
|
581
|
+
holdBox.appendChild(a);
|
|
582
|
+
} else if (!s.noReports) {
|
|
531
583
|
const panel = runPanel(demo, s.answer, s.progress);
|
|
532
|
-
if (panel.note)
|
|
533
|
-
else if (panel.stage === "pull-request") for (const u of unreviewedLines(s.answer))
|
|
584
|
+
if (panel.note) holdBox.appendChild(elRoots("p", "k-note", panel.note, s));
|
|
585
|
+
else if (panel.stage === "pull-request") for (const u of unreviewedLines(s.answer)) holdBox.appendChild(elRoots("p", "k-note k-unknown", u, s));
|
|
534
586
|
}
|
|
587
|
+
right.appendChild(holdBox);
|
|
535
588
|
bar.appendChild(right);
|
|
536
589
|
|
|
537
590
|
if (foot) {
|
|
538
|
-
|
|
591
|
+
// The legend reads the graph; with no graph shown there is nothing for it to explain.
|
|
592
|
+
if (!s.noReports) foot.appendChild(el("span", "k-legend", legendWords(s.view || "resources", s.vendor || null)));
|
|
539
593
|
// behold#571: no line on where the narration was read from: a viewer new to the demo reads the facts.
|
|
540
|
-
for (const f of factLines(demo, s.answer)) foot.appendChild(elRoots("span", "k-fact", f, s));
|
|
594
|
+
if (!s.noReports) for (const f of factLines(demo, s.answer)) foot.appendChild(elRoots("span", "k-fact", f, s));
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** behold#571: the head over the intro, in the card's box between scenarios, and the title of "What is this?". */
|
|
599
|
+
export const INTRO_HEAD = "What this demo is about";
|
|
600
|
+
|
|
601
|
+
/** A fixed box of the header: its text scrolls inside it, and the page keeps its scroll across redraws by `data-box`. */
|
|
602
|
+
function box(name, cls = "") {
|
|
603
|
+
const b = el("div", `k-box${cls ? ` ${cls}` : ""}`);
|
|
604
|
+
if (b.dataset) b.dataset.box = name;
|
|
605
|
+
return b;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* behold#571: "How this hold ends", drawn from approvalModel's rows. A row's
|
|
610
|
+
* chip names the log line it rests on and shows it in the runs pane
|
|
611
|
+
* (`s.showLine`).
|
|
612
|
+
*/
|
|
613
|
+
export function approvalPanel(model, s = {}) {
|
|
614
|
+
const wrap = el("div", "k-ap");
|
|
615
|
+
const head = el("p", "k-ap-head", `${PANEL_TITLE} · wave ${model.wave} `);
|
|
616
|
+
if (model.digest) head.appendChild(el("span", "k-ap-plan", `plan ${shortDigest(model.digest)}`));
|
|
617
|
+
head.append(" · times in UTC");
|
|
618
|
+
wrap.appendChild(head);
|
|
619
|
+
const list = el("ol", "k-ap-rows");
|
|
620
|
+
for (const r of model.rows) {
|
|
621
|
+
const li = el("li", `k-ap-row k-ap-${r.state}`);
|
|
622
|
+
const mark = el("span", "k-ap-mark");
|
|
623
|
+
if (mark.setAttribute) mark.setAttribute("aria-label", r.state === "lit" ? "done" : r.state === "going" ? "under way" : "not yet");
|
|
624
|
+
li.append(mark, el("span", "k-ap-label", r.label));
|
|
625
|
+
const text = elRoots("span", "k-ap-text", r.text, s);
|
|
626
|
+
const facts = rowFacts(r);
|
|
627
|
+
if (facts.length) text.appendChild(el("span", `k-ap-facts${r.seal && r.seal.ok === false ? " k-ap-bad" : ""}`, facts.join(" · ")));
|
|
628
|
+
if (r.line && r.line.a) {
|
|
629
|
+
const chip = annMark(r.line.a, { first: true });
|
|
630
|
+
chip.dataset.annJump = "1";
|
|
631
|
+
chip.title = `${r.line.a.note} Shows run #${r.line.run}, ${r.line.job}, line ${r.line.n}.`;
|
|
632
|
+
const line = r.line;
|
|
633
|
+
chip.addEventListener("click", () => s.showLine && s.showLine(line));
|
|
634
|
+
text.append(" ", chip);
|
|
635
|
+
}
|
|
636
|
+
li.appendChild(text);
|
|
637
|
+
if (r.command) li.appendChild(el("code", "k-command", r.command));
|
|
638
|
+
list.appendChild(li);
|
|
639
|
+
}
|
|
640
|
+
wrap.appendChild(list);
|
|
641
|
+
return wrap;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** behold#571: the header's line when this behold serves another repo than the page's. */
|
|
645
|
+
export const NO_GRAPH_WORDS = "This page shows the runs and their logs; the graph of this repo isn't served here.";
|
|
646
|
+
|
|
647
|
+
/** behold#571: a tool's name, and its logo where its owner allows one, linking to its official site. */
|
|
648
|
+
function brandEl(vendor) {
|
|
649
|
+
const a = el("a", "k-brand");
|
|
650
|
+
a.href = vendor.url;
|
|
651
|
+
a.target = "_blank";
|
|
652
|
+
a.rel = "noopener";
|
|
653
|
+
if (vendor.logo) {
|
|
654
|
+
const img = el("img", "k-brand-logo");
|
|
655
|
+
img.src = vendor.logo.src;
|
|
656
|
+
if (vendor.logo.src2x) img.srcset = `${vendor.logo.src2x} 2x`;
|
|
657
|
+
img.alt = "";
|
|
658
|
+
a.appendChild(img);
|
|
659
|
+
}
|
|
660
|
+
a.appendChild(el("span", "k-brand-name", vendor.name));
|
|
661
|
+
return a;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** behold#571: the footer's credit on a tool's page, its names linked to their official sites. */
|
|
665
|
+
function drawCredit(foot, tool) {
|
|
666
|
+
for (const line of creditLines(tool)) {
|
|
667
|
+
const p = el("span", "k-credit");
|
|
668
|
+
for (const seg of line) {
|
|
669
|
+
if (!seg.url) {
|
|
670
|
+
p.append(seg.text);
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
const a = el("a", "k-credit-link", seg.text);
|
|
674
|
+
a.href = seg.url;
|
|
675
|
+
a.target = "_blank";
|
|
676
|
+
a.rel = "noopener";
|
|
677
|
+
p.appendChild(a);
|
|
678
|
+
}
|
|
679
|
+
foot.appendChild(p);
|
|
541
680
|
}
|
|
542
681
|
}
|
|
543
682
|
|
|
@@ -545,6 +684,22 @@ export function renderKiosk(bar, s, now = new Date(), foot = null) {
|
|
|
545
684
|
export const HOVER_ROOT_RULE = "background: color-mix(in srgb, var(--focus) 32%, transparent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--focus) 32%, transparent); border-radius: 3px;";
|
|
546
685
|
export const HOVER_JOB_RULE = "background: color-mix(in srgb, var(--focus) 24%, transparent); box-shadow: inset 4px 0 0 var(--focus);";
|
|
547
686
|
|
|
687
|
+
/** behold#571: how often the approval panel reads the cycle's runs, and the gate ledger (which the server caches 30 s). */
|
|
688
|
+
export const APPROVAL_POLL_MS = 4_000;
|
|
689
|
+
export const LEDGER_POLL_MS = 10_000;
|
|
690
|
+
|
|
691
|
+
/** behold#571: the approval panel's words for the approval the driver gives next, over its command. */
|
|
692
|
+
export const DRIVER_SIGNS = "Next the driver plays a reviewer with no hand in the change and signs this plan with that reviewer's key:";
|
|
693
|
+
|
|
694
|
+
/** behold#571: "What is this?" before demo.json says. */
|
|
695
|
+
export const WHAT_FALLBACK = "A demo that runs a real infrastructure change through its pipeline in a loop, and shows each step as it happens.";
|
|
696
|
+
|
|
697
|
+
/** behold#571: a fade at a box's edge where its text goes on past it. */
|
|
698
|
+
function fade(b) {
|
|
699
|
+
b.classList.toggle("k-more-below", b.scrollTop + b.clientHeight < b.scrollHeight - 1);
|
|
700
|
+
b.classList.toggle("k-more-above", b.scrollTop > 1);
|
|
701
|
+
}
|
|
702
|
+
|
|
548
703
|
/** How often the kiosk asks for demo.json. The server shares one read between kiosks. */
|
|
549
704
|
export const DEMO_POLL_MS = 3_000;
|
|
550
705
|
|
|
@@ -555,17 +710,53 @@ export const DEMO_POLL_MS = 3_000;
|
|
|
555
710
|
* countdown. When the roots the step is about change, move the framed view
|
|
556
711
|
* with a `behold:view` message; the frame takes them only from this origin.
|
|
557
712
|
*/
|
|
558
|
-
export function startKiosk({ bar, frame, foot, side, main, views }, { fetchJson = defaultFetch, now = () => new Date(), origin = location.origin } = {}) {
|
|
713
|
+
export function startKiosk({ bar, frame, foot, side, main, views, tools = null }, { fetchJson = defaultFetch, now = () => new Date(), origin = location.origin, tool = null } = {}) {
|
|
559
714
|
const s = { demo: null, answer: null, progress: null, components: false, pick: null, view: "resources", roots: [], waves: [], light: false, hover: null, frameHover: null };
|
|
715
|
+
// behold#571: on a tool's page, `live` while its scenario runs, `replay` of its latest cycle otherwise, `none` before it has one.
|
|
716
|
+
const vendor = vendorOf(tool);
|
|
717
|
+
s.vendor = vendor ? tool : null;
|
|
718
|
+
s.mode = vendor ? "loading" : "live";
|
|
719
|
+
s.replay = null;
|
|
720
|
+
s.repo = null;
|
|
721
|
+
s.live = null;
|
|
722
|
+
if (vendor && typeof document !== "undefined") document.title = `${vendor.name} · terragucci demo`;
|
|
723
|
+
/** The time the page shows: the replay's, while it replays. */
|
|
724
|
+
const shownNow = () => (s.mode === "replay" && s.replay && s.replay.at ? s.replay.at : now());
|
|
725
|
+
/** behold#571: the frame and the plan's facts are the served repo's, so a tool's page shows them only for its own repo. */
|
|
726
|
+
const frameOk = () => {
|
|
727
|
+
if (!vendor) return true;
|
|
728
|
+
// Before the tool's first cycle: its repo's checkout, when this behold serves it.
|
|
729
|
+
if (s.mode === "none") return !!notRunRepo(tool, s.viewRepos) && s.aimed === notRunRepo(tool, s.viewRepos);
|
|
730
|
+
return servesRepo(s.answer && !s.answer.error ? s.answer.project : null, s.repo);
|
|
731
|
+
};
|
|
732
|
+
// behold#571: a behold serving several repos (`serve --repos`) answers the
|
|
733
|
+
// graph's reads from the checkout of the repo the page is about, asked by
|
|
734
|
+
// name. `viewRepos` is what it serves, `aimed` the repo the reads are for.
|
|
735
|
+
s.viewRepos = null;
|
|
736
|
+
s.aimed = null;
|
|
737
|
+
const wantRepo = () => repoParam(s.repo, s.viewRepos) || (vendor && s.mode === "none" ? notRunRepo(tool, s.viewRepos) : null);
|
|
738
|
+
const fetchServed = fetchJson;
|
|
739
|
+
fetchJson = (url) => fetchServed(repoUrl(url, wantRepo()));
|
|
740
|
+
/** When the page moves to another served repo, read its project, reports and run view again, and point the frame at it. */
|
|
741
|
+
const followRepo = () => {
|
|
742
|
+
const want = wantRepo();
|
|
743
|
+
if (!want || want === s.aimed) return;
|
|
744
|
+
s.aimed = want;
|
|
745
|
+
void readProject();
|
|
746
|
+
void reports(true);
|
|
747
|
+
void progress(true);
|
|
748
|
+
};
|
|
560
749
|
let lastKey = "";
|
|
561
750
|
let lastFocus;
|
|
562
751
|
const shownView = () => kioskView(s.demo && s.demo.demo, { components: s.components, pick: s.pick, now: now().getTime() });
|
|
563
752
|
const tellFrame = (force) => {
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
const
|
|
753
|
+
// behold#571: before the tool's first cycle the frame draws its repo on the base, with nothing in flight.
|
|
754
|
+
const notRun = !!vendor && s.mode === "none";
|
|
755
|
+
const d = notRun ? null : s.demo && s.demo.demo;
|
|
756
|
+
if (!d && !notRun) return; // nothing said yet: the frame keeps its default until the narration is read
|
|
757
|
+
const roots = d ? focusRoots(d, s.answer, s.progress) : null;
|
|
567
758
|
const focus = roots ? roots.join(",") : null;
|
|
568
|
-
const run = runPin(d);
|
|
759
|
+
const run = d ? runPin(d) : "base";
|
|
569
760
|
const zoom = viewZoom(shownView().view);
|
|
570
761
|
const key = `${focus}|${run}|${zoom}`;
|
|
571
762
|
if (!force && key === lastFocus) return;
|
|
@@ -626,39 +817,235 @@ export function startKiosk({ bar, frame, foot, side, main, views }, { fetchJson
|
|
|
626
817
|
glow();
|
|
627
818
|
});
|
|
628
819
|
}
|
|
820
|
+
|
|
821
|
+
// behold#571: the titles and names demo.json has carried, by scenario, for the runs' subtitles.
|
|
822
|
+
const titles = {};
|
|
823
|
+
const names = {};
|
|
824
|
+
/** Take one narration (demo.json, or a replayed step), and ask for the run it is about when the step moved. */
|
|
825
|
+
const setDemo = (answer) => {
|
|
826
|
+
s.demo = answer;
|
|
827
|
+
const d = answer && answer.demo;
|
|
828
|
+
if (d && d.scenario && typeof d.title === "string" && d.title) titles[d.scenario] = d.title;
|
|
829
|
+
if (d && d.scenario && scenarioName(d)) names[d.scenario] = scenarioName(d);
|
|
830
|
+
const key = d ? `${s.mode}|${d.cycle}|${d.step}|${d.commit}|${d.pull}` : "";
|
|
831
|
+
if (key !== lastKey) {
|
|
832
|
+
lastKey = key;
|
|
833
|
+
// The step moved: the run it is about may be newer than what behold read.
|
|
834
|
+
const stage = d ? demoStage(d) : "none";
|
|
835
|
+
if (stage === "pull-request" || stage === "either") void reports(true);
|
|
836
|
+
if (stage === "apply" || stage === "either") void progress(true);
|
|
837
|
+
}
|
|
838
|
+
};
|
|
839
|
+
// behold#571: the replay's clock, read on every draw.
|
|
840
|
+
const replayTick = () => {
|
|
841
|
+
if (s.mode !== "replay" || !s.replay) return;
|
|
842
|
+
const steps = s.replay.cycle.steps;
|
|
843
|
+
const r = replayAt(steps, now().getTime() - s.replay.start);
|
|
844
|
+
s.replay.at = r.at;
|
|
845
|
+
s.replay.banner = replayBanner(s.replay.cycle, liveEstimate(s.live && s.live.demo, s.replay.cycle, now()));
|
|
846
|
+
setDemo({ source: "replay", read: steps[r.index].at, demo: scrubDemo(steps[r.index], tool) });
|
|
847
|
+
};
|
|
848
|
+
|
|
629
849
|
const draw = () => {
|
|
850
|
+
followRepo();
|
|
851
|
+
replayTick();
|
|
630
852
|
s.light = typeof document !== "undefined" && document.documentElement ? isLightBg(getComputedStyle(document.documentElement).getPropertyValue("--bg")) : false;
|
|
631
853
|
s.rootNames = rootList();
|
|
632
854
|
const shown = shownView();
|
|
633
855
|
s.view = shown.view;
|
|
634
|
-
|
|
635
|
-
|
|
856
|
+
const ok = frameOk();
|
|
857
|
+
s.noReports = !ok;
|
|
858
|
+
s.notRunGraph = !!vendor && s.mode === "none" && ok;
|
|
859
|
+
s.approval = approvalNow();
|
|
860
|
+
// behold#571: the header's boxes redraw every second; each keeps where a viewer scrolled it.
|
|
861
|
+
const scrolled = Object.fromEntries([...bar.querySelectorAll(".k-box")].map((b) => [b.dataset.box, b.scrollTop]));
|
|
862
|
+
renderKiosk(bar, s, shownNow(), foot);
|
|
863
|
+
for (const b of bar.querySelectorAll(".k-box")) {
|
|
864
|
+
if (scrolled[b.dataset.box]) b.scrollTop = scrolled[b.dataset.box];
|
|
865
|
+
fade(b);
|
|
866
|
+
b.addEventListener("scroll", () => fade(b), { passive: true });
|
|
867
|
+
}
|
|
868
|
+
whatText();
|
|
869
|
+
renderViewToggle(views, shown, { components: s.components && ok, onPick: pickView });
|
|
870
|
+
// behold#571: with no graph to show, the graph pane folds away and the runs and the log take its width.
|
|
871
|
+
if (typeof document !== "undefined" && document.body && document.body.classList) {
|
|
872
|
+
document.body.classList.toggle("k-nograph", !!vendor && !ok);
|
|
873
|
+
// Before the tool's first cycle there are no runs and no log: those panes fold away, and the graph (when there is one) takes the width.
|
|
874
|
+
document.body.classList.toggle("k-notyet", !!vendor && s.mode === "none");
|
|
875
|
+
}
|
|
636
876
|
tellFrame(false);
|
|
637
877
|
};
|
|
638
|
-
|
|
639
|
-
// behold#571: the
|
|
640
|
-
const
|
|
641
|
-
const
|
|
642
|
-
|
|
878
|
+
|
|
879
|
+
// behold#571: on a tool's page the runs pane shows the tool's repo alone; in the replay, the cycle's runs as they stood.
|
|
880
|
+
const runDocs = new Map();
|
|
881
|
+
const runDoc = (id) => {
|
|
882
|
+
if (!runDocs.has(id)) runDocs.set(id, fetchJson(`/api/terragucci/runs/${id}`).then((a) => (a && a.doc ? a.doc : null)));
|
|
883
|
+
return runDocs.get(id);
|
|
884
|
+
};
|
|
885
|
+
const replayRuns = async () => {
|
|
886
|
+
const r = s.replay;
|
|
887
|
+
if (r.runs) return r.runs;
|
|
888
|
+
const idx = await fetchJson("/api/terragucci/runs");
|
|
889
|
+
const listed = new Map((idx && idx.doc && Array.isArray(idx.doc.runs) ? idx.doc.runs : []).map((x) => [x.id, x]));
|
|
890
|
+
const runs = [];
|
|
891
|
+
for (const id of r.cycle.runs) {
|
|
892
|
+
const e = listed.get(id) || (await runDoc(id));
|
|
893
|
+
if (e) runs.push(e);
|
|
894
|
+
}
|
|
895
|
+
r.runs = runs.filter((x) => keepRun(x, { tool, repo: r.cycle.repo }));
|
|
896
|
+
return r.runs;
|
|
897
|
+
};
|
|
898
|
+
const runsSource = () => {
|
|
899
|
+
if (!vendor || s.mode === "live") return null;
|
|
900
|
+
if (s.mode !== "replay" || !s.replay) return { index: async () => ({ doc: { schema: "demo.runs/v1", runs: [] } }), run: async () => ({ doc: null, absent: "No run to show yet." }) };
|
|
901
|
+
return {
|
|
902
|
+
index: async () => replayIndex(await replayRuns(), shownNow().getTime()),
|
|
903
|
+
run: async (id) => {
|
|
904
|
+
const doc = await runDoc(id);
|
|
905
|
+
return doc ? { doc: runAsOf(doc, shownNow().getTime()) } : { doc: null, absent: `Run #${id} is no longer kept in the reports.` };
|
|
906
|
+
},
|
|
907
|
+
};
|
|
908
|
+
};
|
|
909
|
+
// behold#571: the approval panel's records: the cycle's runs, the gate ledger's history, and the lane.
|
|
910
|
+
const ap = { runs: new Map(), list: [], lifecycle: null, history: null, at: 0, ledgerAt: 0, busy: false };
|
|
911
|
+
const readApproval = async () => {
|
|
912
|
+
const d = s.demo && s.demo.demo;
|
|
913
|
+
if (!d || !PANEL_STEPS.includes(d.step) || ap.busy) return;
|
|
914
|
+
const t = now().getTime();
|
|
915
|
+
if (t - ap.at < APPROVAL_POLL_MS) return;
|
|
916
|
+
ap.busy = true;
|
|
917
|
+
ap.at = t;
|
|
918
|
+
try {
|
|
919
|
+
const src = runsSource();
|
|
920
|
+
const raw = src ? await src.index() : await fetchJson("/api/terragucci/runs");
|
|
921
|
+
const idx = vendor ? filterIndex(raw, { tool, repo: s.repo }) : raw;
|
|
922
|
+
const mine = cycleRuns(idx && idx.doc && Array.isArray(idx.doc.runs) ? idx.doc.runs : [], d);
|
|
923
|
+
for (const e of mine) {
|
|
924
|
+
const key = `${s.mode}|${e.status}|${e.ended_at}|${src ? Math.floor(shownNow().getTime() / 5000) : ""}`;
|
|
925
|
+
const had = ap.runs.get(e.id);
|
|
926
|
+
if (had && had.key === key) continue;
|
|
927
|
+
const r = src ? await src.run(e.id) : await fetchJson(`/api/terragucci/runs/${e.id}`);
|
|
928
|
+
if (r && r.doc) ap.runs.set(e.id, { key, doc: mergeRun(e, r.doc) });
|
|
929
|
+
}
|
|
930
|
+
ap.list = mine.map((e) => ap.runs.get(e.id)).filter(Boolean).map((x) => x.doc);
|
|
931
|
+
if (t - ap.ledgerAt >= LEDGER_POLL_MS) {
|
|
932
|
+
ap.ledgerAt = t;
|
|
933
|
+
const [lifecycle, history] = await Promise.all([fetchJson("/api/terragucci/lifecycle"), fetchJson("/api/lifecycle/history?limit=200")]);
|
|
934
|
+
ap.lifecycle = lifecycle && !lifecycle.error ? lifecycle : null;
|
|
935
|
+
ap.history = history && !history.error ? history : null;
|
|
936
|
+
}
|
|
937
|
+
} finally {
|
|
938
|
+
ap.busy = false;
|
|
939
|
+
}
|
|
940
|
+
draw();
|
|
941
|
+
};
|
|
942
|
+
const approvalNow = () => {
|
|
943
|
+
const d = s.demo && s.demo.demo;
|
|
944
|
+
if (!d || !PANEL_STEPS.includes(d.step)) return null;
|
|
945
|
+
const appr = pendingApproval(d, s.progress);
|
|
946
|
+
const driver = appr && s.mode !== "replay" ? { gloss: DRIVER_SIGNS, approve: appr.approve, comment: appr.comment, on: appr.on } : null;
|
|
947
|
+
return approvalModel({ demo: d, runs: ap.list, history: ap.history, lifecycle: ap.lifecycle, fallback: appr, now: shownNow().getTime(), driver });
|
|
948
|
+
};
|
|
949
|
+
// behold#571: a chip in the panel shows its log line in the runs pane (opened, on a phone).
|
|
950
|
+
let runsApi = null;
|
|
951
|
+
s.showLine = (line) => {
|
|
952
|
+
if (!runsApi) return;
|
|
953
|
+
if (tools && tools.runsToggle) setRunsOpen(true);
|
|
954
|
+
runsApi.show(line.run, line.job, line.step, line.n);
|
|
955
|
+
};
|
|
956
|
+
const setRunsOpen = (open) => {
|
|
957
|
+
document.body.classList.toggle("k-runs-open", open);
|
|
958
|
+
if (tools && tools.runsToggle) tools.runsToggle.setAttribute("aria-expanded", String(open));
|
|
959
|
+
};
|
|
960
|
+
if (tools && tools.runsToggle) tools.runsToggle.addEventListener("click", () => setRunsOpen(!document.body.classList.contains("k-runs-open")));
|
|
961
|
+
|
|
962
|
+
// behold#571: "What is this?" opens the intro over the page (no layout moves); a click elsewhere or Escape closes it.
|
|
963
|
+
const whatText = () => {
|
|
964
|
+
if (!tools || !tools.pop) return;
|
|
965
|
+
const d = s.demo && s.demo.demo;
|
|
966
|
+
const text = (d && introText(d)) || WHAT_FALLBACK;
|
|
967
|
+
const p = tools.pop.firstElementChild;
|
|
968
|
+
if (p && p.textContent === text) return;
|
|
969
|
+
tools.pop.replaceChildren(el("p", null, text));
|
|
970
|
+
};
|
|
971
|
+
if (tools && tools.what && tools.pop) {
|
|
972
|
+
const setWhat = (open) => {
|
|
973
|
+
tools.pop.hidden = !open;
|
|
974
|
+
tools.what.setAttribute("aria-expanded", String(open));
|
|
975
|
+
};
|
|
976
|
+
tools.what.addEventListener("click", (e) => {
|
|
977
|
+
e.stopPropagation();
|
|
978
|
+
whatText();
|
|
979
|
+
setWhat(tools.pop.hidden);
|
|
980
|
+
});
|
|
981
|
+
document.addEventListener("click", (e) => {
|
|
982
|
+
if (!tools.pop.hidden && !tools.pop.contains(e.target)) setWhat(false);
|
|
983
|
+
});
|
|
984
|
+
document.addEventListener("keydown", (e) => {
|
|
985
|
+
if (e.key === "Escape") setWhat(false);
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
// behold#571: the theme picker, persisted per viewer as the app does; the frame follows without a reload.
|
|
989
|
+
if (tools && tools.theme) mountThemePicker(tools.theme);
|
|
990
|
+
onThemeChange((th) => {
|
|
991
|
+
if (frame && frame.contentWindow) frame.contentWindow.postMessage({ type: "behold:theme", name: th.name }, origin);
|
|
992
|
+
draw();
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
if (side && main) {
|
|
996
|
+
runsApi = startRuns(
|
|
997
|
+
{ side, main },
|
|
998
|
+
{
|
|
999
|
+
fetchJson,
|
|
1000
|
+
now,
|
|
1001
|
+
shown: shownNow,
|
|
1002
|
+
scenarios: SCENARIOS,
|
|
1003
|
+
titles: () => titles,
|
|
1004
|
+
names: () => names,
|
|
1005
|
+
roots: rootList,
|
|
1006
|
+
waves: () => s.waves,
|
|
1007
|
+
light: () => s.light,
|
|
1008
|
+
source: runsSource,
|
|
1009
|
+
filter: vendor ? (index) => filterIndex(index, { tool, repo: s.repo }) : (index) => index,
|
|
1010
|
+
replay: () => s.mode === "replay",
|
|
1011
|
+
hideLine: vendor ? (line) => namesOther(line, tool) : null,
|
|
1012
|
+
},
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
643
1015
|
// #571: whether the repo offers the components zoom (a terragucci.components.json), read before the frame opens so it opens on the step's view.
|
|
1016
|
+
let frameSrcNow = null;
|
|
644
1017
|
const openFrame = () => {
|
|
645
1018
|
if (!frame) return;
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
1019
|
+
const src = frameSrc(origin, viewZoom(shownView().view), wantRepo(), getThemeName());
|
|
1020
|
+
if (src === frameSrcNow) return;
|
|
1021
|
+
if (frameSrcNow === null) {
|
|
1022
|
+
// The frame reads its graph after load; tell it again once it has had the time.
|
|
1023
|
+
frame.addEventListener("load", () => {
|
|
1024
|
+
for (const ms of [1500, 4000, 8000]) setTimeout(() => tellFrame(true), ms);
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
frameSrcNow = src;
|
|
1028
|
+
frame.src = src;
|
|
651
1029
|
};
|
|
652
|
-
|
|
1030
|
+
const readProject = () => fetchJson("/api/project").then((p) => {
|
|
1031
|
+
// behold#571: the repos this behold serves, and which one this answer is.
|
|
1032
|
+
if (p && !p.error && Array.isArray(p.viewRepos)) {
|
|
1033
|
+
s.viewRepos = p.viewRepos;
|
|
1034
|
+
if (s.aimed === null && p.viewRepo) s.aimed = p.viewRepo;
|
|
1035
|
+
}
|
|
653
1036
|
s.components = !!(p && !p.error && p.terragucciComponents);
|
|
654
1037
|
// behold#571: the declared roots (their colours' order) and the wave jobs that apply them.
|
|
655
1038
|
if (p && p.terragucciRoots) {
|
|
656
1039
|
s.roots = Array.isArray(p.terragucciRoots.roots) ? p.terragucciRoots.roots : [];
|
|
657
1040
|
s.waves = Array.isArray(p.terragucciRoots.waves) ? p.terragucciRoots.waves : [];
|
|
1041
|
+
} else if (s.aimed) {
|
|
1042
|
+
s.roots = [];
|
|
1043
|
+
s.waves = [];
|
|
658
1044
|
}
|
|
659
1045
|
openFrame();
|
|
660
1046
|
draw();
|
|
661
1047
|
});
|
|
1048
|
+
void readProject();
|
|
662
1049
|
|
|
663
1050
|
const reports = async (fresh) => {
|
|
664
1051
|
s.answer = await fetchJson(`/api/terragucci${fresh ? "?fresh=1" : ""}`);
|
|
@@ -679,19 +1066,45 @@ export function startKiosk({ bar, frame, foot, side, main, views }, { fetchJson
|
|
|
679
1066
|
draw();
|
|
680
1067
|
};
|
|
681
1068
|
|
|
1069
|
+
// behold#571: a tool's latest cycle, read when its scenario stops being live, and while it has none yet.
|
|
1070
|
+
let cycleRead = 0;
|
|
1071
|
+
const readCycle = async () => {
|
|
1072
|
+
cycleRead = now().getTime();
|
|
1073
|
+
const a = await fetchJson(`/api/terragucci/cycles/${tool}`);
|
|
1074
|
+
if (s.mode === "live") return; // its scenario started while the read was out
|
|
1075
|
+
if (a && a.cycle) {
|
|
1076
|
+
if (!s.replay || s.replay.cycle.cycle !== a.cycle.cycle) s.replay = { cycle: a.cycle, start: now().getTime(), at: null, banner: null, runs: null };
|
|
1077
|
+
s.mode = "replay";
|
|
1078
|
+
s.repo = a.cycle.repo;
|
|
1079
|
+
} else if (a && a.absent) {
|
|
1080
|
+
s.mode = "none";
|
|
1081
|
+
s.replay = null;
|
|
1082
|
+
} else {
|
|
1083
|
+
s.mode = "error";
|
|
1084
|
+
s.replay = null;
|
|
1085
|
+
s.demo = { demo: null, error: a && a.error ? a.error : "The tool's latest cycle could not be read." };
|
|
1086
|
+
}
|
|
1087
|
+
};
|
|
1088
|
+
const CYCLE_REREAD_MS = 60_000;
|
|
1089
|
+
|
|
682
1090
|
const demo = async () => {
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
if (
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
1091
|
+
const live = await fetchJson("/api/terragucci/demo");
|
|
1092
|
+
s.live = live;
|
|
1093
|
+
if (!vendor) {
|
|
1094
|
+
s.repo = live && live.demo ? live.demo.repo : null;
|
|
1095
|
+
setDemo(live);
|
|
1096
|
+
return draw();
|
|
1097
|
+
}
|
|
1098
|
+
const d = live && live.demo;
|
|
1099
|
+
if (liveFor(d, tool, SCENARIOS)) {
|
|
1100
|
+
s.mode = "live";
|
|
1101
|
+
s.replay = null;
|
|
1102
|
+
s.repo = d.repo || s.repo;
|
|
1103
|
+
setDemo({ ...live, demo: scrubDemo(d, tool) });
|
|
1104
|
+
return draw();
|
|
694
1105
|
}
|
|
1106
|
+
// Not this tool's scenario: replay its latest cycle, read again when it went live and ended, or now and then.
|
|
1107
|
+
if (s.mode === "live" || s.mode === "loading" || now().getTime() - cycleRead > CYCLE_REREAD_MS) await readCycle();
|
|
695
1108
|
draw();
|
|
696
1109
|
};
|
|
697
1110
|
|
|
@@ -699,6 +1112,7 @@ export function startKiosk({ bar, frame, foot, side, main, views }, { fetchJson
|
|
|
699
1112
|
void reports(false);
|
|
700
1113
|
void progress(false);
|
|
701
1114
|
setInterval(() => void demo(), DEMO_POLL_MS);
|
|
1115
|
+
setInterval(() => void readApproval(), 1000);
|
|
702
1116
|
setInterval(draw, 1000);
|
|
703
1117
|
|
|
704
1118
|
if (typeof EventSource !== "undefined") {
|