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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/2807-tests-that-read-the-held-panel-and-merge → main 1 commit fork

  • Merged. scout/2807-tests-that-read-the-held-panel-and-merge was merged into main as 88b20f8.

Review

Sign in to review this change.
11 files changed +257 −285
src/web/pages/boards-held.ts +6 −52
@@ -1,17 +1,14 @@
11 /**
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.
76 */
87
9 import type { App } from "../../app.ts";
108 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";
1310 import { html, type Html } from "../html.ts";
14 import { badge, card } from "../ui.ts";
11 import { card } from "../ui.ts";
1512
1613 export const heldCss = /* css */ `
1714 .held-bar { width: 100%; height: 12px; display: block; }
@@ -81,46 +78,3 @@ export function heldCard(rows: readonly HeldRow[]): Html {
8178 { id: "held" },
8279 );
8380 }
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";
1717 import { html, raw, when, type Html } from "../html.ts";
1818 import { fragment, page } from "../layout.ts";
1919 import { btn, card, empty } from "../ui.ts";
20 import { heldCss, pipelinePanel } from "./boards-held.ts";
20 import { flowPanel, flowPanelCss } from "./flow-panel.ts";
2121 import { cellsOf, chartBox, paramsOf, queryFields, readParams, runFor } from "./boards.ts";
2222
2323 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> {
9494 title: "Boards",
9595 body: html`<div class="page-head"><h1>Boards</h1>
9696 ${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,
9999 });
100100 }
101101
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 @@
2929 */
3030
3131 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";
5632 import type { Ctx } from "../../http/context.ts";
5733 import { notFoundPage, signInFirst } from "../chrome.ts";
58 import { html, type Html } from "../html.ts";
34 import { html } from "../html.ts";
5935 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";
11237
11338 // GET /flow
11439 export function flowPage(ctx: Ctx): Response {
@@ -116,19 +41,6 @@ export function flowPage(ctx: Ctx): Response {
11641 const reader = telemetryReader(ctx.app, ctx.auth.user);
11742 if (!reader) return notFoundPage(ctx);
11843 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);
13244 const body = html`<div class="page-head">
13345 <h1>Flow</h1>
13446 <p class="muted">
@@ -140,125 +52,6 @@ export function flowPage(ctx: Ctx): Response {
14052 help is on <a href="/contribute">Contribute</a>.
14153 </p>
14254 </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 });
26457 }
test/conflict_sweep.test.ts +2 −2
@@ -31,7 +31,7 @@ let alice: User;
3131 let repo: Repo;
3232 const fresh = () => getRepoById(app.db, repo.id)!;
3333
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. */
3535 async function flowDoors() {
3636 const signin = await fetch(`${servers.url}/signin`, {
3737 method: "POST",
@@ -43,7 +43,7 @@ async function flowDoors() {
4343 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
4444 });
4545 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();
4747 const res = await fetch(`${servers.url}/mcp`, {
4848 method: "POST",
4949 headers: {
test/crew-held.test.ts +2 −2
@@ -330,7 +330,7 @@ describe("the tick says why each ready issue cannot start", () => {
330330 expect(getIssueById(app.db, direction.id)!.assigneeId).toBeNull();
331331 });
332332
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 () => {
334334 const open = await crewed(app, alice, "open-door", "public");
335335 const shut = await crewed(app, alice, "shut-door");
336336 for (const { repo, mason } of [open, shut]) {
@@ -348,7 +348,7 @@ describe("the tick says why each ready issue cannot start", () => {
348348 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
349349 });
350350 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 } });
352352 expect(res.status).toBe(200);
353353 const page = await res.text();
354354 expect(page).toContain('id="held"');
test/flow-pipeline.test.ts +1 −1
@@ -239,7 +239,7 @@ describe("/flow: the forge shows its own bottleneck", () => {
239239
240240 test("an admin reads the stages as rows under the one sentence, linked from the header", async () => {
241241 const cookie = await login("alice");
242 const res = await get("/flow", cookie);
242 const res = await get("/boards", cookie);
243243 expect(res.status).toBe(200);
244244 const page = await res.text();
245245 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", () => {
176176 });
177177
178178 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");
180180 expect(res.status).toBe(200);
181181 const page = await res.text();
182182 const headline = page.match(/<p id="throughput">([\s\S]*?)<\/p>/)?.[1] ?? "";
@@ -186,15 +186,15 @@ describe("/flow: actual throughput per stage", () => {
186186 expect(throughputSays(readThroughput(app.db, PIPELINE, new Date(NOW)))).toBe(
187187 "landed 2 in the last hour · 6 in 24 h · median ask→live 3.5 h (24 h)",
188188 );
189 expect(headline).toContain('href="/flow?out=landed&amp;hour=0#out"');
190 expect(page).toMatch(/data-out="landed">\s*<a class="mono" href="\/flow\?out=landed&amp;hour=0#out" data-hour="0">2<\/a>/);
189 expect(headline).toContain('href="/boards?out=landed&amp;hour=0#out"');
190 expect(page).toMatch(/data-out="landed">\s*<a class="mono" href="\/boards\?out=landed&amp;hour=0#out" data-hour="0">2<\/a>/);
191191 expect(page).toContain('data-day="6">6 in 24 h');
192192 expect(page).toContain("2 passed · 1 failed");
193193 expect((page.match(/class="flow-bar"/g) ?? []).length).toBe(PIPELINE.length * 24);
194194 });
195195
196196 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();
198198 const svgs = page.match(/<svg[\s\S]*?<\/svg>/g) ?? [];
199199 const sparks = svgs.filter((s) => s.includes('class="flow-bar"'));
200200 expect(sparks).toHaveLength(PIPELINE.length);
@@ -208,8 +208,8 @@ describe("/flow: actual throughput per stage", () => {
208208 for (const key of PIPELINE) {
209209 const out = { ready: "started", building: "opened", "check-queue": "check-started", checking: "checked",
210210 review: "reviewed", approved: "landed", landed: "released", live: "live" }[key];
211 expect(page).toContain(`href="/flow?out=${out}&amp;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}&amp;hour=0#out" data-hour="0"`);
212 expect(page).toMatch(new RegExp(`href="/boards\\?out=${out}#out" data-day=`));
213213 }
214214 });
215215
@@ -268,15 +268,15 @@ describe("/flow: actual throughput per stage", () => {
268268 });
269269
270270 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();
272272 expect(hour).toContain('id="out"');
273273 expect(hour).toContain("2 landed in the last hour");
274274 expect(hour).toContain('href="/alice/shop/pulls/2"');
275275 expect(hour).toContain('href="/alice/shop/pulls/4"');
276276 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();
278278 expect(day).toContain("6 landed in the last 24 hours");
279279 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"');
281281 });
282282 });
test/merge-queue.test.ts +2 −2
@@ -288,7 +288,7 @@ describe("the merge queue", () => {
288288 expect(released).toEqual([{ pull: pulls[1]!.number, tip: `tip-${pulls[1]!.number}` }]);
289289 });
290290
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 () => {
292292 const repo = await autonomousRepo("on-flow");
293293 const first = await greenPull(repo, "first.ts");
294294 const second = await greenPull(repo, "second.ts");
@@ -302,7 +302,7 @@ describe("the merge queue", () => {
302302 while (!queueStatus().some((q) => q.repoId === repo.id && q.queued === 2)) await Bun.sleep(5);
303303 const row = readQueues(app.db, new Date()).find((r) => r.repo === `alice/${repo.name}`);
304304 expect(row).toMatchObject({ queued: 2, gating: 1 });
305 const res = await fetch(`${servers.url}/flow`, {
305 const res = await fetch(`${servers.url}/boards`, {
306306 headers: { cookie: await login() },
307307 redirect: "manual",
308308 });
test/requested.test.ts +2 −2
@@ -236,7 +236,7 @@ describe("a pull with changes requested always moves", () => {
236236 app.db.query("UPDATE issue SET state = 'closed' WHERE id = ?1").run(pull.id);
237237 });
238238
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 () => {
240240 // Every earlier pull is settled: only these two are in play.
241241 app.db.query("UPDATE issue SET state = 'closed' WHERE repo_id = ?1 AND kind = 'pull'").run(repo.id);
242242 const stale = await staleShape();
@@ -259,7 +259,7 @@ describe("a pull with changes requested always moves", () => {
259259 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
260260 });
261261 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();
263263 expect(page).toContain(`id="requested-builder"`);
264264 expect(page).toContain(`waits on a reviewer, oldest first: alice/garden#${stale.number} `);
265265
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
307307 expect(html).toMatch(/id="summary"[^>]*>\s*<span class="pill"/);
308308 expect(html).toContain('<tbody data-kind="check"');
309309 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") } });
311311 expect(await flowPage.text()).toContain('id="duplicate-work" data-count="1"');
312312
313313 const reads = recentReads(app.db).filter((r) => r.username === "muse");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs