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:
@@ -15,6 +15,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 { useTopologyEditor } from './useTopologyEditor';
|
||||
import { useMazeInteraction, type PaletteDrag } from './useMazeInteraction';
|
||||
import { useLayoutPersistor } from './useMazeLayoutStore';
|
||||
import { useTopologyStream, type TopologyStreamEvent } from './useTopologyStream';
|
||||
@@ -53,6 +54,8 @@ const MazeNET: React.FC = () => {
|
||||
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const editor = useTopologyEditor({ api, topoStatus, topoVersion });
|
||||
|
||||
const flashErr = useCallback((err: unknown, fallback: string) => {
|
||||
const msg = (err as { response?: { data?: { detail?: string } }; message?: string })
|
||||
?.response?.data?.detail ?? (err as Error)?.message ?? fallback;
|
||||
@@ -82,7 +85,9 @@ const MazeNET: React.FC = () => {
|
||||
const name = isDmz ? `dmz-${hex4()}` : `subnet-${hex4()}`;
|
||||
try {
|
||||
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 = {
|
||||
id: lan.id, label: lan.name.toUpperCase(), cidr: lan.subnet,
|
||||
kind: isDmz ? 'dmz' : 'subnet', x, y, w, h,
|
||||
@@ -91,14 +96,16 @@ const MazeNET: React.FC = () => {
|
||||
|
||||
if (isDmz) {
|
||||
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,
|
||||
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,
|
||||
is_bridge: true, forwards_l3: true,
|
||||
});
|
||||
}, gw.name, lan.name);
|
||||
const gwNode: DeckyNode = {
|
||||
kind: 'decky', id: gw.uuid, netId: lan.id, name: gw.name,
|
||||
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 name = `decky-${hex4()}`;
|
||||
try {
|
||||
const decky = await api.createDecky(topologyId, {
|
||||
const dRes = await editor.createDecky(topologyId, {
|
||||
name, services: dServices, x: nx, y: ny,
|
||||
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 = {
|
||||
kind: 'decky', id: decky.uuid, netId: overNetId, name: decky.name,
|
||||
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;
|
||||
const nextServices = [...target.services, drag.slug];
|
||||
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'
|
||||
? { ...n, services: nextServices }
|
||||
: 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) ─── */
|
||||
@@ -167,12 +179,18 @@ const MazeNET: React.FC = () => {
|
||||
(e: { decky_uuid: string; lan_id: string; id: string }) =>
|
||||
e.decky_uuid === nodeId && e.lan_id === fromNetId,
|
||||
);
|
||||
if (existingEdge) await api.detachEdge(topologyId, existingEdge.id);
|
||||
await api.attachEdge(topologyId, { decky_uuid: nodeId, lan_id: toNetId });
|
||||
const node = nodes.find((n) => n.id === nodeId);
|
||||
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) {
|
||||
flashErr(err, 'reparent failed');
|
||||
}
|
||||
}, [api, flashErr, topologyId]);
|
||||
}, [editor, flashErr, nets, nodes, topologyId]);
|
||||
|
||||
/* Port→port edges stay UI-only (backend edges are decky↔LAN). */
|
||||
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. */
|
||||
const members = nodes.filter((n) => n.netId === id && n.kind === 'decky');
|
||||
try {
|
||||
for (const m of members) await api.deleteDecky(topologyId, m.id);
|
||||
await api.deleteLan(topologyId, id);
|
||||
for (const m of members) {
|
||||
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));
|
||||
setNodes((p) => p.filter((n) => n.netId !== id));
|
||||
setEdges((p) => p.filter((e) => {
|
||||
@@ -215,7 +236,7 @@ const MazeNET: React.FC = () => {
|
||||
if (!node || node.kind === 'observed') return;
|
||||
if (node.kind === 'decky' && node.decky_config?.forwards_l3) return;
|
||||
try {
|
||||
await api.deleteDecky(topologyId, id);
|
||||
await editor.deleteDecky(topologyId, id, node.kind === 'decky' ? node.name : '');
|
||||
setNodes((p) => p.filter((n) => n.id !== id));
|
||||
setEdges((p) => p.filter((e) => e.from !== id && e.to !== id));
|
||||
setSelection(null);
|
||||
@@ -235,11 +256,16 @@ const MazeNET: React.FC = () => {
|
||||
if (!n || n.kind !== 'decky') return;
|
||||
const name = `${n.name.replace(/-[0-9a-f]{4}$/, '')}-${hex4()}`;
|
||||
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,
|
||||
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 = {
|
||||
kind: 'decky', id: decky.uuid, netId: n.netId, name: decky.name,
|
||||
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;
|
||||
const nextServices = [...n.services, slug];
|
||||
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'
|
||||
? { ...x, services: nextServices } : x));
|
||||
} catch (err) {
|
||||
@@ -324,11 +351,15 @@ const MazeNET: React.FC = () => {
|
||||
onClick: async () => {
|
||||
const name = `decky-${hex4()}`;
|
||||
try {
|
||||
const decky = await api.createDecky(topologyId, {
|
||||
const dRes = await editor.createDecky(topologyId, {
|
||||
name, services: [...a.services], x: 20, y: 40,
|
||||
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 = {
|
||||
kind: 'decky', id: decky.uuid, netId: id, name: decky.name,
|
||||
archetype: a.slug, services: [...a.services], status: 'idle',
|
||||
@@ -432,7 +463,12 @@ const MazeNET: React.FC = () => {
|
||||
const [streamLive, setStreamLive] = useState(false);
|
||||
const streamEnabled = topoStatus === 'active' || topoStatus === 'degraded';
|
||||
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'
|
||||
|| event.name === 'mutation.failed'
|
||||
|| event.name === 'status') {
|
||||
|
||||
132
decnet_web/src/components/MazeNET/useTopologyEditor.ts
Normal file
132
decnet_web/src/components/MazeNET/useTopologyEditor.ts
Normal 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]);
|
||||
}
|
||||
Reference in New Issue
Block a user