Removes the entire fallback submitter mechanism: ckpool patch 0004,
the blocksubmit package, the wiring in main.go, the config fields,
the aggregator's fallback counters and snapshot fields, the healthz
fallback fields, and the TS type fields plus the HealthBanners
fallback alert.
Reasoning: ckpool's primary bitcoind submission must remain the
single source of truth, and getting the parallel "race a fallback
during the submit" semantics right is more architectural complexity
than the marginal reliability gain justifies. The original upstream
behavior — submit to bitcoind, retry indefinitely if unavailable —
is what we want.
Kept intact:
* Submit-attempt vs confirmed counters (block_submit_attempts /
block_submits_confirmed). Useful on their own as a "did bitcoind
confirm the submission?" signal.
* HealthBanners shows submit_gap and zmq_stale only.
* /healthz exposes submit_gap, zmq_stale, etc.
* All P0 reliability work (tailer cursor, reconcile loop, reorg
detection, multi-solve guard) and other P1 (RPC retry, WS
back-pressure, ZMQ tracking, startup readiness gate).
162 lines
5.1 KiB
Go
162 lines
5.1 KiB
Go
// 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,
|
|
"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())
|
|
}
|