@intentius/behold 0.29.2 → 0.29.4
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/dist/cli.js +840 -292
- package/package.json +1 -1
- package/web/app.js +306 -21
- package/web/kiosk-runs.js +61 -6
- package/web/kiosk-runs.test.js +22 -1
- package/web/kiosk.html +21 -3
- package/web/kiosk.js +168 -19
- package/web/kiosk.test.js +160 -3
- package/web/terragucci-change.js +49 -0
- package/web/terragucci-change.test.js +36 -1
- package/web/terragucci-components-live.js +166 -0
- package/web/terragucci-components-live.test.js +145 -0
- package/web/view-url.js +5 -4
- package/web/view-url.test.js +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/behold",
|
|
3
|
-
"version": "0.29.
|
|
3
|
+
"version": "0.29.4",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "behold — a live control plane on chant. See your whole estate (every substrate in one graph), coloured by drift; act through delegated, gated Ops.",
|
package/web/app.js
CHANGED
|
@@ -18,7 +18,8 @@ import { renderLane } from "./terragucci-lifecycle.js";
|
|
|
18
18
|
import { renderReleases } from "./releases.js";
|
|
19
19
|
import { renderYodelLane } from "./sql-yodeler-lane.js";
|
|
20
20
|
import { initTerragucciEstate } from "./terragucci-estate.js";
|
|
21
|
-
import {
|
|
21
|
+
import { componentMarks as tgComponentMarks, waveOfKey as tgWaveOfKey } from "./terragucci-components-live.js";
|
|
22
|
+
import { STATE_WORDS as tgStateWords, changeSection as tgChangeSection, readCardIds as tgReadCardIds, readEdges as tgReadEdges, readLabel as tgReadLabel, readLine as tgReadLine, readTag as tgReadTag, rootStates as tgRootStates, viewCaption as tgViewCaption, pinnedAnswer as tgPinnedAnswer, pinnedCaption as tgPinnedCaption, pinsCommit as tgPinsCommit } from "./terragucci-change.js";
|
|
22
23
|
import { auditLines as yrAuditLines, cornerText as yrCorner, datedWords as yrDated, environmentLines as yrEnvLines, joinMarks as yrJoin, legend as yrLegend, markLine as yrMarkLine } from "./yodel-report.js";
|
|
23
24
|
import { cardProgress as tgCardProgress, progressLines as tgProgressLines, progressSection as tgProgressSection, stampProgress as tgStampProgress } from "./terragucci-progress.js";
|
|
24
25
|
import { seconds, whyQuery, whyView } from "./why.js";
|
|
@@ -964,7 +965,7 @@ function inspect(node) {
|
|
|
964
965
|
// #568: the reads this card's root makes or answers, from the pull
|
|
965
966
|
// request's plan or the run view, dated by its run.
|
|
966
967
|
const tgReadRoot = tgAnswer && !tgAnswer.error && tgRootOfCard(tgAnswer, node.id);
|
|
967
|
-
const tgReads = tgReadRoot ? tgReadEdges(
|
|
968
|
+
const tgReads = tgReadRoot ? tgReadEdges(tgAnswerDrawn(), tgProgress).filter((e) => e.from === tgReadRoot || e.to === tgReadRoot) : [];
|
|
968
969
|
if (tgReads.length) {
|
|
969
970
|
const h = document.createElement("h3");
|
|
970
971
|
h.textContent = "terragucci reads between roots (not live)";
|
|
@@ -1221,9 +1222,14 @@ function inspect(node) {
|
|
|
1221
1222
|
// The heading names the forge the pipeline was actually generated for
|
|
1222
1223
|
// (#164) — it used to say GitLab whatever the project targeted.
|
|
1223
1224
|
const ci = section(`CI (${{ gitlab: "GitLab", github: "GitHub Actions", forgejo: "Forgejo" }[ciForge] || ciForge || "pipeline"})`);
|
|
1224
|
-
|
|
1225
|
+
// #571: a terragucci repo's declared job names the job and its wave; a
|
|
1226
|
+
// chant-generated pipeline names a stage and the script it runs.
|
|
1227
|
+
if (job.jobName) ci("job", job.jobName);
|
|
1228
|
+
if (job.wave !== undefined) ci("wave", String(job.wave));
|
|
1229
|
+
if (job.stage) ci("stage", job.stage);
|
|
1225
1230
|
ci("needs", job.needs.length ? job.needs.join(", ") : "(none)");
|
|
1226
|
-
|
|
1231
|
+
if (job.alsoIn && job.alsoIn.length) ci("also in", job.alsoIn.join(", "));
|
|
1232
|
+
if (job.script) ci("runs", job.script.length ? job.script.join(" && ") : `chant run --components ${node.id}`);
|
|
1227
1233
|
// The last pipeline run of this session, when one touched this component
|
|
1228
1234
|
// (#164) — the server joins it as `_ciJob`/`ci` on the node.
|
|
1229
1235
|
if (node.attrs && node.attrs._ciJob) ci("last run", `${node.attrs._ciJob}: ${node.attrs.ci}`);
|
|
@@ -1241,7 +1247,7 @@ function inspect(node) {
|
|
|
1241
1247
|
if (resources) {
|
|
1242
1248
|
const res = section("resources");
|
|
1243
1249
|
if (!resources.length) {
|
|
1244
|
-
res("", "(none found under this component's source directory)");
|
|
1250
|
+
res("", terragucciComponentsRepo ? "(none in this component's root)" : "(none found under this component's source directory)");
|
|
1245
1251
|
} else {
|
|
1246
1252
|
for (const r of resources) {
|
|
1247
1253
|
res(r.kind, r.physicalId ? `${r.id} (${r.physicalId})` : r.id);
|
|
@@ -1614,7 +1620,12 @@ let autoZoomFallback = true;
|
|
|
1614
1620
|
// order), so the decision is made from what the server already knows rather
|
|
1615
1621
|
// than from a zero-node answer. A chant member anywhere in the estate keeps
|
|
1616
1622
|
// #182's behaviour exactly, and picking components later still shows the note.
|
|
1617
|
-
|
|
1623
|
+
//
|
|
1624
|
+
// #571: a terragucci repo that declares its roots as components
|
|
1625
|
+
// (`terragucci.components.json`, /api/project's `terragucciComponents`) does
|
|
1626
|
+
// have a components picture, so it opens there whatever its member kind.
|
|
1627
|
+
function bootZoom(memberKinds, terragucciComponents) {
|
|
1628
|
+
if (terragucciComponents) return "components";
|
|
1618
1629
|
return memberKinds && memberKinds.length && !memberKinds.includes("chant") ? "resources" : "components";
|
|
1619
1630
|
}
|
|
1620
1631
|
|
|
@@ -3394,7 +3405,7 @@ function watchTerragucci() {
|
|
|
3394
3405
|
if (tgPushFits(pushed)) {
|
|
3395
3406
|
tgProgress = pushed;
|
|
3396
3407
|
paintTgProgress();
|
|
3397
|
-
} else if (view.run
|
|
3408
|
+
} else if (tgPinsCommit(view.run)) void loadTgProgress({ fresh: true });
|
|
3398
3409
|
});
|
|
3399
3410
|
es.addEventListener("lifecycle", (e) => {
|
|
3400
3411
|
tgLifecycle = JSON.parse(e.data);
|
|
@@ -3429,9 +3440,9 @@ async function loadTerragucci({ fresh = false } = {}) {
|
|
|
3429
3440
|
// #511: /api/terragucci/progress, the newest apply's run view on the cards.
|
|
3430
3441
|
let tgProgress = null;
|
|
3431
3442
|
async function loadTgProgress({ fresh = false } = {}) {
|
|
3432
|
-
// #569: `run` pins the run view: a commit's, or none at all (
|
|
3443
|
+
// #569: `run` pins the run view: a commit's, or none at all (a pull request's plan, or the base).
|
|
3433
3444
|
const pin = view.run;
|
|
3434
|
-
if (pin
|
|
3445
|
+
if (pin && !tgPinsCommit(pin)) {
|
|
3435
3446
|
tgProgress = null;
|
|
3436
3447
|
paintTgProgress();
|
|
3437
3448
|
return;
|
|
@@ -3451,12 +3462,12 @@ async function loadTgProgress({ fresh = false } = {}) {
|
|
|
3451
3462
|
function tgPushFits(answer) {
|
|
3452
3463
|
const pin = view.run;
|
|
3453
3464
|
if (!pin) return true;
|
|
3454
|
-
if (pin
|
|
3465
|
+
if (!tgPinsCommit(pin)) return false;
|
|
3455
3466
|
return !!(answer && answer.run && answer.run.commit && (answer.run.commit.startsWith(pin) || pin.startsWith(answer.run.commit)));
|
|
3456
3467
|
}
|
|
3457
|
-
/** #569: the reports as the view draws them: at `run=base`
|
|
3468
|
+
/** #569: the reports as the view draws them: at `run=base` no pull request, at `run=pull-<n>` only that one's plan. */
|
|
3458
3469
|
function tgAnswerDrawn() {
|
|
3459
|
-
return view.run
|
|
3470
|
+
return tgPinnedAnswer(tgAnswer, view.run);
|
|
3460
3471
|
}
|
|
3461
3472
|
function paintTgProgress() {
|
|
3462
3473
|
tgStampProgress(document.querySelector("#graph svg"), tgProgress, new Date());
|
|
@@ -3474,6 +3485,7 @@ function paintTgProgress() {
|
|
|
3474
3485
|
// merged. Not live; redrawn on each render and each read.
|
|
3475
3486
|
const TG_TONE = { plan: "var(--pending)", wait: "var(--foreign)", busy: "var(--runtime)", ok: "var(--managed)", bad: "var(--degraded)", idle: "var(--neutral)" };
|
|
3476
3487
|
function drawTgReads() {
|
|
3488
|
+
watchTgRuns(); // #571: the wave boxes' jobs, while the components zoom is on screen
|
|
3477
3489
|
const svg = document.querySelector("#graph svg");
|
|
3478
3490
|
if (!svg) return;
|
|
3479
3491
|
for (const old of svg.querySelectorAll("[data-tg-reads]")) old.remove();
|
|
@@ -3484,6 +3496,10 @@ function drawTgReads() {
|
|
|
3484
3496
|
r.removeAttribute("data-tg-stroke0");
|
|
3485
3497
|
r.removeAttribute("data-tg-width0");
|
|
3486
3498
|
}
|
|
3499
|
+
for (const r of svg.querySelectorAll("[data-tg-fill0]")) {
|
|
3500
|
+
r.setAttribute("fill", r.getAttribute("data-tg-fill0"));
|
|
3501
|
+
r.removeAttribute("data-tg-fill0");
|
|
3502
|
+
}
|
|
3487
3503
|
for (const t of svg.querySelectorAll("[data-tg-hid]")) {
|
|
3488
3504
|
t.removeAttribute("data-tg-hid");
|
|
3489
3505
|
t.removeAttribute("visibility");
|
|
@@ -3512,13 +3528,24 @@ function drawTgReads() {
|
|
|
3512
3528
|
};
|
|
3513
3529
|
const union = (boxes) => (boxes.length ? { x: Math.min(...boxes.map((b) => b.x)), y: Math.min(...boxes.map((b) => b.y)), x2: Math.max(...boxes.map((b) => b.x2)), y2: Math.max(...boxes.map((b) => b.y2)) } : null);
|
|
3514
3530
|
const rootOf = (path) => tgAnswer.roots.find((x) => x.path === path) || null;
|
|
3531
|
+
// #571: on a terragucci repo's components zoom each root is one card, in a
|
|
3532
|
+
// box per wave: the reads run card to card, and the marks go on the cards
|
|
3533
|
+
// and the wave boxes (drawTgComponentMarks).
|
|
3534
|
+
const compMode = !!(view.components && terragucciComponentsRepo && tgComponentFacets);
|
|
3535
|
+
const componentsOfRoot = (r) => {
|
|
3536
|
+
const roots = (tgComponentFacets && tgComponentFacets.roots) || {};
|
|
3537
|
+
const named = (tgComponentFacets && tgComponentFacets.jobs ? tgComponentFacets.jobs.map((j) => j.component) : []).filter((c) => (roots[c] || c) === r.path || (roots[c] || c) === r.name);
|
|
3538
|
+
return named.length ? named : [r.name];
|
|
3539
|
+
};
|
|
3515
3540
|
const groupRect = (path) => {
|
|
3516
3541
|
const r = rootOf(path);
|
|
3542
|
+
if (compMode) return null;
|
|
3517
3543
|
return r ? svg.querySelector(`rect[data-group-id="${CSS.escape(r.name)}"]`) : null;
|
|
3518
3544
|
};
|
|
3519
3545
|
const cardsOf = (path) => {
|
|
3520
3546
|
const r = rootOf(path);
|
|
3521
3547
|
if (!r) return [];
|
|
3548
|
+
if (compMode) return componentsOfRoot(r).map((c) => svg.querySelector(`[data-node-id="${CSS.escape(c)}"]`)).filter(Boolean);
|
|
3522
3549
|
const prefix = `${r.member}/${r.name}/`;
|
|
3523
3550
|
return [...svg.querySelectorAll("[data-node-id]")].filter((g) => (g.getAttribute("data-node-id") || "").startsWith(prefix));
|
|
3524
3551
|
};
|
|
@@ -3571,8 +3598,12 @@ function drawTgReads() {
|
|
|
3571
3598
|
if (focus && !focus.has(r.path) && !focus.has(r.name)) {
|
|
3572
3599
|
for (const el of [rect && rect.parentElement, ...cardsOf(r.path)]) {
|
|
3573
3600
|
if (!el) continue;
|
|
3574
|
-
|
|
3575
|
-
el.
|
|
3601
|
+
// #571: a card on the components zoom keeps its body opaque, so the graph's edges behind it stay hidden; what is on it dims.
|
|
3602
|
+
const parts = compMode ? [...el.children].filter((c) => c !== el.querySelector(":scope > rect")) : [el];
|
|
3603
|
+
for (const part of parts) {
|
|
3604
|
+
part.setAttribute("data-tg-dim", "1");
|
|
3605
|
+
part.style.opacity = "0.5";
|
|
3606
|
+
}
|
|
3576
3607
|
}
|
|
3577
3608
|
}
|
|
3578
3609
|
if (!rect || !st || !b) continue;
|
|
@@ -3641,6 +3672,13 @@ function drawTgReads() {
|
|
|
3641
3672
|
}
|
|
3642
3673
|
}
|
|
3643
3674
|
|
|
3675
|
+
// #571: the components zoom's own marks; its wave boxes are the boxes the caption and the labels keep clear of.
|
|
3676
|
+
const edgePoints = compMode ? chantEdgePoints(svg) : [];
|
|
3677
|
+
if (compMode) {
|
|
3678
|
+
boxes.length = 0;
|
|
3679
|
+
boxes.push(...drawTgComponentMarks(svg, { drawnAnswer, focus, boxOf, make, edgePoints }));
|
|
3680
|
+
}
|
|
3681
|
+
|
|
3644
3682
|
// The reads. #569 review: no line or label crosses text. Each line is the
|
|
3645
3683
|
// shortest one, from a point on the reader's card (or box) border to a point
|
|
3646
3684
|
// on the read root's box border, that crosses no text and as few cards as
|
|
@@ -3686,6 +3724,20 @@ function drawTgReads() {
|
|
|
3686
3724
|
.map((b) => grow(b, 3));
|
|
3687
3725
|
const cardEls = [...svg.querySelectorAll("[data-node-id]")];
|
|
3688
3726
|
const cardBoxOf = new Map(cardEls.map((g) => [g, boxOf(g)]));
|
|
3727
|
+
// #571: how many of the components graph's own edge points a segment runs over, so a read never hides a dependency line.
|
|
3728
|
+
const nearEdge = (a, z, d = 6) => {
|
|
3729
|
+
if (!edgePoints.length) return 0;
|
|
3730
|
+
const dx = z.x - a.x;
|
|
3731
|
+
const dy = z.y - a.y;
|
|
3732
|
+
const l2 = dx * dx + dy * dy || 1;
|
|
3733
|
+
let n = 0;
|
|
3734
|
+
for (const p of edgePoints) {
|
|
3735
|
+
const t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / l2));
|
|
3736
|
+
if (Math.hypot(a.x + dx * t - p.x, a.y + dy * t - p.y) < d) n++;
|
|
3737
|
+
}
|
|
3738
|
+
return n;
|
|
3739
|
+
};
|
|
3740
|
+
const edgeIn = (r) => edgePoints.filter((p) => p.x >= r.x - 3 && p.x <= r.x2 + 3 && p.y >= r.y - 3 && p.y <= r.y2 + 3).length;
|
|
3689
3741
|
const defs = svg.querySelector("defs") || svg.insertBefore(make("defs", {}), svg.firstChild);
|
|
3690
3742
|
for (const [id, color] of [["tg-arrow-unknown", "var(--foreign)"], ["tg-arrow-planned", "var(--pending)"], ["tg-arrow-applied", "var(--focus)"]]) {
|
|
3691
3743
|
if (svg.querySelector(`#${id}`)) continue;
|
|
@@ -3709,7 +3761,7 @@ function drawTgReads() {
|
|
|
3709
3761
|
const fromBox = (card && cardBoxOf.get(card)) || (card && boxOf(card)) || rootBox(e.from);
|
|
3710
3762
|
const toBox = rootBox(e.to);
|
|
3711
3763
|
if (!fromBox || !toBox) continue;
|
|
3712
|
-
const others = cardEls.filter((g) => g !== card).map((g) => cardBoxOf.get(g)).filter(
|
|
3764
|
+
const others = cardEls.filter((g) => g !== card).map((g) => cardBoxOf.get(g)).filter((b) => b && b !== fromBox && b !== toBox && !(compMode && (sameBox(b, fromBox) || sameBox(b, toBox))));
|
|
3713
3765
|
let best = null;
|
|
3714
3766
|
for (const p0 of borderPoints(fromBox, [0.25, 0.5, 0.75])) {
|
|
3715
3767
|
for (const p1 of borderPoints(toBox, [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9])) {
|
|
@@ -3726,7 +3778,7 @@ function drawTgReads() {
|
|
|
3726
3778
|
const texts = textBoxes.filter((t) => crosses(a, z, t)).length;
|
|
3727
3779
|
const cards = others.filter((c) => crosses(a, z, c)).length;
|
|
3728
3780
|
const lines = drawn.filter((d) => crosses(a, z, grow({ x: Math.min(d.p0.x, d.p1.x), y: Math.min(d.p0.y, d.p1.y), x2: Math.max(d.p0.x, d.p1.x), y2: Math.max(d.p0.y, d.p1.y) }, 0)) && Math.hypot(d.p1.x - p1.x, d.p1.y - p1.y) < 30).length;
|
|
3729
|
-
const score = texts * 10000 + cards * 2000 + lines * 500 + len;
|
|
3781
|
+
const score = texts * 10000 + cards * 2000 + lines * 500 + nearEdge(a, z) * 400 + len;
|
|
3730
3782
|
if (!best || score < best.score) best = { p0, p1, score };
|
|
3731
3783
|
}
|
|
3732
3784
|
}
|
|
@@ -3769,7 +3821,7 @@ function drawTgReads() {
|
|
|
3769
3821
|
}
|
|
3770
3822
|
for (const c of spots) {
|
|
3771
3823
|
const r = { x: c.x - w / 2, y: c.y - h / 2, x2: c.x + w / 2, y2: c.y + h / 2 };
|
|
3772
|
-
const lineHits = drawn.filter((d) => crosses(d.p0, d.p1, r)).length;
|
|
3824
|
+
const lineHits = drawn.filter((d) => crosses(d.p0, d.p1, r)).length + edgeIn(r);
|
|
3773
3825
|
// Outside the boxes' own span the label may leave the drawing when the view is fitted: keep it in.
|
|
3774
3826
|
const span = union(boxes);
|
|
3775
3827
|
const out = span ? (r.x2 - r.x) * (r.y2 - r.y) - overlap(r, { x: span.x - 40, y: span.y - 60, x2: span.x2 + 40, y2: span.y2 + 40 }) : 0;
|
|
@@ -3783,12 +3835,222 @@ function drawTgReads() {
|
|
|
3783
3835
|
}
|
|
3784
3836
|
|
|
3785
3837
|
// Which run the marks come from, above the boxes.
|
|
3786
|
-
|
|
3838
|
+
// #569: a pin that holds the view off every plan says why in its own words.
|
|
3839
|
+
const shown = tgViewCaption(drawnAnswer, tgProgress, new Date());
|
|
3840
|
+
const caption = tgPinnedCaption(tgAnswer, view.run) || (shown ? `Showing ${shown}` : null);
|
|
3787
3841
|
const all = union(boxes);
|
|
3788
3842
|
if (caption && all) {
|
|
3789
|
-
|
|
3843
|
+
const line = make("text", { "data-tg-reads": "caption", x: all.x, y: all.y - 22, "font-size": 16, "font-weight": 600, fill: "var(--muted)" }, caption);
|
|
3844
|
+
layer.appendChild(line);
|
|
3845
|
+
// #571: the components zoom is narrow; a caption wider than its boxes goes on two lines, broken after its colon or comma.
|
|
3846
|
+
let wide = 0;
|
|
3847
|
+
try {
|
|
3848
|
+
wide = line.getComputedTextLength();
|
|
3849
|
+
} catch {
|
|
3850
|
+
/* not laid out */
|
|
3851
|
+
}
|
|
3852
|
+
const cut = caption.indexOf(": ") > 0 ? caption.indexOf(": ") : caption.indexOf(", ");
|
|
3853
|
+
if (compMode && cut > 0 && wide > all.x2 - all.x + 40) {
|
|
3854
|
+
line.textContent = caption.slice(0, cut + 1);
|
|
3855
|
+
line.setAttribute("y", String(all.y - 42));
|
|
3856
|
+
layer.appendChild(make("text", { "data-tg-reads": "caption", x: all.x, y: all.y - 22, "font-size": 16, "font-weight": 600, fill: "var(--muted)" }, caption.slice(cut + 2)));
|
|
3857
|
+
}
|
|
3858
|
+
}
|
|
3859
|
+
}
|
|
3860
|
+
const sameBox = (a, b) => !!a && !!b && Math.abs(a.x - b.x) < 0.5 && Math.abs(a.y - b.y) < 0.5 && Math.abs(a.x2 - b.x2) < 0.5 && Math.abs(a.y2 - b.y2) < 0.5;
|
|
3861
|
+
|
|
3862
|
+
/** #571: points along the components graph's own edges, in the svg's coordinates, every few units. */
|
|
3863
|
+
function chantEdgePoints(svg) {
|
|
3864
|
+
const toSvg = svg.getScreenCTM && svg.getScreenCTM();
|
|
3865
|
+
if (!toSvg) return [];
|
|
3866
|
+
const inv = toSvg.inverse();
|
|
3867
|
+
const out = [];
|
|
3868
|
+
for (const path of svg.querySelectorAll("path.pin-edge-line")) {
|
|
3869
|
+
let len = 0;
|
|
3870
|
+
try {
|
|
3871
|
+
len = path.getTotalLength();
|
|
3872
|
+
} catch {
|
|
3873
|
+
continue;
|
|
3874
|
+
}
|
|
3875
|
+
const ctm = path.getScreenCTM && path.getScreenCTM();
|
|
3876
|
+
if (!ctm || !len) continue;
|
|
3877
|
+
const m = inv.multiply(ctm);
|
|
3878
|
+
for (let d = 0; d <= len; d += 5) {
|
|
3879
|
+
const p = path.getPointAtLength(d);
|
|
3880
|
+
out.push(new DOMPoint(p.x, p.y).matrixTransform(m));
|
|
3881
|
+
}
|
|
3882
|
+
}
|
|
3883
|
+
return out;
|
|
3884
|
+
}
|
|
3885
|
+
|
|
3886
|
+
// #571: the pipeline's runs (terragucci#1038), for the components zoom's wave
|
|
3887
|
+
// boxes: each box's job and its status on the run the view draws.
|
|
3888
|
+
let tgRuns = null;
|
|
3889
|
+
let tgRunsTimer = null;
|
|
3890
|
+
async function loadTgRuns() {
|
|
3891
|
+
const was = JSON.stringify(tgRuns && tgRuns.doc ? tgRuns.doc.runs : null);
|
|
3892
|
+
tgRuns = await apiFetch("/api/terragucci/runs")
|
|
3893
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
3894
|
+
.catch(() => null);
|
|
3895
|
+
if (JSON.stringify(tgRuns && tgRuns.doc ? tgRuns.doc.runs : null) !== was) drawTgReads();
|
|
3896
|
+
}
|
|
3897
|
+
/** Read the runs while the components zoom of a terragucci repo is on screen, every few seconds as the kiosk does. */
|
|
3898
|
+
function watchTgRuns() {
|
|
3899
|
+
const on = !staticMode && !!tgAnswer && !tgAnswer.error && view.components && terragucciComponentsRepo;
|
|
3900
|
+
if (on && !tgRunsTimer) {
|
|
3901
|
+
void loadTgRuns();
|
|
3902
|
+
tgRunsTimer = setInterval(() => void loadTgRuns(), 3_000);
|
|
3903
|
+
} else if (!on && tgRunsTimer) {
|
|
3904
|
+
clearInterval(tgRunsTimer);
|
|
3905
|
+
tgRunsTimer = null;
|
|
3790
3906
|
}
|
|
3791
3907
|
}
|
|
3908
|
+
|
|
3909
|
+
/**
|
|
3910
|
+
* #571: the live marks on a terragucci repo's components zoom, for the run
|
|
3911
|
+
* the view draws (`view.run`): each card in its wave's state, each wave box
|
|
3912
|
+
* outlined in its wave's state with its job's status after its title and its
|
|
3913
|
+
* first fact (a waiting wave's plan digest) in its bottom padding. Text goes
|
|
3914
|
+
* only where it covers no text, no card and no edge of the graph: the first
|
|
3915
|
+
* spot that is clear, in the words that fit, else only in the tooltip.
|
|
3916
|
+
* Returns the wave boxes, in the svg's coordinates.
|
|
3917
|
+
*/
|
|
3918
|
+
function drawTgComponentMarks(svg, { drawnAnswer, focus, boxOf, make, edgePoints }) {
|
|
3919
|
+
const marks = tgComponentMarks(drawnAnswer, tgProgress, tgComponentFacets, { runs: tgRuns, pin: view.run || null });
|
|
3920
|
+
const group = make("g", { "data-tg-root": "components", "pointer-events": "none" });
|
|
3921
|
+
svg.appendChild(group);
|
|
3922
|
+
const grow = (b, d) => ({ x: b.x - d, y: b.y - d, x2: b.x2 + d, y2: b.y2 + d });
|
|
3923
|
+
const hits = (a, b) => a.x < b.x2 && a.x2 > b.x && a.y < b.y2 && a.y2 > b.y;
|
|
3924
|
+
const textBoxes = () =>
|
|
3925
|
+
[...svg.querySelectorAll("text")]
|
|
3926
|
+
.filter((t) => t.getAttribute("visibility") !== "hidden" && (t.textContent || "").trim() && !t.closest("[data-tg-reads]"))
|
|
3927
|
+
.map(boxOf)
|
|
3928
|
+
.filter(Boolean);
|
|
3929
|
+
const cardEls = [...svg.querySelectorAll("[data-node-id]")];
|
|
3930
|
+
const cardBoxes = cardEls.map(boxOf).filter(Boolean);
|
|
3931
|
+
const edgeHit = (r) => edgePoints.some((p) => p.x >= r.x - 4 && p.x <= r.x2 + 4 && p.y >= r.y - 4 && p.y <= r.y2 + 4);
|
|
3932
|
+
const probe = make("text", { "font-size": 12, "font-weight": 700 });
|
|
3933
|
+
group.appendChild(probe);
|
|
3934
|
+
const width = (t, size, weight) => {
|
|
3935
|
+
probe.setAttribute("font-size", String(size));
|
|
3936
|
+
probe.setAttribute("font-weight", String(weight));
|
|
3937
|
+
probe.textContent = t;
|
|
3938
|
+
try {
|
|
3939
|
+
return probe.getComputedTextLength() || t.length * size * 0.58;
|
|
3940
|
+
} catch {
|
|
3941
|
+
return t.length * size * 0.58;
|
|
3942
|
+
}
|
|
3943
|
+
};
|
|
3944
|
+
const tone = (t) => TG_TONE[t] || TG_TONE.idle;
|
|
3945
|
+
|
|
3946
|
+
// The cards: outlined and barred in their state's colour, the state in the
|
|
3947
|
+
// line under the name (the "Component · chant" line gives way to it).
|
|
3948
|
+
for (const g of cardEls) {
|
|
3949
|
+
const id = g.getAttribute("data-node-id");
|
|
3950
|
+
const m = marks.cards[id];
|
|
3951
|
+
if (!m) continue;
|
|
3952
|
+
const rects = [...g.querySelectorAll(":scope > rect")];
|
|
3953
|
+
const body = rects.reduce((a, r) => (!a || Number(r.getAttribute("width")) * Number(r.getAttribute("height")) > Number(a.getAttribute("width")) * Number(a.getAttribute("height")) ? r : a), null);
|
|
3954
|
+
const bar = rects.find((r) => r !== body && Number(r.getAttribute("width")) <= 6);
|
|
3955
|
+
const quiet = m.state === "outside" || m.state === "unchanged" || m.state === "not-started";
|
|
3956
|
+
const inFocus = !focus || focus.has(m.root) || focus.has(id);
|
|
3957
|
+
if (body) {
|
|
3958
|
+
body.setAttribute("data-tg-stroke0", body.getAttribute("stroke") || "");
|
|
3959
|
+
body.setAttribute("data-tg-width0", body.getAttribute("stroke-width") || "1.2");
|
|
3960
|
+
if (!quiet) {
|
|
3961
|
+
body.setAttribute("stroke", tone(m.tone));
|
|
3962
|
+
body.setAttribute("stroke-width", focus && inFocus ? "4" : "2.5");
|
|
3963
|
+
}
|
|
3964
|
+
}
|
|
3965
|
+
if (bar && !quiet) {
|
|
3966
|
+
bar.setAttribute("data-tg-fill0", bar.getAttribute("fill") || "");
|
|
3967
|
+
bar.setAttribute("fill", tone(m.tone));
|
|
3968
|
+
}
|
|
3969
|
+
const texts = [...g.querySelectorAll(":scope > text")];
|
|
3970
|
+
const sub = texts.find((t) => /^Component\b/.test((t.textContent || "").trim())) || texts[1];
|
|
3971
|
+
if (!sub || !body) continue;
|
|
3972
|
+
const x = Number(sub.getAttribute("x"));
|
|
3973
|
+
const y = Number(sub.getAttribute("y"));
|
|
3974
|
+
const room = Number(body.getAttribute("x")) + Number(body.getAttribute("width")) - 10 - x;
|
|
3975
|
+
const words = width(m.words, 12, 700) <= room ? m.words : m.short;
|
|
3976
|
+
sub.setAttribute("visibility", "hidden");
|
|
3977
|
+
sub.setAttribute("data-tg-hid", "1");
|
|
3978
|
+
const t = make("text", { "data-tg-root": "card", x, y, "font-size": 12, "font-weight": 700, fill: quiet ? "var(--muted)" : tone(m.tone) }, words);
|
|
3979
|
+
if (m.state === "applying") t.setAttribute("class", "pin-pulse");
|
|
3980
|
+
if (!inFocus) t.style.opacity = "0.6";
|
|
3981
|
+
t.appendChild(make("title", {}, [`wave ${m.wave} · ${m.words}`, ...m.facts].join("\n") + "\n(from terragucci's reports, not live)"));
|
|
3982
|
+
g.appendChild(t);
|
|
3983
|
+
}
|
|
3984
|
+
|
|
3985
|
+
// The wave boxes.
|
|
3986
|
+
const out = [];
|
|
3987
|
+
const placed = [];
|
|
3988
|
+
for (const rect of svg.querySelectorAll("rect[data-group-id]")) {
|
|
3989
|
+
const n = tgWaveOfKey(rect.getAttribute("data-group-id"));
|
|
3990
|
+
const w = n === null ? null : marks.waves[n];
|
|
3991
|
+
const b = boxOf(rect);
|
|
3992
|
+
if (!b) continue;
|
|
3993
|
+
out.push(b);
|
|
3994
|
+
if (!w) continue;
|
|
3995
|
+
const quiet = !w.state || w.state === "not-started";
|
|
3996
|
+
rect.setAttribute("data-tg-stroke0", rect.getAttribute("stroke") || "");
|
|
3997
|
+
rect.setAttribute("data-tg-width0", rect.getAttribute("stroke-width") || "1.2");
|
|
3998
|
+
if (!quiet) {
|
|
3999
|
+
rect.setAttribute("stroke", tone(w.tone));
|
|
4000
|
+
rect.setAttribute("stroke-width", "2.5");
|
|
4001
|
+
}
|
|
4002
|
+
const title = [...svg.querySelectorAll("text[data-layout-move]")].find((t) => t.getAttribute("data-layout-move") === `box:${rect.getAttribute("data-group-id")}`);
|
|
4003
|
+
const tb = title ? boxOf(title) : null;
|
|
4004
|
+
const others = () => [...textBoxes(), ...placed].filter((x) => !(tb && sameBox(x, tb)));
|
|
4005
|
+
const clear = (r) => r.x >= b.x + 6 && r.x2 <= b.x2 - 6 && !edgeHit(r) && !others().some((x) => hits(grow(x, 2), r)) && !cardBoxes.some((c) => hits(grow(c, 4), r));
|
|
4006
|
+
// The job's status: after the title on its line, else at the right of that line.
|
|
4007
|
+
if (w.status && tb) {
|
|
4008
|
+
const size = 12;
|
|
4009
|
+
const cy = (tb.y + tb.y2) / 2;
|
|
4010
|
+
// Right after the title, as "apply-wave-2 · held at the gate"; else alone at the right of the title's line.
|
|
4011
|
+
const spots = [{ words: `· ${w.status.words}`, at: () => tb.x2 + 6 }, { words: w.status.words, at: (wd) => b.x2 - 16 - wd }];
|
|
4012
|
+
for (const { words, at } of spots) {
|
|
4013
|
+
const wd = width(words, size, 700);
|
|
4014
|
+
const x = at(wd);
|
|
4015
|
+
const r = { x, y: cy - size / 2 - 2, x2: x + wd, y2: cy + size / 2 + 2 };
|
|
4016
|
+
if (x < tb.x2 + 4 || !clear(r)) continue;
|
|
4017
|
+
const t = make("text", { "data-tg-root": "wave-status", x, y: cy, "dominant-baseline": "central", "font-size": size, "font-weight": 700, fill: tone(w.status.tone) }, words);
|
|
4018
|
+
if (w.status.status === "running") t.setAttribute("class", "pin-pulse");
|
|
4019
|
+
group.appendChild(t);
|
|
4020
|
+
placed.push(r);
|
|
4021
|
+
break;
|
|
4022
|
+
}
|
|
4023
|
+
}
|
|
4024
|
+
if (title) {
|
|
4025
|
+
const tip = make("title", { "data-tg-root": "tip" }, [w.title, ...(w.state ? [`wave ${n} ${tgStateWords[w.state] || w.state}`] : []), ...w.facts].join("\n") + "\n(the job's status from the pipeline's runs; the wave's from terragucci's reports)");
|
|
4026
|
+
title.appendChild(tip);
|
|
4027
|
+
}
|
|
4028
|
+
// The wave's first fact (a waiting wave's plan digest) in the box's bottom padding, where it fits.
|
|
4029
|
+
const fact = w.facts[0];
|
|
4030
|
+
if (fact) {
|
|
4031
|
+
const size = 12.5;
|
|
4032
|
+
const digest = /^approve plan (\S+)/.exec(fact);
|
|
4033
|
+
const say = digest ? [fact, `plan ${digest[1]}`, `plan ${digest[1].slice(0, 8)}`, digest[1].slice(0, 8)] : [fact];
|
|
4034
|
+
const fill = w.state === "waiting" ? tone(w.tone) : "var(--muted)";
|
|
4035
|
+
let done = false;
|
|
4036
|
+
for (const words of say) {
|
|
4037
|
+
const wd = width(words, size, 600);
|
|
4038
|
+
for (const x of [b.x2 - 14 - wd, b.x + 18]) {
|
|
4039
|
+
const cy = b.y2 - 14;
|
|
4040
|
+
const r = { x, y: cy - size / 2 - 2, x2: x + wd, y2: cy + size / 2 + 2 };
|
|
4041
|
+
if (!clear(r)) continue;
|
|
4042
|
+
group.appendChild(make("text", { "data-tg-root": "wave-fact", x, y: cy, "dominant-baseline": "central", "font-size": size, "font-weight": 600, fill }, words));
|
|
4043
|
+
placed.push(r);
|
|
4044
|
+
done = true;
|
|
4045
|
+
break;
|
|
4046
|
+
}
|
|
4047
|
+
if (done) break;
|
|
4048
|
+
}
|
|
4049
|
+
}
|
|
4050
|
+
}
|
|
4051
|
+
probe.remove();
|
|
4052
|
+
return out;
|
|
4053
|
+
}
|
|
3792
4054
|
function markTerragucciCards() {
|
|
3793
4055
|
recolorNodesByCategory(); // #569 review: the reports arrived, so the cards may go neutral
|
|
3794
4056
|
const svg = document.querySelector("#graph svg");
|
|
@@ -4273,9 +4535,29 @@ function lensParams(params) {
|
|
|
4273
4535
|
// it synchronously. Component-DAG mode only — cleared otherwise.
|
|
4274
4536
|
let ciByComponent = new Map();
|
|
4275
4537
|
let ciForge = null; // the forge the pipeline was generated for (#164) — heads the inspect section
|
|
4538
|
+
// #571: the served repo declares its components in terragucci.components.json.
|
|
4539
|
+
// Its CI and resources facets then ride the components graph response
|
|
4540
|
+
// (`meta.terragucciComponents`, see takeTerragucciFacets), which a public view
|
|
4541
|
+
// can read, so the two facet fetches below are skipped.
|
|
4542
|
+
let terragucciComponentsRepo = false;
|
|
4543
|
+
// #571: the components graph's facets (jobs, component -> root), for the live marks drawTgReads puts on it.
|
|
4544
|
+
let tgComponentFacets = null;
|
|
4545
|
+
|
|
4546
|
+
/** #571: the CI and resources facets from a terragucci repo's components
|
|
4547
|
+
* graph: component -> its declared job ({jobName, needs, wave, alsoIn}), and
|
|
4548
|
+
* component -> the resources its root holds. */
|
|
4549
|
+
function takeTerragucciFacets(m) {
|
|
4550
|
+
const tg = m && m.terragucciComponents;
|
|
4551
|
+
tgComponentFacets = tg || null;
|
|
4552
|
+
if (!tg) return;
|
|
4553
|
+
ciByComponent = new Map((tg.jobs || []).map((job) => [job.component, job]));
|
|
4554
|
+
ciForge = tg.forge || null;
|
|
4555
|
+
resourcesByComponent = tg.resources || {};
|
|
4556
|
+
}
|
|
4276
4557
|
|
|
4277
4558
|
async function loadCi() {
|
|
4278
4559
|
if (publicMode) return; // #569: /api/ci is refused in a public view
|
|
4560
|
+
if (terragucciComponentsRepo) return; // #571: carried on the graph response
|
|
4279
4561
|
try {
|
|
4280
4562
|
const q = lensParams(new URLSearchParams(view.env ? { env: view.env } : {}));
|
|
4281
4563
|
const res = await apiFetch(`/api/ci?${q}`);
|
|
@@ -4301,6 +4583,7 @@ let resourcesByComponent = {};
|
|
|
4301
4583
|
|
|
4302
4584
|
async function loadResources() {
|
|
4303
4585
|
if (publicMode) return; // #569: /api/resources is refused in a public view
|
|
4586
|
+
if (terragucciComponentsRepo) return; // #571: carried on the graph response
|
|
4304
4587
|
try {
|
|
4305
4588
|
const q = lensParams(new URLSearchParams(view.env ? { env: view.env } : {}));
|
|
4306
4589
|
const res = await apiFetch(`/api/resources?${q}`);
|
|
@@ -5051,7 +5334,7 @@ function render(ir, svg, m) {
|
|
|
5051
5334
|
// Mark each wave lane as a GitLab CI stage when the CI projection is loaded
|
|
5052
5335
|
// (components view only) — waves ARE the pipeline's stages (`chant build
|
|
5053
5336
|
// --components --generate gitlab`, see loadCi / #58).
|
|
5054
|
-
if (view.components && ciByComponent.size) addGitlabWaveBadges(svgEl);
|
|
5337
|
+
if (view.components && ciByComponent.size && !terragucciComponentsRepo) addGitlabWaveBadges(svgEl);
|
|
5055
5338
|
}
|
|
5056
5339
|
ensureZoomControls(g);
|
|
5057
5340
|
ensureBackToInfra(g);
|
|
@@ -6199,6 +6482,7 @@ async function loadOnce(opts = {}, isCurrent = () => true) {
|
|
|
6199
6482
|
// "still reading" claim on screen.
|
|
6200
6483
|
pendingMembers = body.meta && body.meta.mode === "progressive" ? [...(body.meta.pending || [])] : [];
|
|
6201
6484
|
pendingTotal = pendingMembers.length;
|
|
6485
|
+
if (view.components) takeTerragucciFacets(body.meta);
|
|
6202
6486
|
render(body.ir, body.svg, body.meta);
|
|
6203
6487
|
afterPaint(body.ir);
|
|
6204
6488
|
} catch (err) {
|
|
@@ -6318,7 +6602,8 @@ async function initPickers() {
|
|
|
6318
6602
|
// estate never paints the components apology on its way to a picture.
|
|
6319
6603
|
runtimeCapable = !!info.runtimeCapable;
|
|
6320
6604
|
stacksCapable = !!info.stacksCapable;
|
|
6321
|
-
|
|
6605
|
+
terragucciComponentsRepo = !!info.terragucciComponents;
|
|
6606
|
+
const boot = bootZoom(info.memberKinds, terragucciComponentsRepo);
|
|
6322
6607
|
if (boot !== "components") {
|
|
6323
6608
|
applyZoom(boot);
|
|
6324
6609
|
autoZoomFallback = false;
|
package/web/kiosk-runs.js
CHANGED
|
@@ -316,7 +316,7 @@ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () =>
|
|
|
316
316
|
host.replaceChildren();
|
|
317
317
|
if (!answer || answer.error || answer.absent || !answer.doc) {
|
|
318
318
|
host.appendChild(el("p", "r-note", answer ? answer.absent || answer.error : "Reading the run…"));
|
|
319
|
-
return { log: null };
|
|
319
|
+
return { log: null, steps: null, key: null };
|
|
320
320
|
}
|
|
321
321
|
const run = mergeRun(entry, answer.doc);
|
|
322
322
|
const head = el("div", "r-head");
|
|
@@ -355,7 +355,7 @@ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () =>
|
|
|
355
355
|
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(", ")}`));
|
|
356
356
|
host.appendChild(jobsBox);
|
|
357
357
|
|
|
358
|
-
if (!job) return { log: null };
|
|
358
|
+
if (!job) return { log: null, steps: null, key: null };
|
|
359
359
|
host.appendChild(el("div", "r-label", "Steps of the job, with its log"));
|
|
360
360
|
const stepsBox = el("div", "r-steps");
|
|
361
361
|
const sh = el("div", "r-steps-head");
|
|
@@ -395,7 +395,31 @@ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () =>
|
|
|
395
395
|
stepsBox.appendChild(row);
|
|
396
396
|
});
|
|
397
397
|
host.appendChild(stepsBox);
|
|
398
|
-
return { log: logEl, live: LIVE.has(job.status) };
|
|
398
|
+
return { log: logEl, steps: stepsBox, key: logEl ? logKey(run.id, job.name, open) : null, live: LIVE.has(job.status) };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** 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. */
|
|
402
|
+
export function logKey(runId, job, step) {
|
|
403
|
+
return `${runId}|${job}|${step}`;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** How close to the end, in log lines, still counts as at the end: a viewer back within it is followed again. */
|
|
407
|
+
export const FOLLOW_LINES = 3;
|
|
408
|
+
|
|
409
|
+
/** Whether a scroller's view ends within `slack` pixels of its content's end. */
|
|
410
|
+
export function nearEnd({ scrollTop, clientHeight, scrollHeight }, slack) {
|
|
411
|
+
return scrollTop + clientHeight >= scrollHeight - slack;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Whether the log pane follows its end after a redraw, the way a CI page's
|
|
416
|
+
* log does: always when a different log opened (another run, job or step,
|
|
417
|
+
* as when the next step starts); otherwise as the viewer left it, following
|
|
418
|
+
* unless they scrolled up away from the end.
|
|
419
|
+
*/
|
|
420
|
+
export function followsLog(prevKey, key, wasFollowing) {
|
|
421
|
+
if (key !== prevKey) return true;
|
|
422
|
+
return wasFollowing;
|
|
399
423
|
}
|
|
400
424
|
|
|
401
425
|
/** How long a viewer's pick holds before the kiosk follows the live run again. */
|
|
@@ -410,6 +434,9 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
|
|
|
410
434
|
const s = { index: null, run: null, selected: null, picked: 0, ui: { job: null, open: null } };
|
|
411
435
|
let follow = true;
|
|
412
436
|
let lastRunKey = "";
|
|
437
|
+
// The open log and whether the pane follows its end; a viewer who scrolls up stops it until they come back.
|
|
438
|
+
let logShown = null;
|
|
439
|
+
let logFollow = true;
|
|
413
440
|
const following = () => follow || now().getTime() - s.picked > PICK_HOLD_MS;
|
|
414
441
|
const selectedId = () => {
|
|
415
442
|
const runs = s.index && s.index.doc ? s.index.doc.runs : [];
|
|
@@ -427,10 +454,11 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
|
|
|
427
454
|
// One merged read of the selected run for both the sidebar and its page.
|
|
428
455
|
const merged = ans && ans.doc ? mergeRun(entry, ans.doc) : null;
|
|
429
456
|
renderSidebar(side, s.index, id, { now: now(), scenarios, pick, merged });
|
|
430
|
-
//
|
|
457
|
+
// The redraw replaces the scrollers: keep where a viewer left them.
|
|
431
458
|
const old = main.querySelector(".r-log");
|
|
432
|
-
const
|
|
459
|
+
const oldSteps = main.querySelector(".r-steps");
|
|
433
460
|
const prevTop = old ? old.scrollTop : 0;
|
|
461
|
+
const prevStepsTop = oldSteps ? oldSteps.scrollTop : 0;
|
|
434
462
|
const drawn = renderRun(main, ans, s.ui, {
|
|
435
463
|
now: now(),
|
|
436
464
|
entry,
|
|
@@ -445,7 +473,34 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
|
|
|
445
473
|
draw();
|
|
446
474
|
},
|
|
447
475
|
});
|
|
448
|
-
|
|
476
|
+
logFollow = followsLog(logShown, drawn.key, logFollow);
|
|
477
|
+
const sameLog = drawn.key !== null && drawn.key === logShown;
|
|
478
|
+
logShown = drawn.key;
|
|
479
|
+
if (!drawn.log) {
|
|
480
|
+
if (drawn.steps) drawn.steps.scrollTop = prevStepsTop;
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
const log = drawn.log;
|
|
484
|
+
const steps = drawn.steps;
|
|
485
|
+
if (logFollow) {
|
|
486
|
+
log.scrollTop = log.scrollHeight;
|
|
487
|
+
// The steps list scrolls too: bring the log's end into its view.
|
|
488
|
+
steps.scrollTop = sameLog ? prevStepsTop : 0;
|
|
489
|
+
const below = log.getBoundingClientRect().bottom - steps.getBoundingClientRect().bottom;
|
|
490
|
+
if (below > 0) steps.scrollTop += below + 8;
|
|
491
|
+
} else {
|
|
492
|
+
log.scrollTop = prevTop;
|
|
493
|
+
steps.scrollTop = prevStepsTop;
|
|
494
|
+
}
|
|
495
|
+
// A viewer scrolling either list decides whether the pane follows: near the log's end it does again.
|
|
496
|
+
const line = log.querySelector(".r-log-line");
|
|
497
|
+
const slack = (line ? line.getBoundingClientRect().height || 18 : 18) * FOLLOW_LINES;
|
|
498
|
+
const onScroll = () => {
|
|
499
|
+
const logEnd = log.getBoundingClientRect().bottom - steps.getBoundingClientRect().bottom;
|
|
500
|
+
logFollow = nearEnd(log, slack) && logEnd <= slack;
|
|
501
|
+
};
|
|
502
|
+
log.addEventListener("scroll", onScroll, { passive: true });
|
|
503
|
+
steps.addEventListener("scroll", onScroll, { passive: true });
|
|
449
504
|
};
|
|
450
505
|
const hold = () => {
|
|
451
506
|
follow = false;
|
package/web/kiosk-runs.test.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { describe, expect, it } from "vitest";
|
|
4
|
-
import { mergeRun, newerOf, agoWords, defaultJob, defaultStep, durationWords, jobOrder, liveRun, placeWords, sidebarRuns, splitSkipped, STATUS_TONE, triggerWords } from "./kiosk-runs.js";
|
|
4
|
+
import { FOLLOW_LINES, followsLog, logKey, nearEnd, mergeRun, newerOf, agoWords, defaultJob, defaultStep, durationWords, jobOrder, liveRun, placeWords, sidebarRuns, splitSkipped, STATUS_TONE, triggerWords } from "./kiosk-runs.js";
|
|
5
5
|
|
|
6
6
|
const FIX = join(import.meta.dirname, "..", "src", "__fixtures__", "terragucci-demo", "runs");
|
|
7
7
|
const run = (id) => JSON.parse(readFileSync(join(FIX, `${id}.json`), "utf8"));
|
|
@@ -96,3 +96,24 @@ describe("one run, from the index and its own document (#569)", () => {
|
|
|
96
96
|
expect(mergeRun(entry, null)).toBe(entry);
|
|
97
97
|
});
|
|
98
98
|
});
|
|
99
|
+
|
|
100
|
+
describe("the log pane follows its end, like a CI page's log", () => {
|
|
101
|
+
const key = logKey(448, "apply-wave-2", 2);
|
|
102
|
+
it("follows a log that just opened: another run, job or step", () => {
|
|
103
|
+
expect(followsLog(null, key, false)).toBe(true);
|
|
104
|
+
expect(followsLog(logKey(447, "apply-wave-2", 2), key, false)).toBe(true);
|
|
105
|
+
expect(followsLog(logKey(448, "apply-wave-1", 2), key, false)).toBe(true);
|
|
106
|
+
expect(followsLog(logKey(448, "apply-wave-2", 1), key, false)).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
it("on the same log, keeps what the viewer chose", () => {
|
|
109
|
+
expect(followsLog(key, key, true)).toBe(true);
|
|
110
|
+
expect(followsLog(key, key, false)).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
it("counts a view within a few lines of the end as at the end", () => {
|
|
113
|
+
const slack = 18 * FOLLOW_LINES;
|
|
114
|
+
expect(nearEnd({ scrollTop: 600, clientHeight: 400, scrollHeight: 1000 }, slack)).toBe(true);
|
|
115
|
+
expect(nearEnd({ scrollTop: 600 - slack, clientHeight: 400, scrollHeight: 1000 }, slack)).toBe(true);
|
|
116
|
+
expect(nearEnd({ scrollTop: 600 - slack - 1, clientHeight: 400, scrollHeight: 1000 }, slack)).toBe(false);
|
|
117
|
+
expect(nearEnd({ scrollTop: 0, clientHeight: 400, scrollHeight: 1000 }, slack)).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
});
|