Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: the jobs and builds cards are folded by default

Both cards on the jobs tab start shut and read nothing until opened; a link
with ?run= opens the builds card. The fold-and-build-on-first-open part of the
hives card is now one LazyDisclosure, which the hives card uses too. Docs
follow.
This commit is contained in:
iris 2026-10-26 20:09:17 +01:00
commit 8f5b3a2fa8
9 changed files with 96 additions and 36 deletions

View file

@ -472,7 +472,10 @@ previous repo's issues under the error.
### Builds ### Builds
The builds sit in a card under the job graph on the **jobs** tab. The old `/builds` address redirects there and keeps its query. The builds sit in a card under the job graph on the **jobs** tab. Both cards are
folded when the tab opens, and each reads nothing until you open it; a link to
a build opens the builds card. The old `/builds` address redirects there and
keeps its query.
The **builds** card lists the agent config PR heads the swarm builder built, The **builds** card lists the agent config PR heads the swarm builder built,
newest first, from `GET /api/builder/runs`. Each card shows the repo and PR, newest first, from `GET /api/builder/runs`. Each card shows the repo and PR,
@ -482,7 +485,7 @@ read it: for a failed build, what `nix` printed; for a built one, the system
closure; for an error, why no build came back. closure; for an error, why no build came back.
The **Details** link on a `swarm-builder` commit status opens the jobs tab as The **Details** link on a `swarm-builder` commit status opens the jobs tab as
`/jobs?run=<id>`, with that build's card open and in view. Links made before the `/jobs?run=<id>`, with the builds card open and that build's card in view. Links made before the
builds moved, to `/builds?run=<id>`, redirect to the same place. The builder keeps builds moved, to `/builds?run=<id>`, redirect to the same place. The builder keeps
its newest 100 builds, so a link to an older one shows a note instead. its newest 100 builds, so a link to an older one shows a note instead.

View file

@ -14,3 +14,8 @@
flex-direction: column; flex-direction: column;
gap: 1em; gap: 1em;
} }
.ui-jobs-refresh {
display: flex;
justify-content: flex-end;
margin-block-end: 0.5em;
}

View file

