diff --git a/decnet/engine/services_live.py b/decnet/engine/services_live.py index 1234e845..ffa05968 100644 --- a/decnet/engine/services_live.py +++ b/decnet/engine/services_live.py @@ -166,6 +166,7 @@ async def _add_topology_service( topology_id: str, decky_name: str, service_name: str, + initial_config: dict | None = None, ) -> list[str]: decky = await _topology_decky(repo, topology_id, decky_name) services: list[str] = list(decky.get("services") or []) @@ -174,7 +175,17 @@ async def _add_topology_service( f"service {service_name!r} already on decky {decky_name!r}" ) services.append(service_name) - await repo.update_topology_decky(decky["uuid"], {"services": services}) + update: dict[str, Any] = {"services": services} + # If the caller supplied initial config, fold it into decky_config + # BEFORE compose regen so the first ``up`` materialises the env on + # the new container — no follow-up apply needed. + if initial_config: + cfg_blob = dict(decky.get("decky_config") or {}) + sc = dict(cfg_blob.get("service_config") or {}) + sc[service_name] = initial_config + cfg_blob["service_config"] = sc + update["decky_config"] = cfg_blob + await repo.update_topology_decky(decky["uuid"], update) compose_path = await _rerender_topology_compose(repo, topology_id) target = f"{decky_name}-{service_name}" @@ -260,7 +271,10 @@ async def _persist_fleet_change( async def _add_fleet_service( - repo: BaseRepository, decky_name: str, service_name: str, + repo: BaseRepository, + decky_name: str, + service_name: str, + initial_config: dict | None = None, ) -> list[str]: config, compose_path = _fleet_state_or_raise() decky = _fleet_find_decky(config, decky_name) @@ -270,6 +284,12 @@ async def _add_fleet_service( f"service {service_name!r} already on decky {decky_name!r}" ) services.append(service_name) + if initial_config: + # Same path as _update_fleet_service_config: stash the validated + # cfg on the decky model so the compose write picks it up. + sc = dict(getattr(decky, "service_config", None) or {}) + sc[service_name] = initial_config + decky.service_config = sc await _persist_fleet_change(repo, decky, services, compose_path) target = f"{decky_name}-{service_name}" await anyio.to_thread.run_sync( @@ -313,17 +333,24 @@ async def add_service( decky_name: str, service_name: str, topology_id: Optional[str] = None, + config: dict | None = None, ) -> list[str]: """Add *service_name* to a deployed decky. Validates the service registry (rejects unknown / fleet_singleton - names), persists the change, regenerates the compose file, runs + names) and the optional ``config`` against the service's schema, + persists the change, regenerates the compose file, runs ``up -d --no-deps --build -`` in a worker thread, and publishes ``decky..service.added`` on the bus. + ``config`` is the same dict shape PUT/POST .../config accepts; it's + coerced via ``BaseService.validate_cfg`` before any state write so + a 400-class failure leaves zero side-effects. + Returns the post-mutation services list. """ - _validate_service_for_per_decky(service_name) + svc = _validate_service_for_per_decky(service_name) + initial_config = svc.validate_cfg(config) if config else {} if decky_kind == "topology": if not topology_id: raise ServiceMutationError( @@ -331,9 +358,13 @@ async def add_service( ) services = await _add_topology_service( repo, topology_id, decky_name, service_name, + initial_config=initial_config, ) elif decky_kind == "fleet": - services = await _add_fleet_service(repo, decky_name, service_name) + services = await _add_fleet_service( + repo, decky_name, service_name, + initial_config=initial_config, + ) else: # pragma: no cover — Literal narrows raise ServiceMutationError(f"unknown decky_kind {decky_kind!r}") diff --git a/decnet/web/db/models/decky.py b/decnet/web/db/models/decky.py index 2df58cd7..dfbc248c 100644 --- a/decnet/web/db/models/decky.py +++ b/decnet/web/db/models/decky.py @@ -51,8 +51,14 @@ class DeckyServiceAddRequest(BaseModel): and must NOT be ``fleet_singleton`` — those run once fleet-wide, not per-decky. Validation happens server-side in the engine layer and surfaces as 422. + + ``config`` carries optional initial per-service config (same shape as + DeckyServiceConfigRequest.config) so the freshly-added container + comes up with the operator's env from the start, no follow-up Apply + needed. Empty dict = build with defaults. """ name: str = PydanticField(..., min_length=1) + config: dict[str, Any] = PydanticField(default_factory=dict) class DeckyServicesResponse(BaseModel): diff --git a/decnet/web/router/deckies/api_services.py b/decnet/web/router/deckies/api_services.py index 80ea47cb..d2752f29 100644 --- a/decnet/web/router/deckies/api_services.py +++ b/decnet/web/router/deckies/api_services.py @@ -61,7 +61,7 @@ def _map_mutation_error(exc: ServiceMutationError) -> HTTPException: "/deckies/{decky_name}/services", response_model=DeckyServicesResponse, responses={ - 400: {"description": "Malformed request body"}, + 400: {"description": "Malformed request body or initial config rejected by service schema"}, 401: {"description": "Could not validate credentials"}, 403: {"description": "Insufficient permissions"}, 404: {"description": "Decky not found"}, @@ -78,7 +78,10 @@ async def api_fleet_add_service( services = await add_service( repo, decky_kind="fleet", decky_name=decky_name, service_name=req.name, + config=req.config, ) + except ConfigValidationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc except ServiceMutationError as exc: raise _map_mutation_error(exc) from exc return DeckyServicesResponse(decky_name=decky_name, services=services) @@ -197,7 +200,7 @@ async def api_fleet_remove_service( "/{topology_id}/deckies/{decky_name}/services", response_model=DeckyServicesResponse, responses={ - 400: {"description": "Malformed request body"}, + 400: {"description": "Malformed request body or initial config rejected by service schema"}, 401: {"description": "Could not validate credentials"}, 403: {"description": "Insufficient permissions"}, 404: {"description": "Topology or decky not found"}, @@ -215,7 +218,10 @@ async def api_topology_add_service( services = await add_service( repo, decky_kind="topology", topology_id=topology_id, decky_name=decky_name, service_name=req.name, + config=req.config, ) + except ConfigValidationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc except ServiceMutationError as exc: raise _map_mutation_error(exc) from exc return DeckyServicesResponse( diff --git a/decnet_web/src/components/AddServiceConfigModal.tsx b/decnet_web/src/components/AddServiceConfigModal.tsx new file mode 100644 index 00000000..b4bcfaf1 --- /dev/null +++ b/decnet_web/src/components/AddServiceConfigModal.tsx @@ -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) => Promise; +} + +const AddServiceConfigModal: React.FC = ({ pending, onCancel, onConfirm }) => { + const [schema, setSchema] = useState(null); + const [state, setState] = useState({}); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(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(`/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 ( + { /* ignore close-during-busy */ } : onCancel} + title={slug ? `ADD ${slug.toUpperCase()}` : 'ADD SERVICE'} + accent="violet" + footer={ + <> + + + + } + > +
+ {!schema && !err &&
Loading schema…
} + {err &&
{err}
} + {schema && slug && fields.length > 0 && ( + + )} +
+
+ ); +}; + +export default AddServiceConfigModal; diff --git a/decnet_web/src/components/DeckyFleet.tsx b/decnet_web/src/components/DeckyFleet.tsx index b3284d40..ea5f274e 100644 --- a/decnet_web/src/components/DeckyFleet.tsx +++ b/decnet_web/src/components/DeckyFleet.tsx @@ -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 = ({ const [busy, setBusy] = useState(null); const [opError, setOpError] = useState(null); const [openCfgSvc, setOpenCfgSvc] = useState(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 = ({ } }; - 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) => { + 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 = ({