// Package httpapi serves the Kamado REST API. WebSocket push and static // UI serving land in a follow-up commit. package httpapi import ( "encoding/json" "errors" "io/fs" "log/slog" "net/http" "strings" "github.com/kamadopool/kamado-api/internal/state" "github.com/kamadopool/kamado-api/internal/webui" ) type Server struct { Agg *state.Aggregator Hub *Hub Log *slog.Logger } func New(agg *state.Aggregator, log *slog.Logger) *Server { return &Server{Agg: agg, Hub: NewHub(), Log: log} } // Handler returns an http.Handler with all kamado routes mounted under // /api and the embedded Svelte dashboard served under /. Unknown // non-/api paths fall back to index.html for SPA-style routing. func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /api/health", s.health) mux.HandleFunc("GET /api/pool", s.pool) mux.HandleFunc("GET /api/users", s.users) mux.HandleFunc("GET /api/workers", s.workers) mux.HandleFunc("GET /api/clients", s.clients) mux.HandleFunc("GET /api/blocks", s.blocks) mux.HandleFunc("GET /api/snapshot", s.snapshot) mux.HandleFunc("GET /api/ws", s.handleWS) mux.Handle("/", spaHandler(webui.FS())) return mux } // spaHandler serves static files from the embedded dist tree and // falls back to index.html on 404 so client-side routing works. It // refuses anything under /api to keep the contract with mux patterns // explicit (those routes register their own handlers above). func spaHandler(root fs.FS) http.Handler { fileServer := http.FileServerFS(root) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Defence-in-depth — /api routes are matched by the mux first // with their GET patterns, but a bare POST /api/... would fall // through here. Return 404 so we don't accidentally shadow // API semantics with HTML. if strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/api" { http.NotFound(w, r) return } // Fast path: exact file exists in the embed. clean := strings.TrimPrefix(r.URL.Path, "/") if clean == "" { clean = "index.html" } if _, err := fs.Stat(root, clean); err == nil { fileServer.ServeHTTP(w, r) return } else if !errors.Is(err, fs.ErrNotExist) { http.Error(w, "webui: "+err.Error(), http.StatusInternalServerError) return } // SPA fallback: serve index.html with a 200 so reloads on a // client-side route don't 404. r2 := r.Clone(r.Context()) r2.URL.Path = "/" fileServer.ServeHTTP(w, r2) }) } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } // ---- handlers --------------------------------------------------------- func (s *Server) health(w http.ResponseWriter, r *http.Request) { snap := s.Agg.Snapshot() status := http.StatusOK // Submit-attempt gap: if we've tried to submit more blocks than have // been confirmed, surface the gap. A non-zero gap is a strong signal // even if everything else looks healthy. submitGap := snap.BlockSubmitAttempts - snap.BlockSubmitsConfirmed if submitGap < 0 { submitGap = 0 } // ZMQ freshness: only meaningful if the operator enabled it. Stale // = no event in 30 minutes (typical mainnet block interval is 10 // min, but spikes happen). zmqStale := false if snap.ZMQEnabled && snap.HasLastZMQEvent && snap.LastZMQEventAge > 1800 { zmqStale = true } overallOK := snap.CKPoolOK && snap.BitcoinOK && submitGap == 0 && !zmqStale if !snap.CKPoolOK || !snap.BitcoinOK { status = http.StatusServiceUnavailable } writeJSON(w, status, map[string]any{ "ok": overallOK, "ckpool": snap.CKPoolOK, "bitcoin": snap.BitcoinOK, "submit_attempts": snap.BlockSubmitAttempts, "submits_confirmed": snap.BlockSubmitsConfirmed, "submit_gap": submitGap, "fallback_submits_total": snap.FallbackSubmitsTotal, "last_fallback_submit_at": snap.LastFallbackSubmitAt, "last_fallback_via": snap.LastFallbackVia, "zmq_enabled": snap.ZMQEnabled, "zmq_event_age_seconds": snap.LastZMQEventAge, "zmq_has_event": snap.HasLastZMQEvent, "zmq_stale": zmqStale, "last_error": snap.LastError, }) } func (s *Server) snapshot(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, s.Agg.Snapshot()) } func (s *Server) pool(w http.ResponseWriter, r *http.Request) { snap := s.Agg.Snapshot() writeJSON(w, http.StatusOK, map[string]any{ "pool": snap.Pool, "uptime_seconds": snap.Uptime, "hashrate_hs_1m": snap.HashrateHs, "hashrate_hs_5m": snap.HashrateHs5m, "hashrate_hs_1h": snap.HashrateHs1h, "hashrate_hs_24h": snap.HashrateHs24h, "chain": snap.Chain, "network_hashrate_hs": snap.NetworkHashrateHs, }) } func (s *Server) users(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, s.Agg.Snapshot().Users) } func (s *Server) workers(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, s.Agg.Snapshot().Workers) } func (s *Server) clients(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, s.Agg.Snapshot().Clients) } func (s *Server) blocks(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, s.Agg.Blocks()) }