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

472 lines
21 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useWebSocket } from '../hooks/useWebSocket';
import { api } from '../api/client';
import { useState, useEffect, useMemo, type CSSProperties } from 'react';
import { Link } from 'react-router-dom';
import type { Share } from '../types';
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,
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';
import FleetTopologyMap from '../components/Visual/3D/FleetTopologyMap';
import MatrixStreamOverlay from '../components/Visual/MatrixStreamOverlay';
import {
DEFAULT_FLEET_FILTERS,
filterFleetAgents,
agentIsIdleMiner,
formatHashrate,
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();
const [shares, setShares] = useState<Share[]>([]);
const [restAlerts, setRestAlerts] = useState<typeof fleetAlerts>([]);
const [restPools, setRestPools] = useState<typeof poolStatus>([]);
const [restAI, setRestAI] = useState<typeof aiActivity>([]);
const [subtitle, setSubtitle] = useState('security is just an emotion');
const [hashHistory, setHashHistory] = useState<{ time: string; value: number }[]>([]);
const [cpuHistory, setCpuHistory] = useState<{ time: string; value: number }[]>([]);
const [memHistory, setMemHistory] = useState<{ time: string; value: number }[]>([]);
const [hasBuilds, setHasBuilds] = useState(false);
const [filters, setFilters] = useState<FleetFilterState>(DEFAULT_FLEET_FILTERS);
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);
api.getConfig()
.then((cfg) => {
const s = cfg.server?.dashboard_subtitle?.trim();
if (s) setSubtitle(s);
})
.catch(console.error);
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(() => {
if (recentShares.length > 0) {
setShares((prev) => {
const merged = [...recentShares, ...prev];
const seen = new Set<string>();
return merged.filter((s) => {
const key = s.id != null ? String(s.id) : `${s.agent_id}-${s.hash}-${s.timestamp}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
}).slice(0, 20);
});
}
}, [recentShares]);
const totalHashrate = agents.reduce((sum, a) => sum + a.hashrate_15m, 0);
const onlineCount = agents.filter((a) => a.status === 'online').length;
const totalShares = agents.reduce((sum, a) => sum + a.shares_total, 0);
const acceptedShares = agents.reduce((sum, a) => sum + a.shares_good, 0);
const rejectedShares = agents.reduce((sum, a) => sum + a.shares_bad, 0);
const acceptRate = totalShares > 0 ? (acceptedShares / totalShares) * 100 : 0;
const avgCpu = agents.length > 0 ? agents.reduce((s, a) => s + a.cpu_usage_pct, 0) / agents.length : 0;
const avgMem = agents.length > 0 ? agents.reduce((s, a) => s + a.memory_usage_pct, 0) / agents.length : 0;
const onlinePct = agents.length > 0 ? (onlineCount / agents.length) * 100 : 0;
useEffect(() => {
const now = new Date().toLocaleTimeString();
setHashHistory((prev) => [...prev.slice(-59), { time: now, value: totalHashrate }]);
setCpuHistory((prev) => [...prev.slice(-59), { time: now, value: avgCpu }]);
setMemHistory((prev) => [...prev.slice(-59), { time: now, value: avgMem }]);
}, [totalHashrate, avgCpu, avgMem]);
const filteredAgents = useMemo(() => filterFleetAgents(agents, filters), [agents, filters]);
const topAgents = useMemo(
() => [...filteredAgents].sort((a, b) => b.hashrate_15m - a.hashrate_15m).slice(0, 12),
[filteredAgents]
);
const maxAgentHash = Math.max(...topAgents.map((a) => a.hashrate_15m), 1);
const activityItems = useMemo(
() =>
shares.slice(0, 12).map((s) => ({
id: String(s.id ?? `${s.agent_id}-${s.hash}`),
label: s.accepted ? 'OK' : 'BAD',
ok: s.accepted,
time: s.timestamp ? new Date(s.timestamp).toLocaleTimeString() : undefined,
})),
[shares]
);
const totalShareCount = agents.reduce((sum, a) => sum + a.shares_total, 0);
const alerts = fleetAlerts.length > 0 ? fleetAlerts : restAlerts;
const pools = poolStatus.length > 0 ? poolStatus : restPools;
const aiEntries = aiActivity.length > 0 ? aiActivity : restAI;
const agentNameMap = useMemo(
() => Object.fromEntries(agents.map((a) => [a.id, a.name])),
[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') {
targetIds = agents.filter((a) => selectedIds.has(a.id) && agentIsIdleMiner(a)).map((a) => a.id);
if (targetIds.length === 0) {
alert('No selected online agents with idle hashrate.');
return;
}
action = 'restart';
}
const onlineIds = targetIds.filter((id) => agents.find((a) => a.id === id)?.status === 'online');
if (onlineIds.length === 0) return;
if (action === 'stop' && !window.confirm(`Stop ${onlineIds.length} agent(s)?`)) return;
setBulkBusy(true);
try {
await api.sendBulkCommand(onlineIds, action);
} catch (err) {
console.error(err);
alert(err instanceof Error ? err.message : 'Bulk command failed');
} finally {
setBulkBusy(false);
}
};
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>
</div>
<div className="deck-hero-status">
<div className={`live-beacon ${isConnected ? 'on' : ''}`}>
<span className="beacon-ring" />
<span className="beacon-core" />
</div>
<div>
<span className="font-tech live-label">{isConnected ? 'SIGNAL LOCKED' : 'RECONNECTING'}</span>
<span className="live-sub">{agents.length} nodes registered</span>
</div>
<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>
<NeonCard accent="green" className="section" hud>
<h2 className="section-title font-display" style={{ marginBottom: '0.25rem' }}>
<span className="section-ornament"></span> Fleet Pipeline
<span className="section-line" />
</h2>
<p className="form-hint" style={{ marginTop: 0 }}>
Visual progress lit nodes mean that stage is active. <Link to="/guide">Open Field Guide </Link>
</p>
<FleetPipelineStatus
hasBuilds={hasBuilds}
agentCount={agents.length}
onlineCount={onlineCount}
hasHashrate={totalHashrate > 0}
hasShares={totalShareCount > 0}
/>
</NeonCard>
<section className="gauge-row">
<NeonCard accent="cyan" className="gauge-card" hud>
<GaugeRing
value={totalHashrate}
max={Math.max(totalHashrate * 1.2, 1000)}
label="Fleet Hash"
sublabel="15m avg"
color="var(--neon-cyan)"
size={110}
/>
</NeonCard>
<NeonCard accent="green" className="gauge-card" hud>
<GaugeRing value={onlinePct} label="Online" sublabel={`${onlineCount}/${agents.length}`} color="var(--neon-green)" size={110} />
</NeonCard>
<NeonCard accent="purple" className="gauge-card" hud>
<GaugeRing value={acceptRate} label="Accept" sublabel="share rate" color="var(--neon-purple)" size={110} />
</NeonCard>
<NeonCard accent="amber" className="gauge-card" hud>
<GaugeRing value={avgCpu} label="CPU" sublabel={`RAM ${avgMem.toFixed(0)}%`} color="var(--neon-amber)" size={110} />
</NeonCard>
</section>
<div className="grid-4 stats-grid steampunk-stats">
<NeonCard accent="cyan" className="stat-card-wrap">
<div className="stat-label font-tech">Total Hashrate</div>
<div className="stat-value hashrate neon-glow-cyan">{formatHashrate(totalHashrate)}</div>
<div className="stat-sub">{onlineCount} engines firing</div>
</NeonCard>
<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>
<div className="stat-sub">{agents.length - onlineCount} dormant</div>
</NeonCard>
<NeonCard accent="purple" className="stat-card-wrap">
<div className="stat-label font-tech">Accept Rate</div>
<div className="stat-value neon-glow-purple">{acceptRate.toFixed(1)}%</div>
<div className="stat-sub">{acceptedShares} valid · {rejectedShares} rejected</div>
</NeonCard>
<NeonCard accent="amber" className="stat-card-wrap">
<div className="stat-label font-tech">Resources</div>
<div className="stat-value">{avgCpu.toFixed(0)}% CPU</div>
<div className="stat-sub">{avgMem.toFixed(0)}% memory · fleet mean</div>
</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} />
{/* ── 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>
{advancedMode && (
<NeonCard accent="magenta" tilt3d>
<HashrateChart data={cpuHistory} title="CPU Pressure" color="#ff2da6" unit="%" height={300} />
</NeonCard>
)}
</div>
{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">
<span className="section-ornament"></span> Share Activity Pulse
<span className="section-line" />
</h2>
<ActivityPulse items={activityItems} />
</NeonCard>
<section className="section">
<ErrorBoundary
fallback={
<div className="card">
<p className="form-hint">3D fleet map unavailable on this GPU rest of the deck still works.</p>
</div>
}
>
<FleetTopologyMap agents={agents} />
</ErrorBoundary>
</section>
<section className="section">
<h2 className="section-title font-display">
<span className="section-ornament"></span> Machine Roster
<span className="section-line" />
</h2>
{agents.length > 0 && (
<FleetToolbar
agents={agents}
filters={filters}
onChange={setFilters}
selectedCount={selectedIds.size}
filteredCount={filteredAgents.length}
onSelectAllFiltered={() => setSelectedIds(new Set(filteredAgents.map((a) => a.id)))}
onBulkAction={handleBulkAction}
bulkBusy={bulkBusy}
/>
)}
<div className="agent-grid"> {agents.length === 0 && (
<NeonCard accent="brass" className="empty-state">
<div className="empty-icon"></div>
<h3>No miners on the wire</h3>
<p>Forge an installer, deploy once per machine nodes appear here with live neon telemetry.</p>
</NeonCard>
)}
{topAgents.map((agent, i) => (
<NeonCard
key={agent.id}
accent={agent.status === 'online' ? 'cyan' : 'brass'}
className="agent-card detailed machine-panel"
style={{ animationDelay: `${i * 0.05}s` } as CSSProperties}
>
<div className="agent-card-header">
<div className="agent-name">
<input
type="checkbox"
className="checkbox"
checked={selectedIds.has(agent.id)}
onChange={(e) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (e.target.checked) next.add(agent.id);
else next.delete(agent.id);
return next;
});
}}
/>
<span className={`status-dot ${agent.status}`} />
<span>{agent.name}</span>
{agent.platform && (
<span className="agent-tag-chip platform-badge" title={agent.os_version || agent.platform}>
{agent.platform}{agent.arch ? `/${agent.arch}` : ''}
</span>
)}
</div>
<span className={`status-badge ${agent.status}`}>{agent.status}</span>
</div>
{(agent.tags?.length ?? 0) > 0 && (
<div style={{ marginBottom: '0.35rem' }}>
{agent.tags!.map((t) => (
<span key={t} className="agent-tag-chip">{t}</span>
))}
</div>
)} <div className="agent-hash-bar">
<div
className="agent-hash-fill"
style={{ width: `${(agent.hashrate_15m / maxAgentHash) * 100}%` }}
/>
</div>
<div className="agent-detail-lines">
<div><span>Hash 15m</span><strong className="neon-glow-cyan">{formatHashrate(agent.hashrate_15m)}</strong></div>
<div><span>Hash 1m</span><strong>{formatHashrate(agent.hashrate_1m)}</strong></div>
<div><span>CPU / RAM</span><strong>{agent.cpu_usage_pct.toFixed(0)}% / {agent.memory_usage_pct.toFixed(0)}%</strong></div>
<div><span>Hardware</span><strong>{agent.cpu_cores} cores · {agent.memory_gb} GB</strong></div>
<div><span>Shares</span><strong>{agent.shares_good} ok · {agent.shares_bad} bad</strong></div>
<div><span>Node</span><strong className="mono-sm">{agent.ip || '—'} · {agent.id.slice(0, 8)}</strong></div>
<div><span>Uptime</span><strong>{formatUptime(agent.uptime_seconds)}</strong></div>
</div>
<AgentRemoteActions agent={agent} compact online={agent.status === 'online'} />
</NeonCard>
))}
{agents.length > 0 && topAgents.length === 0 && (
<NeonCard accent="brass" className="empty-state">
<p>No agents match current filters.</p>
</NeonCard>
)}
</div>
</section>
{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>
</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.
</footer>
</div>
);
}
function formatTime(t: string): string {
return new Date(t).toLocaleTimeString();
}