diff --git a/decnet/web/db/models/topology.py b/decnet/web/db/models/topology.py index 8fae8f69..bc54696f 100644 --- a/decnet/web/db/models/topology.py +++ b/decnet/web/db/models/topology.py @@ -404,6 +404,12 @@ class NotEditableResponse(BaseModel): class ServiceCatalogResponse(BaseModel): services: list[str] + # Subset of ``services`` that run once fleet-wide (LLMNR, etc.) and + # therefore can't be added to a single decky. Per-decky add UIs + # filter these out so the operator never picks an option that the + # server would reject as 422. Empty when the registry has no + # singletons. + fleet_singletons: list[str] = PydanticField(default_factory=list) class ArchetypeEntry(BaseModel): diff --git a/decnet/web/router/topology/api_catalog.py b/decnet/web/router/topology/api_catalog.py index 74f42ee7..674bcd33 100644 --- a/decnet/web/router/topology/api_catalog.py +++ b/decnet/web/router/topology/api_catalog.py @@ -42,7 +42,14 @@ router = APIRouter() async def api_list_services( _viewer: dict = Depends(require_viewer), ) -> ServiceCatalogResponse: - return ServiceCatalogResponse(services=all_service_names()) + from decnet.services.registry import all_services + registry = all_services() + return ServiceCatalogResponse( + services=all_service_names(), + fleet_singletons=[ + name for name, svc in registry.items() if svc.fleet_singleton + ], + ) @router.get( diff --git a/decnet_web/src/hooks/useServiceRegistry.ts b/decnet_web/src/hooks/useServiceRegistry.ts new file mode 100644 index 00000000..4992af0f --- /dev/null +++ b/decnet_web/src/hooks/useServiceRegistry.ts @@ -0,0 +1,61 @@ +import { useEffect, useState } from 'react'; +import api from '../utils/api'; + +/** Shape of /api/v1/topologies/services. */ +export interface ServiceRegistry { + /** All registered service slugs (e.g. 'ssh', 'http', 'mysql'). */ + services: string[]; + /** Subset that runs once fleet-wide; not addable to a single decky. */ + fleet_singletons: string[]; + /** Per-decky-eligible (services minus fleet_singletons). */ + perDecky: string[]; +} + +const EMPTY: ServiceRegistry = { services: [], fleet_singletons: [], perDecky: [] }; + +// Module-scoped cache. The registry is keyed by the running master and +// changes only when the operator drops a new BYOS file or installs a +// plugin, neither of which happens during a normal session — caching +// across components avoids a re-fetch on every drawer open. +let cached: ServiceRegistry | null = null; +let inflight: Promise | null = null; + +async function fetchRegistry(): Promise { + if (cached) return cached; + if (inflight) return inflight; + inflight = api + .get<{ services: string[]; fleet_singletons?: string[] }>('/topologies/services') + .then((res) => { + const services = res.data.services ?? []; + const singletons = res.data.fleet_singletons ?? []; + const singletonSet = new Set(singletons); + const reg: ServiceRegistry = { + services, + fleet_singletons: singletons, + perDecky: services.filter((s) => !singletonSet.has(s)), + }; + cached = reg; + return reg; + }) + .catch(() => EMPTY) + .finally(() => { inflight = null; }); + return inflight; +} + +/** Reset the cache; call from tests or after a BYOS install. */ +export function invalidateServiceRegistry(): void { + cached = null; +} + +/** Lazily load the service registry. Returns ``EMPTY`` until the first + * fetch resolves. Errors fall through to ``EMPTY`` (the live add/remove + * endpoints will still fail closed at submit time). */ +export function useServiceRegistry(): ServiceRegistry { + const [reg, setReg] = useState(cached ?? EMPTY); + useEffect(() => { + let cancelled = false; + fetchRegistry().then((r) => { if (!cancelled) setReg(r); }); + return () => { cancelled = true; }; + }, []); + return reg; +}