119 lines
3.5 KiB
Python
119 lines
3.5 KiB
Python
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
|