From 117801f88296a5e6c751a71ac51071b0a02836df Mon Sep 17 00:00:00 2001 From: drjones Date: Sat, 30 May 2026 15:10:50 -0700 Subject: [PATCH] feat: fleet intelligence dashboard -- health score, XMR price, contribution map, analytics --- server/internal/api/fleet_handler.go | 57 +++++ server/internal/api/router.go | 1 + server/web/src/api/client.ts | 5 +- .../web/src/components/Fleet/FleetPanels.css | 189 ++++++++++++++ .../web/src/components/Fleet/FleetPanels.tsx | 235 +++++++++++++++++- .../components/Visual/3D/FleetTopologyMap.tsx | 25 +- server/web/src/help/fleetAnalytics.ts | 195 +++++++++++++++ server/web/src/pages/DashboardPage.tsx | 174 +++++++++---- server/web/src/types/index.ts | 6 + 9 files changed, 826 insertions(+), 61 deletions(-) create mode 100644 server/web/src/help/fleetAnalytics.ts diff --git a/server/internal/api/fleet_handler.go b/server/internal/api/fleet_handler.go index 7e1ee81..38f0c20 100644 --- a/server/internal/api/fleet_handler.go +++ b/server/internal/api/fleet_handler.go @@ -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) diff --git a/server/internal/api/router.go b/server/internal/api/router.go index e5a97db..53e8cfd 100644 --- a/server/internal/api/router.go +++ b/server/internal/api/router.go @@ -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 diff --git a/server/web/src/api/client.ts b/server/web/src/api/client.ts index 93c2262..13dc5ff 100644 --- a/server/web/src/api/client.ts +++ b/server/web/src/api/client.ts @@ -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('/market/xmr'), + // Cancel an in-progress forge build by its cancel token. cancelBuild: (cancelToken: string) => fetchJSON<{ cancelled: boolean }>(`/builder/cancel/${encodeURIComponent(cancelToken)}`, { diff --git a/server/web/src/components/Fleet/FleetPanels.css b/server/web/src/components/Fleet/FleetPanels.css index f93b315..2d41c41 100644 --- a/server/web/src/components/Fleet/FleetPanels.css +++ b/server/web/src/components/Fleet/FleetPanels.css @@ -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; } diff --git a/server/web/src/components/Fleet/FleetPanels.tsx b/server/web/src/components/Fleet/FleetPanels.tsx index cdc2d60..2d0d7ec 100644 --- a/server/web/src/components/Fleet/FleetPanels.tsx +++ b/server/web/src/components/Fleet/FleetPanels.tsx @@ -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(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 ( @@ -123,10 +126,27 @@ export function EarningsEstimator({ hashrate }: { hashrate: number }) { {isReal ? '' : '~'}{data.xmr_per_day.toFixed(6)} XMR/day )} + {usdPerDay != null && ( +
≈ ${usdPerDay.toFixed(2)}/day
+ )} {isReal ? (
{data.pending_xmr != null && ( - Pending{data.pending_xmr.toFixed(8)} XMR + + Pending + + {data.pending_xmr.toFixed(8)} XMR + {usdPending != null && (${usdPending.toFixed(2)})} + + + )} + {daysLeft != null && ( + + Payout In + + {daysLeft < 1 ? `${(daysLeft * 24).toFixed(1)} hrs` : `${daysLeft.toFixed(1)} days`} + + )} {data.paid_xmr != null && ( Total Paid{data.paid_xmr.toFixed(4)} XMR @@ -141,8 +161,205 @@ export function EarningsEstimator({ hashrate }: { hashrate: number }) { ) : (
{data.note}
)} -
- {isReal ? 'SupportXMR live data · refreshes every 5 min' : 'Formula estimate · connect wallet for live data'} + {xmrPrice && ( +
+ XMR = ${xmrPrice.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +
+ )} +
+ {isReal ? 'SupportXMR live · 5 min cache' : 'Formula estimate · connect wallet for live data'} +
+ + ); +} + +// ─── 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 ( + +
+
+ FLEET HEALTH + {health.label} +
+
{health.score}
+
+
+
+
+

{health.sentence}

+ + ); +} + +// ─── Contribution Bars ─────────────────────────────────────────────────────── + +export function ContributionBars({ + bars, + xmrPerDay, + xmrPrice, +}: { + bars: ContributionBar[]; + xmrPerDay?: number; + xmrPrice?: number | null; +}) { + if (bars.length === 0) return null; + return ( + +

+ Contribution Map + +

+

+ Each bar shows a machine's share of total fleet hashrate. +

+
+ {bars.map((b) => { + const agentXmr = xmrPerDay != null ? xmrPerDay * (b.pct / 100) : null; + const agentUsd = agentXmr != null && xmrPrice ? agentXmr * xmrPrice : null; + return ( +
+ {b.name} +
+
+
+ {formatHashrate(b.hashrate)} + {b.pct.toFixed(1)}% + {agentUsd != null && ( + ${agentUsd.toFixed(3)}/d + )} +
+ ); + })} +
+ + ); +} + +// ─── 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 ( + +
+

