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:
2026-04-29 12:44:47 -04:00
parent 77ceb9d6f3
commit 94b06ee862
8 changed files with 358 additions and 43 deletions

View File

@@ -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',

View File

@@ -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>
);
};