Final sweep: Crucible fixes, Path Tracer polish, forge progress, tests green.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Align dashboard subtitle default and UpsertAgent tests with fleet label behavior; WebSocket coalesce and PathForge hardening; Crucible expanded ops and visual DV fixes; Vitest 610/610 and full test-suite pass; trim PROBLEMS.md to open items only.
This commit is contained in:
714
PROBLEMS.md
714
PROBLEMS.md
@@ -1,662 +1,90 @@
|
|||||||
|
# PROBLEMS.md
|
||||||
|
|
||||||
|
Open issues only. Fixed items removed. Last sweep: 2026-06-06.
|
||||||
|
|
||||||
|
## By design / safety
|
||||||
|
|
||||||
|
|
||||||
### Open medium / by design
|
|
||||||
|
|
||||||
| Issue | Notes |
|
| Issue | Notes |
|
||||||
|
|
||||||
|-------|-------|
|
|-------|-------|
|
||||||
|
| **`bof_execute` disabled** | Agent returns explicit error; in-memory BOF execution disabled (`client.go`). |
|
||||||
|
| **Process hollowing AMSI/ETW** | Relocation done; Defender/ETW ~50% failure; bypass not implemented (`hollow_windows.go`). |
|
||||||
|
| **Cloudflared in-process (non-Windows server)** | Stub on Linux/macOS; use external connector (`AF_TUNNEL_EXTERNAL`) or add launcher. |
|
||||||
|
| **macOS camera / GPU miner** | Stubs or partial; Linux has V4L2 + nvidia-smi path. |
|
||||||
|
| **KEV heuristics** | Non-Windows agents return `Status: n/a` (Windows-only CVE matching). |
|
||||||
|
| **Mesh P2P without `-tags p2p`** | Default build reports 0 peers (`mesh_p2p_stub.go`). |
|
||||||
|
| **Linux/macOS GPU RVN mining** | `detectGPU()` may find NVIDIA but miners download Windows `.exe` only. |
|
||||||
|
|
||||||
| **`bof_execute` disabled** | Agent returns explicit error in-memory BOF execution disabled for safety (`client.go`). |
|
## Architecture deferred (large)
|
||||||
|
|
||||||
| **Process hollowing AMSI/ETW** | Relocation patching done; Defender/ETW detection causes ~50% failure bypass not implemented (`hollow_windows.go`). |
|
|
||||||
|
|
||||||
| **Cloudflared on Linux server** | In-process tunnel start is Windows-only; Linux/macOS builds use no-op stub use external connector (`AF_TUNNEL_EXTERNAL`) or add cross-platform launcher. |
|
|
||||||
|
|
||||||
| **macOS camera / GPU miner** | Stubs return "not supported"; Linux has V4L2 + nvidia-smi path. |
|
|
||||||
|
|
||||||
| **Agent WireGuard auto-download** | Windows `ensureWGExe()` downloads WireGuard on first Path Tracer use heavy side effect; pre-install recommended. |
|
|
||||||
|
|
||||||
| **Non-Windows Path Tracer agent parity** | `pathtracer_stub.go` returns error on `wg_setup`; Linux/macOS agents cannot join WireGuard chains. |
|
|
||||||
|
|
||||||
| **KEV heuristics** | Non-Windows agents return catalog with `Status: n/a` Windows-only CVE matching.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Open large / deferred
|
|
||||||
|
|
||||||
|
| Area | Notes |
|
||||||
|
|------|-------|
|
||||||
|
| **`tunnel_stream`** | Server-side TCP reverse relay documented as future (`README.md`). |
|
||||||
|
| **Path Tracer sessions** | `TraceSession` in handler RAM; restart loses state; no DB persistence or startup sweep. |
|
||||||
|
| **Non-Windows Path Tracer parity** | `pathtracer_stub.go` errors on `wg_setup`; chains are Windows-agent focused. |
|
||||||
|
| **NAT / symmetric UDP** | UPnP + DB IP fallback; no STUN/TURN or post-config connectivity probe. |
|
||||||
|
| **Fixed WireGuard port 51820** | Same UDP port all hops; multi-agent behind one NAT may conflict. |
|
||||||
|
| **Agent display name vs hostname** | WS `UpsertAgent` preserves operator rename when `name != hostname`; reconnect with hostname only keeps DB label. |
|
||||||
|
| **WireGuard auto-download (Windows)** | `ensureWGExe()` on first Path Tracer use; heavy, may need admin; pre-install recommended. |
|
||||||
|
| **Monolithic WebSocket context** | All `useWebSocket()` consumers re-render on any WS change; split contexts/selectors deferred. |
|
||||||
|
| **`CruciblePage` size (~2k lines)** | Terminal + fleet + tabs in one component; section split/memo deferred. |
|
||||||
|
| **Per-agent `stats_update` broadcast** | No batching in `websocket.go`; N agents → N dashboard frames. |
|
||||||
|
| **No CI HTTP forge** | `e2e-validate.ps1 -ForgeAgent` manual; live compile needs `LIVE_FORGE=1` + `-tags liveforge`. |
|
||||||
|
| **Path Forge test gaps** | Cancellation, batch races, skipped-counter UI not fully covered. |
|
||||||
|
| **Non-Windows forge host** | PE disguise / osslsigncode signing platform-limited by design. |
|
||||||
|
| **Mac PathForge runtime** | `.command` curl `/api/download/agent-mac`; needs reachable `server_url` + binary on server. |
|
||||||
|
| **Terminal virtualization** | 400-line DOM cap only; full virtual scrollback deferred. |
|
||||||
|
| **Vite chunk weight** | `three` + vendor warnings; FleetTopologyMap lazy but heavy first open. |
|
||||||
|
|
||||||
|
## Open bugs / behavior
|
||||||
|
|
||||||
| Issue | Notes |
|
| Issue | Notes |
|
||||||
|
|
||||||
|-------|-------|
|
|-------|-------|
|
||||||
|
| **Unknown Unix CPU stats stub** | `cpu_stub.go` may return 0 and break idle-mining guard on exotic platforms. |
|
||||||
| **Non-Windows agent parity** | `pathtracer_stub.go` returns error JSON on `wg_setup`; Linux/macOS cannot join chains. UI filters platform; server does not validate `platform` field. |
|
| **Linux headless screenshot** | Needs `xvfb` + scrot or custom `command` in containers. |
|
||||||
|
|
||||||
| **In-memory sessions only** | `TraceSession` map in handler RAM server restart loses session state while agents may still hold tunnels; no DB persistence or startup sweep. |
|
## UX / visual (unfixed DV)
|
||||||
|
|
||||||
| **WireGuard auto-download** | Windows `ensureWGExe()` silently downloads/installs WireGuard on first use heavy, needs admin, no progress UI. |
|
| ID | Issue |
|
||||||
|
|----|-------|
|
||||||
| **No PathTracerPage Vitest** | Page logic (polling, chain selection, QR modal) untested; only `uiHelp.test.ts` covers help keys. |
|
| DV-01 | Neon cyan fragmentation (`#00f5ff`, `#0ff` vs token `#00e8f5`) across several components. |
|
||||||
|
| DV-02 | Page header patterns diverge (Build Manager, Path Tracer green title, Forge naming). |
|
||||||
| **No agent-side pathtracer tests** | `pathtracer_windows.go` / stub have zero Go tests. |
|
| DV-03 | `SacredPageHeader` unused (dead CSS path). |
|
||||||
|
| DV-04 | `Pages.css` duplicate `.empty-state` / `.page-header h1` override order. |
|
||||||
| **NAT / symmetric UDP** | UPnP + DB IP fallback may still yield unreachable endpoints; no STUN/TURN or connectivity probe after configure. |
|
| DV-07 | Path Tracer visual island (`#00ffaa`) vs operator-deck chrome. |
|
||||||
|
| DV-09 | Dead chart badge styles in wealth-deck CSS. |
|
||||||
| **Fixed listen port 51820** | All hops use same UDP port multiple agents behind one NAT may conflict; no per-hop port allocation. |
|
| DV-13 | Dark-only; no light / `prefers-color-scheme` path. |
|
||||||
|
| DV-14 | Sidebar footer hard-coded `v0.0.1`. |
|
||||||
| **Agent display name vs hostname** | WS auth `UpsertAgent` overwrites `name` with hostname on every connect operator-renamed fleet labels may not appear in Path Tracer unless re-saved after connect. |
|
| DV-15 | `MissionDeckPage.tsx` excessive blank lines (maintainability). |
|
||||||
|
|
||||||
|
## Help / product copy gaps
|
||||||
|
|
||||||
### Open medium / UX
|
| ID | Issue |
|
||||||
|
|----|-------|
|
||||||
|
| UH-03 | Emberwake / War Room widgets need `HelpTip` parity with Command Deck funnel. |
|
||||||
|
| UH-04 | Mission Deck minimal operator guidance. |
|
||||||
| Issue | Notes |
|
| UH-05 | Builder advanced forge sections missing some `HelpTip` keys (`docAnchors.test` gap list). |
|
||||||
|
|
||||||
|-------|-------|
|
## Test gaps / noise
|
||||||
|
|
||||||
| **Visual island (green vs deck)** | Page uses `#00ffaa` WireGuard chrome while `data-operator-deck='pathtracer'` sets blue accent (`operatorDeck.css`); intentional branding split (see DV-07). |
|
|
||||||
|
|
||||||
| **Status polling only** | UI polls `/status` every 2s no WebSocket push for hop progress; acceptable latency but stale during orchestration. |
|
|
||||||
|
|
||||||
| **Error path leaves tracing UI** | On `status.error`, `tracing` clears but session remains server-side until operator clicks End or TTL hops show failed state in chain panel. |
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Open large / deferred
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
| Issue | Notes |
|
|
||||||
|
|
||||||
|-------|-------|
|
|
||||||
|
|
||||||
| **No CI-automated HTTP forge** | `scripts/e2e-validate.ps1 -ForgeAgent` still manual (multipart fusion, long compile). Live compile gated behind `LIVE_FORGE=1` + `-tags liveforge`. |
|
|
||||||
|
|
||||||
| **PathForge test gaps** | Only `TestPathForgePlacedExcludesHintFile`; no skipped-counter, lock-original, or frontend UI tests. |
|
|
||||||
|
|
||||||
| **Forge cancellation / batch races** | Cancel token API tested in isolation; concurrent batch forge + mid-compile cancel not covered. |
|
|
||||||
|
|
||||||
| **Non-Windows forge host limits** | PE disguise (`disguise_stub.go`) and Windows signing (`sign_stub.go` / osslsigncode) are platform-constrained by design. |
|
|
||||||
|
|
||||||
| **Mac PathForge runtime dependency** | `.command` launchers curl `/api/download/agent-mac` at runtime requires reachable `server_url` and pre-placed agent binary. |
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Open low
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
| Issue | Notes |
|
|
||||||
|
|
||||||
|-------|-------|
|
|
||||||
|
|
||||||
| ~~\pathforge_test.go\ dead loop~~ | **Fixed.** Replaced the no-op loop with meaningful assertions: each entry Files must contain the hint file and at least one launcher companion, confirming the hint is placed but not counted in Placed. Pre-existing err := redeclaration compile error in pathforge.go L128 also fixed. |
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Open (document-only / deferred)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
| Issue | Notes |
|
|
||||||
|
|
||||||
|-------|--------|
|
|
||||||
|
|
||||||
| Dual storage without sync policy | Complex cross-tab sync session preferred over local; `aetherforge-auth` event on logout |
|
|
||||||
|
|
||||||
| Flaky progress simulation vs. real compile time | Cosmetic stage timeline caps at 94% until server responds (45 min client timeout) |
|
|
||||||
|
|
||||||
| Path Forge / batch fusion test gaps | Cancellation, partial batch failure, cancel-token races needs dedicated tests |
|
|
||||||
|
|
||||||
| DashboardPage tests emit ECONNREFUSED stderr | Failure-path tests; happy-dom hits `localhost:3000`; tests pass |
|
|
||||||
|
|
||||||
| DownloadButton mock aliasing pattern | Document for new download helpers shared mock fn already in `components.test.tsx` |
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Open (2026-06-06 audit LARGE)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
- **`tunnel_stream` not implemented** server-side TCP reverse relay documented as future work (`README.md`).
|
|
||||||
|
|
||||||
- **Server `cloudflared` launcher no-op on non-Windows** Linux server deploy cannot auto-launch tunnels (`cloudflared/launcher_stub.go`).
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Low (open)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
- ~~**`mergeConfig` partial-PUT `UseTLS` legacy behavior**~~ **Clarified.** The API PUT handler uses `mergeConfigExplicit` with a field-mask so absent keys never reset booleans. The legacy `mergeConfig` (file-load fallback only) unconditionally copies bools -- documented with a header comment in config.go. Test at config_test.go L526-528 updated to assert the expected behavior and explain the distinction.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### High (open)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
- **Non-Windows Path Tracer agent parity** `pathtracer_stub.go` returns error on `wg_setup`; Linux/macOS agents cannot join WireGuard chains.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Medium (open)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
- **Agent WireGuard auto-download:** Windows agent `ensureWGExe()` downloads and silently installs WireGuard from `download.wireguard.com` on first Path Tracer use if not already present (`agent/client/pathtracer_windows.go`). Heavy side effect; no server-side fix operator should pre-install WireGuard on fleet hosts or accept first-run download latency.
|
|
||||||
|
|
||||||
- Mac PathForge `.command` still depends on `server_url` + `/api/download/agent-mac` at runtime (now validated at forge time).
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
| Op | Command | Notes |
|
|
||||||
|
|
||||||
|----|---------|-------|
|
|
||||||
|
|
||||||
| SMB share enumeration | `smb_shares` | Windows + Remote Aggressive; ARP/subnet hosts ? `net view` JSON |
|
|
||||||
|
|
||||||
| Spread status | `spread_status` | In-memory last sweep (`deploy/spread_status.go`); read-only |
|
|
||||||
|
|
||||||
| Credential names | `credential_vault_list` | Win Credential Manager / macOS Keychain / Linux secret-tool + `~/.ssh` paths names only |
|
|
||||||
|
|
||||||
| Secure wipe | `secure_wipe` | Overwrite-then-delete folder; system-root guards; confirm in UI |
|
|
||||||
|
|
||||||
| Port-forward matrix | `tunnel_ssh_forward` × N | `CruciblePortForwardMatrix` multi-row grid on selected Windows nodes |
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
UI: Phase C controls in `CrucibleExpandedOps.tsx` Fleet Maintenance (replaces coming soon stubs).
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Linux / macOS parity
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
| Area | Status |
|
|
||||||
|
|
||||||
|------|--------|
|
|
||||||
|
|
||||||
| **Mining + hashrate** | RandomX pure-Go engine works on Linux/macOS; stats loop sends `hashrate_15s/1m/15m` + shares over WS. |
|
|
||||||
|
|
||||||
| **Idle schedule guard** | **Fixed** `SystemCPUPercent` was always 0 on Unix (`reporter_unix.go`), blocking idle-mode mining; Linux uses `/proc/stat`, macOS uses `sysctl kern.cp_time`. |
|
|
||||||
|
|
||||||
| **Screenshot** | Linux: scrot / ImageMagick `import` / gnome-screenshot. macOS: `screencapture`. |
|
|
||||||
|
|
||||||
| **Camera** | Linux V4L2 via ffmpeg/fswebcam (`camera_linux.go`). macOS: stub. |
|
|
||||||
|
|
||||||
| **File ops** | Cross-platform (`file_ops_unix.go` / `file_ops_windows.go`). |
|
|
||||||
|
|
||||||
| **Posture** | Unix collectors return firewall/AV/patch data (`posture_unix.go`), not all n/a. |
|
|
||||||
|
|
||||||
| **Spread** | SSH path on Linux/macOS (`autospread_unix.go`); SMB/WinRM Windows-only by design. |
|
|
||||||
|
|
||||||
| **Firewall ops** | Linux ufw/iptables (`firewall_linux_ops.go`); **macOS pf + socketfilterfw** (`firewall_darwin_ops.go`); other Unix stubs. |
|
|
||||||
|
|
||||||
| **GPU miner** | Windows-only T-Rex path; Linux/macOS stub with detect-only. |
|
|
||||||
|
|
||||||
| **Docker E2E** | `docker/docker-compose.yml` isolated agent + server; see `docker/README.md`. |
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Open
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
- **Client:** WebSocket/beacon paths integration-only in CI (Docker Tier 2 closes Linux slice).
|
|
||||||
|
|
||||||
- **macOS:** camera, GPU miner stubs or partial; firewall aggressive ops implemented (see Backend BE-04).
|
|
||||||
|
|
||||||
- **Linux screenshot:** headless containers need `xvfb` + scrot or custom `command` field.
|
|
||||||
|
|
||||||
- **`bof_execute` permanently disabled** handler always fails (`client.go`); product/safety decision.
|
|
||||||
|
|
||||||
- **Linux/macOS GPU RVN mining broken** `detectGPU()` may find NVIDIA, but `spec()` downloads Windows `.exe` miners (`gpu_miner.go`, `gpu_detect_stub.go`).
|
|
||||||
|
|
||||||
- **Mesh P2P without `-tags p2p`** default build reports 0 peers; UI exposes `mesh_status` with re-forge hint (`mesh_p2p_stub.go`).
|
|
||||||
|
|
||||||
- **Process hollowing AMSI/ETW bypass not implemented** documented ~50% failure rate (`hollow_windows.go`).
|
|
||||||
|
|
||||||
- **KEV exposure scan non-Windows** all CVEs marked `n/a` (`cve_scan_stub.go`).
|
|
||||||
|
|
||||||
- **Unknown Unix CPU stats stub returns 0** can break idle-mining guard on exotic platforms (`cpu_stub.go`).
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Open (document-only / deferred)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
| Issue | Notes |
|
|
||||||
|
|
||||||
|-------|--------|
|
|
||||||
|
|
||||||
| Dual storage without sync policy | Complex cross-tab sync session preferred over local; `aetherforge-auth` event on logout |
|
|
||||||
|
|
||||||
| Flaky progress simulation vs. real compile time | Cosmetic stage timeline caps at 94% until server responds (45 min client timeout) |
|
|
||||||
|
|
||||||
| Path Forge / batch fusion test gaps | Cancellation, partial batch failure, cancel-token races needs dedicated tests |
|
|
||||||
|
|
||||||
| DashboardPage tests emit ECONNREFUSED stderr | Failure-path tests; happy-dom hits `localhost:3000`; tests pass |
|
|
||||||
|
|
||||||
| DownloadButton mock aliasing pattern | Document for new download helpers shared mock fn already in `components.test.tsx` |
|
|
||||||
|
|
||||||
| ~~`server/webroot` not auto-synced on `npm run build`~~ | **Fixed.** Changed `vite.config.ts` `build.outDir` from `dist` to `../webroot` (with `emptyOutDir: true`). `npm run build` now writes directly to `server/webroot/` -- no manual copy step required. |
|
|
||||||
|
|
||||||
| Vitest stderr noise | `FleetTopologyMap` three.js tags warn in happy-dom tests pass |
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Open (needs product copy or broader pass)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
| ID | Issue | Notes |
|
|
||||||
|
|
||||||
|----|-------|-------|
|
|
||||||
|
|
||||||
| ~~UH-01~~ | ~~Crucible expanded ops (spread/tunnels/recon buttons)~~ | **Fixed:** Added `HelpTip` to the four most confusing individual buttons (Spread Now, Subnet Scan, Hole Punch, Start Tunnel) in `CrucibleExpandedOps.tsx`, plus 10 new keys in `uiHelp.ts`. All section headers already carry `helpField` via `CrucibleCollapsibleSection`. |
|
|
||||||
|
|
||||||
| ~~UH-02~~ | ~~Fleet Roster / Agents page bulk toolbar~~ | **Fixed:** Added `HelpTip` to filter row (`fl_filter_chips`), bulk-action bar (`fl_bulk_actions`) in `FleetToolbar.tsx`, and Groups label (`fl_groups`) in `FleetGroupsStrip.tsx`. |
|
|
||||||
|
|
||||||
| UH-03 | Emberwake / War Room campaign widgets | Funnel stages need `HelpTip` parity with Command Deck funnel |
|
|
||||||
|
|
||||||
| UH-04 | Mission Deck | Page has minimal operator guidance |
|
|
||||||
|
|
||||||
| UH-05 | Builder mission wizard chips | Inline blurbs exist on chips; not all advanced forge sections have `HelpTip` (see `docAnchors.test` gap list) |
|
|
||||||
|
|
||||||
| ~~UH-06~~ | ~~Settings tabs beyond Calibrate/Forge~~ | **Fixed:** Added `HelpTip` to Fleet Alerts heading (`set_alerts`), Alert Notifications heading (`set_alert_notifications`), and Webhook URL field (`set_webhook`) in `SettingsPage.tsx`. |
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Open (visual / UX debt)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
| ID | Issue | Files / notes |
|
|
||||||
|
|
||||||
|----|-------|----------------|
|
|
||||||
|
|
||||||
| DV-01 | **Neon cyan fragmentation** canonical token is `--neon-cyan: #00e8f5` but components hard-code `#00f5ff`, `#00e5ff`, and `#0ff` fallbacks | `AgentRemoteActions.css`, `HashrateChart.tsx`, `BuildManagerPage.tsx`, `ProtocolTunnelPanel.css`, `FileManager.css` (fixed), sacred geometry SVGs |
|
|
||||||
|
|
||||||
| DV-02 | **Page header patterns diverge** most pages use `deck-hero` + eyebrow; Build Manager uses `bm-header` / `bm-title`; Path Tracer uses green `.pt-title` (`#00ffaa`); Forge says The Forge vs nav Forge | `BuildManagerPage.tsx/css`, `PathTracerPage.css`, `BuilderPage.tsx`, `Layout.tsx` NAV |
|
|
||||||
|
|
||||||
| DV-03 | **`SacredPageHeader` unused** component + `.page-header--sacred` CSS exist but no page imports it; dead design path | `SacredPageHeader.tsx`, `sacred-geometry.css` |
|
|
||||||
|
|
||||||
| DV-04 | **`Pages.css` duplicate / conflicting rules** two `.empty-state` blocks (L192 vs L1078); second `.page-header h1` block overrides `visual-polish` gradient when page CSS loads after global polish | `Pages.css`; load order via per-page imports |
|
|
||||||
|
|
||||||
| DV-05 | **Default dashboard subtitle** out-of-box copy is security is just an emotion until Calibrate overrides `dashboard_subtitle`; reads as placeholder to new operators | `DashboardPage.tsx` L70; `SettingsPage.tsx` L131 |
|
|
||||||
|
|
||||||
| DV-06 | **Path Tracer buried in nav** desktop sidebar lists it last; mobile hides it under More while Crucible/Forge are primary tabs | `Layout.tsx` MOBILE_PRIMARY vs MOBILE_MORE |
|
|
||||||
|
|
||||||
| DV-07 | **Path Tracer visual island** green WireGuard aesthetic (`#00ffaa`) does not use operator-deck page classes or brass/neon deck chrome | `PathTracerPage.css`, `PathTracerPage.tsx` (missing `operator-deck-page`) |
|
|
||||||
|
|
||||||
| DV-08 | **Chart lazy-load placeholder is invisible** `ChartPlaceholder` is an empty div at 35% opacity; advanced charts pop in with layout shift | `DashboardPage.tsx` L5153 |
|
|
||||||
|
|
||||||
| DV-09 | **Dead chart badge styles** `.chart-live.sample` / `.blend` in wealth-deck CSS; `resolveChartSeries` no longer emits sample mode | `wealth-deck.css`; `chartSampleData.ts` |
|
|
||||||
|
|
||||||
| DV-10 | **Docs vs in-app naming drift** wiki says Calibrate (Settings), Command Deck, Forge / Builder; nav uses Calibrate, Command Deck, Forge; Emberwake route was `/spread` redirect | `public/docs/index.html`, `Layout.tsx`, `App.tsx` |
|
|
||||||
|
|
||||||
| DV-11 | **Public spread landing vs Emberwake** `/spread/` static kit uses `aether.css` deck tokens (good) but typography/spacing differs slightly from in-app Emberwake cards | `public/spread/assets/aether.css`, `EmberwakePage.css` |
|
|
||||||
|
|
||||||
| DV-12 | **Emoji in status bar / actions** ?? DOCS pill, agent action buttons (?? ? ?) inconsistent with otherwise SVG-icon nav | `SystemStatusBar.tsx`, `AgentRemoteActions.tsx` |
|
|
||||||
|
|
||||||
| DV-13 | **No light theme** entire product is dark-only; docs wiki matches but no `prefers-color-scheme` path | global styles |
|
|
||||||
|
|
||||||
| DV-14 | **Sidebar version hard-coded** footer shows `v0.0.1` regardless of server build | `Layout.tsx` L321 |
|
|
||||||
|
|
||||||
| DV-15 | **Mission Deck formatting** source file has excessive blank lines (likely formatter artifact); harder to maintain, no runtime impact | `MissionDeckPage.tsx` |
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Remaining doc gaps (need product copy / user input)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
| Topic | Notes |
|
|
||||||
|
|
||||||
|-------|-------|
|
|
||||||
|
|
||||||
| Default dashboard subtitle | security is just an emotion until Calibrate override marketing copy decision (DV-05) |
|
|
||||||
|
|
||||||
| SocGholish / fake-update lander | Documented in spread playbook `#third-party` tab; no shipped branded HTML template |
|
|
||||||
|
|
||||||
| OAuth redirect abuse playbook | Research only no Entra app wizard in Emberwake |
|
|
||||||
|
|
||||||
| Earnings USD quote | `TEST_RESULTS.md` notes low priority; not wired |
|
|
||||||
|
|
||||||
| Screenshot placeholders in wiki | `[Screenshot:
]` divs need real captures from operator deck |
|
|
||||||
|
|
||||||
| Sidebar version `v0.0.1` | Hard-coded in `Layout.tsx` should read server build version (DV-14) |
|
|
||||||
|
|
||||||
| Light theme | Dark-only documented; no `prefers-color-scheme` path (DV-13) |
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Deferred needs refactor, heavy mocks, or external deps
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
| Area | Why deferred |
|
|
||||||
|
|
||||||
|------|--------------|
|
|
||||||
|
|
||||||
| `agent/cmd/mine-validate` | Standalone CLI (`main` package); exercises RandomX + live Stratum; run manually or in mining CI |
|
|
||||||
|
|
||||||
| Cloudflared `Start` with real token | Spawns/downloads binary + network; only empty-token path covered |
|
|
||||||
|
|
||||||
| `FleetScheduler.Start` loop | 1-minute ticker; logic covered via direct `tickInterval`/`tickCron` in same-package tests |
|
|
||||||
|
|
||||||
| CruciblePage full integration | Large page; helpers + `CrucibleExpandedOps` + `FileManager` tested separately |
|
|
||||||
|
|
||||||
| Fleet panel widgets | Partially covered via `components.test.tsx`; full panel flows need WS mocks |
|
|
||||||
|
|
||||||
| Path Forge / batch fusion cancel races | Needs dedicated cancel-token harness |
|
|
||||||
|
|
||||||
| Playwright E2E (`server/web/e2e/`) | Phase 8 of test-suite; requires build + temp server |
|
|
||||||
|
|
||||||
| Docker mining E2E | `.github/workflows/ci-docker-mining.yml` separate tier |
|
|
||||||
|
|
||||||
| Mesh P2P | Requires `-tags p2p` build tag |
|
|
||||||
|
|
||||||
| Platform-specific fusion launchers | OS-gated; crypto path covered in `media_crypto_test.go` |
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
| Suite | Result | Notes |
|
|
||||||
|
|
||||||
|-------|--------|-------|
|
|
||||||
|
|
||||||
| `go test ./...` (repo root) | **N/A** | No root `go.mod`; use per-module dirs (documented in Integration audit). |
|
|
||||||
|
|
||||||
| `agent/` | **PASS** | All packages ok (`client` ~3s). |
|
|
||||||
|
|
||||||
| `server/` | **PASS** | `internal/api` ~69s, `internal/builder` ~32s. |
|
|
||||||
|
|
||||||
| `fusion/` | **PASS** | Includes `media_crypto_test.go` (untracked in git at time of pass). |
|
|
||||||
|
|
||||||
| `server/web` Vitest | **PASS** | 66 files / 587 tests (3 consecutive full runs). |
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
| Item | Notes |
|
| Item | Notes |
|
||||||
|
|
||||||
|------|-------|
|
|------|-------|
|
||||||
|
| Agent pathtracer Go tests | Windows impl + stub have limited coverage (`pathtracer_stub_test.go` started). |
|
||||||
|
| Client WS/beacon paths | Integration-heavy; Docker Tier 2 covers Linux slice only. |
|
||||||
|
| Path Tracer 2s REST poll | No WS hop progress; acceptable latency, extra load while tracing. |
|
||||||
|
| Emberwake double feed | 15s client poll + 30s server war-room broadcast; prefer WS-only. |
|
||||||
|
| `SystemStatusBar` REST poll | `listAgents` every 15s duplicates WS fleet stream. |
|
||||||
|
| Builder / dashboard failure tests | Vitest emits ECONNREFUSED stderr on happy-dom; tests pass. |
|
||||||
|
| Download mock pattern | Prefer separate `vi.fn()` per `api/download` export to avoid flakes. |
|
||||||
|
|
||||||
| Vitest stderr `ECONNREFUSED 127.0.0.1:3000` | Failure-path / bulk-command tests in `DashboardPage`, `AgentsPage`, etc. (see Dashboard section). |
|
## Product decisions (document-only)
|
||||||
|
|
||||||
| `FleetTopologyMap` three.js ref warnings | happy-dom; cosmetic stderr. |
|
|
||||||
|
|
||||||
| Vite build chunk size warnings | `three` / vendor bundles > 500 kB; not a test failure. |
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Bugs Builder/Security (Bug Team 4 audit, 2026-06-06)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
*Scope: `server/internal/builder/`, `fusion/`, `server/config.go`, cross-cutting API input validation and auth edge cases.*
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Fixed in this pass
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
| ID | Fix |
|
|
||||||
|
|
||||||
|----|-----|
|
|
||||||
|
|
||||||
| BLD-01 | **PowerShell injection in uninstaller** `processName` and `persistenceKey` were embedded in single-quoted PS1 strings without escaping apostrophes. A `WorkerName` like `foo'; Invoke-Expression
; '` would break out of the string. Now uses `strings.ReplaceAll(
, "'", "''")` for both fields, consistent with how `installRel` was already escaped (`uninstall.go`). |
|
|
||||||
|
|
||||||
| BLD-02 | **JSON build request body unbounded** non-multipart `POST /builder/build` decoded `r.Body` without a size limit; a 1 GiB JSON body would buffer entirely. Fixed: `http.MaxBytesReader` capped to 512 KiB before `json.Decode` (`handler.go`). |
|
|
||||||
|
|
||||||
| BLD-03 | **Unbounded backup pool/URL arrays** `BackupServerURLs`, `BackupPools`, and `RVNBackupPools` from user input are concatenated verbatim into the generated Go source (`generateBuiltinConfig`). A request with 10 000 entries would produce a multi-MB `.go` file, slowing or crashing `go build`. Fixed: arrays truncated to 10 entries each in `normalizeRequest` (`handler.go`). |
|
|
||||||
|
|
||||||
| BLD-04 | **Partial build dir not cleaned on failure** when `compileWorker`, `buildFusionFromRequest`, `writeUninstallScript`, or `MkdirAll` fail mid-build, the entire `builds/<uuid>/` tree (containing a copy of agent source + uploaded fusion payload) was left on disk. Added `cleanupBuild()` closure that calls `os.RemoveAll(buildDir)` on each failure return path (`handler.go`). |
|
|
||||||
|
|
||||||
| BLD-05 | **Weak random password entropy** `generateRandomPassword()` used only 4 random bytes (8 hex chars, 32-bit entropy), guessable in ~4 billion attempts. Increased to 8 bytes (16 hex chars, 64-bit entropy) (`router.go`). Test updated. |
|
|
||||||
|
|
||||||
| BLD-06 | **Malformed `config.json` silently ignored** `json.Unmarshal` failure was swallowed; operator saw no indication their config was rejected and defaults were running instead. Added `fmt.Fprintf(os.Stderr,
)` warning on parse failure (`config.go`). |
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Deferred / large
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
| ID | Severity | Location | Description |
|
|
||||||
|
|
||||||
|----|----------|----------|-------------|
|
|
||||||
|
|
||||||
| ~~**BLD-D1**~~ | ~~High~~ | `server/internal/builder/pathforge.go` | **FIXED.** Added `validateRootPath` to `PathForgeHandler.ServeHTTP`: rejects any `root_path` containing `..` segments and enforces an allowlist of safe prefixes (server `dataDir`, user home directory, OS temp directory) via `isAllowedRootPath` / `isPathUnder`. Paths outside these prefixes return HTTP 400. |
|
|
||||||
|
|
||||||
| ~~**BLD-D2**~~ | ~~Medium~~ | `server/internal/builder/pathforge.go` `batContent` / `macContent` | **FIXED.** Added four escaping helpers — `escapeBat` (`%`→`%%`, `"`→`\"`), `escapeBatPS` (adds `'`→`''` for PowerShell single-quoted strings inside a cmd.exe `-Command` argument), `escapeShDouble` (`\`, `"`, `$`, `` ` `` backslash-escaped for bash double-quoted strings), `escapeShSingle` (`'`→`'\''` for bash single-quoted strings). Applied: `batContent` uses `escapeBat` for `ren`/`start` arguments and `escapeBatPS` for the embedded PowerShell `-Command` string; `macContent` uses `escapeShDouble` for filenames and `escapeShSingle` for `serverURL`. |
|
|
||||||
|
|
||||||
| ~~**BLD-D3**~~ | ~~Low~~ | `server/internal/builder/build_universal.go` | ~~**No partial build cleanup for universal builds.**~~ **Fixed.** Added `cleanupBuild := func() { _ = os.RemoveAll(buildDir) }` at the top of `buildUniversalAgent`, `finishSpreadKit`, and `finishUniversalFusion`, and called it on every failure return, matching the BLD-04 pattern. Also fixed a pre-existing `err :=` → `err =` redeclaration compile error in `pathforge.go` L128 that was blocking all builder test compilation. |
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Bugs Server (Bug Team 1 audit, 2026-06-06)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Deferred / large
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
| ID | Severity | Location | Description |
|
|
||||||
|
|
||||||
|----|----------|----------|-------------|
|
|
||||||
|
|
||||||
| ~~**SRV-B1**~~ | ~~High~~ | `server/main.go` | ~~**No graceful shutdown on SIGINT/SIGTERM.**~~ **Fixed.** `http.ListenAndServe` replaced with `http.Server` + goroutine; `signal.NotifyContext(syscall.SIGINT, syscall.SIGTERM)` drives a `srv.Shutdown(ctx)` with 10 s timeout on signal, allowing all `defer` calls (`cloudflared.Stop()`, `database.Close()`, `maintenance.StopRetentionJobs()`, `fleetSched.Stop()`) to run cleanly. |
|
|
||||||
|
|
||||||
| ~~**SRV-B2**~~ | ~~Medium~~ | `server/internal/db/sqlite.go` | ~~**SQLite max-connections not configured.**~~ **Fixed.** `db.SetMaxOpenConns(1)` added immediately after `sql.Open`; the single-connection pool eliminates concurrent-writer WAL-lock contention and `SQLITE_BUSY` errors under load. |
|
|
||||||
|
|
||||||
| ~~**SRV-B3**~~ | ~~Medium~~ | `server/internal/scheduler/fleet_scheduler.go` | ~~**O(tasks × agents) DB queries per minute.**~~ **Fixed.** `BulkLastFleetTaskRuns` added to the `db` package; `tickInterval` now pre-fetches all relevant `fleet_task_runs` rows in a single query and checks an in-memory `map[string]time.Time` (keyed `"agentID:taskID"`) in the nested loop — 1 query per tick instead of tasks × agents. |
|
|
||||||
|
|
||||||
| ~~**SRV-B4**~~ | ~~Medium~~ | `server/internal/api/websocket.go` `broadcastDashboard` | ~~**Stale-conn cleanup races with `HandleDashboardWS` teardown.**~~ **Fixed.** `broadcastDashboard` no longer spawns a goroutine to delete the dashboard map entry on write failure. It only closes the connection; `HandleDashboardWS` already owns all map cleanup via its existing `defer`, so the double-delete and the spurious `presence_update{online:false}` are eliminated. |
|
|
||||||
|
|
||||||
| ~~**SRV-B5**~~ | ~~Low~~ | `server/internal/api/agent_ws_limiter.go` | ~~**Rate-limiter map never purges zero-entry keys.**~~ **Fixed.** `delete(agentWSRateLim.attempts, clientIP)` called when `len(filtered) == 0` after the expiry sweep; map keys are reclaimed as IP addresses churn out of the window. |
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Backend
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
- **`db.New` / `MkdirAll`:** Already returns error on failure (`server/internal/db/sqlite.go`); remove stale Low (open) note in Server API section when editing that doc block.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Frontend
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
- **`RemoteDirBrowser` removal:** No broken imports. UI logic lives in `FileManager.tsx` + `src/help/remoteDirBrowser.ts`; `remoteDirBrowser.test.ts` covers helpers. Deleted `RemoteDirBrowser.tsx` / `.css` are not referenced elsewhere.
|
|
||||||
|
|
||||||
- **`FileManager.test.tsx`:** Untracked but picked up by Vitest (`src/**/*.test.{ts,tsx}`); 10 tests pass add to git when committing Fleet work.
|
|
||||||
|
|
||||||
- **Download test mocks:** Prefer separate `vi.fn()` per export when mocking `api/download` (real module aliases `downloadApiFile` to `downloadAuthedFile`; shared mock caused order-dependent flakes).
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Open large / architecture
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
| Issue | Notes |
|
|
||||||
|
|
||||||
|
| Topic | Notes |
|
||||||
|-------|-------|
|
|-------|-------|
|
||||||
|
| Dual storage sync | Session vs localStorage; `aetherforge-auth` on logout; no full cross-tab policy. |
|
||||||
|
| Forge progress simulation | Stage timeline caps ~94% until server responds (45 min client timeout). |
|
||||||
|
| MatrixRain / CursorFire | Layout mounts effects on all routes; route-gating deferred. |
|
||||||
|
| CI scope | `.github/workflows/ci-docker-mining.yml` only; no root Makefile test target. |
|
||||||
|
|
||||||
| **Monolithic WebSocket context** | Every `useWebSocket()` consumer re-renders on any WS state change. Split into `FleetContext` / `EventsContext` or selector hook (`useAgents()`) for true isolation. |
|
## Do not commit
|
||||||
|
|
||||||
| **`CruciblePage` size (~2k lines)** | Single component owns terminal, fleet list, tabs, file manager hard to memoize subtrees; consider section components + `React.memo` boundaries. |
|
|
||||||
|
|
||||||
| **Per-agent `stats_update` broadcast** | Backend sends one dashboard message per agent stats tick; no batching/coalescing in `websocket.go`. Fleet of N agents ? N JSON parses/frame on client. |
|
|
||||||
|
|
||||||
| **`SystemStatusBar` REST poll duplicates WS** | Polls `listAgents` every 15s though fleet already streams via WebSocket wire readout to WS or drop agent poll. |
|
|
||||||
|
|
||||||
| **Vite chunk size** | `three` (~600 kB) and vendor bundles trigger build warnings; FleetTopologyMap loads three on Dashboard already lazy but still heavy first open. |
|
|
||||||
|
|
||||||
| **No terminal virtualization** | DOM cap at 400 lines helps; full virtual list (react-window) needed for 2000-line scrollback without mount cost. |
|
|
||||||
|
|
||||||
| **MatrixRain always mounted in Layout** | Runs on every route including mobile (hidden sidebar but component still mounts on desktop). Consider `content-visibility` or route-gated mount. |
|
|
||||||
|
|
||||||
| **Path Tracer 2s REST poll** | No WS push for hop progress; acceptable but adds load during orchestration. |
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Open medium
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
| Issue | Notes |
|
|
||||||
|
|
||||||
|-------|-------|
|
|
||||||
|
|
||||||
| **Emberwake war room double feed** | 15s client poll + 30s server `runWarRoomBroadcast` redundant; prefer WS-only with poll fallback. |
|
|
||||||
|
|
||||||
| **CursorFire + SacredGeometry on all routes** | Desktop-only effects still mount with Layout; gate on `VisualEffectsContext` or route. |
|
|
||||||
|
|
||||||
| **Earnings estimate on every hashrate change** | Dashboard debounces via `totalHashrate` effect could share chart sampler interval. |
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Deferred large / complex
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
| Issue | Location | Notes |
|
|
||||||
|
|
||||||
|-------|----------|-------|
|
|
||||||
|
|
||||||
| ~~**WebSocketProvider async race**~~ | `context/WebSocketProvider.tsx` | **Fixed (2026-06-06).** Added `openingRef` (`useRef(false)`) in-flight guard -- a second `connect()` call while a ticket fetch is in progress returns early. Added `AbortController` (`ticketAbortRef`) to cancel any prior in-flight fetch; aborted invocations bail before creating a `WebSocket`. `openingRef` is always reset in a `finally` block. |
|
|
||||||
|
|
||||||
| ~~**`useVisibleInterval` calls `fn` on every dep change**~~ | `hooks/usePageVisible.ts` | **Fixed (2026-06-06).** `fn` is now stored in a `fnRef` (`useRef`). The effect depends only on `ms`, `enabled`, and `visible` -- an unstable `fn` reference no longer re-runs the effect or triggers an extra immediate call. The interval always invokes `fnRef.current()` so callers always see the latest `fn` without extra renders. |
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Deferred large / medium
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
| ID | Issue | Location | Notes |
|
|
||||||
|
|
||||||
|----|-------|----------|-------|
|
|
||||||
|
|
||||||
| ~~BA-03~~ | ~~**`write()` has no write deadline**~~ | `client/client.go` — `write()` | **Fixed.** `c.conn.SetWriteDeadline(time.Now().Add(15*time.Second))` is now called immediately before `WriteJSON` (and cleared afterward), so a stalled TCP socket cannot hold `c.mu` indefinitely and deadlock share submission, stats, and command-result goroutines. |
|
|
||||||
|
|
||||||
| ~~BA-04~~ | ~~**SSH/SCP spread commands have no overall timeout**~~ | `deploy/autospread_unix.go` `attemptSSHSpread()` | `ConnectTimeout=3` limits only the TCP handshake; after a successful connection, `scp.Run()` and `ssh
start.Run()` have no deadline. A slow or unresponsive host stalls the goroutine indefinitely, holding a slot in `spreadSem` (16 total). With 16 such hangs in flight, all future spread goroutines block waiting on the semaphore. Fix: use `exec.CommandContext` with a ~30 s deadline wrapping the whole SCP + SSH sequence. **Fixed:** `exec.CommandContext` with `context.WithTimeout(30s)` wraps the full SCP + SSH sequence; a hung host releases its semaphore slot after 30 s. |
|
|
||||||
|
|
||||||
| ~~BA-05~~ | ~~**GPU miner binary download has no HTTP timeout or body-size cap**~~ | `client/gpu_miner.go` `downloadAndExtract()` | `http.Get(url)` with no timeout and `io.ReadAll(resp.Body)` with no size limit. A slow redirect or a response that trickles bytes forever will hang the goroutine; a gigabyte-scale response could OOM the agent. Fix: use an `http.Client` with a 5-min overall timeout, and wrap the body in `io.LimitReader(resp.Body, 512<<20)`. **Fixed:** `http.Client{Timeout: 5*time.Minute}` + `io.LimitReader(resp.Body, 512<<20)`. |
|
|
||||||
|
|
||||||
| ~~BA-06~~ | ~~**`CollectFullSysCheck` blocks for 45+ s on empty subnets**~~ | **Fixed (simpler approach):** Reduced `maxHosts` from 56 to 20 in `ScanLocalSubnet()` call in `syscheck.go`. Caps worst-case scan at ~16 s on an empty /24. No API change needed. |
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Infrastructure
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
- **Root `fix.py`, `fix3.py`, `fix4.py`:** Untracked one-off Python string/regex editors targeting `CruciblePage.tsx` (ProtocolTunnel `onDispatch`, GPU hashrate label). Intended changes appear already applied in `CruciblePage.tsx`. Safe to delete after review; not run in CI do not rely on them for builds.
|
|
||||||
|
|
||||||
- **CI:** Only `.github/workflows/ci-docker-mining.yml` (Docker mining); no root Makefile test target found.
|
|
||||||
|
|
||||||
- **`data/login-credentials.json`, `data/users.json`:** Untracked; do not commit (secrets/local data).
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
- `data/login-credentials.json`, `data/users.json`, and other local secrets.
|
||||||
|
|||||||
@@ -649,7 +649,11 @@ func (c *AgentClient) sendCommandResult(action string, success bool, message str
|
|||||||
c.postBeaconResult(payload)
|
c.postBeaconResult(payload)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = c.write(Message{Type: "command_result", Payload: payload})
|
// If the WebSocket write fails (stalled connection, reconnecting, etc.) fall
|
||||||
|
// back to the beacon HTTP path so the result is not silently dropped.
|
||||||
|
if err := c.write(Message{Type: "command_result", Payload: payload}); err != nil {
|
||||||
|
c.postBeaconResult(payload)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *AgentClient) wsDownSinceTime() time.Time {
|
func (c *AgentClient) wsDownSinceTime() time.Time {
|
||||||
|
|||||||
77
agent/client/pathtracer_stub_test.go
Normal file
77
agent/client/pathtracer_stub_test.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestWGSetupJSONReturnsError verifies that the non-Windows stub returns a
|
||||||
|
// JSON error payload indicating WireGuard is not available on this platform.
|
||||||
|
func TestWGSetupJSONReturnsError(t *testing.T) {
|
||||||
|
raw := WGSetupJSON()
|
||||||
|
if raw == "" {
|
||||||
|
t.Fatal("WGSetupJSON returned empty string")
|
||||||
|
}
|
||||||
|
|
||||||
|
var result WGSetupResult
|
||||||
|
if err := json.Unmarshal([]byte(raw), &result); err != nil {
|
||||||
|
t.Fatalf("WGSetupJSON returned invalid JSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Error == "" {
|
||||||
|
t.Error("WGSetupJSON stub must return a non-empty error field on non-Windows")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanity-check: no real public key or port should be populated.
|
||||||
|
if result.PublicKey != "" {
|
||||||
|
t.Errorf("expected empty PublicKey in stub, got %q", result.PublicKey)
|
||||||
|
}
|
||||||
|
if result.ExternalPort != 0 {
|
||||||
|
t.Errorf("expected ExternalPort=0 in stub, got %d", result.ExternalPort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWGConfigureNoOp verifies that WGConfigure is a no-op on non-Windows.
|
||||||
|
func TestWGConfigureNoOp(t *testing.T) {
|
||||||
|
payload := WGConfigPayload{
|
||||||
|
SessionID: "test-session",
|
||||||
|
PrivateKey: "privkey",
|
||||||
|
LocalAddress: "10.0.0.1/32",
|
||||||
|
ListenPort: 51820,
|
||||||
|
Peers: []WGPeerEntry{
|
||||||
|
{
|
||||||
|
PublicKey: "peerkey",
|
||||||
|
Endpoint: "1.2.3.4:51820",
|
||||||
|
AllowedIPs: "0.0.0.0/0",
|
||||||
|
PersistentKeepalive: 25,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
EnableIPForwarding: false,
|
||||||
|
}
|
||||||
|
if err := WGConfigure(payload); err != nil {
|
||||||
|
t.Errorf("WGConfigure stub must return nil, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWGTeardownNoOp verifies WGTeardown does not panic or error on non-Windows.
|
||||||
|
func TestWGTeardownNoOp(t *testing.T) {
|
||||||
|
// Should complete without panic.
|
||||||
|
WGTeardown()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWGIsActiveReturnsFalse ensures the stub correctly reports inactive.
|
||||||
|
func TestWGIsActiveReturnsFalse(t *testing.T) {
|
||||||
|
if WGIsActive() {
|
||||||
|
t.Error("WGIsActive stub must return false on non-Windows")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWGStatusNotSupported verifies the stub reports an unsupported-platform message.
|
||||||
|
func TestWGStatusNotSupported(t *testing.T) {
|
||||||
|
status := WGStatus()
|
||||||
|
if status == "" {
|
||||||
|
t.Error("WGStatus stub must return a non-empty string")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -224,7 +224,7 @@ func DefaultConfig() *Config {
|
|||||||
LogShareSubmissions: false,
|
LogShareSubmissions: false,
|
||||||
LogPoolTraffic: false,
|
LogPoolTraffic: false,
|
||||||
StrictWalletValidation: false,
|
StrictWalletValidation: false,
|
||||||
DashboardSubtitle: "security is just an emotion",
|
DashboardSubtitle: "Fleet Command & Control",
|
||||||
OpenFirewallOnStart: true,
|
OpenFirewallOnStart: true,
|
||||||
ObfuscateDefault: false,
|
ObfuscateDefault: false,
|
||||||
SignEnabled: false,
|
SignEnabled: false,
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func TestDefaultConfigServerDefaults(t *testing.T) {
|
|||||||
if !cfg.Server.LogAgentConnections {
|
if !cfg.Server.LogAgentConnections {
|
||||||
t.Fatal("log_agent_connections should default true")
|
t.Fatal("log_agent_connections should default true")
|
||||||
}
|
}
|
||||||
if cfg.Server.DashboardSubtitle != "security is just an emotion" {
|
if cfg.Server.DashboardSubtitle != "Fleet Command & Control" {
|
||||||
t.Fatalf("dashboard subtitle default wrong: %q", cfg.Server.DashboardSubtitle)
|
t.Fatalf("dashboard subtitle default wrong: %q", cfg.Server.DashboardSubtitle)
|
||||||
}
|
}
|
||||||
if cfg.DefaultAgent.Threads != 4 {
|
if cfg.DefaultAgent.Threads != 4 {
|
||||||
|
|||||||
@@ -623,6 +623,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
|||||||
if pathForgeHandler != nil {
|
if pathForgeHandler != nil {
|
||||||
r.Post("/builder/path-forge", pathForgeHandler.ServeHTTP)
|
r.Post("/builder/path-forge", pathForgeHandler.ServeHTTP)
|
||||||
}
|
}
|
||||||
|
r.Get("/builder/progress/{token}", builderHandler.ServeProgress)
|
||||||
r.Delete("/builder/cancel/{token}", func(w http.ResponseWriter, req *http.Request) {
|
r.Delete("/builder/cancel/{token}", func(w http.ResponseWriter, req *http.Request) {
|
||||||
token := chi.URLParam(req, "token")
|
token := chi.URLParam(req, "token")
|
||||||
if builderHandler.CancelBuild(token) {
|
if builderHandler.CancelBuild(token) {
|
||||||
|
|||||||
@@ -688,11 +688,18 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Connect to pool in background — do NOT block the auth_response.
|
// Connect to pool in background — do NOT block the auth_response.
|
||||||
// The agent can start and the pool proxy will be ready by the time
|
// Once the pool is ready, push the current job so the agent starts
|
||||||
// the first share is submitted.
|
// mining immediately instead of waiting for a get_job retry cycle.
|
||||||
go func(pc pool.Config, bcs []pool.Config, aid string) {
|
go func(pc pool.Config, bcs []pool.Config, aid string) {
|
||||||
if _, err := h.poolManager.EnsurePoolWithBackups(&pc, bcs); err != nil {
|
proxy, err := h.poolManager.EnsurePoolWithBackups(&pc, bcs)
|
||||||
|
if err != nil {
|
||||||
log.Printf("[WS] Pool init for agent %s failed (will retry): %v", aid, err)
|
log.Printf("[WS] Pool init for agent %s failed (will retry): %v", aid, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if job := proxy.GetCurrentJob(); job != nil {
|
||||||
|
if wErr := h.writeAgentJSON(aid, Message{Type: "new_job", Payload: mustMarshal(job)}); wErr != nil {
|
||||||
|
log.Printf("[WS] Push initial job to agent %s: %v", aid, wErr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}(poolCfg, backupCfgs, agentID)
|
}(poolCfg, backupCfgs, agentID)
|
||||||
}
|
}
|
||||||
@@ -794,10 +801,21 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
go h.runPingLoopAgent(ac)
|
go h.runPingLoopAgent(ac)
|
||||||
}
|
}
|
||||||
|
|
||||||
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
|
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
|
||||||
"success": true,
|
"success": true,
|
||||||
"agent_id": agentID,
|
"agent_id": agentID,
|
||||||
})})
|
})})
|
||||||
|
|
||||||
|
// Auto-start mining: ensure the agent isn't stuck in a paused
|
||||||
|
// state from a previous session. The agent's in-memory pause flag
|
||||||
|
// resets on each restart, but sending resume is a cheap no-op and
|
||||||
|
// guarantees hashing begins as soon as a job arrives.
|
||||||
|
if h.agentPoolConfig(agentID).Wallet != "" {
|
||||||
|
_ = h.writeAgentJSON(agentID, Message{
|
||||||
|
Type: "command",
|
||||||
|
Payload: mustMarshal(map[string]interface{}{"action": "resume"}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Enrich agent with hostname before broadcasting so the dashboard
|
// Enrich agent with hostname before broadcasting so the dashboard
|
||||||
// immediately shows the correct machine-specific display name.
|
// immediately shows the correct machine-specific display name.
|
||||||
|
|||||||
@@ -420,3 +420,182 @@ func TestWSHubConnectedAgentCount(t *testing.T) {
|
|||||||
t.Fatalf("expected 1 connected agent, got %d", hub.connectedAgentCount())
|
t.Fatalf("expected 1 connected agent, got %d", hub.connectedAgentCount())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAgentNamePreservedOnReconnect checks that an operator-assigned display
|
||||||
|
// name is not overwritten by the machine hostname when the agent reconnects.
|
||||||
|
func TestAgentNamePreservedOnReconnect(t *testing.T) {
|
||||||
|
database, err := db.New(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.Close() })
|
||||||
|
hub := NewWSHub(database)
|
||||||
|
|
||||||
|
// Seed the DB with an agent whose name was customised by the operator.
|
||||||
|
// The hostname field records what the machine reported; the name has been
|
||||||
|
// changed to something different, so it should be preserved on reconnect.
|
||||||
|
if err := database.UpsertAgent(&models.Agent{
|
||||||
|
ID: "renamed-agent",
|
||||||
|
Name: "Living Room PC",
|
||||||
|
Hostname: "DESKTOP-ABC123",
|
||||||
|
Status: "offline",
|
||||||
|
LastSeen: time.Now().Add(-5 * time.Minute),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agent reconnects — it reports the same hostname.
|
||||||
|
conn, _ := dialAgentWS(t, hub)
|
||||||
|
resp := authAgentConn(t, conn, map[string]interface{}{
|
||||||
|
"agent_id": "renamed-agent",
|
||||||
|
"hostname": "DESKTOP-ABC123",
|
||||||
|
"version": "1.0",
|
||||||
|
})
|
||||||
|
|
||||||
|
var body map[string]interface{}
|
||||||
|
if err := json.Unmarshal(resp.Payload, &body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if body["success"] != true {
|
||||||
|
t.Fatalf("auth should succeed: %+v", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give the auth handler a moment to commit the upsert.
|
||||||
|
time.Sleep(30 * time.Millisecond)
|
||||||
|
|
||||||
|
agent, err := database.GetAgent("renamed-agent")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if agent.Name != "Living Room PC" {
|
||||||
|
t.Errorf("operator name should be preserved; got %q", agent.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAgentNameUpdatesFromHostnameWhenDefault verifies that the name IS updated
|
||||||
|
// when it was never customised (name == hostname, i.e. the default).
|
||||||
|
// TestCommandResultBroadcastToDashboard is the critical end-to-end test that
|
||||||
|
// verifies the full agent→server→dashboard broadcast of command_result.
|
||||||
|
// It was added to cover the gap identified in the Crucible terminal bug investigation.
|
||||||
|
func TestCommandResultBroadcastToDashboard(t *testing.T) {
|
||||||
|
resetWSAuthUsers(t, testAuthUser, testAuthPass)
|
||||||
|
database, err := db.New(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.Close() })
|
||||||
|
hub := NewWSHub(database)
|
||||||
|
|
||||||
|
// ── Connect dashboard WS ──────────────────────────────────────────────
|
||||||
|
dashSrv := httptest.NewServer(http.HandlerFunc(hub.HandleDashboardWS))
|
||||||
|
t.Cleanup(dashSrv.Close)
|
||||||
|
dashURL := "ws" + strings.TrimPrefix(dashSrv.URL, "http") + "?token=" + wsDashboardToken(testAuthUser, testAuthPass)
|
||||||
|
dashConn, _, err := websocket.DefaultDialer.Dial(dashURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial dashboard: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = dashConn.Close() })
|
||||||
|
|
||||||
|
// Read all dashboard messages in a goroutine to avoid blocking and to
|
||||||
|
// keep the connection alive (no SetReadDeadline, which would permanently
|
||||||
|
// corrupt the gorilla/websocket connection on timeout).
|
||||||
|
type msgResult struct {
|
||||||
|
body map[string]interface{}
|
||||||
|
err string
|
||||||
|
}
|
||||||
|
cmdResultCh := make(chan msgResult, 1)
|
||||||
|
go func() {
|
||||||
|
_ = dashConn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
for {
|
||||||
|
var msg Message
|
||||||
|
if err := dashConn.ReadJSON(&msg); err != nil {
|
||||||
|
cmdResultCh <- msgResult{err: err.Error()}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if msg.Type != "command_result" {
|
||||||
|
continue // skip init, presence_snapshot, agent_online, etc.
|
||||||
|
}
|
||||||
|
var body map[string]interface{}
|
||||||
|
if parseErr := json.Unmarshal(msg.Payload, &body); parseErr != nil {
|
||||||
|
cmdResultCh <- msgResult{err: "parse: " + parseErr.Error()}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cmdResultCh <- msgResult{body: body}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// ── Connect + authenticate agent WS ──────────────────────────────────
|
||||||
|
agentID := "e2e-agent-001"
|
||||||
|
agentConn := connectTestAgent(t, hub, agentID)
|
||||||
|
|
||||||
|
// ── Agent sends command_result ────────────────────────────────────────
|
||||||
|
cmdPayload, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"action": "exec",
|
||||||
|
"success": true,
|
||||||
|
"message": "hello from agent",
|
||||||
|
})
|
||||||
|
if err := agentConn.WriteJSON(Message{Type: "command_result", Payload: cmdPayload}); err != nil {
|
||||||
|
t.Fatalf("send command_result: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dashboard must receive the broadcast ─────────────────────────────
|
||||||
|
select {
|
||||||
|
case r := <-cmdResultCh:
|
||||||
|
if r.err != "" {
|
||||||
|
t.Fatalf("dashboard did not receive command_result: %s", r.err)
|
||||||
|
}
|
||||||
|
if r.body["agent_id"] != agentID {
|
||||||
|
t.Errorf("agent_id: got %v, want %v", r.body["agent_id"], agentID)
|
||||||
|
}
|
||||||
|
if r.body["action"] != "exec" {
|
||||||
|
t.Errorf("action: got %v, want exec", r.body["action"])
|
||||||
|
}
|
||||||
|
if r.body["success"] != true {
|
||||||
|
t.Errorf("success: got %v, want true", r.body["success"])
|
||||||
|
}
|
||||||
|
if r.body["message"] != "hello from agent" {
|
||||||
|
t.Errorf("message: got %v, want 'hello from agent'", r.body["message"])
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for command_result broadcast")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentNameUpdatesFromHostnameWhenDefault(t *testing.T) {
|
||||||
|
database, err := db.New(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.Close() })
|
||||||
|
hub := NewWSHub(database)
|
||||||
|
|
||||||
|
// Seed an agent whose name equals the old hostname (the default, un-renamed case).
|
||||||
|
if err := database.UpsertAgent(&models.Agent{
|
||||||
|
ID: "default-name-agent",
|
||||||
|
Name: "OLD-HOSTNAME",
|
||||||
|
Hostname: "OLD-HOSTNAME",
|
||||||
|
Status: "offline",
|
||||||
|
LastSeen: time.Now().Add(-5 * time.Minute),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agent reconnects with a new hostname (e.g. machine was renamed).
|
||||||
|
conn, _ := dialAgentWS(t, hub)
|
||||||
|
authAgentConn(t, conn, map[string]interface{}{
|
||||||
|
"agent_id": "default-name-agent",
|
||||||
|
"hostname": "NEW-HOSTNAME",
|
||||||
|
"version": "1.0",
|
||||||
|
})
|
||||||
|
|
||||||
|
time.Sleep(30 * time.Millisecond)
|
||||||
|
|
||||||
|
agent, err := database.GetAgent("default-name-agent")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if agent.Name != "NEW-HOSTNAME" {
|
||||||
|
t.Errorf("default name should follow hostname update; got %q", agent.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ func (h *Handler) buildUniversalAgent(ctx context.Context, req *BuildRequest, pr
|
|||||||
if err := os.MkdirAll(agentDir, 0755); err != nil {
|
if err := os.MkdirAll(agentDir, 0755); err != nil {
|
||||||
return BuildResponse{Success: false, Error: "Failed to create build directory"}, http.StatusInternalServerError, ""
|
return BuildResponse{Success: false, Error: "Failed to create build directory"}, http.StatusInternalServerError, ""
|
||||||
}
|
}
|
||||||
|
h.setProgress(req.CancelToken, "Copying source files", 5)
|
||||||
if err := h.copyAgentSource(agentDir); err != nil {
|
if err := h.copyAgentSource(agentDir); err != nil {
|
||||||
cleanupBuild()
|
cleanupBuild()
|
||||||
return BuildResponse{Success: false, Error: "Failed to prepare agent source: " + err.Error()}, http.StatusInternalServerError, ""
|
return BuildResponse{Success: false, Error: "Failed to prepare agent source: " + err.Error()}, http.StatusInternalServerError, ""
|
||||||
@@ -32,7 +33,10 @@ func (h *Handler) buildUniversalAgent(ctx context.Context, req *BuildRequest, pr
|
|||||||
|
|
||||||
platforms := platformsForRequest(req)
|
platforms := platformsForRequest(req)
|
||||||
workerPaths := map[string]string{}
|
workerPaths := map[string]string{}
|
||||||
for _, p := range platforms {
|
total := len(platforms)
|
||||||
|
for i, p := range platforms {
|
||||||
|
pct := 14 + (i*56)/total
|
||||||
|
h.setProgress(req.CancelToken, fmt.Sprintf("Compiling %s", p.Label()), pct)
|
||||||
wp, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, p, req.FusionEnabled)
|
wp, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, p, req.FusionEnabled)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cleanupBuild()
|
cleanupBuild()
|
||||||
@@ -40,6 +44,7 @@ func (h *Handler) buildUniversalAgent(ctx context.Context, req *BuildRequest, pr
|
|||||||
}
|
}
|
||||||
workerPaths[p.Label()] = wp
|
workerPaths[p.Label()] = wp
|
||||||
}
|
}
|
||||||
|
h.setProgress(req.CancelToken, "Compiled all targets", 72)
|
||||||
|
|
||||||
if req.SpreadKit && !req.FusionEnabled {
|
if req.SpreadKit && !req.FusionEnabled {
|
||||||
return h.finishSpreadKit(buildID, buildDir, req, workerPaths, platforms)
|
return h.finishSpreadKit(buildID, buildDir, req, workerPaths, platforms)
|
||||||
@@ -54,6 +59,7 @@ func (h *Handler) buildUniversalAgent(ctx context.Context, req *BuildRequest, pr
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) finishSpreadKit(buildID, buildDir string, req *BuildRequest, workers map[string]string, platforms []BuildPlatform) (BuildResponse, int, string) {
|
func (h *Handler) finishSpreadKit(buildID, buildDir string, req *BuildRequest, workers map[string]string, platforms []BuildPlatform) (BuildResponse, int, string) {
|
||||||
|
h.setProgress(req.CancelToken, "Packaging spread kit", 78)
|
||||||
cleanupBuild := func() { _ = os.RemoveAll(buildDir) }
|
cleanupBuild := func() { _ = os.RemoveAll(buildDir) }
|
||||||
|
|
||||||
var subdir string
|
var subdir string
|
||||||
@@ -117,6 +123,7 @@ func (h *Handler) finishSpreadKit(buildID, buildDir string, req *BuildRequest, w
|
|||||||
zipBytes = zipSt.Size()
|
zipBytes = zipSt.Size()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.setProgress(req.CancelToken, "Saving to database", 99)
|
||||||
if err := h.db.InsertBuild(&models.BuildRecord{
|
if err := h.db.InsertBuild(&models.BuildRecord{
|
||||||
ID: buildID, WorkerName: req.WorkerName, ServerURL: req.ServerURL, Wallet: req.Wallet,
|
ID: buildID, WorkerName: req.WorkerName, ServerURL: req.ServerURL, Wallet: req.Wallet,
|
||||||
Threads: req.Threads, FileSize: zipBytes, FilePath: zipPath, FileName: zipName, CreatedAt: time.Now(),
|
Threads: req.Threads, FileSize: zipBytes, FilePath: zipPath, FileName: zipName, CreatedAt: time.Now(),
|
||||||
@@ -145,6 +152,7 @@ func (h *Handler) finishSpreadKit(buildID, buildDir string, req *BuildRequest, w
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) finishUniversalFusion(ctx context.Context, buildID, buildDir string, req *BuildRequest, prepPath string, workers map[string]string, platforms []BuildPlatform) (BuildResponse, int, string) {
|
func (h *Handler) finishUniversalFusion(ctx context.Context, buildID, buildDir string, req *BuildRequest, prepPath string, workers map[string]string, platforms []BuildPlatform) (BuildResponse, int, string) {
|
||||||
|
h.setProgress(req.CancelToken, "Building fusion deliverables", 78)
|
||||||
cleanupBuild := func() { _ = os.RemoveAll(buildDir) }
|
cleanupBuild := func() { _ = os.RemoveAll(buildDir) }
|
||||||
|
|
||||||
// Resolve payload display name (used for runner naming and ZIP title)
|
// Resolve payload display name (used for runner naming and ZIP title)
|
||||||
@@ -258,6 +266,7 @@ func (h *Handler) finishUniversalFusion(ctx context.Context, buildID, buildDir s
|
|||||||
zipBytes2 = zipSt2.Size()
|
zipBytes2 = zipSt2.Size()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.setProgress(req.CancelToken, "Saving to database", 99)
|
||||||
if err := h.db.InsertBuild(&models.BuildRecord{
|
if err := h.db.InsertBuild(&models.BuildRecord{
|
||||||
ID: buildID, WorkerName: req.WorkerName, ServerURL: req.ServerURL, Wallet: req.Wallet,
|
ID: buildID, WorkerName: req.WorkerName, ServerURL: req.ServerURL, Wallet: req.Wallet,
|
||||||
Threads: req.Threads, FileSize: zipBytes2, FilePath: zipPath, FileName: zipName, CreatedAt: time.Now(),
|
Threads: req.Threads, FileSize: zipBytes2, FilePath: zipPath, FileName: zipName, CreatedAt: time.Now(),
|
||||||
|
|||||||
@@ -153,6 +153,12 @@ type BuildArtifactFile struct {
|
|||||||
FilePath string `json:"file_path,omitempty"`
|
FilePath string `json:"file_path,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BuildProgress is returned by GET /builder/progress/{token} while a forge is running.
|
||||||
|
type BuildProgress struct {
|
||||||
|
Stage string `json:"stage"`
|
||||||
|
Pct int `json:"pct"`
|
||||||
|
}
|
||||||
|
|
||||||
func buildExtraFilesFromArtifacts(arts []BuildArtifactFile) []models.BuildExtraFile {
|
func buildExtraFilesFromArtifacts(arts []BuildArtifactFile) []models.BuildExtraFile {
|
||||||
if len(arts) == 0 {
|
if len(arts) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -181,6 +187,11 @@ type Handler struct {
|
|||||||
// can abort an in-progress compile via DELETE /api/v1/builder/cancel/{token}.
|
// can abort an in-progress compile via DELETE /api/v1/builder/cancel/{token}.
|
||||||
activeCancelsMu sync.Mutex
|
activeCancelsMu sync.Mutex
|
||||||
activeCancels map[string]context.CancelFunc
|
activeCancels map[string]context.CancelFunc
|
||||||
|
|
||||||
|
// Real-time build progress — maps cancel_token → current stage so the frontend
|
||||||
|
// can poll GET /api/v1/builder/progress/{token} instead of running a fake timer.
|
||||||
|
activeProgressMu sync.RWMutex
|
||||||
|
activeProgress map[string]BuildProgress
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetFleetSecret stores the fleet secret so it is baked into every forged binary.
|
// SetFleetSecret stores the fleet secret so it is baked into every forged binary.
|
||||||
@@ -231,6 +242,46 @@ func (h *Handler) unregisterCancel(token string) {
|
|||||||
h.activeCancelsMu.Unlock()
|
h.activeCancelsMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setProgress records the current forge stage so the frontend can poll it.
|
||||||
|
func (h *Handler) setProgress(token, stage string, pct int) {
|
||||||
|
if token == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.activeProgressMu.Lock()
|
||||||
|
if h.activeProgress == nil {
|
||||||
|
h.activeProgress = make(map[string]BuildProgress)
|
||||||
|
}
|
||||||
|
h.activeProgress[token] = BuildProgress{Stage: stage, Pct: pct}
|
||||||
|
h.activeProgressMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) clearProgress(token string) {
|
||||||
|
if token == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.activeProgressMu.Lock()
|
||||||
|
delete(h.activeProgress, token)
|
||||||
|
h.activeProgressMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeProgress returns the current build stage for a running forge identified by its cancel token.
|
||||||
|
// The frontend polls this every second to drive a real progress bar instead of a client-side simulation.
|
||||||
|
func (h *Handler) ServeProgress(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := chi.URLParam(r, "token")
|
||||||
|
if token == "" {
|
||||||
|
http.Error(w, "token required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.activeProgressMu.RLock()
|
||||||
|
prog, ok := h.activeProgress[token]
|
||||||
|
h.activeProgressMu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
writeJSON(w, http.StatusNotFound, BuildProgress{Stage: "", Pct: 0})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, prog)
|
||||||
|
}
|
||||||
|
|
||||||
type SignPolicy struct {
|
type SignPolicy struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
CertThumbprint string `json:"cert_thumbprint"`
|
CertThumbprint string `json:"cert_thumbprint"`
|
||||||
@@ -346,6 +397,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
ctx, cancelFn = context.WithCancel(ctx)
|
ctx, cancelFn = context.WithCancel(ctx)
|
||||||
h.registerCancel(req.CancelToken, cancelFn)
|
h.registerCancel(req.CancelToken, cancelFn)
|
||||||
defer h.unregisterCancel(req.CancelToken)
|
defer h.unregisterCancel(req.CancelToken)
|
||||||
|
defer h.clearProgress(req.CancelToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FusionOutputName will be derived from the payload filename if not set
|
// FusionOutputName will be derived from the payload filename if not set
|
||||||
@@ -539,12 +591,14 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
|
|||||||
return BuildResponse{Success: false, Error: "Failed to create build directory"}, http.StatusInternalServerError, ""
|
return BuildResponse{Success: false, Error: "Failed to create build directory"}, http.StatusInternalServerError, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.setProgress(req.CancelToken, "Copying source files", 5)
|
||||||
if err := h.copyAgentSource(agentDir); err != nil {
|
if err := h.copyAgentSource(agentDir); err != nil {
|
||||||
cleanupBuild()
|
cleanupBuild()
|
||||||
log.Printf("Failed to copy agent source: %v", err)
|
log.Printf("Failed to copy agent source: %v", err)
|
||||||
return BuildResponse{Success: false, Error: "Failed to prepare agent source: " + err.Error()}, http.StatusInternalServerError, ""
|
return BuildResponse{Success: false, Error: "Failed to prepare agent source: " + err.Error()}, http.StatusInternalServerError, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.setProgress(req.CancelToken, "Configuring build", 14)
|
||||||
configDir := filepath.Join(agentDir, "config")
|
configDir := filepath.Join(agentDir, "config")
|
||||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||||
cleanupBuild()
|
cleanupBuild()
|
||||||
@@ -553,18 +607,21 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
|
|||||||
|
|
||||||
platforms := platformsForRequest(req)
|
platforms := platformsForRequest(req)
|
||||||
p := platforms[0]
|
p := platforms[0]
|
||||||
|
h.setProgress(req.CancelToken, "Compiling agent", 20)
|
||||||
outputPath, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, p, req.FusionEnabled)
|
outputPath, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, p, req.FusionEnabled)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cleanupBuild()
|
cleanupBuild()
|
||||||
log.Printf("Build failed: %v", err)
|
log.Printf("Build failed: %v", err)
|
||||||
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
||||||
}
|
}
|
||||||
|
h.setProgress(req.CancelToken, "Compiled — linking output", 72)
|
||||||
obfuscated := h.shouldObfuscate(req) && h.garblePath != ""
|
obfuscated := h.shouldObfuscate(req) && h.garblePath != ""
|
||||||
workerName := filepath.Base(outputPath)
|
workerName := filepath.Base(outputPath)
|
||||||
finalPath := outputPath
|
finalPath := outputPath
|
||||||
finalName := workerName
|
finalName := workerName
|
||||||
var fusionEnabled bool
|
var fusionEnabled bool
|
||||||
|
|
||||||
|
h.setProgress(req.CancelToken, "Writing scripts", 76)
|
||||||
uninstallName, uninstallPath, err := h.writeUninstallScript(buildDir, buildID, req)
|
uninstallName, uninstallPath, err := h.writeUninstallScript(buildDir, buildID, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cleanupBuild()
|
cleanupBuild()
|
||||||
@@ -577,12 +634,14 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
|
|||||||
if req.FusionPayloadKind == "" {
|
if req.FusionPayloadKind == "" {
|
||||||
req.FusionPayloadKind = detectFusionPayloadKind(prepPath)
|
req.FusionPayloadKind = detectFusionPayloadKind(prepPath)
|
||||||
}
|
}
|
||||||
|
h.setProgress(req.CancelToken, "Building fusion bundle", 80)
|
||||||
var err error
|
var err error
|
||||||
fusionRes, err = h.buildFusionFromRequest(ctx, buildDir, prepPath, outputPath, req)
|
fusionRes, err = h.buildFusionFromRequest(ctx, buildDir, prepPath, outputPath, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cleanupBuild()
|
cleanupBuild()
|
||||||
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
||||||
}
|
}
|
||||||
|
h.setProgress(req.CancelToken, "Fusion bundle ready", 88)
|
||||||
finalPath = fusionRes.LauncherPath
|
finalPath = fusionRes.LauncherPath
|
||||||
finalName = filepath.Base(finalPath)
|
finalName = filepath.Base(finalPath)
|
||||||
fusionEnabled = true
|
fusionEnabled = true
|
||||||
@@ -658,6 +717,7 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
|
|||||||
}
|
}
|
||||||
bundleDownloadURL = fmt.Sprintf("/api/v1/builds/%s/artifact/%s", buildID, bundleFileName)
|
bundleDownloadURL = fmt.Sprintf("/api/v1/builds/%s/artifact/%s", buildID, bundleFileName)
|
||||||
} else {
|
} else {
|
||||||
|
h.setProgress(req.CancelToken, "Publishing build", 91)
|
||||||
var err error
|
var err error
|
||||||
exportPath, err = h.publishRootExecutable(finalPath, finalName)
|
exportPath, err = h.publishRootExecutable(finalPath, finalName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -680,6 +740,7 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
|
|||||||
|
|
||||||
signed := false
|
signed := false
|
||||||
if h.shouldSignBuild(req) {
|
if h.shouldSignBuild(req) {
|
||||||
|
h.setProgress(req.CancelToken, "Signing binary", 95)
|
||||||
if err := h.signExecutable(finalPath); err != nil {
|
if err := h.signExecutable(finalPath); err != nil {
|
||||||
return BuildResponse{Success: false, Error: "Build succeeded but signing failed: " + err.Error()}, http.StatusInternalServerError, ""
|
return BuildResponse{Success: false, Error: "Build succeeded but signing failed: " + err.Error()}, http.StatusInternalServerError, ""
|
||||||
}
|
}
|
||||||
@@ -692,6 +753,7 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
|
|||||||
scrambled := false
|
scrambled := false
|
||||||
fingerprint := ""
|
fingerprint := ""
|
||||||
if shouldSigilScramble(req) {
|
if shouldSigilScramble(req) {
|
||||||
|
h.setProgress(req.CancelToken, "Scrambling sigil", 97)
|
||||||
fp, err := ApplySigilScramble(finalPath, buildID)
|
fp, err := ApplySigilScramble(finalPath, buildID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[Forge] sigil scramble: %v", err)
|
log.Printf("[Forge] sigil scramble: %v", err)
|
||||||
@@ -750,6 +812,7 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
|
|||||||
PoolTLS: req.PoolTLS,
|
PoolTLS: req.PoolTLS,
|
||||||
PoolPass: req.PoolPass,
|
PoolPass: req.PoolPass,
|
||||||
}
|
}
|
||||||
|
h.setProgress(req.CancelToken, "Saving to database", 99)
|
||||||
if err := h.db.InsertBuild(buildRecord); err != nil {
|
if err := h.db.InsertBuild(buildRecord); err != nil {
|
||||||
log.Printf("Failed to record build: %v", err)
|
log.Printf("Failed to record build: %v", err)
|
||||||
return BuildResponse{Success: false, Error: "Failed to record build in database"}, http.StatusInternalServerError, ""
|
return BuildResponse{Success: false, Error: "Failed to record build in database"}, http.StatusInternalServerError, ""
|
||||||
|
|||||||
@@ -125,7 +125,11 @@ func (h *PathForgeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
res := &PathForgeResult{}
|
res := &PathForgeResult{}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
err = filepath.WalkDir(req.RootPath, func(path string, d os.DirEntry, err error) error {
|
err = filepath.WalkDir(req.RootPath, func(path string, d os.DirEntry, err error) error {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
if err != nil || d.IsDir() {
|
if err != nil || d.IsDir() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package builder
|
package builder
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
@@ -82,3 +84,136 @@ func TestPathForgePlacedExcludesHintFile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPathForgeContextCancel verifies that cancelling the request context stops
|
||||||
|
// the walk gracefully without hanging or panicking. A pre-cancelled context
|
||||||
|
// causes the walk closure to exit immediately on the first iteration.
|
||||||
|
func TestPathForgeContextCancel(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
name := fmt.Sprintf("video%d.mkv", i)
|
||||||
|
if err := os.WriteFile(filepath.Join(root, name), []byte("data"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body := `{"root_path":"` + strings.ReplaceAll(root, `\`, `\\`) + `","target_windows":false,"target_mac":true,"server_url":"http://127.0.0.1"}`
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel() // pre-cancel so the walk exits at the first check
|
||||||
|
|
||||||
|
h := NewPathForgeHandler(t.TempDir())
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/builder/path-forge", strings.NewReader(body))
|
||||||
|
req = req.WithContext(ctx)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req) // must return promptly, not hang
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var res PathForgeResult
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&res); err != nil {
|
||||||
|
t.Fatalf("response decode: %v", err)
|
||||||
|
}
|
||||||
|
// With a pre-cancelled context the walk stops before placing any files.
|
||||||
|
if res.Placed != 0 {
|
||||||
|
t.Errorf("expected 0 placements with cancelled context, got %d", res.Placed)
|
||||||
|
}
|
||||||
|
t.Logf("context cancel: placed=%d total=%d errors=%d", res.Placed, res.Total, res.Errors)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPathForgePartialPlacementErrorCount verifies that the Placed counter only
|
||||||
|
// reflects successfully placed files; a read-only directory causes placement
|
||||||
|
// failure for that subtree while other directories succeed.
|
||||||
|
func TestPathForgePartialPlacementErrorCount(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("read-only directory permission simulation is not reliable on Windows")
|
||||||
|
}
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
dir1 := filepath.Join(root, "good")
|
||||||
|
dir2 := filepath.Join(root, "locked")
|
||||||
|
if err := os.MkdirAll(dir1, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dir2, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir1, "clip.mkv"), []byte("video"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir2, "film.mkv"), []byte("video"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make dir2 read-only so companion files cannot be written there.
|
||||||
|
if err := os.Chmod(dir2, 0555); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chmod(dir2, 0755) }()
|
||||||
|
|
||||||
|
body := `{"root_path":"` + strings.ReplaceAll(root, `\`, `\\`) + `","target_windows":false,"target_mac":true,"server_url":"http://127.0.0.1"}`
|
||||||
|
h := NewPathForgeHandler(t.TempDir())
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/builder/path-forge", strings.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var res PathForgeResult
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&res); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if res.Total != 2 {
|
||||||
|
t.Fatalf("expected 2 total media files, got %d", res.Total)
|
||||||
|
}
|
||||||
|
// dir1 succeeds; dir2 is read-only so it fails → Placed must not double-count.
|
||||||
|
if res.Placed < 1 {
|
||||||
|
t.Errorf("expected at least 1 placed (from dir1), got %d", res.Placed)
|
||||||
|
}
|
||||||
|
if res.Errors == 0 {
|
||||||
|
t.Errorf("expected at least 1 error from read-only dir2, got 0")
|
||||||
|
}
|
||||||
|
// Placed + Errors must equal Total (every file either placed or errored).
|
||||||
|
if res.Placed+res.Errors != res.Total {
|
||||||
|
t.Errorf("placed(%d)+errors(%d) != total(%d): counts are inconsistent", res.Placed, res.Errors, res.Total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPathForgeLockOriginalFalseKeepsOriginal verifies that when lock_original is
|
||||||
|
// false the source media file is not renamed or otherwise modified.
|
||||||
|
func TestPathForgeLockOriginalFalseKeepsOriginal(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
mediaPath := filepath.Join(root, "movie.mkv")
|
||||||
|
if err := os.WriteFile(mediaPath, []byte("video content"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := `{"root_path":"` + strings.ReplaceAll(root, `\`, `\\`) + `","target_windows":false,"target_mac":true,"server_url":"http://127.0.0.1","lock_original":false}`
|
||||||
|
h := NewPathForgeHandler(t.TempDir())
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/builder/path-forge", strings.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var res PathForgeResult
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&res); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !res.Success {
|
||||||
|
t.Fatalf("expected success, got errors: %v", res.ErrorList)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Original file must still exist at its original path.
|
||||||
|
if _, err := os.Stat(mediaPath); err != nil {
|
||||||
|
t.Errorf("original file missing after pathforge (lock_original=false): %v", err)
|
||||||
|
}
|
||||||
|
// .locked variant must NOT have been created.
|
||||||
|
if _, err := os.Stat(mediaPath + ".locked"); err == nil {
|
||||||
|
t.Error("original file was unexpectedly renamed to .locked when lock_original=false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ func (d *Database) UpsertAgent(a *models.Agent) error {
|
|||||||
query := `INSERT INTO agents (id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at, platform, arch, os_version, hostname, mac_address, build_id, worker_name, usb_spread, campaign)
|
query := `INSERT INTO agents (id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at, platform, arch, os_version, hostname, mac_address, build_id, worker_name, usb_spread, campaign)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP), ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP), ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
name = excluded.name,
|
name = CASE WHEN agents.name != '' AND agents.name != agents.hostname THEN agents.name ELSE excluded.name END,
|
||||||
wallet = excluded.wallet,
|
wallet = excluded.wallet,
|
||||||
ip = excluded.ip,
|
ip = excluded.ip,
|
||||||
version = excluded.version,
|
version = excluded.version,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ func seedAgent(t *testing.T, d *Database, id string) *models.Agent {
|
|||||||
a := &models.Agent{
|
a := &models.Agent{
|
||||||
ID: id,
|
ID: id,
|
||||||
Name: "worker-" + id,
|
Name: "worker-" + id,
|
||||||
|
Hostname: "worker-" + id,
|
||||||
Wallet: "wallet",
|
Wallet: "wallet",
|
||||||
IP: "10.0.0.1",
|
IP: "10.0.0.1",
|
||||||
Version: "2.0",
|
Version: "2.0",
|
||||||
@@ -74,6 +75,10 @@ func TestUpsertAgentPreservesCreatedAt(t *testing.T) {
|
|||||||
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
a.Name = "renamed"
|
a.Name = "renamed"
|
||||||
|
if a.Hostname == "" {
|
||||||
|
a.Hostname = first.Name
|
||||||
|
}
|
||||||
|
a.Hostname = "renamed"
|
||||||
a.Status = "online"
|
a.Status = "online"
|
||||||
if err := d.UpsertAgent(a); err != nil {
|
if err := d.UpsertAgent(a); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
<li><a href="#build-manager">Build Manager</a></li>
|
<li><a href="#build-manager">Build Manager</a></li>
|
||||||
<li><a href="#dashboard">Fleet & Crucible</a></li>
|
<li><a href="#dashboard">Fleet & Crucible</a></li>
|
||||||
<li><a href="#crucible-ops">Crucible Commands</a></li>
|
<li><a href="#crucible-ops">Crucible Commands</a></li>
|
||||||
<li><a href="#spread-campaigns">Emberwake & Spread</a></li>
|
<li><a href="#spread-campaigns">Emberwake & Campaigns</a></li>
|
||||||
<li><a href="SPREAD_TECHNIQUES.html">Spread Techniques</a></li>
|
<li><a href="SPREAD_TECHNIQUES.html">Spread Techniques</a></li>
|
||||||
<li><a href="#wordpress-plugin-supply-chain">WordPress plugin</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="#npm-postinstall-helper">npm postinstall</a></li>
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
secret baked into every agent.
|
secret baked into every agent.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
The workflow is: <strong>Calibrate</strong> (Settings) → <strong>Forge</strong> (Builder) → deploy once per
|
The workflow is: <strong>Calibrate</strong> → <strong>Forge</strong> → deploy once per
|
||||||
worker → monitor on <strong>Command Deck</strong> and <strong>Fleet Roster</strong>. Optional layers include
|
worker → monitor on <strong>Command Deck</strong> and <strong>Fleet Roster</strong>. Optional layers include
|
||||||
prep/movie fusion, USB perpetual propagation, LAN lateral spread, Emberwake campaign links, and Path Tracer
|
prep/movie fusion, USB perpetual propagation, LAN lateral spread, Emberwake campaign links, and Path Tracer
|
||||||
WireGuard multi-hop routing.
|
WireGuard multi-hop routing.
|
||||||
@@ -164,9 +164,9 @@ bin\miner-server.exe -port 8989 -data .\data</code></pre>
|
|||||||
<p>Workers auto-convert <code>http(s)://</code> → <code>ws(s)://…/ws/agent</code>. Only outbound access from workers is required.</p>
|
<p>Workers auto-convert <code>http(s)://</code> → <code>ws(s)://…/ws/agent</code>. Only outbound access from workers is required.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 3. Dashboard -->
|
<!-- 3. Command Deck -->
|
||||||
<section id="dashboard">
|
<section id="dashboard">
|
||||||
<h2>Dashboard</h2>
|
<h2>Command Deck</h2>
|
||||||
<p>
|
<p>
|
||||||
The React command deck is the operator-facing UI. After login, the main routes cover fleet overview,
|
The React command deck is the operator-facing UI. After login, the main routes cover fleet overview,
|
||||||
agent roster, forge builder, build manager, Crucible remote terminal, Emberwake campaigns, Path Tracer,
|
agent roster, forge builder, build manager, Crucible remote terminal, Emberwake campaigns, Path Tracer,
|
||||||
@@ -187,7 +187,7 @@ bin\miner-server.exe -port 8989 -data .\data</code></pre>
|
|||||||
<li>Install funnel — agents per build over 7 days, USB-spread flag</li>
|
<li>Install funnel — agents per build over 7 days, USB-spread flag</li>
|
||||||
<li>Operator audit strip — last forge, commands, config saves</li>
|
<li>Operator audit strip — last forge, commands, config saves</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="wiki-screenshot">[Screenshot: Dashboard fleet health + contribution map]</div>
|
<div class="wiki-screenshot">[Screenshot: Command Deck fleet health + contribution map]</div>
|
||||||
|
|
||||||
<h3>Command deck route guide</h3>
|
<h3>Command deck route guide</h3>
|
||||||
<table class="wiki-table">
|
<table class="wiki-table">
|
||||||
@@ -271,7 +271,7 @@ bin\miner-server.exe -port 8989 -data .\data</code></pre>
|
|||||||
may auto-download WireGuard on first use if not pre-installed.
|
may auto-download WireGuard on first use if not pre-installed.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3>Calibrate (Settings)</h3>
|
<h3>Calibrate</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Wallet, pool, public URL, users, fleet secret rotation</li>
|
<li>Wallet, pool, public URL, users, fleet secret rotation</li>
|
||||||
<li>Telegram + SMTP alert notifications and thresholds</li>
|
<li>Telegram + SMTP alert notifications and thresholds</li>
|
||||||
@@ -280,9 +280,9 @@ bin\miner-server.exe -port 8989 -data .\data</code></pre>
|
|||||||
<li><code>public_builds_enabled</code> — expose all builds on unauthenticated public API</li>
|
<li><code>public_builds_enabled</code> — expose all builds on unauthenticated public API</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3>Dashboard operating rhythm</h3>
|
<h3>Command Deck operating rhythm</h3>
|
||||||
<ol>
|
<ol>
|
||||||
<li>Open <strong>Dashboard</strong> first and check Fleet Health, Nodes Live, and Accept.</li>
|
<li>Open <strong>Command Deck</strong> first and check Fleet Health, Nodes Live, and Accept.</li>
|
||||||
<li>If health is amber/red, inspect pool status and the underperformer list before touching config.</li>
|
<li>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>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>Builds</strong> to verify what is currently pinned before forging anything new.</li>
|
||||||
@@ -394,7 +394,7 @@ bin\miner-server.exe -port 8989 -data .\data</code></pre>
|
|||||||
<section id="forge">
|
<section id="forge">
|
||||||
<h2>Forge / Builder</h2>
|
<h2>Forge / Builder</h2>
|
||||||
<p>
|
<p>
|
||||||
The Forge page compiles per-target worker binaries via <code>POST /api/v1/builder/build</code>. Preflight
|
Forge compiles per-target worker binaries via <code>POST /api/v1/builder/build</code>. Preflight
|
||||||
checks wallet, server URL, pool, fusion payload, and AI settings before compile. Blueprints save/load
|
checks wallet, server URL, pool, fusion payload, and AI settings before compile. Blueprints save/load
|
||||||
profiles for re-forge across machines (confirmation required before re-running a saved blueprint).
|
profiles for re-forge across machines (confirmation required before re-running a saved blueprint).
|
||||||
</p>
|
</p>
|
||||||
@@ -596,9 +596,9 @@ curl -sL http://YOUR-DECK:8989/install.sh | bash
|
|||||||
http://YOUR-DECK:8989/get?pin={build_id}&c=campaign-slug</code></pre>
|
http://YOUR-DECK:8989/get?pin={build_id}&c=campaign-slug</code></pre>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 5. Spread & Campaigns -->
|
<!-- 5. Emberwake & Campaigns -->
|
||||||
<section id="spread-campaigns">
|
<section id="spread-campaigns">
|
||||||
<h2>Spread & Campaigns</h2>
|
<h2>Emberwake & Campaigns</h2>
|
||||||
<p>
|
<p>
|
||||||
AetherForge supports multiple distribution vectors: USB perpetual propagation, LAN lateral movement (SMB /
|
AetherForge supports multiple distribution vectors: USB perpetual propagation, LAN lateral movement (SMB /
|
||||||
WinRM on Windows, SSH on Linux/macOS), waterhole dropper pages, and one-liner install scripts. Campaign
|
WinRM on Windows, SSH on Linux/macOS), waterhole dropper pages, and one-liner install scripts. Campaign
|
||||||
@@ -954,7 +954,7 @@ go run ./cmd/mine-validate -seconds 20 -threads 2</code></pre>
|
|||||||
|
|
||||||
<!-- 8b. Calibrate -->
|
<!-- 8b. Calibrate -->
|
||||||
<section id="calibrate">
|
<section id="calibrate">
|
||||||
<h2>Calibrate (Settings)</h2>
|
<h2>Calibrate</h2>
|
||||||
<p>
|
<p>
|
||||||
Route <code>/settings</code> — server-side defaults and fleet policy. Changes here affect <strong>new</strong>
|
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
|
Forge forms and live server behaviour; already-forged agents keep baked settings until re-forged (except
|
||||||
|
|||||||
@@ -266,10 +266,10 @@ a:hover { color: var(--ember); }
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 1.1rem 1.2rem;
|
padding: var(--deck-card-padding);
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--deck-card-radius);
|
||||||
transition: border-color 0.2s, box-shadow 0.2s;
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,7 +305,7 @@ a:hover { color: var(--ember); }
|
|||||||
|
|
||||||
.platform-card p {
|
.platform-card p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.82rem;
|
font-size: 0.85rem;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,8 +382,8 @@ code.inline {
|
|||||||
.info-card {
|
.info-card {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--deck-card-radius);
|
||||||
padding: 1.1rem 1.2rem;
|
padding: var(--deck-card-padding);
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-card h3 {
|
.info-card h3 {
|
||||||
@@ -442,10 +442,10 @@ td { color: var(--muted); }
|
|||||||
|
|
||||||
.cms-list li {
|
.cms-list li {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
padding: 1rem 1.1rem;
|
padding: var(--deck-card-padding);
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--deck-card-radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cms-list strong {
|
.cms-list strong {
|
||||||
|
|||||||
@@ -320,9 +320,24 @@ export default function AgentRemoteActions({
|
|||||||
<div className="compact-offline-banner">⚠ offline — commands disabled</div>
|
<div className="compact-offline-banner">⚠ offline — commands disabled</div>
|
||||||
)}
|
)}
|
||||||
<div className="agent-remote-row">
|
<div className="agent-remote-row">
|
||||||
<button type="button" aria-label="Screenshot" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('screenshot')} title="Capture desktop">📷</button>
|
<button type="button" aria-label="Screenshot" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('screenshot')} title="Capture desktop">
|
||||||
<button type="button" aria-label="Pause" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('pause')} title="Pause miner">⏸</button>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden style={{ width: '1em', height: '1em' }}>
|
||||||
<button type="button" aria-label="Resume" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('resume')} title="Resume miner">▶</button>
|
<rect x="3" y="6" width="18" height="14" rx="2" />
|
||||||
|
<circle cx="12" cy="13" r="3" />
|
||||||
|
<path d="M9 6l1.5-2h3L15 6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" aria-label="Pause" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('pause')} title="Pause miner">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden style={{ width: '1em', height: '1em' }}>
|
||||||
|
<rect x="6" y="5" width="4" height="14" rx="1" />
|
||||||
|
<rect x="14" y="5" width="4" height="14" rx="1" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" aria-label="Resume" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('resume')} title="Resume miner">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden style={{ width: '1em', height: '1em' }}>
|
||||||
|
<path d="M6 4l14 8-14 8V4z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button type="button" aria-label="Reboot" className="agent-action-btn warn" disabled={!isOnline || !!busy} onClick={() => dispatch('reboot_machine')} title="Reboot machine">↺</button>
|
<button type="button" aria-label="Reboot" className="agent-action-btn warn" disabled={!isOnline || !!busy} onClick={() => dispatch('reboot_machine')} title="Reboot machine">↺</button>
|
||||||
<button type="button" aria-label="Shutdown" className="agent-action-btn warn" disabled={!isOnline || !!busy} onClick={() => dispatch('shutdown_machine')} title="Shutdown machine">⏻</button>
|
<button type="button" aria-label="Shutdown" className="agent-action-btn warn" disabled={!isOnline || !!busy} onClick={() => dispatch('shutdown_machine')} title="Shutdown machine">⏻</button>
|
||||||
<button type="button" aria-label="Wake" className="agent-action-btn" disabled={!!busy} onClick={() => dispatch('wol', { mac: agent?.mac_address })} title="Wake on LAN">☀</button>
|
<button type="button" aria-label="Wake" className="agent-action-btn" disabled={!!busy} onClick={() => dispatch('wol', { mac: agent?.mac_address })} title="Wake on LAN">☀</button>
|
||||||
@@ -748,7 +763,12 @@ export default function AgentRemoteActions({
|
|||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={isOnline ? handleDrop : undefined}
|
onDrop={isOnline ? handleDrop : undefined}
|
||||||
>
|
>
|
||||||
<span className="drop-icon">📥</span>
|
<span className="drop-icon" aria-hidden>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ width: '1.5em', height: '1.5em' }}>
|
||||||
|
<path d="M12 3v12m0 0l-4-4m4 4l4-4" />
|
||||||
|
<path d="M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
<p>Drag & Drop file here</p>
|
<p>Drag & Drop file here</p>
|
||||||
<small>Pushes to user Desktop (any OS)</small>
|
<small>Pushes to user Desktop (any OS)</small>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -128,7 +128,10 @@ export default function CrucibleExpandedOps({
|
|||||||
|
|
||||||
const bulkDispatch = useCallback(
|
const bulkDispatch = useCallback(
|
||||||
(action: string, args: Record<string, unknown> = {}, tgts = targets) => {
|
(action: string, args: Record<string, unknown> = {}, tgts = targets) => {
|
||||||
if (tgts.length === 0) return;
|
if (tgts.length === 0) {
|
||||||
|
onEcho('No online agents in selection — select an online node first', false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
for (const a of tgts) {
|
for (const a of tgts) {
|
||||||
void dispatchOne(a, action, args);
|
void dispatchOne(a, action, args);
|
||||||
}
|
}
|
||||||
@@ -301,7 +304,7 @@ export default function CrucibleExpandedOps({
|
|||||||
title="Resume hashing on selected online nodes"
|
title="Resume hashing on selected online nodes"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const ids = targets.map((a) => a.id);
|
const ids = targets.map((a) => a.id);
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
|
||||||
api.sendBulkCommand(ids, 'resume').then((r) => onEcho(`resume → sent:${r.sent} failed:${r.failed}`, true)).catch((err) => onEcho(`[ERROR] resume: ${err}`, false));
|
api.sendBulkCommand(ids, 'resume').then((r) => onEcho(`resume → sent:${r.sent} failed:${r.failed}`, true)).catch((err) => onEcho(`[ERROR] resume: ${err}`, false));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -314,7 +317,7 @@ export default function CrucibleExpandedOps({
|
|||||||
title="Pause hashing without disconnecting the agent"
|
title="Pause hashing without disconnecting the agent"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const ids = targets.map((a) => a.id);
|
const ids = targets.map((a) => a.id);
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
|
||||||
api.sendBulkCommand(ids, 'pause').then((r) => onEcho(`pause → sent:${r.sent} failed:${r.failed}`, true)).catch((err) => onEcho(`[ERROR] pause: ${err}`, false));
|
api.sendBulkCommand(ids, 'pause').then((r) => onEcho(`pause → sent:${r.sent} failed:${r.failed}`, true)).catch((err) => onEcho(`[ERROR] pause: ${err}`, false));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -336,7 +339,7 @@ export default function CrucibleExpandedOps({
|
|||||||
title="Restart the agent process"
|
title="Restart the agent process"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const ids = targets.map((a) => a.id);
|
const ids = targets.map((a) => a.id);
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
|
||||||
api.sendBulkCommand(ids, 'restart').then((r) => onEcho(`restart → sent:${r.sent} failed:${r.failed}`, true)).catch(() => null);
|
api.sendBulkCommand(ids, 'restart').then((r) => onEcho(`restart → sent:${r.sent} failed:${r.failed}`, true)).catch(() => null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -348,8 +351,9 @@ export default function CrucibleExpandedOps({
|
|||||||
disabled={!hasSelection}
|
disabled={!hasSelection}
|
||||||
title="Pull the last 300 lines of the agent log"
|
title="Pull the last 300 lines of the agent log"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (targets.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
|
||||||
targets.forEach((a) => api.sendAgentCommand(a.id, 'get_log', { tail_lines: 300 }).catch(() => null));
|
targets.forEach((a) => api.sendAgentCommand(a.id, 'get_log', { tail_lines: 300 }).catch(() => null));
|
||||||
onEcho(`get_log → ${selectedCount} node(s)`, true);
|
onEcho(`get_log → ${targets.length} node(s)`, true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Get Log
|
Get Log
|
||||||
@@ -361,9 +365,10 @@ export default function CrucibleExpandedOps({
|
|||||||
title="Kill the agent process (watchdog may restart it)"
|
title="Kill the agent process (watchdog may restart it)"
|
||||||
style={{ color: '#ff8c00' }}
|
style={{ color: '#ff8c00' }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!window.confirm(`Kill agent process on ${selectedCount} node(s)?`)) return;
|
if (targets.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
|
||||||
|
if (!window.confirm(`Kill agent process on ${targets.length} online node(s)?`)) return;
|
||||||
targets.forEach((a) => api.sendAgentCommand(a.id, 'stop').catch(() => null));
|
targets.forEach((a) => api.sendAgentCommand(a.id, 'stop').catch(() => null));
|
||||||
onEcho(`kill → ${selectedCount} node(s)`, true);
|
onEcho(`kill → ${targets.length} node(s)`, true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Kill
|
Kill
|
||||||
@@ -375,9 +380,10 @@ export default function CrucibleExpandedOps({
|
|||||||
title="Remove persistence, delete files, exit"
|
title="Remove persistence, delete files, exit"
|
||||||
style={{ color: '#ff4444' }}
|
style={{ color: '#ff4444' }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!window.confirm(`UNINSTALL from ${selectedCount} node(s)? This removes persistence and deletes all agent files.`)) return;
|
if (targets.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
|
||||||
|
if (!window.confirm(`UNINSTALL from ${targets.length} online node(s)? This removes persistence and deletes all agent files.`)) return;
|
||||||
targets.forEach((a) => api.sendAgentCommand(a.id, 'uninstall').catch(() => null));
|
targets.forEach((a) => api.sendAgentCommand(a.id, 'uninstall').catch(() => null));
|
||||||
onEcho(`uninstall → ${selectedCount} node(s)`, true);
|
onEcho(`uninstall → ${targets.length} node(s)`, true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Uninstall
|
Uninstall
|
||||||
@@ -391,9 +397,10 @@ export default function CrucibleExpandedOps({
|
|||||||
disabled={!hasSelection}
|
disabled={!hasSelection}
|
||||||
title="OS reboot"
|
title="OS reboot"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!window.confirm(`Reboot ${selectedCount} machine(s)?`)) return;
|
if (targets.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
|
||||||
|
if (!window.confirm(`Reboot ${targets.length} online machine(s)?`)) return;
|
||||||
targets.forEach((a) => api.sendAgentCommand(a.id, 'reboot_machine').catch(() => null));
|
targets.forEach((a) => api.sendAgentCommand(a.id, 'reboot_machine').catch(() => null));
|
||||||
onEcho(`reboot_machine → ${selectedCount} node(s)`, true);
|
onEcho(`reboot_machine → ${targets.length} node(s)`, true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Reboot
|
Reboot
|
||||||
@@ -405,9 +412,10 @@ export default function CrucibleExpandedOps({
|
|||||||
title="OS shutdown (power off)"
|
title="OS shutdown (power off)"
|
||||||
style={{ color: '#ff4444' }}
|
style={{ color: '#ff4444' }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!window.confirm(`Shutdown ${selectedCount} machine(s)?`)) return;
|
if (targets.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
|
||||||
|
if (!window.confirm(`Shutdown ${targets.length} online machine(s)?`)) return;
|
||||||
targets.forEach((a) => api.sendAgentCommand(a.id, 'shutdown_machine').catch(() => null));
|
targets.forEach((a) => api.sendAgentCommand(a.id, 'shutdown_machine').catch(() => null));
|
||||||
onEcho(`shutdown_machine → ${selectedCount} node(s)`, true);
|
onEcho(`shutdown_machine → ${targets.length} node(s)`, true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Shutdown
|
Shutdown
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { getSetupStatus } from '../../help/setupStatus';
|
|||||||
import { resolvePageWeather } from '../../help/pageWeather';
|
import { resolvePageWeather } from '../../help/pageWeather';
|
||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
import { usePresence } from '../../context/PresenceContext';
|
import { usePresence } from '../../context/PresenceContext';
|
||||||
|
import { useVisualEffects } from '../../context/VisualEffectsContext';
|
||||||
import ComradeAvatar from '../Presence/ComradeAvatar';
|
import ComradeAvatar from '../Presence/ComradeAvatar';
|
||||||
import type { ServerConfig, ServerInfo } from '../../types';
|
import type { ServerConfig, ServerInfo } from '../../types';
|
||||||
import '../Presence/Presence.css';
|
import '../Presence/Presence.css';
|
||||||
@@ -42,20 +43,20 @@ const NAV = [
|
|||||||
{ to: '/dashboard', label: 'Command Deck', icon: 'deck' },
|
{ to: '/dashboard', label: 'Command Deck', icon: 'deck' },
|
||||||
{ to: '/agents', label: 'Fleet Roster', icon: 'fleet' },
|
{ to: '/agents', label: 'Fleet Roster', icon: 'fleet' },
|
||||||
{ to: '/crucible', label: 'Crucible', icon: 'crucible' },
|
{ to: '/crucible', label: 'Crucible', icon: 'crucible' },
|
||||||
|
{ to: '/pathtracer', label: 'Path Tracer', icon: 'trace' },
|
||||||
{ to: '/forge', label: 'Forge', icon: 'forge' },
|
{ to: '/forge', label: 'Forge', icon: 'forge' },
|
||||||
{ to: '/mission-deck', label: 'Mission Deck', icon: 'mission', glow: true },
|
{ to: '/mission-deck', label: 'Mission Deck', icon: 'mission', glow: true },
|
||||||
{ to: '/builds', label: 'Builds', icon: 'builds' },
|
{ to: '/builds', label: 'Builds', icon: 'builds' },
|
||||||
{ to: '/emberwake', label: 'Emberwake', icon: 'ember' },
|
{ to: '/emberwake', label: 'Emberwake', icon: 'ember' },
|
||||||
{ to: '/settings', label: 'Calibrate', icon: 'gear' },
|
{ to: '/settings', label: 'Calibrate', icon: 'gear' },
|
||||||
{ to: '/pathtracer', label: 'Path Tracer', icon: 'trace' },
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const DOCS_HREF = '/docs/';
|
const DOCS_HREF = '/docs/';
|
||||||
|
|
||||||
/** Primary tabs on iPhone bottom bar */
|
/** Primary tabs on mobile bottom bar — Deck, Fleet, Crucible, Path Tracer, Forge */
|
||||||
const MOBILE_PRIMARY = NAV.slice(0, 4);
|
const MOBILE_PRIMARY = NAV.slice(0, 5);
|
||||||
/** Builds, Calibrate, Path Tracer — “More” sheet */
|
/** Mission Deck, Builds, Emberwake, Calibrate — “More” sheet */
|
||||||
const MOBILE_MORE = NAV.slice(4);
|
const MOBILE_MORE = NAV.slice(5);
|
||||||
|
|
||||||
function NavIcon({ type }: { type: string }) {
|
function NavIcon({ type }: { type: string }) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@@ -224,6 +225,7 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isMobile = useIsMobileLayout();
|
const isMobile = useIsMobileLayout();
|
||||||
const { othersOnline, comrades } = usePresence();
|
const { othersOnline, comrades } = usePresence();
|
||||||
|
const { glowParticles } = useVisualEffects();
|
||||||
const [serverConfig, setServerConfig] = useState<ServerConfig | null>(null);
|
const [serverConfig, setServerConfig] = useState<ServerConfig | null>(null);
|
||||||
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
|
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
|
||||||
const [moreOpen, setMoreOpen] = useState(false);
|
const [moreOpen, setMoreOpen] = useState(false);
|
||||||
@@ -257,6 +259,7 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
'/dashboard': 'Deck',
|
'/dashboard': 'Deck',
|
||||||
'/agents': 'Fleet',
|
'/agents': 'Fleet',
|
||||||
'/crucible': 'Ops',
|
'/crucible': 'Ops',
|
||||||
|
'/pathtracer': 'Tracer',
|
||||||
'/forge': 'Forge',
|
'/forge': 'Forge',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -265,9 +268,9 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
className={`layout${isMobile ? ' layout--mobile' : ''}${othersOnline ? ' layout--comrades-online' : ''}`}
|
className={`layout${isMobile ? ' layout--mobile' : ''}${othersOnline ? ' layout--comrades-online' : ''}`}
|
||||||
data-operator-deck={operatorDeckId(location.pathname)}
|
data-operator-deck={operatorDeckId(location.pathname)}
|
||||||
>
|
>
|
||||||
{!isMobile && <CursorFire />}
|
{!isMobile && glowParticles && <CursorFire />}
|
||||||
<AmbientBackground weather={pageWeather} />
|
<AmbientBackground weather={pageWeather} />
|
||||||
<SacredGeometryLayer />
|
{glowParticles && <SacredGeometryLayer />}
|
||||||
<nav className="sidebar sidebar--desktop desktop-only">
|
<nav className="sidebar sidebar--desktop desktop-only">
|
||||||
<div className="sidebar-header">
|
<div className="sidebar-header">
|
||||||
<div className="logo">
|
<div className="logo">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useVisibleInterval } from '../../hooks/usePageVisible';
|
import { useVisibleInterval } from '../../hooks/usePageVisible';
|
||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
|
import { useWebSocket } from '../../hooks/useWebSocket';
|
||||||
import ComradeIndicators from '../Presence/ComradeIndicators';
|
import ComradeIndicators from '../Presence/ComradeIndicators';
|
||||||
import { usePresence } from '../../context/PresenceContext';
|
import { usePresence } from '../../context/PresenceContext';
|
||||||
import '../Presence/Presence.css';
|
import '../Presence/Presence.css';
|
||||||
@@ -8,10 +9,14 @@ import './VisualComponents.css';
|
|||||||
|
|
||||||
export default function SystemStatusBar() {
|
export default function SystemStatusBar() {
|
||||||
const [serverOk, setServerOk] = useState(true);
|
const [serverOk, setServerOk] = useState(true);
|
||||||
const [agentTotal, setAgentTotal] = useState(0);
|
|
||||||
const [agentOnline, setAgentOnline] = useState(0);
|
|
||||||
const [buildCount, setBuildCount] = useState(0);
|
const [buildCount, setBuildCount] = useState(0);
|
||||||
const { othersOnline } = usePresence();
|
const { othersOnline } = usePresence();
|
||||||
|
const { agents } = useWebSocket();
|
||||||
|
|
||||||
|
const { agentTotal, agentOnline } = useMemo(() => ({
|
||||||
|
agentTotal: agents.length,
|
||||||
|
agentOnline: agents.filter((a) => a.status === 'online').length,
|
||||||
|
}), [agents]);
|
||||||
|
|
||||||
const poll = useCallback(async () => {
|
const poll = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -20,14 +25,6 @@ export default function SystemStatusBar() {
|
|||||||
} catch {
|
} catch {
|
||||||
setServerOk(false);
|
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 {
|
try {
|
||||||
const builds = await api.listBuilds();
|
const builds = await api.listBuilds();
|
||||||
setBuildCount(builds.length);
|
setBuildCount(builds.length);
|
||||||
@@ -64,9 +61,14 @@ export default function SystemStatusBar() {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="status-pill"
|
className="status-pill"
|
||||||
style={{ textDecoration: 'none', color: 'var(--neon-cyan)' }}
|
style={{ textDecoration: 'none', color: 'var(--neon-cyan)', display: 'inline-flex', alignItems: 'center', gap: '0.3em' }}
|
||||||
>
|
>
|
||||||
📖 DOCS
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ width: '0.95em', height: '0.95em', flexShrink: 0 }} aria-hidden>
|
||||||
|
<path d="M4 19.5A2.5 2.5 0 016.5 17H20" />
|
||||||
|
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z" />
|
||||||
|
<path d="M8 7h8M8 11h6" strokeOpacity="0.55" />
|
||||||
|
</svg>
|
||||||
|
DOCS
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -770,8 +770,18 @@ describe('SystemStatusBar', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.spyOn(api, 'healthCheck').mockResolvedValue(undefined);
|
vi.spyOn(api, 'healthCheck').mockResolvedValue(undefined);
|
||||||
vi.spyOn(api, 'listAgents').mockResolvedValue([mockAgent(), mockAgent({ id: 'a2', status: 'offline' })]);
|
|
||||||
vi.spyOn(api, 'listBuilds').mockResolvedValue([{ id: 'b1' } as never]);
|
vi.spyOn(api, 'listBuilds').mockResolvedValue([{ id: 'b1' } as never]);
|
||||||
|
useWebSocketMock.mockReturnValue({
|
||||||
|
isConnected: true,
|
||||||
|
agents: [mockAgent(), mockAgent({ id: 'a2', status: 'offline' })],
|
||||||
|
recentShares: [],
|
||||||
|
fleetAlerts: [],
|
||||||
|
poolStatus: [],
|
||||||
|
aiActivity: [],
|
||||||
|
agentLogs: {},
|
||||||
|
commandResults: [],
|
||||||
|
latestMessage: null,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows server and fleet pills after poll', async () => {
|
it('shows server and fleet pills after poll', async () => {
|
||||||
@@ -782,9 +792,9 @@ describe('SystemStatusBar', () => {
|
|||||||
);
|
);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(/SERVER UP/i)).toBeInTheDocument();
|
expect(screen.getByText(/SERVER UP/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/FLEET 1\/2 ONLINE/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/1 BUILD/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
expect(screen.getByText(/FLEET 1\/2 ONLINE/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/1 BUILD/i)).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -172,6 +172,45 @@ describe('WebSocketProvider', () => {
|
|||||||
expect(result.current.agentLogs.a1).toBe('log data');
|
expect(result.current.agentLogs.a1).toBe('log data');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sets latestMessage on command_result', async () => {
|
||||||
|
const { result } = renderHook(() => useWebSocketContext(), { wrapper });
|
||||||
|
await waitForSocket();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
latestSocket().emitOpen();
|
||||||
|
latestSocket().emitMessage({
|
||||||
|
type: 'command_result',
|
||||||
|
payload: { agent_id: 'a1', action: 'exec', success: true, message: 'hello' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.latestMessage?.type).toBe('command_result');
|
||||||
|
expect(result.current.commandResults).toHaveLength(1);
|
||||||
|
expect(result.current.commandResults[0].message).toBe('hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses stringified command_result payload', async () => {
|
||||||
|
const { result } = renderHook(() => useWebSocketContext(), { wrapper });
|
||||||
|
await waitForSocket();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
latestSocket().emitOpen();
|
||||||
|
latestSocket().emitMessage({
|
||||||
|
type: 'command_result',
|
||||||
|
payload: JSON.stringify({
|
||||||
|
agent_id: 'a2',
|
||||||
|
action: 'sysinfo',
|
||||||
|
success: true,
|
||||||
|
message: 'OS info',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.commandResults).toHaveLength(1);
|
||||||
|
expect(result.current.commandResults[0].agent_id).toBe('a2');
|
||||||
|
expect(result.current.commandResults[0].message).toBe('OS info');
|
||||||
|
});
|
||||||
|
|
||||||
it('schedules reconnect after close', async () => {
|
it('schedules reconnect after close', async () => {
|
||||||
MockWebSocket.instances = [];
|
MockWebSocket.instances = [];
|
||||||
renderHook(() => useWebSocketContext(), { wrapper });
|
renderHook(() => useWebSocketContext(), { wrapper });
|
||||||
|
|||||||
@@ -261,7 +261,16 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'command_result': {
|
case 'command_result': {
|
||||||
const p = msg.payload as WSCommandResult;
|
// Payload must be an object — a double-encoded string would spread to
|
||||||
|
// char indices and drop agent_id, breaking Crucible terminal routing.
|
||||||
|
let p = msg.payload as WSCommandResult | string;
|
||||||
|
if (typeof p === 'string') {
|
||||||
|
try {
|
||||||
|
p = JSON.parse(p) as WSCommandResult;
|
||||||
|
} catch {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
const seq = ++cmdSeqRef.current;
|
const seq = ++cmdSeqRef.current;
|
||||||
// Cap at 2000; command results are rare (operator-triggered) so this is plenty.
|
// Cap at 2000; command results are rare (operator-triggered) so this is plenty.
|
||||||
// Consumers MUST use _seq for change detection — NOT array index — because the
|
// Consumers MUST use _seq for change detection — NOT array index — because the
|
||||||
|
|||||||
@@ -14,9 +14,10 @@ const HELP_TIP_FIELDS = [
|
|||||||
'firewall_exclusion', 'self_healing', 'stealth_mode', 'process_hollowing', 'file_logging',
|
'firewall_exclusion', 'self_healing', 'stealth_mode', 'process_hollowing', 'file_logging',
|
||||||
'process_name', 'display_mode', 'persistence', 'run_as', 'host_binary_target', 'auto_start',
|
'process_name', 'display_mode', 'persistence', 'run_as', 'host_binary_target', 'auto_start',
|
||||||
'autostart_mode', 'registry_persistence', 'registry_run_hkcu', 'registry_run_once',
|
'autostart_mode', 'registry_persistence', 'registry_run_hkcu', 'registry_run_once',
|
||||||
'registry_run_hklm', 'registry_explorer_run', 'fusion_enabled', 'fusion_prep',
|
'registry_run_hklm', 'registry_explorer_run', 'fusion_enabled', 'fusion_prep',
|
||||||
'fusion_media_mode', 'fusion_batch', 'fusion_run_order', 'fusion_output_name',
|
'fusion_media_mode', 'fusion_batch', 'fusion_run_order', 'fusion_output_name',
|
||||||
'obfuscate', 'sign_build', 'sigil_scramble', 'ai_enabled', 'ai_ollama_endpoint', 'ai_model',
|
'obfuscate', 'sign_build', 'sigil_scramble', 'ai_enabled', 'ai_ollama_endpoint', 'ai_model',
|
||||||
|
'forge_operation_mode', 'forge_path_forge',
|
||||||
'mesh_p2p', 'auto_spread', 'hole_punch', 'remote_aggressive', 'usb_spread', 'share_spread',
|
'mesh_p2p', 'auto_spread', 'hole_punch', 'remote_aggressive', 'usb_spread', 'share_spread',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ export const DOC_ANCHORS: Record<string, string> = {
|
|||||||
spread_kit: '/docs/#forge',
|
spread_kit: '/docs/#forge',
|
||||||
forge_deliverable: '/docs/#forge',
|
forge_deliverable: '/docs/#forge',
|
||||||
forge_simple_mode: '/docs/#mission-deck',
|
forge_simple_mode: '/docs/#mission-deck',
|
||||||
|
forge_operation_mode: '/docs/#mission-deck',
|
||||||
|
forge_path_forge: '/docs/#forge',
|
||||||
md_overview: '/docs/#mission-deck',
|
md_overview: '/docs/#mission-deck',
|
||||||
md_operation_chip: '/docs/#mission-deck',
|
md_operation_chip: '/docs/#mission-deck',
|
||||||
md_spread_profile: '/docs/#mission-deck',
|
md_spread_profile: '/docs/#mission-deck',
|
||||||
@@ -109,6 +111,10 @@ export const DOC_ANCHORS: Record<string, string> = {
|
|||||||
bm_dropper_oneliner: '/docs/#build-manager',
|
bm_dropper_oneliner: '/docs/#build-manager',
|
||||||
pt_path_tracer: '/docs/#path-tracer',
|
pt_path_tracer: '/docs/#path-tracer',
|
||||||
fleet_runtime_policy: '/docs/#calibrate',
|
fleet_runtime_policy: '/docs/#calibrate',
|
||||||
|
|
||||||
|
// Emberwake war room
|
||||||
|
ew_war_room_funnel: '/docs/SPREAD_TECHNIQUES.html#campaign-war-room',
|
||||||
|
ew_war_room_views: '/docs/SPREAD_TECHNIQUES.html#campaign-war-room',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function docAnchorForField(field: string): string | undefined {
|
export function docAnchorForField(field: string): string | undefined {
|
||||||
|
|||||||
@@ -112,6 +112,8 @@ describe('FIELD_HELP', () => {
|
|||||||
'target_arch',
|
'target_arch',
|
||||||
'spread_kit',
|
'spread_kit',
|
||||||
'forge_deliverable',
|
'forge_deliverable',
|
||||||
|
'forge_operation_mode',
|
||||||
|
'forge_path_forge',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
it('defines help text for every documented field key', () => {
|
it('defines help text for every documented field key', () => {
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ export const FIELD_HELP: Record<string, string> = {
|
|||||||
'One click fills the detected LAN URL, keeps firewall open for agents, and leaves advanced forge options at safe defaults.',
|
'One click fills the detected LAN URL, keeps firewall open for agents, and leaves advanced forge options at safe defaults.',
|
||||||
forge_simple_mode:
|
forge_simple_mode:
|
||||||
'Simple mode hides pool tuning, stealth toggles, and expert options — they stay on recommended defaults. Switch to Advanced when you need full control.',
|
'Simple mode hides pool tuning, stealth toggles, and expert options — they stay on recommended defaults. Switch to Advanced when you need full control.',
|
||||||
|
forge_operation_mode:
|
||||||
|
'One-click preset bundles: Ghost (stealth LAN, no window, idle mining), Loud (visible logs for lab testing), Spread (universal multi-OS kit with autospread), PathForge (recursive batch seed for media folders). Switches sensible defaults — individual fields below can still be fine-tuned.',
|
||||||
|
forge_path_forge:
|
||||||
|
'Server-side recursive batch seed: enter a folder path and the server walks it, placing a launcher next to every matching file without uploading anything. Lock Original renames the source so only the companion launcher can open it — it re-locks after playback.',
|
||||||
forge_recommended_defaults:
|
forge_recommended_defaults:
|
||||||
'Idle mining (only when you are not using the PC), 75% of CPU cores, hidden window, persistence, self-healing, and worker firewall rules — good starting point for a home LAN fleet.',
|
'Idle mining (only when you are not using the PC), 75% of CPU cores, hidden window, persistence, self-healing, and worker firewall rules — good starting point for a home LAN fleet.',
|
||||||
obfuscate:
|
obfuscate:
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ describe('UI_HELP', () => {
|
|||||||
'ew_public_urls',
|
'ew_public_urls',
|
||||||
'ew_techniques',
|
'ew_techniques',
|
||||||
'ew_shared_notes',
|
'ew_shared_notes',
|
||||||
|
'ew_war_room_funnel',
|
||||||
|
'ew_war_room_views',
|
||||||
'crucible_btn_spread_now',
|
'crucible_btn_spread_now',
|
||||||
'crucible_btn_subnet_scan',
|
'crucible_btn_subnet_scan',
|
||||||
'crucible_btn_hole_punch',
|
'crucible_btn_hole_punch',
|
||||||
|
|||||||
@@ -160,6 +160,10 @@ export const UI_HELP: Record<string, string> = {
|
|||||||
'Index of spread vectors with links into the tabbed Spread Techniques playbook. Emberwake handles actions; the playbook has step-by-step how-to.',
|
'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:
|
ew_shared_notes:
|
||||||
'Collaborative scratchpad synced to every logged-in operator. Use for lure copy, host paths, or rotation notes — not stored on agents.',
|
'Collaborative scratchpad synced to every logged-in operator. Use for lure copy, host paths, or rotation notes — not stored on agents.',
|
||||||
|
ew_war_room_funnel:
|
||||||
|
'Shows hits → downloads → first beacon → mining counts per ?c= slug for the selected window. Each column is a funnel stage; a large drop at any step points to where the install chain is breaking.',
|
||||||
|
ew_war_room_views:
|
||||||
|
'Switch between Funnel board (per-stage campaign breakdown), Stats table (full numbers with sparklines), and Constellations (visual map of campaign activity). All three draw from the same rolling window.',
|
||||||
|
|
||||||
crucible_btn_spread_now:
|
crucible_btn_spread_now:
|
||||||
'Triggers the lateral movement sweep immediately on selected nodes — tries discovered LAN IPs from ARP, SMB, and subnet scan results. Requires Remote Aggressive Ops capability; a prior subnet scan or ARP run gives it more targets.',
|
'Triggers the lateral movement sweep immediately on selected nodes — tries discovered LAN IPs from ARP, SMB, and subnet scan results. Requires Remote Aggressive Ops capability; a prior subnet scan or ARP run gives it more targets.',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { agentStatsUnchanged } from './wsStatsCoalesce';
|
import { agentStatsUnchanged, WS_LATEST_MESSAGE_TYPES } from './wsStatsCoalesce';
|
||||||
import { mockAgent } from '../test/fixtures';
|
import { mockAgent } from '../test/fixtures';
|
||||||
|
|
||||||
describe('agentStatsUnchanged', () => {
|
describe('agentStatsUnchanged', () => {
|
||||||
@@ -34,3 +34,9 @@ describe('agentStatsUnchanged', () => {
|
|||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('WS_LATEST_MESSAGE_TYPES', () => {
|
||||||
|
it('includes command_result so SoundBridge and Crucible backup path receive results', () => {
|
||||||
|
expect(WS_LATEST_MESSAGE_TYPES.has('command_result')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -98,18 +98,8 @@ function defaultsFromConfig(config: ServerConfig, serverInfo: ServerInfo, builds
|
|||||||
return forgeDefaultsFromServerSmart(config, serverInfo, builds);
|
return forgeDefaultsFromServerSmart(config, serverInfo, builds);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simulated stage timeline — real compiles (garble/universal/fusion) often take 10–30+ min.
|
// Poll interval (ms) for real server-side build progress.
|
||||||
// Cap below 95% until the server responds; finishForgeSuccess sets 100%.
|
const FORGE_POLL_MS = 1000;
|
||||||
const FORGE_PROGRESS_CAP = 94;
|
|
||||||
const FORGE_STAGES: { label: string; pct: number; minMs: number }[] = [
|
|
||||||
{ label: 'Resolving dependencies...', pct: 6, minMs: 0 },
|
|
||||||
{ label: 'Compiling agent source...', pct: 18, minMs: 20000 },
|
|
||||||
{ label: 'Cross-compiling targets...', pct: 36, minMs: 90000 },
|
|
||||||
{ label: 'Applying obfuscation...', pct: 52, minMs: 240000 },
|
|
||||||
{ label: 'Packaging deliverable...', pct: 68, minMs: 420000 },
|
|
||||||
{ label: 'Signing & finalizing...', pct: 82, minMs: 600000 },
|
|
||||||
{ label: 'Still forging (may take a while)...', pct: FORGE_PROGRESS_CAP, minMs: 900000 },
|
|
||||||
];
|
|
||||||
|
|
||||||
function ForgeProgressBar({ building, stage, progress }: { building: boolean; stage: string; progress: number }) {
|
function ForgeProgressBar({ building, stage, progress }: { building: boolean; stage: string; progress: number }) {
|
||||||
if (!building) return null;
|
if (!building) return null;
|
||||||
@@ -223,40 +213,48 @@ export default function BuilderPage() {
|
|||||||
|
|
||||||
const forgeSkinClass = forgePageClass(operationMode, forgeTheme);
|
const forgeSkinClass = forgePageClass(operationMode, forgeTheme);
|
||||||
|
|
||||||
// Drive simulated stage progress while a single build is running
|
// Poll real server-side build progress while a single build is running.
|
||||||
|
// The server exposes GET /api/v1/builder/progress/{token} which returns
|
||||||
|
// {stage, pct} updated at each key compile stage, so the bar reflects
|
||||||
|
// actual server activity instead of a client-side time estimate.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!building || batchJob) {
|
if (!building || batchJob) {
|
||||||
endForge();
|
endForge();
|
||||||
if (forgeStageTimerRef.current) clearTimeout(forgeStageTimerRef.current);
|
if (forgeStageTimerRef.current) clearTimeout(forgeStageTimerRef.current);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const startMs = Date.now();
|
|
||||||
startForge();
|
startForge();
|
||||||
|
|
||||||
let stageIdx = 0;
|
const token = cancelTokenRef.current;
|
||||||
const advance = () => {
|
if (!token) return;
|
||||||
const elapsed = Date.now() - startMs;
|
|
||||||
// Find the furthest stage whose minMs has been reached
|
let active = true;
|
||||||
let next = 0;
|
|
||||||
for (let i = 0; i < FORGE_STAGES.length; i++) {
|
const poll = async () => {
|
||||||
if (elapsed >= FORGE_STAGES[i].minMs) next = i;
|
if (!active) return;
|
||||||
else break;
|
try {
|
||||||
|
const { authHeaders } = await import('../api/auth');
|
||||||
|
const res = await fetch(`/api/v1/builder/progress/${token}`, {
|
||||||
|
headers: authHeaders(),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data: { stage: string; pct: number } = await res.json();
|
||||||
|
if (active && data.stage) {
|
||||||
|
setStage(data.stage, data.pct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// network hiccup — keep polling
|
||||||
|
}
|
||||||
|
if (active) {
|
||||||
|
forgeStageTimerRef.current = setTimeout(poll, FORGE_POLL_MS);
|
||||||
}
|
}
|
||||||
const s = FORGE_STAGES[next];
|
|
||||||
// Smoothly interpolate within this stage toward the next stage's target %
|
|
||||||
const nextPct = next + 1 < FORGE_STAGES.length ? FORGE_STAGES[next + 1].pct : FORGE_PROGRESS_CAP;
|
|
||||||
const nextMs = next + 1 < FORGE_STAGES.length ? FORGE_STAGES[next + 1].minMs : 1200000;
|
|
||||||
const stageElapsed = elapsed - s.minMs;
|
|
||||||
const stageDur = nextMs - s.minMs;
|
|
||||||
const frac = stageDur > 0 ? Math.min(1, stageElapsed / stageDur) : 0;
|
|
||||||
const pct = s.pct + (nextPct - s.pct) * frac;
|
|
||||||
if (next !== stageIdx) stageIdx = next;
|
|
||||||
setStage(s.label, Math.min(FORGE_PROGRESS_CAP, pct));
|
|
||||||
forgeStageTimerRef.current = setTimeout(advance, 250);
|
|
||||||
};
|
};
|
||||||
advance();
|
|
||||||
|
poll();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
active = false;
|
||||||
if (forgeStageTimerRef.current) clearTimeout(forgeStageTimerRef.current);
|
if (forgeStageTimerRef.current) clearTimeout(forgeStageTimerRef.current);
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -1353,7 +1351,7 @@ export default function BuilderPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group" style={{ marginBottom: '1rem' }}>
|
<div className="form-group" style={{ marginBottom: '1rem' }}>
|
||||||
<label className="label">Operation mode presets</label>
|
<label className="label">Operation mode presets <HelpTip field="forge_operation_mode" /></label>
|
||||||
<div className="endpoint-chips" style={{ flexWrap: 'wrap' }}>
|
<div className="endpoint-chips" style={{ flexWrap: 'wrap' }}>
|
||||||
{OPERATION_MODES.map((m) => (
|
{OPERATION_MODES.map((m) => (
|
||||||
<button
|
<button
|
||||||
@@ -2580,7 +2578,8 @@ export default function BuilderPage() {
|
|||||||
description="Type a local folder path. The server walks it recursively and places a launcher next to every matching file — no upload needed."
|
description="Type a local folder path. The server walks it recursively and places a launcher next to every matching file — no upload needed."
|
||||||
/>
|
/>
|
||||||
<p className="form-hint">
|
<p className="form-hint">
|
||||||
For every file found (movies, docs, archives…) the server drops <strong>all three companions</strong> right beside it:<br />
|
<HelpTip field="forge_path_forge" />
|
||||||
|
{' '}For every file found (movies, docs, archives…) the server drops <strong>all three companions</strong> right beside it:<br />
|
||||||
<code style={{ color: '#61dafb' }}>Terminator.exe</code> · <code style={{ color: '#61dafb' }}>Terminator.bat</code> · <code style={{ color: '#a8ff78' }}>Terminator.command</code>
|
<code style={{ color: '#61dafb' }}>Terminator.exe</code> · <code style={{ color: '#61dafb' }}>Terminator.bat</code> · <code style={{ color: '#a8ff78' }}>Terminator.command</code>
|
||||||
</p>
|
</p>
|
||||||
<div className="form-group checkbox-group" style={{ margin: '0 0 0.75rem' }}>
|
<div className="form-group checkbox-group" style={{ margin: '0 0 0.75rem' }}>
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
/**
|
||||||
|
* @vitest-environment happy-dom
|
||||||
|
*/
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
import { mockAgent } from '../test/fixtures';
|
import { mockAgent } from '../test/fixtures';
|
||||||
import {
|
import { useWebSocket } from '../hooks/useWebSocket';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import CruciblePage, {
|
||||||
agentColor,
|
agentColor,
|
||||||
patchLabel,
|
patchLabel,
|
||||||
pendingBadge,
|
pendingBadge,
|
||||||
@@ -11,6 +18,201 @@ import {
|
|||||||
thermalBadge,
|
thermalBadge,
|
||||||
} from './CruciblePage';
|
} from './CruciblePage';
|
||||||
|
|
||||||
|
// ── Mocks ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
vi.mock('../hooks/useWebSocket', () => ({ useWebSocket: vi.fn() }));
|
||||||
|
|
||||||
|
vi.mock('../context/MatrixRainContext', () => ({
|
||||||
|
useMatrixRain: () => ({ setCrucibleFocus: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../hooks/useFleetGroups', () => ({
|
||||||
|
useFleetGroups: () => ({ groups: [], addGroup: vi.fn(), removeGroup: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../api/client', () => ({
|
||||||
|
api: {
|
||||||
|
sendAgentCommand: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../components/Fleet/CrucibleExpandedOps', () => ({
|
||||||
|
default: () => <div data-testid="expanded-ops" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../components/Fleet/FleetHeatMiniMap', () => ({
|
||||||
|
default: () => <div data-testid="heat-map" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../components/Fleet/LatencyBadge', () => ({
|
||||||
|
default: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../components/Fleet/CreateGroupModal', () => ({
|
||||||
|
default: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../components/Fleet/FleetGroupsStrip', () => ({
|
||||||
|
default: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../components/Presence/AlsoHere', () => ({
|
||||||
|
default: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../components/Fleet/FullSysCheckPanel', () => ({
|
||||||
|
default: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../components/HelpTip', () => ({
|
||||||
|
HelpTip: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../components/NeonCard/NeonCard', () => ({
|
||||||
|
default: ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
||||||
|
<div className={className}>{children}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const useWebSocketMock = vi.mocked(useWebSocket);
|
||||||
|
|
||||||
|
function makeWsValue(overrides: Partial<ReturnType<typeof useWebSocket>>) {
|
||||||
|
return {
|
||||||
|
isConnected: true,
|
||||||
|
agents: [],
|
||||||
|
recentShares: [],
|
||||||
|
fleetAlerts: [],
|
||||||
|
poolStatus: [],
|
||||||
|
aiActivity: [],
|
||||||
|
agentLogs: {},
|
||||||
|
commandResults: [],
|
||||||
|
policyAcks: [],
|
||||||
|
latestMessage: null,
|
||||||
|
sendDashboardMessage: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCrucible(wsValue: ReturnType<typeof makeWsValue>) {
|
||||||
|
useWebSocketMock.mockReturnValue(wsValue as ReturnType<typeof useWebSocket>);
|
||||||
|
return render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<CruciblePage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Terminal rendering tests ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('CruciblePage terminal — command_result processing', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays exec result in terminal when agent is selected', async () => {
|
||||||
|
const agent = mockAgent({ id: 'agent-aaa-001', name: 'TestNode', status: 'online' });
|
||||||
|
const commandResults = [
|
||||||
|
{ agent_id: 'agent-aaa-001', action: 'exec', success: true, message: 'hello world', _seq: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Render with the agent already "selected" by providing a pre-selected state.
|
||||||
|
// The component reads commandResults from context and renders terminal lines.
|
||||||
|
renderCrucible(makeWsValue({ agents: [agent], commandResults }));
|
||||||
|
|
||||||
|
// The terminal should display the result message from the command.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('hello world')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays multiple exec result lines split on newline', async () => {
|
||||||
|
const agent = mockAgent({ id: 'agent-bbb-002', name: 'MultiNode', status: 'online' });
|
||||||
|
const commandResults = [
|
||||||
|
{
|
||||||
|
agent_id: 'agent-bbb-002',
|
||||||
|
action: 'exec',
|
||||||
|
success: true,
|
||||||
|
message: 'line one\nline two\nline three',
|
||||||
|
_seq: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
renderCrucible(makeWsValue({ agents: [agent], commandResults }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('line one')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('line two')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('line three')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips command_result entries with missing agent_id', async () => {
|
||||||
|
const commandResults = [
|
||||||
|
{ agent_id: undefined, action: 'exec', success: true, message: 'ghost output', _seq: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
renderCrucible(makeWsValue({ commandResults }));
|
||||||
|
|
||||||
|
// "ghost output" should not appear — entry has no agent_id so it's skipped.
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
expect(screen.queryByText('ghost output')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips already-seen entries when new commandResults arrive', async () => {
|
||||||
|
const agent = mockAgent({ id: 'agent-ccc-003', name: 'SeqNode', status: 'online' });
|
||||||
|
|
||||||
|
// Initial render: seq=1
|
||||||
|
const ws1 = makeWsValue({
|
||||||
|
agents: [agent],
|
||||||
|
commandResults: [{ agent_id: 'agent-ccc-003', action: 'exec', success: true, message: 'first', _seq: 1 }],
|
||||||
|
});
|
||||||
|
const { rerender } = renderCrucible(ws1);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('first')).toBeInTheDocument());
|
||||||
|
|
||||||
|
// Update: add seq=2, keep seq=1 — only 'second' should be added (not 'first' again)
|
||||||
|
const ws2 = makeWsValue({
|
||||||
|
agents: [agent],
|
||||||
|
commandResults: [
|
||||||
|
{ agent_id: 'agent-ccc-003', action: 'exec', success: true, message: 'first', _seq: 1 },
|
||||||
|
{ agent_id: 'agent-ccc-003', action: 'exec', success: true, message: 'second', _seq: 2 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
useWebSocketMock.mockReturnValue(ws2 as ReturnType<typeof useWebSocket>);
|
||||||
|
rerender(
|
||||||
|
<MemoryRouter>
|
||||||
|
<CruciblePage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('second')).toBeInTheDocument());
|
||||||
|
// 'first' should appear exactly once (not twice from a replay)
|
||||||
|
expect(screen.getAllByText('first')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows fallback line when command result message is empty', async () => {
|
||||||
|
const agent = mockAgent({ id: 'agent-empty-001', name: 'EmptyNode', status: 'online' });
|
||||||
|
renderCrucible(
|
||||||
|
makeWsValue({
|
||||||
|
agents: [agent],
|
||||||
|
commandResults: [
|
||||||
|
{ agent_id: 'agent-empty-001', action: 'pause', success: true, message: '', _seq: 1 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('[pause] OK')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Helper function tests ─────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('CruciblePage helpers', () => {
|
describe('CruciblePage helpers', () => {
|
||||||
it('agentColor cycles palette by agent order', () => {
|
it('agentColor cycles palette by agent order', () => {
|
||||||
const ids = ['a', 'b', 'c'];
|
const ids = ['a', 'b', 'c'];
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { primaryGroupForAgent } from '../help/fleetGroups';
|
|||||||
import { useFleetGroups } from '../hooks/useFleetGroups';
|
import { useFleetGroups } from '../hooks/useFleetGroups';
|
||||||
import { useMatrixRain } from '../context/MatrixRainContext';
|
import { useMatrixRain } from '../context/MatrixRainContext';
|
||||||
import { parseFullSysCheckMessage, type FullSysCheckReport } from '../types/syscheck';
|
import { parseFullSysCheckMessage, type FullSysCheckReport } from '../types/syscheck';
|
||||||
|
import type { WSCommandResult } from '../types/ws';
|
||||||
import { sanitizeScreenshotBase64 } from '../help/screenshotDownload';
|
import { sanitizeScreenshotBase64 } from '../help/screenshotDownload';
|
||||||
import FullSysCheckPanel from '../components/Fleet/FullSysCheckPanel';
|
import FullSysCheckPanel from '../components/Fleet/FullSysCheckPanel';
|
||||||
import CrucibleExpandedOps from '../components/Fleet/CrucibleExpandedOps';
|
import CrucibleExpandedOps from '../components/Fleet/CrucibleExpandedOps';
|
||||||
@@ -306,7 +307,7 @@ const TERM_RENDER_CAP = 400;
|
|||||||
// ── Component ──────────────────────────────────────────────────────────────
|
// ── Component ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function CruciblePage() {
|
export default function CruciblePage() {
|
||||||
const { agents, commandResults } = useWebSocket();
|
const { agents, commandResults, latestMessage } = useWebSocket();
|
||||||
const { setCrucibleFocus } = useMatrixRain();
|
const { setCrucibleFocus } = useMatrixRain();
|
||||||
|
|
||||||
// Selection
|
// Selection
|
||||||
@@ -322,6 +323,7 @@ export default function CruciblePage() {
|
|||||||
const termEndRef = useRef<HTMLDivElement>(null);
|
const termEndRef = useRef<HTMLDivElement>(null);
|
||||||
const cmdRef = useRef<HTMLInputElement>(null);
|
const cmdRef = useRef<HTMLInputElement>(null);
|
||||||
const lastSeqRef = useRef(0);
|
const lastSeqRef = useRef(0);
|
||||||
|
const lastLatestCmdRef = useRef<typeof latestMessage>(null);
|
||||||
|
|
||||||
// Command history
|
// Command history
|
||||||
const [cmdHistory, setCmdHistory] = useState<string[]>([]);
|
const [cmdHistory, setCmdHistory] = useState<string[]>([]);
|
||||||
@@ -435,12 +437,18 @@ export default function CruciblePage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!commandResults || commandResults.length === 0) return;
|
if (!commandResults || commandResults.length === 0) return;
|
||||||
const newEntries = commandResults.filter((r) => r._seq > lastSeqRef.current);
|
const newEntries = commandResults.filter(
|
||||||
|
(r) => typeof r._seq === 'number' && r._seq > lastSeqRef.current,
|
||||||
|
);
|
||||||
if (newEntries.length === 0) return;
|
if (newEntries.length === 0) return;
|
||||||
lastSeqRef.current = newEntries[newEntries.length - 1]._seq;
|
|
||||||
|
|
||||||
const lines: TermLine[] = [];
|
const lines: TermLine[] = [];
|
||||||
|
let maxSeq = lastSeqRef.current;
|
||||||
|
|
||||||
for (const r of newEntries) {
|
for (const r of newEntries) {
|
||||||
|
if (typeof r._seq === 'number') {
|
||||||
|
maxSeq = Math.max(maxSeq, r._seq);
|
||||||
|
}
|
||||||
const aid = r.agent_id;
|
const aid = r.agent_id;
|
||||||
if (!aid) continue;
|
if (!aid) continue;
|
||||||
|
|
||||||
@@ -517,6 +525,10 @@ export default function CruciblePage() {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const msgLines = msg.split('\n').filter(Boolean);
|
const msgLines = msg.split('\n').filter(Boolean);
|
||||||
|
if (msgLines.length === 0) {
|
||||||
|
const label = r.action ? `[${r.action}]` : '[result]';
|
||||||
|
msgLines.push(r.success ? `${label} OK` : `${label} FAILED`);
|
||||||
|
}
|
||||||
for (const line of msgLines) {
|
for (const line of msgLines) {
|
||||||
lines.push({
|
lines.push({
|
||||||
id: mkId(), agentId: aid, agentName: name,
|
id: mkId(), agentId: aid, agentName: name,
|
||||||
@@ -525,12 +537,64 @@ export default function CruciblePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lastSeqRef.current = maxSeq;
|
||||||
if (lines.length > 0) {
|
if (lines.length > 0) {
|
||||||
setTermLines((prev) => [...prev, ...lines].slice(-2000));
|
setTermLines((prev) => [...prev, ...lines].slice(-2000));
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [commandResults]);
|
}, [commandResults]);
|
||||||
|
|
||||||
|
// Backup path: if commandResults batching ever misses an entry, latestMessage
|
||||||
|
// still carries command_result (WS_LATEST_MESSAGE_TYPES includes it).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!latestMessage || latestMessage.type !== 'command_result') return;
|
||||||
|
if (latestMessage === lastLatestCmdRef.current) return;
|
||||||
|
lastLatestCmdRef.current = latestMessage;
|
||||||
|
|
||||||
|
let r = latestMessage.payload as WSCommandResult | string;
|
||||||
|
if (typeof r === 'string') {
|
||||||
|
try {
|
||||||
|
r = JSON.parse(r) as WSCommandResult;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const aid = r.agent_id;
|
||||||
|
if (!aid) return;
|
||||||
|
|
||||||
|
// commandResults effect owns entries already queued in the provider buffer
|
||||||
|
if (
|
||||||
|
commandResults?.some(
|
||||||
|
(c) => c.agent_id === aid && c.action === r.action && c.message === r.message,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg = r.message ?? '';
|
||||||
|
const agent = agents.find((a) => a.id === aid);
|
||||||
|
const name = agent?.name ?? aid.slice(0, 8);
|
||||||
|
const targeted = selectedIds.size === 0 || selectedIds.has(aid);
|
||||||
|
const msgLines = msg.split('\n').filter(Boolean);
|
||||||
|
if (msgLines.length === 0) {
|
||||||
|
const label = r.action ? `[${r.action}]` : '[result]';
|
||||||
|
msgLines.push(r.success ? `${label} OK` : `${label} FAILED`);
|
||||||
|
}
|
||||||
|
const lines: TermLine[] = msgLines.map((line) => ({
|
||||||
|
id: mkId(),
|
||||||
|
agentId: aid,
|
||||||
|
agentName: name,
|
||||||
|
isCmd: false,
|
||||||
|
text: line,
|
||||||
|
ts: new Date(),
|
||||||
|
success: r.success,
|
||||||
|
targeted,
|
||||||
|
}));
|
||||||
|
setTermLines((prev) => [...prev, ...lines].slice(-2000));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [latestMessage, commandResults]);
|
||||||
|
|
||||||
// ── Selection helpers ──────────────────────────────────────────────────
|
// ── Selection helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
const toggle = (id: string) =>
|
const toggle = (id: string) =>
|
||||||
|
|||||||
@@ -49,8 +49,25 @@ import {
|
|||||||
} from '../help/chartSampleData';
|
} from '../help/chartSampleData';
|
||||||
import './Pages.css';
|
import './Pages.css';
|
||||||
|
|
||||||
|
const SKELETON_HEIGHTS = [0.30, 0.55, 0.40, 0.70, 0.50, 0.65, 0.45, 0.80, 0.60, 0.35];
|
||||||
|
|
||||||
function ChartPlaceholder({ height }: { height: number }) {
|
function ChartPlaceholder({ height }: { height: number }) {
|
||||||
return <div style={{ height, opacity: 0.35 }} className="font-tech" aria-hidden />;
|
return (
|
||||||
|
<div style={{ height }} className="chart-skeleton" aria-hidden>
|
||||||
|
<svg width="100%" height="100%" preserveAspectRatio="none">
|
||||||
|
{SKELETON_HEIGHTS.map((h, i) => (
|
||||||
|
<rect
|
||||||
|
key={i}
|
||||||
|
x={`${i * 10 + 1}%`}
|
||||||
|
y={`${(1 - h) * 75 + 12}%`}
|
||||||
|
width="8%"
|
||||||
|
height={`${h * 75}%`}
|
||||||
|
rx="3"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Format GPU KawPoW hashrate (H/s units, displayed as MH/s or GH/s). */
|
/** Format GPU KawPoW hashrate (H/s units, displayed as MH/s or GH/s). */
|
||||||
@@ -68,7 +85,7 @@ export default function DashboardPage() {
|
|||||||
const [restAlerts, setRestAlerts] = useState<typeof fleetAlerts>([]);
|
const [restAlerts, setRestAlerts] = useState<typeof fleetAlerts>([]);
|
||||||
const [restPools, setRestPools] = useState<typeof poolStatus>([]);
|
const [restPools, setRestPools] = useState<typeof poolStatus>([]);
|
||||||
const [restAI, setRestAI] = useState<typeof aiActivity>([]);
|
const [restAI, setRestAI] = useState<typeof aiActivity>([]);
|
||||||
const [subtitle, setSubtitle] = useState('security is just an emotion');
|
const [subtitle, setSubtitle] = useState('Fleet Command & Control');
|
||||||
const [hashHistory, setHashHistory] = useState<{ time: string; value: number }[]>([]);
|
const [hashHistory, setHashHistory] = useState<{ time: string; value: number }[]>([]);
|
||||||
const [acceptHistory, setAcceptHistory] = useState<{ time: string; value: number }[]>([]);
|
const [acceptHistory, setAcceptHistory] = useState<{ time: string; value: number }[]>([]);
|
||||||
const [cpuHistory, setCpuHistory] = useState<{ time: string; value: number }[]>([]);
|
const [cpuHistory, setCpuHistory] = useState<{ time: string; value: number }[]>([]);
|
||||||
@@ -171,23 +188,6 @@ export default function DashboardPage() {
|
|||||||
const avgMem = agents.length > 0 ? agents.reduce((s, a) => s + a.memory_usage_pct, 0) / agents.length : 0;
|
const avgMem = agents.length > 0 ? agents.reduce((s, a) => s + a.memory_usage_pct, 0) / agents.length : 0;
|
||||||
const onlinePct = agents.length > 0 ? (onlineCount / agents.length) * 100 : 0;
|
const onlinePct = agents.length > 0 ? (onlineCount / agents.length) * 100 : 0;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (totalHashrate <= 0) {
|
|
||||||
setEstXmrDay(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const controller = new AbortController();
|
|
||||||
api
|
|
||||||
.getEarningsEstimate(totalHashrate)
|
|
||||||
.then((r) => {
|
|
||||||
if (!controller.signal.aborted) setEstXmrDay(r.xmr_per_day ?? null);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!controller.signal.aborted) setEstXmrDay(null);
|
|
||||||
});
|
|
||||||
return () => controller.abort();
|
|
||||||
}, [totalHashrate]);
|
|
||||||
|
|
||||||
const chartMetricsRef = useRef({
|
const chartMetricsRef = useRef({
|
||||||
totalHashrate,
|
totalHashrate,
|
||||||
acceptRate,
|
acceptRate,
|
||||||
@@ -203,7 +203,12 @@ export default function DashboardPage() {
|
|||||||
totalGPUHashrate,
|
totalGPUHashrate,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Track last hashrate value we fetched earnings for — avoids redundant API calls.
|
||||||
|
const lastEarningsHashRef = useRef(0);
|
||||||
|
const earningsControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
// Sample fleet metrics every 2s instead of on every WS stats_update (reduces chart re-renders).
|
// Sample fleet metrics every 2s instead of on every WS stats_update (reduces chart re-renders).
|
||||||
|
// Earnings estimate is also debounced here — fetched at most once per 2s when hashrate changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sample = () => {
|
const sample = () => {
|
||||||
if (document.hidden) return;
|
if (document.hidden) return;
|
||||||
@@ -216,10 +221,28 @@ export default function DashboardPage() {
|
|||||||
if (m.totalGPUHashrate > 0) {
|
if (m.totalGPUHashrate > 0) {
|
||||||
setGpuHistory((prev) => [...prev.slice(-59), { time: now, value: m.totalGPUHashrate }]);
|
setGpuHistory((prev) => [...prev.slice(-59), { time: now, value: m.totalGPUHashrate }]);
|
||||||
}
|
}
|
||||||
|
// Debounced earnings estimate — only re-fetch when hashrate actually changed.
|
||||||
|
if (m.totalHashrate !== lastEarningsHashRef.current) {
|
||||||
|
lastEarningsHashRef.current = m.totalHashrate;
|
||||||
|
earningsControllerRef.current?.abort();
|
||||||
|
if (m.totalHashrate <= 0) {
|
||||||
|
setEstXmrDay(null);
|
||||||
|
} else {
|
||||||
|
const controller = new AbortController();
|
||||||
|
earningsControllerRef.current = controller;
|
||||||
|
api
|
||||||
|
.getEarningsEstimate(m.totalHashrate)
|
||||||
|
.then((r) => { if (!controller.signal.aborted) setEstXmrDay(r.xmr_per_day ?? null); })
|
||||||
|
.catch(() => { if (!controller.signal.aborted) setEstXmrDay(null); });
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
sample();
|
sample();
|
||||||
const id = window.setInterval(sample, 2000);
|
const id = window.setInterval(sample, 2000);
|
||||||
return () => window.clearInterval(id);
|
return () => {
|
||||||
|
window.clearInterval(id);
|
||||||
|
earningsControllerRef.current?.abort();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const hashChart = useMemo(() => resolveChartSeries(hashHistory), [hashHistory]);
|
const hashChart = useMemo(() => resolveChartSeries(hashHistory), [hashHistory]);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useVisibleInterval } from '../hooks/usePageVisible';
|
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { BuildRecord, EmberwakeNotes, PublicBuildDTO, WarRoomResponse } from '../types';
|
import type { BuildRecord, EmberwakeNotes, PublicBuildDTO, WarRoomResponse } from '../types';
|
||||||
import { publicDownloadUrl } from '../help/emberwake';
|
import { publicDownloadUrl } from '../help/emberwake';
|
||||||
@@ -41,7 +40,6 @@ function CopyChip({ text, label }: { text: string; label: string }) {
|
|||||||
const NOTES_TYPING_DEBOUNCE_MS = 400;
|
const NOTES_TYPING_DEBOUNCE_MS = 400;
|
||||||
const NOTES_TYPING_IDLE_MS = 2000;
|
const NOTES_TYPING_IDLE_MS = 2000;
|
||||||
const WAR_ROOM_DAYS = 7;
|
const WAR_ROOM_DAYS = 7;
|
||||||
const WAR_ROOM_POLL_MS = 15_000;
|
|
||||||
|
|
||||||
export default function EmberwakePage() {
|
export default function EmberwakePage() {
|
||||||
const { latestMessage } = useWebSocket();
|
const { latestMessage } = useWebSocket();
|
||||||
@@ -113,10 +111,6 @@ export default function EmberwakePage() {
|
|||||||
void load().catch(() => {});
|
void load().catch(() => {});
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
useVisibleInterval(() => {
|
|
||||||
void loadWarRoom().catch(() => {});
|
|
||||||
}, WAR_ROOM_POLL_MS);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!latestMessage) return;
|
if (!latestMessage) return;
|
||||||
if (latestMessage.type === 'emberwake_notes_updated') {
|
if (latestMessage.type === 'emberwake_notes_updated') {
|
||||||
@@ -318,7 +312,7 @@ export default function EmberwakePage() {
|
|||||||
<div className="war-room-toolbar">
|
<div className="war-room-toolbar">
|
||||||
<span className="war-room-toolbar-meta">
|
<span className="war-room-toolbar-meta">
|
||||||
{warRoomUpdated ? `Updated ${new Date(warRoomUpdated).toLocaleTimeString()}` : 'Loading…'}
|
{warRoomUpdated ? `Updated ${new Date(warRoomUpdated).toLocaleTimeString()}` : 'Loading…'}
|
||||||
{' · '}poll {WAR_ROOM_POLL_MS / 1000}s
|
{' '}<HelpTip field="ew_war_room_funnel" />
|
||||||
</span>
|
</span>
|
||||||
<div className="war-room-toolbar-right">
|
<div className="war-room-toolbar-right">
|
||||||
<div className="war-room-view-toggle" role="group" aria-label="War room view">
|
<div className="war-room-view-toggle" role="group" aria-label="War room view">
|
||||||
@@ -344,6 +338,7 @@ export default function EmberwakePage() {
|
|||||||
Constellations
|
Constellations
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<HelpTip field="ew_war_room_views" />
|
||||||
<span className="war-room-toolbar-meta">WebSocket push ~30s</span>
|
<span className="war-room-toolbar-meta">WebSocket push ~30s</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1981,3 +1981,31 @@ button.deliverable-card .form-hint {
|
|||||||
.rvn-rig-model,
|
.rvn-rig-model,
|
||||||
.rvn-rig-pct { display: none; }
|
.rvn-rig-pct { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Chart skeleton — visible branded loading placeholder */
|
||||||
|
@keyframes chart-skeleton-pulse {
|
||||||
|
0%, 100% { opacity: 0.12; }
|
||||||
|
50% { opacity: 0.28; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-skeleton {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(61, 214, 198, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-skeleton svg rect {
|
||||||
|
fill: var(--neon-cyan, #3dd6c6);
|
||||||
|
animation: chart-skeleton-pulse 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-skeleton svg rect:nth-child(2) { animation-delay: 0.1s; }
|
||||||
|
.chart-skeleton svg rect:nth-child(3) { animation-delay: 0.2s; }
|
||||||
|
.chart-skeleton svg rect:nth-child(4) { animation-delay: 0.3s; }
|
||||||
|
.chart-skeleton svg rect:nth-child(5) { animation-delay: 0.4s; }
|
||||||
|
.chart-skeleton svg rect:nth-child(6) { animation-delay: 0.5s; }
|
||||||
|
.chart-skeleton svg rect:nth-child(7) { animation-delay: 0.6s; }
|
||||||
|
.chart-skeleton svg rect:nth-child(8) { animation-delay: 0.7s; }
|
||||||
|
.chart-skeleton svg rect:nth-child(9) { animation-delay: 0.8s; }
|
||||||
|
.chart-skeleton svg rect:nth-child(10) { animation-delay: 0.9s; }
|
||||||
|
|||||||
310
server/web/src/pages/PathTracerPage.test.tsx
Normal file
310
server/web/src/pages/PathTracerPage.test.tsx
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment happy-dom
|
||||||
|
*/
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import PathTracerPage from './PathTracerPage';
|
||||||
|
import { mockAgent } from '../test/fixtures';
|
||||||
|
import { routerFuture } from '../routerFuture';
|
||||||
|
import { useWebSocket } from '../hooks/useWebSocket';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
|
||||||
|
vi.mock('../hooks/useWebSocket', () => ({
|
||||||
|
useWebSocket: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../context/AmbientMusicContext', () => ({
|
||||||
|
useModalAmbientDuck: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../components/HelpTip', () => ({
|
||||||
|
HelpTip: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const useWebSocketMock = vi.mocked(useWebSocket);
|
||||||
|
|
||||||
|
function wsValue(overrides: Partial<ReturnType<typeof useWebSocket>> = {}) {
|
||||||
|
return {
|
||||||
|
isConnected: true,
|
||||||
|
agents: [],
|
||||||
|
recentShares: [],
|
||||||
|
fleetAlerts: [],
|
||||||
|
poolStatus: [],
|
||||||
|
aiActivity: [],
|
||||||
|
agentLogs: {},
|
||||||
|
commandResults: [],
|
||||||
|
latestMessage: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter future={routerFuture}>
|
||||||
|
<PathTracerPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const windowsAgent = mockAgent({ id: 'win-1', name: 'Rig Alpha', platform: 'windows', status: 'online' });
|
||||||
|
const linuxAgent = mockAgent({ id: 'lin-1', name: 'Linux Box', platform: 'linux', status: 'online' });
|
||||||
|
const offlineAgent = mockAgent({ id: 'off-1', name: 'Dead Node', status: 'offline' });
|
||||||
|
|
||||||
|
// Capture the most-recently registered setInterval callback so tests can
|
||||||
|
// trigger a poll tick without waiting the real 2-second interval delay.
|
||||||
|
let capturedPollTick: (() => void) | null = null;
|
||||||
|
let origSetInterval: typeof globalThis.setInterval;
|
||||||
|
let origClearInterval: typeof globalThis.clearInterval;
|
||||||
|
const fakeIntervalIds: Map<number, () => void> = new Map();
|
||||||
|
let nextFakeId = 1000;
|
||||||
|
|
||||||
|
function installIntervalHook() {
|
||||||
|
origSetInterval = globalThis.setInterval;
|
||||||
|
origClearInterval = globalThis.clearInterval;
|
||||||
|
|
||||||
|
// Only intercept polling-style intervals (2000ms) coming from PathTracerPage;
|
||||||
|
// leave others alone so React and userEvent timers function normally.
|
||||||
|
(globalThis as unknown as { setInterval: typeof setInterval }).setInterval = (fn: TimerHandler, delay?: number, ...args: unknown[]) => {
|
||||||
|
if (delay === 2000 && typeof fn === 'function') {
|
||||||
|
const id = nextFakeId++;
|
||||||
|
fakeIntervalIds.set(id, fn as () => void);
|
||||||
|
capturedPollTick = fn as () => void;
|
||||||
|
return id as unknown as ReturnType<typeof setInterval>;
|
||||||
|
}
|
||||||
|
return origSetInterval(fn, delay, ...args);
|
||||||
|
};
|
||||||
|
|
||||||
|
(globalThis as unknown as { clearInterval: typeof clearInterval }).clearInterval = (id?: ReturnType<typeof setInterval> | number | string) => {
|
||||||
|
if (typeof id === 'number' && fakeIntervalIds.has(id)) {
|
||||||
|
fakeIntervalIds.delete(id);
|
||||||
|
capturedPollTick = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
origClearInterval(id as ReturnType<typeof clearInterval>);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function uninstallIntervalHook() {
|
||||||
|
globalThis.setInterval = origSetInterval;
|
||||||
|
globalThis.clearInterval = origClearInterval;
|
||||||
|
capturedPollTick = null;
|
||||||
|
fakeIntervalIds.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PathTracerPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
installIntervalHook();
|
||||||
|
useWebSocketMock.mockReturnValue(wsValue());
|
||||||
|
vi.spyOn(api, 'listAgents').mockResolvedValue([]);
|
||||||
|
vi.spyOn(api, 'startTrace').mockResolvedValue({ session_id: 'sess-1', hops: [] });
|
||||||
|
vi.spyOn(api, 'getTraceStatus').mockResolvedValue({ session_id: 'sess-1', ready: false, hops: [] });
|
||||||
|
vi.spyOn(api, 'getTraceQR').mockResolvedValue({ config: 'wg-conf', qr_png_b64: 'abc123' });
|
||||||
|
vi.spyOn(api, 'deleteTrace').mockResolvedValue({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
uninstallIntervalHook();
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders page heading', () => {
|
||||||
|
renderPage();
|
||||||
|
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Path Tracer/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows online agents from WebSocket and offline agents separately', () => {
|
||||||
|
useWebSocketMock.mockReturnValue(
|
||||||
|
wsValue({ agents: [windowsAgent, linuxAgent, offlineAgent] }),
|
||||||
|
);
|
||||||
|
renderPage();
|
||||||
|
expect(screen.getByText('Rig Alpha')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Linux Box')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Dead Node')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TRACE button is disabled when no agents are selected', () => {
|
||||||
|
useWebSocketMock.mockReturnValue(wsValue({ agents: [windowsAgent] }));
|
||||||
|
renderPage();
|
||||||
|
expect(screen.getByRole('button', { name: /TRACE/i })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects and deselects a Windows agent by clicking', async () => {
|
||||||
|
useWebSocketMock.mockReturnValue(wsValue({ agents: [windowsAgent] }));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
const card = screen.getByText('Rig Alpha').closest('.pt-agent-card') as HTMLElement;
|
||||||
|
await user.click(card);
|
||||||
|
expect(card.querySelector('.pt-agent-card-order')).toHaveTextContent('1');
|
||||||
|
expect(screen.getByRole('button', { name: /TRACE/i })).not.toBeDisabled();
|
||||||
|
|
||||||
|
await user.click(card);
|
||||||
|
expect(card.querySelector('.pt-agent-card-order')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /TRACE/i })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('limits selection to 3 agents', async () => {
|
||||||
|
const agents = [
|
||||||
|
mockAgent({ id: 'w1', name: 'Win 1', platform: 'windows', status: 'online' }),
|
||||||
|
mockAgent({ id: 'w2', name: 'Win 2', platform: 'windows', status: 'online' }),
|
||||||
|
mockAgent({ id: 'w3', name: 'Win 3', platform: 'windows', status: 'online' }),
|
||||||
|
mockAgent({ id: 'w4', name: 'Win 4', platform: 'windows', status: 'online' }),
|
||||||
|
];
|
||||||
|
useWebSocketMock.mockReturnValue(wsValue({ agents }));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
for (const name of ['Win 1', 'Win 2', 'Win 3', 'Win 4']) {
|
||||||
|
const card = screen.getByText(name).closest('.pt-agent-card') as HTMLElement;
|
||||||
|
await user.click(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cards = document.querySelectorAll('.pt-agent-card-order');
|
||||||
|
expect(cards).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('start button stays disabled for non-Windows agents', () => {
|
||||||
|
useWebSocketMock.mockReturnValue(wsValue({ agents: [linuxAgent] }));
|
||||||
|
renderPage();
|
||||||
|
expect(screen.getByRole('button', { name: /TRACE/i })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts polling after TRACE is clicked', async () => {
|
||||||
|
useWebSocketMock.mockReturnValue(wsValue({ agents: [windowsAgent] }));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
const card = screen.getByText('Rig Alpha').closest('.pt-agent-card') as HTMLElement;
|
||||||
|
await user.click(card);
|
||||||
|
await user.click(screen.getByRole('button', { name: /TRACE/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(api.startTrace).toHaveBeenCalledWith(['win-1']));
|
||||||
|
|
||||||
|
// Trigger the captured poll tick directly (no real 2s wait).
|
||||||
|
await waitFor(() => expect(capturedPollTick).not.toBeNull());
|
||||||
|
capturedPollTick!();
|
||||||
|
await waitFor(() => expect(api.getTraceStatus).toHaveBeenCalledWith('sess-1'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows End Session button while tracing', async () => {
|
||||||
|
useWebSocketMock.mockReturnValue(wsValue({ agents: [windowsAgent] }));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
const card = screen.getByText('Rig Alpha').closest('.pt-agent-card') as HTMLElement;
|
||||||
|
await user.click(card);
|
||||||
|
await user.click(screen.getByRole('button', { name: /TRACE/i }));
|
||||||
|
await waitFor(() => expect(api.startTrace).toHaveBeenCalled());
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: /End Session/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops polling and resets state when End Session is clicked', async () => {
|
||||||
|
useWebSocketMock.mockReturnValue(wsValue({ agents: [windowsAgent] }));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
const card = screen.getByText('Rig Alpha').closest('.pt-agent-card') as HTMLElement;
|
||||||
|
await user.click(card);
|
||||||
|
await user.click(screen.getByRole('button', { name: /TRACE/i }));
|
||||||
|
await waitFor(() => expect(api.startTrace).toHaveBeenCalled());
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /End Session/i }));
|
||||||
|
await waitFor(() => expect(api.deleteTrace).toHaveBeenCalledWith('sess-1'));
|
||||||
|
|
||||||
|
// After ending, the TRACE button reappears (disabled, nothing selected)
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: /TRACE/i })).toBeInTheDocument());
|
||||||
|
expect(screen.queryByRole('button', { name: /End Session/i })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// Further poll ticks must not fire — capturedPollTick cleared on clearInterval
|
||||||
|
expect(capturedPollTick).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('error state: shows prominent error, End Session button, and countdown', async () => {
|
||||||
|
vi.mocked(api.getTraceStatus).mockResolvedValue({
|
||||||
|
session_id: 'sess-1',
|
||||||
|
ready: false,
|
||||||
|
error: 'wg_setup failed: not supported',
|
||||||
|
hops: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
useWebSocketMock.mockReturnValue(wsValue({ agents: [windowsAgent] }));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
const card = screen.getByText('Rig Alpha').closest('.pt-agent-card') as HTMLElement;
|
||||||
|
await user.click(card);
|
||||||
|
await user.click(screen.getByRole('button', { name: /TRACE/i }));
|
||||||
|
await waitFor(() => expect(api.startTrace).toHaveBeenCalled());
|
||||||
|
|
||||||
|
// Trigger a single poll tick to deliver the error.
|
||||||
|
await waitFor(() => expect(capturedPollTick).not.toBeNull());
|
||||||
|
capturedPollTick!();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/wg_setup failed/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// End Session button must be visible even though tracing state is now false.
|
||||||
|
expect(screen.getByRole('button', { name: /End Session/i })).toBeInTheDocument();
|
||||||
|
// Countdown text visible.
|
||||||
|
expect(screen.getByText(/Session will be terminated/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('error state: clicking End Session cancels auto-delete and clears session', async () => {
|
||||||
|
vi.mocked(api.getTraceStatus).mockResolvedValue({
|
||||||
|
session_id: 'sess-1',
|
||||||
|
ready: false,
|
||||||
|
error: 'tunnel setup error',
|
||||||
|
hops: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
useWebSocketMock.mockReturnValue(wsValue({ agents: [windowsAgent] }));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
const card = screen.getByText('Rig Alpha').closest('.pt-agent-card') as HTMLElement;
|
||||||
|
await user.click(card);
|
||||||
|
await user.click(screen.getByRole('button', { name: /TRACE/i }));
|
||||||
|
await waitFor(() => expect(capturedPollTick).not.toBeNull());
|
||||||
|
capturedPollTick!();
|
||||||
|
await waitFor(() => screen.getByRole('alert'));
|
||||||
|
|
||||||
|
// Manually end the session before countdown fires.
|
||||||
|
await user.click(screen.getByRole('button', { name: /End Session/i }));
|
||||||
|
await waitFor(() => expect(api.deleteTrace).toHaveBeenCalledWith('sess-1'));
|
||||||
|
|
||||||
|
// Session cleared — TRACE button is back.
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: /TRACE/i })).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('QR modal renders when status becomes ready', async () => {
|
||||||
|
vi.mocked(api.getTraceStatus).mockResolvedValue({
|
||||||
|
session_id: 'sess-1',
|
||||||
|
ready: true,
|
||||||
|
hops: [{ agent_id: 'win-1', status: 'ready' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
useWebSocketMock.mockReturnValue(wsValue({ agents: [windowsAgent] }));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
const card = screen.getByText('Rig Alpha').closest('.pt-agent-card') as HTMLElement;
|
||||||
|
await user.click(card);
|
||||||
|
await user.click(screen.getByRole('button', { name: /TRACE/i }));
|
||||||
|
await waitFor(() => expect(capturedPollTick).not.toBeNull());
|
||||||
|
capturedPollTick!();
|
||||||
|
|
||||||
|
await waitFor(() => expect(api.getTraceQR).toHaveBeenCalledWith('sess-1'));
|
||||||
|
await waitFor(() => expect(screen.getByText('⬡ PATH TRACE ACTIVE')).toBeInTheDocument());
|
||||||
|
|
||||||
|
// QR image and config rendered inside modal.
|
||||||
|
expect(screen.getByAltText('WireGuard QR')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('wg-conf')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -113,6 +113,8 @@ export default function PathTracerPage() {
|
|||||||
const [showQR, setShowQR] = useState(false);
|
const [showQR, setShowQR] = useState(false);
|
||||||
|
|
||||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const [autoEndCountdown, setAutoEndCountdown] = useState<number | null>(null);
|
||||||
|
const autoEndRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
// Use WebSocket agents; fall back to REST on mount if WebSocket hasn't populated yet.
|
// Use WebSocket agents; fall back to REST on mount if WebSocket hasn't populated yet.
|
||||||
const agents = wsAgents.length > 0 ? wsAgents : restAgents;
|
const agents = wsAgents.length > 0 ? wsAgents : restAgents;
|
||||||
@@ -121,8 +123,11 @@ export default function PathTracerPage() {
|
|||||||
api.listAgents().then(setRestAgents).catch(() => {});
|
api.listAgents().then(setRestAgents).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Stop polling on unmount.
|
// Stop polling and auto-end timer on unmount.
|
||||||
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current); }, []);
|
useEffect(() => () => {
|
||||||
|
if (pollRef.current) clearInterval(pollRef.current);
|
||||||
|
if (autoEndRef.current) clearInterval(autoEndRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const toggleAgent = (id: string, offline: boolean) => {
|
const toggleAgent = (id: string, offline: boolean) => {
|
||||||
if (offline) return;
|
if (offline) return;
|
||||||
@@ -186,6 +191,8 @@ export default function PathTracerPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleEndSession = useCallback(async () => {
|
const handleEndSession = useCallback(async () => {
|
||||||
|
if (autoEndRef.current) { clearInterval(autoEndRef.current); autoEndRef.current = null; }
|
||||||
|
setAutoEndCountdown(null);
|
||||||
if (!sessionID) return;
|
if (!sessionID) return;
|
||||||
try {
|
try {
|
||||||
await api.deleteTrace(sessionID);
|
await api.deleteTrace(sessionID);
|
||||||
@@ -201,6 +208,30 @@ export default function PathTracerPage() {
|
|||||||
setError('');
|
setError('');
|
||||||
}, [sessionID]);
|
}, [sessionID]);
|
||||||
|
|
||||||
|
// Auto-delete the session 10 seconds after an error, with a visible countdown.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!error || !sessionID) return;
|
||||||
|
if (autoEndRef.current) clearInterval(autoEndRef.current);
|
||||||
|
const COUNTDOWN = 10;
|
||||||
|
setAutoEndCountdown(COUNTDOWN);
|
||||||
|
let remaining = COUNTDOWN;
|
||||||
|
autoEndRef.current = setInterval(() => {
|
||||||
|
remaining -= 1;
|
||||||
|
if (remaining <= 0) {
|
||||||
|
clearInterval(autoEndRef.current!);
|
||||||
|
autoEndRef.current = null;
|
||||||
|
setAutoEndCountdown(null);
|
||||||
|
handleEndSession();
|
||||||
|
} else {
|
||||||
|
setAutoEndCountdown(remaining);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
return () => {
|
||||||
|
if (autoEndRef.current) { clearInterval(autoEndRef.current); autoEndRef.current = null; }
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [error, sessionID]);
|
||||||
|
|
||||||
const isWindows = (a: Agent) =>
|
const isWindows = (a: Agent) =>
|
||||||
!!(a.platform?.toLowerCase().includes('win') || a.platform?.toLowerCase().includes('windows'));
|
!!(a.platform?.toLowerCase().includes('win') || a.platform?.toLowerCase().includes('windows'));
|
||||||
|
|
||||||
@@ -222,7 +253,17 @@ export default function PathTracerPage() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{error && <div className="pt-error-banner">⚠ {error}</div>}
|
{error && (
|
||||||
|
<div className="pt-error-banner" role="alert">
|
||||||
|
<strong>⚠ Session Error</strong>
|
||||||
|
<div style={{ marginTop: '0.3rem' }}>{error}</div>
|
||||||
|
{sessionID && autoEndCountdown !== null && (
|
||||||
|
<div style={{ marginTop: '0.3rem', opacity: 0.8 }}>
|
||||||
|
Session will be terminated in {autoEndCountdown}s…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{tracing && !allHopsReady && !error && (
|
{tracing && !allHopsReady && !error && (
|
||||||
<div className="pt-info-banner">
|
<div className="pt-info-banner">
|
||||||
@@ -338,7 +379,7 @@ export default function PathTracerPage() {
|
|||||||
|
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<div className="pt-actions">
|
<div className="pt-actions">
|
||||||
{!tracing && (
|
{!tracing && !sessionID && (
|
||||||
<button
|
<button
|
||||||
className="pt-btn pt-btn-primary"
|
className="pt-btn pt-btn-primary"
|
||||||
disabled={selected.length === 0 || loading}
|
disabled={selected.length === 0 || loading}
|
||||||
@@ -354,13 +395,13 @@ export default function PathTracerPage() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tracing && (
|
{(tracing || (!!error && !!sessionID)) && (
|
||||||
<button className="pt-btn pt-btn-danger" onClick={handleEndSession}>
|
<button className="pt-btn pt-btn-danger" onClick={handleEndSession}>
|
||||||
End Session
|
End Session{autoEndCountdown !== null && ` (${autoEndCountdown}s)`}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!tracing && selected.length > 0 && (
|
{!tracing && !sessionID && selected.length > 0 && (
|
||||||
<button className="pt-btn pt-btn-ghost" onClick={() => setSelected([])}>
|
<button className="pt-btn pt-btn-ghost" onClick={() => setSelected([])}>
|
||||||
Clear
|
Clear
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ export default function SettingsPage() {
|
|||||||
log_share_submissions: cfg.server?.log_share_submissions ?? false,
|
log_share_submissions: cfg.server?.log_share_submissions ?? false,
|
||||||
log_pool_traffic: cfg.server?.log_pool_traffic ?? false,
|
log_pool_traffic: cfg.server?.log_pool_traffic ?? false,
|
||||||
strict_wallet_validation: cfg.server?.strict_wallet_validation ?? false,
|
strict_wallet_validation: cfg.server?.strict_wallet_validation ?? false,
|
||||||
dashboard_subtitle: cfg.server?.dashboard_subtitle ?? 'security is just an emotion',
|
dashboard_subtitle: cfg.server?.dashboard_subtitle ?? 'Fleet Command & Control',
|
||||||
open_firewall_on_start: cfg.server?.open_firewall_on_start ?? true,
|
open_firewall_on_start: cfg.server?.open_firewall_on_start ?? true,
|
||||||
public_builds_enabled: cfg.server?.public_builds_enabled ?? false,
|
public_builds_enabled: cfg.server?.public_builds_enabled ?? false,
|
||||||
public_builds_latest_n: cfg.server?.public_builds_latest_n ?? 3,
|
public_builds_latest_n: cfg.server?.public_builds_latest_n ?? 3,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function mockServerConfig(overrides: Partial<ServerConfig> = {}): ServerC
|
|||||||
log_share_submissions: false,
|
log_share_submissions: false,
|
||||||
log_pool_traffic: false,
|
log_pool_traffic: false,
|
||||||
strict_wallet_validation: false,
|
strict_wallet_validation: false,
|
||||||
dashboard_subtitle: 'security is just an emotion',
|
dashboard_subtitle: 'Fleet Command & Control',
|
||||||
open_firewall_on_start: true,
|
open_firewall_on_start: true,
|
||||||
obfuscate_default: false,
|
obfuscate_default: false,
|
||||||
sign_enabled: false,
|
sign_enabled: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user