feat(email): alert and weekly-report emails read as designed messages (#5335)
#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:
+10
-79
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user