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

@ -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} />

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: "/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)" },
];

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