v0.4.1
This commit is contained in:
1 parent
439cc20dcf
commit
722a49f6bc
14 files changed
+564
-283
No files matched your search
+180
-106
@@ -5,53 +5,44 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
Tool string
|
||||
Path string
|
||||
GraphDir string
|
||||
mu sync.Mutex
|
||||
Tool string
|
||||
Path string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// Graph colors mirror the dashboard's theme tokens (internal/web/assets/app.css)
|
||||
// so rendered PNGs sit visually in place inside the panel cards. Each range
|
||||
// is rendered twice (dark + light) so the client can pick the variant that
|
||||
// matches the active site theme without round-tripping through rrdtool.
|
||||
const (
|
||||
graphColorPanel = "#181c19" // --panel (dark)
|
||||
graphColorField = "#121713" // --field (dark plot canvas)
|
||||
graphColorLine = "#313832" // --line (dark minor grid)
|
||||
graphColorLine2 = "#485149" // --line2 (dark axis / major grid)
|
||||
graphColorText = "#edf0ecCC"
|
||||
graphColorAccent = "#8fb9a4" // --accent (dark)
|
||||
graphColorAccentFill = "#8fb9a43d"
|
||||
|
||||
graphLightColorPanel = "#ffffff" // --panel (light)
|
||||
graphLightColorField = "#f7f8f6" // --panel2 (light plot canvas)
|
||||
graphLightColorLine = "#cbd2cb" // --line (light minor grid)
|
||||
graphLightColorLine2 = "#aeb8af" // --line2 (light axis / major grid)
|
||||
graphLightColorText = "#202622CC"
|
||||
graphLightColorAccent = "#507b67" // --accent (light)
|
||||
graphLightColorAccentFill = "#507b673d"
|
||||
)
|
||||
|
||||
type Range struct{ Key, Start, Title string }
|
||||
// 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"},
|
||||
{"day", "-24h", "Ostatnie 24 godziny"},
|
||||
{"72h", "-72h", "Ostatnie 72 godziny"},
|
||||
{"week", "-1w", "Ostatni tydzień"},
|
||||
{"month", "-1m", "Ostatni miesiąc"},
|
||||
{"year", "-1y", "Ostatni rok"},
|
||||
{"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 {
|
||||
@@ -61,7 +52,7 @@ func (m *Manager) Validate() error {
|
||||
if st, err := os.Stat(m.Path); err != nil || st.IsDir() {
|
||||
return fmt.Errorf("RRD file unavailable: %s", m.Path)
|
||||
}
|
||||
return os.MkdirAll(m.GraphDir, 0750)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Update(ctx context.Context, online int) error {
|
||||
@@ -99,81 +90,164 @@ func (m *Manager) Last(ctx context.Context) (int, error) {
|
||||
return int(f + 0.5), err
|
||||
}
|
||||
|
||||
func (m *Manager) Graph(ctx context.Context, key, theme string) (string, error) {
|
||||
var rg *Range
|
||||
for i := range Ranges {
|
||||
if Ranges[i].Key == key {
|
||||
rg = &Ranges[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
// 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 "", errors.New("unknown graph range")
|
||||
return GraphData{}, errors.New("unknown graph range")
|
||||
}
|
||||
if err := os.MkdirAll(m.GraphDir, 0750); err != nil {
|
||||
return "", err
|
||||
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
|
||||
}
|
||||
|
||||
panel, field, line, line2, text, accent, accentFill := graphColorPanel, graphColorField, graphColorLine, graphColorLine2, graphColorText, graphColorAccent, graphColorAccentFill
|
||||
suffix := ""
|
||||
if theme == "light" {
|
||||
panel, field, line, line2, text, accent, accentFill = graphLightColorPanel, graphLightColorField, graphLightColorLine, graphLightColorLine2, graphLightColorText, graphLightColorAccent, graphLightColorAccentFill
|
||||
suffix = "-light"
|
||||
}
|
||||
out := filepath.Join(m.GraphDir, key+suffix+".png")
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if st, err := os.Stat(out); err == nil && time.Since(st.ModTime()) < 55*time.Second {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
def := "DEF:online=" + m.Path + ":online:LAST"
|
||||
watermark := "linuxiarz.pl · " + time.Now().Format("2006-01-02 15:04")
|
||||
args := []string{
|
||||
"graph", out,
|
||||
"--imgformat", "PNG",
|
||||
"--start", rg.Start, "--end", "now",
|
||||
// By default rrdtool's --width/--height size only the plot canvas;
|
||||
// the title, legend rows and axis-label margins then pad the final
|
||||
// PNG to something larger and variable (depends on title/legend
|
||||
// text length). --full-size-mode makes --width/--height the exact
|
||||
// *total* image size instead, so the PNG is always precisely
|
||||
// 1200x320 -- matching the CSS aspect-ratio on the <img> exactly,
|
||||
// with no letterboxing from a size mismatch.
|
||||
"--full-size-mode",
|
||||
"--width", "1200", "--height", "320",
|
||||
"--lower-limit", "0", "--alt-autoscale-max", "--rigid",
|
||||
"--slope-mode",
|
||||
"--title", rg.Title,
|
||||
"--vertical-label", "użytkownicy",
|
||||
"--border", "0",
|
||||
"--color", "BACK" + panel,
|
||||
"--color", "CANVAS" + field,
|
||||
"--color", "FONT" + text,
|
||||
"--color", "AXIS" + line2,
|
||||
"--color", "ARROW" + line2,
|
||||
"--color", "GRID" + line,
|
||||
"--color", "MGRID" + line2,
|
||||
"--color", "FRAME" + panel,
|
||||
"--font", "DEFAULT:0:",
|
||||
"--font", "TITLE:13:",
|
||||
"--font", "AXIS:9:",
|
||||
"--font", "LEGEND:10:",
|
||||
"--font", "UNIT:9:",
|
||||
"--watermark", watermark,
|
||||
def,
|
||||
"AREA:online" + accentFill,
|
||||
"LINE2:online" + accent + ":Online",
|
||||
"GPRINT:online:LAST:Aktualnie\\: %4.0lf",
|
||||
"GPRINT:online:AVERAGE:Średnio\\: %4.0lf",
|
||||
"GPRINT:online:MAX:Maks.\\: %4.0lf\\n",
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, m.Tool, args...)
|
||||
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
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("rrd graph: %w: %s", err, strings.TrimSpace(stderr.String()))
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return GraphData{}, fmt.Errorf("rrd fetch: %w: %s", err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
return out, nil
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package rrd
|
||||
|
||||
import "testing"
|
||||
|
||||
func f(v float64) *float64 { return &v }
|
||||
|
||||
func TestDownsampleNoop(t *testing.T) {
|
||||
in := []Point{{T: 1, V: f(1)}, {T: 2, V: f(2)}, {T: 3, V: f(3)}}
|
||||
out := downsample(in, 10)
|
||||
if len(out) != len(in) {
|
||||
t.Fatalf("expected no-op for points <= target, got %d points", len(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownsampleAveragesBuckets(t *testing.T) {
|
||||
// 10 points, 0..9, bucketed down to 5: expect roughly paired averages.
|
||||
in := make([]Point, 10)
|
||||
for i := range in {
|
||||
in[i] = Point{T: int64(i), V: f(float64(i))}
|
||||
}
|
||||
out := downsample(in, 5)
|
||||
if len(out) != 5 {
|
||||
t.Fatalf("expected 5 points, got %d", len(out))
|
||||
}
|
||||
want := []float64{0.5, 2.5, 4.5, 6.5, 8.5}
|
||||
for i, pt := range out {
|
||||
if pt.V == nil {
|
||||
t.Fatalf("bucket %d: expected a value, got nil", i)
|
||||
}
|
||||
if *pt.V != want[i] {
|
||||
t.Errorf("bucket %d: got %v, want %v", i, *pt.V, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownsampleSkipsNulls(t *testing.T) {
|
||||
in := []Point{{T: 1, V: f(2)}, {T: 2, V: nil}, {T: 3, V: f(4)}, {T: 4, V: nil}}
|
||||
out := downsample(in, 1)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("expected 1 bucket, got %d", len(out))
|
||||
}
|
||||
if out[0].V == nil || *out[0].V != 3 {
|
||||
t.Fatalf("expected average of non-null values (3), got %v", out[0].V)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownsampleAllNullBucketStaysNull(t *testing.T) {
|
||||
in := []Point{{T: 1, V: nil}, {T: 2, V: nil}}
|
||||
out := downsample(in, 1)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("expected 1 bucket, got %d", len(out))
|
||||
}
|
||||
if out[0].V != nil {
|
||||
t.Fatalf("expected nil for an all-null bucket, got %v", *out[0].V)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user