feat: surface fleet_singleton flag on /topologies/services
Adds a fleet_singletons array to ServiceCatalogResponse so per-decky add UIs can filter out services like LLMNR that run once fleet-wide (and would 422 server-side at the live add endpoint). The existing 'services: list[str]' field is unchanged for back-compat with MazeNET/useMazeApi.ts:257; the new field is additive. decnet_web/src/hooks/useServiceRegistry.ts wraps the endpoint with a module-scoped cache (registry only changes on BYOS install / plugin drop, neither of which happens mid-session) and exposes a precomputed .perDecky list so consumers don't need to re-derive the diff.
This commit is contained in:
@@ -404,6 +404,12 @@ class NotEditableResponse(BaseModel):
|
|||||||
|
|
||||||
class ServiceCatalogResponse(BaseModel):
|
class ServiceCatalogResponse(BaseModel):
|
||||||
services: list[str]
|
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):
|
class ArchetypeEntry(BaseModel):
|
||||||
|
|||||||
@@ -42,7 +42,14 @@ router = APIRouter()
|
|||||||
async def api_list_services(
|
async def api_list_services(
|
||||||
_viewer: dict = Depends(require_viewer),
|
_viewer: dict = Depends(require_viewer),
|
||||||
) -> ServiceCatalogResponse:
|
) -> 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(
|
@router.get(
|
||||||
|
|||||||
61
decnet_web/src/hooks/useServiceRegistry.ts
Normal file
61
decnet_web/src/hooks/useServiceRegistry.ts
Normal file
@@ -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<ServiceRegistry> | null = null;
|
||||||
|
|
||||||
|
async function fetchRegistry(): Promise<ServiceRegistry> {
|
||||||
|
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<ServiceRegistry>(cached ?? EMPTY);
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
fetchRegistry().then((r) => { if (!cancelled) setReg(r); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
return reg;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user