# plugins/http/routes.py from __future__ import annotations import json import uuid from datetime import datetime, timezone from quart import Blueprint, current_app, render_template, request, redirect, url_for from sqlalchemy import Integer, cast, func, select from fabledscryer.auth.middleware import require_role from fabledscryer.models.users import UserRole from fabledscryer.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 @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, )