feat(web): useTopologyEditor skeleton + explicit streamLive gate

Phase B step 1 of DEBT-030: introduce a status-aware editor hook that
wraps useMazeApi. Every primitive currently pass-throughs to direct
CRUD and returns {kind: 'applied', data} — behavior is unchanged.
Follow-up commits route active/degraded topologies through
enqueueMutation when status != pending.

Also tighten the SSE LIVE indicator: flip setStreamLive(true) only on
snapshot, mutation.*, or status events, not on any incidental frame.
This commit is contained in:
2026-04-21 19:54:55 -04:00
parent cf5ba5cf2a
commit aa848d5260
2 changed files with 188 additions and 20 deletions

View File

@@ -15,6 +15,7 @@ import { DEFAULT_SERVICES } from './data';
import type { Archetype, ServiceDef } from './data'; import type { Archetype, ServiceDef } from './data';
import type { Net, MazeNode, Edge, DeckyNode } from './types'; import type { Net, MazeNode, Edge, DeckyNode } from './types';
import { useMazeApi } from './useMazeApi'; import { useMazeApi } from './useMazeApi';
import { useTopologyEditor } from './useTopologyEditor';
import { useMazeInteraction, type PaletteDrag } from './useMazeInteraction'; import { useMazeInteraction, type PaletteDrag } from './useMazeInteraction';
import { useLayoutPersistor } from './useMazeLayoutStore'; import { useLayoutPersistor } from './useMazeLayoutStore';
import { useTopologyStream, type TopologyStreamEvent } from './useTopologyStream'; import { useTopologyStream, type TopologyStreamEvent } from './useTopologyStream';
@@ -53,6 +54,8 @@ const MazeNET: React.FC = () => {
const canvasRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLDivElement>(null);
const editor = useTopologyEditor({ api, topoStatus, topoVersion });
const flashErr = useCallback((err: unknown, fallback: string) => { const flashErr = useCallback((err: unknown, fallback: string) => {
const msg = (err as { response?: { data?: { detail?: string } }; message?: string }) const msg = (err as { response?: { data?: { detail?: string } }; message?: string })
?.response?.data?.detail ?? (err as Error)?.message ?? fallback; ?.response?.data?.detail ?? (err as Error)?.message ?? fallback;
@@ -82,7 +85,9 @@ const MazeNET: React.FC = () => {
const name = isDmz ? `dmz-${hex4()}` : `subnet-${hex4()}`; const name = isDmz ? `dmz-${hex4()}` : `subnet-${hex4()}`;
try { try {
const subnet = await api.getNextSubnet().catch(() => undefined); const subnet = await api.getNextSubnet().catch(() => undefined);
const lan = await api.createLan(topologyId, { name, is_dmz: isDmz, x, y, ...(subnet ? { subnet } : {}) }); const lanRes = await editor.createLan(topologyId, { name, is_dmz: isDmz, x, y, ...(subnet ? { subnet } : {}) });
if (lanRes.kind !== 'applied') return;
const lan = lanRes.data;
const net: Net = { const net: Net = {
id: lan.id, label: lan.name.toUpperCase(), cidr: lan.subnet, id: lan.id, label: lan.name.toUpperCase(), cidr: lan.subnet,
kind: isDmz ? 'dmz' : 'subnet', x, y, w, h, kind: isDmz ? 'dmz' : 'subnet', x, y, w, h,
@@ -91,14 +96,16 @@ const MazeNET: React.FC = () => {
if (isDmz) { if (isDmz) {
const gwName = `dmz-gateway-${hex4()}`; const gwName = `dmz-gateway-${hex4()}`;
const gw = await api.createDecky(topologyId, { const gwRes = await editor.createDecky(topologyId, {
name: gwName, services: ['ssh'], x: 20, y: 40, name: gwName, services: ['ssh'], x: 20, y: 40,
decky_config: { archetype: 'deaddeck', forwards_l3: true }, decky_config: { archetype: 'deaddeck', forwards_l3: true },
}); });
await api.attachEdge(topologyId, { if (gwRes.kind !== 'applied') return;
const gw = gwRes.data;
await editor.attachEdge(topologyId, {
decky_uuid: gw.uuid, lan_id: lan.id, decky_uuid: gw.uuid, lan_id: lan.id,
is_bridge: true, forwards_l3: true, is_bridge: true, forwards_l3: true,
}); }, gw.name, lan.name);
const gwNode: DeckyNode = { const gwNode: DeckyNode = {
kind: 'decky', id: gw.uuid, netId: lan.id, name: gw.name, kind: 'decky', id: gw.uuid, netId: lan.id, name: gw.name,
archetype: 'deaddeck', services: ['ssh'], status: 'idle', archetype: 'deaddeck', services: ['ssh'], status: 'idle',
@@ -123,11 +130,15 @@ const MazeNET: React.FC = () => {
const ny = Math.max(28, Math.round(world.y - net.y - 24)); const ny = Math.max(28, Math.round(world.y - net.y - 24));
const name = `decky-${hex4()}`; const name = `decky-${hex4()}`;
try { try {
const decky = await api.createDecky(topologyId, { const dRes = await editor.createDecky(topologyId, {
name, services: dServices, x: nx, y: ny, name, services: dServices, x: nx, y: ny,
decky_config: { archetype: archSlug }, decky_config: { archetype: archSlug },
}); });
await api.attachEdge(topologyId, { decky_uuid: decky.uuid, lan_id: overNetId }); if (dRes.kind !== 'applied') return;
const decky = dRes.data;
await editor.attachEdge(topologyId,
{ decky_uuid: decky.uuid, lan_id: overNetId },
decky.name, net.label);
const node: DeckyNode = { const node: DeckyNode = {
kind: 'decky', id: decky.uuid, netId: overNetId, name: decky.name, kind: 'decky', id: decky.uuid, netId: overNetId, name: decky.name,
archetype: archSlug, services: dServices, status: 'idle', x: nx, y: ny, archetype: archSlug, services: dServices, status: 'idle', x: nx, y: ny,
@@ -146,7 +157,8 @@ const MazeNET: React.FC = () => {
if (target.services.includes(drag.slug)) return; if (target.services.includes(drag.slug)) return;
const nextServices = [...target.services, drag.slug]; const nextServices = [...target.services, drag.slug];
try { try {
await api.updateDecky(topologyId, overNodeId, { services: nextServices }); const r = await editor.updateDecky(topologyId, overNodeId, target.name, { services: nextServices });
if (r.kind !== 'applied') return;
setNodes((p) => p.map((n) => n.id === overNodeId && n.kind === 'decky' setNodes((p) => p.map((n) => n.id === overNodeId && n.kind === 'decky'
? { ...n, services: nextServices } ? { ...n, services: nextServices }
: n)); : n));
@@ -155,7 +167,7 @@ const MazeNET: React.FC = () => {
} }
} }
}, },
[api, archetypes, flashErr, nets, nodes, topologyId], [api, archetypes, editor, flashErr, nets, nodes, topologyId],
); );
/* ── Cross-net reparent via node drag (detach + attach edge) ─── */ /* ── Cross-net reparent via node drag (detach + attach edge) ─── */
@@ -167,12 +179,18 @@ const MazeNET: React.FC = () => {
(e: { decky_uuid: string; lan_id: string; id: string }) => (e: { decky_uuid: string; lan_id: string; id: string }) =>
e.decky_uuid === nodeId && e.lan_id === fromNetId, e.decky_uuid === nodeId && e.lan_id === fromNetId,
); );
if (existingEdge) await api.detachEdge(topologyId, existingEdge.id); const node = nodes.find((n) => n.id === nodeId);
await api.attachEdge(topologyId, { decky_uuid: nodeId, lan_id: toNetId }); const fromNet = nets.find((n) => n.id === fromNetId);
const toNet = nets.find((n) => n.id === toNetId);
const nodeName = node?.kind === 'decky' ? node.name : '';
if (existingEdge) {
await editor.detachEdge(topologyId, existingEdge.id, nodeName, fromNet?.label ?? '');
}
await editor.attachEdge(topologyId, { decky_uuid: nodeId, lan_id: toNetId }, nodeName, toNet?.label ?? '');
} catch (err) { } catch (err) {
flashErr(err, 'reparent failed'); flashErr(err, 'reparent failed');
} }
}, [api, flashErr, topologyId]); }, [editor, flashErr, nets, nodes, topologyId]);
/* Port→port edges stay UI-only (backend edges are decky↔LAN). */ /* Port→port edges stay UI-only (backend edges are decky↔LAN). */
const onAddEdge = useCallback((fromId: string, toId: string) => { const onAddEdge = useCallback((fromId: string, toId: string) => {
@@ -195,8 +213,11 @@ const MazeNET: React.FC = () => {
/* Cascade delete members first — backend will otherwise 400 on orphan risk. */ /* Cascade delete members first — backend will otherwise 400 on orphan risk. */
const members = nodes.filter((n) => n.netId === id && n.kind === 'decky'); const members = nodes.filter((n) => n.netId === id && n.kind === 'decky');
try { try {
for (const m of members) await api.deleteDecky(topologyId, m.id); for (const m of members) {
await api.deleteLan(topologyId, id); const mName = m.kind === 'decky' ? m.name : '';
await editor.deleteDecky(topologyId, m.id, mName);
}
await editor.deleteLan(topologyId, id, net.label);
setNets((p) => p.filter((n) => n.id !== id)); setNets((p) => p.filter((n) => n.id !== id));
setNodes((p) => p.filter((n) => n.netId !== id)); setNodes((p) => p.filter((n) => n.netId !== id));
setEdges((p) => p.filter((e) => { setEdges((p) => p.filter((e) => {
@@ -215,7 +236,7 @@ const MazeNET: React.FC = () => {
if (!node || node.kind === 'observed') return; if (!node || node.kind === 'observed') return;
if (node.kind === 'decky' && node.decky_config?.forwards_l3) return; if (node.kind === 'decky' && node.decky_config?.forwards_l3) return;
try { try {
await api.deleteDecky(topologyId, id); await editor.deleteDecky(topologyId, id, node.kind === 'decky' ? node.name : '');
setNodes((p) => p.filter((n) => n.id !== id)); setNodes((p) => p.filter((n) => n.id !== id));
setEdges((p) => p.filter((e) => e.from !== id && e.to !== id)); setEdges((p) => p.filter((e) => e.from !== id && e.to !== id));
setSelection(null); setSelection(null);
@@ -235,11 +256,16 @@ const MazeNET: React.FC = () => {
if (!n || n.kind !== 'decky') return; if (!n || n.kind !== 'decky') return;
const name = `${n.name.replace(/-[0-9a-f]{4}$/, '')}-${hex4()}`; const name = `${n.name.replace(/-[0-9a-f]{4}$/, '')}-${hex4()}`;
try { try {
const decky = await api.createDecky(topologyId, { const dRes = await editor.createDecky(topologyId, {
name, services: [...n.services], x: n.x + 24, y: n.y + 24, name, services: [...n.services], x: n.x + 24, y: n.y + 24,
decky_config: { archetype: n.archetype }, decky_config: { archetype: n.archetype },
}); });
await api.attachEdge(topologyId, { decky_uuid: decky.uuid, lan_id: n.netId }); if (dRes.kind !== 'applied') return;
const decky = dRes.data;
const parentNet = nets.find((net) => net.id === n.netId);
await editor.attachEdge(topologyId,
{ decky_uuid: decky.uuid, lan_id: n.netId },
decky.name, parentNet?.label ?? '');
const copy: DeckyNode = { const copy: DeckyNode = {
kind: 'decky', id: decky.uuid, netId: n.netId, name: decky.name, kind: 'decky', id: decky.uuid, netId: n.netId, name: decky.name,
archetype: n.archetype, services: [...n.services], status: 'idle', archetype: n.archetype, services: [...n.services], status: 'idle',
@@ -256,7 +282,8 @@ const MazeNET: React.FC = () => {
if (!n || n.kind !== 'decky' || n.services.includes(slug)) return; if (!n || n.kind !== 'decky' || n.services.includes(slug)) return;
const nextServices = [...n.services, slug]; const nextServices = [...n.services, slug];
try { try {
await api.updateDecky(topologyId, id, { services: nextServices }); const r = await editor.updateDecky(topologyId, id, n.name, { services: nextServices });
if (r.kind !== 'applied') return;
setNodes((p) => p.map((x) => x.id === id && x.kind === 'decky' setNodes((p) => p.map((x) => x.id === id && x.kind === 'decky'
? { ...x, services: nextServices } : x)); ? { ...x, services: nextServices } : x));
} catch (err) { } catch (err) {
@@ -324,11 +351,15 @@ const MazeNET: React.FC = () => {
onClick: async () => { onClick: async () => {
const name = `decky-${hex4()}`; const name = `decky-${hex4()}`;
try { try {
const decky = await api.createDecky(topologyId, { const dRes = await editor.createDecky(topologyId, {
name, services: [...a.services], x: 20, y: 40, name, services: [...a.services], x: 20, y: 40,
decky_config: { archetype: a.slug }, decky_config: { archetype: a.slug },
}); });
await api.attachEdge(topologyId, { decky_uuid: decky.uuid, lan_id: id }); if (dRes.kind !== 'applied') return;
const decky = dRes.data;
await editor.attachEdge(topologyId,
{ decky_uuid: decky.uuid, lan_id: id },
decky.name, net.label);
const node: DeckyNode = { const node: DeckyNode = {
kind: 'decky', id: decky.uuid, netId: id, name: decky.name, kind: 'decky', id: decky.uuid, netId: id, name: decky.name,
archetype: a.slug, services: [...a.services], status: 'idle', archetype: a.slug, services: [...a.services], status: 'idle',
@@ -432,7 +463,12 @@ const MazeNET: React.FC = () => {
const [streamLive, setStreamLive] = useState(false); const [streamLive, setStreamLive] = useState(false);
const streamEnabled = topoStatus === 'active' || topoStatus === 'degraded'; const streamEnabled = topoStatus === 'active' || topoStatus === 'degraded';
const onStreamEvent = useCallback((event: TopologyStreamEvent) => { const onStreamEvent = useCallback((event: TopologyStreamEvent) => {
setStreamLive(true); // Flip LIVE only on named, purposeful events — not incidental keepalives.
if (event.name === 'snapshot'
|| event.name.startsWith('mutation.')
|| event.name === 'status') {
setStreamLive(true);
}
if (event.name === 'mutation.applied' if (event.name === 'mutation.applied'
|| event.name === 'mutation.failed' || event.name === 'mutation.failed'
|| event.name === 'status') { || event.name === 'status') {

View File

@@ -0,0 +1,132 @@
/**
* 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`).
*
* 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.
*
* 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.
*/
import { useMemo } from 'react';
import type {
CreateDeckyBody,
CreateLanBody,
DeckyRow,
EdgeRow,
LANRow,
UseMazeApi,
} from './useMazeApi';
export interface UseTopologyEditorOptions {
api: UseMazeApi;
/** Current topology status from :func:`getTopology`. */
topoStatus: string;
/** Last-known topology version for optimistic concurrency. */
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 };
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>>;
updateDecky(
topologyId: string,
uuid: string,
deckyName: string,
patch: Partial<DeckyRow>,
): 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 } = opts;
// topoStatus / topoVersion intentionally unused this pass — see module
// docstring. They'll drive the enqueue branch in the next commits.
return useMemo<UseTopologyEditor>(() => ({
async createLan(topologyId, body) {
const data = await api.createLan(topologyId, body);
return { kind: 'applied', data };
},
async updateLan(topologyId, lanId, _lanName, patch) {
const data = await api.updateLan(topologyId, lanId, patch);
return { kind: 'applied', data };
},
async deleteLan(topologyId, lanId, _lanName) {
await api.deleteLan(topologyId, lanId);
return { kind: 'applied', data: undefined };
},
async createDecky(topologyId, body) {
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 deleteDecky(topologyId, uuid, _deckyName) {
await api.deleteDecky(topologyId, uuid);
return { kind: 'applied', data: undefined };
},
async attachEdge(topologyId, body, _deckyName, _lanName) {
const data = await api.attachEdge(topologyId, body);
return { kind: 'applied', data };
},
async detachEdge(topologyId, edgeId, _deckyName, _lanName) {
await api.detachEdge(topologyId, edgeId);
return { kind: 'applied', data: undefined };
},
}), [api]);
}