feat(mazenet): topology editor updates; refine mutator ops materialisation
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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' : ''}`}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 -----------------------------------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user