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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -29,9 +29,7 @@
|
||||
<span style="color:var(--text-muted);">↓ 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);">↑ 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);">↓</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;">↑</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 %}
|
||||
|
||||
@@ -37,16 +37,12 @@
|
||||
<span>
|
||||
<span style="color:var(--text-muted);">↓</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);">↑</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);">↓</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;">↑</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 %}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user