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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user