From 5f2a3f4629c08e668708d2f99f8e5c138edfcb00 Mon Sep 17 00:00:00 2001 From: anti Date: Sat, 9 May 2026 05:33:19 -0400 Subject: [PATCH] refactor(decnet_web/MazeNET): extract useTopologyData Lift the canvas data plane off the page shell. The hook owns: GET /topologies/:id (hydrates nets/nodes/edges + meta) GET services + archetypes (catalogs, with bundled fallback) POST /topologies/:id/deploy /topologies/:id/events SSE (open only when active/degraded) flashErr() banner timer (auto-clears actionErr after 4s) State setters for nets / nodes / edges are returned so the per-operation callbacks living in the page can optimistically patch local state alongside their REST calls (matches the existing pattern; wholesale lift would mean dragging every mutation along too). - New MazeNET/useTopologyData.ts - useTopologyData.test.ts covers hydrate, loadErr surfacing, streamEnabled gating on active/degraded, onDeploy success + error paths, and the flashErr 4s auto-clear with fake timers. - Wiring into MazeNET.tsx lands in the next commit. --- .../MazeNET/useTopologyData.test.ts | 117 +++++++++++ .../src/components/MazeNET/useTopologyData.ts | 196 ++++++++++++++++++ 2 files changed, 313 insertions(+) create mode 100644 decnet_web/src/components/MazeNET/useTopologyData.test.ts create mode 100644 decnet_web/src/components/MazeNET/useTopologyData.ts diff --git a/decnet_web/src/components/MazeNET/useTopologyData.test.ts b/decnet_web/src/components/MazeNET/useTopologyData.test.ts new file mode 100644 index 00000000..1de4767e --- /dev/null +++ b/decnet_web/src/components/MazeNET/useTopologyData.test.ts @@ -0,0 +1,117 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect, vi } from 'vitest'; +import { act, renderHook, waitFor } from '@testing-library/react'; + +// useTopologyStream opens an EventSource — stub it to a no-op. +vi.mock('./useTopologyStream', () => ({ + useTopologyStream: vi.fn(), +})); + +import { useTopologyData } from './useTopologyData'; +import type { MazeApi } from './useMazeApi'; + +const TOPO_ID = 'topo-1'; + +const stubHydrated = (overrides: Partial<{ status: string; version: number }> = {}) => ({ + topology: { + id: TOPO_ID, + name: 'corp-net', + mode: 'unihost', + target_host_uuid: null, + status: overrides.status ?? 'pending', + version: overrides.version ?? 1, + }, + nets: [], + nodes: [], + edges: [], +}); + +const buildApi = (overrides: Partial = {}): MazeApi => ({ + listTopologies: vi.fn().mockResolvedValue([]), + createBlankTopology: vi.fn(), + getTopology: vi.fn().mockResolvedValue(stubHydrated()), + getServices: vi.fn().mockResolvedValue([]), + getArchetypes: vi.fn().mockResolvedValue([]), + getNextIp: vi.fn(), + getNextSubnet: vi.fn(), + createLan: vi.fn(), + updateLan: vi.fn(), + deleteLan: vi.fn(), + createDecky: vi.fn(), + updateDecky: vi.fn(), + deleteDecky: vi.fn(), + createEdge: vi.fn(), + deleteEdge: vi.fn(), + applyMutation: vi.fn(), + deployTopology: vi.fn().mockResolvedValue(undefined), + ...overrides, +} as unknown as MazeApi); + +describe('useTopologyData', () => { + it('hydrates topology metadata from getTopology on mount', async () => { + const api = buildApi(); + const { result } = renderHook(() => useTopologyData(api, TOPO_ID)); + await waitFor(() => expect(result.current.topoMeta.name).toBe('corp-net')); + expect(result.current.topoMeta.status).toBe('pending'); + expect(api.getTopology).toHaveBeenCalledWith(TOPO_ID); + }); + + it('surfaces loadErr when getTopology rejects', async () => { + const api = buildApi({ + getTopology: vi.fn().mockRejectedValue(new Error('not found')), + }); + const { result } = renderHook(() => useTopologyData(api, TOPO_ID)); + await waitFor(() => expect(result.current.loadErr).toBe('not found')); + }); + + it('streamEnabled flips on for active/degraded topologies', async () => { + const api = buildApi({ + getTopology: vi.fn().mockResolvedValue(stubHydrated({ status: 'active' })), + }); + const { result } = renderHook(() => useTopologyData(api, TOPO_ID)); + await waitFor(() => expect(result.current.topoMeta.status).toBe('active')); + expect(result.current.streamEnabled).toBe(true); + }); + + it('onDeploy fires deployTopology and refetches on success', async () => { + const deploy = vi.fn().mockResolvedValue(undefined); + const get = vi.fn().mockResolvedValue(stubHydrated()); + const api = buildApi({ deployTopology: deploy, getTopology: get }); + const { result } = renderHook(() => useTopologyData(api, TOPO_ID)); + await waitFor(() => expect(result.current.topoMeta.name).toBe('corp-net')); + const initialGetCalls = get.mock.calls.length; + + await act(async () => { await result.current.onDeploy(); }); + expect(deploy).toHaveBeenCalledWith(TOPO_ID); + expect(get.mock.calls.length).toBeGreaterThan(initialGetCalls); + }); + + it('onDeploy surfaces actionErr when deploy throws', async () => { + const deploy = vi.fn().mockRejectedValue({ response: { data: { detail: 'boom' } } }); + const api = buildApi({ deployTopology: deploy }); + const { result } = renderHook(() => useTopologyData(api, TOPO_ID)); + await waitFor(() => expect(result.current.topoMeta.name).toBe('corp-net')); + + await act(async () => { await result.current.onDeploy(); }); + expect(result.current.actionErr).toBe('boom'); + }); + + it('flashErr writes actionErr and auto-clears after 4s', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + const api = buildApi(); + const { result } = renderHook(() => useTopologyData(api, TOPO_ID)); + await waitFor(() => expect(result.current.topoMeta.name).toBe('corp-net')); + + act(() => result.current.flashErr(new Error('oops'), 'fallback')); + expect(result.current.actionErr).toBe('oops'); + + act(() => { vi.advanceTimersByTime(4500); }); + expect(result.current.actionErr).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/decnet_web/src/components/MazeNET/useTopologyData.ts b/decnet_web/src/components/MazeNET/useTopologyData.ts new file mode 100644 index 00000000..dda079e3 --- /dev/null +++ b/decnet_web/src/components/MazeNET/useTopologyData.ts @@ -0,0 +1,196 @@ +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, + }; +}