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:
@@ -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}
|
||||
|
||||
@@ -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,24 +37,60 @@ 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
|
||||
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) return;
|
||||
if (hasSub) return;
|
||||
it.onClick?.();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{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 }}>
|
||||
{items.map((it, i) =>
|
||||
it.separator ? (
|
||||
<div key={i} className="ctx-divider" />
|
||||
) : (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className={`ctx-item ${it.danger ? 'danger' : ''}`}
|
||||
disabled={it.disabled}
|
||||
title={it.title}
|
||||
onClick={() => { if (!it.disabled) { it.onClick?.(); onClose(); } }}
|
||||
>
|
||||
{it.label}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
{title && <div className="ctx-title">{title}</div>}
|
||||
{items.map(renderItem)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
@@ -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 } };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user