diff --git a/decnet_web/src/components/MazeNET/MazeNET.tsx b/decnet_web/src/components/MazeNET/MazeNET.tsx index 0c92251b..13bb9863 100644 --- a/decnet_web/src/components/MazeNET/MazeNET.tsx +++ b/decnet_web/src/components/MazeNET/MazeNET.tsx @@ -469,6 +469,14 @@ const MazeNET: React.FC = () => { || event.name === 'status') { setStreamLive(true); } + 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') { diff --git a/decnet_web/src/components/MazeNET/useTopologyEditor.ts b/decnet_web/src/components/MazeNET/useTopologyEditor.ts index 351ee05b..a8e16cad 100644 --- a/decnet_web/src/components/MazeNET/useTopologyEditor.ts +++ b/decnet_web/src/components/MazeNET/useTopologyEditor.ts @@ -4,18 +4,16 @@ * 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. + * Primitives return a tagged {@link PrimitiveResult}: + * ``{ kind: 'applied', data }`` — backend wrote synchronously; the + * caller may update local state. + * ``{ kind: 'enqueued', mutationId }`` — mutator will apply async; + * caller must NOT touch local state, + * SSE ``mutation.applied`` drives refetch. * - * 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. + * Name arguments (``deckyName``, ``lanName``) are required on every + * primitive because mutation ops are name-keyed while direct CRUD is + * uuid-keyed. Callers plumb both. */ import { useMemo } from 'react'; import type { @@ -35,14 +33,6 @@ export interface UseTopologyEditorOptions { 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 }; @@ -91,42 +81,107 @@ export interface UseTopologyEditor { 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. + const { api, topoStatus, topoVersion } = opts; + const live = topoStatus === 'active' || topoStatus === 'degraded'; return useMemo(() => ({ + // ── LAN ──────────────────────────────────────────────────────────── async createLan(topologyId, body) { - const data = await api.createLan(topologyId, body); - return { kind: 'applied', data }; + if (!live) { + const data = await api.createLan(topologyId, body); + return { kind: 'applied', data }; + } + // add_lan payload: {name, subnet?, is_dmz?, x?, y?} + const payload: Record = { name: body.name }; + if (body.subnet !== undefined) payload.subnet = body.subnet; + if (body.is_dmz !== undefined) payload.is_dmz = body.is_dmz; + if (body.x !== undefined) payload.x = body.x; + if (body.y !== undefined) payload.y = body.y; + const res = await api.enqueueMutation(topologyId, 'add_lan', payload, topoVersion); + return { kind: 'enqueued', mutationId: res.mutation_id }; }, - async updateLan(topologyId, lanId, _lanName, patch) { - const data = await api.updateLan(topologyId, lanId, patch); - return { kind: 'applied', data }; + async updateLan(topologyId, lanId, lanName, patch) { + if (!live) { + const data = await api.updateLan(topologyId, lanId, patch); + return { kind: 'applied', data }; + } + const payload: Record = { name: lanName }; + const patchFields: Record = {}; + for (const [k, v] of Object.entries(patch)) { + if (k === 'x' || k === 'y') payload[k] = v; + else patchFields[k] = v; + } + if (Object.keys(patchFields).length > 0) payload.patch = patchFields; + const res = await api.enqueueMutation(topologyId, 'update_lan', payload, topoVersion); + return { kind: 'enqueued', mutationId: res.mutation_id }; }, - async deleteLan(topologyId, lanId, _lanName) { - await api.deleteLan(topologyId, lanId); - return { kind: 'applied', data: undefined }; + async deleteLan(topologyId, lanId, lanName) { + if (!live) { + await api.deleteLan(topologyId, lanId); + return { kind: 'applied', data: undefined }; + } + const res = await api.enqueueMutation( + topologyId, 'remove_lan', { name: lanName }, topoVersion, + ); + return { kind: 'enqueued', mutationId: res.mutation_id }; }, + + // ── Decky ────────────────────────────────────────────────────────── async createDecky(topologyId, body) { + // No add_decky mutation op — decky creation on active topologies + // is a composite (attach_decky with the create implicit). Phase B + // step 3 handles that; for now creation stays direct-CRUD so the + // pending path keeps working. On active this will 409 today until + // step 3 lands a combined flow. 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 updateDecky(topologyId, uuid, deckyName, patch) { + if (!live) { + const data = await api.updateDecky(topologyId, uuid, patch); + return { kind: 'applied', data }; + } + const payload: Record = { decky: deckyName }; + const patchFields: Record = {}; + for (const [k, v] of Object.entries(patch)) { + if (k === 'services' || k === 'x' || k === 'y') payload[k] = v; + else patchFields[k] = v; + } + if (Object.keys(patchFields).length > 0) payload.patch = patchFields; + const res = await api.enqueueMutation(topologyId, 'update_decky', payload, topoVersion); + return { kind: 'enqueued', mutationId: res.mutation_id }; }, - async deleteDecky(topologyId, uuid, _deckyName) { - await api.deleteDecky(topologyId, uuid); - return { kind: 'applied', data: undefined }; + async deleteDecky(topologyId, uuid, deckyName) { + if (!live) { + await api.deleteDecky(topologyId, uuid); + return { kind: 'applied', data: undefined }; + } + const res = await api.enqueueMutation( + topologyId, 'remove_decky', { decky: deckyName }, topoVersion, + ); + return { kind: 'enqueued', mutationId: res.mutation_id }; }, - async attachEdge(topologyId, body, _deckyName, _lanName) { - const data = await api.attachEdge(topologyId, body); - return { kind: 'applied', data }; + + // ── Edges ────────────────────────────────────────────────────────── + async attachEdge(topologyId, body, deckyName, lanName) { + if (!live) { + const data = await api.attachEdge(topologyId, body); + return { kind: 'applied', data }; + } + const payload: Record = { decky: deckyName, lan: lanName }; + if (body.forwards_l3 !== undefined) payload.forwards_l3 = body.forwards_l3; + const res = await api.enqueueMutation(topologyId, 'attach_decky', payload, topoVersion); + return { kind: 'enqueued', mutationId: res.mutation_id }; }, - async detachEdge(topologyId, edgeId, _deckyName, _lanName) { - await api.detachEdge(topologyId, edgeId); - return { kind: 'applied', data: undefined }; + async detachEdge(topologyId, edgeId, deckyName, lanName) { + if (!live) { + await api.detachEdge(topologyId, edgeId); + return { kind: 'applied', data: undefined }; + } + const res = await api.enqueueMutation( + topologyId, 'detach_decky', { decky: deckyName, lan: lanName }, topoVersion, + ); + return { kind: 'enqueued', mutationId: res.mutation_id }; }, - }), [api]); + }), [api, live, topoVersion]); }