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:
parent
6dffa74d90
commit
5fdbf0b452
3 changed files with 86 additions and 12 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue