Add phenotype cloning, failure atlas, AI court session, and clearance L0-L4
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
This commit is contained in:
432
server/web/src/pages/ROIPage.tsx
Normal file
432
server/web/src/pages/ROIPage.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import { api } from '../api/client';
|
||||
import { formatHashrate } from '../help/fleetFilters';
|
||||
import './ROIPage.css';
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
function fmt(n: number, decimals = 2) {
|
||||
return n.toFixed(decimals);
|
||||
}
|
||||
|
||||
function fmtUSD(n: number): string {
|
||||
if (n >= 1000) return `$${(n / 1000).toFixed(2)}k`;
|
||||
return `$${n.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function platformIcon(platform?: string): string {
|
||||
const p = (platform ?? '').toLowerCase();
|
||||
if (p.includes('win')) return '⊞';
|
||||
if (p.includes('linux')) return '🐧';
|
||||
if (p.includes('darwin')) return '';
|
||||
return '⬡';
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user