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"
)
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
# 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

View File

@@ -48,7 +48,7 @@ body.maze-fullscreen .maze-shell {
transition: all 0.3s;
}
.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 {
background: transparent; color: var(--matrix); opacity: 1;
border-color: var(--matrix); box-shadow: var(--matrix-glow);
@@ -323,11 +323,17 @@ html[data-theme="light"] .maze-net-box.inactive {
/* ── Canvas overlays ────────────────────────── */
.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 {
position: absolute; bottom: 12px; left: 12px;
display: flex; gap: 12px; z-index: 5;
font-size: 0.62rem; opacity: 0.6; letter-spacing: 1px;
background: rgba(0, 0, 0, 0.6); padding: 6px 10px; border: 1px solid var(--border);
font-size: 0.62rem; opacity: 0.92; letter-spacing: 1px;
background: rgba(0, 0, 0, 0.8); padding: 6px 10px; border: 1px solid var(--border);
}
.maze-legend {
position: absolute; bottom: 12px; right: 12px; z-index: 5;
@@ -349,7 +355,7 @@ html[data-theme="light"] .maze-net-box.inactive {
/* Status bar segments */
.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.dim { opacity: 0.45; }
.maze-status .status-seg.dim { opacity: 0.75; }
/* Toolbar button sizing override */
.maze-toolbar .maze-btn.small {

View File

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

View File

@@ -37,15 +37,6 @@ export interface EdgeRow {
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 {
id: string;
@@ -75,11 +66,12 @@ export interface HydratedTopology {
* placement. Decky-to-decky traffic edges are derived from
* shared-LAN co-membership for visualization only. */
export function adaptTopology(detail: TopologyDetail): HydratedTopology {
// Auto-layout: DMZ pinned top-left, subnets flow in a grid to the right.
// We ignore lan.x/lan.y from the backend because canvas position
// persistence is deferred (handled via localStorage in a later pass).
// Computing layout from the graph keeps the canvas readable no matter
// how sloppy the original drop points were.
// Layout: honour the backend's stored x/y (the drop coords sent at
// create time) when present, falling back to a DMZ-first grid for
// topologies that never set canvas coords (e.g. generated ones). Without
// this, every refetch re-grids — so committing a staged edit yanked all
// nets back to the grid. localStorage (applyLayout) still overlays any
// later drags on top of this baseline.
const NET_W = 300;
const NET_H = 240;
const GAP_X = 40;
@@ -94,8 +86,8 @@ export function adaptTopology(detail: TopologyDetail): HydratedTopology {
label: lan.name.toUpperCase(),
cidr: lan.subnet,
kind: lan.is_dmz ? 'dmz' : 'subnet',
x: GAP_X + (i % COLS) * (NET_W + GAP_X),
y: GAP_Y + Math.floor(i / COLS) * (NET_H + GAP_Y),
x: lan.x ?? GAP_X + (i % COLS) * (NET_W + GAP_X),
y: lan.y ?? GAP_Y + Math.floor(i / COLS) * (NET_H + GAP_Y),
w: NET_W,
h: NET_H,
}));
@@ -138,9 +130,9 @@ export function adaptTopology(detail: TopologyDetail): HydratedTopology {
firstLanFor.set(e.decky_uuid, e.lan_id);
}
// Layout deckies in a 2-column grid inside their home LAN so two
// members never overlap regardless of backend x/y. Same reasoning as
// the LAN grid above.
// Deckies: honour stored x/y (drop coords) when present, else a
// 2-column grid inside their home LAN. Same baseline-vs-grid logic as
// the LAN layout above.
const NODE_COL_W = 140;
const NODE_ROW_H = 82;
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',
services: d.services,
status: d.state === 'running' ? 'active' : d.state === 'failed' ? 'hot' : 'idle',
x: NODE_X0 + (idx % 2) * NODE_COL_W,
y: NODE_Y0 + Math.floor(idx / 2) * NODE_ROW_H,
x: d.x ?? NODE_X0 + (idx % 2) * NODE_COL_W,
y: d.y ?? NODE_Y0 + Math.floor(idx / 2) * NODE_ROW_H,
ip: d.ip ?? undefined,
decky_config: d.decky_config ?? undefined,
};
@@ -176,6 +168,16 @@ export function adaptTopology(detail: TopologyDetail): HydratedTopology {
for (const [lanId, members] of byLan) {
for (let i = 0; i < members.length; i++) {
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]}`;
if (seen.has(key)) continue;
seen.add(key);
@@ -259,16 +261,6 @@ export interface MazeApi {
expectedVersion?: number,
) => 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>;
}
@@ -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(
async (
topologyId: string,
@@ -468,7 +430,7 @@ export function useMazeApi(): MazeApi {
createLan, updateLan, deleteLan,
createDecky, updateDecky, deleteDecky,
attachEdge, detachEdge,
enqueueMutation, waitForMutation,
enqueueMutation,
deployTopology,
}),
[
@@ -477,7 +439,7 @@ export function useMazeApi(): MazeApi {
createLan, updateLan, deleteLan,
createDecky, updateDecky, deleteDecky,
attachEdge, detachEdge,
enqueueMutation, waitForMutation,
enqueueMutation,
deployTopology,
],
);

View File

@@ -1,11 +1,10 @@
// 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 { Net, MazeNode, Edge } from './types';
import { DEFAULT_SERVICES, ARCHETYPES as DEFAULT_ARCHETYPES } from './data';
import type { Archetype, ServiceDef } from './data';
import type { MazeApi } from './useMazeApi';
import { MutationFailedError } from './useTopologyEditor';
import { useTopologyStream, type TopologyStreamEvent } from './useTopologyStream';
export interface TopoMeta {
@@ -48,10 +47,6 @@ export interface UseTopologyDataResult {
commitErr: string | null;
clearCommitErr: () => 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
deploying: boolean;
@@ -91,18 +86,7 @@ export function useTopologyData(
const clearCommitErr = useCallback(() => setCommitErr(null), []);
const refetchPausedRef = useRef(false);
const setRefetchPaused = useCallback((paused: boolean) => {
refetchPausedRef.current = paused;
}, []);
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;
setActionErr(msg);
setTimeout(() => setActionErr(null), 4000);
@@ -122,7 +106,22 @@ export function useTopologyData(
const h = await api.getTopology(topologyId);
setNets(h.nets);
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({
status: h.topology.status,
name: h.topology.name,
@@ -152,18 +151,21 @@ export function useTopologyData(
setLastEventAt(new Date());
}
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 reason = typeof p.reason === 'string' ? p.reason
: typeof p.error === 'string' ? p.error
: 'mutation failed — check mutator logs';
setActionErr(`mutation failed: ${reason}`);
setTimeout(() => setActionErr(null), 6000);
setCommitErr(`mutation failed: ${reason}`);
}
if (event.name === 'mutation.applied'
|| event.name === 'mutation.failed'
|| event.name === 'status') {
// Suppressed mid-commit — the committer drives one refetch at the end.
if (!refetchPausedRef.current) void refetch();
// Async queue: each row draining emits one of these; refetch to
// reconcile optimistic placeholders to server truth as they land.
void refetch();
}
// Live service mutations from another tab / admin: optimistically
// patch local state so the chip set reflects shape without a full
@@ -213,7 +215,7 @@ export function useTopologyData(
edges, setEdges,
topoMeta,
services, archetypes,
loadErr, actionErr, commitErr, clearCommitErr, flashErr, setRefetchPaused,
loadErr, actionErr, commitErr, clearCommitErr, flashErr,
deploying, onDeploy,
streamLive, lastEventAt, streamEnabled,
refetch,

View File

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

View File

@@ -27,20 +27,6 @@ import type {
MutationOp,
} 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 {
api: MazeApi;
/** Current topology status from :func:`getTopology`. */
@@ -171,26 +157,27 @@ export function useTopologyEditor(
const commitStaged = useCallback(async (): Promise<number> => {
const ops = staged;
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 {
for (const o of ops) {
const expected = cursorRef.current;
const res = await api.enqueueMutation(o.topologyId, o.op, o.payload, expected);
// Advance even if the apply fails below — enqueue already bumped
// the server version.
await api.enqueueMutation(o.topologyId, o.op, o.payload, expected);
cursorRef.current = expected + 1;
const row = await api.waitForMutation(o.topologyId, res.mutation_id);
if (row.state === 'failed') {
throw new MutationFailedError(o.op, row.reason ?? 'unknown reason');
}
applied += 1;
enqueued += 1;
}
setStaged([]);
return applied;
return enqueued;
} catch (err) {
// Drop the applied prefix; keep the failing op + the rest so the user
// can fix and retry without re-staging everything.
setStaged(ops.slice(applied));
// Enqueue-level failure (e.g. 409 version conflict / network). Drop
// the ops that did enqueue; keep the rest staged for retry.
setStaged(ops.slice(enqueued));
throw err;
}
}, [staged, api]);

View File

@@ -257,7 +257,7 @@ const TopologyList: React.FC = () => {
<Power size={10} /> {armed === `td:${r.id}` ? 'CONFIRM?' : 'TEARDOWN'}
</button>
)}
{!['active', 'degraded', 'deploying'].includes(r.status) && (
{!['active', 'degraded', 'deploying', 'tearing_down'].includes(r.status) && (
<button
type="button"
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] **HTTP/2 fingerprint** — GREASE values, settings frame order, header pseudo-field ordering
- [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
### Network Topology Leakage
@@ -119,11 +119,11 @@
- [x] **ICMP 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)
- [ ] **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
- [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)
- [~] **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 (
MutationError,
apply_add_decky,
apply_add_lan,
apply_attach_decky,
apply_detach_decky,
apply_remove_decky,
apply_remove_lan,
apply_update_decky,
apply_update_lan,
)
@@ -243,6 +245,40 @@ async def test_detach_decky_calls_network_disconnect(repo, stubs):
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 -----------------------------------