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,29 @@
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import config, security
|
||||
from app.database import get_db
|
||||
from app.models import User
|
||||
|
||||
|
||||
class LoginRequired(HTTPException):
|
||||
"""Raised when there is no valid session; handled by redirecting to /admin/login."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
def login_redirect_handler(request: Request, exc: LoginRequired):
|
||||
return RedirectResponse("/admin/login", status_code=303)
|
||||
|
||||
|
||||
def current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
||||
token = request.cookies.get(config.SESSION_COOKIE)
|
||||
user_id = security.read_session_token(token) if token else None
|
||||
if user_id is None:
|
||||
raise LoginRequired()
|
||||
user = db.get(User, user_id)
|
||||
if user is None or not user.is_active:
|
||||
raise LoginRequired()
|
||||
return user
|
||||
@@ -0,0 +1,423 @@
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import config, discovery, security
|
||||
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")
|
||||
templates = Jinja2Templates(directory=str(config.BASE_DIR / "app" / "templates"))
|
||||
|
||||
# Slugs the proxy catch-all must never claim
|
||||
RESERVED_SLUGS = {"admin", "static", "health", "docs", "redoc", "openapi.json"}
|
||||
|
||||
|
||||
def build_tree(endpoints: list[Endpoint]) -> dict:
|
||||
"""Nest endpoints by path segment for the hierarchical access picker.
|
||||
Node: {name, children: {segment: node}, endpoints: [Endpoint]}.
|
||||
Chains of empty single-child nodes are compressed ('api' + 'v1' -> 'api/v1')."""
|
||||
root = {"name": "", "children": {}, "endpoints": []}
|
||||
for e in endpoints:
|
||||
node = root
|
||||
for part in (p for p in e.path.split("/") if p):
|
||||
node = node["children"].setdefault(
|
||||
part, {"name": part, "children": {}, "endpoints": []})
|
||||
node["endpoints"].append(e)
|
||||
|
||||
def compress(node: dict) -> None:
|
||||
for key in list(node["children"]):
|
||||
child = node["children"][key]
|
||||
while not child["endpoints"] and len(child["children"]) == 1:
|
||||
(grandchild,) = child["children"].values()
|
||||
child["name"] = child["name"] + "/" + grandchild["name"]
|
||||
child["endpoints"] = grandchild["endpoints"]
|
||||
child["children"] = grandchild["children"]
|
||||
compress(child)
|
||||
if child["name"] != key:
|
||||
node["children"][child["name"]] = node["children"].pop(key)
|
||||
|
||||
compress(root)
|
||||
return root
|
||||
|
||||
|
||||
def render(request: Request, name: str, user: User | None = None, **ctx):
|
||||
return templates.TemplateResponse(
|
||||
request, name, {"user": user, "active": name.split(".")[0], **ctx}
|
||||
)
|
||||
|
||||
|
||||
def _redirect(url: str) -> RedirectResponse:
|
||||
return RedirectResponse(url, status_code=303)
|
||||
|
||||
|
||||
# ---------- auth ----------
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
def login_page(request: Request):
|
||||
return render(request, "login.html")
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def login(request: Request, username: str = Form(...), password: str = Form(...),
|
||||
db: Session = Depends(get_db)):
|
||||
user = db.query(User).filter(User.username == username).one_or_none()
|
||||
if not user or not user.is_active or not security.verify_password(password, user.password_hash):
|
||||
return render(request, "login.html", error="Invalid username or password.")
|
||||
response = _redirect("/admin")
|
||||
response.set_cookie(
|
||||
config.SESSION_COOKIE,
|
||||
security.create_session_token(user.id),
|
||||
max_age=config.SESSION_MAX_AGE,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/logout")
|
||||
def logout():
|
||||
response = _redirect("/admin/login")
|
||||
response.delete_cookie(config.SESSION_COOKIE)
|
||||
return response
|
||||
|
||||
|
||||
# ---------- dashboard ----------
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
def dashboard(request: Request, user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db)):
|
||||
recent = (
|
||||
db.query(RequestLog).order_by(RequestLog.timestamp.desc()).limit(15).all()
|
||||
)
|
||||
return render(request, "dashboard.html", user, recent=recent)
|
||||
|
||||
|
||||
# ---------- services & endpoints ----------
|
||||
|
||||
@router.get("/services", response_class=HTMLResponse)
|
||||
def services_page(request: Request, user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db)):
|
||||
services = db.query(Service).order_by(Service.name).all()
|
||||
counts = dict(
|
||||
db.query(RequestLog.service_id, func.count(RequestLog.id))
|
||||
.group_by(RequestLog.service_id).all()
|
||||
)
|
||||
return render(request, "services.html", user, services=services, counts=counts)
|
||||
|
||||
|
||||
@router.post("/services")
|
||||
def create_service(name: str = Form(...), slug: str = Form(...), base_url: str = Form(...),
|
||||
description: str = Form(""), timeout_seconds: float = Form(30.0),
|
||||
verify_tls: bool = Form(False),
|
||||
user: User = Depends(current_user), db: Session = Depends(get_db)):
|
||||
slug = slug.strip().lower()
|
||||
if slug in RESERVED_SLUGS:
|
||||
raise HTTPException(400, f"Slug '{slug}' is reserved by the gateway itself.")
|
||||
if db.query(Service).filter(Service.slug == slug).count():
|
||||
raise HTTPException(400, f"Slug '{slug}' is already taken.")
|
||||
service = Service(name=name.strip(), slug=slug, base_url=base_url.strip().rstrip("/"),
|
||||
description=description.strip(), timeout_seconds=timeout_seconds,
|
||||
verify_tls=verify_tls)
|
||||
db.add(service)
|
||||
db.commit()
|
||||
# The page auto-validates the new service (which also caches its endpoints).
|
||||
return _redirect(f"/admin/services?validate={service.id}")
|
||||
|
||||
|
||||
@router.post("/services/{service_id}/update")
|
||||
def update_service(service_id: int, name: str = Form(...), base_url: str = Form(...),
|
||||
description: str = Form(""), timeout_seconds: float = Form(30.0),
|
||||
verify_tls: bool = Form(False),
|
||||
user: User = Depends(current_user), db: Session = Depends(get_db)):
|
||||
service = db.get(Service, service_id)
|
||||
if not service:
|
||||
raise HTTPException(404)
|
||||
service.name, service.base_url = name.strip(), base_url.strip().rstrip("/")
|
||||
service.description, service.timeout_seconds = description.strip(), timeout_seconds
|
||||
service.verify_tls = verify_tls
|
||||
db.commit()
|
||||
return _redirect(f"/admin/services?validate={service.id}")
|
||||
|
||||
|
||||
@router.post("/services/{service_id}/validate")
|
||||
def validate_service(service_id: int, user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db)):
|
||||
"""Probe the upstream; if it publishes an OpenAPI document this also
|
||||
refreshes the endpoint cache."""
|
||||
service = db.get(Service, service_id)
|
||||
if not service:
|
||||
raise HTTPException(404)
|
||||
return discovery.validate_service(db, service)
|
||||
|
||||
|
||||
@router.post("/services/{service_id}/toggle")
|
||||
def toggle_service(service_id: int, user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db)):
|
||||
service = db.get(Service, service_id)
|
||||
if not service:
|
||||
raise HTTPException(404)
|
||||
service.is_active = not service.is_active
|
||||
db.commit()
|
||||
return _redirect("/admin/services")
|
||||
|
||||
|
||||
@router.post("/services/{service_id}/delete")
|
||||
def delete_service(service_id: int, user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db)):
|
||||
service = db.get(Service, service_id)
|
||||
if service:
|
||||
db.delete(service)
|
||||
db.commit()
|
||||
return _redirect("/admin/services")
|
||||
|
||||
|
||||
# ---------- users ----------
|
||||
|
||||
@router.get("/users", response_class=HTMLResponse)
|
||||
def users_page(request: Request, user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db)):
|
||||
users = db.query(User).order_by(User.username).all()
|
||||
return render(request, "users.html", user, users=users)
|
||||
|
||||
|
||||
@router.post("/users")
|
||||
def create_user(username: str = Form(...), password: str = Form(...),
|
||||
user: User = Depends(current_user), db: Session = Depends(get_db)):
|
||||
username = username.strip()
|
||||
if db.query(User).filter(User.username == username).count():
|
||||
raise HTTPException(400, f"Username '{username}' is already taken.")
|
||||
db.add(User(username=username, password_hash=security.hash_password(password)))
|
||||
db.commit()
|
||||
return _redirect("/admin/users")
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/toggle")
|
||||
def toggle_user(user_id: int, user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db)):
|
||||
target = db.get(User, user_id)
|
||||
if not target:
|
||||
raise HTTPException(404)
|
||||
if target.id == user.id:
|
||||
raise HTTPException(400, "You cannot deactivate your own account.")
|
||||
target.is_active = not target.is_active
|
||||
db.commit()
|
||||
return _redirect("/admin/users")
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/password")
|
||||
def reset_password(user_id: int, password: str = Form(...),
|
||||
user: User = Depends(current_user), db: Session = Depends(get_db)):
|
||||
target = db.get(User, user_id)
|
||||
if not target:
|
||||
raise HTTPException(404)
|
||||
target.password_hash = security.hash_password(password)
|
||||
db.commit()
|
||||
return _redirect("/admin/users")
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/delete")
|
||||
def delete_user(user_id: int, user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db)):
|
||||
if user_id == user.id:
|
||||
raise HTTPException(400, "You cannot delete your own account.")
|
||||
target = db.get(User, user_id)
|
||||
if target:
|
||||
db.delete(target)
|
||||
db.commit()
|
||||
return _redirect("/admin/users")
|
||||
|
||||
|
||||
# ---------- API keys / endpoint access ----------
|
||||
|
||||
@router.get("/keys", response_class=HTMLResponse)
|
||||
def keys_page(request: Request, user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db)):
|
||||
services = db.query(Service).order_by(Service.name).all()
|
||||
# Rendered straight from the database — the OpenAPI sync runs in the
|
||||
# background via /admin/api/endpoints/sync, triggered by the page's JS.
|
||||
trees = {s.id: build_tree(s.endpoints) for s in services}
|
||||
synced = {s.id: discovery.spec_status(s.id) for s in services}
|
||||
keys = db.query(ApiKey).order_by(ApiKey.created_at.desc()).all()
|
||||
users = db.query(User).filter(User.is_active).order_by(User.username).all()
|
||||
new_key = request.query_params.get("new_key")
|
||||
return render(request, "keys.html", user, keys=keys, users=users,
|
||||
services=services, trees=trees, synced=synced, new_key=new_key)
|
||||
|
||||
|
||||
@router.get("/api/endpoints/sync")
|
||||
def sync_endpoints(force: bool = False, user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db)):
|
||||
"""Refresh all endpoint catalogs from their OpenAPI documents (TTL-cached).
|
||||
The keys page calls this in the background and reloads if anything changed."""
|
||||
changed_any = False
|
||||
specs = {}
|
||||
for service in db.query(Service).order_by(Service.name).all():
|
||||
found, changed = discovery.sync_service(db, service, force=force)
|
||||
specs[service.id] = found
|
||||
changed_any = changed_any or changed
|
||||
return {"changed": changed_any, "specs": specs}
|
||||
|
||||
|
||||
@router.post("/keys")
|
||||
def create_key(request: Request, name: str = Form(...), user_id: int = Form(...),
|
||||
rate_limit_per_minute: int = Form(60),
|
||||
endpoint_ids: list[int] = Form([]),
|
||||
user: User = Depends(current_user), db: Session = Depends(get_db)):
|
||||
owner = db.get(User, user_id)
|
||||
if not owner:
|
||||
raise HTTPException(400, "Unknown user.")
|
||||
plain, prefix, key_hash = security.generate_api_key()
|
||||
key = ApiKey(user_id=owner.id, name=name.strip(), prefix=prefix, key_hash=key_hash,
|
||||
rate_limit_per_minute=max(0, rate_limit_per_minute))
|
||||
key.endpoints = db.query(Endpoint).filter(
|
||||
Endpoint.id.in_(endpoint_ids)).all() if endpoint_ids else []
|
||||
db.add(key)
|
||||
db.commit()
|
||||
# Shown once on the next page load; never stored in plain text.
|
||||
return _redirect(f"/admin/keys?new_key={plain}")
|
||||
|
||||
|
||||
@router.post("/keys/{key_id}/access")
|
||||
def update_key_access(key_id: int, endpoint_ids: list[int] = Form([]),
|
||||
rate_limit_per_minute: int = Form(60),
|
||||
user: User = Depends(current_user), db: Session = Depends(get_db)):
|
||||
key = db.get(ApiKey, key_id)
|
||||
if not key:
|
||||
raise HTTPException(404)
|
||||
key.endpoints = db.query(Endpoint).filter(
|
||||
Endpoint.id.in_(endpoint_ids)).all() if endpoint_ids else []
|
||||
key.rate_limit_per_minute = max(0, rate_limit_per_minute)
|
||||
db.commit()
|
||||
return _redirect("/admin/keys")
|
||||
|
||||
|
||||
@router.post("/keys/{key_id}/toggle")
|
||||
def toggle_key(key_id: int, user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db)):
|
||||
key = db.get(ApiKey, key_id)
|
||||
if not key:
|
||||
raise HTTPException(404)
|
||||
key.is_active = not key.is_active
|
||||
db.commit()
|
||||
return _redirect("/admin/keys")
|
||||
|
||||
|
||||
@router.post("/keys/{key_id}/delete")
|
||||
def delete_key(key_id: int, user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db)):
|
||||
key = db.get(ApiKey, key_id)
|
||||
if key:
|
||||
db.delete(key)
|
||||
db.commit()
|
||||
return _redirect("/admin/keys")
|
||||
|
||||
|
||||
# ---------- request browser ----------
|
||||
|
||||
PAGE_SIZE = 50
|
||||
|
||||
|
||||
def _int_or_none(value: str | None) -> int | None:
|
||||
"""HTML GET forms submit empty strings for untouched fields — treat
|
||||
anything non-numeric as 'no filter' instead of a validation error."""
|
||||
try:
|
||||
return int(value) if value else None
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/requests", response_class=HTMLResponse)
|
||||
def requests_page(request: Request, user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
service_id: str | None = None, user_id: str | None = None,
|
||||
key_id: str | None = None, status_class: str | None = None,
|
||||
q: str | None = None, page: str | None = None):
|
||||
service_id = _int_or_none(service_id)
|
||||
user_id = _int_or_none(user_id)
|
||||
key_id = _int_or_none(key_id)
|
||||
page = _int_or_none(page) or 1
|
||||
query = db.query(RequestLog)
|
||||
if service_id:
|
||||
query = query.filter(RequestLog.service_id == service_id)
|
||||
if key_id:
|
||||
query = query.filter(RequestLog.api_key_id == key_id)
|
||||
if user_id:
|
||||
query = query.join(ApiKey, RequestLog.api_key_id == ApiKey.id).filter(
|
||||
ApiKey.user_id == user_id)
|
||||
if status_class in ("2", "3", "4", "5"):
|
||||
low = int(status_class) * 100
|
||||
query = query.filter(RequestLog.status_code >= low,
|
||||
RequestLog.status_code < low + 100)
|
||||
if q:
|
||||
query = query.filter(RequestLog.path.contains(q))
|
||||
|
||||
total = query.count()
|
||||
page = max(1, page)
|
||||
logs = (query.order_by(RequestLog.timestamp.desc())
|
||||
.offset((page - 1) * PAGE_SIZE).limit(PAGE_SIZE).all())
|
||||
|
||||
services = db.query(Service).order_by(Service.name).all()
|
||||
users = db.query(User).order_by(User.username).all()
|
||||
keys = db.query(ApiKey).order_by(ApiKey.name).all()
|
||||
return render(request, "requests.html", user, logs=logs, total=total,
|
||||
page=page, pages=max(1, -(-total // PAGE_SIZE)),
|
||||
services=services, users=users, keys=keys,
|
||||
f={"service_id": service_id, "user_id": user_id, "key_id": key_id,
|
||||
"status_class": status_class or "", "q": q or ""})
|
||||
|
||||
|
||||
def _pretty_json(text: str) -> str:
|
||||
import json
|
||||
try:
|
||||
return json.dumps(json.loads(text), indent=2, ensure_ascii=False)
|
||||
except (ValueError, TypeError):
|
||||
return text
|
||||
|
||||
|
||||
@router.get("/requests/{log_id}/data")
|
||||
def request_data(log_id: int, user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db)):
|
||||
"""Everything the inline request inspector needs, as JSON."""
|
||||
log = db.get(RequestLog, log_id)
|
||||
if not log:
|
||||
raise HTTPException(404)
|
||||
return {
|
||||
"id": log.id,
|
||||
"time": log.timestamp.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"status": log.status_code,
|
||||
"latency_ms": round(log.latency_ms, 1),
|
||||
"method": log.method,
|
||||
"path": log.path,
|
||||
"query_string": log.query_string,
|
||||
"service": log.service.name if log.service else None,
|
||||
"slug": log.service.slug if log.service else None,
|
||||
"forwarded_to": (log.service.base_url + log.path +
|
||||
("?" + log.query_string if log.query_string else ""))
|
||||
if log.service else None,
|
||||
"endpoint": f"{log.endpoint.method} {log.endpoint.path}" if log.endpoint else None,
|
||||
"endpoint_description": log.endpoint.description if log.endpoint else "",
|
||||
"key": log.api_key.name if log.api_key else None,
|
||||
"key_prefix": log.api_key.prefix if log.api_key else "",
|
||||
"user": log.api_key.user.username if log.api_key else None,
|
||||
"client_ip": log.client_ip,
|
||||
"request_body": _pretty_json(log.request_body),
|
||||
"response_body": _pretty_json(log.response_body),
|
||||
}
|
||||
|
||||
|
||||
# ---------- monitoring ----------
|
||||
|
||||
@router.get("/monitoring", response_class=HTMLResponse)
|
||||
def monitoring_page(request: Request, user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db)):
|
||||
logs = db.query(RequestLog).order_by(RequestLog.timestamp.desc()).limit(100).all()
|
||||
services = db.query(Service).order_by(Service.name).all()
|
||||
users = db.query(User).order_by(User.username).all()
|
||||
keys = db.query(ApiKey).order_by(ApiKey.name).all()
|
||||
return render(request, "monitoring.html", user, logs=logs,
|
||||
services=services, users=users, keys=keys)
|
||||
@@ -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]}
|
||||
@@ -0,0 +1,21 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
DATABASE_URL = os.environ.get("GATEWAY_DATABASE_URL", f"sqlite:///{BASE_DIR / 'gateway.db'}")
|
||||
SECRET_KEY = os.environ.get("GATEWAY_SECRET_KEY", "dev-secret-change-me")
|
||||
SESSION_COOKIE = "gw_session"
|
||||
SESSION_MAX_AGE = 60 * 60 * 8 # 8 hours
|
||||
|
||||
# Default admin credentials seeded on first startup (change after first login)
|
||||
DEFAULT_ADMIN_USERNAME = os.environ.get("GATEWAY_ADMIN_USER", "admin")
|
||||
DEFAULT_ADMIN_PASSWORD = os.environ.get("GATEWAY_ADMIN_PASSWORD", "admin")
|
||||
|
||||
PROXY_DEFAULT_TIMEOUT = 30.0
|
||||
API_KEY_HEADER = "X-API-Key"
|
||||
|
||||
# Retention (0 = keep forever). Payloads are blanked after the first window;
|
||||
# whole log rows are deleted after the second.
|
||||
PAYLOAD_RETENTION_DAYS = int(os.environ.get("GATEWAY_PAYLOAD_RETENTION_DAYS", "7"))
|
||||
LOG_RETENTION_DAYS = int(os.environ.get("GATEWAY_LOG_RETENTION_DAYS", "90"))
|
||||
@@ -0,0 +1,22 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||
|
||||
from app import config
|
||||
|
||||
engine = create_engine(
|
||||
config.DATABASE_URL,
|
||||
connect_args={"check_same_thread": False} if config.DATABASE_URL.startswith("sqlite") else {},
|
||||
)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Keep service endpoint catalogs in sync with the upstream's OpenAPI document."""
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Endpoint, Service
|
||||
|
||||
METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}
|
||||
|
||||
SPEC_PATHS = ("/openapi.json", "/swagger.json", "/api-docs")
|
||||
|
||||
# Syncing hits the upstream over HTTP, so results are cached per service and
|
||||
# only refreshed after the TTL (or on demand with force=True).
|
||||
SYNC_TTL_SECONDS = 300
|
||||
_last_sync: dict[int, float] = {} # service id -> monotonic time of last attempt
|
||||
_spec_found: dict[int, bool] = {} # service id -> did the last attempt find a spec
|
||||
|
||||
|
||||
def fetch_spec(base_url: str, verify_tls: bool = True) -> dict | None:
|
||||
for candidate in SPEC_PATHS:
|
||||
try:
|
||||
resp = httpx.get(base_url.rstrip("/") + candidate, timeout=2,
|
||||
follow_redirects=True, verify=verify_tls)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
if isinstance(data, dict) and "paths" in data:
|
||||
return data
|
||||
except (httpx.HTTPError, ValueError):
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def spec_status(service_id: int) -> bool | None:
|
||||
"""Did the last sync attempt find an OpenAPI document? None = never tried."""
|
||||
return _spec_found.get(service_id)
|
||||
|
||||
|
||||
def sync_service(db: Session, service: Service, force: bool = False) -> tuple[bool, bool]:
|
||||
"""Mirror the upstream OpenAPI paths into the endpoint catalog: add new
|
||||
operations, refresh descriptions, delete operations that disappeared
|
||||
(their key grants go with them). Services without a reachable spec are
|
||||
left untouched. Returns (spec_found, catalog_changed); results are cached
|
||||
between syncs."""
|
||||
now = time.monotonic()
|
||||
if not force and now - _last_sync.get(service.id, float("-inf")) < SYNC_TTL_SECONDS:
|
||||
return _spec_found.get(service.id, False), False
|
||||
_last_sync[service.id] = now
|
||||
|
||||
spec = fetch_spec(service.base_url, service.verify_tls)
|
||||
_spec_found[service.id] = spec is not None
|
||||
if spec is None:
|
||||
return False, False
|
||||
return True, apply_spec(db, service, spec)
|
||||
|
||||
|
||||
def validate_service(db: Session, service: Service) -> dict:
|
||||
"""Connectivity probe for the admin UI. Prefers the OpenAPI document —
|
||||
finding one both proves reachability and refreshes the endpoint cache —
|
||||
and falls back to a plain GET on the base URL otherwise."""
|
||||
start = time.perf_counter()
|
||||
spec = fetch_spec(service.base_url, service.verify_tls)
|
||||
if spec is not None:
|
||||
_last_sync[service.id] = time.monotonic()
|
||||
_spec_found[service.id] = True
|
||||
apply_spec(db, service, spec)
|
||||
return {"ok": True, "spec_found": True,
|
||||
"endpoints": len(service.endpoints),
|
||||
"latency_ms": round((time.perf_counter() - start) * 1000, 1)}
|
||||
try:
|
||||
resp = httpx.get(service.base_url, timeout=5, follow_redirects=True,
|
||||
verify=service.verify_tls)
|
||||
return {"ok": True, "spec_found": False, "status_code": resp.status_code,
|
||||
"endpoints": len(service.endpoints),
|
||||
"latency_ms": round((time.perf_counter() - start) * 1000, 1)}
|
||||
except httpx.HTTPError as exc:
|
||||
return {"ok": False, "spec_found": False,
|
||||
"error": str(exc) or type(exc).__name__,
|
||||
"latency_ms": round((time.perf_counter() - start) * 1000, 1)}
|
||||
|
||||
|
||||
def apply_spec(db: Session, service: Service, spec: dict) -> bool:
|
||||
"""Write an OpenAPI document's paths into the endpoint catalog.
|
||||
Returns whether anything changed."""
|
||||
wanted: dict[tuple[str, str], str] = {}
|
||||
for path, operations in spec["paths"].items():
|
||||
if not isinstance(operations, dict):
|
||||
continue
|
||||
for method, op in operations.items():
|
||||
method = method.upper()
|
||||
if method not in METHODS:
|
||||
continue
|
||||
summary = op.get("summary", "") if isinstance(op, dict) else ""
|
||||
wanted[(method, path)] = summary
|
||||
|
||||
existing = {(e.method, e.path): e for e in service.endpoints}
|
||||
changed = False
|
||||
for (method, path), summary in wanted.items():
|
||||
endpoint = existing.get((method, path))
|
||||
if endpoint is None:
|
||||
db.add(Endpoint(service_id=service.id, method=method, path=path,
|
||||
description=summary))
|
||||
changed = True
|
||||
elif endpoint.description != summary:
|
||||
endpoint.description = summary
|
||||
changed = True
|
||||
for key, endpoint in existing.items():
|
||||
if key not in wanted:
|
||||
db.delete(endpoint)
|
||||
changed = True
|
||||
db.commit()
|
||||
db.expire(service, ["endpoints"])
|
||||
return changed
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import text
|
||||
|
||||
from app import config, proxy, retention, security
|
||||
from app.admin import routes as admin_routes
|
||||
from app.admin import stats as admin_stats
|
||||
from app.admin.deps import LoginRequired, login_redirect_handler
|
||||
from app.database import Base, SessionLocal, engine
|
||||
from app.models import User
|
||||
|
||||
|
||||
def enable_incremental_vacuum() -> None:
|
||||
"""Deleted log rows should hand their pages back to the filesystem;
|
||||
switching auto_vacuum requires a one-time VACUUM (cannot run in a
|
||||
transaction, hence the raw connection)."""
|
||||
raw = engine.raw_connection()
|
||||
try:
|
||||
cursor = raw.cursor()
|
||||
mode = cursor.execute("PRAGMA auto_vacuum").fetchone()[0]
|
||||
if mode != 2: # 2 = INCREMENTAL
|
||||
cursor.execute("PRAGMA auto_vacuum=INCREMENTAL")
|
||||
raw.commit()
|
||||
cursor.execute("VACUUM")
|
||||
raw.commit()
|
||||
finally:
|
||||
raw.close()
|
||||
|
||||
|
||||
def _table_exists(db, name: str) -> bool:
|
||||
return db.execute(text(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=:n"
|
||||
), {"n": name}).first() is not None
|
||||
|
||||
|
||||
def migrate_schema() -> None:
|
||||
"""Bring older databases up to date (SQLite)."""
|
||||
with engine.begin() as conn:
|
||||
cols = [row[1] for row in conn.exec_driver_sql("PRAGMA table_info(request_logs)")]
|
||||
if cols and "endpoint_id" not in cols:
|
||||
conn.exec_driver_sql(
|
||||
"ALTER TABLE request_logs ADD COLUMN endpoint_id INTEGER "
|
||||
"REFERENCES endpoints(id) ON DELETE SET NULL"
|
||||
)
|
||||
for column, ddl in (
|
||||
("query_string", "VARCHAR(2048) DEFAULT ''"),
|
||||
("request_body", "TEXT DEFAULT ''"),
|
||||
("response_body", "TEXT DEFAULT ''"),
|
||||
):
|
||||
if cols and column not in cols:
|
||||
conn.exec_driver_sql(f"ALTER TABLE request_logs ADD COLUMN {column} {ddl}")
|
||||
service_cols = [row[1] for row in conn.exec_driver_sql("PRAGMA table_info(services)")]
|
||||
if service_cols and "verify_tls" not in service_cols:
|
||||
conn.exec_driver_sql("ALTER TABLE services ADD COLUMN verify_tls BOOLEAN DEFAULT 1")
|
||||
|
||||
|
||||
def seed_and_migrate() -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Role-era databases: replace role->service grants with per-key grants
|
||||
# on a service-wide catch-all endpoint, then drop the role tables.
|
||||
if _table_exists(db, "roles"):
|
||||
db.execute(text(
|
||||
"INSERT INTO endpoints (service_id, method, path, description) "
|
||||
"SELECT id, '*', '/**', 'Migrated: full service access' FROM services"
|
||||
))
|
||||
if _table_exists(db, "role_service_access"):
|
||||
db.execute(text(
|
||||
"INSERT OR IGNORE INTO key_endpoint_access (api_key_id, endpoint_id) "
|
||||
"SELECT k.id, e.id "
|
||||
"FROM api_keys k "
|
||||
"JOIN users u ON u.id = k.user_id "
|
||||
"JOIN role_service_access rsa ON rsa.role_id = u.role_id "
|
||||
"JOIN endpoints e ON e.service_id = rsa.service_id AND e.path = '/**'"
|
||||
))
|
||||
db.execute(text("DROP TABLE role_service_access"))
|
||||
if _table_exists(db, "role_permissions"):
|
||||
db.execute(text("DROP TABLE role_permissions"))
|
||||
db.execute(text("ALTER TABLE users DROP COLUMN role_id"))
|
||||
db.execute(text("DROP TABLE roles"))
|
||||
db.commit()
|
||||
print("Migrated role-based access to per-key endpoint grants.")
|
||||
|
||||
if db.query(User).count() == 0:
|
||||
db.add(User(
|
||||
username=config.DEFAULT_ADMIN_USERNAME,
|
||||
password_hash=security.hash_password(config.DEFAULT_ADMIN_PASSWORD),
|
||||
))
|
||||
db.commit()
|
||||
print(f"Seeded admin user '{config.DEFAULT_ADMIN_USERNAME}' "
|
||||
f"(password: '{config.DEFAULT_ADMIN_PASSWORD}' — change it after first login).")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
Base.metadata.create_all(engine)
|
||||
migrate_schema()
|
||||
seed_and_migrate()
|
||||
enable_incremental_vacuum()
|
||||
retention_task = asyncio.create_task(retention.retention_loop())
|
||||
yield
|
||||
retention_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await retention_task
|
||||
await proxy.close_client()
|
||||
|
||||
|
||||
app = FastAPI(title="API Gateway", version="2.0.0", lifespan=lifespan)
|
||||
app.add_exception_handler(LoginRequired, login_redirect_handler)
|
||||
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
def root():
|
||||
return RedirectResponse("/admin")
|
||||
|
||||
|
||||
@app.get("/health", include_in_schema=False)
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
app.include_router(admin_routes.router)
|
||||
app.include_router(admin_stats.router)
|
||||
app.mount("/static", StaticFiles(directory=str(config.BASE_DIR / "app" / "static")), name="static")
|
||||
# The proxy catch-all (/{slug}/...) must come last so it never shadows
|
||||
# /admin, /static, /docs or /health.
|
||||
app.include_router(proxy.router)
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean, Column, DateTime, Float, ForeignKey, Integer, String, Table, Text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
key_endpoint_access = Table(
|
||||
"key_endpoint_access",
|
||||
Base.metadata,
|
||||
Column("api_key_id", ForeignKey("api_keys.id", ondelete="CASCADE"), primary_key=True),
|
||||
Column("endpoint_id", ForeignKey("endpoints.id", ondelete="CASCADE"), primary_key=True),
|
||||
)
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(256))
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
api_keys: Mapped[list["ApiKey"]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class Service(Base):
|
||||
__tablename__ = "services"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(128))
|
||||
slug: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
base_url: Mapped[str] = mapped_column(String(512))
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
timeout_seconds: Mapped[float] = mapped_column(Float, default=30.0)
|
||||
# Disable for upstreams with self-signed / internal-CA certificates.
|
||||
verify_tls: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
endpoints: Mapped[list["Endpoint"]] = relationship(
|
||||
back_populates="service", cascade="all, delete-orphan",
|
||||
order_by="Endpoint.path",
|
||||
)
|
||||
|
||||
|
||||
class Endpoint(Base):
|
||||
"""A callable route of an upstream service.
|
||||
|
||||
`method` is an HTTP verb or '*' (any). `path` starts with '/' and may use
|
||||
'{param}' (one segment), '*' (any within a segment) and '**' (any depth),
|
||||
e.g. '/orders/{id}', '/reports/**'.
|
||||
"""
|
||||
__tablename__ = "endpoints"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
service_id: Mapped[int] = mapped_column(ForeignKey("services.id", ondelete="CASCADE"))
|
||||
method: Mapped[str] = mapped_column(String(10), default="*")
|
||||
path: Mapped[str] = mapped_column(String(512))
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
service: Mapped[Service] = relationship(back_populates="endpoints")
|
||||
api_keys: Mapped[list["ApiKey"]] = relationship(
|
||||
secondary=key_endpoint_access, back_populates="endpoints"
|
||||
)
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return f"{self.method} {self.path}"
|
||||
|
||||
|
||||
class ApiKey(Base):
|
||||
__tablename__ = "api_keys"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||
name: Mapped[str] = mapped_column(String(128))
|
||||
prefix: Mapped[str] = mapped_column(String(16), index=True) # first chars, for display
|
||||
key_hash: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||
rate_limit_per_minute: Mapped[int] = mapped_column(Integer, default=60) # 0 = unlimited
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
user: Mapped[User] = relationship(back_populates="api_keys")
|
||||
endpoints: Mapped[list[Endpoint]] = relationship(
|
||||
secondary=key_endpoint_access, back_populates="api_keys"
|
||||
)
|
||||
|
||||
|
||||
class RequestLog(Base):
|
||||
__tablename__ = "request_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
|
||||
api_key_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("api_keys.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
service_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("services.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
endpoint_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("endpoints.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
method: Mapped[str] = mapped_column(String(10))
|
||||
path: Mapped[str] = mapped_column(String(1024))
|
||||
query_string: Mapped[str] = mapped_column(String(2048), default="")
|
||||
status_code: Mapped[int] = mapped_column(Integer, index=True)
|
||||
latency_ms: Mapped[float] = mapped_column(Float)
|
||||
client_ip: Mapped[str] = mapped_column(String(64), default="")
|
||||
request_body: Mapped[str] = mapped_column(Text, default="")
|
||||
response_body: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
api_key: Mapped[ApiKey | None] = relationship()
|
||||
service: Mapped[Service | None] = relationship()
|
||||
endpoint: Mapped[Endpoint | None] = relationship()
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
import re
|
||||
import time
|
||||
from functools import lru_cache
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import config, ratelimit, security
|
||||
from app.database import get_db
|
||||
from app.models import ApiKey, Endpoint, RequestLog, Service, utcnow
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Hop-by-hop headers must not be forwarded either direction
|
||||
_HOP_BY_HOP = {
|
||||
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
||||
"te", "trailers", "transfer-encoding", "upgrade", "host", "content-length",
|
||||
}
|
||||
|
||||
# One client per TLS-verification mode (verify is a client-level setting in httpx).
|
||||
_clients: dict[bool, httpx.AsyncClient] = {}
|
||||
|
||||
|
||||
async def get_client(verify_tls: bool = True) -> httpx.AsyncClient:
|
||||
client = _clients.get(verify_tls)
|
||||
if client is None:
|
||||
client = _clients[verify_tls] = httpx.AsyncClient(
|
||||
follow_redirects=False, verify=verify_tls)
|
||||
return client
|
||||
|
||||
|
||||
async def close_client() -> None:
|
||||
for client in _clients.values():
|
||||
await client.aclose()
|
||||
_clients.clear()
|
||||
|
||||
|
||||
@lru_cache(maxsize=1024)
|
||||
def _pattern_to_regex(pattern: str) -> re.Pattern:
|
||||
"""Endpoint path pattern -> regex. '{param}' = one segment, '*' = any
|
||||
within a segment, '**' = any depth."""
|
||||
regex = ""
|
||||
for token in re.split(r"(\{[^}]*\}|\*\*|\*)", pattern):
|
||||
if not token:
|
||||
continue
|
||||
if token == "**":
|
||||
regex += ".*"
|
||||
elif token == "*":
|
||||
regex += "[^/]*"
|
||||
elif token.startswith("{") and token.endswith("}"):
|
||||
regex += "[^/]+"
|
||||
else:
|
||||
regex += re.escape(token)
|
||||
return re.compile("^" + regex + "$")
|
||||
|
||||
|
||||
def match_endpoints(endpoints: list[Endpoint], method: str, path: str) -> list[Endpoint]:
|
||||
"""All endpoints of a service that match this request, most specific
|
||||
(exact method) first."""
|
||||
matches = [
|
||||
e for e in endpoints
|
||||
if e.method in ("*", method) and _pattern_to_regex(e.path).match(path)
|
||||
]
|
||||
return sorted(matches, key=lambda e: e.method == "*")
|
||||
|
||||
|
||||
def _error(status: int, code: str, message: str) -> JSONResponse:
|
||||
return JSONResponse(status_code=status, content={"error": code, "message": message})
|
||||
|
||||
|
||||
# Payload capture for transaction inspection
|
||||
MAX_STORED_BODY = 64 * 1024
|
||||
_TEXTUAL_TYPES = ("application/json", "application/xml",
|
||||
"application/x-www-form-urlencoded", "text/", "+json", "+xml")
|
||||
|
||||
|
||||
def _readable(body: bytes, content_type: str) -> str:
|
||||
"""Body as storable text: textual payloads are kept (truncated at 64 KB),
|
||||
binary ones are summarized."""
|
||||
if not body:
|
||||
return ""
|
||||
ct = (content_type or "").lower()
|
||||
if not any(t in ct for t in _TEXTUAL_TYPES):
|
||||
return f"<{len(body)} bytes of {ct or 'unknown content type'}>"
|
||||
text = body[:MAX_STORED_BODY].decode("utf-8", "replace")
|
||||
if len(body) > MAX_STORED_BODY:
|
||||
text += "\n… (truncated)"
|
||||
return text
|
||||
|
||||
|
||||
@router.api_route(
|
||||
"/{slug}/{path:path}",
|
||||
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
|
||||
)
|
||||
@router.api_route(
|
||||
"/{slug}",
|
||||
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
|
||||
)
|
||||
async def gateway(slug: str, request: Request, path: str = "",
|
||||
db: Session = Depends(get_db)):
|
||||
start = time.perf_counter()
|
||||
|
||||
# --- authenticate ---
|
||||
plain_key = request.headers.get(config.API_KEY_HEADER)
|
||||
if not plain_key:
|
||||
return _error(401, "missing_api_key", f"Provide your API key in the {config.API_KEY_HEADER} header.")
|
||||
|
||||
api_key = (
|
||||
db.query(ApiKey)
|
||||
.filter(ApiKey.key_hash == security.hash_api_key(plain_key))
|
||||
.one_or_none()
|
||||
)
|
||||
if api_key is None or not api_key.is_active or not api_key.user.is_active:
|
||||
return _error(403, "invalid_api_key", "API key is unknown or has been revoked.")
|
||||
|
||||
# --- resolve service ---
|
||||
service = db.query(Service).filter(Service.slug == slug).one_or_none()
|
||||
if service is None or not service.is_active:
|
||||
return _error(404, "unknown_service", f"No active service registered under '{slug}'.")
|
||||
|
||||
# --- read the request payload once (forwarded and logged) ---
|
||||
body = await request.body()
|
||||
stored_request = _readable(body, request.headers.get("content-type", ""))
|
||||
|
||||
def deny(status: int, code: str, message: str, endpoint: Endpoint | None) -> JSONResponse:
|
||||
response = _error(status, code, message)
|
||||
_log(db, api_key, service, endpoint, request, request_path, status, start,
|
||||
stored_request, response.body.decode())
|
||||
return response
|
||||
|
||||
# --- resolve endpoint & authorize ---
|
||||
request_path = "/" + path
|
||||
matched = match_endpoints(service.endpoints, request.method, request_path)
|
||||
if not matched:
|
||||
return deny(404, "unknown_endpoint",
|
||||
f"No endpoint of '{slug}' matches {request.method} {request_path}.", None)
|
||||
|
||||
granted_ids = {e.id for e in api_key.endpoints}
|
||||
endpoint = next((e for e in matched if e.id in granted_ids), None)
|
||||
if endpoint is None:
|
||||
return deny(403, "access_denied",
|
||||
f"This API key has no access to {request.method} {request_path} on '{slug}'.",
|
||||
matched[0])
|
||||
|
||||
# --- rate limit ---
|
||||
if not ratelimit.check(api_key.id, api_key.rate_limit_per_minute):
|
||||
return deny(429, "rate_limited",
|
||||
f"Rate limit of {api_key.rate_limit_per_minute} requests/minute exceeded.",
|
||||
endpoint)
|
||||
|
||||
# --- proxy upstream ---
|
||||
upstream_url = service.base_url.rstrip("/") + request_path
|
||||
headers = {
|
||||
k: v for k, v in request.headers.items()
|
||||
if k.lower() not in _HOP_BY_HOP and k.lower() != config.API_KEY_HEADER.lower()
|
||||
}
|
||||
headers["x-forwarded-for"] = request.client.host if request.client else ""
|
||||
headers["x-forwarded-proto"] = request.url.scheme
|
||||
|
||||
client = await get_client(service.verify_tls)
|
||||
try:
|
||||
upstream = await client.request(
|
||||
request.method,
|
||||
upstream_url,
|
||||
params=request.query_params,
|
||||
content=body,
|
||||
headers=headers,
|
||||
timeout=service.timeout_seconds or config.PROXY_DEFAULT_TIMEOUT,
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
return deny(504, "upstream_timeout", "The upstream API did not respond in time.", endpoint)
|
||||
except httpx.HTTPError:
|
||||
return deny(502, "upstream_unreachable", "Could not reach the upstream API.", endpoint)
|
||||
|
||||
_log(db, api_key, service, endpoint, request, request_path, upstream.status_code, start,
|
||||
stored_request, _readable(upstream.content, upstream.headers.get("content-type", "")))
|
||||
|
||||
response_headers = {
|
||||
k: v for k, v in upstream.headers.items() if k.lower() not in _HOP_BY_HOP
|
||||
}
|
||||
return Response(
|
||||
content=upstream.content,
|
||||
status_code=upstream.status_code,
|
||||
headers=response_headers,
|
||||
media_type=upstream.headers.get("content-type"),
|
||||
)
|
||||
|
||||
|
||||
def _log(db: Session, api_key: ApiKey, service: Service, endpoint: Endpoint | None,
|
||||
request: Request, request_path: str, status: int, start: float,
|
||||
request_body: str = "", response_body: str = "") -> None:
|
||||
api_key.last_used_at = utcnow()
|
||||
db.add(RequestLog(
|
||||
api_key_id=api_key.id,
|
||||
service_id=service.id,
|
||||
endpoint_id=endpoint.id if endpoint else None,
|
||||
method=request.method,
|
||||
path=request_path,
|
||||
query_string=request.url.query,
|
||||
status_code=status,
|
||||
latency_ms=round((time.perf_counter() - start) * 1000, 2),
|
||||
client_ip=request.client.host if request.client else "",
|
||||
request_body=request_body,
|
||||
response_body=response_body,
|
||||
))
|
||||
db.commit()
|
||||
@@ -0,0 +1,26 @@
|
||||
"""In-memory fixed-window rate limiter, keyed per API key.
|
||||
|
||||
Good enough for a single-process gateway; swap for Redis if you scale out.
|
||||
"""
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from threading import Lock
|
||||
|
||||
_windows: dict[int, tuple[int, int]] = defaultdict(lambda: (0, 0)) # key_id -> (window_start, count)
|
||||
_lock = Lock()
|
||||
|
||||
|
||||
def check(key_id: int, limit_per_minute: int) -> bool:
|
||||
"""Returns True if the request is allowed."""
|
||||
if limit_per_minute <= 0:
|
||||
return True
|
||||
now = int(time.time() // 60)
|
||||
with _lock:
|
||||
window, count = _windows[key_id]
|
||||
if window != now:
|
||||
_windows[key_id] = (now, 1)
|
||||
return True
|
||||
if count >= limit_per_minute:
|
||||
return False
|
||||
_windows[key_id] = (window, count + 1)
|
||||
return True
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Tiered log retention.
|
||||
|
||||
Tier 1: request/response payloads are blanked after PAYLOAD_RETENTION_DAYS —
|
||||
the log row stays inspectable, it just loses the bodies.
|
||||
Tier 2: whole log rows are deleted after LOG_RETENTION_DAYS.
|
||||
|
||||
Both run in batches so a large backlog never locks the database against the
|
||||
proxy's own log writes, followed by an incremental vacuum to hand freed pages
|
||||
back to the filesystem. A background loop triggers this every 6 hours.
|
||||
"""
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app import config
|
||||
from app.database import SessionLocal
|
||||
|
||||
PURGE_INTERVAL_SECONDS = 6 * 3600
|
||||
BATCH_SIZE = 5000
|
||||
|
||||
|
||||
def _cutoff(days: int) -> str:
|
||||
"""Naive-UTC timestamp string, matching how rows are stored."""
|
||||
moment = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days)
|
||||
return moment.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def purge_once() -> dict:
|
||||
stats = {"payloads_blanked": 0, "rows_deleted": 0}
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if config.PAYLOAD_RETENTION_DAYS > 0:
|
||||
cutoff = _cutoff(config.PAYLOAD_RETENTION_DAYS)
|
||||
while True:
|
||||
result = db.execute(text(
|
||||
"UPDATE request_logs SET request_body = '', response_body = '' "
|
||||
"WHERE id IN (SELECT id FROM request_logs "
|
||||
" WHERE timestamp < :cutoff "
|
||||
" AND (request_body != '' OR response_body != '') "
|
||||
" LIMIT :batch)"
|
||||
), {"cutoff": cutoff, "batch": BATCH_SIZE})
|
||||
db.commit()
|
||||
stats["payloads_blanked"] += result.rowcount
|
||||
if result.rowcount < BATCH_SIZE:
|
||||
break
|
||||
|
||||
if config.LOG_RETENTION_DAYS > 0:
|
||||
cutoff = _cutoff(config.LOG_RETENTION_DAYS)
|
||||
while True:
|
||||
result = db.execute(text(
|
||||
"DELETE FROM request_logs WHERE id IN "
|
||||
"(SELECT id FROM request_logs WHERE timestamp < :cutoff LIMIT :batch)"
|
||||
), {"cutoff": cutoff, "batch": BATCH_SIZE})
|
||||
db.commit()
|
||||
stats["rows_deleted"] += result.rowcount
|
||||
if result.rowcount < BATCH_SIZE:
|
||||
break
|
||||
|
||||
if stats["rows_deleted"] or stats["payloads_blanked"]:
|
||||
db.execute(text("PRAGMA incremental_vacuum"))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
return stats
|
||||
|
||||
|
||||
async def retention_loop() -> None:
|
||||
while True:
|
||||
try:
|
||||
stats = await asyncio.to_thread(purge_once)
|
||||
if stats["payloads_blanked"] or stats["rows_deleted"]:
|
||||
print(f"Retention: deleted {stats['rows_deleted']} log rows, "
|
||||
f"blanked {stats['payloads_blanked']} payloads.")
|
||||
except Exception as exc: # never let a purge failure kill the loop
|
||||
print(f"Retention run failed: {exc}")
|
||||
await asyncio.sleep(PURGE_INTERVAL_SECONDS)
|
||||
@@ -0,0 +1,58 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
|
||||
from itsdangerous import BadSignature, URLSafeTimedSerializer
|
||||
|
||||
from app import config
|
||||
|
||||
_serializer = URLSafeTimedSerializer(config.SECRET_KEY, salt="gw-session")
|
||||
|
||||
_PBKDF2_ITERATIONS = 600_000
|
||||
|
||||
|
||||
# ---- password hashing (PBKDF2-SHA256) ----
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
salt = secrets.token_hex(16)
|
||||
digest = hashlib.pbkdf2_hmac(
|
||||
"sha256", password.encode(), bytes.fromhex(salt), _PBKDF2_ITERATIONS
|
||||
).hex()
|
||||
return f"pbkdf2${_PBKDF2_ITERATIONS}${salt}${digest}"
|
||||
|
||||
|
||||
def verify_password(password: str, stored: str) -> bool:
|
||||
try:
|
||||
_, iterations, salt, digest = stored.split("$")
|
||||
computed = hashlib.pbkdf2_hmac(
|
||||
"sha256", password.encode(), bytes.fromhex(salt), int(iterations)
|
||||
).hex()
|
||||
return hmac.compare_digest(computed, digest)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
# ---- API keys ----
|
||||
|
||||
def generate_api_key() -> tuple[str, str, str]:
|
||||
"""Returns (plain_key, prefix, key_hash). Plain key is shown once."""
|
||||
plain = "gw_" + secrets.token_urlsafe(32)
|
||||
return plain, plain[:11], hash_api_key(plain)
|
||||
|
||||
|
||||
def hash_api_key(plain: str) -> str:
|
||||
return hashlib.sha256(plain.encode()).hexdigest()
|
||||
|
||||
|
||||
# ---- session cookies ----
|
||||
|
||||
def create_session_token(user_id: int) -> str:
|
||||
return _serializer.dumps({"uid": user_id})
|
||||
|
||||
|
||||
def read_session_token(token: str) -> int | None:
|
||||
try:
|
||||
data = _serializer.loads(token, max_age=config.SESSION_MAX_AGE)
|
||||
return int(data["uid"])
|
||||
except (BadSignature, KeyError, ValueError, TypeError):
|
||||
return None
|
||||
@@ -0,0 +1,218 @@
|
||||
/* Dark admin theme — colors from the validated reference palette (dark mode). */
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--page: #0d0d0d;
|
||||
--surface: #1a1a19;
|
||||
--surface-2: #232322;
|
||||
--ink: #ffffff;
|
||||
--ink-2: #c3c2b7;
|
||||
--muted: #898781;
|
||||
--grid: #2c2c2a;
|
||||
--border: rgba(255, 255, 255, 0.10);
|
||||
--series-1: #3987e5; /* blue */
|
||||
--series-8: #e66767; /* red */
|
||||
--good: #0ca30c;
|
||||
--warning: #fab219;
|
||||
--critical: #d03b3b;
|
||||
--radius: 10px;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--page); color: var(--ink); font-size: 14px; }
|
||||
a { color: var(--series-1); text-decoration: none; }
|
||||
|
||||
.layout { display: flex; min-height: 100vh; }
|
||||
|
||||
/* ---- sidebar ---- */
|
||||
.sidebar {
|
||||
width: 220px; flex-shrink: 0; background: var(--surface);
|
||||
border-right: 1px solid var(--border); padding: 20px 12px;
|
||||
display: flex; flex-direction: column; gap: 4px; position: sticky; top: 0; height: 100vh;
|
||||
}
|
||||
.sidebar .brand { font-size: 16px; font-weight: 700; padding: 0 10px 16px; }
|
||||
.sidebar .brand span { color: var(--series-1); }
|
||||
.sidebar a.nav-item {
|
||||
color: var(--ink-2); padding: 9px 10px; border-radius: 8px; display: block;
|
||||
}
|
||||
.sidebar a.nav-item:hover { background: var(--surface-2); color: var(--ink); }
|
||||
.sidebar a.nav-item.active { background: var(--surface-2); color: var(--ink); font-weight: 600; }
|
||||
.sidebar .spacer { flex: 1; }
|
||||
.sidebar .whoami { color: var(--muted); font-size: 12px; padding: 0 10px 8px; }
|
||||
|
||||
/* ---- main ---- */
|
||||
.main { flex: 1; padding: 28px 32px; max-width: 1200px; }
|
||||
h1 { font-size: 20px; margin: 0 0 20px; }
|
||||
h2 { font-size: 15px; margin: 0 0 12px; color: var(--ink-2); font-weight: 600; }
|
||||
|
||||
.card {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 18px; margin-bottom: 20px;
|
||||
}
|
||||
.grid { display: grid; gap: 16px; }
|
||||
.grid.cols-2 { grid-template-columns: 1fr 1fr; }
|
||||
.grid.cols-3 { grid-template-columns: repeat(3, 1fr); }
|
||||
@media (max-width: 900px) { .grid.cols-2, .grid.cols-3 { grid-template-columns: 1fr; } }
|
||||
|
||||
/* ---- stat tiles ---- */
|
||||
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 16px; margin-bottom: 20px; }
|
||||
.tile { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 18px; }
|
||||
.tile .label { color: var(--muted); font-size: 12px; margin-bottom: 6px; }
|
||||
.tile .value { font-size: 26px; font-weight: 700; }
|
||||
.tile .value small { font-size: 14px; color: var(--ink-2); font-weight: 400; }
|
||||
|
||||
/* ---- tables ---- */
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { text-align: left; color: var(--muted); font-size: 12px; font-weight: 600; padding: 8px 10px; border-bottom: 1px solid var(--grid); }
|
||||
td { padding: 9px 10px; border-bottom: 1px solid var(--grid); color: var(--ink-2); font-variant-numeric: tabular-nums; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
td.strong { color: var(--ink); font-weight: 600; }
|
||||
code { background: var(--surface-2); padding: 2px 6px; border-radius: 5px; font-size: 12.5px; }
|
||||
pre {
|
||||
background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 12px; font-size: 12.5px; line-height: 1.5; overflow: auto; max-height: 480px;
|
||||
white-space: pre-wrap; word-break: break-word; color: var(--ink-2); margin: 0;
|
||||
}
|
||||
|
||||
/* ---- badges ---- */
|
||||
.badge { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; padding: 2px 9px; border-radius: 999px; border: 1px solid var(--border); }
|
||||
.badge.on { color: var(--good); }
|
||||
.badge.off { color: var(--muted); }
|
||||
.badge.admin { color: var(--series-1); }
|
||||
.status-2xx { color: var(--good); }
|
||||
.status-4xx { color: var(--warning); }
|
||||
.status-5xx { color: var(--critical); }
|
||||
|
||||
/* ---- forms ---- */
|
||||
form.inline { display: inline; }
|
||||
label { display: block; color: var(--muted); font-size: 12px; margin: 10px 0 4px; }
|
||||
input[type=text], input[type=password], input[type=url], input[type=number], select, textarea {
|
||||
width: 100%; background: var(--surface-2); border: 1px solid var(--border);
|
||||
border-radius: 8px; color: var(--ink); padding: 8px 10px; font: inherit;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus { outline: 2px solid var(--series-1); outline-offset: -1px; }
|
||||
.checks { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 6px; }
|
||||
.checks label { display: flex; align-items: center; gap: 6px; margin: 0; color: var(--ink-2); font-size: 13px; }
|
||||
|
||||
button, .btn {
|
||||
background: var(--series-1); color: #fff; border: none; border-radius: 8px;
|
||||
padding: 8px 14px; font: inherit; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
button:hover { filter: brightness(1.1); }
|
||||
button.ghost { background: transparent; border: 1px solid var(--border); color: var(--ink-2); font-weight: 500; padding: 5px 10px; font-size: 12.5px; }
|
||||
button.ghost:hover { background: var(--surface-2); color: var(--ink); }
|
||||
button.danger { background: transparent; border: 1px solid var(--border); color: var(--critical); font-weight: 500; padding: 5px 10px; font-size: 12.5px; }
|
||||
button.danger:hover { background: rgba(208, 59, 59, 0.12); }
|
||||
|
||||
/* ---- misc ---- */
|
||||
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
.right { margin-left: auto; }
|
||||
.alert { border-radius: 8px; padding: 12px 14px; margin-bottom: 16px; border: 1px solid var(--border); }
|
||||
.alert.error { color: var(--critical); }
|
||||
.alert.success { color: var(--good); background: rgba(12, 163, 12, 0.08); }
|
||||
.alert.success code { user-select: all; }
|
||||
.hint { color: var(--muted); font-size: 12px; margin-top: 6px; }
|
||||
.chart-box { position: relative; height: 260px; }
|
||||
details.editor { margin-top: 8px; }
|
||||
details.editor summary { cursor: pointer; color: var(--muted); font-size: 12.5px; }
|
||||
.range-picker { display: flex; gap: 6px; margin-bottom: 16px; }
|
||||
.range-picker button.active { background: var(--surface-2); color: var(--ink); }
|
||||
|
||||
/* ---- request browser ---- */
|
||||
tr.req-row { cursor: pointer; }
|
||||
tr.req-row:hover td { background: var(--surface-2); }
|
||||
tr.req-row.selected td { background: var(--surface-2); box-shadow: inset 2px 0 0 var(--series-1); }
|
||||
tr.req-row:focus-visible { outline: 2px solid var(--series-1); outline-offset: -2px; }
|
||||
|
||||
/* Split view: list on top, inspector below, each scrolling internally. */
|
||||
.req-split { display: flex; flex-direction: column; gap: 16px; height: calc(100vh - 130px); }
|
||||
.req-split .card { margin-bottom: 0; }
|
||||
.req-list { flex: 1 1 0; min-height: 0; display: flex; flex-direction: column; }
|
||||
.req-list .table-scroll { flex: 1; min-height: 0; overflow-y: auto; }
|
||||
.req-list .table-scroll thead th { position: sticky; top: 0; background: var(--surface); z-index: 1; }
|
||||
#inspector { flex: 1 1 0; min-height: 0; display: flex; flex-direction: column; }
|
||||
#inspector[hidden] { display: none; }
|
||||
#inspector .inspector-body { flex: 1; min-height: 0; overflow-y: auto; }
|
||||
#inspector .inspector-body pre { max-height: none; }
|
||||
|
||||
/* ---- on/off switch ---- */
|
||||
.switch { position: relative; display: inline-block; width: 36px; height: 20px; vertical-align: middle; }
|
||||
.switch input { opacity: 0; width: 0; height: 0; }
|
||||
.switch .slider {
|
||||
position: absolute; inset: 0; cursor: pointer; border-radius: 999px;
|
||||
background: var(--surface-2); border: 1px solid var(--border); transition: background .15s;
|
||||
}
|
||||
.switch .slider::before {
|
||||
content: ""; position: absolute; width: 14px; height: 14px; border-radius: 50%;
|
||||
left: 2px; top: 2px; background: var(--muted); transition: transform .15s, background .15s;
|
||||
}
|
||||
.switch input:checked + .slider { background: var(--good); border-color: transparent; }
|
||||
.switch input:checked + .slider::before { transform: translateX(16px); background: #fff; }
|
||||
.switch input:focus-visible + .slider { outline: 2px solid var(--series-1); }
|
||||
|
||||
/* ---- icon buttons ---- */
|
||||
button.icon {
|
||||
background: transparent; border: 1px solid var(--border); color: var(--muted);
|
||||
padding: 4px 7px; line-height: 0; border-radius: 7px;
|
||||
}
|
||||
button.icon:hover { color: var(--critical); background: rgba(208, 59, 59, 0.12); }
|
||||
button.armed {
|
||||
color: #fff !important; background: var(--critical) !important;
|
||||
border-color: var(--critical) !important; line-height: 1.2;
|
||||
font-size: 12px; font-weight: 600;
|
||||
}
|
||||
button.icon.edit:hover { color: var(--series-1); background: rgba(57, 135, 229, 0.12); }
|
||||
|
||||
/* ---- connectivity validation ---- */
|
||||
.vresult { font-size: 12px; margin-left: 6px; }
|
||||
.vresult.ok { color: var(--good); }
|
||||
.vresult.warn { color: var(--warning); }
|
||||
.vresult.err { color: var(--critical); }
|
||||
|
||||
/* ---- modal overlay ---- */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0; z-index: 100; display: none;
|
||||
background: rgba(0, 0, 0, 0.55); backdrop-filter: blur(2px);
|
||||
align-items: flex-start; justify-content: center; padding: 7vh 20px 40px;
|
||||
}
|
||||
.modal-overlay.open { display: flex; }
|
||||
.modal {
|
||||
position: relative; width: 100%; max-width: 720px;
|
||||
max-height: 86vh; overflow-y: auto;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 20px 22px;
|
||||
}
|
||||
.modal > h2 { margin: 0 32px 14px 0; color: var(--ink); }
|
||||
.modal .modal-close { position: absolute; top: 14px; right: 14px; }
|
||||
|
||||
/* ---- spinner ---- */
|
||||
.spinner, .tree.syncing::after {
|
||||
display: inline-block; width: 12px; height: 12px; border-radius: 50%;
|
||||
border: 2px solid var(--grid); border-top-color: var(--series-1);
|
||||
animation: spin .8s linear infinite; vertical-align: -2px;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ---- endpoint tree picker ---- */
|
||||
.tree { position: relative; }
|
||||
.tree.syncing::after { content: ""; position: absolute; top: 4px; right: 4px; }
|
||||
.tree.syncing { opacity: .75; }
|
||||
.tree details { margin: 0; }
|
||||
.tree summary { list-style: none; cursor: pointer; padding: 3px 0; }
|
||||
.tree summary::-webkit-details-marker { display: none; }
|
||||
.tree summary::before { content: "▸"; display: inline-block; width: 14px; color: var(--muted); transition: transform .1s; }
|
||||
.tree details[open] > summary::before { transform: rotate(90deg); }
|
||||
.tree .tree-children { margin-left: 22px; border-left: 1px solid var(--grid); padding-left: 12px; }
|
||||
.tree label { display: flex; align-items: center; gap: 7px; margin: 0; padding: 3px 0; color: var(--ink-2); font-size: 13px; cursor: pointer; }
|
||||
.tree summary label { display: inline-flex; }
|
||||
.tree .seg { color: var(--ink); font-weight: 600; font-size: 13px; }
|
||||
.mchip {
|
||||
display: inline-block; min-width: 42px; text-align: center; font-size: 10.5px; font-weight: 700;
|
||||
padding: 1px 6px; border-radius: 5px; background: var(--surface-2); color: var(--series-1);
|
||||
border: 1px solid var(--border); letter-spacing: .3px;
|
||||
}
|
||||
.tree .ep-desc { color: var(--muted); font-size: 12px; }
|
||||
|
||||
/* ---- login ---- */
|
||||
.login-wrap { min-height: 100vh; display: grid; place-items: center; }
|
||||
.login-card { width: 340px; }
|
||||
@@ -0,0 +1,90 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}API Gateway{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<nav class="sidebar">
|
||||
<div class="brand">API <span>Gateway</span></div>
|
||||
<a class="nav-item {% if active == 'dashboard' %}active{% endif %}" href="/admin">Dashboard</a>
|
||||
<a class="nav-item {% if active == 'services' %}active{% endif %}" href="/admin/services">Services</a>
|
||||
<a class="nav-item {% if active == 'users' %}active{% endif %}" href="/admin/users">Users</a>
|
||||
<a class="nav-item {% if active == 'keys' %}active{% endif %}" href="/admin/keys">API Keys</a>
|
||||
<a class="nav-item {% if active == 'requests' %}active{% endif %}" href="/admin/requests">Requests</a>
|
||||
<a class="nav-item {% if active == 'monitoring' %}active{% endif %}" href="/admin/monitoring">Monitoring</a>
|
||||
<div class="spacer"></div>
|
||||
{% if user %}
|
||||
<div class="whoami">{{ user.username }}</div>
|
||||
<a class="nav-item" href="/admin/logout">Log out</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
<main class="main">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
|
||||
<script>
|
||||
if (window.Chart) {
|
||||
Chart.defaults.color = '#898781';
|
||||
Chart.defaults.borderColor = '#2c2c2a';
|
||||
Chart.defaults.font.family = 'system-ui, -apple-system, "Segoe UI", sans-serif';
|
||||
Chart.defaults.plugins.legend.labels.boxWidth = 12;
|
||||
Chart.defaults.plugins.legend.labels.boxHeight = 12;
|
||||
Chart.defaults.animation.duration = 300;
|
||||
}
|
||||
const GW = {
|
||||
blue: '#3987e5',
|
||||
red: '#e66767',
|
||||
ink2: '#c3c2b7',
|
||||
grid: '#2c2c2a',
|
||||
async fetchJSON(url) { const r = await fetch(url); return r.json(); },
|
||||
};
|
||||
|
||||
// Destructive forms carry data-confirm. First click arms the button
|
||||
// ("Confirm?"), a second click within 5s submits. No native dialogs —
|
||||
// confirm() is unreliable in embedded browsers.
|
||||
document.addEventListener('submit', e => {
|
||||
const form = e.target;
|
||||
if (!form.dataset.confirm) return;
|
||||
if (form.dataset.armed === '1') return; // second click: let it through
|
||||
e.preventDefault();
|
||||
const btn = form.querySelector('button');
|
||||
form.dataset.armed = '1';
|
||||
if (!btn.dataset.orig) btn.dataset.orig = btn.innerHTML;
|
||||
btn.innerHTML = 'Confirm?';
|
||||
btn.classList.add('armed');
|
||||
btn.title = form.dataset.confirm;
|
||||
setTimeout(() => {
|
||||
form.dataset.armed = '';
|
||||
btn.innerHTML = btn.dataset.orig;
|
||||
btn.classList.remove('armed');
|
||||
}, 5000);
|
||||
}, true);
|
||||
|
||||
// Modal overlays: [data-modal] opens the overlay with that id; the × button,
|
||||
// a click on the backdrop, or Escape closes it.
|
||||
document.addEventListener('click', e => {
|
||||
const opener = e.target.closest('[data-modal]');
|
||||
if (opener) {
|
||||
e.preventDefault();
|
||||
document.getElementById(opener.dataset.modal).classList.add('open');
|
||||
return;
|
||||
}
|
||||
if (e.target.closest('.modal-close')) {
|
||||
e.target.closest('.modal-overlay').classList.remove('open');
|
||||
return;
|
||||
}
|
||||
if (e.target.classList.contains('modal-overlay')) e.target.classList.remove('open');
|
||||
});
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape')
|
||||
document.querySelectorAll('.modal-overlay.open').forEach(m => m.classList.remove('open'));
|
||||
});
|
||||
</script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,95 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard · API Gateway{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Dashboard</h1>
|
||||
|
||||
<div class="tiles" id="tiles">
|
||||
<div class="tile"><div class="label">Requests (24h)</div><div class="value" id="t-total">–</div></div>
|
||||
<div class="tile"><div class="label">Error rate (24h)</div><div class="value" id="t-errors">–</div></div>
|
||||
<div class="tile"><div class="label">Avg latency (24h)</div><div class="value" id="t-latency">–</div></div>
|
||||
<div class="tile"><div class="label">Active services</div><div class="value" id="t-services">–</div></div>
|
||||
<div class="tile"><div class="label">Active API keys</div><div class="value" id="t-keys">–</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Traffic — last 24 hours</h2>
|
||||
<div class="chart-box"><canvas id="chart-traffic"></canvas></div>
|
||||
</div>
|
||||
|
||||
<div class="grid cols-2">
|
||||
<div class="card">
|
||||
<h2>Requests by service (24h)</h2>
|
||||
<div class="chart-box"><canvas id="chart-services"></canvas></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Recent requests</h2>
|
||||
<table>
|
||||
<thead><tr><th>Time (UTC)</th><th>Service</th><th>Path</th><th>Status</th><th>ms</th></tr></thead>
|
||||
<tbody>
|
||||
{% for log in recent %}
|
||||
<tr>
|
||||
<td>{{ log.timestamp.strftime("%H:%M:%S") }}</td>
|
||||
<td class="strong">{{ log.service.name if log.service else "–" }}</td>
|
||||
<td>{{ log.method }} {{ log.path[:40] }}</td>
|
||||
<td class="status-{{ log.status_code // 100 }}xx">{{ log.status_code }}</td>
|
||||
<td>{{ "%.0f" | format(log.latency_ms) }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5">No traffic yet. Send a request through <code>/gw/<service>/<path></code>.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(async () => {
|
||||
const s = await GW.fetchJSON('/admin/api/stats/summary?hours=24');
|
||||
document.getElementById('t-total').textContent = s.total_requests.toLocaleString();
|
||||
document.getElementById('t-errors').textContent = s.error_rate + '%';
|
||||
document.getElementById('t-latency').innerHTML = s.avg_latency_ms + '<small> ms</small>';
|
||||
document.getElementById('t-services').textContent = s.active_services;
|
||||
document.getElementById('t-keys').textContent = s.active_keys;
|
||||
|
||||
const ts = await GW.fetchJSON('/admin/api/stats/timeseries?hours=24');
|
||||
new Chart(document.getElementById('chart-traffic'), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: ts.labels.map(l => l.slice(11)),
|
||||
datasets: [
|
||||
{ label: 'Succeeded', data: ts.ok, borderColor: GW.blue, backgroundColor: GW.blue,
|
||||
borderWidth: 2, pointRadius: 0, pointHoverRadius: 4, tension: 0.3 },
|
||||
{ label: 'Errors (5xx)', data: ts.errors, borderColor: GW.red, backgroundColor: GW.red,
|
||||
borderWidth: 2, pointRadius: 0, pointHoverRadius: 4, tension: 0.3 },
|
||||
],
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
scales: {
|
||||
x: { grid: { display: false }, ticks: { maxTicksLimit: 12 } },
|
||||
y: { beginAtZero: true, ticks: { precision: 0 } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const bs = await GW.fetchJSON('/admin/api/stats/by-service?hours=24');
|
||||
new Chart(document.getElementById('chart-services'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: bs.labels,
|
||||
datasets: [{ label: 'Requests', data: bs.counts, backgroundColor: GW.blue,
|
||||
borderRadius: 4, maxBarThickness: 26 }],
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
indexAxis: 'y',
|
||||
plugins: { legend: { display: false } },
|
||||
scales: { x: { beginAtZero: true, ticks: { precision: 0 } }, y: { grid: { display: false } } },
|
||||
},
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,239 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}API Keys · API Gateway{% endblock %}
|
||||
|
||||
{% macro trash(label) %}
|
||||
<button class="icon" title="{{ label }}" aria-label="{{ label }}">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
|
||||
</button>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro pencil(label, modal) %}
|
||||
<button class="icon edit" data-modal="{{ modal }}" title="{{ label }}" aria-label="{{ label }}">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
|
||||
</button>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro close_button() %}
|
||||
<button type="button" class="icon modal-close" title="Close" aria-label="Close">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro tree_node(node, granted) %}
|
||||
{% if node.children %}
|
||||
<details open>
|
||||
<summary>
|
||||
<label onclick="event.stopPropagation()">
|
||||
<input type="checkbox" class="group-box"> <span class="seg">{{ node.name }}</span>
|
||||
</label>
|
||||
</summary>
|
||||
<div class="tree-children">
|
||||
{% for e in node.endpoints | sort(attribute='method') %}
|
||||
<label>
|
||||
<input type="checkbox" name="endpoint_ids" value="{{ e.id }}" {% if e.id in granted %}checked{% endif %}>
|
||||
<span class="mchip">{{ 'ANY' if e.method == '*' else e.method }}</span> {{ node.name }}
|
||||
{% if e.description %}<span class="ep-desc">— {{ e.description }}</span>{% endif %}
|
||||
</label>
|
||||
{% endfor %}
|
||||
{% for name, child in node.children | dictsort %}
|
||||
{{ tree_node(child, granted) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% else %}
|
||||
{% for e in node.endpoints | sort(attribute='method') %}
|
||||
<label>
|
||||
<input type="checkbox" name="endpoint_ids" value="{{ e.id }}" {% if e.id in granted %}checked{% endif %}>
|
||||
<span class="mchip">{{ 'ANY' if e.method == '*' else e.method }}</span> {{ node.name }}
|
||||
{% if e.description %}<span class="ep-desc">— {{ e.description }}</span>{% endif %}
|
||||
</label>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro endpoint_picker(services, trees, synced, granted) %}
|
||||
<div class="tree">
|
||||
{% for s in services %}
|
||||
<details open>
|
||||
<summary>
|
||||
<label onclick="event.stopPropagation()">
|
||||
<input type="checkbox" class="group-box">
|
||||
<span class="seg">{{ s.name }}</span>
|
||||
<code>/{{ s.slug }}</code>
|
||||
{% if synced.get(s.id) is sameas false %}<span class="ep-desc">(no OpenAPI document — catalog not auto-synced)</span>{% endif %}
|
||||
</label>
|
||||
</summary>
|
||||
<div class="tree-children">
|
||||
{% for e in trees[s.id].endpoints | sort(attribute='method') %}
|
||||
<label>
|
||||
<input type="checkbox" name="endpoint_ids" value="{{ e.id }}" {% if e.id in granted %}checked{% endif %}>
|
||||
<span class="mchip">{{ 'ANY' if e.method == '*' else e.method }}</span> /
|
||||
{% if e.description %}<span class="ep-desc">— {{ e.description }}</span>{% endif %}
|
||||
</label>
|
||||
{% endfor %}
|
||||
{% for name, child in trees[s.id].children | dictsort %}
|
||||
{{ tree_node(child, granted) }}
|
||||
{% endfor %}
|
||||
{% if not trees[s.id].children and not trees[s.id].endpoints %}
|
||||
<span class="hint">No endpoints known for this service.</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
{% else %}
|
||||
<span class="hint">Register a service first.</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row" style="margin-bottom:20px">
|
||||
<h1 style="margin:0">API Keys & Access</h1>
|
||||
<button class="right" data-modal="modal-new-key">+ Issue key</button>
|
||||
</div>
|
||||
|
||||
{% if new_key %}
|
||||
<div class="alert success">
|
||||
New API key created — copy it now, it will not be shown again:<br>
|
||||
<code style="font-size:14px">{{ new_key }}</code>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Owner</th><th>Key</th><th>Access</th><th>Rate limit</th><th>Last used</th><th>On</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for k in keys %}
|
||||
<tr>
|
||||
<td class="strong">{{ k.name }}</td>
|
||||
<td>{{ k.user.username }}</td>
|
||||
<td><code>{{ k.prefix }}…</code></td>
|
||||
<td>
|
||||
{% if k.endpoints %}{{ k.endpoints | length }} endpoint{{ '' if k.endpoints | length == 1 else 's' }}
|
||||
{% else %}<span class="badge off">none</span>{% endif %}
|
||||
</td>
|
||||
<td>{{ k.rate_limit_per_minute if k.rate_limit_per_minute else '∞' }}/min</td>
|
||||
<td>{{ k.last_used_at.strftime("%Y-%m-%d %H:%M") if k.last_used_at else 'never' }}</td>
|
||||
<td>
|
||||
<form class="inline" method="post" action="/admin/keys/{{ k.id }}/toggle">
|
||||
<label class="switch" title="{{ 'Revoke' if k.is_active else 'Restore' }}">
|
||||
<input type="checkbox" {% if k.is_active %}checked{% endif %} onchange="this.form.submit()">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</form>
|
||||
</td>
|
||||
<td style="white-space:nowrap">
|
||||
{{ pencil('Edit access for ' ~ k.name, 'modal-key-' ~ k.id) }}
|
||||
<form class="inline" method="post" action="/admin/keys/{{ k.id }}/delete"
|
||||
data-confirm="Permanently deletes key {{ k.name }}">
|
||||
{{ trash('Delete key ' ~ k.name) }}
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8">No API keys yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="hint">Endpoint catalogs are mirrored from each service's OpenAPI document
|
||||
(refreshed at most every 5 minutes; grants on removed endpoints are cleaned up).
|
||||
<a href="#" id="sync-now">Refresh now</a>
|
||||
<span id="sync-status"></span></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>How consumers call the gateway</h2>
|
||||
<p style="color:var(--ink-2)">Send requests to <code>/<service-slug>/<path></code> with the header <code>X-API-Key: <key></code>. The request must match one of the service's endpoints and the key must hold a grant on it.</p>
|
||||
</div>
|
||||
|
||||
{% for k in keys %}
|
||||
<div class="modal-overlay" id="modal-key-{{ k.id }}" role="dialog" aria-modal="true" aria-label="Edit access for {{ k.name }}">
|
||||
<div class="modal">
|
||||
<h2>Access for {{ k.name }} <span class="hint">owned by {{ k.user.username }}</span></h2>
|
||||
{{ close_button() }}
|
||||
<form method="post" action="/admin/keys/{{ k.id }}/access">
|
||||
{% set granted = k.endpoints | map(attribute='id') | list %}
|
||||
{{ endpoint_picker(services, trees, synced, granted) }}
|
||||
<div class="row" style="margin-top:14px">
|
||||
<label style="margin:0">Rate limit/min (0 = unlimited)</label>
|
||||
<input type="number" name="rate_limit_per_minute" value="{{ k.rate_limit_per_minute }}" min="0" style="max-width:110px">
|
||||
<button>Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="modal-overlay" id="modal-new-key" role="dialog" aria-modal="true" aria-label="Issue a new key">
|
||||
<div class="modal">
|
||||
<h2>Issue a new key</h2>
|
||||
{{ close_button() }}
|
||||
<form method="post" action="/admin/keys">
|
||||
<div class="grid cols-3">
|
||||
<div><label>Key name</label><input type="text" name="name" placeholder="mobile-app-prod" required></div>
|
||||
<div><label>Owner</label>
|
||||
<select name="user_id">
|
||||
{% for u in users %}<option value="{{ u.id }}">{{ u.username }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div><label>Rate limit/min (0 = unlimited)</label>
|
||||
<input type="number" name="rate_limit_per_minute" value="60" min="0"></div>
|
||||
</div>
|
||||
<label>Endpoint access</label>
|
||||
{{ endpoint_picker(services, trees, synced, []) }}
|
||||
<div style="margin-top:14px"><button>Create key</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// The page renders instantly from the stored catalog; the OpenAPI sync runs
|
||||
// here in the background. Spinners sit on the endpoint trees while it's
|
||||
// pending, and the page reloads only if the catalog actually changed.
|
||||
async function syncCatalogs(force = false) {
|
||||
const trees = document.querySelectorAll('.tree');
|
||||
const status = document.getElementById('sync-status');
|
||||
trees.forEach(t => t.classList.add('syncing'));
|
||||
status.innerHTML = '<span class="spinner"></span> refreshing catalogs…';
|
||||
try {
|
||||
const r = await GW.fetchJSON('/admin/api/endpoints/sync' + (force ? '?force=1' : ''));
|
||||
if (r.changed) { location.reload(); return; }
|
||||
status.textContent = 'up to date';
|
||||
} catch {
|
||||
status.textContent = 'refresh failed';
|
||||
} finally {
|
||||
trees.forEach(t => t.classList.remove('syncing'));
|
||||
}
|
||||
}
|
||||
syncCatalogs();
|
||||
document.getElementById('sync-now').addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
syncCatalogs(true);
|
||||
});
|
||||
|
||||
// Hierarchical picker: a group checkbox selects everything beneath it;
|
||||
// leaf changes roll their state back up (checked / indeterminate).
|
||||
function refreshGroups(scope) {
|
||||
[...scope.querySelectorAll('.tree details')].reverse().forEach(d => {
|
||||
const group = d.querySelector(':scope > summary .group-box');
|
||||
const leaves = d.querySelectorAll('input[name=endpoint_ids]');
|
||||
if (!group || !leaves.length) return;
|
||||
const checked = [...leaves].filter(b => b.checked).length;
|
||||
group.checked = checked === leaves.length;
|
||||
group.indeterminate = checked > 0 && checked < leaves.length;
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.tree').forEach(tree => {
|
||||
tree.addEventListener('change', e => {
|
||||
if (e.target.classList.contains('group-box')) {
|
||||
const details = e.target.closest('details');
|
||||
details.querySelectorAll('input[name=endpoint_ids]').forEach(b => { b.checked = e.target.checked; });
|
||||
}
|
||||
refreshGroups(tree);
|
||||
});
|
||||
refreshGroups(tree);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,26 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sign in · API Gateway</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-wrap">
|
||||
<div class="card login-card">
|
||||
<div class="brand" style="font-size:18px;font-weight:700;margin-bottom:14px;">
|
||||
API <span style="color:var(--series-1)">Gateway</span>
|
||||
</div>
|
||||
{% if error %}<div class="alert error">{{ error }}</div>{% endif %}
|
||||
<form method="post" action="/admin/login">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" type="text" name="username" required autofocus>
|
||||
<label for="password">Password</label>
|
||||
<input id="password" type="password" name="password" required>
|
||||
<div style="margin-top:16px"><button type="submit" style="width:100%">Sign in</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,187 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Monitoring · API Gateway{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Usage Monitoring</h1>
|
||||
|
||||
<div class="row" style="margin-bottom:16px">
|
||||
<div class="range-picker" role="group" aria-label="Time range" style="margin:0">
|
||||
<button class="ghost" data-hours="1">1 h</button>
|
||||
<button class="ghost" data-hours="6">6 h</button>
|
||||
<button class="ghost" data-hours="24">24 h</button>
|
||||
<button class="ghost" data-hours="72">3 days</button>
|
||||
<button class="ghost" data-hours="168">7 days</button>
|
||||
<button class="ghost" data-hours="720">30 days</button>
|
||||
</div>
|
||||
<select id="f-service" class="right" style="max-width:170px">
|
||||
<option value="">All services</option>
|
||||
{% for s in services %}<option value="{{ s.id }}">{{ s.name }}</option>{% endfor %}
|
||||
</select>
|
||||
<select id="f-user" style="max-width:150px">
|
||||
<option value="">All users</option>
|
||||
{% for u in users %}<option value="{{ u.id }}">{{ u.username }}</option>{% endfor %}
|
||||
</select>
|
||||
<select id="f-key" style="max-width:150px">
|
||||
<option value="">All keys</option>
|
||||
{% for k in keys %}<option value="{{ k.id }}">{{ k.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="tiles">
|
||||
<div class="tile"><div class="label">Requests</div><div class="value" id="t-total">–</div></div>
|
||||
<div class="tile"><div class="label">Errors (5xx)</div><div class="value" id="t-errors">–</div></div>
|
||||
<div class="tile"><div class="label">Error rate</div><div class="value" id="t-rate">–</div></div>
|
||||
<div class="tile"><div class="label">Avg latency</div><div class="value" id="t-latency">–</div></div>
|
||||
</div>
|
||||
|
||||
<div class="grid cols-2">
|
||||
<div class="card">
|
||||
<h2>Requests over time</h2>
|
||||
<div class="chart-box"><canvas id="chart-traffic"></canvas></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Average latency over time (ms)</h2>
|
||||
<div class="chart-box"><canvas id="chart-latency-ts"></canvas></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Requests by service</h2>
|
||||
<div class="chart-box"><canvas id="chart-services"></canvas></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Responses by status code</h2>
|
||||
<div class="chart-box"><canvas id="chart-status"></canvas></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Requests by user</h2>
|
||||
<div class="chart-box"><canvas id="chart-users"></canvas></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Requests by endpoint</h2>
|
||||
<div class="chart-box"><canvas id="chart-endpoints"></canvas></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Average latency by service (ms)</h2>
|
||||
<div class="chart-box"><canvas id="chart-latency"></canvas></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Top API keys by usage</h2>
|
||||
<div class="chart-box"><canvas id="chart-keys"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Latest 100 requests</h2>
|
||||
<table>
|
||||
<thead><tr><th>Time (UTC)</th><th>Service</th><th>Key</th><th>User</th><th>Endpoint</th><th>Request</th><th>Status</th><th>Latency</th><th>Client IP</th></tr></thead>
|
||||
<tbody>
|
||||
{% for log in logs %}
|
||||
<tr>
|
||||
<td>{{ log.timestamp.strftime("%Y-%m-%d %H:%M:%S") }}</td>
|
||||
<td class="strong">{{ log.service.name if log.service else "–" }}</td>
|
||||
<td>{{ log.api_key.name if log.api_key else "–" }}</td>
|
||||
<td>{{ log.api_key.user.username if log.api_key else "–" }}</td>
|
||||
<td>{% if log.endpoint %}<code>{{ log.endpoint.path }}</code>{% else %}–{% endif %}</td>
|
||||
<td><a href="/admin/requests?inspect={{ log.id }}">{{ log.method }} {{ log.path[:50] }}</a></td>
|
||||
<td class="status-{{ log.status_code // 100 }}xx">{{ log.status_code }}</td>
|
||||
<td>{{ "%.1f" | format(log.latency_ms) }} ms</td>
|
||||
<td>{{ log.client_ip }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="9">No requests logged yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const charts = {};
|
||||
let hours = 24;
|
||||
|
||||
function qs() {
|
||||
const p = new URLSearchParams({ hours });
|
||||
for (const [param, id] of [['service_id', 'f-service'], ['user_id', 'f-user'], ['key_id', 'f-key']]) {
|
||||
const v = document.getElementById(id).value;
|
||||
if (v) p.set(param, v);
|
||||
}
|
||||
return p.toString();
|
||||
}
|
||||
|
||||
function barChart(id, labels, data, horizontal = false) {
|
||||
if (charts[id]) charts[id].destroy();
|
||||
charts[id] = new Chart(document.getElementById(id), {
|
||||
type: 'bar',
|
||||
data: { labels, datasets: [{ data, backgroundColor: GW.blue, borderRadius: 4, maxBarThickness: 26 }] },
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
indexAxis: horizontal ? 'y' : 'x',
|
||||
plugins: { legend: { display: false } },
|
||||
scales: horizontal
|
||||
? { x: { beginAtZero: true }, y: { grid: { display: false } } }
|
||||
: { x: { grid: { display: false } }, y: { beginAtZero: true, ticks: { precision: 0 } } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function lineChart(id, labels, datasets, showLegend) {
|
||||
if (charts[id]) charts[id].destroy();
|
||||
charts[id] = new Chart(document.getElementById(id), {
|
||||
type: 'line',
|
||||
data: { labels, datasets },
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: { legend: { display: showLegend } },
|
||||
scales: {
|
||||
x: { grid: { display: false }, ticks: { maxTicksLimit: 14 } },
|
||||
y: { beginAtZero: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const lineStyle = { borderWidth: 2, pointRadius: 0, pointHoverRadius: 4, tension: 0.3, spanGaps: true };
|
||||
|
||||
async function load() {
|
||||
const query = qs();
|
||||
const [sum, ts, lts, bs, st, lat, keys, byUser, byEndpoint] = await Promise.all([
|
||||
'summary', 'timeseries', 'latency-timeseries', 'by-service', 'by-status',
|
||||
'latency-by-service', 'top-keys', 'by-user', 'by-endpoint',
|
||||
].map(ep => GW.fetchJSON(`/admin/api/stats/${ep}?${query}`)));
|
||||
|
||||
document.getElementById('t-total').textContent = sum.total_requests.toLocaleString();
|
||||
document.getElementById('t-errors').textContent = sum.error_count.toLocaleString();
|
||||
document.getElementById('t-rate').textContent = sum.error_rate + '%';
|
||||
document.getElementById('t-latency').innerHTML = sum.avg_latency_ms + '<small> ms</small>';
|
||||
|
||||
lineChart('chart-traffic', ts.labels, [
|
||||
{ label: 'Succeeded', data: ts.ok, borderColor: GW.blue, backgroundColor: GW.blue, ...lineStyle },
|
||||
{ label: 'Errors (5xx)', data: ts.errors, borderColor: GW.red, backgroundColor: GW.red, ...lineStyle },
|
||||
], true);
|
||||
|
||||
lineChart('chart-latency-ts', lts.labels, [
|
||||
{ label: 'Avg latency (ms)', data: lts.avg_ms, borderColor: GW.blue, backgroundColor: GW.blue, ...lineStyle },
|
||||
], false);
|
||||
|
||||
barChart('chart-services', bs.labels, bs.counts, true);
|
||||
barChart('chart-status', st.labels, st.counts);
|
||||
barChart('chart-users', byUser.labels, byUser.counts, true);
|
||||
barChart('chart-endpoints', byEndpoint.labels, byEndpoint.counts, true);
|
||||
barChart('chart-latency', lat.labels, lat.avg_ms, true);
|
||||
barChart('chart-keys', keys.labels, keys.counts, true);
|
||||
}
|
||||
|
||||
document.querySelectorAll('.range-picker button').forEach(b =>
|
||||
b.addEventListener('click', () => {
|
||||
hours = +b.dataset.hours;
|
||||
document.querySelectorAll('.range-picker button').forEach(x =>
|
||||
x.classList.toggle('active', x === b));
|
||||
load();
|
||||
}));
|
||||
['f-service', 'f-user', 'f-key'].forEach(id =>
|
||||
document.getElementById(id).addEventListener('change', load));
|
||||
|
||||
document.querySelector('.range-picker button[data-hours="24"]').classList.add('active');
|
||||
load();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,173 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Requests · API Gateway{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Requests</h1>
|
||||
|
||||
<div class="req-split">
|
||||
<div class="card">
|
||||
<form method="get" action="/admin/requests" class="row" id="filter-form">
|
||||
<select name="service_id" style="max-width:160px">
|
||||
<option value="">All services</option>
|
||||
{% for s in services %}<option value="{{ s.id }}" {% if f.service_id == s.id %}selected{% endif %}>{{ s.name }}</option>{% endfor %}
|
||||
</select>
|
||||
<select name="user_id" style="max-width:140px">
|
||||
<option value="">All users</option>
|
||||
{% for u in users %}<option value="{{ u.id }}" {% if f.user_id == u.id %}selected{% endif %}>{{ u.username }}</option>{% endfor %}
|
||||
</select>
|
||||
<select name="key_id" style="max-width:150px">
|
||||
<option value="">All keys</option>
|
||||
{% for k in keys %}<option value="{{ k.id }}" {% if f.key_id == k.id %}selected{% endif %}>{{ k.name }}</option>{% endfor %}
|
||||
</select>
|
||||
<select name="status_class" style="max-width:120px">
|
||||
<option value="">All statuses</option>
|
||||
{% for c in ['2', '3', '4', '5'] %}
|
||||
<option value="{{ c }}" {% if f.status_class == c %}selected{% endif %}>{{ c }}xx</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input type="text" name="q" value="{{ f.q }}" placeholder="Path contains…" style="max-width:200px">
|
||||
<span class="right hint">{{ total }} request{{ '' if total == 1 else 's' }}</span>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card req-list">
|
||||
<div class="table-scroll">
|
||||
<table id="req-table">
|
||||
<thead><tr><th>Time (UTC)</th><th>Service</th><th>Key</th><th>Request</th><th>Status</th><th>Latency</th></tr></thead>
|
||||
<tbody>
|
||||
{% for log in logs %}
|
||||
<tr class="req-row" data-id="{{ log.id }}" tabindex="0" role="button"
|
||||
aria-label="Inspect request {{ log.id }}">
|
||||
<td>{{ log.timestamp.strftime("%Y-%m-%d %H:%M:%S") }}</td>
|
||||
<td class="strong">{{ log.service.name if log.service else "–" }}</td>
|
||||
<td>{{ log.api_key.name if log.api_key else "–" }}</td>
|
||||
<td>{{ log.method }} {{ log.path[:60] }}{% if log.query_string %}?{{ log.query_string[:30] }}{% endif %}</td>
|
||||
<td class="status-{{ log.status_code // 100 }}xx">{{ log.status_code }}</td>
|
||||
<td>{{ "%.1f" | format(log.latency_ms) }} ms</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6">No requests match these filters.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if pages > 1 %}
|
||||
{% set base = '/admin/requests?service_id=' ~ (f.service_id or '') ~ '&user_id=' ~ (f.user_id or '') ~ '&key_id=' ~ (f.key_id or '') ~ '&status_class=' ~ f.status_class ~ '&q=' ~ f.q %}
|
||||
<div class="row" style="margin-top:12px">
|
||||
{% if page > 1 %}<a class="btn ghost" href="{{ base }}&page={{ page - 1 }}" style="padding:5px 10px">← Newer</a>{% endif %}
|
||||
<span class="hint">Page {{ page }} of {{ pages }}</span>
|
||||
{% if page < pages %}<a class="btn ghost" href="{{ base }}&page={{ page + 1 }}" style="padding:5px 10px">Older →</a>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card" id="inspector" hidden>
|
||||
<div class="row" style="margin-bottom:12px">
|
||||
<h2 style="margin:0">Request <span id="i-id"></span></h2>
|
||||
<span id="i-status" class="badge"></span>
|
||||
<span class="hint" id="i-latency"></span>
|
||||
<span class="hint" id="i-time"></span>
|
||||
<button type="button" class="icon right" id="i-close" title="Close inspector" aria-label="Close inspector">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="inspector-body">
|
||||
<table style="margin-bottom:16px">
|
||||
<tbody>
|
||||
<tr><td class="strong" style="width:160px">Gateway request</td><td><code id="i-gwreq"></code></td></tr>
|
||||
<tr><td class="strong">Forwarded to</td><td><code id="i-fwd"></code></td></tr>
|
||||
<tr><td class="strong">Service</td><td id="i-service"></td></tr>
|
||||
<tr><td class="strong">Matched endpoint</td><td id="i-endpoint"></td></tr>
|
||||
<tr><td class="strong">API key</td><td id="i-key"></td></tr>
|
||||
<tr><td class="strong">User</td><td id="i-user"></td></tr>
|
||||
<tr><td class="strong">Client IP</td><td id="i-ip"></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="grid cols-2">
|
||||
<div>
|
||||
<h2>Request payload</h2>
|
||||
<pre id="i-reqbody"></pre>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Response payload</h2>
|
||||
<pre id="i-resbody"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const inspector = document.getElementById('inspector');
|
||||
const $ = id => document.getElementById(id);
|
||||
|
||||
async function inspect(id) {
|
||||
const r = await fetch(`/admin/requests/${id}/data`);
|
||||
if (!r.ok) return;
|
||||
const d = await r.json();
|
||||
|
||||
$('i-id').textContent = '#' + d.id;
|
||||
$('i-status').textContent = d.status;
|
||||
$('i-status').className = `badge status-${Math.floor(d.status / 100)}xx`;
|
||||
$('i-latency').textContent = d.latency_ms + ' ms';
|
||||
$('i-time').textContent = d.time + ' UTC';
|
||||
const qs = d.query_string ? '?' + d.query_string : '';
|
||||
$('i-gwreq').textContent = `${d.method} /${d.slug ?? '?'}${d.path}${qs}`;
|
||||
$('i-fwd').textContent = d.forwarded_to ?? '–';
|
||||
$('i-service').textContent = d.service ?? '–';
|
||||
$('i-endpoint').textContent = d.endpoint
|
||||
? d.endpoint + (d.endpoint_description ? ' — ' + d.endpoint_description : '') : '–';
|
||||
$('i-key').textContent = d.key ? `${d.key} (${d.key_prefix}…)` : '–';
|
||||
$('i-user').textContent = d.user ?? '–';
|
||||
$('i-ip').textContent = d.client_ip || '–';
|
||||
$('i-reqbody').textContent = d.request_body || 'No request body.';
|
||||
$('i-resbody').textContent = d.response_body || 'No response body captured.';
|
||||
|
||||
document.querySelectorAll('.req-row').forEach(row =>
|
||||
row.classList.toggle('selected', +row.dataset.id === d.id));
|
||||
inspector.hidden = false;
|
||||
inspector.querySelector('.inspector-body').scrollTop = 0;
|
||||
|
||||
const url = new URL(location);
|
||||
url.searchParams.set('inspect', d.id);
|
||||
history.replaceState(null, '', url);
|
||||
}
|
||||
|
||||
document.querySelectorAll('.req-row').forEach(row => {
|
||||
row.addEventListener('click', () => inspect(+row.dataset.id));
|
||||
row.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); inspect(+row.dataset.id); }
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('i-close').addEventListener('click', () => {
|
||||
inspector.hidden = true;
|
||||
document.querySelectorAll('.req-row.selected').forEach(r => r.classList.remove('selected'));
|
||||
const url = new URL(location);
|
||||
url.searchParams.delete('inspect');
|
||||
history.replaceState(null, '', url);
|
||||
});
|
||||
|
||||
const preselected = new URLSearchParams(location.search).get('inspect');
|
||||
if (preselected) inspect(+preselected);
|
||||
|
||||
// Filters apply immediately; empty fields are omitted from the URL.
|
||||
const filterForm = document.getElementById('filter-form');
|
||||
function applyFilters() {
|
||||
const p = new URLSearchParams();
|
||||
for (const el of filterForm.elements) {
|
||||
if (el.name && el.value) p.set(el.name, el.value);
|
||||
}
|
||||
location.href = '/admin/requests' + (p.size ? '?' + p.toString() : '');
|
||||
}
|
||||
filterForm.addEventListener('submit', e => { e.preventDefault(); applyFilters(); });
|
||||
filterForm.querySelectorAll('select').forEach(s => s.addEventListener('change', applyFilters));
|
||||
let debounce;
|
||||
filterForm.querySelector('input[name=q]').addEventListener('input', () => {
|
||||
clearTimeout(debounce);
|
||||
debounce = setTimeout(applyFilters, 500);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,147 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Services · API Gateway{% endblock %}
|
||||
|
||||
{% macro trash(label) %}
|
||||
<button class="icon" title="{{ label }}" aria-label="{{ label }}">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
|
||||
</button>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro pencil(label, modal) %}
|
||||
<button class="icon edit" data-modal="{{ modal }}" title="{{ label }}" aria-label="{{ label }}">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
|
||||
</button>
|
||||
{% endmacro %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row" style="margin-bottom:20px">
|
||||
<h1 style="margin:0">Connected APIs</h1>
|
||||
<button class="right" data-modal="modal-new-service">+ Add service</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Gateway route</th><th>Upstream</th><th>Timeout</th><th>Requests</th><th>Connectivity</th><th>On</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in services %}
|
||||
<tr>
|
||||
<td class="strong">{{ s.name }}</td>
|
||||
<td><code>/{{ s.slug }}/…</code></td>
|
||||
<td>{{ s.base_url }}</td>
|
||||
<td>{{ s.timeout_seconds }}s</td>
|
||||
<td>{{ counts.get(s.id, 0) }}</td>
|
||||
<td style="white-space:nowrap">
|
||||
<button type="button" class="ghost validate-btn" data-id="{{ s.id }}">Validate</button>
|
||||
<span class="vresult" id="vresult-{{ s.id }}"></span>
|
||||
</td>
|
||||
<td>
|
||||
<form class="inline" method="post" action="/admin/services/{{ s.id }}/toggle">
|
||||
<label class="switch" title="{{ 'Turn off' if s.is_active else 'Turn on' }}">
|
||||
<input type="checkbox" {% if s.is_active %}checked{% endif %} onchange="this.form.submit()">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</form>
|
||||
</td>
|
||||
<td style="white-space:nowrap">
|
||||
{{ pencil('Edit ' ~ s.name, 'modal-service-' ~ s.id) }}
|
||||
<form class="inline" method="post" action="/admin/services/{{ s.id }}/delete"
|
||||
data-confirm="Deletes {{ s.name }} and its endpoints">
|
||||
{{ trash('Delete ' ~ s.name) }}
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8">No services registered yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="hint">Endpoints are managed on the <a href="/admin/keys">API Keys</a> page,
|
||||
where each service's catalog is kept in sync with its OpenAPI document.</div>
|
||||
</div>
|
||||
|
||||
{% for s in services %}
|
||||
<div class="modal-overlay" id="modal-service-{{ s.id }}" role="dialog" aria-modal="true" aria-label="Edit {{ s.name }}">
|
||||
<div class="modal">
|
||||
<h2>Edit {{ s.name }}</h2>
|
||||
<button type="button" class="icon modal-close" title="Close" aria-label="Close">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
<form method="post" action="/admin/services/{{ s.id }}/update" class="grid cols-2">
|
||||
<div><label>Name</label><input type="text" name="name" value="{{ s.name }}" required></div>
|
||||
<div><label>Base URL</label><input type="url" name="base_url" value="{{ s.base_url }}" required></div>
|
||||
<div><label>Timeout (seconds)</label><input type="number" name="timeout_seconds" value="{{ s.timeout_seconds }}" step="0.5" min="1"></div>
|
||||
<div><label>Description</label><input type="text" name="description" value="{{ s.description }}"></div>
|
||||
<div style="grid-column:1/-1" class="checks">
|
||||
<label><input type="checkbox" name="verify_tls" {% if s.verify_tls %}checked{% endif %}>
|
||||
Verify TLS certificate <span class="hint" style="margin:0">(untick for self-signed / internal-CA upstreams)</span></label>
|
||||
</div>
|
||||
<div><button>Save changes</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="modal-overlay" id="modal-new-service" role="dialog" aria-modal="true" aria-label="Register a new API">
|
||||
<div class="modal">
|
||||
<h2>Register a new API</h2>
|
||||
<button type="button" class="icon modal-close" title="Close" aria-label="Close">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
<form method="post" action="/admin/services" class="grid cols-2">
|
||||
<div><label>Name</label><input type="text" name="name" placeholder="Weather API" required></div>
|
||||
<div><label>Slug (route segment)</label><input type="text" name="slug" placeholder="weather" pattern="[a-z0-9\-]+" required>
|
||||
<div class="hint">Consumers will call <code>/<slug>/…</code></div></div>
|
||||
<div><label>Base URL</label><input type="url" name="base_url" placeholder="https://api.example.com/v1" required></div>
|
||||
<div><label>Timeout (seconds)</label><input type="number" name="timeout_seconds" value="30" step="0.5" min="1"></div>
|
||||
<div style="grid-column:1/-1"><label>Description</label><input type="text" name="description" placeholder="Optional"></div>
|
||||
<div style="grid-column:1/-1" class="checks">
|
||||
<label><input type="checkbox" name="verify_tls" checked>
|
||||
Verify TLS certificate <span class="hint" style="margin:0">(untick for self-signed / internal-CA upstreams)</span></label>
|
||||
</div>
|
||||
<div><button>Add service</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// Connectivity probe. Finding an OpenAPI document also refreshes the
|
||||
// endpoint cache, so a validation doubles as an endpoint import.
|
||||
async function validateService(id) {
|
||||
const out = document.getElementById('vresult-' + id);
|
||||
if (!out) return;
|
||||
out.className = 'vresult';
|
||||
out.innerHTML = '<span class="spinner"></span>';
|
||||
try {
|
||||
const resp = await fetch(`/admin/services/${id}/validate`, { method: 'POST' });
|
||||
const d = await resp.json();
|
||||
if (d.ok && d.spec_found) {
|
||||
out.className = 'vresult ok';
|
||||
out.textContent = `✓ reachable · ${d.endpoints} endpoints cached · ${d.latency_ms} ms`;
|
||||
} else if (d.ok) {
|
||||
out.className = 'vresult warn';
|
||||
out.textContent = `✓ reachable (HTTP ${d.status_code}) · no OpenAPI document · ${d.latency_ms} ms`;
|
||||
} else {
|
||||
out.className = 'vresult err';
|
||||
out.textContent = `✗ unreachable — ${d.error}`;
|
||||
}
|
||||
} catch {
|
||||
out.className = 'vresult err';
|
||||
out.textContent = '✗ validation failed';
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.validate-btn').forEach(btn =>
|
||||
btn.addEventListener('click', () => validateService(btn.dataset.id)));
|
||||
|
||||
// A freshly registered service is validated automatically.
|
||||
const pending = new URLSearchParams(location.search).get('validate');
|
||||
if (pending) {
|
||||
validateService(pending);
|
||||
history.replaceState(null, '', '/admin/services');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,91 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Users · API Gateway{% endblock %}
|
||||
|
||||
{% macro trash(label) %}
|
||||
<button class="icon" title="{{ label }}" aria-label="{{ label }}">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
|
||||
</button>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro pencil(label, modal) %}
|
||||
<button class="icon edit" data-modal="{{ modal }}" title="{{ label }}" aria-label="{{ label }}">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
|
||||
</button>
|
||||
{% endmacro %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row" style="margin-bottom:20px">
|
||||
<h1 style="margin:0">Users</h1>
|
||||
<button class="right" data-modal="modal-new-user">+ Add user</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead><tr><th>Username</th><th>API keys</th><th>Created</th><th>On</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td class="strong">{{ u.username }}</td>
|
||||
<td>{{ u.api_keys | length }}</td>
|
||||
<td>{{ u.created_at.strftime("%Y-%m-%d") }}</td>
|
||||
<td>
|
||||
{% if u.id != user.id %}
|
||||
<form class="inline" method="post" action="/admin/users/{{ u.id }}/toggle">
|
||||
<label class="switch" title="{{ 'Disable' if u.is_active else 'Enable' }}">
|
||||
<input type="checkbox" {% if u.is_active %}checked{% endif %} onchange="this.form.submit()">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</form>
|
||||
{% else %}
|
||||
<label class="switch" title="You cannot deactivate your own account">
|
||||
<input type="checkbox" checked disabled><span class="slider" style="opacity:.5"></span>
|
||||
</label>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="white-space:nowrap">
|
||||
{{ pencil('Edit ' ~ u.username, 'modal-user-' ~ u.id) }}
|
||||
{% if u.id != user.id %}
|
||||
<form class="inline" method="post" action="/admin/users/{{ u.id }}/delete"
|
||||
data-confirm="Deletes {{ u.username }} and all their API keys">
|
||||
{{ trash('Delete ' ~ u.username) }}
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="hint">Every user can sign in to this console and own API keys.
|
||||
Gateway access itself is granted per key on the <a href="/admin/keys">API Keys</a> page.</div>
|
||||
</div>
|
||||
|
||||
{% for u in users %}
|
||||
<div class="modal-overlay" id="modal-user-{{ u.id }}" role="dialog" aria-modal="true" aria-label="Edit {{ u.username }}">
|
||||
<div class="modal">
|
||||
<h2>Edit {{ u.username }}</h2>
|
||||
<button type="button" class="icon modal-close" title="Close" aria-label="Close">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
<form method="post" action="/admin/users/{{ u.id }}/password">
|
||||
<label>New password</label>
|
||||
<input type="password" name="password" required>
|
||||
<div style="margin-top:14px"><button>Reset password</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="modal-overlay" id="modal-new-user" role="dialog" aria-modal="true" aria-label="Add a user">
|
||||
<div class="modal">
|
||||
<h2>Add a user</h2>
|
||||
<button type="button" class="icon modal-close" title="Close" aria-label="Close">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
<form method="post" action="/admin/users" class="grid cols-2">
|
||||
<div><label>Username</label><input type="text" name="username" required></div>
|
||||
<div><label>Password</label><input type="password" name="password" required></div>
|
||||
<div><button>Create user</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user