Skip to content

Commit b471b2c

Browse files
committed
feat(api): add /v1/stats/nodes endpoint for traffic breakdown by server
1 parent 95ff7bf commit b471b2c

5 files changed

Lines changed: 173 additions & 24 deletions

File tree

docs/api.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,11 +216,24 @@ The join token is embedded once and expires in 24h; `/regen-join` issues a new o
216216

217217
```
218218
GET $BASE/v1/stats/series?user_id=5&from=2026-01-01&to=2026-01-31 → daily traffic points
219+
GET $BASE/v1/stats/nodes?user_id=5&from=2026-01-01&to=2026-01-31 → traffic split by server
219220
GET $BASE/v1/stats/users?from=2026-01-01&to=2026-01-31 → per-user totals
220221
```
221222

222-
`user_id` is optional on `series` (omit for a panel-wide series). `from`/`to` are
223-
`YYYY-MM-DD` (in the panel's configured timezone).
223+
`user_id` is optional on `series` and `nodes` (omit for a panel-wide figure).
224+
`from`/`to` are `YYYY-MM-DD` (in the panel's configured timezone).
225+
226+
`nodes` breaks the same traffic down by the server that carried it, busiest first —
227+
`series` tells you how much, this tells you where. `node_id` is `0` for the panel's
228+
own server; names are resolved for you, including servers deleted since (their
229+
traffic rows outlive them).
230+
231+
```json
232+
{ "data": [
233+
{ "node_id": 2, "name": "NL", "up": 46059475, "down": 1488367869 },
234+
{ "node_id": 0, "name": "Этот сервер", "up": 52711616, "down": 3901246326 }
235+
] }
236+
```
224237

225238
### Monitoring
226239

internal/server/api_v1.go

Lines changed: 64 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,16 @@ type (
6969
// the two docs routes take precedence over the authenticated catch-all.
7070
func (rt *Router) apiHandler() http.Handler {
7171
mux := http.NewServeMux()
72-
mux.HandleFunc("GET /v1/openapi.json", rt.apiOpenAPI)
73-
mux.HandleFunc("GET /v1/docs", rt.apiDocs)
74-
mux.HandleFunc("GET /v1/healthz", rt.apiHealthz)
72+
// Recorded like the authenticated routes below, so the OpenAPI coverage test sees
73+
// the whole /v1 surface and not just the part behind apiAuth.
74+
for pattern, h := range map[string]http.HandlerFunc{
75+
"GET /v1/openapi.json": rt.apiOpenAPI,
76+
"GET /v1/docs": rt.apiDocs,
77+
"GET /v1/healthz": rt.apiHealthz,
78+
} {
79+
rt.apiRoutes = append(rt.apiRoutes, pattern)
80+
mux.HandleFunc(pattern, h)
81+
}
7582
mux.Handle("/", rt.apiAuth(rt.apiMux()))
7683
return mux
7784
}
@@ -103,8 +110,16 @@ func (rt *Router) apiHealthz(w http.ResponseWriter, _ *http.Request) {
103110
// caller (apiAuth), so every route here already has a valid key.
104111
func (rt *Router) apiMux() http.Handler {
105112
mux := http.NewServeMux()
113+
// Every /v1 registration goes through hf so the route table is recorded as it is
114+
// built: TestAPISpecCoversEveryRoute reads it back and fails when an endpoint
115+
// ships without an OpenAPI entry. GET /v1/health had drifted that way — reachable,
116+
// documented in docs/api.md, absent from the generated spec.
117+
hf := func(pattern string, h http.HandlerFunc) {
118+
rt.apiRoutes = append(rt.apiRoutes, pattern)
119+
mux.HandleFunc(pattern, h)
120+
}
106121
id := func(pattern string, h func(http.ResponseWriter, *http.Request, int64)) {
107-
mux.HandleFunc(pattern, func(w http.ResponseWriter, r *http.Request) {
122+
hf(pattern, func(w http.ResponseWriter, r *http.Request) {
108123
v, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
109124
if err != nil {
110125
writeAPIErr(w, http.StatusBadRequest, "bad_request", "invalid id")
@@ -114,11 +129,11 @@ func (rt *Router) apiMux() http.Handler {
114129
})
115130
}
116131

117-
mux.HandleFunc("GET /v1/health", rt.apiHealth)
132+
hf("GET /v1/health", rt.apiHealth)
118133

119-
mux.HandleFunc("GET /v1/users", rt.apiListUsers)
120-
mux.HandleFunc("POST /v1/users", rt.apiCreateUser)
121-
mux.HandleFunc("POST /v1/users/bulk", rt.apiBulkUsers)
134+
hf("GET /v1/users", rt.apiListUsers)
135+
hf("POST /v1/users", rt.apiCreateUser)
136+
hf("POST /v1/users/bulk", rt.apiBulkUsers)
122137
id("GET /v1/users/{id}", rt.apiGetUser)
123138
id("PATCH /v1/users/{id}", rt.apiPatchUser)
124139
id("DELETE /v1/users/{id}", rt.apiDeleteUser)
@@ -128,21 +143,22 @@ func (rt *Router) apiMux() http.Handler {
128143
id("POST /v1/users/{id}/plan", rt.apiApplyPlan)
129144
id("GET /v1/users/{id}/connections", rt.apiUserConnections)
130145

131-
mux.HandleFunc("GET /v1/billing/providers", rt.apiListProviders)
132-
mux.HandleFunc("GET /v1/billing/plans", rt.apiListPlans)
133-
mux.HandleFunc("POST /v1/billing/plans", rt.apiSavePlan)
146+
hf("GET /v1/billing/providers", rt.apiListProviders)
147+
hf("GET /v1/billing/plans", rt.apiListPlans)
148+
hf("POST /v1/billing/plans", rt.apiSavePlan)
134149
id("DELETE /v1/billing/plans/{id}", rt.apiDeletePlan)
135-
mux.HandleFunc("GET /v1/billing/orders", rt.apiListOrders)
136-
mux.HandleFunc("POST /v1/billing/orders", rt.apiCreateOrder)
150+
hf("GET /v1/billing/orders", rt.apiListOrders)
151+
hf("POST /v1/billing/orders", rt.apiCreateOrder)
137152
id("POST /v1/billing/orders/{id}/confirm", rt.apiConfirmOrder)
138153
id("POST /v1/billing/orders/{id}/cancel", rt.apiCancelOrder)
139154

140-
mux.HandleFunc("GET /v1/stats/series", rt.apiStatsSeries)
141-
mux.HandleFunc("GET /v1/stats/users", rt.apiStatsUsers)
155+
hf("GET /v1/stats/series", rt.apiStatsSeries)
156+
hf("GET /v1/stats/nodes", rt.apiStatsNodes)
157+
hf("GET /v1/stats/users", rt.apiStatsUsers)
142158

143-
mux.HandleFunc("GET /v1/summary", rt.apiSummary)
144-
mux.HandleFunc("GET /v1/system", rt.apiSystem)
145-
mux.HandleFunc("GET /v1/health/report", rt.apiHealthReport)
159+
hf("GET /v1/summary", rt.apiSummary)
160+
hf("GET /v1/system", rt.apiSystem)
161+
hf("GET /v1/health/report", rt.apiHealthReport)
146162

147163
// Node mutations over the external API must land in the admin audit trail too —
148164
// the panel's audited-middleware wraps only the panel mux, not this one. idFn adds
@@ -158,10 +174,10 @@ func (rt *Router) apiMux() http.Handler {
158174
}
159175
}
160176
nodeAudit := func(pattern, section string, h http.HandlerFunc) {
161-
mux.HandleFunc(pattern, rt.apiAudited(section, h))
177+
hf(pattern, rt.apiAudited(section, h))
162178
}
163179

164-
mux.HandleFunc("GET /v1/nodes", rt.apiListNodes)
180+
hf("GET /v1/nodes", rt.apiListNodes)
165181
nodeAudit("POST /v1/nodes", "API · нода добавлена", rt.apiCreateNode)
166182
id("GET /v1/nodes/{id}", rt.apiGetNode)
167183
nodeAudit("PATCH /v1/nodes/{id}", "API · нода изменена", idFn(rt.apiPatchNode))
@@ -173,7 +189,7 @@ func (rt *Router) apiMux() http.Handler {
173189

174190
// Any unmatched /v1 path (or a wrong method) returns a JSON 404 in-envelope
175191
// rather than the default plain-text one.
176-
mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
192+
hf("/", func(w http.ResponseWriter, _ *http.Request) {
177193
writeAPIErr(w, http.StatusNotFound, "not_found", "no such endpoint")
178194
})
179195
return mux
@@ -312,7 +328,7 @@ func (rt *Router) apiUserView(w http.ResponseWriter, u model.User) {
312328
// ---- handlers ----
313329

314330
func (rt *Router) apiHealth(w http.ResponseWriter, _ *http.Request) {
315-
writeAPIData(w, http.StatusOK, map[string]any{"status": "ok"})
331+
writeAPIData(w, http.StatusOK, oaHealthResp{Status: "ok"})
316332
}
317333

318334
// apiListUsers lists users with optional filtering (?status, ?search) and
@@ -684,6 +700,32 @@ func (rt *Router) apiStatsSeries(w http.ResponseWriter, r *http.Request) {
684700
writeAPIData(w, http.StatusOK, series)
685701
}
686702

703+
// apiStatsNodes is the /v1 twin of the panel's per-server split: which server
704+
// carried the period's traffic. Offered here because a caller building their own
705+
// reporting on top of /v1/stats/series would otherwise have no way to break the same
706+
// numbers down by server.
707+
func (rt *Router) apiStatsNodes(w http.ResponseWriter, r *http.Request) {
708+
q := r.URL.Query()
709+
var userID int64
710+
if s := q.Get("user_id"); s != "" {
711+
v, err := strconv.ParseInt(s, 10, 64)
712+
if err != nil || v < 0 {
713+
writeAPIErr(w, http.StatusBadRequest, "bad_request", "invalid user_id")
714+
return
715+
}
716+
userID = v
717+
}
718+
rows, err := rt.mgr.NodeTrafficBreakdown(userID, q.Get("from"), q.Get("to"))
719+
if err != nil {
720+
writeAPIManagerErr(w, err)
721+
return
722+
}
723+
if rows == nil {
724+
rows = []core.NodeTraffic{}
725+
}
726+
writeAPIData(w, http.StatusOK, rows)
727+
}
728+
687729
func (rt *Router) apiStatsUsers(w http.ResponseWriter, r *http.Request) {
688730
q := r.URL.Query()
689731
totals, err := rt.mgr.StatsByUser(q.Get("from"), q.Get("to"))

internal/server/openapi.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ type oaRoute struct {
3939
noAuth bool // key-free route; overrides the document-wide bearerAuth
4040
}
4141

42+
// oaHealthResp is what GET /v1/health answers. Named rather than an inline map so
43+
// the generated spec reflects its real shape — the same reason request bodies are
44+
// named types here.
45+
type oaHealthResp struct {
46+
Status string `json:"status"` // "ok"
47+
}
48+
4249
// oaOrderResp / oaAffectedResp document the two non-model JSON responses so the
4350
// spec types them precisely (they mirror the maps the handlers write).
4451
type oaOrderResp struct {
@@ -113,13 +120,22 @@ func apiSpecRoutes() []oaRoute {
113120
{name: "to", typ: "string", desc: "YYYY-MM-DD"},
114121
},
115122
resp: t(model.DailyPoint{}), list: true},
123+
{method: "GET", path: "/v1/stats/nodes", tag: "Stats", summary: "Traffic split by server",
124+
query: []oaParam{
125+
{name: "user_id", typ: "integer", desc: "restrict to one user (omit for panel-wide)"},
126+
{name: "from", typ: "string", desc: "YYYY-MM-DD"},
127+
{name: "to", typ: "string", desc: "YYYY-MM-DD"},
128+
},
129+
resp: t(core.NodeTraffic{}), list: true},
116130
{method: "GET", path: "/v1/stats/users", tag: "Stats", summary: "Per-user traffic totals",
117131
query: []oaParam{
118132
{name: "from", typ: "string", desc: "YYYY-MM-DD"},
119133
{name: "to", typ: "string", desc: "YYYY-MM-DD"},
120134
},
121135
resp: t(model.UserTotal{}), list: true},
122136

137+
{method: "GET", path: "/v1/health", tag: "Monitoring", summary: "API reachability check",
138+
resp: t(oaHealthResp{})},
123139
{method: "GET", path: "/v1/summary", tag: "Monitoring", summary: "Panel summary",
124140
resp: t(core.Summary{})},
125141
{method: "GET", path: "/v1/system", tag: "Monitoring", summary: "Live system metrics",
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package server
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
// specDocRoutes are the two endpoints deliberately absent from the spec: they ARE
9+
// the spec and the viewer for it, and describing them inside themselves adds
10+
// nothing a reader can act on.
11+
var specDocRoutes = map[string]bool{
12+
"GET /v1/openapi.json": true,
13+
"GET /v1/docs": true,
14+
// The catch-all that turns an unmatched /v1 path into a JSON 404. Not an
15+
// operation, so there is nothing for the spec to describe.
16+
"/": true,
17+
}
18+
19+
// TestAPISpecCoversEveryRoute keeps the published contract honest.
20+
//
21+
// The spec is hand-declared in openapi.go while routes are registered in
22+
// api_v1.go, so nothing but this test stops the two drifting: an endpoint can ship,
23+
// work, and be missing from the spec with no error anywhere. That is exactly what
24+
// had happened to GET /v1/health — reachable, described in docs/api.md, invisible
25+
// to every client generated from the spec.
26+
func TestAPISpecCoversEveryRoute(t *testing.T) {
27+
rt := &Router{}
28+
rt.apiHandler() // registration is what fills rt.apiRoutes
29+
30+
if len(rt.apiRoutes) == 0 {
31+
t.Fatal("no /v1 routes recorded — the registration helper stopped tracking them")
32+
}
33+
34+
declared := map[string]bool{}
35+
for _, r := range apiSpecRoutes() {
36+
declared[r.method+" "+r.path] = true
37+
}
38+
39+
for _, pattern := range rt.apiRoutes {
40+
if specDocRoutes[pattern] {
41+
continue
42+
}
43+
if !declared[pattern] {
44+
t.Errorf("%s is served but has no OpenAPI entry — clients generated from "+
45+
"the spec cannot see it; add it to apiSpecRoutes() in openapi.go", pattern)
46+
}
47+
}
48+
}
49+
50+
// TestSpecDeclaresNothingImaginary is the other direction: a spec entry for a route
51+
// that does not exist sends callers at a 404.
52+
func TestSpecDeclaresNothingImaginary(t *testing.T) {
53+
rt := &Router{}
54+
rt.apiHandler()
55+
56+
served := map[string]bool{}
57+
for _, p := range rt.apiRoutes {
58+
served[p] = true
59+
}
60+
61+
for _, r := range apiSpecRoutes() {
62+
pattern := r.method + " " + r.path
63+
if !served[pattern] {
64+
t.Errorf("the spec declares %s, but nothing serves it", pattern)
65+
}
66+
}
67+
}
68+
69+
// TestSpecPathsAreVersioned guards a smaller foot-gun: a path written without the
70+
// /v1 prefix would generate a spec clients cannot call.
71+
func TestSpecPathsAreVersioned(t *testing.T) {
72+
for _, r := range apiSpecRoutes() {
73+
if !strings.HasPrefix(r.path, "/v1/") {
74+
t.Errorf("spec path %q is not under /v1/", r.path)
75+
}
76+
}
77+
}

internal/server/server.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ type Router struct {
4646
streams *streamGate // caps concurrent SSE streams
4747
status *statusFeed // one dashboard-payload timer shared by every viewer
4848
routes []string // panel route patterns, in registration order (audit exhaustiveness test)
49+
apiRoutes []string // /v1 route patterns (OpenAPI coverage test)
4950

5051
mu sync.RWMutex
5152
secret string

0 commit comments

Comments
 (0)