fix(dashboard): downsample history graphs + readable tooltip time
The agent reports every few seconds, so multi-hour history series were hundreds– thousands of points — a dense, noisy line (esp. CPU). Bucket-average server-side to ~120 points (keeps the shape, drops the noise) for both the history-graph widget and the host-detail charts. Also fix the chart tooltip title showing the raw epoch-ms (e.g. 1,781,720,471,459) — format it as HH:MM. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -461,11 +461,35 @@ async def widget_history():
|
|||||||
key = "disk_root" if p.resource_name != host.name else p.metric_name
|
key = "disk_root" if p.resource_name != host.name else p.metric_name
|
||||||
series[key].append([int(p.recorded_at.timestamp() * 1000), round(p.value, 2)])
|
series[key].append([int(p.recorded_at.timestamp() * 1000), round(p.value, 2)])
|
||||||
|
|
||||||
|
# Downsample: the agent reports every few seconds, so a multi-hour window is
|
||||||
|
# hundreds–thousands of points — a noisy, unreadable line. Bucket-average to a
|
||||||
|
# readable point count, keeping the shape.
|
||||||
|
series = {k: _downsample(v) for k, v in series.items()}
|
||||||
return await render_template(
|
return await render_template(
|
||||||
"widget_history.html", host=host, series=series, hours=hours, wid=wid,
|
"widget_history.html", host=host, series=series, hours=hours, wid=wid,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _downsample(series: list[list], target: int = 120) -> list[list]:
|
||||||
|
"""Bucket-average a time series [[epoch_ms, value], …] down to ~target points.
|
||||||
|
|
||||||
|
Consecutive samples are grouped into equal buckets and averaged (x = bucket
|
||||||
|
midpoint). Cheap, order-preserving, and smooths the high-frequency noise that
|
||||||
|
makes dense agent series hard to read.
|
||||||
|
"""
|
||||||
|
n = len(series)
|
||||||
|
if n <= target:
|
||||||
|
return series
|
||||||
|
bucket = (n + target - 1) // target
|
||||||
|
out: list[list] = []
|
||||||
|
for i in range(0, n, bucket):
|
||||||
|
chunk = series[i:i + bucket]
|
||||||
|
mid = chunk[len(chunk) // 2][0]
|
||||||
|
avg = sum(p[1] for p in chunk) / len(chunk)
|
||||||
|
out.append([mid, round(avg, 2)])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
# Host-level metrics charted on the detail page (sub-resources are shown as
|
# Host-level metrics charted on the detail page (sub-resources are shown as
|
||||||
# current-value lists, not time series, to keep the page readable).
|
# current-value lists, not time series, to keep the page readable).
|
||||||
HISTORY_METRICS = (
|
HISTORY_METRICS = (
|
||||||
@@ -520,7 +544,9 @@ async def _history_for_host(session, host_name: str, since) -> dict[str, list]:
|
|||||||
series: dict[str, list] = {m: [] for m in HISTORY_METRICS}
|
series: dict[str, list] = {m: [] for m in HISTORY_METRICS}
|
||||||
for p in rows:
|
for p in rows:
|
||||||
series[p.metric_name].append([int(p.recorded_at.timestamp() * 1000), round(p.value, 2)])
|
series[p.metric_name].append([int(p.recorded_at.timestamp() * 1000), round(p.value, 2)])
|
||||||
return series
|
# Downsample to a readable point count (see _downsample) — raw agent cadence
|
||||||
|
# is too dense to read over a multi-hour window.
|
||||||
|
return {m: _downsample(v) for m, v in series.items()}
|
||||||
|
|
||||||
|
|
||||||
@host_agent_bp.get("/<host_id>/")
|
@host_agent_bp.get("/<host_id>/")
|
||||||
|
|||||||
@@ -216,7 +216,10 @@
|
|||||||
x: { type: "linear", ticks: { callback: (v) => fmtTime(v), maxTicksLimit: 8, color: "#8a8a92", font: { size: 10 } }, grid: { color: "#30303a" } },
|
x: { type: "linear", ticks: { callback: (v) => fmtTime(v), maxTicksLimit: 8, color: "#8a8a92", font: { size: 10 } }, grid: { color: "#30303a" } },
|
||||||
y: { beginAtZero: true, max: ymax, ticks: { color: "#8a8a92", font: { size: 10 } }, grid: { color: "#30303a" } },
|
y: { beginAtZero: true, max: ymax, ticks: { color: "#8a8a92", font: { size: 10 } }, grid: { color: "#30303a" } },
|
||||||
},
|
},
|
||||||
plugins: { legend: { labels: { color: "#b8b8b0", boxWidth: 10, font: { size: 11 } } } },
|
plugins: {
|
||||||
|
legend: { labels: { color: "#b8b8b0", boxWidth: 10, font: { size: 11 } } },
|
||||||
|
tooltip: { callbacks: { title: function (items) { return items.length ? fmtTime(items[0].parsed.x) : ""; } } },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const mk = (id, datasets, ymax) => {
|
const mk = (id, datasets, ymax) => {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
|
|||||||
@@ -48,7 +48,10 @@
|
|||||||
// still show the real %, so the values stay honest.
|
// still show the real %, so the values stay honest.
|
||||||
y: { beginAtZero: true, grace: "8%", ticks: { color: "#8a8a92", font: { size: 10 } }, grid: { color: "#30303a" } },
|
y: { beginAtZero: true, grace: "8%", ticks: { color: "#8a8a92", font: { size: 10 } }, grid: { color: "#30303a" } },
|
||||||
},
|
},
|
||||||
plugins: { legend: { labels: { color: "#b8b8b0", boxWidth: 10, font: { size: 11 } } } },
|
plugins: {
|
||||||
|
legend: { labels: { color: "#b8b8b0", boxWidth: 10, font: { size: 11 } } },
|
||||||
|
tooltip: { callbacks: { title: function (items) { return items.length ? fmtTime(items[0].parsed.x) : ""; } } },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user