fix(dashboard): downsample history graphs + readable tooltip time
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 7s
CI / integration (push) Successful in 2m15s
CI / publish (push) Successful in 6s

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:
2026-06-17 16:18:50 -04:00
parent e58c86cf01
commit fed9973899
3 changed files with 35 additions and 3 deletions
+27 -1
View File
@@ -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
# hundredsthousands 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) : ""; } } },
},
}, },
}); });
})(); })();