Files
FabledSteward/plugins/host_agent/templates/_host_metrics.html
T
bvandeusenandClaude Opus 5.5 3f9aae7f46
CI / lint (push) Successful in 2s
CI / unit (push) Failing after 1m32s
CI / integration (push) Successful in 1m31s
CI / publish (push) Skipped
feat(host_agent): NFS delay per mount, read from /proc/self/mountstats (#5996)
Each sample carries each NFS mount's requests since the last one: how many
a second, their round trip and execute time (READ and WRITE on their own),
and how many were sent again or hit a major timeout. mountstats is the
kernel's own count and never touches the share, so it reads while one is
stuck. Steward stores them per mount and the slowest on the host as
nfs_exe_max_ms, for an "NFS slow" rule and the host page's NFS chart beside
mounts_stalled; each mount's bar says what a request took.

Agent 1.22.0 (which also carries the backlog order from 41fc807).

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

220 lines
13 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') }}
{% if m.get('nfs_ops_per_sec') is not none %}
{# NFS delay (#5996): what a request took since the agent's last sample. #}
{% set exe = m.get('nfs_exe_ms') %}
<div style="font-size:0.72rem;color:var(--text-dim);margin-top:0.2rem;">
NFS {% if m.get('nfs_ops_per_sec') > 0 and exe is not none -%}
<span class="{{ 'tone-crit' if exe >= 1000 else ('tone-warn' if exe >= 100 else '') }}">{{ "%.1f"|format(exe) }} ms a request</span> · {{ "%.3g"|format(m.get('nfs_ops_per_sec')) }}/s
{%- else %}idle{% endif %}
{%- if m.get('nfs_retrans') %} · <span class="tone-warn">{{ m.get('nfs_retrans')|int }} sent again</span>{% endif %}
{%- if m.get('nfs_timeouts') %} · <span class="tone-crit">{{ m.get('nfs_timeouts')|int }} timed out</span>{% endif %}
</div>
{% endif %}
</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 %}