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

@@ -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>
);