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 %} -
| Monitor | Latency | 24h | 7d | 30d |
|---|---|---|---|---|
| + {{- 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 -%} + | + {% for k in ("24h", "7d", "30d") %}{% set u = d.uptime.get(k) %} +{% if u is none %}—{% else %}{{ "%.2f"|format(u) }}%{% endif %} | + {% endfor %} +