Watch
0
0
Fork
You've already forked hyperhive
0

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:
iris 2026-10-18 00:38:21 +02:00 • committed by mara
commit 0e77d61a9f
9 changed files with 391 additions and 13 deletions

View file

@ -86,9 +86,10 @@ newest 100. Both are behind the swarm UI vhost's `admins` gate, and answer 503
when the controller has no swarm queue or can't open the bucket, 500 when it when the controller has no swarm queue or can't open the bucket, 500 when it
can't read it. can't read it.
The final status links to `https://<swarm domain>/api/builder/runs/<id>`, so The final status links to `https://<swarm domain>/builds?run=<id>`, the swarm
selecting **Details** on a red `swarm-builder` check opens the record, with UI's builds page, so selecting **Details** on a red `swarm-builder` check opens
`nix`'s tail, in the browser. A status has no link when the record wasn't that build's card, with `nix`'s tail, in the browser. The raw record stays at
`GET /api/builder/runs/<id>`. A status has no link when the record wasn't
stored (the controller logs the failed write) or when the controller doesn't stored (the controller logs the failed write) or when the controller doesn't
serve the swarm vhost (`SWARM_CONTROLLER_PUBLIC_URL` unset). The `pending` serve the swarm vhost (`SWARM_CONTROLLER_PUBLIC_URL` unset). The `pending`
status has no link. status has no link.

View file

@ -19,6 +19,7 @@ domain, covers host-level detail for one hive.
| `/schedules` | the scheduled prompts: add, edit, pause, resume and remove | | `/schedules` | the scheduled prompts: add, edit, pause, resume and remove |
| `/issues` | a cross-repo issue report | | `/issues` | a cross-repo issue report |
| `/review` | the review classifier: its recent runs, a try-out on a real PR, and its settings | | `/review` | the review classifier: its recent runs, a try-out on a real PR, and its settings |
| `/builds` | the swarm builder's recent builds of agent config PRs, with what nix printed when one failed |
Everything it shows comes from [`swarm-controller`](../../swarm-controller/README.md). Everything it shows comes from [`swarm-controller`](../../swarm-controller/README.md).
An agent created here or with `swarmctl agent create` starts `paused`; set it An agent created here or with `swarmctl agent create` starts `paused`; set it
@ -259,6 +260,19 @@ live rows only.
</details> </details>
### Builds
The **builds** page lists the agent config PR heads the swarm builder built,
newest first, from `GET /api/builder/runs`. Each card shows the repo and PR,
the first seven characters of the commit, how the build ended (**built**,
**failed** or **error**), when it started and how long it took. Select a card to
read it: for a failed build, what `nix` printed; for a built one, the system
closure; for an error, why no build came back.
The **Details** link on a `swarm-builder` commit status opens this page as
`/builds?run=<id>`, with that build's card open and in view. The builder keeps
its newest 100 builds, so a link to an older one shows a note instead.
### Inference settings ### Inference settings
The agent's detail panel shows its model and effort under **inference**, each The agent's detail panel shows its model and effort under **inference**, each

View file

@ -5,6 +5,7 @@ import { Shell } from "./shell/Shell.js";
import { AgentsPage } from "./pages/agents/AgentsPage.js"; import { AgentsPage } from "./pages/agents/AgentsPage.js";
import { AgentTerminalPage } from "./pages/agents/AgentTerminalPage.js"; import { AgentTerminalPage } from "./pages/agents/AgentTerminalPage.js";
import { AlertsPage } from "./pages/AlertsPage.js"; import { AlertsPage } from "./pages/AlertsPage.js";
import { BuildsPage } from "./pages/builds/BuildsPage.js";
import { ComponentsPage } from "./pages/ComponentsPage.js"; import { ComponentsPage } from "./pages/ComponentsPage.js";
import { JobsPage } from "./pages/JobsPage.js"; import { JobsPage } from "./pages/JobsPage.js";
import { HivesPage } from "./pages/HivesPage.js"; import { HivesPage } from "./pages/HivesPage.js";
@ -37,6 +38,7 @@ export function App() {
/> />
<Route path="/jobs" component={JobsPage} /> <Route path="/jobs" component={JobsPage} />
<Route path="/schedules" component={SchedulesPage} /> <Route path="/schedules" component={SchedulesPage} />
<Route path="/builds" component={BuildsPage} />
<Route path="/issues" component={IssueReportPage} /> <Route path="/issues" component={IssueReportPage} />
<Route path="/review" component={ReviewPage} /> <Route path="/review" component={ReviewPage} />
<Route path="/components" component={ComponentsPage} /> <Route path="/components" component={ComponentsPage} />

View 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);
}

View 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>
);
}

View 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;
}

View file

@ -56,6 +56,7 @@ const NAV_ITEMS: { href: string; label: string; accent: string }[] = [
{ href: "/schedules", label: "schedules", accent: "var(--cyan)" }, { href: "/schedules", label: "schedules", accent: "var(--cyan)" },
{ href: "/issues", label: "issues", accent: "var(--yellow)" }, { href: "/issues", label: "issues", accent: "var(--yellow)" },
{ href: "/review", label: "review", accent: "var(--amber)" }, { href: "/review", label: "review", accent: "var(--amber)" },
{ href: "/builds", label: "builds", accent: "var(--green)" },
{ href: "/components", label: "components", accent: "var(--blue)" }, { href: "/components", label: "components", accent: "var(--blue)" },
]; ];

View 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);
});

View file

@ -1,7 +1,7 @@
//! The `BuildAgent` node: send a config PR's head to the swarm builder over the //! The `BuildAgent` node: send a config PR's head to the swarm builder over the
//! queue, turn its answer into the [`CONTEXT`] commit status on that head, and //! queue, turn its answer into the [`CONTEXT`] commit status on that head, and
//! record the build, `nix`'s tail with it, in [`runs`], whose //! record the build, `nix`'s tail with it, in [`runs`]. The status links to the
//! `GET /api/builder/runs/{id}` the status links to. //! swarm UI's builds page for that build.
//! //!
//! Nothing is pushed, and the closure the builder made stays in its host's //! Nothing is pushed, and the closure the builder made stays in its host's
//! store. A red status blocks nothing; the merge deploys as before. //! store. A red status blocks nothing; the merge deploys as before.
@ -209,13 +209,7 @@ async fn record(
if !builds.record(&build).await { if !builds.record(&build).await {
return None; return None;
} }
link_base.map(|base| { link_base.map(|base| format!("{}/builds?run={}", base.trim_end_matches('/'), build.id))
format!(
"{}/api/builder/runs/{}",
base.trim_end_matches('/'),
build.id
)
})
} }
/// A failed node carrying `error`, and the status write's own failure when /// A failed node carrying `error`, and the status write's own failure when
@ -354,7 +348,7 @@ mod tests {
assert_eq!((&build.out_path, &build.detail), (&None, &None)); assert_eq!((&build.out_path, &build.detail), (&None, &None));
assert!(build.id.starts_with("atlas-abc-"), "{}", build.id); assert!(build.id.starts_with("atlas-abc-"), "{}", build.id);
assert!(build.finished_unix >= build.started_unix); assert!(build.finished_unix >= build.started_unix);
let link = format!("https://swarm.example/api/builder/runs/{}", build.id); let link = format!("https://swarm.example/builds?run={}", build.id);
assert_eq!( assert_eq!(
written, written,
[ [