155 lines
5.3 KiB
Python
155 lines
5.3 KiB
Python
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."}
|