refactor: extract history + audit + stats + report routes (1.0.0-35)
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>
This commit is contained in:
parent
aa7822d6ce
commit
3c39344069
6 changed files with 381 additions and 315 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
53
app/routes/audit.py
Normal file
53
app/routes/audit.py
Normal file
|
|
@ -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),
|
||||
})
|
||||
184
app/routes/history.py
Normal file
184
app/routes/history.py
Normal file
|
|
@ -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),
|
||||
})
|
||||
24
app/routes/report.py
Normal file
24
app/routes/report.py
Normal file
|
|
@ -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}
|
||||
111
app/routes/stats.py
Normal file
111
app/routes/stats.py
Normal file
|
|
@ -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),
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue