Files
FabledSteward/steward/templates/alerts/rules_form.html
T
bvandeusenandClaude Opus 5.5 a6c24299a2
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 45s
CI / unit (push) Successful in 1m8s
CI / publish (push) Successful in 1m1s
fix(alerts): acknowledgements hold until resolve; clear after N OK; rule changes reset (#5059)
#5059 "Alert state machine: acknowledgement holds until resolve; N
consecutive OK to resolve; editing, disabling or deleting a rule resets it".

- The state machine is now next_step(), a pure function of one sample. An
  acknowledged alert stays acknowledged while breached; before, the next
  breaching sample re-fired it. Firing and acknowledged alerts resolve after
  consecutive_ok_required OK checks in a row (new rule column, default 1;
  rule form, rules list and MCP create/update_alert_rule clear_after_checks).
- Changing what a rule watches (source, resource, metric, operator,
  threshold) or disabling it resets its alert to inactive; a firing or
  acknowledged one records a resolved event whose new note says why, and
  nobody is notified. Web and MCP edits share alerts.service.apply_rule_change.
- Migration 0028: consecutive_ok_required, consecutive_ok_count,
  alert_events.note, and metric_value nullable (a reset has no sample).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:32:17 -04:00

203 lines
11 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% from "_macros.html" import crumbs %}
{% block title %}{% if rule %}Edit Rule{% else %}New Alert Rule{% endif %} — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Alerts", "/alerts/"), ("Edit rule" if rule else "New rule", "")]) }}{% endblock %}
{% block content %}
<div style="max-width:600px;margin:2rem auto;">
<h1 class="page-title">{% if rule %}Edit Rule{% else %}New Alert Rule{% endif %}</h1>
<div class="card">
<form method="post" action="{% if rule %}/alerts/rules/{{ rule.id }}{% else %}/alerts/rules{% endif %}">{{ csrf_field() }}
<div class="form-group">
<label>Rule Name</label>
<input type="text" name="name" required autofocus
value="{{ rule.name if rule else '' }}"
placeholder="e.g. Home server down">
</div>
{# ── Source module ───────────────────────────────────────────────────── #}
<div class="form-group">
<label>Source</label>
<select name="source_module" required
hx-get="/alerts/api/rule_fields"
hx-trigger="change"
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>
{% 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 style="font-size:0.78rem;color:var(--text-muted);margin-top:0.25rem;">
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>
</div>
{# ── Condition ───────────────────────────────────────────────────────── #}
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
<div class="form-group">
<label>Operator</label>
<select name="operator">
{% for val, label in operators %}
<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">
</div>
</div>
<div class="form-group">
<label>Consecutive failures before FIRING</label>
<input type="number" name="consecutive_failures_required" min="1"
value="{{ rule.consecutive_failures_required if rule else 1 }}">
</div>
<div class="form-group">
<label>Consecutive OK checks before it clears</label>
<input type="number" name="consecutive_ok_required" min="1"
value="{{ rule.consecutive_ok_required if rule else 1 }}">
<p style="font-size:0.75rem;color:var(--text-muted);margin-top:0.2rem;">
A firing or acknowledged alert resolves only after this many checks in a row are back
within the threshold. An acknowledgement holds until then.</p>
</div>
{# ── Metric reference ────────────────────────────────────────────────── #}
<details style="margin-bottom:1rem;">
<summary style="cursor:pointer;font-size:0.82rem;color:var(--text-muted);user-select:none;">
Metric reference
</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", "fraction of 5xx responses (0–1)"),
("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")],
} %}
{% for src, pairs in descs.items() %}
<div>
<div style="font-size:0.78rem;font-weight:600;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>
{% endfor %}
</div>
{% endfor %}
</div>
</details>
{# ── Ansible action (admin only) ─────────────────────────────────────── #}
{% if session.get("user_role") == "admin" %}
{% set act = rule.ansible_action if rule and rule.ansible_action else None %}
<div style="margin:1.25rem 0;padding:1rem;border:1px solid var(--border-mid);border-radius:6px;background:var(--bg-elevated);">
<label style="display:flex;align-items:center;gap:0.5rem;font-weight:600;">
<input type="checkbox" name="ansible_enabled" {% if act %}checked{% endif %}>
On firing → run an Ansible playbook
</label>
<p style="font-size:0.78rem;color:var(--text-muted);margin:0.4rem 0 0.75rem;">
Admin only. Runs once when the alert transitions into FIRING.
<code>steward_alert_*</code> context vars (resource, metric, value…) are injected automatically.
</p>
<div class="form-group">
<label>Source</label>
<select name="ansible_source">
<option value="">— select source —</option>
{% for s in ansible_sources %}
<option value="{{ s }}" {% if act and act.source == s %}selected{% endif %}>{{ s }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Playbook <span style="color:var(--text-muted);font-weight:normal;">(path within source)</span></label>
<input type="text" name="ansible_playbook" placeholder="playbooks/shutdown.yml"
value="{{ act.playbook if act else '' }}">
</div>
<div class="form-group">
<label>Inventory <span style="color:var(--text-muted);font-weight:normal;">(path within source)</span></label>
<input type="text" name="ansible_inventory" placeholder="inventory/hosts"
value="{{ act.inventory if act else '' }}">
</div>
<div class="form-group">
<label>Extra vars <span style="color:var(--text-muted);font-weight:normal;">(optional, one key=value per line)</span></label>
<textarea name="ansible_extra_vars" rows="2"
style="width:100%;font-family:ui-monospace,monospace;font-size:0.85rem;">{{ act.extra_vars | join("\n") if act and act.extra_vars else "" }}</textarea>
</div>
<div class="form-group">
<label>Limit to host(s) <span style="color:var(--text-muted);font-weight:normal;">(optional)</span></label>
<input type="text" name="ansible_limit" value="{{ act.limit if act and act.limit else '' }}">
</div>
<div class="form-group">
<label>Tags <span style="color:var(--text-muted);font-weight:normal;">(optional)</span></label>
<input type="text" name="ansible_tags" value="{{ act.tags if act and act.tags else '' }}">
</div>
<label style="display:flex;align-items:center;gap:0.5rem;font-weight:normal;">
<input type="checkbox" name="ansible_check" {% if act and act.check %}checked{% endif %}>
Dry-run (--check --diff)
</label>
</div>
{% 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>
<a href="/alerts/" class="btn btn-ghost">Cancel</a>
</div>
</form>
</div>
</div>
{% endblock %}