feat(email): alert and weekly-report emails read as designed messages (#5335)
CI / lint (push) Successful in 4s
CI / integration (push) Successful in 1m22s
CI / unit (push) Successful in 1m49s
CI / publish (push) Successful in 1m13s

#5335 "Emails Steward sends read as designed messages: an HTML alert email
and weekly report, with a plain-text twin".

- core/mail.py renders each email as multipart/alternative from
  templates/email/: a table-and-inline-style HTML part and a plain-text
  twin carrying the same facts. Colours are the design system's light
  theme, resolved from theme.css; nothing is fetched when a mail opens.
- Alert email: state badge, a headline sentence ("Disk used on web01 is
  94%."), rule, resource, reading, the condition in words, and the time in
  the zone Notifications was saved in with UTC beside it; "View in
  Steward" when Steward's address is set. Subject "[Steward] Firing: <rule>
  — <resource> 94%". Alerts now carry their operator.
- Weekly report: monitors down, active alerts, the week's alert events,
  uptime and busiest Traefik routers as small tables with links. Sent from
  the configured From address; the "Generated … UTC" line and the stale
  repo footer are gone.
- condition_text() states a condition from a rule's parts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 14:08:05 -04:00
co-authored by Claude Opus 5.5
parent 24700ee608
commit ec55faf737
18 changed files with 539 additions and 113 deletions
+9 -3
View File
@@ -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,
+1
View File
@@ -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,
+2
View File
@@ -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"),
+148
View File
@@ -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
+3 -19
View File
@@ -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}
+10 -79
View File
@@ -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")
+3
View File
@@ -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", ""),
}
+6 -3
View File
@@ -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 ───────────────────────────────────────────────────────────
+27
View File
@@ -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. #}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<title>{% block title %}{% endblock %}</title>
</head>
<body style="margin:0;padding:0;background:{{ c.page }};">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:{{ c.page }};">
<tr><td align="center" style="padding:24px 12px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"
style="max-width:600px;background:{{ c.card }};border:1px solid {{ c.border }};border-radius:12px;">
<tr><td style="padding:20px 24px 0 24px;font:600 12px/1.4 {{ c.font }};letter-spacing:0.06em;text-transform:uppercase;color:{{ c.muted }};">Steward</td></tr>
{% block body %}{% endblock %}
<tr><td style="padding:16px 24px 20px 24px;border-top:1px solid {{ c.border }};font:12px/1.5 {{ c.font }};color:{{ c.muted }};">
{% block footer %}{% endblock %}
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
+44
View File
@@ -0,0 +1,44 @@
{# Pieces the email HTML parts share; import "with context" so c is there. #}
{% macro badge(tone, text) -%}
<span style="display:inline-block;padding:2px 10px;border:1px solid {{ c[tone] }};border-radius:9999px;font:600 12px/1.6 {{ c.font }};color:{{ c[tone] }};">{{ text }}</span>
{%- endmacro %}
{% macro button(href, text) -%}
<table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr>
<td style="border-radius:8px;background:{{ c.action }};">
<a href="{{ href }}" style="display:inline-block;padding:10px 18px;font:600 14px/1.2 {{ c.font }};color:{{ c.on_action }};text-decoration:none;border-radius:8px;">{{ text }}</a>
</td></tr></table>
{%- endmacro %}
{% macro fact(label, value) -%}
<tr>
<td valign="top" style="padding:6px 16px 6px 0;width:110px;font:13px/1.5 {{ c.font }};color:{{ c.muted }};">{{ label }}</td>
<td valign="top" style="padding:6px 0;font:14px/1.5 {{ c.font }};color:{{ c.text }};">{{ caller() if caller else value }}</td>
</tr>
{%- endmacro %}
{% macro section(title, href="", more="") -%}
<tr><td style="padding:20px 24px 6px 24px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td style="font:600 15px/1.4 {{ c.font }};color:{{ c.text }};">{{ title }}</td>
{% if href %}<td align="right" style="font:13px/1.4 {{ c.font }};"><a href="{{ href }}" style="color:{{ c.action }};">{{ more or "Open in Steward" }}</a></td>{% endif %}
</tr></table>
</td></tr>
{%- endmacro %}
{% macro rows() -%}
<tr><td style="padding:0 24px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-top:1px solid {{ c.border }};">
{{ caller() }}
</table>
</td></tr>
{%- endmacro %}
{% macro row(cells, last_right=True) -%}
<tr>
{% for cell in cells %}
<td valign="top" {% if last_right and loop.last and not loop.first %}align="right" {% endif %}style="padding:7px {{ '0' if loop.last else '12px' }} 7px 0;border-bottom:1px solid {{ c.border }};font:14px/1.45 {{ c.font }};color:{{ c.text }};">{{ cell }}</td>
{% endfor %}
</tr>
{%- endmacro %}
+27
View File
@@ -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 %}
<tr><td style="padding:14px 24px 0 24px;">{{ badge(a.tone, a.state) }}</td></tr>
<tr><td style="padding:10px 24px 4px 24px;font:600 20px/1.35 {{ c.font }};color:{{ c.text }};">{{ a.headline }}</td></tr>
<tr><td style="padding:10px 24px 4px 24px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
{{ 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 %}<br><span style="color:{{ c.muted }};">{{ a.when_utc }}</span>{% endif %}{% endcall %}
{% if a.note %}{{ fact("Note", a.note) }}{% endif %}
</table>
</td></tr>
{% if link %}
<tr><td style="padding:16px 24px 24px 24px;">{{ button(link, "View in Steward") }}</td></tr>
{% else %}
<tr><td style="padding:0 0 12px 0;"></td></tr>
{% 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 %}
+20
View File
@@ -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.
+63
View File
@@ -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 %}
<tr><td style="padding:10px 24px 0 24px;font:600 22px/1.3 {{ c.font }};color:{{ c.text }};">Weekly report</td></tr>
<tr><td style="padding:2px 24px 0 24px;font:14px/1.5 {{ c.font }};color:{{ c.muted }};">{{ period }}</td></tr>
{% if d.hosts_down %}
<tr><td style="padding:16px 24px 0 24px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"
style="border:1px solid {{ c.crit }};border-left:4px solid {{ c.crit }};border-radius:8px;">
<tr><td style="padding:10px 14px;font:14px/1.5 {{ c.font }};color:{{ c.text }};">
<strong style="color:{{ c.crit }};">{{ d.hosts_down|length }} {{ "monitor is" if d.hosts_down|length == 1 else "monitors are" }} down now:</strong>
{{ d.hosts_down|join(", ") }}
</td></tr></table>
</td></tr>
{% 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 %}
<tr><td style="padding:0 24px;font:14px/1.5 {{ c.font }};color:{{ c.muted }};">None. Nothing is firing.</td></tr>
{% endif %}
<tr><td style="padding:8px 24px 0 24px;font:13px/1.5 {{ c.font }};color:{{ c.muted }};">
This week {{ d.alert_events.fired }} {{ "alert" if d.alert_events.fired == 1 else "alerts" }} fired and {{ d.alert_events.resolved }} resolved.
</td></tr>
{{ 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 %}
<tr><td style="padding:0 24px;font:14px/1.5 {{ c.font }};color:{{ c.muted }};">No monitors are enabled.</td></tr>
{% 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 %}
<tr><td style="padding:24px 24px 24px 24px;">{{ button(base ~ "/", "Open Steward") }}</td></tr>
{% else %}
<tr><td style="padding:0 0 16px 0;"></td></tr>
{% endif %}
{% endblock %}
{% block footer %}
Sent {{ sent }}. The weekly report is turned on, and its day and hour set, in Settings › Reports.
{% endblock %}
+35
View File
@@ -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.
@@ -56,6 +56,10 @@
<input type="text" name="smtp.recipients"
value="{{ settings['smtp.recipients'] | join(', ') }}"
placeholder="admin@example.com, ops@example.com">
<p style="font-size:0.78rem;color:var(--text-muted);margin-top:0.25rem;">
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 <a href="/settings/">General</a> and they link back here.{% endif %}
</p>
</div>
<button type="button" class="btn btn-ghost" hx-post="/settings/notifications/test-email"