feat(ui): audit, alert rules and the rule form say things in words (#5212)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 51s
CI / unit (push) Failing after 1m14s
CI / publish (push) Skipped

#5212 "Plain labels: no raw ids, scopes, metric keys or unexplained jargon",
batch 1:

- Audit log: each action reads as a sentence ("paused monitor"), with the
  code as its tooltip. Detail shows flags as yes/no, lists joined, a run's
  scope by name, spans as spans, and no ids the entity column already gives.
  The filter is now a picker of areas.
- Alert rules, history and Status say the condition in words: "is down",
  "Fullest disk >= 85%". Readings use metric_value, and history times use
  |ago.
- Rule form: sources and metrics by name, the threshold's unit, "fires
  when it is down/up" for an up/down metric instead of 0.5, and a metric
  reference built from the catalog.
- Schedules and Maintenance: viewers no longer see Run now, Pause, Edit,
  Delete or New. Intervals and next runs read as "every 2h" and "in 3h";
  a scope reads "Every alert on Host agent".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 00:23:02 -04:00
co-authored by Claude Opus 5.5
parent 7275fad767
commit 3a772716ae
21 changed files with 487 additions and 156 deletions
+5 -7
View File
@@ -3,12 +3,10 @@
{% from "_macros.html" import status_badge, scroll_more, scroll_end %}
{% for ev in events %}
<tr>
<td style="font-family:ui-monospace,monospace;font-size:0.82rem;white-space:nowrap;">
{{ ev.at.strftime("%Y-%m-%d %H:%M:%S") }}
</td>
<td style="font-size:0.82rem;white-space:nowrap;">{{ ev.at|ago }}</td>
<td>
<a href="/alerts/history?rule={{ ev.rule.id }}&range={{ current_range }}">{{ ev.rule.name }}</a>
<div style="font-size:0.78rem;color:var(--text-dim);">{{ ev.rule.source_module }}/{{ ev.rule.resource_name }}</div>
<div style="font-size:0.78rem;color:var(--text-dim);">{{ source_label(ev.rule.source_module) }} · {{ ev.rule.resource_name }}</div>
</td>
<td style="white-space:nowrap;">
{{ status_badge(ev.from_state) }}
@@ -18,9 +16,9 @@
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.25rem;white-space:normal;">{{ n }}</div>
{% endfor %}
</td>
<td style="font-family:ui-monospace,monospace;font-size:0.85rem;white-space:nowrap;">
{{ ev.rule.metric_name }} = {{ ev.value }}
<div style="font-size:0.75rem;color:var(--text-dim);">rule: {{ ev.condition }}</div>
<td style="font-size:0.85rem;white-space:nowrap;">
{{ ev.value }}
<div style="font-size:0.75rem;color:var(--text-dim);">fires when {{ ev.condition }}</div>
</td>
<td style="font-size:0.82rem;">
{% if ev.muted_by %}
+2 -2
View File
@@ -6,10 +6,10 @@
<table class="table">
<thead>
<tr>
<th>When (UTC)</th>
<th>When</th>
<th>Rule</th>
<th>Change</th>
<th>Value</th>
<th>Reading</th>
<th>Notified</th>
<th>Action</th>
</tr>
+12 -7
View File
@@ -1,6 +1,7 @@
{# alerts/_rule_fields.html — HTMX partial: resource + metric selects for a given source_module #}
{# alerts/_rule_fields.html — the resource and metric pickers for one source;
the rule form includes it and swaps it in again when the source changes. #}
<div class="form-group">
<label>Resource Name</label>
<label>Resource</label>
{% if resources %}
<select name="resource_name" required>
{% if not current_resource %}<option value="" disabled selected>— pick a resource —</option>{% endif %}
@@ -10,20 +11,24 @@
</select>
{% else %}
<select name="resource_name" required disabled>
<option value="">No data yet — run monitors first</option>
<option value="">Nothing has reported from this source yet</option>
</select>
<p class="field-hint">
Resource names populate from live metric data. Start the scheduler and try again.
The list fills from readings this source has recorded. Come back once it has reported.
</p>
{% endif %}
</div>
<div class="form-group">
<label>Metric Name</label>
<select name="metric_name" required>
<label>Metric</label>
<select name="metric_name" required data-metric-picker>
{% if not current_metric %}<option value="" disabled selected>— pick a metric —</option>{% endif %}
{% for m in metrics %}
<option value="{{ m }}" {% if m == current_metric %}selected{% endif %}>{{ m }}</option>
<option value="{{ m }}" data-unit="{{ metric_unit(m) }}" data-about="{{ metric_about(m) }}" {% if m in ("is_up", "is_healthy") %}data-binary{% endif %}
{% if m == current_metric %}selected{% endif %}>{{ metric_label(m) }}</option>
{% endfor %}
</select>
{% if current_metric and metric_about(current_metric) %}
<p class="field-hint" data-metric-about>{{ metric_about(current_metric) }}</p>
{% else %}<p class="field-hint" data-metric-about hidden></p>{% endif %}
</div>
+2 -4
View File
@@ -44,10 +44,8 @@
{%- else %}<span class="tone-ok" style="font-size:0.8rem;" title="Within its threshold">quiet</span>{% endif -%}
</td>
<td style="color:var(--text-muted);font-size:0.85rem;">
<span style="color:var(--text-dim);">{{ rule.source_module }}/</span>{{ rule.resource_name }}
<span style="font-family:ui-monospace,monospace;font-size:0.8rem;">
:{{ rule.metric_name }} {{ rule.operator.value }} {{ rule.threshold }}
</span>
{{ rule.resource_name }} {{ rule_condition(rule) }}
<div style="font-size:0.75rem;color:var(--text-dim);">{{ source_label(rule.source_module) }}</div>
</td>
<td style="color:var(--text-muted);text-align:center;">{{ rule.consecutive_failures_required }} / {{ rule.consecutive_ok_required }}</td>
<td class="td-actions">
+7 -4
View File
@@ -3,8 +3,9 @@
{% block title %}Maintenance Windows — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Alerts", "/alerts/"), ("Maintenance", "")]) }}{% endblock %}
{% block content %}
{% set can_edit = session.user_role in ('operator', 'admin') %}
{% call page_header("Maintenance windows", "During an active window, alerts in its scope still change state and show on the Alerts page, but send no notifications and run no Ansible actions. Each event a window mutes says so in the alert's history. An alert that is still firing when the window ends notifies then, and runs its action.") %}
<a class="btn" href="/alerts/maintenance/new">New window</a>
{% if can_edit %}<a class="btn" href="/alerts/maintenance/new">New window</a>{% endif %}
{% endcall %}
{% macro window_table(rows, kind) %}
@@ -28,9 +29,9 @@
{% if w.scope_source is none %}
<span title="All alert rules">All alerts</span>
{% elif w.scope_resource is none %}
<span>{{ w.scope_source }}/*</span>
<span>Every alert on {{ source_label(w.scope_source) }}</span>
{% else %}
<span>{{ w.scope_source }}/{{ w.scope_resource }}</span>
<span>{{ w.scope_resource }}</span> <span class="field-hint">{{ source_label(w.scope_source) }}</span>
{% endif %}
</td>
<td style="font-size:0.85rem;font-family:ui-monospace,monospace;">
@@ -45,10 +46,12 @@
{% else %}{{ status_badge('inactive', 'Expired') }}<div class="field-hint">ended {{ w.end_at|ago }}</div>{% endif %}
</td>
<td class="td-actions">
{% if session.user_role in ('operator', 'admin') %}
<form method="post" action="/alerts/maintenance/{{ w.id }}/delete" style="display:inline;">{{ csrf_field() }}
<button type="submit" class="btn btn-sm btn-danger"
data-confirm="Delete window &quot;{{ w.name }}&quot;?">Delete</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
@@ -76,6 +79,6 @@
</details>
{% endif %}
{% else %}
<div class="card">{{ empty_state("No maintenance windows yet.", "New window", "/alerts/maintenance/new") }}</div>
<div class="card">{% if can_edit %}{{ empty_state("No maintenance windows yet.", "New window", "/alerts/maintenance/new") }}{% else %}{{ empty_state("No maintenance windows yet.") }}{% endif %}</div>
{% endif %}
{% endblock %}
+48 -85
View File
@@ -24,68 +24,49 @@
hx-target="#rule-fields"
hx-include="[name='source_module']">
{% for src in source_modules %}
<option value="{{ src }}" {% if src == initial_source %}selected{% endif %}>
{{ src }}
</option>
<option value="{{ src }}" {% if src == initial_source %}selected{% endif %}>{{ source_label(src) }}</option>
{% endfor %}
</select>
</div>
{# ── Dynamic resource + metric ────────────────────────────────────────── #}
<div id="rule-fields">
{# Inline the same partial logic so the page is fully usable without JS #}
<div class="form-group">
<label>Resource Name</label>
{% if resources %}
<select name="resource_name" required>
{% if not (rule and rule.resource_name) %}<option value="" disabled selected>— pick a resource —</option>{% endif %}
{% for r in resources %}
<option value="{{ r }}" {% if rule and r == rule.resource_name %}selected{% endif %}>{{ r }}</option>
{% endfor %}
</select>
{% else %}
<select name="resource_name" required disabled>
<option value="">No data yet — run monitors first</option>
</select>
<p class="field-hint">
Resource names populate from live metric data. Start the scheduler and try again.
</p>
{% endif %}
</div>
<div class="form-group">
<label>Metric Name</label>
<select name="metric_name" required>
{% if not (rule and rule.metric_name) %}<option value="" disabled selected>— pick a metric —</option>{% endif %}
{% for m in metrics %}
<option value="{{ m }}" {% if rule and m == rule.metric_name %}selected{% endif %}>{{ m }}</option>
{% endfor %}
</select>
</div>
{% with current_resource = rule.resource_name if rule else "", current_metric = rule.metric_name if rule else "" %}
{% include "alerts/_rule_fields.html" %}
{% endwith %}
</div>
{# ── Condition ───────────────────────────────────────────────────────── #}
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
{# ── Condition: a threshold in the metric's unit, or for an up/down metric
the word it fires on (sent as binary_when; the route turns it into
< 0.5 or >= 0.5). The script swaps the two as the metric changes. #}
{% set m0 = rule.metric_name if rule else "" %}
{% set binary = m0 in ("is_up", "is_healthy") %}
{% set fires_down = not rule or rule.operator.value in ("<", "<=") %}
<div data-cond-binary {% if not binary %}hidden{% endif %} class="form-group">
<label>Fires when it is</label>
<select name="binary_when" {% if not binary %}disabled{% endif %}>
<option value="down" {% if fires_down %}selected{% endif %}>down (unhealthy)</option>
<option value="up" {% if not fires_down %}selected{% endif %}>up (healthy)</option>
</select>
</div>
<div data-cond-numeric {% if binary %}hidden{% endif %} style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
<div class="form-group">
<label>Operator</label>
<select name="operator">
<label>Fires when the reading is</label>
<select name="operator" {% if binary %}disabled{% endif %}>
{% for val, label in operators %}
<option value="{{ val }}" {% if rule and rule.operator.value == val %}selected{% endif %}>
{{ label }}
</option>
<option value="{{ val }}" {% if rule and rule.operator.value == val %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Threshold</label>
<input type="number" name="threshold" step="any" required
value="{{ rule.threshold if rule else '' }}"
placeholder="0.5">
<label>Threshold <span data-threshold-unit style="color:var(--text-muted);font-weight:normal;">{% if metric_unit(m0) %}({{ metric_unit(m0) }}){% endif %}</span></label>
<input type="number" name="threshold" step="any" required {% if binary %}disabled{% endif %}
value="{{ rule.threshold if rule and not binary else '' }}" placeholder="90">
</div>
</div>
<div class="form-group">
<label>Consecutive failures before FIRING</label>
<label>Breaching checks in a row before it fires</label>
<input type="number" name="consecutive_failures_required" min="1"
value="{{ rule.consecutive_failures_required if rule else 1 }}">
</div>
@@ -114,44 +95,16 @@
{# ── Metric reference ────────────────────────────────────────────────── #}
<details style="margin-bottom:1rem;">
<summary style="cursor:pointer;font-size:0.82rem;color:var(--text-muted);user-select:none;">
Metric reference
What each metric measures
</summary>
<div style="margin-top:0.75rem;display:grid;gap:0.5rem;">
{% set descs = {
"icmp": [("is_up", "1.0 = reachable, 0.0 = unreachable"),
("response_ms", "round-trip latency in milliseconds")],
"tcp": [("is_up", "1.0 = port open, 0.0 = unreachable"),
("response_ms", "TCP connect time in milliseconds")],
"dns": [("is_up", "1.0 = resolved (and matches expected IP), 0.0 = failed"),
("response_ms", "resolution time in milliseconds")],
"traefik": [("request_rate", "requests/sec for this router"),
("error_rate_4xx_pct", "% of responses that are 4xx"),
("error_rate_5xx_pct", "% of responses that are 5xx"),
("latency_p50_ms", "median response time ms"),
("latency_p95_ms", "p95 response time ms"),
("latency_p99_ms", "p99 response time ms"),
("response_bytes_rate", "bytes/sec of responses"),
("cert_expiry_days", "days until TLS cert expires")],
"unifi": [("is_up", "1.0 = WAN up, 0.0 = down"),
("latency_ms", "WAN latency in ms"),
("total_clients", "number of connected clients")],
"docker": [("cpu_pct", "container CPU usage %"),
("mem_pct", "container memory usage %")],
"http": [("is_up", "1.0 = check passed, 0.0 = failed"),
("response_ms", "HTTP response time in ms")],
"host_agent": [("seconds_since_seen", "seconds since the agent last reported; > 180 = offline"),
("cpu_pct", "host CPU usage %"),
("mem_used_pct", "host memory usage %"),
("disk_used_pct_worst", "fullest disk, % used"),
("disk_used_pct", "one mount, % used (resource host:mount)")],
} %}
{% for src, pairs in descs.items() %}
{% for src, names in catalog.items() if names %}
<div>
<div style="font-size:0.78rem;font-weight:500;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:0.2rem;">{{ src }}</div>
{% for metric, desc in pairs %}
<div style="display:grid;grid-template-columns:160px 1fr;gap:0.5rem;font-size:0.78rem;padding:0.15rem 0;">
<code>{{ metric }}</code>
<span style="color:var(--text-muted);">{{ desc }}</span>
<div style="font-size:0.78rem;font-weight:500;color:var(--text-dim);margin-bottom:0.2rem;">{{ source_label(src) }}</div>
{% for m in names %}
<div style="display:grid;grid-template-columns:200px 1fr;gap:0.5rem;font-size:0.78rem;padding:0.15rem 0;">
<span>{{ metric_label(m) }}{% if metric_unit(m) %} <span style="color:var(--text-dim);">({{ metric_unit(m) }})</span>{% endif %}</span>
<span style="color:var(--text-muted);">{{ metric_about(m) }}</span>
</div>
{% endfor %}
</div>
@@ -210,19 +163,29 @@
</div>
{% endif %}
{% if not rule %}
<script>
// "Agent offline" is seconds_since_seen > 180 (the agent's stale window).
// The condition follows the metric: an up/down metric asks which word
// fires, any other a threshold in its unit. "Agent offline" is
// seconds_since_seen > 180 (the agent's stale window).
document.addEventListener("change", function (e) {
if (e.target.name !== "metric_name" || e.target.value !== "seconds_since_seen") return;
var form = e.target.form, threshold = form.elements["threshold"];
if (threshold.value === "") {
if (e.target.name !== "metric_name") return;
var form = e.target.form, opt = e.target.selectedOptions[0];
var binary = opt.hasAttribute("data-binary"), unit = opt.dataset.unit || "";
form.querySelector("[data-cond-binary]").hidden = !binary;
form.querySelector("[data-cond-numeric]").hidden = binary;
form.elements["binary_when"].disabled = !binary;
form.elements["operator"].disabled = binary;
form.elements["threshold"].disabled = binary;
form.querySelector("[data-threshold-unit]").textContent = unit ? "(" + unit + ")" : "";
var about = form.querySelector("[data-metric-about]");
if (about) { about.textContent = opt.dataset.about || ""; about.hidden = !opt.dataset.about; }
var threshold = form.elements["threshold"];
if (opt.value === "seconds_since_seen" && threshold.value === "") {
form.elements["operator"].value = ">";
threshold.value = "180";
}
});
</script>
{% endif %}
<div style="display:flex;gap:1rem;margin-top:1.5rem;">
<button type="submit" class="btn">{% if rule %}Save Changes{% else %}Create Rule{% endif %}</button>
+10 -5
View File
@@ -5,10 +5,11 @@
{% macro fmt_interval(secs) %}
{%- set m = {300: "every 5 min", 3600: "hourly", 21600: "every 6h", 43200: "every 12h", 86400: "daily", 604800: "weekly"} -%}
{{ m.get(secs, secs ~ "s") }}
{{ m.get(secs) or ("every " ~ (secs|duration)) }}
{% endmacro %}
{% block content %}
{% set can_edit = session.user_role in ('operator', 'admin') %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.25rem;gap:1rem;flex-wrap:wrap;">
<h1 class="page-title" style="margin-bottom:0;">Ansible Schedules</h1>
<div style="display:flex;gap:0.5rem;">
@@ -19,7 +20,7 @@
{# ── Existing schedules ───────────────────────────────────────────────────── #}
{% if not rows %}
<div class="card empty">No schedules yet. Create one below to run a playbook on a recurring interval.</div>
<div class="card empty">No schedules yet.{% if can_edit %} Create one below to run a playbook on a recurring interval.{% endif %}</div>
{% else %}
<div class="card-flush">
<table class="table">
@@ -46,7 +47,7 @@
{# With a run beside it, the last firing was skipped because that run was
still going (#5158); without one, the firing couldn't start a run. #}
<div style="color:{{ 'var(--text-muted)' if row.last_run else 'var(--red)' }};">
{{ s.last_error }} · {{ s.last_run_at.strftime("%m-%d %H:%M") }}
{{ s.last_error }} · {{ s.last_run_at|ago }}
</div>
{% elif not row.last_run %}
<span style="color:var(--text-dim);">never</span>
@@ -54,10 +55,11 @@
</td>
<td style="font-size:0.82rem;color:var(--text-muted);">
{% if not s.enabled %}<span style="color:var(--text-dim);">paused</span>
{% elif row.next_run %}{{ row.next_run.strftime("%m-%d %H:%M") }}
{% elif row.next_run %}{{ row.next_run|ago }}
{% else %}due now{% endif %}
</td>
<td class="td-actions" style="white-space:nowrap;">
{% if can_edit %}
<form method="post" action="/ansible/schedules/{{ s.id }}/run-now" style="display:inline;">{{ csrf_field() }}
<button class="btn btn-sm btn-ghost" type="submit">Run now</button>
</form>
@@ -69,6 +71,7 @@
data-confirm="Delete schedule {{ s.name }}?">{{ csrf_field() }}
<button class="btn btn-sm btn-danger" type="submit">Delete</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
@@ -77,7 +80,8 @@
</div>
{% endif %}
{# ── Create / edit form ───────────────────────────────────────────────────── #}
{# ── Create / edit form (operators and admins) ───────────────────────────── #}
{% if can_edit %}
{% set p = (editing.params or {}) if editing else {} %}
{% set saved_vars = p.get('extra_vars_map') or {} %}
{% set declared_names = (edit_variables or []) | map(attribute='name') | list %}
@@ -174,4 +178,5 @@
</div>
</form>
</div>
{% endif %}
{% endblock %}
+4 -7
View File
@@ -3,11 +3,10 @@
{% from "_macros.html" import scroll_more, scroll_end %}
{% for item in events %}
{% set e = item.event %}
{% set d = item.detail %}
<tr>
<td style="white-space:nowrap;" title="{{ e.timestamp.strftime('%Y-%m-%d %H:%M:%S UTC') }}">{{ e.timestamp|ago }}</td>
<td style="font-size:0.85rem;">{{ e.username }}</td>
<td><span class="mono">{{ e.action }}</span></td>
<td title="{{ e.action }}">{{ item.action or e.action }}</td>
<td>
{% if item.entity %}
<span class="field-hint">{{ e.entity_type|replace('_', ' ') }}</span>
@@ -16,13 +15,11 @@
{% elif e.entity_type %}<span class="field-hint">{{ e.entity_type|replace('_', ' ') }}</span>{% endif %}
</td>
<td style="font-size:0.8rem;color:var(--text-muted);max-width:340px;">
{% if d %}
{% for k, v in d.items() %}
<span style="color:var(--text-dim);">{{ k }}:</span>
<span class="mono">{{ v }}</span>
{% for f in item.facts %}
<span style="color:var(--text-dim);">{{ f.label }}</span>
{% if f.url %}<a href="{{ f.url }}">{{ f.text }}</a>{% else %}{{ f.text }}{% endif %}
{% if not loop.last %} &nbsp;·&nbsp; {% endif %}
{% endfor %}
{% endif %}
</td>
</tr>
{% endfor %}
+9 -9
View File
@@ -4,13 +4,13 @@
{% block content %}
{% call page_header("Audit log", "Who changed what, newest first.") %}
<form method="get" style="display:flex;gap:0.5rem;align-items:center;">
<input type="text" name="action" value="{{ action_filter }}"
placeholder="Filter by action…" aria-label="Filter by action"
style="width:200px;font-size:0.85rem;">
<button type="submit" class="btn btn-ghost btn-sm">Filter</button>
{% if action_filter %}
<a href="/audit/" class="btn btn-ghost btn-sm">Clear</a>
{% endif %}
<select name="action" aria-label="Show only" style="font-size:0.85rem;" onchange="this.form.submit()">
<option value="">Everything</option>
{% for prefix, label in areas %}
<option value="{{ prefix }}" {% if action_filter == prefix %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<noscript><button type="submit" class="btn btn-ghost btn-sm">Filter</button></noscript>
</form>
{% endcall %}
@@ -21,8 +21,8 @@
<tr>
<th>When</th>
<th>User</th>
<th>Action</th>
<th>Entity</th>
<th>Did</th>
<th>What</th>
<th>Detail</th>
</tr>
</thead>
+1 -1
View File
@@ -25,7 +25,7 @@
<li>
{{ status_badge('no_data' if state.no_data_since and state.state.value == 'inactive' else state.state) }}
<a href="/alerts/history?rule={{ rule.id }}">{{ rule.name }}</a>
<span class="problem-detail">{{ rule.resource_name }} · {{ rule.metric_name }} {{ rule.operator.value }} {{ rule.threshold }} · since {{ (state.no_data_since if state.state.value == 'inactive' else state.last_transition_at)|ago }}</span>
<span class="problem-detail">{{ rule.resource_name }} {{ rule_condition(rule) }} · since {{ (state.no_data_since if state.state.value == 'inactive' else state.last_transition_at)|ago }}</span>
</li>
{% endfor %}
</ul>