v0.4.1_hotfix
This commit is contained in:
1 parent
e0b80d8304
commit
7fa646c904
5 files changed
+165
-42
No files matched your search
Vendored
BIN
Binary file not shown.
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
3b51f741d9067586fa06db6bc3819f3e2d0cd7387f266c93c5ffa32878df6d60 ts3monolith-linux-amd64
|
84c245bc8b2534b0f3b822637cc7c7d51fa7c6918e60909025a326578d284e2a ts3monolith-linux-amd64
|
||||||
+93
-24
@@ -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
|
// 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
|
// 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 {
|
type Point struct {
|
||||||
T int64 `json:"t"`
|
T int64 `json:"t"`
|
||||||
V *float64 `json:"v"`
|
V *float64 `json:"v"`
|
||||||
@@ -103,13 +103,14 @@ type Point struct {
|
|||||||
// front-end doesn't need to re-derive them, mirroring what the old
|
// front-end doesn't need to re-derive them, mirroring what the old
|
||||||
// rrdtool-rendered PNGs printed under the graph via GPRINT.
|
// rrdtool-rendered PNGs printed under the graph via GPRINT.
|
||||||
type GraphData struct {
|
type GraphData struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Step int64 `json:"step"`
|
Step int64 `json:"step"`
|
||||||
Points []Point `json:"points"`
|
Points []Point `json:"points"`
|
||||||
Current *float64 `json:"current"`
|
PeakPoints []Point `json:"peak_points,omitempty"`
|
||||||
Average *float64 `json:"average"`
|
Current *float64 `json:"current"`
|
||||||
Max *float64 `json:"max"`
|
Average *float64 `json:"average"`
|
||||||
|
Max *float64 `json:"max"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// maxChartPoints caps how many samples Fetch returns per range. The raw
|
// 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.
|
// it's returned, independent of any particular browser's viewport.
|
||||||
const maxChartPoints = 450
|
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
|
// 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
|
// via `rrdtool fetch`, for the front-end to render client-side. This
|
||||||
// replaces the previous approach of shelling out to `rrdtool graph` to
|
// 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)
|
data, err := m.fetchCF(ctx, rg, rg.CF)
|
||||||
if err != nil && rg.CF != "LAST" {
|
if err != nil && rg.CF != "LAST" {
|
||||||
// An RRD file that predates packaging/ts3monolith-init-rrd (e.g. an
|
// Older imported RRDs may not contain AVERAGE/MAX archives. Fall
|
||||||
// archived database carried over from the old PHP app, as this
|
// back to LAST so the dashboard still works with such databases.
|
||||||
// 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")
|
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) {
|
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
|
// Current/Average/Max above are computed from the full raw series, not
|
||||||
// diluted by downsampling; only the plotted points themselves shrink.
|
// 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 {
|
if len(data.Points) > 1 {
|
||||||
data.Step = data.Points[1].T - data.Points[0].T
|
data.Step = data.Points[1].T - data.Points[0].T
|
||||||
}
|
}
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// downsample buckets points down to at most target entries. Each bucket's
|
// downsample buckets points down to at most target entries. Missing samples
|
||||||
// value is the average of its non-null samples (nil if the bucket has
|
// inside a bucket count as zero for the plotted average, matching the UI's
|
||||||
// none), and its timestamp is the last raw timestamp in the bucket -- the
|
// NULL -> 0 semantics and preventing one isolated non-zero sample among many
|
||||||
// same shape as an RRD AVERAGE RRA, so long ranges read as a smooth trend
|
// NULLs from becoming a full-height needle. An all-NULL bucket remains NULL
|
||||||
// instead of a wall of raw per-minute noise. Points already at or under
|
// so a completely empty RRD is still detected as "Brak danych".
|
||||||
// the target are returned unchanged.
|
|
||||||
func downsample(points []Point, target int) []Point {
|
func downsample(points []Point, target int) []Point {
|
||||||
if target <= 0 || len(points) <= target {
|
if target <= 0 || len(points) <= target {
|
||||||
return points
|
return points
|
||||||
@@ -244,10 +276,47 @@ func downsample(points []Point, target int) []Point {
|
|||||||
}
|
}
|
||||||
pt := Point{T: t}
|
pt := Point{T: t}
|
||||||
if n > 0 {
|
if n > 0 {
|
||||||
avg := sum / float64(n)
|
avg := sum / float64(end-start)
|
||||||
pt.V = &avg
|
pt.V = &avg
|
||||||
}
|
}
|
||||||
out = append(out, pt)
|
out = append(out, pt)
|
||||||
}
|
}
|
||||||
return out
|
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
|
||||||
|
}
|
||||||
@@ -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}}
|
in := []Point{{T: 1, V: f(2)}, {T: 2, V: nil}, {T: 3, V: f(4)}, {T: 4, V: nil}}
|
||||||
out := downsample(in, 1)
|
out := downsample(in, 1)
|
||||||
if len(out) != 1 {
|
if len(out) != 1 {
|
||||||
t.Fatalf("expected 1 bucket, got %d", len(out))
|
t.Fatalf("expected 1 bucket, got %d", len(out))
|
||||||
}
|
}
|
||||||
if out[0].V == nil || *out[0].V != 3 {
|
if out[0].V == nil || *out[0].V != 1.5 {
|
||||||
t.Fatalf("expected average of non-null values (3), got %v", out[0].V)
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
-14
@@ -99,13 +99,18 @@
|
|||||||
var plotW = Math.max(1, cssW - padL - padR);
|
var plotW = Math.max(1, cssW - padL - padR);
|
||||||
var plotH = Math.max(1, cssH - padT - padB);
|
var plotH = Math.max(1, cssH - padT - padB);
|
||||||
|
|
||||||
|
var peakPoints = (data && data.peak_points) || [];
|
||||||
var values = [];
|
var values = [];
|
||||||
points.forEach(function (p) { if (p.v !== null && p.v !== undefined) values.push(p.v); });
|
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 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) {
|
function xAt(i) {
|
||||||
return padL + (points.length > 1 ? (i / (points.length - 1)) * plotW : plotW / 2);
|
return xAtFor(points, i);
|
||||||
}
|
}
|
||||||
function yAt(v) {
|
function yAt(v) {
|
||||||
return padT + plotH - (v / top) * plotH;
|
return padT + plotH - (v / top) * plotH;
|
||||||
@@ -140,9 +145,47 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// area + line, broken into separate segments across data gaps (nulls)
|
// For week/month/year the API also returns a MAX series. Draw it first
|
||||||
// instead of interpolating through them
|
// 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 = [];
|
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() {
|
function flushSegment() {
|
||||||
if (seg.length < 2) {
|
if (seg.length < 2) {
|
||||||
seg = [];
|
seg = [];
|
||||||
@@ -150,17 +193,20 @@
|
|||||||
}
|
}
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(seg[0][0], padT + plotH);
|
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.lineTo(seg[seg.length - 1][0], padT + plotH);
|
||||||
ctx.closePath();
|
ctx.closePath();
|
||||||
ctx.fillStyle = accent + '38';
|
ctx.fillStyle = accent + '38';
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
seg.forEach(function (pt, idx) {
|
traceSeries(seg);
|
||||||
if (idx === 0) ctx.moveTo(pt[0], pt[1]);
|
|
||||||
else ctx.lineTo(pt[0], pt[1]);
|
|
||||||
});
|
|
||||||
ctx.lineJoin = 'round';
|
ctx.lineJoin = 'round';
|
||||||
ctx.lineCap = 'round';
|
ctx.lineCap = 'round';
|
||||||
ctx.lineWidth = 1.6;
|
ctx.lineWidth = 1.6;
|
||||||
@@ -169,11 +215,8 @@
|
|||||||
seg = [];
|
seg = [];
|
||||||
}
|
}
|
||||||
points.forEach(function (p, i) {
|
points.forEach(function (p, i) {
|
||||||
if (p.v === null || p.v === undefined) {
|
var v = (p.v === null || p.v === undefined) ? 0 : p.v;
|
||||||
flushSegment();
|
seg.push([xAt(i), yAt(v)]);
|
||||||
return;
|
|
||||||
}
|
|
||||||
seg.push([xAt(i), yAt(p.v)]);
|
|
||||||
});
|
});
|
||||||
flushSegment();
|
flushSegment();
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
Reference in new issue
Block a user