import { useCallback, useEffect, useState } from 'react'; import type { ApiError } from '../../utils/api'; import type { Net, MazeNode, Edge } from './types'; import { DEFAULT_SERVICES, ARCHETYPES as DEFAULT_ARCHETYPES } from './data'; import type { Archetype, ServiceDef } from './data'; import type { MazeApi } from './useMazeApi'; import { useTopologyStream, type TopologyStreamEvent } from './useTopologyStream'; export interface TopoMeta { status: string; name: string; version: number; targetHost: string | null; mode: string; } const EMPTY_META: TopoMeta = { status: 'pending', name: '', version: 0, targetHost: null, mode: 'unihost', }; export interface UseTopologyDataResult { // Canvas data nets: Net[]; setNets: React.Dispatch>; nodes: MazeNode[]; setNodes: React.Dispatch>; edges: Edge[]; setEdges: React.Dispatch>; // Topology metadata snapshot topoMeta: TopoMeta; // Catalogs services: ServiceDef[]; archetypes: Archetype[]; // Errors + transient banners loadErr: string | null; actionErr: string | null; flashErr: (err: unknown, fallback: string) => void; // Deploy deploying: boolean; onDeploy: () => Promise; // Live stream streamLive: boolean; lastEventAt: Date | null; streamEnabled: boolean; // Actions refetch: () => Promise; } /** Owns every read/write side of the MazeNET canvas data plane: * the topology hydrate, the services + archetypes catalog, the * deploy POST, and the live-mutation SSE stream. State setters * for nets / nodes / edges are exposed because the per-operation * callbacks living in the page need to optimistically patch * local state alongside their REST calls. */ export function useTopologyData( api: MazeApi, topologyId: string, ): UseTopologyDataResult { const [nets, setNets] = useState([]); const [nodes, setNodes] = useState([]); const [edges, setEdges] = useState([]); const [topoMeta, setTopoMeta] = useState(EMPTY_META); const [services, setServices] = useState(DEFAULT_SERVICES); const [archetypes, setArchetypes] = useState(DEFAULT_ARCHETYPES); const [loadErr, setLoadErr] = useState(null); const [actionErr, setActionErr] = useState(null); const [deploying, setDeploying] = useState(false); const flashErr = useCallback((err: unknown, fallback: string) => { const msg = (err as ApiError)?.response?.data?.detail ?? (err as ApiError)?.message ?? fallback; setActionErr(msg); setTimeout(() => setActionErr(null), 4000); }, []); // Catalogs — fetched once on mount with bundled fallback. useEffect(() => { let cancelled = false; api.getServices().then((s) => { if (!cancelled) setServices(s); }).catch(() => {}); api.getArchetypes().then((a) => { if (!cancelled) setArchetypes(a); }).catch(() => {}); return () => { cancelled = true; }; }, [api]); const refetch = useCallback(async () => { if (!topologyId) return; try { const h = await api.getTopology(topologyId); setNets(h.nets); setNodes(h.nodes); setEdges(h.edges); setTopoMeta({ status: h.topology.status, name: h.topology.name, version: h.topology.version, targetHost: h.topology.target_host_uuid ?? null, mode: h.topology.mode ?? 'unihost', }); setLoadErr(null); } catch (err) { setLoadErr((err as Error)?.message ?? 'topology load failed'); } }, [api, topologyId]); useEffect(() => { void refetch(); }, [refetch]); // Live topology stream — only open when the topology is deployed; // pending topologies have no mutator loop. const [streamLive, setStreamLive] = useState(false); const [lastEventAt, setLastEventAt] = useState(null); const streamEnabled = topoMeta.status === 'active' || topoMeta.status === 'degraded'; const onStreamEvent = useCallback((event: TopologyStreamEvent) => { if (event.name === 'snapshot' || event.name.startsWith('mutation.') || event.name === 'status') { setStreamLive(true); setLastEventAt(new Date()); } if (event.name === 'mutation.failed') { const p = event.payload ?? {}; const reason = typeof p.reason === 'string' ? p.reason : typeof p.error === 'string' ? p.error : 'mutation failed — check mutator logs'; setActionErr(`mutation failed: ${reason}`); setTimeout(() => setActionErr(null), 6000); } if (event.name === 'mutation.applied' || event.name === 'mutation.failed' || event.name === 'status') { void 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({ topologyId: streamEnabled ? topologyId : null, enabled: streamEnabled, onEvent: onStreamEvent, onError: onStreamError, }); useEffect(() => { if (!streamEnabled) setStreamLive(false); }, [streamEnabled]); const onDeploy = useCallback(async () => { if (!topologyId) return; setDeploying(true); try { await api.deployTopology(topologyId); await refetch(); } catch (err) { flashErr(err, 'deploy failed'); } finally { setDeploying(false); } }, [api, topologyId, flashErr, refetch]); return { nets, setNets, nodes, setNodes, edges, setEdges, topoMeta, services, archetypes, loadErr, actionErr, flashErr, deploying, onDeploy, streamLive, lastEventAt, streamEnabled, refetch, }; }