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 React, { forwardRef, useMemo } from 'react';
|
||||||
|
import { RotateCcw, LayoutGrid } from 'lucide-react';
|
||||||
import NetBox from './NetBox';
|
import NetBox from './NetBox';
|
||||||
import NodeCard from './NodeCard';
|
import NodeCard from './NodeCard';
|
||||||
import type { Net, MazeNode, Edge } from './types';
|
import type { Net, MazeNode, Edge } from './types';
|
||||||
@@ -25,15 +26,23 @@ interface Props {
|
|||||||
onNetContextMenu?: (id: string) => (e: React.MouseEvent) => void;
|
onNetContextMenu?: (id: string) => (e: React.MouseEvent) => void;
|
||||||
onEdgeContextMenu?: (id: string) => (e: React.MouseEvent) => void;
|
onEdgeContextMenu?: (id: string) => (e: React.MouseEvent) => void;
|
||||||
onCanvasContextMenu?: (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_W = 140;
|
||||||
const NODE_HEAD_H = 22;
|
const NODE_HEAD_H = 22;
|
||||||
|
|
||||||
const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
|
const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
|
||||||
{ nets, nodes, edges, deployed, selection, setSelection, pan, dropTargetId, dragging, edgeDraw,
|
{ nets, nodes, edges, deployed, selection, setSelection, pan, dropTargetId, dragging, edgeDraw,
|
||||||
onCanvasMouseDown, onNodeMouseDown, onNetMouseDown, onNetResizeMouseDown, onPortMouseDown,
|
onCanvasMouseDown, onNodeMouseDown, onNetMouseDown, onNetResizeMouseDown, onPortMouseDown,
|
||||||
onNodeContextMenu, onNetContextMenu, onEdgeContextMenu, onCanvasContextMenu },
|
onNodeContextMenu, onNetContextMenu, onEdgeContextMenu, onCanvasContextMenu,
|
||||||
|
onResetView, onAutoLayout, sseConnected, lastEventAt },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const netById = useMemo(() => new Map(nets.map((n) => [n.id, n])), [nets]);
|
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>
|
||||||
</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="maze-legend">
|
||||||
<div className="lg-row"><span className="lg-swatch" style={{ background: '#ff4141' }} /> HOT</div>
|
<div className="lg-row"><span className="lg-swatch alert" /> ACTIVE ATTACK</div>
|
||||||
<div className="lg-row"><span className="lg-swatch" style={{ background: '#ee82ee' }} /> ACTIVE</div>
|
<div className="lg-row"><span className="lg-swatch violet" /> OBSERVED FLOW</div>
|
||||||
<div className="lg-row"><span className="lg-swatch" style={{ background: '#00ff41' }} /> IDLE</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -274,7 +274,30 @@
|
|||||||
display: flex; flex-direction: column; gap: 4px;
|
display: flex; flex-direction: column; gap: 4px;
|
||||||
}
|
}
|
||||||
.maze-legend .lg-row { display: flex; align-items: center; gap: 6px; }
|
.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 ──────────────────────────────── */
|
/* ── Inspector ──────────────────────────────── */
|
||||||
.maze-inspector {
|
.maze-inspector {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { useMazeInteraction, type PaletteDrag } from './useMazeInteraction';
|
|||||||
import { useLayoutPersistor } from './useMazeLayoutStore';
|
import { useLayoutPersistor } from './useMazeLayoutStore';
|
||||||
import { useTopologyStream, type TopologyStreamEvent } from './useTopologyStream';
|
import { useTopologyStream, type TopologyStreamEvent } from './useTopologyStream';
|
||||||
import { ARCHETYPES as DEFAULT_ARCHETYPES } from './data';
|
import { ARCHETYPES as DEFAULT_ARCHETYPES } from './data';
|
||||||
|
import { useToast } from '../Toasts/useToast';
|
||||||
|
|
||||||
/* Short unique suffix for default names — avoids the DB uniqueness
|
/* Short unique suffix for default names — avoids the DB uniqueness
|
||||||
* constraint regardless of delete/re-add sequencing on the client. */
|
* constraint regardless of delete/re-add sequencing on the client. */
|
||||||
@@ -33,6 +34,7 @@ const hex4 = (): string => {
|
|||||||
const MazeNET: React.FC = () => {
|
const MazeNET: React.FC = () => {
|
||||||
const api = useMazeApi();
|
const api = useMazeApi();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { push: pushToast } = useToast();
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
const topologyId = params.get('topology') ?? '';
|
const topologyId = params.get('topology') ?? '';
|
||||||
|
|
||||||
@@ -457,6 +459,7 @@ const MazeNET: React.FC = () => {
|
|||||||
* keepalives. On any state-transition event we refetch; DB is the
|
* keepalives. On any state-transition event we refetch; DB is the
|
||||||
* source of truth and the bus is at-most-once. */
|
* source of truth and the bus is at-most-once. */
|
||||||
const [streamLive, setStreamLive] = useState(false);
|
const [streamLive, setStreamLive] = useState(false);
|
||||||
|
const [lastEventAt, setLastEventAt] = useState<Date | null>(null);
|
||||||
const streamEnabled = topoStatus === 'active' || topoStatus === 'degraded';
|
const streamEnabled = topoStatus === 'active' || topoStatus === 'degraded';
|
||||||
const onStreamEvent = useCallback((event: TopologyStreamEvent) => {
|
const onStreamEvent = useCallback((event: TopologyStreamEvent) => {
|
||||||
// Flip LIVE only on named, purposeful events — not incidental keepalives.
|
// Flip LIVE only on named, purposeful events — not incidental keepalives.
|
||||||
@@ -464,6 +467,7 @@ const MazeNET: React.FC = () => {
|
|||||||
|| event.name.startsWith('mutation.')
|
|| event.name.startsWith('mutation.')
|
||||||
|| event.name === 'status') {
|
|| event.name === 'status') {
|
||||||
setStreamLive(true);
|
setStreamLive(true);
|
||||||
|
setLastEventAt(new Date());
|
||||||
}
|
}
|
||||||
if (event.name === 'mutation.failed') {
|
if (event.name === 'mutation.failed') {
|
||||||
const p = event.payload ?? {};
|
const p = event.payload ?? {};
|
||||||
@@ -529,7 +533,7 @@ const MazeNET: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="maze-page-actions">
|
<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
|
<ArrowLeft size={12} /> TOPOLOGIES
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="maze-btn ghost" onClick={() => setInspectorOpen((o) => !o)}>
|
<button type="button" className="maze-btn ghost" onClick={() => setInspectorOpen((o) => !o)}>
|
||||||
@@ -576,6 +580,10 @@ const MazeNET: React.FC = () => {
|
|||||||
onNetContextMenu={onNetContextMenu}
|
onNetContextMenu={onNetContextMenu}
|
||||||
onEdgeContextMenu={onEdgeContextMenu}
|
onEdgeContextMenu={onEdgeContextMenu}
|
||||||
onCanvasContextMenu={onCanvasContextMenu}
|
onCanvasContextMenu={onCanvasContextMenu}
|
||||||
|
onResetView={interaction.resetPan}
|
||||||
|
onAutoLayout={() => pushToast({ text: 'AUTO-LAYOUT COMING SOON', tone: 'violet', icon: 'info' })}
|
||||||
|
sseConnected={streamLive}
|
||||||
|
lastEventAt={lastEventAt}
|
||||||
/>
|
/>
|
||||||
{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)} />
|
||||||
|
|||||||
@@ -1,6 +1,22 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Server, Monitor, Shield, Database, Cpu, Globe, Users, HardDrive, Eye,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
import type { MazeNode } from './types';
|
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 {
|
interface Props {
|
||||||
node: MazeNode;
|
node: MazeNode;
|
||||||
absX: number;
|
absX: number;
|
||||||
@@ -38,7 +54,14 @@ const NodeCard: React.FC<Props> = ({ node, absX, absY, selected, dragging, deplo
|
|||||||
onMouseDown={handleDown}
|
onMouseDown={handleDown}
|
||||||
onContextMenu={onContextMenu?.(node.id)}
|
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>
|
<div className="mn-sub">{node.archetype.toUpperCase()}</div>
|
||||||
{node.services.length > 0 && (
|
{node.services.length > 0 && (
|
||||||
<div className="mn-services">
|
<div className="mn-services">
|
||||||
|
|||||||
Reference in New Issue
Block a user