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:
287
server/web/src/help/networkTopology.ts
Normal file
287
server/web/src/help/networkTopology.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user