"""steward/core/reports.py Weekly digest report: uptime, active alerts, alert events, top metrics. Called by the scheduler (hourly check) or triggered manually from Settings. """ from __future__ import annotations import asyncio import logging from datetime import datetime, timedelta, timezone 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__) async def build_report_data(app) -> dict: """Collect all data for the weekly digest.""" now = datetime.now(timezone.utc) cutoff_7d = now - timedelta(days=7) cutoff_24h = now - timedelta(hours=24) async with app.db_sessionmaker() as db: # ── Uptime summary (7d) — per enabled monitor of any type ───────────── monitors = (await db.execute( select(Monitor).where(Monitor.enabled.is_(True)).order_by(Monitor.name) )).scalars().all() uptime_rows = await db.execute( select( MonitorResult.monitor_id, func.count(MonitorResult.id).label("total"), func.sum(case((MonitorResult.is_up.is_(True), 1), else_=0)).label("up"), ) .where(MonitorResult.checked_at >= cutoff_7d) .group_by(MonitorResult.monitor_id) ) uptime_map: dict[str, float | None] = {} for row in uptime_rows: pct = round(float(row.up) / float(row.total) * 100, 2) if row.total else None uptime_map[row.monitor_id] = pct uptime_summary = [ {"name": m.name, "pct_7d": uptime_map.get(m.id)} for m in monitors ] # ── Active alerts ───────────────────────────────────────────────────── active_rows = await alert_svc.active_alerts(db) active_alerts = [ { "state": alert_svc.alert_label(state), "name": rule.name, "resource": rule.resource_name, "condition": alert_svc.rule_condition(rule), } for state, rule in active_rows ] # ── Alert event counts (7d) ─────────────────────────────────────────── events_result = await db.execute( select( func.count(case((AlertEvent.to_state == "firing", 1), else_=None)).label("fired"), func.count(case((AlertEvent.to_state == "resolved", 1), else_=None)).label("resolved"), ) .where(AlertEvent.transitioned_at >= cutoff_7d) ) event_row = events_result.one() alert_events = { "fired": int(event_row.fired or 0), "resolved": int(event_row.resolved or 0), } # ── Top Traefik routers by avg request_rate (last 24h) ──────────────── top_routers_result = await db.execute( select( PluginMetric.resource_name, func.avg(PluginMetric.value).label("avg_rate"), ) .where( and_( PluginMetric.source_module == "traefik", PluginMetric.metric_name == "request_rate", PluginMetric.recorded_at >= cutoff_24h, ) ) .group_by(PluginMetric.resource_name) .order_by(func.avg(PluginMetric.value).desc()) .limit(10) ) top_routers = [ {"name": row.resource_name, "avg_rate": float(row.avg_rate)} for row in top_routers_result ] # ── Monitors currently down (latest result is_up = false) ───────────── from steward.core.status import _last_n_results enabled = (await db.execute( select(Monitor.id, Monitor.name).where(Monitor.enabled.is_(True)) )).all() latest = await _last_n_results(db, [m.id for m in enabled], n=1) hosts_down = sorted(name for mid, name in enabled if latest.get(mid) and not latest[mid][-1].is_up) return { "generated_at": now, "uptime_summary": uptime_summary, "active_alerts": active_alerts, "alert_events": alert_events, "top_routers": top_routers, "hosts_down": hosts_down, } async def send_report(app) -> tuple[bool, str]: """Build and email the weekly digest. Returns (success, message).""" smtp_cfg = app.config.get("SMTP", {}) if not smtp_cfg.get("host") or not smtp_cfg.get("recipients"): return False, "SMTP not configured — set host and recipients in Settings → Notifications." try: data = await build_report_data(app) except Exception as exc: logger.exception("Failed to build report data") return False, f"Data collection failed: {exc}" 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() from steward.core.notifications import _smtp_send_sync await loop.run_in_executor(None, _smtp_send_sync, smtp_cfg, msg) logger.info("Weekly report sent to %s", smtp_cfg["recipients"]) return True, f"Report sent to {', '.join(smtp_cfg['recipients'])}." except Exception as exc: logger.error("Failed to send weekly report: %s", exc) return False, f"Send failed: {exc}" def report_due(now: datetime, day: int, hour: int, tz) -> bool: """Whether `now` falls in the scheduled hour, read in the schedule's zone.""" local = now.astimezone(tz) return local.weekday() == day and local.hour == hour def schedule_in(day: int, hour: int, saved_tz, view_tz, now: datetime | None = None) -> tuple[int, int]: """The schedule's (weekday, hour) as a reader in `view_tz` sees it: the next time it falls due in `saved_tz`, read on their clock.""" local = (now or datetime.now(timezone.utc)).astimezone(saved_tz) due = (local + timedelta(days=(day - local.weekday()) % 7)).replace( hour=hour, minute=0, second=0, microsecond=0) seen = due.astimezone(view_tz) return seen.weekday(), seen.hour async def check_and_send(app) -> None: """Hourly scheduler hook: send if day/hour match and not sent recently.""" from steward.core.settings import set_setting async with app.db_sessionmaker() as db: enabled = await get_setting(db, "reports.enabled") if not enabled: return schedule_day = int(await get_setting(db, "reports.schedule_day") or 6) schedule_hour = int(await get_setting(db, "reports.schedule_hour") or 8) schedule_tz = zone(await get_setting(db, "reports.schedule_tz")) last_sent_str = await get_setting(db, "reports.last_sent_at") or "" now = datetime.now(timezone.utc) if not report_due(now, schedule_day, schedule_hour, schedule_tz): return if last_sent_str: try: last_sent = datetime.fromisoformat(last_sent_str) if (now - last_sent).total_seconds() < 23 * 3600: return # already sent this window except ValueError: pass ok, msg = await send_report(app) if ok: async with app.db_sessionmaker() as db: async with db.begin(): await set_setting(db, "reports.last_sent_at", now.isoformat()) else: logger.warning("Weekly report send failed: %s", msg)