feat(dashboard): Hosts widget worst first, load against cores, one home per fact (#5251)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m19s
CI / unit (push) Failing after 1m31s
CI / publish (push) Skipped

The Hosts — Overview widget opens with a verdict ("VDNT-Util01: CPU high."
or "All 3 hosts healthy."), then one row per host under one header: CPU,
memory, disk / and load. Rows sort by host_glance: a down monitor, a
reading past critical, a quiet agent, a warning, then the healthy. Load
reads "16.8 / 8 cores", graded per core as Settings → Thresholds intends
(cores counted from the per-core CPU sub-resources, as on the host page).
A quiet agent's last figures are shown uncoloured, with when it last reported.

Ping and 24h uptime are gone from this widget: they belong to Monitors. So
are the unlabelled CPU sparkline and the 30-day uptime query it no longer
needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 13:15:01 -04:00
co-authored by Claude Opus 5.5
parent b5fcccba5f
commit 17abe450cc
5 changed files with 170 additions and 92 deletions
+39 -46
View File
@@ -1,50 +1,43 @@
{# Unified Hosts widget — monitor status + agent glance per host, links to the hub.
Name sits on its own line (never truncated); its data wraps beneath it, grouped
under the host. Prefer vertical growth over cramming a row. #}
{% if hosts %}
<div class="host-blocks">
{% for host in hosts %}
{% set hs = host_status.get(host.id) %}
{% set ut = uptime.get(host.id, {}) %}
{% set a = agent.get(host.name, {}) %}
{% set down = hs and hs.state == 'down' %}
{% set spark = cpu_sparks.get(host.name) %}
<div style="padding:0.5rem 0;border-bottom:1px solid var(--border);">
{# Line 1 — status dot + full host name (own line) + cpu trend #}
<div style="display:flex;align-items:center;gap:0.5rem;">
<span class="dot {% if down %}dot-down{% elif hs and hs.state == 'up' %}dot-up{% else %}dot-dim{% endif %}"
style="flex-shrink:0;"
title="{% if not hs %}no monitors{% else %}{{ hs.state }}{% endif %}"></span>
{% if session.user_id %}
<a href="/hosts/{{ host.id }}" style="font-weight:500;font-size:0.85rem;overflow-wrap:anywhere;">{{ host.name }}</a>
{% else %}
<span style="font-weight:500;font-size:0.85rem;overflow-wrap:anywhere;">{{ host.name }}</span>
{% endif %}
</div>
{# Line 2 — monitors + agent metrics; wraps instead of truncating #}
<div style="display:flex;flex-wrap:wrap;gap:0.25rem 0.9rem;margin-top:0.25rem;padding-left:1.1rem;
font-variant-numeric:tabular-nums;color:var(--text-muted);font-size:0.75rem;">
{% if hs and hs.latency_ms is not none %}
<span title="Latency" style="{{ threshold_style(hs.latency_ms, 'latency') }}"><span style="color:var(--text-dim);">ping</span> {{ "%.0f"|format(hs.latency_ms) }}ms</span>
{% endif %}
{% if ut.get('24h') is not none %}
<span title="Uptime 24h" style="{{ threshold_style(ut['24h'], 'uptime') }}"><span style="color:var(--text-dim);">24h</span> {{ "%.1f"|format(ut['24h']) }}%</span>
{% endif %}
{% if a %}
{% if a.get('cpu_pct') is not none %}<span title="CPU — value + last-hour trend" style="display:inline-flex;align-items:center;gap:0.3rem;{{ threshold_style(a.cpu_pct, 'cpu') }}"><span style="color:var(--text-dim);">cpu</span> {{ "%.0f"|format(a.cpu_pct) }}%{% if spark %}<span style="line-height:0;opacity:0.85;">{{ spark | safe }}</span>{% endif %}</span>{% endif %}
{% if a.get('mem_used_pct') is not none %}<span title="Memory" style="{{ threshold_style(a.mem_used_pct, 'mem') }}"><span style="color:var(--text-dim);">mem</span> {{ "%.0f"|format(a.mem_used_pct) }}%</span>{% endif %}
{% if a.get('disk_root') is not none %}<span title="Root filesystem (/)" style="{{ threshold_style(a.disk_root, 'disk') }}"><span style="color:var(--text-dim);">disk /</span> {{ "%.0f"|format(a.disk_root) }}%</span>{% endif %}
{% if a.get('load_1m') is not none %}<span title="Load average over 1 minute: processes running or waiting to run"><span style="color:var(--text-dim);">load</span> {{ "%.2f"|format(a.load_1m) }}</span>{% endif %}
{% if not a.fresh %}<span title="Agent data is stale" style="color:var(--yellow);">stale</span>{% endif %}
{% else %}
<span style="color:var(--text-dim);" title="No agent reporting">no agent</span>
{% endif %}
</div>
</div>
{% endfor %}
{# hosts/overview_widget.html — the dashboard Hosts widget (#5251): a verdict,
then one row per host, worst first, with its resources under one header.
Load is shown against the host's cores and graded per core. Reachability
and uptime belong to the Monitors widget, so they aren't repeated here. #}
{% from "_macros.html" import status_dot %}
{% if not rows %}
<div class="w-verdict" style="color:var(--text-muted);">
No hosts yet.{% if session.user_id %} <a href="/hosts/new">Add one →</a>{% endif %}
</div>
{% else %}
<div class="empty" style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">
No hosts yet. <a href="/hosts/new">Add one.</a>
{% set problems = rows | selectattr("state", "in", ["down", "crit", "stale", "warn"]) | list %}
<div class="w-verdict {{ 'tone-crit' if problems and problems[0].state in ['down', 'crit'] else ('tone-warn' if problems else 'tone-ok') }}">
{%- if not problems %}All {{ rows|length }} host{{ 's' if rows|length != 1 }} healthy.
{%- elif problems|length == 1 %}{% set p = problems[0] %}{{ p.host.name }}:
{% if p.state == 'down' %}a monitor is down.{% elif p.state == 'stale' %}its agent stopped reporting.{% else %}{{ p.worst[0] }} high.{% endif %}
{%- else %}{{ problems|length }} of {{ rows|length }} hosts need a look.{% endif -%}
</div>
<table class="w-table">
<colgroup><col><col style="width:3.6rem"><col style="width:3.6rem"><col style="width:3.6rem"><col style="width:6.5rem"></colgroup>
<thead><tr><th>Host</th><th>CPU</th><th>Mem</th><th>Disk /</th><th>Load</th></tr></thead>
<tbody>
{% for r in rows %}
{% set a = r.agent %}{% set fresh = a.get('fresh') %}
<tr>
<td class="w-name" title="{{ r.host.name }}">
{{- status_dot(r.state) -}}
{% if session.user_id %}<a href="/hosts/{{ r.host.id }}">{{ r.host.name }}</a>{% else %}{{ r.host.name }}{% endif %}
{%- if r.state == 'down' %}<span class="w-sub tone-crit">a monitor is down</span>
{%- elif r.state == 'stale' %}<span class="w-sub tone-warn">agent last reported {{ a.last_at|ago }}; figures are from then</span>
{%- elif not a %}<span class="w-sub">no agent</span>{% endif %}
</td>
{% for v, kind in ((a.get('cpu_pct'), 'cpu'), (a.get('mem_used_pct'), 'mem'), (a.get('disk_root'), 'disk')) %}
<td style="{{ threshold_style(v, kind) if fresh and v is not none }}">{% if v is none %}—{% else %}{{ "%.0f"|format(v) }}%{% endif %}</td>
{% endfor %}
<td style="{{ threshold_style(a.get('load_per_core'), 'load') if fresh and a.get('load_per_core') is not none }}"
title="{% if a.get('cores') %}Load over 1 minute on {{ a.cores }} cores: {{ a.load_per_core }}% of them busy or waiting{% endif %}">
{%- if a.get('load_1m') is none %}—{% elif a.get('cores') %}{{ "%.1f"|format(a.load_1m) }} / {{ a.cores }} cores{% else %}{{ "%.1f"|format(a.load_1m) }}{% endif -%}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}