Files
FabledSteward/plugins/host_agent/templates/_host_metrics.html
T
bvandeusenandClaude Opus 5.5 6c4ef368f9
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m24s
CI / unit (push) Successful in 1m32s
CI / publish (push) Successful in 1m10s
feat(host_agent): each drive's health, read with smartctl, alertable (#5789)
The root helper gains one read-only action, `drives`: it finds the drives
itself (smartctl --scan-open), reads each as JSON, and writes a summary the
agent carries on every sample (agent 1.20.0). Steward judges the figures in
drives.py (failing / worn / watch / ok / no SMART / unreadable), records them
per drive and as host roll-ups for covering rules, keeps the newest report on
the registration and lists it on the host page.

install-deps.sh installs smartmontools with apt, dnf, yum, zypper, pacman or
apk, run by install.sh and every agent playbook; one it can't install is
reported, never fatal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:17:50 -04:00

209 lines
12 KiB
HTML

{# Current-state fragment for the full-metrics page — polled live via HTMX.
Each poll re-renders the latest snapshot the server has. #}
{% from "_macros.html" import status_badge %}
{# A percentage bar graded by Settings → Thresholds for `kind` (cpu, mem, disk). #}
{% macro bar(pct, kind) %}
<div class="meter"><div class="meter-fill meter-{{ status_class(pct, kind) }}" style="width:{{ (pct or 0)|round(1) }}%;"></div></div>
{% endmacro %}
{% macro gauge(value, kind, text) %}<span class="stat-val tone-{{ status_class(value, kind) }}" style="font-size:1.6rem;">{{ text }}</span>{% 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>{{ status_badge('stale' if stale else 'up', 'Stale' if stale else 'Live') }}</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|duration }}</span>{% endif %}
<span><span style="color:var(--text-dim);">Last seen</span> {{ reg.last_seen_at|ago 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') %}
{{ gauge(c, 'cpu', ("%.0f%%"|format(c)) if c is not none else "—") }}
<div style="margin-top:0.5rem;">{{ bar(c, 'cpu') }}</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') %}
{{ gauge(mp, 'mem', ("%.0f%%"|format(mp)) if mp is not none else "—") }}
<div style="margin-top:0.5rem;">{{ bar(mp, 'mem') }}</div>
<div style="margin-top:0.45rem;font-size:0.72rem;color:var(--text-dim);">
avail {{ hostlvl.get('mem_available_bytes')|bytes }} · cache {{ hostlvl.get('mem_cached_bytes')|bytes }} · swap {{ hostlvl.get('swap_used_bytes')|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> {{ hostlvl.get('net_rx_bps')|rate }}</div>
<div><span style="color:var(--red);">↑</span> {{ hostlvl.get('net_tx_bps')|rate }}</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> {{ hostlvl.get('disk_read_bps')|rate }}</div>
<div><span style="color:var(--text-dim);">wr</span> {{ hostlvl.get('disk_write_bps')|rate }}</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') %}
{{ gauge(t, 'temp', "%.0f°C"|format(t)) }}
</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, 'cpu') }}
</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);">{{ m.get('disk_used_bytes')|bytes }} / {{ m.get('disk_total_bytes')|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'), 'disk') }}
</div>
{% endfor %}
</div>
{% endif %}
{# ── Drive health (#5789): each drive as the root helper read it with
smartctl, hourly; what needs attention first (drives.py judges them). ──── #}
{% set drive_badge = {"failing": ("crit", "Failing"), "unreadable": ("warn", "Unreadable"),
"worn": ("warn", "Worn"), "watch": ("warn", "Watch"), "ok": ("ok", "OK"),
"unavailable": ("inactive", "No SMART")} %}
{% if drives or drives_error %}
<div class="card">
<div class="section-title" style="margin-bottom:0.5rem;display:flex;justify-content:space-between;gap:0.75rem;">
<span>Drive health</span>
{% if drives_at %}<span style="font-weight:400;color:var(--text-dim);font-size:0.72rem;">read {{ drives_at|ago }}</span>{% endif %}
</div>
{% if drives_error %}
<p class="field-hint" style="margin:0;">Not read: {{ drives_error }}.</p>
{% endif %}
{% for d in drives %}
{% set tone, label = drive_badge[d.state] %}
<div style="display:flex;justify-content:space-between;align-items:baseline;gap:0.75rem;font-size:0.8rem;padding:0.2rem 0;">
<span style="min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="{{ d.model }}">
{{ status_badge(tone, label) }}
<span style="font-family:ui-monospace,monospace;">{{ d.device }}</span>
{% if d.model %}<span style="color:var(--text-dim);">{{ d.model }}{% if d.capacity_bytes %} · {{ d.capacity_bytes|bytes }}{% endif %}</span>{% endif %}
{% if d.why %}<span style="color:var(--text-muted);"> — {{ d.why }}</span>{% endif %}
</span>
<span style="color:var(--text-muted);font-variant-numeric:tabular-nums;white-space:nowrap;">
{%- set parts = [] -%}
{%- if d.get("life_used_pct") is not none %}{% set _ = parts.append("%d%% life used"|format(d.life_used_pct)) %}{% endif -%}
{%- if d.get("bad_sectors") is not none %}{% set _ = parts.append("%d bad sectors"|format(d.bad_sectors)) %}{% endif -%}
{%- if d.get("temp_c") is not none %}{% set _ = parts.append("%d°C"|format(d.temp_c)) %}{% endif -%}
{{ parts|join(" · ") if parts else "—" }}
</span>
</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;">↓ {{ m.get('net_rx_bps')|rate }} · ↑ {{ m.get('net_tx_bps')|rate }}</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 {{ m.get('disk_read_bps')|rate }} · wr {{ m.get('disk_write_bps')|rate }}</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 class="tone-{{ status_class(c, 'temp') }}" style="font-variant-numeric:tabular-nums;">{% if c is not none %}{{ "%.0f"|format(c) }}°C{% else %}—{% endif %}</span>
</div>
{% endfor %}
</div>
{% if unwired_temps %}
{# Sensors that aren't thermometers: they run against the CPU or hold a
constant (#5783), or read a value no wired sensor gives (#5759). Left
out of the host's temperature. Each reason is said once, after its
sensors, hottest first within it. #}
<p class="field-hint" style="margin:0.5rem 0 0;">Not counted, likely nothing wired to them:
{% for why, group in unwired_temps.items()|groupby("1.1") %}{% for label, v in group %}{{ label }} {{ "%.0f"|format(v[0]) }}°C{{ ", " if not loop.last }}{% endfor %} ({{ why }}){{ "; " if not loop.last }}{% endfor %}.</p>
{% endif %}
</div>
{% endif %}
{% endif %}