Files
stats-gateway/cmd/snapshot/main.go
T

139 lines
3.7 KiB
Go

package main
import (
"context"
"encoding/json"
"flag"
"log"
"os"
"path/filepath"
"time"
"github.com/bergabruh/stats-gateway/internal/storage"
)
type sample struct {
TS string `json:"ts"`
HTTPMs float64 `json:"http_ms"`
TLSMs float64 `json:"tls_ms"`
TCPMs float64 `json:"tcp_ms"`
}
type nodeData struct {
Recent []sample `json:"recent"`
}
// SpeedtestSnapshot exposes recent speedtest rows + day/week/month median
// aggregates per node. Recent uses storage.SpeedTest's capitalized field
// names (no JSON tags on the struct); the frontend reads them as-is.
type SpeedtestSnapshot struct {
Recent []storage.SpeedTest `json:"recent"`
AggDay map[string]storage.Agg `json:"agg_day"`
AggWeek map[string]storage.Agg `json:"agg_week"`
AggMonth map[string]storage.Agg `json:"agg_month"`
GeneratedAt int64 `json:"generated_at"`
}
type snapshot struct {
GeneratedAt string `json:"generated_at"`
Nodes map[string]nodeData `json:"nodes"`
Speedtest *SpeedtestSnapshot `json:"speedtest,omitempty"`
}
// buildSpeedtestSnapshot collects the last 200 speedtest rows + per-node
// medians over 24h/7d/30d windows. Aggregate errors are logged but
// non-fatal — the rest of the snapshot still ships.
func buildSpeedtestSnapshot(ctx context.Context, st *storage.Store) (*SpeedtestSnapshot, error) {
recent, err := st.ListRecentSpeedTests(ctx, 200)
if err != nil {
return nil, err
}
now := time.Now().Unix()
day, week, month := now-86400, now-7*86400, now-30*86400
aggDay, errDay := st.AggregateSpeedTestsSince(ctx, day)
if errDay != nil {
log.Printf("aggregate day: %v", errDay)
}
aggWeek, errWeek := st.AggregateSpeedTestsSince(ctx, week)
if errWeek != nil {
log.Printf("aggregate week: %v", errWeek)
}
aggMonth, errMonth := st.AggregateSpeedTestsSince(ctx, month)
if errMonth != nil {
log.Printf("aggregate month: %v", errMonth)
}
return &SpeedtestSnapshot{
Recent: recent,
AggDay: aggDay,
AggWeek: aggWeek,
AggMonth: aggMonth,
GeneratedAt: now,
}, nil
}
func main() {
dbPath := flag.String("db", "/var/lib/stats-gateway/stats.db", "")
out := flag.String("out", "/var/www/stats-site/data/snapshot.json", "")
limitPerNode := flag.Int("limit", 288, "max samples per node (288 = ~24h at 5min interval)")
flag.Parse()
st, err := storage.Open(*dbPath)
if err != nil {
log.Fatalf("open db: %v", err)
}
defer st.Close()
s := snapshot{
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
Nodes: map[string]nodeData{},
}
ctx := context.Background()
for _, node := range []string{"fl", "pl1"} {
samples, err := st.QueryRecent(ctx, node, *limitPerNode, time.Time{})
if err != nil {
log.Printf("query %s: %v", node, err)
continue
}
nd := nodeData{Recent: make([]sample, 0, len(samples))}
for _, sm := range samples {
nd.Recent = append(nd.Recent, sample{
TS: sm.Timestamp.Format(time.RFC3339Nano),
HTTPMs: sm.HTTPLatencyMs,
TLSMs: sm.TLSHandshakeMs,
TCPMs: sm.TCPRttMs,
})
}
s.Nodes[node] = nd
}
if stSnap, err := buildSpeedtestSnapshot(ctx, st); err != nil {
log.Printf("build speedtest snapshot: %v", err)
} else {
s.Speedtest = stSnap
}
if err := os.MkdirAll(filepath.Dir(*out), 0755); err != nil {
log.Fatalf("mkdir %s: %v", filepath.Dir(*out), err)
}
// Atomic write: write to .tmp then rename.
tmp := *out + ".tmp"
f, err := os.Create(tmp)
if err != nil {
log.Fatalf("create %s: %v", tmp, err)
}
enc := json.NewEncoder(f)
enc.SetIndent("", " ")
if err := enc.Encode(s); err != nil {
f.Close()
os.Remove(tmp)
log.Fatalf("encode: %v", err)
}
f.Close()
if err := os.Rename(tmp, *out); err != nil {
log.Fatalf("rename: %v", err)
}
log.Printf("snapshot: nodes=%d → %s", len(s.Nodes), *out)
}