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.
This commit is contained in:
2026-04-29 00:29:46 -04:00
parent a27e3f5e0f
commit c002c5a4f1
3 changed files with 92 additions and 1 deletions

View File

@@ -17,6 +17,7 @@ import { DEFAULT_SERVICES } from './data';
import type { Archetype, ServiceDef } from './data';
import type { Net, MazeNode, Edge, DeckyNode } from './types';
import { useMazeApi } from './useMazeApi';
import type { DeckyRow } from './useMazeApi';
import { useTopologyEditor } from './useTopologyEditor';
import { useMazeInteraction, type PaletteDrag } from './useMazeInteraction';
import { useLayoutPersistor } from './useMazeLayoutStore';
@@ -132,6 +133,39 @@ const MazeNET: React.FC = () => {
? { ...x, services: data.services } : x));
}, [topologyId]);
/* forwards_l3 toggle. Active topologies require the destructive
base-recreate path on the backend, gated by force: true; the
Inspector is responsible for confirming with the user before this
fires. */
const toggleGateway = useCallback(async (nodeId: string, nextValue: boolean) => {
const node = nodes.find((n) => n.id === nodeId);
if (!node || node.kind !== 'decky') return;
const live = topoStatus === 'active' || topoStatus === 'degraded';
const r = await editor.updateDecky(
topologyId, nodeId, node.name,
{ decky_config: { ...(node.decky_config ?? {}), forwards_l3: nextValue } } as Partial<DeckyRow>,
live ? { force: true } : undefined,
);
// Optimistic local update — pending path returns 'applied'
// synchronously; active path returns 'enqueued' and the
// mutation.applied SSE will refetch shortly. Either way, paint
// the change immediately so the toggle feels responsive.
setNodes((prev) => prev.map((n) =>
n.id === nodeId && n.kind === 'decky'
? {
...n,
decky_config: { ...(n.decky_config ?? {}), forwards_l3: nextValue },
}
: n,
));
if (r.kind === 'enqueued') {
pushToast({
tone: 'violet',
text: `Gateway ${nextValue ? 'promotion' : 'demotion'} queued — base recreate in flight.`,
});
}
}, [editor, nodes, pushToast, topoStatus, topologyId]);
/* ── Palette drop — create LANs / deckies / services via REST ─── */
const onPaletteDrop = useCallback(
async (drag: PaletteDrag, world: { x: number; y: number }, overNetId: string | null, overNodeId: string | null) => {
@@ -867,6 +901,7 @@ const MazeNET: React.FC = () => {
availableServices={serviceRegistry.perDecky}
onLiveAddService={liveAddService}
onLiveRemoveService={liveRemoveService}
onToggleGateway={toggleGateway}
onAddDecky={(netId) => {
const net = nets.find((n) => n.id === netId);
if (!net) return;