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

@@ -34,6 +34,11 @@ interface Props {
onLiveRemoveService?: (nodeName: string, slug: string) => Promise<void>;
/** Per-decky-eligible service slugs, fetched via useServiceRegistry. */
availableServices?: string[];
/** Toggle ``forwards_l3`` (gateway) on the selected decky. When the
* topology is active/degraded the caller is responsible for the
* destructive-recreate confirm dialog and the ``force: true`` submit
* — this prop just relays the user's intent. */
onToggleGateway?: (nodeId: string, nextValue: boolean) => Promise<void>;
onAddDecky?: (netId: string) => void;
setSelection?: (sel: Selection) => void;
pendingChanges?: number;
@@ -44,6 +49,7 @@ const Inspector: React.FC<Props> = ({
selection, nets, nodes, edges, topologyStatus, onClose,
onDeleteNet, onDeleteNode, onDeleteEdge, onRemoveService,
onLiveAddService, onLiveRemoveService, availableServices = [],
onToggleGateway,
onAddDecky, setSelection,
pendingChanges = 0,
className = '',
@@ -257,6 +263,50 @@ const Inspector: React.FC<Props> = ({
<div className="dim inspector-empty-line">NO EDGES</div>
)}
</div>
{onToggleGateway && !isObserved && (
<button
type="button"
className={`maze-btn small ${isGateway ? 'alert' : ''}`}
disabled={busy === '__gateway__'}
title={
isGateway
? 'Demote this decky from gateway (forwards_l3=false)'
: 'Promote this decky to gateway (forwards_l3=true)'
}
onClick={async () => {
const next = !isGateway;
// forwards_l3 flip on a deployed topology recreates
// the base container — destructive. Confirm before
// hitting the API; the caller (MazeNET.tsx) submits
// with force: true on active topologies.
const live = topologyStatus === 'active' || topologyStatus === 'degraded';
if (live) {
const ok = window.confirm(
`${next ? 'Promote' : 'Demote'} ${node.name} ${next ? 'to' : 'from'} gateway?\n\n` +
'This recreates the base container to apply the new port-publishing config. ' +
'In-container state is lost; active sessions to it drop.',
);
if (!ok) return;
}
setOpError(null);
setBusy('__gateway__');
try {
await onToggleGateway(node.id, next);
} catch (err) {
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail
?? 'Gateway toggle failed.';
setOpError(msg);
} finally {
setBusy(null);
}
}}
>
<Shield size={12} />
{busy === '__gateway__'
? (isGateway ? 'DEMOTING…' : 'PROMOTING…')
: (isGateway ? 'DEMOTE GATEWAY' : 'PROMOTE TO GATEWAY')}
</button>
)}
{onDeleteNode && (
<button
type="button"

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;

View File

@@ -68,6 +68,11 @@ export interface UseTopologyEditor {
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,
@@ -169,7 +174,7 @@ export function useTopologyEditor(
const res = await api.enqueueMutation(topologyId, 'add_decky', payload, topoVersion);
return { kind: 'enqueued', mutationId: res.mutation_id };
},
async updateDecky(topologyId, uuid, deckyName, patch) {
async updateDecky(topologyId, uuid, deckyName, patch, extras) {
if (!live) {
const data = await api.updateDecky(topologyId, uuid, patch);
return { kind: 'applied', data };
@@ -181,6 +186,7 @@ export function useTopologyEditor(
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 };
},