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
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:
@@ -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' });
|
||||
|
||||
@@ -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 & 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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
119
server/web/src/pages/EmberwakePage.test.tsx
Normal file
119
server/web/src/pages/EmberwakePage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 & 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 & 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
|
||||
|
||||
|
||||
@@ -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 ────────────────────────────────────────── */
|
||||
|
||||
|
||||
@@ -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 — click to add to chain (max 3)
|
||||
Online agents — click to add to chain (max 3) <HelpTip field="pt_agent_chain" />
|
||||
</div>
|
||||
<div className="pt-section-panel">
|
||||
{onlineAgents.length === 0 && (
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user