Public: anyone can read this; the crew can change it.
Tests that read the held panel and merge queue from /flow read them from /boards instead #2816
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2807-tests-that-read-the-held-panel-and-merge → main 1 commit fork
11 files
changed +257
−285
src/web/pages/boards-held.ts +6 −52
| @@ -1,17 +1,14 @@ | ||
| 1 | 1 | /** |
| 2 | * The pipeline and the held counts as a panel of `/boards`: one row per | |
| 3 | * stage (`flow/pipeline.ts`) and, per autonomous repository, its ready list | |
| 4 | * as one bar with the count each filter holds (`flow/held.ts`). /flow draws | |
| 5 | * the same held card from here. A private repository is in the rows for the | |
| 6 | * owner only. | |
| 2 | * Why ready issues wait, per autonomous repository: its ready list as one bar | |
| 3 | * with the count each filter holds (`flow/held.ts`). The flow panel | |
| 4 | * (`flow-panel.ts`), on `/boards` and `/flow`, draws it from here. A private | |
| 5 | * repository is in the rows for the owner only. | |
| 7 | 6 | */ |
| 8 | 7 | |
| 9 | import type { App } from "../../app.ts"; | |
| 10 | 8 | import { HOLDS } from "../../crew/held.ts"; |
| 11 | import { readHeld, type HeldRow } from "../../flow/held.ts"; | |
| 12 | import { bottleneck, readPipeline, STAGE_HOLDS, STAGE_LABEL, verdict, waited } from "../../flow/pipeline.ts"; | |
| 9 | import type { HeldRow } from "../../flow/held.ts"; | |
| 13 | 10 | import { html, type Html } from "../html.ts"; |
| 14 | import { badge, card } from "../ui.ts"; | |
| 11 | import { card } from "../ui.ts"; | |
| 15 | 12 | |
| 16 | 13 | export const heldCss = /* css */ ` |
| 17 | 14 | .held-bar { width: 100%; height: 12px; display: block; } |
| @@ -81,46 +78,3 @@ export function heldCard(rows: readonly HeldRow[]): Html { | ||
| 81 | 78 | { id: "held" }, |
| 82 | 79 | ); |
| 83 | 80 | } |
| 84 | ||
| 85 | /** The pipeline, a row per stage, under the sentence naming the bottleneck. */ | |
| 86 | function pipelineCard(app: App, now: number): Html { | |
| 87 | const stages = readPipeline(app.db, new Date(now)); | |
| 88 | const worst = bottleneck(stages); | |
| 89 | return card( | |
| 90 | html`<p data-stage="${worst?.key ?? ""}"><strong>${verdict(stages, now)}</strong></p> | |
| 91 | <div class="table-scroll"> | |
| 92 | <table class="table" aria-label="Pipeline stages"> | |
| 93 | <thead> | |
| 94 | <tr> | |
| 95 | <th scope="col">Stage</th> | |
| 96 | <th scope="col">Waiting</th> | |
| 97 | <th scope="col">Active</th> | |
| 98 | <th scope="col">Oldest wait</th> | |
| 99 | <th scope="col">Capacity</th> | |
| 100 | </tr> | |
| 101 | </thead> | |
| 102 | <tbody> | |
| 103 | ${stages.map( | |
| 104 | (s) => html`<tr data-stage="${s.key}" ${s === worst ? html`data-bottleneck="true"` : null}> | |
| 105 | <th scope="row"> | |
| 106 | ${STAGE_LABEL[s.key]} | |
| 107 | ${s === worst ? badge("bottleneck", { tone: "danger" }) : null} | |
| 108 | <div class="muted small">${STAGE_HOLDS[s.key]}</div> | |
| 109 | </th> | |
| 110 | <td class="mono">${String(s.waiting)}</td> | |
| 111 | <td class="mono">${String(s.active)}</td> | |
| 112 | <td class="mono">${waited(s, now) ?? "—"}</td> | |
| 113 | <td class="small">${s.capacity ?? "—"}</td> | |
| 114 | </tr>`, | |
| 115 | )} | |
| 116 | </tbody> | |
| 117 | </table> | |
| 118 | </div>`, | |
| 119 | { head: html`<h2>Pipeline</h2>`, id: "pipeline", pad: false }, | |
| 120 | ); | |
| 121 | } | |
| 122 | ||
| 123 | /** The pipeline and the held counts; `owner` adds the private repositories. */ | |
| 124 | export function pipelinePanel(app: App, owner: boolean, now = Date.now()): Html { | |
| 125 | return html`${pipelineCard(app, now)}${heldCard(readHeld(app, owner, now))}`; | |
| 126 | } |
src/web/pages/boards-view.ts +3 −3
| @@ -17,7 +17,7 @@ import { notFoundPage } from "../chrome.ts"; | ||
| 17 | 17 | import { html, raw, when, type Html } from "../html.ts"; |
| 18 | 18 | import { fragment, page } from "../layout.ts"; |
| 19 | 19 | import { btn, card, empty } from "../ui.ts"; |
| 20 | import { heldCss, pipelinePanel } from "./boards-held.ts"; | |
| 20 | import { flowPanel, flowPanelCss } from "./flow-panel.ts"; | |
| 21 | 21 | import { cellsOf, chartBox, paramsOf, queryFields, readParams, runFor } from "./boards.ts"; |
| 22 | 22 | |
| 23 | 23 | const RANGE_MS = { "1h": 3_600_000, "24h": 86_400_000, "7d": 604_800_000, "30d": 2_592_000_000 } as const; |
| @@ -94,8 +94,8 @@ export async function boardsList(ctx: Ctx): Promise<Response> { | ||
| 94 | 94 | title: "Boards", |
| 95 | 95 | body: html`<div class="page-head"><h1>Boards</h1> |
| 96 | 96 | ${btn("Explore the data", { href: "/boards/data", variant: "ghost" })}</div> |
| 97 | ${card(make)} ${body} ${pipelinePanel(ctx.app, true)}`, | |
| 98 | styleCss: heldCss, | |
| 97 | ${card(make)} ${body} ${flowPanel(ctx.app, { kind: "owner" }, ctx.url, "/boards")}`, | |
| 98 | styleCss: flowPanelCss, | |
| 99 | 99 | }); |
| 100 | 100 | } |
| 101 | 101 |
src/web/pages/flow-panel.ts added +225
| @@ -0,0 +1,225 @@ | ||
| 1 | /** | |
| 2 | * The flow panel, drawn once for two pages: the pipeline with what each | |
| 3 | * stage let out, the bottleneck sentence, conflicted and requested pulls, | |
| 4 | * throughput, duplicate test work, why ready issues wait, and the merge | |
| 5 | * queue (see `pages/flow.ts` for the numbers' sources). `/boards` shows it | |
| 6 | * to the owner; `/flow` shows it to the owner and to a granted agent. Every | |
| 7 | * count links back to the page that drew it with `?out=<what>[&hour=<0–23>]`. | |
| 8 | */ | |
| 9 | ||
| 10 | import type { Reader } from "../../actions/telemetry.ts"; | |
| 11 | import { queuesFor, systemFor } from "../../system/telemetry.ts"; | |
| 12 | import { conflictedPulls, conflictedSays } from "../../flow/conflicted.ts"; | |
| 13 | import { requestedSays, requestedWaits } from "../../flow/requested.ts"; | |
| 14 | import { | |
| 15 | bottleneck, | |
| 16 | readPipeline, | |
| 17 | span, | |
| 18 | STAGE_HOLDS, | |
| 19 | STAGE_LABEL, | |
| 20 | verdict, | |
| 21 | waited, | |
| 22 | type PipelineKey, | |
| 23 | } from "../../flow/pipeline.ts"; | |
| 24 | import { | |
| 25 | hoursSpan, | |
| 26 | HOURS, | |
| 27 | itemsIn, | |
| 28 | OUTFLOW, | |
| 29 | readThroughput, | |
| 30 | stageOf, | |
| 31 | type StageThroughput, | |
| 32 | } from "../../flow/throughput.ts"; | |
| 33 | import type { QueueRow } from "../../flow/queue.ts"; | |
| 34 | import { readHeld } from "../../flow/held.ts"; | |
| 35 | import type { App } from "../../app.ts"; | |
| 36 | import { html, type Html } from "../html.ts"; | |
| 37 | import { heldCard, heldCss } from "./boards-held.ts"; | |
| 38 | import { badge, card } from "../ui.ts"; | |
| 39 | ||
| 40 | export const flowPanelCss = heldCss + /* css */ ` | |
| 41 | .flow-out { display: inline-flex; align-items: center; gap: 8px; font-variant-numeric: tabular-nums; } | |
| 42 | .flow-out svg { width: 72px; height: 16px; } | |
| 43 | .flow-bar { fill: var(--muted-fg); } | |
| 44 | .flow-bar[data-empty] { fill: var(--muted); } | |
| 45 | `; | |
| 46 | ||
| 47 | /** What `?out=` and `?hour=` ask to list, checked: an unknown stage or hour lists nothing. */ | |
| 48 | function asked(url: URL): { key: PipelineKey; hour: number | null } | null { | |
| 49 | const key = stageOf(url.searchParams.get("out") ?? ""); | |
| 50 | if (key === undefined) return null; | |
| 51 | const h = url.searchParams.get("hour"); | |
| 52 | if (h === null) return { key, hour: null }; | |
| 53 | if (!/^\d{1,2}$/.test(h) || Number(h) >= HOURS) return null; | |
| 54 | return { key, hour: Number(h) }; | |
| 55 | } | |
| 56 | ||
| 57 | const outHref = (base: string, key: PipelineKey, hour: number | null): string => | |
| 58 | `${base}?out=${OUTFLOW[key].out}${hour === null ? "" : `&hour=${hour}`}#out`; | |
| 59 | ||
| 60 | const hourWords = (hour: number | null): string => | |
| 61 | hour === null ? "in the last 24 hours" : hour === 0 ? "in the last hour" : `${hour}–${hour + 1} hours ago`; | |
| 62 | ||
| 63 | /** | |
| 64 | * 24 bars, oldest on the left: a picture only, hidden from assistive tech and | |
| 65 | * out of the tab order. The two counts beside it are the way in (#1786). | |
| 66 | */ | |
| 67 | function sparkline(t: StageThroughput): Html { | |
| 68 | const max = Math.max(1, ...t.hours); | |
| 69 | const bars = t.hours.map((n, hour) => { | |
| 70 | const x = (HOURS - 1 - hour) * 3; | |
| 71 | const h = n === 0 ? 1 : Math.max(2, Math.round((n / max) * 16)); | |
| 72 | return html`<rect class="flow-bar" ${n === 0 ? html`data-empty` : null} x="${String(x)}" y="${String(16 - h)}" width="2" height="${String(h)}" />`; | |
| 73 | }); | |
| 74 | return html`<svg viewBox="0 0 72 16" aria-hidden="true" focusable="false">${bars}</svg>`; | |
| 75 | } | |
| 76 | ||
| 77 | function outCell(base: string, t: StageThroughput): Html { | |
| 78 | const day = t.items.length; | |
| 79 | const passed = t.items.filter((i) => i.outcome === "passed").length; | |
| 80 | return html`<span class="flow-out" data-out="${OUTFLOW[t.key].out}"> | |
| 81 | <a class="mono" href="${outHref(base, t.key, 0)}" data-hour="0">${String(t.hours[0] ?? 0)}</a> | |
| 82 | ${sparkline(t)} | |
| 83 | <a class="muted small" href="${outHref(base, t.key, null)}" data-day="${String(day)}">${String(day)} in 24 h</a> | |
| 84 | </span> | |
| 85 | <div class="muted small"> | |
| 86 | ${OUTFLOW[t.key].label}${t.key === "checking" && day > 0 ? ` · ${passed} passed · ${day - passed} failed` : ""} | |
| 87 | </div>`; | |
| 88 | } | |
| 89 | ||
| 90 | /** The panel for `reader`; `base` is the page it sits on, where its counts link. */ | |
| 91 | export function flowPanel(app: App, reader: Reader, url: URL, base: string): Html { | |
| 92 | const now = Date.now(); | |
| 93 | const stages = readPipeline(app.db, new Date(now)); | |
| 94 | const worst = bottleneck(stages); | |
| 95 | const flow = readThroughput(app.db, stages.map((s) => s.key), new Date(now)); | |
| 96 | const out = new Map(flow.stages.map((t) => [t.key, t])); | |
| 97 | const landed = out.get("approved"); | |
| 98 | const dups = systemFor(app, reader).duplicates; | |
| 99 | const conflicted = conflictedSays(conflictedPulls(app.db, reader.kind === "agent"), now); | |
| 100 | const requested = requestedWaits(app.db, reader.kind === "agent"); | |
| 101 | const onBuilder = requestedSays(requested.builder, "builder", now); | |
| 102 | const onReviewer = requestedSays(requested.reviewer, "reviewer", now); | |
| 103 | const listed = asked(url); | |
| 104 | const shown = listed === null ? undefined : out.get(listed.key); | |
| 105 | return html`<p id="bottleneck" data-stage="${worst?.key ?? ""}"> | |
| 106 | <strong>${verdict(stages, now)}</strong> | |
| 107 | </p> | |
| 108 | ${conflicted === null ? "" : html`<p id="conflicted">${conflicted}</p>`} | |
| 109 | ${onBuilder === null ? "" : html`<p id="requested-builder">${onBuilder}</p>`} | |
| 110 | ${onReviewer === null ? "" : html`<p id="requested-reviewer">${onReviewer}</p>`} | |
| 111 | <p id="throughput"> | |
| 112 | landed | |
| 113 | <a href="${outHref(base, "approved", 0)}" data-landed-hour>${String(landed?.hours[0] ?? 0)}</a> | |
| 114 | in the last hour · | |
| 115 | <a href="${outHref(base, "approved", null)}" data-landed-day>${String(landed?.items.length ?? 0)}</a> | |
| 116 | in 24 h · median ask→live | |
| 117 | <span data-median>${flow.medianAskToLiveMs === null ? "—" : hoursSpan(flow.medianAskToLiveMs)}</span> | |
| 118 | (24 h) | |
| 119 | </p> | |
| 120 | <p id="duplicate-work" data-count="${String(dups.length)}" class="${dups.length === 0 ? "muted" : ""}"> | |
| 121 | ${dups.length === 0 | |
| 122 | ? html`No test file is running in more than one check.` | |
| 123 | : html`<strong>${String(dups.length)} test file${dups.length === 1 ? " is" : "s are"} running in more than one check:</strong> | |
| 124 | ${dups.slice(0, 5).map((d) => html`<span class="mono small">${d.file} (${d.checks.join(", ")})</span> `)}`} | |
| 125 | <a href="/system">System</a> | |
| 126 | </p> | |
| 127 | ${card( | |
| 128 | html`<div class="table-scroll"> | |
| 129 | <table class="table" aria-label="Pipeline stages"> | |
| 130 | <thead> | |
| 131 | <tr> | |
| 132 | <th scope="col">Stage</th> | |
| 133 | <th scope="col">Waiting</th> | |
| 134 | <th scope="col">Active</th> | |
| 135 | <th scope="col">Oldest wait</th> | |
| 136 | <th scope="col">Capacity</th> | |
| 137 | <th scope="col">Out, last hour · 24 h</th> | |
| 138 | </tr> | |
| 139 | </thead> | |
| 140 | <tbody> | |
| 141 | ${stages.map( | |
| 142 | (s) => | |
| 143 | html`<tr | |
| 144 | data-stage="${s.key}" | |
| 145 | ${s === worst ? html`data-bottleneck="true"` : null} | |
| 146 | > | |
| 147 | <th scope="row"> | |
| 148 | ${STAGE_LABEL[s.key]} | |
| 149 | ${s === worst ? badge("bottleneck", { tone: "danger" }) : null} | |
| 150 | <div class="muted small">${STAGE_HOLDS[s.key]}</div> | |
| 151 | </th> | |
| 152 | <td class="mono">${String(s.waiting)}</td> | |
| 153 | <td class="mono">${String(s.active)}</td> | |
| 154 | <td class="mono">${waited(s, now) ?? "—"}</td> | |
| 155 | <td class="small">${s.capacity ?? "—"}</td> | |
| 156 | <td>${outCell(base, out.get(s.key)!)}</td> | |
| 157 | </tr>`, | |
| 158 | )} | |
| 159 | </tbody> | |
| 160 | </table> | |
| 161 | </div>`, | |
| 162 | { head: html`<h2>Pipeline</h2><a class="muted small" href="/settings/capacity">Capacity</a>`, id: "pipeline", pad: false }, | |
| 163 | )} | |
| 164 | ${heldCard(readHeld(app, reader.kind === "owner", now))} | |
| 165 | ${queueCard(queuesFor(app, reader, new Date(now)))} | |
| 166 | ${listed === null || shown === undefined ? null : outList(shown, listed.hour, now)}`; | |
| 167 | } | |
| 168 | ||
| 169 | /** The merge queue, one row per repository with something in it or landed. */ | |
| 170 | function queueCard(rows: readonly QueueRow[]): Html { | |
| 171 | return card( | |
| 172 | html`<h2>Merge queue</h2> | |
| 173 | <p class="muted small"> | |
| 174 | Green, approved pulls join their repository's queue; each candidate | |
| 175 | is gated once and lands whole, or is bisected to the red one. | |
| 176 | </p> | |
| 177 | ${rows.length === 0 | |
| 178 | ? html`<p class="muted">Nothing queued, gating or landed in the last hour.</p>` | |
| 179 | : html`<div class="table-scroll"> | |
| 180 | <table class="table" aria-label="Merge queue"> | |
| 181 | <thead> | |
| 182 | <tr> | |
| 183 | <th scope="col">Repository</th> | |
| 184 | <th scope="col">Queued</th> | |
| 185 | <th scope="col">Gating</th> | |
| 186 | <th scope="col">Landed, last hour</th> | |
| 187 | </tr> | |
| 188 | </thead> | |
| 189 | <tbody> | |
| 190 | ${rows.map( | |
| 191 | (r) => | |
| 192 | html`<tr data-queue="${r.repo}"> | |
| 193 | <th scope="row"><a href="/${r.repo}/pulls">${r.repo}</a></th> | |
| 194 | <td class="mono" data-queued>${String(r.queued)}</td> | |
| 195 | <td class="mono" data-gating>${String(r.gating)}</td> | |
| 196 | <td class="mono" data-landed>${String(r.landedHour)}</td> | |
| 197 | </tr>`, | |
| 198 | )} | |
| 199 | </tbody> | |
| 200 | </table> | |
| 201 | </div>`}`, | |
| 202 | { id: "queue" }, | |
| 203 | ); | |
| 204 | } | |
| 205 | ||
| 206 | /** The items one count counted, newest first, each linked to where it lives. */ | |
| 207 | function outList(t: StageThroughput, hour: number | null, now: number): Html { | |
| 208 | const items = itemsIn(t, hour, now); | |
| 209 | return card( | |
| 210 | html`<h2>${STAGE_LABEL[t.key]}: ${String(items.length)} ${OUTFLOW[t.key].label} ${hourWords(hour)}</h2> | |
| 211 | ${items.length === 0 | |
| 212 | ? html`<p class="muted">None.</p>` | |
| 213 | : html`<ul> | |
| 214 | ${items.map( | |
| 215 | (i) => | |
| 216 | html`<li> | |
| 217 | <a href="${i.href}">${i.label}</a> | |
| 218 | ${i.outcome === null ? null : badge(i.outcome, { tone: i.outcome === "passed" ? "success" : "danger" })} | |
| 219 | <span class="muted small">${span(now - Date.parse(i.at))} ago</span> | |
| 220 | </li>`, | |
| 221 | )} | |
| 222 | </ul>`}`, | |
| 223 | { id: "out" }, | |
| 224 | ); | |
| 225 | } |
src/web/pages/flow.ts +4 −211
| @@ -29,86 +29,11 @@ | ||
| 29 | 29 | */ |
| 30 | 30 | |
| 31 | 31 | import { noteRead, telemetryReader } from "../../actions/telemetry.ts"; |
| 32 | import { queuesFor, systemFor } from "../../system/telemetry.ts"; | |
| 33 | import { conflictedPulls, conflictedSays } from "../../flow/conflicted.ts"; | |
| 34 | import { requestedSays, requestedWaits } from "../../flow/requested.ts"; | |
| 35 | import { | |
| 36 | bottleneck, | |
| 37 | readPipeline, | |
| 38 | span, | |
| 39 | STAGE_HOLDS, | |
| 40 | STAGE_LABEL, | |
| 41 | verdict, | |
| 42 | waited, | |
| 43 | type PipelineKey, | |
| 44 | } from "../../flow/pipeline.ts"; | |
| 45 | import { | |
| 46 | hoursSpan, | |
| 47 | HOURS, | |
| 48 | itemsIn, | |
| 49 | OUTFLOW, | |
| 50 | readThroughput, | |
| 51 | stageOf, | |
| 52 | type StageThroughput, | |
| 53 | } from "../../flow/throughput.ts"; | |
| 54 | import type { QueueRow } from "../../flow/queue.ts"; | |
| 55 | import { readHeld } from "../../flow/held.ts"; | |
| 56 | 32 | import type { Ctx } from "../../http/context.ts"; |
| 57 | 33 | import { notFoundPage, signInFirst } from "../chrome.ts"; |
| 58 | import { html, type Html } from "../html.ts"; | |
| 34 | import { html } from "../html.ts"; | |
| 59 | 35 | import { page } from "../layout.ts"; |
| 60 | import { heldCard, heldCss } from "./boards-held.ts"; | |
| 61 | import { badge, card } from "../ui.ts"; | |
| 62 | ||
| 63 | const css = /* css */ ` | |
| 64 | .flow-out { display: inline-flex; align-items: center; gap: 8px; font-variant-numeric: tabular-nums; } | |
| 65 | .flow-out svg { width: 72px; height: 16px; } | |
| 66 | .flow-bar { fill: var(--muted-fg); } | |
| 67 | .flow-bar[data-empty] { fill: var(--muted); } | |
| 68 | `; | |
| 69 | ||
| 70 | /** What `?out=` and `?hour=` ask to list, checked: an unknown stage or hour lists nothing. */ | |
| 71 | function asked(url: URL): { key: PipelineKey; hour: number | null } | null { | |
| 72 | const key = stageOf(url.searchParams.get("out") ?? ""); | |
| 73 | if (key === undefined) return null; | |
| 74 | const h = url.searchParams.get("hour"); | |
| 75 | if (h === null) return { key, hour: null }; | |
| 76 | if (!/^\d{1,2}$/.test(h) || Number(h) >= HOURS) return null; | |
| 77 | return { key, hour: Number(h) }; | |
| 78 | } | |
| 79 | ||
| 80 | const outHref = (key: PipelineKey, hour: number | null): string => | |
| 81 | `/flow?out=${OUTFLOW[key].out}${hour === null ? "" : `&hour=${hour}`}#out`; | |
| 82 | ||
| 83 | const hourWords = (hour: number | null): string => | |
| 84 | hour === null ? "in the last 24 hours" : hour === 0 ? "in the last hour" : `${hour}–${hour + 1} hours ago`; | |
| 85 | ||
| 86 | /** | |
| 87 | * 24 bars, oldest on the left: a picture only, hidden from assistive tech and | |
| 88 | * out of the tab order. The two counts beside it are the way in (#1786). | |
| 89 | */ | |
| 90 | function sparkline(t: StageThroughput): Html { | |
| 91 | const max = Math.max(1, ...t.hours); | |
| 92 | const bars = t.hours.map((n, hour) => { | |
| 93 | const x = (HOURS - 1 - hour) * 3; | |
| 94 | const h = n === 0 ? 1 : Math.max(2, Math.round((n / max) * 16)); | |
| 95 | return html`<rect class="flow-bar" ${n === 0 ? html`data-empty` : null} x="${String(x)}" y="${String(16 - h)}" width="2" height="${String(h)}" />`; | |
| 96 | }); | |
| 97 | return html`<svg viewBox="0 0 72 16" aria-hidden="true" focusable="false">${bars}</svg>`; | |
| 98 | } | |
| 99 | ||
| 100 | function outCell(t: StageThroughput): Html { | |
| 101 | const day = t.items.length; | |
| 102 | const passed = t.items.filter((i) => i.outcome === "passed").length; | |
| 103 | return html`<span class="flow-out" data-out="${OUTFLOW[t.key].out}"> | |
| 104 | <a class="mono" href="${outHref(t.key, 0)}" data-hour="0">${String(t.hours[0] ?? 0)}</a> | |
| 105 | ${sparkline(t)} | |
| 106 | <a class="muted small" href="${outHref(t.key, null)}" data-day="${String(day)}">${String(day)} in 24 h</a> | |
| 107 | </span> | |
| 108 | <div class="muted small"> | |
| 109 | ${OUTFLOW[t.key].label}${t.key === "checking" && day > 0 ? ` · ${passed} passed · ${day - passed} failed` : ""} | |
| 110 | </div>`; | |
| 111 | } | |
| 36 | import { flowPanel, flowPanelCss } from "./flow-panel.ts"; | |
| 112 | 37 | |
| 113 | 38 | // GET /flow |
| 114 | 39 | export function flowPage(ctx: Ctx): Response { |
| @@ -116,19 +41,6 @@ export function flowPage(ctx: Ctx): Response { | ||
| 116 | 41 | const reader = telemetryReader(ctx.app, ctx.auth.user); |
| 117 | 42 | if (!reader) return notFoundPage(ctx); |
| 118 | 43 | noteRead(ctx.app, ctx.auth.user, reader, "flow"); |
| 119 | const now = Date.now(); | |
| 120 | const stages = readPipeline(ctx.app.db, new Date(now)); | |
| 121 | const worst = bottleneck(stages); | |
| 122 | const flow = readThroughput(ctx.app.db, stages.map((s) => s.key), new Date(now)); | |
| 123 | const out = new Map(flow.stages.map((t) => [t.key, t])); | |
| 124 | const landed = out.get("approved"); | |
| 125 | const dups = systemFor(ctx.app, reader).duplicates; | |
| 126 | const conflicted = conflictedSays(conflictedPulls(ctx.app.db, reader.kind === "agent"), now); | |
| 127 | const requested = requestedWaits(ctx.app.db, reader.kind === "agent"); | |
| 128 | const onBuilder = requestedSays(requested.builder, "builder", now); | |
| 129 | const onReviewer = requestedSays(requested.reviewer, "reviewer", now); | |
| 130 | const listed = asked(ctx.url); | |
| 131 | const shown = listed === null ? undefined : out.get(listed.key); | |
| 132 | 44 | const body = html`<div class="page-head"> |
| 133 | 45 | <h1>Flow</h1> |
| 134 | 46 | <p class="muted"> |
| @@ -140,125 +52,6 @@ export function flowPage(ctx: Ctx): Response { | ||
| 140 | 52 | help is on <a href="/contribute">Contribute</a>. |
| 141 | 53 | </p> |
| 142 | 54 | </div> |
| 143 | <p id="bottleneck" data-stage="${worst?.key ?? ""}"> | |
| 144 | <strong>${verdict(stages, now)}</strong> | |
| 145 | </p> | |
| 146 | ${conflicted === null ? "" : html`<p id="conflicted">${conflicted}</p>`} | |
| 147 | ${onBuilder === null ? "" : html`<p id="requested-builder">${onBuilder}</p>`} | |
| 148 | ${onReviewer === null ? "" : html`<p id="requested-reviewer">${onReviewer}</p>`} | |
| 149 | <p id="throughput"> | |
| 150 | landed | |
| 151 | <a href="${outHref("approved", 0)}" data-landed-hour>${String(landed?.hours[0] ?? 0)}</a> | |
| 152 | in the last hour · | |
| 153 | <a href="${outHref("approved", null)}" data-landed-day>${String(landed?.items.length ?? 0)}</a> | |
| 154 | in 24 h · median ask→live | |
| 155 | <span data-median>${flow.medianAskToLiveMs === null ? "—" : hoursSpan(flow.medianAskToLiveMs)}</span> | |
| 156 | (24 h) | |
| 157 | </p> | |
| 158 | <p id="duplicate-work" data-count="${String(dups.length)}" class="${dups.length === 0 ? "muted" : ""}"> | |
| 159 | ${dups.length === 0 | |
| 160 | ? html`No test file is running in more than one check.` | |
| 161 | : html`<strong>${String(dups.length)} test file${dups.length === 1 ? " is" : "s are"} running in more than one check:</strong> | |
| 162 | ${dups.slice(0, 5).map((d) => html`<span class="mono small">${d.file} (${d.checks.join(", ")})</span> `)}`} | |
| 163 | <a href="/system">System</a> | |
| 164 | </p> | |
| 165 | ${card( | |
| 166 | html`<div class="table-scroll"> | |
| 167 | <table class="table" aria-label="Pipeline stages"> | |
| 168 | <thead> | |
| 169 | <tr> | |
| 170 | <th scope="col">Stage</th> | |
| 171 | <th scope="col">Waiting</th> | |
| 172 | <th scope="col">Active</th> | |
| 173 | <th scope="col">Oldest wait</th> | |
| 174 | <th scope="col">Capacity</th> | |
| 175 | <th scope="col">Out, last hour · 24 h</th> | |
| 176 | </tr> | |
| 177 | </thead> | |
| 178 | <tbody> | |
| 179 | ${stages.map( | |
| 180 | (s) => | |
| 181 | html`<tr | |
| 182 | data-stage="${s.key}" | |
| 183 | ${s === worst ? html`data-bottleneck="true"` : null} | |
| 184 | > | |
| 185 | <th scope="row"> | |
| 186 | ${STAGE_LABEL[s.key]} | |
| 187 | ${s === worst ? badge("bottleneck", { tone: "danger" }) : null} | |
| 188 | <div class="muted small">${STAGE_HOLDS[s.key]}</div> | |
| 189 | </th> | |
| 190 | <td class="mono">${String(s.waiting)}</td> | |
| 191 | <td class="mono">${String(s.active)}</td> | |
| 192 | <td class="mono">${waited(s, now) ?? "—"}</td> | |
| 193 | <td class="small">${s.capacity ?? "—"}</td> | |
| 194 | <td>${outCell(out.get(s.key)!)}</td> | |
| 195 | </tr>`, | |
| 196 | )} | |
| 197 | </tbody> | |
| 198 | </table> | |
| 199 | </div>`, | |
| 200 | { pad: false }, | |
| 201 | )} | |
| 202 | ${heldCard(readHeld(ctx.app, reader.kind === "owner", now))} | |
| 203 | ${queueCard(queuesFor(ctx.app, reader, new Date(now)))} | |
| 204 | ${listed === null || shown === undefined ? null : outList(shown, listed.hour, now)}`; | |
| 205 | return page({ ctx, title: "Flow", body, styleCss: css + heldCss }); | |
| 206 | } | |
| 207 | ||
| 208 | /** The merge queue, one row per repository with something in it or landed. */ | |
| 209 | function queueCard(rows: readonly QueueRow[]): Html { | |
| 210 | return card( | |
| 211 | html`<h2>Merge queue</h2> | |
| 212 | <p class="muted small"> | |
| 213 | Green, approved pulls join their repository's queue; each candidate | |
| 214 | is gated once and lands whole, or is bisected to the red one. | |
| 215 | </p> | |
| 216 | ${rows.length === 0 | |
| 217 | ? html`<p class="muted">Nothing queued, gating or landed in the last hour.</p>` | |
| 218 | : html`<div class="table-scroll"> | |
| 219 | <table class="table" aria-label="Merge queue"> | |
| 220 | <thead> | |
| 221 | <tr> | |
| 222 | <th scope="col">Repository</th> | |
| 223 | <th scope="col">Queued</th> | |
| 224 | <th scope="col">Gating</th> | |
| 225 | <th scope="col">Landed, last hour</th> | |
| 226 | </tr> | |
| 227 | </thead> | |
| 228 | <tbody> | |
| 229 | ${rows.map( | |
| 230 | (r) => | |
| 231 | html`<tr data-queue="${r.repo}"> | |
| 232 | <th scope="row"><a href="/${r.repo}/pulls">${r.repo}</a></th> | |
| 233 | <td class="mono" data-queued>${String(r.queued)}</td> | |
| 234 | <td class="mono" data-gating>${String(r.gating)}</td> | |
| 235 | <td class="mono" data-landed>${String(r.landedHour)}</td> | |
| 236 | </tr>`, | |
| 237 | )} | |
| 238 | </tbody> | |
| 239 | </table> | |
| 240 | </div>`}`, | |
| 241 | { id: "queue" }, | |
| 242 | ); | |
| 243 | } | |
| 244 | ||
| 245 | /** The items one count counted, newest first, each linked to where it lives. */ | |
| 246 | function outList(t: StageThroughput, hour: number | null, now: number): Html { | |
| 247 | const items = itemsIn(t, hour, now); | |
| 248 | return card( | |
| 249 | html`<h2>${STAGE_LABEL[t.key]}: ${String(items.length)} ${OUTFLOW[t.key].label} ${hourWords(hour)}</h2> | |
| 250 | ${items.length === 0 | |
| 251 | ? html`<p class="muted">None.</p>` | |
| 252 | : html`<ul> | |
| 253 | ${items.map( | |
| 254 | (i) => | |
| 255 | html`<li> | |
| 256 | <a href="${i.href}">${i.label}</a> | |
| 257 | ${i.outcome === null ? null : badge(i.outcome, { tone: i.outcome === "passed" ? "success" : "danger" })} | |
| 258 | <span class="muted small">${span(now - Date.parse(i.at))} ago</span> | |
| 259 | </li>`, | |
| 260 | )} | |
| 261 | </ul>`}`, | |
| 262 | { id: "out" }, | |
| 263 | ); | |
| 55 | ${flowPanel(ctx.app, reader, ctx.url, "/flow")}`; | |
| 56 | return page({ ctx, title: "Flow", body, styleCss: flowPanelCss }); | |
| 264 | 57 | } |
test/conflict_sweep.test.ts +2 −2
| @@ -31,7 +31,7 @@ let alice: User; | ||
| 31 | 31 | let repo: Repo; |
| 32 | 32 | const fresh = () => getRepoById(app.db, repo.id)!; |
| 33 | 33 | |
| 34 | /** /flow as the admin, and the MCP flow tool's `conflicted` field. */ | |
| 34 | /** /boards as the admin, and the MCP flow tool's `conflicted` field. */ | |
| 35 | 35 | async function flowDoors() { |
| 36 | 36 | const signin = await fetch(`${servers.url}/signin`, { |
| 37 | 37 | method: "POST", |
| @@ -43,7 +43,7 @@ async function flowDoors() { | ||
| 43 | 43 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), |
| 44 | 44 | }); |
| 45 | 45 | const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; |
| 46 | const page = await (await fetch(`${servers.url}/flow`, { headers: { cookie } })).text(); | |
| 46 | const page = await (await fetch(`${servers.url}/boards`, { headers: { cookie } })).text(); | |
| 47 | 47 | const res = await fetch(`${servers.url}/mcp`, { |
| 48 | 48 | method: "POST", |
| 49 | 49 | headers: { |
test/crew-held.test.ts +2 −2
| @@ -330,7 +330,7 @@ describe("the tick says why each ready issue cannot start", () => { | ||
| 330 | 330 | expect(getIssueById(app.db, direction.id)!.assigneeId).toBeNull(); |
| 331 | 331 | }); |
| 332 | 332 | |
| 333 | test("three doors: /flow, /boards and the terminal's Flow dialog show the counts, private ones to the owner only", async () => { | |
| 333 | test("three doors: /boards, /flow and the terminal's Flow dialog show the counts, private ones to the owner only", async () => { | |
| 334 | 334 | const open = await crewed(app, alice, "open-door", "public"); |
| 335 | 335 | const shut = await crewed(app, alice, "shut-door"); |
| 336 | 336 | for (const { repo, mason } of [open, shut]) { |
| @@ -348,7 +348,7 @@ describe("the tick says why each ready issue cannot start", () => { | ||
| 348 | 348 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), |
| 349 | 349 | }); |
| 350 | 350 | const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; |
| 351 | const res = await fetch(`${servers.url}/flow`, { headers: { cookie } }); | |
| 351 | const res = await fetch(`${servers.url}/boards`, { headers: { cookie } }); | |
| 352 | 352 | expect(res.status).toBe(200); |
| 353 | 353 | const page = await res.text(); |
| 354 | 354 | expect(page).toContain('id="held"'); |
test/flow-pipeline.test.ts +1 −1
| @@ -239,7 +239,7 @@ describe("/flow: the forge shows its own bottleneck", () => { | ||
| 239 | 239 | |
| 240 | 240 | test("an admin reads the stages as rows under the one sentence, linked from the header", async () => { |
| 241 | 241 | const cookie = await login("alice"); |
| 242 | const res = await get("/flow", cookie); | |
| 242 | const res = await get("/boards", cookie); | |
| 243 | 243 | expect(res.status).toBe(200); |
| 244 | 244 | const page = await res.text(); |
| 245 | 245 | expect(page).toContain('id="bottleneck" data-stage="check-queue"'); |
test/flow-throughput.test.ts +9 −9
| @@ -176,7 +176,7 @@ describe("/flow: actual throughput per stage", () => { | ||
| 176 | 176 | }); |
| 177 | 177 | |
| 178 | 178 | test("the page says it in the headline and beside each stage, and every count links to its items", async () => { |
| 179 | const res = await get("/flow"); | |
| 179 | const res = await get("/boards"); | |
| 180 | 180 | expect(res.status).toBe(200); |
| 181 | 181 | const page = await res.text(); |
| 182 | 182 | const headline = page.match(/<p id="throughput">([\s\S]*?)<\/p>/)?.[1] ?? ""; |
| @@ -186,15 +186,15 @@ describe("/flow: actual throughput per stage", () => { | ||
| 186 | 186 | expect(throughputSays(readThroughput(app.db, PIPELINE, new Date(NOW)))).toBe( |
| 187 | 187 | "landed 2 in the last hour · 6 in 24 h · median ask→live 3.5 h (24 h)", |
| 188 | 188 | ); |
| 189 | expect(headline).toContain('href="/flow?out=landed&hour=0#out"'); | |
| 190 | expect(page).toMatch(/data-out="landed">\s*<a class="mono" href="\/flow\?out=landed&hour=0#out" data-hour="0">2<\/a>/); | |
| 189 | expect(headline).toContain('href="/boards?out=landed&hour=0#out"'); | |
| 190 | expect(page).toMatch(/data-out="landed">\s*<a class="mono" href="\/boards\?out=landed&hour=0#out" data-hour="0">2<\/a>/); | |
| 191 | 191 | expect(page).toContain('data-day="6">6 in 24 h'); |
| 192 | 192 | expect(page).toContain("2 passed · 1 failed"); |
| 193 | 193 | expect((page.match(/class="flow-bar"/g) ?? []).length).toBe(PIPELINE.length * 24); |
| 194 | 194 | }); |
| 195 | 195 | |
| 196 | 196 | test("each sparkline is a picture only: hidden from assistive tech, no links or tab stops of its own", async () => { |
| 197 | const page = await (await get("/flow")).text(); | |
| 197 | const page = await (await get("/boards")).text(); | |
| 198 | 198 | const svgs = page.match(/<svg[\s\S]*?<\/svg>/g) ?? []; |
| 199 | 199 | const sparks = svgs.filter((s) => s.includes('class="flow-bar"')); |
| 200 | 200 | expect(sparks).toHaveLength(PIPELINE.length); |
| @@ -208,8 +208,8 @@ describe("/flow: actual throughput per stage", () => { | ||
| 208 | 208 | for (const key of PIPELINE) { |
| 209 | 209 | const out = { ready: "started", building: "opened", "check-queue": "check-started", checking: "checked", |
| 210 | 210 | review: "reviewed", approved: "landed", landed: "released", live: "live" }[key]; |
| 211 | expect(page).toContain(`href="/flow?out=${out}&hour=0#out" data-hour="0"`); | |
| 212 | expect(page).toMatch(new RegExp(`href="/flow\\?out=${out}#out" data-day=`)); | |
| 211 | expect(page).toContain(`href="/boards?out=${out}&hour=0#out" data-hour="0"`); | |
| 212 | expect(page).toMatch(new RegExp(`href="/boards\\?out=${out}#out" data-day=`)); | |
| 213 | 213 | } |
| 214 | 214 | }); |
| 215 | 215 | |
| @@ -268,15 +268,15 @@ describe("/flow: actual throughput per stage", () => { | ||
| 268 | 268 | }); |
| 269 | 269 | |
| 270 | 270 | test("following a count lists the items it counted; a bad ask lists nothing", async () => { |
| 271 | const hour = await (await get("/flow?out=landed&hour=0")).text(); | |
| 271 | const hour = await (await get("/boards?out=landed&hour=0")).text(); | |
| 272 | 272 | expect(hour).toContain('id="out"'); |
| 273 | 273 | expect(hour).toContain("2 landed in the last hour"); |
| 274 | 274 | expect(hour).toContain('href="/alice/shop/pulls/2"'); |
| 275 | 275 | expect(hour).toContain('href="/alice/shop/pulls/4"'); |
| 276 | 276 | expect(hour).not.toContain('href="/alice/shop/pulls/6"'); |
| 277 | const day = await (await get("/flow?out=landed")).text(); | |
| 277 | const day = await (await get("/boards?out=landed")).text(); | |
| 278 | 278 | expect(day).toContain("6 landed in the last 24 hours"); |
| 279 | 279 | for (const bad of ["?out=nope", "?out=landed&hour=24", "?out=landed&hour=-1", "?out=landed&hour=1e1"]) |
| 280 | expect(await (await get(`/flow${bad}`)).text()).not.toContain('id="out"'); | |
| 280 | expect(await (await get(`/boards${bad}`)).text()).not.toContain('id="out"'); | |
| 281 | 281 | }); |
| 282 | 282 | }); |
test/merge-queue.test.ts +2 −2
| @@ -288,7 +288,7 @@ describe("the merge queue", () => { | ||
| 288 | 288 | expect(released).toEqual([{ pull: pulls[1]!.number, tip: `tip-${pulls[1]!.number}` }]); |
| 289 | 289 | }); |
| 290 | 290 | |
| 291 | test("the queue shows through every door: /flow, the terminal and MCP, while gating and after", async () => { | |
| 291 | test("the queue shows through every door: /boards, the terminal and MCP, while gating and after", async () => { | |
| 292 | 292 | const repo = await autonomousRepo("on-flow"); |
| 293 | 293 | const first = await greenPull(repo, "first.ts"); |
| 294 | 294 | const second = await greenPull(repo, "second.ts"); |
| @@ -302,7 +302,7 @@ describe("the merge queue", () => { | ||
| 302 | 302 | while (!queueStatus().some((q) => q.repoId === repo.id && q.queued === 2)) await Bun.sleep(5); |
| 303 | 303 | const row = readQueues(app.db, new Date()).find((r) => r.repo === `alice/${repo.name}`); |
| 304 | 304 | expect(row).toMatchObject({ queued: 2, gating: 1 }); |
| 305 | const res = await fetch(`${servers.url}/flow`, { | |
| 305 | const res = await fetch(`${servers.url}/boards`, { | |
| 306 | 306 | headers: { cookie: await login() }, |
| 307 | 307 | redirect: "manual", |
| 308 | 308 | }); |
test/requested.test.ts +2 −2
| @@ -236,7 +236,7 @@ describe("a pull with changes requested always moves", () => { | ||
| 236 | 236 | app.db.query("UPDATE issue SET state = 'closed' WHERE id = ?1").run(pull.id); |
| 237 | 237 | }); |
| 238 | 238 | |
| 239 | test("both shapes move within one tick, and /flow tells them apart", async () => { | |
| 239 | test("both shapes move within one tick, and /boards tells them apart", async () => { | |
| 240 | 240 | // Every earlier pull is settled: only these two are in play. |
| 241 | 241 | app.db.query("UPDATE issue SET state = 'closed' WHERE repo_id = ?1 AND kind = 'pull'").run(repo.id); |
| 242 | 242 | const stale = await staleShape(); |
| @@ -259,7 +259,7 @@ describe("a pull with changes requested always moves", () => { | ||
| 259 | 259 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), |
| 260 | 260 | }); |
| 261 | 261 | const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; |
| 262 | const page = await (await fetch(`${servers.url}/flow`, { headers: { cookie } })).text(); | |
| 262 | const page = await (await fetch(`${servers.url}/boards`, { headers: { cookie } })).text(); | |
| 263 | 263 | expect(page).toContain(`id="requested-builder"`); |
| 264 | 264 | expect(page).toContain(`waits on a reviewer, oldest first: alice/garden#${stale.number} `); |
| 265 | 265 |
test/system-telemetry.test.ts +1 −1
| @@ -307,7 +307,7 @@ describe("telemetry:read: the owner grants it, an agent reads with it, nobody el | ||
| 307 | 307 | expect(html).toMatch(/id="summary"[^>]*>\s*<span class="pill"/); |
| 308 | 308 | expect(html).toContain('<tbody data-kind="check"'); |
| 309 | 309 | expect(html).not.toMatch(/<[a-z]+ [^>]*\bstyle="/); |
| 310 | const flowPage = await fetch(`${servers.url}/flow`, { headers: { cookie: await login("alice") } }); | |
| 310 | const flowPage = await fetch(`${servers.url}/boards`, { headers: { cookie: await login("alice") } }); | |
| 311 | 311 | expect(await flowPage.text()).toContain('id="duplicate-work" data-count="1"'); |
| 312 | 312 | |
| 313 | 313 | const reads = recentReads(app.db).filter((r) => r.username === "muse"); |
new above
offline