feat(monitors): a monitor's page reads as a page — response time over 24h, labelled actions, how long, not "ago"; the add form says what each type checks (#5214)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m3s
CI / unit (push) Successful in 1m22s
CI / publish (push) Successful in 1m22s

Monitor page: a response-time chart for the last day with failed checks
marked; Edit / Pause / Delete as labelled buttons; the state tile says
"for 29m" instead of "for at least 29m 25s ago"; the check strip fills
its card; no failures is one line, not an empty box; Settings names the
type, target and the actual global interval, with readable field names.

Add monitor: a line under the fields says what the selected type checks
and when it counts as up; Name says it defaults to the target.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 15:57:09 -04:00
co-authored by Claude Opus 5.5
parent a7b280aed9
commit c6fc02def5
7 changed files with 162 additions and 21 deletions
+10 -3
View File
@@ -101,13 +101,18 @@ async def test_the_monitor_page_says_since_when_and_why(app):
"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"}])
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 &lt;api&gt;" in page
assert "since <time" in page and ">12m ago</time>" 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 'aria-label="Delete shop &lt;api&gt;"' in page
assert 'data-confirm="Delete monitor shop &lt;api&gt;? 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)
@@ -119,6 +124,8 @@ async def test_adding_is_its_own_page_and_keeps_what_was_typed(app):
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
+46
View File
@@ -0,0 +1,46 @@
"""Integration: a monitor's response-time history averages its checks per
bucket and counts the failed ones (#5214). Two checks share a bucket and must
merge (lesson #5262). Requires STEWARD_DATABASE_URL."""
from __future__ import annotations
import asyncio
import os
import uuid
from datetime import datetime, timedelta, timezone
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)
def test_response_history_averages_a_bucket_and_counts_its_failures(app):
from steward.models.monitors import Monitor, MonitorResult
from steward.monitors.service import response_history
now = datetime.now(timezone.utc)
width = 24 * 3600 // 96 # 15 minutes
start = datetime.fromtimestamp((int(now.timestamp()) // width - 2) * width, timezone.utc)
mid = str(uuid.uuid4())
def r(secs, up, ms):
return MonitorResult(monitor_id=mid, checked_at=start + timedelta(seconds=secs), is_up=up, response_ms=ms)
async def go():
async with app.db_sessionmaker() as s:
async with s.begin():
s.add(Monitor(id=mid, name=f"m-{mid[:8]}", type="tcp", target="10.9.9.9"))
async with s.begin():
s.add_all([r(10, True, 10.0), r(70, True, 30.0), r(130, False, None)])
async with s.begin():
return await response_history(s, mid, now)
got = asyncio.run(go())
assert got == [{"at": int(start.timestamp() * 1000), "ms": 20.0, "down": 1}]