feat(web): MazeNET 7a — canvas chrome + node-head visuals

Toolbar (RESET VIEW / AUTO-LAYOUT), status bar (GRAPH LIVE + pan + as-of
timestamp), 4-row legend, and archetype icon + status dot in each node
head.
This commit is contained in:
2026-04-22 15:54:11 -04:00
parent 91111ea7ee
commit 6fbac5d057
4 changed files with 95 additions and 7 deletions

View File

@@ -1,4 +1,5 @@
import React, { forwardRef, useMemo } from 'react';
import { RotateCcw, LayoutGrid } from 'lucide-react';
import NetBox from './NetBox';
import NodeCard from './NodeCard';
import type { Net, MazeNode, Edge } from './types';
@@ -25,15 +26,23 @@ interface Props {
onNetContextMenu?: (id: string) => (e: React.MouseEvent) => void;
onEdgeContextMenu?: (id: string) => (e: React.MouseEvent) => void;
onCanvasContextMenu?: (e: React.MouseEvent) => void;
onResetView?: () => void;
onAutoLayout?: () => void;
sseConnected?: boolean;
lastEventAt?: Date | null;
}
const fmtTime = (d: Date) =>
`${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`;
const NODE_W = 140;
const NODE_HEAD_H = 22;
const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
{ nets, nodes, edges, deployed, selection, setSelection, pan, dropTargetId, dragging, edgeDraw,
onCanvasMouseDown, onNodeMouseDown, onNetMouseDown, onNetResizeMouseDown, onPortMouseDown,
onNodeContextMenu, onNetContextMenu, onEdgeContextMenu, onCanvasContextMenu },
onNodeContextMenu, onNetContextMenu, onEdgeContextMenu, onCanvasContextMenu,
onResetView, onAutoLayout, sseConnected, lastEventAt },
ref,
) {
const netById = useMemo(() => new Map(nets.map((n) => [n.id, n])), [nets]);
@@ -169,10 +178,35 @@ const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
</div>
</div>
{(onResetView || onAutoLayout) && (
<div className="maze-toolbar">
{onResetView && (
<button type="button" className="maze-btn ghost small" onClick={onResetView} title="Reset pan to origin">
<RotateCcw size={11} /> RESET VIEW
</button>
)}
{onAutoLayout && (
<button type="button" className="maze-btn ghost small" onClick={onAutoLayout} title="Auto-layout nodes">
<LayoutGrid size={11} /> AUTO-LAYOUT
</button>
)}
</div>
)}
<div className="maze-status">
<span className={`status-seg ${sseConnected ? 'live' : 'dim'}`}>
<span className={`status-dot ${sseConnected ? 'active' : 'idle'}`} />
GRAPH {sseConnected ? 'LIVE' : 'IDLE'}
</span>
<span className="status-seg">PAN: {Math.round(pan.x)},{Math.round(pan.y)}</span>
<span className="status-seg">AS-OF {lastEventAt ? fmtTime(lastEventAt) : '--:--:--'}</span>
</div>
<div className="maze-legend">
<div className="lg-row"><span className="lg-swatch" style={{ background: '#ff4141' }} /> HOT</div>
<div className="lg-row"><span className="lg-swatch" style={{ background: '#ee82ee' }} /> ACTIVE</div>
<div className="lg-row"><span className="lg-swatch" style={{ background: '#00ff41' }} /> IDLE</div>
<div className="lg-row"><span className="lg-swatch alert" /> ACTIVE ATTACK</div>
<div className="lg-row"><span className="lg-swatch violet" /> OBSERVED FLOW</div>
<div className="lg-row"><span className="lg-swatch matrix" /> CONFIGURED</div>
<div className="lg-row"><span className="lg-swatch inactive" /> INACTIVE NET</div>
</div>
</div>
);

View File

