diff --git a/app/config.py b/app/config.py index b485318..65329f3 100644 --- a/app/config.py +++ b/app/config.py @@ -83,7 +83,7 @@ class Settings(BaseSettings): ssh_key: str = "" # PEM private key content (paste full key including headers) # Application version — used by the /api/v1/updates/check endpoint - app_version: str = "1.0.0-34" + app_version: str = "1.0.0-35" # ---- Authentication (1.0.0-22) ---- # session_secret: HMAC key for signing session cookies. Empty = generate diff --git a/app/routes/__init__.py b/app/routes/__init__.py index f2c38da..485a9e7 100644 --- a/app/routes/__init__.py +++ b/app/routes/__init__.py @@ -45,9 +45,17 @@ router = APIRouter() # `app.auth` instead of `app.routes.auth`. import app.routes.auth as _auth_routes # noqa: E402 import app.routes.system as _system_routes # noqa: E402 +import app.routes.history as _history_routes # noqa: E402 +import app.routes.audit as _audit_routes # noqa: E402 +import app.routes.stats as _stats_routes # noqa: E402 +import app.routes.report as _report_routes # noqa: E402 router.include_router(_auth_routes.router) router.include_router(_system_routes.router) +router.include_router(_history_routes.router) +router.include_router(_audit_routes.router) +router.include_router(_stats_routes.router) +router.include_router(_report_routes.router) # --------------------------------------------------------------------------- # Internal helpers @@ -654,132 +662,6 @@ async def burnin_cancel(job_id: int, request: Request, req: CancelBurninRequest) return {"cancelled": True} -# --------------------------------------------------------------------------- -# History pages -# --------------------------------------------------------------------------- - -_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}" - 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), - }) - - -@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), - }) - - # --------------------------------------------------------------------------- # CSV export # --------------------------------------------------------------------------- @@ -829,20 +711,6 @@ async def burnin_export_csv(db: aiosqlite.Connection = Depends(get_db)): ) -# --------------------------------------------------------------------------- -# On-demand email report -# --------------------------------------------------------------------------- - -@router.post("/api/v1/report/send") -async def send_report_now(): - """Trigger the daily status email immediately (for testing SMTP config).""" - if not settings.smtp_host: - raise HTTPException(status_code=503, detail="SMTP not configured (SMTP_HOST is empty)") - try: - await mailer.send_report_now() - except Exception as exc: - raise HTTPException(status_code=502, detail=f"Mail send failed: {exc}") - return {"sent": True, "to": settings.smtp_to} # --------------------------------------------------------------------------- @@ -921,146 +789,8 @@ async def reset_drive( return {"reset": True} -# --------------------------------------------------------------------------- -# Audit log page -# --------------------------------------------------------------------------- - -_AUDIT_QUERY = """ - SELECT - ae.id, ae.event_type, ae.operator, ae.message, ae.created_at, - d.devname, d.serial - FROM audit_events ae - LEFT JOIN drives d ON d.id = ae.drive_id - ORDER BY ae.id DESC - LIMIT 200 -""" - -_AUDIT_EVENT_COLORS = { - "burnin_queued": "yellow", - "burnin_started": "blue", - "burnin_passed": "passed", - "burnin_failed": "failed", - "burnin_cancelled": "cancelled", - "burnin_stuck": "failed", - "burnin_unknown": "unknown", -} -@router.get("/audit", response_class=HTMLResponse) -async def audit_log( - request: Request, - db: aiosqlite.Connection = Depends(get_db), -): - cur = await db.execute(_AUDIT_QUERY) - rows = [dict(r) for r in await cur.fetchall()] - ps = poller.get_state() - return templates.TemplateResponse(request, "audit.html", { - "request": request, - "events": rows, - "event_colors": _AUDIT_EVENT_COLORS, - "poller": ps, - **_stale_context(ps), - }) - - -# --------------------------------------------------------------------------- -# Stats / analytics page -# --------------------------------------------------------------------------- - -@router.get("/stats", response_class=HTMLResponse) -async def stats_page( - request: Request, - db: aiosqlite.Connection = Depends(get_db), -): - # Overall counts - cur = await db.execute(""" - SELECT - COUNT(*) as total, - SUM(CASE WHEN state='passed' THEN 1 ELSE 0 END) as passed, - SUM(CASE WHEN state='failed' THEN 1 ELSE 0 END) as failed, - SUM(CASE WHEN state='running' THEN 1 ELSE 0 END) as running, - SUM(CASE WHEN state='cancelled' THEN 1 ELSE 0 END) as cancelled - FROM burnin_jobs - """) - overall = dict(await cur.fetchone()) - - # Failure rate by drive model (only completed jobs) - cur = await db.execute(""" - SELECT - COALESCE(d.model, 'Unknown') AS model, - COUNT(*) AS total, - SUM(CASE WHEN bj.state='passed' THEN 1 ELSE 0 END) AS passed, - SUM(CASE WHEN bj.state='failed' THEN 1 ELSE 0 END) AS failed, - ROUND(100.0 * SUM(CASE WHEN bj.state='passed' THEN 1 ELSE 0 END) / COUNT(*), 1) AS pass_rate - FROM burnin_jobs bj - JOIN drives d ON d.id = bj.drive_id - WHERE bj.state IN ('passed', 'failed') - GROUP BY COALESCE(d.model, 'Unknown') - ORDER BY total DESC - LIMIT 20 - """) - by_model = [dict(r) for r in await cur.fetchall()] - - # Activity last 14 days - cur = await db.execute(""" - SELECT - date(created_at) AS day, - COUNT(*) AS total, - SUM(CASE WHEN state='passed' THEN 1 ELSE 0 END) AS passed, - SUM(CASE WHEN state='failed' THEN 1 ELSE 0 END) AS failed - FROM burnin_jobs - WHERE created_at >= date('now', '-14 days') - GROUP BY date(created_at) - ORDER BY day DESC - """) - by_day = [dict(r) for r in await cur.fetchall()] - - # Average test duration by drive size (rounded to nearest TB) - cur = await db.execute(""" - SELECT - CAST(ROUND(CAST(d.size_bytes AS REAL) / 1e12) AS INTEGER) AS size_tb, - COUNT(*) AS total, - ROUND(AVG( - (julianday(bj.finished_at) - julianday(bj.started_at)) * 86400 / 3600.0 - ), 1) AS avg_hours - FROM burnin_jobs bj - JOIN drives d ON d.id = bj.drive_id - WHERE bj.state IN ('passed', 'failed') - AND bj.started_at IS NOT NULL - AND bj.finished_at IS NOT NULL - GROUP BY size_tb - ORDER BY size_tb - """) - by_size = [dict(r) for r in await cur.fetchall()] - - # Failure breakdown by stage (which stage caused the failure) - cur = await db.execute(""" - SELECT - COALESCE(bj.stage_name, 'unknown') AS failed_stage, - COUNT(*) AS count - FROM burnin_jobs bj - WHERE bj.state = 'failed' - GROUP BY failed_stage - ORDER BY count DESC - """) - by_failure_stage = [dict(r) for r in await cur.fetchall()] - - # Drives tracked - cur = await db.execute("SELECT COUNT(*) FROM drives") - drives_total = (await cur.fetchone())[0] - - ps = poller.get_state() - return templates.TemplateResponse(request, "stats.html", { - "request": request, - "overall": overall, - "by_model": by_model, - "by_day": by_day, - "by_size": by_size, - "by_failure_stage": by_failure_stage, - "drives_total": drives_total, - "poller": ps, - **_stale_context(ps), - }) # --------------------------------------------------------------------------- @@ -1210,42 +940,6 @@ async def test_ssh(request: Request): # Print view (must be BEFORE /{job_id} int route) # --------------------------------------------------------------------------- -@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, - }) # --------------------------------------------------------------------------- diff --git a/app/routes/audit.py b/app/routes/audit.py new file mode 100644 index 0000000..93ec382 --- /dev/null +++ b/app/routes/audit.py @@ -0,0 +1,53 @@ +"""Audit log page — shows the last 200 entries from `audit_events`.""" + +from __future__ import annotations + +import aiosqlite +from fastapi import APIRouter, Depends, 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() + + +_AUDIT_QUERY = """ + SELECT + ae.id, ae.event_type, ae.operator, ae.message, ae.created_at, + d.devname, d.serial + FROM audit_events ae + LEFT JOIN drives d ON d.id = ae.drive_id + ORDER BY ae.id DESC + LIMIT 200 +""" + +_AUDIT_EVENT_COLORS = { + "burnin_queued": "yellow", + "burnin_started": "blue", + "burnin_passed": "passed", + "burnin_failed": "failed", + "burnin_cancelled": "cancelled", + "burnin_stuck": "failed", + "burnin_unknown": "unknown", +} + + +@router.get("/audit", response_class=HTMLResponse) +async def audit_log( + request: Request, + db: aiosqlite.Connection = Depends(get_db), +): + cur = await db.execute(_AUDIT_QUERY) + rows = [dict(r) for r in await cur.fetchall()] + ps = poller.get_state() + return templates.TemplateResponse(request, "audit.html", { + "request": request, + "events": rows, + "event_colors": _AUDIT_EVENT_COLORS, + "poller": ps, + **stale_context(ps), + }) diff --git a/app/routes/history.py b/app/routes/history.py new file mode 100644 index 0000000..b4c7183 --- /dev/null +++ b/app/routes/history.py @@ -0,0 +1,184 @@ +"""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), + }) diff --git a/app/routes/report.py b/app/routes/report.py new file mode 100644 index 0000000..6d09601 --- /dev/null +++ b/app/routes/report.py @@ -0,0 +1,24 @@ +"""On-demand email report trigger — useful for testing SMTP config.""" + +from __future__ import annotations + +from fastapi import APIRouter, HTTPException, Request + +from app import auth, mailer +from app.config import settings + +router = APIRouter() + + +@router.post("/api/v1/report/send") +async def send_report_now(request: Request): + """Trigger the daily status email immediately. Admin-only because + sending mail is a side effect non-admins shouldn't be able to fire.""" + auth.require_admin(request) + if not settings.smtp_host: + raise HTTPException(status_code=503, detail="SMTP not configured (SMTP_HOST is empty)") + try: + await mailer.send_report_now() + except Exception as exc: + raise HTTPException(status_code=502, detail=f"Mail send failed: {exc}") + return {"sent": True, "to": settings.smtp_to} diff --git a/app/routes/stats.py b/app/routes/stats.py new file mode 100644 index 0000000..b21d895 --- /dev/null +++ b/app/routes/stats.py @@ -0,0 +1,111 @@ +"""Stats / analytics page — aggregates over `burnin_jobs` for dashboards.""" + +from __future__ import annotations + +import aiosqlite +from fastapi import APIRouter, Depends, 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() + + +@router.get("/stats", response_class=HTMLResponse) +async def stats_page( + request: Request, + db: aiosqlite.Connection = Depends(get_db), +): + # Overall counts + cur = await db.execute(""" + SELECT + COUNT(*) as total, + SUM(CASE WHEN state='passed' THEN 1 ELSE 0 END) as passed, + SUM(CASE WHEN state='failed' THEN 1 ELSE 0 END) as failed, + SUM(CASE WHEN state='running' THEN 1 ELSE 0 END) as running, + SUM(CASE WHEN state='cancelled' THEN 1 ELSE 0 END) as cancelled + FROM burnin_jobs + """) + overall = dict(await cur.fetchone()) + + # Failure rate by drive model (only completed jobs) + cur = await db.execute(""" + SELECT + COALESCE(d.model, 'Unknown') AS model, + COUNT(*) AS total, + SUM(CASE WHEN bj.state='passed' THEN 1 ELSE 0 END) AS passed, + SUM(CASE WHEN bj.state='failed' THEN 1 ELSE 0 END) AS failed, + ROUND(100.0 * SUM(CASE WHEN bj.state='passed' THEN 1 ELSE 0 END) / COUNT(*), 1) AS pass_rate + FROM burnin_jobs bj + JOIN drives d ON d.id = bj.drive_id + WHERE bj.state IN ('passed', 'failed') + GROUP BY COALESCE(d.model, 'Unknown') + ORDER BY total DESC + LIMIT 20 + """) + by_model = [dict(r) for r in await cur.fetchall()] + + # Activity last 14 days + cur = await db.execute(""" + SELECT + date(created_at) AS day, + COUNT(*) AS total, + SUM(CASE WHEN state='passed' THEN 1 ELSE 0 END) AS passed, + SUM(CASE WHEN state='failed' THEN 1 ELSE 0 END) AS failed + FROM burnin_jobs + WHERE created_at >= date('now', '-14 days') + GROUP BY date(created_at) + ORDER BY day DESC + """) + by_day = [dict(r) for r in await cur.fetchall()] + + # Average test duration by drive size (rounded to nearest TB) + cur = await db.execute(""" + SELECT + CAST(ROUND(CAST(d.size_bytes AS REAL) / 1e12) AS INTEGER) AS size_tb, + COUNT(*) AS total, + ROUND(AVG( + (julianday(bj.finished_at) - julianday(bj.started_at)) * 86400 / 3600.0 + ), 1) AS avg_hours + FROM burnin_jobs bj + JOIN drives d ON d.id = bj.drive_id + WHERE bj.state IN ('passed', 'failed') + AND bj.started_at IS NOT NULL + AND bj.finished_at IS NOT NULL + GROUP BY size_tb + ORDER BY size_tb + """) + by_size = [dict(r) for r in await cur.fetchall()] + + # Failure breakdown by stage (which stage caused the failure) + cur = await db.execute(""" + SELECT + COALESCE(bj.stage_name, 'unknown') AS failed_stage, + COUNT(*) AS count + FROM burnin_jobs bj + WHERE bj.state = 'failed' + GROUP BY failed_stage + ORDER BY count DESC + """) + by_failure_stage = [dict(r) for r in await cur.fetchall()] + + # Drives tracked + cur = await db.execute("SELECT COUNT(*) FROM drives") + drives_total = (await cur.fetchone())[0] + + ps = poller.get_state() + return templates.TemplateResponse(request, "stats.html", { + "request": request, + "overall": overall, + "by_model": by_model, + "by_day": by_day, + "by_size": by_size, + "by_failure_stage": by_failure_stage, + "drives_total": drives_total, + "poller": ps, + **stale_context(ps), + })