feat: fleet intelligence dashboard -- health score, XMR price, contribution map, analytics
This commit is contained in:
@@ -17,6 +17,18 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// xmrPriceEntry caches the CoinGecko price response to avoid hammering the API.
|
||||
type xmrPriceEntry struct {
|
||||
USD float64
|
||||
fetchedAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
xmrPriceMu sync.Mutex
|
||||
xmrPriceCache *xmrPriceEntry
|
||||
xmrPriceTTL = 10 * time.Minute
|
||||
)
|
||||
|
||||
type FleetHandler struct {
|
||||
db *db.Database
|
||||
ws *WSHub
|
||||
@@ -72,6 +84,51 @@ func (f *FleetHandler) GetAIActivity(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, f.ai.ActivitySnapshot())
|
||||
}
|
||||
|
||||
// GetXMRPrice returns the current XMR/USD price from CoinGecko, cached for 10 minutes.
|
||||
// Falls back to a 503 when the upstream is unreachable so the frontend can degrade gracefully.
|
||||
func (f *FleetHandler) GetXMRPrice(w http.ResponseWriter, r *http.Request) {
|
||||
xmrPriceMu.Lock()
|
||||
if xmrPriceCache != nil && time.Since(xmrPriceCache.fetchedAt) < xmrPriceTTL {
|
||||
usd := xmrPriceCache.USD
|
||||
at := xmrPriceCache.fetchedAt
|
||||
xmrPriceMu.Unlock()
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"usd": usd,
|
||||
"fetched_at": at.UTC().Format(time.RFC3339),
|
||||
"source": "coingecko",
|
||||
})
|
||||
return
|
||||
}
|
||||
xmrPriceMu.Unlock()
|
||||
|
||||
client := &http.Client{Timeout: 8 * time.Second}
|
||||
resp, err := client.Get("https://api.coingecko.com/api/v3/simple/price?ids=monero&vs_currencies=usd") //nolint:gosec
|
||||
if err != nil {
|
||||
http.Error(w, "price fetch failed: "+err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
|
||||
var raw map[string]map[string]float64
|
||||
if err := json.Unmarshal(body, &raw); err != nil || raw["monero"] == nil {
|
||||
http.Error(w, "price parse failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
usd := raw["monero"]["usd"]
|
||||
|
||||
entry := &xmrPriceEntry{USD: usd, fetchedAt: time.Now()}
|
||||
xmrPriceMu.Lock()
|
||||
xmrPriceCache = entry
|
||||
xmrPriceMu.Unlock()
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"usd": usd,
|
||||
"fetched_at": entry.fetchedAt.UTC().Format(time.RFC3339),
|
||||
"source": "coingecko",
|
||||
})
|
||||
}
|
||||
|
||||
// GetEarningsEstimate — kept for backwards compat; delegates to GetEarnings.
|
||||
func (f *FleetHandler) GetEarningsEstimate(w http.ResponseWriter, r *http.Request) {
|
||||
f.GetEarnings(w, r)
|
||||
|
||||
@@ -322,6 +322,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
r.Get("/pools/status", fleetHandler.GetPoolStatus)
|
||||
r.Get("/ai/activity", fleetHandler.GetAIActivity)
|
||||
r.Get("/earnings/estimate", fleetHandler.GetEarningsEstimate)
|
||||
r.Get("/market/xmr", fleetHandler.GetXMRPrice)
|
||||
}
|
||||
|
||||
// Shares
|
||||
|
||||
@@ -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)}`, {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user