package main import ( "context" "errors" "log" "net/http" "os" "os/signal" "strconv" "syscall" "time" "ts3monolith/internal/config" "ts3monolith/internal/rrd" "ts3monolith/internal/ts3" webapp "ts3monolith/internal/web" ) func main() { log.SetFlags(log.LstdFlags | log.LUTC) cfg, err := config.Load() if err != nil { log.Fatal(err) } tq := &ts3.Query{Addr: cfg.TSQueryHost + ":" + strconv.Itoa(cfg.TSQueryPort), User: cfg.TSQueryUser, Password: cfg.TSQueryPassword, VoicePort: cfg.TSVoicePort, Timeout: cfg.TSQueryTimeout} rm := &rrd.Manager{Tool: cfg.RRDTool, Path: cfg.RRDPath, GraphDir: cfg.GraphDir} if err := rm.Validate(); err != nil { log.Fatal(err) } app, err := webapp.New(cfg, tq, rm) if err != nil { log.Fatal(err) } srv := &http.Server{Addr: cfg.ListenAddr, Handler: app.Handler(), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 16 << 10} ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() go collect(ctx, cfg, tq, rm) go func() { <-ctx.Done() c, cancel := context.WithTimeout(context.Background(), 8*time.Second) defer cancel() _ = srv.Shutdown(c) }() log.Printf("listening on %s", cfg.ListenAddr) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatal(err) } } func collect(ctx context.Context, cfg config.Config, tq *ts3.Query, rm *rrd.Manager) { run := func() { c, cancel := context.WithTimeout(ctx, cfg.TSQueryTimeout) n, err := tq.CountOnline(c) cancel() if err != nil { log.Printf("stats query failed: %v", err) } // Treat "query failed" and "query succeeded but nobody is online" // the same way: repeat the last known sample instead of writing 0. // This matches the original PHP app's collector exactly (it never // distinguished the two cases either). At a 1-minute sampling // interval a real but brief empty-server moment plots as a sharp // spike down to 0 and back up, which reads as broken/jagged far // more than it reads as useful information -- so, deliberately, // a true "server went empty for a while" no longer shows as 0 in // the graph, same trade-off the original app made. raw := n if n == 0 { c2, cancel2 := context.WithTimeout(ctx, 2*time.Second) last, lastErr := rm.Last(c2) cancel2() if lastErr != nil { log.Printf("collect: raw=0, rrd lastupdate failed (writing raw 0): %v", lastErr) } else { n = last } } // DEBUG=1 turns on a log line for every single collection cycle // (raw TS3 reading vs. what actually got written to the RRD) -- // useful while diagnosing the stats collector, far too noisy to // leave on permanently (one line per STATS_INTERVAL, forever). if cfg.Debug { log.Printf("collect: raw=%d written=%d", raw, n) } c3, cancel3 := context.WithTimeout(ctx, 5*time.Second) defer cancel3() if err := rm.Update(c3, n); err != nil { log.Printf("rrd update failed: %v", err) } } run() t := time.NewTicker(cfg.StatsInterval) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: run() } } }