Fleet topology epidemiology: strain plague map, interrupt fixes, tests.
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:
182
server/web/src/help/fleetTopologyEpidemiology.test.ts
Normal file
182
server/web/src/help/fleetTopologyEpidemiology.test.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { Agent } from '../types';
|
||||
import {
|
||||
spreadStrainFromJoinLane,
|
||||
resolveAgentStrain,
|
||||
miningContinuity,
|
||||
buildEpidemiologyGraph,
|
||||
buildMiningInterruptState,
|
||||
computeGoalPath,
|
||||
layoutStrainNodes,
|
||||
} from './fleetTopologyEpidemiology';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
describe('spreadStrainFromJoinLane', () => {
|
||||
it('matches Go genealogy color for winrm', () => {
|
||||
expect(spreadStrainFromJoinLane('winrm')).toBe('#ab88e4');
|
||||
});
|
||||
|
||||
it('returns empty for blank lane', () => {
|
||||
expect(spreadStrainFromJoinLane('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildEpidemiologyGraph', () => {
|
||||
it('aggregates hosts into strain nodes not per-agent nodes', () => {
|
||||
const strain = '#aabbcc';
|
||||
const graph = buildEpidemiologyGraph([
|
||||
mkAgent({ id: 'a1', spread_strain: strain, join_lane: 'winrm' }),
|
||||
mkAgent({ id: 'a2', spread_strain: strain, join_lane: 'winrm' }),
|
||||
mkAgent({ id: 'a3', spread_strain: '#112233', join_lane: 'smb' }),
|
||||
]);
|
||||
expect(graph.nodes).toHaveLength(2);
|
||||
const winrm = graph.nodes.find((n) => n.id === strain);
|
||||
expect(winrm?.hostCount).toBe(2);
|
||||
});
|
||||
|
||||
it('weights edges by successful parent spreads only', () => {
|
||||
const parentStrain = '#parent';
|
||||
const childStrain = '#child';
|
||||
const graph = buildEpidemiologyGraph([
|
||||
mkAgent({ id: 'parent', spread_strain: parentStrain, spread_generation: 0 }),
|
||||
mkAgent({
|
||||
id: 'child-ok',
|
||||
spread_strain: childStrain,
|
||||
parent_agent_id: 'parent',
|
||||
join_lane: 'winrm',
|
||||
spread_generation: 1,
|
||||
}),
|
||||
mkAgent({
|
||||
id: 'child-fail',
|
||||
spread_strain: childStrain,
|
||||
parent_agent_id: 'parent',
|
||||
status: 'offline',
|
||||
spread_generation: 1,
|
||||
}),
|
||||
]);
|
||||
const edge = graph.edges.find((e) => e.sourceStrain === parentStrain && e.targetStrain === childStrain);
|
||||
expect(edge?.weight).toBe(1);
|
||||
expect(edge?.failedWeight).toBe(1);
|
||||
expect(edge?.branchStatus).toBe('mixed');
|
||||
});
|
||||
|
||||
it('does not emit cure edges — only spread propagation', () => {
|
||||
const graph = buildEpidemiologyGraph([
|
||||
mkAgent({ id: 'a1', spread_strain: '#aa0000' }),
|
||||
mkAgent({ id: 'a2', spread_strain: '#00aa00', status: 'offline' }),
|
||||
]);
|
||||
for (const e of graph.edges) {
|
||||
expect(e.weight).toBeGreaterThanOrEqual(0);
|
||||
expect(e.id).not.toMatch(/cure|heal|recover/i);
|
||||
}
|
||||
});
|
||||
|
||||
it('computes goal path toward continuous mining strains', () => {
|
||||
const root = '#root';
|
||||
const mid = '#mid';
|
||||
const leaf = '#leaf';
|
||||
const graph = buildEpidemiologyGraph([
|
||||
mkAgent({
|
||||
id: 'root',
|
||||
spread_strain: root,
|
||||
spread_generation: 0,
|
||||
hashrate_15m: 0,
|
||||
chain_exhausted: true,
|
||||
}),
|
||||
mkAgent({
|
||||
id: 'mid',
|
||||
spread_strain: mid,
|
||||
parent_agent_id: 'root',
|
||||
join_lane: 'smb',
|
||||
spread_generation: 1,
|
||||
}),
|
||||
mkAgent({
|
||||
id: 'leaf',
|
||||
spread_strain: leaf,
|
||||
parent_agent_id: 'mid',
|
||||
join_lane: 'winrm',
|
||||
spread_generation: 2,
|
||||
hashrate_15m: 500,
|
||||
active_method: 'container',
|
||||
mining_hashrate: 500,
|
||||
}),
|
||||
]);
|
||||
expect(graph.goalPath).toContain(leaf);
|
||||
expect(graph.goalPath[0]).toBe(root);
|
||||
});
|
||||
});
|
||||
|
||||
describe('miningContinuity', () => {
|
||||
it('flags interrupted mining with failed cascade', () => {
|
||||
const agent = mkAgent({
|
||||
id: 'stuck',
|
||||
chain_exhausted: true,
|
||||
failed_methods: [{ method: 'wsl', reason: 'no distro', at: '2026-06-07T00:00:00Z' }],
|
||||
});
|
||||
expect(miningContinuity(agent)).toBe('interrupted');
|
||||
expect(buildMiningInterruptState(agent)?.agentId).toBe('stuck');
|
||||
});
|
||||
|
||||
it('treats active hashrate as continuous', () => {
|
||||
const agent = mkAgent({
|
||||
id: 'ok',
|
||||
hashrate_15m: 200,
|
||||
active_method: 'cpu_inprocess',
|
||||
});
|
||||
expect(miningContinuity(agent)).toBe('continuous');
|
||||
expect(buildMiningInterruptState(agent)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('layoutStrainNodes', () => {
|
||||
it('returns stable positions per strain id', () => {
|
||||
const nodes = buildEpidemiologyGraph([
|
||||
mkAgent({ id: 'a1', spread_strain: '#111111' }),
|
||||
mkAgent({ id: 'a2', spread_strain: '#222222' }),
|
||||
]).nodes;
|
||||
const a = layoutStrainNodes(nodes);
|
||||
const b = layoutStrainNodes(nodes);
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveAgentStrain', () => {
|
||||
it('prefers spread_strain watermark then join lane', () => {
|
||||
expect(resolveAgentStrain(mkAgent({ id: 'x', spread_strain: '#ff00aa' }))).toBe('#ff00aa');
|
||||
expect(resolveAgentStrain(mkAgent({ id: 'y', join_lane: 'dns_txt' }))).toBe(
|
||||
spreadStrainFromJoinLane('dns_txt'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeGoalPath', () => {
|
||||
it('returns empty when no continuous strains', () => {
|
||||
const nodes = buildEpidemiologyGraph([
|
||||
mkAgent({ id: 'a', spread_strain: '#111', chain_exhausted: true }),
|
||||
]).nodes;
|
||||
expect(computeGoalPath(nodes, [])).toEqual([]);
|
||||
});
|
||||
});
|
||||
394
server/web/src/help/fleetTopologyEpidemiology.ts
Normal file
394
server/web/src/help/fleetTopologyEpidemiology.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* Fleet topology epidemiology — strains not hosts.
|
||||
*
|
||||
* Pure logic for the 3D plague map: nodes = spread strains, edges = successful
|
||||
* parent→child spread weight. No cure edges — only propagation.
|
||||
*/
|
||||
|
||||
import type { Agent } from '../types';
|
||||
|
||||
export type MiningContinuity = 'continuous' | 'interrupted' | 'dormant' | 'offline';
|
||||
export type BranchStatus = 'success' | 'failed' | 'mixed' | 'dormant';
|
||||
|
||||
export interface MiningInterruptState {
|
||||
agentId: string;
|
||||
strain: string;
|
||||
joinLane?: string;
|
||||
spreadGeneration: number;
|
||||
activeMethod?: string;
|
||||
chainExhausted?: boolean;
|
||||
failedMethods?: { method: string; reason: string; at: string }[];
|
||||
lastError?: string;
|
||||
lotlTier?: string;
|
||||
miningHashrate: number;
|
||||
parentAgentId?: string;
|
||||
}
|
||||
|
||||
export interface StrainNode {
|
||||
id: string;
|
||||
label: string;
|
||||
color: string;
|
||||
hostCount: number;
|
||||
onlineCount: number;
|
||||
spreadGeneration: number;
|
||||
miningContinuity: MiningContinuity;
|
||||
branchStatus: BranchStatus;
|
||||
successfulSpreads: number;
|
||||
failedSpreads: number;
|
||||
totalHashrate: number;
|
||||
joinLane?: string;
|
||||
/** Agents on this strain for interrupt reporting. */
|
||||
agentIds: string[];
|
||||
}
|
||||
|
||||
export interface StrainEdge {
|
||||
id: string;
|
||||
sourceStrain: string;
|
||||
targetStrain: string;
|
||||
weight: number;
|
||||
failedWeight: number;
|
||||
branchStatus: BranchStatus;
|
||||
onGoalPath: boolean;
|
||||
}
|
||||
|
||||
export interface EpidemiologyGraph {
|
||||
nodes: StrainNode[];
|
||||
edges: StrainEdge[];
|
||||
goalPath: string[];
|
||||
interrupted: MiningInterruptState[];
|
||||
rootStrain?: string;
|
||||
}
|
||||
|
||||
const WILDTYPE_STRAIN = '#334455';
|
||||
const STRAIN_CAP = 80;
|
||||
|
||||
/** SHA-256 (browser-safe) — matches Go genealogy strain color. */
|
||||
function sha256Bytes(message: Uint8Array): Uint8Array {
|
||||
const K = new Uint32Array([
|
||||
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
||||
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
||||
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
||||
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
||||
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
||||
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
||||
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
||||
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
||||
]);
|
||||
const rotr = (x: number, n: number) => (x >>> n) | (x << (32 - n));
|
||||
const len = message.length;
|
||||
const bitLen = len * 8;
|
||||
const padLen = ((len + 9 + 63) & ~63);
|
||||
const padded = new Uint8Array(padLen);
|
||||
padded.set(message);
|
||||
padded[len] = 0x80;
|
||||
const view = new DataView(padded.buffer);
|
||||
view.setUint32(padLen - 4, bitLen >>> 0, false);
|
||||
view.setUint32(padLen - 8, Math.floor(bitLen / 0x100000000), false);
|
||||
|
||||
let h0 = 0x6a09e667, h1 = 0xbb67ae85, h2 = 0x3c6ef372, h3 = 0xa54ff53a;
|
||||
let h4 = 0x510e527f, h5 = 0x9b05688c, h6 = 0x1f83d9ab, h7 = 0x5be0cd19;
|
||||
const w = new Uint32Array(64);
|
||||
|
||||
for (let off = 0; off < padded.length; off += 64) {
|
||||
for (let i = 0; i < 16; i++) w[i] = view.getUint32(off + i * 4, false);
|
||||
for (let i = 16; i < 64; i++) {
|
||||
const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
|
||||
const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
|
||||
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0;
|
||||
}
|
||||
let a = h0, b = h1, c = h2, d = h3, e = h4, f = h5, g = h6, hh = h7;
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
|
||||
const ch = (e & f) ^ (~e & g);
|
||||
const t1 = (hh + S1 + ch + K[i] + w[i]) >>> 0;
|
||||
const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
|
||||
const maj = (a & b) ^ (a & c) ^ (b & c);
|
||||
const t2 = (S0 + maj) >>> 0;
|
||||
hh = g; g = f; f = e; e = (d + t1) >>> 0;
|
||||
d = c; c = b; b = a; a = (t1 + t2) >>> 0;
|
||||
}
|
||||
h0 = (h0 + a) >>> 0; h1 = (h1 + b) >>> 0; h2 = (h2 + c) >>> 0; h3 = (h3 + d) >>> 0;
|
||||
h4 = (h4 + e) >>> 0; h5 = (h5 + f) >>> 0; h6 = (h6 + g) >>> 0; h7 = (h7 + hh) >>> 0;
|
||||
}
|
||||
const out = new Uint8Array(32);
|
||||
const outView = new DataView(out.buffer);
|
||||
outView.setUint32(0, h0, false); outView.setUint32(4, h1, false);
|
||||
outView.setUint32(8, h2, false); outView.setUint32(12, h3, false);
|
||||
outView.setUint32(16, h4, false); outView.setUint32(20, h5, false);
|
||||
outView.setUint32(24, h6, false); outView.setUint32(28, h7, false);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Stable #RRGGBB strain color from join lane (Go parity). */
|
||||
export function spreadStrainFromJoinLane(lane: string): string {
|
||||
const normalized = lane.trim().toLowerCase();
|
||||
if (!normalized) return '';
|
||||
const bytes = new TextEncoder().encode(`aetherforge-strain:${normalized}`);
|
||||
const digest = sha256Bytes(bytes);
|
||||
const hex = (b: number) => b.toString(16).padStart(2, '0');
|
||||
return `#${hex(digest[0])}${hex(digest[1])}${hex(digest[2])}`;
|
||||
}
|
||||
|
||||
export function resolveAgentStrain(agent: Agent): string {
|
||||
const baked = agent.spread_strain?.trim().toLowerCase();
|
||||
if (baked) return baked;
|
||||
const fromLane = spreadStrainFromJoinLane(agent.join_lane ?? '');
|
||||
if (fromLane) return fromLane;
|
||||
return WILDTYPE_STRAIN;
|
||||
}
|
||||
|
||||
export function strainLabel(strain: string, joinLane?: string, generation?: number): string {
|
||||
if (joinLane) return joinLane;
|
||||
if (generation && generation > 0) return `gen-${generation}`;
|
||||
if (strain === WILDTYPE_STRAIN) return 'wildtype';
|
||||
return strain.replace(/^#/, 'strain-');
|
||||
}
|
||||
|
||||
export function miningContinuity(agent: Agent): MiningContinuity {
|
||||
if (agent.status !== 'online') return 'offline';
|
||||
if (agent.fleet_role === 'seeder') return 'dormant';
|
||||
const hr = agent.mining_hashrate ?? agent.hashrate_15m ?? 0;
|
||||
const gpuHr = agent.gpu_hashrate_15m ?? 0;
|
||||
const hashing = hr > 0 || gpuHr > 0;
|
||||
if (hashing && (agent.active_method || agent.gpu_miner_active)) return 'continuous';
|
||||
if (
|
||||
agent.chain_exhausted ||
|
||||
(agent.failed_methods?.length ?? 0) > 0 ||
|
||||
agent.last_error ||
|
||||
(agent.lotl_attempts?.some((a) => !a.ok) ?? false)
|
||||
) {
|
||||
return 'interrupted';
|
||||
}
|
||||
if (agent.status === 'online' && !hashing) return 'interrupted';
|
||||
return 'dormant';
|
||||
}
|
||||
|
||||
export function buildMiningInterruptState(agent: Agent): MiningInterruptState | null {
|
||||
const continuity = miningContinuity(agent);
|
||||
if (continuity !== 'interrupted') return null;
|
||||
return {
|
||||
agentId: agent.id,
|
||||
strain: resolveAgentStrain(agent),
|
||||
joinLane: agent.join_lane,
|
||||
spreadGeneration: agent.spread_generation ?? 0,
|
||||
activeMethod: agent.active_method,
|
||||
chainExhausted: agent.chain_exhausted,
|
||||
failedMethods: agent.failed_methods,
|
||||
lastError: agent.last_error,
|
||||
lotlTier: agent.lotl_tier,
|
||||
miningHashrate: agent.mining_hashrate ?? agent.hashrate_15m ?? 0,
|
||||
parentAgentId: agent.parent_agent_id,
|
||||
};
|
||||
}
|
||||
|
||||
function isSuccessfulSpread(child: Agent): boolean {
|
||||
if (!child.parent_agent_id) return false;
|
||||
return child.status === 'online' && (!!child.join_lane || child.spread_generation !== undefined);
|
||||
}
|
||||
|
||||
function isFailedSpread(child: Agent): boolean {
|
||||
if (!child.parent_agent_id) return false;
|
||||
return child.status === 'error' || child.status === 'offline';
|
||||
}
|
||||
|
||||
function branchStatusFromCounts(success: number, failed: number, online: number): BranchStatus {
|
||||
if (success === 0 && failed === 0 && online === 0) return 'dormant';
|
||||
if (success > 0 && failed === 0) return 'success';
|
||||
if (failed > 0 && success === 0) return 'failed';
|
||||
return 'mixed';
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export interface StrainLayout {
|
||||
strainId: string;
|
||||
position: [number, number, number];
|
||||
}
|
||||
|
||||
/** Place strain nodes on a stable spherical shell. */
|
||||
export function layoutStrainNodes(nodes: StrainNode[]): StrainLayout[] {
|
||||
const goldenRatio = (1 + Math.sqrt(5)) / 2;
|
||||
return nodes.map((node, i) => {
|
||||
const angle = i * Math.PI * 2 * goldenRatio;
|
||||
const jitter = stableHash(node.id);
|
||||
const radius = 3.5 + jitter * 2.5 + Math.min(node.hostCount, 20) * 0.08;
|
||||
const x = Math.cos(angle) * radius;
|
||||
const z = Math.sin(angle) * radius;
|
||||
const y = (stableHash(node.id + ':y') - 0.5) * 5;
|
||||
return { strainId: node.id, position: [x, y, z] as [number, number, number] };
|
||||
});
|
||||
}
|
||||
|
||||
/** BFS from root strains toward continuous-mining strains — goal path for visualization. */
|
||||
export function computeGoalPath(nodes: StrainNode[], edges: StrainEdge[]): string[] {
|
||||
const byId = new Map(nodes.map((n) => [n.id, n]));
|
||||
const continuous = nodes.filter((n) => n.miningContinuity === 'continuous').map((n) => n.id);
|
||||
if (continuous.length === 0) return [];
|
||||
|
||||
const adj = new Map<string, { target: string; weight: number }[]>();
|
||||
for (const e of edges) {
|
||||
if (e.weight <= 0) continue;
|
||||
if (!adj.has(e.sourceStrain)) adj.set(e.sourceStrain, []);
|
||||
adj.get(e.sourceStrain)!.push({ target: e.targetStrain, weight: e.weight });
|
||||
}
|
||||
|
||||
const roots = nodes
|
||||
.filter((n) => n.spreadGeneration === 0 || n.id === WILDTYPE_STRAIN)
|
||||
.map((n) => n.id);
|
||||
if (roots.length === 0) roots.push(...nodes.map((n) => n.id));
|
||||
|
||||
let bestPath: string[] = [];
|
||||
let bestScore = -1;
|
||||
|
||||
for (const root of roots) {
|
||||
const queue: { id: string; path: string[]; score: number }[] = [{ id: root, path: [root], score: 0 }];
|
||||
const seen = new Set<string>([root]);
|
||||
while (queue.length > 0) {
|
||||
const cur = queue.shift()!;
|
||||
if (continuous.includes(cur.id)) {
|
||||
const node = byId.get(cur.id);
|
||||
const bonus = (node?.totalHashrate ?? 0) + cur.score;
|
||||
if (bonus > bestScore) {
|
||||
bestScore = bonus;
|
||||
bestPath = cur.path;
|
||||
}
|
||||
}
|
||||
for (const next of adj.get(cur.id) ?? []) {
|
||||
if (seen.has(next.target)) continue;
|
||||
seen.add(next.target);
|
||||
queue.push({
|
||||
id: next.target,
|
||||
path: [...cur.path, next.target],
|
||||
score: cur.score + next.weight,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return bestPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build strain epidemiology graph from fleet agents.
|
||||
* Nodes aggregate hosts per strain; edges count successful parent→child spreads only.
|
||||
*/
|
||||
export function buildEpidemiologyGraph(agents: Agent[]): EpidemiologyGraph {
|
||||
const agentsById = new Map(agents.map((a) => [a.id, a]));
|
||||
const strainBuckets = new Map<string, Agent[]>();
|
||||
|
||||
for (const agent of agents) {
|
||||
const strain = resolveAgentStrain(agent);
|
||||
if (!strainBuckets.has(strain)) strainBuckets.set(strain, []);
|
||||
strainBuckets.get(strain)!.push(agent);
|
||||
}
|
||||
|
||||
const nodes: StrainNode[] = [];
|
||||
for (const [strain, hosts] of strainBuckets) {
|
||||
const online = hosts.filter((h) => h.status === 'online');
|
||||
const continuityRank = (c: MiningContinuity) =>
|
||||
c === 'continuous' ? 4 : c === 'interrupted' ? 3 : c === 'dormant' ? 2 : 1;
|
||||
let bestContinuity: MiningContinuity = 'offline';
|
||||
for (const h of hosts) {
|
||||
const c = miningContinuity(h);
|
||||
if (continuityRank(c) > continuityRank(bestContinuity)) bestContinuity = c;
|
||||
}
|
||||
const successSpreads = hosts.filter(isSuccessfulSpread).length;
|
||||
const failedSpreads = hosts.filter(isFailedSpread).length;
|
||||
const joinLane = hosts.find((h) => h.join_lane)?.join_lane;
|
||||
const maxGen = Math.max(0, ...hosts.map((h) => h.spread_generation ?? 0));
|
||||
nodes.push({
|
||||
id: strain,
|
||||
label: strainLabel(strain, joinLane, maxGen),
|
||||
color: strain.startsWith('#') ? strain : `#${strain}`,
|
||||
hostCount: hosts.length,
|
||||
onlineCount: online.length,
|
||||
spreadGeneration: maxGen,
|
||||
miningContinuity: bestContinuity,
|
||||
branchStatus: branchStatusFromCounts(successSpreads, failedSpreads, online.length),
|
||||
successfulSpreads: successSpreads,
|
||||
failedSpreads,
|
||||
totalHashrate: hosts.reduce(
|
||||
(sum, h) => sum + (h.mining_hashrate ?? h.hashrate_15m ?? 0),
|
||||
0,
|
||||
),
|
||||
joinLane,
|
||||
agentIds: hosts.map((h) => h.id),
|
||||
});
|
||||
}
|
||||
|
||||
nodes.sort((a, b) => b.hostCount - a.hostCount);
|
||||
const capped = nodes.length > STRAIN_CAP;
|
||||
const displayNodes = capped ? nodes.slice(0, STRAIN_CAP) : nodes;
|
||||
const displayStrains = new Set(displayNodes.map((n) => n.id));
|
||||
|
||||
const edgeMap = new Map<string, StrainEdge>();
|
||||
for (const child of agents) {
|
||||
if (!child.parent_agent_id) continue;
|
||||
const parent = agentsById.get(child.parent_agent_id);
|
||||
if (!parent) continue;
|
||||
const source = resolveAgentStrain(parent);
|
||||
const target = resolveAgentStrain(child);
|
||||
if (!displayStrains.has(source) || !displayStrains.has(target)) continue;
|
||||
const key = `${source}→${target}`;
|
||||
let edge = edgeMap.get(key);
|
||||
if (!edge) {
|
||||
edge = {
|
||||
id: key,
|
||||
sourceStrain: source,
|
||||
targetStrain: target,
|
||||
weight: 0,
|
||||
failedWeight: 0,
|
||||
branchStatus: 'dormant',
|
||||
onGoalPath: false,
|
||||
};
|
||||
edgeMap.set(key, edge);
|
||||
}
|
||||
if (isSuccessfulSpread(child)) edge.weight += 1;
|
||||
else if (isFailedSpread(child)) edge.failedWeight += 1;
|
||||
}
|
||||
|
||||
const edges = [...edgeMap.values()].map((e) => ({
|
||||
...e,
|
||||
branchStatus: branchStatusFromCounts(e.weight, e.failedWeight, e.weight),
|
||||
}));
|
||||
|
||||
const goalPath = computeGoalPath(displayNodes, edges);
|
||||
const goalSet = new Set(goalPath);
|
||||
for (const e of edges) {
|
||||
e.onGoalPath = goalSet.has(e.sourceStrain) && goalSet.has(e.targetStrain);
|
||||
}
|
||||
|
||||
const interrupted = agents
|
||||
.map(buildMiningInterruptState)
|
||||
.filter((s): s is MiningInterruptState => s !== null);
|
||||
|
||||
const rootStrain =
|
||||
displayNodes.find((n) => n.spreadGeneration === 0)?.id ?? displayNodes[0]?.id;
|
||||
|
||||
return {
|
||||
nodes: displayNodes,
|
||||
edges,
|
||||
goalPath,
|
||||
interrupted,
|
||||
rootStrain,
|
||||
};
|
||||
}
|
||||
|
||||
export function nodeEmissiveIntensity(node: StrainNode): number {
|
||||
if (node.branchStatus === 'success' || node.miningContinuity === 'continuous') return 2.2;
|
||||
if (node.branchStatus === 'failed' || node.miningContinuity === 'interrupted') return 0.25;
|
||||
if (node.branchStatus === 'mixed') return 1.0;
|
||||
return 0.6;
|
||||
}
|
||||
|
||||
export function nodeWireOpacity(node: StrainNode): number {
|
||||
if (node.branchStatus === 'failed') return 0.2;
|
||||
if (node.miningContinuity === 'interrupted') return 0.35;
|
||||
return 0.85;
|
||||
}
|
||||
Reference in New Issue
Block a user