Files
FabledSteward/tests/core/test_dashboard_defaults.py
bvandeusenandClaude Opus 5.5 b29b160baa
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m23s
CI / unit (push) Successful in 1m33s
CI / publish (push) Successful in 1m16s
feat(widgets): Overview verdict, pending monitors' age, fullest filesystems first (#5810)
- Overview: a verdict line when something is wrong ('1 alert firing, 1
  monitor down.'); its picker description now says what it lists (alerts,
  monitors down, anomalies). Host figures have their home in Hosts.
  Comments no longer name the Status page that was folded into Monitors.
- Monitors widget: a monitor not checked yet says when it was added.
- Host page: filesystems fullest first, by the worse of space and inodes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:11:37 -04:00

203 lines
11 KiB
Python

"""The dashboard starts from a curated layout, the duplicate widgets are gone,
and the Overview says what each number counts (#5213)."""
import re
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]
# The Overview and the Alerts side by side first (#5510), then the fleet.
assert items[0] == {"key": "status_overview", "x": 0, "y": 0, "w": 6, "h": 4, "config": {}}
assert items[1] == {"key": "alerts", "x": 6, "y": 0, "w": 6, "h": 4, "config": {}}
assert [(i["key"], i["x"], i["y"]) for i in items[2:4]] == [
("hosts_overview", 0, 4), ("monitors", 6, 4)]
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():
items = recommended_layout({})
assert [i["key"] for i in items] == ["status_overview", "alerts", "hosts_overview", "monitors"]
assert [(i["x"], i["y"]) for i in items] == [(0, 0), (6, 0), (0, 4), (6, 4)]
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
# #5810: a verdict says what is wrong before the list says where.
assert '<div class="w-verdict tone-crit">1 alert firing, 1 monitor down.</div>' in page
assert page.index("1 alert firing, 1 monitor down.") < page.index("nas disk full")
assert not hex_colours(page), hex_colours(page)
async def test_the_overviews_overflow_links_to_alerts_and_to_monitors(app):
"""#5510: what doesn't fit goes to its own list; /status/ is gone."""
now = datetime.now(timezone.utc)
alerts = [{"rule": SimpleNamespace(id=f"r{i}", name=f"rule {i}"), "label": "firing", "since": now}
for i in range(7)]
entries = [StatusEntry(kind="tcp", key=str(i), name=f"m{i}", status="down", detail_url=f"/monitors/{i}")
for i in range(10)]
async with app.test_request_context("/status/widget"):
session["user_id"] = "u1"
page = await render_template("status/widget.html", entries=entries, up=0, down=10, pending=0,
total_hosts=1, alerts=alerts, widget_id="1")
assert '<a href="/alerts/">+1 more alerts</a> · <a href="/monitors/">+2 more monitors</a>' in page
assert "/status/" not in page
async with app.test_request_context("/status/widget"):
page = await render_template("status/widget.html", entries=entries[:3], up=0, down=3, pending=0,
total_hosts=1, alerts=alerts, widget_id="1")
assert "+1 more alerts" in page and "more monitors" not in page
assert 'href="/alerts/"' not in page # the share view has no links
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
def _host(hid, name):
return SimpleNamespace(id=hid, name=name)
def test_hosts_sort_worst_first():
"""#5251: a down monitor, then a reading past critical, then a quiet agent,
then a warning, then the healthy, by name; a host with nothing last."""
from steward.core.settings import to_thresholds_cfg
from steward.hosts.service import host_glance
th = to_thresholds_cfg({})
hosts = [_host("a", "alpha"), _host("b", "busy"), _host("d", "dead"), _host("q", "quiet"),
_host("w", "warm"), _host("n", "nothing")]
went = datetime(2026, 10, 15, 9, 0, tzinfo=timezone.utc)
status = {"d": {"state": "down", "down_since": went}, "a": {"state": "up", "down_since": None}}
agent = {"alpha": {"cpu_pct": 10.0, "fresh": True}, "busy": {"cpu_pct": 99.0, "fresh": True},
"quiet": {"cpu_pct": 99.0, "fresh": False},
"warm": {"cpu_pct": 10.0, "load_per_core": 85, "fresh": True}}
rows = host_glance(hosts, status, agent, th)
assert [(r["host"].name, r["state"]) for r in rows] == [
("dead", "down"), ("busy", "crit"), ("quiet", "stale"), ("warm", "warn"),
("alpha", "up"), ("nothing", "unknown")]
assert rows[1]["worst"][0] == "CPU" and rows[3]["worst"][0] == "Load"
assert rows[2]["worst"] is None # a quiet agent's old figures aren't graded
assert rows[0]["down_since"] == went and rows[4]["down_since"] is None # #5810
async def test_a_down_host_says_for_how_long(app):
"""#5810: the problem row carries its since, as the Hosts list does."""
rows = [{"host": _host("d", "dead"), "state": "down", "worst": None, "agent": {},
"down_since": datetime.now(timezone.utc) - timedelta(hours=3)}]
async with app.test_request_context("/hosts/overview/widget"):
page = await render_template("hosts/overview_widget.html", rows=rows)
page = " ".join(page.split())
assert "a monitor is down for" in page and ">3h<" in page and " ago" not in page
async def test_the_hosts_widget_gives_a_verdict_and_load_against_cores(app):
from datetime import datetime, timezone
rows = [
{"host": _host("h2", "VDNT-Util01"), "state": "crit", "worst": ("CPU", 99.0, "crit"),
"agent": {"cpu_pct": 99.0, "mem_used_pct": 28.0, "disk_root": 52.0, "load_1m": 16.77,
"cores": 8, "load_per_core": 210, "fresh": True}},
{"host": _host("h1", "VDNT-Docker02"), "state": "up", "worst": None,
"agent": {"cpu_pct": 1.0, "mem_used_pct": 4.0, "disk_root": 44.0, "load_1m": 0.92,
"cores": 4, "load_per_core": 23, "fresh": True}},
{"host": _host("h3", "nas"), "state": "stale", "worst": None,
"agent": {"cpu_pct": 5.0, "fresh": False, "last_at": datetime(2026, 10, 7, tzinfo=timezone.utc)}},
]
async with app.test_request_context("/hosts/overview/widget"):
session["user_id"] = "u1"
page = await render_template("hosts/overview_widget.html", rows=rows)
assert "2 of 3 hosts need a look." in page
assert page.index("VDNT-Util01") < page.index("VDNT-Docker02")
assert "16.8 / 8 cores" in page and "0.9 / 4 cores" in page
assert "agent last reported" in page
assert len(re.findall(r"<th[ >]", page)) == 5 and ">ping<" not in page and "24h" not in page
assert not hex_colours(page), hex_colours(page)
async def test_one_busy_host_is_named_in_the_verdict(app):
rows = [{"host": _host("h2", "web-1"), "state": "crit", "worst": ("CPU", 99.0, "crit"),
"agent": {"cpu_pct": 99.0, "fresh": True}}]
async with app.test_request_context("/hosts/overview/widget"):
page = await render_template("hosts/overview_widget.html", rows=rows)
assert "web-1:" in page and "CPU high." in page and "tone-crit" in page
async def test_a_widget_polls_at_its_own_pace(app):
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="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
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
async def test_a_grid_item_carries_its_saved_layout(app):
"""#5249: the saved 12-column place and height, which the page fits to
content in view mode and restores in edit mode."""
item = SimpleNamespace(db=SimpleNamespace(id=7, grid_x=6, grid_y=3, grid_w=6, grid_h=5),
title="Uptime", defn={"poll": False, "detail_url": "/x"}, 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 'data-x="6" data-y="3"' in page and 'data-w="6" data-h="5"' in page
assert 'gs-x="6" gs-y="3"' in page