/** * Network Topology Map — pure logic helpers. * * No React, no DOM — fully unit-testable. * All data is derived from the existing Agent type; no backend changes needed. */ import type { Agent } from '../types'; // ── Subnet parsing ───────────────────────────────────────────────────────── /** * Extract the /24 subnet string from an IPv4 address. * "192.168.1.42" → "192.168.1.0/24" * Returns "unknown" when the IP is missing or non-IPv4. */ export function parseSubnet(ip: string | undefined): string { if (!ip) return 'unknown'; const parts = ip.split('.'); if (parts.length !== 4 || parts.some((p) => isNaN(Number(p)))) return 'unknown'; return `${parts[0]}.${parts[1]}.${parts[2]}.0/24`; } /** Human-friendly label: "192.168.1.x" */ export function subnetLabel(subnet: string): string { if (subnet === 'unknown') return 'Unknown'; return subnet.replace('.0/24', '.x'); } /** Group agents by their /24 subnet. */ export function groupBySubnet(agents: Agent[]): Map { const map = new Map(); for (const a of agents) { const s = parseSubnet(a.ip); if (!map.has(s)) map.set(s, []); map.get(s)!.push(a); } return map; } // ── Layout ───────────────────────────────────────────────────────────────── /** Stable hash from a string → 0..1 float. */ function stableHash(seed: string): number { let h = 2166136261 >>> 0; for (let i = 0; i < seed.length; i++) { h ^= seed.charCodeAt(i); h = Math.imul(h, 16777619) >>> 0; } return (h % 10000) / 10000; } /** Two independent stable floats for x/y from one seed. */ function stableXY(seed: string): { x: number; y: number } { return { x: stableHash(seed + ':x'), y: stableHash(seed + ':y'), }; } export interface SubnetLayout { subnet: string; label: string; cx: number; // center x, 0-100 viewBox cy: number; // center y, 0-100 viewBox r: number; // radius of the bubble ring color: string; } const SUBNET_PALETTE = [ '#00e8f5', // cyan '#b24bf3', // violet '#39ff14', // neon green '#ff2da6', // magenta '#ffb020', // amber '#ff6b35', // orange '#3a86ff', // blue '#06d6a0', // teal '#ffd60a', // yellow '#f72585', // hot pink ]; export function subnetColor(index: number): string { return SUBNET_PALETTE[index % SUBNET_PALETTE.length]; } /** * Place subnet bubbles in a circle around the center. * Single subnet gets center position. */ export function layoutSubnets(subnets: string[]): SubnetLayout[] { const total = subnets.length; const BASE_R = 18; const ORBIT_R = total === 1 ? 0 : 28; return subnets.map((subnet, i) => { const angle = total === 1 ? 0 : (i / total) * Math.PI * 2 - Math.PI / 2; const cx = 50 + Math.cos(angle) * ORBIT_R; const cy = 50 + Math.sin(angle) * ORBIT_R; return { subnet, label: subnetLabel(subnet), cx, cy, r: BASE_R, color: subnetColor(i), }; }); } export interface TopoNode { id: string; agentId: string; label: string; x: number; y: number; subnet: string; subnetColor: string; online: boolean; platform: string; ip: string; hashrate: number; latencyMs?: number; joinLane?: string; canSpread: boolean; capabilities: Agent['capabilities']; } /** * Compute pixel positions for all nodes. * Nodes within a subnet scatter around the subnet center. */ export function layoutNodes( agents: Agent[], subnetLayouts: SubnetLayout[], ): TopoNode[] { const subnetMap = new Map(subnetLayouts.map((s) => [s.subnet, s])); const subnetAgentCounts = new Map(); for (const a of agents) { const s = parseSubnet(a.ip); subnetAgentCounts.set(s, (subnetAgentCounts.get(s) ?? 0) + 1); } const subnetCounters = new Map(); return agents.map((agent): TopoNode => { const subnet = parseSubnet(agent.ip); const layout = subnetMap.get(subnet) ?? { cx: 50, cy: 50, r: 18, color: '#00e8f5', subnet, label: 'Unknown' }; const total = subnetAgentCounts.get(subnet) ?? 1; const idx = subnetCounters.get(subnet) ?? 0; subnetCounters.set(subnet, idx + 1); // Stable jitter within the bubble radius const jitter = stableXY(`${subnet}:${agent.id}`); const angle = (idx / Math.max(total, 1)) * Math.PI * 2 + (jitter.x - 0.5) * 0.8; const dist = (total === 1 ? 0 : 4 + Math.sqrt(total) * 2.5) * (0.6 + jitter.y * 0.4); const maxDist = layout.r * 0.75; return { id: agent.id, agentId: agent.id, label: agent.name, x: layout.cx + Math.cos(angle) * Math.min(dist, maxDist), y: layout.cy + Math.sin(angle) * Math.min(dist, maxDist), subnet, subnetColor: layout.color, online: agent.status === 'online', platform: agent.platform ?? 'unknown', ip: agent.ip ?? '—', hashrate: agent.hashrate_15m ?? 0, latencyMs: agent.latency_ms, joinLane: agent.join_lane, canSpread: !!(agent.capabilities?.auto_spread), capabilities: agent.capabilities, }; }); } // ── Edge graph ────────────────────────────────────────────────────────────── export type EdgeKind = | 'subnet' // same /24 — implies reachability | 'spread' // lateral spread candidate | 'smb' // SMB admin$ path (Windows + port 445) | 'winrm' // WinRM (Windows + 5985/5986) | 'ssh' // SSH lateral (Linux/Darwin) | 'cross_subnet'; // different subnets, but both spread-capable export interface TopoEdge { id: string; sourceId: string; targetId: string; kind: EdgeKind; /** Highlight when either endpoint is selected. */ active: boolean; } /** Determine what spread lanes exist between two agents. */ export function spreadLanesBetween(a: Agent, b: Agent): EdgeKind[] { if (!a.capabilities?.auto_spread || !b.capabilities?.auto_spread) return []; const lanes: EdgeKind[] = ['spread']; const aWin = (a.platform ?? '').toLowerCase().includes('win'); const bWin = (b.platform ?? '').toLowerCase().includes('win'); const aLin = (a.platform ?? '').toLowerCase().includes('linux') || (a.platform ?? '').toLowerCase().includes('darwin'); const bLin = (b.platform ?? '').toLowerCase().includes('linux') || (b.platform ?? '').toLowerCase().includes('darwin'); if (aWin && bWin) { lanes.push('smb'); lanes.push('winrm'); } if ((aLin && bWin) || (aWin && bLin) || (aLin && bLin)) lanes.push('ssh'); return lanes; } /** * Build all edges for the topology graph. * * Rules: * - Same subnet + both online → `subnet` edge * - Both have auto_spread + online → additional `spread` / protocol edges * - Cross-subnet + both spread-capable → `cross_subnet` */ export function buildEdges(agents: Agent[], selectedIds: Set): TopoEdge[] { const online = agents.filter((a) => a.status === 'online'); const edges: TopoEdge[] = []; const seen = new Set(); for (let i = 0; i < online.length; i++) { for (let j = i + 1; j < online.length; j++) { const a = online[i]; const b = online[j]; const subA = parseSubnet(a.ip); const subB = parseSubnet(b.ip); const sameSubnet = subA === subB && subA !== 'unknown'; const edgeKey = [a.id, b.id].sort().join('::'); if (seen.has(edgeKey)) continue; seen.add(edgeKey); const active = selectedIds.has(a.id) || selectedIds.has(b.id); if (sameSubnet) { edges.push({ id: edgeKey + ':subnet', sourceId: a.id, targetId: b.id, kind: 'subnet', active }); } // Spread lanes (may add on top of subnet edge) const lanes = spreadLanesBetween(a, b); for (const lane of lanes) { if (lane === 'spread' && sameSubnet) continue; // subnet edge covers it const spreadKey = edgeKey + ':' + lane; if (seen.has(spreadKey)) continue; seen.add(spreadKey); edges.push({ id: spreadKey, sourceId: a.id, targetId: b.id, kind: sameSubnet ? lane : 'cross_subnet', active, }); } } } return edges; } /** * Which agents can be reached laterally from a source agent? * Returns agent IDs with the best available lane. */ export function spreadCandidates( source: Agent, allAgents: Agent[], ): { agentId: string; lane: EdgeKind }[] { if (!source.capabilities?.auto_spread || source.status !== 'online') return []; const results: { agentId: string; lane: EdgeKind }[] = []; for (const target of allAgents) { if (target.id === source.id || target.status !== 'online') continue; const lanes = spreadLanesBetween(source, target); if (lanes.length === 0) continue; // Prefer most specific lane const preferred = lanes.find((l) => l !== 'spread') ?? lanes[0]; results.push({ agentId: target.id, lane: preferred }); } return results; }