feat(ui): shared |bytes |rate |duration |ago filters and status_class from Settings → Thresholds (#5093)
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 1m28s
CI / integration (push) Successful in 1m7s
CI / publish (push) Successful in 2m40s

#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:
2026-10-06 14:25:06 -04:00
co-authored by Claude Opus 5.5
parent 65411d3ce8
commit bf08169176
17 changed files with 287 additions and 186 deletions
+17 -43
View File
@@ -12,6 +12,7 @@ from sqlalchemy import func, select
from steward.auth.middleware import require_role
from steward.models.users import UserRole
from steward.models.hosts import Host
from steward.core.formatting import human_bytes, human_duration, seconds_since
from steward.core.time_range import parse_range, DEFAULT_RANGE
from . import service as docker_svc
from .dedup import dedup_by_container_id
@@ -31,37 +32,10 @@ def _error(status: int, code: str, detail: str | None = None):
return jsonify(body), status
def _human_bytes(n: int | None) -> str:
"""Compact binary size string (e.g. '1.4 GiB', '512 MiB', '0 B')."""
if n is None:
return "—"
size = float(n)
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
if abs(size) < 1024.0 or unit == "TiB":
if unit == "B":
return f"{int(size)} {unit}"
return f"{size:.1f} {unit}"
size /= 1024.0
return f"{size:.1f} TiB"
def _human_uptime(started_at: datetime | None) -> str | None:
"""Compact 'how long running' string (e.g. '3d 4h', '5h 12m', '8m')."""
if started_at is None:
return None
if started_at.tzinfo is None:
started_at = started_at.replace(tzinfo=timezone.utc)
secs = int((datetime.now(timezone.utc) - started_at).total_seconds())
if secs < 0:
return None
d, rem = divmod(secs, 86400)
h, rem = divmod(rem, 3600)
m, _ = divmod(rem, 60)
if d:
return f"{d}d {h}h"
if h:
return f"{h}h {m}m"
return f"{m}m"
def _uptime(started_at: datetime | None) -> str | None:
"""How long a container has run ('3d 4h'), or None if unknown."""
secs = seconds_since(started_at)
return None if secs is None or secs < 0 else human_duration(secs)
def _sparkline(values: list[float], width: int = 80, height: int = 20) -> str:
@@ -148,7 +122,7 @@ async def rows():
g["containers"].append({
"container": c,
"ports": json.loads(c.ports_json) if c.ports_json else [],
"uptime": _human_uptime(c.started_at) if c.status == "running" else None,
"uptime": _uptime(c.started_at) if c.status == "running" else None,
"sparkline_cpu": _sparkline(cpu_hist),
"sparkline_mem": _sparkline(mem_hist),
})
@@ -339,9 +313,9 @@ async def container_detail(host_id: str, name: str):
host_id=host_id,
name=name,
ports=json.loads(c.ports_json) if c.ports_json else [],
uptime=_human_uptime(c.started_at) if c.status == "running" else None,
net_rx=_human_bytes(c.net_rx_bytes), net_tx=_human_bytes(c.net_tx_bytes),
blk_read=_human_bytes(c.blk_read_bytes), blk_write=_human_bytes(c.blk_write_bytes),
uptime=_uptime(c.started_at) if c.status == "running" else None,
net_rx=human_bytes(c.net_rx_bytes), net_tx=human_bytes(c.net_tx_bytes),
blk_read=human_bytes(c.blk_read_bytes), blk_write=human_bytes(c.blk_write_bytes),
events=[
{"event": e.event, "detail": e.detail, "at": e.at,
"glyph": _EVENT_STYLE.get(e.event, ("•", "var(--text-muted)"))[0],
@@ -494,8 +468,8 @@ async def disk():
images_by_host: dict[str, list] = {}
for im in images:
images_by_host.setdefault(im.host_id, []).append({
"repo_tag": im.repo_tag, "size": _human_bytes(im.size),
"shared": _human_bytes(im.shared_size), "containers": im.containers,
"repo_tag": im.repo_tag, "size": human_bytes(im.size),
"shared": human_bytes(im.shared_size), "containers": im.containers,
"reclaimable": im.containers == 0,
})
@@ -505,14 +479,14 @@ async def disk():
"host_name": hosts[s.host_id].name if s.host_id in hosts else s.host_id,
"summary": {
"images_total": s.images_total, "images_active": s.images_active,
"images_size": _human_bytes(s.images_size),
"images_reclaimable": _human_bytes(s.images_reclaimable),
"layers_size": _human_bytes(s.layers_size),
"images_size": human_bytes(s.images_size),
"images_reclaimable": human_bytes(s.images_reclaimable),
"layers_size": human_bytes(s.layers_size),
"containers_count": s.containers_count,
"containers_size": _human_bytes(s.containers_size),
"containers_size": human_bytes(s.containers_size),
"volumes_count": s.volumes_count,
"volumes_size": _human_bytes(s.volumes_size),
"build_cache_size": _human_bytes(s.build_cache_size),
"volumes_size": human_bytes(s.volumes_size),
"build_cache_size": human_bytes(s.build_cache_size),
},
"stopped": stopped_by_host.get(s.host_id, 0),
"images": images_by_host.get(s.host_id, []),
@@ -4,12 +4,6 @@
crash_looping, failures_1h, failures_24h and last_error; `style` is
optional extra CSS for the wrapper. Renders nothing for a healthy service
with no recent failures. -#}
{%- macro ago(sec) -%}
{%- if sec < 90 -%}{{ sec }}s ago
{%- elif sec < 5400 -%}{{ sec // 60 }}m ago
{%- elif sec < 172800 -%}{{ sec // 3600 }}h ago
{%- else -%}{{ sec // 86400 }}d ago{%- endif -%}
{%- endmacro -%}
{%- set e = s.last_error -%}
{%- if s.crash_looping or s.failures_24h or (e and s.state != 'healthy') %}
<div style="display:flex;flex-wrap:wrap;align-items:baseline;gap:0.2rem 0.6rem;font-size:0.78rem;color:var(--text-muted);{{ style or '' }}">
@@ -24,7 +18,7 @@
<span style="color:var(--text);font-family:ui-monospace,monospace;overflow-wrap:anywhere;">{{ e.error or e.message or e.state }}</span>
{%- if e.exit_code %} · exit {{ e.exit_code }}{% endif %}
{%- if e.node %} · {{ e.node }}{% endif %}
{%- if e.age_s is not none %} · {{ ago(e.age_s) }}{% endif %}
{%- if e.age_s is not none %} · {{ e.age_s|duration }} ago{% endif %}
</span>
{% endif %}
</div>
+10 -27
View File
@@ -1,22 +1,5 @@
{# Current-state fragment for the full-metrics page — polled live via HTMX.
Self-contained (defines its own format macros) so each poll re-renders the
latest snapshot the server has. #}
{% macro fmt_bps(v) %}
{%- if v is none -%}—
{%- elif v >= 1048576 -%}{{ "%.1f"|format(v / 1048576) }} MB/s
{%- elif v >= 1024 -%}{{ "%.1f"|format(v / 1024) }} KB/s
{%- else -%}{{ "%.0f"|format(v) }} B/s
{%- endif -%}
{% endmacro %}
{% macro fmt_bytes(v) %}
{%- if v is none -%}—
{%- elif v >= 1125899906842624 -%}{{ "%.1f"|format(v / 1125899906842624) }} PB
{%- elif v >= 1099511627776 -%}{{ "%.1f"|format(v / 1099511627776) }} TB
{%- elif v >= 1073741824 -%}{{ "%.1f"|format(v / 1073741824) }} GB
{%- elif v >= 1048576 -%}{{ "%.0f"|format(v / 1048576) }} MB
{%- else -%}{{ "%.0f"|format(v / 1024) }} KB
{%- endif -%}
{% endmacro %}
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);">
@@ -34,7 +17,7 @@
{% if reg.arch %}<span><span style="color:var(--text-dim);">Arch</span> {{ reg.arch }}</span>{% endif %}
{% 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 // 86400)|int }}d {{ ((up % 86400) // 3600)|int }}h</span>{% endif %}
{% 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>
{% else %}
<span>No agent registration found for this host.</span>
@@ -59,7 +42,7 @@
<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>
<div style="margin-top:0.45rem;font-size:0.72rem;color:var(--text-dim);">
avail {{ fmt_bytes(hostlvl.get('mem_available_bytes')) }} · cache {{ fmt_bytes(hostlvl.get('mem_cached_bytes')) }} · swap {{ fmt_bytes(hostlvl.get('swap_used_bytes')) }}
avail {{ hostlvl.get('mem_available_bytes')|bytes }} · cache {{ hostlvl.get('mem_cached_bytes')|bytes }} · swap {{ hostlvl.get('swap_used_bytes')|bytes }}
</div>
</div>
<div class="card" style="margin-bottom:0;">
@@ -73,15 +56,15 @@
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.35rem;">Network</div>
<div style="font-size:0.95rem;font-variant-numeric:tabular-nums;">
<div><span style="color:var(--green);">↓</span> {{ fmt_bps(hostlvl.get('net_rx_bps')) }}</div>
<div><span style="color:var(--red);">↑</span> {{ fmt_bps(hostlvl.get('net_tx_bps')) }}</div>
<div><span style="color:var(--green);">↓</span> {{ hostlvl.get('net_rx_bps')|rate }}</div>
<div><span style="color:var(--red);">↑</span> {{ hostlvl.get('net_tx_bps')|rate }}</div>
</div>
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.35rem;">Disk I/O</div>
<div style="font-size:0.95rem;font-variant-numeric:tabular-nums;">
<div><span style="color:var(--text-dim);">rd</span> {{ fmt_bps(hostlvl.get('disk_read_bps')) }}</div>
<div><span style="color:var(--text-dim);">wr</span> {{ fmt_bps(hostlvl.get('disk_write_bps')) }}</div>
<div><span style="color:var(--text-dim);">rd</span> {{ hostlvl.get('disk_read_bps')|rate }}</div>
<div><span style="color:var(--text-dim);">wr</span> {{ hostlvl.get('disk_write_bps')|rate }}</div>
</div>
</div>
{% if hostlvl.get('temp_c_max') is not none %}
@@ -128,7 +111,7 @@
<div style="margin-bottom:0.6rem;">
<div style="display:flex;justify-content:space-between;font-size:0.8rem;margin-bottom:0.2rem;">
<span style="font-family:ui-monospace,monospace;">{{ mount }}</span>
<span style="color:var(--text-muted);">{{ fmt_bytes(m.get('disk_used_bytes')) }} / {{ fmt_bytes(m.get('disk_total_bytes')) }}{% if m.get('disk_used_pct') is not none %} · {{ "%.0f"|format(m.get('disk_used_pct')) }}%{% endif %}</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')) }}
</div>
@@ -145,7 +128,7 @@
{% for iface, m in nets.items() %}
<div style="display:flex;justify-content:space-between;gap:0.75rem;font-size:0.8rem;padding:0.15rem 0;">
<span style="font-family:ui-monospace,monospace;">{{ iface }}</span>
<span style="color:var(--text-muted);font-variant-numeric:tabular-nums;white-space:nowrap;">↓ {{ fmt_bps(m.get('net_rx_bps')) }} · ↑ {{ fmt_bps(m.get('net_tx_bps')) }}</span>
<span style="color:var(--text-muted);font-variant-numeric:tabular-nums;white-space:nowrap;">↓ {{ m.get('net_rx_bps')|rate }} · ↑ {{ m.get('net_tx_bps')|rate }}</span>
</div>
{% endfor %}
</div>
@@ -156,7 +139,7 @@
{% for dev, m in disks_io.items() %}
<div style="display:flex;justify-content:space-between;gap:0.75rem;font-size:0.8rem;padding:0.15rem 0;">
<span style="font-family:ui-monospace,monospace;">{{ dev }}</span>
<span style="color:var(--text-muted);font-variant-numeric:tabular-nums;white-space:nowrap;">rd {{ fmt_bps(m.get('disk_read_bps')) }} · wr {{ fmt_bps(m.get('disk_write_bps')) }}</span>
<span style="color:var(--text-muted);font-variant-numeric:tabular-nums;white-space:nowrap;">rd {{ m.get('disk_read_bps')|rate }} · wr {{ m.get('disk_write_bps')|rate }}</span>
</div>
{% endfor %}
</div>
+2 -10
View File
@@ -13,21 +13,13 @@
<div style="line-height:0;height:16px;">{{ svg | safe }}</div>
</div>
{% endmacro %}
{# Human-readable throughput, matching the host-detail page. #}
{% macro fmt_bps(v) %}
{%- if v is none -%}—
{%- elif v >= 1048576 -%}{{ "%.1f"|format(v / 1048576) }} MB/s
{%- elif v >= 1024 -%}{{ "%.1f"|format(v / 1024) }} KB/s
{%- else -%}{{ "%.0f"|format(v) }} B/s
{%- endif -%}
{% endmacro %}
{# Two-line down/up (or read/write) throughput cell. #}
{% macro io_cell(label, down_sym, down_val, up_sym, up_val, title="") %}
<div style="min-width:80px;flex:0 0 auto;" title="{{ title }}">
<div style="font-size:0.62rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.03em;">{{ label }}</div>
<div style="font-size:0.72rem;font-variant-numeric:tabular-nums;line-height:1.3;">
<div>{{ down_sym }} {{ fmt_bps(down_val) }}</div>
<div>{{ up_sym }} {{ fmt_bps(up_val) }}</div>
<div>{{ down_sym }} {{ down_val|rate }}</div>
<div>{{ up_sym }} {{ up_val|rate }}</div>
</div>
</div>
{% endmacro %}
+2 -5
View File
@@ -28,9 +28,8 @@
{% if global_stat.process_memory_bytes is not none %}
<span>
<span style="color:var(--text-muted);">Memory</span>
{% set mb = (global_stat.process_memory_bytes / 1048576) %}
<span style="margin-left:0.4rem;">
{% if mb >= 1024 %}{{ "%.1f"|format(mb / 1024) }} GB{% else %}{{ "%.0f"|format(mb) }} MB{% endif %}
{{ global_stat.process_memory_bytes|bytes }}
</span>
</span>
{% endif %}
@@ -58,9 +57,7 @@
<td style="color:var(--text-muted);padding:0.2rem 0;">Bandwidth</td>
<td style="padding:0.2rem 0.5rem;">
{% set bps = r.latest.response_bytes_rate %}
{% if bps >= 1048576 %}{{ "%.1f"|format(bps / 1048576) }} MB/s
{% elif bps >= 1024 %}{{ "%.1f"|format(bps / 1024) }} KB/s
{% else %}{{ "%.0f"|format(bps) }} B/s{% endif %}
{{ bps|rate }}
</td>
<td style="padding:0.2rem 0;"></td>
</tr>
@@ -20,8 +20,7 @@
<span>
<span style="color:var(--text-muted);">Bytes</span>
<span style="margin-left:0.4rem;">
{% set mb = summary.total_bytes / 1048576 %}
{% if mb >= 1024 %}{{ "%.1f"|format(mb / 1024) }} GB{% else %}{{ "%.1f"|format(mb) }} MB{% endif %}
{{ summary.total_bytes|bytes }}
</span>
</span>
@@ -26,9 +26,7 @@
<span style="color:var(--text-muted);">bw</span>
<span style="margin-left:0.3rem;">
{% set bps = r.latest.response_bytes_rate %}
{% if bps >= 1048576 %}{{ "%.1f"|format(bps / 1048576) }}MB/s
{% elif bps >= 1024 %}{{ "%.0f"|format(bps / 1024) }}KB/s
{% else %}{{ "%.0f"|format(bps) }}B/s{% endif %}
{{ bps|rate }}
</span>
</span>
<span title="p95 latency">
+2 -8
View File
@@ -21,10 +21,7 @@
<tr>
<td style="padding:0.25rem 0.5rem 0.25rem 0;">{{ cat.cat_name or cat.cat_id }}</td>
<td style="padding:0.25rem 0.3rem;text-align:right;color:var(--text-muted);font-size:0.78rem;white-space:nowrap;">
{% if bytes >= 1073741824 %}{{ "%.1f"|format(bytes / 1073741824) }} GB
{% elif bytes >= 1048576 %}{{ "%.1f"|format(bytes / 1048576) }} MB
{% elif bytes >= 1024 %}{{ "%.0f"|format(bytes / 1024) }} KB
{% else %}{{ bytes }} B{% endif %}
{{ bytes|bytes }}
</td>
<td style="padding:0.25rem 0;width:4rem;">
<div style="background:var(--border);border-radius:2px;height:6px;overflow:hidden;">
@@ -50,10 +47,7 @@
</td>
<td style="padding:0.25rem 0;text-align:right;font-size:0.78rem;color:var(--text-muted);white-space:nowrap;">
{% set bytes = c.bytes %}
{% if bytes >= 1073741824 %}{{ "%.1f"|format(bytes / 1073741824) }} GB
{% elif bytes >= 1048576 %}{{ "%.1f"|format(bytes / 1048576) }} MB
{% elif bytes >= 1024 %}{{ "%.0f"|format(bytes / 1024) }} KB
{% else %}{{ bytes }} B{% endif %}
{{ bytes|bytes }}
</td>
</tr>
{% endfor %}
+6 -14
View File
@@ -29,9 +29,7 @@
<span style="color:var(--text-muted);">&#8595; RX</span>
<span style="margin-left:0.3rem;">
{% set bps = latest_wan.rx_bytes_rate %}
{% if bps >= 1048576 %}{{ "%.1f"|format(bps / 1048576) }} MB/s
{% elif bps >= 1024 %}{{ "%.0f"|format(bps / 1024) }} KB/s
{% else %}{{ "%.0f"|format(bps) }} B/s{% endif %}
{{ bps|rate }}
</span>
<span style="margin-left:0.3rem;">{{ sparkline_rx | safe }}</span>
</span>
@@ -39,9 +37,7 @@
<span style="color:var(--text-muted);">&#8593; TX</span>
<span style="margin-left:0.3rem;">
{% set bps = latest_wan.tx_bytes_rate %}
{% if bps >= 1048576 %}{{ "%.1f"|format(bps / 1048576) }} MB/s
{% elif bps >= 1024 %}{{ "%.0f"|format(bps / 1024) }} KB/s
{% else %}{{ "%.0f"|format(bps) }} B/s{% endif %}
{{ bps|rate }}
</span>
<span style="margin-left:0.3rem;">{{ sparkline_tx | safe }}</span>
</span>
@@ -49,7 +45,7 @@
{% if latest_wan.uptime_seconds is not none %}
<span style="color:var(--text-dim);font-size:0.8rem;">
up {{ (latest_wan.uptime_seconds // 3600) }}h {{ ((latest_wan.uptime_seconds % 3600) // 60) }}m
up {{ latest_wan.uptime_seconds|duration }}
</span>
{% endif %}
</div>
@@ -96,7 +92,7 @@
{% if d.state == 1 and d.uptime_seconds %}
<tr>
<td colspan="3" style="padding:0 0 0.3rem 1.2rem;color:var(--text-dim);font-size:0.75rem;">
up {{ (d.uptime_seconds // 86400) }}d {{ ((d.uptime_seconds % 86400) // 3600) }}h
up {{ d.uptime_seconds|duration }}
</td>
</tr>
{% endif %}
@@ -121,13 +117,9 @@
<td style="text-align:right;padding:0.2rem 0;white-space:nowrap;font-size:0.78rem;">
{% set rx = c.rx_rate %}{% set tx = c.tx_rate %}
<span style="color:var(--text-muted);">&#8595;</span>
{% if rx >= 1048576 %}{{ "%.1f"|format(rx/1048576) }}M
{% elif rx >= 1024 %}{{ "%.0f"|format(rx/1024) }}K
{% else %}{{ "%.0f"|format(rx) }}B{% endif %}
{{ rx|rate }}
<span style="color:var(--text-muted);margin-left:0.3rem;">&#8593;</span>
{% if tx >= 1048576 %}{{ "%.1f"|format(tx/1048576) }}M
{% elif tx >= 1024 %}{{ "%.0f"|format(tx/1024) }}K
{% else %}{{ "%.0f"|format(tx) }}B{% endif %}
{{ tx|rate }}
</td>
</tr>
{% endfor %}
+4 -12
View File
@@ -37,16 +37,12 @@
<span>
<span style="color:var(--text-muted);">&#8595;</span>
{% set bps = latest_wan.rx_bytes_rate %}
{% if bps >= 1048576 %}{{ "%.1f"|format(bps / 1048576) }}MB/s
{% elif bps >= 1024 %}{{ "%.0f"|format(bps / 1024) }}KB/s
{% else %}{{ "%.0f"|format(bps) }}B/s{% endif %}
{{ bps|rate }}
</span>
<span>
<span style="color:var(--text-muted);">&#8593;</span>
{% set bps = latest_wan.tx_bytes_rate %}
{% if bps >= 1048576 %}{{ "%.1f"|format(bps / 1048576) }}MB/s
{% elif bps >= 1024 %}{{ "%.0f"|format(bps / 1024) }}KB/s
{% else %}{{ "%.0f"|format(bps) }}B/s{% endif %}
{{ bps|rate }}
</span>
{% endif %}
</div>
@@ -76,14 +72,10 @@
{% set total_bps = c.tx_rate + c.rx_rate %}
<span style="color:var(--text-muted);">&#8595;</span>
{% set rx = c.rx_rate %}
{% if rx >= 1048576 %}{{ "%.1f"|format(rx / 1048576) }}M
{% elif rx >= 1024 %}{{ "%.0f"|format(rx / 1024) }}K
{% else %}{{ "%.0f"|format(rx) }}B{% endif %}
{{ rx|rate }}
<span style="color:var(--text-muted);margin-left:0.4rem;">&#8593;</span>
{% set tx = c.tx_rate %}
{% if tx >= 1048576 %}{{ "%.1f"|format(tx / 1048576) }}M
{% elif tx >= 1024 %}{{ "%.0f"|format(tx / 1024) }}K
{% else %}{{ "%.0f"|format(tx) }}B{% endif %}
{{ tx|rate }}
</div>
</div>
{% endfor %}
+11
View File
@@ -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
View File
@@ -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)
+97
View File
@@ -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
View File
@@ -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:
+111
View File
@@ -0,0 +1,111 @@
"""The shared format filters and status_class (#5093): one way to write a size,
a rate, a span and a moment, and one grading against Settings → Thresholds."""
import pathlib
from datetime import datetime, timedelta, timezone
import jinja2
import pytest
import steward
from steward.core import formatting as f
from steward.core.settings import threshold_style_for, to_thresholds_cfg
_REPO = pathlib.Path(steward.__file__).parent.parent
TH = to_thresholds_cfg({})
NOW = datetime(2026, 10, 6, 18, 0, 0, tzinfo=timezone.utc)
@pytest.mark.parametrize("n, text", [
(None, "—"), (0, "0 B"), (512, "512 B"), (1023.9, "1023 B"),
(1024, "1.0 KiB"), (1536, "1.5 KiB"), (1024 ** 2, "1.0 MiB"),
(int(1.5 * 1024 ** 3), "1.5 GiB"), (1024 ** 4, "1.0 TiB"), (3 * 1024 ** 5, "3.0 PiB"),
(1024 ** 6, "1024.0 PiB"),
])
def test_bytes(n, text):
assert f.human_bytes(n) == text
@pytest.mark.parametrize("n, text", [(None, "—"), (0, "0 B/s"), (2.5 * 1024 ** 2, "2.5 MiB/s")])
def test_rate(n, text):
assert f.human_rate(n) == text
@pytest.mark.parametrize("secs, text", [
(None, "—"), (-5, "—"), (0, "0s"), (0.9, "0s"), (45, "45s"), (60, "1m"), (90, "1m 30s"),
(18 * 60, "18m"), (3600, "1h"), (2 * 3600 + 5 * 60 + 59, "2h 5m"), (86400, "1d"),
(3 * 86400 + 4 * 3600 + 30, "3d 4h"), (400 * 86400, "400d"),
])
def test_duration(secs, text):
assert f.human_duration(secs) == text
@pytest.mark.parametrize("when, text", [
(NOW - timedelta(minutes=3), "3m ago"),
(NOW - timedelta(days=2, hours=1), "2d 1h ago"),
(NOW.replace(tzinfo=None) - timedelta(seconds=45), "45s ago"), # naive is UTC
(NOW + timedelta(minutes=5), "in 5m"),
(NOW, "just now"),
])
def test_ago_reads_relative_and_titles_the_absolute_utc_time(when, text):
out = str(f.ago(when, now=NOW))
stamp = when.replace(tzinfo=timezone.utc) if when.tzinfo is None else when
assert out == (f'<time datetime="{stamp:%Y-%m-%dT%H:%M:%SZ}" '
f'title="{stamp:%Y-%m-%d %H:%M:%S} UTC">{text}</time>')
def test_ago_of_nothing_and_of_another_zone():
assert str(f.ago(None)) == "—"
est = timezone(timedelta(hours=-4))
assert 'title="2026-10-06 18:00:00 UTC"' in str(f.ago(NOW.astimezone(est), now=NOW))
@pytest.mark.parametrize("value, kind, cls", [
(None, "cpu", "unknown"),
(10, "cpu", "ok"), (80, "cpu", "warn"), (89.9, "cpu", "warn"), (90, "cpu", "crit"),
(49, "latency", "ok"), (50, "latency", "warn"), (250, "latency", "crit"),
# Lower is worse: uptime % and days of TLS left.
(99.9, "uptime", "ok"), (98, "uptime", "warn"), (90, "uptime", "crit"),
(30, "tls_days", "ok"), (10, "tls_days", "warn"), (1, "tls_days", "crit"),
])
def test_status_class_against_the_default_thresholds(value, kind, cls):
assert f.status_class(value, kind, TH) == cls
def test_status_class_follows_settings_and_refuses_an_unknown_kind():
th = to_thresholds_cfg({"thresholds.cpu_warn": 50, "thresholds.cpu_crit": 60})
assert [f.status_class(v, "cpu", th) for v in (49, 50, 60)] == ["ok", "warn", "crit"]
with pytest.raises(KeyError):
f.status_class(10, "cpuu", TH)
def test_threshold_style_is_graded_by_status_class():
assert threshold_style_for(10, "cpu", TH) == ""
assert threshold_style_for(None, "cpu", TH) == ""
assert "var(--yellow)" in threshold_style_for(85, "cpu", TH)
assert "var(--red)" in threshold_style_for(95, "cpu", TH)
assert "700" not in threshold_style_for(95, "cpu", TH) # 500 is the heaviest weight
# ── every template compiles against the app's filters ────────────────────────
def _templates():
roots = [_REPO / "steward" / "templates", *(_REPO / "plugins").glob("*/templates")]
return sorted(p for root in roots for p in root.rglob("*.html"))
async def test_compiling_catches_an_unknown_filter(app):
with pytest.raises(jinja2.TemplateAssertionError, match="no_such"):
app.jinja_env.compile("{{ 1|no_such }}")
app.jinja_env.compile("{{ 1|bytes }} {{ 1|rate }} {{ 1|duration }} {{ none|ago }}")
async def test_every_template_compiles_with_the_app_filters(app):
files = _templates()
assert len(files) > 100
failed = {}
for path in files:
try:
app.jinja_env.compile(path.read_text(), name=str(path.relative_to(_REPO)))
except jinja2.TemplateError as exc:
failed[str(path.relative_to(_REPO))] = str(exc)
assert not failed, failed
+1 -1
View File
@@ -85,7 +85,7 @@ def test_silence_is_marked_once_and_cleared_by_data(app):
assert st.no_data_since == now - timedelta(minutes=18) + timedelta(seconds=300)
[(frm, to, note)] = await events("loud")
assert (frm, to) == ("inactive", "no_data")
assert note == "no sample for 18m (usually every 60s)"
assert note == "no sample for 18m (usually every 1m)"
assert len(await events("hushed")) == 1
assert sent == [(f"loud {tag}", "no_data", note)] # the hushed rule notifies nobody
+4 -16
View File
@@ -41,21 +41,9 @@ def test_container_log_views_defined():
assert callable(r.container_logs_lines)
def test_human_bytes_formats_binary_units():
assert r._human_bytes(None) == "—"
assert r._human_bytes(0) == "0 B"
assert r._human_bytes(512) == "512 B"
assert r._human_bytes(1024) == "1.0 KiB"
assert r._human_bytes(1536) == "1.5 KiB"
assert r._human_bytes(1024 ** 2) == "1.0 MiB"
assert r._human_bytes(int(1.5 * 1024 ** 3)) == "1.5 GiB"
assert r._human_bytes(1024 ** 4) == "1.0 TiB"
def test_human_uptime_compact():
def test_uptime_is_the_shared_duration_or_none():
from datetime import datetime, timedelta, timezone
now = datetime.now(timezone.utc)
assert r._human_uptime(None) is None
assert r._human_uptime(now - timedelta(minutes=8)).endswith("m")
assert "h" in r._human_uptime(now - timedelta(hours=5, minutes=12))
assert "d" in r._human_uptime(now - timedelta(days=3, hours=4))
assert r._uptime(None) is None
assert r._uptime(now + timedelta(minutes=5)) is None # clock skew: not "-5m"
assert r._uptime(now - timedelta(days=3, hours=4, seconds=30)) == "3d 4h"