"""JSON endpoints backing the monitoring charts. All endpoints accept: hours — window size (1..720) service_id — restrict to one service user_id — restrict to keys owned by one user key_id — restrict to one API key Time series use adaptive buckets: 5 min (<=6 h), 1 h (<=48 h), 1 day beyond. SQLite-specific date functions are used (see README). """ import time from datetime import datetime, timedelta, timezone from fastapi import APIRouter, Depends, Query from sqlalchemy import Integer, func from sqlalchemy.orm import Query as OrmQuery, Session from app.admin.deps import current_user from app.database import get_db from app.models import ApiKey, Endpoint, RequestLog, Service, User router = APIRouter(prefix="/admin/api", dependencies=[Depends(current_user)]) class Filters: def __init__( self, hours: int = Query(24, ge=1, le=720), service_id: int | None = Query(None), user_id: int | None = Query(None), key_id: int | None = Query(None), ): self.hours = hours self.since = datetime.now(timezone.utc) - timedelta(hours=hours) self.service_id = service_id self.user_id = user_id self.key_id = key_id def apply(self, q: OrmQuery, joined_key: bool = False) -> OrmQuery: """Apply window + filters. Pass joined_key when the query already joins ApiKey so we don't join twice.""" q = q.filter(RequestLog.timestamp >= self.since) if self.service_id: q = q.filter(RequestLog.service_id == self.service_id) if self.key_id: q = q.filter(RequestLog.api_key_id == self.key_id) if self.user_id: if not joined_key: q = q.join(ApiKey, RequestLog.api_key_id == ApiKey.id) q = q.filter(ApiKey.user_id == self.user_id) return q def _bucket_seconds(hours: int) -> int: if hours <= 6: return 300 if hours <= 48: return 3600 return 86400 def _label_format(hours: int, bucket: int) -> str: if bucket < 3600: return "%H:%M" if bucket == 3600: return "%H:00" if hours <= 24 else "%m-%d %H:00" return "%Y-%m-%d" def _bucket_expr(bucket: int): # epoch - (epoch % bucket) floors to the bucket start; plain `/` must be # avoided because SQLAlchemy renders it as true (float) division. epoch = func.cast(func.strftime("%s", RequestLog.timestamp), Integer) return epoch - (epoch % bucket) def _series(f: Filters, db: Session, *aggregates): """Group the filtered logs into time buckets and fill gaps with None. Returns (labels, [values-per-aggregate]).""" bucket = _bucket_seconds(f.hours) fmt = _label_format(f.hours, bucket) expr = _bucket_expr(bucket).label("bucket") rows = ( f.apply(db.query(expr, *aggregates)) .group_by("bucket").order_by("bucket").all() ) found = {int(r[0]): r for r in rows} start = int(f.since.timestamp()) // bucket * bucket now = int(time.time()) labels: list[str] = [] series: list[list] = [[] for _ in aggregates] for t in range(start, now + 1, bucket): labels.append(datetime.fromtimestamp(t, timezone.utc).strftime(fmt)) row = found.get(t) for i in range(len(aggregates)): series[i].append(row[i + 1] if row else None) return labels, series @router.get("/stats/summary") def summary(f: Filters = Depends(), db: Session = Depends(get_db)): total = f.apply(db.query(func.count(RequestLog.id))).scalar() or 0 errors = f.apply(db.query(func.count(RequestLog.id))).filter( RequestLog.status_code >= 500).scalar() or 0 avg_latency = f.apply(db.query(func.avg(RequestLog.latency_ms))).scalar() or 0 return { "total_requests": total, "error_count": errors, "error_rate": round(errors / total * 100, 2) if total else 0, "avg_latency_ms": round(avg_latency, 1), "active_services": db.query(Service).filter(Service.is_active).count(), "active_keys": db.query(ApiKey).filter(ApiKey.is_active).count(), } @router.get("/stats/timeseries") def timeseries(f: Filters = Depends(), db: Session = Depends(get_db)): labels, (ok, errors) = _series( f, db, func.sum(func.iif(RequestLog.status_code < 500, 1, 0)), func.sum(func.iif(RequestLog.status_code >= 500, 1, 0)), ) return { "labels": labels, "ok": [int(v) if v is not None else 0 for v in ok], "errors": [int(v) if v is not None else 0 for v in errors], } @router.get("/stats/latency-timeseries") def latency_timeseries(f: Filters = Depends(), db: Session = Depends(get_db)): labels, (avg_ms,) = _series(f, db, func.avg(RequestLog.latency_ms)) return { "labels": labels, "avg_ms": [round(v, 1) if v is not None else None for v in avg_ms], } @router.get("/stats/by-service") def by_service(f: Filters = Depends(), db: Session = Depends(get_db)): q = ( db.query(Service.name, func.count(RequestLog.id).label("count")) .join(RequestLog, RequestLog.service_id == Service.id) ) rows = ( f.apply(q) .group_by(Service.id) .order_by(func.count(RequestLog.id).desc()) .all() ) return {"labels": [r.name for r in rows], "counts": [r.count for r in rows]} @router.get("/stats/by-endpoint") def by_endpoint(f: Filters = Depends(), db: Session = Depends(get_db)): q = ( db.query(Service.slug, Endpoint.method, Endpoint.path, func.count(RequestLog.id).label("count")) .select_from(RequestLog) .join(Endpoint, RequestLog.endpoint_id == Endpoint.id) .join(Service, Endpoint.service_id == Service.id) ) rows = ( f.apply(q) .group_by(Endpoint.id) .order_by(func.count(RequestLog.id).desc()) .limit(15) .all() ) return { "labels": [f"{r.slug}: {r.method} {r.path}" for r in rows], "counts": [r.count for r in rows], } @router.get("/stats/by-status") def by_status(f: Filters = Depends(), db: Session = Depends(get_db)): q = ( db.query(RequestLog.status_code, func.count(RequestLog.id)) ) rows = ( f.apply(q) .group_by(RequestLog.status_code) .order_by(RequestLog.status_code) .all() ) return {"labels": [str(r[0]) for r in rows], "counts": [r[1] for r in rows]} @router.get("/stats/latency-by-service") def latency_by_service(f: Filters = Depends(), db: Session = Depends(get_db)): q = ( db.query(Service.name, func.avg(RequestLog.latency_ms).label("avg_ms")) .join(RequestLog, RequestLog.service_id == Service.id) ) rows = ( f.apply(q) .group_by(Service.id) .order_by(func.avg(RequestLog.latency_ms).desc()) .all() ) return {"labels": [r.name for r in rows], "avg_ms": [round(r.avg_ms, 1) for r in rows]} @router.get("/stats/top-keys") def top_keys(f: Filters = Depends(), db: Session = Depends(get_db)): q = ( db.query(ApiKey.name, func.count(RequestLog.id).label("count")) .join(RequestLog, RequestLog.api_key_id == ApiKey.id) ) rows = ( f.apply(q, joined_key=True) .group_by(ApiKey.id) .order_by(func.count(RequestLog.id).desc()) .limit(10) .all() ) return {"labels": [r.name for r in rows], "counts": [r.count for r in rows]} @router.get("/stats/by-user") def by_user(f: Filters = Depends(), db: Session = Depends(get_db)): q = ( db.query(User.username, func.count(RequestLog.id).label("count")) .select_from(RequestLog) .join(ApiKey, RequestLog.api_key_id == ApiKey.id) .join(User, ApiKey.user_id == User.id) ) rows = ( f.apply(q, joined_key=True) .group_by(User.id) .order_by(func.count(RequestLog.id).desc()) .limit(15) .all() ) return {"labels": [r.username for r in rows], "counts": [r.count for r in rows]}