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 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.
+ {%- 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.Active
Rules
+Rules
{% if rules %}
@@ -23,47 +29,42 @@
{% for rule in rules %}
- Name
Now
- Condition
- Fire / clear
+ Fires when
-
- {{ rule.name }}
- {% if not rule.enabled %}
- disabled
- {% endif %}
+ {% set fired = (last_fired or {}).get(rule.id) %}
+
+
{% 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 %}
-
+ {{ rule.name }}
+
{%- 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 -%}
+
-
- {{ rule.resource_name }} {{ rule_condition(rule) }}
-
+ {{ rule.resource_name }} · {{ rule_condition(rule) }}
+
- {{ rule.consecutive_failures_required }} / {{ rule.consecutive_ok_required }}
History
{% if can_edit %}
- Edit
{% endif %}
- {% if session.user_role == 'admin' %}
-
- {% endif %}
{% if rule %}Edit Rule{% else %}New Alert Rule{% endif %}
- 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 ("<", "<=") %}
+ What happens
+