Final sweep: Crucible fixes, Path Tracer polish, forge progress, tests green.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled

Align dashboard subtitle default and UpsertAgent tests with fleet label behavior; WebSocket coalesce and PathForge hardening; Crucible expanded ops and visual DV fixes; Vitest 610/610 and full test-suite pass; trim PROBLEMS.md to open items only.
This commit is contained in:
AetherForge
2026-06-06 18:07:47 -07:00
parent e65753ce49
commit 6372b07e6c
40 changed files with 1495 additions and 794 deletions

View File

@@ -34,7 +34,7 @@
<li><a href="#build-manager">Build Manager</a></li>
<li><a href="#dashboard">Fleet &amp; Crucible</a></li>
<li><a href="#crucible-ops">Crucible Commands</a></li>
<li><a href="#spread-campaigns">Emberwake &amp; Spread</a></li>
<li><a href="#spread-campaigns">Emberwake &amp; Campaigns</a></li>
<li><a href="SPREAD_TECHNIQUES.html">Spread Techniques</a></li>
<li><a href="#wordpress-plugin-supply-chain">WordPress plugin</a></li>
<li><a href="#npm-postinstall-helper">npm postinstall</a></li>
@@ -70,7 +70,7 @@
secret baked into every agent.
</p>
<p>
The workflow is: <strong>Calibrate</strong> (Settings)<strong>Forge</strong> (Builder) → deploy once per
The workflow is: <strong>Calibrate</strong><strong>Forge</strong> → deploy once per
worker → monitor on <strong>Command Deck</strong> and <strong>Fleet Roster</strong>. Optional layers include
prep/movie fusion, USB perpetual propagation, LAN lateral spread, Emberwake campaign links, and Path Tracer
WireGuard multi-hop routing.
@@ -164,9 +164,9 @@ bin\miner-server.exe -port 8989 -data .\data</code></pre>
<p>Workers auto-convert <code>http(s)://</code><code>ws(s)://…/ws/agent</code>. Only outbound access from workers is required.</p>
</section>
<!-- 3. Dashboard -->
<!-- 3. Command Deck -->
<section id="dashboard">
<h2>Dashboard</h2>
<h2>Command Deck</h2>
<p>
The React command deck is the operator-facing UI. After login, the main routes cover fleet overview,
agent roster, forge builder, build manager, Crucible remote terminal, Emberwake campaigns, Path Tracer,
@@ -187,7 +187,7 @@ bin\miner-server.exe -port 8989 -data .\data</code></pre>
<li>Install funnel — agents per build over 7 days, USB-spread flag</li>
<li>Operator audit strip — last forge, commands, config saves</li>
</ul>
<div class="wiki-screenshot">[Screenshot: Dashboard fleet health + contribution map]</div>
<div class="wiki-screenshot">[Screenshot: Command Deck fleet health + contribution map]</div>
<h3>Command deck route guide</h3>
<table class="wiki-table">
@@ -271,7 +271,7 @@ bin\miner-server.exe -port 8989 -data .\data</code></pre>
may auto-download WireGuard on first use if not pre-installed.
</p>
<h3>Calibrate (Settings)</h3>
<h3>Calibrate</h3>
<ul>
<li>Wallet, pool, public URL, users, fleet secret rotation</li>
<li>Telegram + SMTP alert notifications and thresholds</li>
@@ -280,9 +280,9 @@ bin\miner-server.exe -port 8989 -data .\data</code></pre>
<li><code>public_builds_enabled</code> — expose all builds on unauthenticated public API</li>
</ul>
<h3>Dashboard operating rhythm</h3>
<h3>Command Deck operating rhythm</h3>
<ol>
<li>Open <strong>Dashboard</strong> first and check Fleet Health, Nodes Live, and Accept.</li>
<li>Open <strong>Command Deck</strong> first and check Fleet Health, Nodes Live, and Accept.</li>
<li>If health is amber/red, inspect pool status and the underperformer list before touching config.</li>
<li>Open <strong>Agents</strong> only after the dashboard tells you which machines need attention.</li>
<li>Use <strong>Builds</strong> to verify what is currently pinned before forging anything new.</li>
@@ -394,7 +394,7 @@ bin\miner-server.exe -port 8989 -data .\data</code></pre>
<section id="forge">
<h2>Forge / Builder</h2>
<p>
The Forge page compiles per-target worker binaries via <code>POST /api/v1/builder/build</code>. Preflight
Forge compiles per-target worker binaries via <code>POST /api/v1/builder/build</code>. Preflight
checks wallet, server URL, pool, fusion payload, and AI settings before compile. Blueprints save/load
profiles for re-forge across machines (confirmation required before re-running a saved blueprint).
</p>
@@ -596,9 +596,9 @@ curl -sL http://YOUR-DECK:8989/install.sh | bash
http://YOUR-DECK:8989/get?pin={build_id}&amp;c=campaign-slug</code></pre>
</section>
<!-- 5. Spread & Campaigns -->
<!-- 5. Emberwake & Campaigns -->
<section id="spread-campaigns">
<h2>Spread &amp; Campaigns</h2>
<h2>Emberwake &amp; Campaigns</h2>
<p>
AetherForge supports multiple distribution vectors: USB perpetual propagation, LAN lateral movement (SMB /
WinRM on Windows, SSH on Linux/macOS), waterhole dropper pages, and one-liner install scripts. Campaign
@@ -954,7 +954,7 @@ go run ./cmd/mine-validate -seconds 20 -threads 2</code></pre>
<!-- 8b. Calibrate -->
<section id="calibrate">
<h2>Calibrate (Settings)</h2>
<h2>Calibrate</h2>
<p>
Route <code>/settings</code> — server-side defaults and fleet policy. Changes here affect <strong>new</strong>
Forge forms and live server behaviour; already-forged agents keep baked settings until re-forged (except

View File

@@ -266,10 +266,10 @@ a:hover { color: var(--ember); }
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1.1rem 1.2rem;
padding: var(--deck-card-padding);
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
border-radius: var(--deck-card-radius);
transition: border-color 0.2s, box-shadow 0.2s;
}
@@ -305,7 +305,7 @@ a:hover { color: var(--ember); }
.platform-card p {
margin: 0;
font-size: 0.82rem;
font-size: 0.85rem;
flex: 1;
}
@@ -382,8 +382,8 @@ code.inline {
.info-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.1rem 1.2rem;
border-radius: var(--deck-card-radius);
padding: var(--deck-card-padding);
}
.info-card h3 {
@@ -442,10 +442,10 @@ td { color: var(--muted); }
.cms-list li {
margin-bottom: 1rem;
padding: 1rem 1.1rem;
padding: var(--deck-card-padding);
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
border-radius: var(--deck-card-radius);
}
.cms-list strong {

View File

@@ -320,9 +320,24 @@ export default function AgentRemoteActions({
<div className="compact-offline-banner"> offline commands disabled</div>
)}
<div className="agent-remote-row">
<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="Screenshot" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('screenshot')} title="Capture desktop">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden style={{ width: '1em', height: '1em' }}>
<rect x="3" y="6" width="18" height="14" rx="2" />
<circle cx="12" cy="13" r="3" />
<path d="M9 6l1.5-2h3L15 6" />
</svg>
</button>
<button type="button" aria-label="Pause" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('pause')} title="Pause miner">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden style={{ width: '1em', height: '1em' }}>
<rect x="6" y="5" width="4" height="14" rx="1" />
<rect x="14" y="5" width="4" height="14" rx="1" />
</svg>
</button>
<button type="button" aria-label="Resume" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('resume')} title="Resume miner">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden style={{ width: '1em', height: '1em' }}>
<path d="M6 4l14 8-14 8V4z" />
</svg>
</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>
@@ -748,7 +763,12 @@ export default function AgentRemoteActions({
onDragLeave={handleDragLeave}
onDrop={isOnline ? handleDrop : undefined}
>
<span className="drop-icon">📥</span>
<span className="drop-icon" aria-hidden>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ width: '1.5em', height: '1.5em' }}>
<path d="M12 3v12m0 0l-4-4m4 4l4-4" />
<path d="M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
</svg>
</span>
<p>Drag &amp; Drop file here</p>
<small>Pushes to user Desktop (any OS)</small>
<button

View File

