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
|
/* <CreateAgentPage> — the create-agent form. Input/button chrome now
|
||||||
comes from the shared `ui/` form kit (`TextField`/`Button`); this
|
comes from the shared `ui/` form kit (`TextField`/`SelectField`/
|
||||||
file only owns the page's own layout + copy. Reuses the same base16
|
`Button`); this file only owns the page's own layout + copy. Column,
|
||||||
slots (../theme.css) every other component draws from. */
|
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 {
|
.create-agent-intro {
|
||||||
margin: 0 0 1.5em;
|
margin: 0 0 1.5em;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
.create-agent-form {
|
.create-agent-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
gap: 0.75em;
|
gap: 0.75em;
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
.create-agent-result {
|
.create-agent-result {
|
||||||
margin-top: 1em;
|
margin-top: 1em;
|
||||||
|
|
|
||||||
|
|
@ -16,19 +16,35 @@
|
||||||
// one path segment deep — a real bug, filed separately rather than
|
// one path segment deep — a real bug, filed separately rather than
|
||||||
// fixed here, but reason enough to avoid a nested route today.
|
// fixed here, but reason enough to avoid a nested route today.
|
||||||
//
|
//
|
||||||
// First real form in this package — its name field + submit button now
|
// `hive` field added because agent creation had no way to record which
|
||||||
// come from the shared `ui/` form kit (`TextField`/`Button`) rather than
|
// hive an agent runs on. `POST /api/agents` now requires it, so the
|
||||||
// page-scoped input/button chrome, so a hive-picker soon landing on this
|
// roster fetched off `GET /api/hives` backs a required select here
|
||||||
// same page has something to reuse instead of copying this page's CSS.
|
// rather than a free-text field — a spawn target has to be one of the
|
||||||
import { useState } from 'preact/hooks';
|
// 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 { Link } from 'wouter-preact';
|
||||||
import { ApiErrorPanel } from '@hive/shared/api-error-panel.js';
|
import { ApiErrorPanel } from '@hive/shared/api-error-panel.js';
|
||||||
import { readApiError, type ProblemDetails } from '@hive/shared/api-error.js';
|
import { readApiError, type ProblemDetails } from '@hive/shared/api-error.js';
|
||||||
import { Panel } from '../ui/panel/Panel.js';
|
import { Panel } from '../ui/panel/Panel.js';
|
||||||
import { TextField } from '../ui/text-field/TextField.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 { Button } from '../ui/button/Button.js';
|
||||||
import './CreateAgentPage.css';
|
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 {
|
interface CreateAgentResponse {
|
||||||
node_id: number;
|
node_id: number;
|
||||||
}
|
}
|
||||||
|
|
@ -56,8 +72,37 @@ const NAME_PATTERN = '[a-z0-9\\-]{1,63}';
|
||||||
|
|
||||||
export function CreateAgentPage() {
|
export function CreateAgentPage() {
|
||||||
const [name, setName] = 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');
|
||||||
|
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) {
|
async function submit(e: Event) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setResult({ status: 'submitting' });
|
setResult({ status: 'submitting' });
|
||||||
|
|
@ -65,7 +110,7 @@ export function CreateAgentPage() {
|
||||||
const r = await fetch('/api/agents', {
|
const r = await fetch('/api/agents', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ name }),
|
body: JSON.stringify({ name, hive }),
|
||||||
});
|
});
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
setResult({ status: 'error', problem: await readApiError(r) });
|
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{' '}
|
Create a new agent's swarm-level identity. This only queues the job — check{' '}
|
||||||
<Link href="/jobs">jobs</Link> to watch it settle.
|
<Link href="/jobs">jobs</Link> to watch it settle.
|
||||||
</p>
|
</p>
|
||||||
|
{hivesError && <ApiErrorPanel context="failed to load the hive list" problem={hivesError} />}
|
||||||
<form class="create-agent-form" onSubmit={submit}>
|
<form class="create-agent-form" onSubmit={submit}>
|
||||||
<TextField
|
<TextField
|
||||||
id="agent-name"
|
id="agent-name"
|
||||||
|
|
@ -95,7 +141,17 @@ export function CreateAgentPage() {
|
||||||
required
|
required
|
||||||
onInput={setName}
|
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'}
|
{result.status === 'submitting' ? 'creating…' : 'create'}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ export function SelectField({
|
||||||
onChange,
|
onChange,
|
||||||
options,
|
options,
|
||||||
required,
|
required,
|
||||||
|
disabled,
|
||||||
|
placeholder,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -25,6 +27,13 @@ export function SelectField({
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
options: SelectOption[];
|
options: SelectOption[];
|
||||||
required?: boolean;
|
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 (
|
return (
|
||||||
<FormField label={label} htmlFor={id}>
|
<FormField label={label} htmlFor={id}>
|
||||||
|
|
@ -33,8 +42,14 @@ export function SelectField({
|
||||||
class="ui-form-control"
|
class="ui-form-control"
|
||||||
value={value}
|
value={value}
|
||||||
required={required}
|
required={required}
|
||||||
|
disabled={disabled}
|
||||||
onChange={(e) => onChange((e.target as HTMLSelectElement).value)}
|
onChange={(e) => onChange((e.target as HTMLSelectElement).value)}
|
||||||
>
|
>
|
||||||
|
{placeholder !== undefined && (
|
||||||
|
<option value="" disabled>
|
||||||
|
{placeholder}
|
||||||
|
</option>
|
||||||
|
)}
|
||||||
{options.map((o) => (
|
{options.map((o) => (
|
||||||
<option key={o.value} value={o.value}>
|
<option key={o.value} value={o.value}>
|
||||||
{o.label}
|
{o.label}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue