Adds a generic ui/dialog/Dialog primitive (native <dialog>, no third-party modal lib and no shadow-DOM custom element per the esbuild gap on those) and wires a "+ agent" button into AgentsPage that opens the existing create-agent form inside it, content unchanged from its former life as a standalone /create-agent route/nav item. Removes the "new agent" top-level nav entry and the /create-agent route entirely -- creation now only reachable from the roster that gets populated by it. CreateAgentPage.tsx/css renamed to CreateAgentForm.tsx/css to match its new role as a mounted component rather than a page. Screenshot-verified the dialog open/closed states against a mock server.
197 lines
8.5 KiB
TypeScript
197 lines
8.5 KiB
TypeScript
// <CreateAgentForm> — minimal agent creation form, POSTs to
|
|
// swarm-controller's `POST /api/agents`. That endpoint only queues a
|
|
// `CreateIdentity` job and returns its node id — the identity isn't
|
|
// guaranteed to exist yet when this gets a response — so this shows
|
|
// the queued confirmation and links to the Jobs page to watch it
|
|
// settle, rather than duplicating JobqGraph's per-node polling here.
|
|
// Scope matches the originating issue exactly: name field only, no
|
|
// forge/deploy options (those aren't wired server-side yet either).
|
|
//
|
|
// Rendered inside a `Dialog` from `AgentsPage`'s own "+ agent" button,
|
|
// not a standalone route — the roster it feeds is the natural home for
|
|
// the action that populates it, and a distinct top-level nav item next
|
|
// to it was one click of indirection for no benefit. Kept as its own
|
|
// component (not inlined into AgentsPage) since the two-panel layout
|
|
// below is unchanged from its former life as a page: same content, same
|
|
// review history, just a different mount point.
|
|
//
|
|
// `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 './CreateAgentForm.css';
|
|
|
|
// Mirrors swarm-controller's `HiveEntry` — same shape `HivesPage`
|
|
// 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;
|
|
}
|
|
|
|
type SubmitState =
|
|
| { 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
|
|
// validation; a name this rejects would fail server-side anyway, so
|
|
// catching it before a round-trip is a pure UX win, not a new gate.
|
|
//
|
|
// Hyphen escaped (`\-`) rather than left trailing in the class: modern
|
|
// browsers validate the `pattern` attribute's regex in Unicode-set (`v`)
|
|
// mode, which is far stricter about hyphen placement than classic mode —
|
|
// `[a-z0-9-]` throws "Invalid character class" under `v` mode even though
|
|
// it's valid classic-mode regex (a trailing `-` is unambiguous there).
|
|
// 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}';
|
|
|
|
export function CreateAgentForm() {
|
|
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' });
|
|
try {
|
|
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) });
|
|
return;
|
|
}
|
|
const data = (await r.json()) as CreateAgentResponse;
|
|
setResult({ status: 'done', nodeId: data.node_id });
|
|
setName('');
|
|
} catch (err) {
|
|
setResult({ status: 'error', problem: { detail: String(err) } });
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div class="create-agent-layout">
|
|
<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.
|
|
</p>
|
|
{hivesError && (
|
|
<ApiErrorPanel context="failed to load the hive list" problem={hivesError} />
|
|
)}
|
|
<form class="create-agent-form" onSubmit={submit}>
|
|
<TextField
|
|
id="agent-name"
|
|
label="agent name"
|
|
value={name}
|
|
pattern={NAME_PATTERN}
|
|
title="1-63 chars: lowercase letters, digits, hyphens"
|
|
required
|
|
onInput={setName}
|
|
/>
|
|
<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>
|
|
{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>
|
|
</p>
|
|
)}
|
|
{result.status === 'error' && (
|
|
<ApiErrorPanel context="failed to queue" problem={result.problem} />
|
|
)}
|
|
</Panel>
|
|
</div>
|
|
<div class="create-agent-info-col">
|
|
{/* Describes the intended end state (a running agent), not today's
|
|
literal behaviour (deploying the container onto the hive isn't
|
|
wired up server-side yet — see this file's top comment) —
|
|
mara's explicit call on this copy: read as finished, not as a
|
|
running commentary on partial implementation. The 🪪 glyph that
|
|
used to sit here as body copy now lives on `Panel`'s own `icon`
|
|
prop instead — it's what prompted that prop to exist at all
|
|
(see Panel.tsx's doc comment) */}
|
|
<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>.
|
|
</p>
|
|
</Panel>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|