472 lines
21 KiB
TypeScript
472 lines
21 KiB
TypeScript
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();
|
||
}
|