Files
AetherForge/server/web/src/pages/ROIPage.tsx

428 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useMemo } from 'react';
import { useWebSocket } from '../hooks/useWebSocket';
import { api } from '../api/client';
import { formatHashrate } from '../help/fleetFilters';
import { platformIcon } from '../help/platform';
import './ROIPage.css';
// ── helpers ───────────────────────────────────────────────────────────────
function fmt(n: number, decimals = 2) {
return n.toFixed(decimals);
}
function fmtUSD(n: number): string {
const sign = n < 0 ? '-' : '';
const abs = Math.abs(n);
if (abs >= 1000) return `${sign}$${(abs / 1000).toFixed(2)}k`;
return `${sign}$${abs.toFixed(2)}`;
}
function effBadge(pct: number): { label: string; cls: string } {
if (pct >= 75) return { label: 'TOP', cls: 'top' };
if (pct >= 40) return { label: 'MID', cls: 'mid' };
if (pct > 0) return { label: 'LOW', cls: 'low' };
return { label: 'OFF', cls: 'off' };
}
const WATT_PER_CORE_ESTIMATE = 15; // rough W per active CPU core
const XMR_COINGECKO_FALLBACK = 150; // offline fallback price USD
// ── ROI Page ──────────────────────────────────────────────────────────────
export default function ROIPage() {
const { agents } = useWebSocket();
const [xmrPrice, setXmrPrice] = useState<number | null>(null);
const [xmrPriceAt, setXmrPriceAt] = useState<string | null>(null);
const [priceLoading, setPriceLoading] = useState(true);
const [estXmrDay, setEstXmrDay] = useState<number | null>(null);
const [kwh, setKwh] = useState<number>(() => {
try { return parseFloat(localStorage.getItem('roi-kwh') ?? '0.10'); } catch { return 0.10; }
});
const [sparkData, setSparkData] = useState<number[]>([]);
// Fetch XMR price on mount, refresh every 10min
useEffect(() => {
const fetch = () => {
setPriceLoading(true);
api.getXmrPrice()
.then((r) => { setXmrPrice(r.usd); setXmrPriceAt(r.fetched_at); })
.catch(() => setXmrPrice(XMR_COINGECKO_FALLBACK))
.finally(() => setPriceLoading(false));
};
fetch();
const t = setInterval(fetch, 10 * 60 * 1000);
return () => clearInterval(t);
}, []);
// Earnings estimate from fleet hashrate
const onlineAgents = useMemo(() => agents.filter((a) => a.status === 'online'), [agents]);
const totalHashrate = useMemo(() => onlineAgents.reduce((s, a) => s + (a.hashrate_15m ?? 0), 0), [onlineAgents]);
useEffect(() => {
if (totalHashrate <= 0) { setEstXmrDay(null); return; }
api.getEarningsEstimate(totalHashrate)
.then((r) => setEstXmrDay(r.xmr_per_day ?? null))
.catch(() => setEstXmrDay(null));
}, [totalHashrate]);
// Spark history — sample every 4s
useEffect(() => {
const id = setInterval(() => {
setSparkData((prev) => [...prev.slice(-29), totalHashrate]);
}, 4000);
return () => clearInterval(id);
}, [totalHashrate]);
// Save kWh preference
const handleKwh = (v: number) => {
setKwh(v);
try { localStorage.setItem('roi-kwh', String(v)); } catch { /* noop */ }
};
// ── Derived numbers ──────────────────────────────────────────────────────
const price = xmrPrice ?? XMR_COINGECKO_FALLBACK;
const xmrPerDay = estXmrDay ?? 0;
const usdPerDay = xmrPerDay * price;
const usdPerWeek = usdPerDay * 7;
const usdPerMonth = usdPerDay * 30;
// Electricity cost estimate
const totalCores = onlineAgents.reduce((s, a) => s + (a.cpu_cores ?? 0), 0);
const estimatedWatts = totalCores * WATT_PER_CORE_ESTIMATE;
const kwhPerDay = (estimatedWatts / 1000) * 24;
const electricityCostDay = kwhPerDay * kwh;
const netProfitDay = usdPerDay - electricityCostDay;
// Per-node profitability — sorted by USD/day desc
const nodeProfit = useMemo(() => {
const maxHash = Math.max(...agents.map((a) => a.hashrate_15m ?? 0), 1);
return agents
.map((a) => {
const hr = a.hashrate_15m ?? 0;
const pct = hr / maxHash;
// Linear interpolation of fleet earnings by hashrate share
const nodeXmrDay = xmrPerDay > 0 && totalHashrate > 0
? (hr / totalHashrate) * xmrPerDay
: 0;
const nodeUsdDay = nodeXmrDay * price;
const nodeCores = a.cpu_cores ?? 0;
const nodeWatts = nodeCores * WATT_PER_CORE_ESTIMATE;
const nodeKwhDay = (nodeWatts / 1000) * 24;
const nodeElecCost = nodeKwhDay * kwh;
const nodeNet = nodeUsdDay - nodeElecCost;
return { a, hr, pct, nodeUsdDay, nodeXmrDay, nodeNet };
})
.sort((x, y) => y.nodeUsdDay - x.nodeUsdDay);
}, [agents, xmrPerDay, totalHashrate, price, kwh]);
// Platform breakdown
const platformStats = useMemo(() => {
const byPlatform: Record<string, { count: number; hashrate: number }> = {};
for (const a of onlineAgents) {
const p = a.platform ?? 'unknown';
if (!byPlatform[p]) byPlatform[p] = { count: 0, hashrate: 0 };
byPlatform[p].count++;
byPlatform[p].hashrate += a.hashrate_15m ?? 0;
}
const maxHr = Math.max(...Object.values(byPlatform).map((v) => v.hashrate), 1);
return Object.entries(byPlatform)
.sort((a, b) => b[1].hashrate - a[1].hashrate)
.map(([platform, stats]) => ({ platform, ...stats, pct: stats.hashrate / maxHr }));
}, [onlineAgents]);
// Mining method breakdown
const methodStats = useMemo(() => {
const counts: Record<string, number> = {};
for (const a of onlineAgents) {
const m = a.active_method ?? 'unknown';
counts[m] = (counts[m] ?? 0) + 1;
}
return Object.entries(counts).sort((a, b) => b[1] - a[1]);
}, [onlineAgents]);
const maxSparkVal = Math.max(...sparkData, 1);
// ── Empty state ──────────────────────────────────────────────────────────
if (agents.length === 0) {
return (
<div className="page fade-in roi-page">
<div className="roi-empty">
<span className="roi-empty-icon">💹</span>
No nodes online. Deploy agents to start tracking ROI.
</div>
</div>
);
}
// ── Render ───────────────────────────────────────────────────────────────
return (
<div className="page fade-in roi-page">
{/* ── Hero ─────────────────────────────────────────────────────────── */}
<header className="roi-hero">
<div className="roi-hero-text">
<p className="roi-eyebrow">FINANCIAL INTELLIGENCE</p>
<h1>ROI Dashboard</h1>
<p className="page-subtitle">
Live earnings · per-node profitability · net profit after electricity
</p>
</div>
<div className="roi-price-ticker">
<div className="roi-price-dot" />
<span className="roi-price-symbol">XMR</span>
{priceLoading ? (
<div className="roi-loading">
<div className="roi-spinner" />
<span>fetching</span>
</div>
) : (
<>
<span className="roi-price-usd">${xmrPrice?.toFixed(2) ?? '—'}</span>
<span className="roi-price-label">
USD{xmrPriceAt ? ` · ${new Date(xmrPriceAt).toLocaleTimeString()}` : ''}
</span>
</>
)}
</div>
</header>
{/* ── KPI Row ──────────────────────────────────────────────────────── */}
<div className="roi-kpi-row">
<div className="roi-kpi-card amber">
<div className="roi-kpi-accent" />
<div className="roi-kpi-label">XMR / DAY</div>
<div className="roi-kpi-value">{xmrPerDay > 0 ? fmt(xmrPerDay, 6) : '—'}</div>
<div className="roi-kpi-sub">at {formatHashrate(totalHashrate)}</div>
</div>
<div className="roi-kpi-card green">
<div className="roi-kpi-accent" />
<div className="roi-kpi-label">USD / DAY</div>
<div className="roi-kpi-value">{usdPerDay > 0 ? fmtUSD(usdPerDay) : '—'}</div>
<div className="roi-kpi-sub">gross revenue</div>
</div>
<div className="roi-kpi-card cyan">
<div className="roi-kpi-accent" />
<div className="roi-kpi-label">USD / MONTH</div>
<div className="roi-kpi-value">{usdPerMonth > 0 ? fmtUSD(usdPerMonth) : '—'}</div>
<div className="roi-kpi-sub">30-day projection</div>
</div>
<div className="roi-kpi-card magenta">
<div className="roi-kpi-accent" />
<div className="roi-kpi-label">NET PROFIT / DAY</div>
<div className={`roi-kpi-value ${netProfitDay < 0 ? '' : ''}`}>
{usdPerDay > 0 ? fmtUSD(netProfitDay) : '—'}
</div>
<div className="roi-kpi-sub">after electricity est.</div>
</div>
<div className="roi-kpi-card violet">
<div className="roi-kpi-accent" />
<div className="roi-kpi-label">ONLINE NODES</div>
<div className="roi-kpi-value">{onlineAgents.length}</div>
<div className="roi-kpi-sub">of {agents.length} total</div>
</div>
<div className="roi-kpi-card orange">
<div className="roi-kpi-accent" />
<div className="roi-kpi-label">EST. POWER DRAW</div>
<div className="roi-kpi-value">{estimatedWatts > 0 ? `${estimatedWatts}W` : '—'}</div>
<div className="roi-kpi-sub">{totalCores} cores × {WATT_PER_CORE_ESTIMATE}W est.</div>
</div>
</div>
{/* ── Main grid row 1 ──────────────────────────────────────────────── */}
<div className="roi-grid">
{/* Hashrate sparkline + projections */}
<div className="roi-section">
<div className="roi-section-title">
<span className="roi-section-ornament"></span> EARNINGS PROJECTION
</div>
{/* Spark */}
{sparkData.length > 1 && (
<div className="roi-spark" style={{ marginBottom: '1rem' }}>
{sparkData.map((v, i) => (
<div
key={i}
className="roi-spark-bar"
style={{ height: `${Math.max(4, (v / maxSparkVal) * 100)}%` }}
/>
))}
</div>
)}
<div className="roi-projection-grid">
<div className="roi-proj-item">
<div className="roi-proj-label">TODAY</div>
<div className="roi-proj-value green">{usdPerDay > 0 ? fmtUSD(usdPerDay) : '—'}</div>
</div>
<div className="roi-proj-item">
<div className="roi-proj-label">THIS WEEK</div>
<div className="roi-proj-value amber">{usdPerWeek > 0 ? fmtUSD(usdPerWeek) : '—'}</div>
</div>
<div className="roi-proj-item">
<div className="roi-proj-label">THIS MONTH</div>
<div className="roi-proj-value cyan">{usdPerMonth > 0 ? fmtUSD(usdPerMonth) : '—'}</div>
</div>
<div className="roi-proj-item">
<div className="roi-proj-label">THIS YEAR</div>
<div className="roi-proj-value magenta">{usdPerDay > 0 ? fmtUSD(usdPerDay * 365) : '—'}</div>
</div>
</div>
{/* Net profit calculator */}
<div style={{ marginTop: '1.25rem', borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: '1rem' }}>
<div className="roi-cost-row">
<span className="roi-cost-label">ELECTRICITY RATE</span>
<div className="roi-cost-input-wrap">
<input
type="number"
min={0}
max={10}
step={0.01}
value={kwh}
onChange={(e) => handleKwh(parseFloat(e.target.value) || 0)}
/>
<span className="roi-cost-unit">$/kWh</span>
</div>
<span className="roi-cost-label" style={{ color: 'var(--text-muted)' }}>
{kwhPerDay.toFixed(1)} kWh/day · {fmtUSD(electricityCostDay)}/day cost
</span>
</div>
<div className="roi-net-banner">
<span className="roi-net-label">NET DAILY PROFIT</span>
<span className={`roi-net-value ${netProfitDay < 0 ? 'negative' : ''}`}>
{usdPerDay > 0 ? fmtUSD(netProfitDay) : '—'}
</span>
</div>
</div>
</div>
{/* Platform breakdown */}
<div className="roi-section">
<div className="roi-section-title">
<span className="roi-section-ornament"></span> PLATFORM BREAKDOWN
</div>
{platformStats.length === 0 ? (
<p style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>No online agents.</p>
) : (
<div className="roi-platform-list">
{platformStats.map(({ platform, count, hashrate, pct }) => {
const colors: Record<string, string> = {
windows: '#00e8f5', linux: '#39ff14', darwin: '#b24bf3',
};
const color = colors[platform.toLowerCase()] ?? '#ffb020';
return (
<div key={platform} className="roi-platform-row">
<span className="roi-platform-icon">{platformIcon(platform)}</span>
<span className="roi-platform-label">{platform}</span>
<div className="roi-platform-bar-wrap">
<div className="roi-platform-bar-track">
<div
className="roi-platform-bar-fill"
style={{ width: `${pct * 100}%`, background: color }}
/>
</div>
</div>
<span className="roi-platform-val">
{count}n · {formatHashrate(hashrate)}
</span>
</div>
);
})}
</div>
)}
{/* Mining method chips */}
{methodStats.length > 0 && (
<>
<div className="roi-section-title" style={{ marginTop: '1.25rem', marginBottom: '0.75rem' }}>
<span className="roi-section-ornament"></span> ACTIVE MINING METHODS
</div>
<div className="roi-method-chips">
{methodStats.map(([method, count]) => (
<span key={method} className={`roi-method-chip ${method.toLowerCase().replace(/[^a-z]/g, '') || 'unknown'}`}>
{method} · {count}
</span>
))}
</div>
</>
)}
</div>
</div>
{/* ── Node profitability table (full width) ────────────────────────── */}
<div className="roi-full">
<div className="roi-section">
<div className="roi-section-title">
<span className="roi-section-ornament"></span>
NODE PROFITABILITY RANKING
<span style={{ marginLeft: 'auto', color: 'var(--text-muted)', fontSize: '0.6rem' }}>
sorted by USD/day
</span>
</div>
<table className="roi-node-table">
<thead>
<tr>
<th>#</th>
<th>NODE</th>
<th>PLATFORM</th>
<th>HASHRATE</th>
<th>XMR/DAY</th>
<th>USD/DAY</th>
<th>NET/DAY</th>
<th>SHARE</th>
<th>EFF</th>
</tr>
</thead>
<tbody>
{nodeProfit.slice(0, 25).map(({ a, hr, pct, nodeUsdDay, nodeXmrDay, nodeNet }, idx) => {
const isOffline = a.status !== 'online';
const badge = effBadge(pct * 100);
return (
<tr key={a.id} className={isOffline ? 'roi-node-offline' : ''}>
<td className="roi-node-rank">{idx + 1}</td>
<td className="roi-node-name">
{a.name}
</td>
<td>
<span className="roi-node-platform">{platformIcon(a.platform)}</span>
<span style={{ fontSize: '0.65rem', color: 'var(--text-muted)' }}>{a.platform ?? '—'}</span>
</td>
<td className="roi-node-hr">{hr > 0 ? formatHashrate(hr) : <span style={{ color: 'var(--text-muted)' }}></span>}</td>
<td style={{ fontFamily: 'monospace', fontSize: '0.7rem', color: '#ffb020' }}>
{nodeXmrDay > 0 ? nodeXmrDay.toFixed(6) : '—'}
</td>
<td className="roi-node-usd">{nodeUsdDay > 0 ? fmtUSD(nodeUsdDay) : '—'}</td>
<td style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: nodeNet >= 0 ? '#39ff14' : '#ff6b6b' }}>
{nodeUsdDay > 0 ? fmtUSD(nodeNet) : '—'}
</td>
<td>
<div className="roi-node-bar-wrap">
<div className="roi-node-bar-track">
<div className="roi-node-bar-fill" style={{ width: `${pct * 100}%` }} />
</div>
</div>
</td>
<td>
<span className={`roi-eff-badge ${badge.cls}`}>{badge.label}</span>
</td>
</tr>
);
})}
</tbody>
</table>
{nodeProfit.length > 25 && (
<p style={{ fontSize: '0.65rem', color: 'var(--text-muted)', marginTop: '0.5rem', fontFamily: 'monospace' }}>
{nodeProfit.length - 25} more nodes
</p>
)}
</div>
</div>
</div>
);
}