initial commit
This commit is contained in:
154
api/app/routes/admin.py
Normal file
154
api/app/routes/admin.py
Normal file
@@ -0,0 +1,154 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.auth import (
|
||||
ADMIN_USERNAME,
|
||||
admin_configured,
|
||||
check_login_rate_limit,
|
||||
create_access_token,
|
||||
record_failed_login,
|
||||
require_admin,
|
||||
verify_password,
|
||||
)
|
||||
from app.cache import cache_get, cache_set, redis_available
|
||||
from app.config import (
|
||||
APP_NAME,
|
||||
APP_VERSION,
|
||||
BRIGHTSKY_BASE_URL,
|
||||
CACHE_TTL_FORECAST,
|
||||
CACHE_TTL_GEOCODE,
|
||||
CACHE_TTL_OBSERVATIONS,
|
||||
CACHE_TTL_WARNINGS,
|
||||
CACHE_TTL_WMS_TIMES,
|
||||
DATA_DIR,
|
||||
DWD_RADAR_BASE_URL,
|
||||
DWD_WMS_URL,
|
||||
OPEN_METEO_BASE_URL,
|
||||
RADAR_PRODUCT,
|
||||
RADAR_RETENTION_HOURS,
|
||||
RATE_LIMIT_PER_MINUTE,
|
||||
RAW_RADAR_DIR,
|
||||
RENDERED_RADAR_DIR,
|
||||
)
|
||||
from app.database import pool_available
|
||||
from app.services.health import check_sources
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str = Field(min_length=1, max_length=64)
|
||||
password: str = Field(min_length=8, max_length=128)
|
||||
|
||||
|
||||
class SettingsUpdate(BaseModel):
|
||||
rate_limit_per_minute: int | None = Field(default=None, ge=10, le=1000)
|
||||
cache_ttl_forecast: int | None = Field(default=None, ge=60, le=86400)
|
||||
cache_ttl_observations: int | None = Field(default=None, ge=60, le=86400)
|
||||
cache_ttl_warnings: int | None = Field(default=None, ge=30, le=3600)
|
||||
radar_retention_hours: int | None = Field(default=None, ge=6, le=168)
|
||||
|
||||
|
||||
def _dir_stats(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {"path": str(path), "exists": False, "files": 0, "bytes": 0}
|
||||
files = [p for p in path.rglob("*") if p.is_file()]
|
||||
total = sum(p.stat().st_size for p in files)
|
||||
return {"path": str(path), "exists": True, "files": len(files), "bytes": total}
|
||||
|
||||
|
||||
def _public_config() -> dict[str, Any]:
|
||||
return {
|
||||
"app_name": APP_NAME,
|
||||
"version": APP_VERSION,
|
||||
"open_meteo_base_url": OPEN_METEO_BASE_URL,
|
||||
"brightsky_base_url": BRIGHTSKY_BASE_URL,
|
||||
"dwd_radar_base_url": DWD_RADAR_BASE_URL,
|
||||
"dwd_wms_url": DWD_WMS_URL,
|
||||
"radar_product": RADAR_PRODUCT,
|
||||
"radar_retention_hours": RADAR_RETENTION_HOURS,
|
||||
"rate_limit_per_minute": RATE_LIMIT_PER_MINUTE,
|
||||
"cache_ttl": {
|
||||
"forecast": CACHE_TTL_FORECAST,
|
||||
"observations": CACHE_TTL_OBSERVATIONS,
|
||||
"warnings": CACHE_TTL_WARNINGS,
|
||||
"geocode": CACHE_TTL_GEOCODE,
|
||||
"wms_times": CACHE_TTL_WMS_TIMES,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def admin_status_public() -> dict[str, Any]:
|
||||
return {"admin_enabled": admin_configured(), "version": APP_VERSION}
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def admin_login(request: Request, payload: LoginRequest) -> dict[str, Any]:
|
||||
if not admin_configured():
|
||||
raise HTTPException(status_code=503, detail="Admin login is not configured on this instance")
|
||||
check_login_rate_limit(request)
|
||||
if payload.username != ADMIN_USERNAME or not verify_password(payload.password, os.getenv("ADMIN_PASSWORD_HASH", "")):
|
||||
record_failed_login(request)
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
token = create_access_token(payload.username)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"expires_in_hours": int(os.getenv("ADMIN_JWT_EXPIRE_HOURS", "8")),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/dashboard")
|
||||
async def admin_dashboard(_: dict[str, Any] = Depends(require_admin)) -> dict[str, Any]:
|
||||
sources = await check_sources()
|
||||
return {
|
||||
"time_utc": datetime.now(timezone.utc).isoformat(),
|
||||
"services": sources,
|
||||
"infrastructure": {
|
||||
"postgres": "ok" if pool_available() else "disabled",
|
||||
"redis": "ok" if redis_available() else "disabled",
|
||||
},
|
||||
"storage": {
|
||||
"data_dir": _dir_stats(DATA_DIR),
|
||||
"radar_raw": _dir_stats(RAW_RADAR_DIR),
|
||||
"radar_rendered": _dir_stats(RENDERED_RADAR_DIR),
|
||||
},
|
||||
"config": _public_config(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/cache/clear")
|
||||
async def admin_clear_cache(_: dict[str, Any] = Depends(require_admin)) -> dict[str, Any]:
|
||||
from app.cache import flush_cache
|
||||
|
||||
if not redis_available():
|
||||
raise HTTPException(status_code=503, detail="Redis cache is not available")
|
||||
await flush_cache()
|
||||
return {"status": "ok", "message": "Redis cache cleared"}
|
||||
|
||||
|
||||
@router.get("/settings")
|
||||
async def admin_get_settings(_: dict[str, Any] = Depends(require_admin)) -> dict[str, Any]:
|
||||
runtime = await cache_get("admin:runtime_settings") or {}
|
||||
return {"defaults": _public_config(), "runtime_overrides": runtime}
|
||||
|
||||
|
||||
@router.put("/settings")
|
||||
async def admin_update_settings(payload: SettingsUpdate, _: dict[str, Any] = Depends(require_admin)) -> dict[str, Any]:
|
||||
from app.cache import cache_set
|
||||
|
||||
current = await cache_get("admin:runtime_settings") or {}
|
||||
updates = payload.model_dump(exclude_none=True)
|
||||
if not updates:
|
||||
raise HTTPException(status_code=400, detail="No settings provided")
|
||||
current.update(updates)
|
||||
await cache_set("admin:runtime_settings", current, ttl_seconds=86400 * 30)
|
||||
return {"status": "ok", "runtime_overrides": current, "note": "Overrides are stored in Redis. Service restart applies full .env changes."}
|
||||
Reference in New Issue
Block a user