feat(host_agent): lazy-load full-metrics charts + live-poll current state
The full-metrics page rendered once server-side with the history query inline, so the charts blocked first paint and nothing refreshed without a reload (it reads the latest snapshot the server holds — the agent pushes ~every 30s). Split it into a shell + two HTMX fragments: - Shell (host_detail.html): header + shared time-range toggle + two containers; paints instantly. - Current state (/<id>/metrics → _host_metrics.html): identity + gauges + per-core + filesystems + interfaces/disks + temps, from the DISTINCT ON latest query. hx-trigger "load, every 15s" → live numbers at ~the agent cadence. - History charts (/<id>/charts → _host_charts.html): the 3 charts + Chart.js, from the date_bin history query. hx-trigger "load, every 60s, rangeChange" so they lazy-load (never block paint), refresh slowly, and follow the range selector. Leak-safe: previous Chart instances are destroyed before re-render. - Range toggle switched from full-reload links to the shared _time_range.html (setTimeRange + rangeChange), so only the fragments refetch. - routes: host_detail (shell) + host_detail_metrics + host_detail_charts, with a _split_host_metrics helper. - tests/test_templates_parse.py now also parses plugin templates (these fragments aren't rendered in the unit lane). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
This commit is contained in:
@@ -545,30 +545,25 @@ def _downsample(series: list[list], target: int = 120) -> list[list]:
|
||||
return out
|
||||
|
||||
|
||||
@host_agent_bp.get("/<host_id>/")
|
||||
@require_role(UserRole.viewer)
|
||||
async def host_detail(host_id: str):
|
||||
"""Netdata-style per-host detail: current gauges + history charts."""
|
||||
since, range_key = parse_range(request.args.get("range"))
|
||||
async with current_app.db_sessionmaker() as session:
|
||||
host = (await session.execute(
|
||||
select(Host).where(Host.id == host_id))).scalar_one_or_none()
|
||||
if host is None:
|
||||
return _error(404, "not_found")
|
||||
reg = (await session.execute(select(HostAgentRegistration).where(
|
||||
HostAgentRegistration.host_id == host_id))).scalar_one_or_none()
|
||||
latest = await _latest_metrics_for_host(session, host.name)
|
||||
series = await _history_for_host(session, host.name, since)
|
||||
# Full-metrics refresh cadences. The agent pushes every ~30s by default, so
|
||||
# polling the current snapshot at 15s keeps it ≤ one cadence stale; the history
|
||||
# charts move slowly over a multi-hour/day window, so they refresh less often.
|
||||
_DETAIL_POLL_SECONDS = 15
|
||||
_CHART_POLL_SECONDS = 60
|
||||
|
||||
hostlvl = latest.get(host.name, {})
|
||||
|
||||
def _split_host_metrics(host_name: str, latest: dict) -> tuple:
|
||||
"""Split a host's latest snapshot into
|
||||
(hostlvl, cores, nets, disks_io, temps, mounts) for the current-state fragment."""
|
||||
hostlvl = latest.get(host_name, {})
|
||||
cores: list = []
|
||||
nets: dict = {}
|
||||
disks_io: dict = {}
|
||||
temps: dict = {}
|
||||
mounts: dict = {}
|
||||
plen = len(host.name) + 1
|
||||
plen = len(host_name) + 1
|
||||
for res, metrics in latest.items():
|
||||
if res == host.name:
|
||||
if res == host_name:
|
||||
continue
|
||||
suffix = res[plen:]
|
||||
if suffix.startswith("core"):
|
||||
@@ -586,18 +581,66 @@ async def host_detail(host_id: str):
|
||||
else:
|
||||
mounts[suffix] = metrics
|
||||
cores.sort(key=lambda c: c[0])
|
||||
return hostlvl, cores, nets, disks_io, temps, mounts
|
||||
|
||||
ls = reg.last_seen_at if reg else None
|
||||
stale = ls is None or (datetime.now(timezone.utc) - ls).total_seconds() > _stale_after_seconds()
|
||||
|
||||
@host_agent_bp.get("/<host_id>/")
|
||||
@require_role(UserRole.viewer)
|
||||
async def host_detail(host_id: str):
|
||||
"""Full-metrics shell: header + range toggle only. The current-state and
|
||||
history sections stream in via HTMX (host_detail_metrics / host_detail_charts)
|
||||
so the heavy history query never blocks first paint and the data live-updates."""
|
||||
_, range_key = parse_range(request.args.get("range"))
|
||||
async with current_app.db_sessionmaker() as session:
|
||||
host = (await session.execute(
|
||||
select(Host).where(Host.id == host_id))).scalar_one_or_none()
|
||||
if host is None:
|
||||
return _error(404, "not_found")
|
||||
return await render_template(
|
||||
"host_detail.html",
|
||||
host=host, current_range=range_key, range_options=RANGE_OPTIONS,
|
||||
poll_seconds=_DETAIL_POLL_SECONDS, chart_poll_seconds=_CHART_POLL_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
@host_agent_bp.get("/<host_id>/metrics")
|
||||
@require_role(UserRole.viewer)
|
||||
async def host_detail_metrics(host_id: str):
|
||||
"""Current-state fragment (gauges/cores/filesystems/IO/temps) — polled live so
|
||||
the page shows the latest snapshot the server holds without a full reload."""
|
||||
async with current_app.db_sessionmaker() as session:
|
||||
host = (await session.execute(
|
||||
select(Host).where(Host.id == host_id))).scalar_one_or_none()
|
||||
if host is None:
|
||||
return "", 404
|
||||
reg = (await session.execute(select(HostAgentRegistration).where(
|
||||
HostAgentRegistration.host_id == host_id))).scalar_one_or_none()
|
||||
latest = await _latest_metrics_for_host(session, host.name)
|
||||
hostlvl, cores, nets, disks_io, temps, mounts = _split_host_metrics(host.name, latest)
|
||||
ls = reg.last_seen_at if reg else None
|
||||
stale = ls is None or (datetime.now(timezone.utc) - ls).total_seconds() > _stale_after_seconds()
|
||||
return await render_template(
|
||||
"_host_metrics.html",
|
||||
host=host, reg=reg, stale=stale, hostlvl=hostlvl,
|
||||
cores=cores, nets=nets, disks_io=disks_io, temps=temps, mounts=mounts,
|
||||
series=series, range_key=range_key, range_options=RANGE_OPTIONS,
|
||||
)
|
||||
|
||||
|
||||
@host_agent_bp.get("/<host_id>/charts")
|
||||
@require_role(UserRole.viewer)
|
||||
async def host_detail_charts(host_id: str):
|
||||
"""History-charts fragment — lazy-loaded so the date_bin query never blocks
|
||||
the page shell; refreshed on a slow cadence and on range change."""
|
||||
since, range_key = parse_range(request.args.get("range"))
|
||||
async with current_app.db_sessionmaker() as session:
|
||||
host = (await session.execute(
|
||||
select(Host).where(Host.id == host_id))).scalar_one_or_none()
|
||||
if host is None:
|
||||
return "", 404
|
||||
series = await _history_for_host(session, host.name, since)
|
||||
return await render_template("_host_charts.html", series=series, range_key=range_key)
|
||||
|
||||
|
||||
def _new_token_pair() -> tuple[str, str]:
|
||||
raw = secrets.token_urlsafe(32)
|
||||
return raw, _hash_token(raw)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
{# History-charts fragment for the full-metrics page — lazy-loaded via HTMX so
|
||||
the date_bin history query never blocks the page shell, and refreshed on a
|
||||
slow cadence / range change. #}
|
||||
<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>
|
||||
(function () {
|
||||
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>
|
||||
@@ -0,0 +1,182 @@
|
||||
{# Current-state fragment for the full-metrics page — polled live via HTMX.
|
||||
Self-contained (defines its own format macros) so each poll re-renders the
|
||||
latest snapshot the server has. #}
|
||||
{% macro fmt_bps(v) %}
|
||||
{%- if v is none -%}—
|
||||
{%- elif v >= 1048576 -%}{{ "%.1f"|format(v / 1048576) }} MB/s
|
||||
{%- elif v >= 1024 -%}{{ "%.1f"|format(v / 1024) }} KB/s
|
||||
{%- else -%}{{ "%.0f"|format(v) }} B/s
|
||||
{%- endif -%}
|
||||
{% endmacro %}
|
||||
{% macro fmt_bytes(v) %}
|
||||
{%- if v is none -%}—
|
||||
{%- elif v >= 1125899906842624 -%}{{ "%.1f"|format(v / 1125899906842624) }} PB
|
||||
{%- elif v >= 1099511627776 -%}{{ "%.1f"|format(v / 1099511627776) }} TB
|
||||
{%- elif v >= 1073741824 -%}{{ "%.1f"|format(v / 1073741824) }} GB
|
||||
{%- elif v >= 1048576 -%}{{ "%.0f"|format(v / 1048576) }} MB
|
||||
{%- else -%}{{ "%.0f"|format(v / 1024) }} KB
|
||||
{%- endif -%}
|
||||
{% endmacro %}
|
||||
{% macro bar(pct) %}
|
||||
{%- set p = pct if pct is not none else 0 -%}
|
||||
<div style="height:8px;background:var(--bg-elevated);border-radius:4px;overflow:hidden;border:1px solid var(--border);">
|
||||
<div style="height:100%;width:{{ p|round(1) }}%;background:{% if pct is none %}var(--text-dim){% elif pct < 70 %}var(--green){% elif pct < 90 %}var(--yellow){% else %}var(--red){% endif %};"></div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{# ── Identity / metadata ─────────────────────────────────────────────────── #}
|
||||
<div class="card" style="display:flex;flex-wrap:wrap;gap:0.5rem 1.25rem;align-items:center;font-size:0.82rem;color:var(--text-muted);">
|
||||
<span>{% if stale %}<span class="badge badge-red">stale</span>{% else %}<span class="badge badge-green">live</span>{% endif %}</span>
|
||||
<span><span style="color:var(--text-dim);">Address</span> {{ host.address or "—" }}</span>
|
||||
{% if reg %}
|
||||
{% if reg.distro %}<span><span style="color:var(--text-dim);">OS</span> {{ reg.distro }}</span>{% endif %}
|
||||
{% if reg.kernel %}<span><span style="color:var(--text-dim);">Kernel</span> {{ reg.kernel }}</span>{% endif %}
|
||||
{% if reg.arch %}<span><span style="color:var(--text-dim);">Arch</span> {{ reg.arch }}</span>{% endif %}
|
||||
{% if reg.agent_version %}<span><span style="color:var(--text-dim);">Agent</span> v{{ reg.agent_version }}</span>{% endif %}
|
||||
{% set up = hostlvl.get('uptime_secs') %}
|
||||
{% if up %}<span><span style="color:var(--text-dim);">Uptime</span> {{ (up // 86400)|int }}d {{ ((up % 86400) // 3600)|int }}h</span>{% endif %}
|
||||
<span><span style="color:var(--text-dim);">Last seen</span> {{ reg.last_seen_at.strftime("%Y-%m-%d %H:%M:%S") ~ " UTC" if reg.last_seen_at else "never" }}</span>
|
||||
{% else %}
|
||||
<span>No agent registration found for this host.</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if not hostlvl %}
|
||||
<div class="card empty">No metrics reported yet. Once the agent checks in, CPU, memory, network, disk I/O, temperatures, and pressure will appear here.</div>
|
||||
{% else %}
|
||||
|
||||
{# ── Current headline gauges ─────────────────────────────────────────────── #}
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:0.75rem;margin-bottom:1rem;">
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.35rem;">CPU</div>
|
||||
{% set c = hostlvl.get('cpu_pct') %}
|
||||
<span class="stat-val" style="font-size:1.6rem;color:{% if c is none %}var(--text-dim){% elif c < 70 %}var(--green){% elif c < 90 %}var(--yellow){% else %}var(--red){% endif %};">{% if c is not none %}{{ "%.0f"|format(c) }}%{% else %}—{% endif %}</span>
|
||||
<div style="margin-top:0.5rem;">{{ bar(c) }}</div>
|
||||
</div>
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.35rem;">Memory</div>
|
||||
{% set mp = hostlvl.get('mem_used_pct') %}
|
||||
<span class="stat-val" style="font-size:1.6rem;color:{% if mp is none %}var(--text-dim){% elif mp < 70 %}var(--green){% elif mp < 90 %}var(--yellow){% else %}var(--red){% endif %};">{% if mp is not none %}{{ "%.0f"|format(mp) }}%{% else %}—{% endif %}</span>
|
||||
<div style="margin-top:0.5rem;">{{ bar(mp) }}</div>
|
||||
<div style="margin-top:0.45rem;font-size:0.72rem;color:var(--text-dim);">
|
||||
avail {{ fmt_bytes(hostlvl.get('mem_available_bytes')) }} · cache {{ fmt_bytes(hostlvl.get('mem_cached_bytes')) }} · swap {{ fmt_bytes(hostlvl.get('swap_used_bytes')) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.35rem;">Load</div>
|
||||
<span class="stat-val" style="font-size:1.6rem;">{% if hostlvl.get('load_1m') is not none %}{{ "%.2f"|format(hostlvl.get('load_1m')) }}{% else %}—{% endif %}</span>
|
||||
<div style="margin-top:0.45rem;font-size:0.72rem;color:var(--text-dim);">
|
||||
5m {% if hostlvl.get('load_5m') is not none %}{{ "%.2f"|format(hostlvl.get('load_5m')) }}{% else %}—{% endif %} ·
|
||||
15m {% if hostlvl.get('load_15m') is not none %}{{ "%.2f"|format(hostlvl.get('load_15m')) }}{% else %}—{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.35rem;">Network</div>
|
||||
<div style="font-size:0.95rem;font-variant-numeric:tabular-nums;">
|
||||
<div><span style="color:var(--green);">↓</span> {{ fmt_bps(hostlvl.get('net_rx_bps')) }}</div>
|
||||
<div><span style="color:var(--red);">↑</span> {{ fmt_bps(hostlvl.get('net_tx_bps')) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.35rem;">Disk I/O</div>
|
||||
<div style="font-size:0.95rem;font-variant-numeric:tabular-nums;">
|
||||
<div><span style="color:var(--text-dim);">rd</span> {{ fmt_bps(hostlvl.get('disk_read_bps')) }}</div>
|
||||
<div><span style="color:var(--text-dim);">wr</span> {{ fmt_bps(hostlvl.get('disk_write_bps')) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if hostlvl.get('temp_c_max') is not none %}
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.35rem;">Temp (max)</div>
|
||||
{% set t = hostlvl.get('temp_c_max') %}
|
||||
<span class="stat-val" style="font-size:1.6rem;color:{% if t < 70 %}var(--green){% elif t < 85 %}var(--yellow){% else %}var(--red){% endif %};">{{ "%.0f"|format(t) }}°C</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if hostlvl.get('psi_mem_some_avg10') is not none %}
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.35rem;" title="Pressure stall — % of time stalled, 10s avg">Pressure (10s)</div>
|
||||
<div style="font-size:0.78rem;font-variant-numeric:tabular-nums;color:var(--text-muted);">
|
||||
<div>mem {{ "%.1f"|format(hostlvl.get('psi_mem_some_avg10')) }}%</div>
|
||||
{% if hostlvl.get('psi_cpu_some_avg10') is not none %}<div>cpu {{ "%.1f"|format(hostlvl.get('psi_cpu_some_avg10')) }}%</div>{% endif %}
|
||||
{% if hostlvl.get('psi_io_some_avg10') is not none %}<div>io {{ "%.1f"|format(hostlvl.get('psi_io_some_avg10')) }}%</div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── Per-core CPU ─────────────────────────────────────────────────────────── #}
|
||||
{% if cores %}
|
||||
<div class="card">
|
||||
<div class="section-title" style="margin-bottom:0.6rem;">Per-core CPU</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:0.5rem 0.9rem;">
|
||||
{% for idx, pct in cores %}
|
||||
<div>
|
||||
<div style="display:flex;justify-content:space-between;font-size:0.72rem;color:var(--text-dim);margin-bottom:0.2rem;">
|
||||
<span>core {{ idx }}</span><span>{% if pct is not none %}{{ "%.0f"|format(pct) }}%{% else %}—{% endif %}</span>
|
||||
</div>
|
||||
{{ bar(pct) }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Filesystems ──────────────────────────────────────────────────────────── #}
|
||||
{% if mounts %}
|
||||
<div class="card">
|
||||
<div class="section-title" style="margin-bottom:0.6rem;">Filesystems</div>
|
||||
{% for mount, m in mounts.items() %}
|
||||
<div style="margin-bottom:0.6rem;">
|
||||
<div style="display:flex;justify-content:space-between;font-size:0.8rem;margin-bottom:0.2rem;">
|
||||
<span style="font-family:ui-monospace,monospace;">{{ mount }}</span>
|
||||
<span style="color:var(--text-muted);">{{ fmt_bytes(m.get('disk_used_bytes')) }} / {{ fmt_bytes(m.get('disk_total_bytes')) }}{% if m.get('disk_used_pct') is not none %} · {{ "%.0f"|format(m.get('disk_used_pct')) }}%{% endif %}</span>
|
||||
</div>
|
||||
{{ bar(m.get('disk_used_pct')) }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Interfaces / disks (short panels, side by side at natural height) ─────── #}
|
||||
{% if nets or disks_io %}
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:1rem;align-items:start;">
|
||||
{% if nets %}
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.5rem;">Interfaces</div>
|
||||
{% for iface, m in nets.items() %}
|
||||
<div style="display:flex;justify-content:space-between;gap:0.75rem;font-size:0.8rem;padding:0.15rem 0;">
|
||||
<span style="font-family:ui-monospace,monospace;">{{ iface }}</span>
|
||||
<span style="color:var(--text-muted);font-variant-numeric:tabular-nums;white-space:nowrap;">↓ {{ fmt_bps(m.get('net_rx_bps')) }} · ↑ {{ fmt_bps(m.get('net_tx_bps')) }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if disks_io %}
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.5rem;">Disks</div>
|
||||
{% for dev, m in disks_io.items() %}
|
||||
<div style="display:flex;justify-content:space-between;gap:0.75rem;font-size:0.8rem;padding:0.15rem 0;">
|
||||
<span style="font-family:ui-monospace,monospace;">{{ dev }}</span>
|
||||
<span style="color:var(--text-muted);font-variant-numeric:tabular-nums;white-space:nowrap;">rd {{ fmt_bps(m.get('disk_read_bps')) }} · wr {{ fmt_bps(m.get('disk_write_bps')) }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Temperatures (full width; cores flow into a compact multi-column grid) ── #}
|
||||
{% if temps %}
|
||||
<div class="card" style="margin-top:1rem;margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.5rem;">Temperatures</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:0.1rem 1.25rem;">
|
||||
{% for label, c in temps.items() %}
|
||||
<div style="display:flex;justify-content:space-between;gap:0.5rem;font-size:0.8rem;padding:0.15rem 0;">
|
||||
<span style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ label }}</span>
|
||||
<span style="color:{% if c is none %}var(--text-dim){% elif c < 70 %}var(--green){% elif c < 85 %}var(--yellow){% else %}var(--red){% endif %};font-variant-numeric:tabular-nums;">{% if c is not none %}{{ "%.0f"|format(c) }}°C{% else %}—{% endif %}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
@@ -1,262 +1,32 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import crumbs %}
|
||||
{% block title %}{{ host.name }} — Host Agent — Steward{% endblock %}
|
||||
{% block title %}{{ host.name }} — Metrics — Steward{% endblock %}
|
||||
{% block breadcrumb %}{{ crumbs([("Hosts", "/hosts/"), (host.name, "/hosts/" ~ host.id), ("Metrics", "")]) }}{% endblock %}
|
||||
|
||||
{% macro fmt_bps(v) %}
|
||||
{%- if v is none -%}—
|
||||
{%- elif v >= 1048576 -%}{{ "%.1f"|format(v / 1048576) }} MB/s
|
||||
{%- elif v >= 1024 -%}{{ "%.1f"|format(v / 1024) }} KB/s
|
||||
{%- else -%}{{ "%.0f"|format(v) }} B/s
|
||||
{%- endif -%}
|
||||
{% endmacro %}
|
||||
{% macro fmt_bytes(v) %}
|
||||
{%- if v is none -%}—
|
||||
{%- elif v >= 1125899906842624 -%}{{ "%.1f"|format(v / 1125899906842624) }} PB
|
||||
{%- elif v >= 1099511627776 -%}{{ "%.1f"|format(v / 1099511627776) }} TB
|
||||
{%- elif v >= 1073741824 -%}{{ "%.1f"|format(v / 1073741824) }} GB
|
||||
{%- elif v >= 1048576 -%}{{ "%.0f"|format(v / 1048576) }} MB
|
||||
{%- else -%}{{ "%.0f"|format(v / 1024) }} KB
|
||||
{%- endif -%}
|
||||
{% endmacro %}
|
||||
{% macro bar(pct) %}
|
||||
{%- set p = pct if pct is not none else 0 -%}
|
||||
<div style="height:8px;background:var(--bg-elevated);border-radius:4px;overflow:hidden;border:1px solid var(--border);">
|
||||
<div style="height:100%;width:{{ p|round(1) }}%;background:{% if pct is none %}var(--text-dim){% elif pct < 70 %}var(--green){% elif pct < 90 %}var(--yellow){% else %}var(--red){% endif %};"></div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% block content %}
|
||||
{# Shell only: the current-state and history-chart sections stream in via HTMX
|
||||
below, so the heavy history query never blocks first paint and the data
|
||||
refreshes live (≈ the agent's push cadence) without a full reload. #}
|
||||
<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;gap:0.6rem;">
|
||||
<h1 class="page-title" style="margin-bottom:0;">{{ host.name }}</h1>
|
||||
{% if stale %}<span class="badge badge-red">stale</span>{% else %}<span class="badge badge-green">live</span>{% endif %}
|
||||
</div>
|
||||
<div style="display:flex;gap:0.3rem;">
|
||||
{% for r in range_options %}
|
||||
<a class="range-btn {% if r == range_key %}active{% endif %}" href="?range={{ r }}">{{ r }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<h1 class="page-title" style="margin-bottom:0;">{{ host.name }}</h1>
|
||||
{% include "_time_range.html" %}
|
||||
</div>
|
||||
|
||||
{# ── Identity / metadata ─────────────────────────────────────────────────── #}
|
||||
<div class="card" style="display:flex;flex-wrap:wrap;gap:0.5rem 1.25rem;align-items:center;font-size:0.82rem;color:var(--text-muted);">
|
||||
<span><span style="color:var(--text-dim);">Address</span> {{ host.address or "—" }}</span>
|
||||
{% if reg %}
|
||||
{% if reg.distro %}<span><span style="color:var(--text-dim);">OS</span> {{ reg.distro }}</span>{% endif %}
|
||||
{% if reg.kernel %}<span><span style="color:var(--text-dim);">Kernel</span> {{ reg.kernel }}</span>{% endif %}
|
||||
{% if reg.arch %}<span><span style="color:var(--text-dim);">Arch</span> {{ reg.arch }}</span>{% endif %}
|
||||
{% if reg.agent_version %}<span><span style="color:var(--text-dim);">Agent</span> v{{ reg.agent_version }}</span>{% endif %}
|
||||
{% set up = hostlvl.get('uptime_secs') %}
|
||||
{% if up %}<span><span style="color:var(--text-dim);">Uptime</span> {{ (up // 86400)|int }}d {{ ((up % 86400) // 3600)|int }}h</span>{% endif %}
|
||||
<span><span style="color:var(--text-dim);">Last seen</span> {{ reg.last_seen_at.strftime("%Y-%m-%d %H:%M:%S") ~ " UTC" if reg.last_seen_at else "never" }}</span>
|
||||
{% else %}
|
||||
<span>No agent registration found for this host.</span>
|
||||
{% endif %}
|
||||
{# Current state — lazy + polled live (latest snapshot the server holds). #}
|
||||
<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>
|
||||
|
||||
{% if not hostlvl %}
|
||||
<div class="card empty">No metrics reported yet. Once the agent checks in, CPU, memory, network, disk I/O, temperatures, and pressure will appear here.</div>
|
||||
{% else %}
|
||||
|
||||
{# ── Current headline gauges ─────────────────────────────────────────────── #}
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:0.75rem;margin-bottom:1rem;">
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.35rem;">CPU</div>
|
||||
{% set c = hostlvl.get('cpu_pct') %}
|
||||
<span class="stat-val" style="font-size:1.6rem;color:{% if c is none %}var(--text-dim){% elif c < 70 %}var(--green){% elif c < 90 %}var(--yellow){% else %}var(--red){% endif %};">{% if c is not none %}{{ "%.0f"|format(c) }}%{% else %}—{% endif %}</span>
|
||||
<div style="margin-top:0.5rem;">{{ bar(c) }}</div>
|
||||
</div>
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.35rem;">Memory</div>
|
||||
{% set mp = hostlvl.get('mem_used_pct') %}
|
||||
<span class="stat-val" style="font-size:1.6rem;color:{% if mp is none %}var(--text-dim){% elif mp < 70 %}var(--green){% elif mp < 90 %}var(--yellow){% else %}var(--red){% endif %};">{% if mp is not none %}{{ "%.0f"|format(mp) }}%{% else %}—{% endif %}</span>
|
||||
<div style="margin-top:0.5rem;">{{ bar(mp) }}</div>
|
||||
<div style="margin-top:0.45rem;font-size:0.72rem;color:var(--text-dim);">
|
||||
avail {{ fmt_bytes(hostlvl.get('mem_available_bytes')) }} · cache {{ fmt_bytes(hostlvl.get('mem_cached_bytes')) }} · swap {{ fmt_bytes(hostlvl.get('swap_used_bytes')) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.35rem;">Load</div>
|
||||
<span class="stat-val" style="font-size:1.6rem;">{% if hostlvl.get('load_1m') is not none %}{{ "%.2f"|format(hostlvl.get('load_1m')) }}{% else %}—{% endif %}</span>
|
||||
<div style="margin-top:0.45rem;font-size:0.72rem;color:var(--text-dim);">
|
||||
5m {% if hostlvl.get('load_5m') is not none %}{{ "%.2f"|format(hostlvl.get('load_5m')) }}{% else %}—{% endif %} ·
|
||||
15m {% if hostlvl.get('load_15m') is not none %}{{ "%.2f"|format(hostlvl.get('load_15m')) }}{% else %}—{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.35rem;">Network</div>
|
||||
<div style="font-size:0.95rem;font-variant-numeric:tabular-nums;">
|
||||
<div><span style="color:var(--green);">↓</span> {{ fmt_bps(hostlvl.get('net_rx_bps')) }}</div>
|
||||
<div><span style="color:var(--red);">↑</span> {{ fmt_bps(hostlvl.get('net_tx_bps')) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.35rem;">Disk I/O</div>
|
||||
<div style="font-size:0.95rem;font-variant-numeric:tabular-nums;">
|
||||
<div><span style="color:var(--text-dim);">rd</span> {{ fmt_bps(hostlvl.get('disk_read_bps')) }}</div>
|
||||
<div><span style="color:var(--text-dim);">wr</span> {{ fmt_bps(hostlvl.get('disk_write_bps')) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if hostlvl.get('temp_c_max') is not none %}
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.35rem;">Temp (max)</div>
|
||||
{% set t = hostlvl.get('temp_c_max') %}
|
||||
<span class="stat-val" style="font-size:1.6rem;color:{% if t < 70 %}var(--green){% elif t < 85 %}var(--yellow){% else %}var(--red){% endif %};">{{ "%.0f"|format(t) }}°C</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if hostlvl.get('psi_mem_some_avg10') is not none %}
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.35rem;" title="Pressure stall — % of time stalled, 10s avg">Pressure (10s)</div>
|
||||
<div style="font-size:0.78rem;font-variant-numeric:tabular-nums;color:var(--text-muted);">
|
||||
<div>mem {{ "%.1f"|format(hostlvl.get('psi_mem_some_avg10')) }}%</div>
|
||||
{% if hostlvl.get('psi_cpu_some_avg10') is not none %}<div>cpu {{ "%.1f"|format(hostlvl.get('psi_cpu_some_avg10')) }}%</div>{% endif %}
|
||||
{% if hostlvl.get('psi_io_some_avg10') is not none %}<div>io {{ "%.1f"|format(hostlvl.get('psi_io_some_avg10')) }}%</div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{# History charts — lazy (never blocks paint), refreshed on a slow cadence and
|
||||
on range change; range comes from the shared #time-range input. #}
|
||||
<div id="hm-charts"
|
||||
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"
|
||||
style="margin-top:1rem;">
|
||||
<div class="card empty">Loading charts…</div>
|
||||
</div>
|
||||
|
||||
{# ── Per-core CPU ─────────────────────────────────────────────────────────── #}
|
||||
{% if cores %}
|
||||
<div class="card">
|
||||
<div class="section-title" style="margin-bottom:0.6rem;">Per-core CPU</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:0.5rem 0.9rem;">
|
||||
{% for idx, pct in cores %}
|
||||
<div>
|
||||
<div style="display:flex;justify-content:space-between;font-size:0.72rem;color:var(--text-dim);margin-bottom:0.2rem;">
|
||||
<span>core {{ idx }}</span><span>{% if pct is not none %}{{ "%.0f"|format(pct) }}%{% else %}—{% endif %}</span>
|
||||
</div>
|
||||
{{ bar(pct) }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Filesystems ──────────────────────────────────────────────────────────── #}
|
||||
{% if mounts %}
|
||||
<div class="card">
|
||||
<div class="section-title" style="margin-bottom:0.6rem;">Filesystems</div>
|
||||
{% for mount, m in mounts.items() %}
|
||||
<div style="margin-bottom:0.6rem;">
|
||||
<div style="display:flex;justify-content:space-between;font-size:0.8rem;margin-bottom:0.2rem;">
|
||||
<span style="font-family:ui-monospace,monospace;">{{ mount }}</span>
|
||||
<span style="color:var(--text-muted);">{{ fmt_bytes(m.get('disk_used_bytes')) }} / {{ fmt_bytes(m.get('disk_total_bytes')) }}{% if m.get('disk_used_pct') is not none %} · {{ "%.0f"|format(m.get('disk_used_pct')) }}%{% endif %}</span>
|
||||
</div>
|
||||
{{ bar(m.get('disk_used_pct')) }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Interfaces / disks (short panels, side by side at natural height) ─────── #}
|
||||
{% if nets or disks_io %}
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:1rem;align-items:start;">
|
||||
{% if nets %}
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.5rem;">Interfaces</div>
|
||||
{% for iface, m in nets.items() %}
|
||||
<div style="display:flex;justify-content:space-between;gap:0.75rem;font-size:0.8rem;padding:0.15rem 0;">
|
||||
<span style="font-family:ui-monospace,monospace;">{{ iface }}</span>
|
||||
<span style="color:var(--text-muted);font-variant-numeric:tabular-nums;white-space:nowrap;">↓ {{ fmt_bps(m.get('net_rx_bps')) }} · ↑ {{ fmt_bps(m.get('net_tx_bps')) }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if disks_io %}
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.5rem;">Disks</div>
|
||||
{% for dev, m in disks_io.items() %}
|
||||
<div style="display:flex;justify-content:space-between;gap:0.75rem;font-size:0.8rem;padding:0.15rem 0;">
|
||||
<span style="font-family:ui-monospace,monospace;">{{ dev }}</span>
|
||||
<span style="color:var(--text-muted);font-variant-numeric:tabular-nums;white-space:nowrap;">rd {{ fmt_bps(m.get('disk_read_bps')) }} · wr {{ fmt_bps(m.get('disk_write_bps')) }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Temperatures (full width; cores flow into a compact multi-column grid so a
|
||||
many-core CPU reads wide-and-short instead of one very tall column) ─────── #}
|
||||
{% if temps %}
|
||||
<div class="card" style="margin-top:1rem;margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.5rem;">Temperatures</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:0.1rem 1.25rem;">
|
||||
{% for label, c in temps.items() %}
|
||||
<div style="display:flex;justify-content:space-between;gap:0.5rem;font-size:0.8rem;padding:0.15rem 0;">
|
||||
<span style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ label }}</span>
|
||||
<span style="color:{% if c is none %}var(--text-dim){% elif c < 70 %}var(--green){% elif c < 85 %}var(--yellow){% else %}var(--red){% endif %};font-variant-numeric:tabular-nums;">{% if c is not none %}{{ "%.0f"|format(c) }}°C{% else %}—{% endif %}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── History charts ───────────────────────────────────────────────────────── #}
|
||||
<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 {{ 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>
|
||||
(function () {
|
||||
const series = {{ series|tojson }};
|
||||
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;
|
||||
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>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -11,12 +11,24 @@ import jinja2
|
||||
|
||||
import steward
|
||||
|
||||
_ROOT = pathlib.Path(steward.__file__).parent / "templates"
|
||||
_REPO = pathlib.Path(steward.__file__).parent.parent
|
||||
_CORE_TEMPLATES = _REPO / "steward" / "templates"
|
||||
_PLUGINS = _REPO / "plugins"
|
||||
|
||||
|
||||
def test_all_steward_templates_parse():
|
||||
env = jinja2.Environment()
|
||||
files = sorted(_ROOT.rglob("*.html"))
|
||||
files = sorted(_CORE_TEMPLATES.rglob("*.html"))
|
||||
assert files, "no steward templates found"
|
||||
for f in files:
|
||||
env.parse(f.read_text()) # raises TemplateSyntaxError on a bad tag
|
||||
|
||||
|
||||
def test_all_plugin_templates_parse():
|
||||
# First-party plugins ship templates too (host_agent fragments, docker, …);
|
||||
# they aren't rendered in the unit lane, so parse them here to catch a bad tag.
|
||||
env = jinja2.Environment()
|
||||
files = sorted(_PLUGINS.rglob("templates/**/*.html"))
|
||||
assert files, "no plugin templates found"
|
||||
for f in files:
|
||||
env.parse(f.read_text())
|
||||
|
||||
Reference in New Issue
Block a user