feat(services): initial config on ADD SERVICE — schema modal in DeckyCard, MazeNET drag, and Inspector
- DeckyServiceAddRequest gains an optional `config: dict` field, validated
against the service's config_schema before any state mutation (400 on
bad type, no half-written rows).
- Engine: add_service threads `config` into _add_topology_service /
_add_fleet_service, persisting validated cfg to decky_config.service_config
BEFORE compose regen so the first `up -d --build` materialises the env on
the new container. No follow-up apply needed.
- Frontend: shared AddServiceConfigModal — same wizard accordion shape, used by:
* DeckyCard's ADD SERVICE picker (Fleet & MazeNET inspectors via shared component)
* MazeNET Inspector's ADD SERVICE picker
* MazeNET palette drag-drop onto a deployed decky
Empty-schema services short-circuit to a one-click add (no modal flash).
Operator can cancel; errors surface in the modal.
- Tests: add_service config plumbing — persist, drop unknown keys, 400-equivalent
on bad types, back-compat empty-config.
- Drive-by: fix stale repo-method names in test_services_live.py
(create_topology_decky → add_topology_decky, get_topology_decky → list+pick helper,
service.added → service_added topic).
This commit is contained in:
120
decnet_web/src/components/AddServiceConfigModal.tsx
Normal file
120
decnet_web/src/components/AddServiceConfigModal.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import api from '../utils/api';
|
||||
import Modal from './Modal/Modal';
|
||||
import ServiceConfigFields, {
|
||||
type FormState as SvcFormState,
|
||||
type ServiceConfigFieldDTO as SvcFieldDTO,
|
||||
type SchemaResponse,
|
||||
buildInitial as svcBuildInitial,
|
||||
compactPayload as svcCompactPayload,
|
||||
fmtSchemaError,
|
||||
} from './ServiceConfigFields';
|
||||
|
||||
interface Props {
|
||||
/** When non-null, modal is open for this {decky, slug}. */
|
||||
pending: { deckyName: string; slug: string } | null;
|
||||
/** Operator dismissed the modal without adding. */
|
||||
onCancel: () => void;
|
||||
/** User confirmed (or schema is empty — auto-confirm path). */
|
||||
onConfirm: (deckyName: string, slug: string, config: Record<string, unknown>) => Promise<void>;
|
||||
}
|
||||
|
||||
const AddServiceConfigModal: React.FC<Props> = ({ pending, onCancel, onConfirm }) => {
|
||||
const [schema, setSchema] = useState<SchemaResponse | null>(null);
|
||||
const [state, setState] = useState<SvcFormState>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const slug = pending?.slug ?? null;
|
||||
const deckyName = pending?.deckyName ?? null;
|
||||
|
||||
// Reset on slug change so leftover state from a previous open doesn't
|
||||
// bleed through into a different service's form.
|
||||
useEffect(() => {
|
||||
setSchema(null);
|
||||
setState({});
|
||||
setBusy(false);
|
||||
setErr(null);
|
||||
if (!slug || !deckyName) return;
|
||||
let cancelled = false;
|
||||
api.get<SchemaResponse>(`/topologies/services/${encodeURIComponent(slug)}/schema`)
|
||||
.then(({ data }) => {
|
||||
if (cancelled) return;
|
||||
setSchema(data);
|
||||
// Empty schema → no operator decision to make; fire immediately
|
||||
// and close. The caller's onConfirm handles the POST.
|
||||
if (data.fields.length === 0) {
|
||||
onConfirm(deckyName, slug, {}).catch(() => { /* caller surfaces */ });
|
||||
return;
|
||||
}
|
||||
setState(svcBuildInitial(data.fields, {}));
|
||||
})
|
||||
.catch((loadErr) => {
|
||||
if (cancelled) return;
|
||||
setErr(fmtSchemaError(loadErr, 'Schema load failed.'));
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [slug, deckyName, onConfirm]);
|
||||
|
||||
// Don't render anything while we're auto-confirming an empty-schema add —
|
||||
// saves the brief flash of an empty modal.
|
||||
if (!pending) return null;
|
||||
if (schema && schema.fields.length === 0) return null;
|
||||
|
||||
const fields: SvcFieldDTO[] = schema?.fields ?? [];
|
||||
|
||||
const submit = async () => {
|
||||
if (!schema || !slug || !deckyName) return;
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const compact = svcCompactPayload(fields, state);
|
||||
await onConfirm(deckyName, slug, compact);
|
||||
} catch (e) {
|
||||
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail
|
||||
?? 'Add failed.';
|
||||
setErr(msg);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={Boolean(pending)}
|
||||
onClose={busy ? () => { /* ignore close-during-busy */ } : onCancel}
|
||||
title={slug ? `ADD ${slug.toUpperCase()}` : 'ADD SERVICE'}
|
||||
accent="violet"
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="btn small" onClick={onCancel} disabled={busy}>
|
||||
CANCEL
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn violet small"
|
||||
onClick={submit}
|
||||
disabled={busy || !schema}
|
||||
>
|
||||
{busy ? 'ADDING…' : 'ADD'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="modal-body">
|
||||
{!schema && !err && <div className="svc-cfg-status">Loading schema…</div>}
|
||||
{err && <div className="svc-cfg-status alert-text">{err}</div>}
|
||||
{schema && slug && fields.length > 0 && (
|
||||
<ServiceConfigFields
|
||||
serviceSlug={slug}
|
||||
value={state}
|
||||
onChange={setState}
|
||||
idScope={`add-${slug}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddServiceConfigModal;
|
||||
@@ -9,6 +9,7 @@ import { useToast } from './Toasts/useToast';
|
||||
import Modal from './Modal/Modal';
|
||||
import { useServiceRegistry } from '../hooks/useServiceRegistry';
|
||||
import ServiceConfigForm from './ServiceConfigForm';
|
||||
import AddServiceConfigModal from './AddServiceConfigModal';
|
||||
import ServiceConfigFields, {
|
||||
type FormState as SvcFormState,
|
||||
type ServiceConfigFieldDTO as SvcFieldDTO,
|
||||
@@ -164,6 +165,9 @@ const DeckyCard: React.FC<DeckyCardProps> = ({
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [opError, setOpError] = useState<string | null>(null);
|
||||
const [openCfgSvc, setOpenCfgSvc] = useState<string | null>(null);
|
||||
// Pending add — when non-null, AddServiceConfigModal is mounted and
|
||||
// will either auto-fire onConfirm (no schema fields) or show the form.
|
||||
const [pendingAdd, setPendingAdd] = useState<{ deckyName: string; slug: string } | null>(null);
|
||||
|
||||
const removeService = async (slug: string) => {
|
||||
setOpError(null);
|
||||
@@ -182,22 +186,30 @@ const DeckyCard: React.FC<DeckyCardProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const addService = async () => {
|
||||
const beginAdd = () => {
|
||||
if (!addSlug) return;
|
||||
setOpError(null);
|
||||
setBusy(addSlug);
|
||||
setPendingAdd({ deckyName: decky.name, slug: addSlug });
|
||||
};
|
||||
|
||||
const confirmAdd = async (deckyName: string, slug: string, cfg: Record<string, unknown>) => {
|
||||
setBusy(slug);
|
||||
try {
|
||||
const { data } = await api.post<{ services: string[] }>(
|
||||
`/deckies/${encodeURIComponent(decky.name)}/services`,
|
||||
{ name: addSlug },
|
||||
`/deckies/${encodeURIComponent(deckyName)}/services`,
|
||||
{ name: slug, config: cfg },
|
||||
);
|
||||
onServicesChanged(decky.name, data.services);
|
||||
onServicesChanged(deckyName, data.services);
|
||||
setPendingAdd(null);
|
||||
setAddOpen(false);
|
||||
setAddSlug('');
|
||||
} catch (err) {
|
||||
// Re-raise so the modal can surface the error in its own status row.
|
||||
// Also mirror onto opError for the inline picker case.
|
||||
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail
|
||||
?? 'Add failed.';
|
||||
setOpError(msg);
|
||||
throw err;
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
@@ -343,7 +355,7 @@ const DeckyCard: React.FC<DeckyCardProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
disabled={!addSlug || busy === addSlug}
|
||||
onClick={addService}
|
||||
onClick={beginAdd}
|
||||
className="btn violet small"
|
||||
>
|
||||
{busy === addSlug ? 'ADDING' : 'ADD'}
|
||||
@@ -409,6 +421,11 @@ const DeckyCard: React.FC<DeckyCardProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<AddServiceConfigModal
|
||||
pending={pendingAdd}
|
||||
onCancel={() => setPendingAdd(null)}
|
||||
onConfirm={confirmAdd}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -34,7 +34,10 @@ interface Props {
|
||||
// topologyStatus (active/degraded → live, pending/anything else →
|
||||
// design-time only). Wiring these props from MazeNET.tsx is the
|
||||
// single switch that turns chips into live controls.
|
||||
onLiveAddService?: (nodeName: string, slug: string) => Promise<void>;
|
||||
/** Trigger the schema-driven add-service flow. Synchronous: opens
|
||||
* the AddServiceConfigModal at the page level (or auto-confirms if
|
||||
* the service has no schema fields). Errors surface inside the modal. */
|
||||
onLiveAddService?: (nodeName: string, slug: string) => void;
|
||||
onLiveRemoveService?: (nodeName: string, slug: string) => Promise<void>;
|
||||
/** Per-decky-eligible service slugs, fetched via useServiceRegistry. */
|
||||
availableServices?: string[];
|
||||
@@ -202,21 +205,15 @@ const Inspector: React.FC<Props> = ({
|
||||
<button
|
||||
type="button"
|
||||
disabled={!addSlug || busy === addSlug}
|
||||
onClick={async () => {
|
||||
onClick={() => {
|
||||
if (!addSlug) return;
|
||||
setOpError(null);
|
||||
setBusy(addSlug);
|
||||
try {
|
||||
await onLiveAddService!(node.name, addSlug);
|
||||
setAddOpen(false);
|
||||
setAddSlug('');
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail
|
||||
?? 'Add failed.';
|
||||
setOpError(msg);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
// Fire-and-forget: opens the schema-driven config
|
||||
// modal at the page level (or auto-confirms for
|
||||
// schema-less services). Errors surface in the modal.
|
||||
onLiveAddService!(node.name, addSlug);
|
||||
setAddOpen(false);
|
||||
setAddSlug('');
|
||||
}}
|
||||
style={{
|
||||
padding: '4px 10px', fontSize: '0.7rem',
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useTopologyStream, type TopologyStreamEvent } from './useTopologyStream
|
||||
import { ARCHETYPES as DEFAULT_ARCHETYPES } from './data';
|
||||
import { useToast } from '../Toasts/useToast';
|
||||
import { useServiceRegistry } from '../../hooks/useServiceRegistry';
|
||||
import AddServiceConfigModal from '../AddServiceConfigModal';
|
||||
|
||||
/* Short unique suffix for default names — avoids the DB uniqueness
|
||||
* constraint regardless of delete/re-add sequencing on the client. */
|
||||
@@ -116,15 +117,40 @@ const MazeNET: React.FC = () => {
|
||||
cross-tab. */
|
||||
const serviceRegistry = useServiceRegistry();
|
||||
|
||||
const liveAddService = useCallback(async (nodeName: string, slug: string) => {
|
||||
const liveAddService = useCallback(async (
|
||||
nodeName: string,
|
||||
slug: string,
|
||||
config: Record<string, unknown> = {},
|
||||
) => {
|
||||
const { data } = await axios.post<{ services: string[] }>(
|
||||
`/topologies/${encodeURIComponent(topologyId)}/deckies/${encodeURIComponent(nodeName)}/services`,
|
||||
{ name: slug },
|
||||
{ name: slug, config },
|
||||
);
|
||||
setNodes((p) => p.map((x) => x.kind === 'decky' && x.name === nodeName
|
||||
? { ...x, services: data.services } : x));
|
||||
}, [topologyId]);
|
||||
|
||||
// Pending add for the schema-driven config modal — both the palette
|
||||
// drag-drop and the Inspector ADD SERVICE picker funnel through here so
|
||||
// operators get the same "configure on first up" flow either way.
|
||||
const [pendingAddSvc, setPendingAddSvc] = useState<{ deckyName: string; slug: string } | null>(null);
|
||||
|
||||
const requestAddService = useCallback((nodeName: string, slug: string) => {
|
||||
setPendingAddSvc({ deckyName: nodeName, slug });
|
||||
}, []);
|
||||
|
||||
const confirmAddService = useCallback(async (
|
||||
nodeName: string, slug: string, cfg: Record<string, unknown>,
|
||||
) => {
|
||||
try {
|
||||
await liveAddService(nodeName, slug, cfg);
|
||||
setPendingAddSvc(null);
|
||||
} catch (err) {
|
||||
flashErr(err, 'add service failed');
|
||||
throw err;
|
||||
}
|
||||
}, [liveAddService, flashErr]);
|
||||
|
||||
const liveRemoveService = useCallback(async (nodeName: string, slug: string) => {
|
||||
const { data } = await axios.delete<{ services: string[] }>(
|
||||
`/topologies/${encodeURIComponent(topologyId)}/deckies/${encodeURIComponent(nodeName)}/services/${encodeURIComponent(slug)}`,
|
||||
@@ -273,16 +299,13 @@ const MazeNET: React.FC = () => {
|
||||
// For active/degraded topologies, route through the live W3
|
||||
// endpoint — the design-time mutator queue would silently
|
||||
// enqueue and the dropped chip would never visibly land
|
||||
// (resulting in the "no way to APPLY" feedback). liveAddService
|
||||
// returns the post-mutation services list and patches local
|
||||
// state so the chip appears immediately.
|
||||
// (resulting in the "no way to APPLY" feedback). Funnel through
|
||||
// requestAddService so the schema-driven config modal pops if
|
||||
// the service has any user-tunable fields; empty-schema services
|
||||
// auto-confirm and short-circuit, keeping drag fluency.
|
||||
const live = topoStatus === 'active' || topoStatus === 'degraded';
|
||||
if (live) {
|
||||
try {
|
||||
await liveAddService(target.name, drag.slug);
|
||||
} catch (err) {
|
||||
flashErr(err, 'add service failed');
|
||||
}
|
||||
requestAddService(target.name, drag.slug);
|
||||
return;
|
||||
}
|
||||
const nextServices = [...target.services, drag.slug];
|
||||
@@ -297,7 +320,7 @@ const MazeNET: React.FC = () => {
|
||||
}
|
||||
}
|
||||
},
|
||||
[api, archetypes, editor, flashErr, nets, nodes, topologyId, topoStatus, liveAddService],
|
||||
[api, archetypes, editor, flashErr, nets, nodes, topologyId, topoStatus, requestAddService],
|
||||
);
|
||||
|
||||
/* ── Cross-net reparent via node drag (detach + attach edge) ─── */
|
||||
@@ -900,7 +923,7 @@ const MazeNET: React.FC = () => {
|
||||
onDeleteEdge={removeEdge}
|
||||
onRemoveService={removeServiceFromNode}
|
||||
availableServices={serviceRegistry.perDecky}
|
||||
onLiveAddService={liveAddService}
|
||||
onLiveAddService={requestAddService}
|
||||
onLiveRemoveService={liveRemoveService}
|
||||
onToggleGateway={toggleGateway}
|
||||
onAddDecky={(netId) => {
|
||||
@@ -917,6 +940,11 @@ const MazeNET: React.FC = () => {
|
||||
className={inspectorOpen ? '' : 'collapsed'}
|
||||
/>
|
||||
</div>
|
||||
<AddServiceConfigModal
|
||||
pending={pendingAddSvc}
|
||||
onCancel={() => setPendingAddSvc(null)}
|
||||
onConfirm={confirmAddService}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user