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
+71 -15
View File
@@ -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 %}