feat(web/mazenet): polish editor UX

Canvas grew a deployed prop so nodes can visually distinguish "live in
docker" from "planned". ContextMenu learned nested submenus with
ChevronRight affordance; NetBox renders a ShieldAlert for DMZ LANs;
Palette got additional lucide icons. Dead PendingChange union pulled
out of types.ts — Phase-3 mutation ops are driven by the API layer now,
not a frontend type.
This commit is contained in:
2026-04-21 10:24:32 -04:00
parent 59d618d25f
commit 4727ea0af2
7 changed files with 115 additions and 74 deletions

View File

@@ -9,6 +9,7 @@ interface Props {
nets: Net[];
nodes: MazeNode[];
edges: Edge[];
deployed: boolean;
selection: Selection;
setSelection: (s: Selection) => void;
pan: { x: number; y: number };
@@ -30,7 +31,7 @@ const NODE_W = 140;
const NODE_HEAD_H = 22;
const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
{ nets, nodes, edges, selection, setSelection, pan, dropTargetId, dragging, edgeDraw,
{ nets, nodes, edges, deployed, selection, setSelection, pan, dropTargetId, dragging, edgeDraw,
onCanvasMouseDown, onNodeMouseDown, onNetMouseDown, onNetResizeMouseDown, onPortMouseDown,
onNodeContextMenu, onNetContextMenu, onEdgeContextMenu, onCanvasContextMenu },
ref,
@@ -139,6 +140,7 @@ const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
selected={net.id === selNetId}
dropTarget={dropTargetId === net.id}
inactive={inactive}
deployed={deployed}
onSelect={(id) => setSelection({ type: 'net', id })}
onHeaderMouseDown={onNetMouseDown}
onResizeMouseDown={onNetResizeMouseDown}
@@ -155,6 +157,7 @@ const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
absX={p.x}
absY={p.y}
selected={n.id === selNodeId}
deployed={deployed}
dragging={dragging && n.id === selNodeId}
onSelect={(id) => setSelection({ type: 'node', id })}
onMouseDown={onNodeMouseDown}

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useRef } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { ChevronRight } from 'lucide-react';
export interface MenuItem {
label: string;
@@ -7,6 +8,8 @@ export interface MenuItem {
title?: string;
danger?: boolean;
separator?: boolean;
icon?: React.ReactNode;
submenu?: MenuItem[];
}
interface Props {
@@ -14,10 +17,12 @@ interface Props {
y: number;
items: MenuItem[];
onClose: () => void;
title?: string;
}
const ContextMenu: React.FC<Props> = ({ x, y, items, onClose }) => {
const ContextMenu: React.FC<Props> = ({ x, y, items, onClose, title }) => {
const ref = useRef<HTMLDivElement>(null);
const [openSub, setOpenSub] = useState<number | null>(null);
useEffect(() => {
const onDown = (e: MouseEvent) => {
@@ -32,25 +37,61 @@ const ContextMenu: React.FC<Props> = ({ x, y, items, onClose }) => {
};
}, [onClose]);
const renderItem = (it: MenuItem, i: number) => {
if (it.separator) return <div key={i} className="ctx-divider" />;
const hasSub = !!it.submenu?.length;
return (
<div ref={ref} className="ctx-menu" style={{ left: x, top: y }}>
{items.map((it, i) =>
it.separator ? (
<div key={i} className="ctx-divider" />
) : (
<button
<div
key={i}
className="ctx-item-wrap"
onMouseEnter={() => setOpenSub(hasSub ? i : null)}
>
<button
type="button"
className={`ctx-item ${it.danger ? 'danger' : ''}`}
disabled={it.disabled}
title={it.title}
onClick={() => { if (!it.disabled) { it.onClick?.(); onClose(); } }}
onClick={() => {
if (it.disabled) return;
if (hasSub) return;
it.onClick?.();
onClose();
}}
>
{it.label}
{it.icon && <span className="ctx-icon">{it.icon}</span>}
<span className="ctx-label">{it.label}</span>
{hasSub && <ChevronRight size={12} className="ctx-chev" />}
</button>
{hasSub && openSub === i && (
<div className="ctx-submenu">
{it.submenu!.map((s, j) =>
s.separator ? (
<div key={j} className="ctx-divider" />
) : (
<button
key={j}
type="button"
className={`ctx-item ${s.danger ? 'danger' : ''}`}
disabled={s.disabled}
title={s.title}
onClick={() => { if (!s.disabled) { s.onClick?.(); onClose(); } }}
>
{s.icon && <span className="ctx-icon">{s.icon}</span>}
<span className="ctx-label">{s.label}</span>
</button>
),
)}
</div>
)}
</div>
);
};
return (
<div ref={ref} className="ctx-menu" style={{ left: x, top: y }}>
{title && <div className="ctx-title">{title}</div>}
{items.map(renderItem)}
</div>
);
};

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Globe, GitMerge } from 'lucide-react';
import { Globe, GitMerge, ShieldAlert } from 'lucide-react';
import type { Net } from './types';
import type { ResizeHandle } from './useMazeInteraction';
@@ -8,6 +8,7 @@ interface Props {
selected: boolean;
dropTarget: boolean;
inactive: boolean;
deployed?: boolean;
onSelect?: (id: string) => void;
onHeaderMouseDown?: (id: string) => (e: React.MouseEvent) => void;
onResizeMouseDown?: (id: string, handle: ResizeHandle) => (e: React.MouseEvent) => void;
@@ -16,17 +17,19 @@ interface Props {
}
const NetBox: React.FC<Props> = ({
net, selected, dropTarget, inactive, onSelect, onHeaderMouseDown, onResizeMouseDown, onContextMenu, children,
net, selected, dropTarget, inactive, deployed, onSelect, onHeaderMouseDown, onResizeMouseDown, onContextMenu, children,
}) => {
const classes = [
'maze-net-box',
net.kind === 'internet' ? 'internet' : '',
net.kind === 'dmz' ? 'dmz' : '',
selected ? 'selected' : '',
dropTarget ? 'drop-target' : '',
inactive ? 'inactive' : '',
deployed ? 'deployed' : '',
].filter(Boolean).join(' ');
const Icon = net.kind === 'internet' ? Globe : GitMerge;
const Icon = net.kind === 'internet' ? Globe : net.kind === 'dmz' ? ShieldAlert : GitMerge;
const resizable = net.kind !== 'internet';
const handleBoxDown = (e: React.MouseEvent) => {

View File

@@ -1,12 +1,13 @@
import React from 'react';
import { GitMerge, Server, Monitor, Shield, Database, Cpu, Globe,
import { GitMerge, ShieldAlert, Server, Monitor, Shield, Database, Cpu, Globe,
Terminal, Lock, Folder, HardDrive, Users, KeyRound,
Radio, Zap, Wifi, Circle } from 'lucide-react';
import { ARCHETYPES } from './data';
import type { ServiceDef, Archetype } from './data';
import type { PaletteDrag } from './useMazeInteraction';
const ICON: Record<string, React.ComponentType<{ size?: number; className?: string }>> = {
'git-merge': GitMerge, server: Server, monitor: Monitor, shield: Shield,
'git-merge': GitMerge, 'shield-alert': ShieldAlert,
server: Server, monitor: Monitor, shield: Shield,
database: Database, cpu: Cpu, globe: Globe, terminal: Terminal, lock: Lock,
folder: Folder, 'hard-drive': HardDrive, users: Users, 'key-round': KeyRound,
radio: Radio, zap: Zap, wifi: Wifi, circle: Circle,
@@ -19,28 +20,42 @@ function Icon({ name, size = 14, className }: { name: string; size?: number; cla
interface Props {
services: ServiceDef[];
onPaletteDragStart?: (kind: 'network' | 'archetype' | 'service', slug: string, label: string) => void;
archetypes: Archetype[];
startPaletteDrag: (d: Omit<PaletteDrag, 'clientX' | 'clientY'>, e: React.MouseEvent) => void;
}
const Palette: React.FC<Props> = ({ services, onPaletteDragStart }) => {
const start = (kind: 'network' | 'archetype' | 'service', slug: string, label: string) =>
(e: React.MouseEvent) => { e.preventDefault(); onPaletteDragStart?.(kind, slug, label); };
const Palette: React.FC<Props> = ({ services, archetypes, startPaletteDrag }) => {
const start = (d: Omit<PaletteDrag, 'clientX' | 'clientY'>) =>
(e: React.MouseEvent) => {
if (e.button !== 0) return;
e.preventDefault();
startPaletteDrag(d, e);
};
return (
<div className="maze-palette">
<div className="palette-group">
<label> NETWORKS</label>
<div className="palette-item" onMouseDown={start('network', 'subnet', 'SUBNET')}>
<div className="palette-item" onMouseDown={start({ kind: 'network-subnet', slug: 'subnet', label: 'SUBNET' })}>
<Icon name="git-merge" className="violet-accent" />
<span>Subnet</span>
<span className="chip-mini">VLAN</span>
</div>
<div className="palette-item" onMouseDown={start({ kind: 'network-dmz', slug: 'dmz', label: 'DMZ' })}>
<Icon name="shield-alert" className="alert-text" />
<span>DMZ</span>
<span className="chip-mini">HOST</span>
</div>
</div>
<div className="palette-group">
<label> ARCHETYPES</label>
{ARCHETYPES.map((a: Archetype) => (
<div key={a.slug} className="palette-item" onMouseDown={start('archetype', a.slug, a.name)}>
{archetypes.map((a: Archetype) => (
<div
key={a.slug}
className="palette-item"
onMouseDown={start({ kind: 'archetype', slug: a.slug, label: a.name, services: a.services })}
>
<Icon name={a.icon} className="violet-accent" />
<span>{a.name}</span>
<span className="chip-mini">{a.services.length}</span>
@@ -51,7 +66,11 @@ const Palette: React.FC<Props> = ({ services, onPaletteDragStart }) => {
<div className="palette-group">
<label> SERVICES</label>
{services.map((s) => (
<div key={s.slug} className="palette-item" onMouseDown={start('service', s.slug, s.name)}>
<div
key={s.slug}
className="palette-item"
onMouseDown={start({ kind: 'service', slug: s.slug, label: s.name })}
>
<Icon
name={s.icon}
size={12}
@@ -66,8 +85,8 @@ const Palette: React.FC<Props> = ({ services, onPaletteDragStart }) => {
<div className="palette-group">
<label>HINT</label>
<div className="palette-hint">
Drag empty canvas to pan. Right-click anything for a menu. Subnets
must be wired to something or they go inactive.
Drag a network onto the canvas, or an archetype onto a network,
or a service onto a decky. Right-click for menus.
</div>
</div>
</div>

View File

@@ -1,5 +1,3 @@
import type { Net, MazeNode, Edge } from './types';
export interface Archetype {
slug: string;
name: string;
@@ -43,29 +41,3 @@ export const DEFAULT_SERVICES: ServiceDef[] = [
{ slug: 'coap', name: 'CoAP', port: 5683, proto: 'udp', icon: 'wifi', risk: 'low' },
];
/* Demo seed mirroring design-handoff/.../MazeNET.jsx INITIAL_* */
export const DEMO_NETS: Net[] = [
{ id: 'net-internet', label: 'INTERNET', cidr: '0.0.0.0/0', kind: 'internet', x: 40, y: 40, w: 240, h: 220 },
{ id: 'net-dmz', label: 'DMZ', cidr: '10.4.2.0/24', kind: 'subnet', x: 340, y: 40, w: 340, h: 260 },
{ id: 'net-corp', label: 'CORP-LAN', cidr: '10.20.0.0/16', kind: 'subnet', x: 340, y: 340, w: 340, h: 240 },
{ id: 'net-vault', label: 'DB-VAULT', cidr: '10.88.1.0/24', kind: 'subnet', x: 740, y: 200, w: 260, h: 220 },
];
export const DEMO_NODES: MazeNode[] = [
{ id: 'n-scan', kind: 'observed', netId: 'net-internet', name: 'SCANNERS', archetype: 'attacker-pool', services: ['*'], status: 'hot', x: 60, y: 80 },
{ id: 'n-edge', kind: 'decky', netId: 'net-dmz', name: 'decky-01', archetype: 'linux-server', services: ['ssh', 'http'], status: 'active', x: 20, y: 60 },
{ id: 'n-jump', kind: 'decky', netId: 'net-dmz', name: 'decky-03', archetype: 'linux-server', services: ['ssh'], status: 'hot', x: 180, y: 60 },
{ id: 'n-web', kind: 'decky', netId: 'net-dmz', name: 'decky-07', archetype: 'web-application', services: ['http'], status: 'active', x: 20, y: 160 },
{ id: 'n-ws', kind: 'decky', netId: 'net-corp', name: 'decky-02', archetype: 'windows-workstation', services: ['smb', 'rdp'], status: 'active', x: 20, y: 60 },
{ id: 'n-dc', kind: 'decky', netId: 'net-corp', name: 'decky-05', archetype: 'domain-controller', services: ['ldap', 'smb'], status: 'active', x: 180, y: 60 },
{ id: 'n-db', kind: 'decky', netId: 'net-vault', name: 'decky-12', archetype: 'database-server', services: ['mysql', 'postgres'], status: 'active', x: 50, y: 80 },
];
export const DEMO_EDGES: Edge[] = [
{ id: 'e1', from: 'n-scan', to: 'n-edge', traffic: 'hot', label: 'TCP 443' },
{ id: 'e2', from: 'n-scan', to: 'n-jump', traffic: 'hot', label: 'TCP 22' },
{ id: 'e3', from: 'n-edge', to: 'n-ws', traffic: 'active', label: '' },
{ id: 'e4', from: 'n-jump', to: 'n-dc', traffic: 'hot', label: 'LAT-MOV' },
{ id: 'e5', from: 'n-dc', to: 'n-db', traffic: 'active', label: '' },
{ id: 'e6', from: 'n-web', to: 'n-db', traffic: 'active', label: 'SQL' },
];

View File

@@ -47,14 +47,3 @@ export interface Edge {
label?: string;
}
/* ── Pending changes — mirrors Phase-3 MutationEnqueueRequest.op ── */
export type PendingChange =
| { op: 'add_lan'; payload: { id: string; label: string; cidr: string; x: number; y: number; w: number; h: number } }
| { op: 'remove_lan'; payload: { id: string } }
| { op: 'update_lan'; payload: { id: string; patch: Partial<Net> } }
| { op: 'attach_decky'; payload: { nodeId: string; netId: string; archetype: string; name: string; x: number; y: number; services: string[] } }
| { op: 'detach_decky'; payload: { nodeId: string; netId: string } }
| { op: 'remove_decky'; payload: { nodeId: string } }
| { op: 'update_decky'; payload: { nodeId: string; patch: Partial<DeckyNode> } }
| { op: 'add_edge'; payload: { id: string; from: string; to: string } }
| { op: 'remove_edge'; payload: { id: string } };

View File

@@ -89,8 +89,22 @@ export function adaptTopology(detail: TopologyDetail): HydratedTopology {
// Home LAN = first edge; a multi-homed gateway is drawn inside its
// home LAN, membership in others is expressed via the edge list.
// Gateways (forwards_l3) MUST render inside a DMZ — auto-bridge adds
// subnet edges after the original DMZ edge, but edge ordering from the
// backend is not guaranteed, so we pick DMZ explicitly for gateways.
const dmzIds = new Set(detail.lans.filter((l) => l.is_dmz).map((l) => l.id));
const gatewayUuids = new Set(
detail.edges.filter((e) => e.forwards_l3).map((e) => e.decky_uuid),
);
const firstLanFor = new Map<string, string>();
for (const e of detail.edges) {
if (gatewayUuids.has(e.decky_uuid)) {
// Only accept a DMZ edge as home for a gateway.
if (dmzIds.has(e.lan_id) && !firstLanFor.has(e.decky_uuid)) {
firstLanFor.set(e.decky_uuid, e.lan_id);
}
continue;
}
if (!firstLanFor.has(e.decky_uuid)) firstLanFor.set(e.decky_uuid, e.lan_id);
}