fix(host_agent): update full-metrics charts in place to kill refresh flicker
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 47s
CI / integration (push) Successful in 2m21s
CI / publish (push) Successful in 7s

The live refresh re-created the Chart.js charts each poll, which blanked the
canvases and re-ran the grow-in animation — a jarring flash/jump every cycle.

Make the canvases persistent in the page shell and poll only the data:
- The 3 chart canvases + their Chart instances are created once in the shell,
  with animation disabled.
- /charts is now a data-only fragment swapped into a hidden div; it calls
  window.applyHostSeries(series, range), which sets each dataset's data and
  calls chart.update("none") — in-place, no re-create, no animation, fixed
  height. Range labels update via .hm-chart-range spans.
- Current-state fragment keeps its atomic innerHTML poll into fixed-height
  cards, so numbers update without a layout jump.

Net: live updates morph smoothly with no flicker or layout shift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
This commit is contained in:
2026-06-20 12:25:51 -04:00
parent 9e4f1983f8
commit 10dfd8ffd2
2 changed files with 91 additions and 79 deletions
+4 -68
View File
@@ -1,70 +1,6 @@
{# History-charts fragment for the full-metrics page — lazy-loaded via HTMX so {# History data-only fragment. Swapped into the hidden #hm-charts-data poller;
the date_bin history query never blocks the page shell, and refreshed on a the script runs and feeds the persistent charts created by host_detail.html,
slow cadence / range change. #} updating them in place (no canvas swap → no flicker / no re-animation). #}
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:1rem;">
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.5rem;">Utilization % — last {{ range_key }}</div>
<div style="height:220px;"><canvas id="chart-util"></canvas></div>
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.5rem;">Throughput (B/s) — last {{ range_key }}</div>
<div style="height:220px;"><canvas id="chart-net"></canvas></div>
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.5rem;">Load &amp; pressure — last {{ range_key }}</div>
<div style="height:220px;"><canvas id="chart-pressure"></canvas></div>
</div>
</div>
<script> <script>
(function () { if (window.applyHostSeries) window.applyHostSeries({{ series|tojson }}, {{ range_key|tojson }});
const series = {{ series|tojson }};
// This fragment is re-swapped on poll / range change. Destroy the previous
// chart instances first so Chart.js doesn't leak detached canvases.
(window._hostCharts || []).forEach(function (c) { try { c.destroy(); } catch (e) {} });
window._hostCharts = [];
const fmtTime = (v) => { const d = new Date(v); return ("0" + d.getHours()).slice(-2) + ":" + ("0" + d.getMinutes()).slice(-2); };
const baseOpts = (ymax) => ({
responsive: true, maintainAspectRatio: false,
interaction: { mode: "index", intersect: false },
elements: { point: { radius: 0 } },
scales: {
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" } },
},
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 el = document.getElementById(id);
if (!el) return;
window._hostCharts.push(new Chart(el.getContext("2d"), {
type: "line",
data: { datasets: datasets.map((d) => ({
label: d.label,
data: (series[d.key] || []).map((p) => ({ x: p[0], y: p[1] })),
borderColor: d.color, backgroundColor: d.color, borderWidth: 1.5, tension: 0.25,
})) },
options: baseOpts(ymax),
}));
};
mk("chart-util", [
{ key: "cpu_pct", label: "CPU %", color: "#c8a840" },
{ key: "mem_used_pct", label: "Mem %", color: "#4aa86a" },
{ key: "disk_used_pct_worst", label: "Disk % (worst)", color: "#c87840" },
], 100);
mk("chart-net", [
{ key: "net_rx_bps", label: "Net RX", color: "#4aa86a" },
{ key: "net_tx_bps", label: "Net TX", color: "#c84048" },
{ key: "disk_read_bps", label: "Disk read", color: "#6aa0d0" },
{ key: "disk_write_bps", label: "Disk write", color: "#c8a840" },
], undefined);
mk("chart-pressure", [
{ key: "load_1m", label: "Load 1m", color: "#c8a840" },
{ key: "psi_mem_some_avg10", label: "Mem PSI %", color: "#c84048" },
{ key: "temp_c_max", label: "Temp °C", color: "#c87840" },
], undefined);
})();
</script> </script>
+87 -11
View File
@@ -3,15 +3,17 @@
{% block title %}{{ host.name }} — Metrics — Steward{% endblock %} {% block title %}{{ host.name }} — Metrics — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Hosts", "/hosts/"), (host.name, "/hosts/" ~ host.id), ("Metrics", "")]) }}{% endblock %} {% block breadcrumb %}{{ crumbs([("Hosts", "/hosts/"), (host.name, "/hosts/" ~ host.id), ("Metrics", "")]) }}{% endblock %}
{% block content %} {% block content %}
{# Shell only: the current-state and history-chart sections stream in via HTMX {# Shell only: current-state + history data stream in via HTMX so the heavy
below, so the heavy history query never blocks first paint and the data history query never blocks first paint and the data refreshes live (≈ the
refreshes live (≈ the agent's push cadence) without a full reload. #} agent's push cadence). The chart CANVASES live here permanently — only their
data is polled and applied in place, so refreshes never re-create/flicker. #}
<div style="display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap;margin-bottom:0.75rem;"> <div style="display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap;margin-bottom:0.75rem;">
<h1 class="page-title" style="margin-bottom:0;">{{ host.name }}</h1> <h1 class="page-title" style="margin-bottom:0;">{{ host.name }}</h1>
{% include "_time_range.html" %} {% include "_time_range.html" %}
</div> </div>
{# Current state — lazy + polled live (latest snapshot the server holds). #} {# Current state — lazy + polled live. Atomic innerHTML swap of fixed-height
cards, so values update without a layout jump. #}
<div id="hm-current" <div id="hm-current"
hx-get="/plugins/host_agent/{{ host.id }}/metrics" hx-get="/plugins/host_agent/{{ host.id }}/metrics"
hx-trigger="load, every {{ poll_seconds }}s" hx-trigger="load, every {{ poll_seconds }}s"
@@ -19,14 +21,88 @@
<div class="card empty">Loading metrics…</div> <div class="card empty">Loading metrics…</div>
</div> </div>
{# History charts — lazy (never blocks paint), refreshed on a slow cadence and {# History charts — persistent canvases (created once below); only data is polled. #}
on range change; range comes from the shared #time-range input. #} <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:1rem;margin-top:1rem;">
<div id="hm-charts" <div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.5rem;">Utilization % — last <span class="hm-chart-range">{{ current_range }}</span></div>
<div style="height:220px;"><canvas id="chart-util"></canvas></div>
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.5rem;">Throughput (B/s) — last <span class="hm-chart-range">{{ current_range }}</span></div>
<div style="height:220px;"><canvas id="chart-net"></canvas></div>
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.5rem;">Load &amp; pressure — last <span class="hm-chart-range">{{ current_range }}</span></div>
<div style="height:220px;"><canvas id="chart-pressure"></canvas></div>
</div>
</div>
{# Hidden poller: fetches the history series and applies it to the charts above
(no visible swap). Lazy on load → never blocks paint; refreshed slowly + on
range change. #}
<div id="hm-charts-data" style="display:none;"
hx-get="/plugins/host_agent/{{ host.id }}/charts" hx-get="/plugins/host_agent/{{ host.id }}/charts"
hx-trigger="load, every {{ chart_poll_seconds }}s, rangeChange from:body" hx-trigger="load, every {{ chart_poll_seconds }}s, rangeChange from:body"
hx-include="#time-range" hx-include="#time-range"
hx-swap="innerHTML" hx-swap="innerHTML"></div>
style="margin-top:1rem;">
<div class="card empty">Loading charts…</div> <script>
</div> (function () {
const fmtTime = (v) => { const d = new Date(v); return ("0" + d.getHours()).slice(-2) + ":" + ("0" + d.getMinutes()).slice(-2); };
const baseOpts = (ymax) => ({
responsive: true, maintainAspectRatio: false,
animation: false, // no grow-in / re-animate on refresh
interaction: { mode: "index", intersect: false },
elements: { point: { radius: 0 } },
scales: {
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" } },
},
plugins: {
legend: { labels: { color: "#b8b8b0", boxWidth: 10, font: { size: 11 } } },
tooltip: { callbacks: { title: (items) => items.length ? fmtTime(items[0].parsed.x) : "" } },
},
});
const defs = {
"chart-util": { ymax: 100, ds: [
{ key: "cpu_pct", label: "CPU %", color: "#c8a840" },
{ key: "mem_used_pct", label: "Mem %", color: "#4aa86a" },
{ key: "disk_used_pct_worst", label: "Disk % (worst)", color: "#c87840" },
] },
"chart-net": { ymax: undefined, ds: [
{ key: "net_rx_bps", label: "Net RX", color: "#4aa86a" },
{ key: "net_tx_bps", label: "Net TX", color: "#c84048" },
{ key: "disk_read_bps", label: "Disk read", color: "#6aa0d0" },
{ key: "disk_write_bps", label: "Disk write", color: "#c8a840" },
] },
"chart-pressure": { ymax: undefined, ds: [
{ key: "load_1m", label: "Load 1m", color: "#c8a840" },
{ key: "psi_mem_some_avg10", label: "Mem PSI %", color: "#c84048" },
{ key: "temp_c_max", label: "Temp °C", color: "#c87840" },
] },
};
const charts = {};
for (const id of Object.keys(defs)) {
const el = document.getElementById(id);
if (!el) continue;
charts[id] = new Chart(el.getContext("2d"), {
type: "line",
data: { datasets: defs[id].ds.map((d) => ({ label: d.label, data: [], borderColor: d.color, backgroundColor: d.color, borderWidth: 1.5, tension: 0.25 })) },
options: baseOpts(defs[id].ymax),
});
}
// Called by the polled /charts data fragment. Updates data in place (no
// re-create, no animation), so refreshes never flicker or shift layout.
window.applyHostSeries = function (series, rangeKey) {
for (const id of Object.keys(defs)) {
const ch = charts[id];
if (!ch) continue;
defs[id].ds.forEach((d, i) => {
ch.data.datasets[i].data = (series[d.key] || []).map((p) => ({ x: p[0], y: p[1] }));
});
ch.update("none");
}
if (rangeKey) document.querySelectorAll(".hm-chart-range").forEach((s) => { s.textContent = rangeKey; });
};
})();
</script>
{% endblock %} {% endblock %}