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.
This commit is contained in:
117
decnet_web/src/components/MazeNET/useTopologyData.test.ts
Normal file
117
decnet_web/src/components/MazeNET/useTopologyData.test.ts
Normal file
@@ -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> = {}): 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
196
decnet_web/src/components/MazeNET/useTopologyData.ts
Normal file
196
decnet_web/src/components/MazeNET/useTopologyData.ts
Normal file
@@ -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<React.SetStateAction<Net[]>>;
|
||||
nodes: MazeNode[];
|
||||
setNodes: React.Dispatch<React.SetStateAction<MazeNode[]>>;
|
||||
edges: Edge[];
|
||||
setEdges: React.Dispatch<React.SetStateAction<Edge[]>>;
|
||||
|
||||
// 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<void>;
|
||||
|
||||
// Live stream
|
||||
streamLive: boolean;
|
||||
lastEventAt: Date | null;
|
||||
streamEnabled: boolean;
|
||||
|
||||
// Actions
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** 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<Net[]>([]);
|
||||
const [nodes, setNodes] = useState<MazeNode[]>([]);
|
||||
const [edges, setEdges] = useState<Edge[]>([]);
|
||||
const [topoMeta, setTopoMeta] = useState<TopoMeta>(EMPTY_META);
|
||||
|
||||
const [services, setServices] = useState<ServiceDef[]>(DEFAULT_SERVICES);
|
||||
const [archetypes, setArchetypes] = useState<Archetype[]>(DEFAULT_ARCHETYPES);
|
||||
|
||||
const [loadErr, setLoadErr] = useState<string | null>(null);
|
||||
const [actionErr, setActionErr] = useState<string | null>(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<Date | null>(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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user