Files
FabledSteward/plugins/host_agent/templates/widget_history.html
T
bvandeusenandClaude Opus 5.5 414c942cbf
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m5s
CI / unit (push) Successful in 1m19s
CI / publish (push) Successful in 57s
feat(plugins): Traefik problems first, UniFi/SNMP/fleet on shared shapes, audit names its entities, templates on tokens (#5126)
Traefik: routers are graded from their latest scrape (router_state: crit on
any real 5xx share, warn on p95 past the latency critical cutoff or a high
4xx share). Failing and slow routers lead as cards that say why; healthy
ones fold into one table that stays open or shut across polls. Proxy
counts are stat tiles; certificates are graded by Settings -> Thresholds
(tls_days).

UniFi: WAN health is a stat strip (latency graded by the thresholds),
devices carry status dots, event categories and firewall actions are
badges, the alarm count reuses .count-badge (renamed from .nav-count).
SNMP: each device wears a status badge and its readings are stat tiles.

Agent fleet: the table comes first, with a Status column (Reporting /
Silent / Never reported, by the host page's stale_after_seconds), silent
agents first, hosts linked, last seen as |ago.

Audit: entries name their entity (one query per type, steward/audit/
service.py) and link it; something deleted since keeps the name its entry
recorded and says "(deleted)".

Tokens: charts read colours through chart-colors.js (stewardColor resolves
a token to the rgb the browser computed; Chart.js defaults take the page's
text and border colours), so host, history, Traefik and SNMP charts drop
their hex. Template hex colours reach 0 (HEX_CEILING = 0), the last rgba
pills go, and heavy weights (600/700/bold, 60 of them) become 500, the
house pair being 400/500; a guard keeps templates there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:25:09 -04:00

91 lines
4.1 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{# host_agent history-graph widget — the host-view utilization chart, embedded
on a dashboard. Fills the (resizable) panel; epoch-ms linear axis so no
Chart.js date adapter is needed. #}
{% set has_data = host and (series.cpu_pct or series.mem_used_pct or series.disk_root) %}
{% if not host %}
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.75rem 0;">
No host selected. <strong>Edit</strong> this dashboard and pick a host for this graph.
</div>
{% elif not has_data %}
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.75rem 0;">
No agent metrics for <strong>{{ host.name }}</strong> in the last {{ hours }}h yet.
</div>
{% else %}
<div style="display:flex;flex-direction:column;height:100%;min-height:200px;">
{# Name the host the graph represents (panel title is generic) + a live range
toggle that re-requests this widget without entering edit mode. #}
<div style="flex:0 0 auto;display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;font-size:0.75rem;color:var(--text-muted);margin-bottom:0.3rem;">
{% if session.user_id %}
<a href="/hosts/{{ host.id }}" style="font-weight:500;">{{ host.name }}</a>
{% else %}
<strong style="color:var(--text);">{{ host.name }}</strong>
{% endif %}
<span class="hist-range" style="margin-left:auto;display:inline-flex;gap:0.25rem;">
{% for opt in [1, 6, 24] %}
<button type="button" onclick="histRange({{ wid|int }}, {{ opt|int }})"
class="range-btn{% if opt == hours %} active{% endif %}">{{ opt }}h</button>
{% endfor %}
</span>
</div>
<div style="flex:1 1 auto;min-height:150px;position:relative;">
<canvas id="host-hist-{{ wid }}"></canvas>
</div>
</div>
<script>
// Live time-range switch. Rewrites the parent cell's hx-get so the choice sticks
// across polls (htmx re-reads the attribute each poll), then fetches immediately.
// Only `hours` changes: the rest of the query (host, widget id, a share link's
// token) is kept, so the switch also works on a shared dashboard.
window.histRange = function (wid, hours) {
var cell = document.getElementById("widget-" + wid);
if (!cell) return;
var parts = (cell.getAttribute("hx-get") || "").split("?");
var query = new URLSearchParams(parts[1] || "");
query.set("hours", hours);
var url = parts[0] + "?" + query.toString();
cell.setAttribute("hx-get", url);
if (window.htmx) window.htmx.ajax("GET", url, { target: cell, swap: "innerHTML" });
};
(function () {
var el = document.getElementById("host-hist-{{ wid }}");
if (!el) return;
var series = {{ series|tojson }};
var fmtTime = function (v) {
var d = new Date(v);
return ("0" + d.getHours()).slice(-2) + ":" + ("0" + d.getMinutes()).slice(-2);
};
var ds = [
{ key: "cpu_pct", label: "CPU %", color: stewardColor("--gold") },
{ key: "mem_used_pct", label: "Mem %", color: stewardColor("--green") },
{ key: "disk_root", label: "Disk / %", color: stewardColor("--yellow") },
];
new Chart(el.getContext("2d"), {
type: "line",
data: { datasets: ds.map(function (d) {
return {
label: d.label,
data: (series[d.key] || []).map(function (p) { return { x: p[0], y: p[1] }; }),
borderColor: d.color, backgroundColor: d.color, borderWidth: 1.5, tension: 0.25,
};
}) },
options: {
responsive: true, maintainAspectRatio: false,
interaction: { mode: "index", intersect: false },
elements: { point: { radius: 0 } },
scales: {
x: { type: "linear", ticks: { callback: function (v) { return fmtTime(v); }, maxTicksLimit: 6, font: { size: 10 } } },
// Fixed 0–{{ y_max }} ceiling (server snaps the data peak up to the next
// 10% band). Steady across refreshes — auto-scaling made the zoom jump on
// every poll — while a banded ceiling still keeps the lines filling the panel.
y: { min: 0, max: {{ y_max }}, ticks: { font: { size: 10 } } },
},
plugins: {
legend: { labels: { boxWidth: 10, font: { size: 11 } } },
tooltip: { callbacks: { title: function (items) { return items.length ? fmtTime(items[0].parsed.x) : ""; } } },
},
},
});
})();
</script>
{% endif %}