v0.4.1_hotfix

This commit is contained in:
Mateusz Gruszczyński committed 2026-11-05 13:49:33 +01:00
1 parent e0b80d8304
commit 7fa646c904
5 files changed
+165 -42

No files matched your search

+93 -24
View File
@@ -92,7 +92,7 @@ func (m *Manager) Last(ctx context.Context) (int, error) {
// 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.
// front-end renders as zero while preserving NULL as "missing" in the API.
type Point struct {
T int64 `json:"t"`
V *float64 `json:"v"`
@@ -103,13 +103,14 @@ type Point struct {
// 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"`
Key string `json:"key"`
Title string `json:"title"`
Step int64 `json:"step"`
Points []Point `json:"points"`
PeakPoints []Point `json:"peak_points,omitempty"`
Current *float64 `json:"current"`
Average *float64 `json:"average"`
Max *float64 `json:"max"`
}
// maxChartPoints caps how many samples Fetch returns per range. The raw
@@ -122,6 +123,23 @@ type GraphData struct {
// it's returned, independent of any particular browser's viewport.
const maxChartPoints = 450
// chartPointTarget keeps roughly comparable visual density across ranges.
// Long ranges get wider buckets, which suppresses one-sample needles while
// preserving the overall trend. True peaks are returned separately from the
// RRD MAX archive, so smoothing the average line does not hide maxima.
func chartPointTarget(key string) int {
switch key {
case "week":
return 336 // about 30 minutes per point
case "month":
return 372 // about 2 hours per point
case "year":
return 365 // about 1 day per point
default:
return maxChartPoints
}
}
// 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
@@ -137,16 +155,26 @@ func (m *Manager) Fetch(ctx context.Context, key string) (GraphData, error) {
}
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.
// Older imported RRDs may not contain AVERAGE/MAX archives. Fall
// back to LAST so the dashboard still works with such databases.
data, err = m.fetchCF(ctx, rg, "LAST")
}
return data, err
if err != nil {
return data, err
}
// For consolidated long ranges, fetch the matching MAX archive too.
// The main line stays a smoothed average, while PeakPoints and Max keep
// real peaks visible instead of deriving a fake maximum from averages.
if rg.CF == "AVERAGE" {
if peaks, peakErr := m.fetchCF(ctx, rg, "MAX"); peakErr == nil {
data.PeakPoints = peaks.Points
if peaks.Max != nil {
data.Max = peaks.Max
}
}
}
return data, nil
}
func (m *Manager) fetchCF(ctx context.Context, rg *Range, cf string) (GraphData, error) {
@@ -202,19 +230,23 @@ func (m *Manager) fetchCF(ctx context.Context, rg *Range, cf string) (GraphData,
}
// 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)
target := chartPointTarget(rg.Key)
if cf == "MAX" {
data.Points = downsampleMax(raw, target)
} else {
data.Points = downsample(raw, target)
}
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.
// downsample buckets points down to at most target entries. Missing samples
// inside a bucket count as zero for the plotted average, matching the UI's
// NULL -> 0 semantics and preventing one isolated non-zero sample among many
// NULLs from becoming a full-height needle. An all-NULL bucket remains NULL
// so a completely empty RRD is still detected as "Brak danych".
func downsample(points []Point, target int) []Point {
if target <= 0 || len(points) <= target {
return points
@@ -244,10 +276,47 @@ func downsample(points []Point, target int) []Point {
}
pt := Point{T: t}
if n > 0 {
avg := sum / float64(n)
avg := sum / float64(end-start)
pt.V = &avg
}
out = append(out, pt)
}
return out
}
// downsampleMax keeps the highest valid value from each bucket. It is used
// only for the secondary peak line fetched from an RRD MAX archive.
func downsampleMax(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
}
pt := Point{T: points[end-1].T}
var max *float64
for _, p := range points[start:end] {
if p.V == nil {
continue
}
if max == nil || *p.V > *max {
v := *p.V
max = &v
}
}
pt.V = max
out = append(out, pt)
}
return out
}
+14 -3
View File
@@ -33,14 +33,14 @@ func TestDownsampleAveragesBuckets(t *testing.T) {
}
}
func TestDownsampleSkipsNulls(t *testing.T) {
func TestDownsampleTreatsNullsAsZeroInsideNonEmptyBucket(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)
if out[0].V == nil || *out[0].V != 1.5 {
t.Fatalf("expected NULL-as-zero bucket average (1.5), got %v", out[0].V)
}
}
@@ -54,3 +54,14 @@ func TestDownsampleAllNullBucketStaysNull(t *testing.T) {
t.Fatalf("expected nil for an all-null bucket, got %v", *out[0].V)
}
}
func TestDownsampleMaxKeepsPeak(t *testing.T) {
in := []Point{{T: 1, V: f(1)}, {T: 2, V: f(7)}, {T: 3, V: nil}, {T: 4, V: f(3)}}
out := downsampleMax(in, 1)
if len(out) != 1 {
t.Fatalf("expected 1 bucket, got %d", len(out))
}
if out[0].V == nil || *out[0].V != 7 {
t.Fatalf("expected bucket peak 7, got %v", out[0].V)
}
}