feat(ui): audit, alert rules and the rule form say things in words (#5212)
#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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user