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:
@@ -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') {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user