import asyncio import logging import sqlite3 from contextlib import asynccontextmanager from datetime import timedelta from pathlib import Path from fastapi import APIRouter, Depends, FastAPI, HTTPException, Query, Request from . import db, repo from .auth import require_ingest_key from .models import IngestIn from .settings import HISTORY_DAYS, Settings, load_settings from .timeutil import to_db, utcnow log = logging.getLogger("backup_dashboard") PURGE_INTERVAL_SECONDS = 6 * 3600 router = APIRouter(prefix="/api/v1") @router.get("/health") def health(conn: sqlite3.Connection = Depends(db.get_db)): conn.execute("SELECT 1") return {"status": "ok"} @router.get("/jobs") def list_jobs(conn: sqlite3.Connection = Depends(db.get_db)): return {"generated_at": to_db(utcnow()), "jobs": repo.list_jobs(conn)} @router.get("/jobs/{job_id}") def get_job(job_id: int, conn: sqlite3.Connection = Depends(db.get_db)): job = repo.get_job(conn, job_id) if job is None: raise HTTPException(404, "Job not found") return job @router.get("/jobs/{job_id}/runs") def list_runs( job_id: int, days: int = Query(HISTORY_DAYS, ge=1, le=HISTORY_DAYS), conn: sqlite3.Connection = Depends(db.get_db), ): if conn.execute("SELECT 1 FROM jobs WHERE id = ?", (job_id,)).fetchone() is None: raise HTTPException(404, "Job not found") since = utcnow() - timedelta(days=days) return {"job_id": job_id, "since": to_db(since), "runs": repo.list_runs(conn, job_id, since)} @router.get("/runs/{run_id}") def get_run(run_id: int, conn: sqlite3.Connection = Depends(db.get_db)): run = repo.get_run(conn, run_id) if run is None: raise HTTPException(404, "Run not found") return run @router.post("/ingest") def ingest( item: IngestIn, key_name: str = Depends(require_ingest_key), conn: sqlite3.Connection = Depends(db.get_db), ): job_id, run_id = repo.ingest(conn, item) log.info("ingest key=%s tenant=%r job=%r run=%s", key_name, item.tenant, item.job.name, run_id) return {"job_id": job_id, "run_id": run_id} async def _purge_loop(settings: Settings) -> None: def purge() -> int: conn = db.connect(settings.db_path) try: return repo.purge_old_runs(conn, settings.retention_days) finally: conn.close() while True: try: if deleted := await asyncio.to_thread(purge): log.info("purged %d runs older than %d days", deleted, settings.retention_days) except Exception: log.exception("run purge failed") await asyncio.sleep(PURGE_INTERVAL_SECONDS) def create_app(settings: Settings | None = None) -> FastAPI: settings = settings or load_settings() @asynccontextmanager async def lifespan(app: FastAPI): db.init_db(settings.db_path) task = asyncio.create_task(_purge_loop(settings)) try: yield finally: task.cancel() # Interactive docs are off: nginx's strict CSP would block their CDN assets anyway. app = FastAPI( title="Backup Dashboard API", lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url="/api/v1/openapi.json", ) app.state.settings = settings app.include_router(router) if settings.serve_static: from fastapi.staticfiles import StaticFiles public = Path(__file__).resolve().parents[2] / "public" app.mount("/", StaticFiles(directory=public, html=True), name="static") return app