+ Underperformers + ({underperformers.length}) + +

+ +
+

+ Nodes below 70% of fleet median ({formatHashrate(medianHashrate)}). +

+ {msg &&

{msg}

} +
+ {underperformers.map((a) => ( +
+ + {a.name} + {formatHashrate(a.hashrate_15m)} + + {medianHashrate > 0 ? `${((a.hashrate_15m / medianHashrate) * 100).toFixed(0)}% of median` : ''} + +
+ ))} +
+
+ ); +} + +// ─── 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 ( + +
OS / Arch Breakdown
+
+ {platforms.map((p, i) => ( +
+ {p.label} +
+
+
+ {p.count} +
+ ))} +
+ + ); +} + +// ─── LAN Group View ─────────────────────────────────────────────────────────── + +export function LANGroupView({ groups }: { groups: SubnetGroup[] }) { + if (groups.length < 2) return null; + return ( + +
Network Segments
+
+ {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 ( +
+ {g.subnet} + + {online}/{g.agents.length} online + {formatHashrate(hash)} + +
+ ); + })}
); diff --git a/server/web/src/components/Visual/3D/FleetTopologyMap.tsx b/server/web/src/components/Visual/3D/FleetTopologyMap.tsx index 1b16b9c..9e6bfe7 100644 --- a/server/web/src/components/Visual/3D/FleetTopologyMap.tsx +++ b/server/web/src/components/Visual/3D/FleetTopologyMap.tsx @@ -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(null); + const ringRef = useRef(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 + + {/* Staleness warning ring — amber halo for online-but-silent nodes */} + {isStale && ( + + + + + )} + {/* Connection Line */} - + {/* Laser Pulse simulating hashing packets */} - {isOnline && isHashing && ( + {isOnline && isHashing && !isStale && ( )} diff --git a/server/web/src/help/fleetAnalytics.ts b/server/web/src/help/fleetAnalytics.ts new file mode 100644 index 0000000..0b1942d --- /dev/null +++ b/server/web/src/help/fleetAnalytics.ts @@ -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(); + 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(); + 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 { + 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; +} diff --git a/server/web/src/pages/DashboardPage.tsx b/server/web/src/pages/DashboardPage.tsx index 12f77da..c51272d 100644 --- a/server/web/src/pages/DashboardPage.tsx +++ b/server/web/src/pages/DashboardPage.tsx @@ -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>(new Set()); const [bulkBusy, setBulkBusy] = useState(false); const [showMatrix, setShowMatrix] = useState(false); + const [advancedMode, setAdvancedMode] = useState(() => { + try { return localStorage.getItem('aether-dash-advanced') === '1'; } catch { return false; } + }); + const [xmrPrice, setXmrPrice] = useState(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 (
+ return ( +
+ + {/* Fleet Health — always above the fold */} + +

PERSONAL NETWORK · LIVE TELEMETRY

Command Deck

-

- {subtitle} -

+

{subtitle}

@@ -154,9 +200,22 @@ export default function DashboardPage() { {isConnected ? 'SIGNAL LOCKED' : 'RECONNECTING'} {agents.length} nodes registered
- + {advancedMode && ( + + )}
@@ -205,7 +264,7 @@ export default function DashboardPage() {
{formatHashrate(totalHashrate)}
{onlineCount} engines firing
- +
Fleet Online
{onlineCount} / {agents.length}
@@ -223,22 +282,37 @@ export default function DashboardPage() {
+ {/* ── Analytics row — always visible ─────────────────────────────────── */} + + + {(platforms.length > 0 || lanGroups.length > 1) && ( +
+ + +
+ )} + - + {/* ── Advanced-only panels ─────────────────────────────────────────────── */} + {advancedMode && }
- - - + {advancedMode && ( + + + + )}
- - - + {advancedMode && ( + + + + )}

@@ -347,41 +421,43 @@ export default function DashboardPage() { )}

-
-

- Share Log - -

- - - - - - - - - - - - {shares.length === 0 && ( - - )} - {shares.map((share) => ( - - - - - + {advancedMode && ( +
+

+ Share Log + +

+ +
TimeAgentStatusHash
No shares yet — awaiting proof of work...
{formatTime(share.timestamp)}{share.agent_id?.substring(0, 8)}… - - {share.accepted ? 'Accepted' : 'Rejected'} - - {share.hash?.substring(0, 24)}…
+ + + + + + - ))} - -
TimeAgentStatusHash
-
-
+ + + {shares.length === 0 && ( + No shares yet — awaiting proof of work... + )} + {shares.map((share) => ( + + {formatTime(share.timestamp)} + {share.agent_id?.substring(0, 8)}… + + + {share.accepted ? 'Accepted' : 'Rejected'} + + + {share.hash?.substring(0, 24)}… + + ))} + + + + + )} setShowMatrix(false)} />