From b5fcccba5f5376b40077f732905aad8f6e7a93f6 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 7 Oct 2026 13:12:58 -0400 Subject: [PATCH] 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 --- steward/core/widgets.py | 18 +---- steward/hosts/routes.py | 14 ---- .../versions/0033_retire_uptime_widget.py | 46 +++++++++++ steward/monitors/routes.py | 12 +-- steward/monitors/service.py | 1 + steward/static/steward.css | 16 ++++ steward/templates/hosts/uptime_widget.html | 47 ----------- steward/templates/monitors/widget.html | 78 +++++++++---------- tests/core/test_dashboard_defaults.py | 10 +-- tests/core/test_monitors_page.py | 33 ++++++++ 10 files changed, 149 insertions(+), 126 deletions(-) create mode 100644 steward/migrations/versions/0033_retire_uptime_widget.py delete mode 100644 steward/templates/hosts/uptime_widget.html diff --git a/steward/core/widgets.py b/steward/core/widgets.py index 6fa6b09..57c2c60 100644 --- a/steward/core/widgets.py +++ b/steward/core/widgets.py @@ -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", diff --git a/steward/hosts/routes.py b/steward/hosts/routes.py index 50d30f9..cced3b9 100644 --- a/steward/hosts/routes.py +++ b/steward/hosts/routes.py @@ -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, - ) diff --git a/steward/migrations/versions/0033_retire_uptime_widget.py b/steward/migrations/versions/0033_retire_uptime_widget.py new file mode 100644 index 0000000..f520e09 --- /dev/null +++ b/steward/migrations/versions/0033_retire_uptime_widget.py @@ -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 diff --git a/steward/monitors/routes.py b/steward/monitors/routes.py index ed7efd1..244c3bb 100644 --- a/steward/monitors/routes.py +++ b/steward/monitors/routes.py @@ -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, ) diff --git a/steward/monitors/service.py b/steward/monitors/service.py index 474f55f..c081f3a 100644 --- a/steward/monitors/service.py +++ b/steward/monitors/service.py @@ -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, diff --git a/steward/static/steward.css b/steward/static/steward.css index b654537..b4290c9 100644 --- a/steward/static/steward.css +++ b/steward/static/steward.css @@ -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; } diff --git a/steward/templates/hosts/uptime_widget.html b/steward/templates/hosts/uptime_widget.html deleted file mode 100644 index af26dcf..0000000 --- a/steward/templates/hosts/uptime_widget.html +++ /dev/null @@ -1,47 +0,0 @@ -{# hosts/uptime_widget.html — dashboard widget: SLA uptime summary #} -{% if not hosts %} -
- No hosts configured. -
-{% else %} - -
-
- Host - 24h - 7d - 30d -
- {% for host in hosts %} - {% set ut = uptime.get(host.id, {}) %} -
- {% if session.user_id %} - {{ host.name }} - {% else %} - {{ host.name }} - {% endif %} - {% for window in ["24h", "7d", "30d"] %} - {% set pct = ut.get(window) %} - - {% if pct is none %} - — - {% 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') %} - {{ "%.1f"|format(pct) }}% - {% endif %} - - {% endfor %} -
- {% endfor %} -
- -{% if session.user_id %} -
- Full SLA → -
-{% endif %} - -{% endif %} diff --git a/steward/templates/monitors/widget.html b/steward/templates/monitors/widget.html index 37a23e2..c37a752 100644 --- a/steward/templates/monitors/widget.html +++ b/steward/templates/monitors/widget.html @@ -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 %} -
- No monitors configured. Add a monitor → +{# 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 %} +
+ No monitors configured.{% if session.user_id %} Add a monitor →{% endif %}
{% else %} - -
- {% if up %} -
{{ up }} - up
- {% endif %} - {% if down %} -
{{ down }} - down
- {% endif %} - {% if pending %} -
{{ pending }} - pending
- {% endif %} +
+ {%- 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 -%}
- -
- {% for d in monitor_data %} - {% set latest = d.latest %} -
- {% if not latest %} - {% elif latest.is_up %} - {% else %}{% endif %} - {% if d.monitor.host_id %} - {{ d.monitor.name }} - {% else %} - {{ d.monitor.name }} - {% endif %} - {% if latest and latest.response_ms is not none %} - {{ "%.0f"|format(latest.response_ms) }}ms - {% elif latest and not latest.is_up %} - {{ latest.status_code or "down" }} - {% endif %} -
+{% if problems or rest %} + + + + + {% for d in problems + rest %} + {% set m = d.monitor %}{% set latest = d.latest %} + + + + {% for k in ("24h", "7d", "30d") %}{% set u = d.uptime.get(k) %} + + {% endfor %} + {% endfor %} - - + +
MonitorLatency24h7d30d
+ {{- status_dot(d.state) -}} + {% if session.user_id %}{{ m.name }}{% else %}{{ m.name }}{% endif %} + {%- if d.state == 'down' %}went down {{ d.down_since|ago if d.down_since else '' }}{% if d.down_reason %} · {{ d.down_reason }}{% endif %} + {%- elif d.state == 'degraded' %}{% if latest and status_class(latest.response_ms, 'latency') == 'crit' %}slow replies{% else %}checks failing now and then{% endif %} + {%- elif d.state == 'pending' %}not checked yet{% endif %} + + {%- if latest and latest.is_up and latest.response_ms is not none %}{{ "%.0f"|format(latest.response_ms) }} ms{% else %}—{% endif -%} + {% if u is none %}—{% else %}{{ "%.2f"|format(u) }}%{% endif %}
+{% endif %} +{% if more %}
+{{ more }} more up{% if session.user_id %} · all monitors{% endif %}
{% endif %} {% endif %} diff --git a/tests/core/test_dashboard_defaults.py b/tests/core/test_dashboard_defaults.py index 9fe3e7f..66afb01 100644 --- a/tests/core/test_dashboard_defaults.py +++ b/tests/core/test_dashboard_defaults.py @@ -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 diff --git a/tests/core/test_monitors_page.py b/tests/core/test_monitors_page.py index 3e5ef34..b64adf4 100644 --- a/tests/core/test_monitors_page.py +++ b/tests/core/test_monitors_page.py @@ -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 · 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("") == 5 and "30d" 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