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 %} -
+
- {{ m.type | upper }} - {{ m.name }} - {{ m.target }} +
+ {{ status_dot('paused' if not m.enabled else ('pending' if latest is none else ('up' if latest.is_up else 'down'))) }} + {{ m.name }} +
+ {{ m.type|monitor_type }} · {{ m.target }}
- {% 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'] %} -
- Edit -
{{ csrf_field() }} - +
+ {{ icon_button("✎", "Edit " ~ m.name, href="/monitors/" ~ m.id ~ "/edit") }} + {{ csrf_field() }} + {{ icon_button("×", "Delete " ~ m.name, type="submit", confirm="Delete monitor " ~ m.name ~ "?") }}
{% endif %} diff --git a/tests/plugins/host_agent/test_host_health.py b/tests/plugins/host_agent/test_host_health.py new file mode 100644 index 0000000..1ff9690 --- /dev/null +++ b/tests/plugins/host_agent/test_host_health.py @@ -0,0 +1,79 @@ +"""The host page says how the host is in one header, and grades every reading by +Settings → Thresholds, nowhere else (#5122).""" +import re +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace + +import pytest +from quart import render_template + +from steward.core.formatting import worst_reading +from steward.core.settings import to_thresholds_cfg +from tests.test_style_tokens import hex_colours + +TH = to_thresholds_cfg({}) # cpu/mem/disk warn 80 crit 90; load warn 80 crit 100 + + +@pytest.mark.parametrize("readings, worst", [ + ([("CPU", 40, "cpu"), ("Memory", 50, "mem")], None), + ([("CPU", None, "cpu")], None), # no reading is not a problem + ([("CPU", 85, "cpu"), ("Memory", 95, "mem")], ("Memory", 95, "crit")), # crit beats warn + ([("CPU", 92, "cpu"), ("Memory", 95, "mem")], ("CPU", 92, "crit")), # first of equals + ([("Disk /", 81, "disk")], ("Disk /", 81, "warn")), +]) +def test_worst_reading(readings, worst): + assert worst_reading(readings, TH) == worst + + +async def _vitals(app, *, cpu=40.0, mem=50.0, stale=False, seen_min=1): + readings = (("CPU", cpu, "cpu"), ("Memory", mem, "mem"), ("Disk /", 30.0, "disk"), + ("Load per core", 20, "load")) + async with app.test_request_context("/plugins/host_agent/vitals/h1"): + page = await render_template( + "_host_vitals.html", reporting=True, stale=stale, + reg=SimpleNamespace(last_seen_at=datetime.now(timezone.utc) - timedelta(minutes=seen_min), + agent_version="1.9.0"), + cpu=cpu, mem=mem, disk_root=30.0, load_per_core=20, + sparks={"cpu": "", "mem": "", "disk": "", "load": ""}, + psi={"cpu": None, "mem": None, "io": None}, worst=worst_reading(readings, TH)) + return re.sub(r">\s+<", "><", " ".join(page.split())) + + +async def test_a_host_over_a_threshold_says_which_reading(app): + page = await _vitals(app, cpu=40.0, mem=93.0) + assert 'Live' in page + assert 'Memory 93% is past its critical threshold.' in page + assert 'class="stat-tile stat-crit">Memory' in page + assert 'class="stat-tile stat-ok">CPU' in page + assert "seen Stale' in page + assert "No report since 20m ago; the readings below are from then." in page + assert "past its" not in page # an old reading is not news + + +async def test_the_metrics_page_grades_by_the_same_cutoffs(app): + # 75 % was amber under the old hard-coded 70/90; Settings → Thresholds says warn at 80. + async with app.test_request_context("/plugins/host_agent/h1/metrics"): + page = await render_template( + "_host_metrics.html", host=SimpleNamespace(address="10.0.0.2"), stale=False, + reg=SimpleNamespace(distro=None, kernel=None, arch=None, agent_version=None, + last_seen_at=datetime.now(timezone.utc)), + hostlvl={"cpu_pct": 75.0, "mem_used_pct": 95.0, "temp_c_max": 72.0}, + cores=[(0, 85.0)], nets={}, disks_io={}, temps={"cpu": 90.0}, + mounts={"/": {"disk_used_pct": 50.0, "disk_used_bytes": 1, "disk_total_bytes": 2}}) + assert 'class="stat-val tone-ok" style="font-size:1.6rem;">75%' in page + assert 'class="stat-val tone-crit" style="font-size:1.6rem;">95%' in page + assert 'class="meter-fill meter-ok" style="width:75.0%;"' in page + assert 'class="meter-fill meter-warn" style="width:85.0%;"' in page # the core + assert 'class="stat-val tone-warn" style="font-size:1.6rem;">72°C' in page + assert '90°C' in page + assert not hex_colours(page), hex_colours(page) diff --git a/tests/test_style_tokens.py b/tests/test_style_tokens.py index cc276c4..d5f2409 100644 --- a/tests/test_style_tokens.py +++ b/tests/test_style_tokens.py @@ -22,7 +22,7 @@ _USED = re.compile(r"var\(\s*(--[\w-]+)") # string -- but not an HTML attribute's value (href="#abc" is a link to an id). _HEX = re.compile(r"""(?:[:(,]\s*|(?