From aa848d526031a6a4a964c3a408ae5be103b82ffc Mon Sep 17 00:00:00 2001 From: anti Date: Tue, 21 Apr 2026 19:54:55 -0400 Subject: [PATCH] feat(web): useTopologyEditor skeleton + explicit streamLive gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase B step 1 of DEBT-030: introduce a status-aware editor hook that wraps useMazeApi. Every primitive currently pass-throughs to direct CRUD and returns {kind: 'applied', data} — behavior is unchanged. Follow-up commits route active/degraded topologies through enqueueMutation when status != pending. Also tighten the SSE LIVE indicator: flip setStreamLive(true) only on snapshot, mutation.*, or status events, not on any incidental frame. --- decnet_web/src/components/MazeNET/MazeNET.tsx | 76 +++++++--- .../components/MazeNET/useTopologyEditor.ts | 132 ++++++++++++++++++ 2 files changed, 188 insertions(+), 20 deletions(-) create mode 100644 decnet_web/src/components/MazeNET/useTopologyEditor.ts diff --git a/decnet_web/src/components/MazeNET/MazeNET.tsx b/decnet_web/src/components/MazeNET/MazeNET.tsx index 925bebf3..0c92251b 100644 --- a/decnet_web/src/components/MazeNET/MazeNET.tsx +++ b/decnet_web/src/components/MazeNET/MazeNET.tsx @@ -15,6 +15,7 @@ import { DEFAULT_SERVICES } from './data'; import type { Archetype, ServiceDef } from './data'; import type { Net, MazeNode, Edge, DeckyNode } from './types'; import { useMazeApi } from './useMazeApi'; +import { useTopologyEditor } from './useTopologyEditor'; import { useMazeInteraction, type PaletteDrag } from './useMazeInteraction'; import { useLayoutPersistor } from './useMazeLayoutStore'; import { useTopologyStream, type TopologyStreamEvent } from './useTopologyStream'; @@ -53,6 +54,8 @@ const MazeNET: React.FC = () => { const canvasRef = useRef(null); + const editor = useTopologyEditor({ api, topoStatus, topoVersion }); + const flashErr = useCallback((err: unknown, fallback: string) => { const msg = (err as { response?: { data?: { detail?: string } }; message?: string }) ?.response?.data?.detail ?? (err as Error)?.message ?? fallback; @@ -82,7 +85,9 @@ const MazeNET: React.FC = () => { const name = isDmz ? `dmz-${hex4()}` : `subnet-${hex4()}`; try { const subnet = await api.getNextSubnet().catch(() => undefined); - const lan = await api.createLan(topologyId, { name, is_dmz: isDmz, x, y, ...(subnet ? { subnet } : {}) }); + const lanRes = await editor.createLan(topologyId, { name, is_dmz: isDmz, x, y, ...(subnet ? { subnet } : {}) }); + if (lanRes.kind !== 'applied') return; + const lan = lanRes.data; const net: Net = { id: lan.id, label: lan.name.toUpperCase(), cidr: lan.subnet, kind: isDmz ? 'dmz' : 'subnet', x, y, w, h, @@ -91,14 +96,16 @@ const MazeNET: React.FC = () => { if (isDmz) { const gwName = `dmz-gateway-${hex4()}`; - const gw = await api.createDecky(topologyId, { + const gwRes = await editor.createDecky(topologyId, { name: gwName, services: ['ssh'], x: 20, y: 40, decky_config: { archetype: 'deaddeck', forwards_l3: true }, }); - await api.attachEdge(topologyId, { + if (gwRes.kind !== 'applied') return; + const gw = gwRes.data; + await editor.attachEdge(topologyId, { decky_uuid: gw.uuid, lan_id: lan.id, is_bridge: true, forwards_l3: true, - }); + }, gw.name, lan.name); const gwNode: DeckyNode = { kind: 'decky', id: gw.uuid, netId: lan.id, name: gw.name, archetype: 'deaddeck', services: ['ssh'], status: 'idle', @@ -123,11 +130,15 @@ const MazeNET: React.FC = () => { const ny = Math.max(28, Math.round(world.y - net.y - 24)); const name = `decky-${hex4()}`; try { - const decky = await api.createDecky(topologyId, { + const dRes = await editor.createDecky(topologyId, { name, services: dServices, x: nx, y: ny, decky_config: { archetype: archSlug }, }); - await api.attachEdge(topologyId, { decky_uuid: decky.uuid, lan_id: overNetId }); + if (dRes.kind !== 'applied') return; + const decky = dRes.data; + await editor.attachEdge(topologyId, + { decky_uuid: decky.uuid, lan_id: overNetId }, + decky.name, net.label); const node: DeckyNode = { kind: 'decky', id: decky.uuid, netId: overNetId, name: decky.name, archetype: archSlug, services: dServices, status: 'idle', x: nx, y: ny, @@ -146,7 +157,8 @@ const MazeNET: React.FC = () => { if (target.services.includes(drag.slug)) return; const nextServices = [...target.services, drag.slug]; try { - await api.updateDecky(topologyId, overNodeId, { services: nextServices }); + const r = await editor.updateDecky(topologyId, overNodeId, target.name, { services: nextServices }); + if (r.kind !== 'applied') return; setNodes((p) => p.map((n) => n.id === overNodeId && n.kind === 'decky' ? { ...n, services: nextServices } : n)); @@ -155,7 +167,7 @@ const MazeNET: React.FC = () => { } } }, - [api, archetypes, flashErr, nets, nodes, topologyId], + [api, archetypes, editor, flashErr, nets, nodes, topologyId], ); /* ── Cross-net reparent via node drag (detach + attach edge) ─── */ @@ -167,12 +179,18 @@ const MazeNET: React.FC = () => { (e: { decky_uuid: string; lan_id: string; id: string }) => e.decky_uuid === nodeId && e.lan_id === fromNetId, ); - if (existingEdge) await api.detachEdge(topologyId, existingEdge.id); - await api.attachEdge(topologyId, { decky_uuid: nodeId, lan_id: toNetId }); + const node = nodes.find((n) => n.id === nodeId); + const fromNet = nets.find((n) => n.id === fromNetId); + const toNet = nets.find((n) => n.id === toNetId); + const nodeName = node?.kind === 'decky' ? node.name : ''; + if (existingEdge) { + await editor.detachEdge(topologyId, existingEdge.id, nodeName, fromNet?.label ?? ''); + } + await editor.attachEdge(topologyId, { decky_uuid: nodeId, lan_id: toNetId }, nodeName, toNet?.label ?? ''); } catch (err) { flashErr(err, 'reparent failed'); } - }, [api, flashErr, topologyId]); + }, [editor, flashErr, nets, nodes, topologyId]); /* Port→port edges stay UI-only (backend edges are decky↔LAN). */ const onAddEdge = useCallback((fromId: string, toId: string) => { @@ -195,8 +213,11 @@ const MazeNET: React.FC = () => { /* Cascade delete members first — backend will otherwise 400 on orphan risk. */ const members = nodes.filter((n) => n.netId === id && n.kind === 'decky'); try { - for (const m of members) await api.deleteDecky(topologyId, m.id); - await api.deleteLan(topologyId, id); + for (const m of members) { + const mName = m.kind === 'decky' ? m.name : ''; + await editor.deleteDecky(topologyId, m.id, mName); + } + await editor.deleteLan(topologyId, id, net.label); setNets((p) => p.filter((n) => n.id !== id)); setNodes((p) => p.filter((n) => n.netId !== id)); setEdges((p) => p.filter((e) => { @@ -215,7 +236,7 @@ const MazeNET: React.FC = () => { if (!node || node.kind === 'observed') return; if (node.kind === 'decky' && node.decky_config?.forwards_l3) return; try { - await api.deleteDecky(topologyId, id); + await editor.deleteDecky(topologyId, id, node.kind === 'decky' ? node.name : ''); setNodes((p) => p.filter((n) => n.id !== id)); setEdges((p) => p.filter((e) => e.from !== id && e.to !== id)); setSelection(null); @@ -235,11 +256,16 @@ const MazeNET: React.FC = () => { if (!n || n.kind !== 'decky') return; const name = `${n.name.replace(/-[0-9a-f]{4}$/, '')}-${hex4()}`; try { - const decky = await api.createDecky(topologyId, { + const dRes = await editor.createDecky(topologyId, { name, services: [...n.services], x: n.x + 24, y: n.y + 24, decky_config: { archetype: n.archetype }, }); - await api.attachEdge(topologyId, { decky_uuid: decky.uuid, lan_id: n.netId }); + if (dRes.kind !== 'applied') return; + const decky = dRes.data; + const parentNet = nets.find((net) => net.id === n.netId); + await editor.attachEdge(topologyId, + { decky_uuid: decky.uuid, lan_id: n.netId }, + decky.name, parentNet?.label ?? ''); const copy: DeckyNode = { kind: 'decky', id: decky.uuid, netId: n.netId, name: decky.name, archetype: n.archetype, services: [...n.services], status: 'idle', @@ -256,7 +282,8 @@ const MazeNET: React.FC = () => { if (!n || n.kind !== 'decky' || n.services.includes(slug)) return; const nextServices = [...n.services, slug]; try { - await api.updateDecky(topologyId, id, { services: nextServices }); + const r = await editor.updateDecky(topologyId, id, n.name, { services: nextServices }); + if (r.kind !== 'applied') return; setNodes((p) => p.map((x) => x.id === id && x.kind === 'decky' ? { ...x, services: nextServices } : x)); } catch (err) { @@ -324,11 +351,15 @@ const MazeNET: React.FC = () => { onClick: async () => { const name = `decky-${hex4()}`; try { - const decky = await api.createDecky(topologyId, { + const dRes = await editor.createDecky(topologyId, { name, services: [...a.services], x: 20, y: 40, decky_config: { archetype: a.slug }, }); - await api.attachEdge(topologyId, { decky_uuid: decky.uuid, lan_id: id }); + if (dRes.kind !== 'applied') return; + const decky = dRes.data; + await editor.attachEdge(topologyId, + { decky_uuid: decky.uuid, lan_id: id }, + decky.name, net.label); const node: DeckyNode = { kind: 'decky', id: decky.uuid, netId: id, name: decky.name, archetype: a.slug, services: [...a.services], status: 'idle', @@ -432,7 +463,12 @@ const MazeNET: React.FC = () => { const [streamLive, setStreamLive] = useState(false); const streamEnabled = topoStatus === 'active' || topoStatus === 'degraded'; const onStreamEvent = useCallback((event: TopologyStreamEvent) => { - setStreamLive(true); + // Flip LIVE only on named, purposeful events — not incidental keepalives. + if (event.name === 'snapshot' + || event.name.startsWith('mutation.') + || event.name === 'status') { + setStreamLive(true); + } if (event.name === 'mutation.applied' || event.name === 'mutation.failed' || event.name === 'status') { diff --git a/decnet_web/src/components/MazeNET/useTopologyEditor.ts b/decnet_web/src/components/MazeNET/useTopologyEditor.ts new file mode 100644 index 00000000..351ee05b --- /dev/null +++ b/decnet_web/src/components/MazeNET/useTopologyEditor.ts @@ -0,0 +1,132 @@ +/** + * Status-aware topology editor — wraps {@link useMazeApi} so the MazeNET + * editor can call one set of primitives regardless of whether the + * topology is ``pending`` (direct CRUD) or ``active|degraded`` (mutation + * queue via :func:`enqueueMutation`). + * + * Phase B scaffolding — for now every primitive is a pass-through to + * the direct-CRUD method on ``useMazeApi``. Behavior is unchanged from + * calling ``api.*`` directly. Status branching lands one primitive at + * a time in the follow-up commits so each change is small and + * reviewable. + * + * The ``*Name`` arguments (``deckyName``, ``lanName``, …) are unused in + * this pass — they're captured on the call site now so the signatures + * don't change when the enqueue branches are added: mutation ops are + * name-keyed while direct CRUD is uuid-keyed, and forcing the caller + * to plumb both through the editor hook up-front avoids a + * signature-churn commit later. + */ +import { useMemo } from 'react'; +import type { + CreateDeckyBody, + CreateLanBody, + DeckyRow, + EdgeRow, + LANRow, + UseMazeApi, +} from './useMazeApi'; + +export interface UseTopologyEditorOptions { + api: UseMazeApi; + /** Current topology status from :func:`getTopology`. */ + topoStatus: string; + /** Last-known topology version for optimistic concurrency. */ + topoVersion: number; +} + +/** + * Tagged result for every primitive. ``applied`` = backend wrote the + * row synchronously (pending path) and the caller can update local + * state with ``data``. ``enqueued`` = the mutator will apply the + * change asynchronously; the caller must NOT touch local state and + * should wait for the SSE ``mutation.applied`` refetch to reflect + * truth. + */ +export type PrimitiveResult = + | { kind: 'applied'; data: T } + | { kind: 'enqueued'; mutationId: string }; + +export interface UseTopologyEditor { + createLan(topologyId: string, body: CreateLanBody): Promise>; + updateLan( + topologyId: string, + lanId: string, + lanName: string, + patch: Partial, + ): Promise>; + deleteLan( + topologyId: string, + lanId: string, + lanName: string, + ): Promise>; + + createDecky(topologyId: string, body: CreateDeckyBody): Promise>; + updateDecky( + topologyId: string, + uuid: string, + deckyName: string, + patch: Partial, + ): Promise>; + deleteDecky( + topologyId: string, + uuid: string, + deckyName: string, + ): Promise>; + + attachEdge( + topologyId: string, + body: { decky_uuid: string; lan_id: string; is_bridge?: boolean; forwards_l3?: boolean }, + deckyName: string, + lanName: string, + ): Promise>; + detachEdge( + topologyId: string, + edgeId: string, + deckyName: string, + lanName: string, + ): Promise>; +} + +export function useTopologyEditor( + opts: UseTopologyEditorOptions, +): UseTopologyEditor { + const { api } = opts; + // topoStatus / topoVersion intentionally unused this pass — see module + // docstring. They'll drive the enqueue branch in the next commits. + + return useMemo(() => ({ + async createLan(topologyId, body) { + const data = await api.createLan(topologyId, body); + return { kind: 'applied', data }; + }, + async updateLan(topologyId, lanId, _lanName, patch) { + const data = await api.updateLan(topologyId, lanId, patch); + return { kind: 'applied', data }; + }, + async deleteLan(topologyId, lanId, _lanName) { + await api.deleteLan(topologyId, lanId); + return { kind: 'applied', data: undefined }; + }, + async createDecky(topologyId, body) { + const data = await api.createDecky(topologyId, body); + return { kind: 'applied', data }; + }, + async updateDecky(topologyId, uuid, _deckyName, patch) { + const data = await api.updateDecky(topologyId, uuid, patch); + return { kind: 'applied', data }; + }, + async deleteDecky(topologyId, uuid, _deckyName) { + await api.deleteDecky(topologyId, uuid); + return { kind: 'applied', data: undefined }; + }, + async attachEdge(topologyId, body, _deckyName, _lanName) { + const data = await api.attachEdge(topologyId, body); + return { kind: 'applied', data }; + }, + async detachEdge(topologyId, edgeId, _deckyName, _lanName) { + await api.detachEdge(topologyId, edgeId); + return { kind: 'applied', data: undefined }; + }, + }), [api]); +}