From 4f141c1a54df31a14d7d3a68f0b3d403157fd953 Mon Sep 17 00:00:00 2001 From: anti Date: Tue, 16 Jun 2026 12:59:57 -0400 Subject: [PATCH] feat(web): stage live MazeNET edits behind an UPDATE button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live topology edits fired one mutation per canvas action. That coupled each edit to an immediate enqueue+apply, which (post-serialization) raced the SSE refetch and duplicated optimistic placeholders, and gave the user no chance to assemble a coherent changeset (add a net AND bridge it) before any of it landed. Live edits now STAGE: each editor primitive records its op and returns immediately; the optimistic placeholders callers already draw are the staged preview. The action button reads UPDATE (n) when live (DEPLOY when pending) and flushes the batch through the slice-1 submit queue — sequential, version-cursored, each awaited to a terminal state, stopping loudly on the first failure with the unapplied remainder kept for retry. REFRESH becomes DISCARD (n) to drop the batch. SSE refetch is paused during a commit so per-mutation applied events don't wipe still-staged placeholders mid-batch; one refetch reconciles at the end. Also fix _dropArchetype, which bailed without an optimistic node on the staged path, leaving a decky added to an uncommitted LAN invisible until UPDATE. --- decnet_web/src/components/MazeNET/MazeNET.tsx | 83 +++++++++++--- .../src/components/MazeNET/useTopologyData.ts | 16 ++- .../MazeNET/useTopologyEditor.test.ts | 57 +++++++--- .../components/MazeNET/useTopologyEditor.ts | 101 +++++++++++++----- 4 files changed, 194 insertions(+), 63 deletions(-) diff --git a/decnet_web/src/components/MazeNET/MazeNET.tsx b/decnet_web/src/components/MazeNET/MazeNET.tsx index d7a148ef..acab7f50 100644 --- a/decnet_web/src/components/MazeNET/MazeNET.tsx +++ b/decnet_web/src/components/MazeNET/MazeNET.tsx @@ -124,10 +124,14 @@ async function _dropArchetype( { name, services: dServices, x: nx, y: ny, decky_config: { archetype: drag.slug } }, overNetId, net.name, ); - if (dRes.kind !== 'applied') return; - const decky = dRes.data; + // On a live topology the add is STAGED (kind 'enqueued') — no server + // uuid yet, so use a temp id and render the node optimistically. + // Without this the decky is invisible until UPDATE + refetch. Mirrors + // the pending-net placeholder in _dropNetwork. + const id = dRes.kind === 'applied' ? dRes.data.uuid : `pending-decky-${name}`; + const nodeName = dRes.kind === 'applied' ? dRes.data.name : name; setNodes((p) => [...p, { - kind: 'decky', id: decky.uuid, netId: overNetId, name: decky.name, + kind: 'decky', id, netId: overNetId, name: nodeName, archetype: drag.slug, services: dServices, status: 'idle', x: nx, y: ny, } as DeckyNode]); } catch (err) { @@ -181,7 +185,7 @@ const MazeNET: React.FC = () => { const { nets, setNets, nodes, setNodes, edges, setEdges, topoMeta, services, archetypes, - loadErr, actionErr, commitErr, clearCommitErr, flashErr, + loadErr, actionErr, commitErr, clearCommitErr, flashErr, setRefetchPaused, deploying, onDeploy, streamLive, lastEventAt, streamEnabled, refetch, @@ -531,9 +535,37 @@ const MazeNET: React.FC = () => { }, []); const canDeploy = topoStatus === 'pending' && nets.length > 0; + const liveTopo = topoStatus === 'active' || topoStatus === 'degraded'; + const pendingCount = editor.pendingCount; + const [committing, setCommitting] = useState(false); 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. */ + const handleCommit = useCallback(async () => { + if (!topologyId || pendingCount === 0) return; + const n = pendingCount; + setCommitting(true); + setRefetchPaused(true); + try { + const applied = await editor.commitStaged(); + pushToast({ + text: `UPDATED · ${applied} CHANGE${applied === 1 ? '' : 'S'}`, + tone: 'matrix', icon: 'check-circle', + }); + } catch (err) { + flashErr(err, `update failed after ${n - editor.pendingCount}/${n} changes`); + } finally { + setRefetchPaused(false); + await refetch(); + setCommitting(false); + } + }, [editor, topologyId, pendingCount, refetch, pushToast, flashErr, setRefetchPaused]); + return (
@@ -592,8 +624,13 @@ const MazeNET: React.FC = () => { {fullscreen ? : } {fullscreen ? ' EXIT FULL' : ' FULLSCREEN'} - - + {liveTopo ? ( + + ) : ( + + )}
diff --git a/decnet_web/src/components/MazeNET/useTopologyData.ts b/decnet_web/src/components/MazeNET/useTopologyData.ts index 60431d90..a4031032 100644 --- a/decnet_web/src/components/MazeNET/useTopologyData.ts +++ b/decnet_web/src/components/MazeNET/useTopologyData.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, 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'; @@ -48,6 +48,10 @@ 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; @@ -87,6 +91,11 @@ 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. @@ -153,7 +162,8 @@ export function useTopologyData( if (event.name === 'mutation.applied' || event.name === 'mutation.failed' || event.name === 'status') { - void refetch(); + // Suppressed mid-commit — the committer drives one refetch at the end. + if (!refetchPausedRef.current) void refetch(); } // Live service mutations from another tab / admin: optimistically // patch local state so the chip set reflects shape without a full @@ -203,7 +213,7 @@ export function useTopologyData( edges, setEdges, topoMeta, services, archetypes, - loadErr, actionErr, commitErr, clearCommitErr, flashErr, + loadErr, actionErr, commitErr, clearCommitErr, flashErr, setRefetchPaused, 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 508df954..241cd2c5 100644 --- a/decnet_web/src/components/MazeNET/useTopologyEditor.test.ts +++ b/decnet_web/src/components/MazeNET/useTopologyEditor.test.ts @@ -19,27 +19,32 @@ const editorFor = (api: MazeApi, topoVersion = 5) => useTopologyEditor({ api, topoStatus: 'active', topoVersion }), ); -describe('useTopologyEditor live mutation queue', () => { - it('serialises concurrent submits and advances expected_version per enqueue', async () => { +describe('useTopologyEditor live staging', () => { + it('stages live edits without sending; commit flushes 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); - // Fire two structural ops in the SAME tick — the pre-fix bug was both - // sending expected_version=5 and the loser 409ing. await act(async () => { - await Promise.all([ - result.current.createLan('t', { name: 'a', is_dmz: false, x: 0, y: 0 }), - result.current.deleteLan('t', 'lid', 'b'), - ]); + await result.current.createLan('t', { name: 'a', is_dmz: false, x: 0, y: 0 }); + await result.current.deleteLan('t', 'lid', 'b'); + }); + + // Staged, not sent. + expect(result.current.pendingCount).toBe(2); + expect(enqueue).not.toHaveBeenCalled(); + + await act(async () => { + await result.current.commitStaged(); }); 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('throws MutationFailedError on a failed mutation but keeps the queue alive', async () => { + it('commit stops loudly on a failed op, keeps the remainder, and retries cleanly', async () => { const wait = vi .fn() .mockResolvedValueOnce({ state: 'failed', reason: 'post-apply validation failed: IP_COLLISION' }) @@ -48,16 +53,36 @@ describe('useTopologyEditor live mutation queue', () => { const { result } = editorFor(api, 1); await act(async () => { - await expect( - result.current.createLan('t', { name: 'a', is_dmz: false, x: 0, y: 0 }), - ).rejects.toBeInstanceOf(MutationFailedError); + await result.current.createLan('t', { name: 'a', is_dmz: false, x: 0, y: 0 }); + await result.current.deleteLan('t', 'lid', 'b'); }); + expect(result.current.pendingCount).toBe(2); - // A failed op must not wedge the chain — the next submit still resolves. await act(async () => { - await expect( - result.current.deleteLan('t', 'lid', 'b'), - ).resolves.toEqual({ kind: 'enqueued', mutationId: 'm' }); + await expect(result.current.commitStaged()).rejects.toBeInstanceOf(MutationFailedError); }); + // First op failed → nothing applied → both stay staged for retry. + expect(result.current.pendingCount).toBe(2); + + // Retry: waitForMutation now resolves 'applied' for both. + await act(async () => { + await result.current.commitStaged(); + }); + expect(result.current.pendingCount).toBe(0); + }); + + it('discardStaged drops the batch without sending', async () => { + const enqueue = vi.fn().mockResolvedValue({ mutation_id: 'm', state: 'pending' }); + const api = buildApi({ enqueueMutation: enqueue }); + const { result } = editorFor(api, 1); + + await act(async () => { + await result.current.createLan('t', { name: 'a', is_dmz: false, x: 0, y: 0 }); + }); + expect(result.current.pendingCount).toBe(1); + + act(() => result.current.discardStaged()); + expect(result.current.pendingCount).toBe(0); + expect(enqueue).not.toHaveBeenCalled(); }); }); diff --git a/decnet_web/src/components/MazeNET/useTopologyEditor.ts b/decnet_web/src/components/MazeNET/useTopologyEditor.ts index 1a94a935..70e11707 100644 --- a/decnet_web/src/components/MazeNET/useTopologyEditor.ts +++ b/decnet_web/src/components/MazeNET/useTopologyEditor.ts @@ -16,7 +16,7 @@ * primitive because mutation ops are name-keyed while direct CRUD is * uuid-keyed. Callers plumb both. */ -import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { CreateDeckyBody, CreateLanBody, @@ -53,6 +53,12 @@ export type PrimitiveResult = | { kind: 'applied'; data: T } | { kind: 'enqueued'; mutationId: string }; +interface StagedOp { + topologyId: string; + op: MutationOp; + payload: Record; +} + export interface UseTopologyEditor { createLan(topologyId: string, body: CreateLanBody): Promise>; updateLan( @@ -108,6 +114,18 @@ export interface UseTopologyEditor { deckyName: string, lanName: string, ): Promise>; + + // ── Staging (live topologies only) ─────────────────────────────────── + /** Count of staged-but-unsent live edits. 0 on a pending topology. */ + pendingCount: number; + /** Flush staged edits as one sequential mutation batch (version-cursored, + * each awaited to a terminal state). Stops on the first failure, keeping + * the failing op + remainder staged, and rethrows MutationFailedError so + * the caller can surface it. Resolves with the count applied. */ + commitStaged(): Promise; + /** Drop all staged edits without sending (paired with a refetch to wipe + * their optimistic placeholders). */ + discardStaged(): void; } export function useTopologyEditor( @@ -116,19 +134,19 @@ export function useTopologyEditor( const { api, topoStatus, topoVersion } = opts; const live = topoStatus === 'active' || topoStatus === 'degraded'; - // Serialised mutation submission. Two problems this solves, both - // proven against the live backend: - // 1. expected_version is bumped at ENQUEUE (not at apply), so two - // ops fired back-to-back race: whichever HTTP request the server - // sees second carries a stale version and 409s. We chain submits - // so only one enqueue is ever in flight, in submission order. - // 2. A failed mutation silently degrades the topology. We await each - // mutation to a terminal state and throw MutationFailedError on - // 'failed' so the caller can surface it loudly. - const chainRef = useRef>(Promise.resolve()); + // Live edits STAGE rather than send. Each live primitive records its + // op here and returns immediately; nothing hits the backend until + // commitStaged() flushes the batch (the UPDATE button). Staging is what + // lets the user assemble a coherent changeset (e.g. add a net AND bridge + // it) before any of it lands — and it kills the per-action SSE-refetch + // race that duplicated optimistic placeholders. + const [staged, setStaged] = useState([]); + // Optimistic expected_version cursor. enqueue bumps the server version - // by exactly 1, so we advance locally rather than waiting for a refetch - // between queued ops (onReparent fires detach + attach in one handler). + // by exactly 1, so within a commit batch we advance locally rather than + // waiting for a refetch between ops. NB: a *failed* mutation still bumps + // the version (the check happens at enqueue), so we advance after enqueue + // regardless of the apply outcome. const cursorRef = useRef(topoVersion); useEffect(() => { // Adopt a higher server version (a refetch landed, or another editor @@ -139,25 +157,45 @@ export function useTopologyEditor( const submit = useCallback( (topologyId: string, op: MutationOp, payload: Record): Promise => { - const task = chainRef.current.then(async () => { - const expected = cursorRef.current; - const res = await api.enqueueMutation(topologyId, op, payload, expected); - cursorRef.current = expected + 1; - const row = await api.waitForMutation(topologyId, res.mutation_id); - if (row.state === 'failed') { - throw new MutationFailedError(op, row.reason ?? 'unknown reason'); - } - return res.mutation_id; - }); - // Keep the chain alive after a rejection so one failed op doesn't - // wedge every subsequent submit. - chainRef.current = task.then(() => undefined, () => undefined); - return task; + setStaged((prev) => [...prev, { topologyId, op, payload }]); + // Sentinel id — callers thread this into optimistic state but it + // never reaches the backend; the post-commit refetch reconciles to + // real ids. + return Promise.resolve('staged'); }, - [api], + [], ); - return useMemo(() => ({ + const discardStaged = useCallback(() => setStaged([]), []); + + const commitStaged = useCallback(async (): Promise => { + const ops = staged; + if (ops.length === 0) return 0; + let applied = 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. + 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; + } + setStaged([]); + return applied; + } 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)); + throw err; + } + }, [staged, api]); + + const primitives = useMemo>(() => ({ // ── LAN ──────────────────────────────────────────────────────────── async createLan(topologyId, body) { if (!live) { @@ -274,4 +312,9 @@ export function useTopologyEditor( return { kind: 'enqueued', mutationId }; }, }), [api, live, submit]); + + return useMemo( + () => ({ ...primitives, pendingCount: staged.length, commitStaged, discardStaged }), + [primitives, staged.length, commitStaged, discardStaged], + ); }