fix(mazenet-ui): detect gateway via forwards_l3, drop host-mode
Gateway detection in the editor previously matched archetype === 'host-gateway' (a fictional archetype that never existed in decnet/archetypes.py). Switch to decky_config.forwards_l3 — the real runtime marker the composer already reads — so deletion guards, drag-pinning, context menu locking, and NodeCard DMZ-gateway styling all line up with what actually ships at deploy time. On DMZ palette drop, create the gateway with archetype=deaddeck, services=['ssh'], forwards_l3=true, and mark the edge is_bridge=true, forwards_l3=true. attachEdge now accepts those flags so callers can seed a real bridge attachment.
This commit is contained in:
@@ -1,75 +1,266 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
import { PanelRightOpen, PanelRightClose, RotateCcw, UploadCloud } from 'lucide-react';
|
import {
|
||||||
|
PanelRightOpen, PanelRightClose, RotateCcw, UploadCloud, ArrowLeft,
|
||||||
|
Plus, Trash2, Zap, Copy, Eye, ShieldAlert, GitMerge, Server,
|
||||||
|
} from 'lucide-react';
|
||||||
import './MazeNET.css';
|
import './MazeNET.css';
|
||||||
|
import axios from '../../utils/api';
|
||||||
import Palette from './Palette';
|
import Palette from './Palette';
|
||||||
import Canvas from './Canvas';
|
import Canvas from './Canvas';
|
||||||
import Inspector from './Inspector';
|
import Inspector from './Inspector';
|
||||||
import type { Selection } from './Inspector';
|
import type { Selection } from './Inspector';
|
||||||
import ContextMenu, { type MenuItem } from './ContextMenu';
|
import ContextMenu, { type MenuItem } from './ContextMenu';
|
||||||
import { DEFAULT_SERVICES, DEMO_NETS, DEMO_NODES, DEMO_EDGES } from './data';
|
import { DEFAULT_SERVICES } from './data';
|
||||||
import type { ServiceDef } from './data';
|
import type { Archetype, ServiceDef } from './data';
|
||||||
import type { Net, MazeNode, Edge, PendingChange } from './types';
|
import type { Net, MazeNode, Edge, DeckyNode } from './types';
|
||||||
import { useMazeApi } from './useMazeApi';
|
import { useMazeApi } from './useMazeApi';
|
||||||
import { useMazeInteraction } from './useMazeInteraction';
|
import { useMazeInteraction, type PaletteDrag } from './useMazeInteraction';
|
||||||
|
import { ARCHETYPES as DEFAULT_ARCHETYPES } from './data';
|
||||||
|
|
||||||
|
/* Short unique suffix for default names — avoids the DB uniqueness
|
||||||
|
* constraint regardless of delete/re-add sequencing on the client. */
|
||||||
|
const hex4 = (): string => {
|
||||||
|
const r = typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||||
|
? crypto.randomUUID().replace(/-/g, '')
|
||||||
|
: Math.random().toString(16).slice(2);
|
||||||
|
return r.slice(0, 4);
|
||||||
|
};
|
||||||
|
|
||||||
const MazeNET: React.FC = () => {
|
const MazeNET: React.FC = () => {
|
||||||
const api = useMazeApi();
|
const api = useMazeApi();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
const topologyId = params.get('topology');
|
const topologyId = params.get('topology') ?? '';
|
||||||
|
|
||||||
const [nets, setNets] = useState<Net[]>(DEMO_NETS);
|
const [nets, setNets] = useState<Net[]>([]);
|
||||||
const [nodes, setNodes] = useState<MazeNode[]>(DEMO_NODES);
|
const [nodes, setNodes] = useState<MazeNode[]>([]);
|
||||||
const [edges, setEdges] = useState<Edge[]>(DEMO_EDGES);
|
const [edges, setEdges] = useState<Edge[]>([]);
|
||||||
const [pending, setPending] = useState<PendingChange[]>([]);
|
const [topoStatus, setTopoStatus] = useState<string>('pending');
|
||||||
|
const [topoName, setTopoName] = useState<string>('');
|
||||||
|
const [topoVersion, setTopoVersion] = useState<number>(0);
|
||||||
const [selection, setSelection] = useState<Selection>(null);
|
const [selection, setSelection] = useState<Selection>(null);
|
||||||
const [inspectorOpen, setInspectorOpen] = useState(true);
|
const [inspectorOpen, setInspectorOpen] = useState(true);
|
||||||
const [services, setServices] = useState<ServiceDef[]>(DEFAULT_SERVICES);
|
const [services, setServices] = useState<ServiceDef[]>(DEFAULT_SERVICES);
|
||||||
|
const [archetypes, setArchetypes] = useState<Archetype[]>(DEFAULT_ARCHETYPES);
|
||||||
const [loadErr, setLoadErr] = useState<string | null>(null);
|
const [loadErr, setLoadErr] = useState<string | null>(null);
|
||||||
|
const [actionErr, setActionErr] = useState<string | null>(null);
|
||||||
|
const [deploying, setDeploying] = useState(false);
|
||||||
|
|
||||||
const canvasRef = useRef<HTMLDivElement>(null);
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
const applyChange = useCallback((pc: PendingChange) => {
|
|
||||||
setPending((p) => [...p, pc]);
|
const flashErr = useCallback((err: unknown, fallback: string) => {
|
||||||
if (pc.op === 'add_edge') {
|
const msg = (err as { response?: { data?: { detail?: string } }; message?: string })
|
||||||
const payload = pc.payload;
|
?.response?.data?.detail ?? (err as Error)?.message ?? fallback;
|
||||||
setEdges((prev) => prev.some((e) => e.id === payload.id)
|
setActionErr(msg);
|
||||||
? prev
|
setTimeout(() => setActionErr(null), 4000);
|
||||||
: [...prev, { id: payload.id, from: payload.from, to: payload.to, traffic: 'active' as const }]);
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
const interaction = useMazeInteraction({ nets, nodes, setNets, setNodes, applyChange, canvasRef });
|
|
||||||
|
/* ── Palette drop — create LANs / deckies / services via REST ─── */
|
||||||
|
const onPaletteDrop = useCallback(
|
||||||
|
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') {
|
||||||
|
const isDmz = drag.kind === 'network-dmz';
|
||||||
|
if (isDmz && nets.some((n) => n.kind === 'dmz')) {
|
||||||
|
flashErr(null, 'topology already has a DMZ');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const w = 320, h = 240;
|
||||||
|
const x = Math.round(world.x - w / 2);
|
||||||
|
const y = Math.round(world.y - h / 2);
|
||||||
|
const name = isDmz ? `dmz-${hex4()}` : `subnet-${hex4()}`;
|
||||||
|
try {
|
||||||
|
const subnet = await api.getNextSubnet().catch(() => undefined);
|
||||||
|
const lan = await api.createLan(topologyId, { name, is_dmz: isDmz, x, y, ...(subnet ? { subnet } : {}) });
|
||||||
|
const net: Net = {
|
||||||
|
id: lan.id, label: lan.name.toUpperCase(), cidr: lan.subnet,
|
||||||
|
kind: isDmz ? 'dmz' : 'subnet', x, y, w, h,
|
||||||
|
};
|
||||||
|
setNets((p) => [...p, net]);
|
||||||
|
|
||||||
|
if (isDmz) {
|
||||||
|
const gwName = `dmz-gateway-${hex4()}`;
|
||||||
|
const gw = await api.createDecky(topologyId, {
|
||||||
|
name: gwName, services: ['ssh'], x: 20, y: 40,
|
||||||
|
decky_config: { archetype: 'deaddeck', forwards_l3: true },
|
||||||
|
});
|
||||||
|
await api.attachEdge(topologyId, {
|
||||||
|
decky_uuid: gw.uuid, lan_id: lan.id,
|
||||||
|
is_bridge: true, forwards_l3: true,
|
||||||
|
});
|
||||||
|
const gwNode: DeckyNode = {
|
||||||
|
kind: 'decky', id: gw.uuid, netId: lan.id, name: gw.name,
|
||||||
|
archetype: 'deaddeck', services: ['ssh'], status: 'idle',
|
||||||
|
x: 20, y: 40, decky_config: { forwards_l3: true },
|
||||||
|
};
|
||||||
|
setNodes((p) => [...p, gwNode]);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
flashErr(err, 'create network failed');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (drag.kind === 'archetype') {
|
||||||
|
if (!overNetId) return;
|
||||||
|
const net = nets.find((n) => n.id === overNetId);
|
||||||
|
if (!net) return;
|
||||||
|
const arch = archetypes.find((a) => a.slug === drag.slug);
|
||||||
|
const archSlug = drag.slug;
|
||||||
|
const dServices = drag.services ?? arch?.services ?? [];
|
||||||
|
const nx = Math.max(8, Math.round(world.x - net.x - 70));
|
||||||
|
const ny = Math.max(28, Math.round(world.y - net.y - 24));
|
||||||
|
const name = `decky-${hex4()}`;
|
||||||
|
try {
|
||||||
|
const decky = await api.createDecky(topologyId, {
|
||||||
|
name, services: dServices, x: nx, y: ny,
|
||||||
|
decky_config: { archetype: archSlug },
|
||||||
|
});
|
||||||
|
await api.attachEdge(topologyId, { decky_uuid: decky.uuid, lan_id: overNetId });
|
||||||
|
const node: DeckyNode = {
|
||||||
|
kind: 'decky', id: decky.uuid, netId: overNetId, name: decky.name,
|
||||||
|
archetype: archSlug, services: dServices, status: 'idle', x: nx, y: ny,
|
||||||
|
};
|
||||||
|
setNodes((p) => [...p, node]);
|
||||||
|
} catch (err) {
|
||||||
|
flashErr(err, 'create decky failed');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (drag.kind === 'service') {
|
||||||
|
if (!overNodeId) return;
|
||||||
|
const target = nodes.find((n) => n.id === overNodeId);
|
||||||
|
if (!target || target.kind !== 'decky') return;
|
||||||
|
if (target.services.includes(drag.slug)) return;
|
||||||
|
const nextServices = [...target.services, drag.slug];
|
||||||
|
try {
|
||||||
|
await api.updateDecky(topologyId, overNodeId, { services: nextServices });
|
||||||
|
setNodes((p) => p.map((n) => n.id === overNodeId && n.kind === 'decky'
|
||||||
|
? { ...n, services: nextServices }
|
||||||
|
: n));
|
||||||
|
} catch (err) {
|
||||||
|
flashErr(err, 'update services failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[api, archetypes, flashErr, nets, nodes, topologyId],
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ── Cross-net reparent via node drag (detach + attach edge) ─── */
|
||||||
|
const onReparent = useCallback(async (nodeId: string, fromNetId: string, toNetId: string) => {
|
||||||
|
if (!topologyId) return;
|
||||||
|
try {
|
||||||
|
const { data: detail } = await axios.get(`/topologies/${topologyId}`);
|
||||||
|
const existingEdge = (detail.edges ?? []).find(
|
||||||
|
(e: { decky_uuid: string; lan_id: string; id: string }) =>
|
||||||
|
e.decky_uuid === nodeId && e.lan_id === fromNetId,
|
||||||
|
);
|
||||||
|
if (existingEdge) await api.detachEdge(topologyId, existingEdge.id);
|
||||||
|
await api.attachEdge(topologyId, { decky_uuid: nodeId, lan_id: toNetId });
|
||||||
|
} catch (err) {
|
||||||
|
flashErr(err, 'reparent failed');
|
||||||
|
}
|
||||||
|
}, [api, flashErr, topologyId]);
|
||||||
|
|
||||||
|
/* Port→port edges stay UI-only (backend edges are decky↔LAN). */
|
||||||
|
const onAddEdge = useCallback((fromId: string, toId: string) => {
|
||||||
|
const id = `viz-${fromId}-${toId}-${Date.now()}`;
|
||||||
|
setEdges((prev) => prev.some((e) => (e.from === fromId && e.to === toId) || (e.from === toId && e.to === fromId))
|
||||||
|
? prev
|
||||||
|
: [...prev, { id, from: fromId, to: toId, traffic: 'active' as const }]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const interaction = useMazeInteraction({
|
||||||
|
nets, nodes, setNets, setNodes, canvasRef,
|
||||||
|
onPaletteDrop, onReparent, onAddEdge,
|
||||||
|
});
|
||||||
|
|
||||||
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; items: MenuItem[] } | null>(null);
|
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; items: MenuItem[] } | null>(null);
|
||||||
|
|
||||||
const removeNet = (id: string) => {
|
const removeNet = async (id: string) => {
|
||||||
const net = nets.find((n) => n.id === id);
|
const net = nets.find((n) => n.id === id);
|
||||||
if (!net || net.kind === 'internet') return;
|
if (!net || net.kind === 'internet') return;
|
||||||
setNets((p) => p.filter((n) => n.id !== id));
|
/* Cascade delete members first — backend will otherwise 400 on orphan risk. */
|
||||||
setNodes((p) => p.filter((n) => n.netId !== id));
|
const members = nodes.filter((n) => n.netId === id && n.kind === 'decky');
|
||||||
setEdges((p) => p.filter((e) => {
|
try {
|
||||||
const a = nodes.find((x) => x.id === e.from)?.netId;
|
for (const m of members) await api.deleteDecky(topologyId, m.id);
|
||||||
const b = nodes.find((x) => x.id === e.to)?.netId;
|
await api.deleteLan(topologyId, id);
|
||||||
return a !== id && b !== id;
|
setNets((p) => p.filter((n) => n.id !== id));
|
||||||
}));
|
setNodes((p) => p.filter((n) => n.netId !== id));
|
||||||
applyChange({ op: 'remove_lan', payload: { id } });
|
setEdges((p) => p.filter((e) => {
|
||||||
setSelection(null);
|
const a = nodes.find((x) => x.id === e.from)?.netId;
|
||||||
|
const b = nodes.find((x) => x.id === e.to)?.netId;
|
||||||
|
return a !== id && b !== id;
|
||||||
|
}));
|
||||||
|
setSelection(null);
|
||||||
|
} catch (err) {
|
||||||
|
flashErr(err, 'delete network failed');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeNode = (id: string) => {
|
const removeNode = async (id: string) => {
|
||||||
const node = nodes.find((n) => n.id === id);
|
const node = nodes.find((n) => n.id === id);
|
||||||
if (!node || node.kind === 'observed') return;
|
if (!node || node.kind === 'observed') return;
|
||||||
setNodes((p) => p.filter((n) => n.id !== id));
|
if (node.kind === 'decky' && node.decky_config?.forwards_l3) return;
|
||||||
setEdges((p) => p.filter((e) => e.from !== id && e.to !== id));
|
try {
|
||||||
applyChange({ op: 'remove_decky', payload: { nodeId: id } });
|
await api.deleteDecky(topologyId, id);
|
||||||
setSelection(null);
|
setNodes((p) => p.filter((n) => n.id !== id));
|
||||||
|
setEdges((p) => p.filter((e) => e.from !== id && e.to !== id));
|
||||||
|
setSelection(null);
|
||||||
|
} catch (err) {
|
||||||
|
flashErr(err, 'delete decky failed');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeEdge = (id: string) => {
|
const removeEdge = (id: string) => {
|
||||||
|
/* Viz-only edges: backend has no edge to delete here. */
|
||||||
setEdges((p) => p.filter((e) => e.id !== id));
|
setEdges((p) => p.filter((e) => e.id !== id));
|
||||||
applyChange({ op: 'remove_edge', payload: { id } });
|
|
||||||
setSelection(null);
|
setSelection(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const duplicateNode = async (id: string) => {
|
||||||
|
const n = nodes.find((x) => x.id === id);
|
||||||
|
if (!n || n.kind !== 'decky') return;
|
||||||
|
const name = `${n.name.replace(/-[0-9a-f]{4}$/, '')}-${hex4()}`;
|
||||||
|
try {
|
||||||
|
const decky = await api.createDecky(topologyId, {
|
||||||
|
name, services: [...n.services], x: n.x + 24, y: n.y + 24,
|
||||||
|
decky_config: { archetype: n.archetype },
|
||||||
|
});
|
||||||
|
await api.attachEdge(topologyId, { decky_uuid: decky.uuid, lan_id: n.netId });
|
||||||
|
const copy: DeckyNode = {
|
||||||
|
kind: 'decky', id: decky.uuid, netId: n.netId, name: decky.name,
|
||||||
|
archetype: n.archetype, services: [...n.services], status: 'idle',
|
||||||
|
x: n.x + 24, y: n.y + 24,
|
||||||
|
};
|
||||||
|
setNodes((p) => [...p, copy]);
|
||||||
|
} catch (err) {
|
||||||
|
flashErr(err, 'duplicate failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addServiceToNode = async (id: string, slug: string) => {
|
||||||
|
const n = nodes.find((x) => x.id === id);
|
||||||
|
if (!n || n.kind !== 'decky' || n.services.includes(slug)) return;
|
||||||
|
const nextServices = [...n.services, slug];
|
||||||
|
try {
|
||||||
|
await api.updateDecky(topologyId, id, { services: nextServices });
|
||||||
|
setNodes((p) => p.map((x) => x.id === id && x.kind === 'decky'
|
||||||
|
? { ...x, services: nextServices } : x));
|
||||||
|
} catch (err) {
|
||||||
|
flashErr(err, 'add service failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Force-mutate is a no-op against a pending topology (no live containers).
|
||||||
|
* Keep the menu item disabled for now; real hook lands with live-editing polish. */
|
||||||
|
const forceMutate = (_id: string) => {
|
||||||
|
flashErr(null, 'force-mutate only applies to deployed topologies');
|
||||||
|
};
|
||||||
|
|
||||||
const onNodeContextMenu = (id: string) => (e: React.MouseEvent) => {
|
const onNodeContextMenu = (id: string) => (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -77,18 +268,38 @@ const MazeNET: React.FC = () => {
|
|||||||
if (!node) return;
|
if (!node) return;
|
||||||
setSelection({ type: 'node', id });
|
setSelection({ type: 'node', id });
|
||||||
const isObs = node.kind === 'observed';
|
const isObs = node.kind === 'observed';
|
||||||
|
const isGateway = node.kind === 'decky' && !!node.decky_config?.forwards_l3;
|
||||||
|
const locked = isObs || isGateway;
|
||||||
|
const lockedTitle = isObs
|
||||||
|
? 'observed entity — not a deployed decky'
|
||||||
|
: isGateway ? 'DMZ gateway — pinned to its DMZ network' : undefined;
|
||||||
|
const usedServices = node.kind === 'decky' ? new Set(node.services) : new Set<string>();
|
||||||
|
const serviceSubmenu: MenuItem[] = services
|
||||||
|
.filter((s) => !usedServices.has(s.slug))
|
||||||
|
.slice(0, 16)
|
||||||
|
.map((s) => ({
|
||||||
|
label: `${s.name} · ${s.proto.toUpperCase()}:${s.port}`,
|
||||||
|
disabled: isObs,
|
||||||
|
onClick: () => addServiceToNode(id, s.slug),
|
||||||
|
}));
|
||||||
|
if (serviceSubmenu.length === 0) {
|
||||||
|
serviceSubmenu.push({ label: '(no free services)', disabled: true });
|
||||||
|
}
|
||||||
|
|
||||||
setCtxMenu({
|
setCtxMenu({
|
||||||
x: e.clientX, y: e.clientY,
|
x: e.clientX, y: e.clientY,
|
||||||
items: [
|
items: [
|
||||||
{ label: 'INSPECT', onClick: () => setSelection({ type: 'node', id }) },
|
{ label: 'Add service…', icon: <Plus size={12} />, disabled: isObs,
|
||||||
|
title: isObs ? 'observed entity — services fixed' : undefined,
|
||||||
|
submenu: serviceSubmenu },
|
||||||
|
{ label: 'Force mutate', icon: <Zap size={12} />, disabled: isObs,
|
||||||
|
onClick: () => forceMutate(id) },
|
||||||
|
{ label: 'Duplicate decky', icon: <Copy size={12} />, disabled: locked,
|
||||||
|
title: lockedTitle, onClick: () => duplicateNode(id) },
|
||||||
{ separator: true, label: '' },
|
{ separator: true, label: '' },
|
||||||
{
|
{ label: 'Delete decky', icon: <Trash2 size={12} />, danger: true,
|
||||||
label: 'DELETE NODE',
|
disabled: locked, title: lockedTitle,
|
||||||
danger: true,
|
onClick: () => removeNode(id) },
|
||||||
disabled: isObs,
|
|
||||||
title: isObs ? 'observed entity — not a deployed decky' : undefined,
|
|
||||||
onClick: () => removeNode(id),
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -99,18 +310,39 @@ const MazeNET: React.FC = () => {
|
|||||||
const net = nets.find((n) => n.id === id);
|
const net = nets.find((n) => n.id === id);
|
||||||
if (!net) return;
|
if (!net) return;
|
||||||
setSelection({ type: 'net', id });
|
setSelection({ type: 'net', id });
|
||||||
|
const archetypeSubmenu: MenuItem[] = archetypes.map((a) => ({
|
||||||
|
label: a.name, icon: <Server size={12} />,
|
||||||
|
onClick: async () => {
|
||||||
|
const name = `decky-${hex4()}`;
|
||||||
|
try {
|
||||||
|
const decky = await api.createDecky(topologyId, {
|
||||||
|
name, services: [...a.services], x: 20, y: 40,
|
||||||
|
decky_config: { archetype: a.slug },
|
||||||
|
});
|
||||||
|
await api.attachEdge(topologyId, { decky_uuid: decky.uuid, lan_id: id });
|
||||||
|
const node: DeckyNode = {
|
||||||
|
kind: 'decky', id: decky.uuid, netId: id, name: decky.name,
|
||||||
|
archetype: a.slug, services: [...a.services], status: 'idle',
|
||||||
|
x: 20, y: 40,
|
||||||
|
};
|
||||||
|
setNodes((p) => [...p, node]);
|
||||||
|
} catch (err) {
|
||||||
|
flashErr(err, 'create decky failed');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
setCtxMenu({
|
setCtxMenu({
|
||||||
x: e.clientX, y: e.clientY,
|
x: e.clientX, y: e.clientY,
|
||||||
items: [
|
items: [
|
||||||
{ label: 'INSPECT', onClick: () => setSelection({ type: 'net', id }) },
|
{ label: 'Add decky…', icon: <Plus size={12} />, submenu: archetypeSubmenu },
|
||||||
|
{ label: 'Inspect', icon: <Eye size={12} />, onClick: () => setSelection({ type: 'net', id }) },
|
||||||
{ separator: true, label: '' },
|
{ separator: true, label: '' },
|
||||||
{
|
{ label: net.kind === 'dmz' ? 'Delete DMZ' : 'Delete network',
|
||||||
label: 'DELETE NET',
|
icon: <Trash2 size={12} />, danger: true,
|
||||||
danger: true,
|
|
||||||
disabled: net.kind === 'internet',
|
disabled: net.kind === 'internet',
|
||||||
title: net.kind === 'internet' ? 'internet zone cannot be removed' : undefined,
|
title: net.kind === 'internet' ? 'internet zone cannot be removed' : undefined,
|
||||||
onClick: () => removeNet(id),
|
onClick: () => removeNet(id) },
|
||||||
},
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -122,46 +354,79 @@ const MazeNET: React.FC = () => {
|
|||||||
setCtxMenu({
|
setCtxMenu({
|
||||||
x: e.clientX, y: e.clientY,
|
x: e.clientX, y: e.clientY,
|
||||||
items: [
|
items: [
|
||||||
{ label: 'REMOVE EDGE', danger: true, onClick: () => removeEdge(id) },
|
{ label: 'Remove edge', icon: <Trash2 size={12} />, danger: true, onClick: () => removeEdge(id) },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/* Load service catalog from API (fall back to defaults if 401/offline). */
|
const onCanvasContextMenu = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setCtxMenu({
|
||||||
|
x: e.clientX, y: e.clientY,
|
||||||
|
items: [
|
||||||
|
{ label: 'Add subnet here', icon: <GitMerge size={12} />,
|
||||||
|
onClick: () => {
|
||||||
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||||||
|
const wx = e.clientX - (rect?.left ?? 0) - interaction.pan.x;
|
||||||
|
const wy = e.clientY - (rect?.top ?? 0) - interaction.pan.y;
|
||||||
|
onPaletteDrop(
|
||||||
|
{ kind: 'network-subnet', slug: 'subnet', label: 'SUBNET', clientX: e.clientX, clientY: e.clientY },
|
||||||
|
{ x: wx, y: wy }, null, null,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ label: 'Add DMZ here', icon: <ShieldAlert size={12} />,
|
||||||
|
onClick: () => {
|
||||||
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||||||
|
const wx = e.clientX - (rect?.left ?? 0) - interaction.pan.x;
|
||||||
|
const wy = e.clientY - (rect?.top ?? 0) - interaction.pan.y;
|
||||||
|
onPaletteDrop(
|
||||||
|
{ kind: 'network-dmz', slug: 'dmz', label: 'DMZ', clientX: e.clientX, clientY: e.clientY },
|
||||||
|
{ x: wx, y: wy }, null, null,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Load catalogs. */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
api.getServices().then((s) => { if (!cancelled) setServices(s); }).catch(() => {});
|
api.getServices().then((s) => { if (!cancelled) setServices(s); }).catch(() => {});
|
||||||
|
api.getArchetypes().then((a) => { if (!cancelled) setArchetypes(a); }).catch(() => {});
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [api]);
|
}, [api]);
|
||||||
|
|
||||||
/* If ?topology=<id> is present, hydrate from the real backend. */
|
/* Hydrate topology. Route guard in App.tsx ensures topologyId is set;
|
||||||
useEffect(() => {
|
* if the id is bogus, surface a friendly error. */
|
||||||
|
const refetch = useCallback(async () => {
|
||||||
if (!topologyId) return;
|
if (!topologyId) return;
|
||||||
let cancelled = false;
|
try {
|
||||||
api.getTopology(topologyId)
|
const h = await api.getTopology(topologyId);
|
||||||
.then((h) => {
|
setNets(h.nets); setNodes(h.nodes); setEdges(h.edges);
|
||||||
if (cancelled) return;
|
setTopoStatus(h.topology.status);
|
||||||
setNets(h.nets); setNodes(h.nodes); setEdges(h.edges);
|
setTopoName(h.topology.name);
|
||||||
setSelection(null);
|
setTopoVersion(h.topology.version);
|
||||||
setLoadErr(null);
|
setLoadErr(null);
|
||||||
})
|
} catch (err) {
|
||||||
.catch((err) => {
|
setLoadErr((err as Error)?.message ?? 'topology load failed');
|
||||||
if (!cancelled) setLoadErr(err?.message ?? 'topology load failed');
|
}
|
||||||
});
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [api, topologyId]);
|
}, [api, topologyId]);
|
||||||
|
|
||||||
const onReset = () => {
|
useEffect(() => { refetch(); }, [refetch]);
|
||||||
if (topologyId) {
|
|
||||||
api.getTopology(topologyId).then((h) => {
|
const onDeploy = async () => {
|
||||||
setNets(h.nets); setNodes(h.nodes); setEdges(h.edges);
|
if (!topologyId) return;
|
||||||
}).catch(() => {});
|
setDeploying(true);
|
||||||
} else {
|
try {
|
||||||
setNets(DEMO_NETS); setNodes(DEMO_NODES); setEdges(DEMO_EDGES);
|
await api.deployTopology(topologyId);
|
||||||
|
await refetch();
|
||||||
|
} catch (err) {
|
||||||
|
flashErr(err, 'deploy failed');
|
||||||
|
} finally {
|
||||||
|
setDeploying(false);
|
||||||
}
|
}
|
||||||
setSelection(null);
|
|
||||||
setPending([]);
|
|
||||||
interaction.resetPan();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -172,38 +437,38 @@ const MazeNET: React.FC = () => {
|
|||||||
return () => window.removeEventListener('keydown', onKey);
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const canDeploy = topoStatus === 'pending' && nets.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="maze-page">
|
<div className="maze-page">
|
||||||
<div className="maze-page-header">
|
<div className="maze-page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>MAZENET</h1>
|
<h1>MAZENET · {topoName || topologyId}</h1>
|
||||||
<div className="maze-page-sub">
|
<div className="maze-page-sub">
|
||||||
{topologyId ? `TOPOLOGY ${topologyId} · ` : 'DEMO · '}
|
{topoStatus.toUpperCase()} · v{topoVersion} ·{' '}
|
||||||
{nets.length} NETS · {nodes.length} NODES · {edges.length} PATHS ·{' '}
|
{nets.length} NETS · {nodes.length} NODES · {edges.length} PATHS
|
||||||
{pending.length > 0 ? `${pending.length} UNCOMMITTED` : 'LIVE'}
|
|
||||||
{loadErr && <span className="alert-text"> · {loadErr}</span>}
|
{loadErr && <span className="alert-text"> · {loadErr}</span>}
|
||||||
|
{actionErr && <span className="alert-text"> · {actionErr}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="maze-page-actions">
|
<div className="maze-page-actions">
|
||||||
<button
|
<button type="button" className="maze-btn ghost" onClick={() => navigate('/topologies')}>
|
||||||
type="button"
|
<ArrowLeft size={12} /> TOPOLOGIES
|
||||||
className="maze-btn ghost"
|
|
||||||
onClick={() => setInspectorOpen((o) => !o)}
|
|
||||||
title={inspectorOpen ? 'Hide inspector' : 'Show inspector'}
|
|
||||||
>
|
|
||||||
{inspectorOpen ? <PanelRightClose size={12} /> : <PanelRightOpen size={12} />}
|
|
||||||
INSPECTOR
|
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="maze-btn ghost" onClick={onReset}>
|
<button type="button" className="maze-btn ghost" onClick={() => setInspectorOpen((o) => !o)}>
|
||||||
<RotateCcw size={12} /> RESET
|
{inspectorOpen ? <PanelRightClose size={12} /> : <PanelRightOpen size={12} />} INSPECTOR
|
||||||
|
</button>
|
||||||
|
<button type="button" className="maze-btn ghost" onClick={refetch} title="Revert local state to server">
|
||||||
|
<RotateCcw size={12} /> REFRESH
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="maze-btn"
|
className="maze-btn"
|
||||||
disabled={pending.length === 0}
|
disabled={!canDeploy || deploying}
|
||||||
onClick={() => api.commit(topologyId ?? '', pending)}
|
onClick={onDeploy}
|
||||||
|
title={canDeploy ? 'Deploy topology' : 'Deploy requires pending status + at least one network'}
|
||||||
>
|
>
|
||||||
<UploadCloud size={12} /> COMMIT {pending.length > 0 ? `(${pending.length})` : ''}
|
<UploadCloud size={12} /> {deploying ? 'DEPLOYING…' : 'DEPLOY'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -212,12 +477,13 @@ const MazeNET: React.FC = () => {
|
|||||||
className="maze-shell"
|
className="maze-shell"
|
||||||
style={{ gridTemplateColumns: inspectorOpen ? '240px 1fr 320px' : '240px 1fr' }}
|
style={{ gridTemplateColumns: inspectorOpen ? '240px 1fr 320px' : '240px 1fr' }}
|
||||||
>
|
>
|
||||||
<Palette services={services} />
|
<Palette services={services} archetypes={archetypes} startPaletteDrag={interaction.startPaletteDrag} />
|
||||||
<Canvas
|
<Canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
nets={nets}
|
nets={nets}
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
|
deployed={topoStatus === 'active' || topoStatus === 'degraded'}
|
||||||
selection={selection}
|
selection={selection}
|
||||||
setSelection={setSelection}
|
setSelection={setSelection}
|
||||||
pan={interaction.pan}
|
pan={interaction.pan}
|
||||||
@@ -232,17 +498,26 @@ const MazeNET: React.FC = () => {
|
|||||||
onNodeContextMenu={onNodeContextMenu}
|
onNodeContextMenu={onNodeContextMenu}
|
||||||
onNetContextMenu={onNetContextMenu}
|
onNetContextMenu={onNetContextMenu}
|
||||||
onEdgeContextMenu={onEdgeContextMenu}
|
onEdgeContextMenu={onEdgeContextMenu}
|
||||||
|
onCanvasContextMenu={onCanvasContextMenu}
|
||||||
/>
|
/>
|
||||||
{ctxMenu && (
|
{ctxMenu && (
|
||||||
<ContextMenu x={ctxMenu.x} y={ctxMenu.y} items={ctxMenu.items} onClose={() => setCtxMenu(null)} />
|
<ContextMenu x={ctxMenu.x} y={ctxMenu.y} items={ctxMenu.items} onClose={() => setCtxMenu(null)} />
|
||||||
)}
|
)}
|
||||||
|
{interaction.paletteDrag && (
|
||||||
|
<div
|
||||||
|
className="palette-ghost"
|
||||||
|
style={{ left: interaction.paletteDrag.clientX + 8, top: interaction.paletteDrag.clientY + 8 }}
|
||||||
|
>
|
||||||
|
{interaction.paletteDrag.label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{inspectorOpen && (
|
{inspectorOpen && (
|
||||||
<Inspector
|
<Inspector
|
||||||
selection={selection}
|
selection={selection}
|
||||||
nets={nets}
|
nets={nets}
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
pending={pending}
|
topologyStatus={topoStatus}
|
||||||
onClose={() => setInspectorOpen(false)}
|
onClose={() => setInspectorOpen(false)}
|
||||||
onDeleteNet={removeNet}
|
onDeleteNet={removeNet}
|
||||||
onDeleteNode={removeNode}
|
onDeleteNode={removeNode}
|
||||||
|
|||||||
@@ -7,19 +7,23 @@ interface Props {
|
|||||||
absY: number;
|
absY: number;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
dragging?: boolean;
|
dragging?: boolean;
|
||||||
|
deployed?: boolean;
|
||||||
onSelect?: (id: string) => void;
|
onSelect?: (id: string) => void;
|
||||||
onMouseDown?: (id: string) => (e: React.MouseEvent) => void;
|
onMouseDown?: (id: string) => (e: React.MouseEvent) => void;
|
||||||
onPortMouseDown?: (id: string) => (e: React.MouseEvent) => void;
|
onPortMouseDown?: (id: string) => (e: React.MouseEvent) => void;
|
||||||
onContextMenu?: (id: string) => (e: React.MouseEvent) => void;
|
onContextMenu?: (id: string) => (e: React.MouseEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NodeCard: React.FC<Props> = ({ node, absX, absY, selected, dragging, onSelect, onMouseDown, onPortMouseDown, onContextMenu }) => {
|
const NodeCard: React.FC<Props> = ({ node, absX, absY, selected, dragging, deployed, onSelect, onMouseDown, onPortMouseDown, onContextMenu }) => {
|
||||||
|
const isDmzGateway = !!(node as { decky_config?: { forwards_l3?: boolean } }).decky_config?.forwards_l3;
|
||||||
const classes = [
|
const classes = [
|
||||||
'maze-node',
|
'maze-node',
|
||||||
node.kind === 'observed' ? 'observed' : '',
|
node.kind === 'observed' ? 'observed' : '',
|
||||||
node.status === 'hot' ? 'hot' : '',
|
node.status === 'hot' ? 'hot' : '',
|
||||||
selected ? 'selected' : '',
|
selected ? 'selected' : '',
|
||||||
dragging ? 'dragging' : '',
|
dragging ? 'dragging' : '',
|
||||||
|
deployed ? 'deployed' : '',
|
||||||
|
deployed && isDmzGateway ? 'dmz-gateway' : '',
|
||||||
].filter(Boolean).join(' ');
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
const handleDown = (e: React.MouseEvent) => {
|
const handleDown = (e: React.MouseEvent) => {
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
import api from '../../utils/api';
|
import api from '../../utils/api';
|
||||||
import { DEFAULT_SERVICES } from './data';
|
import { ARCHETYPES as DEFAULT_ARCHETYPES, DEFAULT_SERVICES } from './data';
|
||||||
import type { ServiceDef } from './data';
|
import type { Archetype, ServiceDef } from './data';
|
||||||
import type { Net, MazeNode, Edge, DeckyNode, PendingChange } from './types';
|
import type { Net, MazeNode, Edge, DeckyNode } from './types';
|
||||||
|
|
||||||
interface LANRow {
|
export interface LANRow {
|
||||||
id: string;
|
id: string;
|
||||||
|
topology_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
subnet: string;
|
subnet: string;
|
||||||
is_dmz: boolean;
|
is_dmz: boolean;
|
||||||
@@ -13,8 +14,9 @@ interface LANRow {
|
|||||||
y?: number | null;
|
y?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DeckyRow {
|
export interface DeckyRow {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
|
topology_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
services: string[];
|
services: string[];
|
||||||
decky_config?: Record<string, unknown> | null;
|
decky_config?: Record<string, unknown> | null;
|
||||||
@@ -24,15 +26,16 @@ interface DeckyRow {
|
|||||||
y?: number | null;
|
y?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EdgeRow {
|
export interface EdgeRow {
|
||||||
id: string;
|
id: string;
|
||||||
|
topology_id: string;
|
||||||
decky_uuid: string;
|
decky_uuid: string;
|
||||||
lan_id: string;
|
lan_id: string;
|
||||||
is_bridge: boolean;
|
is_bridge: boolean;
|
||||||
forwards_l3: boolean;
|
forwards_l3: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TopologySummary {
|
export interface TopologySummary {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
mode: string;
|
mode: string;
|
||||||
@@ -47,17 +50,17 @@ interface TopologyDetail {
|
|||||||
edges: EdgeRow[];
|
edges: EdgeRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HydratedTopology {
|
export interface HydratedTopology {
|
||||||
topology: TopologySummary;
|
topology: TopologySummary;
|
||||||
nets: Net[];
|
nets: Net[];
|
||||||
nodes: MazeNode[];
|
nodes: MazeNode[];
|
||||||
edges: Edge[];
|
edges: Edge[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Adapt the Phase-3 TopologyDetail wire shape to canvas entities.
|
/** Adapt the wire shape to canvas entities. Backend edges are
|
||||||
* Backend edges are decky↔LAN membership (bipartite); we surface them
|
* decky↔LAN membership (bipartite); we surface them as node-in-net
|
||||||
* as node-in-net placement. Decky-to-decky traffic edges are derived
|
* placement. Decky-to-decky traffic edges are derived from
|
||||||
* from shared-LAN co-membership for now (Step 4 may refine this). */
|
* shared-LAN co-membership for visualization only. */
|
||||||
export function adaptTopology(detail: TopologyDetail): HydratedTopology {
|
export function adaptTopology(detail: TopologyDetail): HydratedTopology {
|
||||||
const nets: Net[] = detail.lans.map((lan, i) => ({
|
const nets: Net[] = detail.lans.map((lan, i) => ({
|
||||||
id: lan.id,
|
id: lan.id,
|
||||||
@@ -70,7 +73,6 @@ export function adaptTopology(detail: TopologyDetail): HydratedTopology {
|
|||||||
h: 240,
|
h: 240,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
/* A decky sits in the first LAN it attaches to. */
|
|
||||||
const firstLanFor = new Map<string, string>();
|
const firstLanFor = new Map<string, string>();
|
||||||
for (const e of detail.edges) {
|
for (const e of detail.edges) {
|
||||||
if (!firstLanFor.has(e.decky_uuid)) firstLanFor.set(e.decky_uuid, e.lan_id);
|
if (!firstLanFor.has(e.decky_uuid)) firstLanFor.set(e.decky_uuid, e.lan_id);
|
||||||
@@ -81,7 +83,7 @@ export function adaptTopology(detail: TopologyDetail): HydratedTopology {
|
|||||||
id: d.uuid,
|
id: d.uuid,
|
||||||
netId: firstLanFor.get(d.uuid) ?? (nets[0]?.id ?? ''),
|
netId: firstLanFor.get(d.uuid) ?? (nets[0]?.id ?? ''),
|
||||||
name: d.name,
|
name: d.name,
|
||||||
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: d.x ?? 20 + (i % 2) * 160,
|
x: d.x ?? 20 + (i % 2) * 160,
|
||||||
@@ -90,7 +92,6 @@ export function adaptTopology(detail: TopologyDetail): HydratedTopology {
|
|||||||
decky_config: d.decky_config ?? undefined,
|
decky_config: d.decky_config ?? undefined,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
/* Derive decky-to-decky edges from shared-LAN membership. */
|
|
||||||
const byLan = new Map<string, string[]>();
|
const byLan = new Map<string, string[]>();
|
||||||
for (const e of detail.edges) {
|
for (const e of detail.edges) {
|
||||||
const arr = byLan.get(e.lan_id) ?? [];
|
const arr = byLan.get(e.lan_id) ?? [];
|
||||||
@@ -118,21 +119,71 @@ export function adaptTopology(detail: TopologyDetail): HydratedTopology {
|
|||||||
return { topology: detail.topology, nets, nodes, edges };
|
return { topology: detail.topology, nets, nodes, edges };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MazeApi {
|
interface ArchetypeRow {
|
||||||
listTopologies: () => Promise<TopologySummary[]>;
|
slug: string;
|
||||||
getTopology: (id: string) => Promise<HydratedTopology>;
|
display_name: string;
|
||||||
getServices: () => Promise<ServiceDef[]>;
|
description: string;
|
||||||
getNextIp: (topologyId: string, lanId: string) => Promise<string>;
|
services: string[];
|
||||||
getNextSubnet: (base: string) => Promise<string>;
|
preferred_distros: string[];
|
||||||
commit: (topologyId: string, changes: PendingChange[]) => Promise<void>;
|
nmap_os: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMazeApi(toast?: (msg: string) => void): MazeApi {
|
const NMAP_OS_TO_ICON: Record<string, string> = {
|
||||||
|
linux: 'server',
|
||||||
|
windows: 'monitor',
|
||||||
|
embedded: 'cpu',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CreateLanBody {
|
||||||
|
name: string;
|
||||||
|
is_dmz: boolean;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
subnet?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateDeckyBody {
|
||||||
|
name: string;
|
||||||
|
services: string[];
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
decky_config?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MazeApi {
|
||||||
|
listTopologies: () => Promise<TopologySummary[]>;
|
||||||
|
createBlankTopology: (name: string) => Promise<TopologySummary>;
|
||||||
|
getTopology: (id: string) => Promise<HydratedTopology>;
|
||||||
|
getServices: () => Promise<ServiceDef[]>;
|
||||||
|
getArchetypes: () => Promise<Archetype[]>;
|
||||||
|
getNextIp: (topologyId: string, lanId: string) => Promise<string>;
|
||||||
|
getNextSubnet: (base?: string) => Promise<string>;
|
||||||
|
|
||||||
|
createLan: (topologyId: string, body: CreateLanBody) => Promise<LANRow>;
|
||||||
|
updateLan: (topologyId: string, lanId: string, patch: Partial<LANRow>) => Promise<LANRow>;
|
||||||
|
deleteLan: (topologyId: string, lanId: string) => Promise<void>;
|
||||||
|
|
||||||
|
createDecky: (topologyId: string, body: CreateDeckyBody) => Promise<DeckyRow>;
|
||||||
|
updateDecky: (topologyId: string, uuid: string, patch: Partial<DeckyRow>) => Promise<DeckyRow>;
|
||||||
|
deleteDecky: (topologyId: string, uuid: string) => Promise<void>;
|
||||||
|
|
||||||
|
attachEdge: (topologyId: string, body: { decky_uuid: string; lan_id: string; is_bridge?: boolean; forwards_l3?: boolean }) => Promise<EdgeRow>;
|
||||||
|
detachEdge: (topologyId: string, edgeId: string) => Promise<void>;
|
||||||
|
|
||||||
|
deployTopology: (topologyId: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMazeApi(): MazeApi {
|
||||||
const listTopologies = useCallback(async () => {
|
const listTopologies = useCallback(async () => {
|
||||||
const { data } = await api.get('/topologies/');
|
const { data } = await api.get('/topologies/');
|
||||||
return (data?.data ?? []) as TopologySummary[];
|
return (data?.data ?? []) as TopologySummary[];
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const createBlankTopology = useCallback(async (name: string): Promise<TopologySummary> => {
|
||||||
|
const { data } = await api.post<TopologySummary>('/topologies/blank', { name });
|
||||||
|
return data;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const getTopology = useCallback(async (id: string) => {
|
const getTopology = useCallback(async (id: string) => {
|
||||||
const { data } = await api.get<TopologyDetail>(`/topologies/${id}`);
|
const { data } = await api.get<TopologyDetail>(`/topologies/${id}`);
|
||||||
return adaptTopology(data);
|
return adaptTopology(data);
|
||||||
@@ -158,6 +209,21 @@ export function useMazeApi(toast?: (msg: string) => void): MazeApi {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const getArchetypes = useCallback(async (): Promise<Archetype[]> => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<{ archetypes: ArchetypeRow[] }>('/topologies/archetypes');
|
||||||
|
const known = new Map(DEFAULT_ARCHETYPES.map((a) => [a.slug, a.icon]));
|
||||||
|
return data.archetypes.map((a) => ({
|
||||||
|
slug: a.slug,
|
||||||
|
name: a.display_name,
|
||||||
|
services: a.services,
|
||||||
|
icon: known.get(a.slug) ?? NMAP_OS_TO_ICON[a.nmap_os] ?? 'server',
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_ARCHETYPES;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const getNextIp = useCallback(async (topologyId: string, lanId: string) => {
|
const getNextIp = useCallback(async (topologyId: string, lanId: string) => {
|
||||||
const { data } = await api.get<{ subnet: string; ip: string }>(
|
const { data } = await api.get<{ subnet: string; ip: string }>(
|
||||||
`/topologies/${topologyId}/lans/${lanId}/next-ip`,
|
`/topologies/${topologyId}/lans/${lanId}/next-ip`,
|
||||||
@@ -165,7 +231,7 @@ export function useMazeApi(toast?: (msg: string) => void): MazeApi {
|
|||||||
return data.ip;
|
return data.ip;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getNextSubnet = useCallback(async (base: string) => {
|
const getNextSubnet = useCallback(async (base: string = '10.0') => {
|
||||||
const { data } = await api.get<{ subnet: string }>(
|
const { data } = await api.get<{ subnet: string }>(
|
||||||
`/topologies/next-subnet`,
|
`/topologies/next-subnet`,
|
||||||
{ params: { base } },
|
{ params: { base } },
|
||||||
@@ -173,14 +239,93 @@ export function useMazeApi(toast?: (msg: string) => void): MazeApi {
|
|||||||
return data.subnet;
|
return data.subnet;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const commit = useCallback(
|
const createLan = useCallback(
|
||||||
async (_topologyId: string, changes: PendingChange[]) => {
|
async (topologyId: string, body: CreateLanBody): Promise<LANRow> => {
|
||||||
/* Phase-3 Steps 3–5 land the real endpoints. For now, just surface. */
|
const { data } = await api.post<LANRow>(`/topologies/${topologyId}/lans`, body);
|
||||||
console.log('[MazeNET] commit stub — pending changes:', changes);
|
return data;
|
||||||
toast?.(`commit stubbed (${changes.length} change${changes.length === 1 ? '' : 's'})`);
|
|
||||||
},
|
},
|
||||||
[toast],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { listTopologies, getTopology, getServices, getNextIp, getNextSubnet, commit };
|
const updateLan = useCallback(
|
||||||
|
async (topologyId: string, lanId: string, patch: Partial<LANRow>): Promise<LANRow> => {
|
||||||
|
const { data } = await api.patch<LANRow>(`/topologies/${topologyId}/lans/${lanId}`, patch);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteLan = useCallback(
|
||||||
|
async (topologyId: string, lanId: string): Promise<void> => {
|
||||||
|
await api.delete(`/topologies/${topologyId}/lans/${lanId}`);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const createDecky = useCallback(
|
||||||
|
async (topologyId: string, body: CreateDeckyBody): Promise<DeckyRow> => {
|
||||||
|
const { data } = await api.post<DeckyRow>(`/topologies/${topologyId}/deckies`, body);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateDecky = useCallback(
|
||||||
|
async (topologyId: string, uuid: string, patch: Partial<DeckyRow>): Promise<DeckyRow> => {
|
||||||
|
const { data } = await api.patch<DeckyRow>(
|
||||||
|
`/topologies/${topologyId}/deckies/${uuid}`,
|
||||||
|
patch,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteDecky = useCallback(
|
||||||
|
async (topologyId: string, uuid: string): Promise<void> => {
|
||||||
|
await api.delete(`/topologies/${topologyId}/deckies/${uuid}`);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const attachEdge = useCallback(
|
||||||
|
async (topologyId: string, body: { decky_uuid: string; lan_id: string; is_bridge?: boolean; forwards_l3?: boolean }): Promise<EdgeRow> => {
|
||||||
|
const { data } = await api.post<EdgeRow>(`/topologies/${topologyId}/edges`, body);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const detachEdge = useCallback(
|
||||||
|
async (topologyId: string, edgeId: string): Promise<void> => {
|
||||||
|
await api.delete(`/topologies/${topologyId}/edges/${edgeId}`);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const deployTopology = useCallback(
|
||||||
|
async (topologyId: string): Promise<void> => {
|
||||||
|
await api.post(`/topologies/${topologyId}/deploy`, {});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
listTopologies, createBlankTopology, getTopology, getServices, getArchetypes,
|
||||||
|
getNextIp, getNextSubnet,
|
||||||
|
createLan, updateLan, deleteLan,
|
||||||
|
createDecky, updateDecky, deleteDecky,
|
||||||
|
attachEdge, detachEdge,
|
||||||
|
deployTopology,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
listTopologies, createBlankTopology, getTopology, getServices, getArchetypes,
|
||||||
|
getNextIp, getNextSubnet,
|
||||||
|
createLan, updateLan, deleteLan,
|
||||||
|
createDecky, updateDecky, deleteDecky,
|
||||||
|
attachEdge, detachEdge,
|
||||||
|
deployTopology,
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import type { Net, MazeNode, PendingChange } from './types';
|
import type { Net, MazeNode } from './types';
|
||||||
|
|
||||||
export type ResizeHandle = 'e' | 'w' | 'n' | 's' | 'ne' | 'nw' | 'se' | 'sw';
|
export type ResizeHandle = 'e' | 'w' | 'n' | 's' | 'ne' | 'nw' | 'se' | 'sw';
|
||||||
|
|
||||||
|
export type PaletteDragKind = 'network-subnet' | 'network-dmz' | 'archetype' | 'service';
|
||||||
|
export interface PaletteDrag {
|
||||||
|
kind: PaletteDragKind;
|
||||||
|
slug: string;
|
||||||
|
label: string;
|
||||||
|
services?: string[];
|
||||||
|
clientX: number;
|
||||||
|
clientY: number;
|
||||||
|
}
|
||||||
|
|
||||||
type Drag =
|
type Drag =
|
||||||
| null
|
| null
|
||||||
| { type: 'pan'; startX: number; startY: number; panX: number; panY: number }
|
| { type: 'pan'; startX: number; startY: number; panX: number; panY: number }
|
||||||
@@ -15,8 +25,11 @@ interface Args {
|
|||||||
nodes: MazeNode[];
|
nodes: MazeNode[];
|
||||||
setNets: React.Dispatch<React.SetStateAction<Net[]>>;
|
setNets: React.Dispatch<React.SetStateAction<Net[]>>;
|
||||||
setNodes: React.Dispatch<React.SetStateAction<MazeNode[]>>;
|
setNodes: React.Dispatch<React.SetStateAction<MazeNode[]>>;
|
||||||
applyChange: (pc: PendingChange) => void;
|
|
||||||
canvasRef: React.RefObject<HTMLDivElement | null>;
|
canvasRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
onPaletteDrop?: (drag: PaletteDrag, world: { x: number; y: number }, overNetId: string | null, overNodeId: string | null) => void;
|
||||||
|
/** Structural callbacks — only these hit the backend. */
|
||||||
|
onReparent?: (nodeId: string, fromNetId: string, toNetId: string) => void;
|
||||||
|
onAddEdge?: (fromNodeId: string, toNodeId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EdgeDraw {
|
interface EdgeDraw {
|
||||||
@@ -26,13 +39,20 @@ interface EdgeDraw {
|
|||||||
hoverTarget: string | null;
|
hoverTarget: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMazeInteraction({ nets, nodes, setNets, setNodes, applyChange, canvasRef }: Args) {
|
export function useMazeInteraction({ nets, nodes, setNets, setNodes, canvasRef, onPaletteDrop, onReparent, onAddEdge }: Args) {
|
||||||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||||
const [drag, setDrag] = useState<Drag>(null);
|
const [drag, setDrag] = useState<Drag>(null);
|
||||||
const [dropTargetId, setDropTargetId] = useState<string | null>(null);
|
const [dropTargetId, setDropTargetId] = useState<string | null>(null);
|
||||||
const [edgeDraw, setEdgeDraw] = useState<EdgeDraw | null>(null);
|
const [edgeDraw, setEdgeDraw] = useState<EdgeDraw | null>(null);
|
||||||
|
const [paletteDrag, setPaletteDrag] = useState<PaletteDrag | null>(null);
|
||||||
const edgeDrawRef = useRef<EdgeDraw | null>(null);
|
const edgeDrawRef = useRef<EdgeDraw | null>(null);
|
||||||
|
const paletteDragRef = useRef<PaletteDrag | null>(null);
|
||||||
useEffect(() => { edgeDrawRef.current = edgeDraw; }, [edgeDraw]);
|
useEffect(() => { edgeDrawRef.current = edgeDraw; }, [edgeDraw]);
|
||||||
|
useEffect(() => { paletteDragRef.current = paletteDrag; }, [paletteDrag]);
|
||||||
|
|
||||||
|
const startPaletteDrag = useCallback((d: Omit<PaletteDrag, 'clientX' | 'clientY'>, e: React.MouseEvent) => {
|
||||||
|
setPaletteDrag({ ...d, clientX: e.clientX, clientY: e.clientY });
|
||||||
|
}, []);
|
||||||
|
|
||||||
/* Refs to avoid re-binding global listeners on every state change. */
|
/* Refs to avoid re-binding global listeners on every state change. */
|
||||||
const netsRef = useRef(nets);
|
const netsRef = useRef(nets);
|
||||||
@@ -109,6 +129,11 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, applyChange
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onMove = (e: MouseEvent) => {
|
const onMove = (e: MouseEvent) => {
|
||||||
|
const pd = paletteDragRef.current;
|
||||||
|
if (pd) {
|
||||||
|
setPaletteDrag({ ...pd, clientX: e.clientX, clientY: e.clientY });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const ed = edgeDrawRef.current;
|
const ed = edgeDrawRef.current;
|
||||||
if (ed) {
|
if (ed) {
|
||||||
const o = canvasOriginRef.current();
|
const o = canvasOriginRef.current();
|
||||||
@@ -121,7 +146,7 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, applyChange
|
|||||||
if (!parent) return false;
|
if (!parent) return false;
|
||||||
const ax = parent.x + n.x;
|
const ax = parent.x + n.x;
|
||||||
const ay = parent.y + n.y;
|
const ay = parent.y + n.y;
|
||||||
return wx >= ax - 8 && wx <= ax + 8 && wy >= ay + 14 && wy <= ay + 30;
|
return wx >= ax - 12 && wx <= ax + 140 && wy >= ay && wy <= ay + 80;
|
||||||
});
|
});
|
||||||
setEdgeDraw({ ...ed, toX: wx, toY: wy, hoverTarget: hover?.id ?? null });
|
setEdgeDraw({ ...ed, toX: wx, toY: wy, hoverTarget: hover?.id ?? null });
|
||||||
return;
|
return;
|
||||||
@@ -150,7 +175,8 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, applyChange
|
|||||||
const node = nodesRef.current.find((n) => n.id === d.id);
|
const node = nodesRef.current.find((n) => n.id === d.id);
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
const isObserved = node.kind === 'observed';
|
const isObserved = node.kind === 'observed';
|
||||||
const targetNet = !isObserved ? netsRef.current.find((net) => {
|
const isPinned = node.kind === 'decky' && !!node.decky_config?.forwards_l3;
|
||||||
|
const targetNet = !isObserved && !isPinned ? netsRef.current.find((net) => {
|
||||||
if (net.id === node.netId) return false;
|
if (net.id === node.netId) return false;
|
||||||
return w.x >= net.x && w.x <= net.x + net.w && w.y >= net.y && w.y <= net.y + net.h;
|
return w.x >= net.x && w.x <= net.x + net.w && w.y >= net.y && w.y <= net.y + net.h;
|
||||||
}) : undefined;
|
}) : undefined;
|
||||||
@@ -158,8 +184,10 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, applyChange
|
|||||||
|
|
||||||
const parent = netsRef.current.find((n) => n.id === node.netId);
|
const parent = netsRef.current.find((n) => n.id === node.netId);
|
||||||
if (!parent) return;
|
if (!parent) return;
|
||||||
const nx = Math.max(8, Math.round(w.x - d.offX - parent.x));
|
const maxX = Math.max(8, parent.w - 148);
|
||||||
const ny = Math.max(28, Math.round(w.y - d.offY - parent.y));
|
const maxY = Math.max(28, parent.h - 88);
|
||||||
|
const nx = Math.min(maxX, Math.max(8, Math.round(w.x - d.offX - parent.x)));
|
||||||
|
const ny = Math.min(maxY, Math.max(28, Math.round(w.y - d.offY - parent.y)));
|
||||||
setNodes((prev) => prev.map((n) => n.id === d.id ? { ...n, x: nx, y: ny } : n));
|
setNodes((prev) => prev.map((n) => n.id === d.id ? { ...n, x: nx, y: ny } : n));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -187,14 +215,39 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, applyChange
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onUp = () => {
|
const onUp = (e: MouseEvent) => {
|
||||||
|
const pd = paletteDragRef.current;
|
||||||
|
if (pd) {
|
||||||
|
setPaletteDrag(null);
|
||||||
|
const o = canvasOriginRef.current();
|
||||||
|
const p = panRef.current;
|
||||||
|
const wx = e.clientX - o.x - p.x;
|
||||||
|
const wy = e.clientY - o.y - p.y;
|
||||||
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||||||
|
const inside = rect
|
||||||
|
? e.clientX >= rect.left && e.clientX <= rect.right
|
||||||
|
&& e.clientY >= rect.top && e.clientY <= rect.bottom
|
||||||
|
: false;
|
||||||
|
if (!inside) return;
|
||||||
|
const overNet = netsRef.current.find(
|
||||||
|
(n) => wx >= n.x && wx <= n.x + n.w && wy >= n.y && wy <= n.y + n.h,
|
||||||
|
);
|
||||||
|
const overNode = nodesRef.current.find((n) => {
|
||||||
|
const parent = netsRef.current.find((nn) => nn.id === n.netId);
|
||||||
|
if (!parent) return false;
|
||||||
|
const ax = parent.x + n.x;
|
||||||
|
const ay = parent.y + n.y;
|
||||||
|
return wx >= ax && wx <= ax + 140 && wy >= ay && wy <= ay + 80;
|
||||||
|
});
|
||||||
|
onPaletteDrop?.(pd, { x: wx, y: wy }, overNet?.id ?? null, overNode?.id ?? null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const ed = edgeDrawRef.current;
|
const ed = edgeDrawRef.current;
|
||||||
if (ed) {
|
if (ed) {
|
||||||
if (ed.hoverTarget && ed.hoverTarget !== ed.fromId) {
|
if (ed.hoverTarget && ed.hoverTarget !== ed.fromId) {
|
||||||
const target = nodesRef.current.find((n) => n.id === ed.hoverTarget);
|
const target = nodesRef.current.find((n) => n.id === ed.hoverTarget);
|
||||||
if (target && target.kind !== 'observed') {
|
if (target && target.kind !== 'observed') {
|
||||||
const id = `e-${ed.fromId}-${ed.hoverTarget}-${Date.now()}`;
|
onAddEdge?.(ed.fromId, ed.hoverTarget);
|
||||||
applyChange({ op: 'add_edge', payload: { id, from: ed.fromId, to: ed.hoverTarget } });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setEdgeDraw(null);
|
setEdgeDraw(null);
|
||||||
@@ -215,22 +268,12 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, applyChange
|
|||||||
const absY = parentOld.y + node.y;
|
const absY = parentOld.y + node.y;
|
||||||
const relX = Math.max(8, absX - parentNew.x);
|
const relX = Math.max(8, absX - parentNew.x);
|
||||||
const relY = Math.max(28, absY - parentNew.y);
|
const relY = Math.max(28, absY - parentNew.y);
|
||||||
|
const fromNetId = node.netId;
|
||||||
setNodes((prev) => prev.map((n) => n.id === d.id ? { ...n, netId: target, x: relX, y: relY } : n));
|
setNodes((prev) => prev.map((n) => n.id === d.id ? { ...n, netId: target, x: relX, y: relY } : n));
|
||||||
applyChange({ op: 'detach_decky', payload: { nodeId: d.id, netId: node.netId } });
|
onReparent?.(d.id, fromNetId, target);
|
||||||
applyChange({ op: 'attach_decky', payload: {
|
|
||||||
nodeId: d.id, netId: target, archetype: node.archetype, name: node.name,
|
|
||||||
x: relX, y: relY, services: node.services,
|
|
||||||
}});
|
|
||||||
}
|
}
|
||||||
} else if (node && node.kind === 'decky') {
|
|
||||||
applyChange({ op: 'update_decky', payload: { nodeId: node.id, patch: { x: node.x, y: node.y } } });
|
|
||||||
}
|
}
|
||||||
} else if (d.type === 'net') {
|
/* Intra-net moves and net/resize drags are cosmetic — never persisted. */
|
||||||
const net = netsRef.current.find((n) => n.id === d.id);
|
|
||||||
if (net) applyChange({ op: 'update_lan', payload: { id: net.id, patch: { x: net.x, y: net.y } } });
|
|
||||||
} else if (d.type === 'resize') {
|
|
||||||
const net = netsRef.current.find((n) => n.id === d.id);
|
|
||||||
if (net) applyChange({ op: 'update_lan', payload: { id: net.id, patch: { x: net.x, y: net.y, w: net.w, h: net.h } } });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setDropTargetId(null);
|
setDropTargetId(null);
|
||||||
@@ -243,7 +286,7 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, applyChange
|
|||||||
window.removeEventListener('mousemove', onMove);
|
window.removeEventListener('mousemove', onMove);
|
||||||
window.removeEventListener('mouseup', onUp);
|
window.removeEventListener('mouseup', onUp);
|
||||||
};
|
};
|
||||||
}, [applyChange, setNets, setNodes, dropTargetId]);
|
}, [setNets, setNodes, dropTargetId, onPaletteDrop, onReparent, onAddEdge, canvasRef]);
|
||||||
|
|
||||||
const resetPan = useCallback(() => setPan({ x: 0, y: 0 }), []);
|
const resetPan = useCallback(() => setPan({ x: 0, y: 0 }), []);
|
||||||
|
|
||||||
@@ -252,6 +295,8 @@ export function useMazeInteraction({ nets, nodes, setNets, setNodes, applyChange
|
|||||||
dropTargetId,
|
dropTargetId,
|
||||||
dragging: drag !== null,
|
dragging: drag !== null,
|
||||||
edgeDraw,
|
edgeDraw,
|
||||||
|
paletteDrag,
|
||||||
|
startPaletteDrag,
|
||||||
onCanvasMouseDown,
|
onCanvasMouseDown,
|
||||||
onNodeMouseDown,
|
onNodeMouseDown,
|
||||||
onNetMouseDown,
|
onNetMouseDown,
|
||||||
|
|||||||
Reference in New Issue
Block a user