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