package rrd import ( "bytes" "context" "errors" "fmt" "math" "os" "os/exec" "strconv" "strings" "sync" ) type Manager struct { Tool string Path string mu sync.Mutex } // Range describes one of the dashboard's graph time windows. CF is the RRD // consolidation function to fetch with: "LAST" for the windows covered by // the full-resolution 1x60s RRA (see packaging/ts3monolith-init-rrd), and // "AVERAGE" for the longer windows that only exist in the coarser, // pre-averaged RRAs -- asking those for LAST would silently return nothing // older than the full-resolution RRA's ~75h span. type Range struct{ Key, Start, Title, CF string } var Ranges = []Range{ {"hour", "-12h", "Ostatnie 12 godzin", "LAST"}, {"day", "-24h", "Ostatnie 24 godziny", "LAST"}, {"72h", "-72h", "Ostatnie 72 godziny", "LAST"}, {"week", "-1w", "Ostatni tydzień", "AVERAGE"}, {"month", "-1m", "Ostatni miesiąc", "AVERAGE"}, {"year", "-1y", "Ostatni rok", "AVERAGE"}, } func rangeByKey(key string) *Range { for i := range Ranges { if Ranges[i].Key == key { return &Ranges[i] } } return nil } func (m *Manager) Validate() error { if _, err := exec.LookPath(m.Tool); err != nil { return fmt.Errorf("rrdtool not found: %w", err) } if st, err := os.Stat(m.Path); err != nil || st.IsDir() { return fmt.Errorf("RRD file unavailable: %s", m.Path) } return nil } func (m *Manager) Update(ctx context.Context, online int) error { if online < 0 { return errors.New("online count cannot be negative") } cmd := exec.CommandContext(ctx, m.Tool, "update", m.Path, "N:"+strconv.Itoa(online)) var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return fmt.Errorf("rrd update: %w: %s", err, strings.TrimSpace(stderr.String())) } return nil } func (m *Manager) Last(ctx context.Context) (int, error) { cmd := exec.CommandContext(ctx, m.Tool, "lastupdate", m.Path) out, err := cmd.Output() if err != nil { return 0, err } lines := strings.Split(strings.TrimSpace(string(out)), "\n") if len(lines) < 2 { return 0, errors.New("unexpected rrd lastupdate output") } fields := strings.Fields(lines[len(lines)-1]) if len(fields) < 2 { return 0, errors.New("unexpected rrd lastupdate data") } v := fields[len(fields)-1] if v == "U" { return 0, nil } f, err := strconv.ParseFloat(v, 64) return int(f + 0.5), err } // Point is one JSON-serializable RRD sample. V is nil when the RRA has no // data for that timestamp (rrdtool reports it as "nan"), which the // front-end renders as a gap in the line rather than a drop to zero. type Point struct { T int64 `json:"t"` V *float64 `json:"v"` } // GraphData is the JSON payload served at GET /stats/graph/{key}. Aktualnie // / Średnio / Maks. are computed here (over the fetched window) so the // front-end doesn't need to re-derive them, mirroring what the old // rrdtool-rendered PNGs printed under the graph via GPRINT. type GraphData struct { Key string `json:"key"` Title string `json:"title"` Step int64 `json:"step"` Points []Point `json:"points"` Current *float64 `json:"current"` Average *float64 `json:"average"` Max *float64 `json:"max"` } // maxChartPoints caps how many samples Fetch returns per range. The raw // RRA rows can be far denser than any chart will ever display pixel-for- // pixel (72h at the full-resolution 60s RRA is 4320 rows), which both // bloats the JSON response for no visual benefit and, rendered 1:1, // produces a dense, spiky look where many adjacent ups/downs alias into // what looks like noise. downsample buckets the raw series down to this // many points (each bucket averaged, like an RRD AVERAGE RRA does) before // it's returned, independent of any particular browser's viewport. const maxChartPoints = 450 // Fetch reads the samples for one graph range directly from the RRD file // via `rrdtool fetch`, for the front-end to render client-side. This // replaces the previous approach of shelling out to `rrdtool graph` to // render a PNG per theme: fetch returns a small JSON array instead of an // image, so the browser can draw it (and redraw it on theme change or // resize) without a round-trip, and it never produces the server-side- // rendering quirks (fixed margins, fixed colors) that made the PNGs // awkward to fit responsively. func (m *Manager) Fetch(ctx context.Context, key string) (GraphData, error) { rg := rangeByKey(key) if rg == nil { return GraphData{}, errors.New("unknown graph range") } data, err := m.fetchCF(ctx, rg, rg.CF) if err != nil && rg.CF != "LAST" { // An RRD file that predates packaging/ts3monolith-init-rrd (e.g. an // archived database carried over from the old PHP app, as this // project's README tells operators to do) may not have an // AVERAGE-consolidated RRA at all. LAST is the one CF every range // used exclusively before this JSON rewrite (see git history), so // it's guaranteed to exist -- fall back to it rather than leaving // week/month/year permanently broken against such a file. data, err = m.fetchCF(ctx, rg, "LAST") } return data, err } func (m *Manager) fetchCF(ctx context.Context, rg *Range, cf string) (GraphData, error) { cmd := exec.CommandContext(ctx, m.Tool, "fetch", m.Path, cf, "--start", rg.Start, "--end", "now") var stderr bytes.Buffer cmd.Stderr = &stderr out, err := cmd.Output() if err != nil { return GraphData{}, fmt.Errorf("rrd fetch: %w: %s", err, strings.TrimSpace(stderr.String())) } data := GraphData{Key: rg.Key, Title: rg.Title} var raw []Point var sum float64 var n int var prevT int64 = -1 for _, line := range strings.Split(string(out), "\n") { line = strings.TrimSpace(line) if line == "" { continue } idx := strings.IndexByte(line, ':') if idx < 0 { continue // header line, e.g. " online" } ts, err := strconv.ParseInt(strings.TrimSpace(line[:idx]), 10, 64) if err != nil { continue // not a data row } pt := Point{T: ts} if f, err := strconv.ParseFloat(strings.TrimSpace(line[idx+1:]), 64); err == nil && !math.IsNaN(f) { v := f pt.V = &v sum += f n++ if data.Max == nil || f > *data.Max { mx := f data.Max = &mx } cur := f data.Current = &cur // last valid sample wins } if prevT >= 0 { data.Step = ts - prevT } prevT = ts raw = append(raw, pt) } if n > 0 { avg := sum / float64(n) data.Average = &avg } // Current/Average/Max above are computed from the full raw series, not // diluted by downsampling; only the plotted points themselves shrink. data.Points = downsample(raw, maxChartPoints) if len(data.Points) > 1 { data.Step = data.Points[1].T - data.Points[0].T } return data, nil } // downsample buckets points down to at most target entries. Each bucket's // value is the average of its non-null samples (nil if the bucket has // none), and its timestamp is the last raw timestamp in the bucket -- the // same shape as an RRD AVERAGE RRA, so long ranges read as a smooth trend // instead of a wall of raw per-minute noise. Points already at or under // the target are returned unchanged. func downsample(points []Point, target int) []Point { if target <= 0 || len(points) <= target { return points } bucket := float64(len(points)) / float64(target) out := make([]Point, 0, target) for i := 0; i < target; i++ { start := int(float64(i) * bucket) end := int(float64(i+1) * bucket) if end <= start { end = start + 1 } if end > len(points) { end = len(points) } if start >= end { break } var sum float64 var n int t := points[end-1].T for _, p := range points[start:end] { if p.V != nil { sum += *p.V n++ } } pt := Point{T: t} if n > 0 { avg := sum / float64(n) pt.V = &avg } out = append(out, pt) } return out }