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') {
|
|| event.name === 'status') {
|
||||||
setStreamLive(true);
|
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'
|
if (event.name === 'mutation.applied'
|
||||||
|| event.name === 'mutation.failed'
|
|| event.name === 'mutation.failed'
|
||||||
|| event.name === 'status') {
|
|| event.name === 'status') {
|
||||||
|
|||||||
@@ -4,18 +4,16 @@
|
|||||||
* topology is ``pending`` (direct CRUD) or ``active|degraded`` (mutation
|
* topology is ``pending`` (direct CRUD) or ``active|degraded`` (mutation
|
||||||
* queue via :func:`enqueueMutation`).
|
* queue via :func:`enqueueMutation`).
|
||||||
*
|
*
|
||||||
* Phase B scaffolding — for now every primitive is a pass-through to
|
* Primitives return a tagged {@link PrimitiveResult}:
|
||||||
* the direct-CRUD method on ``useMazeApi``. Behavior is unchanged from
|
* ``{ kind: 'applied', data }`` — backend wrote synchronously; the
|
||||||
* calling ``api.*`` directly. Status branching lands one primitive at
|
* caller may update local state.
|
||||||
* a time in the follow-up commits so each change is small and
|
* ``{ kind: 'enqueued', mutationId }`` — mutator will apply async;
|
||||||
* reviewable.
|
* caller must NOT touch local state,
|
||||||
|
* SSE ``mutation.applied`` drives refetch.
|
||||||
*
|
*
|
||||||
* The ``*Name`` arguments (``deckyName``, ``lanName``, …) are unused in
|
* Name arguments (``deckyName``, ``lanName``) are required on every
|
||||||
* this pass — they're captured on the call site now so the signatures
|
* primitive because mutation ops are name-keyed while direct CRUD is
|
||||||
* don't change when the enqueue branches are added: mutation ops are
|
* uuid-keyed. Callers plumb both.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import type {
|
import type {
|
||||||
@@ -35,14 +33,6 @@ export interface UseTopologyEditorOptions {
|
|||||||
topoVersion: number;
|
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> =
|
export type PrimitiveResult<T> =
|
||||||
| { kind: 'applied'; data: T }
|
| { kind: 'applied'; data: T }
|
||||||
| { kind: 'enqueued'; mutationId: string };
|
| { kind: 'enqueued'; mutationId: string };
|
||||||
@@ -91,42 +81,107 @@ export interface UseTopologyEditor {
|
|||||||
export function useTopologyEditor(
|
export function useTopologyEditor(
|
||||||
opts: UseTopologyEditorOptions,
|
opts: UseTopologyEditorOptions,
|
||||||
): UseTopologyEditor {
|
): UseTopologyEditor {
|
||||||
const { api } = opts;
|
const { api, topoStatus, topoVersion } = opts;
|
||||||
// topoStatus / topoVersion intentionally unused this pass — see module
|
const live = topoStatus === 'active' || topoStatus === 'degraded';
|
||||||
// docstring. They'll drive the enqueue branch in the next commits.
|
|
||||||
|
|
||||||
return useMemo<UseTopologyEditor>(() => ({
|
return useMemo<UseTopologyEditor>(() => ({
|
||||||
|
// ── LAN ────────────────────────────────────────────────────────────
|
||||||
async createLan(topologyId, body) {
|
async createLan(topologyId, body) {
|
||||||
const data = await api.createLan(topologyId, body);
|
if (!live) {
|
||||||
return { kind: 'applied', data };
|
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) {
|
async updateLan(topologyId, lanId, lanName, patch) {
|
||||||
const data = await api.updateLan(topologyId, lanId, patch);
|
if (!live) {
|
||||||
return { kind: 'applied', data };
|
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) {
|
async deleteLan(topologyId, lanId, lanName) {
|
||||||
await api.deleteLan(topologyId, lanId);
|
if (!live) {
|
||||||
return { kind: 'applied', data: undefined };
|
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) {
|
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);
|
const data = await api.createDecky(topologyId, body);
|
||||||
return { kind: 'applied', data };
|
return { kind: 'applied', data };
|
||||||
},
|
},
|
||||||
async updateDecky(topologyId, uuid, _deckyName, patch) {
|
async updateDecky(topologyId, uuid, deckyName, patch) {
|
||||||
const data = await api.updateDecky(topologyId, uuid, patch);
|
if (!live) {
|
||||||
return { kind: 'applied', data };
|
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) {
|
async deleteDecky(topologyId, uuid, deckyName) {
|
||||||
await api.deleteDecky(topologyId, uuid);
|
if (!live) {
|
||||||
return { kind: 'applied', data: undefined };
|
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);
|
// ── Edges ──────────────────────────────────────────────────────────
|
||||||
return { kind: 'applied', data };
|
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) {
|
async detachEdge(topologyId, edgeId, deckyName, lanName) {
|
||||||
await api.detachEdge(topologyId, edgeId);
|
if (!live) {
|
||||||
return { kind: 'applied', data: undefined };
|
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