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

@@ -1,4 +1,4 @@
import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, EarningsEstimate, FusionEstimate } from '../types';
import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, EarningsEstimate, FusionEstimate, XmrPrice } from '../types';
import { authHeaders } from './auth';
const API_BASE = '/api/v1';
@@ -140,6 +140,9 @@ export const api = {
body: JSON.stringify({ username, password }),
}),
// XMR market price (server-side CoinGecko cache, refreshed every 10 min)
getXmrPrice: () => fetchJSON<XmrPrice>('/market/xmr'),
// Cancel an in-progress forge build by its cancel token.
cancelBuild: (cancelToken: string) =>
fetchJSON<{ cancelled: boolean }>(`/builder/cancel/${encodeURIComponent(cancelToken)}`, {

View File

@@ -209,3 +209,192 @@
width: 120px;
height: 120px;
}
/* ── Earnings USD ─────────────────────────────────────────────────────────── */
.earnings-usd-day {
font-size: 1rem;
font-weight: 600;
color: var(--neon-green, #39ff14);
margin-bottom: 0.25rem;
}
.er-usd { opacity: 0.7; margin-left: 0.25rem; }
.er-highlight { color: var(--neon-cyan, #00f5ff); font-weight: 700; }
/* ── Fleet Health Card ────────────────────────────────────────────────────── */
.fleet-health-card {
margin-bottom: 1rem;
}
.fh-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.6rem;
}
.fh-label {
font-size: 0.72rem;
letter-spacing: 0.08em;
opacity: 0.7;
display: block;
margin-bottom: 0.2rem;
}
.fh-status-chip {
font-family: var(--font-mono, monospace);
font-size: 0.7rem;
padding: 0.15rem 0.5rem;
border-radius: 3px;
font-weight: 700;
letter-spacing: 0.05em;
}
.fh-green { background: rgba(57, 255, 20, 0.15); color: var(--neon-green, #39ff14); border: 1px solid rgba(57, 255, 20, 0.35); }
.fh-amber { background: rgba(255, 176, 32, 0.15); color: var(--neon-amber, #ffb020); border: 1px solid rgba(255, 176, 32, 0.35); }
.fh-red { background: rgba(255, 60, 80, 0.15); color: #ff3c50; border: 1px solid rgba(255, 60, 80, 0.35); }
.fh-score {
font-size: 2.8rem;
font-weight: 800;
font-family: var(--font-display, sans-serif);
line-height: 1;
}
.fh-bar-track {
width: 100%;
height: 6px;
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
overflow: hidden;
margin-bottom: 0.6rem;
}
.fh-bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.6s ease;
}
.fh-sentence {
margin: 0;
font-size: 0.85rem;
opacity: 0.8;
}
/* ── Contribution Bars ────────────────────────────────────────────────────── */
.contrib-panel {}
.contrib-list { display: flex; flex-direction: column; gap: 0.45rem; }
.contrib-row {
display: grid;
grid-template-columns: 140px 1fr 80px 52px;
align-items: center;
gap: 0.5rem;
font-size: 0.82rem;
}
.contrib-row.has-usd {
grid-template-columns: 140px 1fr 80px 52px 70px;
}
.contrib-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
opacity: 0.85;
}
.contrib-track {
height: 8px;
background: rgba(255, 255, 255, 0.08);
border-radius: 4px;
overflow: hidden;
}
.contrib-fill {
height: 100%;
background: var(--neon-cyan, #00f5ff);
border-radius: 4px;
box-shadow: 0 0 6px var(--neon-cyan, #00f5ff);
transition: width 0.5s ease;
}
.contrib-hash, .contrib-pct { opacity: 0.75; font-size: 0.75rem; text-align: right; }
.contrib-usd { color: var(--neon-green, #39ff14); font-size: 0.72rem; text-align: right; }
/* ── Underperformer Panel ─────────────────────────────────────────────────── */
.underperf-panel {}
.underperf-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.25rem;
}
.underperf-count { color: var(--neon-amber, #ffb020); margin-left: 0.25rem; }
.underperf-restart-btn {
font-size: 0.75rem;
padding: 0.25rem 0.75rem;
white-space: nowrap;
flex-shrink: 0;
}
.underperf-list { display: flex; flex-direction: column; gap: 0.4rem; margin-top: 0.5rem; }
.underperf-row {
display: grid;
grid-template-columns: 14px 1fr 90px 1fr;
align-items: center;
gap: 0.5rem;
font-size: 0.82rem;
padding: 0.3rem 0.5rem;
background: rgba(255, 176, 32, 0.05);
border-left: 2px solid rgba(255, 176, 32, 0.4);
border-radius: 3px;
}
.underperf-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.underperf-hash { text-align: right; color: var(--neon-amber, #ffb020); }
.underperf-ratio { text-align: right; font-size: 0.72rem; }
/* ── OS / Arch Card ───────────────────────────────────────────────────────── */
.os-arch-card { }
.os-arch-bars { display: flex; flex-direction: column; gap: 0.45rem; margin-top: 0.6rem; }
.os-arch-row {
display: grid;
grid-template-columns: 90px 1fr 28px;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
}
.os-arch-label { font-weight: 600; font-size: 0.78rem; }
.os-arch-track {
height: 6px;
background: rgba(255,255,255,0.08);
border-radius: 3px;
overflow: hidden;
}
.os-arch-fill {
height: 100%;
border-radius: 3px;
opacity: 0.85;
transition: width 0.5s ease;
}
.os-arch-count { text-align: right; opacity: 0.7; font-size: 0.75rem; }
/* ── LAN Group Card ───────────────────────────────────────────────────────── */
.lan-group-card {}
.lan-group-list { display: flex; flex-direction: column; gap: 0.4rem; margin-top: 0.6rem; }
.lan-group-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 0.35rem 0.5rem;
background: rgba(57, 255, 20, 0.04);
border-left: 2px solid rgba(57, 255, 20, 0.25);
border-radius: 3px;
font-size: 0.82rem;
}
.lan-subnet { color: var(--neon-cyan, #00f5ff); font-size: 0.78rem; }
.lan-meta { display: flex; gap: 1rem; font-size: 0.76rem; opacity: 0.75; }
.lan-online { color: var(--neon-green, #39ff14); }
.lan-hash { font-family: var(--font-mono, monospace); }
/* ── Dashboard toggle ─────────────────────────────────────────────────────── */
.dash-mode-btn {
background: transparent;
border: 1px solid rgba(0,245,255,0.35);
color: var(--neon-cyan, #00f5ff);
font-family: monospace;
font-size: 0.78rem;
padding: 0.25rem 0.6rem;
border-radius: 3px;
cursor: pointer;
transition: background 0.15s;
}
.dash-mode-btn:hover { background: rgba(0,245,255,0.1); }
.dash-mode-btn.active { background: rgba(0,245,255,0.18); font-weight: 700; }

View File

@@ -1,7 +1,10 @@
import { useEffect, useState } from 'react';
import { api } from '../../api/client';
import NeonCard from '../NeonCard/NeonCard';
import type { FleetAlert, PoolStatus, AIActivityEntry } from '../../types';
import type { Agent, FleetAlert, PoolStatus, AIActivityEntry } from '../../types';
import type { FleetHealth, ContributionBar, SubnetGroup, PlatformCount } from '../../help/fleetAnalytics';
import { timeToPayout } from '../../help/fleetAnalytics';
import { formatHashrate } from '../../help/fleetFilters';
import './FleetPanels.css';
export function AlertBanner({ alerts }: { alerts: FleetAlert[] }) {
@@ -94,14 +97,11 @@ interface EarningsData {
last_payment_time?: string;
}
export function EarningsEstimator({ hashrate }: { hashrate: number }) {
export function EarningsEstimator({ hashrate, xmrPrice }: { hashrate: number; xmrPrice?: number | null }) {
const [data, setData] = useState<EarningsData | null>(null);
useEffect(() => {
if (hashrate <= 0) {
setData(null);
return;
}
if (hashrate <= 0) { setData(null); return; }
const controller = new AbortController();
api.getEarningsEstimate(hashrate).then((r: EarningsData) => {
if (!controller.signal.aborted) setData(r);
@@ -112,6 +112,9 @@ export function EarningsEstimator({ hashrate }: { hashrate: number }) {
if (!data || hashrate <= 0) return null;
const isReal = data.source === 'pool_api';
const usdPerDay = data.xmr_per_day != null && xmrPrice ? data.xmr_per_day * xmrPrice : null;
const usdPending = data.pending_xmr != null && xmrPrice ? data.pending_xmr * xmrPrice : null;
const daysLeft = timeToPayout(data.pending_xmr, data.xmr_per_day);
return (
<NeonCard accent="amber" className="stat-card-wrap earnings-estimator">
@@ -123,10 +126,27 @@ export function EarningsEstimator({ hashrate }: { hashrate: number }) {
{isReal ? '' : '~'}{data.xmr_per_day.toFixed(6)} XMR/day
</div>
)}
{usdPerDay != null && (
<div className="earnings-usd-day"> ${usdPerDay.toFixed(2)}/day</div>
)}
{isReal ? (
<div className="earnings-real-grid">
{data.pending_xmr != null && (
<span className="er-row"><span className="er-lbl">Pending</span><span className="er-val">{data.pending_xmr.toFixed(8)} XMR</span></span>
<span className="er-row">
<span className="er-lbl">Pending</span>
<span className="er-val">
{data.pending_xmr.toFixed(8)} XMR
{usdPending != null && <span className="er-usd"> (${usdPending.toFixed(2)})</span>}
</span>
</span>
)}
{daysLeft != null && (
<span className="er-row">
<span className="er-lbl">Payout In</span>
<span className="er-val er-highlight">
{daysLeft < 1 ? `${(daysLeft * 24).toFixed(1)} hrs` : `${daysLeft.toFixed(1)} days`}
</span>
</span>
)}
{data.paid_xmr != null && (
<span className="er-row"><span className="er-lbl">Total Paid</span><span className="er-val">{data.paid_xmr.toFixed(4)} XMR</span></span>
@@ -141,8 +161,205 @@ export function EarningsEstimator({ hashrate }: { hashrate: number }) {
) : (
<div className="stat-sub">{data.note}</div>
)}
<div className="stat-sub" style={{ marginTop: 4, opacity: 0.5, fontSize: '0.7rem' }}>
{isReal ? 'SupportXMR live data · refreshes every 5 min' : 'Formula estimate · connect wallet for live data'}
{xmrPrice && (
<div className="stat-sub" style={{ marginTop: 4, opacity: 0.6, fontSize: '0.7rem' }}>
XMR = ${xmrPrice.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
)}
<div className="stat-sub" style={{ marginTop: 2, opacity: 0.45, fontSize: '0.68rem' }}>
{isReal ? 'SupportXMR live · 5 min cache' : 'Formula estimate · connect wallet for live data'}
</div>
</NeonCard>
);
}
// ─── Fleet Health Card ────────────────────────────────────────────────────────
export function FleetHealthCard({ health }: { health: FleetHealth }) {
const accent = health.color === 'green' ? 'green' : health.color === 'amber' ? 'amber' : 'red';
const barColor =
health.color === 'green'
? 'var(--neon-green, #39ff14)'
: health.color === 'amber'
? 'var(--neon-amber, #ffb020)'
: '#ff3c50';
return (
<NeonCard accent={accent as any} className="fleet-health-card" hud>
<div className="fh-header">
<div>
<span className="fh-label font-tech">FLEET HEALTH</span>
<span className={`fh-status-chip fh-${health.color}`}>{health.label}</span>
</div>
<div className="fh-score" style={{ color: barColor }}>{health.score}</div>
</div>
<div className="fh-bar-track">
<div
className="fh-bar-fill"
style={{ width: `${health.score}%`, background: barColor, boxShadow: `0 0 8px ${barColor}` }}
/>
</div>
<p className="fh-sentence">{health.sentence}</p>
</NeonCard>
);
}
// ─── Contribution Bars ───────────────────────────────────────────────────────
export function ContributionBars({
bars,
xmrPerDay,
xmrPrice,
}: {
bars: ContributionBar[];
xmrPerDay?: number;
xmrPrice?: number | null;
}) {
if (bars.length === 0) return null;
return (
<NeonCard accent="cyan" className="section contrib-panel" hud>
<h2 className="section-title font-display">
<span className="section-ornament"></span> Contribution Map
<span className="section-line" />
</h2>
<p className="form-hint" style={{ marginTop: 0 }}>
Each bar shows a machine's share of total fleet hashrate.
</p>
<div className="contrib-list">
{bars.map((b) => {
const agentXmr = xmrPerDay != null ? xmrPerDay * (b.pct / 100) : null;
const agentUsd = agentXmr != null && xmrPrice ? agentXmr * xmrPrice : null;
return (
<div key={b.id} className="contrib-row">
<span className="contrib-name" title={b.name}>{b.name}</span>
<div className="contrib-track">
<div className="contrib-fill" style={{ width: `${b.pct}%` }} />
</div>
<span className="contrib-hash font-tech">{formatHashrate(b.hashrate)}</span>
<span className="contrib-pct font-tech">{b.pct.toFixed(1)}%</span>
{agentUsd != null && (
<span className="contrib-usd font-tech">${agentUsd.toFixed(3)}/d</span>
)}
</div>
);
})}
</div>
</NeonCard>
);
}
// ─── Underperformer List ──────────────────────────────────────────────────────
export function UnderperformerList({
underperformers,
medianHashrate,
}: {
underperformers: Agent[];
medianHashrate: number;
}) {
const [restarting, setRestarting] = useState(false);
const [msg, setMsg] = useState('');
if (underperformers.length === 0) return null;
const handleRestart = async () => {
setRestarting(true);
try {
await api.sendBulkCommand(underperformers.map((a) => a.id), 'restart');
setMsg(`Restart sent to ${underperformers.length} node(s).`);
} catch {
setMsg('Restart failed check agent connections.');
} finally {
setRestarting(false);
setTimeout(() => setMsg(''), 4000);
}
};
return (
<NeonCard accent="amber" className="section underperf-panel" hud>
<div className="underperf-header">
<h2 className="section-title font-display" style={{ margin: 0 }}>
<span className="section-ornament">◆</span> Underperformers
<span className="underperf-count"> ({underperformers.length})</span>
<span className="section-line" />
</h2>
<button
className="btn btn-outline underperf-restart-btn"
onClick={handleRestart}
disabled={restarting}
title="Send restart command to all underperforming nodes"
>
{restarting ? 'Restarting' : ' Restart All'}
</button>
</div>
<p className="form-hint" style={{ marginTop: '0.25rem' }}>
Nodes below 70% of fleet median ({formatHashrate(medianHashrate)}).
</p>
{msg && <p className="form-hint" style={{ color: 'var(--neon-amber)' }}>{msg}</p>}
<div className="underperf-list">
{underperformers.map((a) => (
<div key={a.id} className="underperf-row">
<span className={`status-dot ${a.status}`} />
<span className="underperf-name">{a.name}</span>
<span className="underperf-hash font-tech">{formatHashrate(a.hashrate_15m)}</span>
<span className="underperf-ratio font-tech" style={{ color: '#ff3c50' }}>
{medianHashrate > 0 ? `${((a.hashrate_15m / medianHashrate) * 100).toFixed(0)}% of median` : ''}
</span>
</div>
))}
</div>
</NeonCard>
);
}
// ─── OS / Arch Breakdown ──────────────────────────────────────────────────────
export function OSArchBreakdown({ platforms }: { platforms: PlatformCount[] }) {
if (platforms.length === 0) return null;
const total = platforms.reduce((s, p) => s + p.count, 0);
const colors = ['var(--neon-cyan)', 'var(--neon-purple)', 'var(--neon-amber)', 'var(--neon-green)', '#ff3c50'];
return (
<NeonCard accent="purple" className="os-arch-card" hud>
<div className="stat-label font-tech">OS / Arch Breakdown</div>
<div className="os-arch-bars">
{platforms.map((p, i) => (
<div key={p.key} className="os-arch-row">
<span className="os-arch-label" style={{ color: colors[i % colors.length] }}>{p.label}</span>
<div className="os-arch-track">
<div
className="os-arch-fill"
style={{ width: `${(p.count / total) * 100}%`, background: colors[i % colors.length] }}
/>
</div>
<span className="os-arch-count font-tech">{p.count}</span>
</div>
))}
</div>
</NeonCard>
);
}
// ─── LAN Group View ───────────────────────────────────────────────────────────
export function LANGroupView({ groups }: { groups: SubnetGroup[] }) {
if (groups.length < 2) return null;
return (
<NeonCard accent="green" className="lan-group-card" hud>
<div className="stat-label font-tech">Network Segments</div>
<div className="lan-group-list">
{groups.map((g) => {
const online = g.agents.filter((a) => a.status === 'online').length;
const hash = g.agents.reduce((s, a) => s + a.hashrate_15m, 0);
return (
<div key={g.subnet} className="lan-group-row">
<span className="lan-subnet font-tech">{g.subnet}</span>
<span className="lan-meta">
<span className="lan-online">{online}/{g.agents.length} online</span>
<span className="lan-hash">{formatHashrate(hash)}</span>
</span>
</div>
);
})}
</div>
</NeonCard>
);

View File

@@ -26,11 +26,18 @@ function LaserPulse({ start, end, color }: { start: [number, number, number], en
);
}
const STALE_THRESHOLD_MS = 5 * 60 * 1000;
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 ? '#00f5ff' : '#00aa55') : '#ff4444';
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;
useFrame((state) => {
if (isOnline && pulseRef.current) {
@@ -41,6 +48,11 @@ function AgentNode({ agent, position, serverPos }: { agent: Agent, position: [nu
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;
}
});
return (
@@ -48,11 +60,20 @@ function AgentNode({ agent, position, serverPos }: { agent: Agent, position: [nu
<Sphere ref={pulseRef} args={[0.3, 16, 16]}>
<meshStandardMaterial color={color} emissive={color} emissiveIntensity={isOnline ? (isHashing ? 2 : 1) : 0.2} wireframe />
</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 />
</mesh>
)}
{/* Connection Line */}
<Line points={[[0,0,0], [serverPos[0] - position[0], serverPos[1] - position[1], serverPos[2] - position[2]]]} color={isOnline ? '#004455' : '#330000'} lineWidth={1} transparent opacity={0.4} />
<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 && (
{isOnline && isHashing && !isStale && (
<LaserPulse start={[0,0,0]} end={[serverPos[0] - position[0], serverPos[1] - position[1], serverPos[2] - position[2]]} color="#00ffff" />
)}
</group>

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;
}

View File

@@ -7,7 +7,17 @@ import HashrateChart from '../components/Charts/HashrateChart';
import GaugeRing from '../components/Charts/GaugeRing';
import NeonCard from '../components/NeonCard/NeonCard';
import { FleetPipelineStatus, ActivityPulse } from '../components/Visual/VisualComponents';
import { AlertBanner, PoolStatusPanel, AIActivityPanel, EarningsEstimator } from '../components/Fleet/FleetPanels';
import {
AlertBanner,
PoolStatusPanel,
AIActivityPanel,
EarningsEstimator,
FleetHealthCard,
ContributionBars,
UnderperformerList,
OSArchBreakdown,
LANGroupView,
} from '../components/Fleet/FleetPanels';
import AgentRemoteActions from '../components/Fleet/AgentRemoteActions';
import FleetToolbar from '../components/Fleet/FleetToolbar';
import ErrorBoundary from '../components/ErrorBoundary';
@@ -21,6 +31,14 @@ import {
formatUptime,
} from '../help/fleetFilters';
import type { FleetFilterState } from '../help/fleetFilters';
import {
computeFleetHealth,
contributionBars,
findUnderperformers,
fleetMedianHashrate,
groupBySubnet,
osArchBreakdown,
} from '../help/fleetAnalytics';
import './Pages.css';
export default function DashboardPage() {
const { isConnected, agents, recentShares, fleetAlerts, poolStatus, aiActivity } = useWebSocket();
@@ -37,6 +55,18 @@ export default function DashboardPage() {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [bulkBusy, setBulkBusy] = useState(false);
const [showMatrix, setShowMatrix] = useState(false);
const [advancedMode, setAdvancedMode] = useState<boolean>(() => {
try { return localStorage.getItem('aether-dash-advanced') === '1'; } catch { return false; }
});
const [xmrPrice, setXmrPrice] = useState<number | null>(null);
const toggleAdvanced = () =>
setAdvancedMode((prev) => {
const next = !prev;
try { localStorage.setItem('aether-dash-advanced', next ? '1' : '0'); } catch { /* ignore */ }
return next;
});
useEffect(() => {
api.getRecentShares(20).then(setShares).catch(console.error);
api.listBuilds().then((b) => setHasBuilds(b.length > 0)).catch(console.error);
@@ -49,6 +79,11 @@ export default function DashboardPage() {
api.getAlerts().then(setRestAlerts).catch(console.error);
api.getPoolStatus().then(setRestPools).catch(console.error);
api.getAIActivity().then(setRestAI).catch(console.error);
// XMR market price — refresh every 10 min matching server-side cache TTL
const fetchPrice = () => api.getXmrPrice().then((r) => setXmrPrice(r.usd)).catch(() => {});
fetchPrice();
const priceTimer = setInterval(fetchPrice, 10 * 60 * 1000);
return () => clearInterval(priceTimer);
}, []);
useEffect(() => {
@@ -111,6 +146,14 @@ export default function DashboardPage() {
[agents]
);
// ── Analytics ─────────────────────────────────────────────────────────────
const fleetHealth = useMemo(() => computeFleetHealth(agents, pools), [agents, pools]);
const contribs = useMemo(() => contributionBars(agents), [agents]);
const underperformers = useMemo(() => findUnderperformers(agents), [agents]);
const medianHash = useMemo(() => fleetMedianHashrate(agents), [agents]);
const lanGroups = useMemo(() => groupBySubnet(agents), [agents]);
const platforms = useMemo(() => osArchBreakdown(agents), [agents]);
const handleBulkAction = async (action: string) => {
let targetIds = [...selectedIds];
if (action === 'restart_idle') {
@@ -135,15 +178,18 @@ export default function DashboardPage() {
}
};
return ( <div className="page fade-in command-deck">
return (
<div className="page fade-in command-deck">
<AlertBanner alerts={alerts} />
{/* Fleet Health — always above the fold */}
<FleetHealthCard health={fleetHealth} />
<header className="deck-hero">
<div className="deck-hero-text">
<p className="deck-eyebrow font-tech">PERSONAL NETWORK · LIVE TELEMETRY</p>
<h1>Command Deck</h1>
<p className="page-subtitle">
{subtitle}
</p>
<p className="page-subtitle">{subtitle}</p>
</div>
<div className="deck-hero-status">
<div className={`live-beacon ${isConnected ? 'on' : ''}`}>
@@ -154,9 +200,22 @@ export default function DashboardPage() {
<span className="font-tech live-label">{isConnected ? 'SIGNAL LOCKED' : 'RECONNECTING'}</span>
<span className="live-sub">{agents.length} nodes registered</span>
</div>
<button className="button matrix-toggle-btn" onClick={() => setShowMatrix(true)} style={{ marginLeft: '1rem', background: 'transparent', border: '1px solid #0f0', color: '#0f0', fontFamily: 'monospace' }}>
[RAW_STREAM]
<button
className={`dash-mode-btn${advancedMode ? ' active' : ''}`}
onClick={toggleAdvanced}
title={advancedMode ? 'Switch to Overview (hide charts / logs)' : 'Switch to Advanced (show all panels)'}
>
{advancedMode ? '[OVERVIEW]' : '[ADVANCED]'}
</button>
{advancedMode && (
<button
className="button matrix-toggle-btn"
onClick={() => setShowMatrix(true)}
style={{ background: 'transparent', border: '1px solid #0f0', color: '#0f0', fontFamily: 'monospace' }}
>
[RAW_STREAM]
</button>
)}
</div>
</header>
@@ -205,7 +264,7 @@ export default function DashboardPage() {
<div className="stat-value hashrate neon-glow-cyan">{formatHashrate(totalHashrate)}</div>
<div className="stat-sub">{onlineCount} engines firing</div>
</NeonCard>
<EarningsEstimator hashrate={totalHashrate} />
<EarningsEstimator hashrate={totalHashrate} xmrPrice={xmrPrice} />
<NeonCard accent="green" className="stat-card-wrap">
<div className="stat-label font-tech">Fleet Online</div>
<div className="stat-value accepted">{onlineCount} <span className="stat-dim">/ {agents.length}</span></div>
@@ -223,22 +282,37 @@ export default function DashboardPage() {
</NeonCard>
</div>
{/* ── Analytics row — always visible ─────────────────────────────────── */}
<ContributionBars bars={contribs} xmrPrice={xmrPrice} />
<UnderperformerList underperformers={underperformers} medianHashrate={medianHash} />
{(platforms.length > 0 || lanGroups.length > 1) && (
<div className="grid-2" style={{ gap: '1rem', marginTop: '1rem' }}>
<OSArchBreakdown platforms={platforms} />
<LANGroupView groups={lanGroups} />
</div>
)}
<PoolStatusPanel pools={pools} />
<AIActivityPanel entries={aiEntries} agentNames={agentNameMap} />
{/* ── Advanced-only panels ─────────────────────────────────────────────── */}
{advancedMode && <AIActivityPanel entries={aiEntries} agentNames={agentNameMap} />}
<div className="grid-2 chart-row">
<NeonCard accent="cyan" tilt3d>
<HashrateChart data={hashHistory} title="Fleet Hashrate Wave" color="#00f5ff" unit="H/s" height={300} />
</NeonCard>
<NeonCard accent="magenta" tilt3d>
<HashrateChart data={cpuHistory} title="CPU Pressure" color="#ff2da6" unit="%" height={300} />
</NeonCard>
{advancedMode && (
<NeonCard accent="magenta" tilt3d>
<HashrateChart data={cpuHistory} title="CPU Pressure" color="#ff2da6" unit="%" height={300} />
</NeonCard>
)}
</div>
<NeonCard accent="brass" className="chart-row-full" tilt3d>
<HashrateChart data={memHistory} title="Memory Load — Fleet Average" color="#ffb020" unit="%" height={220} />
</NeonCard>
{advancedMode && (
<NeonCard accent="brass" className="chart-row-full" tilt3d>
<HashrateChart data={memHistory} title="Memory Load — Fleet Average" color="#ffb020" unit="%" height={220} />
</NeonCard>
)}
<NeonCard accent="purple" className="section" hud>
<h2 className="section-title font-display">
@@ -347,41 +421,43 @@ export default function DashboardPage() {
)}
</div>
</section>
<section className="section">
<h2 className="section-title font-display">
<span className="section-ornament"></span> Share Log
<span className="section-line" />
</h2>
<NeonCard accent="brass" className="table-card" hud>
<table className="shares-table steampunk-table">
<thead>
<tr>
<th>Time</th>
<th>Agent</th>
<th>Status</th>
<th>Hash</th>
</tr>
</thead>
<tbody>
{shares.length === 0 && (
<tr><td colSpan={4} className="empty-table">No shares yet awaiting proof of work...</td></tr>
)}
{shares.map((share) => (
<tr key={share.id}>
<td className="time-cell font-tech">{formatTime(share.timestamp)}</td>
<td className="mono-sm">{share.agent_id?.substring(0, 8)}</td>
<td>
<span className={`status-badge ${share.accepted ? 'online' : 'error'}`}>
{share.accepted ? 'Accepted' : 'Rejected'}
</span>
</td>
<td className="hash-cell mono-sm">{share.hash?.substring(0, 24)}</td>
{advancedMode && (
<section className="section">
<h2 className="section-title font-display">
<span className="section-ornament"></span> Share Log
<span className="section-line" />
</h2>
<NeonCard accent="brass" className="table-card" hud>
<table className="shares-table steampunk-table">
<thead>
<tr>
<th>Time</th>
<th>Agent</th>
<th>Status</th>
<th>Hash</th>
</tr>
))}
</tbody>
</table>
</NeonCard>
</section>
</thead>
<tbody>
{shares.length === 0 && (
<tr><td colSpan={4} className="empty-table">No shares yet awaiting proof of work...</td></tr>
)}
{shares.map((share) => (
<tr key={share.id}>
<td className="time-cell font-tech">{formatTime(share.timestamp)}</td>
<td className="mono-sm">{share.agent_id?.substring(0, 8)}</td>
<td>
<span className={`status-badge ${share.accepted ? 'online' : 'error'}`}>
{share.accepted ? 'Accepted' : 'Rejected'}
</span>
</td>
<td className="hash-cell mono-sm">{share.hash?.substring(0, 24)}</td>
</tr>
))}
</tbody>
</table>
</NeonCard>
</section>
)}
<MatrixStreamOverlay active={showMatrix} onClose={() => setShowMatrix(false)} />
<footer style={{ marginTop: '3rem', paddingTop: '1rem', borderTop: '1px solid #333', textAlign: 'center', color: '#ff4444', fontSize: '0.85rem', fontFamily: 'monospace' }}>
DISCLAIMER: Use only on personal machines on your own network. Anything else is a crime.

View File

@@ -223,6 +223,12 @@ export interface EarningsEstimate {
note: string;
}
export interface XmrPrice {
usd: number;
fetched_at: string;
source: string;
}
export interface BuildRequest {
worker_name: string;
server_url: string;