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:
parent
5d24bedd60
commit
39b95c2ede
203 changed files with 10090 additions and 6085 deletions
|
|
@ -1,13 +1,13 @@
|
|||
// Root shell component — routing only. Each route's content is its own
|
||||
// page component under `./pages/`; this file just maps paths to them.
|
||||
import { Route, Switch } from 'wouter-preact';
|
||||
import { Shell } from './shell/Shell.js';
|
||||
import { AgentsPage } from './pages/AgentsPage.js';
|
||||
import { ComponentsPage } from './pages/ComponentsPage.js';
|
||||
import { JobsPage } from './pages/JobsPage.js';
|
||||
import { HivesPage } from './pages/HivesPage.js';
|
||||
import { IssueReportPage } from './pages/IssueReportPage.js';
|
||||
import { Panel } from './ui/panel/Panel.js';
|
||||
import { Route, Switch } from "wouter-preact";
|
||||
import { Shell } from "./shell/Shell.js";
|
||||
import { AgentsPage } from "./pages/AgentsPage.js";
|
||||
import { ComponentsPage } from "./pages/ComponentsPage.js";
|
||||
import { JobsPage } from "./pages/JobsPage.js";
|
||||
import { HivesPage } from "./pages/HivesPage.js";
|
||||
import { IssueReportPage } from "./pages/IssueReportPage.js";
|
||||
import { Panel } from "./ui/panel/Panel.js";
|
||||
|
||||
function NotFound() {
|
||||
return (
|
||||
|
|
|
|||
2
frontend/packages/swarm-ui/src/css.d.ts
vendored
2
frontend/packages/swarm-ui/src/css.d.ts
vendored
|
|
@ -1,4 +1,4 @@
|
|||
// Ambient module for `import './Foo.css'` side-effect imports (esbuild
|
||||
// resolves these directly, see build.mjs; tsc otherwise has no idea what
|
||||
// a `.css` specifier is and refuses the whole side-effect import).
|
||||
declare module '*.css';
|
||||
declare module "*.css";
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>hyperhive swarm</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="/static/colors.css">
|
||||
<link rel="stylesheet" href="/static/theme.css">
|
||||
<link rel="stylesheet" href="/static/swarm-ui.css">
|
||||
<link rel="stylesheet" href="/static/main.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/static/main.js"></script>
|
||||
</body>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>hyperhive swarm</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="stylesheet" href="/static/colors.css" />
|
||||
<link rel="stylesheet" href="/static/theme.css" />
|
||||
<link rel="stylesheet" href="/static/swarm-ui.css" />
|
||||
<link rel="stylesheet" href="/static/main.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/static/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { render } from 'preact';
|
||||
import { App } from './App.js';
|
||||
import { render } from "preact";
|
||||
import { App } from "./App.js";
|
||||
|
||||
const root = document.getElementById('root');
|
||||
const root = document.getElementById("root");
|
||||
if (root) {
|
||||
render(<App />, root);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
cursor: pointer;
|
||||
}
|
||||
.links-menu-button:hover,
|
||||
.links-menu-button[aria-expanded='true'] {
|
||||
.links-menu-button[aria-expanded="true"] {
|
||||
border-color: var(--border);
|
||||
background: var(--bg-elev);
|
||||
}
|
||||
|
|
@ -65,10 +65,10 @@
|
|||
animation: none;
|
||||
}
|
||||
}
|
||||
:root[data-motion='reduce'] .links-menu-popover {
|
||||
:root[data-motion="reduce"] .links-menu-popover {
|
||||
animation: none;
|
||||
}
|
||||
:root[data-motion='allow'] .links-menu-popover {
|
||||
:root[data-motion="allow"] .links-menu-popover {
|
||||
animation: links-menu-popover-enter 140ms ease;
|
||||
}
|
||||
.links-menu-item {
|
||||
|
|
|
|||
|
|
@ -11,9 +11,9 @@
|
|||
// hides the button rather than showing an empty popover, same "don't
|
||||
// render a dead affordance" rule the old dashboard's H0M3 tiles follow
|
||||
// for Forge/Matrix.
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { LinkIcon } from '@hive/shared/icons.js';
|
||||
import './LinksMenu.css';
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import { LinkIcon } from "@hive/shared/icons.js";
|
||||
import "./LinksMenu.css";
|
||||
|
||||
interface ServiceLink {
|
||||
label: string;
|
||||
|
|
@ -27,8 +27,12 @@ export function LinksMenu() {
|
|||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/links')
|
||||
.then((r) => (r.ok ? (r.json() as Promise<ServiceLink[]>) : Promise.reject(new Error(`http ${r.status}`))))
|
||||
fetch("/api/links")
|
||||
.then((r) =>
|
||||
r.ok
|
||||
? (r.json() as Promise<ServiceLink[]>)
|
||||
: Promise.reject(new Error(`http ${r.status}`)),
|
||||
)
|
||||
.then(setLinks)
|
||||
.catch(() => setLinks([])); // best-effort: no button beats a broken one
|
||||
}, []);
|
||||
|
|
@ -38,16 +42,17 @@ export function LinksMenu() {
|
|||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onPointerDown(e: MouseEvent) {
|
||||
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
|
||||
if (rootRef.current && !rootRef.current.contains(e.target as Node))
|
||||
setOpen(false);
|
||||
}
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
}
|
||||
document.addEventListener('pointerdown', onPointerDown);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
document.addEventListener("pointerdown", onPointerDown);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onPointerDown);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
document.removeEventListener("pointerdown", onPointerDown);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
|
|
|
|||
|
|
@ -47,10 +47,10 @@
|
|||
transition: none;
|
||||
}
|
||||
}
|
||||
:root[data-motion='reduce'] .shell-brand {
|
||||
:root[data-motion="reduce"] .shell-brand {
|
||||
transition: none;
|
||||
}
|
||||
:root[data-motion='allow'] .shell-brand {
|
||||
:root[data-motion="allow"] .shell-brand {
|
||||
transition: color 140ms ease;
|
||||
}
|
||||
.shell-nav {
|
||||
|
|
@ -118,10 +118,10 @@
|
|||
transition: none;
|
||||
}
|
||||
}
|
||||
:root[data-motion='reduce'] .shell-nav-indicator {
|
||||
:root[data-motion="reduce"] .shell-nav-indicator {
|
||||
transition: none;
|
||||
}
|
||||
:root[data-motion='allow'] .shell-nav-indicator {
|
||||
:root[data-motion="allow"] .shell-nav-indicator {
|
||||
transition:
|
||||
left 140ms ease,
|
||||
top 140ms ease,
|
||||
|
|
@ -170,9 +170,9 @@
|
|||
animation: none;
|
||||
}
|
||||
}
|
||||
:root[data-motion='reduce'] .shell-body {
|
||||
:root[data-motion="reduce"] .shell-body {
|
||||
animation: none;
|
||||
}
|
||||
:root[data-motion='allow'] .shell-body {
|
||||
:root[data-motion="allow"] .shell-body {
|
||||
animation: shell-page-enter 160ms ease;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,30 +26,30 @@
|
|||
// the palette follows. `.shell-brand` rides the identical accent value
|
||||
// (`navAccent` below), so the header reads as one accent changing, not
|
||||
// the underline alone.
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import type { ComponentChildren } from 'preact';
|
||||
import { Link, useLocation } from 'wouter-preact';
|
||||
import { LinksMenu } from './LinksMenu.js';
|
||||
import { UserMenu } from './UserMenu.js';
|
||||
import { SettingsMenu } from '@hive/shared/settings-menu.js';
|
||||
import { useApplyThemeOverride } from '@hive/shared/theme-apply.js';
|
||||
import { useApplyMotionOverride } from '@hive/shared/motion-apply.js';
|
||||
import './Shell.css';
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import type { ComponentChildren } from "preact";
|
||||
import { Link, useLocation } from "wouter-preact";
|
||||
import { LinksMenu } from "./LinksMenu.js";
|
||||
import { UserMenu } from "./UserMenu.js";
|
||||
import { SettingsMenu } from "@hive/shared/settings-menu.js";
|
||||
import { useApplyThemeOverride } from "@hive/shared/theme-apply.js";
|
||||
import { useApplyMotionOverride } from "@hive/shared/motion-apply.js";
|
||||
import "./Shell.css";
|
||||
|
||||
// Storage keys this app owns for `@hive/shared`'s settings mechanism —
|
||||
// kept here, not derived, so the two mount points below (the
|
||||
// `useApply*` effects and `<SettingsMenu>`) always agree on which
|
||||
// browser-local key they're reading/writing. Theme defaults to `'dark'`,
|
||||
// not `'system'` — see `@hive/shared/theme-apply.js`'s file comment.
|
||||
const THEME_KEY = 'swarm-ui:theme-override';
|
||||
const MOTION_KEY = 'swarm-ui:motion-override';
|
||||
const THEME_KEY = "swarm-ui:theme-override";
|
||||
const MOTION_KEY = "swarm-ui:motion-override";
|
||||
|
||||
const NAV_ITEMS: { href: string; label: string; accent: string }[] = [
|
||||
{ href: '/', label: 'hives', accent: 'var(--purple)' },
|
||||
{ href: '/agents', label: 'agents', accent: 'var(--green)' },
|
||||
{ href: '/jobs', label: 'jobs', accent: 'var(--pink)' },
|
||||
{ href: '/issues', label: 'issues', accent: 'var(--yellow)' },
|
||||
{ href: '/components', label: 'components', accent: 'var(--blue)' },
|
||||
{ href: "/", label: "hives", accent: "var(--purple)" },
|
||||
{ href: "/agents", label: "agents", accent: "var(--green)" },
|
||||
{ href: "/jobs", label: "jobs", accent: "var(--pink)" },
|
||||
{ href: "/issues", label: "issues", accent: "var(--yellow)" },
|
||||
{ href: "/components", label: "components", accent: "var(--blue)" },
|
||||
];
|
||||
|
||||
// Routes that opt out of `.shell-body`'s 60em readable-line-length cap
|
||||
|
|
@ -60,13 +60,13 @@ const NAV_ITEMS: { href: string; label: string; accent: string }[] = [
|
|||
// route's layout is untouched — first user of this allowlist, but a
|
||||
// future wide page reaches for the same class rather than inventing
|
||||
// its own cap.
|
||||
const WIDE_BODY_ROUTES = new Set(['/issues']);
|
||||
const WIDE_BODY_ROUTES = new Set(["/issues"]);
|
||||
|
||||
// Static fallback — matches `index.html`'s `<title>` default, so a page
|
||||
// never flashes something else before the fetch below resolves, and an
|
||||
// operator who never set `services.hyperhive.swarm.name` sees the exact
|
||||
// same generic label the pre-fetch page already showed, not a blank.
|
||||
const DEFAULT_BRAND = 'hyperhive swarm';
|
||||
const DEFAULT_BRAND = "hyperhive swarm";
|
||||
|
||||
interface IndicatorRect {
|
||||
left: number;
|
||||
|
|
@ -90,9 +90,9 @@ const HOP_MS = 140;
|
|||
// override semantics `@hive/shared/motion-apply.js` establishes.
|
||||
function prefersReducedMotion(): boolean {
|
||||
const override = document.documentElement.dataset.motion;
|
||||
if (override === 'reduce') return true;
|
||||
if (override === 'allow') return false;
|
||||
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
if (override === "reduce") return true;
|
||||
if (override === "allow") return false;
|
||||
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
}
|
||||
|
||||
function NavLink({
|
||||
|
|
@ -115,7 +115,7 @@ function NavLink({
|
|||
than the full-height tappable box. */}
|
||||
<span
|
||||
ref={textRef}
|
||||
class={'shell-nav-link-text' + (active ? ' shell-nav-link-active' : '')}
|
||||
class={"shell-nav-link-text" + (active ? " shell-nav-link-active" : "")}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
|
|
@ -233,8 +233,8 @@ export function Shell({ children }: { children: ComponentChildren }) {
|
|||
if (settledIndexRef.current === -1) return;
|
||||
setIndicator(measureIndex(settledIndexRef.current));
|
||||
}
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, []);
|
||||
|
||||
// Fetched once here, not per-page: every route mounts inside one
|
||||
|
|
@ -245,7 +245,7 @@ export function Shell({ children }: { children: ComponentChildren }) {
|
|||
// not worth an `ApiErrorPanel`.
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const r = await fetch('/api/swarm');
|
||||
const r = await fetch("/api/swarm");
|
||||
if (!r.ok) return;
|
||||
const data = (await r.json()) as { name: string | null };
|
||||
if (data.name) setSwarmName(data.name);
|
||||
|
|
@ -263,7 +263,7 @@ export function Shell({ children }: { children: ComponentChildren }) {
|
|||
// the brand text rides the identical hop sequence rather than
|
||||
// computing its own, so "the header's accent" reads as one thing
|
||||
// changing, not two things that happen to agree at rest.
|
||||
const navAccent = indicator?.accent ?? 'var(--purple)';
|
||||
const navAccent = indicator?.accent ?? "var(--purple)";
|
||||
|
||||
return (
|
||||
<div class="shell">
|
||||
|
|
@ -288,12 +288,15 @@ export function Shell({ children }: { children: ComponentChildren }) {
|
|||
nav item (404), so it doesn't pop in with a stale position
|
||||
the next time a real nav item becomes active. */}
|
||||
<span
|
||||
class={'shell-nav-indicator' + (indicatorSettledOnce ? '' : ' shell-nav-indicator-instant')}
|
||||
class={
|
||||
"shell-nav-indicator" +
|
||||
(indicatorSettledOnce ? "" : " shell-nav-indicator-instant")
|
||||
}
|
||||
style={{
|
||||
left: `${indicator?.left ?? 0}px`,
|
||||
top: `${indicator?.top ?? 0}px`,
|
||||
width: `${indicator?.width ?? 0}px`,
|
||||
backgroundColor: indicator?.accent ?? 'transparent',
|
||||
backgroundColor: indicator?.accent ?? "transparent",
|
||||
}}
|
||||
/>
|
||||
</nav>
|
||||
|
|
@ -312,7 +315,13 @@ export function Shell({ children }: { children: ComponentChildren }) {
|
|||
`shell-body-wide` is additive (see `WIDE_BODY_ROUTES` above),
|
||||
not a swap — `.shell-body`'s padding/animation rules still
|
||||
apply, only the max-width cap changes. */}
|
||||
<div class={'shell-body' + (WIDE_BODY_ROUTES.has(location) ? ' shell-body-wide' : '')} key={location}>
|
||||
<div
|
||||
class={
|
||||
"shell-body" +
|
||||
(WIDE_BODY_ROUTES.has(location) ? " shell-body-wide" : "")
|
||||
}
|
||||
key={location}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@
|
|||
text-shadow: 0 0.05em 0.15em rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.user-menu-button:hover,
|
||||
.user-menu-button[aria-expanded='true'] {
|
||||
.user-menu-button[aria-expanded="true"] {
|
||||
border-color: var(--fg);
|
||||
}
|
||||
.user-menu-popover {
|
||||
|
|
@ -61,10 +61,10 @@
|
|||
animation: none;
|
||||
}
|
||||
}
|
||||
:root[data-motion='reduce'] .user-menu-popover {
|
||||
:root[data-motion="reduce"] .user-menu-popover {
|
||||
animation: none;
|
||||
}
|
||||
:root[data-motion='allow'] .user-menu-popover {
|
||||
:root[data-motion="allow"] .user-menu-popover {
|
||||
animation: user-menu-popover-enter 140ms ease;
|
||||
}
|
||||
.user-menu-name {
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@
|
|||
// second source of the domain — `/settings` and `/logout` are appended
|
||||
// client-side, both real authelia routes (mara confirmed `/settings` is
|
||||
// the real path after the plain domain-root link landed short of it).
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import './UserMenu.css';
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import "./UserMenu.css";
|
||||
|
||||
interface WhoAmI {
|
||||
displayName: string | null;
|
||||
|
|
@ -39,18 +39,19 @@ interface ServiceLink {
|
|||
// not imported from there directly: that list is route accents, this is
|
||||
// a name→colour pick, different domains that happen to share a palette.
|
||||
const AVATAR_ACCENTS = [
|
||||
'var(--red)',
|
||||
'var(--yellow)',
|
||||
'var(--green)',
|
||||
'var(--cyan)',
|
||||
'var(--blue)',
|
||||
'var(--purple)',
|
||||
'var(--pink)',
|
||||
"var(--red)",
|
||||
"var(--yellow)",
|
||||
"var(--green)",
|
||||
"var(--cyan)",
|
||||
"var(--blue)",
|
||||
"var(--purple)",
|
||||
"var(--pink)",
|
||||
];
|
||||
|
||||
function pickAccent(seed: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < seed.length; i++) hash = (hash * 31 + seed.charCodeAt(i)) | 0;
|
||||
for (let i = 0; i < seed.length; i++)
|
||||
hash = (hash * 31 + seed.charCodeAt(i)) | 0;
|
||||
return AVATAR_ACCENTS[Math.abs(hash) % AVATAR_ACCENTS.length];
|
||||
}
|
||||
|
||||
|
|
@ -61,9 +62,11 @@ function pickAccent(seed: string): string {
|
|||
function parseWhoAmI(body: unknown): WhoAmI {
|
||||
const record = (body ?? {}) as Record<string, unknown>;
|
||||
const data = (record.data ?? record) as Record<string, unknown>;
|
||||
const displayName = typeof data.display_name === 'string' ? data.display_name : null;
|
||||
const displayName =
|
||||
typeof data.display_name === "string" ? data.display_name : null;
|
||||
const emails = Array.isArray(data.emails) ? data.emails : null;
|
||||
const email = emails && typeof emails[0] === 'string' ? (emails[0] as string) : null;
|
||||
const email =
|
||||
emails && typeof emails[0] === "string" ? (emails[0] as string) : null;
|
||||
return { displayName, email };
|
||||
}
|
||||
|
||||
|
|
@ -74,8 +77,10 @@ export function UserMenu() {
|
|||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/whoami')
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`http ${r.status}`))))
|
||||
fetch("/api/whoami")
|
||||
.then((r) =>
|
||||
r.ok ? r.json() : Promise.reject(new Error(`http ${r.status}`)),
|
||||
)
|
||||
.then((body) => setWho(parseWhoAmI(body)))
|
||||
.catch(() => setWho(null)); // cosmetic only — see file-top comment
|
||||
}, []);
|
||||
|
|
@ -83,10 +88,14 @@ export function UserMenu() {
|
|||
// Reuses `LinksMenu`'s own data source rather than a second endpoint —
|
||||
// see file-top comment.
|
||||
useEffect(() => {
|
||||
fetch('/api/links')
|
||||
.then((r) => (r.ok ? (r.json() as Promise<ServiceLink[]>) : Promise.reject(new Error(`http ${r.status}`))))
|
||||
fetch("/api/links")
|
||||
.then((r) =>
|
||||
r.ok
|
||||
? (r.json() as Promise<ServiceLink[]>)
|
||||
: Promise.reject(new Error(`http ${r.status}`)),
|
||||
)
|
||||
.then((links) => {
|
||||
const authelia = links.find((l) => l.label === 'Authelia');
|
||||
const authelia = links.find((l) => l.label === "Authelia");
|
||||
if (authelia) setAutheliaUrl(authelia.url);
|
||||
})
|
||||
.catch(() => {
|
||||
|
|
@ -99,27 +108,28 @@ export function UserMenu() {
|
|||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onPointerDown(e: MouseEvent) {
|
||||
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
|
||||
if (rootRef.current && !rootRef.current.contains(e.target as Node))
|
||||
setOpen(false);
|
||||
}
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
}
|
||||
document.addEventListener('pointerdown', onPointerDown);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
document.addEventListener("pointerdown", onPointerDown);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onPointerDown);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
document.removeEventListener("pointerdown", onPointerDown);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const label = who?.displayName || who?.email || null;
|
||||
const initial = label ? label.trim().charAt(0).toUpperCase() : '?';
|
||||
const accent = pickAccent(label ?? 'operator');
|
||||
const initial = label ? label.trim().charAt(0).toUpperCase() : "?";
|
||||
const accent = pickAccent(label ?? "operator");
|
||||
// Trailing slash normalised off so `/logout` appends cleanly
|
||||
// regardless of whether the contributed link kept one (the `Authelia`
|
||||
// entry does — see `swarm-authelia.nix` — but this isn't the only
|
||||
// conceivable shape a future contributor's own URL could take).
|
||||
const base = autheliaUrl?.replace(/\/+$/, '') ?? null;
|
||||
const base = autheliaUrl?.replace(/\/+$/, "") ?? null;
|
||||
|
||||
return (
|
||||
<div class="user-menu" ref={rootRef}>
|
||||
|
|
@ -129,7 +139,7 @@ export function UserMenu() {
|
|||
style={{ background: accent }}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open}
|
||||
aria-label={label ? `signed in as ${label}` : 'account'}
|
||||
aria-label={label ? `signed in as ${label}` : "account"}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
{initial}
|
||||
|
|
@ -149,7 +159,12 @@ export function UserMenu() {
|
|||
>
|
||||
authelia settings
|
||||
</a>
|
||||
<a class="user-menu-item" href={`${base}/logout`} role="menuitem" onClick={() => setOpen(false)}>
|
||||
<a
|
||||
class="user-menu-item"
|
||||
href={`${base}/logout`}
|
||||
role="menuitem"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
log out
|
||||
</a>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -4,27 +4,27 @@
|
|||
// `'default'` (everything else) — two is enough for every real caller
|
||||
// so far; a third reading gets added the day something needs it, not
|
||||
// speculatively.
|
||||
import type { ComponentChildren } from 'preact';
|
||||
import './Button.css';
|
||||
import type { ComponentChildren } from "preact";
|
||||
import "./Button.css";
|
||||
|
||||
export type ButtonVariant = 'primary' | 'default';
|
||||
export type ButtonVariant = "primary" | "default";
|
||||
|
||||
export function Button({
|
||||
variant = 'default',
|
||||
type = 'button',
|
||||
variant = "default",
|
||||
type = "button",
|
||||
disabled,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
variant?: ButtonVariant;
|
||||
type?: 'button' | 'submit';
|
||||
type?: "button" | "submit";
|
||||
disabled?: boolean;
|
||||
onClick?: (e: Event) => void;
|
||||
children: ComponentChildren;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
class={'ui-button ui-button-' + variant}
|
||||
class={"ui-button ui-button-" + variant}
|
||||
type={type}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
|
|
|
|||
|
|
@ -10,9 +10,9 @@
|
|||
// from its former life as a standalone route — reusing that unchanged
|
||||
// is simpler and lower-risk than re-deriving a modal-specific layout
|
||||
// for content mara has already reviewed).
|
||||
import { useEffect, useRef } from 'preact/hooks';
|
||||
import type { ComponentChildren } from 'preact';
|
||||
import './Dialog.css';
|
||||
import { useEffect, useRef } from "preact/hooks";
|
||||
import type { ComponentChildren } from "preact";
|
||||
import "./Dialog.css";
|
||||
|
||||
export function Dialog({
|
||||
open,
|
||||
|
|
@ -50,8 +50,8 @@ export function Dialog({
|
|||
function handleClose() {
|
||||
onClose();
|
||||
}
|
||||
el.addEventListener('close', handleClose);
|
||||
return () => el.removeEventListener('close', handleClose);
|
||||
el.addEventListener("close", handleClose);
|
||||
return () => el.removeEventListener("close", handleClose);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
|
|
@ -67,7 +67,12 @@ export function Dialog({
|
|||
if (e.target === ref.current) onClose();
|
||||
}}
|
||||
>
|
||||
<button type="button" class="ui-dialog-close" aria-label="close" onClick={onClose}>
|
||||
<button
|
||||
type="button"
|
||||
class="ui-dialog-close"
|
||||
aria-label="close"
|
||||
onClick={onClose}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<div class="ui-dialog-body">{children}</div>
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@
|
|||
// don't each reinvent the label/spacing chrome; a bare labelled `<div>`
|
||||
// wrapper has no independent identity worth a `/components` entry of
|
||||
// its own.
|
||||
import type { ComponentChildren } from 'preact';
|
||||
import './FormField.css';
|
||||
import type { ComponentChildren } from "preact";
|
||||
import "./FormField.css";
|
||||
|
||||
export function FormField({
|
||||
label,
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@
|
|||
// hardcoding. `aria-hidden` — decorative only, the title text still
|
||||
// carries the actual label, so this never becomes a second source of
|
||||
// truth an assistive-tech user has to parse.
|
||||
import type { ComponentChildren } from 'preact';
|
||||
import './Panel.css';
|
||||
import type { ComponentChildren } from "preact";
|
||||
import "./Panel.css";
|
||||
|
||||
export function Panel({
|
||||
title,
|
||||
|
|
|
|||
|
|
@ -25,19 +25,19 @@
|
|||
// own fetch callback, and it's the CALLER's job to make sure that
|
||||
// callback doesn't clobber an input the operator is mid-edit on — this
|
||||
// hook only decides *when* to call it.
|
||||
import { useEffect, useRef } from 'preact/hooks';
|
||||
import './RefreshInterval.css';
|
||||
import { useEffect, useRef } from "preact/hooks";
|
||||
import "./RefreshInterval.css";
|
||||
|
||||
// `null` means "off" throughout this module — no separate boolean, so
|
||||
// there's exactly one way to represent "not polling".
|
||||
export type RefreshIntervalMs = number | null;
|
||||
|
||||
const PRESETS: { value: RefreshIntervalMs; label: string }[] = [
|
||||
{ value: null, label: 'off' },
|
||||
{ value: 5_000, label: '5s' },
|
||||
{ value: 10_000, label: '10s' },
|
||||
{ value: 30_000, label: '30s' },
|
||||
{ value: 60_000, label: '1m' },
|
||||
{ value: null, label: "off" },
|
||||
{ value: 5_000, label: "5s" },
|
||||
{ value: 10_000, label: "10s" },
|
||||
{ value: 30_000, label: "30s" },
|
||||
{ value: 60_000, label: "1m" },
|
||||
];
|
||||
|
||||
export function RefreshIntervalPicker({
|
||||
|
|
@ -94,7 +94,10 @@ export function RefreshIntervalPicker({
|
|||
// should default its own `intervalMs` state to a real cadence, not
|
||||
// `null`, and add a separate mount effect only if it genuinely wants
|
||||
// "off" to still mean "load once."
|
||||
export function useRefreshInterval(intervalMs: RefreshIntervalMs, onTick: () => void) {
|
||||
export function useRefreshInterval(
|
||||
intervalMs: RefreshIntervalMs,
|
||||
onTick: () => void,
|
||||
) {
|
||||
// Always-current via a ref rather than a `useEffect` dependency:
|
||||
// callers pass an inline closure that's a new value every render, and
|
||||
// depending on it directly would re-arm the timer (losing whatever's
|
||||
|
|
@ -118,15 +121,15 @@ export function useRefreshInterval(intervalMs: RefreshIntervalMs, onTick: () =>
|
|||
id = undefined;
|
||||
};
|
||||
|
||||
if (document.visibilityState === 'visible') start();
|
||||
if (document.visibilityState === "visible") start();
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === 'visible') start();
|
||||
if (document.visibilityState === "visible") start();
|
||||
else stop();
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
return () => {
|
||||
stop();
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
};
|
||||
}, [intervalMs]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@
|
|||
// running for no visible benefit, same instinct as the dashboard
|
||||
// package's matrix-rain effect — and resyncs immediately on becoming
|
||||
// visible again instead of waiting out the rest of a stale interval.
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { fmtAgo } from '../../util.js';
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { fmtAgo } from "../../util.js";
|
||||
|
||||
// Cheap at the handful of rows/chips this renders for today, and coarse
|
||||
// enough to be correct without visibly ticking every frame — the same
|
||||
|
|
@ -31,15 +31,15 @@ export function RelativeTime({ epochMs }: { epochMs: number }) {
|
|||
id = undefined;
|
||||
};
|
||||
|
||||
if (document.visibilityState === 'visible') start();
|
||||
if (document.visibilityState === "visible") start();
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === 'visible') start();
|
||||
if (document.visibilityState === "visible") start();
|
||||
else stop();
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
return () => {
|
||||
stop();
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
// value/label pairs, not `ComponentChildren` — every real caller so far
|
||||
// has flat string options, and generic children would need a second
|
||||
// primitive (`SelectField.Option`) for zero real benefit today.
|
||||
import { FormField } from '../form-field/FormField.js';
|
||||
import { FormField } from "../form-field/FormField.js";
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@
|
|||
// a `<table>` doesn't shrink below its content's natural width on its
|
||||
// own, so without this the page itself would break, not just look
|
||||
// cramped.
|
||||
import type { ComponentChildren } from 'preact';
|
||||
import './Table.css';
|
||||
import type { ComponentChildren } from "preact";
|
||||
import "./Table.css";
|
||||
|
||||
export interface TableColumn<T> {
|
||||
key: string;
|
||||
|
|
@ -30,7 +30,7 @@ export interface TableColumn<T> {
|
|||
* sortable header renders is `aria-hidden` and carries no information
|
||||
* on its own.
|
||||
*/
|
||||
ariaSort?: 'ascending' | 'descending' | 'none';
|
||||
ariaSort?: "ascending" | "descending" | "none";
|
||||
}
|
||||
|
||||
export function Table<T>({
|
||||
|
|
|
|||
|
|
@ -3,14 +3,14 @@
|
|||
// of hand-rolling its own label/input pair. No textarea/multi-line
|
||||
// mode — promote that the day a real caller needs one, same "don't
|
||||
// build ahead of a caller" rule the rest of `ui/` follows.
|
||||
import { FormField } from '../form-field/FormField.js';
|
||||
import { FormField } from "../form-field/FormField.js";
|
||||
|
||||
export function TextField({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onInput,
|
||||
type = 'text',
|
||||
type = "text",
|
||||
pattern,
|
||||
title,
|
||||
required,
|
||||
|
|
|
|||
|
|
@ -8,12 +8,15 @@
|
|||
// with `style: 'narrow'`, which produces the same "5s/5m/5h/5d ago"
|
||||
// shape the dashboard package's hand-rolled equivalent does, verified
|
||||
// against a real `Intl` call rather than assumed from the spec.
|
||||
const RTF = new Intl.RelativeTimeFormat('en', { numeric: 'always', style: 'narrow' });
|
||||
const RTF = new Intl.RelativeTimeFormat("en", {
|
||||
numeric: "always",
|
||||
style: "narrow",
|
||||
});
|
||||
|
||||
export function fmtAgo(ageSeconds: number): string {
|
||||
const age = Math.max(0, Math.floor(ageSeconds));
|
||||
if (age < 60) return RTF.format(-age, 'second');
|
||||
if (age < 3600) return RTF.format(-Math.floor(age / 60), 'minute');
|
||||
if (age < 86400) return RTF.format(-Math.floor(age / 3600), 'hour');
|
||||
return RTF.format(-Math.floor(age / 86400), 'day');
|
||||
if (age < 60) return RTF.format(-age, "second");
|
||||
if (age < 3600) return RTF.format(-Math.floor(age / 60), "minute");
|
||||
if (age < 86400) return RTF.format(-Math.floor(age / 3600), "hour");
|
||||
return RTF.format(-Math.floor(age / 86400), "day");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue