diff --git a/decnet_web/src/components/MazeNET/Canvas.tsx b/decnet_web/src/components/MazeNET/Canvas.tsx index afd8516b..f01b7e60 100644 --- a/decnet_web/src/components/MazeNET/Canvas.tsx +++ b/decnet_web/src/components/MazeNET/Canvas.tsx @@ -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(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(function Canvas( + {(onResetView || onAutoLayout) && ( +
+ {onResetView && ( + + )} + {onAutoLayout && ( + + )} +
+ )} + +
+ + + GRAPH {sseConnected ? 'LIVE' : 'IDLE'} + + PAN: {Math.round(pan.x)},{Math.round(pan.y)} + AS-OF {lastEventAt ? fmtTime(lastEventAt) : '--:--:--'} +
+
-
HOT
-
ACTIVE
-
IDLE
+
ACTIVE ATTACK
+
OBSERVED FLOW
+
CONFIGURED
+
INACTIVE NET
); diff --git a/decnet_web/src/components/MazeNET/MazeNET.css b/decnet_web/src/components/MazeNET/MazeNET.css index 611585c0..a0ed2fa1 100644 --- a/decnet_web/src/components/MazeNET/MazeNET.css +++ b/decnet_web/src/components/MazeNET/MazeNET.css @@ -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 { diff --git a/decnet_web/src/components/MazeNET/MazeNET.tsx b/decnet_web/src/components/MazeNET/MazeNET.tsx index 6522cda1..0a9fd766 100644 --- a/decnet_web/src/components/MazeNET/MazeNET.tsx +++ b/decnet_web/src/components/MazeNET/MazeNET.tsx @@ -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(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 = () => {
-