From c0efdfb0cad3d5d322ff54614ad7dfccd81241ce Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 23:23:13 +0000 Subject: [PATCH] refactor(api): unify monitoring documents for AX --- api/router.go | 192 +++------------------------------------------ api/router_test.go | 104 ++++++++++++++++++++++++ api_rows.go | 70 +++++++++++++++++ state_impl.go | 84 ++++++-------------- 4 files changed, 209 insertions(+), 241 deletions(-) create mode 100644 api/router_test.go diff --git a/api/router.go b/api/router.go index 0ed146f..ddbb800 100644 --- a/api/router.go +++ b/api/router.go @@ -12,7 +12,6 @@ package api import ( "encoding/json" "net/http" - "time" "dappco.re/go/proxy" ) @@ -22,195 +21,30 @@ type Router interface { HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) } -// SummaryResponse is the /1/summary JSON body. -// -// {"version":"1.0.0","mode":"nicehash","hashrate":{"total":[...]}, ...} -type SummaryResponse struct { - Version string `json:"version"` - Mode string `json:"mode"` - Hashrate HashrateResponse `json:"hashrate"` - Miners MinersCountResponse `json:"miners"` - Workers uint64 `json:"workers"` - Upstreams UpstreamResponse `json:"upstreams"` - Results ResultsResponse `json:"results"` - CustomDiffStats map[uint64]proxy.CustomDiffBucketStats `json:"custom_diff_stats,omitempty"` -} - -// HashrateResponse carries the per-window hashrate array. -// -// HashrateResponse{Total: [6]float64{12345.67, 11900.00, 12100.00, 11800.00, 12000.00, 12200.00}} -type HashrateResponse struct { - Total [6]float64 `json:"total"` -} - -// MinersCountResponse carries current and peak miner counts. -// -// MinersCountResponse{Now: 142, Max: 200} -type MinersCountResponse struct { - Now uint64 `json:"now"` - Max uint64 `json:"max"` -} - -// UpstreamResponse carries pool connection state counts. -// -// UpstreamResponse{Active: 1, Sleep: 0, Error: 0, Total: 1, Ratio: 142.0} -type UpstreamResponse struct { - Active uint64 `json:"active"` - Sleep uint64 `json:"sleep"` - Error uint64 `json:"error"` - Total uint64 `json:"total"` - Ratio float64 `json:"ratio"` -} - -// ResultsResponse carries share acceptance statistics. -// -// ResultsResponse{Accepted: 4821, Rejected: 3, Invalid: 0, Expired: 12} -type ResultsResponse struct { - Accepted uint64 `json:"accepted"` - Rejected uint64 `json:"rejected"` - Invalid uint64 `json:"invalid"` - Expired uint64 `json:"expired"` - AvgTime uint32 `json:"avg_time"` - Latency uint32 `json:"latency"` - HashesTotal uint64 `json:"hashes_total"` - Best [10]uint64 `json:"best"` -} - -// WorkersResponse is the /1/workers JSON body. -// -// {"mode":"rig-id","workers":[["rig-alpha","10.0.0.1",1,10,0,0,100000,1712232000,1.0,1.0,1.0,1.0,1.0]]} -type WorkersResponse struct { - Mode string `json:"mode"` - Workers []proxy.WorkerRow `json:"workers"` -} - -// MinersResponse is the /1/miners JSON body. -// -// {"format":["id","ip","tx","rx","state","diff","user","password","rig_id","agent"],"miners":[[1,"10.0.0.1:49152",4096,512,2,100000,"WALLET","********","rig-alpha","XMRig/6.21.0"]]} -type MinersResponse struct { - Format []string `json:"format"` - Miners []proxy.MinerRow `json:"miners"` -} - // RegisterRoutes wires the monitoring endpoints onto the supplied router. // // proxyapi.RegisterRoutes(mux, p) // // GET /1/summary, /1/workers, and /1/miners are now live. -func RegisterRoutes(r Router, p *proxy.Proxy) { - if r == nil || p == nil { +func RegisterRoutes(router Router, p *proxy.Proxy) { + if router == nil || p == nil { return } - r.HandleFunc("/1/summary", func(w http.ResponseWriter, req *http.Request) { - writeJSON(w, summaryResponse(p)) - }) - r.HandleFunc("/1/workers", func(w http.ResponseWriter, req *http.Request) { - writeJSON(w, workersResponse(p)) - }) - r.HandleFunc("/1/miners", func(w http.ResponseWriter, req *http.Request) { - writeJSON(w, minersResponse(p)) - }) + registerGETRoute(router, "/1/summary", func() any { return p.SummaryDocument() }) + registerGETRoute(router, "/1/workers", func() any { return p.WorkersDocument() }) + registerGETRoute(router, "/1/miners", func() any { return p.MinersDocument() }) } -func summaryResponse(p *proxy.Proxy) SummaryResponse { - summary := p.Summary() - now, max := p.MinerCount() - upstreams := p.Upstreams() - return SummaryResponse{ - Version: "1.0.0", - Mode: p.Mode(), - Hashrate: HashrateResponse{ - Total: summary.Hashrate, - }, - CustomDiffStats: summary.CustomDiffStats, - Miners: MinersCountResponse{ - Now: now, - Max: max, - }, - Workers: uint64(len(p.WorkerRecords())), - Upstreams: UpstreamResponse{ - Active: upstreams.Active, - Sleep: upstreams.Sleep, - Error: upstreams.Error, - Total: upstreams.Total, - Ratio: upstreamRatio(now, upstreams.Total), - }, - Results: ResultsResponse{ - Accepted: summary.Accepted, - Rejected: summary.Rejected, - Invalid: summary.Invalid, - Expired: summary.Expired, - AvgTime: summary.AvgTime, - Latency: summary.AvgLatency, - HashesTotal: summary.Hashes, - Best: summary.TopDiff, - }, - } -} - -func workersResponse(p *proxy.Proxy) WorkersResponse { - records := p.WorkerRecords() - rows := make([]proxy.WorkerRow, 0, len(records)) - for _, record := range records { - rows = append(rows, proxy.WorkerRow{ - record.Name, - record.LastIP, - record.Connections, - record.Accepted, - record.Rejected, - record.Invalid, - record.Hashes, - unixOrZero(record.LastHashAt), - record.Hashrate(60), - record.Hashrate(600), - record.Hashrate(3600), - record.Hashrate(43200), - record.Hashrate(86400), - }) - } - return WorkersResponse{ - Mode: string(p.WorkersMode()), - Workers: rows, - } -} - -func minersResponse(p *proxy.Proxy) MinersResponse { - records := p.MinerSnapshots() - rows := make([]proxy.MinerRow, 0, len(records)) - for _, miner := range records { - rows = append(rows, proxy.MinerRow{ - miner.ID, - miner.IP, - miner.TX, - miner.RX, - miner.State, - miner.Diff, - miner.User, - miner.Password, - miner.RigID, - miner.Agent, - }) - } - return MinersResponse{ - Format: []string{"id", "ip", "tx", "rx", "state", "diff", "user", "password", "rig_id", "agent"}, - Miners: rows, - } +func registerGETRoute(router Router, pattern string, document func() any) { + router.HandleFunc(pattern, func(w http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + writeJSON(w, document()) + }) } func writeJSON(w http.ResponseWriter, payload any) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(payload) } - -func upstreamRatio(now, total uint64) float64 { - if total == 0 { - return 0 - } - return float64(now) / float64(total) -} - -func unixOrZero(value time.Time) int64 { - if value.IsZero() { - return 0 - } - return value.Unix() -} diff --git a/api/router_test.go b/api/router_test.go new file mode 100644 index 0000000..f4cf1b9 --- /dev/null +++ b/api/router_test.go @@ -0,0 +1,104 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "dappco.re/go/proxy" +) + +func TestRegisterRoutes_GETSummary_Good(t *testing.T) { + config := &proxy.Config{ + Mode: "nicehash", + Workers: proxy.WorkersByRigID, + Bind: []proxy.BindAddr{{Host: "127.0.0.1", Port: 3333}}, + Pools: []proxy.PoolConfig{{URL: "pool.example:3333", Enabled: true}}, + } + p, result := proxy.New(config) + if !result.OK { + t.Fatalf("new proxy: %v", result.Error) + } + + router := http.NewServeMux() + RegisterRoutes(router, p) + + request := httptest.NewRequest(http.MethodGet, "/1/summary", nil) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("expected %d, got %d", http.StatusOK, recorder.Code) + } + + var document proxy.SummaryDocument + if err := json.Unmarshal(recorder.Body.Bytes(), &document); err != nil { + t.Fatalf("decode summary document: %v", err) + } + if document.Mode != "nicehash" { + t.Fatalf("expected mode %q, got %q", "nicehash", document.Mode) + } + if document.Version != "1.0.0" { + t.Fatalf("expected version %q, got %q", "1.0.0", document.Version) + } +} + +func TestRegisterRoutes_POSTSummary_Bad(t *testing.T) { + config := &proxy.Config{ + Mode: "nicehash", + Workers: proxy.WorkersByRigID, + Bind: []proxy.BindAddr{{Host: "127.0.0.1", Port: 3333}}, + Pools: []proxy.PoolConfig{{URL: "pool.example:3333", Enabled: true}}, + } + p, result := proxy.New(config) + if !result.OK { + t.Fatalf("new proxy: %v", result.Error) + } + + router := http.NewServeMux() + RegisterRoutes(router, p) + + request := httptest.NewRequest(http.MethodPost, "/1/summary", nil) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected %d, got %d", http.StatusMethodNotAllowed, recorder.Code) + } +} + +func TestRegisterRoutes_GETMiners_Ugly(t *testing.T) { + config := &proxy.Config{ + Mode: "simple", + Workers: proxy.WorkersDisabled, + Bind: []proxy.BindAddr{{Host: "127.0.0.1", Port: 3333}}, + Pools: []proxy.PoolConfig{{URL: "pool.example:3333", Enabled: true}}, + } + p, result := proxy.New(config) + if !result.OK { + t.Fatalf("new proxy: %v", result.Error) + } + + router := http.NewServeMux() + RegisterRoutes(router, p) + + request := httptest.NewRequest(http.MethodGet, "/1/miners", nil) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("expected %d, got %d", http.StatusOK, recorder.Code) + } + + var document proxy.MinersDocument + if err := json.Unmarshal(recorder.Body.Bytes(), &document); err != nil { + t.Fatalf("decode miners document: %v", err) + } + if len(document.Format) != 10 { + t.Fatalf("expected 10 miner columns, got %d", len(document.Format)) + } + if len(document.Miners) != 0 { + t.Fatalf("expected no miners in a new proxy, got %d", len(document.Miners)) + } +} diff --git a/api_rows.go b/api_rows.go index d869c74..b256420 100644 --- a/api_rows.go +++ b/api_rows.go @@ -9,3 +9,73 @@ type WorkerRow [13]any // // MinerRow{1, "10.0.0.1:49152", 4096, 512, 2, 100000, "WALLET", "********", "rig-alpha", "XMRig/6.21.0"} type MinerRow [10]any + +// SummaryDocument is the RFC-shaped /1/summary response body. +// +// doc := p.SummaryDocument() +type SummaryDocument struct { + Version string `json:"version"` + Mode string `json:"mode"` + Hashrate HashrateDocument `json:"hashrate"` + Miners MinersCountDocument `json:"miners"` + Workers uint64 `json:"workers"` + Upstreams UpstreamDocument `json:"upstreams"` + Results ResultsDocument `json:"results"` + CustomDiffStats map[uint64]CustomDiffBucketStats `json:"custom_diff_stats,omitempty"` +} + +// HashrateDocument carries the per-window hashrate array. +// +// HashrateDocument{Total: [6]float64{12345.67, 11900.00, 12100.00, 11800.00, 12000.00, 12200.00}} +type HashrateDocument struct { + Total [6]float64 `json:"total"` +} + +// MinersCountDocument carries current and peak miner counts. +// +// MinersCountDocument{Now: 142, Max: 200} +type MinersCountDocument struct { + Now uint64 `json:"now"` + Max uint64 `json:"max"` +} + +// UpstreamDocument carries pool connection state counts. +// +// UpstreamDocument{Active: 1, Sleep: 0, Error: 0, Total: 1, Ratio: 142.0} +type UpstreamDocument struct { + Active uint64 `json:"active"` + Sleep uint64 `json:"sleep"` + Error uint64 `json:"error"` + Total uint64 `json:"total"` + Ratio float64 `json:"ratio"` +} + +// ResultsDocument carries share acceptance statistics. +// +// ResultsDocument{Accepted: 4821, Rejected: 3, Invalid: 0, Expired: 12} +type ResultsDocument struct { + Accepted uint64 `json:"accepted"` + Rejected uint64 `json:"rejected"` + Invalid uint64 `json:"invalid"` + Expired uint64 `json:"expired"` + AvgTime uint32 `json:"avg_time"` + Latency uint32 `json:"latency"` + HashesTotal uint64 `json:"hashes_total"` + Best [10]uint64 `json:"best"` +} + +// WorkersDocument is the RFC-shaped /1/workers response body. +// +// doc := p.WorkersDocument() +type WorkersDocument struct { + Mode string `json:"mode"` + Workers []WorkerRow `json:"workers"` +} + +// MinersDocument is the RFC-shaped /1/miners response body. +// +// doc := p.MinersDocument() +type MinersDocument struct { + Format []string `json:"format"` + Miners []MinerRow `json:"miners"` +} diff --git a/state_impl.go b/state_impl.go index 47494d8..bab56d3 100644 --- a/state_impl.go +++ b/state_impl.go @@ -601,7 +601,7 @@ func (p *Proxy) startHTTP() bool { w.WriteHeader(status) return } - p.writeJSON(w, p.summaryDocument()) + p.writeJSON(w, p.SummaryDocument()) }) mux.HandleFunc("/1/workers", func(w http.ResponseWriter, r *http.Request) { if status, ok := p.allowHTTP(r); !ok { @@ -611,7 +611,7 @@ func (p *Proxy) startHTTP() bool { w.WriteHeader(status) return } - p.writeJSON(w, p.workersDocument()) + p.writeJSON(w, p.WorkersDocument()) }) mux.HandleFunc("/1/miners", func(w http.ResponseWriter, r *http.Request) { if status, ok := p.allowHTTP(r); !ok { @@ -621,7 +621,7 @@ func (p *Proxy) startHTTP() bool { w.WriteHeader(status) return } - p.writeJSON(w, p.minersDocument()) + p.writeJSON(w, p.MinersDocument()) }) addr := net.JoinHostPort(p.config.HTTP.Host, strconv.Itoa(int(p.config.HTTP.Port))) listener, err := net.Listen("tcp", addr) @@ -659,73 +659,27 @@ func (p *Proxy) writeJSON(w http.ResponseWriter, payload any) { _ = json.NewEncoder(w).Encode(payload) } -type summaryDocumentPayload struct { - Version string `json:"version"` - Mode string `json:"mode"` - Hashrate summaryHashratePayload `json:"hashrate"` - Miners summaryMinersPayload `json:"miners"` - Workers uint64 `json:"workers"` - Upstreams summaryUpstreamsPayload `json:"upstreams"` - Results summaryResultsPayload `json:"results"` - CustomDiffStats map[uint64]CustomDiffBucketStats `json:"custom_diff_stats,omitempty"` -} - -type summaryHashratePayload struct { - Total [6]float64 `json:"total"` -} - -type summaryMinersPayload struct { - Now uint64 `json:"now"` - Max uint64 `json:"max"` -} - -type summaryUpstreamsPayload struct { - Active uint64 `json:"active"` - Sleep uint64 `json:"sleep"` - Error uint64 `json:"error"` - Total uint64 `json:"total"` - Ratio float64 `json:"ratio"` -} - -type summaryResultsPayload struct { - Accepted uint64 `json:"accepted"` - Rejected uint64 `json:"rejected"` - Invalid uint64 `json:"invalid"` - Expired uint64 `json:"expired"` - AvgTime uint32 `json:"avg_time"` - Latency uint32 `json:"latency"` - HashesTotal uint64 `json:"hashes_total"` - Best [10]uint64 `json:"best"` -} - -type workersDocumentPayload struct { - Mode string `json:"mode"` - Workers []WorkerRow `json:"workers"` -} - -type minersDocumentPayload struct { - Format []string `json:"format"` - Miners []MinerRow `json:"miners"` -} - -func (p *Proxy) summaryDocument() summaryDocumentPayload { +// SummaryDocument builds the RFC-shaped /1/summary response body. +// +// doc := p.SummaryDocument() +func (p *Proxy) SummaryDocument() SummaryDocument { summary := p.Summary() now, max := p.MinerCount() upstreams := p.Upstreams() - return summaryDocumentPayload{ + return SummaryDocument{ Version: "1.0.0", Mode: p.Mode(), - Hashrate: summaryHashratePayload{ + Hashrate: HashrateDocument{ Total: summary.Hashrate, }, CustomDiffStats: summary.CustomDiffStats, - Miners: summaryMinersPayload{ + Miners: MinersCountDocument{ Now: now, Max: max, }, Workers: uint64(len(p.WorkerRecords())), - Upstreams: summaryUpstreamsPayload{Active: upstreams.Active, Sleep: upstreams.Sleep, Error: upstreams.Error, Total: upstreams.Total, Ratio: upstreamRatio(now, upstreams)}, - Results: summaryResultsPayload{ + Upstreams: UpstreamDocument{Active: upstreams.Active, Sleep: upstreams.Sleep, Error: upstreams.Error, Total: upstreams.Total, Ratio: upstreamRatio(now, upstreams)}, + Results: ResultsDocument{ Accepted: summary.Accepted, Rejected: summary.Rejected, Invalid: summary.Invalid, @@ -738,7 +692,10 @@ func (p *Proxy) summaryDocument() summaryDocumentPayload { } } -func (p *Proxy) workersDocument() workersDocumentPayload { +// WorkersDocument builds the RFC-shaped /1/workers response body. +// +// doc := p.WorkersDocument() +func (p *Proxy) WorkersDocument() WorkersDocument { records := p.WorkerRecords() rows := make([]WorkerRow, 0, len(records)) for _, record := range records { @@ -758,13 +715,16 @@ func (p *Proxy) workersDocument() workersDocumentPayload { record.Hashrate(86400), }) } - return workersDocumentPayload{ + return WorkersDocument{ Mode: string(p.WorkersMode()), Workers: rows, } } -func (p *Proxy) minersDocument() minersDocumentPayload { +// MinersDocument builds the RFC-shaped /1/miners response body. +// +// doc := p.MinersDocument() +func (p *Proxy) MinersDocument() MinersDocument { records := p.MinerSnapshots() rows := make([]MinerRow, 0, len(records)) for _, miner := range records { @@ -781,7 +741,7 @@ func (p *Proxy) minersDocument() minersDocumentPayload { miner.Agent, }) } - return minersDocumentPayload{ + return MinersDocument{ Format: []string{"id", "ip", "tx", "rx", "state", "diff", "user", "password", "rig_id", "agent"}, Miners: rows, }