# plugins/http/routes.py from __future__ import annotations import uuid from datetime import datetime, timedelta, timezone from quart import Blueprint, current_app, render_template, request, redirect, url_for from sqlalchemy import Integer, and_, case, cast, func, select from steward.auth.middleware import require_role from steward.models.users import UserRole from steward.core.status import StatusEntry, HEARTBEAT_COUNT from steward.core.time_range import parse_range, DEFAULT_RANGE, bucket_seconds from .models import HttpMonitor, HttpResult http_bp = Blueprint("http", __name__, template_folder="templates") def _sparkline(values: list[float], width: int = 80, height: int = 20) -> str: if len(values) < 2: return f'' mn, mx = min(values), max(values) if mx == mn: mx = mn + 1.0 step = width / (len(values) - 1) pts = [] for i, v in enumerate(values): x = i * step y = height - (v - mn) / (mx - mn) * (height - 2) - 1 pts.append(f"{x:.1f},{y:.1f}") poly = " ".join(pts) return ( f'' f'' f'' ) async def _render_rows(range_key: str = DEFAULT_RANGE): since, range_key = parse_range(range_key) b_secs = bucket_seconds(since) bucket_col = ( cast(func.extract('epoch', HttpResult.checked_at), Integer) / b_secs ).label("bucket") async with current_app.db_sessionmaker() as db: result = await db.execute( select(HttpMonitor).order_by(HttpMonitor.created_at) ) monitors = list(result.scalars()) # Latest result per monitor latest_map: dict[str, HttpResult] = {} histories: dict[str, list] = {} for m in monitors: r = await db.execute( select(HttpResult) .where(HttpResult.monitor_id == m.id) .order_by(HttpResult.checked_at.desc()) .limit(1) ) latest = r.scalar_one_or_none() if latest: latest_map[m.id] = latest r2 = await db.execute( select( func.avg(HttpResult.response_ms).label("response_ms"), func.min(HttpResult.is_up.cast(Integer)).label("had_down"), bucket_col, ) .where(HttpResult.monitor_id == m.id) .where(HttpResult.checked_at >= since) .group_by(bucket_col) .order_by(bucket_col) ) histories[m.id] = r2.all() monitor_data = [] for m in monitors: hist = histories.get(m.id, []) latest = latest_map.get(m.id) now = datetime.now(timezone.utc) tls_days = None if latest and latest.tls_expires_at: tls_days = (latest.tls_expires_at - now).total_seconds() / 86400.0 monitor_data.append({ "monitor": m, "latest": latest, "tls_days": tls_days, "sparkline_ms": _sparkline([r.response_ms or 0 for r in hist]), }) up = sum(1 for d in monitor_data if d["latest"] and d["latest"].is_up) down = sum(1 for d in monitor_data if d["latest"] and not d["latest"].is_up) return monitor_data, up, down, range_key async def http_status_source(db) -> list[StatusEntry]: """Contribute enabled HTTP monitors to the unified Status page. Registered with steward.core.status from this plugin's setup() so the core Status surface can include HTTP without importing plugin tables directly. """ monitors = list((await db.execute( select(HttpMonitor).where(HttpMonitor.enabled == True) # noqa: E712 .order_by(HttpMonitor.created_at) )).scalars()) ids = [m.id for m in monitors] if not ids: return [] now = datetime.now(timezone.utc) c30, c7, c24 = now - timedelta(days=30), now - timedelta(days=7), now - timedelta(hours=24) up_res = await db.execute( select( HttpResult.monitor_id, func.count().label("total_30d"), func.sum(case((HttpResult.is_up == True, 1), else_=0)).label("up_30d"), # noqa: E712 func.sum(case((HttpResult.checked_at >= c7, 1), else_=0)).label("total_7d"), func.sum(case((and_(HttpResult.checked_at >= c7, HttpResult.is_up == True), 1), else_=0)).label("up_7d"), # noqa: E712 func.sum(case((HttpResult.checked_at >= c24, 1), else_=0)).label("total_24h"), func.sum(case((and_(HttpResult.checked_at >= c24, HttpResult.is_up == True), 1), else_=0)).label("up_24h"), # noqa: E712 ) .where(HttpResult.monitor_id.in_(ids)) .where(HttpResult.checked_at >= c30) .group_by(HttpResult.monitor_id) ) def _pct(u, t): return round(float(u) / float(t) * 100, 2) if t else None uptime = {r.monitor_id: { "24h": _pct(r.up_24h, r.total_24h), "7d": _pct(r.up_7d, r.total_7d), "30d": _pct(r.up_30d, r.total_30d), } for r in up_res} # Last N results per monitor (one query) for the heartbeat bar + sparkline. rn = func.row_number().over( partition_by=HttpResult.monitor_id, order_by=HttpResult.checked_at.desc() ).label("rn") subq = select(HttpResult.id, rn).where(HttpResult.monitor_id.in_(ids)).subquery() recent_res = await db.execute( select(HttpResult) .join(subq, HttpResult.id == subq.c.id) .where(subq.c.rn <= HEARTBEAT_COUNT) .order_by(HttpResult.monitor_id, HttpResult.checked_at.asc()) ) recent: dict[str, list] = {mid: [] for mid in ids} for row in recent_res.scalars(): recent[row.monitor_id].append(row) entries: list[StatusEntry] = [] for m in monitors: rows = recent.get(m.id, []) latest = rows[-1] if rows else None heartbeat = [{ "state": "up" if r.is_up else "down", "title": ( f"{r.status_code or 'โ€”'} ยท {r.response_ms:.0f} ms โ€” {r.checked_at:%H:%M:%S} UTC" if r.is_up and r.response_ms is not None else f"{'Up' if r.is_up else 'Down'} โ€” {r.checked_at:%H:%M:%S} UTC" ), } for r in rows] status = "pending" if latest is None else ("up" if latest.is_up else "down") tls_days = None if latest and latest.tls_expires_at: tls_days = (latest.tls_expires_at - now).total_seconds() / 86400.0 entries.append(StatusEntry( kind="http", key=m.id, name=m.name, target=m.url, status=status, last_checked=latest.checked_at if latest else None, uptime=uptime.get(m.id, {}), heartbeat=heartbeat, latency_ms=latest.response_ms if latest else None, spark=[r.response_ms for r in rows if r.response_ms is not None], tls_days=tls_days, detail_url="/plugins/http/", )) return entries @http_bp.get("/") @require_role(UserRole.viewer) async def index(): poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60) current_range = request.args.get("range", DEFAULT_RANGE) return await render_template( "http/index.html", poll_interval=poll_interval, current_range=current_range, ) @http_bp.get("/rows") @require_role(UserRole.viewer) async def rows(): """HTMX fragment: monitor list with status + sparklines.""" monitor_data, up, down, range_key = await _render_rows( request.args.get("range", DEFAULT_RANGE) ) return await render_template( "http/rows.html", monitor_data=monitor_data, up=up, down=down, range_key=range_key, ) @http_bp.post("/add") @require_role(UserRole.operator) async def add_monitor(): """Add a new HTTP monitor.""" form = await request.form url = form.get("url", "").strip() if not url: return redirect(url_for("http.index")) if not url.startswith(("http://", "https://")): url = "https://" + url monitor = HttpMonitor( id=str(uuid.uuid4()), name=form.get("name", "").strip() or url, url=url, method=form.get("method", "GET").upper(), expected_status=int(form.get("expected_status", 200) or 200), content_match=form.get("content_match", "").strip(), headers_json="{}", timeout_seconds=int(form.get("timeout_seconds", 10) or 10), check_interval_seconds=int(form.get("check_interval_seconds", 0) or 0), follow_redirects="follow_redirects" in form, verify_ssl="verify_ssl" in form, enabled=True, created_at=datetime.now(timezone.utc), ) async with current_app.db_sessionmaker() as db: async with db.begin(): db.add(monitor) return redirect(url_for("http.index")) @http_bp.post("//delete") @require_role(UserRole.operator) async def delete_monitor(monitor_id: str): async with current_app.db_sessionmaker() as db: async with db.begin(): m = await db.get(HttpMonitor, monitor_id) if m: await db.delete(m) return redirect(url_for("http.index")) @http_bp.post("//toggle") @require_role(UserRole.operator) async def toggle_monitor(monitor_id: str): async with current_app.db_sessionmaker() as db: async with db.begin(): m = await db.get(HttpMonitor, monitor_id) if m: m.enabled = not m.enabled return redirect(url_for("http.index")) @http_bp.get("/widget") @require_role(UserRole.viewer) async def widget(): """HTMX dashboard widget: up/down counts + monitor list.""" show_down_only = request.args.get("show_down_only", "no") == "yes" limit = max(1, min(20, int(request.args.get("limit", 10) or 10))) widget_id = request.args.get("wid", "0") async with current_app.db_sessionmaker() as db: result = await db.execute( select(HttpMonitor) .where(HttpMonitor.enabled == True) # noqa: E712 .order_by(HttpMonitor.created_at) ) monitors = list(result.scalars()) latest_map: dict[str, HttpResult] = {} for m in monitors: r = await db.execute( select(HttpResult) .where(HttpResult.monitor_id == m.id) .order_by(HttpResult.checked_at.desc()) .limit(1) ) latest = r.scalar_one_or_none() if latest: latest_map[m.id] = latest monitor_data = [ {"monitor": m, "latest": latest_map.get(m.id)} for m in monitors ] up = sum(1 for d in monitor_data if d["latest"] and d["latest"].is_up) down = sum(1 for d in monitor_data if d["latest"] and not d["latest"].is_up) pending = sum(1 for d in monitor_data if not d["latest"]) if show_down_only: monitor_data = [d for d in monitor_data if not (d["latest"] and d["latest"].is_up)] return await render_template( "http/widget.html", monitor_data=monitor_data[:limit], up=up, down=down, pending=pending, show_down_only=show_down_only, widget_id=widget_id, )