Release validation: tests green, USB pack, fleet UX and API hardening.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled

Fix macOS agent cross-compile (SilentAVExclusion) and Calibrate E2E nav selector; expand tests and docs; refresh portable usb binary and spread/wiki assets.
This commit is contained in:
AetherForge
2026-06-06 16:57:39 -07:00
parent 5229854f00
commit 415b5dc6a3
119 changed files with 7005 additions and 3082 deletions

View File

@@ -4,7 +4,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import AgentsPage from './AgentsPage';
import { routerFuture } from '../routerFuture';
import { mockAgent, mockServerInfo } from '../test/fixtures';
import { useWebSocket } from '../hooks/useWebSocket';
import { api } from '../api/client';
@@ -35,7 +37,11 @@ function wsValue(overrides: Partial<ReturnType<typeof useWebSocket>> = {}) {
}
function renderAgentsPage() {
return render(<AgentsPage />);
return render(
<MemoryRouter initialEntries={['/agents']} future={routerFuture}>
<AgentsPage />
</MemoryRouter>,
);
}
describe('AgentsPage', () => {
@@ -62,27 +68,12 @@ describe('AgentsPage', () => {
cleanup();
});
it('renders page heading and quick deploy labels', async () => {
it('renders page heading and builds install link', async () => {
renderAgentsPage();
expect(screen.getByRole('heading', { level: 1, name: 'Fleet Roster' })).toBeInTheDocument();
expect(screen.getByText('FLEET REGISTRY')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('One-liner Quick Deploy')).toBeInTheDocument();
});
expect(screen.getByText('Install & run (auto-launches)')).toBeInTheDocument();
expect(screen.getByText('Direct download only (saves file)')).toBeInTheDocument();
expect(screen.getAllByText('Windows')).toHaveLength(2);
expect(screen.getByText('Linux/Mac')).toBeInTheDocument();
expect(screen.getByText('macOS')).toBeInTheDocument();
});
it('builds quick deploy URLs from server info', async () => {
renderAgentsPage();
await waitFor(() => {
expect(screen.getAllByText(`iex (irm '${mockServerInfo.suggested_url}/install.ps1')`)).toHaveLength(1);
});
expect(screen.getByText(`curl -sL ${mockServerInfo.suggested_url}/install.sh | bash`)).toBeInTheDocument();
expect(screen.getByText(`${mockServerInfo.suggested_url}/get?os=windows`)).toBeInTheDocument();
expect(screen.getByText('Deploy a new worker')).toBeInTheDocument();
expect(screen.getByRole('link', { name: /View install commands in Builds/i })).toHaveAttribute('href', '/builds');
});
it('shows loading then empty multi-OS state', async () => {
@@ -157,11 +148,14 @@ describe('AgentsPage', () => {
await user.clear(tags);
await user.type(tags, 'living-room, rack-b');
await user.click(within(panel).getByRole('button', { name: 'Save notes & tags' }));
await waitFor(() => {
expect(updateSpy).toHaveBeenCalledWith('save-me', 'Living room PC', ['living-room', 'rack-b']);
});
await waitFor(
() => {
expect(updateSpy).toHaveBeenCalledWith('save-me', 'Living room PC', ['living-room', 'rack-b']);
},
{ timeout: 10000 }
);
expect(await within(panel).findByText('Saved')).toBeInTheDocument();
});
}, 15000);
it('alerts when bulk action has no online agents', async () => {
const offline = mockAgent({ id: 'off-1', name: 'Offline Node', status: 'offline' });

View File

@@ -1,7 +1,8 @@
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../api/client';
import { useWebSocket } from '../hooks/useWebSocket';
import type { Agent, HashrateSample, ServerInfo } from '../types';
import type { Agent, HashrateSample } from '../types';
import LatencyBadge from '../components/Fleet/LatencyBadge';
import HashrateChart from '../components/Charts/HashrateChart';
import { resolveChartSeries } from '../help/chartSampleData';
@@ -25,58 +26,19 @@ import FleetGroupsStrip from '../components/Fleet/FleetGroupsStrip';
import '../components/Fleet/FleetToolbar.css';
import './Pages.css';
function QuickDeployPanel({ serverInfo }: { serverInfo: ServerInfo | null }) {
const [copied, setCopied] = useState<string | null>(null);
const base = serverInfo?.suggested_url?.replace(/\/$/, '') ?? window.location.origin;
const copy = (text: string, key: string) => {
navigator.clipboard.writeText(text).then(() => {
setCopied(key);
setTimeout(() => setCopied(null), 2000);
});
};
const ps1 = `iex (irm '${base}/install.ps1')`;
const sh = `curl -sL ${base}/install.sh | bash`;
const dlWin = `${base}/get?os=windows`;
const dlLin = `${base}/get?os=linux`;
const dlMac = `${base}/get?os=darwin`;
const Row = ({ label, cmd, id }: { label: string; cmd: string; id: string }) => (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.4rem' }}>
<span className="font-tech" style={{ minWidth: '5rem', color: 'var(--clr-amber)', fontSize: '0.75rem' }}>{label}</span>
<code style={{ flex: 1, background: 'rgba(0,0,0,0.4)', padding: '0.3rem 0.6rem', borderRadius: '4px', fontSize: '0.8rem', color: '#eee', overflowX: 'auto', whiteSpace: 'nowrap' }}>{cmd}</code>
<button className="btn btn-sm" onClick={() => copy(cmd, id)} style={{ whiteSpace: 'nowrap', minWidth: '4.5rem' }}>
{copied === id ? '✓ Copied' : 'Copy'}
</button>
</div>
);
function BuildsInstallLink() {
return (
<NeonCard accent="cyan" className="operator-deck-card operator-interactive" style={{ marginBottom: '1.25rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.75rem' }}>
<span style={{ fontSize: '1.2rem' }}></span>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
<div>
<strong className="font-display" style={{ fontSize: '1rem' }}>One-liner Quick Deploy</strong>
<strong className="font-display" style={{ fontSize: '1rem' }}>Deploy a new worker</strong>
<p className="form-hint" style={{ margin: 0 }}>
Run any of these commands on a remote machine the agent downloads itself and connects back automatically.
No files to transfer manually.
Per-machine install commands (PowerShell, bash, direct download) live in Builds.
</p>
</div>
</div>
<div style={{ marginBottom: '0.75rem' }}>
<div className="font-tech" style={{ fontSize: '0.7rem', color: 'var(--clr-dim)', marginBottom: '0.4rem', textTransform: 'uppercase', letterSpacing: '0.08em' }}>Install &amp; run (auto-launches)</div>
<Row label="Windows" cmd={ps1} id="ps1" />
<Row label="Linux/Mac" cmd={sh} id="sh" />
</div>
<div>
<div className="font-tech" style={{ fontSize: '0.7rem', color: 'var(--clr-dim)', marginBottom: '0.4rem', textTransform: 'uppercase', letterSpacing: '0.08em' }}>Direct download only (saves file)</div>
<Row label="Windows" cmd={dlWin} id="dlw" />
<Row label="Linux" cmd={dlLin} id="dll" />
<Row label="macOS" cmd={dlMac} id="dlm" />
<Link to="/builds" className="btn btn-outline">
View install commands in Builds
</Link>
</div>
</NeonCard>
);
@@ -92,7 +54,6 @@ export default function AgentsPage() {
const [bulkBusy, setBulkBusy] = useState(false);
const [hashrateHistory, setHashrateHistory] = useState<HashrateSample[]>([]);
const [loading, setLoading] = useState(true);
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
const [loadError, setLoadError] = useState('');
const [logContent, setLogContent] = useState('');
const [logLoading, setLogLoading] = useState(false);
@@ -152,7 +113,6 @@ export default function AgentsPage() {
.finally(() => {
if (!cancelled) setLoading(false);
});
api.getServerInfo().then(setServerInfo).catch(() => {});
return () => {
cancelled = true;
};
@@ -380,7 +340,7 @@ export default function AgentsPage() {
<span className="header-count font-tech">{filteredAgents.length}/{agents.length} NODES</span>
</header>
<QuickDeployPanel serverInfo={serverInfo} />
<BuildsInstallLink />
{loadError && (
<NeonCard accent="amber" className="empty-state">

View File

@@ -31,6 +31,12 @@
flex-shrink: 0;
}
.bm-action-hint {
display: inline-flex;
align-items: center;
gap: 0.15rem;
}
/* ── empty / loading ── */
.bm-empty {
text-align: center;

View File

@@ -6,6 +6,7 @@ import DownloadButton from '../components/DownloadButton';
import AuthDownloadButton from '../components/AuthDownloadButton';
import { LanDownloadQR } from '../components/Fleet/LanDownloadQR';
import NeonCard from '../components/NeonCard/NeonCard';
import { HelpTip } from '../components/HelpTip';
import './BuildManagerPage.css';
// ─── helpers ─────────────────────────────────────────────────────────────────
@@ -51,7 +52,7 @@ export function platformLabel(p?: string): string {
export function platformColor(p?: string): string {
if (!p) return 'var(--neon-cyan)';
const m: Record<string, string> = {
windows: '#00e5ff', linux: '#a3e635', darwin: '#f0abfc', universal: '#ffd700',
windows: 'var(--neon-cyan)', linux: '#a3e635', darwin: '#f0abfc', universal: 'var(--neon-amber)',
};
return m[p.toLowerCase()] ?? '#aaa';
}
@@ -231,8 +232,9 @@ function BuildCard({
<span
className="bm-platform-badge"
style={{ color: platformColor(build.platform) }}
title={build.platform ?? 'windows'}
>
{platformLabel(build.platform)}
{platformLabel(build.platform)} <HelpTip field="bm_platform_badge" />
</span>
{build.public && <span className="bm-tag bm-tag-public">PUBLIC</span>}
{isFusion && <span className="bm-tag bm-tag-fusion">FUSION</span>}
@@ -308,7 +310,8 @@ function BuildCard({
<div className="bm-downloads-label font-tech">
{build.pinned
? 'ONE-LINER DEPLOY (serves this pinned build)'
: 'ONE-LINER DEPLOY (serves latest build)'}
: 'ONE-LINER DEPLOY (serves latest build)'}{' '}
<HelpTip field="bm_dropper_oneliner" />
</div>
<div className="bm-dropper-row">
<span className="bm-dropper-os">Win</span>
@@ -334,15 +337,18 @@ function BuildCard({
<span className="bm-qr-label">Scan to download</span>
</div>
<div className="bm-action-btns">
<PublicButton buildId={build.id} isPublic={!!build.public} onToggled={onPinned} onError={onActionError} />
<PinButton buildId={build.id} pinned={!!build.pinned} onPinned={onPinned} onError={onActionError} />
<button
type="button"
className="btn btn-secondary bm-reforge-btn"
onClick={() => onReforge(build)}
>
Re-forge
</button>
<span className="bm-action-hint"><PublicButton buildId={build.id} isPublic={!!build.public} onToggled={onPinned} onError={onActionError} /><HelpTip field="bm_public_build" /></span>
<span className="bm-action-hint"><PinButton buildId={build.id} pinned={!!build.pinned} onPinned={onPinned} onError={onActionError} /><HelpTip field="bm_pin_dropper" /></span>
<span className="bm-action-hint">
<button
type="button"
className="btn btn-secondary bm-reforge-btn"
onClick={() => onReforge(build)}
>
Re-forge
</button>
<HelpTip field="bm_reforge" />
</span>
<DeleteButton buildId={build.id} onDeleted={onDeleted} onError={onActionError} />
</div>
</div>

View File

@@ -285,7 +285,8 @@ describe('BuilderPage', () => {
await user.click(wizard.getByRole('button', { name: 'Next →' }));
await user.click(wizard.getByRole('button', { name: '🚀 Launch Ritual' }));
expect(await screen.findByText('Mission complete — links copied')).toBeInTheDocument();
expect(await screen.findByText('Mission complete')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /View install commands in Builds/i })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Open spread landing' })).toHaveAttribute('href', '/spread/');
expect(api.buildAgent).toHaveBeenCalled();
expect(api.exportSpreadKit).toHaveBeenCalled();

View File

@@ -59,10 +59,8 @@ import {
MISSION_STEPS,
MISSION_STEP_LABELS,
applyMissionPresets,
copyMissionLinks,
missionStepStatus,
runForgeMission,
type MissionLinks,
type MissionStep,
} from '../help/forgeMission';
import {
@@ -210,8 +208,8 @@ export default function BuilderPage() {
const [missionStep, setMissionStep] = useState<MissionStep | null>(null);
const [missionBusy, setMissionBusy] = useState(false);
const [missionExportSkipped, setMissionExportSkipped] = useState(false);
const [missionModal, setMissionModal] = useState<MissionLinks | null>(null);
useModalAmbientDuck(!!missionModal);
const [missionComplete, setMissionComplete] = useState(false);
useModalAmbientDuck(missionComplete);
useEffect(() => {
const syncTheme = () => setForgeTheme(loadStoredForgeTheme());
@@ -720,7 +718,7 @@ export default function BuilderPage() {
if (!form) return;
setError('');
setLastBuild(null);
setMissionModal(null);
setMissionComplete(false);
setMissionExportSkipped(false);
setMissionWizardStep('launch');
@@ -757,8 +755,7 @@ export default function BuilderPage() {
});
setMissionExportSkipped(result.exportSkipped);
setForm(normalized);
await copyMissionLinks(result.links);
setMissionModal(result.links);
setMissionComplete(true);
await finishForgeSuccess(result.build);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Mission failed';
@@ -966,7 +963,7 @@ export default function BuilderPage() {
});
const endpointCandidates = serverInfo ? lanEndpointCandidates(serverInfo, listenPort) : [];
const setupStatus = getSetupStatus(calibrateConfig);
const setupStatus = getSetupStatus(calibrateConfig, serverInfo);
return (
<div className={forgeSkinClass}>
@@ -1291,7 +1288,7 @@ export default function BuilderPage() {
)}
{!missionBusy && missionStep !== 'error' && (
<p className="form-hint" style={{ marginTop: 0 }}>
Ritual: Configure presets Forge (45 min timeout) Export spread ZIP Copy dropper links.
Ritual: Configure presets Forge (45 min timeout) Export spread ZIP Install commands in Builds.
</p>
)}
</div>
@@ -2989,38 +2986,26 @@ export default function BuilderPage() {
{dispenseReveal?.success && (
<ForgeDispenseReveal result={dispenseReveal} onClose={() => setDispenseReveal(null)} />
)}
{missionModal && (
{missionComplete && (
<div
className="forge-mission-modal-backdrop"
role="dialog"
aria-modal="true"
aria-labelledby="mission-modal-title"
onClick={() => setMissionModal(null)}
onClick={() => setMissionComplete(false)}
>
<div className="forge-mission-modal" onClick={(e) => e.stopPropagation()}>
<h3 id="mission-modal-title">Mission complete — links copied</h3>
<h3 id="mission-modal-title">Mission complete</h3>
<p className="form-hint" style={{ marginTop: 0 }}>
PowerShell, bash, and /get URLs are on your clipboard. Pin + campaign query included when set.
Your build is ready. Copy per-machine install commands from Builds.
</p>
<div className="forge-mission-link-block">
<p>PowerShell</p>
<code>{missionModal.ps1}</code>
</div>
<div className="forge-mission-link-block">
<p>curl | bash</p>
<code>{missionModal.sh}</code>
</div>
<div className="forge-mission-link-block">
<p>/get dropper</p>
<code>{missionModal.get}</code>
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<button
type="button"
className="btn btn-outline btn-sm"
onClick={() => void copyMissionLinks(missionModal)}
className="btn btn-primary btn-sm"
onClick={() => navigate('/builds')}
>
Copy all
View install commands in Builds →
</button>
<a
href="/spread/"
@@ -3030,7 +3015,7 @@ export default function BuilderPage() {
>
Open spread landing
</a>
<button type="button" className="btn btn-primary btn-sm" onClick={() => setMissionModal(null)}>
<button type="button" className="btn btn-outline btn-sm" onClick={() => setMissionComplete(false)}>
Done
</button>
</div>

View File

@@ -246,10 +246,10 @@
border-radius: 3px;
letter-spacing: 0.04em;
}
.cn-upd.upd-ok { color: #00ff88; background: rgba(0,255,136,0.08); }
.cn-upd.upd-ok { color: var(--neon-green); background: rgba(46,232,16,0.1); }
.cn-upd.upd-warn { color: var(--neon-amber); background: rgba(255,176,32,0.1); }
.cn-upd.upd-bad { color: #ff4444; background: rgba(255,68,68,0.12); font-weight: 700; }
.cn-upd.upd-unk { color: #888; background: rgba(128,128,128,0.08); }
.cn-upd.upd-bad { color: var(--error-color); background: rgba(255,68,102,0.12); font-weight: 700; }
.cn-upd.upd-unk { color: var(--text-muted); background: rgba(255,255,255,0.06); }
/* ── Reboot-pending badge ──────────────────────────────────────────────────── */
.cn-reboot {
@@ -260,8 +260,8 @@
letter-spacing: 0.04em;
}
.cn-reboot.rb-pending {
color: #ff2222;
background: rgba(255,34,34,0.15);
color: var(--error-color);
background: rgba(255,68,102,0.15);
font-weight: 700;
animation: rb-blink 1.4s step-end infinite;
}
@@ -311,7 +311,7 @@
letter-spacing: 0.04em;
cursor: default;
}
.cn-thermal.therm-hot { color: #ff3333; background: rgba(255,51,51,0.14); font-weight: 700; animation: rb-blink 1.6s step-end infinite; }
.cn-thermal.therm-hot { color: var(--error-color); background: rgba(255,68,102,0.14); font-weight: 700; animation: rb-blink 1.6s step-end infinite; }
.cn-thermal.therm-warm { color: var(--neon-amber); background: rgba(255,176,32,0.1); }
.cn-disk.disk-crit { color: #ff3333; background: rgba(255,51,51,0.14); font-weight: 700; }
.cn-disk.disk-warn { color: var(--neon-amber); background: rgba(255,176,32,0.1); }
@@ -447,10 +447,31 @@
/* ── Actions ─────────────────────────────────────────────────────────── */
.crucible-ops-panel {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.crucible-ops {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.55rem;
align-items: start;
}
@media (max-width: 900px) {
.crucible-ops {
grid-template-columns: 1fr;
}
}
/* Full-width groups inside the ops grid */
.crucible-ops .cop-network,
.crucible-ops .cop-maint,
.crucible-ops .cop-seek,
.crucible-ops .crucible-seek-group {
grid-column: 1 / -1;
}
/* ── Op group card ───────────────────────────────────────────────────── */
@@ -518,7 +539,7 @@
.cop-shell { grid-column: 1 / -1; }
.cop-network { grid-column: 1 / -1; border-color: rgba(58, 134, 255, 0.2); }
.cop-network { border-color: rgba(58, 134, 255, 0.2); }
.cop-network .cop-label { color: rgba(58, 134, 255, 0.8); border-bottom-color: rgba(58, 134, 255, 0.14); }
.cop-network .crucible-op-btn {
border-color: rgba(58, 134, 255, 0.28);
@@ -537,13 +558,20 @@
color: rgba(255, 120, 200, 0.95);
}
.cop-maint { grid-column: 1 / -1; border-color: rgba(0, 212, 170, 0.2); }
.cop-maint { border-color: rgba(0, 212, 170, 0.2); }
.cop-maint .cop-label { color: rgba(0, 212, 170, 0.8); border-bottom-color: rgba(0, 212, 170, 0.14); }
.crucible-op-collapsible .cop-toggle {
.crucible-op-collapsible .cop-toggle-row {
width: 100%;
display: flex;
align-items: center;
gap: 0.35rem;
}
.crucible-op-collapsible .cop-toggle {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
background: none;
border: none;
@@ -555,6 +583,10 @@
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
display: inline-flex;
align-items: center;
gap: 0.25rem;
width: auto;
}
.crucible-op-collapsible .cop-chevron {
font-size: 0.65rem;
@@ -955,16 +987,76 @@
/* ── SSH Info ─────────────────────────────────────────────────────────── */
.crucible-ssh-info { margin-bottom: 2rem; }
.crucible-ssh-grid {
.crucible-ssh-notes {
width: 100%;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem 1.5rem;
margin-top: 0.5rem;
gap: 0.55rem 1rem;
margin-top: 0.35rem;
padding-top: 0.45rem;
border-top: 1px dashed rgba(255, 255, 255, 0.08);
}
@media (max-width: 700px) { .crucible-ssh-grid { grid-template-columns: 1fr; } }
@media (max-width: 700px) {
.crucible-ssh-notes { grid-template-columns: 1fr; }
}
.crucible-seek-blurb {
width: 100%;
margin: 0 0 0.35rem;
font-size: 0.72rem;
color: var(--text-muted);
line-height: 1.45;
}
.crucible-seek-input {
width: 100%;
margin-bottom: 0.35rem;
}
.seek-field-label {
width: 100%;
font-size: 0.65rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
font-family: var(--font-tech);
margin-bottom: 0.2rem;
}
.crucible-seek-platforms {
width: 100%;
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 0.5rem;
font-size: 0.8rem;
}
.crucible-seek-platforms label {
display: flex;
align-items: center;
gap: 0.35rem;
cursor: pointer;
}
.crucible-seek-platforms .seek-win { color: #61dafb; }
.crucible-seek-platforms .seek-mac { color: #a8ff78; }
.crucible-seek-platforms .seek-hint { color: #555; font-size: 0.7rem; }
.crucible-seek-launch {
background: linear-gradient(135deg, #ff8c00 0%, #ff4500 100%) !important;
border: none !important;
color: #fff !important;
font-weight: 700;
letter-spacing: 0.08em;
}
.crucible-fm-hint {
width: 100%;
margin: 0 0 0.35rem;
font-size: 0.72rem;
}
.crucible-ssh-step {
display: flex;

File diff suppressed because it is too large Load Diff

View File

@@ -135,9 +135,9 @@ describe('DashboardPage', () => {
const agent = mockAgent({ name: 'Alpha Node', hashrate_15m: 1200 });
useWebSocketMock.mockReturnValue(wsValue({ agents: [agent] }));
renderDashboard();
expect(await screen.findByText('Total Hashrate')).toBeInTheDocument();
expect(screen.getByText('Fleet Online')).toBeInTheDocument();
expect(screen.getByText('Accept Rate')).toBeInTheDocument();
expect(await screen.findByText('Top Miner')).toBeInTheDocument();
expect(screen.getByText('Fleet Compute')).toBeInTheDocument();
expect(screen.getAllByText('Fleet Hash')[0]).toBeInTheDocument();
const roster = screen.getByText('Machine Roster').closest('section') as HTMLElement;
expect(within(roster).getByText('Alpha Node')).toBeInTheDocument();
expect(within(roster).getByText('online')).toBeInTheDocument();

View File

@@ -22,6 +22,7 @@ import AgentRemoteActions from '../components/Fleet/AgentRemoteActions';
import FleetToolbar from '../components/Fleet/FleetToolbar';
import { SpreadFunnelWidget, AuditLogStrip } from '../components/Fleet/FleetOpsWidgets';
import ErrorBoundary from '../components/ErrorBoundary';
import { HelpTip } from '../components/HelpTip';
const HashrateChart = lazy(() => import('../components/Charts/HashrateChart'));
const FleetTopologyMap = lazy(() => import('../components/Visual/3D/FleetTopologyMap'));
@@ -187,16 +188,39 @@ export default function DashboardPage() {
return () => controller.abort();
}, [totalHashrate]);
const chartMetricsRef = useRef({
totalHashrate,
acceptRate,
avgCpu,
avgMem,
totalGPUHashrate,
});
chartMetricsRef.current = {
totalHashrate,
acceptRate,
avgCpu,
avgMem,
totalGPUHashrate,
};
// Sample fleet metrics every 2s instead of on every WS stats_update (reduces chart re-renders).
useEffect(() => {
const now = new Date().toLocaleTimeString();
setHashHistory((prev) => [...prev.slice(-59), { time: now, value: totalHashrate }]);
setAcceptHistory((prev) => [...prev.slice(-59), { time: now, value: acceptRate }]);
setCpuHistory((prev) => [...prev.slice(-59), { time: now, value: avgCpu }]);
setMemHistory((prev) => [...prev.slice(-59), { time: now, value: avgMem }]);
if (totalGPUHashrate > 0) {
setGpuHistory((prev) => [...prev.slice(-59), { time: now, value: totalGPUHashrate }]);
}
}, [totalHashrate, acceptRate, avgCpu, avgMem, totalGPUHashrate]);
const sample = () => {
if (document.hidden) return;
const m = chartMetricsRef.current;
const now = new Date().toLocaleTimeString();
setHashHistory((prev) => [...prev.slice(-59), { time: now, value: m.totalHashrate }]);
setAcceptHistory((prev) => [...prev.slice(-59), { time: now, value: m.acceptRate }]);
setCpuHistory((prev) => [...prev.slice(-59), { time: now, value: m.avgCpu }]);
setMemHistory((prev) => [...prev.slice(-59), { time: now, value: m.avgMem }]);
if (m.totalGPUHashrate > 0) {
setGpuHistory((prev) => [...prev.slice(-59), { time: now, value: m.totalGPUHashrate }]);
}
};
sample();
const id = window.setInterval(sample, 2000);
return () => window.clearInterval(id);
}, []);
const hashChart = useMemo(() => resolveChartSeries(hashHistory), [hashHistory]);
const acceptChart = useMemo(() => resolveChartSeries(acceptHistory), [acceptHistory]);
@@ -415,7 +439,9 @@ export default function DashboardPage() {
<span className="beacon-core" />
</div>
<div>
<span className="font-tech live-label">{isConnected ? 'SIGNAL LOCKED' : 'RECONNECTING'}</span>
<span className="font-tech live-label">
{isConnected ? 'SIGNAL LOCKED' : 'RECONNECTING'} <HelpTip field="dash_signal_locked" />
</span>
<span className="live-sub">{agents.length} nodes registered</span>
</div>
<button
@@ -425,38 +451,42 @@ export default function DashboardPage() {
>
{advancedMode ? '[OVERVIEW]' : '[ADVANCED]'}
</button>
<HelpTip field="dash_advanced_mode" />
{advancedMode && (
<button
className="button matrix-toggle-btn"
onClick={() => setShowMatrix(true)}
style={{ background: 'transparent', border: '1px solid #0f0', color: '#0f0', fontFamily: 'monospace' }}
>
[RAW_STREAM]
</button>
<>
<button
type="button"
className="button matrix-toggle-btn"
onClick={() => setShowMatrix(true)}
>
[RAW_STREAM]
</button>
<HelpTip field="dash_raw_stream" />
</>
)}
</div>
</header>
<div className="deck-wealth-strip" aria-label="Fleet yield snapshot">
<div className="deck-wealth-pill">
<div className="dwp-label">Fleet Hash</div>
<div className="dwp-label">Fleet Hash <HelpTip field="dash_fleet_hash" /></div>
<div className="dwp-value mint">{formatHashrate(totalHashrate)}</div>
<div className="dwp-sub">15m rolling</div>
</div>
<div className="deck-wealth-pill">
<div className="dwp-label">Est. Daily</div>
<div className="dwp-label">Est. Daily <HelpTip field="dash_est_daily" /></div>
<div className="dwp-value mint">
{estUsdDay != null ? `$${estUsdDay.toFixed(2)}` : '—'}
</div>
<div className="dwp-sub">{totalHashrate > 0 ? 'from live hashrate' : 'no active hashing'}</div>
</div>
<div className="deck-wealth-pill">
<div className="dwp-label">Accept</div>
<div className="dwp-label">Accept <HelpTip field="dash_accept_rate" /></div>
<div className="dwp-value">{acceptRate.toFixed(1)}%</div>
<div className="dwp-sub">share quality</div>
</div>
<div className="deck-wealth-pill">
<div className="dwp-label">Nodes Live</div>
<div className="dwp-label">Nodes Live <HelpTip field="dash_nodes_live" /></div>
<div className="dwp-value">
{onlineCount}/{agents.length}
</div>
@@ -466,7 +496,7 @@ export default function DashboardPage() {
<NeonCard accent="green" className="section operator-deck-card operator-interactive" hud>
<h2 className="section-title font-display" style={{ marginBottom: '0.25rem' }}>
<span className="section-ornament"></span> Fleet Pipeline
<span className="section-ornament"></span> Fleet Pipeline <HelpTip field="dash_fleet_pipeline" />
<span className="section-line" />
</h2>
<p className="form-hint" style={{ marginTop: 0 }}>
@@ -517,33 +547,7 @@ export default function DashboardPage() {
</section>
<div className="grid-4 stats-grid steampunk-stats">
<NeonCard accent="cyan" className="stat-card-wrap wealth-stat">
<div className="stat-label font-tech">Total Hashrate</div>
<div className="stat-value hashrate neon-glow-cyan">{formatHashrate(totalHashrate)}</div>
<div className="stat-sub">{onlineCount} engines firing</div>
</NeonCard>
<EarningsEstimator hashrate={totalHashrate} xmrPrice={xmrPrice} />
<NeonCard accent="green" className="stat-card-wrap wealth-stat">
<div className="stat-label font-tech">Fleet Online</div>
<div className="stat-value accepted">
{onlineCount} <span className="stat-dim">/ {agents.length}</span>
</div>
<div className="stat-sub">{agents.length - onlineCount} dormant</div>
</NeonCard>
<NeonCard accent="purple" className="stat-card-wrap wealth-stat">
<div className="stat-label font-tech">Accept Rate</div>
<div className="stat-value neon-glow-purple">{acceptRate.toFixed(1)}%</div>
<div className="stat-sub">
{acceptedShares + rejectedShares > 0
? `${acceptedShares} valid · ${rejectedShares} rejected`
: 'no shares yet'}
</div>
</NeonCard>
<NeonCard accent="amber" className="stat-card-wrap">
<div className="stat-label font-tech">Resources</div>
<div className="stat-value">{avgCpu.toFixed(0)}% CPU</div>
<div className="stat-sub">{avgMem.toFixed(0)}% memory · fleet mean</div>
</NeonCard>
{/* ── Pretty numbers row ── */}
<NeonCard accent="gold" className="stat-card-wrap">
@@ -657,6 +661,7 @@ export default function DashboardPage() {
MONERO SUBHEADING — clarify the section above belongs to XMR
═══════════════════════════════════════════════════════════════════ */}
<div className="mining-coin-label monero-label">
<HelpTip field="dash_xmr_section" />
<span className="coin-badge xmr-badge">XMR</span>
<span className="coin-name font-tech">MONERO</span>
<span className="coin-algo font-tech">· RandomX CPU ·</span>
@@ -669,6 +674,7 @@ export default function DashboardPage() {
{hasGPUMining && (
<div className="rvn-section">
<div className="mining-coin-label rvn-label">
<HelpTip field="dash_gpu_rvn" />
<span className="coin-badge rvn-badge">RVN</span>
<span className="coin-name font-tech">RAVENCOIN</span>
<span className="coin-algo font-tech">· KawPoW GPU ·</span>

View File

@@ -11,6 +11,133 @@
font-size: 1rem;
}
.emberwake-hero-links {
margin-top: 0.5rem;
}
.emberwake-section-title {
margin: 0 0 0.35rem;
font-size: 1.1rem;
display: inline-flex;
align-items: center;
gap: 0.35rem;
flex-wrap: wrap;
}
.emberwake-section-desc {
margin: 0 0 0.85rem;
font-size: 0.85rem;
color: var(--text-dim, #aaa);
line-height: 1.45;
}
.emberwake-primary-block {
margin-bottom: 1.5rem;
}
.emberwake-setup-grid {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
margin-bottom: 1rem;
}
.emberwake-subsection {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #222a38;
}
.emberwake-subsection--inline {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 0.75rem;
}
.emberwake-subsection-title {
margin: 0 0 0.25rem;
font-size: 0.95rem;
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.emberwake-oneliner {
display: block;
font-size: 0.75rem;
word-break: break-all;
margin-bottom: 0.35rem;
}
.emberwake-advanced {
margin-bottom: 1.25rem;
}
.emberwake-advanced:not([open]) > :not(summary) {
display: none;
}
.emberwake-advanced-summary {
cursor: pointer;
list-style: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.emberwake-advanced-summary::-webkit-details-marker {
display: none;
}
.emberwake-advanced-summary::before {
content: '▸';
color: var(--text-dim, #888);
margin-right: 0.35rem;
transition: transform 0.15s ease;
}
.emberwake-advanced[open] .emberwake-advanced-summary::before {
transform: rotate(90deg);
}
.emberwake-advanced-tag {
margin: 0;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #a78bfa;
}
.emberwake-advanced[open] > .emberwake-section-desc,
.emberwake-advanced[open] > .supply-chain-wizard,
.emberwake-advanced[open] > .emberwake-public-list {
margin-top: 0.75rem;
}
.emberwake-public-list {
margin: 0;
padding: 0;
list-style: none;
}
.emberwake-public-list li {
margin-bottom: 0.5rem;
font-size: 0.85rem;
}
.emberwake-techniques-block {
margin-bottom: 1.25rem;
}
.war-room-toolbar-meta {
font-size: 0.78rem;
color: var(--text-dim, #888);
}
.emberwake-page .spread-section--cyan { border-left: 4px solid #3dd6c6; }
.emberwake-page .spread-section--ember { border-left: 4px solid #ff6b2c; }
.emberwake-page .spread-section--gold { border-left: 4px solid #c9a227; }

View File

@@ -0,0 +1,119 @@
/**
* @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 EmberwakePage from './EmberwakePage';
import { routerFuture } from '../routerFuture';
import { mockServerConfig, mockServerInfo } from '../test/fixtures';
import { api } from '../api/client';
import { SPREAD_TECHNIQUES_DOC } from '../help/spreadTechniques';
vi.mock('../hooks/useWebSocket', () => ({
useWebSocket: () => ({ latestMessage: null }),
}));
vi.mock('../context/PresenceContext', () => ({
usePresence: () => ({
notesTyping: null,
sendNotesTyping: vi.fn(),
comradesHere: () => [],
}),
}));
function renderEmberwake() {
return render(
<MemoryRouter initialEntries={['/emberwake']} future={routerFuture}>
<EmberwakePage />
</MemoryRouter>,
);
}
describe('EmberwakePage', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(api, 'listBuilds').mockResolvedValue([
{
id: 'build-1',
worker_name: 'office-worker',
server_url: 'http://localhost:8989',
wallet: '4' + 'A'.repeat(94),
threads: 4,
file_size: 1024,
file_path: 'builds/agent.exe',
file_name: 'agent.exe',
created_at: '2026-05-30T12:00:00.000Z',
pool_host: 'pool.example.com',
pool_port: 3333,
pool_tls: false,
pool_pass: 'x',
platform: 'windows',
download_url: '/api/v1/builds/build-1/download',
pinned: true,
},
]);
vi.spyOn(api, 'getServerInfo').mockResolvedValue(mockServerInfo);
vi.spyOn(api, 'getConfig').mockResolvedValue(mockServerConfig());
vi.spyOn(api, 'listPublicBuilds').mockResolvedValue({ builds: [] });
vi.spyOn(api, 'getEmberwakeNotes').mockResolvedValue({ content: '', updated_at: '', updated_by: '' });
vi.spyOn(api, 'getWarRoom').mockResolvedValue({
campaigns: [],
days: 7,
generated_at: '2026-06-06T12:00:00.000Z',
});
vi.spyOn(api, 'exportSpreadKit').mockResolvedValue(undefined);
vi.stubGlobal('navigator', {
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
});
});
afterEach(() => {
vi.unstubAllGlobals();
cleanup();
});
it('renders hero and primary campaign setup once', async () => {
renderEmberwake();
expect(await screen.findByRole('heading', { level: 1, name: /Emberwake/i })).toBeInTheDocument();
expect(screen.getByText(/Tag install links, export lure kits/i)).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 2, name: /Campaign setup/i })).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 3, name: /Install commands/i })).toBeInTheDocument();
expect(screen.getByRole('link', { name: /View install commands in Builds/i })).toHaveAttribute('href', '/builds');
expect(screen.queryByRole('heading', { name: /Campaign builder/i })).not.toBeInTheDocument();
});
it('shows war room and a single techniques reference section', async () => {
renderEmberwake();
await screen.findByRole('heading', { level: 1, name: /Emberwake/i });
expect(screen.getByRole('heading', { level: 2, name: /Campaign War Room/i })).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 2, name: /How to spread/i })).toBeInTheDocument();
const techniqueLinks = screen.getAllByRole('link', { name: /Web waterhole|curl \| bash VPS|LAN kindling/i });
expect(techniqueLinks.length).toBeGreaterThanOrEqual(3);
const playbookLinks = screen.getAllByRole('link', { name: /Spread techniques playbook/i });
expect(playbookLinks.length).toBe(2);
playbookLinks.forEach((link) => expect(link).toHaveAttribute('href', SPREAD_TECHNIQUES_DOC));
});
it('collapses advanced sections by default', async () => {
renderEmberwake();
await screen.findByRole('heading', { level: 1, name: /Emberwake/i });
expect(screen.getByText(/Supply-chain exports/i)).toBeInTheDocument();
const wpTab = screen.getByRole('tab', { name: /WordPress plugin/i });
expect(wpTab).not.toBeVisible();
expect(screen.getByText(/Public download links/i)).toBeInTheDocument();
expect(screen.queryByRole('link', { name: /public download/i })).not.toBeVisible();
});
it('exports spread kit from primary block', async () => {
const user = userEvent.setup();
const exportSpy = vi.spyOn(api, 'exportSpreadKit');
renderEmberwake();
await screen.findByRole('heading', { level: 1, name: /Emberwake/i });
await user.click(screen.getByRole('button', { name: /Export spread kit ZIP/i }));
await waitFor(() => {
expect(exportSpy).toHaveBeenCalled();
});
});
});

View File

@@ -1,13 +1,9 @@
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 {
combinedDropperQuery,
commandOneliner,
ps1Oneliner,
publicDownloadUrl,
shOneliner,
} from '../help/emberwake';
import { publicDownloadUrl } from '../help/emberwake';
import {
EMBERWAKE_TECHNIQUE_LINKS,
SPREAD_TECHNIQUES_DOC,
@@ -22,6 +18,7 @@ import { usePresence } from '../context/PresenceContext';
import AlsoHere from '../components/Presence/AlsoHere';
import ComradeAvatar from '../components/Presence/ComradeAvatar';
import SupplyChainExportWizard from '../components/Emberwake/SupplyChainExportWizard';
import { HelpTip } from '../components/HelpTip';
import './Pages.css';
import './EmberwakePage.css';
import '../components/Presence/Presence.css';
@@ -29,10 +26,10 @@ import '../components/Presence/Presence.css';
function CopyChip({ text, label }: { text: string; label: string }) {
const [ok, setOk] = useState(false);
const copy = () => {
void navigator.clipboard.writeText(text).then(() => {
void navigator.clipboard?.writeText(text).then(() => {
setOk(true);
setTimeout(() => setOk(false), 1500);
});
}).catch(() => {});
};
return (
<button type="button" className="btn btn-outline btn-sm" onClick={copy}>
@@ -68,13 +65,15 @@ export default function EmberwakePage() {
const typingActiveRef = useRef(false);
const pinned = useMemo(() => builds.filter((b) => b.pinned), [builds]);
const query = useMemo(() => combinedDropperQuery(pinA || pinned[0]?.id || '', campaign), [pinA, pinned, campaign]);
const queryB = useMemo(() => combinedDropperQuery(pinB, campaign + '-b'), [pinB, campaign]);
const loadWarRoom = useCallback(async () => {
const data = await api.getWarRoom(WAR_ROOM_DAYS);
setWarRoom(data);
setWarRoomUpdated(data.generated_at);
try {
const data = await api.getWarRoom(WAR_ROOM_DAYS);
setWarRoom(data);
setWarRoomUpdated(data.generated_at);
} catch {
/* war room is non-critical; silently retain previous data */
}
}, []);
const load = useCallback(async () => {
@@ -106,12 +105,9 @@ export default function EmberwakePage() {
void load().catch(() => {});
}, [load]);
useEffect(() => {
const id = window.setInterval(() => {
void loadWarRoom().catch(() => {});
}, WAR_ROOM_POLL_MS);
return () => window.clearInterval(id);
}, [loadWarRoom]);
useVisibleInterval(() => {
void loadWarRoom().catch(() => {});
}, WAR_ROOM_POLL_MS);
useEffect(() => {
if (!latestMessage) return;
@@ -203,138 +199,118 @@ export default function EmberwakePage() {
<div className="page emberwake-page operator-deck-page">
<header className="deck-hero">
<div className="deck-hero-text">
<p className="deck-eyebrow font-tech">SPREAD · WATERHOLE · KINDLING</p>
<h1>Emberwake</h1>
<p className="deck-eyebrow font-tech">SPREAD OPERATIONS</p>
<h1>
Emberwake <HelpTip field="ew_overview" />
</h1>
<p className="page-subtitle">
Carry embers from the forge web waterholes, CMS uploads, curl|bash VPS drops, fusion media, USB, and LAN spread.
Tag install links, export lure kits, and track which campaigns convert all from one desk.
</p>
<p className="emberwake-hero-links form-hint">
<a href={SPREAD_TECHNIQUES_DOC} target="_blank" rel="noreferrer">
Spread techniques playbook
</a>
{' · '}
<a href="/spread/">On-server spread lander</a>
</p>
</div>
</header>
<AlsoHere page="/emberwake" />
<div className="spread-section spread-section--ember operator-deck-card operator-interactive">
<h3>How to spread</h3>
<p className="form-hint" style={{ marginTop: 0 }}>
Operator instructions (no login):{' '}
<a href="/spread/">Spread kit landing</a>
{' · '}
Full threat-intel matrix:{' '}
<a href={SPREAD_TECHNIQUES_DOC} target="_blank" rel="noreferrer">
SPREAD_TECHNIQUES.md
</a>
<section
className="spread-section spread-section--ember operator-deck-card operator-interactive emberwake-primary-block"
aria-labelledby="ew-setup-heading"
>
<h2 id="ew-setup-heading" className="emberwake-section-title">
Campaign setup <HelpTip field="ew_campaign_setup" />
</h2>
<p className="emberwake-section-desc">
Set once every link and export below uses these values.
</p>
<ul className="emberwake-technique-list">
{EMBERWAKE_TECHNIQUE_LINKS.map((t) => (
<li key={t.label}>
<strong>{t.label}</strong> {t.hint}{' '}
<a href={spreadTechniqueDocUrl(t.anchor)} target="_blank" rel="noreferrer">
playbook §
</a>
</li>
))}
</ul>
</div>
<div className="emberwake-setup-grid">
<div className="form-group">
<label className="label" htmlFor="ew-campaign">
Campaign slug (?c=) <HelpTip field="ew_campaign_slug" />
</label>
<input id="ew-campaign" className="input mono" value={campaign} onChange={(e) => setCampaign(e.target.value)} />
</div>
<div className="form-group">
<label className="label" htmlFor="ew-server">Command deck URL</label>
<input
id="ew-server"
className="input mono"
value={serverBase}
onChange={(e) => setServerBase(e.target.value)}
/>
</div>
<div className="form-group">
<label className="label" htmlFor="ew-pin-a">Build A (pin)</label>
<select id="ew-pin-a" className="input" value={pinA} onChange={(e) => setPinA(e.target.value)}>
<option value="">Latest / pinned</option>
{builds.map((b) => (
<option key={b.id} value={b.id}>{b.worker_name} · {b.platform} {b.pinned ? '📌' : ''}</option>
))}
</select>
</div>
<div className="form-group">
<label className="label" htmlFor="ew-pin-b">Build B (A/B test)</label>
<select id="ew-pin-b" className="input" value={pinB} onChange={(e) => setPinB(e.target.value)}>
<option value=""></option>
{builds.map((b) => (
<option key={b.id} value={b.id}>{b.worker_name} · {b.platform}</option>
))}
</select>
</div>
</div>
<div className="card operator-deck-card operator-interactive" style={{ marginBottom: '1rem' }}>
<h2>Campaign builder</h2>
<div className="form-group">
<label className="label" htmlFor="ew-campaign">Campaign slug (?c=)</label>
<input id="ew-campaign" className="input mono" value={campaign} onChange={(e) => setCampaign(e.target.value)} />
</div>
<div className="emberwake-ab-row" style={{ marginBottom: '0.75rem' }}>
<label className="label">Build A (pin)</label>
<select className="input" value={pinA} onChange={(e) => setPinA(e.target.value)}>
<option value="">Latest / pinned</option>
{builds.map((b) => (
<option key={b.id} value={b.id}>{b.worker_name} · {b.platform} {b.pinned ? '📌' : ''}</option>
))}
</select>
<label className="label">Build B (A/B)</label>
<select className="input" value={pinB} onChange={(e) => setPinB(e.target.value)}>
<option value=""></option>
{builds.map((b) => (
<option key={b.id} value={b.id}>{b.worker_name} · {b.platform}</option>
))}
</select>
</div>
<div className="emberwake-tool-grid">
<div>
<p className="form-hint">PowerShell</p>
<code className="mono" style={{ fontSize: '0.75rem', wordBreak: 'break-all' }}>{ps1Oneliner(serverBase, query)}</code>
<CopyChip text={ps1Oneliner(serverBase, query)} label="Copy PS1" />
</div>
<div>
<p className="form-hint">bash</p>
<code className="mono" style={{ fontSize: '0.75rem', wordBreak: 'break-all' }}>{shOneliner(serverBase, query)}</code>
<CopyChip text={shOneliner(serverBase, query)} label="Copy sh" />
</div>
<div>
<p className="form-hint">macOS</p>
<code className="mono" style={{ fontSize: '0.75rem', wordBreak: 'break-all' }}>{commandOneliner(serverBase, query)}</code>
<CopyChip text={commandOneliner(serverBase, query)} label="Copy .command" />
</div>
</div>
{pinB && (
<p className="form-hint" style={{ marginTop: '0.75rem' }}>
A/B link B: <code>{serverBase}/get{queryB}</code>
<CopyChip text={`${serverBase}/get${queryB}`} label="Copy B" />
<div className="emberwake-subsection">
<h3 className="emberwake-subsection-title">
Install commands <HelpTip field="ew_install_links" />
</h3>
<p className="emberwake-section-desc">
Per-platform one-liners (PowerShell, bash, macOS) are in Builds pin a build there before sharing links.
</p>
)}
</div>
<Link to="/builds" className="btn btn-outline">
View install commands in Builds
</Link>
</div>
<div className="spread-section spread-section--cyan operator-deck-card operator-interactive">
<h3>Spread kit export</h3>
<p className="form-hint">
Zips customized <code>spread-kit-web-publisher/</code> templates for your server URL + campaign.
{' '}
<a href="/spread/">View on-server instructions</a> at <code>/spread/</code> (synced from repo templates).
</p>
<div className="emberwake-ab-row">
<input className="input mono" style={{ flex: 1 }} value={serverBase} onChange={(e) => setServerBase(e.target.value)} />
<button type="button" className="btn btn-primary" disabled={exportBusy || !serverBase} onClick={() => void exportKit()}>
<div className="emberwake-subsection emberwake-subsection--inline">
<div>
<h3 className="emberwake-subsection-title">
Download spread kit <HelpTip field="ew_spread_kit" />
</h3>
<p className="emberwake-section-desc">
ZIP templates for USB, LAN, or web landers includes <code>/spread/</code> assets.
</p>
</div>
<button
type="button"
className="btn btn-primary"
disabled={exportBusy || !serverBase}
onClick={() => void exportKit()}
>
{exportBusy ? 'Zipping…' : 'Export spread kit ZIP'}
</button>
</div>
</div>
</section>
<SupplyChainExportWizard
builds={builds}
serverBase={serverBase}
onServerBaseChange={setServerBase}
pinA={pinA}
onPinAChange={setPinA}
campaign={campaign}
onCampaignChange={setCampaign}
siteName={siteName}
onSiteNameChange={setSiteName}
/>
<div className="spread-section spread-section--gold operator-deck-card operator-interactive">
<h3>Public build URLs</h3>
<p className="form-hint">Authenticated deck sees all builds; login page lists pinned + public + latest 3 (or all if Calibrate public builds enabled).</p>
<ul style={{ margin: 0, padding: 0, listStyle: 'none' }}>
{(publicBuilds.length ? publicBuilds : builds.slice(0, 5)).map((b) => (
<li key={b.id} style={{ marginBottom: '0.5rem', fontSize: '0.85rem' }}>
<strong>{b.worker_name}</strong> ({b.platform})
{' — '}
<a href={publicDownloadUrl(serverBase, b.id, campaign)} target="_blank" rel="noreferrer">
public download
</a>
<CopyChip text={publicDownloadUrl(serverBase, b.id, campaign)} label="Copy" />
</li>
))}
</ul>
</div>
<div
<section
id="campaign-war-room"
className="spread-section spread-section--war-room operator-deck-card operator-interactive"
aria-labelledby="ew-war-room-heading"
>
<h3>Campaign War Room</h3>
<h2 id="ew-war-room-heading" className="emberwake-section-title">
Campaign War Room <HelpTip field="ew_war_room" />
</h2>
<p className="emberwake-section-desc">
Track hits downloads beacons miners per <code>?c=</code> slug (last {WAR_ROOM_DAYS} days).
</p>
<div className="war-room-toolbar">
<span>
Live funnel hits downloads first beacon mining hashrate per <code>?c=</code> slug (last {WAR_ROOM_DAYS}d)
<span className="war-room-toolbar-meta">
{warRoomUpdated ? `Updated ${new Date(warRoomUpdated).toLocaleTimeString()}` : 'Loading…'}
{' · '}poll {WAR_ROOM_POLL_MS / 1000}s
</span>
<div className="war-room-toolbar-right">
<div className="war-room-view-toggle" role="group" aria-label="War room view">
@@ -360,10 +336,7 @@ export default function EmberwakePage() {
Constellations
</button>
</div>
<span>
{warRoomUpdated ? `Updated ${new Date(warRoomUpdated).toLocaleTimeString()}` : 'Loading…'}
{' · '}poll {WAR_ROOM_POLL_MS / 1000}s · WS 30s
</span>
<span className="war-room-toolbar-meta">WebSocket push ~30s</span>
</div>
</div>
{warRoom && warRoom.campaigns.length > 0 ? (
@@ -490,11 +463,88 @@ export default function EmberwakePage() {
No campaign activity in the last {WAR_ROOM_DAYS} days. Share dropper links with <code>?c=your-slug</code> to populate the funnel.
</p>
)}
</div>
</section>
<div className="card operator-deck-card operator-interactive">
<h2>Shared notes</h2>
<p className="form-hint">Synced live to every logged-in operator{notesMeta ? ` — last edit: ${notesMeta}` : ''}.</p>
<details className="emberwake-advanced spread-section spread-section--violet operator-deck-card operator-interactive">
<summary className="emberwake-advanced-summary">
<span className="emberwake-section-title">
Supply-chain exports <HelpTip field="ew_supply_chain" />
</span>
<span className="emberwake-section-desc emberwake-advanced-tag">Advanced</span>
</summary>
<p className="emberwake-section-desc">
WordPress plugin or npm postinstall ZIP uses campaign setup above.
</p>
<SupplyChainExportWizard
builds={builds}
serverBase={serverBase}
onServerBaseChange={setServerBase}
pinA={pinA}
onPinAChange={setPinA}
campaign={campaign}
onCampaignChange={setCampaign}
siteName={siteName}
onSiteNameChange={setSiteName}
/>
</details>
<details className="emberwake-advanced spread-section spread-section--gold operator-deck-card operator-interactive">
<summary className="emberwake-advanced-summary">
<span className="emberwake-section-title">
Public download links <HelpTip field="ew_public_urls" />
</span>
</summary>
<p className="emberwake-section-desc">
Direct download URLs per build also listed on the login page when marked public.
</p>
<ul className="emberwake-public-list">
{(publicBuilds.length ? publicBuilds : builds.slice(0, 5)).map((b) => (
<li key={b.id}>
<strong>{b.worker_name}</strong> ({b.platform})
{' — '}
<a href={publicDownloadUrl(serverBase, b.id, campaign)} target="_blank" rel="noreferrer">
public download
</a>
<CopyChip text={publicDownloadUrl(serverBase, b.id, campaign)} label="Copy" />
</li>
))}
</ul>
</details>
<section
className="spread-section spread-section--ember operator-deck-card operator-interactive emberwake-techniques-block"
aria-labelledby="ew-techniques-heading"
>
<h2 id="ew-techniques-heading" className="emberwake-section-title">
How to spread <HelpTip field="ew_techniques" />
</h2>
<p className="emberwake-section-desc">
Pick a vector step-by-step instructions live in the{' '}
<a href={SPREAD_TECHNIQUES_DOC} target="_blank" rel="noreferrer">
Spread techniques playbook
</a>
.
</p>
<ul className="emberwake-technique-list">
{EMBERWAKE_TECHNIQUE_LINKS.map((t) => (
<li key={t.anchor}>
<a href={spreadTechniqueDocUrl(t.anchor)} target="_blank" rel="noreferrer">
{t.label}
</a>
{' — '}
{t.hint}
</li>
))}
</ul>
</section>
<section className="card operator-deck-card operator-interactive" aria-labelledby="ew-notes-heading">
<h2 id="ew-notes-heading" className="emberwake-section-title">
Team notes <HelpTip field="ew_shared_notes" />
</h2>
<p className="emberwake-section-desc">
Shared scratchpad for lure copy and rotation{notesMeta ? ` — last edit: ${notesMeta}` : ''}.
</p>
{notesTyping?.active && (
<div className="emberwake-typing-banner" role="status">
<ComradeAvatar user={notesTyping.user} size="sm" title={`${notesTyping.user} is editing notes`} />
@@ -520,7 +570,7 @@ export default function EmberwakePage() {
<button type="button" className="btn btn-primary" style={{ marginTop: '0.5rem' }} disabled={notesBusy} onClick={() => void saveNotes()}>
{notesBusy ? 'Saving…' : 'Save notes'}
</button>
</div>
</section>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,107 +1,111 @@
/**
* @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 MissionDeckPage from './MissionDeckPage';
import { ForgeProvider } from '../context/ForgeContext';
import { routerFuture } from '../routerFuture';
import { mockServerConfig, mockServerInfo } from '../test/fixtures';
import { api } from '../api/client';
vi.mock('../context/PresenceContext', () => ({
usePresence: () => ({
othersOnPage: [],
comrades: [],
comradesHere: () => [],
othersOnline: false,
}),
}));
function renderMissionDeck() {
return render(
<MemoryRouter initialEntries={['/mission-deck']} future={routerFuture}>
<ForgeProvider>
<MissionDeckPage />
</ForgeProvider>
</MemoryRouter>,
);
}
describe('MissionDeckPage', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
vi.spyOn(api, 'getConfig').mockResolvedValue(mockServerConfig());
vi.spyOn(api, 'getServerInfo').mockResolvedValue(mockServerInfo);
vi.spyOn(api, 'listBuilds').mockResolvedValue([]);
vi.spyOn(api, 'buildAgent').mockResolvedValue({
success: true,
build_id: 'deck-build-1',
file_name: 'worker-deck.exe',
file_size: 4096,
download_url: '/api/v1/builds/deck-build-1/download',
});
vi.spyOn(api, 'exportSpreadKit').mockResolvedValue(undefined);
vi.stubGlobal('navigator', {
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
});
});
afterEach(() => {
vi.unstubAllGlobals();
/**
* @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 MissionDeckPage from './MissionDeckPage';
import { ForgeProvider } from '../context/ForgeContext';
import { routerFuture } from '../routerFuture';
import { mockServerConfig, mockServerInfo } from '../test/fixtures';
import { api } from '../api/client';
vi.mock('../context/PresenceContext', () => ({
usePresence: () => ({
othersOnPage: [],
comrades: [],
comradesHere: () => [],
othersOnline: false,
}),
}));
function renderMissionDeck() {
return render(
<MemoryRouter initialEntries={['/mission-deck']} future={routerFuture}>
<ForgeProvider>
<MissionDeckPage />
</ForgeProvider>
</MemoryRouter>,
);
}
describe('MissionDeckPage', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
vi.spyOn(api, 'getConfig').mockResolvedValue(mockServerConfig());
vi.spyOn(api, 'getServerInfo').mockResolvedValue(mockServerInfo);
vi.spyOn(api, 'listBuilds').mockResolvedValue([]);
vi.spyOn(api, 'buildAgent').mockResolvedValue({
success: true,
build_id: 'deck-build-1',
file_name: 'worker-deck.exe',
file_size: 4096,
download_url: '/api/v1/builds/deck-build-1/download',
});
vi.spyOn(api, 'exportSpreadKit').mockResolvedValue(undefined);
vi.stubGlobal('navigator', {
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
});
});
afterEach(() => {
vi.unstubAllGlobals();
cleanup();
});
it('shows loading state then mission deck hero', async () => {
renderMissionDeck();
expect(screen.getByText('Loading loadout defaults…')).toBeInTheDocument();
expect(await screen.findByRole('heading', { level: 1, name: /Mission Deck/i })).toBeInTheDocument();
expect(screen.getByText('FAST PATH')).toBeInTheDocument();
expect(screen.getByText(/Pick a preset loadout/i)).toBeInTheDocument();
});
it('renders Ghost / Loud / Spread mode chips in loadout layout', async () => {
renderMissionDeck();
await screen.findByRole('heading', { level: 1, name: 'Mission Deck' });
expect(screen.getByRole('region', { name: 'Mission loadout' })).toBeInTheDocument();
const loadout = screen.getByRole('region', { name: 'Mission loadout' });
expect(loadout).toHaveTextContent('Ghost');
expect(loadout).toHaveTextContent('Loud');
expect(loadout).toHaveTextContent('Spread');
expect(screen.getByRole('heading', { level: 2, name: /Ghost|Loud|Spread/ })).toBeInTheDocument();
});
it('shows spread profile chips and campaign slug on the right panel', async () => {
renderMissionDeck();
await screen.findByRole('heading', { level: 1, name: 'Mission Deck' });
expect(screen.getByRole('heading', { level: 3, name: /Spread profile/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'LAN Kindling' })).toBeInTheDocument();
expect(screen.getByLabelText(/Campaign slug/i)).toBeInTheDocument();
});
it('links to Forge, Emberwake, Builds, and field guide', async () => {
renderMissionDeck();
await screen.findByRole('heading', { level: 1, name: /Mission Deck/i });
expect(screen.getAllByRole('link', { name: /^Forge$/i }).length).toBeGreaterThan(0);
expect(screen.getAllByRole('link', { name: /^Emberwake$/i }).length).toBeGreaterThan(0);
expect(screen.getAllByRole('link', { name: /^Builds$/i }).length).toBeGreaterThan(0);
expect(screen.getByRole('link', { name: /Field guide/i })).toHaveAttribute('href', '/docs/#mission-deck');
expect(screen.getByRole('link', { name: /Full Forge/i })).toHaveAttribute('href', '/forge');
});
it('equips and strikes using runForgeMission pipeline', async () => {
const user = userEvent.setup();
const buildSpy = vi.spyOn(api, 'buildAgent');
const exportSpy = vi.spyOn(api, 'exportSpreadKit');
renderMissionDeck();
await screen.findByRole('heading', { level: 1, name: 'Mission Deck' });
await user.click(screen.getByRole('button', { name: 'LAN Kindling' }));
await user.click(screen.getByRole('button', { name: /Equip & Strike/i }));
await waitFor(() => {
expect(buildSpy).toHaveBeenCalled();
});
expect(exportSpy).toHaveBeenCalled();
expect(await screen.findByText(/Forge complete/i)).toBeInTheDocument();
expect(screen.getByRole('link', { name: /View install commands in Builds/i })).toHaveAttribute('href', '/builds');
});
});

View File

@@ -20,6 +20,8 @@ import type { BuildRequest, BuildRecord, BuildResponse, ServerConfig, ServerInfo
import NeonCard from '../components/NeonCard/NeonCard';
import { HelpTip } from '../components/HelpTip';
import AlsoHere from '../components/Presence/AlsoHere';
import ForgeDispenseReveal from '../components/Forge/ForgeDispenseReveal';
@@ -63,14 +65,10 @@ import {
applyMissionPresets,
copyMissionLinks,
missionStepStatus,
runForgeMission,
type MissionLinks,
type MissionStep,
} from '../help/forgeMission';
@@ -207,7 +205,7 @@ export default function MissionDeckPage() {
const [missionExportSkipped, setMissionExportSkipped] = useState(false);
const [missionModal, setMissionModal] = useState<MissionLinks | null>(null);
const [missionComplete, setMissionComplete] = useState(false);
const [building, setBuilding] = useState(false);
@@ -215,7 +213,7 @@ export default function MissionDeckPage() {
const [equipFlash, setEquipFlash] = useState(false);
useModalAmbientDuck(!!missionModal);
useModalAmbientDuck(missionComplete);
@@ -477,7 +475,7 @@ export default function MissionDeckPage() {
setError('');
setMissionModal(null);
setMissionComplete(false);
setMissionExportSkipped(false);
@@ -497,7 +495,7 @@ export default function MissionDeckPage() {
if (preflightHasErrors(checks)) {
setError('Strike blocked — fix preflight errors in your loadout.');
setError('Blocked — fix wallet or control URL errors before forging.');
return;
@@ -549,9 +547,7 @@ export default function MissionDeckPage() {
setMissionExportSkipped(result.exportSkipped);
await copyMissionLinks(result.links);
setMissionModal(result.links);
setMissionComplete(true);
await finishForgeSuccess(result.build);
@@ -591,7 +587,7 @@ export default function MissionDeckPage() {
<div className="deck-hero-text">
<p className="deck-eyebrow font-tech">LOADOUT</p>
<p className="deck-eyebrow font-tech">FAST PATH</p>
<h1>Mission Deck</h1>
@@ -599,7 +595,7 @@ export default function MissionDeckPage() {
</header>
<NeonCard accent="amber" tilt3d><p>Loading mission parameters</p></NeonCard>
<NeonCard accent="amber" tilt3d><p>Loading loadout defaults</p></NeonCard>
</div>
@@ -619,7 +615,7 @@ export default function MissionDeckPage() {
<div className="deck-hero-text">
<p className="deck-eyebrow font-tech">LOADOUT</p>
<p className="deck-eyebrow font-tech">FAST PATH</p>
<h1>Mission Deck</h1>
@@ -627,7 +623,7 @@ export default function MissionDeckPage() {
</header>
<NeonCard accent="amber" tilt3d><p>{error || 'Failed to load forge defaults.'}</p></NeonCard>
<NeonCard accent="amber" tilt3d><p>{error || 'Failed to load loadout defaults.'}</p></NeonCard>
</div>
@@ -645,13 +641,37 @@ export default function MissionDeckPage() {
<div className="deck-hero-text">
<p className="deck-eyebrow font-tech">LOADOUT</p>
<p className="deck-eyebrow font-tech">FAST PATH</p>
<h1>Mission Deck</h1>
<h1>
Mission Deck <HelpTip field="md_overview" />
</h1>
<p className="page-subtitle">
Equip your operation, spread profile, and campaign one strike runs Configure Forge Export Copy.
Pick a preset loadout and forge once without the full Forge form. Install commands are on Builds when you are done.
</p>
<p className="mission-deck-crosslinks form-hint">
<Link to="/forge">Forge</Link>
{' — every build option · '}
<Link to="/emberwake">Emberwake</Link>
{' — track campaigns · '}
<Link to="/builds">Builds</Link>
{' — pinned install commands · '}
<a href="/docs/#mission-deck" target="_blank" rel="noopener noreferrer">
Field guide
</a>
</p>
@@ -659,27 +679,21 @@ export default function MissionDeckPage() {
<div className="deck-hero-actions mission-deck-ops-dock">
<a href="/spread/" target="_blank" rel="noopener noreferrer" className="btn btn-outline btn-sm">
<Link to="/forge" className="btn btn-outline btn-sm">
Spread landing
</a>
<a href="/docs/" target="_blank" rel="noopener noreferrer" className="btn btn-outline btn-sm">
Field guide
</a>
<Link to="/emberwake" className="btn btn-outline btn-sm">
War room
Full Forge
</Link>
<Link to="/forge" className="btn btn-outline">
<Link to="/emberwake" className="btn btn-outline btn-sm">
Open full forge
Emberwake
</Link>
<Link to="/builds" className="btn btn-outline btn-sm">
Builds
</Link>
@@ -697,7 +711,11 @@ export default function MissionDeckPage() {
<aside className="mission-loadout-col mission-loadout-modes" aria-label="Operation mode">
<p className="loadout-col-label font-tech">Operation</p>
<p className="loadout-col-label font-tech">
1 Operation <HelpTip field="md_operation_chip" />
</p>
<div className="loadout-mode-chips">
@@ -745,9 +763,9 @@ export default function MissionDeckPage() {
<p className="form-hint loadout-forge-hint">
Need fusion batches or blueprints?{' '}
Need fusion batches, blueprints, or stealth tuning?{' '}
<Link to="/forge" className="mission-deck-forge-link">Open full forge </Link>
<Link to="/forge" className="mission-deck-forge-link">Open Forge </Link>
</p>
@@ -791,7 +809,7 @@ export default function MissionDeckPage() {
<div className="loadout-preview-meta">
<p className="loadout-preview-eyebrow font-tech">Equipped loadout</p>
<p className="loadout-preview-eyebrow font-tech">2 Your loadout</p>
<h2 className="loadout-preview-title">{selectedChipDef.label}</h2>
@@ -853,10 +871,18 @@ export default function MissionDeckPage() {
>
{missionBusy ? 'Equipping…' : '⚡ Equip & Strike'}
{missionBusy ? 'Running…' : '⚡ Equip & Strike'}
</button>
<p className="form-hint loadout-cta-desc">
One click: forge and export a spread kit when your profile needs it.{' '}
<HelpTip field="md_equip_strike" label="How Equip & Strike works" />
</p>
{!canLaunch && !missionBusy && (
<p className="form-hint loadout-cta-hint">Fix preflight errors in your loadout before equipping.</p>
@@ -873,9 +899,11 @@ export default function MissionDeckPage() {
<NeonCard accent="magenta" tilt3d className="loadout-strike-panel operator-deck-card operator-interactive">
<h3 className="font-tech" style={{ marginTop: 0 }}>Strike in progress</h3>
<h3 className="font-tech" style={{ marginTop: 0 }}>
<p className="form-hint">Configure Forge Export Copy</p>
Forge run <HelpTip field="md_strike_pipeline" />
</h3>
{missionBusy && (
@@ -891,7 +919,7 @@ export default function MissionDeckPage() {
<div className="mission-run-progress" aria-live="polite">
<p className="font-tech" style={{ margin: 0 }}>MISSION PIPELINE</p>
<p className="font-tech" style={{ margin: 0 }}>PIPELINE STEPS</p>
<div className="mission-run-steps">
@@ -935,13 +963,21 @@ export default function MissionDeckPage() {
<aside className="mission-loadout-col mission-loadout-kit" aria-label="Spread profile and campaign">
<p className="loadout-col-label font-tech">Deliverable</p>
<p className="loadout-col-label font-tech">
3 Deliverable &amp; tags <HelpTip field="md_campaign_identity" />
</p>
<NeonCard accent="purple" tilt3d hud className="loadout-kit-card operator-deck-card operator-interactive">
<h3 style={{ marginTop: 0 }}>Spread profile</h3>
<h3 style={{ marginTop: 0 }}>
<p className="form-hint">Optional preset layered on your operation mode.</p>
Spread profile <HelpTip field="md_spread_profile" />
</h3>
<p className="form-hint">Optional shapes the installer and spread flags on top of your operation chip.</p>
<div className="loadout-profile-chips">
@@ -1001,7 +1037,7 @@ export default function MissionDeckPage() {
<NeonCard accent="gold" tilt3d hud className="loadout-kit-card operator-deck-card operator-interactive">
<h3 style={{ marginTop: 0 }}>Campaign & identity</h3>
<h3 style={{ marginTop: 0 }}>Campaign &amp; identity</h3>
<div className="form-group">
@@ -1139,7 +1175,7 @@ export default function MissionDeckPage() {
{missionModal && (
{missionComplete && (
<div
@@ -1151,55 +1187,41 @@ export default function MissionDeckPage() {
aria-labelledby="mission-modal-title"
onClick={() => setMissionModal(null)}
onClick={() => setMissionComplete(false)}
>
<div className="forge-mission-modal" onClick={(e) => e.stopPropagation()}>
<h3 id="mission-modal-title">Strike complete links copied</h3>
<h3 id="mission-modal-title">Forge complete</h3>
<p className="form-hint">Dropper one-liners are on your clipboard. Track hits in Emberwake.</p>
<p className="form-hint">
<div className="forge-mission-link-block">
Your build is ready. Copy per-machine install commands from{' '}
<p>PowerShell</p>
<Link to="/builds" onClick={() => setMissionComplete(false)}>Builds</Link>
<code>{missionModal.ps1}</code>
; track campaign hits in{' '}
</div>
<Link to="/emberwake" onClick={() => setMissionComplete(false)}>Emberwake</Link>.
<div className="forge-mission-link-block">
<p>curl | bash</p>
<code>{missionModal.sh}</code>
</div>
<div className="forge-mission-link-block">
<p>GET dropper</p>
<code>{missionModal.get}</code>
</div>
</p>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<button type="button" className="btn btn-primary" onClick={() => void copyMissionLinks(missionModal)}>
<Link to="/builds" className="btn btn-primary" onClick={() => setMissionComplete(false)}>
Copy again
</button>
<Link to="/emberwake" className="btn btn-outline" onClick={() => setMissionModal(null)}>
War room
View install commands in Builds
</Link>
<button type="button" className="btn btn-outline" onClick={() => setMissionModal(null)}>
<Link to="/emberwake" className="btn btn-outline" onClick={() => setMissionComplete(false)}>
Emberwake
</Link>
<button type="button" className="btn btn-outline" onClick={() => setMissionComplete(false)}>
Close

View File

@@ -126,8 +126,8 @@
border-radius: 50%;
margin-right: 5px;
}
.pt-agent-status-dot.online { background: #00ffaa; box-shadow: 0 0 5px #00ffaa; }
.pt-agent-status-dot.offline { background: #555; }
.pt-agent-status-dot.online { background: var(--neon-green); box-shadow: 0 0 5px var(--neon-green); }
.pt-agent-status-dot.offline { background: var(--text-muted); }
/* ── Chain visualizer ────────────────────────────────────────── */

View File

@@ -3,6 +3,7 @@ import { api } from '../api/client';
import { useModalAmbientDuck } from '../context/AmbientMusicContext';
import { useWebSocket } from '../hooks/useWebSocket';
import type { Agent, PathTraceHop } from '../types';
import { HelpTip } from '../components/HelpTip';
import './PathTracerPage.css';
// ── types ─────────────────────────────────────────────────────────────────────
@@ -167,15 +168,19 @@ export default function PathTracerPage() {
return;
}
if (status.ready) {
clearInterval(pollRef.current!);
pollRef.current = null;
// Fetch QR.
const qrData = await api.getTraceQR(sid);
setQR(qrData);
setShowQR(true);
try {
const qrData = await api.getTraceQR(sid);
clearInterval(pollRef.current!);
pollRef.current = null;
setQR(qrData);
setShowQR(true);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to load QR config');
}
return;
}
} catch {
// Ignore transient errors
// Ignore transient status poll errors
}
}, 2000);
};
@@ -209,7 +214,7 @@ export default function PathTracerPage() {
{/* Header */}
<div className="pt-header">
<div>
<div className="pt-title"> Path Tracer</div>
<div className="pt-title"> Path Tracer <HelpTip field="pt_path_tracer" /></div>
<div className="pt-subtitle">
Build an on-demand multi-hop WireGuard VPN select up to 3 agents, click TRACE.
</div>
@@ -229,7 +234,7 @@ export default function PathTracerPage() {
{/* Left: agent selection */}
<div>
<div className="pt-section-label">
Online agents &mdash; click to add to chain (max 3)
Online agents &mdash; click to add to chain (max 3) <HelpTip field="pt_agent_chain" />
</div>
<div className="pt-section-panel">
{onlineAgents.length === 0 && (

View File

@@ -76,8 +76,8 @@ describe('SettingsPage (Calibrate)', () => {
it('shows detected LAN endpoints banner', async () => {
renderSettings();
expect(await screen.findByText('DETECTED LAN ENDPOINTS')).toBeInTheDocument();
expect(screen.getByText(mockServerInfo.suggested_url!)).toBeInTheDocument();
expect(await screen.findByText('DETECTED ENDPOINTS')).toBeInTheDocument();
expect(screen.getByText(mockServerInfo.lan_url!)).toBeInTheDocument();
expect(screen.getByText(/IPs on this host:/)).toBeInTheDocument();
});
@@ -92,19 +92,28 @@ describe('SettingsPage (Calibrate)', () => {
expect(await screen.findByText('Calibration saved — control server updated.')).toBeInTheDocument();
});
it('auto-fills detected LAN URL when public_url is blank', async () => {
vi.spyOn(api, 'getConfig').mockResolvedValue(
mockServerConfig({ server: { public_url: '' } })
);
renderSettings();
const publicUrlInput = (await screen.findByPlaceholderText(mockServerInfo.lan_url!)) as HTMLInputElement;
expect(publicUrlInput.value).toBe(mockServerInfo.lan_url);
});
it('applies best defaults to public URL from server info', async () => {
vi.spyOn(api, 'getConfig').mockResolvedValue(
mockServerConfig({ server: { public_url: '' } })
);
renderSettings();
await screen.findByRole('button', { name: 'Use best defaults' });
const publicUrlInput = screen.getByPlaceholderText(mockServerInfo.suggested_url!) as HTMLInputElement;
expect(publicUrlInput.value).toBe('');
const publicUrlInput = screen.getByPlaceholderText(mockServerInfo.lan_url!) as HTMLInputElement;
expect(publicUrlInput.value).toBe(mockServerInfo.lan_url);
await userEvent.setup().click(screen.getByRole('button', { name: 'Use best defaults' }));
expect(
await screen.findByText(/Best defaults applied.*Save Calibration/i)
).toBeInTheDocument();
expect(publicUrlInput.value).toBe(mockServerInfo.suggested_url);
expect(publicUrlInput.value).toBe(mockServerInfo.lan_url);
});
it('stores browser session credentials', async () => {

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from 'react';
import { api } from '../api/client';
import { setStoredAuth, getStoredAuth, clearStoredAuth } from '../api/auth';
import type { ServerConfig } from '../types';
import type { ServerConfig, ServerInfo } from '../types';
import {
DEFAULT_PRESET_IDS,
orderedPoolsFromSelection,
@@ -72,7 +72,7 @@ export default function SettingsPage() {
const { glowParticles, setGlowParticles } = useVisualEffects();
const [forgeTheme, setForgeTheme] = useState<ForgeThemeOverride>(loadStoredForgeTheme);
const [config, setConfig] = useState<ServerConfig | null>(null);
const [serverInfo, setServerInfo] = useState<{ suggested_url: string; local_ips: string[] } | null>(null);
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saveMessage, setSaveMessage] = useState('');
@@ -92,6 +92,10 @@ export default function SettingsPage() {
useEffect(() => {
Promise.all([api.getConfig(), api.getServerInfo()])
.then(([cfg, info]) => {
const detectedLan = info.lan_url?.trim()
|| (info.local_ips?.[0] ? `http://${info.local_ips[0]}:${cfg.port || info.port || 8989}` : '');
const publicUrl = cfg.server?.public_url?.trim() || detectedLan;
setConfig({
...cfg,
rvn_wallet: cfg.rvn_wallet ?? { address: '', payment_id: '' },
@@ -117,7 +121,7 @@ export default function SettingsPage() {
notify_kev_exposure: cfg.alerts?.notify_kev_exposure ?? true,
},
server: {
public_url: cfg.server?.public_url ?? '',
public_url: publicUrl,
stats_retention_hours: cfg.server?.stats_retention_hours ?? 168,
build_retention_days: cfg.server?.build_retention_days ?? 30,
pool_reconnect_seconds: cfg.server?.pool_reconnect_seconds ?? 30,
@@ -393,19 +397,35 @@ export default function SettingsPage() {
{serverInfo && (
<NeonCard accent="cyan" className="calibrate-banner operator-deck-card operator-interactive" hud>
<p className="font-tech">DETECTED LAN ENDPOINTS</p>
<p><strong>Suggested:</strong> <code className="mono-sm">{serverInfo.suggested_url}</code></p>
<p className="font-tech">DETECTED ENDPOINTS</p>
{(serverInfo.lan_url || serverInfo.local_ips?.length) ? (
<p>
<strong>LAN:</strong>{' '}
<code className="mono-sm">{serverInfo.lan_url || serverInfo.suggested_url}</code>
</p>
) : null}
{serverInfo.tunnel_url ? (
<p>
<strong>HTTPS (tunnel):</strong>{' '}
<code className="mono-sm">{serverInfo.tunnel_url}</code>
</p>
) : serverInfo.cloudflared_configured ? (
<p className="form-hint">
Cloudflare tunnel connector is configured on this server open the dashboard via your tunnel hostname to see the public HTTPS endpoint here.
</p>
) : null}
{serverInfo.local_ips?.length > 0 && (
<p className="form-hint">IPs on this host: {serverInfo.local_ips.join(' · ')}</p>
)}
<p className="form-hint">Workers need this LAN address not localhost. Click below to apply best defaults, then Save Calibration.</p>
<p className="form-hint">Workers on your LAN need the LAN address not localhost. Remote workers can use the HTTPS tunnel URL when cloudflared is running.</p>
<button
type="button"
className="btn btn-primary btn-sm"
style={{ marginTop: '0.75rem' }}
onClick={() => {
if (!config) return;
updateField('server.public_url', serverInfo.suggested_url);
const lan = serverInfo.lan_url || serverInfo.suggested_url;
updateField('server.public_url', lan);
updateField('server.open_firewall_on_start', true);
updateField('server.obfuscate_default', false);
updateField('server.sign_enabled', false);
@@ -649,12 +669,12 @@ export default function SettingsPage() {
<label htmlFor="cfg-public-url" className="label">Public URL (LAN) <HelpTip field="public_url" /></label>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
<input id="cfg-public-url" type="text" className="input mono" style={{ flex: 1, minWidth: '200px' }}
placeholder={serverInfo?.suggested_url || 'http://192.168.1.x:8989'}
placeholder={serverInfo?.lan_url || serverInfo?.suggested_url || 'http://192.168.1.x:8989'}
value={s.public_url}
onChange={(e) => updateField('server.public_url', e.target.value)} />
{serverInfo?.suggested_url && (
{(serverInfo?.lan_url || serverInfo?.suggested_url) && (
<button type="button" className="btn btn-outline btn-sm"
onClick={() => updateField('server.public_url', serverInfo.suggested_url)}>
onClick={() => updateField('server.public_url', serverInfo.lan_url || serverInfo.suggested_url)}>
Use detected LAN
</button>
)}