Initial commit: API gateway with admin console
- FastAPI async gateway with httpx proxying to multiple upstreams - SQLite database with SQLAlchemy ORM - Admin console: manage services, users, API keys, endpoint access - Per-key, per-endpoint granular access control - OpenAPI document sync and caching (5-minute TTL) - Request/response logging with full transaction inspection - In-memory rate limiting (per-key, fixed-window) - Tiered log retention (7d payloads, 90d rows, incremental vacuum) - TLS verification toggle per service (for self-signed certificates) - Service connectivity validation with automatic endpoint refresh - Request browser with filters and deep-link inspection - Docker setup with persistent volume - Modal forms for create/edit flows Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
"""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]}
|
||||
Reference in New Issue
Block a user