swarm-ui: a builds page for the swarm builder's runs, and the status links to it
/builds lists the builder's recent builds newest first, one card each with how it ended, and opens a failed build's nix tail. /builds?run=<id> opens that build's card and scrolls to it. The swarm-builder commit status now links there instead of to the raw JSON record.
This commit is contained in:
parent
3a7272bdfb
commit
0e77d61a9f
9 changed files with 391 additions and 13 deletions
|
|
@ -5,6 +5,7 @@ import { Shell } from "./shell/Shell.js";
|
|||
import { AgentsPage } from "./pages/agents/AgentsPage.js";
|
||||
import { AgentTerminalPage } from "./pages/agents/AgentTerminalPage.js";
|
||||
import { AlertsPage } from "./pages/AlertsPage.js";
|
||||
import { BuildsPage } from "./pages/builds/BuildsPage.js";
|
||||
import { ComponentsPage } from "./pages/ComponentsPage.js";
|
||||
import { JobsPage } from "./pages/JobsPage.js";
|
||||
import { HivesPage } from "./pages/HivesPage.js";
|
||||
|
|
@ -37,6 +38,7 @@ export function App() {
|
|||
/>
|
||||
<Route path="/jobs" component={JobsPage} />
|
||||
<Route path="/schedules" component={SchedulesPage} />
|
||||
<Route path="/builds" component={BuildsPage} />
|
||||
<Route path="/issues" component={IssueReportPage} />
|
||||
<Route path="/review" component={ReviewPage} />
|
||||
<Route path="/components" component={ComponentsPage} />
|
||||
|
|
|
|||
56
frontend/packages/swarm-ui/src/pages/builds/BuildsPage.css
Normal file
56
frontend/packages/swarm-ui/src/pages/builds/BuildsPage.css
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/* The build list: one card per build, the head row a button that opens it. */
|
||||
.ui-builds-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75em;
|
||||
margin-top: 1em;
|
||||
}
|
||||
.ui-builds-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5em 0.75em;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ui-builds-head:focus-visible {
|
||||
outline: 2px solid var(--purple);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.ui-builds-title {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5em;
|
||||
}
|
||||
.ui-builds-meta {
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
}
|
||||
.ui-builds-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4em;
|
||||
margin-top: 0.6em;
|
||||
}
|
||||
.ui-builds-text {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* What nix printed: kept as printed, scrolls sideways rather than wrapping. */
|
||||
.ui-builds-tail {
|
||||
margin: 0;
|
||||
padding: 0.6em 0.75em;
|
||||
max-height: 24em;
|
||||
overflow: auto;
|
||||
border-radius: 0.4em;
|
||||
background: color-mix(in srgb, var(--crust) 60%, transparent);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.ui-builds-empty {
|
||||
color: var(--muted);
|
||||
}
|
||||
189
frontend/packages/swarm-ui/src/pages/builds/BuildsPage.tsx
Normal file
189
frontend/packages/swarm-ui/src/pages/builds/BuildsPage.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
// <BuildsPage> — the swarm builder's recent builds, newest first: which agent
|
||||
// config PR heads it built, how each ended, and what `nix` printed when one
|
||||
// failed. One card per build; selecting a card opens it. Reads
|
||||
// `GET /api/builder/runs`. A `swarm-builder` commit status links here as
|
||||
// `/builds?run=<id>`, which opens that build's card and scrolls to it.
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import { useSearch } from "wouter-preact";
|
||||
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
||||
import { Badge } from "@hive/shared/badge.js";
|
||||
import { Card } from "../../ui/card/Card.js";
|
||||
import { Panel } from "../../ui/panel/Panel.js";
|
||||
import {
|
||||
RefreshIntervalPicker,
|
||||
useRefreshInterval,
|
||||
type RefreshIntervalMs,
|
||||
} from "../../ui/refresh-interval/RefreshInterval.js";
|
||||
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
|
||||
import { durationLabel } from "../review-runs/RunsTypes.js";
|
||||
import {
|
||||
OUTCOME_LABEL,
|
||||
OUTCOME_TONE,
|
||||
bodyText,
|
||||
wantedRun,
|
||||
type BuildRun,
|
||||
} from "./builds-types.js";
|
||||
import "./BuildsPage.css";
|
||||
|
||||
const RUNS = "/api/builder/runs";
|
||||
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
|
||||
|
||||
function cardId(id: string): string {
|
||||
return `build-run-${id}`;
|
||||
}
|
||||
|
||||
function BuildCard({
|
||||
run,
|
||||
open,
|
||||
onToggle,
|
||||
}: {
|
||||
run: BuildRun;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const body = bodyText(run);
|
||||
const bodyId = `${cardId(run.id)}-body`;
|
||||
return (
|
||||
<Card class="ui-builds-card">
|
||||
<div id={cardId(run.id)}>
|
||||
<button
|
||||
type="button"
|
||||
class="ui-builds-head"
|
||||
aria-expanded={open}
|
||||
aria-controls={bodyId}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<span class="ui-builds-title">
|
||||
<strong>
|
||||
{run.repo} #{run.pr}
|
||||
</strong>
|
||||
<code>{run.head_sha.slice(0, 7)}</code>
|
||||
</span>
|
||||
<Badge
|
||||
tone={OUTCOME_TONE[run.outcome]}
|
||||
value={OUTCOME_LABEL[run.outcome]}
|
||||
/>
|
||||
<span class="ui-builds-meta">
|
||||
<RelativeTime epochMs={run.started_unix * 1000} /> · took{" "}
|
||||
{durationLabel(run.started_unix, run.finished_unix)}
|
||||
</span>
|
||||
</button>
|
||||
{open ? (
|
||||
<div class="ui-builds-body" id={bodyId}>
|
||||
{body ? (
|
||||
<>
|
||||
<p class="ui-builds-meta">{body.label}</p>
|
||||
{body.preformatted ? (
|
||||
<pre class="ui-builds-tail">{body.text}</pre>
|
||||
) : (
|
||||
<p class="ui-builds-text">{body.text}</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p class="ui-builds-meta">
|
||||
nothing more was recorded for this build.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function BuildsPage() {
|
||||
const wanted = wantedRun(useSearch());
|
||||
const [intervalMs, setIntervalMs] =
|
||||
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
|
||||
const [runs, setRuns] = useState<BuildRun[] | null>(null);
|
||||
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||
// Which cards are open. Survives a refresh, so a list that updates under
|
||||
// you does not fold the card you are reading. A linked build starts open.
|
||||
const [open, setOpen] = useState<ReadonlySet<string>>(
|
||||
() => new Set(wanted ? [wanted] : []),
|
||||
);
|
||||
const scrolledTo = useRef<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch(RUNS);
|
||||
if (!res.ok) {
|
||||
setError(await readApiError(res));
|
||||
return;
|
||||
}
|
||||
setRuns((await res.json()) as BuildRun[]);
|
||||
setError(null);
|
||||
} catch (e: unknown) {
|
||||
setError({ detail: String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
useRefreshInterval(intervalMs, () => void load());
|
||||
|
||||
// Scroll to the linked build once its card exists, not on every refresh.
|
||||
useEffect(() => {
|
||||
if (!wanted || !runs || scrolledTo.current === wanted) return;
|
||||
const el = document.getElementById(cardId(wanted));
|
||||
if (!el) return;
|
||||
scrolledTo.current = wanted;
|
||||
setOpen((cur) => new Set(cur).add(wanted));
|
||||
el.scrollIntoView({ block: "center" });
|
||||
}, [wanted, runs]);
|
||||
|
||||
function toggle(id: string) {
|
||||
setOpen((cur) => {
|
||||
const next = new Set(cur);
|
||||
if (!next.delete(id)) next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const missing =
|
||||
wanted !== null && runs !== null && !runs.some((r) => r.id === wanted);
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title="builds"
|
||||
icon="🔨"
|
||||
actions={
|
||||
<RefreshIntervalPicker
|
||||
id="builds-refresh"
|
||||
value={intervalMs}
|
||||
onChange={setIntervalMs}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<p>
|
||||
The agent config PR heads the swarm builder built, newest first: how
|
||||
each ended, and what nix printed when one failed. Select a build to read
|
||||
it.
|
||||
</p>
|
||||
{error ? (
|
||||
<ApiErrorPanel
|
||||
context="the builds could not be loaded"
|
||||
problem={error}
|
||||
/>
|
||||
) : null}
|
||||
{missing ? (
|
||||
<p class="ui-builds-meta" role="status">
|
||||
the build in this link is no longer listed: the builder keeps only its
|
||||
newest builds.
|
||||
</p>
|
||||
) : null}
|
||||
{runs !== null && runs.length === 0 && !error ? (
|
||||
<p class="ui-builds-empty">No builds yet.</p>
|
||||
) : null}
|
||||
<div class="ui-builds-list">
|
||||
{(runs ?? []).map((run) => (
|
||||
<BuildCard
|
||||
key={run.id}
|
||||
run={run}
|
||||
open={open.has(run.id)}
|
||||
onToggle={() => toggle(run.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
70
frontend/packages/swarm-ui/src/pages/builds/builds-types.ts
Normal file
70
frontend/packages/swarm-ui/src/pages/builds/builds-types.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
// What the builds page decides without a DOM: the wire shape of one builder
|
||||
// run, how each outcome reads, and which run a `?run=` link names. Kept out of
|
||||
// `BuildsPage.tsx` so it is plain functions and can be tested.
|
||||
import type { BadgeTone } from "@hive/shared/badge.js";
|
||||
|
||||
/** How a build ended, as `swarm-controller`'s `BuildOutcome` spells it. */
|
||||
export type BuildOutcome = "built" | "failed" | "error";
|
||||
|
||||
/** One finished build, `GET /api/builder/runs`. */
|
||||
export interface BuildRun {
|
||||
/** `<agent>-<first 12 of head_sha>-<started_unix>`. */
|
||||
id: string;
|
||||
/** `agent-configs/<agent>`. */
|
||||
repo: string;
|
||||
pr: number;
|
||||
head_sha: string;
|
||||
started_unix: number;
|
||||
finished_unix: number;
|
||||
outcome: BuildOutcome;
|
||||
/** The system closure's store path, for `built`. */
|
||||
out_path?: string;
|
||||
/** The last lines `nix` printed, for `failed`. */
|
||||
error_tail?: string;
|
||||
/** Why no build ran or came back, for `error`. */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export const OUTCOME_LABEL: Record<BuildOutcome, string> = {
|
||||
built: "built",
|
||||
failed: "failed",
|
||||
error: "error",
|
||||
};
|
||||
|
||||
export const OUTCOME_TONE: Record<BuildOutcome, BadgeTone> = {
|
||||
built: "positive",
|
||||
failed: "negative",
|
||||
error: "warning",
|
||||
};
|
||||
|
||||
/** The run a `?run=<id>` query names, or `null` when it names none. */
|
||||
export function wantedRun(search: string): string | null {
|
||||
const id = new URLSearchParams(search).get("run");
|
||||
return id === null || id === "" ? null : id;
|
||||
}
|
||||
|
||||
/** What a run's card shows under its head: the text for its outcome. */
|
||||
export function bodyText(run: BuildRun): {
|
||||
label: string;
|
||||
text: string;
|
||||
preformatted: boolean;
|
||||
} | null {
|
||||
if (run.outcome === "failed" && run.error_tail) {
|
||||
return {
|
||||
label: "what nix printed",
|
||||
text: run.error_tail,
|
||||
preformatted: true,
|
||||
};
|
||||
}
|
||||
if (run.outcome === "built" && run.out_path) {
|
||||
return { label: "system closure", text: run.out_path, preformatted: false };
|
||||
}
|
||||
if (run.outcome === "error" && run.detail) {
|
||||
return {
|
||||
label: "why no build came back",
|
||||
text: run.detail,
|
||||
preformatted: false,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -56,6 +56,7 @@ const NAV_ITEMS: { href: string; label: string; accent: string }[] = [
|
|||
{ href: "/schedules", label: "schedules", accent: "var(--cyan)" },
|
||||
{ href: "/issues", label: "issues", accent: "var(--yellow)" },
|
||||
{ href: "/review", label: "review", accent: "var(--amber)" },
|
||||
{ href: "/builds", label: "builds", accent: "var(--green)" },
|
||||
{ href: "/components", label: "components", accent: "var(--blue)" },
|
||||
];
|
||||
|
||||
|
|
|
|||
51
frontend/packages/swarm-ui/test/builds-types.test.mjs
Normal file
51
frontend/packages/swarm-ui/test/builds-types.test.mjs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
// The builds page's `?run=` link and what each outcome's card shows. Run by
|
||||
// `npm test`, which Node runs with its built-in test runner and type
|
||||
// stripping, so the `.ts` module is imported as it is.
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { bodyText, wantedRun } from "../src/pages/builds/builds-types.ts";
|
||||
|
||||
const base = {
|
||||
id: "atlas-abc-1",
|
||||
repo: "agent-configs/atlas",
|
||||
pr: 3,
|
||||
head_sha: "abcdef0123456789",
|
||||
started_unix: 100,
|
||||
finished_unix: 160,
|
||||
};
|
||||
|
||||
test("a ?run= link names the run it carries", () => {
|
||||
assert.equal(wantedRun("?run=atlas-abc-1"), "atlas-abc-1");
|
||||
assert.equal(wantedRun("run=atlas-abc-1&x=1"), "atlas-abc-1");
|
||||
});
|
||||
|
||||
test("no run, or an empty one, names none", () => {
|
||||
assert.equal(wantedRun(""), null);
|
||||
assert.equal(wantedRun("?x=1"), null);
|
||||
assert.equal(wantedRun("?run="), null);
|
||||
});
|
||||
|
||||
test("a failed build shows what nix printed, as it was printed", () => {
|
||||
const body = bodyText({ ...base, outcome: "failed", error_tail: "error: x" });
|
||||
assert.deepEqual(body, {
|
||||
label: "what nix printed",
|
||||
text: "error: x",
|
||||
preformatted: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("a built one shows its closure, an errored one its reason", () => {
|
||||
assert.equal(
|
||||
bodyText({ ...base, outcome: "built", out_path: "/nix/store/x" })?.text,
|
||||
"/nix/store/x",
|
||||
);
|
||||
assert.equal(
|
||||
bodyText({ ...base, outcome: "error", detail: "no node" })?.text,
|
||||
"no node",
|
||||
);
|
||||
});
|
||||
|
||||
test("a run with no text for its outcome shows no body", () => {
|
||||
assert.equal(bodyText({ ...base, outcome: "failed" }), null);
|
||||
assert.equal(bodyText({ ...base, outcome: "built", error_tail: "x" }), null);
|
||||
});
|
||||
Loading…
Reference in a new issue