254 lines
7.6 KiB
Go
254 lines
7.6 KiB
Go
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
|
|
}
|