feat(ui): shared |bytes |rate |duration |ago filters and status_class from Settings → Thresholds (#5093)
#5093 "Shared format filters and status_class driven by Settings → Thresholds". - steward/core/formatting.py: human_bytes (binary units), human_rate, human_duration (two largest units), ago (a <time> titled with the absolute UTC time) and status_class(value, kind, thresholds) -> ok/warn/crit/unknown, which raises on a kind the thresholds don't name. - app.py registers them as the bytes, rate, duration and ago filters and the status_class global; threshold_style is now graded by status_class (and caps its weight at 500). - Replaces the copies: docker's _human_bytes/_human_uptime, core alerts' _duration, the host-agent fmt_bps/fmt_bytes macros, the Traefik and UniFi byte-rate arithmetic, the swarm-failures ago macro and two uptime spans. - Tests: a table per helper, and every template compiled against the app's filters, so an unknown filter fails CI rather than a page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -130,6 +130,17 @@ def create_app(
|
||||
lambda value, kind: _ts_for(value, kind, app.config.get("THRESHOLDS", {}))
|
||||
)
|
||||
|
||||
# One way to write a size, a rate, a span and a moment, and to grade a
|
||||
# reading ('ok' / 'warn' / 'crit' / 'unknown') against the same cutoffs.
|
||||
from .core import formatting as _fmt
|
||||
app.jinja_env.filters["bytes"] = _fmt.human_bytes
|
||||
app.jinja_env.filters["rate"] = _fmt.human_rate
|
||||
app.jinja_env.filters["duration"] = _fmt.human_duration
|
||||
app.jinja_env.filters["ago"] = _fmt.ago
|
||||
app.jinja_env.globals["status_class"] = (
|
||||
lambda value, kind: _fmt.status_class(value, kind, app.config.get("THRESHOLDS", {}))
|
||||
)
|
||||
|
||||
# Forged-request protection: every POST form renders csrf_field(), HTMX and
|
||||
# fetch() send csrf_token() as X-CSRF-Token, and one check refuses any
|
||||
# unsafe request without it (steward/auth/csrf.py).
|
||||
|
||||
+5
-13
@@ -17,6 +17,7 @@ from steward.models.alerts import (
|
||||
AlertStateEnum,
|
||||
MaintenanceWindow,
|
||||
)
|
||||
from steward.core.formatting import human_duration
|
||||
from steward.models.metrics import PluginMetric
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -256,15 +257,6 @@ def silent_since(last_sample_at: datetime | None, interval: float | None,
|
||||
return last + limit if now - last > limit else None
|
||||
|
||||
|
||||
def _duration(seconds: float) -> str:
|
||||
seconds = int(seconds)
|
||||
if seconds < 120:
|
||||
return f"{seconds}s"
|
||||
if seconds < 7200:
|
||||
return f"{seconds // 60}m"
|
||||
return f"{seconds // 3600}h{(seconds % 3600) // 60:02d}m"
|
||||
|
||||
|
||||
def _track_samples(session: AsyncSession, rule: AlertRule, state: AlertState,
|
||||
samples: list[PluginMetric], now: datetime, muted: str | None = None
|
||||
) -> tuple[str, float, str] | None:
|
||||
@@ -281,7 +273,7 @@ def _track_samples(session: AsyncSession, rule: AlertRule, state: AlertState,
|
||||
state.last_sample_at = sample.recorded_at
|
||||
if state.no_data_since is None or not samples:
|
||||
return None
|
||||
note = f"data resumed after {_duration((now - state.no_data_since).total_seconds())} silent"
|
||||
note = f"data resumed after {human_duration((now - state.no_data_since).total_seconds())} silent"
|
||||
value = samples[-1].value
|
||||
event_id = _add_event(session, rule.id, "no_data", state.state.value, value, now,
|
||||
note=f"{note}; {muted}" if muted else note)
|
||||
@@ -559,12 +551,12 @@ async def check_silence(app: "Quart") -> None:
|
||||
continue
|
||||
state.no_data_since = since
|
||||
if state.last_sample_at is None:
|
||||
note = (f"no sample yet, {_duration((now - watching_since).total_seconds())}"
|
||||
note = (f"no sample yet, {human_duration((now - watching_since).total_seconds())}"
|
||||
" after the rule started watching")
|
||||
else:
|
||||
note = f"no sample for {_duration((now - state.last_sample_at).total_seconds())}"
|
||||
note = f"no sample for {human_duration((now - state.last_sample_at).total_seconds())}"
|
||||
if state.sample_interval_s:
|
||||
note += f" (usually every {_duration(state.sample_interval_s)})"
|
||||
note += f" (usually every {human_duration(state.sample_interval_s)})"
|
||||
muted = _muted_by(windows, rule)
|
||||
event_id = _add_event(session, rule.id, state.state.value, "no_data", None,
|
||||
now, note=f"{note}; {muted}" if muted else note)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""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"
|
||||
+12
-25
@@ -427,9 +427,9 @@ def to_thresholds_cfg(settings: dict[str, Any]) -> dict:
|
||||
"""Per-metric (warn, crit, direction) policy for degraded-value coloring.
|
||||
|
||||
Centralizes "what counts as degraded" so templates just name a metric kind
|
||||
via the threshold_style() jinja global. `dir` is "high" (higher is worse,
|
||||
e.g. CPU) or "low" (lower is worse, e.g. uptime %). Latency reuses the
|
||||
existing ping good/warn thresholds.
|
||||
via the status_class() and threshold_style() jinja globals. `dir` is
|
||||
"high" (higher is worse, e.g. CPU) or "low" (lower is worse, e.g. uptime
|
||||
%). Latency reuses the existing ping good/warn thresholds.
|
||||
"""
|
||||
g = settings.get
|
||||
return {
|
||||
@@ -445,31 +445,18 @@ def to_thresholds_cfg(settings: dict[str, Any]) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def threshold_style_for(value: Any, kind: str, thresholds: dict) -> str:
|
||||
"""Return an inline-style fragment (amber at warn, red at crit) for a metric.
|
||||
_STATUS_STYLE = {"warn": "color:var(--yellow);font-weight:500;",
|
||||
"crit": "color:var(--red);font-weight:500;"}
|
||||
|
||||
The Python twin of the old _macros.metric_style, but reading configurable
|
||||
cutoffs from `thresholds` (see to_thresholds_cfg) and handling both
|
||||
directions. Empty string when normal/unknown/None — drop straight into a
|
||||
|
||||
def threshold_style_for(value: Any, kind: str, thresholds: dict) -> str:
|
||||
"""Return an inline-style fragment (amber at warn, red at crit) for a metric,
|
||||
graded by formatting.status_class against `thresholds` (to_thresholds_cfg).
|
||||
Empty string when the reading is normal or missing — drop straight into a
|
||||
span's style="".
|
||||
"""
|
||||
if value is None:
|
||||
return ""
|
||||
th = thresholds.get(kind)
|
||||
if not th:
|
||||
return ""
|
||||
warn, crit, direction = th["warn"], th["crit"], th.get("dir", "high")
|
||||
if direction == "low":
|
||||
if value < crit:
|
||||
return "color:var(--red);font-weight:700;"
|
||||
if value < warn:
|
||||
return "color:var(--yellow);font-weight:600;"
|
||||
else:
|
||||
if value >= crit:
|
||||
return "color:var(--red);font-weight:700;"
|
||||
if value >= warn:
|
||||
return "color:var(--yellow);font-weight:600;"
|
||||
return ""
|
||||
from steward.core.formatting import status_class
|
||||
return _STATUS_STYLE.get(status_class(value, kind, thresholds), "")
|
||||
|
||||
|
||||
def to_plugins_cfg(settings: dict[str, Any]) -> dict:
|
||||
|
||||
Reference in New Issue
Block a user