feat(dashboard): a curated default layout, merged duplicate widgets, a labelled Overview (#5213)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 50s
CI / unit (push) Failing after 1m16s
CI / publish (push) Skipped

- New installs start from widgets.RECOMMENDED, packed row by row for the
  enabled plugins; the edit drawer can reset a dashboard to it.
- Host Agent — Resources folds into Hosts — Overview (which gains load 1m),
  UniFi — Devices into UniFi — Overview; migration 0032 moves existing
  dashboards onto the survivors.
- The Overview labels what it counts (hosts, monitors up/down/pending,
  alerts firing) and lists firing alerts and down monitors with since-times.
- Uptime polls every 5 minutes (poll_seconds) instead of every cycle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 00:42:04 -04:00
co-authored by Claude Opus 5.5
parent 5ec930bf76
commit 5492336f1e
18 changed files with 381 additions and 309 deletions
+108
View File
@@ -0,0 +1,108 @@
"""The dashboard starts from a curated layout, the duplicate widgets are gone,
and the Overview says what each number counts (#5213)."""
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from quart import render_template, session
from steward.core.status import StatusEntry
from steward.core.widgets import RECOMMENDED, RETIRED, WIDGET_REGISTRY, recommended_layout
from steward.status.routes import overview_alerts
from tests.test_style_tokens import hex_colours
ALL_ON = {p: {"enabled": True} for p in ("docker", "host_agent", "traefik", "unifi", "snmp")}
def _overlap(a, b):
return (a["x"] < b["x"] + b["w"] and b["x"] < a["x"] + a["w"]
and a["y"] < b["y"] + b["h"] and b["y"] < a["y"] + a["h"])
def test_retired_widgets_point_at_the_one_that_replaced_them():
for old, new in RETIRED.items():
assert old not in WIDGET_REGISTRY and new in WIDGET_REGISTRY
assert all(key in WIDGET_REGISTRY for key, *_ in RECOMMENDED)
def test_the_recommended_layout_fills_rows_without_overlap():
items = recommended_layout(ALL_ON)
assert [i["key"] for i in items] == [key for key, *_ in RECOMMENDED]
assert items[0] == {"key": "status_overview", "x": 0, "y": 0, "w": 12, "h": 3, "config": {}}
assert (items[1]["x"], items[1]["y"], items[2]["x"], items[2]["y"]) == (0, 3, 6, 3)
for i, a in enumerate(items):
assert a["x"] + a["w"] <= 12
assert not any(_overlap(a, b) for b in items[i + 1:]), a
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)
def test_overview_alerts_put_the_breaching_first():
from steward.models.alerts import AlertStateEnum
ack = SimpleNamespace(state=AlertStateEnum.acknowledged, last_transition_at=None, no_data_since=None)
silent = SimpleNamespace(state=AlertStateEnum.inactive, last_transition_at=None,
no_data_since=datetime(2026, 10, 7, tzinfo=timezone.utc))
firing = SimpleNamespace(state=AlertStateEnum.firing, last_transition_at=datetime(2026, 10, 6, tzinfo=timezone.utc),
no_data_since=None)
rows = overview_alerts([(ack, "r-ack"), (silent, "r-silent"), (firing, "r-firing")])
assert [(r["rule"], r["label"]) for r in rows] == [
("r-firing", "firing"), ("r-silent", "no_data"), ("r-ack", "acknowledged")]
assert rows[1]["since"] == silent.no_data_since # silent since the series stopped
assert rows[0]["since"] == firing.last_transition_at
async def test_the_overview_labels_its_counts_and_lists_what_is_wrong(app):
now = datetime.now(timezone.utc)
rule = SimpleNamespace(id="r1", name="nas disk full")
alerts = [{"rule": rule, "label": "firing", "since": now - timedelta(minutes=12)}]
entries = [
StatusEntry(kind="http", key="1", name="web", status="up", detail_url="/monitors/1"),
StatusEntry(kind="tcp", key="2", name="ssh-nas", status="down", detail_url="/monitors/2",
since=now - timedelta(hours=2)),
]
async with app.test_request_context("/status/widget"):
session["user_id"] = "u1"
page = await render_template("status/widget.html", entries=entries, up=1, down=1, pending=0,
total_hosts=3, alerts=alerts, widget_id="1")
assert ">Monitors<" in page and ">Alerts<" in page and ">firing<" in page and ">hosts<" in page
assert '<span class="badge badge-crit">Firing</span>' in page
assert 'href="/alerts/history?rule=r1"' in page and "12m ago" in page
assert '<span class="badge badge-crit">Down</span>' in page and "2h ago" in page
assert page.index("nas disk full") < page.index("ssh-nas") # alerts, then monitors
assert ">pending<" not in page # a zero pending count is not shown
assert not hex_colours(page), hex_colours(page)
async def test_a_quiet_overview_says_so(app):
async with app.test_request_context("/status/widget"):
page = await render_template("status/widget.html", entries=[], up=0, down=0, pending=0,
total_hosts=1, alerts=[], widget_id="1")
assert "No monitors configured, and no alert is firing." in page
assert ">host<" in page and 'href="/alerts/"' not in page # the share view has no links
async def test_the_hosts_widget_shows_load(app):
host = SimpleNamespace(id="h1", name="web-1")
agent = {"web-1": {"cpu_pct": 12.0, "mem_used_pct": 40.0, "load_1m": 0.734, "fresh": True}}
async with app.test_request_context("/hosts/overview/widget"):
page = await render_template("hosts/overview_widget.html", hosts=[host], host_status={},
uptime={}, agent=agent, cpu_sparks={})
assert ">load</span> 0.73" in page
async def test_a_widget_polls_at_its_own_pace(app):
defn = {**WIDGET_REGISTRY["uptime_summary"]}
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")
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
defn.pop("poll_seconds")
async with app.test_request_context("/"):
page = await render_template("dashboard/_grid_item.html", item=item, can_edit=False, poll_interval=30)
assert "every 30s" in page
+69
View File
@@ -0,0 +1,69 @@
"""Integration: "Reset to the recommended layout" replaces a dashboard's
widgets with widgets.RECOMMENDED, and only for someone who may edit it
(#5213), against real Postgres."""
from __future__ import annotations
import asyncio
import os
import time
import uuid
import pytest
pytestmark = pytest.mark.integration
@pytest.fixture
def app():
if not os.environ.get("STEWARD_DATABASE_URL"):
pytest.skip("needs Postgres")
from steward.app import create_app
return create_app(testing=False)
async def _client(app, role):
from steward.models.users import User, UserRole
uid, tag = str(uuid.uuid4()), uuid.uuid4().hex[:8]
async with app.db_sessionmaker() as s:
async with s.begin():
s.add(User(id=uid, username=f"reset-{tag}", email=f"reset-{tag}@test",
password_hash="x", role=UserRole(role), is_active=True))
client = app.test_client()
async with client.session_transaction() as sess:
sess.update(user_id=uid, user_role=role, username=f"reset-{tag}",
_csrf="test-token", login_at=time.time())
return client, uid
CSRF = {"X-CSRF-Token": "test-token"}
def test_reset_puts_back_the_recommended_layout_for_its_owner_only(app):
from sqlalchemy import select
from steward.core.widgets import recommended_layout
from steward.models.dashboard import Dashboard, DashboardWidget
async def _go():
owner, owner_id = await _client(app, "operator")
other, _ = await _client(app, "operator")
async with app.db_sessionmaker() as s:
async with s.begin():
dash = Dashboard(name="mine", owner_id=owner_id)
s.add(dash)
await s.flush()
s.add(DashboardWidget(dashboard_id=dash.id, widget_key="monitors",
grid_x=3, grid_y=9, grid_w=2, grid_h=2))
dash_id = dash.id
assert (await other.post(f"/d/{dash_id}/edit/reset", headers=CSRF)).status_code == 403
resp = await owner.post(f"/d/{dash_id}/edit/reset", headers=CSRF)
assert resp.status_code == 302 and resp.headers["Location"].endswith(f"/d/{dash_id}")
async with app.db_sessionmaker() as s:
rows = (await s.execute(select(DashboardWidget).where(DashboardWidget.dashboard_id == dash_id)
.order_by(DashboardWidget.grid_y, DashboardWidget.grid_x))).scalars().all()
want = recommended_layout(app.config.get("PLUGINS", {}))
assert [(w.widget_key, w.grid_x, w.grid_y, w.grid_w, w.grid_h) for w in rows] == \
[(i["key"], i["x"], i["y"], i["w"], i["h"]) for i in want]
asyncio.run(_go())
+1 -1
View File
@@ -52,7 +52,7 @@ def test_every_route_says_who_may_reach_it():
def test_the_scan_sees_routes():
names = {n.name for _, text in _sources() for n, _ in route_views(text)}
assert {"login", "health", "ingest", "widget_table", "public_share"} <= names
assert {"login", "health", "ingest", "widget_history", "public_share"} <= names
@pytest.mark.asyncio