feat(web): stage live MazeNET edits behind an UPDATE button
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.
This commit is contained in:
@@ -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 (
|
||||
<div className="maze-page">
|
||||
<div className="maze-page-header">
|
||||
@@ -592,8 +624,13 @@ const MazeNET: React.FC = () => {
|
||||
{fullscreen ? <Minimize2 size={12} /> : <Maximize2 size={12} />}
|
||||
{fullscreen ? ' EXIT FULL' : ' FULLSCREEN'}
|
||||
</button>
|
||||
<button type="button" className="maze-btn ghost" onClick={refetch} title="Revert local state to server">
|
||||
<RotateCcw size={12} /> REFRESH
|
||||
<button
|
||||
type="button"
|
||||
className="maze-btn ghost"
|
||||
onClick={() => { editor.discardStaged(); void refetch(); }}
|
||||
title={pendingCount > 0 ? 'Discard staged changes + reload from server' : 'Reload from server'}
|
||||
>
|
||||
<RotateCcw size={12} /> {pendingCount > 0 ? `DISCARD (${pendingCount})` : 'REFRESH'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -604,15 +641,31 @@ const MazeNET: React.FC = () => {
|
||||
>
|
||||
<Mail size={12} /> PERSONAS
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="maze-btn"
|
||||
disabled={!canDeploy || deploying}
|
||||
onClick={onDeploy}
|
||||
title={canDeploy ? 'Deploy topology' : 'Deploy requires pending status + at least one network'}
|
||||
>
|
||||
<UploadCloud size={12} /> {deploying ? 'DEPLOYING…' : 'DEPLOY'}
|
||||
</button>
|
||||
{liveTopo ? (
|
||||
<button
|
||||
type="button"
|
||||
className="maze-btn"
|
||||
disabled={pendingCount === 0 || committing}
|
||||
onClick={handleCommit}
|
||||
title={pendingCount > 0
|
||||
? `Apply ${pendingCount} staged change(s) to the live topology`
|
||||
: 'No staged changes'}
|
||||
>
|
||||
<UploadCloud size={12} /> {committing
|
||||
? 'UPDATING…'
|
||||
: pendingCount > 0 ? `UPDATE (${pendingCount})` : 'UPDATE'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="maze-btn"
|
||||
disabled={!canDeploy || deploying}
|
||||
onClick={onDeploy}
|
||||
title={canDeploy ? 'Deploy topology' : 'Deploy requires pending status + at least one network'}
|
||||
>
|
||||
<UploadCloud size={12} /> {deploying ? 'DEPLOYING…' : 'DEPLOY'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<T> =
|
||||
| { kind: 'applied'; data: T }
|
||||
| { kind: 'enqueued'; mutationId: string };
|
||||
|
||||
interface StagedOp {
|
||||
topologyId: string;
|
||||
op: MutationOp;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface UseTopologyEditor {
|
||||
createLan(topologyId: string, body: CreateLanBody): Promise<PrimitiveResult<LANRow>>;
|
||||
updateLan(
|
||||
@@ -108,6 +114,18 @@ export interface UseTopologyEditor {
|
||||
deckyName: string,
|
||||
lanName: string,
|
||||
): Promise<PrimitiveResult<void>>;
|
||||
|
||||
// ── 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<number>;
|
||||
/** 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<unknown>>(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<StagedOp[]>([]);
|
||||
|
||||
// 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<number>(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<string, unknown>): Promise<string> => {
|
||||
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<UseTopologyEditor>(() => ({
|
||||
const discardStaged = useCallback(() => setStaged([]), []);
|
||||
|
||||
const commitStaged = useCallback(async (): Promise<number> => {
|
||||
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<Omit<UseTopologyEditor, 'pendingCount' | 'commitStaged' | 'discardStaged'>>(() => ({
|
||||
// ── LAN ────────────────────────────────────────────────────────────
|
||||
async createLan(topologyId, body) {
|
||||
if (!live) {
|
||||
@@ -274,4 +312,9 @@ export function useTopologyEditor(
|
||||
return { kind: 'enqueued', mutationId };
|
||||
},
|
||||
}), [api, live, submit]);
|
||||
|
||||
return useMemo<UseTopologyEditor>(
|
||||
() => ({ ...primitives, pendingCount: staged.length, commitStaged, discardStaged }),
|
||||
[primitives, staged.length, commitStaged, discardStaged],
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user