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"
|
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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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' : ''}`}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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 -----------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user