diff --git a/app/burnin/_common.py b/app/burnin/_common.py index 805a948..2f3b50c 100644 --- a/app/burnin/_common.py +++ b/app/burnin/_common.py @@ -221,6 +221,41 @@ async def _update_stage_bb_mbps( await db.commit() +async def _record_bb_phase_start( + job_id: int, stage_name: str, phase: int, ts: str, +) -> None: + """Record the moment a phase first becomes current. Idempotent: + re-entry of the same phase keeps the original timestamp so a + transient parser reset doesn't blow away history. + + Stored as a JSON object keyed by phase number (string). The + drawer reads it to compute per-pattern elapsed times. + """ + async with _db() as db: + await db.execute("PRAGMA journal_mode=WAL") + cur = await db.execute( + "SELECT bb_phase_history FROM burnin_stages " + "WHERE burnin_job_id=? AND stage_name=?", + (job_id, stage_name), + ) + row = await cur.fetchone() + existing = {} + if row and row[0]: + try: + existing = json.loads(row[0]) + except (json.JSONDecodeError, TypeError): + existing = {} + key = str(phase) + if key not in existing: + existing[key] = ts + await db.execute( + "UPDATE burnin_stages SET bb_phase_history=? " + "WHERE burnin_job_id=? AND stage_name=?", + (json.dumps(existing), job_id, stage_name), + ) + await db.commit() + + async def _store_smart_attrs(drive_id: int, attrs: dict) -> None: """Persist latest SMART attribute dict to drives.smart_attrs (JSON).""" # Convert int keys to str for JSON serialisation diff --git a/app/burnin/stages.py b/app/burnin/stages.py index 5989b0d..6921632 100644 --- a/app/burnin/stages.py +++ b/app/burnin/stages.py @@ -115,8 +115,10 @@ from ._common import ( _append_stage_log, _db, _is_cancelled, + _now, _push_update, _recalculate_progress, + _record_bb_phase_start, _set_stage_error, _store_smart_attrs, _store_smart_raw_output, @@ -522,6 +524,11 @@ async def _stage_surface_validate_ssh(job_id: int, devname: str, drive_id: int) job_id, "surface_validate", progress.phase, progress.phase_pct, ) + # Stamp phase 1 (write 0xaa) start so the drawer's + # duration history starts populating immediately. + await _record_bb_phase_start( + job_id, "surface_validate", progress.phase, _now(), + ) _push_update() async def _drain(stream, is_stderr: bool): @@ -556,6 +563,14 @@ async def _stage_surface_validate_ssh(job_id: int, devname: str, drive_id: int) phase_changed = progress.phase != prev_phase pct_changed = progress.overall_pct != prev if phase_changed or pct_changed or _BB_PERCENT_RE.search(line): + if phase_changed: + # Stamp the moment we first see this + # phase so the drawer can show + # per-pattern elapsed times. + await _record_bb_phase_start( + job_id, "surface_validate", + progress.phase, _now(), + ) await _update_stage_percent( job_id, "surface_validate", progress.overall_pct, ) diff --git a/app/config.py b/app/config.py index 1c31724..43e493a 100644 --- a/app/config.py +++ b/app/config.py @@ -86,7 +86,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-46" + app_version: str = "1.0.0-47" # ---- Authentication (1.0.0-22) ---- # session_secret: HMAC key for signing session cookies. Empty = generate diff --git a/app/database.py b/app/database.py index d6dc63f..e2a2067 100644 --- a/app/database.py +++ b/app/database.py @@ -105,6 +105,12 @@ _MIGRATIONS = [ # Updated on every progress line; NULL until the second progress # line arrives (need two samples to compute a rate). "ALTER TABLE burnin_stages ADD COLUMN bb_mbps REAL", + # 1.0.0-47: per-pattern duration history. JSON map of + # {"1": "2026-05-09T05:39:44+00:00", "2": ..., ...} where each key + # is the phase number (1-8) and the value is when the parser first + # observed that phase. Drawer derives "0xaa: 14h 22m" by diffing + # consecutive phase-1 keys. + "ALTER TABLE burnin_stages ADD COLUMN bb_phase_history TEXT", # 1.0.0-19: enforce one active burn-in per drive at the storage layer. # Closes the read-then-insert race in burnin.start_job — without this, # two concurrent /api/v1/burnin/start requests for the same drive could diff --git a/app/routes/drives.py b/app/routes/drives.py index 2ac8f9c..9031a52 100644 --- a/app/routes/drives.py +++ b/app/routes/drives.py @@ -58,7 +58,7 @@ async def drive_drawer(drive_id: int, db: aiosqlite.Connection = Depends(get_db) cur = await db.execute( "SELECT id, stage_name, state, percent, started_at, finished_at, " "duration_seconds, error_text, log_text, bad_blocks, " - "bb_phase, bb_phase_pct, bb_mbps " + "bb_phase, bb_phase_pct, bb_mbps, bb_phase_history " "FROM burnin_stages WHERE burnin_job_id=? ORDER BY id", (job_row["id"],), ) diff --git a/app/static/app.css b/app/static/app.css index 57e58e2..2b788b0 100644 --- a/app/static/app.css +++ b/app/static/app.css @@ -2817,3 +2817,51 @@ tr.drawer-row-active { font-weight: 500; font-variant-numeric: tabular-nums; } + +/* ----------------------------------------------------------------------- + Phase caption + per-pattern history (1.0.0-47). +----------------------------------------------------------------------- */ +.bb-caption { + font-family: "SF Mono", "Consolas", monospace; + font-size: 11px; + color: var(--text-muted); + padding: 6px 12px 0 12px; + letter-spacing: .02em; +} +.bb-history { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + padding: 6px 12px 8px 12px; + font-family: "SF Mono", "Consolas", monospace; + font-size: 10px; + color: var(--text-muted); +} +.bb-hist-title { + text-transform: uppercase; + letter-spacing: .04em; + font-size: 9px; + margin-right: 4px; +} +.bb-hist-row { + display: inline-flex; + align-items: baseline; + gap: 4px; + background: var(--bg, #0d1117); + border: 1px solid var(--border, #30363d); + border-radius: 3px; + padding: 1px 6px; +} +.bb-hist-label { + color: var(--green, #3fb950); + font-weight: 600; +} +.bb-hist-dur { + color: var(--text-strong, #f0f6fc); + font-variant-numeric: tabular-nums; +} + +/* Bad-block counter colour states inside the vitals row */ +.bb-vital-good { color: var(--green, #3fb950); } +.bb-vital-bad { color: var(--red, #f85149); } diff --git a/app/static/app.js b/app/static/app.js index 869bc9f..d6bd602 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -1356,6 +1356,54 @@ return m + 'm'; } + // Phase caption — explicit text below the meters: e.g. + // "Pattern 2 of 4 · Verify 0x55 · 47% within phase". + function _drawerRenderBadblocksCaption(phase, phasePct) { + if (!phase) return ''; + var p = parseInt(phase, 10); + var pct = parseFloat(phasePct || 0); + var labels = ['0xaa', '0x55', '0xff', '0x00']; + var pattern = Math.ceil(p / 2); + var subPhase = (p % 2 === 1) ? 'Write' : 'Verify'; + var label = labels[pattern - 1]; + var html = '