diff --git a/dist/ts3monolith-linux-amd64 b/dist/ts3monolith-linux-amd64 index fd79837..8cbef38 100755 Binary files a/dist/ts3monolith-linux-amd64 and b/dist/ts3monolith-linux-amd64 differ diff --git a/dist/ts3monolith-linux-amd64.sha256 b/dist/ts3monolith-linux-amd64.sha256 index f0a4779..6c6fa95 100644 --- a/dist/ts3monolith-linux-amd64.sha256 +++ b/dist/ts3monolith-linux-amd64.sha256 @@ -1 +1 @@ -3b51f741d9067586fa06db6bc3819f3e2d0cd7387f266c93c5ffa32878df6d60 ts3monolith-linux-amd64 +84c245bc8b2534b0f3b822637cc7c7d51fa7c6918e60909025a326578d284e2a ts3monolith-linux-amd64 diff --git a/internal/rrd/rrd.go b/internal/rrd/rrd.go index 4de9222..b40193a 100644 --- a/internal/rrd/rrd.go +++ b/internal/rrd/rrd.go @@ -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 +} diff --git a/internal/rrd/rrd_test.go b/internal/rrd/rrd_test.go index 0c64113..529a66f 100644 --- a/internal/rrd/rrd_test.go +++ b/internal/rrd/rrd_test.go @@ -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) + } +} diff --git a/internal/web/assets/app.js b/internal/web/assets/app.js index bc5c73e..9f36052 100644 --- a/internal/web/assets/app.js +++ b/internal/web/assets/app.js @@ -99,13 +99,18 @@ var plotW = Math.max(1, cssW - padL - padR); var plotH = Math.max(1, cssH - padT - padB); + var peakPoints = (data && data.peak_points) || []; var values = []; points.forEach(function (p) { if (p.v !== null && p.v !== undefined) values.push(p.v); }); + peakPoints.forEach(function (p) { if (p.v !== null && p.v !== undefined) values.push(p.v); }); var maxV = values.length ? Math.max.apply(null, values) : 1; - var top = Math.max(1, Math.ceil(maxV * 1.2)); + var top = Math.max(1, Math.ceil(maxV * 1.15)); + function xAtFor(series, i) { + return padL + (series.length > 1 ? (i / (series.length - 1)) * plotW : plotW / 2); + } function xAt(i) { - return padL + (points.length > 1 ? (i / (points.length - 1)) * plotW : plotW / 2); + return xAtFor(points, i); } function yAt(v) { return padT + plotH - (v / top) * plotH; @@ -140,9 +145,47 @@ }); } - // area + line, broken into separate segments across data gaps (nulls) - // instead of interpolating through them + // For week/month/year the API also returns a MAX series. Draw it first + // as a subtle dashed line: the solid/filled line remains the smoothed + // average trend, while real historical peaks are still visible. + if (peakPoints.length > 1) { + ctx.save(); + ctx.beginPath(); + peakPoints.forEach(function (p, i) { + var v = (p.v === null || p.v === undefined) ? 0 : p.v; + var x = xAtFor(peakPoints, i); + var y = yAt(v); + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.lineJoin = 'round'; + ctx.lineCap = 'round'; + ctx.lineWidth = 1; + ctx.strokeStyle = accent; + ctx.globalAlpha = 0.42; + ctx.setLineDash([4, 4]); + ctx.stroke(); + ctx.restore(); + } + + // Main average/trend line; missing samples are rendered as zero so the + // graph falls cleanly to the baseline instead of breaking into segments. + // Short windows represent a discrete user count, so draw them as steps: + // the value stays constant until the next sample instead of producing + // narrow triangular needles between 0 and 1/2 users. + var useSteps = data.key === 'hour' || data.key === 'day'; var seg = []; + + function traceSeries(segment) { + ctx.moveTo(segment[0][0], segment[0][1]); + for (var i = 1; i < segment.length; i++) { + if (useSteps) { + ctx.lineTo(segment[i][0], segment[i - 1][1]); + } + ctx.lineTo(segment[i][0], segment[i][1]); + } + } + function flushSegment() { if (seg.length < 2) { seg = []; @@ -150,17 +193,20 @@ } ctx.beginPath(); ctx.moveTo(seg[0][0], padT + plotH); - seg.forEach(function (pt) { ctx.lineTo(pt[0], pt[1]); }); + ctx.lineTo(seg[0][0], seg[0][1]); + for (var i = 1; i < seg.length; i++) { + if (useSteps) { + ctx.lineTo(seg[i][0], seg[i - 1][1]); + } + ctx.lineTo(seg[i][0], seg[i][1]); + } ctx.lineTo(seg[seg.length - 1][0], padT + plotH); ctx.closePath(); ctx.fillStyle = accent + '38'; ctx.fill(); ctx.beginPath(); - seg.forEach(function (pt, idx) { - if (idx === 0) ctx.moveTo(pt[0], pt[1]); - else ctx.lineTo(pt[0], pt[1]); - }); + traceSeries(seg); ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.lineWidth = 1.6; @@ -169,11 +215,8 @@ seg = []; } points.forEach(function (p, i) { - if (p.v === null || p.v === undefined) { - flushSegment(); - return; - } - seg.push([xAt(i), yAt(p.v)]); + var v = (p.v === null || p.v === undefined) ? 0 : p.v; + seg.push([xAt(i), yAt(v)]); }); flushSegment(); return true;