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)
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:
@@ -114,6 +114,7 @@ async def detail(monitor_id: str):
|
||||
return redirect(url_for("monitors.index"))
|
||||
st = (await monitor_svc.monitor_states(db, [m]))[m.id]
|
||||
failures = await monitor_svc.recent_failures(db, m.id)
|
||||
history = await monitor_svc.response_history(db, m.id, datetime.now(timezone.utc))
|
||||
host = await db.get(Host, m.host_id) if m.host_id else None
|
||||
thresholds = current_app.config.get("THRESHOLDS", {})
|
||||
latest = st["latest"]
|
||||
@@ -129,6 +130,7 @@ async def detail(monitor_id: str):
|
||||
since=since, since_at_least=since_at_least, uptime=st["uptime"], tls_days=tls_days,
|
||||
heartbeat=[{"state": "up" if r.is_up else "down", "title": _heartbeat_title(m.type, r)}
|
||||
for r in st["recent"]],
|
||||
history=history, poll_interval=current_app.config.get("MONITORS_POLL_INTERVAL", 60),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
@@ -255,6 +255,26 @@ async def monitor_states(db, monitors: list[Monitor]) -> dict[str, dict]:
|
||||
return out
|
||||
|
||||
|
||||
async def response_history(db, monitor_id: str, now: datetime, hours: int = 24,
|
||||
points: int = 96) -> list[dict]:
|
||||
"""The monitor's response time and failed checks over the last `hours`,
|
||||
in about `points` buckets, oldest first: [{at (epoch ms), ms, down}].
|
||||
`ms` is the bucket's average (None when no check returned a time, as for
|
||||
DNS); `down` counts the failed checks in it."""
|
||||
width = max(60, hours * 3600 // points)
|
||||
bucket = func.date_bin(func.make_interval(0, 0, 0, 0, 0, 0, width), MonitorResult.checked_at,
|
||||
func.to_timestamp(0)).label("bucket")
|
||||
rows = (await db.execute(
|
||||
select(bucket, func.avg(MonitorResult.response_ms),
|
||||
func.count().filter(MonitorResult.is_up.is_(False)))
|
||||
.where(MonitorResult.monitor_id == monitor_id,
|
||||
MonitorResult.checked_at >= now - timedelta(hours=hours))
|
||||
.group_by(bucket).order_by(bucket)
|
||||
)).all()
|
||||
return [{"at": int(at.timestamp() * 1000), "ms": None if ms is None else round(float(ms), 1),
|
||||
"down": int(down)} for at, ms, down in rows]
|
||||
|
||||
|
||||
async def recent_failures(db, monitor_id: str, limit: int = 10) -> list[MonitorResult]:
|
||||
"""The monitor's latest failed checks, newest first."""
|
||||
return list((await db.execute(
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
script (mtoggle) shows only the ones relevant to the selected type. #}
|
||||
{% macro type_fields(config={}) %}
|
||||
|
||||
{# What each type checks, shown under the row for the selected type. #}
|
||||
<p class="field-hint mfield" data-types="icmp" style="grid-column:1/-1;margin:0;">
|
||||
<strong>Ping</strong> sends an ICMP echo to the target, a hostname or IP. Up when it answers; the round trip is the latency.</p>
|
||||
<p class="field-hint mfield" data-types="tcp" style="grid-column:1/-1;margin:0;">
|
||||
<strong>TCP</strong> opens a connection to the target on the port below (22 for SSH, 443 for HTTPS, …). Up when the port accepts; the connect time is the latency. Nothing is sent.</p>
|
||||
<p class="field-hint mfield" data-types="http" style="grid-column:1/-1;margin:0;">
|
||||
<strong>HTTP</strong> requests the target URL (http:// or https://). Up when the status matches, and the page contains the text below if you set one. For https, the certificate's expiry is tracked too.</p>
|
||||
<p class="field-hint mfield" data-types="dns" style="grid-column:1/-1;margin:0;">
|
||||
<strong>DNS</strong> looks the target name up (google.com, nas.lan, …) through the resolver Steward's container uses. Up when it resolves, and to the expected IP if you set one. No response time is recorded.</p>
|
||||
|
||||
{# tcp #}
|
||||
<div class="mfield" data-types="tcp">
|
||||
<label class="field-label">Port</label>
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import crumbs, page_header, stat_tile, status_badge, icon_button, empty_state %}
|
||||
{# One monitor: its state and since when, latency and uptime against Settings →
|
||||
Thresholds, its last checks, and the failures behind them. #}
|
||||
{% from "_macros.html" import crumbs, page_header, stat_tile %}
|
||||
{# One monitor: its state and for how long, latency and uptime against
|
||||
Settings → Thresholds, its response time over the last day, its last
|
||||
checks, the failures behind them and how it is set up. #}
|
||||
{% block title %}{{ monitor.name }} — Monitors — Steward{% endblock %}
|
||||
{% block breadcrumb %}{{ crumbs([("Monitors", "/monitors/"), (monitor.name, "")]) }}{% endblock %}
|
||||
{% block content %}
|
||||
{% set can_edit = session.user_role in ['operator', 'admin'] %}
|
||||
{% call page_header(monitor.name, (monitor.type|monitor_type) ~ " · " ~ monitor.target) %}
|
||||
{% if can_edit %}
|
||||
{{ icon_button("✎", "Edit " ~ monitor.name, href="/monitors/" ~ monitor.id ~ "/edit") }}
|
||||
<a class="btn btn-sm btn-ghost" href="/monitors/{{ monitor.id }}/edit">Edit</a>
|
||||
<form method="post" action="/monitors/{{ monitor.id }}/toggle">{{ csrf_field() }}
|
||||
{{ icon_button("⏸" if monitor.enabled else "▶", ("Pause " if monitor.enabled else "Resume ") ~ monitor.name, type="submit") }}
|
||||
<button type="submit" class="btn btn-sm btn-ghost">{{ "Pause" if monitor.enabled else "Resume" }}</button>
|
||||
</form>
|
||||
<form method="post" action="/monitors/{{ monitor.id }}/delete">{{ csrf_field() }}
|
||||
{{ icon_button("×", "Delete " ~ monitor.name, type="submit", cls="btn-danger", confirm="Delete monitor " ~ monitor.name ~ "? Its check history goes with it.") }}
|
||||
<form method="post" action="/monitors/{{ monitor.id }}/delete" data-confirm="Delete monitor {{ monitor.name }}? Its check history goes with it.">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endcall %}
|
||||
|
||||
<div class="stat-strip">
|
||||
{% set since_hint = ((("for at least " if since_at_least else "since ") ~ since|ago) if since and state in ("up", "down", "degraded") else none) %}
|
||||
{% set since_hint = ((("for at least " if since_at_least else "for ") ~ (since|ago|striptags|replace(" ago", ""))) if since and state in ("up", "down", "degraded") else none) %}
|
||||
{{ stat_tile("State", status_info(state)[1], state=state, hint=since_hint) }}
|
||||
{% if latest and latest.response_ms is not none %}
|
||||
{{ stat_tile("Latency", "%.0f ms"|format(latest.response_ms), state=status_class(latest.response_ms, "latency")) }}
|
||||
@@ -33,16 +34,69 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% set timed = history | selectattr("ms", "ne", none) | list %}
|
||||
{% if timed|length > 1 %}
|
||||
<section class="card" aria-labelledby="m-chart">
|
||||
<h2 id="m-chart" class="section-title">Response time · last 24h</h2>
|
||||
<div style="position:relative;height:180px;" id="m-chart-box">
|
||||
<canvas id="m-chart-canvas" aria-label="Response time over the last 24 hours, failed checks in red"></canvas>
|
||||
</div>
|
||||
<p class="field-hint">Average per {{ ((24 * 60) // 96) }} minutes, your local time. Red bars mark failed checks.</p>
|
||||
</section>
|
||||
<script>
|
||||
(function () {
|
||||
var box = document.getElementById("m-chart-box"), ctx = document.getElementById("m-chart-canvas");
|
||||
if (!window.Chart) { box.innerHTML = '<p class="field-hint">The chart library didn\'t load.</p>'; return; }
|
||||
var pts = {{ history | tojson }};
|
||||
var hhmm = function (ms) { return new Date(ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); };
|
||||
var maxMs = Math.max.apply(null, pts.map(function (p) { return p.ms || 0; }));
|
||||
var chart = new Chart(ctx.getContext("2d"), {
|
||||
data: {
|
||||
labels: pts.map(function (p) { return p.at; }),
|
||||
datasets: [
|
||||
{ type: "line", label: "Response (ms)", data: pts.map(function (p) { return p.ms; }),
|
||||
borderColor: stewardColor("--blue"), backgroundColor: stewardColor("--blue-dim"), fill: true,
|
||||
tension: 0.3, pointRadius: 0, borderWidth: 1.5, spanGaps: false, yAxisID: "y" },
|
||||
{ type: "bar", label: "Failed checks", data: pts.map(function (p) { return p.down ? maxMs : null; }),
|
||||
backgroundColor: stewardColor("--red"), barPercentage: 1, categoryPercentage: 1, yAxisID: "y" },
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false, animation: false,
|
||||
interaction: { mode: "index", intersect: false },
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: { callbacks: {
|
||||
title: function (items) { return items.length ? hhmm(pts[items[0].dataIndex].at) : ""; },
|
||||
label: function (item) {
|
||||
var p = pts[item.dataIndex];
|
||||
return item.datasetIndex === 0 ? (p.ms == null ? "no response time" : p.ms + " ms")
|
||||
: p.down + " failed check" + (p.down === 1 ? "" : "s");
|
||||
},
|
||||
} },
|
||||
},
|
||||
scales: {
|
||||
x: { ticks: { font: { size: 10 }, maxRotation: 0, autoSkip: true, maxTicksLimit: 8,
|
||||
callback: function (v) { return hhmm(pts[v].at); } }, grid: { display: false } },
|
||||
y: { min: 0, ticks: { font: { size: 10 } }, title: { display: true, text: "ms", font: { size: 9 } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
setTimeout(function () { if (ctx.isConnected) { chart.resize(); chart.update("none"); } }, 250);
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
<section class="card" aria-labelledby="m-checks">
|
||||
<h2 id="m-checks" class="section-title">Last checks</h2>
|
||||
<h2 id="m-checks" class="section-title">Last 30 checks</h2>
|
||||
{% if heartbeat %}
|
||||
<div class="ping-pills" style="margin:0.5rem 0;">
|
||||
<div class="ping-pills mon-pills" style="margin:0.5rem 0;">
|
||||
{% for _ in range([30 - heartbeat|length, 0]|max) %}<span class="pill pill-empty" title="No data"></span>{% endfor %}
|
||||
{% for h in heartbeat %}<span class="pill pill-{{ h.state }}" title="{{ h.title }}"></span>{% endfor %}
|
||||
</div>
|
||||
<p class="field-hint">Newest on the right.{% if latest %} Last checked {{ latest.checked_at|ago }}.{% endif %}</p>
|
||||
<p class="field-hint">Newest on the right; hover one for its result.{% if latest %} Last checked {{ latest.checked_at|ago }}.{% endif %}</p>
|
||||
{% else %}
|
||||
{{ empty_state("Not checked yet. The first check runs on the next scheduler tick." if monitor.enabled else "Paused, and never checked.") }}
|
||||
<p class="field-hint">{{ "Not checked yet. The first check runs on the next scheduler tick." if monitor.enabled else "Paused, and never checked." }}</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
@@ -62,19 +116,21 @@
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
{{ empty_state("No failed checks on record.") }}
|
||||
<p class="field-hint">No failed checks on record.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="card" aria-labelledby="m-config">
|
||||
<h2 id="m-config" class="section-title">Settings</h2>
|
||||
<dl class="kv">
|
||||
<dt>Type</dt><dd>{{ monitor.type|monitor_type }}</dd>
|
||||
<dt>Target</dt><dd style="overflow-wrap:anywhere;">{{ monitor.target }}</dd>
|
||||
<dt>Checked every</dt>
|
||||
<dd>{{ monitor.check_interval_seconds|duration if monitor.check_interval_seconds else "the global interval" }}</dd>
|
||||
<dd>{% if monitor.check_interval_seconds %}{{ monitor.check_interval_seconds|duration }}{% else %}{{ poll_interval|duration }} <span class="tone-dim">(the global interval, Settings)</span>{% endif %}</dd>
|
||||
{% if host %}<dt>Host</dt><dd><a href="/hosts/{{ host.id }}">{{ host.name }}</a></dd>{% endif %}
|
||||
{% for key, value in monitor.config.items() %}
|
||||
{% if value not in (none, "", {}) %}
|
||||
<dt>{{ key|replace("_", " ")|capitalize }}</dt>
|
||||
<dt>{{ field_label(key) }}</dt>
|
||||
<dd style="overflow-wrap:anywhere;">{{ value|join(", ") if value is mapping else ("yes" if value is sameas true else ("no" if value is sameas false else value)) }}</dd>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
@@ -33,14 +33,14 @@
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label" for="m-name">Name</label>
|
||||
<input type="text" id="m-name" name="name" value="{{ monitor.name if monitor else v.name }}" placeholder="optional" autocomplete="off" class="input">
|
||||
<input type="text" id="m-name" name="name" value="{{ monitor.name if monitor else v.name }}" placeholder="defaults to the target" autocomplete="off" class="input">
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label" for="m-target">Target <span class="tone-crit" aria-hidden="true">*</span></label>
|
||||
<input type="text" id="m-target" name="target" value="{{ monitor.target if monitor else v.target }}" placeholder="host, IP, or URL" autocomplete="off" required class="input">
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label" for="m-interval">Interval (s, 0 = global)</label>
|
||||
<label class="field-label" for="m-interval" title="0 uses the global interval set in Settings">Check every (s, 0 = global)</label>
|
||||
<input type="number" id="m-interval" name="check_interval_seconds" value="{{ monitor.check_interval_seconds if monitor else (v.check_interval_seconds or 0) }}" min="0" class="input">
|
||||
</div>
|
||||
{{ type_fields(config) }}
|
||||
|
||||
@@ -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 <api>" 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 <api>"' in page
|
||||
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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}]
|
||||
Reference in New Issue
Block a user