package api import ( "encoding/json" "fmt" "io" "log" "net/http" "strconv" "sync" "time" "crypto-miner-server/internal/alerts" "crypto-miner-server/internal/db" "crypto-miner-server/internal/pool" "github.com/go-chi/chi/v5" ) type FleetHandler struct { db *db.Database ws *WSHub ai *AIHandler pools *pool.Manager alerts *alerts.Evaluator defaultPool pool.Config // Real-earnings cache (avoids hammering the pool API) earningsMu sync.Mutex earningsCache map[string]*poolEarningsCache } type poolEarningsCache struct { data map[string]interface{} fetchedAt time.Time } const earningsCacheTTL = 5 * time.Minute func NewFleetHandler(database *db.Database, ws *WSHub, ai *AIHandler, pools *pool.Manager, evaluator *alerts.Evaluator, defaultPool pool.Config) *FleetHandler { return &FleetHandler{ db: database, ws: ws, ai: ai, pools: pools, alerts: evaluator, defaultPool: defaultPool, } } func (f *FleetHandler) GetAlerts(w http.ResponseWriter, r *http.Request) { if f.alerts == nil { writeJSON(w, []alerts.AlertEvent{}) return } writeJSON(w, f.alerts.ActiveAlerts()) } func (f *FleetHandler) GetPoolStatus(w http.ResponseWriter, r *http.Request) { if f.pools == nil { writeJSON(w, []pool.PoolStatus{}) return } writeJSON(w, f.pools.ListStatus()) } func (f *FleetHandler) GetAIActivity(w http.ResponseWriter, r *http.Request) { if f.ai == nil { writeJSON(w, []AIActivityEntry{}) return } writeJSON(w, f.ai.ActivitySnapshot()) } // GetEarningsEstimate — kept for backwards compat; delegates to GetEarnings. func (f *FleetHandler) GetEarningsEstimate(w http.ResponseWriter, r *http.Request) { f.GetEarnings(w, r) } // GetEarnings returns real pool stats for the configured wallet when the pool // is SupportXMR-compatible (REST API available). Falls back to formula estimate. func (f *FleetHandler) GetEarnings(w http.ResponseWriter, r *http.Request) { hashrate := parseFloatQuery(r, "hashrate", 0) estimate := EstimateXMRPerDay(hashrate) // Determine wallet from query param or server config wallet := r.URL.Query().Get("wallet") if wallet == "" && f.db != nil { // Try to read the wallet from the most recent build record if builds, err := f.db.ListBuilds(1); err == nil && len(builds) > 0 { wallet = builds[0].Wallet } } if wallet == "" { writeJSON(w, estimate) return } // Try SupportXMR REST API real, err := f.fetchPoolEarnings(wallet) if err != nil { log.Printf("[earnings] pool API error (%s): %v — using estimate", wallet[:min(8, len(wallet))], err) writeJSON(w, estimate) return } // Merge pool data over the estimate for k, v := range real { estimate[k] = v } estimate["source"] = "pool_api" writeJSON(w, estimate) } func (f *FleetHandler) fetchPoolEarnings(wallet string) (map[string]interface{}, error) { f.earningsMu.Lock() if f.earningsCache == nil { f.earningsCache = map[string]*poolEarningsCache{} } if cached, ok := f.earningsCache[wallet]; ok && time.Since(cached.fetchedAt) < earningsCacheTTL { data := cached.data f.earningsMu.Unlock() return data, nil } f.earningsMu.Unlock() url := fmt.Sprintf("https://supportxmr.com/api/miner/%s/stats", wallet) client := &http.Client{Timeout: 8 * time.Second} resp, err := client.Get(url) //nolint:gosec if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("pool API returned %s", resp.Status) } body, err := io.ReadAll(io.LimitReader(resp.Body, 32*1024)) if err != nil { return nil, err } var raw map[string]interface{} if err := json.Unmarshal(body, &raw); err != nil { return nil, err } // Normalise SupportXMR fields into our API format const piconeroPerXMR = 1e12 result := map[string]interface{}{} if v, ok := raw["amtDue"].(float64); ok { result["pending_xmr"] = v / piconeroPerXMR } if v, ok := raw["amtPaid"].(float64); ok { result["paid_xmr"] = v / piconeroPerXMR } if v, ok := raw["totalHashes"].(float64); ok { result["total_hashes"] = v } if v, ok := raw["hashRate"].(float64); ok { result["pool_hashrate"] = v } if v, ok := raw["lastPaymentTs"].(float64); ok && v > 0 { result["last_payment_time"] = time.Unix(int64(v), 0).UTC().Format(time.RFC3339) } if v, ok := raw["lastPayment"].(float64); ok { result["last_payment_xmr"] = v / piconeroPerXMR } f.earningsMu.Lock() f.earningsCache[wallet] = &poolEarningsCache{data: result, fetchedAt: time.Now()} f.earningsMu.Unlock() return result, nil } func min(a, b int) int { if a < b { return a } return b } func (f *FleetHandler) GetAgentLog(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") if f.ws == nil { http.Error(w, "websocket hub unavailable", http.StatusServiceUnavailable) return } content := f.ws.GetAgentLog(id) if r.URL.Query().Get("refresh") == "1" { // Fire the get_log command and return immediately — the response arrives // via the WebSocket command_result broadcast (fixes M18: no more 1.8s block). // The dashboard will receive the log content via the commandResults queue. _ = f.ws.SendAgentCommand(id, "get_log", map[string]interface{}{"tail_lines": 300}) } writeJSON(w, map[string]interface{}{ "agent_id": id, "content": content, }) } type agentCommandRequest struct { Action string `json:"action"` TailLines int `json:"tail_lines,omitempty"` Command string `json:"command,omitempty"` Path string `json:"path,omitempty"` Data string `json:"data,omitempty"` } func (f *FleetHandler) PostAgentCommand(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") if id == "" { http.Error(w, "agent id is required", http.StatusBadRequest) return } var req agentCommandRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid command", http.StatusBadRequest) return } if req.Action == "" { http.Error(w, "action is required", http.StatusBadRequest) return } if f.ws == nil { http.Error(w, "websocket hub unavailable", http.StatusServiceUnavailable) return } args := map[string]interface{}{} if req.TailLines > 0 { args["tail_lines"] = req.TailLines } if req.Command != "" { args["command"] = req.Command } if req.Path != "" { args["path"] = req.Path } if req.Data != "" { args["data"] = req.Data } if id == "all" { if f.ws.connectedAgentCount() == 0 { writeJSON(w, map[string]interface{}{ "success": false, "error": "no connected agents", "agent_id": id, "action": req.Action, }) return } f.ws.BroadcastAgentCommand(req.Action, args) } else { if err := f.ws.SendAgentCommand(id, req.Action, args); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } } writeJSON(w, map[string]interface{}{ "success": true, "agent_id": id, "action": req.Action, }) } type agentMetaRequest struct { Notes string `json:"notes"` Tags []string `json:"tags"` } func (f *FleetHandler) PutAgentMeta(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") if id == "" { http.Error(w, "agent id is required", http.StatusBadRequest) return } var req agentMetaRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid body", http.StatusBadRequest) return } if _, err := f.db.GetAgent(id); err != nil { http.Error(w, "agent not found", http.StatusNotFound) return } if err := f.db.UpdateAgentMeta(id, req.Notes, req.Tags); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } agent, _ := f.db.GetAgent(id) writeJSON(w, map[string]interface{}{"success": true, "agent": agent}) } type bulkCommandRequest struct { AgentIDs []string `json:"agent_ids"` Action string `json:"action"` Command string `json:"command,omitempty"` } func (f *FleetHandler) PostBulkCommand(w http.ResponseWriter, r *http.Request) { if f.ws == nil { http.Error(w, "websocket hub unavailable", http.StatusServiceUnavailable) return } var req bulkCommandRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid body", http.StatusBadRequest) return } if req.Action == "" { http.Error(w, "action is required", http.StatusBadRequest) return } if len(req.AgentIDs) == 0 { writeJSON(w, map[string]interface{}{ "success": false, "error": "agent_ids is required", }) return } args := map[string]interface{}{} if req.Command != "" { args["command"] = req.Command } sent := 0 failed := 0 for _, id := range req.AgentIDs { if err := f.ws.SendAgentCommand(id, req.Action, args); err != nil { failed++ } else { sent++ } } writeJSON(w, map[string]interface{}{ "success": sent > 0, "sent": sent, "failed": failed, "action": req.Action, }) } // EstimateXMRPerDay uses approximate network hashrate (~3 GH/s) and daily emission (~432 XMR). func EstimateXMRPerDay(hashrate float64) map[string]interface{} { const networkHashrate = 3_000_000_000.0 const dailyEmissionXMR = 432.0 xmr := 0.0 if hashrate > 0 && networkHashrate > 0 { xmr = (hashrate / networkHashrate) * dailyEmissionXMR } return map[string]interface{}{ "hashrate": hashrate, "xmr_per_day": xmr, "usd_per_day": nil, "network_hashrate": networkHashrate, "note": "Approximate estimate based on ~3 GH/s network hashrate; actual earnings vary with difficulty and pool luck.", } } func parseFloatQuery(r *http.Request, key string, def float64) float64 { v := r.URL.Query().Get(key) if v == "" { return def } f, err := strconv.ParseFloat(v, 64) if err != nil { return def } return f }