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:
@@ -3,138 +3,217 @@ import { OrbitControls, Stars, Line, Sphere } from '@react-three/drei';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { Agent } from '../../../types';
|
||||
import {
|
||||
buildEpidemiologyGraph,
|
||||
layoutStrainNodes,
|
||||
nodeEmissiveIntensity,
|
||||
nodeWireOpacity,
|
||||
type StrainEdge,
|
||||
type StrainNode,
|
||||
} from '../../../help/fleetTopologyEpidemiology';
|
||||
|
||||
function LaserPulse({ start, end, color }: { start: [number, number, number], end: [number, number, number], color: string }) {
|
||||
function PlaguePulse({
|
||||
start,
|
||||
end,
|
||||
color,
|
||||
speed = 1.8,
|
||||
}: {
|
||||
start: [number, number, number];
|
||||
end: [number, number, number];
|
||||
color: string;
|
||||
speed?: number;
|
||||
}) {
|
||||
const meshRef = useRef<THREE.Mesh>(null);
|
||||
useFrame((state) => {
|
||||
if (meshRef.current) {
|
||||
// Move pulse from start to end
|
||||
const t = (state.clock.elapsedTime * 2) % 1;
|
||||
const t = (state.clock.elapsedTime * speed) % 1;
|
||||
meshRef.current.position.set(
|
||||
start[0] + (end[0] - start[0]) * t,
|
||||
start[1] + (end[1] - start[1]) * t,
|
||||
start[2] + (end[2] - start[2]) * t
|
||||
start[2] + (end[2] - start[2]) * t,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<mesh ref={meshRef}>
|
||||
<sphereGeometry args={[0.08, 8, 8]} />
|
||||
<sphereGeometry args={[0.1, 8, 8]} />
|
||||
<meshBasicMaterial color={color} />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
const STALE_THRESHOLD_MS = 5 * 60 * 1000;
|
||||
/** Cap 3D nodes to keep WebGL performant on large fleets. */
|
||||
const TOPOLOGY_NODE_CAP = 200;
|
||||
|
||||
function AgentNode({ agent, position, serverPos }: { agent: Agent, position: [number, number, number], serverPos: [number, number, number] }) {
|
||||
const isOnline = agent.status === 'online';
|
||||
const isHashing = agent.hashrate_15m > 0;
|
||||
const color = isOnline ? (isHashing ? '#00e8f5' : '#00aa55') : '#ff4444';
|
||||
function StrainNodeMesh({
|
||||
node,
|
||||
position,
|
||||
onGoalPath,
|
||||
}: {
|
||||
node: StrainNode;
|
||||
position: [number, number, number];
|
||||
onGoalPath: boolean;
|
||||
}) {
|
||||
const pulseRef = useRef<THREE.Mesh>(null);
|
||||
const ringRef = useRef<THREE.Mesh>(null);
|
||||
|
||||
// Stale = online status but last_seen older than 5 min (silently dead)
|
||||
const isStale = isOnline && !!agent.last_seen &&
|
||||
(Date.now() - new Date(agent.last_seen).getTime()) > STALE_THRESHOLD_MS;
|
||||
const isSuccess = node.branchStatus === 'success' || node.miningContinuity === 'continuous';
|
||||
const isFailed = node.branchStatus === 'failed' || node.miningContinuity === 'interrupted';
|
||||
const baseColor = node.color;
|
||||
const emissive = isFailed ? '#331111' : baseColor;
|
||||
const intensity = onGoalPath ? nodeEmissiveIntensity(node) * 1.4 : nodeEmissiveIntensity(node);
|
||||
const radius = 0.35 + Math.min(node.hostCount, 12) * 0.04;
|
||||
const opacity = nodeWireOpacity(node);
|
||||
|
||||
useFrame((state) => {
|
||||
if (isOnline && pulseRef.current) {
|
||||
const scale = 1 + Math.sin(state.clock.elapsedTime * (isHashing ? 5 : 1)) * 0.15 * (isHashing ? 1 : 0.5);
|
||||
pulseRef.current.scale.set(scale, scale, scale);
|
||||
if (isHashing) {
|
||||
pulseRef.current.rotation.y += 0.05;
|
||||
pulseRef.current.rotation.x += 0.05;
|
||||
}
|
||||
}
|
||||
// Slowly spin the staleness warning ring
|
||||
if (isStale && ringRef.current) {
|
||||
ringRef.current.rotation.z += 0.01;
|
||||
ringRef.current.rotation.x = Math.sin(state.clock.elapsedTime * 0.5) * 0.3;
|
||||
}
|
||||
if (!pulseRef.current) return;
|
||||
const pulse = isSuccess
|
||||
? 1 + Math.sin(state.clock.elapsedTime * 3) * 0.18
|
||||
: isFailed
|
||||
? 1 + Math.sin(state.clock.elapsedTime * 0.6) * 0.04
|
||||
: 1;
|
||||
pulseRef.current.scale.set(pulse, pulse, pulse);
|
||||
if (isSuccess) pulseRef.current.rotation.y += 0.02;
|
||||
});
|
||||
|
||||
return (
|
||||
<group position={position}>
|
||||
<Sphere ref={pulseRef} args={[0.3, 16, 16]}>
|
||||
<meshStandardMaterial color={color} emissive={color} emissiveIntensity={isOnline ? (isHashing ? 2 : 1) : 0.2} wireframe />
|
||||
<Sphere ref={pulseRef} args={[radius, 16, 16]}>
|
||||
<meshStandardMaterial
|
||||
color={baseColor}
|
||||
emissive={emissive}
|
||||
emissiveIntensity={intensity}
|
||||
wireframe
|
||||
transparent
|
||||
opacity={opacity}
|
||||
/>
|
||||
</Sphere>
|
||||
|
||||
{/* Staleness warning ring — amber halo for online-but-silent nodes */}
|
||||
{isStale && (
|
||||
<mesh ref={ringRef}>
|
||||
<torusGeometry args={[0.55, 0.04, 8, 40]} />
|
||||
<meshBasicMaterial color="#ffb020" opacity={0.85} transparent />
|
||||
{onGoalPath && node.miningContinuity === 'continuous' && (
|
||||
<mesh>
|
||||
<torusGeometry args={[radius + 0.25, 0.03, 8, 32]} />
|
||||
<meshBasicMaterial color="#39ff14" opacity={0.9} transparent />
|
||||
</mesh>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Connection Line */}
|
||||
<Line points={[[0,0,0], [serverPos[0] - position[0], serverPos[1] - position[1], serverPos[2] - position[2]]]} color={isStale ? '#665500' : isOnline ? '#004455' : '#330000'} lineWidth={1} transparent opacity={0.4} />
|
||||
|
||||
{/* Laser Pulse simulating hashing packets */}
|
||||
{isOnline && isHashing && !isStale && (
|
||||
<LaserPulse start={[0,0,0]} end={[serverPos[0] - position[0], serverPos[1] - position[1], serverPos[2] - position[2]]} color="#00e8f5" />
|
||||
function StrainEdgeLine({
|
||||
edge,
|
||||
positions,
|
||||
}: {
|
||||
edge: StrainEdge;
|
||||
positions: Map<string, [number, number, number]>;
|
||||
}) {
|
||||
const start = positions.get(edge.sourceStrain);
|
||||
const end = positions.get(edge.targetStrain);
|
||||
if (!start || !end) return null;
|
||||
|
||||
const failed = edge.branchStatus === 'failed';
|
||||
const success = edge.weight > 0;
|
||||
const color = edge.onGoalPath ? '#39ff14' : failed ? '#442222' : '#b24bf3';
|
||||
const lineWidth = edge.onGoalPath ? 2.5 : Math.min(4, 0.8 + edge.weight * 0.35);
|
||||
const opacity = failed && !success ? 0.15 : edge.onGoalPath ? 0.9 : 0.55;
|
||||
|
||||
return (
|
||||
<group>
|
||||
<Line points={[start, end]} color={color} lineWidth={lineWidth} transparent opacity={opacity} />
|
||||
{success && !failed && (
|
||||
<PlaguePulse
|
||||
start={start}
|
||||
end={end}
|
||||
color={edge.onGoalPath ? '#39ff14' : '#ff2da6'}
|
||||
speed={1.2 + edge.weight * 0.2}
|
||||
/>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FleetTopologyMap({ agents }: { agents: Agent[] }) {
|
||||
const serverPos: [number, number, number] = [0, 0, 0];
|
||||
const displayAgents = useMemo(() => {
|
||||
if (agents.length <= TOPOLOGY_NODE_CAP) return agents;
|
||||
const online = agents.filter((a) => a.status === 'online');
|
||||
const pool = online.length >= TOPOLOGY_NODE_CAP ? online : agents;
|
||||
return pool.slice(0, TOPOLOGY_NODE_CAP);
|
||||
}, [agents]);
|
||||
const capped = agents.length > TOPOLOGY_NODE_CAP;
|
||||
const graph = useMemo(() => buildEpidemiologyGraph(agents), [agents]);
|
||||
const layouts = useMemo(() => layoutStrainNodes(graph.nodes), [graph.nodes]);
|
||||
const positionMap = useMemo(() => {
|
||||
const map = new Map<string, [number, number, number]>();
|
||||
for (const layout of layouts) {
|
||||
map.set(layout.strainId, layout.position);
|
||||
}
|
||||
return map;
|
||||
}, [layouts]);
|
||||
const goalSet = useMemo(() => new Set(graph.goalPath), [graph.goalPath]);
|
||||
|
||||
const agentNodes = useMemo(() => {
|
||||
return displayAgents.map((agent, i) => {
|
||||
const goldenRatio = (1 + Math.sqrt(5)) / 2;
|
||||
const angle = i * Math.PI * 2 * goldenRatio;
|
||||
// Distribute in a spherical/cylindrical rough cluster
|
||||
const radius = 4 + Math.random() * 3 + (i * 0.05);
|
||||
const x = Math.cos(angle) * radius;
|
||||
const z = Math.sin(angle) * radius;
|
||||
const y = (Math.random() - 0.5) * 6;
|
||||
return { agent, position: [x, y, z] as [number, number, number] };
|
||||
});
|
||||
}, [displayAgents]);
|
||||
const plagueEdges = graph.edges.filter((e) => e.weight > 0).length;
|
||||
const continuousStrains = graph.nodes.filter((n) => n.miningContinuity === 'continuous').length;
|
||||
const interrupted = graph.interrupted.length;
|
||||
|
||||
return (
|
||||
<div className="topology-container" style={{ width: '100%', height: '500px', background: '#050508', borderRadius: '8px', overflow: 'hidden', border: '1px solid var(--neon-cyan)', position: 'relative', boxShadow: '0 0 20px rgba(0, 232, 245, 0.1)' }}>
|
||||
<div style={{ position: 'absolute', top: 15, left: 15, zIndex: 10, color: 'var(--neon-cyan)', fontFamily: 'monospace', textShadow: '0 0 5px var(--neon-cyan)' }}>
|
||||
<span className="live-beacon on" style={{ display: 'inline-block', marginRight: 8, verticalAlign: 'middle' }}></span>
|
||||
3D_MESH_TOPOLOGY // {displayAgents.filter(a => a.status === 'online').length} NODES LINKED
|
||||
{capped && ` (showing ${TOPOLOGY_NODE_CAP}/${agents.length})`}
|
||||
<div
|
||||
className="topology-container epidemiology-topology"
|
||||
data-testid="fleet-epidemiology-map"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '500px',
|
||||
background: '#050508',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid var(--neon-cyan)',
|
||||
position: 'relative',
|
||||
boxShadow: '0 0 20px rgba(0, 232, 245, 0.1)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 15,
|
||||
left: 15,
|
||||
zIndex: 10,
|
||||
color: 'var(--neon-cyan)',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
textShadow: '0 0 5px var(--neon-cyan)',
|
||||
}}
|
||||
>
|
||||
<span className="live-beacon on" style={{ display: 'inline-block', marginRight: 8, verticalAlign: 'middle' }} />
|
||||
STRAIN_EPIDEMIOLOGY // {graph.nodes.length} STRAINS · {plagueEdges} PLAGUE_EDGES
|
||||
{graph.goalPath.length > 0 && ` · GOAL_PATH ${graph.goalPath.length}`}
|
||||
{continuousStrains > 0 && ` · ${continuousStrains} MINING`}
|
||||
{interrupted > 0 && ` · ${interrupted} INTERRUPTED`}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 12,
|
||||
left: 15,
|
||||
zIndex: 10,
|
||||
color: '#888',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 10,
|
||||
}}
|
||||
>
|
||||
glow = successful branch · dim = failed tree · pulses = spread weight (no cures)
|
||||
</div>
|
||||
<Canvas camera={{ position: [0, 8, 14], fov: 50 }}>
|
||||
<color attach="background" args={['#050508']} />
|
||||
<ambientLight intensity={0.5} />
|
||||
<pointLight position={[10, 10, 10]} intensity={1.5} color="#00e8f5" />
|
||||
<Stars radius={100} depth={50} count={3000} factor={3} saturation={0.5} fade speed={1} />
|
||||
|
||||
{/* Server Node (Mothership) */}
|
||||
<group position={serverPos}>
|
||||
<Sphere args={[0.7, 32, 32]}>
|
||||
<meshStandardMaterial color="#ffb020" emissive="#ffb020" emissiveIntensity={1.2} wireframe />
|
||||
</Sphere>
|
||||
<Sphere args={[0.3, 16, 16]}>
|
||||
<meshStandardMaterial color="#ffffff" emissive="#ffffff" emissiveIntensity={2} />
|
||||
</Sphere>
|
||||
</group>
|
||||
<ambientLight intensity={0.45} />
|
||||
<pointLight position={[10, 10, 10]} intensity={1.4} color="#ff2da6" />
|
||||
<pointLight position={[-8, 4, -6]} intensity={0.8} color="#00e8f5" />
|
||||
<Stars radius={100} depth={50} count={2500} factor={3} saturation={0.4} fade speed={0.8} />
|
||||
|
||||
{/* Agent Nodes */}
|
||||
{agentNodes.map((node) => (
|
||||
<AgentNode key={node.agent.id} agent={node.agent} position={node.position} serverPos={serverPos} />
|
||||
{graph.nodes.map((node) => {
|
||||
const pos = positionMap.get(node.id);
|
||||
if (!pos) return null;
|
||||
return (
|
||||
<StrainNodeMesh
|
||||
key={node.id}
|
||||
node={node}
|
||||
position={pos}
|
||||
onGoalPath={goalSet.has(node.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{graph.edges.map((edge) => (
|
||||
<StrainEdgeLine key={edge.id} edge={edge} positions={positionMap} />
|
||||
))}
|
||||
|
||||
<OrbitControls enablePan={true} enableZoom={true} enableRotate={true} autoRotate autoRotateSpeed={0.8} />
|
||||
<OrbitControls enablePan enableZoom enableRotate autoRotate autoRotateSpeed={0.6} />
|
||||
</Canvas>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -854,9 +854,28 @@ describe('SystemStatusBar', () => {
|
||||
describe('FleetTopologyMap', () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('renders canvas wrapper for agents', () => {
|
||||
it('renders strain epidemiology canvas for agents', () => {
|
||||
render(<FleetTopologyMap agents={[mockAgent()]} />);
|
||||
expect(screen.getByTestId('three-canvas')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('fleet-epidemiology-map')).toBeInTheDocument();
|
||||
expect(screen.getByText(/STRAIN_EPIDEMIOLOGY/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows plague edge count for spread genealogy', () => {
|
||||
const parent = mockAgent({
|
||||
id: 'parent-1',
|
||||
spread_strain: '#aabbcc',
|
||||
spread_generation: 0,
|
||||
});
|
||||
const child = mockAgent({
|
||||
id: 'child-1',
|
||||
spread_strain: '#ddeeff',
|
||||
parent_agent_id: 'parent-1',
|
||||
join_lane: 'winrm',
|
||||
spread_generation: 1,
|
||||
});
|
||||
render(<FleetTopologyMap agents={[parent, child]} />);
|
||||
expect(screen.getByText(/PLAGUE_EDGES/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user