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:
parent
a2f000bab4
commit
8f5b3a2fa8
9 changed files with 96 additions and 36 deletions
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue