Largest routes/ slice yet — drives.py (8 endpoints) and burnin.py (4 endpoints). Drives helpers live in _drives_helpers.py so the dashboard SSE handler in routes/__init__.py and mailer.py can both keep using them via re-export. routes/__init__.py shrinks from 815 → 163 LoC; only the dashboard / and /sse/drives stream remain there. Routes split is now functionally complete: 12 files, ~1800 LoC distributed by feature.
156 lines
5 KiB
Python
156 lines
5 KiB
Python
"""Burn-in endpoints — start, cancel, CSV export, job detail.
|
|
|
|
POST /api/v1/burnin/start
|
|
POST /api/v1/burnin/{job_id}/cancel
|
|
GET /api/v1/burnin/export.csv — must register before /{job_id}
|
|
so int("export.csv") doesn't 422
|
|
GET /api/v1/burnin/{job_id}
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import csv
|
|
import io
|
|
|
|
import aiosqlite
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
from fastapi.responses import StreamingResponse
|
|
|
|
from app import burnin
|
|
from app.database import get_db
|
|
from app.models import (
|
|
BurninJobResponse, BurninStageResponse,
|
|
CancelBurninRequest, StartBurninRequest,
|
|
)
|
|
|
|
from ._helpers import operator_for
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _row_to_burnin(row: aiosqlite.Row, stages: list[aiosqlite.Row]) -> BurninJobResponse:
|
|
return BurninJobResponse(
|
|
id=row["id"],
|
|
drive_id=row["drive_id"],
|
|
profile=row["profile"],
|
|
state=row["state"],
|
|
percent=row["percent"] or 0,
|
|
stage_name=row["stage_name"],
|
|
operator=row["operator"],
|
|
created_at=row["created_at"],
|
|
started_at=row["started_at"],
|
|
finished_at=row["finished_at"],
|
|
error_text=row["error_text"],
|
|
stages=[
|
|
BurninStageResponse(
|
|
id=s["id"],
|
|
stage_name=s["stage_name"],
|
|
state=s["state"],
|
|
percent=s["percent"] or 0,
|
|
started_at=s["started_at"],
|
|
finished_at=s["finished_at"],
|
|
error_text=s["error_text"],
|
|
)
|
|
for s in stages
|
|
],
|
|
)
|
|
|
|
|
|
@router.post("/api/v1/burnin/start")
|
|
async def burnin_start(request: Request, req: StartBurninRequest):
|
|
operator = operator_for(request, req.operator)
|
|
results = []
|
|
errors = []
|
|
for drive_id in req.drive_ids:
|
|
try:
|
|
job_id = await burnin.start_job(
|
|
drive_id, req.profile, operator, stage_order=req.stage_order
|
|
)
|
|
results.append({"drive_id": drive_id, "job_id": job_id})
|
|
except burnin.PoolMemberError as exc:
|
|
errors.append({
|
|
"drive_id": drive_id,
|
|
"error": str(exc),
|
|
"pool_name": exc.pool_name,
|
|
"pool_role": exc.pool_role,
|
|
"pool_locked": True,
|
|
})
|
|
except ValueError as exc:
|
|
errors.append({"drive_id": drive_id, "error": str(exc)})
|
|
if errors and not results:
|
|
# Surface the first error's structured fields so the UI can render
|
|
# an unlock affordance instead of a generic toast.
|
|
raise HTTPException(status_code=409, detail=errors[0])
|
|
return {"queued": results, "errors": errors}
|
|
|
|
|
|
@router.post("/api/v1/burnin/{job_id}/cancel")
|
|
async def burnin_cancel(job_id: int, request: Request, req: CancelBurninRequest):
|
|
operator = operator_for(request, req.operator)
|
|
ok = await burnin.cancel_job(job_id, operator)
|
|
if not ok:
|
|
raise HTTPException(status_code=409, detail="Job not found or not cancellable")
|
|
return {"cancelled": True}
|
|
|
|
|
|
# /api/v1/burnin/export.csv MUST be declared BEFORE /api/v1/burnin/{job_id}
|
|
# so FastAPI's path matching tries the literal first; otherwise the int
|
|
# coercion fires int("export.csv") and 422s.
|
|
|
|
@router.get("/api/v1/burnin/export.csv")
|
|
async def burnin_export_csv(db: aiosqlite.Connection = Depends(get_db)):
|
|
cur = await db.execute("""
|
|
SELECT
|
|
bj.id AS job_id,
|
|
bj.drive_id,
|
|
d.devname,
|
|
d.serial,
|
|
d.model,
|
|
bj.profile,
|
|
bj.state,
|
|
bj.operator,
|
|
bj.created_at,
|
|
bj.started_at,
|
|
bj.finished_at,
|
|
CAST(
|
|
(julianday(bj.finished_at) - julianday(bj.started_at)) * 86400
|
|
AS INTEGER
|
|
) AS duration_seconds,
|
|
bj.error_text
|
|
FROM burnin_jobs bj
|
|
JOIN drives d ON d.id = bj.drive_id
|
|
ORDER BY bj.id DESC
|
|
""")
|
|
rows = await cur.fetchall()
|
|
|
|
buf = io.StringIO()
|
|
writer = csv.writer(buf)
|
|
writer.writerow([
|
|
"job_id", "drive_id", "devname", "serial", "model",
|
|
"profile", "state", "operator",
|
|
"created_at", "started_at", "finished_at", "duration_seconds",
|
|
"error_text",
|
|
])
|
|
for r in rows:
|
|
writer.writerow(list(r))
|
|
|
|
buf.seek(0)
|
|
return StreamingResponse(
|
|
iter([buf.getvalue()]),
|
|
media_type="text/csv",
|
|
headers={"Content-Disposition": "attachment; filename=burnin_history.csv"},
|
|
)
|
|
|
|
|
|
@router.get("/api/v1/burnin/{job_id}", response_model=BurninJobResponse)
|
|
async def burnin_get(job_id: int, db: aiosqlite.Connection = Depends(get_db)):
|
|
db.row_factory = aiosqlite.Row
|
|
cur = await db.execute("SELECT * FROM burnin_jobs WHERE id=?", (job_id,))
|
|
row = await cur.fetchone()
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Burn-in job not found")
|
|
cur = await db.execute(
|
|
"SELECT * FROM burnin_stages WHERE burnin_job_id=? ORDER BY id", (job_id,)
|
|
)
|
|
stages = await cur.fetchall()
|
|
return _row_to_burnin(row, stages)
|