feat(monitors): problems first, a page per monitor, adding on its own page (#5121)
The Monitors list now opens with a count strip (down, up with how many degraded, pending, paused) and sorts down, degraded, pending, up, paused, by name within each. Degraded is up but worth a look: uptime at or below its warning floor, or the latest response past its critical cutoff, both from Settings → Thresholds; a slow-but-tolerable reply is coloured, not sorted. A monitor had no page of its own: every link went to the list. Now /monitors/<id> shows its state and since when, latency, uptime for 24h/7d/ 30d, TLS days, the last checks, recent failures with their errors, and its settings (HTTP header names, never their values). Status links to it, and adding or editing returns to it. Adding is its own page (/monitors/new, form.html shared with edit) behind an "Add monitor" button; a refused add re-renders the form with the error and what was typed instead of a bare 400 text. Row actions are labelled icon buttons, type names read Ping/TCP/DNS/HTTP (|monitor_type), and the heartbeat uses pill-up/pill-down, so HEX_CEILING drops to 56. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
{% 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. #}
|
||||
{% 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") }}
|
||||
<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") }}
|
||||
</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>
|
||||
{% 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) %}
|
||||
{{ 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>
|
||||
|
||||
<section class="card" aria-labelledby="m-checks">
|
||||
<h2 id="m-checks" class="section-title">Last checks</h2>
|
||||
{% if heartbeat %}
|
||||
<div class="ping-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>
|
||||
{% else %}
|
||||
{{ empty_state("Not checked yet. The first check runs on the next scheduler tick." if monitor.enabled else "Paused, and never checked.") }}
|
||||
{% 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 %}
|
||||
{{ empty_state("No failed checks on record.") }}
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="card" aria-labelledby="m-config">
|
||||
<h2 id="m-config" class="section-title">Settings</h2>
|
||||
<dl class="kv">
|
||||
<dt>Checked every</dt>
|
||||
<dd>{{ monitor.check_interval_seconds|duration if monitor.check_interval_seconds else "the global interval" }}</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>
|
||||
<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 %}
|
||||
</dl>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -1,47 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import crumbs %}
|
||||
{% from "monitors/_fields.html" import type_fields, toggle_script %}
|
||||
{% block title %}Edit Monitor — Steward{% endblock %}
|
||||
{% block breadcrumb %}{{ crumbs([("Monitors", "/monitors/"), ("Edit monitor", "")]) }}{% endblock %}
|
||||
{% block content %}
|
||||
<div style="margin-bottom:1.25rem;">
|
||||
<h1 class="page-title" style="margin-bottom:0;">Edit Monitor</h1>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="card" style="max-width:760px;">
|
||||
<form method="post" action="/monitors/{{ monitor.id }}/edit" style="display:grid;gap:0.6rem;">{{ csrf_field() }}
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:0.6rem;align-items:end;">
|
||||
<div>
|
||||
<label class="field-label">Type</label>
|
||||
<input type="text" value="{{ monitor.type | upper }}" disabled class="input">
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label">Name</label>
|
||||
<input type="text" name="name" value="{{ monitor.name }}" autocomplete="off" class="input">
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label">Target <span style="color:var(--red);">*</span></label>
|
||||
<input type="text" name="target" value="{{ monitor.target }}" autocomplete="off" required class="input">
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label">Interval (s, 0=global)</label>
|
||||
<input type="number" name="check_interval_seconds" value="{{ monitor.check_interval_seconds }}" min="0" class="input">
|
||||
</div>
|
||||
{{ type_fields(config) }}
|
||||
</div>
|
||||
{% if monitor.host_id %}
|
||||
<p style="font-size:0.78rem;color:var(--text-dim);margin:0;">
|
||||
Linked to <a href="/hosts/{{ monitor.host_id }}" style="color:var(--accent);">its host</a>.
|
||||
</p>
|
||||
{% endif %}
|
||||
<div style="display:flex;gap:0.5rem;">
|
||||
<button type="submit" class="btn btn-sm">Save</button>
|
||||
<a href="/monitors/" class="btn btn-sm" style="background:transparent;border:1px solid var(--border-mid);">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{ toggle_script() }}
|
||||
<script>mtoggle({{ monitor.type | tojson }});</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,66 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import crumbs, page_header %}
|
||||
{% from "monitors/_fields.html" import type_fields, toggle_script %}
|
||||
{# Add (monitor is none; `values` holds what was typed) or edit a monitor. #}
|
||||
{% block title %}{{ "Edit " ~ monitor.name if monitor else "Add monitor" }} — Steward{% endblock %}
|
||||
{% block breadcrumb %}
|
||||
{%- if monitor -%}
|
||||
{{ crumbs([("Monitors", "/monitors/"), (monitor.name, "/monitors/" ~ monitor.id), ("Edit", "")]) }}
|
||||
{%- else -%}
|
||||
{{ crumbs([("Monitors", "/monitors/"), ("Add monitor", "")]) }}
|
||||
{%- endif -%}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
{% set v = values or {} %}
|
||||
{{ page_header("Edit monitor" if monitor else "Add monitor") }}
|
||||
|
||||
<div class="card" style="max-width:760px;">
|
||||
{% if error %}<p class="field-error" role="alert">{{ error }}</p>{% endif %}
|
||||
<form method="post" action="{{ '/monitors/' ~ monitor.id ~ '/edit' if monitor else '/monitors/add' }}" style="display:grid;gap:0.6rem;">{{ csrf_field() }}
|
||||
{% if not monitor and v.host_id %}<input type="hidden" name="host_id" value="{{ v.host_id }}">{% endif %}
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:0.6rem;align-items:end;">
|
||||
<div>
|
||||
<label class="field-label" for="mtype">Type</label>
|
||||
{% if monitor %}
|
||||
<input type="text" id="mtype" value="{{ monitor.type|monitor_type }}" disabled class="input">
|
||||
{% else %}
|
||||
<select name="type" id="mtype" class="input">
|
||||
{% for t in types %}
|
||||
<option value="{{ t.value }}" {% if t.value == mtype %}selected{% endif %}>{{ t.value|monitor_type }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% endif %}
|
||||
</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">
|
||||
</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>
|
||||
<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) }}
|
||||
</div>
|
||||
{% if monitor and monitor.host_id %}
|
||||
<p class="field-hint">Linked to <a href="/hosts/{{ monitor.host_id }}">its host</a>.</p>
|
||||
{% endif %}
|
||||
<div style="display:flex;gap:0.5rem;">
|
||||
<button type="submit" class="btn btn-sm">{{ "Save" if monitor else "Add monitor" }}</button>
|
||||
<a href="{{ '/monitors/' ~ monitor.id if monitor else '/monitors/' }}" class="btn btn-sm btn-ghost">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{ toggle_script() }}
|
||||
<script>
|
||||
(function () {
|
||||
var sel = document.getElementById('mtype');
|
||||
mtoggle({{ mtype|tojson }});
|
||||
if (sel.tagName === 'SELECT') sel.addEventListener('change', function () { mtoggle(sel.value); });
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,58 +1,17 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "monitors/_fields.html" import type_fields, toggle_script %}
|
||||
{% from "_macros.html" import page_header %}
|
||||
{% block title %}Monitors — Steward{% endblock %}
|
||||
{% block content %}
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;gap:1rem;flex-wrap:wrap;">
|
||||
<div style="display:flex;align-items:baseline;gap:1rem;">
|
||||
<h1 class="page-title" style="margin-bottom:0;">Monitors</h1>
|
||||
<span style="color:var(--text-muted);font-size:0.85rem;">polling every {{ poll_interval }}s</span>
|
||||
</div>
|
||||
{% call page_header("Monitors", "Checked every " ~ poll_interval ~ "s. Problems first.") %}
|
||||
{% include "_time_range.html" %}
|
||||
{% if session.user_role in ['operator', 'admin'] %}<a class="btn btn-sm" href="/monitors/new">Add monitor</a>{% endif %}
|
||||
{% endcall %}
|
||||
|
||||
<div id="monitor-rows"
|
||||
hx-get="/monitors/rows"
|
||||
hx-trigger="load, every {{ poll_interval }}s, rangeChange from:body"
|
||||
hx-include="#time-range"
|
||||
hx-swap="innerHTML">
|
||||
<div class="card"><p class="field-hint">Loading…</p></div>
|
||||
</div>
|
||||
|
||||
|
||||
{# ── Add monitor ──────────────────────────────────────────────────────────── #}
|
||||
<div class="card" style="margin-bottom:1.25rem;">
|
||||
<div class="section-title" style="margin-bottom:0.75rem;">Add Monitor</div>
|
||||
<form method="post" action="/monitors/add" style="display:grid;gap:0.6rem;">{{ csrf_field() }}
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:0.6rem;align-items:end;">
|
||||
<div>
|
||||
<label class="field-label">Type</label>
|
||||
<select name="type" id="mtype" onchange="mtoggle(this.value)" class="input">
|
||||
{% for t in types %}
|
||||
<option value="{{ t.value }}" {% if t.value == 'icmp' %}selected{% endif %}>{{ t.value | upper }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label">Name</label>
|
||||
<input type="text" name="name" placeholder="optional" autocomplete="off" class="input">
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label">Target <span style="color:var(--red);">*</span></label>
|
||||
<input type="text" name="target" placeholder="host, IP, or URL" autocomplete="off" required class="input">
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label">Interval (s, 0=global)</label>
|
||||
<input type="number" name="check_interval_seconds" value="0" min="0" class="input">
|
||||
</div>
|
||||
{{ type_fields() }}
|
||||
</div>
|
||||
<div><button type="submit" class="btn btn-sm">Add Monitor</button></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{# ── Live monitor rows ────────────────────────────────────────────────────── #}
|
||||
<div class="card ping-card">
|
||||
<div id="monitor-rows"
|
||||
hx-get="/monitors/rows"
|
||||
hx-trigger="load, every {{ poll_interval }}s, rangeChange from:body"
|
||||
hx-include="#time-range"
|
||||
hx-swap="innerHTML">
|
||||
<div style="color:var(--text-muted);font-size:0.9rem;padding:2rem;">Loading…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ toggle_script() }}
|
||||
<script>mtoggle(document.getElementById('mtype').value);</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,74 +1,69 @@
|
||||
{# HTMX fragment — monitor rows (list page). #}
|
||||
{# HTMX fragment — the Monitors list: counts, then every monitor, problems first
|
||||
(service.problems_first). Colours come from Settings → Thresholds. #}
|
||||
{% from "_macros.html" import stat_tile, status_badge, status_dot, icon_button, empty_state %}
|
||||
<div class="stat-strip">
|
||||
{{ stat_tile("Down", counts.down, state="down" if counts.down else "inactive") }}
|
||||
{{ stat_tile("Up", counts.up, state="up" if counts.up else "inactive",
|
||||
hint=(counts.degraded ~ " degraded") if counts.degraded else none) }}
|
||||
{{ stat_tile("Pending", counts.pending, state="pending" if counts.pending else "inactive") }}
|
||||
{{ stat_tile("Paused", counts.paused, state="paused") }}
|
||||
</div>
|
||||
|
||||
{% if monitor_data %}
|
||||
<div class="card ping-card">
|
||||
{% set can_edit = session.user_role in ['operator', 'admin'] %}
|
||||
{% for d in monitor_data %}
|
||||
{% set m = d.monitor %}
|
||||
{% set latest = d.latest %}
|
||||
<div class="ping-row" {% if not m.enabled %}style="opacity:0.5;"{% endif %}>
|
||||
<div class="ping-meta" style="min-width:160px;max-width:240px;">
|
||||
<span class="badge" style="font-size:0.62rem;vertical-align:middle;">{{ m.type | upper }}</span>
|
||||
{% if m.host_id %}
|
||||
<a class="ping-name" href="/hosts/{{ m.host_id }}" title="{{ m.name }}"
|
||||
style="color:inherit;text-decoration:none;">{{ m.name }}</a>
|
||||
{% else %}
|
||||
<span class="ping-name" title="{{ m.name }}">{{ m.name }}</span>
|
||||
{% endif %}
|
||||
<span class="ping-addr" title="{{ m.target }}">{{ m.target }}</span>
|
||||
<div class="ping-row{% if d.state == 'paused' %} ping-row-paused{% endif %}">
|
||||
<div class="ping-meta" style="min-width:160px;max-width:260px;">
|
||||
<div style="display:flex;align-items:baseline;gap:0.4rem;">
|
||||
{{ status_dot(d.state) }}
|
||||
<a class="ping-name" href="/monitors/{{ m.id }}">{{ m.name }}</a>
|
||||
</div>
|
||||
<span class="ping-addr" title="{{ m.target }}">{{ m.type|monitor_type }} · {{ m.target }}</span>
|
||||
</div>
|
||||
|
||||
<div class="ping-pills">
|
||||
{% for _ in range([30 - d.heartbeat|length, 0]|max) %}
|
||||
<span class="pill pill-empty" title="No data"></span>
|
||||
{% endfor %}
|
||||
{% for h in d.heartbeat %}
|
||||
<span class="pill" title="{{ h.title }}"
|
||||
style="background:{{ '#1a6632' if h.state == 'up' else '#6a1515' }};"></span>
|
||||
{% endfor %}
|
||||
{% for _ in range([30 - d.heartbeat|length, 0]|max) %}<span class="pill pill-empty" title="No data"></span>{% endfor %}
|
||||
{% for h in d.heartbeat %}<span class="pill pill-{{ h.state }}" title="{{ h.title }}"></span>{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="ping-cur">
|
||||
{% if latest is none %}
|
||||
<span class="ping-cur-nd">—</span>
|
||||
{% elif not latest.is_up %}
|
||||
<span class="ping-cur-down">DOWN</span>
|
||||
{% if d.state in ('down', 'paused', 'pending') %}{{ status_badge(d.state) }}
|
||||
{% elif latest.response_ms is not none %}
|
||||
<span style="{{ threshold_style(latest.response_ms, 'latency') or 'color:var(--green);' }}">
|
||||
{{ "%.0f"|format(latest.response_ms) }} ms</span>
|
||||
{% elif m.type == 'dns' and latest.resolved_ip %}
|
||||
<span class="ping-cur-good" style="font-size:0.72rem;">{{ latest.resolved_ip }}</span>
|
||||
{% else %}
|
||||
<span class="ping-cur-good">UP</span>
|
||||
{% endif %}
|
||||
<span class="tone-{{ status_class(latest.response_ms, 'latency') }}">{{ "%.0f"|format(latest.response_ms) }} ms</span>
|
||||
{% elif m.type == 'dns' and latest.resolved_ip %}<span class="tone-ok">{{ latest.resolved_ip }}</span>
|
||||
{% else %}{{ status_badge('up') }}{% endif %}
|
||||
</div>
|
||||
|
||||
<div style="min-width:4.5rem;text-align:right;font-size:0.78rem;font-variant-numeric:tabular-nums;{{ threshold_style(d.uptime_pct, 'uptime') if d.uptime_pct is not none else 'color:var(--text-dim);' }}">
|
||||
{% if d.uptime_pct is not none %}{{ "%.1f"|format(d.uptime_pct) }}%{% else %}—{% endif %}
|
||||
<span style="color:var(--text-dim);font-size:0.7rem;margin-left:0.15rem;">{{ range_key }}</span>
|
||||
<div style="min-width:4.5rem;text-align:right;font-size:0.78rem;font-variant-numeric:tabular-nums;">
|
||||
{% if d.uptime_pct is not none %}<span class="tone-{{ status_class(d.uptime_pct, 'uptime') }}">{{ "%.1f"|format(d.uptime_pct) }}%</span>{% else %}<span class="tone-unknown">—</span>{% endif %}
|
||||
<span class="tone-dim" style="font-size:0.7rem;margin-left:0.15rem;">{{ range_key }}</span>
|
||||
</div>
|
||||
|
||||
{% if d.tls_days is not none %}
|
||||
<div title="TLS certificate expiry" style="min-width:4rem;text-align:right;font-size:0.72rem;{{ 'color:var(--red);' if d.tls_days < 14 else ('color:var(--yellow);' if d.tls_days < 30 else 'color:var(--text-dim);') }}">
|
||||
cert {{ d.tls_days|round|int }}d
|
||||
<div style="min-width:4rem;text-align:right;font-size:0.72rem;">
|
||||
{% if d.tls_days < 0 %}<span class="tone-crit" title="Certificate expired">cert expired</span>
|
||||
{% else %}<span class="tone-{{ status_class(d.tls_days, 'tls_days') }}" title="TLS certificate expires in {{ d.tls_days|round|int }} days">cert {{ d.tls_days|round|int }}d</span>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if session.user_role in ['operator', 'admin'] %}
|
||||
<div style="display:flex;gap:0.35rem;flex-shrink:0;">
|
||||
<a href="/monitors/{{ m.id }}/edit" class="btn btn-sm" style="padding:0.2rem 0.5rem;font-size:0.72rem;">Edit</a>
|
||||
<form method="post" action="/monitors/{{ m.id }}/toggle" style="display:inline;">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-sm" style="padding:0.2rem 0.5rem;font-size:0.72rem;">
|
||||
{{ "Pause" if m.enabled else "Resume" }}</button>
|
||||
{% if can_edit %}
|
||||
<div class="row-actions">
|
||||
{{ icon_button("✎", "Edit " ~ m.name, href="/monitors/" ~ m.id ~ "/edit") }}
|
||||
<form method="post" action="/monitors/{{ m.id }}/toggle">{{ csrf_field() }}
|
||||
{{ icon_button("⏸" if m.enabled else "▶", ("Pause " if m.enabled else "Resume ") ~ m.name, type="submit") }}
|
||||
</form>
|
||||
<form method="post" action="/monitors/{{ m.id }}/delete" style="display:inline;"
|
||||
data-confirm="Delete monitor {{ m.name }}?">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-sm" style="padding:0.2rem 0.5rem;font-size:0.72rem;color:var(--red);">✕</button>
|
||||
<form method="post" action="/monitors/{{ m.id }}/delete">{{ csrf_field() }}
|
||||
{{ icon_button("×", "Delete " ~ m.name, type="submit", confirm="Delete monitor " ~ m.name ~ "?") }}
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p style="color:var(--text-muted);font-size:0.9rem;padding:1rem 0;">
|
||||
No monitors yet. Add one above, or attach checks to a host from the
|
||||
<a href="/hosts/" style="color:var(--accent);">Hosts</a> page.
|
||||
</p>
|
||||
<div class="card">{{ empty_state("No monitors yet. Add one, or attach checks to a host from its page.",
|
||||
"Add monitor" if session.user_role in ['operator', 'admin'] else none, "/monitors/new") }}</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{# monitors/widget.html — dashboard widget: unified monitor summary #}
|
||||
{% if not monitor_data and up == 0 and down == 0 and pending == 0 %}
|
||||
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">
|
||||
No monitors configured. <a href="/monitors/">Add monitors →</a>
|
||||
No monitors configured. <a href="/monitors/new">Add a monitor →</a>
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user