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)
|
ssh_key: str = "" # PEM private key content (paste full key including headers)
|
||||||
|
|
||||||
# Application version — used by the /api/v1/updates/check endpoint
|
# 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) ----
|
# ---- Authentication (1.0.0-22) ----
|
||||||
# session_secret: HMAC key for signing session cookies. Empty = generate
|
# session_secret: HMAC key for signing session cookies. Empty = generate
|
||||||
|
|
|
||||||
|
|
@ -45,9 +45,17 @@ router = APIRouter()
|
||||||
# `app.auth` instead of `app.routes.auth`.
|
# `app.auth` instead of `app.routes.auth`.
|
||||||
import app.routes.auth as _auth_routes # noqa: E402
|
import app.routes.auth as _auth_routes # noqa: E402
|
||||||
import app.routes.system as _system_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(_auth_routes.router)
|
||||||
router.include_router(_system_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
|
# Internal helpers
|
||||||
|
|
@ -654,132 +662,6 @@ async def burnin_cancel(job_id: int, request: Request, req: CancelBurninRequest)
|
||||||
return {"cancelled": True}
|
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
|
# 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}
|
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)
|
# 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