Add phenotype cloning, failure atlas, AI court session, and clearance L0-L4
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled

This commit is contained in:
AetherForge
2026-06-07 02:41:54 -07:00
parent 4b94776432
commit d9f36f182c
47 changed files with 4859 additions and 94 deletions

View File

@@ -10,7 +10,9 @@ import {
layoutAgentPoints,
layoutComradePoints,
} from '../../help/fleetHeatMap';
import NetworkTopoMap from './NetworkTopoMap';
import './FleetHeatMiniMap.css';
import './NetworkTopoMap.css';
interface FleetHeatMiniMapProps {
agents: Agent[];
@@ -30,6 +32,7 @@ export default function FleetHeatMiniMap({
const { comrades } = usePresence();
const prevHashrateRef = useRef<Record<string, number>>({});
const [spikingIds, setSpikingIds] = useState<Set<string>>(() => new Set());
const [view, setView] = useState<'heat' | 'topo'>('heat');
useEffect(() => {
const spikes = new Set<string>();
@@ -58,14 +61,46 @@ export default function FleetHeatMiniMap({
<div className="fleet-heat-minimap">
<div className="fleet-heat-header font-tech">
<span className="section-ornament"></span>
FLEET HEAT
{view === 'heat' ? 'FLEET HEAT' : 'NETWORK MAP'}
<span className="fleet-heat-count">
{onlineCount}/{agents.length}
</span>
{/* HEAT / TOPO view toggle */}
<span style={{ marginLeft: 'auto', display: 'flex', gap: '0.2rem' }}>
{(['heat', 'topo'] as const).map((v) => (
<button
key={v}
type="button"
onClick={() => setView(v)}
style={{
padding: '0.1rem 0.4rem',
fontSize: '0.55rem',
fontFamily: 'inherit',
letterSpacing: '0.06em',
background: view === v ? 'rgba(0,232,245,0.15)' : 'none',
border: `1px solid ${view === v ? 'rgba(0,232,245,0.5)' : 'rgba(0,232,245,0.18)'}`,
borderRadius: '3px',
color: view === v ? 'var(--neon-cyan)' : 'var(--text-muted)',
cursor: 'pointer',
transition: 'all 0.15s',
}}
>
{v.toUpperCase()}
</button>
))}
</span>
</div>
{agents.length === 0 ? (
<p className="fleet-heat-empty">No nodes yet deploy a build to see the map.</p>
) : view === 'topo' ? (
<NetworkTopoMap
agents={agents}
groups={groups}
allIds={allIds}
selectedIds={selectedIds}
onSelectAgent={onSelectAgent}
/>
) : (
<div
className="fleet-heat-canvas"

View File

@@ -0,0 +1,286 @@
/* ── Network Topology Map ────────────────────────────────────────────────── */
/* Container */
.net-topo-wrap {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
}
/* SVG canvas */
.net-topo-svg {
width: 100%;
aspect-ratio: 1;
min-height: 200px;
border-radius: 8px;
border: 1px solid rgba(0, 232, 245, 0.18);
background:
radial-gradient(ellipse at 50% 45%, rgba(0, 232, 245, 0.06) 0%, transparent 70%),
rgba(0, 0, 0, 0.5);
display: block;
overflow: visible;
}
/* ── Subnet bubbles ──────────────────────────────────────────────────────── */
.topo-subnet-bubble {
fill: transparent;
stroke-width: 0.6;
opacity: 0.85;
}
.topo-subnet-label {
font-size: 2.2px;
font-family: 'Share Tech Mono', 'Courier New', monospace;
fill-opacity: 0.6;
letter-spacing: 0.15px;
pointer-events: none;
user-select: none;
}
/* ── Edges ───────────────────────────────────────────────────────────────── */
.topo-edge {
stroke-linecap: round;
pointer-events: none;
transition: opacity 0.2s, stroke-width 0.2s;
}
.topo-edge--subnet {
stroke-opacity: 0.18;
stroke-width: 0.3;
}
.topo-edge--subnet.active {
stroke-opacity: 0.55;
stroke-width: 0.45;
}
/* SMB / WinRM / spread edges — animated dash */
.topo-edge--spread,
.topo-edge--smb,
.topo-edge--winrm,
.topo-edge--ssh,
.topo-edge--cross_subnet {
stroke-width: 0.4;
stroke-opacity: 0.15;
stroke-dasharray: 1.2 1.8;
animation: topo-dash 2.5s linear infinite;
}
.topo-edge--spread.active,
.topo-edge--smb.active,
.topo-edge--winrm.active,
.topo-edge--ssh.active,
.topo-edge--cross_subnet.active {
stroke-opacity: 0.85;
stroke-width: 0.65;
animation-duration: 1.2s;
}
@keyframes topo-dash {
to { stroke-dashoffset: -6; }
}
/* Edge colors */
.topo-edge--smb { stroke: #ffb020; }
.topo-edge--winrm { stroke: #b24bf3; }
.topo-edge--ssh { stroke: #39ff14; }
.topo-edge--spread { stroke: #00e8f5; }
.topo-edge--cross_subnet { stroke: #ff6b35; stroke-dasharray: 0.8 2.2; }
/* ── Nodes ───────────────────────────────────────────────────────────────── */
.topo-node-circle {
transition: r 0.15s, filter 0.15s;
cursor: pointer;
}
.topo-node-circle--online {
filter: drop-shadow(0 0 1.2px var(--node-color, #00e8f5));
}
.topo-node-circle--offline {
opacity: 0.28;
filter: none;
}
.topo-node-circle--selected {
r: 3.2;
filter:
drop-shadow(0 0 2px #fff)
drop-shadow(0 0 4px var(--node-color, #00e8f5));
animation: topo-node-pulse 1.4s ease-in-out infinite;
}
@keyframes topo-node-pulse {
0%, 100% { filter: drop-shadow(0 0 2px #fff) drop-shadow(0 0 4px var(--node-color)); }
50% { filter: drop-shadow(0 0 3.5px #fff) drop-shadow(0 0 7px var(--node-color)); }
}
.topo-node-ring {
fill: none;
stroke-width: 0.5;
opacity: 0.6;
pointer-events: none;
}
.topo-node-icon {
font-size: 2.8px;
text-anchor: middle;
dominant-baseline: central;
pointer-events: none;
user-select: none;
}
/* Hashrate spike flash */
.topo-node-spike {
animation: topo-spike 1.1s ease-out forwards;
}
@keyframes topo-spike {
0% { r: 2.2; opacity: 1; }
40% { r: 5.5; opacity: 0.7; }
100% { r: 2.2; opacity: 0; }
}
/* ── Tooltip ─────────────────────────────────────────────────────────────── */
.topo-tooltip {
position: fixed;
z-index: 9999;
pointer-events: none;
background: rgba(5, 8, 15, 0.96);
border: 1px solid rgba(0, 232, 245, 0.35);
border-radius: 8px;
padding: 0.55rem 0.75rem;
font-family: 'Share Tech Mono', 'Courier New', monospace;
font-size: 0.72rem;
color: #c8d8e8;
min-width: 160px;
box-shadow:
0 0 0 1px rgba(0, 232, 245, 0.08),
0 6px 24px rgba(0, 0, 0, 0.7),
0 0 18px rgba(0, 232, 245, 0.12);
backdrop-filter: blur(8px);
}
.topo-tooltip-name {
font-size: 0.78rem;
font-weight: 700;
color: var(--neon-cyan, #00e8f5);
margin-bottom: 0.3rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.topo-tooltip-row {
display: flex;
justify-content: space-between;
gap: 0.75rem;
color: var(--text-muted, #8899aa);
font-size: 0.66rem;
line-height: 1.5;
}
.topo-tooltip-row span:last-child {
color: #c8d8e8;
text-align: right;
}
.topo-tooltip-lane {
display: inline-block;
margin-top: 0.3rem;
padding: 0.1rem 0.4rem;
border-radius: 4px;
font-size: 0.62rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.topo-tooltip-lane--smb { background: rgba(255,176,32,0.18); color: #ffb020; border: 1px solid rgba(255,176,32,0.3); }
.topo-tooltip-lane--winrm { background: rgba(178,75,243,0.18); color: #b24bf3; border: 1px solid rgba(178,75,243,0.3); }
.topo-tooltip-lane--ssh { background: rgba(57,255,20,0.12); color: #39ff14; border: 1px solid rgba(57,255,20,0.25); }
.topo-tooltip-lane--spread { background: rgba(0,232,245,0.12); color: #00e8f5; border: 1px solid rgba(0,232,245,0.25); }
/* ── Legend ──────────────────────────────────────────────────────────────── */
.topo-legend {
display: flex;
flex-wrap: wrap;
gap: 0.35rem 0.6rem;
font-family: 'Share Tech Mono', 'Courier New', monospace;
font-size: 0.6rem;
color: var(--text-muted, #8899aa);
letter-spacing: 0.04em;
}
.topo-legend-item {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.topo-legend-line {
display: inline-block;
width: 14px;
height: 2px;
border-radius: 1px;
flex-shrink: 0;
}
.topo-legend-line--subnet { background: rgba(200,216,232,0.35); }
.topo-legend-line--smb { background: #ffb020; }
.topo-legend-line--winrm { background: #b24bf3; }
.topo-legend-line--ssh { background: #39ff14; }
.topo-legend-line--cross_subnet{ background: #ff6b35; }
/* ── Mode tabs ───────────────────────────────────────────────────────────── */
.topo-mode-tabs {
display: flex;
gap: 0.25rem;
}
.topo-mode-tab {
padding: 0.18rem 0.5rem;
font-size: 0.6rem;
font-family: 'Share Tech Mono', 'Courier New', monospace;
letter-spacing: 0.08em;
background: none;
border: 1px solid rgba(0, 232, 245, 0.18);
border-radius: 4px;
color: var(--text-muted, #8899aa);
cursor: pointer;
transition: all 0.15s;
}
.topo-mode-tab:hover {
border-color: rgba(0, 232, 245, 0.5);
color: #c8d8e8;
}
.topo-mode-tab.active {
background: rgba(0, 232, 245, 0.12);
border-color: rgba(0, 232, 245, 0.5);
color: var(--neon-cyan, #00e8f5);
}
/* ── Empty state ─────────────────────────────────────────────────────────── */
.topo-empty {
margin: 0;
font-size: 0.72rem;
color: var(--text-muted, #8899aa);
}
/* ── Segmentation wall ───────────────────────────────────────────────────── */
.topo-seg-wall {
stroke: rgba(255, 100, 50, 0.12);
stroke-width: 0.2;
stroke-dasharray: 0.5 1;
pointer-events: none;
}

View File

@@ -0,0 +1,518 @@
import { useMemo, useState, useRef, useCallback, useEffect } from 'react';
import type { Agent } from '../../types';
import type { FleetGroup } from '../../help/fleetGroups';
import { formatHashrate } from '../../help/fleetFilters';
import {
groupBySubnet,
layoutSubnets,
layoutNodes,
buildEdges,
type TopoNode,
type TopoEdge,
type SubnetLayout,
} from '../../help/networkTopology';
import { agentAccentColor } from '../../help/fleetHeatMap';
import './NetworkTopoMap.css';
// ── Types ──────────────────────────────────────────────────────────────────
export type TopoMode = 'subnet' | 'spread' | 'flat';
interface Props {
agents: Agent[];
groups: FleetGroup[];
allIds: string[];
selectedIds: Set<string>;
onSelectAgent: (id: string) => void;
mode?: TopoMode;
}
// ── Platform icon helper ───────────────────────────────────────────────────
function platformIcon(platform: string): string {
const p = platform.toLowerCase();
if (p.includes('win')) return '⊞';
if (p.includes('linux')) return '🐧';
if (p.includes('darwin')) return '';
return '⬡';
}
// ── Hashrate spike tracking ────────────────────────────────────────────────
const SPIKE_RATIO = 1.3;
const SPIKE_MIN = 50;
function detectSpikes(
agents: Agent[],
prev: Record<string, number>,
): { spikes: Set<string>; next: Record<string, number> } {
const spikes = new Set<string>();
const next: Record<string, number> = {};
for (const a of agents) {
const cur = a.hashrate_15s ?? 0;
const p = prev[a.id];
if (cur > 0 && (p === undefined || p <= 0 ? cur >= SPIKE_MIN : cur - p >= SPIKE_MIN && cur >= p * SPIKE_RATIO)) {
spikes.add(a.id);
}
next[a.id] = cur;
}
return { spikes, next };
}
// ── Tooltip component ──────────────────────────────────────────────────────
interface TooltipState {
node: TopoNode;
x: number;
y: number;
}
function NodeTooltip({ tip }: { tip: TooltipState }) {
const { node, x, y } = tip;
const laneClass = node.joinLane ? `topo-tooltip-lane--${node.joinLane}` : 'topo-tooltip-lane--spread';
// Clamp tooltip to viewport
const tipW = 175;
const tipH = 130;
const left = Math.min(x + 12, window.innerWidth - tipW - 8);
const top = Math.min(y + 12, window.innerHeight - tipH - 8);
return (
<div
className="topo-tooltip"
style={{ left, top }}
>
<div className="topo-tooltip-name">
{platformIcon(node.platform)} {node.label}
</div>
<div className="topo-tooltip-row">
<span>IP</span><span>{node.ip}</span>
</div>
<div className="topo-tooltip-row">
<span>Subnet</span><span>{node.subnet === 'unknown' ? '—' : node.subnet.replace('.0/24', '.x')}</span>
</div>
<div className="topo-tooltip-row">
<span>Status</span>
<span style={{ color: node.online ? '#39ff14' : '#ff4444' }}>
{node.online ? 'ONLINE' : 'OFFLINE'}
</span>
</div>
{node.hashrate > 0 && (
<div className="topo-tooltip-row">
<span>Hashrate</span><span>{formatHashrate(node.hashrate)}</span>
</div>
)}
{node.latencyMs !== undefined && node.online && (
<div className="topo-tooltip-row">
<span>Latency</span><span>{node.latencyMs}ms</span>
</div>
)}
{node.joinLane && (
<div>
<span className={`topo-tooltip-lane ${laneClass}`}>
{node.joinLane.toUpperCase()} lane
</span>
</div>
)}
{node.canSpread && (
<div>
<span className="topo-tooltip-lane topo-tooltip-lane--spread">
SPREAD CAPABLE
</span>
</div>
)}
</div>
);
}
// ── Subnet bubble ──────────────────────────────────────────────────────────
function SubnetBubble({ layout }: { layout: SubnetLayout }) {
return (
<>
{/* Outer glow ring */}
<circle
cx={layout.cx}
cy={layout.cy}
r={layout.r + 1.5}
fill="none"
stroke={layout.color}
strokeWidth={0.3}
strokeOpacity={0.12}
/>
{/* Main bubble */}
<circle
className="topo-subnet-bubble"
cx={layout.cx}
cy={layout.cy}
r={layout.r}
stroke={layout.color}
strokeDasharray="2 1.5"
/>
{/* Fill */}
<circle
cx={layout.cx}
cy={layout.cy}
r={layout.r}
fill={layout.color}
fillOpacity={0.03}
pointerEvents="none"
/>
{/* Label */}
<text
className="topo-subnet-label"
x={layout.cx}
y={layout.cy - layout.r + 2.8}
textAnchor="middle"
fill={layout.color}
>
{layout.label}
</text>
</>
);
}
// ── Edge ───────────────────────────────────────────────────────────────────
function TopoEdgeEl({
edge,
nodeMap,
mode,
}: {
edge: TopoEdge;
nodeMap: Map<string, TopoNode>;
mode: TopoMode;
}) {
const src = nodeMap.get(edge.sourceId);
const tgt = nodeMap.get(edge.targetId);
if (!src || !tgt) return null;
// In spread mode, only show spread/protocol edges
if (mode === 'spread' && edge.kind === 'subnet') return null;
// In subnet mode, still show spread edges but dimmer unless active
const opacity = mode === 'flat' ? 0.1 : undefined;
const colorMap: Record<string, string> = {
subnet: 'rgba(200,216,232,0.25)',
spread: '#00e8f5',
smb: '#ffb020',
winrm: '#b24bf3',
ssh: '#39ff14',
cross_subnet: '#ff6b35',
};
return (
<line
className={`topo-edge topo-edge--${edge.kind} ${edge.active ? 'active' : ''}`}
x1={src.x}
y1={src.y}
x2={tgt.x}
y2={tgt.y}
stroke={colorMap[edge.kind] ?? '#00e8f5'}
style={opacity !== undefined ? { opacity } : undefined}
/>
);
}
// ── Node ───────────────────────────────────────────────────────────────────
function TopoNodeEl({
node,
selected,
spiking,
color,
onHover,
onLeave,
onClick,
}: {
node: TopoNode;
selected: boolean;
spiking: boolean;
color: string;
onHover: (node: TopoNode, e: React.MouseEvent) => void;
onLeave: () => void;
onClick: (id: string) => void;
}) {
const r = selected ? 3.0 : 2.1;
const statusCls = node.online
? selected ? 'topo-node-circle--online topo-node-circle--selected' : 'topo-node-circle--online'
: 'topo-node-circle--offline';
return (
<g
style={{ cursor: 'pointer' }}
onClick={() => onClick(node.id)}
onMouseEnter={(e) => onHover(node, e)}
onMouseLeave={onLeave}
>
{/* Spike flash ring */}
{spiking && (
<circle
cx={node.x}
cy={node.y}
r={r}
fill={color}
fillOpacity={0.5}
className="topo-node-spike"
style={{ '--node-color': color } as React.CSSProperties}
/>
)}
{/* Selection ring */}
{selected && (
<circle
cx={node.x}
cy={node.y}
r={r + 1.6}
className="topo-node-ring"
stroke={color}
/>
)}
{/* Spread-capable indicator ring */}
{node.canSpread && !selected && node.online && (
<circle
cx={node.x}
cy={node.y}
r={r + 0.8}
fill="none"
stroke={color}
strokeWidth={0.25}
strokeOpacity={0.4}
strokeDasharray="0.6 0.6"
/>
)}
{/* Main dot */}
<circle
cx={node.x}
cy={node.y}
r={r}
fill={color}
className={`topo-node-circle ${statusCls}`}
style={{ '--node-color': color } as React.CSSProperties}
/>
{/* Platform icon — only rendered at reasonable sizes */}
<text
x={node.x}
y={node.y}
className="topo-node-icon"
fillOpacity={node.online ? 0.9 : 0.4}
style={{ fontSize: selected ? '3.2px' : '2.6px', fill: '#000' }}
>
{node.platform.includes('win') ? '⊞' : node.platform.includes('linux') ? '⬡' : node.platform.includes('darwin') ? '◉' : '·'}
</text>
</g>
);
}
// ── Grid / background ──────────────────────────────────────────────────────
function TopoGrid() {
return (
<g pointerEvents="none">
{/* Horizontal lines */}
{[20, 40, 60, 80].map((y) => (
<line key={`h${y}`} x1={0} y1={y} x2={100} y2={y}
stroke="rgba(0,232,245,0.04)" strokeWidth={0.3} />
))}
{/* Vertical lines */}
{[20, 40, 60, 80].map((x) => (
<line key={`v${x}`} x1={x} y1={0} x2={x} y2={100}
stroke="rgba(0,232,245,0.04)" strokeWidth={0.3} />
))}
</g>
);
}
// ── Legend ─────────────────────────────────────────────────────────────────
function TopoLegend({ hasSpread }: { hasSpread: boolean }) {
return (
<div className="topo-legend">
<span className="topo-legend-item">
<span className="topo-legend-line topo-legend-line--subnet" />
Reachable
</span>
{hasSpread && (
<>
<span className="topo-legend-item">
<span className="topo-legend-line topo-legend-line--smb" />
SMB
</span>
<span className="topo-legend-item">
<span className="topo-legend-line topo-legend-line--winrm" />
WinRM
</span>
<span className="topo-legend-item">
<span className="topo-legend-line topo-legend-line--ssh" />
SSH
</span>
<span className="topo-legend-item">
<span className="topo-legend-line topo-legend-line--cross_subnet" />
Cross-subnet
</span>
</>
)}
</div>
);
}
// ── Main component ─────────────────────────────────────────────────────────
export default function NetworkTopoMap({
agents,
groups: _groups,
allIds,
selectedIds,
onSelectAgent,
mode: externalMode,
}: Props) {
const [mode, setMode] = useState<TopoMode>(externalMode ?? 'subnet');
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
const [spikingIds, setSpikingIds] = useState<Set<string>>(new Set());
const prevHashRef = useRef<Record<string, number>>({});
const svgRef = useRef<SVGSVGElement>(null);
// Spike detection
useEffect(() => {
const { spikes, next } = detectSpikes(agents, prevHashRef.current);
prevHashRef.current = next;
if (spikes.size === 0) return;
setSpikingIds(spikes);
const t = setTimeout(() => setSpikingIds(new Set()), 1300);
return () => clearTimeout(t);
}, [agents]);
// Layout
const subnetGroups = useMemo(() => groupBySubnet(agents), [agents]);
const subnets = useMemo(() => [...subnetGroups.keys()], [subnetGroups]);
const subnetLayouts = useMemo(() => layoutSubnets(subnets), [subnets]);
const nodes = useMemo(() => layoutNodes(agents, subnetLayouts), [agents, subnetLayouts]);
const edges = useMemo(() => buildEdges(agents, selectedIds), [agents, selectedIds]);
const nodeMap = useMemo(() => new Map(nodes.map((n) => [n.id, n])), [nodes]);
// Stats for legend
const hasSpread = edges.some((e) => e.kind !== 'subnet');
const onlineCount = agents.filter((a) => a.status === 'online').length;
const handleHover = useCallback((node: TopoNode, e: React.MouseEvent) => {
setTooltip({ node, x: e.clientX, y: e.clientY });
}, []);
const handleMove = useCallback((e: React.MouseEvent) => {
setTooltip((prev) => prev ? { ...prev, x: e.clientX, y: e.clientY } : null);
}, []);
const handleLeave = useCallback(() => setTooltip(null), []);
// In flat mode — use same positions as heat map (hashPosition fallback)
// In subnet/spread mode — use subnet-ring layout
if (agents.length === 0) {
return (
<div className="net-topo-wrap">
<p className="topo-empty">No nodes deploy a build to see topology.</p>
</div>
);
}
return (
<div className="net-topo-wrap">
{/* Mode tabs */}
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
<span style={{ fontSize: '0.6rem', color: 'var(--text-muted)', fontFamily: 'monospace', letterSpacing: '0.08em', marginRight: '0.15rem' }}>
{onlineCount}/{agents.length}
</span>
<div className="topo-mode-tabs">
{(['subnet', 'spread', 'flat'] as TopoMode[]).map((m) => (
<button
key={m}
type="button"
className={`topo-mode-tab ${mode === m ? 'active' : ''}`}
onClick={() => setMode(m)}
>
{m === 'subnet' ? 'SUBNETS' : m === 'spread' ? 'SPREAD' : 'FLAT'}
</button>
))}
</div>
</div>
{/* SVG canvas */}
<svg
ref={svgRef}
className="net-topo-svg"
viewBox="0 0 100 100"
preserveAspectRatio="xMidYMid meet"
onMouseMove={handleMove}
onMouseLeave={handleLeave}
aria-label={`Network topology map — ${agents.length} nodes across ${subnets.length} subnets`}
>
<TopoGrid />
{/* Subnet bubbles — only in subnet/spread mode */}
{mode !== 'flat' && subnetLayouts.map((sl) => (
<SubnetBubble key={sl.subnet} layout={sl} />
))}
{/* Edges — drawn below nodes */}
{edges.map((edge) => (
<TopoEdgeEl
key={edge.id}
edge={edge}
nodeMap={nodeMap}
mode={mode}
/>
))}
{/* Nodes */}
{nodes.map((node) => {
const color = agentAccentColor(node.id, allIds);
return (
<TopoNodeEl
key={node.id}
node={node}
selected={selectedIds.has(node.id)}
spiking={spikingIds.has(node.id)}
color={color}
onHover={handleHover}
onLeave={handleLeave}
onClick={onSelectAgent}
/>
);
})}
{/* Node name labels for selected nodes */}
{nodes
.filter((n) => selectedIds.has(n.id))
.map((node) => {
const color = agentAccentColor(node.id, allIds);
return (
<text
key={`label-${node.id}`}
x={node.x}
y={node.y - 4.5}
textAnchor="middle"
fill={color}
fontSize="2px"
fontFamily="'Share Tech Mono', monospace"
fontWeight={700}
pointerEvents="none"
style={{ letterSpacing: '0.05px' }}
>
{node.label.slice(0, 18)}{node.label.length > 18 ? '…' : ''}
</text>
);
})
}
</svg>
{/* Legend */}
<TopoLegend hasSpread={hasSpread} />
{/* Tooltip portal */}
{tooltip && <NodeTooltip tip={tooltip} />}
</div>
);
}

View File

@@ -36,12 +36,16 @@ function operatorDeckId(pathname: string): string {
if (path.startsWith('/settings')) return 'settings';
if (path.startsWith('/pathtracer')) return 'pathtracer';
if (path.startsWith('/lotl-timeline') || path.startsWith('/onion')) return 'lotl-timeline';
if (path.startsWith('/roi')) return 'roi';
if (path.startsWith('/activity')) return 'activity';
return 'dashboard';
}
const NAV = [
{ to: '/dashboard', label: 'Command Deck', icon: 'deck' },
{ to: '/crucible', label: 'Crucible', icon: 'crucible' },
{ to: '/activity', label: 'Activity Feed', icon: 'activity' },
{ to: '/roi', label: 'ROI Intelligence', icon: 'roi' },
{ to: '/lotl-timeline', label: 'Onion', icon: 'onion' },
{ to: '/pathtracer', label: 'Path Tracer', icon: 'trace' },
{ to: '/forge', label: 'Forge', icon: 'forge' },
@@ -67,6 +71,20 @@ function NavIcon({ type }: { type: string }) {
<circle cx="12" cy="14" r="2" />
</svg>
);
case 'roi':
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M3 17l4-6 4 3 4-7 4 4" />
<path d="M3 20h18" strokeOpacity="0.4" />
<circle cx="19" cy="11" r="1.5" fill="currentColor" strokeWidth="0" />
</svg>
);
case 'activity':
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<polyline points="2,12 6,12 8,5 10,19 12,9 14,15 16,12 22,12" />
</svg>
);
case 'fleet':
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">