treefmt: apply prettier

Pure `nix fmt` output from the commit before this one — no hand edits.
203 files: 52 md, 42 tsx, 32 js, 32 css, 21 ts, 13 html, 8 json, 3 mjs.

Reproduce with `nix develop -c nix fmt` on the parent commit; the result
should be byte-identical to this tree.

None of the 13 `.prettierignore` entries appears here — verified by
intersecting the changed-file list against the ignore file, with a
control proving the intersection finds a match when one exists.
This commit is contained in:
atlas 2026-09-02 14:29:33 +02:00
commit 39b95c2ede
203 changed files with 10090 additions and 6085 deletions

View file

@ -23,28 +23,28 @@
// nav entry would be one click of indirection for no benefit. The form
// itself (`CreateAgentForm`) mounts inside a `Dialog` here rather than
// its own route.
import { useState } from 'preact/hooks';
import { ApiErrorPanel } from '@hive/shared/api-error-panel.js';
import { readApiError, type ProblemDetails } from '@hive/shared/api-error.js';
import { Badge, type BadgeTone } from '@hive/shared/badge.js';
import { Button } from '../ui/button/Button.js';
import { Dialog } from '../ui/dialog/Dialog.js';
import { Panel } from '../ui/panel/Panel.js';
import { RelativeTime } from '../ui/relative-time/RelativeTime.js';
import { useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
import { Button } from "../ui/button/Button.js";
import { Dialog } from "../ui/dialog/Dialog.js";
import { Panel } from "../ui/panel/Panel.js";
import { RelativeTime } from "../ui/relative-time/RelativeTime.js";
import {
RefreshIntervalPicker,
useRefreshInterval,
type RefreshIntervalMs,
} from '../ui/refresh-interval/RefreshInterval.js';
import { Table, type TableColumn } from '../ui/table/Table.js';
import { CreateAgentForm } from './CreateAgentForm.js';
} from "../ui/refresh-interval/RefreshInterval.js";
import { Table, type TableColumn } from "../ui/table/Table.js";
import { CreateAgentForm } from "./CreateAgentForm.js";
interface ConfigPrStatus {
pr_number: number;
html_url: string | null;
}
type Freshness = 'fresh' | 'stale' | 'never_reported' | 'unknown';
type Freshness = "fresh" | "stale" | "never_reported" | "unknown";
interface AgentStatusSnapshot {
status_text: string | null;
@ -70,18 +70,18 @@ interface AgentRow {
// Same tone/label pairing as HivesPage — one freshness enum shared by both
// endpoints, so the same rendering rule applies to both pages.
const FRESHNESS: Record<Freshness, { tone: BadgeTone; label: string }> = {
fresh: { tone: 'positive', label: 'fresh' },
stale: { tone: 'warning', label: 'stale' },
never_reported: { tone: 'neutral', label: 'never reported' },
unknown: { tone: 'negative', label: 'unknown' },
fresh: { tone: "positive", label: "fresh" },
stale: { tone: "warning", label: "stale" },
never_reported: { tone: "neutral", label: "never reported" },
unknown: { tone: "negative", label: "unknown" },
};
const COLUMNS: TableColumn<AgentRow>[] = [
{ key: 'name', header: 'name', render: (a) => a.name },
{ key: 'hive', header: 'hive', render: (a) => a.hive ?? '—' },
{ key: "name", header: "name", render: (a) => a.name },
{ key: "hive", header: "hive", render: (a) => a.hive ?? "—" },
{
key: 'status',
header: 'status',
key: "status",
header: "status",
render: (a) => {
const { tone, label } = FRESHNESS[a.freshness];
const text = a.snapshot?.status_text;
@ -94,11 +94,11 @@ const COLUMNS: TableColumn<AgentRow>[] = [
tone={tone}
value={
<>
{text ? `${text}` : ''}
{text ? `${text}` : ""}
{label}
{a.last_seen_unix !== null ? (
<>
{' '}
{" "}
(<RelativeTime epochMs={a.last_seen_unix * 1000} />)
</>
) : null}
@ -109,8 +109,8 @@ const COLUMNS: TableColumn<AgentRow>[] = [
},
},
{
key: 'config-pr',
header: 'config PR',
key: "config-pr",
header: "config PR",
render: (a) =>
a.config_pr ? (
<Badge
@ -126,7 +126,7 @@ const COLUMNS: TableColumn<AgentRow>[] = [
}
/>
) : (
'—'
"—"
),
},
];
@ -139,12 +139,13 @@ const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
export function AgentsPage() {
const [rows, setRows] = useState<AgentRow[] | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null);
const [intervalMs, setIntervalMs] = useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
const [intervalMs, setIntervalMs] =
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
const [createOpen, setCreateOpen] = useState(false);
useRefreshInterval(intervalMs, () => {
(async () => {
const res = await fetch('/api/agents/status');
const res = await fetch("/api/agents/status");
if (!res.ok) {
setError(await readApiError(res));
return;
@ -166,11 +167,20 @@ export function AgentsPage() {
<Button variant="primary" onClick={() => setCreateOpen(true)}>
+ agent
</Button>
<RefreshIntervalPicker id="agents-refresh" value={intervalMs} onChange={setIntervalMs} />
<RefreshIntervalPicker
id="agents-refresh"
value={intervalMs}
onChange={setIntervalMs}
/>
</>
}
>
{error ? <ApiErrorPanel context="failed to load the agent roster" problem={error} /> : null}
{error ? (
<ApiErrorPanel
context="failed to load the agent roster"
problem={error}
/>
) : null}
{!error && rows === null ? <p>loading</p> : null}
{rows ? (
<Table
@ -180,7 +190,11 @@ export function AgentsPage() {
emptyMessage="no agents yet — the swarm-wide identity store has no agents registered on any hive"
/>
) : null}
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} label="create agent">
<Dialog
open={createOpen}
onClose={() => setCreateOpen(false)}
label="create agent"
>
<CreateAgentForm />
</Dialog>
</Panel>

View file

@ -5,21 +5,33 @@
// primitive gets a section here the same day it's added. Sample data
// only, no network calls — this page must render the same whether
// swarm-controller's API is up or not.
import { useRef, useState } from 'preact/hooks';
import type { ComponentChildren } from 'preact';
import { Panel } from '../ui/panel/Panel.js';
import { RelativeTime } from '../ui/relative-time/RelativeTime.js';
import { RefreshIntervalPicker, type RefreshIntervalMs } from '../ui/refresh-interval/RefreshInterval.js';
import { Table, type TableColumn } from '../ui/table/Table.js';
import { TextField } from '../ui/text-field/TextField.js';
import { SelectField, type SelectOption } from '../ui/select-field/SelectField.js';
import { Button, type ButtonVariant } from '../ui/button/Button.js';
import { Badge, type BadgeTone } from '@hive/shared/badge.js';
import { GearIcon } from '@hive/shared/icons.js';
import { Dropdown, type DropdownOption } from '@hive/shared/dropdown.js';
import './ComponentsPage.css';
import { useRef, useState } from "preact/hooks";
import type { ComponentChildren } from "preact";
import { Panel } from "../ui/panel/Panel.js";
import { RelativeTime } from "../ui/relative-time/RelativeTime.js";
import {
RefreshIntervalPicker,
type RefreshIntervalMs,
} from "../ui/refresh-interval/RefreshInterval.js";
import { Table, type TableColumn } from "../ui/table/Table.js";
import { TextField } from "../ui/text-field/TextField.js";
import {
SelectField,
type SelectOption,
} from "../ui/select-field/SelectField.js";
import { Button, type ButtonVariant } from "../ui/button/Button.js";
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
import { GearIcon } from "@hive/shared/icons.js";
import { Dropdown, type DropdownOption } from "@hive/shared/dropdown.js";
import "./ComponentsPage.css";
function Section({ title, children }: { title: string; children: ComponentChildren }) {
function Section({
title,
children,
}: {
title: string;
children: ComponentChildren;
}) {
return (
<section class="components-section">
<h2 class="components-section-title">{title}</h2>
@ -28,7 +40,13 @@ function Section({ title, children }: { title: string; children: ComponentChildr
);
}
function Sample({ label, children }: { label: string; children: ComponentChildren }) {
function Sample({
label,
children,
}: {
label: string;
children: ComponentChildren;
}) {
return (
<div class="components-sample">
<div class="components-sample-label">{label}</div>
@ -43,40 +61,57 @@ interface Row {
}
const TABLE_COLUMNS: TableColumn<Row>[] = [
{ key: 'name', header: 'name', render: (r) => r.name },
{ key: 'detail', header: 'detail', render: (r) => r.detail },
{ key: 'status', header: 'status', render: () => <Badge tone="positive" value="ok" /> },
{ key: "name", header: "name", render: (r) => r.name },
{ key: "detail", header: "detail", render: (r) => r.detail },
{
key: "status",
header: "status",
render: () => <Badge tone="positive" value="ok" />,
},
];
const TABLE_ROWS: Row[] = [
{ name: 'alpha', detail: 'sample row one' },
{ name: 'beta', detail: 'sample row two' },
{ name: "alpha", detail: "sample row one" },
{ name: "beta", detail: "sample row two" },
];
const SELECT_OPTIONS: SelectOption[] = [
{ value: 'alpha', label: 'alpha' },
{ value: 'beta', label: 'beta' },
{ value: "alpha", label: "alpha" },
{ value: "beta", label: "beta" },
];
const BUTTON_VARIANTS: ButtonVariant[] = ['primary', 'default'];
const BADGE_TONES: BadgeTone[] = ['neutral', 'positive', 'warning', 'negative', 'accent'];
const BUTTON_VARIANTS: ButtonVariant[] = ["primary", "default"];
const BADGE_TONES: BadgeTone[] = [
"neutral",
"positive",
"warning",
"negative",
"accent",
];
const MODEL_OPTIONS: DropdownOption[] = [
{ value: 'haiku', label: 'haiku', description: 'fast' },
{ value: 'sonnet', label: 'sonnet', description: 'balanced' },
{ value: 'opus', label: 'opus', description: 'powerful' },
{ value: "haiku", label: "haiku", description: "fast" },
{ value: "sonnet", label: "sonnet", description: "balanced" },
{ value: "opus", label: "opus", description: "powerful" },
];
// Controlled samples need their own state to actually type/select into —
// module-level consts can't do that, hence these two small wrappers
// rather than inline JSX in the page body below.
function TextFieldSample() {
const [value, setValue] = useState('');
return <TextField id="sample-text-field" label="agent name" value={value} onInput={setValue} />;
const [value, setValue] = useState("");
return (
<TextField
id="sample-text-field"
label="agent name"
value={value}
onInput={setValue}
/>
);
}
function SelectFieldSample() {
const [value, setValue] = useState('alpha');
const [value, setValue] = useState("alpha");
return (
<SelectField
id="sample-select-field"
@ -90,7 +125,13 @@ function SelectFieldSample() {
function RefreshIntervalPickerSample() {
const [value, setValue] = useState<RefreshIntervalMs>(30_000);
return <RefreshIntervalPicker id="sample-refresh-interval" value={value} onChange={setValue} />;
return (
<RefreshIntervalPicker
id="sample-refresh-interval"
value={value}
onChange={setValue}
/>
);
}
// The badge-triggers-a-dropdown-right-underneath-itself pattern this is
@ -99,7 +140,7 @@ function RefreshIntervalPickerSample() {
// needs `position: relative` (`.components-badge-anchor`) so `Dropdown`
// anchors under this badge specifically, not the page.
function BadgePickerSample() {
const [value, setValue] = useState('sonnet');
const [value, setValue] = useState("sonnet");
const [open, setOpen] = useState(false);
const anchorRef = useRef<HTMLDivElement>(null);
return (
@ -133,8 +174,8 @@ function BadgeToggleSample() {
const [paused, setPaused] = useState(false);
return (
<Badge
value={paused ? '▶ resume' : '⏸ pause'}
tone={paused ? 'warning' : 'neutral'}
value={paused ? "▶ resume" : "⏸ pause"}
tone={paused ? "warning" : "neutral"}
onClick={() => setPaused((p) => !p)}
/>
);
@ -144,8 +185,8 @@ export function ComponentsPage() {
return (
<Panel title="components" icon="🧰">
<p class="components-intro">
Every primitive in <code>src/ui/</code>, shown in each mode it supports. Sample data
only nothing here calls the API.
Every primitive in <code>src/ui/</code>, shown in each mode it supports.
Sample data only nothing here calls the API.
</p>
<Section title="Panel">
@ -163,7 +204,13 @@ export function ComponentsPage() {
<Sample label="with actions (e.g. HivesPage's refresh picker)">
<Panel
title="example title"
actions={<RefreshIntervalPicker id="sample-panel-actions" value={30_000} onChange={() => {}} />}
actions={
<RefreshIntervalPicker
id="sample-panel-actions"
value={30_000}
onChange={() => {}}
/>
}
>
panel body content
</Panel>
@ -172,7 +219,11 @@ export function ComponentsPage() {
<Section title="Table">
<Sample label="populated">
<Table columns={TABLE_COLUMNS} rows={TABLE_ROWS} rowKey={(r) => r.name} />
<Table
columns={TABLE_COLUMNS}
rows={TABLE_ROWS}
rowKey={(r) => r.name}
/>
</Sample>
<Sample label="empty">
<Table columns={TABLE_COLUMNS} rows={[]} rowKey={(r) => r.name} />
@ -238,7 +289,12 @@ export function ComponentsPage() {
<BadgeToggleSample />
</Sample>
<Sample label="interactive, quiet variant (icon-only header trigger, e.g. settings)">
<Badge value={<GearIcon />} title="settings" variant="quiet" onClick={() => {}} />
<Badge
value={<GearIcon />}
title="settings"
variant="quiet"
onClick={() => {}}
/>
</Sample>
</div>
</Section>

View file

@ -25,15 +25,18 @@
// Its name field, hive select, and submit button all come from the
// shared `ui/` form kit (`TextField`/`SelectField`/`Button`) rather than
// page-scoped input/button chrome — see that kit's own comments for why.
import { useEffect, useState } from 'preact/hooks';
import { Link } from 'wouter-preact';
import { ApiErrorPanel } from '@hive/shared/api-error-panel.js';
import { readApiError, type ProblemDetails } from '@hive/shared/api-error.js';
import { Panel } from '../ui/panel/Panel.js';
import { TextField } from '../ui/text-field/TextField.js';
import { SelectField, type SelectOption } from '../ui/select-field/SelectField.js';
import { Button } from '../ui/button/Button.js';
import './CreateAgentForm.css';
import { useEffect, useState } from "preact/hooks";
import { Link } from "wouter-preact";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Panel } from "../ui/panel/Panel.js";
import { TextField } from "../ui/text-field/TextField.js";
import {
SelectField,
type SelectOption,
} from "../ui/select-field/SelectField.js";
import { Button } from "../ui/button/Button.js";
import "./CreateAgentForm.css";
// Mirrors swarm-controller's `HiveEntry` — same shape `HivesPage`
// consumes off `/api/hives/status`, but this page hits the plain
@ -49,10 +52,10 @@ interface CreateAgentResponse {
}
type SubmitState =
| { status: 'idle' }
| { status: 'submitting' }
| { status: 'done'; nodeId: number }
| { status: 'error'; problem: ProblemDetails };
| { status: "idle" }
| { status: "submitting" }
| { status: "done"; nodeId: number }
| { status: "error"; problem: ProblemDetails };
// Client-side mirror of `hive_types::Ident` (1-63 chars of `[a-z0-9-]`) —
// a `pattern` hint only, not a substitute for the server's own
@ -67,24 +70,24 @@ type SubmitState =
// Reproduced directly: `new RegExp('[a-z0-9-]', 'v')` throws,
// `new RegExp('[a-z0-9\\-]', 'v')` doesn't — confirmed via mara's console
// exception report on this page.
const NAME_PATTERN = '[a-z0-9\\-]{1,63}';
const NAME_PATTERN = "[a-z0-9\\-]{1,63}";
export function CreateAgentForm() {
const [name, setName] = useState('');
const [hive, setHive] = useState('');
const [name, setName] = useState("");
const [hive, setHive] = useState("");
// `null` = still loading, `[]` = loaded but empty (a real, if unusual,
// swarm state) — distinct from "not fetched yet" so the placeholder
// option's label can tell the two apart.
const [hives, setHives] = useState<HiveEntry[] | null>(null);
const [hivesError, setHivesError] = useState<ProblemDetails | null>(null);
const [result, setResult] = useState<SubmitState>({ status: 'idle' });
const [result, setResult] = useState<SubmitState>({ status: "idle" });
// Loaded once, not re-fetched on submit: the roster changes rarely
// enough (a nix-level swarm config change) that staleness within one
// page visit isn't worth a second round-trip per keystroke/submit.
useEffect(() => {
(async () => {
const r = await fetch('/api/hives');
const r = await fetch("/api/hives");
if (!r.ok) {
setHivesError(await readApiError(r));
return;
@ -99,27 +102,30 @@ export function CreateAgentForm() {
})().catch((e: unknown) => setHivesError({ detail: String(e) }));
}, []);
const hiveOptions: SelectOption[] = (hives ?? []).map((h) => ({ value: h.name, label: h.name }));
const hiveOptions: SelectOption[] = (hives ?? []).map((h) => ({
value: h.name,
label: h.name,
}));
const hivesReady = hives !== null && hives.length > 0;
async function submit(e: Event) {
e.preventDefault();
setResult({ status: 'submitting' });
setResult({ status: "submitting" });
try {
const r = await fetch('/api/agents', {
method: 'POST',
headers: { 'content-type': 'application/json' },
const r = await fetch("/api/agents", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name, hive }),
});
if (!r.ok) {
setResult({ status: 'error', problem: await readApiError(r) });
setResult({ status: "error", problem: await readApiError(r) });
return;
}
const data = (await r.json()) as CreateAgentResponse;
setResult({ status: 'done', nodeId: data.node_id });
setName('');
setResult({ status: "done", nodeId: data.node_id });
setName("");
} catch (err) {
setResult({ status: 'error', problem: { detail: String(err) } });
setResult({ status: "error", problem: { detail: String(err) } });
}
}
@ -128,11 +134,14 @@ export function CreateAgentForm() {
<div class="create-agent-form-col">
<Panel title="create agent" icon="🤖">
<p class="create-agent-intro">
Create a new agent's swarm-level identity. This only queues the job — check{' '}
<Link href="/jobs">jobs</Link> to watch it settle.
Create a new agent's swarm-level identity. This only queues the job
check <Link href="/jobs">jobs</Link> to watch it settle.
</p>
{hivesError && (
<ApiErrorPanel context="failed to load the hive list" problem={hivesError} />
<ApiErrorPanel
context="failed to load the hive list"
problem={hivesError}
/>
)}
<form class="create-agent-form" onSubmit={submit}>
<TextField
@ -153,23 +162,28 @@ export function CreateAgentForm() {
required
disabled={!hivesReady}
placeholder={
!hives ? 'loading…' : hives.length === 0 ? 'no hives configured' : 'select a hive'
!hives
? "loading…"
: hives.length === 0
? "no hives configured"
: "select a hive"
}
/>
<Button
variant="primary"
type="submit"
disabled={result.status === 'submitting' || !hivesReady}
disabled={result.status === "submitting" || !hivesReady}
>
{result.status === 'submitting' ? 'creating…' : 'create'}
{result.status === "submitting" ? "creating…" : "create"}
</Button>
</form>
{result.status === 'done' && (
{result.status === "done" && (
<p class="create-agent-result create-agent-result-ok">
queued as job node #{result.nodeId} <Link href="/jobs">watch it in jobs</Link>
queued as job node #{result.nodeId} {" "}
<Link href="/jobs">watch it in jobs</Link>
</p>
)}
{result.status === 'error' && (
{result.status === "error" && (
<ApiErrorPanel context="failed to queue" problem={result.problem} />
)}
</Panel>
@ -182,10 +196,10 @@ export function CreateAgentForm() {
running commentary on partial implementation. */}
<Panel title="what this creates" icon="🪪">
<p>
Submitting this queues everything a new agent needs: a swarm-level identity, a config
repo on the forge with the operator added as a collaborator, and a container running
the agent on the hive you pick above. Watch it all settle on{' '}
<Link href="/jobs">jobs</Link>.
Submitting this queues everything a new agent needs: a swarm-level
identity, a config repo on the forge with the operator added as a
collaborator, and a container running the agent on the hive you pick
above. Watch it all settle on <Link href="/jobs">jobs</Link>.
</p>
</Panel>
</div>

View file

@ -12,20 +12,20 @@
// at mount — no inputs on this page, so a re-fetch clobbering an
// in-progress edit (the hook's stated caller obligation) isn't a live
// concern here.
import { useState } from 'preact/hooks';
import { ApiErrorPanel } from '@hive/shared/api-error-panel.js';
import { readApiError, type ProblemDetails } from '@hive/shared/api-error.js';
import { Badge, type BadgeTone } from '@hive/shared/badge.js';
import { Panel } from '../ui/panel/Panel.js';
import { RelativeTime } from '../ui/relative-time/RelativeTime.js';
import { useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
import { Panel } from "../ui/panel/Panel.js";
import { RelativeTime } from "../ui/relative-time/RelativeTime.js";
import {
RefreshIntervalPicker,
useRefreshInterval,
type RefreshIntervalMs,
} from '../ui/refresh-interval/RefreshInterval.js';
import { Table, type TableColumn } from '../ui/table/Table.js';
} from "../ui/refresh-interval/RefreshInterval.js";
import { Table, type TableColumn } from "../ui/table/Table.js";
type Freshness = 'fresh' | 'stale' | 'never_reported' | 'unknown';
type Freshness = "fresh" | "stale" | "never_reported" | "unknown";
interface HiveStatus {
name: string;
@ -42,29 +42,29 @@ interface HiveStatus {
// from the roster) reads `negative` — it's the one case this endpoint
// cannot vouch for at all, per `status.rs`'s doc comment.
const FRESHNESS: Record<Freshness, { tone: BadgeTone; label: string }> = {
fresh: { tone: 'positive', label: 'fresh' },
stale: { tone: 'warning', label: 'stale' },
never_reported: { tone: 'neutral', label: 'never reported' },
unknown: { tone: 'negative', label: 'unknown' },
fresh: { tone: "positive", label: "fresh" },
stale: { tone: "warning", label: "stale" },
never_reported: { tone: "neutral", label: "never reported" },
unknown: { tone: "negative", label: "unknown" },
};
const COLUMNS: TableColumn<HiveStatus>[] = [
{ key: 'name', header: 'name', render: (h) => h.name },
{ key: "name", header: "name", render: (h) => h.name },
{
key: 'domain',
header: 'domain',
key: "domain",
header: "domain",
render: (h) =>
h.domain ? (
<a href={`https://${h.domain}/`} target="_blank" rel="noreferrer">
{h.domain}
</a>
) : (
'—'
"—"
),
},
{
key: 'status',
header: 'status',
key: "status",
header: "status",
render: (h) => {
const { tone, label } = FRESHNESS[h.freshness];
return (
@ -95,11 +95,12 @@ const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
export function HivesPage() {
const [hives, setHives] = useState<HiveStatus[] | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null);
const [intervalMs, setIntervalMs] = useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
const [intervalMs, setIntervalMs] =
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
useRefreshInterval(intervalMs, () => {
(async () => {
const r = await fetch('/api/hives/status');
const r = await fetch("/api/hives/status");
if (!r.ok) {
setError(await readApiError(r));
return;
@ -116,11 +117,24 @@ export function HivesPage() {
<Panel
title="hives"
icon="🐝"
actions={<RefreshIntervalPicker id="hives-refresh" value={intervalMs} onChange={setIntervalMs} />}
actions={
<RefreshIntervalPicker
id="hives-refresh"
value={intervalMs}
onChange={setIntervalMs}
/>
}
>
{error ? <ApiErrorPanel context="failed to load the hive roster" problem={error} /> : null}
{error ? (
<ApiErrorPanel
context="failed to load the hive roster"
problem={error}
/>
) : null}
{!error && hives === null ? <p>loading</p> : null}
{hives ? <Table columns={COLUMNS} rows={hives} rowKey={(h) => h.name} /> : null}
{hives ? (
<Table columns={COLUMNS} rows={hives} rowKey={(h) => h.name} />
) : null}
</Panel>
);
}

View file

@ -26,15 +26,15 @@
// URL params. `labelFilter` is `string[]`, not `Set<string>`: a `Set`
// serializes to `"{}"` through `JSON.stringify` and silently loses its
// contents, which is exactly what this hook round-trips through.
import { useEffect, useMemo, useState } from 'preact/hooks';
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 { useLocalSetting } from '@hive/shared/settings-storage.js';
import { Panel } from '../ui/panel/Panel.js';
import { SelectField } from '../ui/select-field/SelectField.js';
import { Table, type TableColumn } from '../ui/table/Table.js';
import './IssueReportPage.css';
import { useEffect, useMemo, useState } from "preact/hooks";
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 { useLocalSetting } from "@hive/shared/settings-storage.js";
import { Panel } from "../ui/panel/Panel.js";
import { SelectField } from "../ui/select-field/SelectField.js";
import { Table, type TableColumn } from "../ui/table/Table.js";
import "./IssueReportPage.css";
interface IssueReportRow {
repo: string;
@ -53,49 +53,53 @@ interface IssueReportRow {
}
type SortKey =
| 'repo'
| 'number'
| 'title'
| 'assignees'
| 'blocked'
| 'depended_on_by_count'
| 'transitively_blocks_count';
type SortDir = 'asc' | 'desc';
| "repo"
| "number"
| "title"
| "assignees"
| "blocked"
| "depended_on_by_count"
| "transitively_blocks_count";
type SortDir = "asc" | "desc";
// Sentinel `<select>` value for "every repo" — `''` can't collide with a
// real `owner/name` value, which always contains a slash.
const ALL_REPOS = '';
const ALL_REPOS = "";
// One key per persisted control, namespaced like the theme/motion keys
// (`swarm-ui:issue-report:…`) so nothing else on the page — or a future
// page — collides with these by accident.
const REPO_FILTER_KEY = 'swarm-ui:issue-report:repo-filter';
const HIDE_BLOCKED_KEY = 'swarm-ui:issue-report:hide-blocked';
const LABEL_FILTER_KEY = 'swarm-ui:issue-report:label-filter';
const SORT_KEY_KEY = 'swarm-ui:issue-report:sort-key';
const SORT_DIR_KEY = 'swarm-ui:issue-report:sort-dir';
const REPO_FILTER_KEY = "swarm-ui:issue-report:repo-filter";
const HIDE_BLOCKED_KEY = "swarm-ui:issue-report:hide-blocked";
const LABEL_FILTER_KEY = "swarm-ui:issue-report:label-filter";
const SORT_KEY_KEY = "swarm-ui:issue-report:sort-key";
const SORT_DIR_KEY = "swarm-ui:issue-report:sort-dir";
function splitRepo(repo: string): { org: string; name: string } | null {
const i = repo.indexOf('/');
const i = repo.indexOf("/");
if (i < 0) return null;
return { org: repo.slice(0, i), name: repo.slice(i + 1) };
}
function compareRows(a: IssueReportRow, b: IssueReportRow, key: SortKey): number {
function compareRows(
a: IssueReportRow,
b: IssueReportRow,
key: SortKey,
): number {
switch (key) {
case 'repo':
case "repo":
return a.repo.localeCompare(b.repo);
case 'number':
case "number":
return a.number - b.number;
case 'title':
case "title":
return a.title.localeCompare(b.title);
case 'assignees':
return a.assignees.join(', ').localeCompare(b.assignees.join(', '));
case 'blocked':
case "assignees":
return a.assignees.join(", ").localeCompare(b.assignees.join(", "));
case "blocked":
return Number(a.blocked) - Number(b.blocked);
case 'depended_on_by_count':
case "depended_on_by_count":
return a.depended_on_by_count - b.depended_on_by_count;
case 'transitively_blocks_count':
case "transitively_blocks_count":
return a.transitively_blocks_count - b.transitively_blocks_count;
}
}
@ -115,23 +119,41 @@ function SortHeader({
}) {
const active = sortKey === activeKey;
return (
<button type="button" class="issue-report-sort-btn" onClick={() => onSort(sortKey)}>
<button
type="button"
class="issue-report-sort-btn"
onClick={() => onSort(sortKey)}
>
{label}
{active ? <span aria-hidden="true"> {dir === 'asc' ? '▲' : '▼'}</span> : null}
{active ? (
<span aria-hidden="true"> {dir === "asc" ? "▲" : "▼"}</span>
) : null}
</button>
);
}
export function IssueReportPage() {
const [repos, setRepos] = useState<string[] | null>(null);
const [repoFilter, setRepoFilter] = useLocalSetting(REPO_FILTER_KEY, ALL_REPOS);
const [repoFilter, setRepoFilter] = useLocalSetting(
REPO_FILTER_KEY,
ALL_REPOS,
);
const [rows, setRows] = useState<IssueReportRow[] | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null);
const [loading, setLoading] = useState(false);
const [hideBlocked, setHideBlocked] = useLocalSetting(HIDE_BLOCKED_KEY, false);
const [labelFilter, setLabelFilter] = useLocalSetting<string[]>(LABEL_FILTER_KEY, []);
const [sortKey, setSortKey] = useLocalSetting<SortKey>(SORT_KEY_KEY, 'depended_on_by_count');
const [sortDir, setSortDir] = useLocalSetting<SortDir>(SORT_DIR_KEY, 'desc');
const [hideBlocked, setHideBlocked] = useLocalSetting(
HIDE_BLOCKED_KEY,
false,
);
const [labelFilter, setLabelFilter] = useLocalSetting<string[]>(
LABEL_FILTER_KEY,
[],
);
const [sortKey, setSortKey] = useLocalSetting<SortKey>(
SORT_KEY_KEY,
"depended_on_by_count",
);
const [sortDir, setSortDir] = useLocalSetting<SortDir>(SORT_DIR_KEY, "desc");
// Repo dropdown source, fetched once — this page has no refresh
// cadence, and a repo gaining/losing its first/last open issue between
@ -142,7 +164,7 @@ export function IssueReportPage() {
useEffect(() => {
let cancelled = false;
(async () => {
const r = await fetch('/api/repos');
const r = await fetch("/api/repos");
if (!r.ok) {
if (!cancelled) setError(await readApiError(r));
return;
@ -163,7 +185,9 @@ export function IssueReportPage() {
let cancelled = false;
setLoading(true);
const split = repoFilter ? splitRepo(repoFilter) : null;
const url = split ? `/api/repos/${split.org}/${split.name}/issue-report` : '/api/issue-report';
const url = split
? `/api/repos/${split.org}/${split.name}/issue-report`
: "/api/issue-report";
(async () => {
const r = await fetch(url);
if (!r.ok) {
@ -199,25 +223,28 @@ export function IssueReportPage() {
const visibleRows = useMemo(() => {
let out = rows ?? [];
if (hideBlocked) out = out.filter((r) => !r.blocked);
if (labelFilter.length > 0) out = out.filter((r) => r.labels.some((l) => labelFilter.includes(l)));
if (labelFilter.length > 0)
out = out.filter((r) => r.labels.some((l) => labelFilter.includes(l)));
return [...out].sort((a, b) => {
const c = compareRows(a, b, sortKey);
return sortDir === 'asc' ? c : -c;
return sortDir === "asc" ? c : -c;
});
}, [rows, hideBlocked, labelFilter, sortKey, sortDir]);
function onSort(key: SortKey) {
if (key === sortKey) {
setSortDir(sortDir === 'asc' ? 'desc' : 'asc');
setSortDir(sortDir === "asc" ? "desc" : "asc");
} else {
setSortKey(key);
setSortDir('asc');
setSortDir("asc");
}
}
function toggleLabel(label: string) {
setLabelFilter(
labelFilter.includes(label) ? labelFilter.filter((l) => l !== label) : [...labelFilter, label],
labelFilter.includes(label)
? labelFilter.filter((l) => l !== label)
: [...labelFilter, label],
);
}
@ -225,9 +252,9 @@ export function IssueReportPage() {
// but not the active column, the real direction while it is. Screen
// readers announce this; the ▲/▼ glyph in `SortHeader` is `aria-hidden`
// and carries no information without it.
function ariaSortFor(key: SortKey): 'ascending' | 'descending' | 'none' {
if (sortKey !== key) return 'none';
return sortDir === 'asc' ? 'ascending' : 'descending';
function ariaSortFor(key: SortKey): "ascending" | "descending" | "none" {
if (sortKey !== key) return "none";
return sortDir === "asc" ? "ascending" : "descending";
}
const columns: TableColumn<IssueReportRow>[] = [
@ -235,15 +262,31 @@ export function IssueReportPage() {
// a fixed column set means the table's shape doesn't shift under
// sort/filter state, and it's a free confirmation of what's loaded.
{
key: 'repo',
header: <SortHeader label="repo" sortKey="repo" activeKey={sortKey} dir={sortDir} onSort={onSort} />,
ariaSort: ariaSortFor('repo'),
key: "repo",
header: (
<SortHeader
label="repo"
sortKey="repo"
activeKey={sortKey}
dir={sortDir}
onSort={onSort}
/>
),
ariaSort: ariaSortFor("repo"),
render: (r) => r.repo,
},
{
key: 'number',
header: <SortHeader label="issue" sortKey="number" activeKey={sortKey} dir={sortDir} onSort={onSort} />,
ariaSort: ariaSortFor('number'),
key: "number",
header: (
<SortHeader
label="issue"
sortKey="number"
activeKey={sortKey}
dir={sortDir}
onSort={onSort}
/>
),
ariaSort: ariaSortFor("number"),
render: (r) =>
r.html_url ? (
<a href={r.html_url} target="_blank" rel="noreferrer">
@ -254,32 +297,55 @@ export function IssueReportPage() {
),
},
{
key: 'title',
header: <SortHeader label="title" sortKey="title" activeKey={sortKey} dir={sortDir} onSort={onSort} />,
ariaSort: ariaSortFor('title'),
key: "title",
header: (
<SortHeader
label="title"
sortKey="title"
activeKey={sortKey}
dir={sortDir}
onSort={onSort}
/>
),
ariaSort: ariaSortFor("title"),
render: (r) => r.title,
},
{
key: 'labels',
header: 'labels',
render: (r) => (r.labels.length ? r.labels.join(', ') : '—'),
key: "labels",
header: "labels",
render: (r) => (r.labels.length ? r.labels.join(", ") : "—"),
},
{
key: 'assignees',
key: "assignees",
header: (
<SortHeader label="assignees" sortKey="assignees" activeKey={sortKey} dir={sortDir} onSort={onSort} />
<SortHeader
label="assignees"
sortKey="assignees"
activeKey={sortKey}
dir={sortDir}
onSort={onSort}
/>
),
ariaSort: ariaSortFor('assignees'),
render: (r) => (r.assignees.length ? r.assignees.join(', ') : '—'),
ariaSort: ariaSortFor("assignees"),
render: (r) => (r.assignees.length ? r.assignees.join(", ") : "—"),
},
{
key: 'blocked',
header: <SortHeader label="blocked" sortKey="blocked" activeKey={sortKey} dir={sortDir} onSort={onSort} />,
ariaSort: ariaSortFor('blocked'),
render: (r) => (r.blocked ? <Badge tone="warning" value="blocked" /> : '—'),
key: "blocked",
header: (
<SortHeader
label="blocked"
sortKey="blocked"
activeKey={sortKey}
dir={sortDir}
onSort={onSort}
/>
),
ariaSort: ariaSortFor("blocked"),
render: (r) =>
r.blocked ? <Badge tone="warning" value="blocked" /> : "—",
},
{
key: 'depended_on_by_count',
key: "depended_on_by_count",
header: (
<SortHeader
label="depended on by"
@ -289,11 +355,11 @@ export function IssueReportPage() {
onSort={onSort}
/>
),
ariaSort: ariaSortFor('depended_on_by_count'),
ariaSort: ariaSortFor("depended_on_by_count"),
render: (r) => r.depended_on_by_count,
},
{
key: 'transitively_blocks_count',
key: "transitively_blocks_count",
header: (
<SortHeader
label="transitively blocks"
@ -303,7 +369,7 @@ export function IssueReportPage() {
onSort={onSort}
/>
),
ariaSort: ariaSortFor('transitively_blocks_count'),
ariaSort: ariaSortFor("transitively_blocks_count"),
render: (r) => r.transitively_blocks_count,
},
];
@ -317,7 +383,7 @@ export function IssueReportPage() {
value={repoFilter}
onChange={setRepoFilter}
options={[
{ value: ALL_REPOS, label: 'all repos' },
{ value: ALL_REPOS, label: "all repos" },
...(repos ?? []).map((r) => ({ value: r, label: r })),
]}
/>
@ -325,7 +391,9 @@ export function IssueReportPage() {
<input
type="checkbox"
checked={hideBlocked}
onChange={(e) => setHideBlocked((e.target as HTMLInputElement).checked)}
onChange={(e) =>
setHideBlocked((e.target as HTMLInputElement).checked)
}
/>
hide blocked (open dependency)
</label>
@ -334,13 +402,22 @@ export function IssueReportPage() {
<div class="issue-report-labels">
{allLabels.map((l) => (
<label key={l} class="issue-report-label-chip">
<input type="checkbox" checked={labelFilter.includes(l)} onChange={() => toggleLabel(l)} />
<input
type="checkbox"
checked={labelFilter.includes(l)}
onChange={() => toggleLabel(l)}
/>
{l}
</label>
))}
</div>
) : null}
{error ? <ApiErrorPanel context="failed to load the issue report" problem={error} /> : null}
{error ? (
<ApiErrorPanel
context="failed to load the issue report"
problem={error}
/>
) : null}
{!error && loading ? <p>generating report</p> : null}
{!loading && rows ? (
<Table

View file

@ -18,16 +18,16 @@
// fetch — both `JobqGraph`/`JobqRollup` already accept that prop
// (bumping it is their documented "refetch now" lever) since the
// dashboard's own rebuild-queue view needed the same thing first.
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 { 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 {
RefreshIntervalPicker,
useRefreshInterval,
type RefreshIntervalMs,
} from '../ui/refresh-interval/RefreshInterval.js';
import './JobsPage.css';
} from "../ui/refresh-interval/RefreshInterval.js";
import "./JobsPage.css";
// Same 30s default + same reasoning as `HivesPage`: no inputs on this
// page for a refresh to clobber, so the out-of-the-box behaviour should
@ -35,7 +35,8 @@ import './JobsPage.css';
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
export function JobsPage() {
const [intervalMs, setIntervalMs] = useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
const [intervalMs, setIntervalMs] =
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
const [refreshToken, setRefreshToken] = useState(0);
// `useRefreshInterval` ticks once immediately on mount (by design —
@ -52,7 +53,13 @@ export function JobsPage() {
<Panel
title="jobs"
icon="🧩"
actions={<RefreshIntervalPicker id="jobs-refresh" value={intervalMs} onChange={setIntervalMs} />}
actions={
<RefreshIntervalPicker
id="jobs-refresh"
value={intervalMs}
onChange={setIntervalMs}
/>
}
>
<JobqRollup endpoint="/api/jobq/rollup" refreshToken={refreshToken} />
<JobqGraph endpoint="/api/jobq/graph" refreshToken={refreshToken} />