fix(host_agent): update full-metrics charts in place to kill refresh flicker
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:
@@ -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 & 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>
|
||||||
|
|||||||
@@ -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 & 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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user