# plugins/traefik/routes.py from __future__ import annotations from collections import defaultdict from datetime import datetime, timezone from quart import Blueprint, current_app, render_template, request from sqlalchemy import Integer, cast, func, select import json 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, subsample from .models import TraefikAccessSummary, TraefikCert, TraefikGlobalStat, TraefikMetric traefik_bp = Blueprint("traefik", __name__, template_folder="templates") def _sparkline(values: list[float], width: int = 80, height: int = 20) -> str: """Generate an inline SVG sparkline from a list of float values.""" 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'' ) @traefik_bp.get("/") @require_role(UserRole.viewer) async def index(): poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60) _al_cfg = current_app.config["PLUGINS"]["traefik"].get("access_log", {}) if not isinstance(_al_cfg, dict): _al_cfg = {} access_log_enabled = _al_cfg.get("enabled", False) current_range = request.args.get("range", DEFAULT_RANGE) return await render_template( "traefik/index.html", poll_interval=poll_interval, access_log_enabled=access_log_enabled, current_range=current_range, ) @traefik_bp.get("/rows") @require_role(UserRole.viewer) async def rows(): """HTMX fragment: service cards with sparklines scoped to selected time range.""" since, range_key = parse_range(request.args.get("range")) now = datetime.now(timezone.utc) async with current_app.db_sessionmaker() as db: # All known routers result = await db.execute( select(TraefikMetric.router_name) .distinct() .order_by(TraefikMetric.router_name) ) routers = [row[0] for row in result.all()] b_secs = bucket_seconds(since) bucket_col = ( cast(func.strftime('%s', TraefikMetric.scraped_at), Integer) / b_secs ).label("bucket") router_data = [] for router in routers: # Bucketed history for sparklines — always ~80 points regardless of range result = await db.execute( select( func.avg(TraefikMetric.request_rate).label("request_rate"), func.avg(TraefikMetric.latency_p95_ms).label("latency_p95_ms"), func.avg(TraefikMetric.error_rate_5xx_pct).label("error_rate_5xx_pct"), func.min(TraefikMetric.scraped_at).label("scraped_at"), bucket_col, ) .where(TraefikMetric.router_name == router) .where(TraefikMetric.scraped_at >= since) .group_by(bucket_col) .order_by(bucket_col) ) history = result.all() # Latest value — always the most recent raw row result = await db.execute( select(TraefikMetric) .where(TraefikMetric.router_name == router) .order_by(TraefikMetric.scraped_at.desc()) .limit(1) ) latest = result.scalar_one_or_none() if not latest: continue router_data.append({ "name": router, "latest": latest, "sparkline_req": _sparkline([r.request_rate for r in history]), "sparkline_p95": _sparkline([r.latency_p95_ms for r in history]), "sparkline_5xx": _sparkline([r.error_rate_5xx_pct for r in history]), }) # Latest global stat (always most recent, not range-filtered) result = await db.execute( select(TraefikGlobalStat) .order_by(TraefikGlobalStat.scraped_at.desc()) .limit(1) ) global_stat = result.scalar_one_or_none() # All certs, sorted by soonest expiry result = await db.execute( select(TraefikCert).order_by(TraefikCert.not_after) ) raw_certs = result.scalars().all() certs = [] for c in raw_certs: days = (c.not_after - now).total_seconds() / 86400.0 certs.append({ "serial": c.serial, "cn": c.cn or c.serial, "sans": c.sans, "not_after": c.not_after, "days_remaining": days, }) return await render_template( "traefik/rows.html", router_data=router_data, global_stat=global_stat, certs=certs, range_key=range_key, ) @traefik_bp.get("/widget") @require_role(UserRole.viewer) async def widget(): """HTMX dashboard widget fragment.""" now = datetime.now(timezone.utc) async with current_app.db_sessionmaker() as db: result = await db.execute( select(TraefikMetric.router_name).distinct().order_by(TraefikMetric.router_name) ) routers = [row[0] for row in result.all()] router_data = [] for router in routers: result = await db.execute( select(TraefikMetric) .where(TraefikMetric.router_name == router) .order_by(TraefikMetric.scraped_at.desc()) .limit(1) ) latest = result.scalar_one_or_none() if latest: router_data.append({"name": router, "latest": latest}) router_data.sort(key=lambda r: ( -(1 if r["latest"].error_rate_5xx_pct > 0.1 else 0), -(1 if r["latest"].error_rate_4xx_pct > 5.0 else 0), -r["latest"].request_rate, )) total_routers = len(router_data) router_data = router_data[:5] result = await db.execute( select(TraefikCert).order_by(TraefikCert.not_after) ) raw_certs = result.scalars().all() expiring_certs = [] for c in raw_certs: days = (c.not_after - now).total_seconds() / 86400.0 if days < 30: expiring_certs.append({"cn": c.cn or c.serial, "days_remaining": days}) return await render_template( "traefik/widget.html", router_data=router_data, expiring_certs=expiring_certs, total_routers=total_routers, ) @traefik_bp.get("/traffic") @require_role(UserRole.viewer) async def traffic(): """HTMX fragment: traffic origin summary scoped to selected time range.""" since, range_key = parse_range(request.args.get("range")) async with current_app.db_sessionmaker() as db: result = await db.execute( select(TraefikAccessSummary) .where(TraefikAccessSummary.period_end >= since) .order_by(TraefikAccessSummary.period_end.asc()) # No LIMIT — all summaries in range needed for accurate totals; # sparkline subsamples to 80 points afterwards ) rows = result.scalars().all() if not rows: return await render_template( "traefik/traffic.html", summary=None, history=[], range_key=range_key ) total = sum(r.total_requests for r in rows) internal = sum(r.internal_requests for r in rows) external = sum(r.external_requests for r in rows) total_bytes = sum(r.total_bytes for r in rows) ip_counts: dict = defaultdict(lambda: {"count": 0, "internal": True, "country": None}) country_counts: dict[str, int] = defaultdict(int) router_counts: dict[str, int] = defaultdict(int) for row in rows: for entry in json.loads(row.top_ips_json): k = entry["ip"] ip_counts[k]["count"] += entry["count"] ip_counts[k]["internal"] = entry["internal"] ip_counts[k]["country"] = entry.get("country") for entry in json.loads(row.top_countries_json): country_counts[entry["country"]] += entry["count"] for entry in json.loads(row.top_routers_json): router_counts[entry["router"]] += entry["count"] top_ips = sorted( [{"ip": k, **v} for k, v in ip_counts.items()], key=lambda x: x["count"], reverse=True )[:10] top_countries = sorted( [{"country": k, "count": v} for k, v in country_counts.items()], key=lambda x: x["count"], reverse=True )[:10] top_routers = sorted( [{"router": k, "count": v} for k, v in router_counts.items()], key=lambda x: x["count"], reverse=True )[:10] history = [ { "period_end": r.period_end, "external_pct": (r.external_requests / r.total_requests * 100.0) if r.total_requests else 0.0, } for r in rows ] summary = { "total": total, "internal": internal, "external": external, "total_bytes": total_bytes, "external_pct": (external / total * 100.0) if total else 0.0, "top_ips": top_ips, "top_countries": top_countries, "top_routers": top_routers, "sparkline_external": _sparkline( [h["external_pct"] for h in subsample(history, 80)] ), } return await render_template( "traefik/traffic.html", summary=summary, history=history, range_key=range_key )