Continues the routes/ package split — four more clean extractions, all
following the same absolute-import pattern documented in the 1.0.0-34
gotcha note.
* routes/history.py (184 LoC) — /history, /history/{id}, and the
/history/{id}/print view that MUST register before the {id} int route
to avoid FastAPI's int("print") 422. Helpers _PAGE_SIZE,
_ALL_STATES, _HISTORY_QUERY, _state_where moved with the endpoints.
B608 nosec annotated on the count_sql f-string (it's two hardcoded
literals; user input goes through bound params).
* routes/audit.py (53 LoC) — /audit page only. Owns _AUDIT_QUERY +
_AUDIT_EVENT_COLORS.
* routes/stats.py (111 LoC) — /stats analytics page. Pure aggregation
queries against burnin_jobs/drives, no shared helpers beyond
stale_context.
* routes/report.py (24 LoC) — POST /api/v1/report/send. Now requires
admin (was open to any authenticated user; sending mail is a side
effect non-admins shouldn't be able to fire — same principle as the
settings mutation gates added in 1.0.0-28).
routes/__init__.py shrank from 1261 -> 960 LoC. Remaining work:
drives, burnin, settings, dashboard — same pattern. Each future slice
will use the `import app.routes.X as _Y` absolute-import gotcha
workaround from 1.0.0-34.
Verification: 59/59 tests pass; /login 200 (public); /history /audit
/stats 401 (correctly auth-gated by middleware); container boots
clean at 1.0.0-35.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
184 lines
5.6 KiB
Python
184 lines
5.6 KiB
Python
"""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),
|
|
})
|