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>
111 lines
3.7 KiB
Python
111 lines
3.7 KiB
Python
"""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),
|
|
})
|