feat: forward decky.*.service.* on per-topology SSE stream

The /topologies/{id}/events SSE proxy now subscribes to two bus
patterns concurrently and merges them through a bounded asyncio.Queue:

* topology.{id}.>  — lifecycle (status, mutation.*) — unchanged.
* decky.>          — per-decky events, filtered by payload.topology_id
                     so a fleet decky sharing a name with a topology
                     decky doesn't leak across.

_sse_name_for routes 'decky.<name>.service.added' to the SSE event
name 'decky.service.added' (kept the prefix so the frontend doesn't
collide with topology lifecycle events that share leaf names like
'status').

useTopologyStream surfaces the two new event names; MazeNET.tsx's
onStreamEvent optimistically patches the matching node's services
list so a second tab reflects shape changes without a refetch.
This commit is contained in:
2026-04-28 23:15:38 -04:00
parent e7d49d7237
commit 0e5484648f
3 changed files with 89 additions and 28 deletions

View File

@@ -650,6 +650,22 @@ const MazeNET: React.FC = () => {
|| event.name === 'status') {
refetch();
}
// Live service mutations from another tab / admin: optimistically
// patch local state so the chip set reflects shape without a full
// re-hydrate. The post-mutation services list lives on the
// payload; same shape the actor's POST/DELETE response carries.
if (event.name === 'decky.service.added'
|| event.name === 'decky.service.removed') {
const p = event.payload ?? {};
const deckyName = typeof p.decky_name === 'string' ? p.decky_name : null;
const services = Array.isArray(p.services) ? p.services as string[] : null;
if (deckyName && services) {
setNodes((prev) => prev.map((n) => n.kind === 'decky' && n.name === deckyName
? { ...n, services } : n));
setStreamLive(true);
setLastEventAt(new Date());
}
}
}, [refetch]);
const onStreamError = useCallback(() => { setStreamLive(false); }, []);
useTopologyStream({