@@ -274,7 +274,30 @@
display: flex; flex-direction: column; gap: 4px;
}
.maze-legend .lg-row { display: flex; align-items: center; gap: 6px; }
.maze-legend .lg-swatch { width: 14px; height: 2px; }
.maze-legend .lg-swatch { width: 14px; height: 2px; background: var(--matrix); }
.maze-legend .lg-swatch.alert { background: var(--alert); box-shadow: 0 0 6px var(--alert); }
.maze-legend .lg-swatch.violet { background: var(--violet); box-shadow: 0 0 6px var(--violet); }
.maze-legend .lg-swatch.matrix { background: var(--matrix); }
.maze-legend .lg-swatch.inactive {
background: transparent;
height: 0;
border-top: 1px dashed var(--border);
}
/* Status bar segments */
.maze-status .status-seg { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
.maze-status .status-seg.live { color: var(--matrix); opacity: 0.9; }
.maze-status .status-seg.dim { opacity: 0.45; }
/* Toolbar button sizing override */
.maze-toolbar .maze-btn.small {
padding: 4px 10px; font-size: 0.62rem; letter-spacing: 1.5px;
background: rgba(0, 0, 0, 0.6);
}
/* NodeCard head icon alignment */
.maze-node .mn-head .mn-head-icon { opacity: 0.8; flex-shrink: 0; }
.maze-node .mn-head .mn-head-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ── Inspector ──────────────────────────────── */
.maze-inspector {

View File

@@ -20,6 +20,7 @@ import { useMazeInteraction, type PaletteDrag } from './useMazeInteraction';
import { useLayoutPersistor } from './useMazeLayoutStore';
import { useTopologyStream, type TopologyStreamEvent } from './useTopologyStream';
import { ARCHETYPES as DEFAULT_ARCHETYPES } from './data';
import { useToast } from '../Toasts/useToast';
/* Short unique suffix for default names — avoids the DB uniqueness
* constraint regardless of delete/re-add sequencing on the client. */
@@ -33,6 +34,7 @@ const hex4 = (): string => {
const MazeNET: React.FC = () => {
const api = useMazeApi();
const navigate = useNavigate();
const { push: pushToast } = useToast();
const [params] = useSearchParams();
const topologyId = params.get('topology') ?? '';
@@ -457,6 +459,7 @@ const MazeNET: React.FC = () => {
* keepalives. On any state-transition event we refetch; DB is the
* source of truth and the bus is at-most-once. */
const [streamLive, setStreamLive] = useState(false);
const [lastEventAt, setLastEventAt] = useState<Date | null>(null);
const streamEnabled = topoStatus === 'active' || topoStatus === 'degraded';
const onStreamEvent = useCallback((event: TopologyStreamEvent) => {
// Flip LIVE only on named, purposeful events — not incidental keepalives.
@@ -464,6 +467,7 @@ const MazeNET: React.FC = () => {
|| event.name.startsWith('mutation.')
|| event.name === 'status') {
setStreamLive(true);
setLastEventAt(new Date());
}
if (event.name === 'mutation.failed') {
const p = event.payload ?? {};
@@ -529,7 +533,7 @@ const MazeNET: React.FC = () => {
</div>
</div>
<div className="maze-page-actions">
<button type="button" className="maze-btn ghost" onClick={() => navigate('/topologies')}>
<button type="button" className="maze-btn ghost" onClick={() => navigate('/mazenet')}>
<ArrowLeft size={12} /> TOPOLOGIES
</button>
<button type="button" className="maze-btn ghost" onClick={() => setInspectorOpen((o) => !o)}>
@@ -576,6 +580,10 @@ const MazeNET: React.FC = () => {
onNetContextMenu={onNetContextMenu}
onEdgeContextMenu={onEdgeContextMenu}
onCanvasContextMenu={onCanvasContextMenu}
onResetView={interaction.resetPan}
onAutoLayout={() => pushToast({ text: 'AUTO-LAYOUT COMING SOON', tone: 'violet', icon: 'info' })}
sseConnected={streamLive}
lastEventAt={lastEventAt}
/>
{ctxMenu && (
<ContextMenu x={ctxMenu.x} y={ctxMenu.y} items={ctxMenu.items} onClose={() => setCtxMenu(null)} />

View File

@@ -1,6 +1,22 @@
import React from 'react';
import {
Server, Monitor, Shield, Database, Cpu, Globe, Users, HardDrive, Eye,
type LucideIcon,
} from 'lucide-react';
import type { MazeNode } from './types';
const ARCHETYPE_ICONS: Record<string, LucideIcon> = {
'linux-server': Server,
'windows-workstation': Monitor,
'domain-controller': Shield,
'database-server': Database,
'iot-device': Cpu,
'web-application': Globe,
'deaddeck': HardDrive,
'attacker-pool': Eye,
'directory-services': Users,
};
interface Props {
node: MazeNode;
absX: number;
@@ -38,7 +54,14 @@ const NodeCard: React.FC<Props> = ({ node, absX, absY, selected, dragging, deplo
onMouseDown={handleDown}
onContextMenu={onContextMenu?.(node.id)}
>
<div className="mn-head">{node.name}</div>
<div className="mn-head">
<span className={`status-dot ${node.status}`} />
{(() => {
const Icon = ARCHETYPE_ICONS[node.archetype] ?? Server;
return <Icon size={10} className="mn-head-icon" />;
})()}
<span className="mn-head-name">{node.name}</span>
</div>
<div className="mn-sub">{node.archetype.toUpperCase()}</div>
{node.services.length > 0 && (
<div className="mn-services">