mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-09-02 09:03:45 +00:00
483 lines
18 KiB
Go
483 lines
18 KiB
Go
// Copyright 2026 Beacon Contributors
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
package handlers
|
|
|
|
import (
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/MeshCore-Beacon/beacon-server/internal/api"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// StatsRouter mounts all /stats routes onto a subrouter.
|
|
//
|
|
// GET /stats/overview → getStatsOverview
|
|
// GET /stats/observations → getStatsObservations
|
|
// GET /stats/payload-breakdown → getStatsPayloadBreakdown
|
|
// GET /stats/top-nodes → getStatsTopNodes
|
|
// GET /stats/top-observers → getStatsTopObservers
|
|
// GET /stats/top-advertisers → getStatsTopAdvertisers
|
|
// GET /stats/top-talkers → getStatsTopTalkers
|
|
// GET /stats/radio-presets → getStatsRadioPresets
|
|
// GET /stats/scopes → GetStatsScopes
|
|
//
|
|
// All endpoints accept an optional iata= filter (case-insensitive).
|
|
func StatsRouter(reader api.Reader) http.Handler {
|
|
r := chi.NewRouter()
|
|
r.Get("/overview", getStatsOverview(reader))
|
|
r.Get("/observations", getStatsObservations(reader))
|
|
r.Get("/payload-breakdown", getStatsPayloadBreakdown(reader))
|
|
r.Get("/top-nodes", getStatsTopNodes(reader))
|
|
r.Get("/top-observers", getStatsTopObservers(reader))
|
|
r.Get("/top-advertisers", getStatsTopAdvertisers(reader))
|
|
r.Get("/clock-drift", getStatsClockDrift(reader))
|
|
r.Get("/top-talkers", getStatsTopTalkers(reader))
|
|
r.Get("/radio-presets", getStatsRadioPresets(reader))
|
|
r.Get("/scopes", getStatsScopes(reader))
|
|
r.Get("/node-types", getStatsNodeTypes(reader))
|
|
return r
|
|
}
|
|
|
|
// getStatsOverview godoc
|
|
//
|
|
// @Summary Network overview stats (last 24h)
|
|
// @Tags Stats
|
|
// @Produce json
|
|
// @Param iatas query string false "Comma-separated IATA codes"
|
|
// @Param regionId query int false "Filter by region ID, expands to member IATAs"
|
|
// @Param region query string false "Filter by region slug, expands to member IATAs"
|
|
// @Success 200 {object} api.StatsOverview
|
|
// @Failure 500 {object} handlers.APIError
|
|
// @Router /stats/overview [get]
|
|
func getStatsOverview(reader api.Reader) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
iatas := parseIATAs(r)
|
|
if regionIDStr := r.URL.Query().Get("regionId"); regionIDStr != "" || r.URL.Query().Get("region") != "" {
|
|
regionIATAs, err := resolveRegionIATAs(r.Context(), regionIDStr, r.URL.Query().Get("region"), reader)
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
iatas = append(iatas, regionIATAs...)
|
|
}
|
|
overview, err := reader.GetStatsOverview(r.Context(), iatas)
|
|
if err != nil {
|
|
log.Printf("api: GetStatsOverview failed: %v", err)
|
|
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
respond(w, http.StatusOK, overview)
|
|
}
|
|
}
|
|
|
|
// getStatsObservations godoc
|
|
//
|
|
// @Summary Hourly observation time series
|
|
// @Tags Stats
|
|
// @Produce json
|
|
// @Param iatas query string false "Comma-separated IATA codes"
|
|
// @Param regionId query int false "Filter by region ID, expands to member IATAs"
|
|
// @Param region query string false "Filter by region slug, expands to member IATAs"
|
|
// @Param since query int false "Start of window epoch ms (default 7 days ago)"
|
|
// @Success 200 {array} api.ObservationPoint
|
|
// @Failure 500 {object} handlers.APIError
|
|
// @Router /stats/observations [get]
|
|
func getStatsObservations(reader api.Reader) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
iatas := parseIATAs(r)
|
|
if regionIDStr := r.URL.Query().Get("regionId"); regionIDStr != "" || r.URL.Query().Get("region") != "" {
|
|
regionIATAs, err := resolveRegionIATAs(r.Context(), regionIDStr, r.URL.Query().Get("region"), reader)
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
iatas = append(iatas, regionIATAs...)
|
|
}
|
|
var since time.Time
|
|
if p := r.URL.Query().Get("since"); p != "" {
|
|
ms, err := strconv.ParseInt(p, 10, 64)
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, "since must be epoch milliseconds")
|
|
return
|
|
}
|
|
since = time.UnixMilli(ms)
|
|
}
|
|
points, err := reader.GetStatsObservations(r.Context(), iatas, since)
|
|
if err != nil {
|
|
log.Printf("api: GetStatsObservations failed: %v", err)
|
|
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
respond(w, http.StatusOK, points)
|
|
}
|
|
}
|
|
|
|
// getStatsPayloadBreakdown godoc
|
|
//
|
|
// @Summary Observation counts by payload type (last 24h by default)
|
|
// @Tags Stats
|
|
// @Produce json
|
|
// @Param iatas query string false "Comma-separated IATA codes"
|
|
// @Param regionId query int false "Filter by region ID, expands to member IATAs"
|
|
// @Param region query string false "Filter by region slug, expands to member IATAs"
|
|
// @Param since query int false "Start of window epoch ms (default last 24h)"
|
|
// @Success 200 {array} api.PayloadBreakdownItem
|
|
// @Failure 500 {object} handlers.APIError
|
|
// @Router /stats/payload-breakdown [get]
|
|
func getStatsPayloadBreakdown(reader api.Reader) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
iatas := parseIATAs(r)
|
|
if regionIDStr := r.URL.Query().Get("regionId"); regionIDStr != "" || r.URL.Query().Get("region") != "" {
|
|
regionIATAs, err := resolveRegionIATAs(r.Context(), regionIDStr, r.URL.Query().Get("region"), reader)
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
iatas = append(iatas, regionIATAs...)
|
|
}
|
|
var since time.Time
|
|
if p := r.URL.Query().Get("since"); p != "" {
|
|
ms, err := strconv.ParseInt(p, 10, 64)
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, "since must be epoch milliseconds")
|
|
return
|
|
}
|
|
since = time.UnixMilli(ms)
|
|
}
|
|
breakdown, err := reader.GetStatsPayloadBreakdown(r.Context(), iatas, since)
|
|
if err != nil {
|
|
log.Printf("api: GetStatsPayloadBreakdown failed: %v", err)
|
|
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
respond(w, http.StatusOK, breakdown)
|
|
}
|
|
}
|
|
|
|
// getStatsTopNodes godoc
|
|
//
|
|
// @Summary Top N nodes by observation count (from materialized view)
|
|
// @Tags Stats
|
|
// @Produce json
|
|
// @Param iatas query string false "Comma-separated IATA codes"
|
|
// @Param regionId query int false "Filter by region ID, expands to member IATAs"
|
|
// @Param region query string false "Filter by region slug, expands to member IATAs"
|
|
// @Param limit query int false "Max results (default 10)"
|
|
// @Success 200 {array} api.TopNode
|
|
// @Failure 500 {object} handlers.APIError
|
|
// @Router /stats/top-nodes [get]
|
|
func getStatsTopNodes(reader api.Reader) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
iatas := parseIATAs(r)
|
|
if regionIDStr := r.URL.Query().Get("regionId"); regionIDStr != "" || r.URL.Query().Get("region") != "" {
|
|
regionIATAs, err := resolveRegionIATAs(r.Context(), regionIDStr, r.URL.Query().Get("region"), reader)
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
iatas = append(iatas, regionIATAs...)
|
|
}
|
|
var limit int32 = 10
|
|
if p := r.URL.Query().Get("limit"); p != "" {
|
|
l, err := strconv.ParseInt(p, 10, 32)
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, "limit must be an integer")
|
|
return
|
|
}
|
|
limit = int32(l)
|
|
}
|
|
nodes, err := reader.GetStatsTopNodes(r.Context(), iatas, limit)
|
|
if err != nil {
|
|
log.Printf("api: GetStatsTopNodes failed: %v", err)
|
|
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
respond(w, http.StatusOK, nodes)
|
|
}
|
|
}
|
|
|
|
// getStatsTopObservers godoc
|
|
//
|
|
// @Summary Top N observers by observation count (last 24h by default)
|
|
// @Tags Stats
|
|
// @Produce json
|
|
// @Param iatas query string false "Comma-separated IATA codes"
|
|
// @Param regionId query int false "Filter by region ID, expands to member IATAs"
|
|
// @Param region query string false "Filter by region slug, expands to member IATAs"
|
|
// @Param since query int false "Start of window epoch ms (default last 24h)"
|
|
// @Param limit query int false "Max results (default 10)"
|
|
// @Success 200 {array} api.TopObserver
|
|
// @Failure 500 {object} handlers.APIError
|
|
// @Router /stats/top-observers [get]
|
|
func getStatsTopObservers(reader api.Reader) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
iatas := parseIATAs(r)
|
|
if regionIDStr := r.URL.Query().Get("regionId"); regionIDStr != "" || r.URL.Query().Get("region") != "" {
|
|
regionIATAs, err := resolveRegionIATAs(r.Context(), regionIDStr, r.URL.Query().Get("region"), reader)
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
iatas = append(iatas, regionIATAs...)
|
|
}
|
|
var since time.Time
|
|
if p := r.URL.Query().Get("since"); p != "" {
|
|
ms, err := strconv.ParseInt(p, 10, 64)
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, "since must be epoch milliseconds")
|
|
return
|
|
}
|
|
since = time.UnixMilli(ms)
|
|
}
|
|
var limit int32 = 10
|
|
if p := r.URL.Query().Get("limit"); p != "" {
|
|
l, err := strconv.ParseInt(p, 10, 32)
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, "limit must be an integer")
|
|
return
|
|
}
|
|
limit = int32(l)
|
|
}
|
|
observers, err := reader.GetStatsTopObservers(r.Context(), iatas, since, limit)
|
|
if err != nil {
|
|
log.Printf("api: GetStatsTopObservers failed: %v", err)
|
|
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
respond(w, http.StatusOK, observers)
|
|
}
|
|
}
|
|
|
|
// getStatsTopAdvertisers godoc
|
|
//
|
|
// @Summary Top N nodes by distinct ADVERT packet count (last 24h by default)
|
|
// @Tags Stats
|
|
// @Produce json
|
|
// @Param iatas query string false "Comma-separated IATA codes"
|
|
// @Param regionId query int false "Filter by region ID, expands to member IATAs"
|
|
// @Param region query string false "Filter by region slug, expands to member IATAs"
|
|
// @Param since query int false "Start of window epoch ms (default last 24h)"
|
|
// @Param limit query int false "Max results (default 10)"
|
|
// @Success 200 {array} api.TopAdvertiser
|
|
// @Failure 500 {object} handlers.APIError
|
|
// @Router /stats/top-advertisers [get]
|
|
func getStatsTopAdvertisers(reader api.Reader) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
iatas := parseIATAs(r)
|
|
if regionIDStr := r.URL.Query().Get("regionId"); regionIDStr != "" || r.URL.Query().Get("region") != "" {
|
|
regionIATAs, err := resolveRegionIATAs(r.Context(), regionIDStr, r.URL.Query().Get("region"), reader)
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
iatas = append(iatas, regionIATAs...)
|
|
}
|
|
var since time.Time
|
|
if p := r.URL.Query().Get("since"); p != "" {
|
|
ms, err := strconv.ParseInt(p, 10, 64)
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, "since must be epoch milliseconds")
|
|
return
|
|
}
|
|
since = time.UnixMilli(ms)
|
|
}
|
|
var limit int32 = 10
|
|
if p := r.URL.Query().Get("limit"); p != "" {
|
|
l, err := strconv.ParseInt(p, 10, 32)
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, "limit must be an integer")
|
|
return
|
|
}
|
|
limit = int32(l)
|
|
}
|
|
advertisers, err := reader.GetStatsTopAdvertisers(r.Context(), iatas, since, limit)
|
|
if err != nil {
|
|
log.Printf("api: GetStatsTopAdvertisers failed: %v", err)
|
|
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
respond(w, http.StatusOK, advertisers)
|
|
}
|
|
}
|
|
|
|
// getStatsClockDrift godoc
|
|
//
|
|
// @Summary Repeaters/room servers whose clock has drifted beyond the configured threshold, worst first
|
|
// @Tags Stats
|
|
// @Produce json
|
|
// @Param iatas query string false "Comma-separated IATA codes"
|
|
// @Param regionId query int false "Filter by region ID, expands to member IATAs"
|
|
// @Param region query string false "Filter by region slug, expands to member IATAs"
|
|
// @Param limit query int false "Max results (default 10)"
|
|
// @Success 200 {array} api.ClockDriftEntry
|
|
// @Failure 500 {object} handlers.APIError
|
|
// @Router /stats/clock-drift [get]
|
|
func getStatsClockDrift(reader api.Reader) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
iatas := parseIATAs(r)
|
|
if regionIDStr := r.URL.Query().Get("regionId"); regionIDStr != "" || r.URL.Query().Get("region") != "" {
|
|
regionIATAs, err := resolveRegionIATAs(r.Context(), regionIDStr, r.URL.Query().Get("region"), reader)
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
iatas = append(iatas, regionIATAs...)
|
|
}
|
|
var limit int32 = 10
|
|
if p := r.URL.Query().Get("limit"); p != "" {
|
|
l, err := strconv.ParseInt(p, 10, 32)
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, "limit must be an integer")
|
|
return
|
|
}
|
|
limit = int32(l)
|
|
}
|
|
entries, err := reader.GetStatsClockDrift(r.Context(), iatas, limit)
|
|
if err != nil {
|
|
log.Printf("api: GetStatsClockDrift failed: %v", err)
|
|
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
respond(w, http.StatusOK, entries)
|
|
}
|
|
}
|
|
|
|
// getStatsTopTalkers godoc
|
|
//
|
|
// @Summary Top N companion names by decrypted channel message count (last 24h by default)
|
|
// @Tags Stats
|
|
// @Produce json
|
|
// @Param iatas query string false "Comma-separated IATA codes"
|
|
// @Param regionId query int false "Filter by region ID, expands to member IATAs"
|
|
// @Param region query string false "Filter by region slug, expands to member IATAs"
|
|
// @Param since query int false "Start of window epoch ms (default last 24h)"
|
|
// @Param limit query int false "Max results (default 10)"
|
|
// @Success 200 {array} api.TopTalker
|
|
// @Failure 500 {object} handlers.APIError
|
|
// @Router /stats/top-talkers [get]
|
|
func getStatsTopTalkers(reader api.Reader) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
iatas := parseIATAs(r)
|
|
if regionIDStr := r.URL.Query().Get("regionId"); regionIDStr != "" || r.URL.Query().Get("region") != "" {
|
|
regionIATAs, err := resolveRegionIATAs(r.Context(), regionIDStr, r.URL.Query().Get("region"), reader)
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
iatas = append(iatas, regionIATAs...)
|
|
}
|
|
var since time.Time
|
|
if p := r.URL.Query().Get("since"); p != "" {
|
|
ms, err := strconv.ParseInt(p, 10, 64)
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, "since must be epoch milliseconds")
|
|
return
|
|
}
|
|
since = time.UnixMilli(ms)
|
|
}
|
|
var limit int32 = 10
|
|
if p := r.URL.Query().Get("limit"); p != "" {
|
|
l, err := strconv.ParseInt(p, 10, 32)
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, "limit must be an integer")
|
|
return
|
|
}
|
|
limit = int32(l)
|
|
}
|
|
talkers, err := reader.GetStatsTopTalkers(r.Context(), iatas, since, limit)
|
|
if err != nil {
|
|
log.Printf("api: GetStatsTopTalkers failed: %v", err)
|
|
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
respond(w, http.StatusOK, talkers)
|
|
}
|
|
}
|
|
|
|
// getStatsRadioPresets godoc
|
|
//
|
|
// @Summary Radio preset usage by IATA
|
|
// @Tags Stats
|
|
// @Produce json
|
|
// @Param preset query string false "Filter by preset string e.g. 910.525,62.5,7"
|
|
// @Param iatas query string false "Comma-separated IATA codes"
|
|
// @Param regionId query int false "Filter by region ID, expands to member IATAs"
|
|
// @Param region query string false "Filter by region slug, expands to member IATAs"
|
|
// @Success 200 {object} []api.RadioPreset
|
|
// @Failure 500 {object} handlers.APIError
|
|
// @Router /stats/radio-presets [get]
|
|
func getStatsRadioPresets(reader api.Reader) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
preset := r.URL.Query().Get("preset")
|
|
iatas := parseIATAs(r)
|
|
if regionIDStr := r.URL.Query().Get("regionId"); regionIDStr != "" || r.URL.Query().Get("region") != "" {
|
|
regionIATAs, err := resolveRegionIATAs(r.Context(), regionIDStr, r.URL.Query().Get("region"), reader)
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
iatas = append(iatas, regionIATAs...)
|
|
}
|
|
presets, err := reader.GetRadioPresets(r.Context(), preset, iatas)
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
respond(w, http.StatusOK, presets)
|
|
}
|
|
}
|
|
|
|
// getStatsScopes godoc
|
|
//
|
|
// @Summary Scope statistics
|
|
// @Tags Stats
|
|
// @Produce json
|
|
// @Success 200 {object} []api.ScopeStats
|
|
// @Failure 500 {object} handlers.APIError
|
|
// @Router /stats/scopes [get]
|
|
func getStatsScopes(reader api.Reader) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
stats, err := reader.GetScopeStats(r.Context())
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
respond(w, http.StatusOK, stats)
|
|
}
|
|
}
|
|
|
|
// getStatsNodeTypes godoc
|
|
//
|
|
// @Summary Node type breakdown
|
|
// @Tags Stats
|
|
// @Produce json
|
|
// @Param iatas query string false "Comma-separated IATA codes"
|
|
// @Param regionId query int false "Filter by region ID, expands to member IATAs"
|
|
// @Param region query string false "Filter by region slug, expands to member IATAs"
|
|
// @Success 200 {array} api.NodeTypeCount
|
|
// @Failure 500 {object} handlers.APIError
|
|
// @Router /stats/node-types [get]
|
|
func getStatsNodeTypes(reader api.Reader) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
iatas := parseIATAs(r)
|
|
if regionIDStr := r.URL.Query().Get("regionId"); regionIDStr != "" || r.URL.Query().Get("region") != "" {
|
|
regionIATAs, err := resolveRegionIATAs(r.Context(), regionIDStr, r.URL.Query().Get("region"), reader)
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
iatas = append(iatas, regionIATAs...)
|
|
}
|
|
result, err := reader.GetStatsNodeTypes(r.Context(), iatas)
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "failed to get node type stats")
|
|
return
|
|
}
|
|
respond(w, http.StatusOK, result)
|
|
}
|
|
}
|