feat(mazenet): topology editor updates; refine mutator ops materialisation

This commit is contained in:
2026-06-16 18:55:20 -04:00
parent c9e4bf4022
commit a9c3f42ef9
10 changed files with 183 additions and 157 deletions

View File

@@ -619,6 +619,35 @@ async def apply_remove_lan(
f"{d['decky_config']['name']!r}; remove the decky first" f"{d['decky_config']['name']!r}; remove the decky first"
) )
lan_name = lan["name"] lan_name = lan["name"]
# Detach every bridge (non-home) member still attached to this LAN
# before dropping it. The home-LAN refusal above guarantees survivors
# are all multi-homed visitors; if we leave their edge + ips_by_lan
# entry behind, the row points at a LAN that no longer exists and
# _assert_valid_after raises IP_UNKNOWN_LAN — degrading the topology
# on a plain delete. Mirrors apply_detach_decky's per-decky cleanup.
for e in hydrated["edges"]:
if e["lan_id"] != lan["id"]:
continue
decky = next(
(d for d in hydrated["deckies"] if d["uuid"] == e["decky_uuid"]),
None,
)
if decky is not None:
new_cfg = dict(decky["decky_config"])
new_ips = dict(new_cfg.get("ips_by_lan", {}))
new_ips.pop(lan_name, None)
new_cfg["ips_by_lan"] = new_ips
await repo.update_topology_decky(
decky["uuid"], {"decky_config": new_cfg}
)
await _materialise_decky_disconnect(
repo, topology_id,
decky_name=decky["decky_config"]["name"],
lan_name=lan_name,
)
await repo.delete_topology_edge(e["id"], enforce_pending=False)
# enforce_pending=False: the mutator queue is the live-editing # enforce_pending=False: the mutator queue is the live-editing
# surface, gated on topology status by us before we got here. The # surface, gated on topology status by us before we got here. The
# repo's pending-only guard is for HTTP CRUD callers that mustn't # repo's pending-only guard is for HTTP CRUD callers that mustn't

View File

