Files
FabledSteward/plugins/host_agent/templates/host_detail.html
T
bvandeusenandClaude Opus 5.5 521d84f318
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m29s
CI / integration (push) Successful in 1m37s
CI / publish (push) Successful in 1m9s
feat(syslog): lines beside the readings: around an anomaly, in get_anomaly, and marked on host and SNMP charts (#6002)
get_anomaly's circumstances gain the host's or SNMP device's syslog
warnings and worse (mcp_events / mcp_resource_events). An anomaly row opens
"Syslog around it", read only when opened. A shared Chart.js plugin
(chart-marks.js) draws a dashed line at each syslog error on the host
metrics charts and an SNMP device's charts, with a line saying how many.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 06:36:31 -04:00

125 lines
6.3 KiB
HTML

{% extends "base.html" %}
{% from "_macros.html" import crumbs %}
{% block title %}{{ host.name }} — Metrics — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Hosts", "/hosts/"), (host.name, "/hosts/" ~ host.id), ("Metrics", "")]) }}{% endblock %}
{% block content %}
{# Shell only: current-state + history data stream in via HTMX so the heavy
history query never blocks first paint and the data refreshes live (≈ the
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;">
<h1 class="page-title" style="margin-bottom:0;">{{ host.name }}</h1>
{% include "_time_range.html" %}
</div>
{# Current state — lazy + polled live. Atomic innerHTML swap of fixed-height
cards, so values update without a layout jump. #}
<div id="hm-current"
hx-get="/plugins/host_agent/{{ host.id }}/metrics"
hx-trigger="load, every {{ poll_seconds }}s"
hx-swap="innerHTML">
<div class="card empty">Loading metrics…</div>
</div>
{# History charts — persistent canvases (created once below); only data is polled. #}
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:1rem;margin-top:1rem;">
<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>
{% if has_nfs %}
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.5rem;">NFS delay (ms) — last <span class="hm-chart-range">{{ current_range }}</span></div>
<div style="height:220px;"><canvas id="chart-nfs"></canvas></div>
</div>
{% endif %}
</div>
{% if "syslog" in enabled_plugins %}<p id="hm-marks-note" class="field-hint" hidden style="margin:0.5rem 0 0;"></p>{% endif %}
{# 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-trigger="load, every {{ chart_poll_seconds }}s, rangeChange from:body"
hx-include="#time-range"
hx-swap="innerHTML"></div>
<script>
(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, font: { size: 10 } } },
y: { beginAtZero: true, max: ymax, ticks: { font: { size: 10 } } },
},
plugins: {
legend: { labels: { 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: stewardColor("--gold") },
{ key: "mem_used_pct", label: "Mem %", color: stewardColor("--green") },
{ key: "disk_used_pct_worst", label: "Disk % (worst)", color: stewardColor("--yellow") },
] },
"chart-net": { ymax: undefined, ds: [
{ key: "net_rx_bps", label: "Net RX", color: stewardColor("--green") },
{ key: "net_tx_bps", label: "Net TX", color: stewardColor("--red") },
{ key: "disk_read_bps", label: "Disk read", color: stewardColor("--blue") },
{ key: "disk_write_bps", label: "Disk write", color: stewardColor("--gold") },
] },
"chart-pressure": { ymax: undefined, ds: [
{ key: "load_1m", label: "Load 1m", color: stewardColor("--gold") },
{ key: "psi_mem_some_avg10", label: "Mem PSI %", color: stewardColor("--red") },
{ key: "temp_c_max", label: "Temp °C", color: stewardColor("--yellow") },
] },
"chart-nfs": { ymax: undefined, ds: [
{ key: "nfs_exe_max_ms", label: "Slowest mount, ms a request", color: stewardColor("--blue") },
{ key: "mounts_stalled", label: "Mounts not answering", color: stewardColor("--red") },
] },
};
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; });
{% if "syslog" in enabled_plugins %}
// The host's own syslog errors over the same span (#6002).
stewardMarkCharts("/plugins/syslog/marks?host_id={{ host.id }}&range=" + encodeURIComponent(rangeKey || ""),
Object.values(charts), document.getElementById("hm-marks-note"));
{% endif %}
};
})();
</script>
{% endblock %}