Files
FabledSteward/steward/templates/monitors/detail.html
T
bvandeusenandClaude Opus 5.5 6788ce60f1
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 53s
CI / unit (push) Successful in 1m19s
CI / publish (push) Successful in 1m20s
feat(monitors): a DNS monitor can name a resolver; the host page puts Ansible above Docker (#5214)
DNS monitors take an optional Resolver, a server's IP. Set, the check asks
that server directly for A and AAAA over UDP and records how long it took
to answer; empty keeps today's lookup through the container's resolver.
"Does my local DNS still answer for public names" and "does 1.1.1.1" are
now separate monitors. No new dependency.

On a host's page the Ansible card moves above the Docker list, which can
run to dozens of containers and hid it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:14:11 -04:00

143 lines
7.3 KiB
HTML

{% extends "base.html" %}
{% 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 %}
<a class="btn btn-sm btn-ghost" href="/monitors/{{ monitor.id }}/edit">Edit</a>
<form method="post" action="/monitors/{{ monitor.id }}/toggle">{{ csrf_field() }}
<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" 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 "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")) }}
{% endif %}
{% for window in ("24h", "7d", "30d") %}
{% set pct = uptime.get(window) %}
{{ stat_tile("Uptime " ~ window, "%.2f%%"|format(pct) if pct is not none else "—", state=status_class(pct, "uptime")) }}
{% endfor %}
{% if tls_days is not none %}
{{ stat_tile("TLS certificate", "expired" if tls_days < 0 else (tls_days|round|int) ~ " days", state="crit" if tls_days < 0 else status_class(tls_days, "tls_days")) }}
{% 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 30 checks</h2>
{% if heartbeat %}
<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; hover one for its result.{% if latest %} Last checked {{ latest.checked_at|ago }}.{% endif %}</p>
{% else %}
<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>
<section class="card" aria-labelledby="m-failures">
<h2 id="m-failures" class="section-title">Recent failures</h2>
{% if failures %}
<table class="table">
<thead><tr><th>When</th><th>Error</th>{% if monitor.type == "http" %}<th>Status</th>{% endif %}</tr></thead>
<tbody>
{% for f in failures %}
<tr>
<td style="white-space:nowrap;">{{ f.checked_at|ago }}</td>
<td style="overflow-wrap:anywhere;">{{ f.error_msg or "Down" }}</td>
{% if monitor.type == "http" %}<td>{{ f.status_code or "—" }}</td>{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<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>{% 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>{{ 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 %}
{% if monitor.type == "dns" and not monitor.config.get("resolver") %}
<dt>Resolver</dt><dd>the container's own (its resolv.conf)</dd>
{% endif %}
</dl>
</section>
{% endblock %}