feat: fleet intelligence dashboard -- health score, XMR price, contribution map, analytics

This commit is contained in:
drjones
2026-05-30 15:10:50 -07:00
parent 2e36483158
commit 117801f882
9 changed files with 826 additions and 61 deletions

View File

@@ -0,0 +1,195 @@
import type { Agent, PoolStatus } from '../types';
// ─── Types ───────────────────────────────────────────────────────────────────
export interface FleetHealth {
score: number; // 0100
color: 'green' | 'amber' | 'red';
label: 'NOMINAL' | 'DEGRADED' | 'CRITICAL';
sentence: string; // One human-readable summary line
issues: string[]; // Short issue fragments that compose the sentence
}
export interface ContributionBar {
id: string;
name: string;
hashrate: number;
pct: number; // 0100
}
export interface SubnetGroup {
subnet: string;
agents: Agent[];
}
export interface PlatformCount {
key: string;
label: string; // Human-friendly: Win/Linux/macOS + arch
count: number;
}
// ─── Health Score ─────────────────────────────────────────────────────────────
/**
* Weighted fleet health (0100):
* Online % 40 pts
* Accept rate 30 pts
* Pool connected 20 pts
* Has hashrate 10 pts
*/
export function computeFleetHealth(agents: Agent[], pools: PoolStatus[]): FleetHealth {
const issues: string[] = [];
let score = 0;
// 40 pts — online ratio
const online = agents.filter((a) => a.status === 'online');
const onlinePct = agents.length > 0 ? (online.length / agents.length) * 100 : 100;
score += (onlinePct / 100) * 40;
if (agents.length > 0 && onlinePct < 100) {
issues.push(`${agents.length - online.length} node${agents.length - online.length !== 1 ? 's' : ''} offline`);
}
// 30 pts — share accept rate
const totalShares = agents.reduce((s, a) => s + a.shares_total, 0);
const goodShares = agents.reduce((s, a) => s + a.shares_good, 0);
const acceptRate = totalShares > 0 ? (goodShares / totalShares) * 100 : 100;
score += (acceptRate / 100) * 30;
if (totalShares > 10 && acceptRate < 95) {
issues.push(`${(100 - acceptRate).toFixed(1)}% rejection rate`);
}
// 20 pts — pool green
const poolGreen = pools.length === 0 || pools.some((p) => p.status === 'green');
score += poolGreen ? 20 : 0;
if (pools.length > 0 && !poolGreen) {
issues.push('pool connection degraded');
}
// 10 pts — hashrate > 0
const totalHash = agents.reduce((s, a) => s + a.hashrate_15m, 0);
score += totalHash > 0 ? 10 : 0;
if (agents.length > 0 && totalHash === 0) {
issues.push('no hashrate detected');
}
const rounded = Math.round(score);
const color = rounded >= 80 ? 'green' : rounded >= 55 ? 'amber' : 'red';
const label = rounded >= 80 ? 'NOMINAL' : rounded >= 55 ? 'DEGRADED' : 'CRITICAL';
const sentence =
issues.length === 0
? 'All systems nominal — fleet is mining at full capacity.'
: issues.join(' · ');
return { score: rounded, color, label, sentence, issues };
}
// ─── Contribution Map ─────────────────────────────────────────────────────────
export function contributionBars(agents: Agent[]): ContributionBar[] {
const total = agents.reduce((s, a) => s + a.hashrate_15m, 0);
return agents
.filter((a) => a.status === 'online')
.map((a) => ({
id: a.id,
name: a.name,
hashrate: a.hashrate_15m,
pct: total > 0 ? (a.hashrate_15m / total) * 100 : 0,
}))
.sort((a, b) => b.hashrate - a.hashrate);
}
// ─── Underperformers ─────────────────────────────────────────────────────────
function median(values: number[]): number {
if (values.length === 0) return 0;
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0
? (sorted[mid - 1] + sorted[mid]) / 2
: sorted[mid];
}
/**
* Returns online agents whose 15m hashrate is below 70 % of the fleet median.
* Requires at least 2 online agents with non-zero hash to be meaningful.
*/
export function findUnderperformers(agents: Agent[]): Agent[] {
const online = agents.filter((a) => a.status === 'online' && a.hashrate_15m > 0);
if (online.length < 2) return [];
const med = median(online.map((a) => a.hashrate_15m));
return online.filter((a) => a.hashrate_15m < med * 0.7);
}
export function fleetMedianHashrate(agents: Agent[]): number {
const online = agents.filter((a) => a.status === 'online' && a.hashrate_15m > 0);
return median(online.map((a) => a.hashrate_15m));
}
// ─── LAN Grouping ─────────────────────────────────────────────────────────────
/** Group agents by /24 subnet (first 3 octets). Uses the same logic as fleetFilters. */
export function groupBySubnet(agents: Agent[]): SubnetGroup[] {
const map = new Map<string, Agent[]>();
for (const a of agents) {
const ip = (a.ip || '').trim();
const parts = ip.split('.');
const subnet = parts.length >= 3 ? `${parts[0]}.${parts[1]}.${parts[2]}.x` : 'unrouted';
if (!map.has(subnet)) map.set(subnet, []);
map.get(subnet)!.push(a);
}
return [...map.entries()]
.sort((a, b) => b[1].length - a[1].length)
.map(([subnet, ags]) => ({ subnet, agents: ags }));
}
// ─── OS / Arch Breakdown ──────────────────────────────────────────────────────
export function osArchBreakdown(agents: Agent[]): PlatformCount[] {
const map = new Map<string, number>();
for (const a of agents) {
const plat = a.platform || 'unknown';
const arch = a.arch || '';
const key = arch ? `${plat}/${arch}` : plat;
map.set(key, (map.get(key) ?? 0) + 1);
}
return [...map.entries()]
.sort((a, b) => b[1] - a[1])
.map(([key, count]) => ({
key,
label: key
.replace('windows', 'Win')
.replace('linux', 'Linux')
.replace('darwin', 'macOS'),
count,
}));
}
// ─── Staleness ────────────────────────────────────────────────────────────────
const STALE_MS = 5 * 60 * 1000; // 5 minutes
/**
* Returns a Set of agent IDs that claim "online" but haven't been seen
* in more than 5 minutes — silently dead before the offline alert fires.
*/
export function staleAgentIds(agents: Agent[]): Set<string> {
const now = Date.now();
return new Set(
agents
.filter(
(a) =>
a.status === 'online' &&
a.last_seen &&
now - new Date(a.last_seen).getTime() > STALE_MS,
)
.map((a) => a.id),
);
}
// ─── Time to Payout ───────────────────────────────────────────────────────────
/** Returns days until next payout, or null if insufficient data. */
export function timeToPayout(pendingXmr: number | undefined, xmrPerDay: number | undefined): number | null {
if (!pendingXmr || !xmrPerDay || xmrPerDay <= 0 || pendingXmr <= 0) return null;
return pendingXmr / xmrPerDay;
}