Files
FabledSteward/steward/core/formatting.py
T
bvandeusenandClaude Opus 5.5 8c19f3c21f
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 47s
CI / unit (push) Failing after 1m14s
CI / publish (push) Skipped
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>
2026-10-06 17:58:22 -04:00

152 lines
5.4 KiB
Python

"""steward/core/formatting.py
How Steward writes a size, a rate, a span of time and a moment, and how it
grades a reading against Settings → Thresholds — once, for every page and
plugin (#5093). Registered as Jinja filters (|bytes, |rate, |duration, |ago)
and the status_class global in app.py; Python callers import them from here.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from markupsafe import Markup, escape
NONE = "—"
_UNITS = ("B", "KiB", "MiB", "GiB", "TiB", "PiB")
_SPANS = (("d", 86400), ("h", 3600), ("m", 60), ("s", 1))
def human_bytes(n: float | None) -> str:
"""Binary units: '0 B', '512 B', '1.5 KiB', '1.4 GiB'."""
if n is None:
return NONE
size = float(n)
for unit in _UNITS:
if abs(size) < 1024 or unit == _UNITS[-1]:
return f"{int(size)} B" if unit == "B" else f"{size:.1f} {unit}"
size /= 1024
raise AssertionError("unreachable")
def human_rate(bytes_per_s: float | None) -> str:
"""Bytes per second in binary units: '12.3 MiB/s'."""
return NONE if bytes_per_s is None else f"{human_bytes(bytes_per_s)}/s"
def human_duration(seconds: float | None) -> str:
"""The two largest units of a span: '45s', '8m', '1m 30s', '2h 5m', '3d 4h'.
The second unit is dropped when it is zero. Negative spans read as None."""
if seconds is None or seconds < 0:
return NONE
left = int(seconds)
if left == 0:
return "0s"
parts = []
for i, (unit, size) in enumerate(_SPANS):
count, left = divmod(left, size)
if count:
parts.append(f"{count}{unit}")
nxt = _SPANS[i + 1] if i + 1 < len(_SPANS) else None
if nxt and left // nxt[1]:
parts.append(f"{left // nxt[1]}{nxt[0]}")
break
return " ".join(parts)
def _utc(when: datetime) -> datetime:
return when.replace(tzinfo=timezone.utc) if when.tzinfo is None else when.astimezone(timezone.utc)
def seconds_since(when: datetime | None, now: datetime | None = None) -> float | None:
"""Seconds from `when` to now; a naive datetime is taken as UTC."""
if when is None:
return None
return ((now or datetime.now(timezone.utc)) - _utc(when)).total_seconds()
def ago(when: datetime | None, now: datetime | None = None) -> Markup:
"""'3m ago' (or 'in 3m' for a future moment) in a <time> element whose
title is the absolute UTC time, so a hover gives the exact moment."""
if when is None:
return Markup(NONE)
when = _utc(when)
secs = seconds_since(when, now)
if abs(secs) < 1:
text = "just now"
elif secs > 0:
text = f"{human_duration(secs)} ago"
else:
text = f"in {human_duration(-secs)}"
return Markup('<time datetime="{}" title="{}">{}</time>').format(
when.strftime("%Y-%m-%dT%H:%M:%SZ"), when.strftime("%Y-%m-%d %H:%M:%S UTC"), escape(text))
def status_class(value: Any, kind: str, thresholds: dict) -> str:
"""Grade a reading against Settings → Thresholds (to_thresholds_cfg):
'ok', 'warn' or 'crit', or 'unknown' when there is no reading.
A kind the thresholds don't name is a mistake in the caller, not a
reading to colour as healthy, so it raises."""
if value is None:
return "unknown"
th = thresholds[kind]
warn, crit = th["warn"], th["crit"]
if th.get("dir", "high") == "low":
return "crit" if value < crit else "warn" if value < warn else "ok"
return "crit" if value >= crit else "warn" if value >= warn else "ok"
# Every state a page shows, and the tone it is drawn in: ok (green), warn
# (amber), crit (red), info (blue) or dim. The status_badge and status_dot
# macros read this, so one state looks the same on every page.
STATES: dict[str, tuple[str, str]] = {
"up": ("ok", "Up"),
"ok": ("ok", "OK"),
"resolved": ("ok", "Resolved"),
"down": ("crit", "Down"),
"firing": ("crit", "Firing"),
"crit": ("crit", "Critical"),
"warn": ("warn", "Warning"),
"pending": ("warn", "Pending"),
"degraded": ("warn", "Degraded"),
"stale": ("warn", "Stale"),
"no_data": ("warn", "No data"),
"acknowledged": ("info", "Acknowledged"),
"inactive": ("dim", "Inactive"),
"paused": ("dim", "Paused"),
"unknown": ("dim", "Unknown"),
}
def status_info(state: Any) -> tuple[str, str]:
"""(tone, label) for a state name or enum. A state the map doesn't know
is drawn dim under its own name rather than breaking the page."""
key = str(getattr(state, "value", state) or "unknown").lower()
return STATES.get(key, ("dim", key.replace("_", " ").capitalize()))
def required(value: Any, what: str) -> Any:
"""For a macro argument that must be given: raise if it is empty."""
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