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
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:
@@ -34,7 +34,7 @@
|
||||
<li><a href="#build-manager">Build Manager</a></li>
|
||||
<li><a href="#dashboard">Fleet & Crucible</a></li>
|
||||
<li><a href="#crucible-ops">Crucible Commands</a></li>
|
||||
<li><a href="#spread-campaigns">Emberwake & Spread</a></li>
|
||||
<li><a href="#spread-campaigns">Emberwake & 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}&c=campaign-slug</code></pre>
|
||||
</section>
|
||||
|
||||
<!-- 5. Spread & Campaigns -->
|
||||
<!-- 5. Emberwake & Campaigns -->
|
||||
<section id="spread-campaigns">
|
||||
<h2>Spread & Campaigns</h2>
|
||||
<h2>Emberwake & 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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 & Drop file here</p>
|
||||
<small>Pushes to user Desktop (any OS)</small>
|
||||
<button
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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() {
|
||||
</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' }}>
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
310
server/web/src/pages/PathTracerPage.test.tsx
Normal file
310
server/web/src/pages/PathTracerPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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…
|
||||
</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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user