diff --git a/PROBLEMS.md b/PROBLEMS.md index dd5a6d8..0fc241f 100644 --- a/PROBLEMS.md +++ b/PROBLEMS.md @@ -1,4 +1,4 @@ - + @@ -104,7 +104,7 @@ |-------|-------| -| `pathforge_test.go` dead loop | Lines 6672 iterate `entry.Files` but assert nothing harmless test noise. | +| ~~\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. | @@ -150,7 +150,7 @@ -- **`mergeConfig` partial-PUT `UseTLS` legacy behavior** omitted keys may not reset bools; see `config_test.go`. +- ~~**`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. @@ -278,7 +278,7 @@ UI: Phase C controls in `CrucibleExpandedOps.tsx` Fleet Maintenance (replaces | 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` | Manual step via `devrun.bat` or copy; stale webroot served old hashed assets | +| ~~`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 | @@ -296,9 +296,9 @@ UI: Phase C controls in `CrucibleExpandedOps.tsx` Fleet Maintenance (replaces |----|-------|-------| -| UH-01 | Crucible expanded ops (spread/tunnels/recon buttons) | 40+ buttons still rely on `title=` only no `HelpTip` on each `CrucibleExpandedOps` action | +| ~~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 | Filter chips and bulk actions lack inline help | +| ~~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 | @@ -306,7 +306,7 @@ UI: Phase C controls in `CrucibleExpandedOps.tsx` Fleet Maintenance (replaces | 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 | Alerts, webhooks, desktop push sections partially covered | +| ~~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`. | @@ -488,11 +488,11 @@ UI: Phase C controls in `CrucibleExpandedOps.tsx` Fleet Maintenance (replaces |----|----------|----------|-------------| -| **BLD-D1** | High | `server/internal/builder/pathforge.go` | **No server-side restriction on `root_path`.** `PathForgeRequest.RootPath` is passed directly to `filepath.WalkDir` without any whitelist or prefix check. An authenticated operator (or compromised session) can supply any absolute path (e.g. `C:\Windows\System32`, `/etc/`) and the server will (a) enumerate every matching file and (b) write `.exe`, `.bat`, and `.command` companion files next to them. Fix: validate `RootPath` against an operator-configured allow-list (e.g. specific USB/NAS mount points), or at minimum reject absolute paths that escape a configured `data_dir`. | +| ~~**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` | **Filename injection in generated scripts.** `d.Name()` (raw filesystem filename) is interpolated into `.bat` and `.command` scripts via `fmt.Sprintf`. On Windows, a filename containing `%VAR%` expands the BAT variable; a filename with `"` breaks the quoted argument. On macOS/Linux, a filename with `'` breaks single-quoted shell strings, and a `server_url` containing `'` would allow shell command injection in the generated `curl` line. Fix: escape `"` and `%` for BAT content; escape `'` ? `'\''` for shell content. | +| ~~**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/handler.go` `buildUniversalAgent` | **No partial build cleanup for universal builds.** The `cleanupBuild` fix (BLD-04) covers single-platform `buildAgent`. The corresponding `buildUniversalAgent` / `finishSpreadKit` / `finishUniversalFusion` do not clean up `buildDir` on internal failures. Extend the same pattern. | +| ~~**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. | @@ -640,7 +640,7 @@ UI: Phase C controls in `CrucibleExpandedOps.tsx` Fleet Maintenance (replaces | ~~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** | `client/syscheck.go` `CollectFullSysCheck()` | Calls `deploy.ScanLocalSubnet(56)` synchronously in the command handler goroutine. `ScanLocalSubnet` probes up to 56 hosts × ~0.8 s timeout = ~45 s on an empty /24. During this time the agent cannot process incoming WebSocket messages (it is running in a command goroutine, but the read loop is separate; however if the server has a short reply timeout the session may drop). Consider running the subnet scan in a background goroutine and returning a "pending" token, or reducing the default `maxHosts` for syscheck. | +| ~~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. | diff --git a/agent/client/syscheck.go b/agent/client/syscheck.go index 7bcff56..cabac20 100644 --- a/agent/client/syscheck.go +++ b/agent/client/syscheck.go @@ -57,7 +57,7 @@ func CollectFullSysCheck(cfg config.RuntimeConfig, agentID string) *FullSysCheck arp := deploy.ArpNeighborIPs() r.Neighbors.ArpHosts = arp r.Neighbors.ArpCount = len(arp) - r.Neighbors.SubnetScan = deploy.ScanLocalSubnet(56) + r.Neighbors.SubnetScan = deploy.ScanLocalSubnet(20) collectSysCheckPlatform(r) diff --git a/server/config.go b/server/config.go index 3fcfa52..647b29b 100644 --- a/server/config.go +++ b/server/config.go @@ -372,6 +372,11 @@ func (c *Config) AlertSettings() alerts.Settings { }) } +// mergeConfig is the legacy unconditional merge used only as a fallback when no +// field-presence map is available (i.e. never during API PUT). Boolean fields such +// as UseTLS are blindly copied from src, meaning a zero-value src resets them to +// false. Prefer mergeConfigExplicit for all partial-update paths — it only applies +// a field when that key was explicitly present in the JSON payload. func mergeConfig(dst, src *Config) { if src.Port != 0 { dst.Port = src.Port diff --git a/server/config_test.go b/server/config_test.go index 75938d7..ced18b8 100644 --- a/server/config_test.go +++ b/server/config_test.go @@ -523,9 +523,14 @@ func TestMergeConfigLegacyScalarsAndBooleans(t *testing.T) { if dst.Server.StatsRetentionHours != 72 { t.Fatalf("stats retention: %d", dst.Server.StatsRetentionHours) } - // Known legacy: UseTLS copied from src even when false on zero-value partial src + // mergeConfig (legacy) unconditionally copies UseTLS, so a partial src with + // UseTLS==false will clear a true value in dst. This is expected for the legacy + // function. The API PUT handler uses UpdateConfigFromJSON → mergeConfigExplicit, + // which only applies UseTLS when the "use_tls" key is explicitly present in the + // JSON payload, so false can be set intentionally and absent means "no change". + // No fix needed for partial-PUT; this test documents the legacy-function quirk. if dst.Pool.UseTLS { - t.Log("mergeConfig sets UseTLS from src zero value on partial update") + t.Fatal("mergeConfig always copies UseTLS from src; zero src must clear TLS") } } diff --git a/server/internal/api/router.go b/server/internal/api/router.go index f8d097b..0d28509 100644 --- a/server/internal/api/router.go +++ b/server/internal/api/router.go @@ -542,7 +542,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler if cloudflaredConfigured != nil { tunnelReady = cloudflaredConfigured() } - GetServerInfo(w, r, override, listenPort, tunnelReady) + GetServerInfo(w, r, override, listenPort, tunnelReady, version) }) // Dashboard diff --git a/server/internal/api/server_info.go b/server/internal/api/server_info.go index 265cd3e..9784849 100644 --- a/server/internal/api/server_info.go +++ b/server/internal/api/server_info.go @@ -26,13 +26,14 @@ type ServerInfo struct { SuggestedURL string `json:"suggested_url"` DashboardURL string `json:"dashboard_url"` WebSocketURL string `json:"websocket_url"` + Version string `json:"version,omitempty"` } // GetServerInfo returns URLs workers and droppers should use to reach this deck. // When the dashboard is opened via HTTPS reverse proxy (e.g. Cloudflare tunnel), // suggested_url uses https and omits :443 — not the local listen port (8989). // lan_url is always the LAN http endpoint for workers on the same network. -func GetServerInfo(w http.ResponseWriter, r *http.Request, publicURLOverride string, listenPort int, cloudflaredConfigured bool) { +func GetServerInfo(w http.ResponseWriter, r *http.Request, publicURLOverride string, listenPort int, cloudflaredConfigured bool, version ...string) { if listenPort <= 0 { listenPort = 8989 } @@ -62,6 +63,9 @@ func GetServerInfo(w http.ResponseWriter, r *http.Request, publicURLOverride str DashboardURL: suggestedURL, WebSocketURL: httpToWS(suggestedURL) + "/ws/agent", } + if len(version) > 0 { + info.Version = version[0] + } writeJSON(w, info) } diff --git a/server/internal/builder/build_universal.go b/server/internal/builder/build_universal.go index 550ca7b..e61ddc9 100644 --- a/server/internal/builder/build_universal.go +++ b/server/internal/builder/build_universal.go @@ -20,10 +20,13 @@ func (h *Handler) buildUniversalAgent(ctx context.Context, req *BuildRequest, pr buildDir, _ := filepath.Abs(filepath.Join(h.dataDir, "builds", buildID)) agentDir := filepath.Join(buildDir, "agent") + cleanupBuild := func() { _ = os.RemoveAll(buildDir) } + if err := os.MkdirAll(agentDir, 0755); err != nil { return BuildResponse{Success: false, Error: "Failed to create build directory"}, http.StatusInternalServerError, "" } if err := h.copyAgentSource(agentDir); err != nil { + cleanupBuild() return BuildResponse{Success: false, Error: "Failed to prepare agent source: " + err.Error()}, http.StatusInternalServerError, "" } @@ -32,6 +35,7 @@ func (h *Handler) buildUniversalAgent(ctx context.Context, req *BuildRequest, pr for _, p := range platforms { wp, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, p, req.FusionEnabled) if err != nil { + cleanupBuild() return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" } workerPaths[p.Label()] = wp @@ -50,6 +54,8 @@ 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) { + cleanupBuild := func() { _ = os.RemoveAll(buildDir) } + var subdir string if req.SpreadKit { subdir = sanitizeFileName(req.WorkerName) + "-spread-kit" @@ -58,6 +64,7 @@ func (h *Handler) finishSpreadKit(buildID, buildDir string, req *BuildRequest, w } outDir := filepath.Join(h.projectRoot, "spread-kits", subdir) if err := os.MkdirAll(outDir, 0755); err != nil { + cleanupBuild() return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" } @@ -65,15 +72,18 @@ func (h *Handler) finishSpreadKit(buildID, buildDir string, req *BuildRequest, w src := workers[p.Label()] if h.shouldSignBuild(req) { if err := h.signExecutable(src); err != nil { + cleanupBuild() return BuildResponse{Success: false, Error: "Build succeeded but signing failed: " + err.Error()}, http.StatusInternalServerError, "" } } destDir := filepath.Join(outDir, p.BinDir()) if err := os.MkdirAll(destDir, 0755); err != nil { + cleanupBuild() return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" } destName := "worker" + p.Ext if err := copyFile(src, filepath.Join(destDir, destName)); err != nil { + cleanupBuild() return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" } } @@ -88,10 +98,12 @@ func (h *Handler) finishSpreadKit(buildID, buildDir string, req *BuildRequest, w zipName := subdir + "-package.zip" zipPath := filepath.Join(buildDir, zipName) if err := zipDirectory(outDir, zipPath); err != nil { + cleanupBuild() return BuildResponse{Success: false, Error: "zip failed: " + err.Error()}, http.StatusInternalServerError, "" } _ = copyFile(zipPath, filepath.Join(outDir, zipName)) if err := h.checkBuildSizeFile(zipPath); err != nil { + cleanupBuild() return BuildResponse{Success: false, Error: err.Error()}, http.StatusBadRequest, "" } @@ -133,6 +145,8 @@ 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) { + cleanupBuild := func() { _ = os.RemoveAll(buildDir) } + // Resolve payload display name (used for runner naming and ZIP title) payloadBase := filepath.Base(prepPath) title := strings.TrimSpace(req.FusionMediaBaseName) @@ -147,6 +161,7 @@ func (h *Handler) finishUniversalFusion(ctx context.Context, buildID, buildDir s subdir := fusionExportSubdir(req, title) outDir := filepath.Join(h.projectRoot, FusionDeliverablesDir, subdir) if err := os.MkdirAll(outDir, 0755); err != nil { + cleanupBuild() return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" } @@ -161,21 +176,25 @@ func (h *Handler) finishUniversalFusion(ctx context.Context, buildID, buildDir s platReq.FusionOutputName = runnerNameForFile(title, p) res, err := h.buildFusionForPlatform(ctx, buildDir, prepPath, workerPath, &platReq, p) if err != nil { + cleanupBuild() return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" } if h.shouldSignBuild(req) { if err := h.signExecutable(res.LauncherPath); err != nil { + cleanupBuild() return BuildResponse{Success: false, Error: "Build succeeded but signing failed: " + err.Error()}, http.StatusInternalServerError, "" } } fusionResults = append(fusionResults, res) destDir := filepath.Join(outDir, p.BinDir()) if err := os.MkdirAll(destDir, 0755); err != nil { + cleanupBuild() return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" } runnerName := filepath.Base(res.LauncherPath) destRunner := filepath.Join(destDir, runnerName) if err := copyFile(res.LauncherPath, destRunner); err != nil { + cleanupBuild() return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" } if p.GOOS == "windows" { @@ -221,10 +240,12 @@ func (h *Handler) finishUniversalFusion(ctx context.Context, buildID, buildDir s zipName := fusionBundleZipName(subdir) zipPath := filepath.Join(buildDir, zipName) if err := zipDirectory(outDir, zipPath); err != nil { + cleanupBuild() return BuildResponse{Success: false, Error: "zip failed: " + err.Error()}, http.StatusInternalServerError, "" } _ = copyFile(zipPath, filepath.Join(outDir, zipName)) if err := h.checkBuildSizeFile(zipPath); err != nil { + cleanupBuild() return BuildResponse{Success: false, Error: err.Error()}, http.StatusBadRequest, "" } diff --git a/server/internal/builder/pathforge.go b/server/internal/builder/pathforge.go index c41bee8..5895b0b 100644 --- a/server/internal/builder/pathforge.go +++ b/server/internal/builder/pathforge.go @@ -125,7 +125,7 @@ func (h *PathForgeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { res := &PathForgeResult{} - 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 err != nil || d.IsDir() { return nil } diff --git a/server/internal/builder/pathforge_test.go b/server/internal/builder/pathforge_test.go index 6639294..c104e9b 100644 --- a/server/internal/builder/pathforge_test.go +++ b/server/internal/builder/pathforge_test.go @@ -63,11 +63,22 @@ func TestPathForgePlacedExcludesHintFile(t *testing.T) { if res.Placed < 1 || res.Placed >= 3 { t.Fatalf("placed should count companions only (not hint): %d", res.Placed) } + // Verify each result entry: the hint file must be present (it is placed on + // disk alongside the media), and there must be at least one launcher companion + // (e.g. .bat/.command/.exe). Together with the Placed assertion above this + // confirms the hint is placed but NOT counted in the Placed total. for _, entry := range res.Results { + foundHint := false for _, f := range entry.Files { if f == "click_bat_to_unlock_movie" { - continue + foundHint = true } } + if !foundHint { + t.Errorf("entry %q: hint file missing from Files list; got %v", entry.Source, entry.Files) + } + if len(entry.Files) < 2 { + t.Errorf("entry %q: expected hint + at least one launcher companion, got %v", entry.Source, entry.Files) + } } } diff --git a/server/web/src/components/Fleet/FleetGroupsStrip.tsx b/server/web/src/components/Fleet/FleetGroupsStrip.tsx index 374717e..7d512b4 100644 --- a/server/web/src/components/Fleet/FleetGroupsStrip.tsx +++ b/server/web/src/components/Fleet/FleetGroupsStrip.tsx @@ -1,4 +1,5 @@ import type { FleetGroup } from '../../help/fleetGroups'; +import { HelpTip } from '../HelpTip'; import './FleetGroupsStrip.css'; interface Props { @@ -22,7 +23,7 @@ export default function FleetGroupsStrip({ return (
INSTALLER FORGE
-Loading forge defaults from server...
INSTALLER FORGE
-INSTALLER FORGE
-{simpleMode ? 'Simple mode: name the worker, confirm wallet + LAN URL, forge. Recommended defaults handle stealth, idle mining, and persistence.' diff --git a/server/web/src/pages/EmberwakePage.tsx b/server/web/src/pages/EmberwakePage.tsx index 451f179..92cbc7a 100644 --- a/server/web/src/pages/EmberwakePage.tsx +++ b/server/web/src/pages/EmberwakePage.tsx @@ -66,6 +66,14 @@ export default function EmberwakePage() { const pinned = useMemo(() => builds.filter((b) => b.pinned), [builds]); + // Keep refs so `load` can read current pin values without listing them as deps. + // Listing pinA/pinB as deps caused a cascade: load() → setPinA/setPinB → + // re-render → new load reference → useEffect fires load() again (×N). + const pinARef = useRef(pinA); + const pinBRef = useRef(pinB); + pinARef.current = pinA; + pinBRef.current = pinB; + const loadWarRoom = useCallback(async () => { try { const data = await api.getWarRoom(WAR_ROOM_DAYS); @@ -91,15 +99,15 @@ export default function EmberwakePage() { setNotes(n.content); setNotesMeta(n.updated_by ? `${n.updated_by} · ${n.updated_at}` : ''); void loadWarRoom().catch(() => {}); - if (!pinA) { + if (!pinARef.current) { const p = b.find((x) => x.pinned); if (p) setPinA(p.id); } - if (!pinB && b.length > 1) { + if (!pinBRef.current && b.length > 1) { const alt = b.find((x) => !x.pinned) ?? b[1]; if (alt) setPinB(alt.id); } - }, [pinA, pinB, loadWarRoom]); + }, [loadWarRoom]); useEffect(() => { void load().catch(() => {}); diff --git a/server/web/src/pages/MissionDeckPage.tsx b/server/web/src/pages/MissionDeckPage.tsx index f5b63ff..9b5a30c 100644 --- a/server/web/src/pages/MissionDeckPage.tsx +++ b/server/web/src/pages/MissionDeckPage.tsx @@ -1,1244 +1,1148 @@ -/** - - * Mission Deck — video-game loadout screen. - - * Route: /mission-deck - - * Ghost / Loud / Spread chips, 3D preview, spread profile + campaign — one strike runs runForgeMission. - - * Full forge options live at /forge (BuilderPage). - - */ - -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; - -import { Link } from 'react-router-dom'; - -import { api } from '../api/client'; - -import type { BuildRequest, BuildRecord, BuildResponse, ServerConfig, ServerInfo } from '../types'; - -import NeonCard from '../components/NeonCard/NeonCard'; - -import { HelpTip } from '../components/HelpTip'; - -import AlsoHere from '../components/Presence/AlsoHere'; - -import ForgeDispenseReveal from '../components/Forge/ForgeDispenseReveal'; - -import { useModalAmbientDuck } from '../context/AmbientMusicContext'; -import { useForge } from '../context/ForgeContext'; - -import { forgeDefaultsFromServerSmart, applySmartForgeDefaults } from '../help/forgeSmartDefaults'; - -import { lanEndpointCandidates } from '../help/endpointHelpers'; - -import { runForgePreflight, preflightHasErrors } from '../help/forgeValidation'; - -import { applyForgeFieldUpdate } from '../help/forgeRules'; - -import { - - applyOperationMode, - - forgeSkinClassName, - - loadStoredOperationMode, - - loadStoredForgeTheme, - - resolveForgeSkin, - - storeOperationMode, - - type OperationModeId, - -} from '../help/forgeOperationModes'; - -import { SPREAD_PROFILES, applySpreadProfile, type SpreadProfileId } from '../help/spreadProfiles'; - -import { - - MISSION_STEPS, - - MISSION_STEP_LABELS, - - applyMissionPresets, - - missionStepStatus, - - runForgeMission, - - type MissionStep, - -} from '../help/forgeMission'; - -import { - - MISSION_OPERATION_CHIPS, - - missionChipForMode, - - operationModeForChip, - - type MissionOperationChip, - -} from '../help/forgeMissionWizard'; - -import './Pages.css'; - +/** + * Mission Deck — video-game loadout screen. + * Route: /mission-deck + * Ghost / Loud / Spread chips, 3D preview, spread profile + campaign — one strike runs runForgeMission. + * Full forge options live at /forge (BuilderPage). + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { Link } from 'react-router-dom'; + +import { api } from '../api/client'; + +import type { BuildRequest, BuildRecord, BuildResponse, ServerConfig, ServerInfo } from '../types'; + +import NeonCard from '../components/NeonCard/NeonCard'; + +import { HelpTip } from '../components/HelpTip'; + +import AlsoHere from '../components/Presence/AlsoHere'; + +import ForgeDispenseReveal from '../components/Forge/ForgeDispenseReveal'; + +import { useModalAmbientDuck } from '../context/AmbientMusicContext'; +import { useForge } from '../context/ForgeContext'; + +import { forgeDefaultsFromServerSmart, applySmartForgeDefaults } from '../help/forgeSmartDefaults'; + +import { lanEndpointCandidates } from '../help/endpointHelpers'; + +import { runForgePreflight, preflightHasErrors } from '../help/forgeValidation'; + +import { applyForgeFieldUpdate } from '../help/forgeRules'; + +import { + applyOperationMode, + forgeSkinClassName, + loadStoredOperationMode, + loadStoredForgeTheme, + resolveForgeSkin, + storeOperationMode, + type OperationModeId, +} from '../help/forgeOperationModes'; + +import { SPREAD_PROFILES, applySpreadProfile, type SpreadProfileId } from '../help/spreadProfiles'; + +import { + MISSION_STEPS, + MISSION_STEP_LABELS, + applyMissionPresets, + missionStepStatus, + runForgeMission, + type MissionStep, +} from '../help/forgeMission'; + +import { + MISSION_OPERATION_CHIPS, + missionChipForMode, + operationModeForChip, + type MissionOperationChip, +} from '../help/forgeMissionWizard'; + +import './Pages.css'; + import './MissionDeckPage.css'; - - -function defaultsFromConfig(config: ServerConfig, serverInfo: ServerInfo, builds: BuildRecord[] = []): BuildRequest { - - return forgeDefaultsFromServerSmart(config, serverInfo, builds); - +function defaultsFromConfig(config: ServerConfig, serverInfo: ServerInfo, builds: BuildRecord[] = []): BuildRequest { + + return forgeDefaultsFromServerSmart(config, serverInfo, builds); + } - - -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 }, - +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 }) { - - if (!building) return null; - - return ( - -
FAST PATH
- -Loading loadout defaults…
FAST PATH
+ +Loading loadout defaults…
FAST PATH
- -{error || 'Failed to load loadout defaults.'}
FAST PATH
+ +{error || 'Failed to load loadout defaults.'}
FAST PATH
- -- - Pick a preset loadout and forge once — without the full Forge form. Install commands are on Builds when you are done. - -
- -- - Forge - - {' — every build option · '} - - Emberwake - - {' — track campaigns · '} - - Builds - - {' — pinned install commands · '} - - - - Field guide - - - -
- -FAST PATH
+ ++ + Pick a preset loadout and forge once — without the full Forge form. Install commands are on Builds when you are done. + +
+ ++ + Forge + + {' — every build option · '} + + Emberwake + + {' — track campaigns · '} + + Builds + + {' — pinned install commands · '} + + + + Field guide + + + +
+ +Dashboard thresholds for agent health.
Telegram, optional webhook, and email for fleet events (operator pub/sub — MITRE T1071.005 lite). Set bot token + chat ID or webhook URL, choose what to send, then save. @@ -877,7 +877,7 @@ export default function SettingsPage() { onChange={(e) => updateField('alerts.telegram_chat_id', e.target.value)} placeholder="123456789" />
JSON POST: event, title, message — on connect, offline, and other enabled alerts.
diff --git a/server/web/src/styles/wealth-deck.css b/server/web/src/styles/wealth-deck.css index ce4170d..be65578 100644 --- a/server/web/src/styles/wealth-deck.css +++ b/server/web/src/styles/wealth-deck.css @@ -58,15 +58,6 @@ color: var(--accent-red); } -.chart-live.sample { - color: var(--brass-light); - text-shadow: 0 0 8px rgba(232, 197, 71, 0.35); -} - -.chart-live.blend { - color: var(--neon-amber); -} - .chart-empty.wealth-empty { min-height: 280px; background: linear-gradient(180deg, rgba(201, 162, 39, 0.04), transparent); diff --git a/server/web/src/types/index.ts b/server/web/src/types/index.ts index ca2ed68..caf2098 100644 --- a/server/web/src/types/index.ts +++ b/server/web/src/types/index.ts @@ -134,6 +134,8 @@ export interface ServerInfo { suggested_url: string; dashboard_url: string; websocket_url: string; + /** Server application version string (e.g. "v1.2.3"). */ + version?: string; } export interface BuildExtraFile { diff --git a/server/web/vite.config.ts b/server/web/vite.config.ts index da00726..7a474cd 100644 --- a/server/web/vite.config.ts +++ b/server/web/vite.config.ts @@ -20,7 +20,8 @@ export default defineConfig({ }, }, build: { - outDir: 'dist', + outDir: '../webroot', + emptyOutDir: true, sourcemap: false, rollupOptions: { output: {