feat(alerts): Steward's own confirm dialog, a verdict-first rules list, and a rule page in three sections (#5314, #5315, #5317)
Confirm dialog (#5314): - Every data-confirm form now asks in one native <dialog> (base.html + static/confirm.js) instead of window.confirm. - The question becomes the heading and what follows it the detail; the act's button is named after the form's own button; Cancel has the focus and Esc backs out. Optional data-confirm-title/-detail/-action/-dismiss attributes. - All 27 existing uses work as they are. Cancelling a run says 'Keep running'. Alerts list (#5315): - A verdict line (what is firing, how many rules, where notifications go, or 'notifies nobody yet' with a link) replaces the empty Active card. - Each rule's name opens it; rows show 'last fired' and fold fire/clear into the condition; History and Disable stay; Delete leaves the list. - .docker-verdict becomes the shared .page-verdict. Rule page (#5317): - Named for the rule (title and crumb), with when it last fired and how many changes of state it holds. - Three sections: what it watches, when it fires (checks side by side, seconds echoed as a duration), what happens (where notifications go, no-data, the playbook, whose fields stay folded until ticked). - Delete at the foot, behind the dialog, saying its history goes with it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,7 +16,7 @@
|
||||
|
||||
{# ── Verdict ──────────────────────────────────────────────────────────────── #}
|
||||
{% set problems = attention|length + failing_services|length %}
|
||||
<p class="docker-verdict">
|
||||
<p class="page-verdict">
|
||||
{% if problems %}<strong class="tone-crit">{{ problems }} need{{ "s" if problems == 1 }} attention</strong>
|
||||
{% else %}<strong class="tone-ok">Nothing needs attention</strong>{% endif %}
|
||||
{%- if services_total %} · <span class="tone-{{ 'ok' if services_healthy == services_total else 'warn' }}">{{ services_healthy }}/{{ services_total }}</span> services healthy{% endif %}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
{% for g in swarms %}
|
||||
{% set down_nodes = g.nodes|rejectattr("ok")|list %}
|
||||
<section class="swarm-group" aria-label="Swarm reported by {{ g.managers|join(', ') }}">
|
||||
<p class="docker-verdict">
|
||||
<p class="page-verdict">
|
||||
{% if down_nodes or g.troubled %}<strong class="tone-crit">{{ down_nodes|length + g.troubled|length }} need{{ "s" if (down_nodes|length + g.troubled|length) == 1 }} attention</strong>
|
||||
{% else %}<strong class="tone-ok">Every node is up and taking work</strong>{% endif %}
|
||||
· {{ g.nodes|length }} node{{ "s" if g.nodes|length != 1 }}
|
||||
|
||||
@@ -2,13 +2,13 @@ from __future__ import annotations
|
||||
from datetime import datetime, timezone
|
||||
from quart import Blueprint, render_template, request, redirect, url_for, current_app, session
|
||||
from steward.core.audit import log_audit
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from steward.auth.middleware import require_role
|
||||
from steward.alerts import service as alert_svc
|
||||
from steward.core.paging import PAGE_SIZE, next_page
|
||||
from steward.core.time_range import parse_range
|
||||
from steward.ansible import sources as src_module
|
||||
from steward.models.alerts import AlertRule, AlertOperator, AlertState, MaintenanceWindow
|
||||
from steward.models.alerts import AlertEvent, AlertRule, AlertOperator, AlertState, MaintenanceWindow
|
||||
from steward.models.users import UserRole
|
||||
|
||||
alerts_bp = Blueprint("alerts", __name__, url_prefix="/alerts")
|
||||
@@ -102,29 +102,41 @@ def _parse_ansible_action(form) -> tuple[dict | None, str | None]:
|
||||
return action, None
|
||||
|
||||
|
||||
def _notify() -> list[str]:
|
||||
"""Where alerts' notifications go now, from the settings the app runs with."""
|
||||
return alert_svc.notify_targets(current_app.config.get("SMTP", {}), current_app.config.get("WEBHOOK", {}))
|
||||
|
||||
|
||||
@alerts_bp.get("/")
|
||||
@require_role(UserRole.viewer)
|
||||
async def list_alerts():
|
||||
"""The verdict and what is active, then every rule with what it is doing
|
||||
now and when it last fired (#5315)."""
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
active = await alert_svc.active_rows(db, datetime.now(timezone.utc))
|
||||
rules = await alert_svc.list_rules(db)
|
||||
states = {s.rule_id: s for s in (await db.execute(select(AlertState))).scalars()}
|
||||
fired = await alert_svc.last_fired(db)
|
||||
|
||||
# Every rule with what it is doing now; disabled rules last.
|
||||
rules.sort(key=lambda r: (not r.enabled, r.name.lower()))
|
||||
rule_state = {r.id: alert_svc.alert_label(states[r.id]) for r in rules if r.id in states}
|
||||
return await render_template(
|
||||
"alerts/list.html", active=active, rules=rules, rule_state=rule_state,
|
||||
operators=_OPERATORS, poll_interval=current_app.config.get("MONITORS_POLL_INTERVAL", 60))
|
||||
"alerts/list.html", active=active, rules=rules, rule_state=rule_state, last_fired=fired,
|
||||
notify=_notify(), operators=_OPERATORS,
|
||||
poll_interval=current_app.config.get("MONITORS_POLL_INTERVAL", 60))
|
||||
|
||||
|
||||
@alerts_bp.get("/active")
|
||||
@require_role(UserRole.viewer)
|
||||
async def active_fragment():
|
||||
"""The Active section alone, which the alerts page polls."""
|
||||
"""The verdict and Active section alone, which the alerts page polls."""
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
active = await alert_svc.active_rows(db, datetime.now(timezone.utc))
|
||||
return await render_template("alerts/_active.html", active=active)
|
||||
rules = await alert_svc.list_rules(db)
|
||||
return await render_template(
|
||||
"alerts/_active.html", active=active, notify=_notify(), rules_total=len(rules),
|
||||
rules_off=sum(1 for r in rules if not r.enabled))
|
||||
|
||||
|
||||
def _history_row(event, rule) -> dict:
|
||||
@@ -215,6 +227,7 @@ async def new_rule():
|
||||
metrics=alert_svc.metric_catalog().get(first_source, []),
|
||||
catalog=alert_svc.metric_catalog(),
|
||||
ansible_sources=_ansible_source_names(),
|
||||
notify=_notify(),
|
||||
)
|
||||
|
||||
|
||||
@@ -227,9 +240,12 @@ async def edit_rule(rule_id: str):
|
||||
if rule is None:
|
||||
return "Not found", 404
|
||||
resources = await alert_svc.resources_for(db, rule.source_module)
|
||||
history_count = (await db.execute(
|
||||
select(func.count()).select_from(AlertEvent).where(AlertEvent.rule_id == rule.id))).scalar_one()
|
||||
fired = (await alert_svc.last_fired(db, rule.id)).get(rule.id)
|
||||
return await render_template(
|
||||
"alerts/rules_form.html",
|
||||
rule=rule,
|
||||
rule=rule, history_count=history_count, fired=fired, notify=_notify(),
|
||||
operators=_OPERATORS,
|
||||
source_modules=alert_svc.source_modules(),
|
||||
initial_source=rule.source_module,
|
||||
|
||||
@@ -251,6 +251,34 @@ async def active_count(db) -> int:
|
||||
)).scalar_one()
|
||||
|
||||
|
||||
def notify_targets(smtp: dict, webhook: dict) -> list[str]:
|
||||
"""Where an alert's notifications go, in words, by the same tests
|
||||
core.notifications.dispatch_notifications sends by: email when a server
|
||||
and recipients are set, a webhook when its URL is. Empty: nobody (#5315)."""
|
||||
from urllib.parse import urlsplit
|
||||
out = []
|
||||
recipients = smtp.get("recipients") or []
|
||||
if isinstance(recipients, str):
|
||||
recipients = [r.strip() for r in recipients.split(",") if r.strip()]
|
||||
if smtp.get("host") and recipients:
|
||||
out.append("email to " + ", ".join(recipients))
|
||||
if url := webhook.get("url"):
|
||||
out.append("a webhook to " + (urlsplit(url).hostname or url))
|
||||
return out
|
||||
|
||||
|
||||
async def last_fired(db, rule_id: str | None = None) -> dict[str, datetime]:
|
||||
"""{rule id: when it last went to firing}, for the rules that ever have;
|
||||
one rule's when `rule_id` is given."""
|
||||
stmt = (select(AlertEvent.rule_id, func.max(AlertEvent.transitioned_at))
|
||||
.where(AlertEvent.to_state == AlertStateEnum.firing.value)
|
||||
.group_by(AlertEvent.rule_id))
|
||||
if rule_id:
|
||||
stmt = stmt.where(AlertEvent.rule_id == rule_id)
|
||||
rows = await db.execute(stmt)
|
||||
return {rid: at for rid, at in rows.all()}
|
||||
|
||||
|
||||
async def list_rules(db) -> list[AlertRule]:
|
||||
return list((await db.execute(select(AlertRule).order_by(AlertRule.name))).scalars())
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// Steward's own confirm dialog (#5314): a form, or its submit button, with
|
||||
// data-confirm="Question? What follows." asks in the #confirm-dialog that
|
||||
// base.html renders before it submits, never through window.confirm.
|
||||
//
|
||||
// The prompt is attribute text read through dataset and written with
|
||||
// textContent, never parsed as HTML or JavaScript, so a name inside it
|
||||
// cannot run as script (#5099). Optional attributes beside data-confirm:
|
||||
// data-confirm-title the heading (default: the prompt up to its first "? ")
|
||||
// data-confirm-detail the line under it (default: the rest of the prompt)
|
||||
// data-confirm-action the button that goes ahead (default: the submit
|
||||
// button's own label, or the prompt's first word)
|
||||
// data-confirm-dismiss the button that backs out (default: "Cancel")
|
||||
// Cancel has the focus, and Esc backs out.
|
||||
(function () {
|
||||
function label(btn, title) {
|
||||
var own = btn ? (btn.dataset.confirmAction || btn.textContent || btn.value || '').trim() : '';
|
||||
if (own.length > 2) return own; // "×" and other icons say nothing
|
||||
return title.split(' ')[0].replace(/\?$/, '');
|
||||
}
|
||||
|
||||
function ask(form, btn) {
|
||||
var src = btn && btn.dataset.confirm ? btn : form;
|
||||
var prompt = src.dataset.confirm;
|
||||
var cut = prompt.indexOf('? ');
|
||||
var title = src.dataset.confirmTitle || (cut > 0 ? prompt.slice(0, cut + 1) : prompt);
|
||||
var detail = src.dataset.confirmDetail || (cut > 0 ? prompt.slice(cut + 2) : '');
|
||||
var d = document.getElementById('confirm-dialog');
|
||||
d.querySelector('[data-part="title"]').textContent = title;
|
||||
var p = d.querySelector('[data-part="detail"]');
|
||||
p.textContent = detail;
|
||||
p.hidden = !detail;
|
||||
d.querySelector('[data-part="go"]').textContent = src.dataset.confirmAction || label(btn, title);
|
||||
d.querySelector('[data-part="back"]').textContent = src.dataset.confirmDismiss || 'Cancel';
|
||||
d.returnValue = '';
|
||||
d.onclose = function () {
|
||||
if (d.returnValue !== 'go') return;
|
||||
form.dataset.confirmed = '1';
|
||||
form.requestSubmit(btn && btn.form === form ? btn : undefined);
|
||||
};
|
||||
d.showModal();
|
||||
}
|
||||
|
||||
document.addEventListener('submit', function (ev) {
|
||||
var form = ev.target, btn = ev.submitter;
|
||||
if (!((btn && btn.dataset.confirm) || form.dataset.confirm)) return;
|
||||
if (form.dataset.confirmed === '1') { delete form.dataset.confirmed; return; } // the asked-for submit
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation(); // before HTMX sees it: an hx-post form waits for the answer too
|
||||
ask(form, btn);
|
||||
}, true);
|
||||
})();
|
||||
@@ -208,7 +208,8 @@ strong, b, th { font-weight: 500; }
|
||||
.cell-sub { font-size: 0.75rem; color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.table td.cell-image { max-width: 14rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.8rem; color: var(--text-muted); }
|
||||
/* The Docker page's one-line answer, above its tiles (#5298). */
|
||||
.docker-verdict { margin: 0 0 0.75rem; font-size: 0.95rem; }
|
||||
.page-verdict { margin: 0 0 0.75rem; font-size: 0.95rem; }
|
||||
.table tr.row-off > td { opacity: 0.55; }
|
||||
/* A problem section's heading carries its own count, so no tile repeats it (#5214). */
|
||||
.section-count { margin-bottom: 0.6rem; }
|
||||
.section-count > span:first-child { margin-left: 0.25rem; }
|
||||
@@ -373,6 +374,20 @@ a.stat-tile:hover { border-color: var(--border-mid); background: var(--bg-elevat
|
||||
border: 1px solid var(--border); border-radius: var(--fs-radius-md); box-shadow: var(--fs-shadow-3); }
|
||||
.modal::backdrop { background: var(--fs-overlay); }
|
||||
.modal-title { font-family: var(--font-serif); font-size: 1.25rem; font-weight: 500; margin: 0 0 1rem; }
|
||||
/* The confirm dialog (#5314): a question, what follows from it, then Cancel and the act. */
|
||||
.modal-confirm { width: min(440px, calc(100vw - 2rem)); }
|
||||
.modal-confirm .modal-title { margin-bottom: 0.5rem; overflow-wrap: anywhere; }
|
||||
.confirm-detail { color: var(--text-muted); font-size: 0.9rem; margin: 0 0 1.25rem; overflow-wrap: anywhere; }
|
||||
.modal-actions { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1rem; }
|
||||
/* A form page in sections (#5317): capped, left-aligned under its crumbs. */
|
||||
.form-page { max-width: 760px; }
|
||||
.form-section > .section-title { margin-bottom: 0.9rem; }
|
||||
.form-pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(14rem, 100%), 1fr)); gap: 0 1rem; }
|
||||
.form-pair[hidden] { display: none; }
|
||||
.ansible-fields { padding: 0.9rem 1rem 0.25rem; margin-bottom: 0.75rem; border: 1px solid var(--border); border-radius: var(--fs-radius-md); background: var(--bg-elevated); }
|
||||
.form-actions { display: flex; gap: 0.75rem; margin: 0.5rem 0 1.5rem; }
|
||||
.danger-zone { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap;
|
||||
border-color: color-mix(in srgb, var(--fs-error) 45%, var(--border)); }
|
||||
/* A filled bar for a percentage; the fill takes its grade from status_class. */
|
||||
.meter { height: 8px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 4px; overflow: hidden; }
|
||||
.meter-fill { height: 100%; }
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
{# alerts/_active.html — the Alerts page's Active section, polled on its own:
|
||||
{# alerts/_active.html — the Alerts page's verdict and Active section, polled
|
||||
on their own (#5315): one line that says what is firing, how many rules
|
||||
there are and where their notifications go; then the alerts themselves,
|
||||
firing first, then no data, then acknowledged, then pending
|
||||
(alert_svc.active_rows). An acknowledged alert says how long it has been
|
||||
breaching, and apart from that who acknowledged it and when. #}
|
||||
{% from "_macros.html" import status_badge, empty_state %}
|
||||
{% from "_macros.html" import status_badge %}
|
||||
{% set can_edit = session.user_role in ('operator', 'admin') %}
|
||||
{% set words = {"firing": "firing", "no_data": "silent", "acknowledged": "acknowledged", "pending": "pending"} %}
|
||||
<p class="page-verdict">
|
||||
{%- set firing = active|selectattr("label", "equalto", "firing")|list|length %}
|
||||
{%- if firing %}<strong class="tone-crit">{{ firing }} firing</strong>
|
||||
{%- elif active %}<strong class="tone-warn">Nothing is firing</strong>
|
||||
{%- else %}<strong class="tone-ok">Nothing is firing</strong>{% endif %}
|
||||
{%- for key in ("no_data", "acknowledged", "pending") %}
|
||||
{%- set n = active|selectattr("label", "equalto", key)|list|length %}
|
||||
{%- if n %} · <span class="tone-warn">{{ n }} {{ words[key] }}</span>{% endif %}
|
||||
{%- endfor %}
|
||||
{%- if rules_total %} · {{ rules_total }} rule{{ "s" if rules_total != 1 }}{% if rules_off %}, {{ rules_off }} off{% endif %}{% endif %}
|
||||
{%- if notify %} · notifies {{ notify|join(" and ") }}
|
||||
{%- else %} · <span class="tone-warn" title="No email server with recipients and no webhook URL is set, so an alert that fires tells no one">notifies nobody yet</span>
|
||||
{%- if session.user_role == 'admin' %} — <a href="/settings/notifications/">set up email or a webhook</a>{% endif %}{% endif %}
|
||||
</p>
|
||||
{% if active %}
|
||||
<h2 class="section-title" style="margin-bottom:1rem;">Active</h2>
|
||||
<div class="card-flush" style="margin-bottom:2rem;">
|
||||
@@ -46,6 +63,4 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card" style="margin-bottom:2rem;">{{ empty_state("No active alerts. Every rule is quiet.") }}</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
{# alerts/list.html — the Alerts page (#5315): the verdict and what is active
|
||||
(alerts/_active.html, polled), then every rule: what it is doing now and
|
||||
when it last fired, what it fires on, and its History and Disable. A rule's
|
||||
name opens it; deleting one is on its own page, behind the confirm dialog. #}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Alerts — Steward{% endblock %}
|
||||
{% from "_macros.html" import page_header, status_badge, empty_state %}
|
||||
{% block content %}
|
||||
{% set can_edit = session.user_role in ('operator', 'admin') %}
|
||||
{% call page_header("Alerts", "What is breaching now, in a sentence each, then every rule.") %}
|
||||
{% set rules_total = rules|length %}
|
||||
{% set rules_off = rules|rejectattr("enabled")|list|length %}
|
||||
{% call page_header("Alerts") %}
|
||||
<a class="btn btn-ghost" href="/alerts/history">History</a>
|
||||
<a class="btn btn-ghost" href="/alerts/maintenance">Maintenance</a>
|
||||
{% if can_edit %}<a class="btn" href="/alerts/rules/new">New rule</a>{% endif %}
|
||||
{% endcall %}
|
||||
|
||||
{# The Active section re-reads itself, so an alert that clears or starts shows
|
||||
without a reload. #}
|
||||
{# The verdict and Active section re-read themselves, so an alert that clears
|
||||
or starts shows without a reload. #}
|
||||
<div id="alerts-active" hx-get="/alerts/active" hx-trigger="every {{ poll_interval or 60 }}s" hx-swap="innerHTML">
|
||||
{% include "alerts/_active.html" %}
|
||||
</div>
|
||||
|
||||
<h2 class="section-title" style="margin-bottom:1rem;">Rules</h2>
|
||||
<h2 class="section-title" style="margin:1.5rem 0 1rem;">Rules</h2>
|
||||
{% if rules %}
|
||||
<div class="card-flush">
|
||||
<table class="table">
|
||||
@@ -23,47 +29,42 @@
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Now</th>
|
||||
<th>Condition</th>
|
||||
<th style="text-align:center;" title="Checks in a row to fire / to clear">Fire / clear</th>
|
||||
<th>Fires when</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for rule in rules %}
|
||||
<tr {% if not rule.enabled %}style="opacity:0.55;"{% endif %}>
|
||||
<td>
|
||||
{{ rule.name }}
|
||||
{% if not rule.enabled %}
|
||||
<span style="font-size:0.72rem;color:var(--text-dim);margin-left:0.35rem;">disabled</span>
|
||||
{% endif %}
|
||||
{% set fired = (last_fired or {}).get(rule.id) %}
|
||||
<tr{% if not rule.enabled %} class="row-off"{% endif %}>
|
||||
<td class="cell-name">
|
||||
<a href="{{ ('/alerts/rules/' ~ rule.id ~ '/edit') if can_edit else ('/alerts/history?rule=' ~ rule.id) }}" title="{{ rule.name }}">{{ rule.name }}</a>
|
||||
<div class="cell-sub">{{ source_label(rule.source_module) }}</div>
|
||||
</td>
|
||||
<td>
|
||||
{%- set now_label = (rule_state or {}).get(rule.id) %}
|
||||
{%- if not rule.enabled %}<span class="tone-dim" style="font-size:0.8rem;">off</span>
|
||||
{%- if not rule.enabled %}<span class="tone-dim">off</span>
|
||||
{%- elif now_label and now_label not in ('inactive', 'resolved') %}{{ status_badge(now_label) }}
|
||||
{%- else %}<span class="tone-ok" style="font-size:0.8rem;" title="Within its threshold">quiet</span>{% endif -%}
|
||||
{%- else %}<span class="tone-ok" title="Within its threshold">quiet</span>{% endif -%}
|
||||
<div class="cell-sub">{% if fired %}last fired {{ fired|ago }}{% else %}never fired{% endif %}</div>
|
||||
</td>
|
||||
<td style="color:var(--text-muted);font-size:0.85rem;">
|
||||
{{ rule.resource_name }} {{ rule_condition(rule) }}
|
||||
<div style="font-size:0.75rem;color:var(--text-dim);">{{ source_label(rule.source_module) }}</div>
|
||||
<td>
|
||||
{{ rule.resource_name }} · {{ rule_condition(rule) }}
|
||||
<div class="cell-sub">
|
||||
{%- set n_fire, n_ok = rule.consecutive_failures_required, rule.consecutive_ok_required %}
|
||||
{%- if n_fire > 1 %}after {{ n_fire }} checks in a row{% else %}on the first check{% endif %}
|
||||
{%- if n_ok > 1 %}; clears after {{ n_ok }} good ones{% endif -%}
|
||||
</div>
|
||||
</td>
|
||||
<td style="color:var(--text-muted);text-align:center;">{{ rule.consecutive_failures_required }} / {{ rule.consecutive_ok_required }}</td>
|
||||
<td class="td-actions">
|
||||
<a href="/alerts/history?rule={{ rule.id }}" class="btn btn-sm btn-ghost">History</a>
|
||||
{% if can_edit %}
|
||||
<a href="/alerts/rules/{{ rule.id }}/edit" class="btn btn-sm btn-ghost">Edit</a>
|
||||
<form method="post" action="/alerts/rules/{{ rule.id }}/toggle" style="display:inline;">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-sm btn-ghost">
|
||||
{% if rule.enabled %}Disable{% else %}Enable{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if session.user_role == 'admin' %}
|
||||
<form method="post" action="/alerts/rules/{{ rule.id }}/delete" style="display:inline;">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-sm btn-danger"
|
||||
data-confirm="Delete rule {{ rule.name }}?">Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@@ -1,16 +1,34 @@
|
||||
{# alerts/rules_form.html — a new rule, or one rule's own page (#5317): named
|
||||
for the rule, in three sections (what it watches, when it fires, what
|
||||
happens), then Delete at the foot, behind the confirm dialog, saying that
|
||||
its history goes with it. #}
|
||||
{% 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 %}
|
||||
{% from "_macros.html" import crumbs, page_header %}
|
||||
{% block title %}{{ rule.name if rule else "New alert rule" }} — Alerts — Steward{% endblock %}
|
||||
{% block breadcrumb %}{{ crumbs([("Alerts", "/alerts/"), (rule.name 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-page">
|
||||
{% if rule %}
|
||||
{% call page_header(rule.name) %}
|
||||
<a class="btn btn-sm btn-ghost" href="/alerts/history?rule={{ rule.id }}">History</a>
|
||||
{% endcall %}
|
||||
<p class="page-verdict">
|
||||
{%- if not rule.enabled %}<span class="tone-dim">Off</span> · {% endif -%}
|
||||
{%- if fired %}Last fired {{ fired|ago }}{% else %}Never fired{% endif -%}
|
||||
{%- if history_count is defined and history_count is not none %} · {{ history_count }} change{{ "s" if history_count != 1 }} of state on record{% endif -%}
|
||||
</p>
|
||||
{% else %}
|
||||
{{ page_header("New alert rule") }}
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{% if rule %}/alerts/rules/{{ rule.id }}{% else %}/alerts/rules{% endif %}">{{ csrf_field() }}
|
||||
|
||||
{# ── What it watches ─────────────────────────────────────────────────── #}
|
||||
<section class="card form-section" aria-labelledby="rule-watches">
|
||||
<h2 id="rule-watches" class="section-title">What it watches</h2>
|
||||
<div class="form-group">
|
||||
<label>Rule Name</label>
|
||||
<input type="text" name="name" required autofocus
|
||||
<label for="rule-name">Name</label>
|
||||
<input type="text" id="rule-name" name="name" required {% if not rule %}autofocus{% endif %}
|
||||
value="{{ rule.name if rule else '' }}"
|
||||
placeholder="e.g. Home server down">
|
||||
</div>
|
||||
@@ -35,63 +53,6 @@
|
||||
{% include "alerts/_rule_fields.html" %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
{# ── 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>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>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<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>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>
|
||||
|
||||
<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 class="field-hint">
|
||||
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>
|
||||
|
||||
<div class="form-group">
|
||||
<label style="display:flex;align-items:center;gap:0.5rem;font-weight:normal;">
|
||||
<input type="checkbox" name="notify_no_data"
|
||||
{% if not rule or rule.notify_no_data %}checked{% endif %}>
|
||||
Notify when this metric stops reporting
|
||||
</label>
|
||||
<p class="field-hint">
|
||||
Silence is always shown as NO DATA on the Alerts page: no sample for three of the
|
||||
metric's usual gaps (at least 5 minutes). This also sends a notification when it starts
|
||||
and when data resumes. A firing alert that goes silent stays firing.</p>
|
||||
</div>
|
||||
|
||||
{# ── Metric reference ────────────────────────────────────────────────── #}
|
||||
<details style="margin-bottom:1rem;">
|
||||
<summary style="cursor:pointer;font-size:0.82rem;color:var(--text-muted);user-select:none;">
|
||||
@@ -111,19 +72,93 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
{# ── When it fires ───────────────────────────────────────────────────── #}
|
||||
<section class="card form-section" aria-labelledby="rule-fires">
|
||||
<h2 id="rule-fires" class="section-title">When it fires</h2>
|
||||
{# ── 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 %} class="form-pair">
|
||||
<div class="form-group">
|
||||
<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>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<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="{{ ('%g'|format(rule.threshold)) if rule and not binary else '' }}" placeholder="90">
|
||||
<p class="field-hint" data-threshold-echo {% if metric_unit(m0) != "seconds" or not rule %}hidden{% endif %}>
|
||||
{%- if rule and metric_unit(m0) == "seconds" %}= {{ metric_value(m0, rule.threshold) }}{% endif %}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-pair">
|
||||
<div class="form-group">
|
||||
<label for="rule-fire">Checks in a row before it fires</label>
|
||||
<input type="number" id="rule-fire" name="consecutive_failures_required" min="1"
|
||||
value="{{ rule.consecutive_failures_required if rule else 1 }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="rule-clear">Good checks in a row before it clears</label>
|
||||
<input type="number" id="rule-clear" name="consecutive_ok_required" min="1"
|
||||
value="{{ rule.consecutive_ok_required if rule else 1 }}">
|
||||
</div>
|
||||
</div>
|
||||
<p class="field-hint" style="margin-top:-0.5rem;">
|
||||
A check is each new reading of the metric, as its source reports it. A firing or
|
||||
acknowledged alert clears only after that many readings in a row are back within the
|
||||
threshold; an acknowledgement holds until then.</p>
|
||||
</section>
|
||||
|
||||
{# ── What happens ────────────────────────────────────────────────────── #}
|
||||
<section class="card form-section" aria-labelledby="rule-happens">
|
||||
<h2 id="rule-happens" class="section-title">What happens</h2>
|
||||
<p style="margin:0 0 0.75rem;">
|
||||
{%- if notify %}It notifies {{ notify|join(" and ") }}.
|
||||
{%- else %}<span class="tone-warn">It notifies nobody yet:</span> no email server with recipients and no webhook is set
|
||||
{%- if session.user_role == 'admin' %} (<a href="/settings/notifications/">Settings → Notifications</a>){% endif %}.{% endif -%}
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label style="display:flex;align-items:center;gap:0.5rem;font-weight:normal;">
|
||||
<input type="checkbox" name="notify_no_data"
|
||||
{% if not rule or rule.notify_no_data %}checked{% endif %}>
|
||||
Notify when this metric stops reporting
|
||||
</label>
|
||||
<p class="field-hint">
|
||||
Silence is always shown as NO DATA on the Alerts page: no sample for three of the
|
||||
metric's usual gaps (at least 5 minutes). This also sends a notification when it starts
|
||||
and when data resumes. A firing alert that goes silent stays firing.</p>
|
||||
</div>
|
||||
|
||||
{# ── 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:500;">
|
||||
<input type="checkbox" name="ansible_enabled" {% if act %}checked{% endif %}>
|
||||
On firing → run an Ansible playbook
|
||||
<div class="form-group" style="margin-top:1rem;">
|
||||
<label style="display:flex;align-items:center;gap:0.5rem;font-weight:normal;">
|
||||
<input type="checkbox" name="ansible_enabled" {% if act %}checked{% endif %} data-ansible-toggle>
|
||||
Run an Ansible playbook when it fires
|
||||
</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>
|
||||
<p class="field-hint">
|
||||
Admins only. Runs once each time the alert starts firing, with <code>steward_alert_*</code>
|
||||
variables (resource, metric, value…) passed to it.</p>
|
||||
</div>
|
||||
<div class="ansible-fields" data-ansible-fields {% if not act %}hidden{% endif %}>
|
||||
<div class="form-group">
|
||||
<label>Source</label>
|
||||
<select name="ansible_source">
|
||||
@@ -163,7 +198,25 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</section>
|
||||
|
||||
<script>
|
||||
// A threshold in seconds reads back as a duration: 180 → "= 3m".
|
||||
function showDuration(form) {
|
||||
var s = Math.round(parseFloat(form.elements["threshold"].value)), echo = form.querySelector("[data-threshold-echo]");
|
||||
if (!(s >= 0)) { echo.textContent = ""; return; }
|
||||
var d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600), m = Math.floor(s % 3600 / 60), r = s % 60;
|
||||
var parts = [d && d + "d", h && h + "h", m && m + "m", (r || s === 0) && r + "s"].filter(Boolean);
|
||||
echo.textContent = "= " + parts.slice(0, 2).join(" ");
|
||||
}
|
||||
document.addEventListener("input", function (e) {
|
||||
if (e.target.name === "threshold" && !e.target.form.querySelector("[data-threshold-echo]").hidden) showDuration(e.target.form);
|
||||
});
|
||||
// The playbook's fields show only while "run a playbook" is ticked.
|
||||
document.addEventListener("change", function (e) {
|
||||
if (!e.target.hasAttribute("data-ansible-toggle")) return;
|
||||
e.target.form.querySelector("[data-ansible-fields]").hidden = !e.target.checked;
|
||||
});
|
||||
// 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).
|
||||
@@ -177,6 +230,9 @@
|
||||
form.elements["operator"].disabled = binary;
|
||||
form.elements["threshold"].disabled = binary;
|
||||
form.querySelector("[data-threshold-unit]").textContent = unit ? "(" + unit + ")" : "";
|
||||
var echo = form.querySelector("[data-threshold-echo]");
|
||||
echo.hidden = unit !== "seconds";
|
||||
if (unit === "seconds") showDuration(form);
|
||||
var about = form.querySelector("[data-metric-about]");
|
||||
if (about) { about.textContent = opt.dataset.about || ""; about.hidden = !opt.dataset.about; }
|
||||
var threshold = form.elements["threshold"];
|
||||
@@ -187,11 +243,24 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div style="display:flex;gap:1rem;margin-top:1.5rem;">
|
||||
<button type="submit" class="btn">{% if rule %}Save Changes{% else %}Create Rule{% endif %}</button>
|
||||
<div class="form-actions">
|
||||
<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>
|
||||
|
||||
{# ── Delete: on the rule's own page, never in the list (#5317) ─────────── #}
|
||||
{% if rule and session.user_role == 'admin' %}
|
||||
<section class="card danger-zone" aria-labelledby="rule-delete">
|
||||
<div>
|
||||
<h2 id="rule-delete" class="section-title">Delete this rule</h2>
|
||||
<p class="field-hint" style="margin:0.25rem 0 0;">Its history goes with it{% if history_count %} ({{ history_count }} change{{ "s" if history_count != 1 }} of state){% endif %}. Disabling it on the Alerts page keeps the history.</p>
|
||||
</div>
|
||||
<form method="post" action="/alerts/rules/{{ rule.id }}/delete"
|
||||
data-confirm="Delete rule {{ rule.name }}? Its history goes with it{% if history_count %} ({{ history_count }} change{{ 's' if history_count != 1 }} of state){% endif %}. Disabling the rule keeps it.">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-sm btn-danger">Delete rule</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{% block content %}
|
||||
{% call page_header(row.title, "From the " ~ run.source_name ~ " playbook source") %}
|
||||
{% if run.status.value in ("running", "queued") and session.user_role in ("operator", "admin") %}
|
||||
<form method="post" action="/ansible/runs/{{ run.id }}/cancel" data-confirm="Cancel this run?">{{ csrf_field() }}
|
||||
<form method="post" action="/ansible/runs/{{ run.id }}/cancel" data-confirm="Cancel this run? Ansible stops where it is; tasks already applied stay applied." data-confirm-dismiss="Keep running">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-sm btn-danger">Cancel run</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
@@ -10,13 +10,6 @@
|
||||
document.addEventListener('htmx:configRequest', function (ev) {
|
||||
ev.detail.headers['X-CSRF-Token'] = document.querySelector('meta[name="csrf-token"]').content;
|
||||
});
|
||||
// A form (or its submit button) with data-confirm="…" asks before it
|
||||
// submits. The prompt is attribute text read through dataset, never
|
||||
// parsed as JavaScript, so a name inside it cannot run as script (#5099).
|
||||
document.addEventListener('submit', function (ev) {
|
||||
var ask = (ev.submitter && ev.submitter.dataset.confirm) || ev.target.dataset.confirm;
|
||||
if (ask && !window.confirm(ask)) { ev.preventDefault(); ev.stopPropagation(); }
|
||||
}, true);
|
||||
</script>
|
||||
<title>{% block title %}Steward{% endblock %}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='logo/steward-mark.svg') }}">
|
||||
@@ -28,6 +21,8 @@
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='steward.css') }}">
|
||||
<script src="{{ url_for('static', filename='chart-colors.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='scroll-more.js') }}" defer></script>
|
||||
{# A form with data-confirm="…" asks in #confirm-dialog below before it submits (#5314). #}
|
||||
<script src="{{ url_for('static', filename='confirm.js') }}" defer></script>
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
@@ -167,6 +162,19 @@ document.addEventListener('keydown', function (e) {
|
||||
<div class="nav-scrim" onclick="setNavOpen(false)"></div>
|
||||
{% endif %}
|
||||
|
||||
{# Steward's own confirm dialog (#5314): static/confirm.js fills it from the
|
||||
data-confirm attributes of the form being submitted. Cancel has the focus. #}
|
||||
<dialog id="confirm-dialog" class="modal modal-confirm" aria-labelledby="confirm-dialog-title" aria-describedby="confirm-dialog-detail">
|
||||
<form method="dialog">
|
||||
<h2 id="confirm-dialog-title" class="modal-title" data-part="title"></h2>
|
||||
<p id="confirm-dialog-detail" class="confirm-detail" data-part="detail"></p>
|
||||
<div class="modal-actions">
|
||||
<button type="submit" class="btn btn-sm btn-ghost" value="back" data-part="back" autofocus>Cancel</button>
|
||||
<button type="submit" class="btn btn-sm btn-danger" value="go" data-part="go">Continue</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
|
||||
</body>
|
||||
|
||||
@@ -104,13 +104,72 @@ async def test_a_viewer_reads_but_gets_no_actions(app):
|
||||
assert 'href="/alerts/history?rule=r1"' in page
|
||||
|
||||
|
||||
async def test_an_admin_can_delete_a_rule(app):
|
||||
async def test_deleting_a_rule_is_on_its_own_page_and_says_what_goes(app):
|
||||
"""#5317: the list offers no Delete; the rule's page does, behind the
|
||||
confirm dialog, saying its history goes with it."""
|
||||
rule = _rule()
|
||||
rule.ansible_action, rule.notify_no_data = None, True
|
||||
async with app.test_request_context("/alerts/"):
|
||||
session["user_role"] = "admin"
|
||||
listing = await render_template("alerts/list.html", active=[], rules=[rule], operators=[])
|
||||
page = await render_template(
|
||||
"alerts/rules_form.html", rule=rule, operators=[], source_modules=["host_agent"],
|
||||
initial_source="host_agent", resources=["web-1"], metrics=["cpu_pct"], ansible_sources=[],
|
||||
catalog={}, history_count=12, fired=None, notify=[])
|
||||
assert "/delete" not in listing
|
||||
assert 'action="/alerts/rules/r1/delete"' in page
|
||||
assert ('data-confirm="Delete rule web-1 cpu_pct? Its history goes with it (12 changes of state). '
|
||||
'Disabling the rule keeps it."') in page
|
||||
assert page.index('id="rule-watches"') < page.index('id="rule-fires"') < page.index('id="rule-happens"') \
|
||||
< page.index('id="rule-delete"')
|
||||
|
||||
|
||||
async def test_the_rule_page_is_named_for_the_rule(app):
|
||||
"""#5317: the title and crumb are the rule's name; seconds read back as a
|
||||
duration; the playbook's fields wait for their box; where alerts go is said."""
|
||||
rule = _rule("seconds_since_seen", AlertOperator.gt, 180.0, "VDNT-Docker02")
|
||||
rule.name, rule.ansible_action, rule.notify_no_data = "Agent offline: VDNT-Docker02", None, True
|
||||
async with app.test_request_context("/alerts/rules/r1/edit"):
|
||||
session["user_role"] = "admin"
|
||||
page = await render_template(
|
||||
"alerts/rules_form.html", rule=rule, operators=[(">", ">")], source_modules=["host_agent"],
|
||||
initial_source="host_agent", resources=["VDNT-Docker02"], metrics=["seconds_since_seen"],
|
||||
ansible_sources=[], catalog={}, history_count=10,
|
||||
fired=datetime.now(timezone.utc) - timedelta(hours=6), notify=["email to me@example.com"])
|
||||
page = " ".join(page.split())
|
||||
assert '<h1 class="page-title">Agent offline: VDNT-Docker02</h1>' in page
|
||||
assert '<span class="breadcrumb-cur" aria-current="page">Agent offline: VDNT-Docker02</span>' in page
|
||||
assert "Edit Rule" not in page and "Last fired <time" in page and "10 changes of state on record" in page
|
||||
assert 'value="180" placeholder="90">' in page and "data-threshold-echo >= 3m</p>" in page
|
||||
assert "data-ansible-fields hidden" in page
|
||||
assert "It notifies email to me@example.com." in page
|
||||
|
||||
|
||||
def test_where_alerts_go_is_said_as_the_dispatcher_decides():
|
||||
"""#5315: email needs a server and recipients; a webhook its URL."""
|
||||
assert alert_svc.notify_targets({}, {}) == []
|
||||
assert alert_svc.notify_targets({"host": "mx", "recipients": []}, {}) == []
|
||||
assert alert_svc.notify_targets({"host": "mx", "recipients": ["a@x", "b@x"]}, {}) == ["email to a@x, b@x"]
|
||||
assert alert_svc.notify_targets({"host": "mx", "recipients": "a@x, b@x"}, {"url": "https://hooks.example/abc"}) \
|
||||
== ["email to a@x, b@x", "a webhook to hooks.example"]
|
||||
|
||||
|
||||
async def test_the_page_opens_on_a_verdict_and_says_where_alerts_go(app):
|
||||
"""#5315: no card for an empty Active list; one line instead."""
|
||||
rule = _rule()
|
||||
async with app.test_request_context("/alerts/"):
|
||||
session["user_role"] = "admin"
|
||||
page = await render_template("alerts/list.html", active=[], rules=[rule], operators=[])
|
||||
assert 'action="/alerts/rules/r1/delete"' in page
|
||||
assert 'data-confirm="Delete rule web-1 cpu_pct?"' in page
|
||||
quiet = await render_template("alerts/list.html", active=[], rules=[rule], operators=[], notify=[])
|
||||
firing = await render_template("alerts/list.html", active=[_row(rule, "firing", None)], rules=[rule],
|
||||
operators=[], notify=["a webhook to hooks.example"],
|
||||
last_fired={"r1": datetime.now(timezone.utc) - timedelta(minutes=12)})
|
||||
quiet, firing = " ".join(quiet.split()), " ".join(firing.split())
|
||||
assert '<strong class="tone-ok">Nothing is firing</strong> · 1 rule ·' in quiet
|
||||
assert "notifies nobody yet</span> — <a href=\"/settings/notifications/\">set up email or a webhook</a>" in quiet
|
||||
assert "empty-state" not in quiet and ">Active</h2>" not in quiet
|
||||
assert '<strong class="tone-crit">1 firing</strong>' in firing and "notifies a webhook to hooks.example" in firing
|
||||
assert "last fired <time" in firing and "never fired" in quiet
|
||||
assert "on the first check" in quiet
|
||||
|
||||
|
||||
async def _active_count(_db):
|
||||
@@ -163,7 +222,7 @@ async def test_rules_say_what_each_is_doing_now(app):
|
||||
rules = page[page.index(">Rules</h2>"):]
|
||||
assert '<span class="badge badge-crit">Firing</span>' in rules
|
||||
assert 'title="Within its threshold">quiet</span>' in rules
|
||||
assert '<span class="tone-dim" style="font-size:0.8rem;">off</span>' in rules
|
||||
assert '<span class="tone-dim">off</span>' in rules and 'class="row-off"' in rules
|
||||
assert 'hx-get="/alerts/active"' in page
|
||||
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ async def test_a_crash_loop_comes_first_and_a_stale_row_says_so(app):
|
||||
containers=[_crow(loop, exit_failed=True), _crow(old, stale=True)], containers_total=2)
|
||||
|
||||
# #5298: the verdict first; each problem section carries its own count (#5214).
|
||||
assert page.index('class="docker-verdict"') < page.index("Needs attention")
|
||||
assert page.index('class="page-verdict"') < page.index("Needs attention")
|
||||
assert "1 needs attention" in page and "1 container running, 1 stopped" in page
|
||||
assert " unhealthy</span>" not in page and "Failed in the last 24h" not in page and "stat-strip" not in page
|
||||
card = page[page.index("Needs attention"):page.index("</section>")]
|
||||
|
||||
@@ -38,6 +38,14 @@ def test_no_template_renders_a_value_into_an_inline_handler():
|
||||
|
||||
|
||||
def test_base_asks_before_a_data_confirm_form_submits():
|
||||
"""#5314: in Steward's own dialog, filled as text, never the browser's confirm()."""
|
||||
base = (_REPO / "steward" / "templates" / "base.html").read_text()
|
||||
assert "dataset.confirm" in base and "window.confirm(ask)" in base
|
||||
js = (_REPO / "steward" / "static" / "confirm.js").read_text()
|
||||
assert "filename='confirm.js'" in base and '<dialog id="confirm-dialog"' in base
|
||||
assert '<form method="dialog">' in base
|
||||
assert re.search(r'<button type="submit"[^>]*value="back"[^>]*autofocus>', base) # Cancel has the focus
|
||||
assert re.search(r'<button type="submit"[^>]*btn-danger[^>]*value="go"', base)
|
||||
assert "dataset.confirm" in js and "getElementById('confirm-dialog')" in js and "requestSubmit" in js
|
||||
assert "textContent = title" in js and "innerHTML" not in js # text, never markup
|
||||
assert "window.confirm" not in js and "window.confirm" not in base
|
||||
assert any("data-confirm=" in p.read_text() for p in _templates())
|
||||
|
||||
Reference in New Issue
Block a user