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
7 changed files with 96 additions and 36 deletions

View file

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

View file

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

View file

@ -54,3 +54,8 @@
.ui-builds-empty {
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 { Badge } from "@hive/shared/badge.js";
import { Card } from "../../ui/card/Card.js";
import { Panel } from "../../ui/panel/Panel.js";
import { LazyDisclosure } from "../../ui/disclosure/LazyDisclosure.js";
import {
RefreshIntervalPicker,
type RefreshIntervalMs,
@ -93,7 +93,7 @@ function BuildCard({
);
}
export function BuildsCard() {
function BuildsBody() {
const wanted = wantedRun(useSearch());
const [intervalMs, setIntervalMs] =
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
@ -129,17 +129,14 @@ export function BuildsCard() {
wanted !== null && runs !== null && !runs.some((r) => r.id === wanted);
return (
<Panel
title="builds"
icon="🔨"
actions={
<>
<div class="ui-builds-refresh">
<RefreshIntervalPicker
id="builds-refresh"
value={intervalMs}
onChange={setIntervalMs}
/>
}
>
</div>
<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
@ -170,6 +167,21 @@ export function BuildsCard() {
/>
))}
</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;
}