"""The Monitors page leads with what needs looking at (#5121): a count strip, problems first, each monitor on a page of its own, adding behind a button.""" import re from datetime import datetime, timedelta, timezone from types import SimpleNamespace import pytest from quart import render_template, session from steward.core.settings import to_thresholds_cfg from steward.monitors.service import problems_first, row_state from tests.test_style_tokens import hex_colours TH = to_thresholds_cfg({}) # latency warn 50 / crit 200 ms; uptime warn 99 / crit 95 % NOW = datetime.now(timezone.utc) def _mon(name, mtype="http", enabled=True, mid=None): return SimpleNamespace(id=mid or name, name=name, type=mtype, target=f"https://{name}/", enabled=enabled, host_id=None, check_interval_seconds=0, config={}) def _check(up=True, ms=20.0, ago_min=1): return SimpleNamespace(is_up=up, response_ms=ms, resolved_ip=None, status_code=200 if up else 503, error_msg=None if up else "503 Service Unavailable", tls_expires_at=None, checked_at=NOW - timedelta(minutes=ago_min)) @pytest.mark.parametrize("enabled, latest, uptime, state", [ (False, _check(False), 50.0, "paused"), # paused wins over everything (True, None, None, "pending"), (True, _check(False), 100.0, "down"), (True, _check(ms=20), 100.0, "up"), (True, _check(ms=120), 100.0, "up"), # slow but tolerable: coloured, not sorted (True, _check(ms=250), 100.0, "degraded"), # past the latency critical cutoff (True, _check(ms=20), 98.0, "degraded"), # uptime under its warning floor (True, _check(ms=20), None, "up"), # no uptime yet is not a problem ]) def test_row_state(enabled, latest, uptime, state): assert row_state(_mon("m", enabled=enabled), latest, uptime, TH) == state def test_problems_come_first_then_by_name(): rows = [{"monitor": _mon(n), "state": s} for n, s in [ ("alpha", "up"), ("Zed", "down"), ("beta", "paused"), ("gamma", "degraded"), ("delta", "pending"), ("able", "down")]] assert [r["monitor"].name for r in problems_first(rows)] == [ "able", "Zed", "gamma", "delta", "alpha", "beta"] def _row(m, state, latest, uptime=None, tls_days=None): return {"monitor": m, "state": state, "latest": latest, "uptime": {"24h": uptime} if uptime is not None else {}, "tls_days": tls_days, "spark": "", "heartbeat": [{"state": "up" if latest.is_up else "down", "title": "t"}] if latest else []} async def test_the_list_counts_then_shows_problems_first(app): data = problems_first([ _row(_mon("web", mid="m1"), "up", _check(ms=30), 100.0, tls_days=2.0), _row(_mon("api", mid="m2"), "down", _check(False), 90.0), _row(_mon("nas", "icmp", mid="m3", enabled=False), "paused", None), ]) counts = {"up": 1, "degraded": 0, "down": 1, "pending": 0, "paused": 1} async with app.test_request_context("/monitors/rows"): session["user_role"] = "operator" page = await render_template("monitors/rows.html", monitor_data=data, counts=counts) assert page.index('Down') < page.index("ping-row") assert page.index('href="/monitors/m2"') < page.index('href="/monitors/m1"') < page.index('href="/monitors/m3"') assert "HTTP · https://web/" in page and "Ping · https://nas/" in page assert '30 ms' in page assert '90.00%' in page and '100.00%' in page assert 'class="tone-crit" title="TLS certificate expires in 2 days">2d' in page # One header row names every column (#5270, absorbing the Status page). head = page[page.index("mon-head"):page.index('href="/monitors/m2"')] for label in ("Monitor", "Last 30 checks", "Latest", "24h", "7d", "30d", "TLS"): assert f">{label}<" in head, label assert 'Down' in page and 'Paused' in page assert 'aria-label="Delete api"' in page and 'data-confirm="Delete monitor api?"' in page assert "onclick" not in page and not hex_colours(page), hex_colours(page) async def test_a_viewer_gets_no_row_actions_and_an_empty_board_says_so(app): async with app.test_request_context("/monitors/rows"): page = await render_template("monitors/rows.html", monitor_data=[_row(_mon("web"), "up", _check())], counts={"up": 1, "degraded": 0, "down": 0, "pending": 0, "paused": 0}) empty = await render_template("monitors/rows.html", monitor_data=[], counts=dict.fromkeys(("up", "degraded", "down", "pending", "paused"), 0)) assert "row-actions" not in page assert ">TLS<" not in page # no monitor has a certificate: no column of dashes assert "No monitors yet" in empty and "/monitors/new" not in empty async def test_the_monitor_page_says_since_when_and_why(app): m = _mon("shop ", mid="m9") m.config = {"method": "GET", "expected_status": 200, "headers": {"Authorization": "s3cr3t-token"}, "verify_ssl": True} down = _check(False, ms=None) async with app.test_request_context("/monitors/m9"): session["user_role"] = "admin" page = await render_template( "monitors/detail.html", monitor=m, host=None, latest=down, failures=[down], state="down", since=datetime.now(timezone.utc) - timedelta(minutes=12), since_at_least=False, tls_days=None, uptime={"24h": 93.0, "7d": None, "30d": 99.5}, heartbeat=[{"state": "up", "title": "a"}, {"state": "down", "title": "b"}], history=[{"at": 1, "ms": 20.0, "down": 0}, {"at": 2, "ms": 30.0, "down": 0}, {"at": 3, "ms": None, "down": 3}], poll_interval=60) assert 'Monitors' in page and "shop <api>" in page assert ">for 12m<" in page # how long, not "… ago" assert "Response time · last 24h" in page and '"down": 3' in page assert "Expected status" in page and "1m Edit" in page and ">Pause" in page assert not hex_colours(page), hex_colours(page) async def test_adding_is_its_own_page_and_keeps_what_was_typed(app): async with app.test_request_context("/monitors/new"): session["user_role"] = "operator" page = await render_template( "monitors/form.html", monitor=None, mtype="tcp", config={"port": "22"}, types=[], values={"name": "db", "target": "10.0.0.9", "host_id": "", "check_interval_seconds": "30"}, error="port must be between 1 and 65535, got 0.") assert 'Monitors' in page and 'action="/monitors/add"' in page for kind in ("Ping", "TCP", "HTTP", "DNS"): # each type says what it checks assert f"{kind}" in page assert '' in page assert 'value="db"' in page and 'value="10.0.0.9"' in page and 'value="30"' in page def test_down_reason_says_why_in_a_few_words(): from steward.monitors.service import down_reason assert down_reason(_check(False)) == "503 Service Unavailable" # the probe's own error first assert down_reason(SimpleNamespace(error_msg=None, status_code=502, content_matched=None)) == "HTTP 502" assert down_reason(SimpleNamespace(error_msg=None, status_code=None, content_matched=False)) == \ "expected content not found" assert down_reason(None) is None async def test_a_down_monitor_says_since_when_and_why_in_the_list(app): row = _row(_mon("api", mid="m2"), "down", _check(False), 90.0) row.update(down_since=datetime.now(timezone.utc) - timedelta(minutes=12), down_reason="connection refused") async with app.test_request_context("/monitors/rows"): page = await render_template("monitors/rows.html", monitor_data=[row], counts={"up": 0, "degraded": 0, "down": 1, "pending": 0, "paused": 0}) 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": datetime.now(timezone.utc) - 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 async def test_a_pending_monitor_says_when_it_was_added(app): """#5810: a monitor not yet checked says how long it has waited.""" m = _mon("new-api", mid="m7") m.created_at = datetime.now(timezone.utc) - timedelta(minutes=6) pending = {**_row(m, "pending", None), "uptime": {}} page = await _widget(app, [pending], [], counts={"up": 0, "degraded": 0, "down": 0, "pending": 1, "paused": 0}) page = " ".join(page.split()) assert "not checked yet · added