feat(hosts): one health header, graded by one set of thresholds (#5122)
The host page's vitals strip becomes its health header: Live or Stale, then one sentence — "Memory 93% is past its critical threshold", "Every reading is within its thresholds", or, for a stale host, "No report since 20m ago; the readings below are from then" — and last seen as |ago. CPU, memory, root disk and load per core follow as stat tiles graded by Settings → Thresholds, with their sparklines. The full-metrics page drew its bars and gauges at a hard-coded 70/90 (temperatures 70/85) while every other page used the configured cutoffs (80/90 by default), so a 75 % CPU was amber here and fine everywhere else. bar() now takes the metric kind and colours its .meter-fill through status_class; gauges, temperatures and per-core bars do the same. The host's monitor rows move onto the Monitors list's shapes: a status dot, the name linking to the monitor's page, Ping/TCP/DNS/HTTP, pill-up/ pill-down, latency and uptime through status_class, labelled icon buttons. HEX_CEILING drops to 54. New formatting.worst_reading(readings, thresholds) picks the reading furthest past its cutoff, the first of equals winning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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 -%}
|
||||
<div style="height:8px;background:var(--bg-elevated);border-radius:4px;overflow:hidden;border:1px solid var(--border);">
|
||||
<div style="height:100%;width:{{ p|round(1) }}%;background:{% if pct is none %}var(--text-dim){% elif pct < 70 %}var(--green){% elif pct < 90 %}var(--yellow){% else %}var(--red){% endif %};"></div>
|
||||
</div>
|
||||
{% 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>{% if stale %}<span class="badge badge-red">stale</span>{% else %}<span class="badge badge-green">live</span>{% endif %}</span>
|
||||
<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 %}
|
||||
@@ -18,7 +18,7 @@
|
||||
{% 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.strftime("%Y-%m-%d %H:%M:%S") ~ " UTC" if reg.last_seen_at else "never" }}</span>
|
||||
<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 %}
|
||||
@@ -33,14 +33,14 @@
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.35rem;">CPU</div>
|
||||
{% set c = hostlvl.get('cpu_pct') %}
|
||||
<span class="stat-val" style="font-size:1.6rem;color:{% if c is none %}var(--text-dim){% elif c < 70 %}var(--green){% elif c < 90 %}var(--yellow){% else %}var(--red){% endif %};">{% if c is not none %}{{ "%.0f"|format(c) }}%{% else %}—{% endif %}</span>
|
||||
<div style="margin-top:0.5rem;">{{ bar(c) }}</div>
|
||||
{{ 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') %}
|
||||
<span class="stat-val" style="font-size:1.6rem;color:{% if mp is none %}var(--text-dim){% elif mp < 70 %}var(--green){% elif mp < 90 %}var(--yellow){% else %}var(--red){% endif %};">{% if mp is not none %}{{ "%.0f"|format(mp) }}%{% else %}—{% endif %}</span>
|
||||
<div style="margin-top:0.5rem;">{{ bar(mp) }}</div>
|
||||
{{ 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>
|
||||
@@ -71,7 +71,7 @@
|
||||
<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') %}
|
||||
<span class="stat-val" style="font-size:1.6rem;color:{% if t < 70 %}var(--green){% elif t < 85 %}var(--yellow){% else %}var(--red){% endif %};">{{ "%.0f"|format(t) }}°C</span>
|
||||
{{ gauge(t, 'temp', "%.0f°C"|format(t)) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if hostlvl.get('psi_mem_some_avg10') is not none %}
|
||||
@@ -96,7 +96,7 @@
|
||||
<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) }}
|
||||
{{ bar(pct, 'cpu') }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -113,7 +113,7 @@
|
||||
<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')) }}
|
||||
{{ bar(m.get('disk_used_pct'), 'disk') }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -155,7 +155,7 @@
|
||||
{% 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 style="color:{% if c is none %}var(--text-dim){% elif c < 70 %}var(--green){% elif c < 85 %}var(--yellow){% else %}var(--red){% endif %};font-variant-numeric:tabular-nums;">{% if c is not none %}{{ "%.0f"|format(c) }}°C{% else %}—{% endif %}</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>
|
||||
|
||||
@@ -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) %}
|
||||
<div style="min-width:88px;">
|
||||
<div style="{{ lbl }}">{{ label }}</div>
|
||||
<div style="font-size:1.5rem;font-weight:700;line-height:1.1;{{ threshold_style(value, kind) }}">{{ text }}</div>
|
||||
<div style="margin-top:0.2rem;">{{ spark | safe }}</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
<div class="card" style="display:flex;flex-wrap:wrap;align-items:flex-start;gap:1rem 2rem;margin-bottom:1rem;">
|
||||
{{ 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) }}
|
||||
|
||||
<div style="margin-left:auto;display:flex;flex-direction:column;align-items:flex-end;gap:0.3rem;font-size:0.75rem;color:var(--text-muted);text-align:right;">
|
||||
<span>
|
||||
{% if stale %}<span class="badge badge-red">stale</span>{% else %}<span class="badge badge-green">live</span>{% endif %}
|
||||
{% if reg and reg.agent_version %}<span style="margin-left:0.3rem;">v{{ reg.agent_version }}</span>{% endif %}
|
||||
<section class="card host-health" aria-label="Host health">
|
||||
<div class="host-health-head">
|
||||
{{ status_badge('stale' if stale else 'up', 'Stale' if stale else 'Live') }}
|
||||
<span class="host-health-summary">
|
||||
{%- if stale %}No report since {{ reg.last_seen_at|ago }}; the readings below are from then.
|
||||
{%- elif worst %}<span class="tone-{{ worst[2] }}">{{ worst[0] }} {{ pct(worst[1]) }}</span> is past its {{ "critical" if worst[2] == "crit" else "warning" }} threshold.
|
||||
{%- else %}Every reading is within its thresholds.{% endif -%}
|
||||
</span>
|
||||
{% if psi.cpu is not none or psi.mem is not none or psi.io is not none %}
|
||||
<span title="Pressure stall — % of the last 10s tasks waited for the resource">
|
||||
<span style="color:var(--text-dim);text-transform:uppercase;font-size:0.66rem;letter-spacing:0.04em;">Pressure 10s</span>
|
||||
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 '—' }}
|
||||
<span class="problem-detail">
|
||||
{%- if not stale %}seen {{ reg.last_seen_at|ago }}{% endif %}
|
||||
{%- if reg.agent_version %}{{ " · " if not stale }}agent v{{ reg.agent_version }}{% endif -%}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if reg and reg.last_seen_at %}<span>seen {{ reg.last_seen_at.strftime("%H:%M:%S") }} UTC</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-strip" style="margin-bottom:0;">
|
||||
{{ 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) }}
|
||||
</div>
|
||||
{% if psi.cpu is not none or psi.mem is not none or psi.io is not none %}
|
||||
<p class="field-hint" style="margin-top:0.6rem;" title="Pressure stall: the share of the last 10 s that tasks waited for the resource">
|
||||
Pressure (10 s): cpu {{ pct(psi.cpu) }} · mem {{ pct(psi.mem) }} · io {{ pct(psi.io) }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -268,6 +268,16 @@ a.stat-tile:hover { border-color: var(--border-mid); background: var(--bg-elevat
|
||||
/* A label/value list: <dl class="kv"><dt>…</dt><dd>…</dd></dl>. */
|
||||
.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 */
|
||||
|
||||
@@ -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 @@
|
||||
<h3 class="section-title" style="margin-bottom:0;">Monitors</h3>
|
||||
{% if uptime %}
|
||||
<span style="font-size:0.8rem;color:var(--text-muted);">
|
||||
uptime 24h <strong>{{ ("%.1f%%"|format(uptime['24h'])) if uptime['24h'] is not none else '—' }}</strong>
|
||||
· 7d <strong>{{ ("%.1f%%"|format(uptime['7d'])) if uptime['7d'] is not none else '—' }}</strong>
|
||||
· 30d <strong>{{ ("%.1f%%"|format(uptime['30d'])) if uptime['30d'] is not none else '—' }}</strong>
|
||||
uptime{% for w in ("24h", "7d", "30d") %}{{ " ·" if not loop.first }} {{ w }}
|
||||
<strong class="tone-{{ status_class(uptime[w], 'uptime') }}">{{ ("%.1f%%"|format(uptime[w])) if uptime[w] is not none else '—' }}</strong>{% endfor %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -50,34 +49,30 @@
|
||||
{% for d in monitors %}
|
||||
{% set m = d.monitor %}
|
||||
{% set latest = d.latest %}
|
||||
<div class="ping-row" {% if not m.enabled %}style="opacity:0.5;"{% endif %}>
|
||||
<div class="ping-row{% if not m.enabled %} ping-row-paused{% endif %}">
|
||||
<div class="ping-meta" style="min-width:150px;max-width:230px;">
|
||||
<span class="badge" style="font-size:0.62rem;">{{ m.type | upper }}</span>
|
||||
<span class="ping-name" title="{{ m.name }}">{{ m.name }}</span>
|
||||
<span class="ping-addr" title="{{ m.target }}">{{ m.target }}</span>
|
||||
<div style="display:flex;align-items:baseline;gap:0.4rem;">
|
||||
{{ status_dot('paused' if not m.enabled else ('pending' if latest is none else ('up' if latest.is_up else 'down'))) }}
|
||||
<a class="ping-name" href="/monitors/{{ m.id }}">{{ m.name }}</a>
|
||||
</div>
|
||||
<span class="ping-addr" title="{{ m.target }}">{{ m.type|monitor_type }} · {{ m.target }}</span>
|
||||
</div>
|
||||
<div class="ping-pills">
|
||||
{% for _ in range([30 - d.heartbeat|length, 0]|max) %}
|
||||
<span class="pill pill-empty" title="No data"></span>
|
||||
{% endfor %}
|
||||
{% for h in d.heartbeat %}
|
||||
<span class="pill" title="{{ h.title }}"
|
||||
style="background:{{ '#1a6632' if h.state == 'up' else '#6a1515' }};"></span>
|
||||
{% endfor %}
|
||||
{% for _ in range([30 - d.heartbeat|length, 0]|max) %}<span class="pill pill-empty" title="No data"></span>{% endfor %}
|
||||
{% for h in d.heartbeat %}<span class="pill pill-{{ h.state }}" title="{{ h.title }}"></span>{% endfor %}
|
||||
</div>
|
||||
<div class="ping-cur">
|
||||
{% if latest is none %}<span class="ping-cur-nd">—</span>
|
||||
{% elif not latest.is_up %}<span class="ping-cur-down">DOWN</span>
|
||||
{% if latest is none %}<span class="tone-dim">—</span>
|
||||
{% elif not latest.is_up %}{{ status_badge('down') }}
|
||||
{% elif latest.response_ms is not none %}
|
||||
<span style="{{ threshold_style(latest.response_ms, 'latency') or 'color:var(--green);' }}">{{ "%.0f"|format(latest.response_ms) }} ms</span>
|
||||
{% else %}<span class="ping-cur-good">UP</span>{% endif %}
|
||||
<span class="tone-{{ status_class(latest.response_ms, 'latency') }}">{{ "%.0f"|format(latest.response_ms) }} ms</span>
|
||||
{% else %}{{ status_badge('up') }}{% endif %}
|
||||
</div>
|
||||
{% if session.user_role in ['operator', 'admin'] %}
|
||||
<div style="display:flex;gap:0.35rem;flex-shrink:0;">
|
||||
<a href="/monitors/{{ m.id }}/edit" class="btn btn-sm btn-ghost" style="padding:0.2rem 0.5rem;font-size:0.72rem;">Edit</a>
|
||||
<form method="post" action="/monitors/{{ m.id }}/delete" style="display:inline;"
|
||||
data-confirm="Delete monitor {{ m.name }}?">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-sm" style="padding:0.2rem 0.5rem;font-size:0.72rem;color:var(--red);">✕</button>
|
||||
<div class="row-actions">
|
||||
{{ icon_button("✎", "Edit " ~ m.name, href="/monitors/" ~ m.id ~ "/edit") }}
|
||||
<form method="post" action="/monitors/{{ m.id }}/delete">{{ csrf_field() }}
|
||||
{{ icon_button("×", "Delete " ~ m.name, type="submit", confirm="Delete monitor " ~ m.name ~ "?") }}
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -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 '<span class="badge badge-ok">Live</span>' in page
|
||||
assert '<span class="tone-crit">Memory 93%</span> is past its critical threshold.' in page
|
||||
assert 'class="stat-tile stat-crit"><span class="stat-tile-label">Memory</span>' in page
|
||||
assert 'class="stat-tile stat-ok"><span class="stat-tile-label">CPU</span>' in page
|
||||
assert "seen <time" in page and "agent v1.9.0" in page
|
||||
assert not hex_colours(page), hex_colours(page)
|
||||
|
||||
|
||||
async def test_a_quiet_host_says_so(app):
|
||||
assert "Every reading is within its thresholds." in await _vitals(app)
|
||||
|
||||
|
||||
async def test_a_stale_host_says_since_when_before_any_reading(app):
|
||||
page = await _vitals(app, mem=93.0, stale=True, seen_min=20)
|
||||
assert '<span class="badge badge-warn">Stale</span>' in page
|
||||
assert "No report since <time" in page and ">20m ago</time>; 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 '<span class="tone-crit" style="font-variant-numeric:tabular-nums;">90°C' in page
|
||||
assert not hex_colours(page), hex_colours(page)
|
||||
@@ -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*|(?<![\w-]=)['"])#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3})(?![0-9a-zA-Z_-])""")
|
||||
|
||||
HEX_CEILING = 56 # 75 at #5092, 58 at #5095, 56 with the Monitors list on tokens (#5121); only lower it
|
||||
HEX_CEILING = 54 # 75 at #5092, 58 at #5095, 56 at #5121, 54 with the host page on tokens (#5122); only lower it
|
||||
|
||||
|
||||
def _sheets():
|
||||
|
||||
Reference in New Issue
Block a user