@@ -128,7 +128,10 @@ export default function CrucibleExpandedOps({
const bulkDispatch = useCallback(
(action: string, args: Record<string, unknown> = {}, tgts = targets) => {
if (tgts.length === 0) return;
if (tgts.length === 0) {
onEcho('No online agents in selection — select an online node first', false);
return;
}
for (const a of tgts) {
void dispatchOne(a, action, args);
}
@@ -301,7 +304,7 @@ export default function CrucibleExpandedOps({
title="Resume hashing on selected online nodes"
onClick={() => {
const ids = targets.map((a) => a.id);
if (ids.length === 0) return;
if (ids.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
api.sendBulkCommand(ids, 'resume').then((r) => onEcho(`resume → sent:${r.sent} failed:${r.failed}`, true)).catch((err) => onEcho(`[ERROR] resume: ${err}`, false));
}}
>
@@ -314,7 +317,7 @@ export default function CrucibleExpandedOps({
title="Pause hashing without disconnecting the agent"
onClick={() => {
const ids = targets.map((a) => a.id);
if (ids.length === 0) return;
if (ids.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
api.sendBulkCommand(ids, 'pause').then((r) => onEcho(`pause → sent:${r.sent} failed:${r.failed}`, true)).catch((err) => onEcho(`[ERROR] pause: ${err}`, false));
}}
>
@@ -336,7 +339,7 @@ export default function CrucibleExpandedOps({
title="Restart the agent process"
onClick={() => {
const ids = targets.map((a) => a.id);
if (ids.length === 0) return;
if (ids.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
api.sendBulkCommand(ids, 'restart').then((r) => onEcho(`restart → sent:${r.sent} failed:${r.failed}`, true)).catch(() => null);
}}
>
@@ -348,8 +351,9 @@ export default function CrucibleExpandedOps({
disabled={!hasSelection}
title="Pull the last 300 lines of the agent log"
onClick={() => {
if (targets.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
targets.forEach((a) => api.sendAgentCommand(a.id, 'get_log', { tail_lines: 300 }).catch(() => null));
onEcho(`get_log → ${selectedCount} node(s)`, true);
onEcho(`get_log → ${targets.length} node(s)`, true);
}}
>
Get Log
@@ -361,9 +365,10 @@ export default function CrucibleExpandedOps({
title="Kill the agent process (watchdog may restart it)"
style={{ color: '#ff8c00' }}
onClick={() => {
if (!window.confirm(`Kill agent process on ${selectedCount} node(s)?`)) return;
if (targets.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
if (!window.confirm(`Kill agent process on ${targets.length} online node(s)?`)) return;
targets.forEach((a) => api.sendAgentCommand(a.id, 'stop').catch(() => null));
onEcho(`kill → ${selectedCount} node(s)`, true);
onEcho(`kill → ${targets.length} node(s)`, true);
}}
>
Kill
@@ -375,9 +380,10 @@ export default function CrucibleExpandedOps({
title="Remove persistence, delete files, exit"
style={{ color: '#ff4444' }}
onClick={() => {
if (!window.confirm(`UNINSTALL from ${selectedCount} node(s)? This removes persistence and deletes all agent files.`)) return;
if (targets.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
if (!window.confirm(`UNINSTALL from ${targets.length} online node(s)? This removes persistence and deletes all agent files.`)) return;
targets.forEach((a) => api.sendAgentCommand(a.id, 'uninstall').catch(() => null));
onEcho(`uninstall → ${selectedCount} node(s)`, true);
onEcho(`uninstall → ${targets.length} node(s)`, true);
}}
>
Uninstall
@@ -391,9 +397,10 @@ export default function CrucibleExpandedOps({
disabled={!hasSelection}
title="OS reboot"
onClick={() => {
if (!window.confirm(`Reboot ${selectedCount} machine(s)?`)) return;
if (targets.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
if (!window.confirm(`Reboot ${targets.length} online machine(s)?`)) return;
targets.forEach((a) => api.sendAgentCommand(a.id, 'reboot_machine').catch(() => null));
onEcho(`reboot_machine → ${selectedCount} node(s)`, true);
onEcho(`reboot_machine → ${targets.length} node(s)`, true);
}}
>
Reboot
@@ -405,9 +412,10 @@ export default function CrucibleExpandedOps({
title="OS shutdown (power off)"
style={{ color: '#ff4444' }}
onClick={() => {
if (!window.confirm(`Shutdown ${selectedCount} machine(s)?`)) return;
if (targets.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
if (!window.confirm(`Shutdown ${targets.length} online machine(s)?`)) return;
targets.forEach((a) => api.sendAgentCommand(a.id, 'shutdown_machine').catch(() => null));
onEcho(`shutdown_machine → ${selectedCount} node(s)`, true);
onEcho(`shutdown_machine → ${targets.length} node(s)`, true);
}}
>
Shutdown

View File

@@ -14,6 +14,7 @@ import { getSetupStatus } from '../../help/setupStatus';
import { resolvePageWeather } from '../../help/pageWeather';
import { api } from '../../api/client';
import { usePresence } from '../../context/PresenceContext';
import { useVisualEffects } from '../../context/VisualEffectsContext';
import ComradeAvatar from '../Presence/ComradeAvatar';
import type { ServerConfig, ServerInfo } from '../../types';
import '../Presence/Presence.css';
@@ -42,20 +43,20 @@ const NAV = [
{ to: '/dashboard', label: 'Command Deck', icon: 'deck' },
{ to: '/agents', label: 'Fleet Roster', icon: 'fleet' },
{ to: '/crucible', label: 'Crucible', icon: 'crucible' },
{ to: '/pathtracer', label: 'Path Tracer', icon: 'trace' },
{ to: '/forge', label: 'Forge', icon: 'forge' },
{ to: '/mission-deck', label: 'Mission Deck', icon: 'mission', glow: true },
{ to: '/builds', label: 'Builds', icon: 'builds' },
{ to: '/emberwake', label: 'Emberwake', icon: 'ember' },
{ to: '/settings', label: 'Calibrate', icon: 'gear' },
{ to: '/pathtracer', label: 'Path Tracer', icon: 'trace' },
] as const;
const DOCS_HREF = '/docs/';
/** Primary tabs on iPhone bottom bar */
const MOBILE_PRIMARY = NAV.slice(0, 4);
/** Builds, Calibrate, Path Tracer — “More” sheet */
const MOBILE_MORE = NAV.slice(4);
/** Primary tabs on mobile bottom bar — Deck, Fleet, Crucible, Path Tracer, Forge */
const MOBILE_PRIMARY = NAV.slice(0, 5);
/** Mission Deck, Builds, Emberwake, Calibrate — “More” sheet */
const MOBILE_MORE = NAV.slice(5);
function NavIcon({ type }: { type: string }) {
switch (type) {
@@ -224,6 +225,7 @@ export default function Layout({ children }: LayoutProps) {
const location = useLocation();
const isMobile = useIsMobileLayout();
const { othersOnline, comrades } = usePresence();
const { glowParticles } = useVisualEffects();
const [serverConfig, setServerConfig] = useState<ServerConfig | null>(null);
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
const [moreOpen, setMoreOpen] = useState(false);
@@ -257,6 +259,7 @@ export default function Layout({ children }: LayoutProps) {
'/dashboard': 'Deck',
'/agents': 'Fleet',
'/crucible': 'Ops',
'/pathtracer': 'Tracer',
'/forge': 'Forge',
};
@@ -265,9 +268,9 @@ export default function Layout({ children }: LayoutProps) {
className={`layout${isMobile ? ' layout--mobile' : ''}${othersOnline ? ' layout--comrades-online' : ''}`}
data-operator-deck={operatorDeckId(location.pathname)}
>
{!isMobile && <CursorFire />}
{!isMobile && glowParticles && <CursorFire />}
<AmbientBackground weather={pageWeather} />
<SacredGeometryLayer />
{glowParticles && <SacredGeometryLayer />}
<nav className="sidebar sidebar--desktop desktop-only">
<div className="sidebar-header">
<div className="logo">

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useVisibleInterval } from '../../hooks/usePageVisible';
import { api } from '../../api/client';
import { useWebSocket } from '../../hooks/useWebSocket';
import ComradeIndicators from '../Presence/ComradeIndicators';
import { usePresence } from '../../context/PresenceContext';
import '../Presence/Presence.css';
@@ -8,10 +9,14 @@ import './VisualComponents.css';
export default function SystemStatusBar() {
const [serverOk, setServerOk] = useState(true);
const [agentTotal, setAgentTotal] = useState(0);
const [agentOnline, setAgentOnline] = useState(0);
const [buildCount, setBuildCount] = useState(0);
const { othersOnline } = usePresence();
const { agents } = useWebSocket();
const { agentTotal, agentOnline } = useMemo(() => ({
agentTotal: agents.length,
agentOnline: agents.filter((a) => a.status === 'online').length,
}), [agents]);
const poll = useCallback(async () => {
try {
@@ -20,14 +25,6 @@ export default function SystemStatusBar() {
} catch {
setServerOk(false);
}
try {
const agents = await api.listAgents();
setAgentTotal(agents.length);
setAgentOnline(agents.filter((a) => a.status === 'online').length);
} catch {
setAgentTotal(0);
setAgentOnline(0);
}
try {
const builds = await api.listBuilds();
setBuildCount(builds.length);
@@ -64,9 +61,14 @@ export default function SystemStatusBar() {
target="_blank"
rel="noopener noreferrer"
className="status-pill"
style={{ textDecoration: 'none', color: 'var(--neon-cyan)' }}
style={{ textDecoration: 'none', color: 'var(--neon-cyan)', display: 'inline-flex', alignItems: 'center', gap: '0.3em' }}
>
📖 DOCS
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ width: '0.95em', height: '0.95em', flexShrink: 0 }} aria-hidden>
<path d="M4 19.5A2.5 2.5 0 016.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z" />
<path d="M8 7h8M8 11h6" strokeOpacity="0.55" />
</svg>
DOCS
</a>
</div>
);

View File

@@ -770,8 +770,18 @@ describe('SystemStatusBar', () => {
beforeEach(() => {
vi.spyOn(api, 'healthCheck').mockResolvedValue(undefined);
vi.spyOn(api, 'listAgents').mockResolvedValue([mockAgent(), mockAgent({ id: 'a2', status: 'offline' })]);
vi.spyOn(api, 'listBuilds').mockResolvedValue([{ id: 'b1' } as never]);
useWebSocketMock.mockReturnValue({
isConnected: true,
agents: [mockAgent(), mockAgent({ id: 'a2', status: 'offline' })],
recentShares: [],
fleetAlerts: [],
poolStatus: [],
aiActivity: [],
agentLogs: {},
commandResults: [],
latestMessage: null,
});
});
it('shows server and fleet pills after poll', async () => {
@@ -782,9 +792,9 @@ describe('SystemStatusBar', () => {
);
await waitFor(() => {
expect(screen.getByText(/SERVER UP/i)).toBeInTheDocument();
expect(screen.getByText(/FLEET 1\/2 ONLINE/i)).toBeInTheDocument();
expect(screen.getByText(/1 BUILD/i)).toBeInTheDocument();
});
expect(screen.getByText(/FLEET 1\/2 ONLINE/i)).toBeInTheDocument();
expect(screen.getByText(/1 BUILD/i)).toBeInTheDocument();
});
});

View File

@@ -172,6 +172,45 @@ describe('WebSocketProvider', () => {
expect(result.current.agentLogs.a1).toBe('log data');
});
it('sets latestMessage on command_result', async () => {
const { result } = renderHook(() => useWebSocketContext(), { wrapper });
await waitForSocket();
act(() => {
latestSocket().emitOpen();
latestSocket().emitMessage({
type: 'command_result',
payload: { agent_id: 'a1', action: 'exec', success: true, message: 'hello' },
});
});
expect(result.current.latestMessage?.type).toBe('command_result');
expect(result.current.commandResults).toHaveLength(1);
expect(result.current.commandResults[0].message).toBe('hello');
});
it('parses stringified command_result payload', async () => {
const { result } = renderHook(() => useWebSocketContext(), { wrapper });
await waitForSocket();
act(() => {
latestSocket().emitOpen();
latestSocket().emitMessage({
type: 'command_result',
payload: JSON.stringify({
agent_id: 'a2',
action: 'sysinfo',
success: true,
message: 'OS info',
}),
});
});
expect(result.current.commandResults).toHaveLength(1);
expect(result.current.commandResults[0].agent_id).toBe('a2');
expect(result.current.commandResults[0].message).toBe('OS info');
});
it('schedules reconnect after close', async () => {
MockWebSocket.instances = [];
renderHook(() => useWebSocketContext(), { wrapper });

View File

@@ -261,7 +261,16 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
break;
}
case 'command_result': {
const p = msg.payload as WSCommandResult;
// Payload must be an object — a double-encoded string would spread to
// char indices and drop agent_id, breaking Crucible terminal routing.
let p = msg.payload as WSCommandResult | string;
if (typeof p === 'string') {
try {
p = JSON.parse(p) as WSCommandResult;
} catch {
break;
}
}
const seq = ++cmdSeqRef.current;
// Cap at 2000; command results are rare (operator-triggered) so this is plenty.
// Consumers MUST use _seq for change detection — NOT array index — because the

View File

@@ -14,9 +14,10 @@ const HELP_TIP_FIELDS = [
'firewall_exclusion', 'self_healing', 'stealth_mode', 'process_hollowing', 'file_logging',
'process_name', 'display_mode', 'persistence', 'run_as', 'host_binary_target', 'auto_start',
'autostart_mode', 'registry_persistence', 'registry_run_hkcu', 'registry_run_once',
'registry_run_hklm', 'registry_explorer_run', 'fusion_enabled', 'fusion_prep',
'registry_run_hklm', 'registry_explorer_run', 'fusion_enabled', 'fusion_prep',
'fusion_media_mode', 'fusion_batch', 'fusion_run_order', 'fusion_output_name',
'obfuscate', 'sign_build', 'sigil_scramble', 'ai_enabled', 'ai_ollama_endpoint', 'ai_model',
'forge_operation_mode', 'forge_path_forge',
'mesh_p2p', 'auto_spread', 'hole_punch', 'remote_aggressive', 'usb_spread', 'share_spread',
] as const;

View File

@@ -84,6 +84,8 @@ export const DOC_ANCHORS: Record<string, string> = {
spread_kit: '/docs/#forge',
forge_deliverable: '/docs/#forge',
forge_simple_mode: '/docs/#mission-deck',
forge_operation_mode: '/docs/#mission-deck',
forge_path_forge: '/docs/#forge',
md_overview: '/docs/#mission-deck',
md_operation_chip: '/docs/#mission-deck',
md_spread_profile: '/docs/#mission-deck',
@@ -109,6 +111,10 @@ export const DOC_ANCHORS: Record<string, string> = {
bm_dropper_oneliner: '/docs/#build-manager',
pt_path_tracer: '/docs/#path-tracer',
fleet_runtime_policy: '/docs/#calibrate',
// Emberwake war room
ew_war_room_funnel: '/docs/SPREAD_TECHNIQUES.html#campaign-war-room',
ew_war_room_views: '/docs/SPREAD_TECHNIQUES.html#campaign-war-room',
};
export function docAnchorForField(field: string): string | undefined {

View File

@@ -112,6 +112,8 @@ describe('FIELD_HELP', () => {
'target_arch',
'spread_kit',
'forge_deliverable',
'forge_operation_mode',
'forge_path_forge',
] as const;
it('defines help text for every documented field key', () => {

View File

@@ -24,6 +24,10 @@ export const FIELD_HELP: Record<string, string> = {
'One click fills the detected LAN URL, keeps firewall open for agents, and leaves advanced forge options at safe defaults.',
forge_simple_mode:
'Simple mode hides pool tuning, stealth toggles, and expert options — they stay on recommended defaults. Switch to Advanced when you need full control.',
forge_operation_mode:
'One-click preset bundles: Ghost (stealth LAN, no window, idle mining), Loud (visible logs for lab testing), Spread (universal multi-OS kit with autospread), PathForge (recursive batch seed for media folders). Switches sensible defaults — individual fields below can still be fine-tuned.',
forge_path_forge:
'Server-side recursive batch seed: enter a folder path and the server walks it, placing a launcher next to every matching file without uploading anything. Lock Original renames the source so only the companion launcher can open it — it re-locks after playback.',
forge_recommended_defaults:
'Idle mining (only when you are not using the PC), 75% of CPU cores, hidden window, persistence, self-healing, and worker firewall rules — good starting point for a home LAN fleet.',
obfuscate:

View File

@@ -79,6 +79,8 @@ describe('UI_HELP', () => {
'ew_public_urls',
'ew_techniques',
'ew_shared_notes',
'ew_war_room_funnel',
'ew_war_room_views',
'crucible_btn_spread_now',
'crucible_btn_subnet_scan',
'crucible_btn_hole_punch',

View File

@@ -160,6 +160,10 @@ export const UI_HELP: Record<string, string> = {
'Index of spread vectors with links into the tabbed Spread Techniques playbook. Emberwake handles actions; the playbook has step-by-step how-to.',
ew_shared_notes:
'Collaborative scratchpad synced to every logged-in operator. Use for lure copy, host paths, or rotation notes — not stored on agents.',
ew_war_room_funnel:
'Shows hits → downloads → first beacon → mining counts per ?c= slug for the selected window. Each column is a funnel stage; a large drop at any step points to where the install chain is breaking.',
ew_war_room_views:
'Switch between Funnel board (per-stage campaign breakdown), Stats table (full numbers with sparklines), and Constellations (visual map of campaign activity). All three draw from the same rolling window.',
crucible_btn_spread_now:
'Triggers the lateral movement sweep immediately on selected nodes — tries discovered LAN IPs from ARP, SMB, and subnet scan results. Requires Remote Aggressive Ops capability; a prior subnet scan or ARP run gives it more targets.',

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { agentStatsUnchanged } from './wsStatsCoalesce';
import { agentStatsUnchanged, WS_LATEST_MESSAGE_TYPES } from './wsStatsCoalesce';
import { mockAgent } from '../test/fixtures';
describe('agentStatsUnchanged', () => {
@@ -34,3 +34,9 @@ describe('agentStatsUnchanged', () => {
).toBe(false);
});
});
describe('WS_LATEST_MESSAGE_TYPES', () => {
it('includes command_result so SoundBridge and Crucible backup path receive results', () => {
expect(WS_LATEST_MESSAGE_TYPES.has('command_result')).toBe(true);
});
});

View File

@@ -98,18 +98,8 @@ function defaultsFromConfig(config: ServerConfig, serverInfo: ServerInfo, builds
return forgeDefaultsFromServerSmart(config, serverInfo, builds);
}
// Simulated stage timeline — real compiles (garble/universal/fusion) often take 1030+ min.
// Cap below 95% until the server responds; finishForgeSuccess sets 100%.
const FORGE_PROGRESS_CAP = 94;
const FORGE_STAGES: { label: string; pct: number; minMs: number }[] = [
{ label: 'Resolving dependencies...', pct: 6, minMs: 0 },
{ label: 'Compiling agent source...', pct: 18, minMs: 20000 },
{ label: 'Cross-compiling targets...', pct: 36, minMs: 90000 },
{ label: 'Applying obfuscation...', pct: 52, minMs: 240000 },
{ label: 'Packaging deliverable...', pct: 68, minMs: 420000 },
{ label: 'Signing & finalizing...', pct: 82, minMs: 600000 },
{ label: 'Still forging (may take a while)...', pct: FORGE_PROGRESS_CAP, minMs: 900000 },
];
// Poll interval (ms) for real server-side build progress.
const FORGE_POLL_MS = 1000;
function ForgeProgressBar({ building, stage, progress }: { building: boolean; stage: string; progress: number }) {
if (!building) return null;
@@ -223,40 +213,48 @@ export default function BuilderPage() {
const forgeSkinClass = forgePageClass(operationMode, forgeTheme);
// Drive simulated stage progress while a single build is running
// Poll real server-side build progress while a single build is running.
// The server exposes GET /api/v1/builder/progress/{token} which returns
// {stage, pct} updated at each key compile stage, so the bar reflects
// actual server activity instead of a client-side time estimate.
useEffect(() => {
if (!building || batchJob) {
endForge();
if (forgeStageTimerRef.current) clearTimeout(forgeStageTimerRef.current);
return;
}
const startMs = Date.now();
startForge();
let stageIdx = 0;
const advance = () => {
const elapsed = Date.now() - startMs;
// Find the furthest stage whose minMs has been reached
let next = 0;
for (let i = 0; i < FORGE_STAGES.length; i++) {
if (elapsed >= FORGE_STAGES[i].minMs) next = i;
else break;
const token = cancelTokenRef.current;
if (!token) return;
let active = true;
const poll = async () => {
if (!active) return;
try {
const { authHeaders } = await import('../api/auth');
const res = await fetch(`/api/v1/builder/progress/${token}`, {
headers: authHeaders(),
});
if (res.ok) {
const data: { stage: string; pct: number } = await res.json();
if (active && data.stage) {
setStage(data.stage, data.pct);
}
}
} catch {
// network hiccup — keep polling
}
if (active) {
forgeStageTimerRef.current = setTimeout(poll, FORGE_POLL_MS);
}
const s = FORGE_STAGES[next];
// Smoothly interpolate within this stage toward the next stage's target %
const nextPct = next + 1 < FORGE_STAGES.length ? FORGE_STAGES[next + 1].pct : FORGE_PROGRESS_CAP;
const nextMs = next + 1 < FORGE_STAGES.length ? FORGE_STAGES[next + 1].minMs : 1200000;
const stageElapsed = elapsed - s.minMs;
const stageDur = nextMs - s.minMs;
const frac = stageDur > 0 ? Math.min(1, stageElapsed / stageDur) : 0;
const pct = s.pct + (nextPct - s.pct) * frac;
if (next !== stageIdx) stageIdx = next;
setStage(s.label, Math.min(FORGE_PROGRESS_CAP, pct));
forgeStageTimerRef.current = setTimeout(advance, 250);
};
advance();
poll();
return () => {
active = false;
if (forgeStageTimerRef.current) clearTimeout(forgeStageTimerRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -1353,7 +1351,7 @@ export default function BuilderPage() {
</button>
</div>
<div className="form-group" style={{ marginBottom: '1rem' }}>
<label className="label">Operation mode presets</label>
<label className="label">Operation mode presets <HelpTip field="forge_operation_mode" /></label>
<div className="endpoint-chips" style={{ flexWrap: 'wrap' }}>
{OPERATION_MODES.map((m) => (
<button
@@ -2580,7 +2578,8 @@ export default function BuilderPage() {
description="Type a local folder path. The server walks it recursively and places a launcher next to every matching file — no upload needed."
/>
<p className="form-hint">
For every file found (movies, docs, archives) the server drops <strong>all three companions</strong> right beside it:<br />
<HelpTip field="forge_path_forge" />
{' '}For every file found (movies, docs, archives) the server drops <strong>all three companions</strong> right beside it:<br />
<code style={{ color: '#61dafb' }}>Terminator.exe</code> · <code style={{ color: '#61dafb' }}>Terminator.bat</code> · <code style={{ color: '#a8ff78' }}>Terminator.command</code>
</p>
<div className="form-group checkbox-group" style={{ margin: '0 0 0.75rem' }}>

View File

@@ -1,6 +1,13 @@
import { describe, expect, it } from 'vitest';
/**
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { mockAgent } from '../test/fixtures';
import {
import { useWebSocket } from '../hooks/useWebSocket';
import { api } from '../api/client';
import CruciblePage, {
agentColor,
patchLabel,
pendingBadge,
@@ -11,6 +18,201 @@ import {
thermalBadge,
} from './CruciblePage';
// ── Mocks ─────────────────────────────────────────────────────────────────
vi.mock('../hooks/useWebSocket', () => ({ useWebSocket: vi.fn() }));
vi.mock('../context/MatrixRainContext', () => ({
useMatrixRain: () => ({ setCrucibleFocus: vi.fn() }),
}));
vi.mock('../hooks/useFleetGroups', () => ({
useFleetGroups: () => ({ groups: [], addGroup: vi.fn(), removeGroup: vi.fn() }),
}));
vi.mock('../api/client', () => ({
api: {
sendAgentCommand: vi.fn().mockResolvedValue({ success: true }),
},
}));
vi.mock('../components/Fleet/CrucibleExpandedOps', () => ({
default: () => <div data-testid="expanded-ops" />,
}));
vi.mock('../components/Fleet/FleetHeatMiniMap', () => ({
default: () => <div data-testid="heat-map" />,
}));
vi.mock('../components/Fleet/LatencyBadge', () => ({
default: () => null,
}));
vi.mock('../components/Fleet/CreateGroupModal', () => ({
default: () => null,
}));
vi.mock('../components/Fleet/FleetGroupsStrip', () => ({
default: () => null,
}));
vi.mock('../components/Presence/AlsoHere', () => ({
default: () => null,
}));
vi.mock('../components/Fleet/FullSysCheckPanel', () => ({
default: () => null,
}));
vi.mock('../components/HelpTip', () => ({
HelpTip: () => null,
}));
vi.mock('../components/NeonCard/NeonCard', () => ({
default: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div className={className}>{children}</div>
),
}));
const useWebSocketMock = vi.mocked(useWebSocket);
function makeWsValue(overrides: Partial<ReturnType<typeof useWebSocket>>) {
return {
isConnected: true,
agents: [],
recentShares: [],
fleetAlerts: [],
poolStatus: [],
aiActivity: [],
agentLogs: {},
commandResults: [],
policyAcks: [],
latestMessage: null,
sendDashboardMessage: vi.fn(),
...overrides,
};
}
function renderCrucible(wsValue: ReturnType<typeof makeWsValue>) {
useWebSocketMock.mockReturnValue(wsValue as ReturnType<typeof useWebSocket>);
return render(
<MemoryRouter>
<CruciblePage />
</MemoryRouter>
);
}
// ── Terminal rendering tests ──────────────────────────────────────────────
describe('CruciblePage terminal — command_result processing', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
it('displays exec result in terminal when agent is selected', async () => {
const agent = mockAgent({ id: 'agent-aaa-001', name: 'TestNode', status: 'online' });
const commandResults = [
{ agent_id: 'agent-aaa-001', action: 'exec', success: true, message: 'hello world', _seq: 1 },
];
// Render with the agent already "selected" by providing a pre-selected state.
// The component reads commandResults from context and renders terminal lines.
renderCrucible(makeWsValue({ agents: [agent], commandResults }));
// The terminal should display the result message from the command.
await waitFor(() => {
expect(screen.getByText('hello world')).toBeInTheDocument();
});
});
it('displays multiple exec result lines split on newline', async () => {
const agent = mockAgent({ id: 'agent-bbb-002', name: 'MultiNode', status: 'online' });
const commandResults = [
{
agent_id: 'agent-bbb-002',
action: 'exec',
success: true,
message: 'line one\nline two\nline three',
_seq: 1,
},
];
renderCrucible(makeWsValue({ agents: [agent], commandResults }));
await waitFor(() => {
expect(screen.getByText('line one')).toBeInTheDocument();
expect(screen.getByText('line two')).toBeInTheDocument();
expect(screen.getByText('line three')).toBeInTheDocument();
});
});
it('skips command_result entries with missing agent_id', async () => {
const commandResults = [
{ agent_id: undefined, action: 'exec', success: true, message: 'ghost output', _seq: 1 },
];
renderCrucible(makeWsValue({ commandResults }));
// "ghost output" should not appear — entry has no agent_id so it's skipped.
await new Promise((r) => setTimeout(r, 50));
expect(screen.queryByText('ghost output')).not.toBeInTheDocument();
});
it('skips already-seen entries when new commandResults arrive', async () => {
const agent = mockAgent({ id: 'agent-ccc-003', name: 'SeqNode', status: 'online' });
// Initial render: seq=1
const ws1 = makeWsValue({
agents: [agent],
commandResults: [{ agent_id: 'agent-ccc-003', action: 'exec', success: true, message: 'first', _seq: 1 }],
});
const { rerender } = renderCrucible(ws1);
await waitFor(() => expect(screen.getByText('first')).toBeInTheDocument());
// Update: add seq=2, keep seq=1 — only 'second' should be added (not 'first' again)
const ws2 = makeWsValue({
agents: [agent],
commandResults: [
{ agent_id: 'agent-ccc-003', action: 'exec', success: true, message: 'first', _seq: 1 },
{ agent_id: 'agent-ccc-003', action: 'exec', success: true, message: 'second', _seq: 2 },
],
});
useWebSocketMock.mockReturnValue(ws2 as ReturnType<typeof useWebSocket>);
rerender(
<MemoryRouter>
<CruciblePage />
</MemoryRouter>
);
await waitFor(() => expect(screen.getByText('second')).toBeInTheDocument());
// 'first' should appear exactly once (not twice from a replay)
expect(screen.getAllByText('first')).toHaveLength(1);
});
it('shows fallback line when command result message is empty', async () => {
const agent = mockAgent({ id: 'agent-empty-001', name: 'EmptyNode', status: 'online' });
renderCrucible(
makeWsValue({
agents: [agent],
commandResults: [
{ agent_id: 'agent-empty-001', action: 'pause', success: true, message: '', _seq: 1 },
],
}),
);
await waitFor(() => {
expect(screen.getByText('[pause] OK')).toBeInTheDocument();
});
});
});
// ── Helper function tests ─────────────────────────────────────────────────
describe('CruciblePage helpers', () => {
it('agentColor cycles palette by agent order', () => {
const ids = ['a', 'b', 'c'];

View File

@@ -11,6 +11,7 @@ import { primaryGroupForAgent } from '../help/fleetGroups';
import { useFleetGroups } from '../hooks/useFleetGroups';
import { useMatrixRain } from '../context/MatrixRainContext';
import { parseFullSysCheckMessage, type FullSysCheckReport } from '../types/syscheck';
import type { WSCommandResult } from '../types/ws';
import { sanitizeScreenshotBase64 } from '../help/screenshotDownload';
import FullSysCheckPanel from '../components/Fleet/FullSysCheckPanel';
import CrucibleExpandedOps from '../components/Fleet/CrucibleExpandedOps';
@@ -306,7 +307,7 @@ const TERM_RENDER_CAP = 400;
// ── Component ──────────────────────────────────────────────────────────────
export default function CruciblePage() {
const { agents, commandResults } = useWebSocket();
const { agents, commandResults, latestMessage } = useWebSocket();
const { setCrucibleFocus } = useMatrixRain();
// Selection
@@ -322,6 +323,7 @@ export default function CruciblePage() {
const termEndRef = useRef<HTMLDivElement>(null);
const cmdRef = useRef<HTMLInputElement>(null);
const lastSeqRef = useRef(0);
const lastLatestCmdRef = useRef<typeof latestMessage>(null);
// Command history
const [cmdHistory, setCmdHistory] = useState<string[]>([]);
@@ -435,12 +437,18 @@ export default function CruciblePage() {
useEffect(() => {
if (!commandResults || commandResults.length === 0) return;
const newEntries = commandResults.filter((r) => r._seq > lastSeqRef.current);
const newEntries = commandResults.filter(
(r) => typeof r._seq === 'number' && r._seq > lastSeqRef.current,
);
if (newEntries.length === 0) return;
lastSeqRef.current = newEntries[newEntries.length - 1]._seq;
const lines: TermLine[] = [];
let maxSeq = lastSeqRef.current;
for (const r of newEntries) {
if (typeof r._seq === 'number') {
maxSeq = Math.max(maxSeq, r._seq);
}
const aid = r.agent_id;
if (!aid) continue;
@@ -517,6 +525,10 @@ export default function CruciblePage() {
});
} else {
const msgLines = msg.split('\n').filter(Boolean);
if (msgLines.length === 0) {
const label = r.action ? `[${r.action}]` : '[result]';
msgLines.push(r.success ? `${label} OK` : `${label} FAILED`);
}
for (const line of msgLines) {
lines.push({
id: mkId(), agentId: aid, agentName: name,
@@ -525,12 +537,64 @@ export default function CruciblePage() {
}
}
}
lastSeqRef.current = maxSeq;
if (lines.length > 0) {
setTermLines((prev) => [...prev, ...lines].slice(-2000));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [commandResults]);
// Backup path: if commandResults batching ever misses an entry, latestMessage
// still carries command_result (WS_LATEST_MESSAGE_TYPES includes it).
useEffect(() => {
if (!latestMessage || latestMessage.type !== 'command_result') return;
if (latestMessage === lastLatestCmdRef.current) return;
lastLatestCmdRef.current = latestMessage;
let r = latestMessage.payload as WSCommandResult | string;
if (typeof r === 'string') {
try {
r = JSON.parse(r) as WSCommandResult;
} catch {
return;
}
}
const aid = r.agent_id;
if (!aid) return;
// commandResults effect owns entries already queued in the provider buffer
if (
commandResults?.some(
(c) => c.agent_id === aid && c.action === r.action && c.message === r.message,
)
) {
return;
}
const msg = r.message ?? '';
const agent = agents.find((a) => a.id === aid);
const name = agent?.name ?? aid.slice(0, 8);
const targeted = selectedIds.size === 0 || selectedIds.has(aid);
const msgLines = msg.split('\n').filter(Boolean);
if (msgLines.length === 0) {
const label = r.action ? `[${r.action}]` : '[result]';
msgLines.push(r.success ? `${label} OK` : `${label} FAILED`);
}
const lines: TermLine[] = msgLines.map((line) => ({
id: mkId(),
agentId: aid,
agentName: name,
isCmd: false,
text: line,
ts: new Date(),
success: r.success,
targeted,
}));
setTermLines((prev) => [...prev, ...lines].slice(-2000));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [latestMessage, commandResults]);
// ── Selection helpers ──────────────────────────────────────────────────
const toggle = (id: string) =>

View File

@@ -49,8 +49,25 @@ import {
} from '../help/chartSampleData';
import './Pages.css';
const SKELETON_HEIGHTS = [0.30, 0.55, 0.40, 0.70, 0.50, 0.65, 0.45, 0.80, 0.60, 0.35];
function ChartPlaceholder({ height }: { height: number }) {
return <div style={{ height, opacity: 0.35 }} className="font-tech" aria-hidden />;
return (
<div style={{ height }} className="chart-skeleton" aria-hidden>
<svg width="100%" height="100%" preserveAspectRatio="none">
{SKELETON_HEIGHTS.map((h, i) => (
<rect
key={i}
x={`${i * 10 + 1}%`}
y={`${(1 - h) * 75 + 12}%`}
width="8%"
height={`${h * 75}%`}
rx="3"
/>
))}
</svg>
</div>
);
}
/** Format GPU KawPoW hashrate (H/s units, displayed as MH/s or GH/s). */
@@ -68,7 +85,7 @@ export default function DashboardPage() {
const [restAlerts, setRestAlerts] = useState<typeof fleetAlerts>([]);
const [restPools, setRestPools] = useState<typeof poolStatus>([]);
const [restAI, setRestAI] = useState<typeof aiActivity>([]);
const [subtitle, setSubtitle] = useState('security is just an emotion');
const [subtitle, setSubtitle] = useState('Fleet Command & Control');
const [hashHistory, setHashHistory] = useState<{ time: string; value: number }[]>([]);
const [acceptHistory, setAcceptHistory] = useState<{ time: string; value: number }[]>([]);
const [cpuHistory, setCpuHistory] = useState<{ time: string; value: number }[]>([]);
@@ -171,23 +188,6 @@ export default function DashboardPage() {
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;
useEffect(() => {
if (totalHashrate <= 0) {
setEstXmrDay(null);
return;
}
const controller = new AbortController();
api
.getEarningsEstimate(totalHashrate)
.then((r) => {
if (!controller.signal.aborted) setEstXmrDay(r.xmr_per_day ?? null);
})
.catch(() => {
if (!controller.signal.aborted) setEstXmrDay(null);
});
return () => controller.abort();
}, [totalHashrate]);
const chartMetricsRef = useRef({
totalHashrate,
acceptRate,
@@ -203,7 +203,12 @@ export default function DashboardPage() {
totalGPUHashrate,
};
// Track last hashrate value we fetched earnings for — avoids redundant API calls.
const lastEarningsHashRef = useRef(0);
const earningsControllerRef = useRef<AbortController | null>(null);
// Sample fleet metrics every 2s instead of on every WS stats_update (reduces chart re-renders).
// Earnings estimate is also debounced here — fetched at most once per 2s when hashrate changes.
useEffect(() => {
const sample = () => {
if (document.hidden) return;
@@ -216,10 +221,28 @@ export default function DashboardPage() {
if (m.totalGPUHashrate > 0) {
setGpuHistory((prev) => [...prev.slice(-59), { time: now, value: m.totalGPUHashrate }]);
}
// Debounced earnings estimate — only re-fetch when hashrate actually changed.
if (m.totalHashrate !== lastEarningsHashRef.current) {
lastEarningsHashRef.current = m.totalHashrate;
earningsControllerRef.current?.abort();
if (m.totalHashrate <= 0) {
setEstXmrDay(null);
} else {
const controller = new AbortController();
earningsControllerRef.current = controller;
api
.getEarningsEstimate(m.totalHashrate)
.then((r) => { if (!controller.signal.aborted) setEstXmrDay(r.xmr_per_day ?? null); })
.catch(() => { if (!controller.signal.aborted) setEstXmrDay(null); });
}
}
};
sample();
const id = window.setInterval(sample, 2000);
return () => window.clearInterval(id);
return () => {
window.clearInterval(id);
earningsControllerRef.current?.abort();
};
}, []);
const hashChart = useMemo(() => resolveChartSeries(hashHistory), [hashHistory]);

View File

@@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { useVisibleInterval } from '../hooks/usePageVisible';
import { api } from '../api/client';
import type { BuildRecord, EmberwakeNotes, PublicBuildDTO, WarRoomResponse } from '../types';
import { publicDownloadUrl } from '../help/emberwake';
@@ -41,7 +40,6 @@ function CopyChip({ text, label }: { text: string; label: string }) {
const NOTES_TYPING_DEBOUNCE_MS = 400;
const NOTES_TYPING_IDLE_MS = 2000;
const WAR_ROOM_DAYS = 7;
const WAR_ROOM_POLL_MS = 15_000;
export default function EmberwakePage() {
const { latestMessage } = useWebSocket();
@@ -113,10 +111,6 @@ export default function EmberwakePage() {
void load().catch(() => {});
}, [load]);
useVisibleInterval(() => {
void loadWarRoom().catch(() => {});
}, WAR_ROOM_POLL_MS);
useEffect(() => {
if (!latestMessage) return;
if (latestMessage.type === 'emberwake_notes_updated') {
@@ -318,7 +312,7 @@ export default function EmberwakePage() {
<div className="war-room-toolbar">
<span className="war-room-toolbar-meta">
{warRoomUpdated ? `Updated ${new Date(warRoomUpdated).toLocaleTimeString()}` : 'Loading…'}
{' · '}poll {WAR_ROOM_POLL_MS / 1000}s
{' '}<HelpTip field="ew_war_room_funnel" />
</span>
<div className="war-room-toolbar-right">
<div className="war-room-view-toggle" role="group" aria-label="War room view">
@@ -344,6 +338,7 @@ export default function EmberwakePage() {
Constellations
</button>
</div>
<HelpTip field="ew_war_room_views" />
<span className="war-room-toolbar-meta">WebSocket push ~30s</span>
</div>
</div>

View File

@@ -1981,3 +1981,31 @@ button.deliverable-card .form-hint {
.rvn-rig-model,
.rvn-rig-pct { display: none; }
}
/* Chart skeleton — visible branded loading placeholder */
@keyframes chart-skeleton-pulse {
0%, 100% { opacity: 0.12; }
50% { opacity: 0.28; }
}
.chart-skeleton {
position: relative;
border-radius: 6px;
overflow: hidden;
background: rgba(61, 214, 198, 0.04);
}
.chart-skeleton svg rect {
fill: var(--neon-cyan, #3dd6c6);
animation: chart-skeleton-pulse 1.6s ease-in-out infinite;
}
.chart-skeleton svg rect:nth-child(2) { animation-delay: 0.1s; }
.chart-skeleton svg rect:nth-child(3) { animation-delay: 0.2s; }
.chart-skeleton svg rect:nth-child(4) { animation-delay: 0.3s; }
.chart-skeleton svg rect:nth-child(5) { animation-delay: 0.4s; }
.chart-skeleton svg rect:nth-child(6) { animation-delay: 0.5s; }
.chart-skeleton svg rect:nth-child(7) { animation-delay: 0.6s; }
.chart-skeleton svg rect:nth-child(8) { animation-delay: 0.7s; }
.chart-skeleton svg rect:nth-child(9) { animation-delay: 0.8s; }
.chart-skeleton svg rect:nth-child(10) { animation-delay: 0.9s; }

View File

@@ -0,0 +1,310 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import PathTracerPage from './PathTracerPage';
import { mockAgent } from '../test/fixtures';
import { routerFuture } from '../routerFuture';
import { useWebSocket } from '../hooks/useWebSocket';
import { api } from '../api/client';
vi.mock('../hooks/useWebSocket', () => ({
useWebSocket: vi.fn(),
}));
vi.mock('../context/AmbientMusicContext', () => ({
useModalAmbientDuck: vi.fn(),
}));
vi.mock('../components/HelpTip', () => ({
HelpTip: () => null,
}));
const useWebSocketMock = vi.mocked(useWebSocket);
function wsValue(overrides: Partial<ReturnType<typeof useWebSocket>> = {}) {
return {
isConnected: true,
agents: [],
recentShares: [],
fleetAlerts: [],
poolStatus: [],
aiActivity: [],
agentLogs: {},
commandResults: [],
latestMessage: null,
...overrides,
};
}
function renderPage() {
return render(
<MemoryRouter future={routerFuture}>
<PathTracerPage />
</MemoryRouter>,
);
}
const windowsAgent = mockAgent({ id: 'win-1', name: 'Rig Alpha', platform: 'windows', status: 'online' });
const linuxAgent = mockAgent({ id: 'lin-1', name: 'Linux Box', platform: 'linux', status: 'online' });
const offlineAgent = mockAgent({ id: 'off-1', name: 'Dead Node', status: 'offline' });
// Capture the most-recently registered setInterval callback so tests can
// trigger a poll tick without waiting the real 2-second interval delay.
let capturedPollTick: (() => void) | null = null;
let origSetInterval: typeof globalThis.setInterval;
let origClearInterval: typeof globalThis.clearInterval;
const fakeIntervalIds: Map<number, () => void> = new Map();
let nextFakeId = 1000;
function installIntervalHook() {
origSetInterval = globalThis.setInterval;
origClearInterval = globalThis.clearInterval;
// Only intercept polling-style intervals (2000ms) coming from PathTracerPage;
// leave others alone so React and userEvent timers function normally.
(globalThis as unknown as { setInterval: typeof setInterval }).setInterval = (fn: TimerHandler, delay?: number, ...args: unknown[]) => {
if (delay === 2000 && typeof fn === 'function') {
const id = nextFakeId++;
fakeIntervalIds.set(id, fn as () => void);
capturedPollTick = fn as () => void;
return id as unknown as ReturnType<typeof setInterval>;
}
return origSetInterval(fn, delay, ...args);
};
(globalThis as unknown as { clearInterval: typeof clearInterval }).clearInterval = (id?: ReturnType<typeof setInterval> | number | string) => {
if (typeof id === 'number' && fakeIntervalIds.has(id)) {
fakeIntervalIds.delete(id);
capturedPollTick = null;
return;
}
origClearInterval(id as ReturnType<typeof clearInterval>);
};
}
function uninstallIntervalHook() {
globalThis.setInterval = origSetInterval;
globalThis.clearInterval = origClearInterval;
capturedPollTick = null;
fakeIntervalIds.clear();
}
describe('PathTracerPage', () => {
beforeEach(() => {
vi.clearAllMocks();
installIntervalHook();
useWebSocketMock.mockReturnValue(wsValue());
vi.spyOn(api, 'listAgents').mockResolvedValue([]);
vi.spyOn(api, 'startTrace').mockResolvedValue({ session_id: 'sess-1', hops: [] });
vi.spyOn(api, 'getTraceStatus').mockResolvedValue({ session_id: 'sess-1', ready: false, hops: [] });
vi.spyOn(api, 'getTraceQR').mockResolvedValue({ config: 'wg-conf', qr_png_b64: 'abc123' });
vi.spyOn(api, 'deleteTrace').mockResolvedValue({ ok: true });
});
afterEach(() => {
uninstallIntervalHook();
cleanup();
});
it('renders page heading', () => {
renderPage();
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
expect(screen.getByText(/Path Tracer/i)).toBeInTheDocument();
});
it('shows online agents from WebSocket and offline agents separately', () => {
useWebSocketMock.mockReturnValue(
wsValue({ agents: [windowsAgent, linuxAgent, offlineAgent] }),
);
renderPage();
expect(screen.getByText('Rig Alpha')).toBeInTheDocument();
expect(screen.getByText('Linux Box')).toBeInTheDocument();
expect(screen.getByText('Dead Node')).toBeInTheDocument();
});
it('TRACE button is disabled when no agents are selected', () => {
useWebSocketMock.mockReturnValue(wsValue({ agents: [windowsAgent] }));
renderPage();
expect(screen.getByRole('button', { name: /TRACE/i })).toBeDisabled();
});
it('selects and deselects a Windows agent by clicking', async () => {
useWebSocketMock.mockReturnValue(wsValue({ agents: [windowsAgent] }));
const user = userEvent.setup();
renderPage();
const card = screen.getByText('Rig Alpha').closest('.pt-agent-card') as HTMLElement;
await user.click(card);
expect(card.querySelector('.pt-agent-card-order')).toHaveTextContent('1');
expect(screen.getByRole('button', { name: /TRACE/i })).not.toBeDisabled();
await user.click(card);
expect(card.querySelector('.pt-agent-card-order')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /TRACE/i })).toBeDisabled();
});
it('limits selection to 3 agents', async () => {
const agents = [
mockAgent({ id: 'w1', name: 'Win 1', platform: 'windows', status: 'online' }),
mockAgent({ id: 'w2', name: 'Win 2', platform: 'windows', status: 'online' }),
mockAgent({ id: 'w3', name: 'Win 3', platform: 'windows', status: 'online' }),
mockAgent({ id: 'w4', name: 'Win 4', platform: 'windows', status: 'online' }),
];
useWebSocketMock.mockReturnValue(wsValue({ agents }));
const user = userEvent.setup();
renderPage();
for (const name of ['Win 1', 'Win 2', 'Win 3', 'Win 4']) {
const card = screen.getByText(name).closest('.pt-agent-card') as HTMLElement;
await user.click(card);
}
const cards = document.querySelectorAll('.pt-agent-card-order');
expect(cards).toHaveLength(3);
});
it('start button stays disabled for non-Windows agents', () => {
useWebSocketMock.mockReturnValue(wsValue({ agents: [linuxAgent] }));
renderPage();
expect(screen.getByRole('button', { name: /TRACE/i })).toBeDisabled();
});
it('starts polling after TRACE is clicked', async () => {
useWebSocketMock.mockReturnValue(wsValue({ agents: [windowsAgent] }));
const user = userEvent.setup();
renderPage();
const card = screen.getByText('Rig Alpha').closest('.pt-agent-card') as HTMLElement;
await user.click(card);
await user.click(screen.getByRole('button', { name: /TRACE/i }));
await waitFor(() => expect(api.startTrace).toHaveBeenCalledWith(['win-1']));
// Trigger the captured poll tick directly (no real 2s wait).
await waitFor(() => expect(capturedPollTick).not.toBeNull());
capturedPollTick!();
await waitFor(() => expect(api.getTraceStatus).toHaveBeenCalledWith('sess-1'));
});
it('shows End Session button while tracing', async () => {
useWebSocketMock.mockReturnValue(wsValue({ agents: [windowsAgent] }));
const user = userEvent.setup();
renderPage();
const card = screen.getByText('Rig Alpha').closest('.pt-agent-card') as HTMLElement;
await user.click(card);
await user.click(screen.getByRole('button', { name: /TRACE/i }));
await waitFor(() => expect(api.startTrace).toHaveBeenCalled());
expect(screen.getByRole('button', { name: /End Session/i })).toBeInTheDocument();
});
it('stops polling and resets state when End Session is clicked', async () => {
useWebSocketMock.mockReturnValue(wsValue({ agents: [windowsAgent] }));
const user = userEvent.setup();
renderPage();
const card = screen.getByText('Rig Alpha').closest('.pt-agent-card') as HTMLElement;
await user.click(card);
await user.click(screen.getByRole('button', { name: /TRACE/i }));
await waitFor(() => expect(api.startTrace).toHaveBeenCalled());
await user.click(screen.getByRole('button', { name: /End Session/i }));
await waitFor(() => expect(api.deleteTrace).toHaveBeenCalledWith('sess-1'));
// After ending, the TRACE button reappears (disabled, nothing selected)
await waitFor(() => expect(screen.getByRole('button', { name: /TRACE/i })).toBeInTheDocument());
expect(screen.queryByRole('button', { name: /End Session/i })).not.toBeInTheDocument();
// Further poll ticks must not fire — capturedPollTick cleared on clearInterval
expect(capturedPollTick).toBeNull();
});
it('error state: shows prominent error, End Session button, and countdown', async () => {
vi.mocked(api.getTraceStatus).mockResolvedValue({
session_id: 'sess-1',
ready: false,
error: 'wg_setup failed: not supported',
hops: [],
});
useWebSocketMock.mockReturnValue(wsValue({ agents: [windowsAgent] }));
const user = userEvent.setup();
renderPage();
const card = screen.getByText('Rig Alpha').closest('.pt-agent-card') as HTMLElement;
await user.click(card);
await user.click(screen.getByRole('button', { name: /TRACE/i }));
await waitFor(() => expect(api.startTrace).toHaveBeenCalled());
// Trigger a single poll tick to deliver the error.
await waitFor(() => expect(capturedPollTick).not.toBeNull());
capturedPollTick!();
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument();
expect(screen.getByText(/wg_setup failed/i)).toBeInTheDocument();
});
// End Session button must be visible even though tracing state is now false.
expect(screen.getByRole('button', { name: /End Session/i })).toBeInTheDocument();
// Countdown text visible.
expect(screen.getByText(/Session will be terminated/i)).toBeInTheDocument();
});
it('error state: clicking End Session cancels auto-delete and clears session', async () => {
vi.mocked(api.getTraceStatus).mockResolvedValue({
session_id: 'sess-1',
ready: false,
error: 'tunnel setup error',
hops: [],
});
useWebSocketMock.mockReturnValue(wsValue({ agents: [windowsAgent] }));
const user = userEvent.setup();
renderPage();
const card = screen.getByText('Rig Alpha').closest('.pt-agent-card') as HTMLElement;
await user.click(card);
await user.click(screen.getByRole('button', { name: /TRACE/i }));
await waitFor(() => expect(capturedPollTick).not.toBeNull());
capturedPollTick!();
await waitFor(() => screen.getByRole('alert'));
// Manually end the session before countdown fires.
await user.click(screen.getByRole('button', { name: /End Session/i }));
await waitFor(() => expect(api.deleteTrace).toHaveBeenCalledWith('sess-1'));
// Session cleared — TRACE button is back.
await waitFor(() => expect(screen.getByRole('button', { name: /TRACE/i })).toBeInTheDocument());
});
it('QR modal renders when status becomes ready', async () => {
vi.mocked(api.getTraceStatus).mockResolvedValue({
session_id: 'sess-1',
ready: true,
hops: [{ agent_id: 'win-1', status: 'ready' }],
});
useWebSocketMock.mockReturnValue(wsValue({ agents: [windowsAgent] }));
const user = userEvent.setup();
renderPage();
const card = screen.getByText('Rig Alpha').closest('.pt-agent-card') as HTMLElement;
await user.click(card);
await user.click(screen.getByRole('button', { name: /TRACE/i }));
await waitFor(() => expect(capturedPollTick).not.toBeNull());
capturedPollTick!();
await waitFor(() => expect(api.getTraceQR).toHaveBeenCalledWith('sess-1'));
await waitFor(() => expect(screen.getByText('⬡ PATH TRACE ACTIVE')).toBeInTheDocument());
// QR image and config rendered inside modal.
expect(screen.getByAltText('WireGuard QR')).toBeInTheDocument();
expect(screen.getByText('wg-conf')).toBeInTheDocument();
});
});

View File

@@ -113,6 +113,8 @@ export default function PathTracerPage() {
const [showQR, setShowQR] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [autoEndCountdown, setAutoEndCountdown] = useState<number | null>(null);
const autoEndRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Use WebSocket agents; fall back to REST on mount if WebSocket hasn't populated yet.
const agents = wsAgents.length > 0 ? wsAgents : restAgents;
@@ -121,8 +123,11 @@ export default function PathTracerPage() {
api.listAgents().then(setRestAgents).catch(() => {});
}, []);
// Stop polling on unmount.
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current); }, []);
// Stop polling and auto-end timer on unmount.
useEffect(() => () => {
if (pollRef.current) clearInterval(pollRef.current);
if (autoEndRef.current) clearInterval(autoEndRef.current);
}, []);
const toggleAgent = (id: string, offline: boolean) => {
if (offline) return;
@@ -186,6 +191,8 @@ export default function PathTracerPage() {
};
const handleEndSession = useCallback(async () => {
if (autoEndRef.current) { clearInterval(autoEndRef.current); autoEndRef.current = null; }
setAutoEndCountdown(null);
if (!sessionID) return;
try {
await api.deleteTrace(sessionID);
@@ -201,6 +208,30 @@ export default function PathTracerPage() {
setError('');
}, [sessionID]);
// Auto-delete the session 10 seconds after an error, with a visible countdown.
useEffect(() => {
if (!error || !sessionID) return;
if (autoEndRef.current) clearInterval(autoEndRef.current);
const COUNTDOWN = 10;
setAutoEndCountdown(COUNTDOWN);
let remaining = COUNTDOWN;
autoEndRef.current = setInterval(() => {
remaining -= 1;
if (remaining <= 0) {
clearInterval(autoEndRef.current!);
autoEndRef.current = null;
setAutoEndCountdown(null);
handleEndSession();
} else {
setAutoEndCountdown(remaining);
}
}, 1000);
return () => {
if (autoEndRef.current) { clearInterval(autoEndRef.current); autoEndRef.current = null; }
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [error, sessionID]);
const isWindows = (a: Agent) =>
!!(a.platform?.toLowerCase().includes('win') || a.platform?.toLowerCase().includes('windows'));
@@ -222,7 +253,17 @@ export default function PathTracerPage() {
</div>
</header>
{error && <div className="pt-error-banner"> {error}</div>}
{error && (
<div className="pt-error-banner" role="alert">
<strong> Session Error</strong>
<div style={{ marginTop: '0.3rem' }}>{error}</div>
{sessionID && autoEndCountdown !== null && (
<div style={{ marginTop: '0.3rem', opacity: 0.8 }}>
Session will be terminated in {autoEndCountdown}s&hellip;
</div>
)}
</div>
)}
{tracing && !allHopsReady && !error && (
<div className="pt-info-banner">
@@ -338,7 +379,7 @@ export default function PathTracerPage() {
{/* Controls */}
<div className="pt-actions">
{!tracing && (
{!tracing && !sessionID && (
<button
className="pt-btn pt-btn-primary"
disabled={selected.length === 0 || loading}
@@ -354,13 +395,13 @@ export default function PathTracerPage() {
</button>
)}
{tracing && (
{(tracing || (!!error && !!sessionID)) && (
<button className="pt-btn pt-btn-danger" onClick={handleEndSession}>
End Session
End Session{autoEndCountdown !== null && ` (${autoEndCountdown}s)`}
</button>
)}
{!tracing && selected.length > 0 && (
{!tracing && !sessionID && selected.length > 0 && (
<button className="pt-btn pt-btn-ghost" onClick={() => setSelected([])}>
Clear
</button>

View File

@@ -132,7 +132,7 @@ export default function SettingsPage() {
log_share_submissions: cfg.server?.log_share_submissions ?? false,
log_pool_traffic: cfg.server?.log_pool_traffic ?? false,
strict_wallet_validation: cfg.server?.strict_wallet_validation ?? false,
dashboard_subtitle: cfg.server?.dashboard_subtitle ?? 'security is just an emotion',
dashboard_subtitle: cfg.server?.dashboard_subtitle ?? 'Fleet Command & Control',
open_firewall_on_start: cfg.server?.open_firewall_on_start ?? true,
public_builds_enabled: cfg.server?.public_builds_enabled ?? false,
public_builds_latest_n: cfg.server?.public_builds_latest_n ?? 3,

View File

@@ -30,7 +30,7 @@ export function mockServerConfig(overrides: Partial<ServerConfig> = {}): ServerC
log_share_submissions: false,
log_pool_traffic: false,
strict_wallet_validation: false,
dashboard_subtitle: 'security is just an emotion',
dashboard_subtitle: 'Fleet Command & Control',
open_firewall_on_start: true,
obfuscate_default: false,
sign_enabled: false,