feat(dashboard): Monitors widget carries uptime; Uptime / SLA retired (#5250)

The Monitors widget opens with a verdict ("1 of 5 monitors down."), then one
row per monitor under a single header: latency and uptime over 24h, 7d and
30d, down monitors first with why and since when, the name linking to the
monitor. Counts follow the type filter. Problems always show; the limit
trims only the healthy rest ("+N more up").

Uptime / SLA (uptime_summary) repeated those numbers per host; migration
0033 moves dashboards onto Monitors. Shared widget styles (.w-verdict,
.w-table, .w-sub, .w-more) carry the widget-shape ruling for the widgets
that follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 13:12:58 -04:00
co-authored by Claude Opus 5.5
parent 4dcf958022
commit b5fcccba5f
10 changed files with 149 additions and 126 deletions
+3 -15
View File
@@ -18,7 +18,7 @@ WIDGET_REGISTRY: dict[str, dict] = {
"monitors": {
"key": "monitors",
"label": "Monitors",
"description": "Uptime checks of every type — ping, DNS, and HTTP — with up/down status and latency",
"description": "Every uptime check, problems first: latency and uptime over 24h, 7d and 30d",
"hx_url": "/monitors/widget",
"detail_url": "/monitors/",
"plugin": None,
@@ -80,18 +80,6 @@ WIDGET_REGISTRY: dict[str, dict] = {
"poll": True,
"params": [],
},
"uptime_summary": {
"key": "uptime_summary",
"label": "Uptime / SLA",
"description": "Per-host rolling uptime % for 24h, 7d, and 30d windows",
"hx_url": "/hosts/uptime/widget",
"detail_url": "/hosts/uptime",
"plugin": None,
"poll": True,
# Uptime moves slowly and reads 30 days of checks: every 5 minutes, not every poll.
"poll_seconds": 300,
"params": [],
},
"traefik_routers": {
"key": "traefik_routers",
"label": "Traefik — Routers",
@@ -278,6 +266,7 @@ WIDGET_REGISTRY: dict[str, dict] = {
RETIRED = {
"host_resources": "hosts_overview", # Hosts — Overview carries cpu/mem/disk/load per host
"unifi_devices": "unifi_overview", # UniFi — Overview lists the offline devices
"uptime_summary": "monitors", # Monitors shows 24h/7d/30d uptime per monitor (#5250)
}
# The recommended dashboard (#5213): what needs attention first, then the
@@ -291,7 +280,6 @@ RECOMMENDED: list[tuple[str, int, int, dict]] = [
("monitors", 6, 7, {"show_down_only": "no", "limit": 10}),
("docker_containers", 6, 6, {}),
("docker_resources", 6, 6, {"limit": 10}),
("uptime_summary", 6, 5, {}),
("traefik_routers", 6, 5, {}),
("unifi_overview", 6, 4, {}),
("snmp_devices", 6, 4, {}),
@@ -318,7 +306,7 @@ def recommended_layout(plugins_cfg: dict) -> list[dict]:
# Group each widget for the picker (Core monitors / Monitoring capabilities /
# Integrations), mirroring the Settings → Plugins taxonomy. Capability plugins
# are host/monitoring facets; traefik/unifi are discrete vendor integrations.
_CORE_WIDGETS = {"monitors", "status_overview", "uptime_summary", "hosts_overview"}
_CORE_WIDGETS = {"monitors", "status_overview", "hosts_overview"}
_INTEGRATION_PLUGINS = {"traefik", "unifi"}
GROUP_LABELS = {
"core": "Core monitors",
-14
View File
@@ -314,17 +314,3 @@ async def uptime_page():
uptime = await host_svc.compute_uptime(db)
hosts = host_svc.worst_uptime_first(result.scalars().all(), uptime)
return await render_template("hosts/uptime.html", hosts=hosts, uptime=uptime)
@hosts_bp.get("/uptime/widget")
@require_role(UserRole.viewer)
async def uptime_widget():
async with current_app.db_sessionmaker() as db:
result = await db.execute(select(Host).order_by(Host.name))
uptime = await host_svc.compute_uptime(db)
hosts = host_svc.worst_uptime_first(result.scalars().all(), uptime)
return await render_template(
"hosts/uptime_widget.html",
hosts=hosts,
uptime=uptime,
)
@@ -0,0 +1,46 @@
"""Uptime / SLA folds into Monitors (#5250)
Revision ID: 0033_retire_uptime_widget
Revises: 0032_retire_duplicate_widgets
Create Date: 2026-10-07
The Monitors widget now shows each monitor's uptime over 24h, 7d and 30d, so
the separate Uptime / SLA widget (uptime_summary) repeated it. A dashboard
that already shows Monitors loses the Uptime placement; any other keeps the
cell, now showing Monitors. The same steps as 0032.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0033_retire_uptime_widget"
down_revision: Union[str, None] = "0032_retire_duplicate_widgets"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_OLD, _NEW = "uptime_summary", "monitors"
def upgrade() -> None:
op.execute(sa.text("""
DELETE FROM dashboard_widgets d
WHERE d.widget_key = :old
AND EXISTS (SELECT 1 FROM dashboard_widgets k
WHERE k.dashboard_id = d.dashboard_id AND k.widget_key = :new)
""").bindparams(old=_OLD, new=_NEW))
op.execute(sa.text("""
DELETE FROM dashboard_widgets d
WHERE d.widget_key = :old
AND EXISTS (SELECT 1 FROM dashboard_widgets k
WHERE k.dashboard_id = d.dashboard_id AND k.widget_key = :old
AND k.id < d.id)
""").bindparams(old=_OLD))
op.execute(sa.text("""
UPDATE dashboard_widgets SET widget_key = :new, config_json = NULL
WHERE widget_key = :old
""").bindparams(old=_OLD, new=_NEW))
def downgrade() -> None:
# One-way, as 0032: the retired widget no longer exists to render.
pass
+7 -5
View File
@@ -221,14 +221,16 @@ async def widget():
async with current_app.db_sessionmaker() as db:
data, counts, _ = await monitor_svc.monitor_rows(
db, DEFAULT_RANGE, current_app.config.get("THRESHOLDS", {}))
up, down, pending = counts["up"], counts["down"], counts["pending"]
if type_filter in MONITOR_TYPE_VALUES:
data = [d for d in data if d["monitor"].type == type_filter]
if show_down_only:
data = [d for d in data if not (d["latest"] and d["latest"].is_up)]
# Counted after the type filter, so the verdict speaks for what is listed.
counts = {k: sum(d["state"] == k for d in data) for k in ("up", "degraded", "down", "pending", "paused")}
problems = [d for d in data if d["state"] in ("down", "degraded", "pending")]
rest = [] if show_down_only else [d for d in data if d["state"] not in ("down", "degraded", "pending")]
shown = max(0, limit - len(problems))
return await render_template(
"monitors/widget.html",
monitor_data=data[:limit], up=up, down=down, pending=pending,
show_down_only=show_down_only, widget_id=widget_id,
problems=problems, rest=rest[:shown], more=len(rest) - min(len(rest), shown),
counts=counts, total=len(data), show_down_only=show_down_only, widget_id=widget_id,
)
+1
View File
@@ -204,6 +204,7 @@ async def monitor_rows(db, range_key: str, thresholds: dict):
"heartbeat": [{"state": "up" if r.is_up else "down",
"title": _heartbeat_title(m.type, r)} for r in rows],
"uptime_pct": uptime_pct,
"uptime": uptime.get(m.id, {}),
"tls_days": tls_days,
"down_since": None,
"down_reason": down_reason(latest) if latest is not None and not latest.is_up else None,
+16
View File
@@ -381,6 +381,22 @@ body.dash-editing .widget-drawer { transform:translateY(0); }
.cell-state { font-size:0.85rem; color:var(--text-muted); padding:0.25rem 0; }
.cell-state-failed { display:flex; flex-direction:column; align-items:flex-start; gap:0.5rem; color:var(--yellow); }
.widget-link:hover { color:var(--text); }
/* Widget content, in the order of the widget-shape ruling (UI & Design):
a verdict line, the exceptions, then the healthy rest in a compact table
whose column labels appear once. Names cut off with … and show whole on
hover; the table never scrolls sideways. */
.w-verdict { font-size:0.85rem; margin-bottom:0.5rem; }
.w-table { width:100%; border-collapse:collapse; table-layout:fixed; font-size:0.82rem; font-variant-numeric:tabular-nums; }
.w-table th { text-align:right; font-weight:500; font-size:var(--fs-size-tiny); color:var(--text-dim); text-transform:uppercase; letter-spacing:var(--fs-tracking-tiny); padding:0 0 0.25rem 0.5rem; white-space:nowrap; }
.w-table td { text-align:right; padding:0.2rem 0 0.2rem 0.5rem; border-top:1px solid var(--border); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.w-table th:first-child, .w-table td:first-child { text-align:left; padding-left:0; }
.w-table td.w-name { overflow:hidden; text-overflow:ellipsis; }
.w-table td.w-name a { color:inherit; text-decoration:none; }
.w-table td.w-name a:hover { text-decoration:underline; }
.w-table .dot { margin-right:0.4rem; vertical-align:middle; }
.w-sub { display:block; font-size:0.72rem; color:var(--text-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.w-more { font-size:0.75rem; color:var(--text-dim); padding-top:0.3rem; }
/* Time range selector */
.range-btn { background:none; border:1px solid var(--border-control); border-radius:4px; color:var(--text-muted); padding:0.18rem 0.5rem; cursor:pointer; font-size:0.75rem; font-family:inherit; transition:background .1s,color .1s,border-color .1s; }
@@ -1,47 +0,0 @@
{# hosts/uptime_widget.html — dashboard widget: SLA uptime summary #}
{% if not hosts %}
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">
No hosts configured.
</div>
{% else %}
<div style="display:grid;gap:2px;">
<div style="display:grid;grid-template-columns:1fr 52px 52px 52px;gap:0.35rem;font-size:0.72rem;color:var(--text-dim);padding:0 0 0.25rem;border-bottom:1px solid var(--border);">
<span>Host</span>
<span style="text-align:center;">24h</span>
<span style="text-align:center;">7d</span>
<span style="text-align:center;">30d</span>
</div>
{% for host in hosts %}
{% set ut = uptime.get(host.id, {}) %}
<div style="display:grid;grid-template-columns:1fr 52px 52px 52px;gap:0.35rem;align-items:center;padding:0.15rem 0;font-size:0.82rem;">
{% if session.user_id %}
<a href="/hosts/{{ host.id }}" title="{{ host.name }}"
style="overflow-wrap:anywhere;color:inherit;text-decoration:none;">{{ host.name }}</a>
{% else %}
<span style="overflow-wrap:anywhere;" title="{{ host.name }}">{{ host.name }}</span>
{% endif %}
{% for window in ["24h", "7d", "30d"] %}
{% set pct = ut.get(window) %}
<span style="text-align:center;font-family:ui-monospace,monospace;font-size:0.78rem;">
{% if pct is none %}
<span style="color:var(--text-dim);">—</span>
{% else %}
{# Configurable uptime thresholds (Settings → Thresholds); a normal value
(no degraded style) stays green to read as healthy at a glance. #}
{% set us = threshold_style(pct, 'uptime') %}
<span style="{{ us if us else 'color:var(--green);' }}">{{ "%.1f"|format(pct) }}%</span>
{% endif %}
</span>
{% endfor %}
</div>
{% endfor %}
</div>
{% if session.user_id %}
<div style="margin-top:0.5rem;text-align:right;">
<a href="/hosts/uptime" style="font-size:0.75rem;color:var(--text-muted);">Full SLA →</a>
</div>
{% endif %}
{% endif %}
+39 -39
View File
@@ -1,44 +1,44 @@
{# monitors/widget.html — dashboard widget: unified monitor summary #}
{% if not monitor_data and up == 0 and down == 0 and pending == 0 %}
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">
No monitors configured. <a href="/monitors/new">Add a monitor →</a>
{# monitors/widget.html — the dashboard Monitors widget (#5250): a verdict,
then one row per monitor, problems first with why and since when, under one
header of latency and uptime over 24h / 7d / 30d. It replaced the separate
Uptime / SLA widget, so uptime has one home. #}
{% from "_macros.html" import status_dot %}
{% if not total %}
<div class="w-verdict" style="color:var(--text-muted);">
No monitors configured.{% if session.user_id %} <a href="/monitors/new">Add a monitor →</a>{% endif %}
</div>
{% else %}
<div style="display:flex;gap:1rem;margin-bottom:0.65rem;flex-wrap:wrap;">
{% if up %}
<div><span style="font-size:1.4rem;font-weight:500;line-height:1;color:var(--green);">{{ up }}</span>
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">up</span></div>
{% endif %}
{% if down %}
<div><span style="font-size:1.4rem;font-weight:500;line-height:1;color:var(--red);">{{ down }}</span>
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">down</span></div>
{% endif %}
{% if pending %}
<div><span style="font-size:1.4rem;font-weight:500;line-height:1;color:var(--text-dim);">{{ pending }}</span>
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">pending</span></div>
{% endif %}
<div class="w-verdict {{ 'tone-crit' if counts.down else ('tone-warn' if counts.degraded or counts.pending else 'tone-ok') }}">
{%- if counts.down %}{{ counts.down }} of {{ total }} monitor{{ 's' if total != 1 }} down{% if counts.degraded %}, {{ counts.degraded }} degraded{% endif %}.
{%- elif counts.degraded %}{{ counts.degraded }} of {{ total }} monitor{{ 's' if total != 1 }} degraded, the rest up.
{%- elif counts.pending %}{{ counts.pending }} not checked yet, {{ counts.up }} up.
{%- else %}All {{ counts.up }} monitor{{ 's' if counts.up != 1 }} up{% if counts.paused %}, {{ counts.paused }} paused{% endif %}.{% endif -%}
</div>
<div style="display:grid;gap:3px;">
{% for d in monitor_data %}
{% set latest = d.latest %}
<div style="display:flex;align-items:center;gap:0.5rem;font-size:0.82rem;padding:0.15rem 0;">
{% if not latest %}<span class="dot dot-dim"></span>
{% elif latest.is_up %}<span class="dot dot-up"></span>
{% else %}<span class="dot dot-down"></span>{% endif %}
{% if d.monitor.host_id %}
<a href="/hosts/{{ d.monitor.host_id }}" style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:inherit;text-decoration:none;">{{ d.monitor.name }}</a>
{% else %}
<span style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ d.monitor.name }}</span>
{% endif %}
{% if latest and latest.response_ms is not none %}
<span style="font-size:0.72rem;font-family:ui-monospace,monospace;color:var(--text-muted);flex-shrink:0;">{{ "%.0f"|format(latest.response_ms) }}ms</span>
{% elif latest and not latest.is_up %}
<span style="font-size:0.72rem;color:var(--red);flex-shrink:0;">{{ latest.status_code or "down" }}</span>
{% endif %}
</div>
{% if problems or rest %}
<table class="w-table">
<colgroup><col><col style="width:4.5rem"><col style="width:4.5rem"><col style="width:4.5rem"><col style="width:4.5rem"></colgroup>
<thead><tr><th>Monitor</th><th>Latency</th><th>24h</th><th>7d</th><th>30d</th></tr></thead>
<tbody>
{% for d in problems + rest %}
{% set m = d.monitor %}{% set latest = d.latest %}
<tr>
<td class="w-name" title="{{ m.name }}">
{{- status_dot(d.state) -}}
{% if session.user_id %}<a href="/monitors/{{ m.id }}">{{ m.name }}</a>{% else %}{{ m.name }}{% endif %}
{%- if d.state == 'down' %}<span class="w-sub tone-crit">went down {{ d.down_since|ago if d.down_since else '' }}{% if d.down_reason %} · {{ d.down_reason }}{% endif %}</span>
{%- elif d.state == 'degraded' %}<span class="w-sub tone-warn">{% if latest and status_class(latest.response_ms, 'latency') == 'crit' %}slow replies{% else %}checks failing now and then{% endif %}</span>
{%- elif d.state == 'pending' %}<span class="w-sub">not checked yet</span>{% endif %}
</td>
<td style="{{ threshold_style(latest.response_ms, 'latency') if latest and latest.is_up and latest.response_ms is not none }}">
{%- if latest and latest.is_up and latest.response_ms is not none %}{{ "%.0f"|format(latest.response_ms) }} ms{% else %}—{% endif -%}
</td>
{% for k in ("24h", "7d", "30d") %}{% set u = d.uptime.get(k) %}
<td style="{{ threshold_style(u, 'uptime') if u is not none }}">{% if u is none %}—{% else %}{{ "%.2f"|format(u) }}%{% endif %}</td>
{% endfor %}
</tr>
{% endfor %}
</div>
</tbody>
</table>
{% endif %}
{% if more %}<div class="w-more">+{{ more }} more up{% if session.user_id %} · <a href="/monitors/">all monitors</a>{% endif %}</div>{% endif %}
{% endif %}
+4 -6
View File
@@ -35,11 +35,9 @@ def test_the_recommended_layout_fills_rows_without_overlap():
def test_the_recommended_layout_skips_what_is_not_enabled():
keys = [i["key"] for i in recommended_layout({})]
assert keys == ["status_overview", "hosts_overview", "monitors", "uptime_summary"]
# uptime no longer fits beside the monitors, so it starts the next row
items = recommended_layout({})
assert (items[3]["x"], items[3]["y"]) == (0, 10)
assert [i["key"] for i in items] == ["status_overview", "hosts_overview", "monitors"]
assert [(i["x"], i["y"]) for i in items] == [(0, 0), (0, 3), (6, 3)]
def test_overview_alerts_put_the_breaching_first():
@@ -96,9 +94,9 @@ async def test_the_hosts_widget_shows_load(app):
async def test_a_widget_polls_at_its_own_pace(app):
defn = {**WIDGET_REGISTRY["uptime_summary"]}
defn = {"poll": True, "poll_seconds": 300, "detail_url": "/x"}
item = SimpleNamespace(db=SimpleNamespace(id=7, grid_x=0, grid_y=0, grid_w=6, grid_h=5),
title="Uptime", defn=defn, hx_url="/uptime/widget")
title="Slow", defn=defn, hx_url="/slow/widget")
async with app.test_request_context("/"):
page = await render_template("dashboard/_grid_item.html", item=item, can_edit=False, poll_interval=30)
assert "every 300s" in page
+33
View File
@@ -137,3 +137,36 @@ async def test_a_down_monitor_says_since_when_and_why_in_the_list(app):
range_key="24h")
page = " ".join(page.split())
assert re.search(r'Down <time [^>]*>12m</time> · connection refused', page), page
async def _widget(app, problems, rest, more=0, counts=None):
counts = counts or {"up": len(rest), "degraded": 0, "down": len(problems), "pending": 0, "paused": 0}
async with app.test_request_context("/monitors/widget"):
session["user_id"] = "u1"
return await render_template("monitors/widget.html", problems=problems, rest=rest, more=more,
counts=counts, total=len(problems) + len(rest) + more,
show_down_only=False, widget_id="1")
async def test_the_widget_gives_a_verdict_then_problems_with_uptime_columns(app):
"""#5250: one home for uptime. A verdict, the down monitor first with why
and since when, then each monitor's latency and 24h/7d/30d under one header."""
down = {**_row(_mon("api", mid="m2"), "down", _check(False)),
"uptime": {"24h": 90.0, "7d": 97.5, "30d": 99.12},
"down_since": NOW - timedelta(minutes=12), "down_reason": "HTTP 503"}
up = {**_row(_mon("web", mid="m1"), "up", _check(ms=31)), "uptime": {"24h": 100.0, "7d": 99.96}}
page = await _widget(app, [down], [up], more=3)
assert "1 of 5 monitors down." in page
assert page.index(">api<") < page.index(">web<")
assert 'href="/monitors/m2"' in page and "went down" in page and "12m ago" in page and "HTTP 503" in page
assert page.count("<th>") == 5 and "<th>30d</th>" in page
assert "99.96%" in page and "31 ms" in page
assert ">—<" in page # web has no 30d yet: a dash, not 0
assert "+3 more up" in page
assert not hex_colours(page), hex_colours(page)
async def test_the_widget_says_when_all_is_well(app):
up = {**_row(_mon("web", mid="m1"), "up", _check()), "uptime": {"24h": 100.0}}
page = await _widget(app, [], [up])
assert "All 1 monitor up." in page and "tone-ok" in page