From a9c3f42ef9756b2a7ba47eb7cd4f6f088f74b18d Mon Sep 17 00:00:00 2001 From: anti Date: Tue, 16 Jun 2026 18:55:20 -0400 Subject: [PATCH] feat(mazenet): topology editor updates; refine mutator ops materialisation --- decnet/mutator/ops.py | 29 ++++++ decnet_web/src/components/MazeNET/MazeNET.css | 14 ++- decnet_web/src/components/MazeNET/MazeNET.tsx | 52 ++++++----- .../src/components/MazeNET/useMazeApi.ts | 88 ++++++------------- .../src/components/MazeNET/useTopologyData.ts | 48 +++++----- .../MazeNET/useTopologyEditor.test.ts | 24 ++--- .../components/MazeNET/useTopologyEditor.ts | 41 +++------ .../components/TopologyList/TopologyList.tsx | 2 +- development/DEVELOPMENT.md | 6 +- tests/mutator/test_ops_materialisation.py | 36 ++++++++ 10 files changed, 183 insertions(+), 157 deletions(-) diff --git a/decnet/mutator/ops.py b/decnet/mutator/ops.py index be939ca9..d1c70452 100644 --- a/decnet/mutator/ops.py +++ b/decnet/mutator/ops.py @@ -619,6 +619,35 @@ async def apply_remove_lan( f"{d['decky_config']['name']!r}; remove the decky first" ) lan_name = lan["name"] + + # Detach every bridge (non-home) member still attached to this LAN + # before dropping it. The home-LAN refusal above guarantees survivors + # are all multi-homed visitors; if we leave their edge + ips_by_lan + # entry behind, the row points at a LAN that no longer exists and + # _assert_valid_after raises IP_UNKNOWN_LAN — degrading the topology + # on a plain delete. Mirrors apply_detach_decky's per-decky cleanup. + for e in hydrated["edges"]: + if e["lan_id"] != lan["id"]: + continue + decky = next( + (d for d in hydrated["deckies"] if d["uuid"] == e["decky_uuid"]), + None, + ) + if decky is not None: + new_cfg = dict(decky["decky_config"]) + new_ips = dict(new_cfg.get("ips_by_lan", {})) + new_ips.pop(lan_name, None) + new_cfg["ips_by_lan"] = new_ips + await repo.update_topology_decky( + decky["uuid"], {"decky_config": new_cfg} + ) + await _materialise_decky_disconnect( + repo, topology_id, + decky_name=decky["decky_config"]["name"], + lan_name=lan_name, + ) + await repo.delete_topology_edge(e["id"], enforce_pending=False) + # enforce_pending=False: the mutator queue is the live-editing # surface, gated on topology status by us before we got here. The # repo's pending-only guard is for HTTP CRUD callers that mustn't diff --git a/decnet_web/src/components/MazeNET/MazeNET.css b/decnet_web/src/components/MazeNET/MazeNET.css index 37a9934c..9a620771 100644 --- a/decnet_web/src/components/MazeNET/MazeNET.css +++ b/decnet_web/src/components/MazeNET/MazeNET.css @@ -48,7 +48,7 @@ body.maze-fullscreen .maze-shell { transition: all 0.3s; } .maze-btn:hover { background: var(--matrix); color: var(--bg); box-shadow: var(--matrix-glow); } -.maze-btn.ghost { border-color: var(--border); color: var(--matrix); opacity: 0.7; } +.maze-btn.ghost { border-color: var(--border); color: var(--matrix); opacity: 0.92; } .maze-btn.ghost:hover { background: transparent; color: var(--matrix); opacity: 1; border-color: var(--matrix); box-shadow: var(--matrix-glow); @@ -323,11 +323,17 @@ html[data-theme="light"] .maze-net-box.inactive { /* ── Canvas overlays ────────────────────────── */ .maze-toolbar { position: absolute; top: 12px; left: 12px; display: flex; gap: 8px; z-index: 5; } +/* Solid theme-aware backing (same as the legend) so the toolbar buttons + stay legible over net boxes in both themes — a hardcoded dark fill went + ink-on-ink in light mode. Full opacity; matched-specificity hover keeps + the fill behaviour. */ +.maze-toolbar .maze-btn { background: var(--panel); opacity: 1; } +.maze-toolbar .maze-btn:hover { background: var(--matrix); } .maze-status { position: absolute; bottom: 12px; left: 12px; display: flex; gap: 12px; z-index: 5; - font-size: 0.62rem; opacity: 0.6; letter-spacing: 1px; - background: rgba(0, 0, 0, 0.6); padding: 6px 10px; border: 1px solid var(--border); + font-size: 0.62rem; opacity: 0.92; letter-spacing: 1px; + background: rgba(0, 0, 0, 0.8); padding: 6px 10px; border: 1px solid var(--border); } .maze-legend { position: absolute; bottom: 12px; right: 12px; z-index: 5; @@ -349,7 +355,7 @@ html[data-theme="light"] .maze-net-box.inactive { /* Status bar segments */ .maze-status .status-seg { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; } .maze-status .status-seg.live { color: var(--matrix); opacity: 0.9; } -.maze-status .status-seg.dim { opacity: 0.45; } +.maze-status .status-seg.dim { opacity: 0.75; } /* Toolbar button sizing override */ .maze-toolbar .maze-btn.small { diff --git a/decnet_web/src/components/MazeNET/MazeNET.tsx b/decnet_web/src/components/MazeNET/MazeNET.tsx index acab7f50..1ba2f100 100644 --- a/decnet_web/src/components/MazeNET/MazeNET.tsx +++ b/decnet_web/src/components/MazeNET/MazeNET.tsx @@ -37,14 +37,14 @@ const tempIdSuffix = (): string => { return r.slice(0, 4); }; -const NET_GRID_W = 300; -const NET_GRID_H = 240; -const NET_GRID_GAP = 40; -const NET_GRID_COLS = 3; +const NET_GRID_W = 300; +const NET_GRID_H = 240; async function _dropNetwork( drag: PaletteDrag, + world: { x: number; y: number }, topologyId: string, + live: boolean, nets: Net[], api: ReturnType, editor: ReturnType, @@ -57,12 +57,18 @@ async function _dropNetwork( flashErr(null, 'topology already has a DMZ'); return; } - const i = nets.filter((n) => n.kind !== 'internet').length; - const x = NET_GRID_GAP + (i % NET_GRID_COLS) * (NET_GRID_W + NET_GRID_GAP); - const y = NET_GRID_GAP + Math.floor(i / NET_GRID_COLS) * (NET_GRID_H + NET_GRID_GAP); + // Place the box centred on the drop point (canvas/world coords), not in a + // grid slot — dropping should land where the cursor released. + const x = Math.round(world.x - NET_GRID_W / 2); + const y = Math.round(world.y - NET_GRID_H / 2); const name = isDmz ? `dmz-${tempIdSuffix()}` : `subnet-${tempIdSuffix()}`; try { - const subnet = await api.getNextSubnet().catch(() => undefined); + // On a live topology edits are STAGED and applied as a batch, so a + // server-side next-subnet lookup would hand every staged net the SAME + // free subnet (the prior ones aren't committed yet) → SUBNET_OVERLAP on + // commit. Leave it unset and let apply_add_lan allocate at apply time; + // the mutator drains sequentially so each sees the previous one. + const subnet = live ? undefined : await api.getNextSubnet().catch(() => undefined); const lanRes = await editor.createLan(topologyId, { name, is_dmz: isDmz, x, y, ...(subnet ? { subnet } : {}) }); if (lanRes.kind !== 'applied') { const tempId = `pending-lan-${name}`; @@ -185,7 +191,7 @@ const MazeNET: React.FC = () => { const { nets, setNets, nodes, setNodes, edges, setEdges, topoMeta, services, archetypes, - loadErr, actionErr, commitErr, clearCommitErr, flashErr, setRefetchPaused, + loadErr, actionErr, commitErr, clearCommitErr, flashErr, deploying, onDeploy, streamLive, lastEventAt, streamEnabled, refetch, @@ -289,7 +295,8 @@ const MazeNET: React.FC = () => { async (drag: PaletteDrag, world: { x: number; y: number }, overNetId: string | null, overNodeId: string | null) => { if (!topologyId) return; if (drag.kind === 'network-subnet' || drag.kind === 'network-dmz') { - await _dropNetwork(drag, topologyId, nets, api, editor, setNets, setNodes, flashErr); + const liveNow = topoStatus === 'active' || topoStatus === 'degraded'; + await _dropNetwork(drag, world, topologyId, liveNow, nets, api, editor, setNets, setNodes, flashErr); } else if (drag.kind === 'archetype' && overNetId) { await _dropArchetype(drag, world, overNetId, topologyId, nets, archetypes, editor, setNodes, flashErr); } else if (drag.kind === 'service' && overNodeId) { @@ -541,30 +548,27 @@ const MazeNET: React.FC = () => { const deckyNodes = nodes.filter((n) => n.kind === 'decky'); const runningDeckies = deckyNodes.filter((n) => n.status === 'active').length; - /* UPDATE button: flush the staged changeset as one sequential mutation - batch. SSE refetch is paused so per-mutation applied events don't wipe - still-staged placeholders mid-batch; one refetch reconciles at the end - (success or failure). A failed op throws MutationFailedError, which - flashErr pins as a persistent banner. */ + /* UPDATE button: enqueue the staged changeset (async). We await only the + enqueue POSTs — sequentially, so expected_version stays ordered — then + return. The mutator drains the rows on its own loop; the SSE stream + drives refetch as each lands and surfaces any apply failure as the + persistent commitErr banner. No per-op polling (that flooded the API + and froze the UI). */ const handleCommit = useCallback(async () => { if (!topologyId || pendingCount === 0) return; - const n = pendingCount; setCommitting(true); - setRefetchPaused(true); try { - const applied = await editor.commitStaged(); + const queued = await editor.commitStaged(); pushToast({ - text: `UPDATED · ${applied} CHANGE${applied === 1 ? '' : 'S'}`, - tone: 'matrix', icon: 'check-circle', + text: `QUEUED · ${queued} CHANGE${queued === 1 ? '' : 'S'} APPLYING`, + tone: 'violet', icon: 'terminal', }); } catch (err) { - flashErr(err, `update failed after ${n - editor.pendingCount}/${n} changes`); + flashErr(err, 'failed to queue changes'); } finally { - setRefetchPaused(false); - await refetch(); setCommitting(false); } - }, [editor, topologyId, pendingCount, refetch, pushToast, flashErr, setRefetchPaused]); + }, [editor, topologyId, pendingCount, pushToast, flashErr]); return (
diff --git a/decnet_web/src/components/MazeNET/useMazeApi.ts b/decnet_web/src/components/MazeNET/useMazeApi.ts index 9564f0cc..574dd5cc 100644 --- a/decnet_web/src/components/MazeNET/useMazeApi.ts +++ b/decnet_web/src/components/MazeNET/useMazeApi.ts @@ -37,15 +37,6 @@ export interface EdgeRow { forwards_l3: boolean; } -export type MutationState = 'pending' | 'applying' | 'applied' | 'failed'; - -export interface MutationRow { - id: string; - topology_id: string; - op: string; - state: MutationState; - reason: string | null; -} export interface TopologySummary { id: string; @@ -75,11 +66,12 @@ export interface HydratedTopology { * placement. Decky-to-decky traffic edges are derived from * shared-LAN co-membership for visualization only. */ export function adaptTopology(detail: TopologyDetail): HydratedTopology { - // Auto-layout: DMZ pinned top-left, subnets flow in a grid to the right. - // We ignore lan.x/lan.y from the backend because canvas position - // persistence is deferred (handled via localStorage in a later pass). - // Computing layout from the graph keeps the canvas readable no matter - // how sloppy the original drop points were. + // Layout: honour the backend's stored x/y (the drop coords sent at + // create time) when present, falling back to a DMZ-first grid for + // topologies that never set canvas coords (e.g. generated ones). Without + // this, every refetch re-grids — so committing a staged edit yanked all + // nets back to the grid. localStorage (applyLayout) still overlays any + // later drags on top of this baseline. const NET_W = 300; const NET_H = 240; const GAP_X = 40; @@ -94,8 +86,8 @@ export function adaptTopology(detail: TopologyDetail): HydratedTopology { label: lan.name.toUpperCase(), cidr: lan.subnet, kind: lan.is_dmz ? 'dmz' : 'subnet', - x: GAP_X + (i % COLS) * (NET_W + GAP_X), - y: GAP_Y + Math.floor(i / COLS) * (NET_H + GAP_Y), + x: lan.x ?? GAP_X + (i % COLS) * (NET_W + GAP_X), + y: lan.y ?? GAP_Y + Math.floor(i / COLS) * (NET_H + GAP_Y), w: NET_W, h: NET_H, })); @@ -138,9 +130,9 @@ export function adaptTopology(detail: TopologyDetail): HydratedTopology { firstLanFor.set(e.decky_uuid, e.lan_id); } - // Layout deckies in a 2-column grid inside their home LAN so two - // members never overlap regardless of backend x/y. Same reasoning as - // the LAN grid above. + // Deckies: honour stored x/y (drop coords) when present, else a + // 2-column grid inside their home LAN. Same baseline-vs-grid logic as + // the LAN layout above. const NODE_COL_W = 140; const NODE_ROW_H = 82; const NODE_X0 = 12; @@ -158,8 +150,8 @@ export function adaptTopology(detail: TopologyDetail): HydratedTopology { archetype: (d.decky_config as { archetype?: string } | null)?.archetype ?? 'linux-server', services: d.services, status: d.state === 'running' ? 'active' : d.state === 'failed' ? 'hot' : 'idle', - x: NODE_X0 + (idx % 2) * NODE_COL_W, - y: NODE_Y0 + Math.floor(idx / 2) * NODE_ROW_H, + x: d.x ?? NODE_X0 + (idx % 2) * NODE_COL_W, + y: d.y ?? NODE_Y0 + Math.floor(idx / 2) * NODE_ROW_H, ip: d.ip ?? undefined, decky_config: d.decky_config ?? undefined, }; @@ -176,6 +168,16 @@ export function adaptTopology(detail: TopologyDetail): HydratedTopology { for (const [lanId, members] of byLan) { for (let i = 0; i < members.length; i++) { for (let j = i + 1; j < members.length; j++) { + // Draw an edge between two co-members of a LAN only when at least + // one of them is HOME here. A pair that are both merely *visiting* + // (multi-homed bridges, e.g. a subnet's L3 gateway whose home is + // the DMZ) would otherwise render a line between their two display + // homes — drawing a phantom link to the DMZ/root from any net you + // bridge into. The home↔visitor edge still carries the real + // connection (e.g. subnet→gateway shows the subnet reaching root). + const iHome = firstLanFor.get(members[i]) === lanId; + const jHome = firstLanFor.get(members[j]) === lanId; + if (!iHome && !jHome) continue; const key = `${members[i]}::${members[j]}`; if (seen.has(key)) continue; seen.add(key); @@ -259,16 +261,6 @@ export interface MazeApi { expectedVersion?: number, ) => Promise; - /** Poll the mutation queue until ``mutationId`` reaches a terminal - * state (``applied`` | ``failed``). Resolves with that row; rejects - * only on timeout. A ``failed`` row resolves (not rejects) so callers - * can read ``reason`` — the editor turns it into a loud error. */ - waitForMutation: ( - topologyId: string, - mutationId: string, - opts?: { timeoutMs?: number; intervalMs?: number }, - ) => Promise; - deployTopology: (topologyId: string) => Promise; } @@ -413,36 +405,6 @@ export function useMazeApi(): MazeApi { [], ); - const waitForMutation = useCallback( - async ( - topologyId: string, - mutationId: string, - opts: { timeoutMs?: number; intervalMs?: number } = {}, - ): Promise => { - const { timeoutMs = 30000, intervalMs = 400 } = opts; - const deadline = Date.now() + timeoutMs; - // ponytail: poll the existing list endpoint; the SSE stream also - // carries mutation.applied/failed but wiring a one-shot waiter into - // it couples the editor to the stream hook for no real gain here. - for (;;) { - const { data } = await api.get( - `/topologies/${topologyId}/mutations`, - ); - const row = data.find((r) => r.id === mutationId); - if (row && (row.state === 'applied' || row.state === 'failed')) { - return row; - } - if (Date.now() >= deadline) { - throw new Error( - `mutation ${mutationId} did not settle within ${timeoutMs}ms`, - ); - } - await new Promise((res) => setTimeout(res, intervalMs)); - } - }, - [], - ); - const enqueueMutation = useCallback( async ( topologyId: string, @@ -468,7 +430,7 @@ export function useMazeApi(): MazeApi { createLan, updateLan, deleteLan, createDecky, updateDecky, deleteDecky, attachEdge, detachEdge, - enqueueMutation, waitForMutation, + enqueueMutation, deployTopology, }), [ @@ -477,7 +439,7 @@ export function useMazeApi(): MazeApi { createLan, updateLan, deleteLan, createDecky, updateDecky, deleteDecky, attachEdge, detachEdge, - enqueueMutation, waitForMutation, + enqueueMutation, deployTopology, ], ); diff --git a/decnet_web/src/components/MazeNET/useTopologyData.ts b/decnet_web/src/components/MazeNET/useTopologyData.ts index a4031032..4fd5a198 100644 --- a/decnet_web/src/components/MazeNET/useTopologyData.ts +++ b/decnet_web/src/components/MazeNET/useTopologyData.ts @@ -1,11 +1,10 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import { useCallback, useEffect, useRef, useState } from 'react'; +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 { MutationFailedError } from './useTopologyEditor'; import { useTopologyStream, type TopologyStreamEvent } from './useTopologyStream'; export interface TopoMeta { @@ -48,10 +47,6 @@ export interface UseTopologyDataResult { commitErr: string | null; clearCommitErr: () => void; flashErr: (err: unknown, fallback: string) => void; - /** Pause SSE-driven refetch while a commit batch is in flight, so the - * per-mutation ``applied`` events don't wipe the still-staged - * placeholders mid-batch. The committer does one refetch at the end. */ - setRefetchPaused: (paused: boolean) => void; // Deploy deploying: boolean; @@ -91,18 +86,7 @@ export function useTopologyData( const clearCommitErr = useCallback(() => setCommitErr(null), []); - const refetchPausedRef = useRef(false); - const setRefetchPaused = useCallback((paused: boolean) => { - refetchPausedRef.current = paused; - }, []); - const flashErr = useCallback((err: unknown, fallback: string) => { - // A failed live mutation is loud + persistent: the queue halted and - // the topology probably degraded — don't let it vanish in 4s. - if (err instanceof MutationFailedError) { - setCommitErr(err.message); - return; - } const msg = (err as ApiError)?.response?.data?.detail ?? (err as ApiError)?.message ?? fallback; setActionErr(msg); setTimeout(() => setActionErr(null), 4000); @@ -122,7 +106,22 @@ export function useTopologyData( const h = await api.getTopology(topologyId); setNets(h.nets); setNodes(h.nodes); - setEdges(h.edges); + // Keep optimistic bridge edges (those carrying a backendEdgeId) alive + // across refetches until the server actually derives the equivalent + // pair. A bridge attach is best-effort and applies asynchronously, so + // wholesale-replacing edges here would blink the just-drawn link out + // until the mutator catches up. We only retain ones whose endpoints + // still exist and that the server hasn't yet produced. + const pairKey = (a: string, b: string) => (a < b ? `${a}::${b}` : `${b}::${a}`); + const serverPairs = new Set(h.edges.map((e) => pairKey(e.from, e.to))); + const nodeIds = new Set(h.nodes.map((n) => n.id)); + setEdges((prev) => { + const pending = prev.filter((e) => + e.backendEdgeId + && !serverPairs.has(pairKey(e.from, e.to)) + && nodeIds.has(e.from) && nodeIds.has(e.to)); + return [...h.edges, ...pending]; + }); setTopoMeta({ status: h.topology.status, name: h.topology.name, @@ -152,18 +151,21 @@ export function useTopologyData( setLastEventAt(new Date()); } if (event.name === 'mutation.failed') { + // A queued mutation failed to apply (the topology likely degraded). + // Loud + persistent — this is the async equivalent of the old + // blocking commit's thrown error; dismissed via clearCommitErr. 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); + setCommitErr(`mutation failed: ${reason}`); } if (event.name === 'mutation.applied' || event.name === 'mutation.failed' || event.name === 'status') { - // Suppressed mid-commit — the committer drives one refetch at the end. - if (!refetchPausedRef.current) void refetch(); + // Async queue: each row draining emits one of these; refetch to + // reconcile optimistic placeholders to server truth as they land. + void refetch(); } // Live service mutations from another tab / admin: optimistically // patch local state so the chip set reflects shape without a full @@ -213,7 +215,7 @@ export function useTopologyData( edges, setEdges, topoMeta, services, archetypes, - loadErr, actionErr, commitErr, clearCommitErr, flashErr, setRefetchPaused, + loadErr, actionErr, commitErr, clearCommitErr, flashErr, deploying, onDeploy, streamLive, lastEventAt, streamEnabled, refetch, diff --git a/decnet_web/src/components/MazeNET/useTopologyEditor.test.ts b/decnet_web/src/components/MazeNET/useTopologyEditor.test.ts index 241cd2c5..3bae77fc 100644 --- a/decnet_web/src/components/MazeNET/useTopologyEditor.test.ts +++ b/decnet_web/src/components/MazeNET/useTopologyEditor.test.ts @@ -5,12 +5,11 @@ import { describe, it, expect, vi } from 'vitest'; import { act, renderHook } from '@testing-library/react'; -import { useTopologyEditor, MutationFailedError } from './useTopologyEditor'; +import { useTopologyEditor } from './useTopologyEditor'; import type { MazeApi } from './useMazeApi'; const buildApi = (overrides: Partial = {}): MazeApi => ({ enqueueMutation: vi.fn().mockResolvedValue({ mutation_id: 'm', state: 'pending' }), - waitForMutation: vi.fn().mockResolvedValue({ state: 'applied', reason: null }), ...overrides, } as unknown as MazeApi); @@ -20,7 +19,7 @@ const editorFor = (api: MazeApi, topoVersion = 5) => ); describe('useTopologyEditor live staging', () => { - it('stages live edits without sending; commit flushes them in order with a version cursor', async () => { + it('stages live edits without sending; commit enqueues them in order with a version cursor', async () => { const enqueue = vi.fn().mockResolvedValue({ mutation_id: 'm', state: 'pending' }); const api = buildApi({ enqueueMutation: enqueue }); const { result } = editorFor(api, 5); @@ -38,18 +37,20 @@ describe('useTopologyEditor live staging', () => { await result.current.commitStaged(); }); + // Enqueued (not waited-on): no apply polling. expect(enqueue).toHaveBeenCalledTimes(2); expect(enqueue.mock.calls[0][3]).toBe(5); // first uses server version expect(enqueue.mock.calls[1][3]).toBe(6); // second advanced by the cursor expect(result.current.pendingCount).toBe(0); }); - it('commit stops loudly on a failed op, keeps the remainder, and retries cleanly', async () => { - const wait = vi + it('keeps the un-enqueued remainder staged when an enqueue POST fails', async () => { + const enqueue = vi .fn() - .mockResolvedValueOnce({ state: 'failed', reason: 'post-apply validation failed: IP_COLLISION' }) - .mockResolvedValue({ state: 'applied', reason: null }); - const api = buildApi({ waitForMutation: wait }); + .mockResolvedValueOnce({ mutation_id: 'm', state: 'pending' }) + .mockRejectedValueOnce(new Error('409 version conflict')) + .mockResolvedValue({ mutation_id: 'm', state: 'pending' }); + const api = buildApi({ enqueueMutation: enqueue }); const { result } = editorFor(api, 1); await act(async () => { @@ -59,12 +60,11 @@ describe('useTopologyEditor live staging', () => { expect(result.current.pendingCount).toBe(2); await act(async () => { - await expect(result.current.commitStaged()).rejects.toBeInstanceOf(MutationFailedError); + await expect(result.current.commitStaged()).rejects.toThrow('409'); }); - // First op failed → nothing applied → both stay staged for retry. - expect(result.current.pendingCount).toBe(2); + // First op enqueued, second threw → one remains staged for retry. + expect(result.current.pendingCount).toBe(1); - // Retry: waitForMutation now resolves 'applied' for both. await act(async () => { await result.current.commitStaged(); }); diff --git a/decnet_web/src/components/MazeNET/useTopologyEditor.ts b/decnet_web/src/components/MazeNET/useTopologyEditor.ts index 70e11707..c72412e4 100644 --- a/decnet_web/src/components/MazeNET/useTopologyEditor.ts +++ b/decnet_web/src/components/MazeNET/useTopologyEditor.ts @@ -27,20 +27,6 @@ import type { MutationOp, } from './useMazeApi'; -/** Thrown by a live primitive when its mutation settles as ``failed``. - * Carries the op + backend reason so the page can surface a loud, - * persistent error instead of a transient toast. */ -export class MutationFailedError extends Error { - readonly op: string; - readonly reason: string; - constructor(op: string, reason: string) { - super(`mutation ${op} failed: ${reason}`); - this.name = 'MutationFailedError'; - this.op = op; - this.reason = reason; - } -} - export interface UseTopologyEditorOptions { api: MazeApi; /** Current topology status from :func:`getTopology`. */ @@ -171,26 +157,27 @@ export function useTopologyEditor( const commitStaged = useCallback(async (): Promise => { const ops = staged; if (ops.length === 0) return 0; - let applied = 0; + // ASYNC queue: enqueue the batch and return. We await only the enqueue + // POSTs (sequentially, so expected_version stays ordered — the server + // bumps it per enqueue), NOT each mutation's apply. The mutator drains + // the rows on its own loop and the SSE stream reports applied/failed; + // polling every row to a terminal state here flooded the API (200+ GET + // /mutations per session) and froze the UI. Apply-time failures surface + // loudly via the SSE 'mutation.failed' handler in useTopologyData. + let enqueued = 0; try { for (const o of ops) { const expected = cursorRef.current; - const res = await api.enqueueMutation(o.topologyId, o.op, o.payload, expected); - // Advance even if the apply fails below — enqueue already bumped - // the server version. + await api.enqueueMutation(o.topologyId, o.op, o.payload, expected); cursorRef.current = expected + 1; - const row = await api.waitForMutation(o.topologyId, res.mutation_id); - if (row.state === 'failed') { - throw new MutationFailedError(o.op, row.reason ?? 'unknown reason'); - } - applied += 1; + enqueued += 1; } setStaged([]); - return applied; + return enqueued; } catch (err) { - // Drop the applied prefix; keep the failing op + the rest so the user - // can fix and retry without re-staging everything. - setStaged(ops.slice(applied)); + // Enqueue-level failure (e.g. 409 version conflict / network). Drop + // the ops that did enqueue; keep the rest staged for retry. + setStaged(ops.slice(enqueued)); throw err; } }, [staged, api]); diff --git a/decnet_web/src/components/TopologyList/TopologyList.tsx b/decnet_web/src/components/TopologyList/TopologyList.tsx index c861606a..03cd794f 100644 --- a/decnet_web/src/components/TopologyList/TopologyList.tsx +++ b/decnet_web/src/components/TopologyList/TopologyList.tsx @@ -257,7 +257,7 @@ const TopologyList: React.FC = () => { {armed === `td:${r.id}` ? 'CONFIRM?' : 'TEARDOWN'} )} - {!['active', 'degraded', 'deploying'].includes(r.status) && ( + {!['active', 'degraded', 'deploying', 'tearing_down'].includes(r.status) && (