swarm-ui: add required hive dropdown to CreateAgentPage

Closes #3434.

Agent creation had no way to record which hive an agent runs on.
POST /api/agents now requires a hive, so the roster fetched off
GET /api/hives backs a required SelectField here rather than a
free-text field. Single-hive swarms auto-select their only hive;
multi-hive swarms show a disabled placeholder and force an explicit
choice.

Rebuilt on top of the #3448 form kit (merged after this branch was
originally opened): reuses TextField/SelectField/Button instead of
page-scoped input chrome, and SelectField gains an optional disabled
placeholder option (needed for the loading/empty/multi-hive states
here, generalizes cleanly for future callers). Form goes back to a
column layout per mara's earlier visual feedback on this same page
(two fields of different natural width no longer line up in a row).
This commit is contained in:
iris 2026-08-18 20:34:27 +02:00 committed by mara
commit 5fdbf0b452
3 changed files with 86 additions and 12 deletions

View file

@ -1,16 +1,19 @@
/* <CreateAgentPage> the create-agent form. Input/button chrome now
comes from the shared `ui/` form kit (`TextField`/`Button`); this
file only owns the page's own layout + copy. Reuses the same base16
slots (../theme.css) every other component draws from. */
comes from the shared `ui/` form kit (`TextField`/`SelectField`/
`Button`); this file only owns the page's own layout + copy. Column,
not row: with a second field (hive) added, a row layout put fields of
different natural widths on one baseline and looked misaligned
mara: "make it a col". Reuses the same base16 slots (../theme.css)
every other component draws from. */
.create-agent-intro {
margin: 0 0 1.5em;
color: var(--muted);
}
.create-agent-form {
display: flex;
align-items: flex-end;
flex-direction: column;
align-items: flex-start;
gap: 0.75em;
flex-wrap: wrap;
}
.create-agent-result {
margin-top: 1em;

View file

@ -16,19 +16,35 @@
// one path segment deep — a real bug, filed separately rather than
// fixed here, but reason enough to avoid a nested route today.
//
// First real form in this package — its name field + submit button now
// come from the shared `ui/` form kit (`TextField`/`Button`) rather than
// page-scoped input/button chrome, so a hive-picker soon landing on this
// same page has something to reuse instead of copying this page's CSS.
import { useState } from 'preact/hooks';
// `hive` field added because agent creation had no way to record which
// hive an agent runs on. `POST /api/agents` now requires it, so the
// roster fetched off `GET /api/hives` backs a required select here
// rather than a free-text field — a spawn target has to be one of the
// swarm's actual hives, same reasoning `Ident.parse` gets client-side
// pattern validation for `name`.
//
// 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 './CreateAgentPage.css';
// Mirrors swarm-controller's `HiveEntry` — same shape `OverviewPage`
// consumes off `/api/hives/status`, but this page hits the plain
// `/api/hives` roster (no status/freshness needed, just "what hives
// exist to spawn into").
interface HiveEntry {
name: string;
domain: string;
}
interface CreateAgentResponse {
node_id: number;
}
@ -56,8 +72,37 @@ const NAME_PATTERN = '[a-z0-9\\-]{1,63}';
export function CreateAgentPage() {
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' });
// 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');
if (!r.ok) {
setHivesError(await readApiError(r));
return;
}
const data = (await r.json()) as HiveEntry[];
setHives(data);
// Single-hive swarms are the common case — default it rather than
// making the operator pick the only option. Multi-hive swarms get
// no default (the placeholder stays selected), so `required` on
// the select below forces an explicit choice per mara's scope.
if (data.length === 1) setHive(data[0].name);
})().catch((e: unknown) => setHivesError({ detail: String(e) }));
}, []);
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' });
@ -65,7 +110,7 @@ export function CreateAgentPage() {
const r = await fetch('/api/agents', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name }),
body: JSON.stringify({ name, hive }),
});
if (!r.ok) {
setResult({ status: 'error', problem: await readApiError(r) });
@ -85,6 +130,7 @@ export function CreateAgentPage() {
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} />}
<form class="create-agent-form" onSubmit={submit}>
<TextField
id="agent-name"
@ -95,7 +141,17 @@ export function CreateAgentPage() {
required
onInput={setName}
/>
<Button variant="primary" type="submit" disabled={result.status === 'submitting'}>
<SelectField
id="agent-hive"
label="hive"
value={hive}
onChange={setHive}
options={hiveOptions}
required
disabled={!hivesReady}
placeholder={!hives ? 'loading…' : hives.length === 0 ? 'no hives configured' : 'select a hive'}
/>
<Button variant="primary" type="submit" disabled={result.status === 'submitting' || !hivesReady}>
{result.status === 'submitting' ? 'creating…' : 'create'}
</Button>
</form>

View file

@ -18,6 +18,8 @@ export function SelectField({
onChange,
options,
required,
disabled,
placeholder,
}: {
id: string;
label: string;
@ -25,6 +27,13 @@ export function SelectField({
onChange: (value: string) => void;
options: SelectOption[];
required?: boolean;
disabled?: boolean;
// Rendered as a disabled, always-first `value=""` option — for a
// required select with no default (e.g. a multi-hive roster: no
// single right answer to pre-select), so the control shows something
// other than silently landing on whatever option happens to be first.
// Omit when the field always has a real default (`value` never `''`).
placeholder?: string;
}) {
return (
<FormField label={label} htmlFor={id}>
@ -33,8 +42,14 @@ export function SelectField({
class="ui-form-control"
value={value}
required={required}
disabled={disabled}
onChange={(e) => onChange((e.target as HTMLSelectElement).value)}
>
{placeholder !== undefined && (
<option value="" disabled>
{placeholder}
</option>
)}
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}