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
7 changed files with 96 additions and 36 deletions
|
|
@ -14,3 +14,8 @@
|
|||
flex-direction: column;
|
||||
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 { 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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,3 +54,8 @@
|
|||
.ui-builds-empty {
|
||||
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 { 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in a new issue