diff --git a/steward/alerts/service.py b/steward/alerts/service.py index cca5da1..080eb1a 100644 --- a/steward/alerts/service.py +++ b/steward/alerts/service.py @@ -134,15 +134,21 @@ _BINARY = ("is_up", "is_healthy") def rule_condition(rule: AlertRule) -> str: """What a rule fires on, in words: "CPU > 90%", "Fullest disk >= 85%", or "is down" for an up/down metric rather than "is_up < 0.5".""" + return condition_text(rule.metric_name, rule.operator, rule.threshold) + + +def condition_text(metric: str, operator: AlertOperator | str, threshold: float) -> str: + """rule_condition from a rule's parts, for an alert carried as a dict (an + email); the operator may be its value, ">=", rather than the enum.""" from steward.core.alerts import _is_breached from steward.core.formatting import metric_label, metric_value - metric = rule.metric_name + operator = AlertOperator(operator) if metric in _BINARY: fires_on = [metric_value(metric, v) for v in (1.0, 0.0) - if _is_breached(v, rule.operator, rule.threshold)] + if _is_breached(v, operator, threshold)] if len(fires_on) == 1: return f"is {fires_on[0]}" - return f"{metric_label(metric)} {rule.operator.value} {metric_value(metric, rule.threshold)}" + return f"{metric_label(metric)} {operator.value} {metric_value(metric, threshold)}" def alert_sentence(rule: AlertRule, label: str, value: float | None, diff --git a/steward/core/alerts.py b/steward/core/alerts.py index 87801a1..3c8c080 100644 --- a/steward/core/alerts.py +++ b/steward/core/alerts.py @@ -484,6 +484,7 @@ async def _dispatch_notification( "state": notif_type.upper(), "metric": rule.metric_name, "value": value, + "operator": rule.operator.value, "threshold": rule.threshold, "resource": rule.resource_name, "source_module": rule.source_module, diff --git a/steward/core/formatting.py b/steward/core/formatting.py index 7194b03..a0a9d78 100644 --- a/steward/core/formatting.py +++ b/steward/core/formatting.py @@ -185,6 +185,8 @@ STATES: dict[str, tuple[str, str]] = { "stale": ("warn", "Stale"), "no_data": ("warn", "No data"), "acknowledged": ("info", "Acknowledged"), + "data_resumed": ("ok", "Reporting again"), + "test": ("info", "Test"), "inactive": ("dim", "Inactive"), "paused": ("dim", "Paused"), "unknown": ("dim", "Unknown"), diff --git a/steward/core/mail.py b/steward/core/mail.py new file mode 100644 index 0000000..f1223c9 --- /dev/null +++ b/steward/core/mail.py @@ -0,0 +1,148 @@ +"""Emails Steward sends (#5335): an alert and the weekly report. + +Each is multipart/alternative, a plain-text part and an HTML part rendered +from the same facts by templates/email/. The HTML keeps to what mail +clients draw: a table layout, inline styles and no remote assets, so +opening the mail fetches nothing (no fonts, images or stylesheets). Mail +clients do not read CSS variables, so the colours are the design system's +light theme resolved to plain values from theme.css (palette()). +""" +from __future__ import annotations +import re +from datetime import datetime, timedelta, timezone, tzinfo +from email.message import EmailMessage +from functools import lru_cache +from pathlib import Path + +from jinja2 import Environment, FileSystemLoader, select_autoescape + +_STEWARD = Path(__file__).resolve().parent.parent + +# The part each colour plays in an email, and the theme token it is. +_ROLES = { + "page": "--fs-surface-page", "card": "--fs-surface-raised", "border": "--fs-border-color", + "text": "--fs-text-primary", "muted": "--fs-text-secondary", "faint": "--fs-text-tertiary", + "action": "--fs-action-primary", "on_action": "--fs-text-on-action", + "ok": "--fs-success", "warn": "--fs-warning", "crit": "--fs-error", "info": "--fs-info", +} +# A "dim" state is drawn in secondary text: the faintest is under 4.5:1 on the card. +_TONE_DIM = "muted" + +_env = Environment(loader=FileSystemLoader(_STEWARD / "templates" / "email"), + autoescape=select_autoescape(["html"]), trim_blocks=True, lstrip_blocks=True, + keep_trailing_newline=True) + + +def _block(css: str, selector: str) -> dict[str, str]: + i = css.index(selector + " {") + body = re.sub(r"/\*.*?\*/", "", css[i:css.index("\n}", i)], flags=re.S) + return {k: v.strip() for k, v in re.findall(r"(--[\w-]+)\s*:\s*([^;]+);", body)} + + +@lru_cache(maxsize=1) +def palette() -> dict[str, str]: + """{role: colour} from theme.css, the light theme over :root with var() + followed to its value, and the body font as "font".""" + css = (_STEWARD / "static" / "theme.css").read_text() + tokens = {**_block(css, ":root"), **_block(css, '[data-theme="light"]')} + + def resolve(name: str) -> str: + m = re.fullmatch(r"var\((--[\w-]+)\)", tokens[name]) + return resolve(m.group(1)) if m else tokens[name] + colours = {role: resolve(token) for role, token in _ROLES.items()} + colours["dim"] = colours[_TONE_DIM] + colours["font"] = tokens["--fs-font-body"] # the stack's fallbacks; nothing is loaded + return colours + + +def _render(name: str, **ctx) -> tuple[str, str]: + ctx = {"c": palette(), **ctx} + return (_env.get_template(f"{name}.txt").render(ctx), + _env.get_template(f"{name}.html").render(ctx)) + + +def sender(cfg: dict) -> str: + return cfg.get("from") or cfg.get("username") or "steward@localhost" + + +def message(cfg: dict, subject: str, text: str, html: str) -> EmailMessage: + msg = EmailMessage() + # A header may not hold a line break; a rule name can (MCP takes any string). + msg["Subject"] = " ".join(subject.split()) + msg["From"] = sender(cfg) + msg["To"] = ", ".join(cfg["recipients"]) + msg.set_content(text) + msg.add_alternative(html, subtype="html") + return msg + + +def moment(at: datetime, tz: tzinfo) -> str: + """'Thu 8 Oct 2026 13:58 EDT': a time in an email, always with its date + and zone, since a mail is read long after it is sent.""" + local = at.astimezone(tz) + return f"{local:%a} {local.day} {local:%b %Y %H:%M} {local.tzname()}" + + +def _lower_first(label: str) -> str: + """A label mid-sentence: "Response time" -> "response time", "CPU" stays.""" + return label if label[:2].isupper() else label[:1].lower() + label[1:] + + +def alert_facts(alert: dict, tz: tzinfo) -> dict: + """What an alert email says, in words, from the dict alerts dispatch.""" + from steward.alerts.service import _BINARY, condition_text + from steward.core.formatting import metric_label, metric_value, status_info + tone, label = status_info(alert["state"]) + metric, resource, value = alert["metric"], alert["resource"], alert.get("value") + reading = metric_value(metric, value) if value is not None else None + what = _lower_first(metric_label(metric)) + state = alert["state"].lower() + if state == "no_data": + headline = f"Steward has had no {what} reading from {resource}." + elif state == "data_resumed": + headline = f"{resource} is reporting {what} again." + elif metric in _BINARY: + headline = f"{resource} is {reading or 'back to normal'}." + elif state == "resolved": + headline = f"{metric_label(metric)} on {resource} is back within its limit" + ( + f", at {reading}." if reading else ".") + else: + headline = f"{metric_label(metric)} on {resource} is {reading}." + at = datetime.fromisoformat(alert["timestamp"]) + local, utc = moment(at, tz), moment(at, timezone.utc) + return { + "state": label, "tone": tone, "headline": headline, + "rule": alert["rule_name"], "resource": resource, + "reading": reading or "no data", + "condition": condition_text(metric, alert["operator"], alert["threshold"]), + "when": local, "when_utc": utc if utc != local else "", + "note": alert.get("note") or "", + "subject": f"[Steward] {label}: {alert['rule_name']} — {resource}" + + (f" {reading}" if reading and state in ("firing", "test") else ""), + } + + +def alert_email(cfg: dict, alert: dict) -> tuple[str, str, str]: + """(subject, text, html) for an alert, in the zone the SMTP settings were + saved in, linking to Steward when its address is set.""" + from steward.core.formatting import zone + facts = alert_facts(alert, zone(cfg.get("tz"))) + base = (cfg.get("base_url") or "").rstrip("/") + text, html = _render("alert", a=facts, link=f"{base}/alerts/" if base else "") + return facts["subject"], text, html + + +def report_email(cfg: dict, data: dict, tz: tzinfo) -> tuple[str, str, str]: + """(subject, text, html) for the weekly report built by + reports.build_report_data, its dates in the schedule's zone.""" + from steward.core.formatting import status_info + end = data["generated_at"].astimezone(tz) + start = end - timedelta(days=7) + period = (f"{start.day} {start:%b} – {end.day} {end:%b %Y}" if start.year == end.year + else f"{start.day} {start:%b %Y} – {end.day} {end:%b %Y}") + alerts = [{**a, "tone": status_info(a["state"])[0], "state": status_info(a["state"])[1]} + for a in data["active_alerts"]] + base = (cfg.get("base_url") or "").rstrip("/") + text, html = _render("report", d=data, alerts=alerts, period=period, + sent=moment(data["generated_at"], tz), base=base) + return f"[Steward] Weekly report, {period}", text, html diff --git a/steward/core/notifications.py b/steward/core/notifications.py index 03dfbec..f5793bc 100644 --- a/steward/core/notifications.py +++ b/steward/core/notifications.py @@ -41,27 +41,11 @@ def smtp_mode(port, tls) -> str: return "starttls" if tls else "plain" -def email_body(alert: dict) -> str: - value = "no data" if alert.get("value") is None else alert["value"] - return ( - f"Alert: {alert['state']}\n" - f"Rule: {alert['rule_name']}\n" - f"Resource: {alert['resource']}\n" - f"Metric: {alert['metric']} = {value}\n" - f"Threshold: {alert['threshold']}\n" - + (f"Note: {alert['note']}\n" if alert.get("note") else "") - + f"Time: {alert['timestamp']}\n" - ) - - async def _send_email(cfg: dict, alert: dict) -> dict: + from steward.core.mail import alert_email, message try: - msg = EmailMessage() - # A header may not hold a line break; a rule name can (MCP takes any string). - msg["Subject"] = " ".join(f"[Steward] {alert['state']} — {alert['rule_name']}".split()) - msg["From"] = cfg.get("from") or cfg.get("username") or "steward@localhost" - msg["To"] = ", ".join(cfg["recipients"]) - msg.set_content(email_body(alert)) + subject, text, html = alert_email(cfg, alert) + msg = message(cfg, subject, text, html) loop = asyncio.get_running_loop() await loop.run_in_executor(None, _smtp_send_sync, cfg, msg) return {"sent": True, "error": None} diff --git a/steward/core/reports.py b/steward/core/reports.py index fbed313..ebeec5a 100644 --- a/steward/core/reports.py +++ b/steward/core/reports.py @@ -7,20 +7,19 @@ from __future__ import annotations import asyncio import logging from datetime import datetime, timedelta, timezone -from email.message import EmailMessage from sqlalchemy import and_, case, func, select from steward.alerts import service as alert_svc +from steward.core.formatting import zone +from steward.core.mail import message, report_email +from steward.core.settings import get_setting from steward.models.alerts import AlertEvent from steward.models.metrics import PluginMetric from steward.models.monitors import Monitor, MonitorResult logger = logging.getLogger(__name__) -_DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] - - async def build_report_data(app) -> dict: """Collect all data for the weekly digest.""" now = datetime.now(timezone.utc) @@ -55,12 +54,10 @@ async def build_report_data(app) -> dict: active_rows = await alert_svc.active_alerts(db) active_alerts = [ { - "state": alert_svc.alert_label(state).upper(), + "state": alert_svc.alert_label(state), "name": rule.name, "resource": rule.resource_name, - "metric": rule.metric_name, - "operator": rule.operator.value, - "threshold": rule.threshold, + "condition": alert_svc.rule_condition(rule), } for state, rule in active_rows ] @@ -120,66 +117,6 @@ async def build_report_data(app) -> dict: } -def _render_report_text(data: dict) -> str: - now = data["generated_at"] - lines: list[str] = [] - - lines.append("Steward — Weekly Report") - lines.append(f"Generated: {now.strftime('%Y-%m-%d %H:%M UTC')}") - lines.append("") - - # ── Hosts currently down ────────────────────────────────────────────────── - down = data["hosts_down"] - if down: - lines.append(f"⚠ {len(down)} monitor(s) currently DOWN: {', '.join(down)}") - lines.append("") - - # ── Uptime summary ──────────────────────────────────────────────────────── - lines.append("UPTIME SUMMARY (7-day)") - lines.append("─" * 40) - for entry in data["uptime_summary"]: - pct = entry["pct_7d"] - pct_str = f"{pct:.2f}%" if pct is not None else "no data" - lines.append(f" {entry['name']:<32} {pct_str}") - if not data["uptime_summary"]: - lines.append(" (no monitors configured)") - lines.append("") - - # ── Active alerts ───────────────────────────────────────────────────────── - alerts = data["active_alerts"] - lines.append(f"ACTIVE ALERTS ({len(alerts)})") - lines.append("─" * 40) - if alerts: - for a in alerts: - lines.append( - f" [{a['state']}] {a['name']} — {a['resource']}:" - f"{a['metric']} {a['operator']} {a['threshold']}" - ) - else: - lines.append(" None — all clear.") - lines.append("") - - # ── Alert events this week ──────────────────────────────────────────────── - ev = data["alert_events"] - lines.append("ALERT EVENTS THIS WEEK") - lines.append("─" * 40) - lines.append(f" Fired: {ev['fired']}") - lines.append(f" Resolved: {ev['resolved']}") - lines.append("") - - # ── Top Traefik routers ─────────────────────────────────────────────────── - if data["top_routers"]: - lines.append("TOP TRAEFIK ROUTERS (avg req/s, last 24h)") - lines.append("─" * 40) - for r in data["top_routers"]: - lines.append(f" {r['name']:<40} {r['avg_rate']:.2f} req/s") - lines.append("") - - lines.append("─" * 40) - lines.append("Steward — https://git.fabledsword.com/bvandeusen/Steward") - return "\n".join(lines) - - async def send_report(app) -> tuple[bool, str]: """Build and email the weekly digest. Returns (success, message).""" smtp_cfg = app.config.get("SMTP", {}) @@ -192,15 +129,10 @@ async def send_report(app) -> tuple[bool, str]: logger.exception("Failed to build report data") return False, f"Data collection failed: {exc}" - body = _render_report_text(data) - date_str = data["generated_at"].strftime("%Y-%m-%d") - subject = f"[Steward] Weekly Report — {date_str}" - - msg = EmailMessage() - msg["Subject"] = subject - msg["From"] = smtp_cfg.get("username", "steward@localhost") - msg["To"] = ", ".join(smtp_cfg["recipients"]) - msg.set_content(body) + async with app.db_sessionmaker() as db: + tz = zone(await get_setting(db, "reports.schedule_tz")) + subject, text, html = report_email(smtp_cfg, data, tz) + msg = message(smtp_cfg, subject, text, html) try: loop = asyncio.get_running_loop() @@ -231,8 +163,7 @@ def schedule_in(day: int, hour: int, saved_tz, view_tz, now: datetime | None = N async def check_and_send(app) -> None: """Hourly scheduler hook: send if day/hour match and not sent recently.""" - from steward.core.formatting import zone - from steward.core.settings import get_setting, set_setting + from steward.core.settings import set_setting async with app.db_sessionmaker() as db: enabled = await get_setting(db, "reports.enabled") diff --git a/steward/core/settings.py b/steward/core/settings.py index a15e08f..e782d11 100644 --- a/steward/core/settings.py +++ b/steward/core/settings.py @@ -63,6 +63,7 @@ DEFAULTS: dict[str, Any] = { "smtp.password": "", "smtp.recipients": [], "smtp.from": "", + "smtp.tz": "", # the zone of whoever saved Notifications: alert emails give times in it "webhook.url": "", "webhook.template": _DEFAULT_WEBHOOK_TEMPLATE, "ansible.sources": [], @@ -388,6 +389,8 @@ def to_smtp_cfg(settings: dict[str, Any]) -> dict: "password": settings.get("smtp.password", ""), "recipients": settings.get("smtp.recipients", []), "from": settings.get("smtp.from", ""), + "tz": settings.get("smtp.tz", ""), + "base_url": settings.get("general.public_base_url", ""), } diff --git a/steward/settings/routes.py b/steward/settings/routes.py index 2daad8f..47ea0cf 100644 --- a/steward/settings/routes.py +++ b/steward/settings/routes.py @@ -240,6 +240,7 @@ async def save_notifications(): await set_setting(db, "smtp.password", form.get("smtp.password")) await set_setting(db, "smtp.recipients", recipients) await set_setting(db, "smtp.from", form.get("smtp.from", "").strip()) + await set_setting(db, "smtp.tz", session.get("tz") or "") if "webhook.remove" in form: await set_setting(db, "webhook.url", "") elif form.get("webhook.url", "").strip(): @@ -255,7 +256,7 @@ def _sample_alert() -> dict: has quotes in it, so a template that cannot take them shows it.""" from datetime import datetime, timezone return {"rule_name": 'Test alert "from" Steward', "state": "TEST", "metric": "cpu_pct", - "value": 97.5, "threshold": 90.0, "resource": "example-host", + "value": 97.5, "operator": ">", "threshold": 90.0, "resource": "example-host", "source_module": "host_agent", "note": "sent from Settings → Notifications", "timestamp": datetime.now(timezone.utc).isoformat()} @@ -296,7 +297,8 @@ async def test_webhook(): async def test_email(): """Send a sample alert email using the SMTP settings in the form (unsaved); a blank password uses the saved one.""" - from steward.core.notifications import _send_email, email_body, smtp_mode + from steward.core.mail import alert_email + from steward.core.notifications import _send_email, smtp_mode form = await request.form async with current_app.db_sessionmaker() as db: saved = to_smtp_cfg(await get_all_settings(db)) @@ -308,6 +310,7 @@ async def test_email(): "tls": "smtp.tls" in form, "username": form.get("smtp.username", ""), "password": form.get("smtp.password") or saved["password"], "from": (form.get("smtp.from") or "").strip(), + "tz": session.get("tz") or "", "base_url": saved["base_url"], "recipients": [r.strip() for r in form.get("smtp.recipients", "").split(",") if r.strip()]} if not cfg["host"] or not cfg["recipients"]: return await _notify_test_result("email", False, "Enter an SMTP host and at least one recipient.") @@ -316,7 +319,7 @@ async def test_email(): how = {"ssl": "implicit TLS", "starttls": "STARTTLS", "plain": "no TLS"}[smtp_mode(port, cfg["tls"])] result = (f"Sent to {', '.join(cfg['recipients'])} via {cfg['host']}:{port} ({how})." if sent["sent"] else f"{cfg['host']}:{port} ({how}): {sent['error']}") - return await _notify_test_result("email", sent["sent"], result, email_body(alert)) + return await _notify_test_result("email", sent["sent"], result, alert_email(cfg, alert)[1]) # ── Ansible Sources ─────────────────────────────────────────────────────────── diff --git a/steward/templates/email/_layout.html b/steward/templates/email/_layout.html new file mode 100644 index 0000000..052e3e3 --- /dev/null +++ b/steward/templates/email/_layout.html @@ -0,0 +1,27 @@ +{# The frame every Steward email's HTML part sits in (core/mail.py, #5335). + Tables and inline styles only, colours from c (the light theme resolved + to values), and nothing fetched from anywhere. #} + + + + + + + +{% block title %}{% endblock %} + + + + +
+ + +{% block body %}{% endblock %} + +
Steward
+{% block footer %}{% endblock %} +
+
+ + diff --git a/steward/templates/email/_parts.html b/steward/templates/email/_parts.html new file mode 100644 index 0000000..7954c93 --- /dev/null +++ b/steward/templates/email/_parts.html @@ -0,0 +1,44 @@ +{# Pieces the email HTML parts share; import "with context" so c is there. #} + +{% macro badge(tone, text) -%} +{{ text }} +{%- endmacro %} + +{% macro button(href, text) -%} + +
+{{ text }} +
+{%- endmacro %} + +{% macro fact(label, value) -%} + +{{ label }} +{{ caller() if caller else value }} + +{%- endmacro %} + +{% macro section(title, href="", more="") -%} + + + +{% if href %}{% endif %} +
{{ title }}{{ more or "Open in Steward" }}
+ +{%- endmacro %} + +{% macro rows() -%} + + +{{ caller() }} +
+ +{%- endmacro %} + +{% macro row(cells, last_right=True) -%} + +{% for cell in cells %} +{{ cell }} +{% endfor %} + +{%- endmacro %} diff --git a/steward/templates/email/alert.html b/steward/templates/email/alert.html new file mode 100644 index 0000000..069fabb --- /dev/null +++ b/steward/templates/email/alert.html @@ -0,0 +1,27 @@ +{# An alert email's HTML part: a = mail.alert_facts(), link = the alerts page or "". #} +{% extends "_layout.html" %} +{% from "_parts.html" import badge, button, fact with context %} +{% block title %}{{ a.state }}: {{ a.rule }}{% endblock %} +{% block body %} +{{ badge(a.tone, a.state) }} +{{ a.headline }} + + +{{ fact("Rule", a.rule) }} +{{ fact("Resource", a.resource) }} +{{ fact("Reading", a.reading) }} +{{ fact("Fires when", a.condition) }} +{% call fact("When") %}{{ a.when }}{% if a.when_utc %}
{{ a.when_utc }}{% endif %}{% endcall %} +{% if a.note %}{{ fact("Note", a.note) }}{% endif %} +
+ +{% if link %} +{{ button(link, "View in Steward") }} +{% else %} + +{% endif %} +{% endblock %} +{% block footer %} +Steward sends this to everyone on its email list when an alert rule changes state. +Who gets it is set in Settings › Notifications. +{% endblock %} diff --git a/steward/templates/email/alert.txt b/steward/templates/email/alert.txt new file mode 100644 index 0000000..36cda95 --- /dev/null +++ b/steward/templates/email/alert.txt @@ -0,0 +1,20 @@ +{# An alert email's plain-text part; the same facts as alert.html. #} +{{ a.state }}: {{ a.headline }} + +Rule: {{ a.rule }} +Resource: {{ a.resource }} +Reading: {{ a.reading }} +Fires when: {{ a.condition }} +When: {{ a.when }}{% if a.when_utc %} ({{ a.when_utc }}){% endif %} + +{% if a.note %} +Note: {{ a.note }} +{% endif %} +{% if link %} + +View in Steward: {{ link }} +{% endif %} + +-- +Steward sends this to everyone on its email list when an alert rule changes +state. Who gets it is set in Settings › Notifications. diff --git a/steward/templates/email/report.html b/steward/templates/email/report.html new file mode 100644 index 0000000..c0ff029 --- /dev/null +++ b/steward/templates/email/report.html @@ -0,0 +1,63 @@ +{# The weekly report's HTML part: d = reports.build_report_data(), alerts = its + active alerts with tone and label, period, sent, base = Steward's address or "". #} +{% extends "_layout.html" %} +{% from "_parts.html" import badge, button, section, rows, row with context %} +{% block title %}Weekly report, {{ period }}{% endblock %} +{% block body %} +Weekly report +{{ period }} + +{% if d.hosts_down %} + + +
+{{ d.hosts_down|length }} {{ "monitor is" if d.hosts_down|length == 1 else "monitors are" }} down now: +{{ d.hosts_down|join(", ") }} +
+ +{% endif %} + +{{ section("Active alerts", base ~ "/alerts/" if base else "") }} +{% if alerts %} +{% call rows() %} +{% for a in alerts %} +{{ row([badge(a.tone, a.state), a.name ~ " — " ~ a.resource, a.condition]) }} +{% endfor %} +{% endcall %} +{% else %} +None. Nothing is firing. +{% endif %} + +This week {{ d.alert_events.fired }} {{ "alert" if d.alert_events.fired == 1 else "alerts" }} fired and {{ d.alert_events.resolved }} resolved. + + +{{ section("Uptime over 7 days", base ~ "/monitors/" if base else "") }} +{% if d.uptime_summary %} +{% call rows() %} +{% for m in d.uptime_summary %} +{{ row([m.name, "—" if m.pct_7d is none else "%.2f%%"|format(m.pct_7d)]) }} +{% endfor %} +{% endcall %} +{% else %} +No monitors are enabled. +{% endif %} + +{% if d.top_routers %} +{{ section("Busiest Traefik routers, last 24 hours", base ~ "/plugins/traefik/" if base else "") }} +{% call rows() %} +{% for r in d.top_routers %} +{{ row([r.name, "%.2f req/s"|format(r.avg_rate)]) }} +{% endfor %} +{% endcall %} +{% endif %} + +{% if base %} +{{ button(base ~ "/", "Open Steward") }} +{% else %} + +{% endif %} +{% endblock %} +{% block footer %} +Sent {{ sent }}. The weekly report is turned on, and its day and hour set, in Settings › Reports. +{% endblock %} diff --git a/steward/templates/email/report.txt b/steward/templates/email/report.txt new file mode 100644 index 0000000..f66602d --- /dev/null +++ b/steward/templates/email/report.txt @@ -0,0 +1,35 @@ +{# The weekly report's plain-text part; the same facts as report.html. #} +Steward weekly report, {{ period }} + +{% if d.hosts_down %} +{{ d.hosts_down|length }} {{ "monitor is" if d.hosts_down|length == 1 else "monitors are" }} down now: {{ d.hosts_down|join(", ") }} + +{% endif %} +ACTIVE ALERTS{% if base %} ({{ base }}/alerts/){% endif %} + +{% for a in alerts %} +- {{ a.state }}: {{ a.name }} — {{ a.resource }} ({{ a.condition }}) +{% else %} +None. Nothing is firing. +{% endfor %} +This week {{ d.alert_events.fired }} {{ "alert" if d.alert_events.fired == 1 else "alerts" }} fired and {{ d.alert_events.resolved }} resolved. + +UPTIME OVER 7 DAYS{% if base %} ({{ base }}/monitors/){% endif %} + +{% for m in d.uptime_summary %} +- {{ m.name }}: {{ "—" if m.pct_7d is none else "%.2f%%"|format(m.pct_7d) }} +{% else %} +No monitors are enabled. +{% endfor %} +{% if d.top_routers %} + +BUSIEST TRAEFIK ROUTERS, LAST 24 HOURS{% if base %} ({{ base }}/plugins/traefik/){% endif %} + +{% for r in d.top_routers %} +- {{ r.name }}: {{ "%.2f req/s"|format(r.avg_rate) }} +{% endfor %} +{% endif %} + +-- +Sent {{ sent }}. The weekly report is turned on, and its day and hour set, +in Settings › Reports. diff --git a/steward/templates/settings/notifications.html b/steward/templates/settings/notifications.html index 360ef2f..c50fc43 100644 --- a/steward/templates/settings/notifications.html +++ b/steward/templates/settings/notifications.html @@ -56,6 +56,10 @@ +

+ Alert emails give times in {{ tz_label() }}, your zone when you save, with UTC beside them. + {% if not settings['general.public_base_url'] %}Set Steward's address in General and they link back here.{% endif %} +