diff --git a/plugins/host_agent/routes.py b/plugins/host_agent/routes.py
index 4e6e720..672b609 100644
--- a/plugins/host_agent/routes.py
+++ b/plugins/host_agent/routes.py
@@ -14,6 +14,7 @@ from steward.models.users import UserRole
from sqlalchemy import select, func, or_, and_
from datetime import timedelta
+from steward.core.formatting import worst_reading
from steward.core.settings import get_setting, public_base_url
from steward.core.time_range import parse_range, RANGE_OPTIONS
from steward.models.hosts import Host
@@ -723,11 +724,14 @@ async def host_vitals(host_id: str):
ls = reg.last_seen_at if reg else None
reporting = bool(reg) and ls is not None
stale = reporting and (datetime.now(timezone.utc) - ls).total_seconds() > _stale_after_seconds()
+ cpu, mem = hostlvl.get("cpu_pct"), hostlvl.get("mem_used_pct")
+ worst = worst_reading((("CPU", cpu, "cpu"), ("Memory", mem, "mem"), ("Disk /", disk_root, "disk"),
+ ("Load per core", load_per_core, "load")),
+ current_app.config.get("THRESHOLDS", {}))
return await render_template(
"_host_vitals.html",
- reg=reg, cpu=hostlvl.get("cpu_pct"), mem=hostlvl.get("mem_used_pct"),
- disk_root=disk_root, load_per_core=load_per_core, sparks=sparks, psi=psi,
- reporting=reporting, stale=stale,
+ reg=reg, cpu=cpu, mem=mem, disk_root=disk_root, load_per_core=load_per_core,
+ sparks=sparks, psi=psi, reporting=reporting, stale=stale, worst=worst,
)
diff --git a/plugins/host_agent/templates/_host_metrics.html b/plugins/host_agent/templates/_host_metrics.html
index 1439a39..4ef1876 100644
--- a/plugins/host_agent/templates/_host_metrics.html
+++ b/plugins/host_agent/templates/_host_metrics.html
@@ -1,15 +1,15 @@
{# Current-state fragment for the full-metrics page — polled live via HTMX.
Each poll re-renders the latest snapshot the server has. #}
-{% macro bar(pct) %}
-{%- set p = pct if pct is not none else 0 -%}
-
+{% from "_macros.html" import status_badge %}
+{# A percentage bar graded by Settings → Thresholds for `kind` (cpu, mem, disk). #}
+{% macro bar(pct, kind) %}
+
{% endmacro %}
+{% macro gauge(value, kind, text) %}{{ text }}{% endmacro %}
{# ── Identity / metadata ─────────────────────────────────────────────────── #}
-
{% if stale %}stale{% else %}live{% endif %}
+
{{ status_badge('stale' if stale else 'up', 'Stale' if stale else 'Live') }}
Address {{ host.address or "—" }}
{% if reg %}
{% if reg.distro %}
OS {{ reg.distro }}{% endif %}
@@ -18,7 +18,7 @@
{% if reg.agent_version %}
Agent v{{ reg.agent_version }}{% endif %}
{% set up = hostlvl.get('uptime_secs') %}
{% if up %}
Uptime {{ up|duration }}{% endif %}
-
Last seen {{ reg.last_seen_at.strftime("%Y-%m-%d %H:%M:%S") ~ " UTC" if reg.last_seen_at else "never" }}
+
Last seen {{ reg.last_seen_at|ago if reg.last_seen_at else "never" }}
{% else %}
No agent registration found for this host.
{% endif %}
@@ -33,14 +33,14 @@
CPU
{% set c = hostlvl.get('cpu_pct') %}
-
{% if c is not none %}{{ "%.0f"|format(c) }}%{% else %}—{% endif %}
-
{{ bar(c) }}
+ {{ gauge(c, 'cpu', ("%.0f%%"|format(c)) if c is not none else "—") }}
+
{{ bar(c, 'cpu') }}
Memory
{% set mp = hostlvl.get('mem_used_pct') %}
-
{% if mp is not none %}{{ "%.0f"|format(mp) }}%{% else %}—{% endif %}
-
{{ bar(mp) }}
+ {{ gauge(mp, 'mem', ("%.0f%%"|format(mp)) if mp is not none else "—") }}
+
{{ bar(mp, 'mem') }}
avail {{ hostlvl.get('mem_available_bytes')|bytes }} · cache {{ hostlvl.get('mem_cached_bytes')|bytes }} · swap {{ hostlvl.get('swap_used_bytes')|bytes }}
@@ -71,7 +71,7 @@
Temp (max)
{% set t = hostlvl.get('temp_c_max') %}
-
{{ "%.0f"|format(t) }}°C
+ {{ gauge(t, 'temp', "%.0f°C"|format(t)) }}
{% endif %}
{% if hostlvl.get('psi_mem_some_avg10') is not none %}
@@ -96,7 +96,7 @@
core {{ idx }}{% if pct is not none %}{{ "%.0f"|format(pct) }}%{% else %}—{% endif %}
- {{ bar(pct) }}
+ {{ bar(pct, 'cpu') }}
{% endfor %}
@@ -113,7 +113,7 @@
{{ mount }}
{{ 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 %}
- {{ bar(m.get('disk_used_pct')) }}
+ {{ bar(m.get('disk_used_pct'), 'disk') }}
{% endfor %}
@@ -155,7 +155,7 @@
{% for label, c in temps.items() %}
{{ label }}
- {% if c is not none %}{{ "%.0f"|format(c) }}°C{% else %}—{% endif %}
+ {% if c is not none %}{{ "%.0f"|format(c) }}°C{% else %}—{% endif %}
{% endfor %}
diff --git a/plugins/host_agent/templates/_host_vitals.html b/plugins/host_agent/templates/_host_vitals.html
index eea0c37..930fa36 100644
--- a/plugins/host_agent/templates/_host_vitals.html
+++ b/plugins/host_agent/templates/_host_vitals.html
@@ -1,35 +1,33 @@
-{# Host vitals strip — compact CPU/MEM/DISK/LOAD + pressure, live-polled into the
- host hub. Renders nothing until the agent reports (the Agent panel below then
- shows provisioning). #}
+{# Host health header — live polled into the host hub: live or stale, the worst
+ reading against Settings → Thresholds, last seen, then CPU / memory / disk /
+ load tiles graded by the same cutoffs. Renders nothing until the agent
+ reports (the Agent panel below then shows provisioning). #}
+{% from "_macros.html" import status_badge, stat_tile %}
+{% macro pct(v) %}{{ '%.0f%%'|format(v) if v is not none else '—' }}{% endmacro %}
{% if reporting %}
-{% set lbl = "font-size:0.68rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;" %}
-{% macro vital(label, value, text, kind, spark) %}
-
-
{{ label }}
-
{{ text }}
-
{{ spark | safe }}
-
-{% endmacro %}
-
- {{ vital("CPU", cpu, ('%.0f%%'|format(cpu) if cpu is not none else '—'), 'cpu', sparks.cpu) }}
- {{ vital("Memory", mem, ('%.0f%%'|format(mem) if mem is not none else '—'), 'mem', sparks.mem) }}
- {{ vital("Disk /", disk_root, ('%.0f%%'|format(disk_root) if disk_root is not none else '—'), 'disk', sparks.disk) }}
- {{ vital("Load /core", load_per_core, ('%d%%'|format(load_per_core) if load_per_core is not none else '—'), 'load', sparks.load) }}
-
-
-
- {% if stale %}stale{% else %}live{% endif %}
- {% if reg and reg.agent_version %}v{{ reg.agent_version }}{% endif %}
+
+
+ {{ status_badge('stale' if stale else 'up', 'Stale' if stale else 'Live') }}
+
+ {%- if stale %}No report since {{ reg.last_seen_at|ago }}; the readings below are from then.
+ {%- elif worst %}{{ worst[0] }} {{ pct(worst[1]) }} is past its {{ "critical" if worst[2] == "crit" else "warning" }} threshold.
+ {%- else %}Every reading is within its thresholds.{% endif -%}
- {% if psi.cpu is not none or psi.mem is not none or psi.io is not none %}
-
- Pressure 10s
- cpu {{ '%.0f%%'|format(psi.cpu) if psi.cpu is not none else '—' }}
- · mem {{ '%.0f%%'|format(psi.mem) if psi.mem is not none else '—' }}
- · io {{ '%.0f%%'|format(psi.io) if psi.io is not none else '—' }}
+
+ {%- if not stale %}seen {{ reg.last_seen_at|ago }}{% endif %}
+ {%- if reg.agent_version %}{{ " · " if not stale }}agent v{{ reg.agent_version }}{% endif -%}
- {% endif %}
- {% if reg and reg.last_seen_at %}seen {{ reg.last_seen_at.strftime("%H:%M:%S") }} UTC{% endif %}
-
+
+ {{ stat_tile("CPU", pct(cpu), state=status_class(cpu, 'cpu'), hint=sparks.cpu|safe) }}
+ {{ stat_tile("Memory", pct(mem), state=status_class(mem, 'mem'), hint=sparks.mem|safe) }}
+ {{ stat_tile("Disk /", pct(disk_root), state=status_class(disk_root, 'disk'), hint=sparks.disk|safe) }}
+ {{ stat_tile("Load per core", pct(load_per_core), state=status_class(load_per_core, 'load'), hint=sparks.load|safe) }}
+
+ {% if psi.cpu is not none or psi.mem is not none or psi.io is not none %}
+
+ Pressure (10 s): cpu {{ pct(psi.cpu) }} · mem {{ pct(psi.mem) }} · io {{ pct(psi.io) }}
+
+ {% endif %}
+
{% endif %}
diff --git a/steward/core/formatting.py b/steward/core/formatting.py
index f0747c9..80c638d 100644
--- a/steward/core/formatting.py
+++ b/steward/core/formatting.py
@@ -131,3 +131,21 @@ def required(value: Any, what: str) -> Any:
if value is None or not str(value).strip():
raise ValueError(f"{what} is required")
return value
+
+
+_SEVERITY = {"crit": 2, "warn": 1}
+
+
+def worst_reading(readings, thresholds: dict):
+ """The reading furthest past its cutoff, as (label, value, grade), or None
+ when every reading is within its cutoffs or missing.
+
+ `readings` is (label, value, kind) triples, graded like status_class; the
+ first of equally bad readings wins, so list them in the order a reader
+ should hear about them."""
+ worst, rank = None, 0
+ for label, value, kind in readings:
+ grade = status_class(value, kind, thresholds)
+ if _SEVERITY.get(grade, 0) > rank:
+ worst, rank = (label, value, grade), _SEVERITY[grade]
+ return worst
diff --git a/steward/static/steward.css b/steward/static/steward.css
index c339737..5e21127 100644
--- a/steward/static/steward.css
+++ b/steward/static/steward.css
@@ -268,6 +268,16 @@ a.stat-tile:hover { border-color: var(--border-mid); background: var(--bg-elevat
/* A label/value list:
- …
- …
. */
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 0.35rem 1.25rem; font-size: 0.88rem; }
.kv dt { color: var(--text-muted); }
+/* The host page's health header (_host_vitals.html). */
+.host-health-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.35rem 0.75rem; margin-bottom: 0.75rem; }
+.host-health-summary { font-size: 0.92rem; }
+/* A filled bar for a percentage; the fill takes its grade from status_class. */
+.meter { height: 8px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 4px; overflow: hidden; }
+.meter-fill { height: 100%; }
+.meter-ok { background: var(--green); }
+.meter-warn { background: var(--yellow); }
+.meter-crit { background: var(--red); }
+.meter-unknown { background: var(--text-dim); }
.problem-detail { color: var(--text-muted); font-size: 0.82rem; }
/* Breadcrumbs */
diff --git a/steward/templates/hosts/detail.html b/steward/templates/hosts/detail.html
index a766c9a..ff1e4f8 100644
--- a/steward/templates/hosts/detail.html
+++ b/steward/templates/hosts/detail.html
@@ -1,5 +1,5 @@
{% extends "base.html" %}
-{% from "_macros.html" import crumbs %}
+{% from "_macros.html" import crumbs, status_dot, status_badge, icon_button %}
{% from "monitors/_fields.html" import type_fields, toggle_script %}
{% block title %}{{ host.name }} — Hosts — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Hosts", "/hosts/"), (host.name, "")]) }}{% endblock %}
@@ -39,9 +39,8 @@
Monitors
{% if uptime %}
- uptime 24h {{ ("%.1f%%"|format(uptime['24h'])) if uptime['24h'] is not none else '—' }}
- · 7d {{ ("%.1f%%"|format(uptime['7d'])) if uptime['7d'] is not none else '—' }}
- · 30d {{ ("%.1f%%"|format(uptime['30d'])) if uptime['30d'] is not none else '—' }}
+ uptime{% for w in ("24h", "7d", "30d") %}{{ " ·" if not loop.first }} {{ w }}
+ {{ ("%.1f%%"|format(uptime[w])) if uptime[w] is not none else '—' }}{% endfor %}
{% endif %}
@@ -50,34 +49,30 @@
{% for d in monitors %}
{% set m = d.monitor %}
{% set latest = d.latest %}
-
+
- {% for _ in range([30 - d.heartbeat|length, 0]|max) %}
-
- {% endfor %}
- {% for h in d.heartbeat %}
-
- {% endfor %}
+ {% for _ in range([30 - d.heartbeat|length, 0]|max) %}{% endfor %}
+ {% for h in d.heartbeat %}{% endfor %}
- {% if latest is none %}—
- {% elif not latest.is_up %}DOWN
+ {% if latest is none %}—
+ {% elif not latest.is_up %}{{ status_badge('down') }}
{% elif latest.response_ms is not none %}
- {{ "%.0f"|format(latest.response_ms) }} ms
- {% else %}UP{% endif %}
+ {{ "%.0f"|format(latest.response_ms) }} ms
+ {% else %}{{ status_badge('up') }}{% endif %}
{% if session.user_role in ['operator', 'admin'] %}
-