feat: fleet intelligence dashboard -- health score, XMR price, contribution map, analytics
This commit is contained in:
195
server/web/src/help/fleetAnalytics.ts
Normal file
195
server/web/src/help/fleetAnalytics.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import type { Agent, PoolStatus } from '../types';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface FleetHealth {
|
||||
score: number; // 0–100
|
||||
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; // 0–100
|
||||
}
|
||||
|
||||
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 (0–100):
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user