From 0e77d61a9f92eb697049a240787532a1e6a38810 Mon Sep 17 00:00:00 2001 From: iris Date: Sun, 18 Oct 2026 00:38:21 +0200 Subject: [PATCH] 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= opens that build's card and scrolls to it. The swarm-builder commit status now links there instead of to the raw JSON record. --- docs/swarm/builder.md | 7 +- docs/swarm/ui.md | 14 ++ frontend/packages/swarm-ui/src/App.tsx | 2 + .../swarm-ui/src/pages/builds/BuildsPage.css | 56 ++++++ .../swarm-ui/src/pages/builds/BuildsPage.tsx | 189 ++++++++++++++++++ .../swarm-ui/src/pages/builds/builds-types.ts | 70 +++++++ .../packages/swarm-ui/src/shell/Shell.tsx | 1 + .../swarm-ui/test/builds-types.test.mjs | 51 +++++ swarm-controller/src/builder.rs | 14 +- 9 files changed, 391 insertions(+), 13 deletions(-) create mode 100644 frontend/packages/swarm-ui/src/pages/builds/BuildsPage.css create mode 100644 frontend/packages/swarm-ui/src/pages/builds/BuildsPage.tsx create mode 100644 frontend/packages/swarm-ui/src/pages/builds/builds-types.ts create mode 100644 frontend/packages/swarm-ui/test/builds-types.test.mjs diff --git a/docs/swarm/builder.md b/docs/swarm/builder.md index e3ad2e8e..3fb34f63 100644 --- a/docs/swarm/builder.md +++ b/docs/swarm/builder.md @@ -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 can't read it. -The final status links to `https:///api/builder/runs/`, so -selecting **Details** on a red `swarm-builder` check opens the record, with -`nix`'s tail, in the browser. A status has no link when the record wasn't +The final status links to `https:///builds?run=`, the swarm +UI's builds page, so selecting **Details** on a red `swarm-builder` check opens +that build's card, with `nix`'s tail, in the browser. The raw record stays at +`GET /api/builder/runs/`. A status has no link when the record wasn'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` status has no link. diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index d0a58a76..5b2372d4 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -19,6 +19,7 @@ domain, covers host-level detail for one hive. | `/schedules` | the scheduled prompts: add, edit, pause, resume and remove | | `/issues` | a cross-repo issue report | | `/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). An agent created here or with `swarmctl agent create` starts `paused`; set it @@ -259,6 +260,19 @@ live rows only. +### 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=`, 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 The agent's detail panel shows its model and effort under **inference**, each diff --git a/frontend/packages/swarm-ui/src/App.tsx b/frontend/packages/swarm-ui/src/App.tsx index 619028c6..bf7e6ea9 100644 --- a/frontend/packages/swarm-ui/src/App.tsx +++ b/frontend/packages/swarm-ui/src/App.tsx @@ -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() { /> + diff --git a/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.css b/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.css new file mode 100644 index 00000000..a76fab4f --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.css @@ -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); +} diff --git a/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.tsx b/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.tsx new file mode 100644 index 00000000..161c663f --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.tsx @@ -0,0 +1,189 @@ +// — 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=`, 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 ( + +
+ + {open ? ( +
+ {body ? ( + <> +

{body.label}

+ {body.preformatted ? ( +
{body.text}
+ ) : ( +

{body.text}

+ )} + + ) : ( +

+ nothing more was recorded for this build. +

+ )} +
+ ) : null} +
+
+ ); +} + +export function BuildsPage() { + const wanted = wantedRun(useSearch()); + const [intervalMs, setIntervalMs] = + useState(DEFAULT_INTERVAL_MS); + const [runs, setRuns] = useState(null); + const [error, setError] = useState(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>( + () => new Set(wanted ? [wanted] : []), + ); + const scrolledTo = useRef(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 ( + + } + > +

+ 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. +

+ {error ? ( + + ) : null} + {missing ? ( +

+ the build in this link is no longer listed: the builder keeps only its + newest builds. +

+ ) : null} + {runs !== null && runs.length === 0 && !error ? ( +

No builds yet.

+ ) : null} +
+ {(runs ?? []).map((run) => ( + toggle(run.id)} + /> + ))} +
+
+ ); +} diff --git a/frontend/packages/swarm-ui/src/pages/builds/builds-types.ts b/frontend/packages/swarm-ui/src/pages/builds/builds-types.ts new file mode 100644 index 00000000..322ba6a5 --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/builds/builds-types.ts @@ -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 { + /** `--`. */ + id: string; + /** `agent-configs/`. */ + 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 = { + built: "built", + failed: "failed", + error: "error", +}; + +export const OUTCOME_TONE: Record = { + built: "positive", + failed: "negative", + error: "warning", +}; + +/** The run a `?run=` 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; +} diff --git a/frontend/packages/swarm-ui/src/shell/Shell.tsx b/frontend/packages/swarm-ui/src/shell/Shell.tsx index 13e41c10..8d2736c7 100644 --- a/frontend/packages/swarm-ui/src/shell/Shell.tsx +++ b/frontend/packages/swarm-ui/src/shell/Shell.tsx @@ -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)" }, ]; diff --git a/frontend/packages/swarm-ui/test/builds-types.test.mjs b/frontend/packages/swarm-ui/test/builds-types.test.mjs new file mode 100644 index 00000000..2cba6d47 --- /dev/null +++ b/frontend/packages/swarm-ui/test/builds-types.test.mjs @@ -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); +}); diff --git a/swarm-controller/src/builder.rs b/swarm-controller/src/builder.rs index e79278a2..cef00653 100644 --- a/swarm-controller/src/builder.rs +++ b/swarm-controller/src/builder.rs @@ -1,7 +1,7 @@ //! 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 -//! record the build, `nix`'s tail with it, in [`runs`], whose -//! `GET /api/builder/runs/{id}` the status links to. +//! record the build, `nix`'s tail with it, in [`runs`]. The status links to the +//! swarm UI's builds page for that build. //! //! 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. @@ -209,13 +209,7 @@ async fn record( if !builds.record(&build).await { return None; } - link_base.map(|base| { - format!( - "{}/api/builder/runs/{}", - base.trim_end_matches('/'), - build.id - ) - }) + link_base.map(|base| format!("{}/builds?run={}", base.trim_end_matches('/'), build.id)) } /// 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!(build.id.starts_with("atlas-abc-"), "{}", build.id); 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!( written, [