diff --git a/plugins/docker/templates/docker/rows.html b/plugins/docker/templates/docker/rows.html index 1971211..4db61ae 100644 --- a/plugins/docker/templates/docker/rows.html +++ b/plugins/docker/templates/docker/rows.html @@ -16,7 +16,7 @@ {# ── Verdict ──────────────────────────────────────────────────────────────── #} {% set problems = attention|length + failing_services|length %} -

+

{% if problems %}{{ problems }} need{{ "s" if problems == 1 }} attention {% else %}Nothing needs attention{% endif %} {%- if services_total %} · {{ services_healthy }}/{{ services_total }} services healthy{% endif %} diff --git a/plugins/docker/templates/docker/swarm.html b/plugins/docker/templates/docker/swarm.html index 9009e02..98224e2 100644 --- a/plugins/docker/templates/docker/swarm.html +++ b/plugins/docker/templates/docker/swarm.html @@ -15,7 +15,7 @@ {% for g in swarms %} {% set down_nodes = g.nodes|rejectattr("ok")|list %}

-

+

{% if down_nodes or g.troubled %}{{ down_nodes|length + g.troubled|length }} need{{ "s" if (down_nodes|length + g.troubled|length) == 1 }} attention {% else %}Every node is up and taking work{% endif %} · {{ g.nodes|length }} node{{ "s" if g.nodes|length != 1 }} diff --git a/steward/alerts/routes.py b/steward/alerts/routes.py index 7d6f6e9..9cfc175 100644 --- a/steward/alerts/routes.py +++ b/steward/alerts/routes.py @@ -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, diff --git a/steward/alerts/service.py b/steward/alerts/service.py index 5124932..6389f7e 100644 --- a/steward/alerts/service.py +++ b/steward/alerts/service.py @@ -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()) diff --git a/steward/static/confirm.js b/steward/static/confirm.js new file mode 100644 index 0000000..979d951 --- /dev/null +++ b/steward/static/confirm.js @@ -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); +})(); diff --git a/steward/static/steward.css b/steward/static/steward.css index 5abb0f5..352db6b 100644 --- a/steward/static/steward.css +++ b/steward/static/steward.css @@ -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%; } diff --git a/steward/templates/alerts/_active.html b/steward/templates/alerts/_active.html index 4233544..703e5b4 100644 --- a/steward/templates/alerts/_active.html +++ b/steward/templates/alerts/_active.html @@ -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"} %} +

+ {%- set firing = active|selectattr("label", "equalto", "firing")|list|length %} + {%- if firing %}{{ firing }} firing + {%- elif active %}Nothing is firing + {%- else %}Nothing is firing{% endif %} + {%- for key in ("no_data", "acknowledged", "pending") %} + {%- set n = active|selectattr("label", "equalto", key)|list|length %} + {%- if n %} · {{ n }} {{ words[key] }}{% 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 %} · notifies nobody yet + {%- if session.user_role == 'admin' %} — set up email or a webhook{% endif %}{% endif %} +

{% if active %}

Active

@@ -46,6 +63,4 @@
-{% else %} -
{{ empty_state("No active alerts. Every rule is quiet.") }}
{% endif %} diff --git a/steward/templates/alerts/list.html b/steward/templates/alerts/list.html index 0b14b82..f04e4d8 100644 --- a/steward/templates/alerts/list.html +++ b/steward/templates/alerts/list.html @@ -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") %} History Maintenance {% if can_edit %}New rule{% 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. #}
{% include "alerts/_active.html" %}
-

Rules

+

Rules

{% if rules %}
@@ -23,47 +29,42 @@ - - + {% for rule in rules %} - - - - {% endfor %} diff --git a/steward/templates/alerts/rules_form.html b/steward/templates/alerts/rules_form.html index 0403e39..2b833ed 100644 --- a/steward/templates/alerts/rules_form.html +++ b/steward/templates/alerts/rules_form.html @@ -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 %} -
-

{% if rule %}Edit Rule{% else %}New Alert Rule{% endif %}

-
-
{{ csrf_field() }} +
+ {% if rule %} + {% call page_header(rule.name) %} + History + {% endcall %} +

+ {%- if not rule.enabled %}Off · {% 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 -%} +

+ {% else %} + {{ page_header("New alert rule") }} + {% endif %} + {{ csrf_field() }} + + {# ── What it watches ─────────────────────────────────────────────────── #} +
+

What it watches

- - Name +
@@ -35,63 +53,6 @@ {% include "alerts/_rule_fields.html" %} {% endwith %}
- - {# ── 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 ("<", "<=") %} -
- - -
-
-
- - -
-
- - -
-
- -
- - -
- -
- - -

- A firing or acknowledged alert resolves only after this many checks in a row are back - within the threshold. An acknowledgement holds until then.

-
- -
- -

- 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.

-
- {# ── Metric reference ────────────────────────────────────────────────── #}
@@ -111,19 +72,93 @@ {% endfor %}
+ + + {# ── When it fires ───────────────────────────────────────────────────── #} +
+

When it fires

+ {# ── 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 ("<", "<=") %} +
+ + +
+
+
+ + +
+
+ + +

+ {%- if rule and metric_unit(m0) == "seconds" %}= {{ metric_value(m0, rule.threshold) }}{% endif %}

+
+
+ +
+
+ + +
+
+ + +
+
+

+ 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.

+
+ + {# ── What happens ────────────────────────────────────────────────────── #} +
+

What happens

+

+ {%- if notify %}It notifies {{ notify|join(" and ") }}. + {%- else %}It notifies nobody yet: no email server with recipients and no webhook is set + {%- if session.user_role == 'admin' %} (Settings → Notifications){% endif %}.{% endif -%} +

+
+ +

+ 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.

+
{# ── Ansible action (admin only) ─────────────────────────────────────── #} {% if session.get("user_role") == "admin" %} {% set act = rule.ansible_action if rule and rule.ansible_action else None %} -
-
Name NowConditionFire / clearFires when
- {{ rule.name }} - {% if not rule.enabled %} - disabled - {% endif %} + {% set fired = (last_fired or {}).get(rule.id) %} + + + {{ rule.name }} +
{{ source_label(rule.source_module) }}
{%- set now_label = (rule_state or {}).get(rule.id) %} - {%- if not rule.enabled %}off + {%- if not rule.enabled %}off {%- elif now_label and now_label not in ('inactive', 'resolved') %}{{ status_badge(now_label) }} - {%- else %}quiet{% endif -%} + {%- else %}quiet{% endif -%} +
{% if fired %}last fired {{ fired|ago }}{% else %}never fired{% endif %}
- {{ rule.resource_name }} {{ rule_condition(rule) }} -
{{ source_label(rule.source_module) }}
+
+ {{ rule.resource_name }} · {{ rule_condition(rule) }} +
+ {%- 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 -%} +
{{ rule.consecutive_failures_required }} / {{ rule.consecutive_ok_required }} History {% if can_edit %} - Edit
{{ csrf_field() }}
{% endif %} - {% if session.user_role == 'admin' %} -
{{ csrf_field() }} - -
- {% endif %}