@ -21,7 +21,7 @@
import { useState } from "preact/hooks"; import { useState } from "preact/hooks";
import { JobqGraph } from "@hive/shared/jobq-graph.js"; import { JobqGraph } from "@hive/shared/jobq-graph.js";
import { JobqRollup } from "@hive/shared/jobq-rollup.js"; import { JobqRollup } from "@hive/shared/jobq-rollup.js";
import { Panel } from "../ui/panel/Panel.js"; import { LazyDisclosure } from "../ui/disclosure/LazyDisclosure.js";
import { BuildsCard } from "./builds/BuildsPage.js"; import { BuildsCard } from "./builds/BuildsPage.js";
import { import {
RefreshIntervalPicker, RefreshIntervalPicker,
@ -35,7 +35,7 @@ import "./JobsPage.css";
// just solve staleness rather than require an opt-in every visit. // just solve staleness rather than require an opt-in every visit.
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000; const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
function JobsCard() { function JobsBody() {
const [intervalMs, setIntervalMs] = const [intervalMs, setIntervalMs] =
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS); useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
const [refreshToken, setRefreshToken] = useState(0); const [refreshToken, setRefreshToken] = useState(0);
@ -51,29 +51,32 @@ function JobsCard() {
useRefreshInterval(intervalMs, () => setRefreshToken((t) => t + 1)); useRefreshInterval(intervalMs, () => setRefreshToken((t) => t + 1));
return ( return (
<Panel <>
title="jobs" <div class="ui-jobs-refresh">
icon="🧩"
actions={
<RefreshIntervalPicker <RefreshIntervalPicker
id="jobs-refresh" id="jobs-refresh"
value={intervalMs} value={intervalMs}
onChange={setIntervalMs} onChange={setIntervalMs}
/> />
} </div>
>
<JobqRollup endpoint="/api/jobq/rollup" refreshToken={refreshToken} /> <JobqRollup endpoint="/api/jobq/rollup" refreshToken={refreshToken} />
<JobqGraph endpoint="/api/jobq/graph" refreshToken={refreshToken} /> <JobqGraph endpoint="/api/jobq/graph" refreshToken={refreshToken} />
</Panel> </>
); );
} }
// The jobs tab holds two cards: the controller's job graph, and the swarm // The jobs tab holds two folded cards: the controller's job graph, and the swarm
// builder's recent builds under it. // builder's recent builds under it. Neither reads anything until it is opened.
export function JobsPage() { export function JobsPage() {
return ( return (
<div class="ui-jobs-page"> <div class="ui-jobs-page">
<JobsCard /> <LazyDisclosure
title="jobs"
icon="🧩"
hint="the controller's job graph: agent creation, credential mints"
>
<JobsBody />
</LazyDisclosure>
<BuildsCard /> <BuildsCard />
</div> </div>
); );

View file

@ -16,7 +16,7 @@
import { useState } from "preact/hooks"; import { useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { Badge, type BadgeTone } from "@hive/shared/badge.js"; import { Badge, type BadgeTone } from "@hive/shared/badge.js";
import { Disclosure } from "../../ui/disclosure/Disclosure.js"; import { LazyDisclosure } from "../../ui/disclosure/LazyDisclosure.js";
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js"; import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
import { import {
RefreshIntervalPicker, RefreshIntervalPicker,
@ -25,7 +25,6 @@ import {
import { fetchJson, usePolled } from "../../ui/polled/usePolled.js"; import { fetchJson, usePolled } from "../../ui/polled/usePolled.js";
import { Table, type TableColumn } from "../../ui/table/Table.js"; import { Table, type TableColumn } from "../../ui/table/Table.js";
import { domainCell } from "../hive-domain.js"; import { domainCell } from "../hive-domain.js";
import { builtAfterToggle } from "./lazy-open.js";
type Freshness = "fresh" | "stale" | "never_reported" | "unknown"; type Freshness = "fresh" | "stale" | "never_reported" | "unknown";
@ -151,20 +150,13 @@ function HiveRoster() {
} }
export function HivesCard() { export function HivesCard() {
const [open, setOpen] = useState(false);
const [built, setBuilt] = useState(false);
return ( return (
<Disclosure <LazyDisclosure
title="hives" title="hives"
icon="🐝" icon="🐝"
hint="the swarm's hives and when each last reported" hint="the swarm's hives and when each last reported"
open={open}
onToggle={(next) => {
setOpen(next);
setBuilt((was) => builtAfterToggle(was, next));
}}
> >
{built ? <HiveRoster /> : null} <HiveRoster />
</Disclosure> </LazyDisclosure>
); );
} }

View file

@ -54,3 +54,8 @@
.ui-builds-empty { .ui-builds-empty {
color: var(--muted); color: var(--muted);
} }
.ui-builds-refresh {
display: flex;
justify-content: flex-end;
margin-block-end: 0.5em;
}

View file

@ -10,7 +10,7 @@ import { useSearch } from "wouter-preact";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { Badge } from "@hive/shared/badge.js"; import { Badge } from "@hive/shared/badge.js";
import { Card } from "../../ui/card/Card.js"; import { Card } from "../../ui/card/Card.js";
import { Panel } from "../../ui/panel/Panel.js"; import { LazyDisclosure } from "../../ui/disclosure/LazyDisclosure.js";
import { import {
RefreshIntervalPicker, RefreshIntervalPicker,
type RefreshIntervalMs, type RefreshIntervalMs,
@ -93,7 +93,7 @@ function BuildCard({
); );
} }
export function BuildsCard() { function BuildsBody() {
const wanted = wantedRun(useSearch()); const wanted = wantedRun(useSearch());
const [intervalMs, setIntervalMs] = const [intervalMs, setIntervalMs] =
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS); useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
@ -129,17 +129,14 @@ export function BuildsCard() {
wanted !== null && runs !== null && !runs.some((r) => r.id === wanted); wanted !== null && runs !== null && !runs.some((r) => r.id === wanted);
return ( return (
<Panel <>
title="builds" <div class="ui-builds-refresh">
icon="🔨"
actions={
<RefreshIntervalPicker <RefreshIntervalPicker
id="builds-refresh" id="builds-refresh"
value={intervalMs} value={intervalMs}
onChange={setIntervalMs} onChange={setIntervalMs}
/> />
} </div>
>
<p> <p>
The agent config PR heads the swarm builder built, newest first: how 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 each ended, and what nix printed when one failed. Select a build to read
@ -170,6 +167,21 @@ export function BuildsCard() {
/> />
))} ))}
</div> </div>
</Panel> </>
);
}
// Shut by default, and open from the start when the URL names a build: a
// status link goes straight to that build's card.
export function BuildsCard() {
return (
<LazyDisclosure
title="builds"
icon="🔨"
hint="the agent config PRs the swarm builder built"
startOpen={wantedRun(useSearch()) !== null}
>
<BuildsBody />
</LazyDisclosure>
); );
} }

View file

@ -0,0 +1,40 @@
// <LazyDisclosure> — a Disclosure that is shut by default and builds its body
// on the first open. For a card that polls or fetches: one nobody opens costs
// nothing, and one that was opened keeps its body, and what it read, after a
// fold. `startOpen` opens it from the first render, for a link that points
// into it.
import { useState } from "preact/hooks";
import type { ComponentChildren } from "preact";
import { Disclosure } from "./Disclosure.js";
import { builtAfterToggle } from "./lazy-open.js";
export function LazyDisclosure({
title,
icon,
hint,
startOpen = false,
children,
}: {
title: string;
icon?: string;
hint?: ComponentChildren;
startOpen?: boolean;
children: ComponentChildren;
}) {
const [open, setOpen] = useState(startOpen);
const [built, setBuilt] = useState(startOpen);
return (
<Disclosure
title={title}
icon={icon}
hint={hint}
open={open}
onToggle={(next) => {
setOpen(next);
setBuilt((was) => builtAfterToggle(was, next));
}}
>
{built ? children : null}
</Disclosure>
);
}

View file

@ -0,0 +1,5 @@
// A folded card builds its body on the first open and keeps it after, so a
// card nobody opens never fetches, and closing it does not drop what it read.
export function builtAfterToggle(built: boolean, open: boolean): boolean {
return built || open;
}

View file

@ -1,7 +1,7 @@
// A folded card fetches nothing until first opened. Run by `npm test`. // A folded card fetches nothing until first opened. Run by `npm test`.
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import { builtAfterToggle } from "../src/pages/agents/lazy-open.ts"; import { builtAfterToggle } from "../src/ui/disclosure/lazy-open.ts";
test("a card nobody opened is not built", () => { test("a card nobody opened is not built", () => {
assert.equal(builtAfterToggle(false, false), false); assert.equal(builtAfterToggle(false, false), false);