@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/behold",
3
- "version": "0.29.2",
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 { changeSection as tgChangeSection, readCardIds as tgReadCardIds, readEdges as tgReadEdges, readLabel as tgReadLabel, readLine as tgReadLine, readTag as tgReadTag, rootStates as tgRootStates, viewCaption as tgViewCaption } from "./terragucci-change.js";
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(tgAnswer, tgProgress).filter((e) => e.from === tgReadRoot || e.to === tgReadRoot) : [];
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
- ci("stage", job.stage);
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
- ci("runs", job.script.length ? job.script.join(" && ") : `chant run --components ${node.id}`);
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
- function bootZoom(memberKinds) {
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 && view.run !== "none" && view.run !== "base") void loadTgProgress({ fresh: true });
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 (the pull request's plan, or the base).
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 === "none" || pin === "base") {
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 === "none" || pin === "base") return false;
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` nothing is in flight, so no pull request either. */
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 === "base" && tgAnswer && !tgAnswer.error ? { ...tgAnswer, pull_request: null } : tgAnswer;
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
- el.setAttribute("data-tg-dim", "1");
3575
- el.style.opacity = "0.5";
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(Boolean);
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
- const caption = view.run === "base" ? "nothing in flight: the states went back to the base" : tgViewCaption(drawnAnswer, tgProgress, new Date());
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
- layer.appendChild(make("text", { "data-tg-reads": "caption", x: all.x, y: all.y - 22, "font-size": 16, "font-weight": 600, fill: "var(--muted)" }, `Showing ${caption}`));
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
- const boot = bootZoom(info.memberKinds);
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
- // Keep the log's scroll unless it was at the bottom, so a live log follows.
457
+ // The redraw replaces the scrollers: keep where a viewer left them.
431
458
  const old = main.querySelector(".r-log");
432
- const atBottom = !old || old.scrollTop + old.clientHeight >= old.scrollHeight - 8;
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
- if (drawn.log) drawn.log.scrollTop = atBottom ? drawn.log.scrollHeight : prevTop;
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;
@@ -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
+ });