diff --git a/plugins/docker/routes.py b/plugins/docker/routes.py
index 226b31f..264cc82 100644
--- a/plugins/docker/routes.py
+++ b/plugins/docker/routes.py
@@ -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, []),
diff --git a/plugins/docker/templates/docker/_swarm_failures.html b/plugins/docker/templates/docker/_swarm_failures.html
index 97cece0..836998b 100644
--- a/plugins/docker/templates/docker/_swarm_failures.html
+++ b/plugins/docker/templates/docker/_swarm_failures.html
@@ -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') %}
@@ -24,7 +18,7 @@
{{ e.error or e.message or e.state }}
{%- 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 %}
{% endif %}
diff --git a/plugins/host_agent/templates/_host_metrics.html b/plugins/host_agent/templates/_host_metrics.html
index c06c49b..1439a39 100644
--- a/plugins/host_agent/templates/_host_metrics.html
+++ b/plugins/host_agent/templates/_host_metrics.html
@@ -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 -%}
@@ -34,7 +17,7 @@
{% if reg.arch %}
Arch {{ reg.arch }}{% endif %}
{% if reg.agent_version %}
Agent v{{ reg.agent_version }}{% endif %}
{% set up = hostlvl.get('uptime_secs') %}
- {% if up %}
Uptime {{ (up // 86400)|int }}d {{ ((up % 86400) // 3600)|int }}h{% endif %}
+ {% 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" }}
{% else %}
No agent registration found for this host.
@@ -59,7 +42,7 @@
{% if mp is not none %}{{ "%.0f"|format(mp) }}%{% else %}—{% endif %}
{{ bar(mp) }}
- 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 }}
@@ -73,15 +56,15 @@
Network
-
↓ {{ fmt_bps(hostlvl.get('net_rx_bps')) }}
-
↑ {{ fmt_bps(hostlvl.get('net_tx_bps')) }}
+
↓ {{ hostlvl.get('net_rx_bps')|rate }}
+
↑ {{ hostlvl.get('net_tx_bps')|rate }}
Disk I/O
-
rd {{ fmt_bps(hostlvl.get('disk_read_bps')) }}
-
wr {{ fmt_bps(hostlvl.get('disk_write_bps')) }}
+
rd {{ hostlvl.get('disk_read_bps')|rate }}
+
wr {{ hostlvl.get('disk_write_bps')|rate }}
{% if hostlvl.get('temp_c_max') is not none %}
@@ -128,7 +111,7 @@
{{ mount }}
- {{ 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 %}
+ {{ 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')) }}
@@ -145,7 +128,7 @@
{% for iface, m in nets.items() %}
{{ iface }}
- ↓ {{ fmt_bps(m.get('net_rx_bps')) }} · ↑ {{ fmt_bps(m.get('net_tx_bps')) }}
+ ↓ {{ m.get('net_rx_bps')|rate }} · ↑ {{ m.get('net_tx_bps')|rate }}
{% endfor %}
@@ -156,7 +139,7 @@
{% for dev, m in disks_io.items() %}
{{ dev }}
- rd {{ fmt_bps(m.get('disk_read_bps')) }} · wr {{ fmt_bps(m.get('disk_write_bps')) }}
+ rd {{ m.get('disk_read_bps')|rate }} · wr {{ m.get('disk_write_bps')|rate }}
{% endfor %}
diff --git a/plugins/host_agent/templates/widget_table.html b/plugins/host_agent/templates/widget_table.html
index 163823f..2c2b1e2 100644
--- a/plugins/host_agent/templates/widget_table.html
+++ b/plugins/host_agent/templates/widget_table.html
@@ -13,21 +13,13 @@
{{ svg | safe }}
{% 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="") %}
{{ label }}
-
{{ down_sym }} {{ fmt_bps(down_val) }}
-
{{ up_sym }} {{ fmt_bps(up_val) }}
+
{{ down_sym }} {{ down_val|rate }}
+
{{ up_sym }} {{ up_val|rate }}
{% endmacro %}
diff --git a/plugins/traefik/templates/traefik/rows.html b/plugins/traefik/templates/traefik/rows.html
index a8aee7f..55202fb 100644
--- a/plugins/traefik/templates/traefik/rows.html
+++ b/plugins/traefik/templates/traefik/rows.html
@@ -28,9 +28,8 @@
{% if global_stat.process_memory_bytes is not none %}
Memory
- {% set mb = (global_stat.process_memory_bytes / 1048576) %}
- {% if mb >= 1024 %}{{ "%.1f"|format(mb / 1024) }} GB{% else %}{{ "%.0f"|format(mb) }} MB{% endif %}
+ {{ global_stat.process_memory_bytes|bytes }}
{% endif %}
@@ -58,9 +57,7 @@
Bandwidth |
{% 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 }}
|
|
diff --git a/plugins/traefik/templates/traefik/traffic.html b/plugins/traefik/templates/traefik/traffic.html
index 73b48e4..3a2ee84 100644
--- a/plugins/traefik/templates/traefik/traffic.html
+++ b/plugins/traefik/templates/traefik/traffic.html
@@ -20,8 +20,7 @@
Bytes
- {% set mb = summary.total_bytes / 1048576 %}
- {% if mb >= 1024 %}{{ "%.1f"|format(mb / 1024) }} GB{% else %}{{ "%.1f"|format(mb) }} MB{% endif %}
+ {{ summary.total_bytes|bytes }}
diff --git a/plugins/traefik/templates/traefik/widget.html b/plugins/traefik/templates/traefik/widget.html
index aa43085..8e3021e 100644
--- a/plugins/traefik/templates/traefik/widget.html
+++ b/plugins/traefik/templates/traefik/widget.html
@@ -26,9 +26,7 @@
bw
{% 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 }}
diff --git a/plugins/unifi/templates/unifi/dpi.html b/plugins/unifi/templates/unifi/dpi.html
index 6e085b7..f154d1d 100644
--- a/plugins/unifi/templates/unifi/dpi.html
+++ b/plugins/unifi/templates/unifi/dpi.html
@@ -21,10 +21,7 @@
| {{ cat.cat_name or cat.cat_id }} |
- {% 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 }}
|
@@ -50,10 +47,7 @@
|
{% 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 }}
|
{% endfor %}
diff --git a/plugins/unifi/templates/unifi/rows.html b/plugins/unifi/templates/unifi/rows.html
index 93a8914..7085dad 100644
--- a/plugins/unifi/templates/unifi/rows.html
+++ b/plugins/unifi/templates/unifi/rows.html
@@ -29,9 +29,7 @@
↓ RX
{% 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 }}
{{ sparkline_rx | safe }}
@@ -39,9 +37,7 @@
↑ TX
{% 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 }}
{{ sparkline_tx | safe }}
@@ -49,7 +45,7 @@
{% if latest_wan.uptime_seconds is not none %}
- up {{ (latest_wan.uptime_seconds // 3600) }}h {{ ((latest_wan.uptime_seconds % 3600) // 60) }}m
+ up {{ latest_wan.uptime_seconds|duration }}
{% endif %}
@@ -96,7 +92,7 @@
{% if d.state == 1 and d.uptime_seconds %}
|
- up {{ (d.uptime_seconds // 86400) }}d {{ ((d.uptime_seconds % 86400) // 3600) }}h
+ up {{ d.uptime_seconds|duration }}
|
{% endif %}
@@ -121,13 +117,9 @@
{% set rx = c.rx_rate %}{% set tx = c.tx_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 }}
↑
- {% if tx >= 1048576 %}{{ "%.1f"|format(tx/1048576) }}M
- {% elif tx >= 1024 %}{{ "%.0f"|format(tx/1024) }}K
- {% else %}{{ "%.0f"|format(tx) }}B{% endif %}
+ {{ tx|rate }}
|
{% endfor %}
diff --git a/plugins/unifi/templates/unifi/widget.html b/plugins/unifi/templates/unifi/widget.html
index 2993ea6..6235cd1 100644
--- a/plugins/unifi/templates/unifi/widget.html
+++ b/plugins/unifi/templates/unifi/widget.html
@@ -37,16 +37,12 @@
↓
{% 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 }}
↑
{% 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 }}
{% endif %}
@@ -76,14 +72,10 @@
{% set total_bps = c.tx_rate + c.rx_rate %}
↓
{% 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 }}
↑
{% 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 }}
{% endfor %}
diff --git a/steward/app.py b/steward/app.py
index 74c0389..59704a5 100644
--- a/steward/app.py
+++ b/steward/app.py
@@ -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).
diff --git a/steward/core/alerts.py b/steward/core/alerts.py
index 06e2dc9..665da88 100644
--- a/steward/core/alerts.py
+++ b/steward/core/alerts.py
@@ -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)
diff --git a/steward/core/formatting.py b/steward/core/formatting.py
new file mode 100644
index 0000000..16be5a1
--- /dev/null
+++ b/steward/core/formatting.py
@@ -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