feat(web): route editor actions through mutation queue on active topologies

useTopologyEditor now branches on topoStatus: pending keeps direct CRUD,
active/degraded routes through enqueueMutation with expected_version.
Every primitive returns a tagged PrimitiveResult; callers skip local
state updates on enqueued and wait for the SSE mutation.applied refetch
to reflect DB truth.

- remove_lan/remove_decky/detach_decky: direct name-keyed enqueues.
- update_decky/update_lan: services/x/y lifted to top-level payload keys,
  remainder placed under patch (matches apply_update_* contract).
- attach_decky: enqueued with decky+lan names; requires the decky to
  already exist (Phase B step 3 adds the create+attach composite).
- createDecky stays direct-CRUD this pass — no add_decky op yet, so
  new-decky drag will 409 on active until a follow-up commit.
- MazeNET surfaces mutation.failed payload.reason/error into actionErr
  so the status bar tells the user WHY a queue op was rejected.
This commit is contained in:
2026-04-21 19:58:29 -04:00
parent a93cbe76f9
commit 8fd166470f
2 changed files with 106 additions and 43 deletions

View File

@@ -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') {

View File

@@ -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<T> =
| { 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<UseTopologyEditor>(() => ({
// ── 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<string, unknown> = { 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<string, unknown> = { name: lanName };
const patchFields: Record<string, unknown> = {};
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<string, unknown> = { decky: deckyName };
const patchFields: Record<string, unknown> = {};
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<string, unknown> = { 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]);
}