- 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>
195 lines
11 KiB
Python
195 lines
11 KiB
Python
"""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('<span class="stat-tile-label">Down</span>') < 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 '<span class="tone-ok">30 ms</span>' in page
|
|
assert '<span class="tone-crit">90.00%</span>' in page and '<span class="tone-ok">100.00%</span>' 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 '<span class="badge badge-crit">Down</span>' in page and '<span class="badge badge-dim">Paused</span>' 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 <api>", 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 '<a href="/monitors/">Monitors</a>' 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 <span" in page # the global interval, named
|
|
assert "503 Service Unavailable" in page
|
|
assert 'class="stat-tile stat-crit"' in page and "93.00%" in page
|
|
assert "Authorization" in page and "s3cr3t-token" not in page # header names, never their values
|
|
assert 'data-confirm="Delete monitor shop <api>? Its check history goes with it."' in page
|
|
assert ">Edit</a>" in page and ">Pause</button>" 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 '<a href="/monitors/">Monitors</a>' in page and 'action="/monitors/add"' in page
|
|
for kind in ("Ping", "TCP", "HTTP", "DNS"): # each type says what it checks
|
|
assert f"<strong>{kind}</strong>" in page
|
|
assert '<p class="field-error" role="alert">port must be between 1 and 65535, got 0.</p>' 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 <time [^>]*>12m</time> · 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("<th>") == 5 and "<th>30d</th>" 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 <time" in page and "6m ago" in page
|