Adds hundreds of unit/integration/e2e tests, fixes WS bcrypt auth, config merge, fleet analytics, agent schedule/log tail, and documents stale PROBLEMS items. Updates PROBLEMS.md, README, and test scripts; ignores local spread-kits and coverage dirs.
458 lines
19 KiB
TypeScript
458 lines
19 KiB
TypeScript
import { useState, useEffect, useMemo, useCallback, useRef } 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 & 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('');
|
||
const isConnectedRef = useRef(isConnected);
|
||
isConnectedRef.current = isConnected;
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
api.listAgents()
|
||
.then((data) => {
|
||
if (!cancelled && !isConnectedRef.current) setAgents(data);
|
||
})
|
||
.catch((err) => {
|
||
if (!cancelled) setLoadError(err instanceof Error ? err.message : 'Failed to load agents');
|
||
})
|
||
.finally(() => {
|
||
if (!cancelled) setLoading(false);
|
||
});
|
||
api.getServerInfo().then(setServerInfo).catch(() => {});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, []);
|
||
|
||
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);
|
||
alert(err instanceof Error ? err.message : 'Bulk command failed');
|
||
} 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}
|
||
filteredCount={filteredAgents.length}
|
||
onSelectAllFiltered={() => setSelectedIds(new Set(filteredAgents.map((a) => a.id)))}
|
||
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 & Tags</h3>
|
||
<p className="form-hint">Labels like "Living room PC" or "Rack B" — 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>
|
||
);
|
||
}
|