Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
288 lines
9.0 KiB
TypeScript
288 lines
9.0 KiB
TypeScript
/**
|
|
* 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<string, Agent[]> {
|
|
const map = new Map<string, Agent[]>();
|
|
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<string, number>();
|
|
|
|
for (const a of agents) {
|
|
const s = parseSubnet(a.ip);
|
|
subnetAgentCounts.set(s, (subnetAgentCounts.get(s) ?? 0) + 1);
|
|
}
|
|
|
|
const subnetCounters = new Map<string, number>();
|
|
|
|
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<string>): TopoEdge[] {
|
|
const online = agents.filter((a) => a.status === 'online');
|
|
const edges: TopoEdge[] = [];
|
|
const seen = new Set<string>();
|
|
|
|
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;
|
|
}
|