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>
209 lines
12 KiB
HTML
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 %}
|