Files
DECNET/decnet_web/src/components/MazeNET/useTopologyEditor.ts
anti c002c5a4f1 feat(ui): forwards_l3 toggle in Inspector with destructive-recreate confirm
W5's apply_update_decky now accepts a forwards_l3 flip on a live
topology only when payload['force'] is true (the unforced flip raises
MutationError to keep half-thinking operators from killing
in-container state).  Until this commit there was no UI surface that
could even submit such a flip.

Inspector grows a 'PROMOTE TO GATEWAY' / 'DEMOTE GATEWAY' button when
a (non-observed) decky is selected.  The handler:

* On pending topologies → submits via editor.updateDecky immediately.
  No confirm dialog; no live containers to disturb.
* On active/degraded topologies → window.confirm() explaining the
  destructive base recreate ('In-container state is lost; active
  sessions to it drop'), then submits with extras.force=true.

useTopologyEditor.updateDecky grows an optional extras arg that
threads force: true into the queued mutation payload.  The pending
CRUD path ignores it (no force needed when no containers exist).

MazeNET.tsx wires a toggleGateway callback that handles the
optimistic local state update, surfaces an enqueue toast on the
active path, and lets the SSE forwarder reconcile when
mutation.applied lands.
2026-04-29 00:29:46 -04:00

227 lines
9.1 KiB
TypeScript

/**
* Status-aware topology editor — wraps {@link useMazeApi} so the MazeNET
* editor can call one set of primitives regardless of whether the
* topology is ``pending`` (direct CRUD) or ``active|degraded`` (mutation
* queue via :func:`enqueueMutation`).
*
* 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.
*
* 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 {
CreateDeckyBody,
CreateLanBody,
DeckyRow,
EdgeRow,
LANRow,
MazeApi,
} from './useMazeApi';
export interface UseTopologyEditorOptions {
api: MazeApi;
/** Current topology status from :func:`getTopology`. */
topoStatus: string;
/** Last-known topology version for optimistic concurrency. */
topoVersion: number;
}
export type PrimitiveResult<T> =
| { kind: 'applied'; data: T }
| { kind: 'enqueued'; mutationId: string };
export interface UseTopologyEditor {
createLan(topologyId: string, body: CreateLanBody): Promise<PrimitiveResult<LANRow>>;
updateLan(
topologyId: string,
lanId: string,
lanName: string,
patch: Partial<LANRow>,
): Promise<PrimitiveResult<LANRow>>;
deleteLan(
topologyId: string,
lanId: string,
lanName: string,
): Promise<PrimitiveResult<void>>;
createDecky(topologyId: string, body: CreateDeckyBody): Promise<PrimitiveResult<DeckyRow>>;
/** Composite: create a decky and attach it to its home LAN. On pending
* this is two CRUD calls; on active it's one ``add_decky`` enqueue.
* Callers should prefer this over ``createDecky`` + ``attachEdge`` so
* the active path doesn't 409 on the CRUD half. */
addDeckyToLan(
topologyId: string,
body: CreateDeckyBody,
lanId: string,
lanName: string,
opts?: { is_bridge?: boolean; forwards_l3?: boolean },
): Promise<PrimitiveResult<DeckyRow>>;
updateDecky(
topologyId: string,
uuid: string,
deckyName: string,
patch: Partial<DeckyRow>,
/** Extra top-level flags for the queued mutation payload — currently
* only ``force`` (opts in to destructive recreates like the
* forwards_l3 flip on a live topology). Ignored on the pending
* CRUD path since pending edits never need force. */
extras?: { force?: boolean },
): Promise<PrimitiveResult<DeckyRow>>;
deleteDecky(
topologyId: string,
uuid: string,
deckyName: string,
): Promise<PrimitiveResult<void>>;
attachEdge(
topologyId: string,
body: { decky_uuid: string; lan_id: string; is_bridge?: boolean; forwards_l3?: boolean },
deckyName: string,
lanName: string,
): Promise<PrimitiveResult<EdgeRow>>;
detachEdge(
topologyId: string,
edgeId: string,
deckyName: string,
lanName: string,
): Promise<PrimitiveResult<void>>;
}
export function useTopologyEditor(
opts: UseTopologyEditorOptions,
): UseTopologyEditor {
const { api, topoStatus, topoVersion } = opts;
const live = topoStatus === 'active' || topoStatus === 'degraded';
return useMemo<UseTopologyEditor>(() => ({
// ── LAN ────────────────────────────────────────────────────────────
async createLan(topologyId, body) {
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) {
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) {
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) {
// Bare create — only valid on pending. On active callers should use
// addDeckyToLan() instead; the backend guard will 409 here.
const data = await api.createDecky(topologyId, body);
return { kind: 'applied', data };
},
async addDeckyToLan(topologyId, body, lanId, lanName, opts) {
if (!live) {
const data = await api.createDecky(topologyId, body);
await api.attachEdge(topologyId, {
decky_uuid: data.uuid,
lan_id: lanId,
is_bridge: opts?.is_bridge,
forwards_l3: opts?.forwards_l3,
});
return { kind: 'applied', data };
}
const payload: Record<string, unknown> = {
name: body.name,
lan: lanName,
services: body.services,
};
const cfg = body.decky_config ?? {};
if (cfg.archetype !== undefined) payload.archetype = cfg.archetype;
const fwd = opts?.forwards_l3 ?? cfg.forwards_l3;
if (fwd !== undefined) payload.forwards_l3 = fwd;
if (body.x !== undefined) payload.x = body.x;
if (body.y !== undefined) payload.y = body.y;
const res = await api.enqueueMutation(topologyId, 'add_decky', payload, topoVersion);
return { kind: 'enqueued', mutationId: res.mutation_id };
},
async updateDecky(topologyId, uuid, deckyName, patch, extras) {
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;
if (extras?.force) payload.force = true;
const res = await api.enqueueMutation(topologyId, 'update_decky', payload, topoVersion);
return { kind: 'enqueued', mutationId: res.mutation_id };
},
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 };
},
// ── 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) {
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, live, topoVersion]);
}