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:
+17
-43
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user