@@ -48,7 +48,7 @@ body.maze-fullscreen .maze-shell {
transition: all 0.3s; transition: all 0.3s;
} }
.maze-btn:hover { background: var(--matrix); color: var(--bg); box-shadow: var(--matrix-glow); } .maze-btn:hover { background: var(--matrix); color: var(--bg); box-shadow: var(--matrix-glow); }
.maze-btn.ghost { border-color: var(--border); color: var(--matrix); opacity: 0.7; } .maze-btn.ghost { border-color: var(--border); color: var(--matrix); opacity: 0.92; }
.maze-btn.ghost:hover { .maze-btn.ghost:hover {
background: transparent; color: var(--matrix); opacity: 1; background: transparent; color: var(--matrix); opacity: 1;
border-color: var(--matrix); box-shadow: var(--matrix-glow); border-color: var(--matrix); box-shadow: var(--matrix-glow);
@@ -323,11 +323,17 @@ html[data-theme="light"] .maze-net-box.inactive {
/* ── Canvas overlays ────────────────────────── */ /* ── Canvas overlays ────────────────────────── */
.maze-toolbar { position: absolute; top: 12px; left: 12px; display: flex; gap: 8px; z-index: 5; } .maze-toolbar { position: absolute; top: 12px; left: 12px; display: flex; gap: 8px; z-index: 5; }
/* Solid theme-aware backing (same as the legend) so the toolbar buttons
stay legible over net boxes in both themes — a hardcoded dark fill went
ink-on-ink in light mode. Full opacity; matched-specificity hover keeps
the fill behaviour. */
.maze-toolbar .maze-btn { background: var(--panel); opacity: 1; }
.maze-toolbar .maze-btn:hover { background: var(--matrix); }
.maze-status { .maze-status {
position: absolute; bottom: 12px; left: 12px; position: absolute; bottom: 12px; left: 12px;
display: flex; gap: 12px; z-index: 5; display: flex; gap: 12px; z-index: 5;
font-size: 0.62rem; opacity: 0.6; letter-spacing: 1px; font-size: 0.62rem; opacity: 0.92; letter-spacing: 1px;
background: rgba(0, 0, 0, 0.6); padding: 6px 10px; border: 1px solid var(--border); background: rgba(0, 0, 0, 0.8); padding: 6px 10px; border: 1px solid var(--border);
} }
.maze-legend { .maze-legend {
position: absolute; bottom: 12px; right: 12px; z-index: 5; position: absolute; bottom: 12px; right: 12px; z-index: 5;
@@ -349,7 +355,7 @@ html[data-theme="light"] .maze-net-box.inactive {
/* Status bar segments */ /* Status bar segments */
.maze-status .status-seg { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; } .maze-status .status-seg { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
.maze-status .status-seg.live { color: var(--matrix); opacity: 0.9; } .maze-status .status-seg.live { color: var(--matrix); opacity: 0.9; }
.maze-status .status-seg.dim { opacity: 0.45; } .maze-status .status-seg.dim { opacity: 0.75; }
/* Toolbar button sizing override */ /* Toolbar button sizing override */
.maze-toolbar .maze-btn.small { .maze-toolbar .maze-btn.small {

View File

@@ -37,14 +37,14 @@ const tempIdSuffix = (): string => {
return r.slice(0, 4); return r.slice(0, 4);
}; };
const NET_GRID_W = 300; const NET_GRID_W = 300;
const NET_GRID_H = 240; const NET_GRID_H = 240;
const NET_GRID_GAP = 40;
const NET_GRID_COLS = 3;
async function _dropNetwork( async function _dropNetwork(
drag: PaletteDrag, drag: PaletteDrag,
world: { x: number; y: number },
topologyId: string, topologyId: string,
live: boolean,
nets: Net[], nets: Net[],
api: ReturnType<typeof useMazeApi>, api: ReturnType<typeof useMazeApi>,
editor: ReturnType<typeof useTopologyEditor>, editor: ReturnType<typeof useTopologyEditor>,
@@ -57,12 +57,18 @@ async function _dropNetwork(
flashErr(null, 'topology already has a DMZ'); flashErr(null, 'topology already has a DMZ');
return; return;
} }
const i = nets.filter((n) => n.kind !== 'internet').length; // Place the box centred on the drop point (canvas/world coords), not in a
const x = NET_GRID_GAP + (i % NET_GRID_COLS) * (NET_GRID_W + NET_GRID_GAP); // grid slot — dropping should land where the cursor released.
const y = NET_GRID_GAP + Math.floor(i / NET_GRID_COLS) * (NET_GRID_H + NET_GRID_GAP); const x = Math.round(world.x - NET_GRID_W / 2);
const y = Math.round(world.y - NET_GRID_H / 2);
const name = isDmz ? `dmz-${tempIdSuffix()}` : `subnet-${tempIdSuffix()}`; const name = isDmz ? `dmz-${tempIdSuffix()}` : `subnet-${tempIdSuffix()}`;
try { try {
const subnet = await api.getNextSubnet().catch(() => undefined); // On a live topology edits are STAGED and applied as a batch, so a
// server-side next-subnet lookup would hand every staged net the SAME
// free subnet (the prior ones aren't committed yet) → SUBNET_OVERLAP on
// commit. Leave it unset and let apply_add_lan allocate at apply time;
// the mutator drains sequentially so each sees the previous one.
const subnet = live ? undefined : await api.getNextSubnet().catch(() => undefined);
const lanRes = await editor.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') { if (lanRes.kind !== 'applied') {
const tempId = `pending-lan-${name}`; const tempId = `pending-lan-${name}`;
@@ -185,7 +191,7 @@ const MazeNET: React.FC = () => {
const { const {
nets, setNets, nodes, setNodes, edges, setEdges, nets, setNets, nodes, setNodes, edges, setEdges,
topoMeta, services, archetypes, topoMeta, services, archetypes,
loadErr, actionErr, commitErr, clearCommitErr, flashErr, setRefetchPaused, loadErr, actionErr, commitErr, clearCommitErr, flashErr,
deploying, onDeploy, deploying, onDeploy,
streamLive, lastEventAt, streamEnabled, streamLive, lastEventAt, streamEnabled,
refetch, refetch,
@@ -289,7 +295,8 @@ const MazeNET: React.FC = () => {
async (drag: PaletteDrag, world: { x: number; y: number }, overNetId: string | null, overNodeId: string | null) => { async (drag: PaletteDrag, world: { x: number; y: number }, overNetId: string | null, overNodeId: string | null) => {
if (!topologyId) return; if (!topologyId) return;
if (drag.kind === 'network-subnet' || drag.kind === 'network-dmz') { if (drag.kind === 'network-subnet' || drag.kind === 'network-dmz') {
await _dropNetwork(drag, topologyId, nets, api, editor, setNets, setNodes, flashErr); const liveNow = topoStatus === 'active' || topoStatus === 'degraded';
await _dropNetwork(drag, world, topologyId, liveNow, nets, api, editor, setNets, setNodes, flashErr);
} else if (drag.kind === 'archetype' && overNetId) { } else if (drag.kind === 'archetype' && overNetId) {
await _dropArchetype(drag, world, overNetId, topologyId, nets, archetypes, editor, setNodes, flashErr); await _dropArchetype(drag, world, overNetId, topologyId, nets, archetypes, editor, setNodes, flashErr);
} else if (drag.kind === 'service' && overNodeId) { } else if (drag.kind === 'service' && overNodeId) {
@@ -541,30 +548,27 @@ const MazeNET: React.FC = () => {
const deckyNodes = nodes.filter((n) => n.kind === 'decky'); const deckyNodes = nodes.filter((n) => n.kind === 'decky');
const runningDeckies = deckyNodes.filter((n) => n.status === 'active').length; const runningDeckies = deckyNodes.filter((n) => n.status === 'active').length;
/* UPDATE button: flush the staged changeset as one sequential mutation /* UPDATE button: enqueue the staged changeset (async). We await only the
batch. SSE refetch is paused so per-mutation applied events don't wipe enqueue POSTs — sequentially, so expected_version stays ordered — then
still-staged placeholders mid-batch; one refetch reconciles at the end return. The mutator drains the rows on its own loop; the SSE stream
(success or failure). A failed op throws MutationFailedError, which drives refetch as each lands and surfaces any apply failure as the
flashErr pins as a persistent banner. */ persistent commitErr banner. No per-op polling (that flooded the API
and froze the UI). */
const handleCommit = useCallback(async () => { const handleCommit = useCallback(async () => {
if (!topologyId || pendingCount === 0) return; if (!topologyId || pendingCount === 0) return;
const n = pendingCount;
setCommitting(true); setCommitting(true);
setRefetchPaused(true);
try { try {
const applied = await editor.commitStaged(); const queued = await editor.commitStaged();
pushToast({ pushToast({
text: `UPDATED · ${applied} CHANGE${applied === 1 ? '' : 'S'}`, text: `QUEUED · ${queued} CHANGE${queued === 1 ? '' : 'S'} APPLYING`,
tone: 'matrix', icon: 'check-circle', tone: 'violet', icon: 'terminal',
}); });
} catch (err) { } catch (err) {
flashErr(err, `update failed after ${n - editor.pendingCount}/${n} changes`); flashErr(err, 'failed to queue changes');
} finally { } finally {
setRefetchPaused(false);
await refetch();
setCommitting(false); setCommitting(false);
} }
}, [editor, topologyId, pendingCount, refetch, pushToast, flashErr, setRefetchPaused]); }, [editor, topologyId, pendingCount, pushToast, flashErr]);
return ( return (
<div className="maze-page"> <div className="maze-page">

View File

@@ -37,15 +37,6 @@ export interface EdgeRow {
forwards_l3: boolean; forwards_l3: boolean;
} }
export type MutationState = 'pending' | 'applying' | 'applied' | 'failed';
export interface MutationRow {
id: string;
topology_id: string;
op: string;
state: MutationState;
reason: string | null;
}
export interface TopologySummary { export interface TopologySummary {
id: string; id: string;
@@ -75,11 +66,12 @@ export interface HydratedTopology {
* placement. Decky-to-decky traffic edges are derived from * placement. Decky-to-decky traffic edges are derived from
* shared-LAN co-membership for visualization only. */ * shared-LAN co-membership for visualization only. */
export function adaptTopology(detail: TopologyDetail): HydratedTopology { export function adaptTopology(detail: TopologyDetail): HydratedTopology {
// Auto-layout: DMZ pinned top-left, subnets flow in a grid to the right. // Layout: honour the backend's stored x/y (the drop coords sent at
// We ignore lan.x/lan.y from the backend because canvas position // create time) when present, falling back to a DMZ-first grid for
// persistence is deferred (handled via localStorage in a later pass). // topologies that never set canvas coords (e.g. generated ones). Without
// Computing layout from the graph keeps the canvas readable no matter // this, every refetch re-grids — so committing a staged edit yanked all
// how sloppy the original drop points were. // nets back to the grid. localStorage (applyLayout) still overlays any
// later drags on top of this baseline.
const NET_W = 300; const NET_W = 300;
const NET_H = 240; const NET_H = 240;
const GAP_X = 40; const GAP_X = 40;
@@ -94,8 +86,8 @@ export function adaptTopology(detail: TopologyDetail): HydratedTopology {
label: lan.name.toUpperCase(), label: lan.name.toUpperCase(),
cidr: lan.subnet, cidr: lan.subnet,
kind: lan.is_dmz ? 'dmz' : 'subnet', kind: lan.is_dmz ? 'dmz' : 'subnet',
x: GAP_X + (i % COLS) * (NET_W + GAP_X), x: lan.x ?? GAP_X + (i % COLS) * (NET_W + GAP_X),
y: GAP_Y + Math.floor(i / COLS) * (NET_H + GAP_Y), y: lan.y ?? GAP_Y + Math.floor(i / COLS) * (NET_H + GAP_Y),
w: NET_W, w: NET_W,
h: NET_H, h: NET_H,
})); }));
@@ -138,9 +130,9 @@ export function adaptTopology(detail: TopologyDetail): HydratedTopology {
firstLanFor.set(e.decky_uuid, e.lan_id); firstLanFor.set(e.decky_uuid, e.lan_id);
} }
// Layout deckies in a 2-column grid inside their home LAN so two // Deckies: honour stored x/y (drop coords) when present, else a
// members never overlap regardless of backend x/y. Same reasoning as // 2-column grid inside their home LAN. Same baseline-vs-grid logic as
// the LAN grid above. // the LAN layout above.
const NODE_COL_W = 140; const NODE_COL_W = 140;
const NODE_ROW_H = 82; const NODE_ROW_H = 82;
const NODE_X0 = 12; const NODE_X0 = 12;
@@ -158,8 +150,8 @@ export function adaptTopology(detail: TopologyDetail): HydratedTopology {
archetype: (d.decky_config as { archetype?: string } | null)?.archetype ?? 'linux-server', archetype: (d.decky_config as { archetype?: string } | null)?.archetype ?? 'linux-server',
services: d.services, services: d.services,
status: d.state === 'running' ? 'active' : d.state === 'failed' ? 'hot' : 'idle', status: d.state === 'running' ? 'active' : d.state === 'failed' ? 'hot' : 'idle',
x: NODE_X0 + (idx % 2) * NODE_COL_W, x: d.x ?? NODE_X0 + (idx % 2) * NODE_COL_W,
y: NODE_Y0 + Math.floor(idx / 2) * NODE_ROW_H, y: d.y ?? NODE_Y0 + Math.floor(idx / 2) * NODE_ROW_H,
ip: d.ip ?? undefined, ip: d.ip ?? undefined,
decky_config: d.decky_config ?? undefined, decky_config: d.decky_config ?? undefined,
}; };
@@ -176,6 +168,16 @@ export function adaptTopology(detail: TopologyDetail): HydratedTopology {
for (const [lanId, members] of byLan) { for (const [lanId, members] of byLan) {
for (let i = 0; i < members.length; i++) { for (let i = 0; i < members.length; i++) {
for (let j = i + 1; j < members.length; j++) { for (let j = i + 1; j < members.length; j++) {
// Draw an edge between two co-members of a LAN only when at least
// one of them is HOME here. A pair that are both merely *visiting*
// (multi-homed bridges, e.g. a subnet's L3 gateway whose home is
// the DMZ) would otherwise render a line between their two display
// homes — drawing a phantom link to the DMZ/root from any net you
// bridge into. The home↔visitor edge still carries the real
// connection (e.g. subnet→gateway shows the subnet reaching root).
const iHome = firstLanFor.get(members[i]) === lanId;
const jHome = firstLanFor.get(members[j]) === lanId;
if (!iHome && !jHome) continue;
const key = `${members[i]}::${members[j]}`; const key = `${members[i]}::${members[j]}`;
if (seen.has(key)) continue; if (seen.has(key)) continue;
seen.add(key); seen.add(key);
@@ -259,16 +261,6 @@ export interface MazeApi {
expectedVersion?: number, expectedVersion?: number,
) => Promise<EnqueueMutationResponse>; ) => Promise<EnqueueMutationResponse>;
/** Poll the mutation queue until ``mutationId`` reaches a terminal
* state (``applied`` | ``failed``). Resolves with that row; rejects
* only on timeout. A ``failed`` row resolves (not rejects) so callers
* can read ``reason`` — the editor turns it into a loud error. */
waitForMutation: (
topologyId: string,
mutationId: string,
opts?: { timeoutMs?: number; intervalMs?: number },
) => Promise<MutationRow>;
deployTopology: (topologyId: string) => Promise<void>; deployTopology: (topologyId: string) => Promise<void>;
} }
@@ -413,36 +405,6 @@ export function useMazeApi(): MazeApi {
[], [],
); );
const waitForMutation = useCallback(
async (
topologyId: string,
mutationId: string,
opts: { timeoutMs?: number; intervalMs?: number } = {},
): Promise<MutationRow> => {
const { timeoutMs = 30000, intervalMs = 400 } = opts;
const deadline = Date.now() + timeoutMs;
// ponytail: poll the existing list endpoint; the SSE stream also
// carries mutation.applied/failed but wiring a one-shot waiter into
// it couples the editor to the stream hook for no real gain here.
for (;;) {
const { data } = await api.get<MutationRow[]>(
`/topologies/${topologyId}/mutations`,
);
const row = data.find((r) => r.id === mutationId);
if (row && (row.state === 'applied' || row.state === 'failed')) {
return row;
}
if (Date.now() >= deadline) {
throw new Error(
`mutation ${mutationId} did not settle within ${timeoutMs}ms`,
);
}
await new Promise((res) => setTimeout(res, intervalMs));
}
},
[],
);
const enqueueMutation = useCallback( const enqueueMutation = useCallback(
async ( async (
topologyId: string, topologyId: string,
@@ -468,7 +430,7 @@ export function useMazeApi(): MazeApi {
createLan, updateLan, deleteLan, createLan, updateLan, deleteLan,
createDecky, updateDecky, deleteDecky, createDecky, updateDecky, deleteDecky,
attachEdge, detachEdge, attachEdge, detachEdge,
enqueueMutation, waitForMutation, enqueueMutation,
deployTopology, deployTopology,
}), }),
[ [
@@ -477,7 +439,7 @@ export function useMazeApi(): MazeApi {
createLan, updateLan, deleteLan, createLan, updateLan, deleteLan,
createDecky, updateDecky, deleteDecky, createDecky, updateDecky, deleteDecky,
attachEdge, detachEdge, attachEdge, detachEdge,
enqueueMutation, waitForMutation, enqueueMutation,
deployTopology, deployTopology,
], ],
); );

View File

@@ -1,11 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import type { ApiError } from '../../utils/api'; import type { ApiError } from '../../utils/api';
import type { Net, MazeNode, Edge } from './types'; import type { Net, MazeNode, Edge } from './types';
import { DEFAULT_SERVICES, ARCHETYPES as DEFAULT_ARCHETYPES } from './data'; import { DEFAULT_SERVICES, ARCHETYPES as DEFAULT_ARCHETYPES } from './data';
import type { Archetype, ServiceDef } from './data'; import type { Archetype, ServiceDef } from './data';
import type { MazeApi } from './useMazeApi'; import type { MazeApi } from './useMazeApi';
import { MutationFailedError } from './useTopologyEditor';
import { useTopologyStream, type TopologyStreamEvent } from './useTopologyStream'; import { useTopologyStream, type TopologyStreamEvent } from './useTopologyStream';
export interface TopoMeta { export interface TopoMeta {
@@ -48,10 +47,6 @@ export interface UseTopologyDataResult {
commitErr: string | null; commitErr: string | null;
clearCommitErr: () => void; clearCommitErr: () => void;
flashErr: (err: unknown, fallback: string) => void; flashErr: (err: unknown, fallback: string) => void;
/** Pause SSE-driven refetch while a commit batch is in flight, so the
* per-mutation ``applied`` events don't wipe the still-staged
* placeholders mid-batch. The committer does one refetch at the end. */
setRefetchPaused: (paused: boolean) => void;
// Deploy // Deploy
deploying: boolean; deploying: boolean;
@@ -91,18 +86,7 @@ export function useTopologyData(
const clearCommitErr = useCallback(() => setCommitErr(null), []); const clearCommitErr = useCallback(() => setCommitErr(null), []);
const refetchPausedRef = useRef(false);
const setRefetchPaused = useCallback((paused: boolean) => {
refetchPausedRef.current = paused;
}, []);
const flashErr = useCallback((err: unknown, fallback: string) => { const flashErr = useCallback((err: unknown, fallback: string) => {
// A failed live mutation is loud + persistent: the queue halted and
// the topology probably degraded — don't let it vanish in 4s.
if (err instanceof MutationFailedError) {
setCommitErr(err.message);
return;
}
const msg = (err as ApiError)?.response?.data?.detail ?? (err as ApiError)?.message ?? fallback; const msg = (err as ApiError)?.response?.data?.detail ?? (err as ApiError)?.message ?? fallback;
setActionErr(msg); setActionErr(msg);
setTimeout(() => setActionErr(null), 4000); setTimeout(() => setActionErr(null), 4000);
@@ -122,7 +106,22 @@ export function useTopologyData(
const h = await api.getTopology(topologyId); const h = await api.getTopology(topologyId);
setNets(h.nets); setNets(h.nets);
setNodes(h.nodes); setNodes(h.nodes);
setEdges(h.edges); // Keep optimistic bridge edges (those carrying a backendEdgeId) alive
// across refetches until the server actually derives the equivalent
// pair. A bridge attach is best-effort and applies asynchronously, so
// wholesale-replacing edges here would blink the just-drawn link out
// until the mutator catches up. We only retain ones whose endpoints
// still exist and that the server hasn't yet produced.
const pairKey = (a: string, b: string) => (a < b ? `${a}::${b}` : `${b}::${a}`);
const serverPairs = new Set(h.edges.map((e) => pairKey(e.from, e.to)));
const nodeIds = new Set(h.nodes.map((n) => n.id));
setEdges((prev) => {
const pending = prev.filter((e) =>
e.backendEdgeId
&& !serverPairs.has(pairKey(e.from, e.to))
&& nodeIds.has(e.from) && nodeIds.has(e.to));
return [...h.edges, ...pending];
});
setTopoMeta({ setTopoMeta({
status: h.topology.status, status: h.topology.status,
name: h.topology.name, name: h.topology.name,
@@ -152,18 +151,21 @@ export function useTopologyData(
setLastEventAt(new Date()); setLastEventAt(new Date());
} }
if (event.name === 'mutation.failed') { if (event.name === 'mutation.failed') {
// A queued mutation failed to apply (the topology likely degraded).
// Loud + persistent — this is the async equivalent of the old
// blocking commit's thrown error; dismissed via clearCommitErr.
const p = event.payload ?? {}; const p = event.payload ?? {};
const reason = typeof p.reason === 'string' ? p.reason const reason = typeof p.reason === 'string' ? p.reason
: typeof p.error === 'string' ? p.error : typeof p.error === 'string' ? p.error
: 'mutation failed — check mutator logs'; : 'mutation failed — check mutator logs';
setActionErr(`mutation failed: ${reason}`); setCommitErr(`mutation failed: ${reason}`);
setTimeout(() => setActionErr(null), 6000);
} }
if (event.name === 'mutation.applied' if (event.name === 'mutation.applied'
|| event.name === 'mutation.failed' || event.name === 'mutation.failed'
|| event.name === 'status') { || event.name === 'status') {
// Suppressed mid-commit — the committer drives one refetch at the end. // Async queue: each row draining emits one of these; refetch to
if (!refetchPausedRef.current) void refetch(); // reconcile optimistic placeholders to server truth as they land.
void refetch();
} }
// Live service mutations from another tab / admin: optimistically // Live service mutations from another tab / admin: optimistically
// patch local state so the chip set reflects shape without a full // patch local state so the chip set reflects shape without a full
@@ -213,7 +215,7 @@ export function useTopologyData(
edges, setEdges, edges, setEdges,
topoMeta, topoMeta,
services, archetypes, services, archetypes,
loadErr, actionErr, commitErr, clearCommitErr, flashErr, setRefetchPaused, loadErr, actionErr, commitErr, clearCommitErr, flashErr,
deploying, onDeploy, deploying, onDeploy,
streamLive, lastEventAt, streamEnabled, streamLive, lastEventAt, streamEnabled,
refetch, refetch,

View File

@@ -5,12 +5,11 @@
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { act, renderHook } from '@testing-library/react'; import { act, renderHook } from '@testing-library/react';
import { useTopologyEditor, MutationFailedError } from './useTopologyEditor'; import { useTopologyEditor } from './useTopologyEditor';
import type { MazeApi } from './useMazeApi'; import type { MazeApi } from './useMazeApi';
const buildApi = (overrides: Partial<MazeApi> = {}): MazeApi => ({ const buildApi = (overrides: Partial<MazeApi> = {}): MazeApi => ({
enqueueMutation: vi.fn().mockResolvedValue({ mutation_id: 'm', state: 'pending' }), enqueueMutation: vi.fn().mockResolvedValue({ mutation_id: 'm', state: 'pending' }),
waitForMutation: vi.fn().mockResolvedValue({ state: 'applied', reason: null }),
...overrides, ...overrides,
} as unknown as MazeApi); } as unknown as MazeApi);
@@ -20,7 +19,7 @@ const editorFor = (api: MazeApi, topoVersion = 5) =>
); );
describe('useTopologyEditor live staging', () => { describe('useTopologyEditor live staging', () => {
it('stages live edits without sending; commit flushes them in order with a version cursor', async () => { it('stages live edits without sending; commit enqueues them in order with a version cursor', async () => {
const enqueue = vi.fn().mockResolvedValue({ mutation_id: 'm', state: 'pending' }); const enqueue = vi.fn().mockResolvedValue({ mutation_id: 'm', state: 'pending' });
const api = buildApi({ enqueueMutation: enqueue }); const api = buildApi({ enqueueMutation: enqueue });
const { result } = editorFor(api, 5); const { result } = editorFor(api, 5);
@@ -38,18 +37,20 @@ describe('useTopologyEditor live staging', () => {
await result.current.commitStaged(); await result.current.commitStaged();
}); });
// Enqueued (not waited-on): no apply polling.
expect(enqueue).toHaveBeenCalledTimes(2); expect(enqueue).toHaveBeenCalledTimes(2);
expect(enqueue.mock.calls[0][3]).toBe(5); // first uses server version expect(enqueue.mock.calls[0][3]).toBe(5); // first uses server version
expect(enqueue.mock.calls[1][3]).toBe(6); // second advanced by the cursor expect(enqueue.mock.calls[1][3]).toBe(6); // second advanced by the cursor
expect(result.current.pendingCount).toBe(0); expect(result.current.pendingCount).toBe(0);
}); });
it('commit stops loudly on a failed op, keeps the remainder, and retries cleanly', async () => { it('keeps the un-enqueued remainder staged when an enqueue POST fails', async () => {
const wait = vi const enqueue = vi
.fn() .fn()
.mockResolvedValueOnce({ state: 'failed', reason: 'post-apply validation failed: IP_COLLISION' }) .mockResolvedValueOnce({ mutation_id: 'm', state: 'pending' })
.mockResolvedValue({ state: 'applied', reason: null }); .mockRejectedValueOnce(new Error('409 version conflict'))
const api = buildApi({ waitForMutation: wait }); .mockResolvedValue({ mutation_id: 'm', state: 'pending' });
const api = buildApi({ enqueueMutation: enqueue });
const { result } = editorFor(api, 1); const { result } = editorFor(api, 1);
await act(async () => { await act(async () => {
@@ -59,12 +60,11 @@ describe('useTopologyEditor live staging', () => {
expect(result.current.pendingCount).toBe(2); expect(result.current.pendingCount).toBe(2);
await act(async () => { await act(async () => {
await expect(result.current.commitStaged()).rejects.toBeInstanceOf(MutationFailedError); await expect(result.current.commitStaged()).rejects.toThrow('409');
}); });
// First op failed → nothing applied → both stay staged for retry. // First op enqueued, second threw → one remains staged for retry.
expect(result.current.pendingCount).toBe(2); expect(result.current.pendingCount).toBe(1);
// Retry: waitForMutation now resolves 'applied' for both.
await act(async () => { await act(async () => {
await result.current.commitStaged(); await result.current.commitStaged();
}); });

View File

@@ -27,20 +27,6 @@ import type {
MutationOp, MutationOp,
} from './useMazeApi'; } from './useMazeApi';
/** Thrown by a live primitive when its mutation settles as ``failed``.
* Carries the op + backend reason so the page can surface a loud,
* persistent error instead of a transient toast. */
export class MutationFailedError extends Error {
readonly op: string;
readonly reason: string;
constructor(op: string, reason: string) {
super(`mutation ${op} failed: ${reason}`);
this.name = 'MutationFailedError';
this.op = op;
this.reason = reason;
}
}
export interface UseTopologyEditorOptions { export interface UseTopologyEditorOptions {
api: MazeApi; api: MazeApi;
/** Current topology status from :func:`getTopology`. */ /** Current topology status from :func:`getTopology`. */
@@ -171,26 +157,27 @@ export function useTopologyEditor(
const commitStaged = useCallback(async (): Promise<number> => { const commitStaged = useCallback(async (): Promise<number> => {
const ops = staged; const ops = staged;
if (ops.length === 0) return 0; if (ops.length === 0) return 0;
let applied = 0; // ASYNC queue: enqueue the batch and return. We await only the enqueue
// POSTs (sequentially, so expected_version stays ordered — the server
// bumps it per enqueue), NOT each mutation's apply. The mutator drains
// the rows on its own loop and the SSE stream reports applied/failed;
// polling every row to a terminal state here flooded the API (200+ GET
// /mutations per session) and froze the UI. Apply-time failures surface
// loudly via the SSE 'mutation.failed' handler in useTopologyData.
let enqueued = 0;
try { try {
for (const o of ops) { for (const o of ops) {
const expected = cursorRef.current; const expected = cursorRef.current;
const res = await api.enqueueMutation(o.topologyId, o.op, o.payload, expected); await api.enqueueMutation(o.topologyId, o.op, o.payload, expected);
// Advance even if the apply fails below — enqueue already bumped
// the server version.
cursorRef.current = expected + 1; cursorRef.current = expected + 1;
const row = await api.waitForMutation(o.topologyId, res.mutation_id); enqueued += 1;
if (row.state === 'failed') {
throw new MutationFailedError(o.op, row.reason ?? 'unknown reason');
}
applied += 1;
} }
setStaged([]); setStaged([]);
return applied; return enqueued;
} catch (err) { } catch (err) {
// Drop the applied prefix; keep the failing op + the rest so the user // Enqueue-level failure (e.g. 409 version conflict / network). Drop
// can fix and retry without re-staging everything. // the ops that did enqueue; keep the rest staged for retry.
setStaged(ops.slice(applied)); setStaged(ops.slice(enqueued));
throw err; throw err;
} }
}, [staged, api]); }, [staged, api]);

View File

@@ -257,7 +257,7 @@ const TopologyList: React.FC = () => {
<Power size={10} /> {armed === `td:${r.id}` ? 'CONFIRM?' : 'TEARDOWN'} <Power size={10} /> {armed === `td:${r.id}` ? 'CONFIRM?' : 'TEARDOWN'}
</button> </button>
)} )}
{!['active', 'degraded', 'deploying'].includes(r.status) && ( {!['active', 'degraded', 'deploying', 'tearing_down'].includes(r.status) && (
<button <button
type="button" type="button"
className={`tlist-btn small danger ${armed === r.id ? 'armed' : ''}`} className={`tlist-btn small danger ${armed === r.id ? 'armed' : ''}`}

View File

@@ -111,7 +111,7 @@
- [x] **HASSH / HASSHServer** — SSH KEX algo, cipher, MAC order → tool fingerprint - [x] **HASSH / HASSHServer** — SSH KEX algo, cipher, MAC order → tool fingerprint
- [x] **HTTP/2 fingerprint** — GREASE values, settings frame order, header pseudo-field ordering - [x] **HTTP/2 fingerprint** — GREASE values, settings frame order, header pseudo-field ordering
- [x] **QUIC fingerprint** — Connection ID length, transport parameters order - [x] **QUIC fingerprint** — Connection ID length, transport parameters order
- [ ] **DNS behavior** — Query patterns, recursion flags, EDNS0 options, resolver fingerprint - [x] **DNS behavior** — Query patterns, recursion flags, EDNS0 options, resolver fingerprint
- [x] **HTTP header ordering** — Tool-specific capitalization and ordering quirks - [x] **HTTP header ordering** — Tool-specific capitalization and ordering quirks
### Network Topology Leakage ### Network Topology Leakage
@@ -119,11 +119,11 @@
- [x] **ICMP error messages** — Internal IP leakage from misconfigured attacker infra - [x] **ICMP error messages** — Internal IP leakage from misconfigured attacker infra
- [x] **ICMPv6 error messages** — Internal IP leakage from misconfigured attacker infra - [x] **ICMPv6 error messages** — Internal IP leakage from misconfigured attacker infra
- [x] **IPv6 link-local leakage** — IPv6 addrs leaked even over IPv4 VPN (common opsec fail) - [x] **IPv6 link-local leakage** — IPv6 addrs leaked even over IPv4 VPN (common opsec fail)
- [ ] **mDNS/LLMNR leakage** — Attacker hostname/device info from misconfigured systems - [~] **mDNS/LLMNR leakage** — Attacker hostname/device info from misconfigured systems - Deferred to v2.
### Geolocation & Infrastructure ### Geolocation & Infrastructure
- [x] **ASN lookup** — Source IP autonomous system number and org name - [x] **ASN lookup** — Source IP autonomous system number and org name
- [ ] **BGP prefix / RPKI validity** — Route origin legitimacy - [x] **BGP prefix / RPKI validity** — Route origin legitimacy
- [x] **PTR records** — rDNS for attacker IPs (catches infra with forgotten reverse DNS) - [x] **PTR records** — rDNS for attacker IPs (catches infra with forgotten reverse DNS)
- [~] **Latency triangulation** — JA4L RTT estimates for rough geolocation. - Deferred to Federation release. - [~] **Latency triangulation** — JA4L RTT estimates for rough geolocation. - Deferred to Federation release.

View File

@@ -18,9 +18,11 @@ import pytest
from decnet.mutator.ops import ( from decnet.mutator.ops import (
MutationError, MutationError,
apply_add_decky, apply_add_decky,
apply_add_lan,
apply_attach_decky, apply_attach_decky,
apply_detach_decky, apply_detach_decky,
apply_remove_decky, apply_remove_decky,
apply_remove_lan,
apply_update_decky, apply_update_decky,
apply_update_lan, apply_update_lan,
) )
@@ -243,6 +245,40 @@ async def test_detach_decky_calls_network_disconnect(repo, stubs):
stubs["network"].disconnect.assert_called_once() stubs["network"].disconnect.assert_called_once()
# ---------------- apply_remove_lan -------------------------------------
@pytest.mark.anyio
async def test_remove_lan_detaches_bridge_members(repo, stubs):
"""Removing a LAN must prune bridge (non-home) members' ips_by_lan +
edges, not just delete the LAN — else the orphaned ips_by_lan entry
fails post-apply validation (IP_UNKNOWN_LAN) and degrades the topology.
"""
tid = await _make_active(repo)
deckies = await repo.list_topology_deckies(tid)
target = deckies[0]
target_name = (target.decky_config or {})["name"]
# New empty LAN (no home deckies), then bridge an existing decky into it.
await apply_add_lan(repo, tid, {"name": "transit-lan", "subnet": "10.250.0.0/24"})
await apply_attach_decky(repo, tid, {"decky": target_name, "lan": "transit-lan"})
mid = next(d for d in await repo.list_topology_deckies(tid)
if (d.decky_config or {})["name"] == target_name)
assert "transit-lan" in (mid.decky_config or {})["ips_by_lan"]
# The bug: this used to raise MutationError(IP_UNKNOWN_LAN).
await apply_remove_lan(repo, tid, {"name": "transit-lan"})
lans = await repo.list_lans_for_topology(tid)
assert all(l.name != "transit-lan" for l in lans)
after = next(d for d in await repo.list_topology_deckies(tid)
if (d.decky_config or {})["name"] == target_name)
assert "transit-lan" not in (after.decky_config or {})["ips_by_lan"]
# The bridge member was disconnected from the doomed LAN's network.
stubs["network"].disconnect.assert_called()
# ---------------- apply_update_decky ----------------------------------- # ---------------- apply_update_decky -----------------------------------