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
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
This commit is contained in:
@@ -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"
|
||||
|
||||
286
server/web/src/components/Fleet/NetworkTopoMap.css
Normal file
286
server/web/src/components/Fleet/NetworkTopoMap.css
Normal 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;
|
||||
}
|
||||
518
server/web/src/components/Fleet/NetworkTopoMap.tsx
Normal file
518
server/web/src/components/Fleet/NetworkTopoMap.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user