"""Burn-in history pages: paginated list + per-job detail + print view. GET /history — filterable + paginated list GET /history/{job_id} — per-job detail with stages GET /history/{job_id}/print — clean print-friendly variant """ from __future__ import annotations import aiosqlite from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi.responses import HTMLResponse from app import poller from app.database import get_db from app.renderer import templates from ._helpers import stale_context router = APIRouter() _PAGE_SIZE = 50 _ALL_STATES = ("queued", "running", "passed", "failed", "cancelled", "unknown") _HISTORY_QUERY = """ SELECT bj.id, bj.drive_id, bj.profile, bj.state, bj.operator, bj.created_at, bj.started_at, bj.finished_at, bj.error_text, d.devname, d.serial, d.model, d.size_bytes, CAST( (julianday(bj.finished_at) - julianday(bj.started_at)) * 86400 AS INTEGER ) AS duration_seconds FROM burnin_jobs bj JOIN drives d ON d.id = bj.drive_id {where} ORDER BY bj.id DESC """ def _state_where(state: str) -> tuple[str, list]: if state == "all": return "", [] return "WHERE bj.state = ?", [state] @router.get("/history", response_class=HTMLResponse) async def history_list( request: Request, state: str = Query(default="all"), page: int = Query(default=1, ge=1), db: aiosqlite.Connection = Depends(get_db), ): if state not in ("all",) + _ALL_STATES: state = "all" where_clause, params = _state_where(state) # Total count count_sql = f"SELECT COUNT(*) FROM burnin_jobs bj JOIN drives d ON d.id = bj.drive_id {where_clause}" # nosec B608 — `where_clause` is one of two hardcoded literals from _state_where; user input goes through bound params. cur = await db.execute(count_sql, params) total_count = (await cur.fetchone())[0] total_pages = max(1, (total_count + _PAGE_SIZE - 1) // _PAGE_SIZE) page = min(page, total_pages) offset = (page - 1) * _PAGE_SIZE # Per-state counts for badges cur = await db.execute( "SELECT state, COUNT(*) FROM burnin_jobs GROUP BY state" ) counts = {"all": total_count if state == "all" else 0} for r in await cur.fetchall(): counts[r[0]] = r[1] if state != "all": cur2 = await db.execute("SELECT COUNT(*) FROM burnin_jobs") counts["all"] = (await cur2.fetchone())[0] # Job rows sql = _HISTORY_QUERY.format(where=where_clause) + " LIMIT ? OFFSET ?" cur = await db.execute(sql, params + [_PAGE_SIZE, offset]) rows = await cur.fetchall() jobs = [dict(r) for r in rows] ps = poller.get_state() return templates.TemplateResponse(request, "history.html", { "request": request, "jobs": jobs, "active_state": state, "counts": counts, "page": page, "total_pages": total_pages, "total_count": total_count, "poller": ps, **stale_context(ps), }) # /history/{job_id}/print MUST be registered before /history/{job_id} so # FastAPI's route matching tries the literal "print" before the int # coercion would attempt int("print") and 422. @router.get("/history/{job_id}/print", response_class=HTMLResponse) async def history_print( request: Request, job_id: int, db: aiosqlite.Connection = Depends(get_db), ): cur = await db.execute(""" SELECT bj.*, d.devname, d.serial, d.model, d.size_bytes, CAST( (julianday(bj.finished_at) - julianday(bj.started_at)) * 86400 AS INTEGER ) AS duration_seconds FROM burnin_jobs bj JOIN drives d ON d.id = bj.drive_id WHERE bj.id = ? """, (job_id,)) row = await cur.fetchone() if not row: raise HTTPException(status_code=404, detail="Job not found") job = dict(row) cur = await db.execute(""" SELECT *, CAST( (julianday(finished_at) - julianday(started_at)) * 86400 AS INTEGER ) AS duration_seconds FROM burnin_stages WHERE burnin_job_id=? ORDER BY id """, (job_id,)) job["stages"] = [dict(r) for r in await cur.fetchall()] return templates.TemplateResponse(request, "job_print.html", { "request": request, "job": job, }) @router.get("/history/{job_id}", response_class=HTMLResponse) async def history_detail( request: Request, job_id: int, db: aiosqlite.Connection = Depends(get_db), ): # Job + drive info cur = await db.execute(""" SELECT bj.*, d.devname, d.serial, d.model, d.size_bytes, CAST( (julianday(bj.finished_at) - julianday(bj.started_at)) * 86400 AS INTEGER ) AS duration_seconds FROM burnin_jobs bj JOIN drives d ON d.id = bj.drive_id WHERE bj.id = ? """, (job_id,)) row = await cur.fetchone() if not row: raise HTTPException(status_code=404, detail="Burn-in job not found") job = dict(row) # Stages (with duration) cur = await db.execute(""" SELECT *, CAST( (julianday(finished_at) - julianday(started_at)) * 86400 AS INTEGER ) AS duration_seconds FROM burnin_stages WHERE burnin_job_id = ? ORDER BY id """, (job_id,)) job["stages"] = [dict(r) for r in await cur.fetchall()] ps = poller.get_state() return templates.TemplateResponse(request, "job_detail.html", { "request": request, "job": job, "poller": ps, **stale_context(ps), })