From 30062affc2675c3c4e7dbdd953e6d09dcfe26e2d Mon Sep 17 00:00:00 2001 From: Brandon Walter <51866976+echoparkbaby@users.noreply.github.com> Date: Fri, 8 May 2026 22:34:35 -0700 Subject: [PATCH] feat: per-pattern badblocks meters in drive drawer (1.0.0-44) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User asked for one meter per badblocks pattern. The drawer now shows 4 meters (one per pattern: 0xaa / 0x55 / 0xff / 0x00), each split into write (left, blue) + verify (right, green) halves so a glance shows both which pattern is current AND whether you're writing or verifying within it. Backend: - New columns burnin_stages.bb_phase (1-8) + bb_phase_pct (0-100) via idempotent ALTER TABLE migration - _update_stage_bb_phase() helper called from the badblocks parser on every tick (when phase or percent changes) - /api/v1/drives/{id}/drawer SELECT now returns the new fields Frontend (app.js + app.css): - _drawerRenderBadblocksMeters(phase, phasePct) computes per-pattern fill state and emits 4-meter HTML with W/V sub-labels - Conditional render: only shows when stage_name === 'surface_validate' AND bb_phase is set, so historical pre-1.0.0-44 stage rows render unchanged (single percent, no meters) 3 new tests cover the migration columns, single-tick persistence, and overwrite-on-second-tick. Total suite: 75 tests. Image rebuilt and tagged but NOT deployed — 4 burn-ins are running right now and a recreate would SIGHUP them. Deploy with `docker compose up -d` after the current batch finishes; the migration runs at init and the meters light up for the next batch. --- app/burnin/_common.py | 15 +++++ app/burnin/stages.py | 10 ++- app/config.py | 2 +- app/database.py | 6 ++ app/routes/drives.py | 3 +- app/static/app.css | 77 ++++++++++++++++++++++ app/static/app.js | 47 ++++++++++++++ tests/test_bb_phase_persistence.py | 100 +++++++++++++++++++++++++++++ 8 files changed, 257 insertions(+), 3 deletions(-) create mode 100644 tests/test_bb_phase_persistence.py diff --git a/app/burnin/_common.py b/app/burnin/_common.py index 385ed9c..154e8e7 100644 --- a/app/burnin/_common.py +++ b/app/burnin/_common.py @@ -190,6 +190,21 @@ async def _update_stage_bad_blocks(job_id: int, stage_name: str, count: int) -> await db.commit() +async def _update_stage_bb_phase( + job_id: int, stage_name: str, phase: int, phase_pct: float, +) -> None: + """Persist per-pattern badblocks progress so the drive-drawer UI + can render 4 meters with separate write/verify halves.""" + async with _db() as db: + await db.execute("PRAGMA journal_mode=WAL") + await db.execute( + "UPDATE burnin_stages SET bb_phase=?, bb_phase_pct=? " + "WHERE burnin_job_id=? AND stage_name=?", + (phase, phase_pct, 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 bfa8119..dd4b991 100644 --- a/app/burnin/stages.py +++ b/app/burnin/stages.py @@ -121,6 +121,7 @@ from ._common import ( _store_smart_attrs, _store_smart_raw_output, _update_stage_bad_blocks, + _update_stage_bb_phase, _update_stage_percent, ) @@ -521,11 +522,18 @@ async def _stage_surface_validate_ssh(job_id: int, devname: str, drive_id: int) # and comparing" headers (which advance the phase # counter), not just the percent-done lines. prev = progress.overall_pct + prev_phase = progress.phase progress.update(line) - if progress.overall_pct != prev or _BB_PERCENT_RE.search(line): + phase_changed = progress.phase != prev_phase + pct_changed = progress.overall_pct != prev + if phase_changed or pct_changed or _BB_PERCENT_RE.search(line): await _update_stage_percent( job_id, "surface_validate", progress.overall_pct, ) + await _update_stage_bb_phase( + job_id, "surface_validate", + progress.phase, progress.phase_pct, + ) await _update_stage_bad_blocks(job_id, "surface_validate", bad_blocks_total) await _recalculate_progress(job_id) _push_update() diff --git a/app/config.py b/app/config.py index ef97840..370efc5 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-43" + app_version: str = "1.0.0-44" # ---- 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 d39bdd1..edcad2f 100644 --- a/app/database.py +++ b/app/database.py @@ -93,6 +93,12 @@ _MIGRATIONS = [ "ALTER TABLE drives ADD COLUMN pool_name TEXT", "ALTER TABLE drives ADD COLUMN pool_role TEXT", "ALTER TABLE drives ADD COLUMN pool_seen_at TEXT", + # 1.0.0-44: per-pattern badblocks progress for the drive drawer's + # 4-meter UI. bb_phase is 1-8 (1=write 0xaa, 2=verify 0xaa, 3=write + # 0x55, 4=verify 0x55, 5=write 0xff, 6=verify 0xff, 7=write 0x00, + # 8=verify 0x00). bb_phase_pct is 0-100 within the current phase. + "ALTER TABLE burnin_stages ADD COLUMN bb_phase INTEGER", + "ALTER TABLE burnin_stages ADD COLUMN bb_phase_pct REAL", # 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 03ef6be..8d6e154 100644 --- a/app/routes/drives.py +++ b/app/routes/drives.py @@ -57,7 +57,8 @@ async def drive_drawer(drive_id: int, db: aiosqlite.Connection = Depends(get_db) job = dict(job_row) cur = await db.execute( "SELECT id, stage_name, state, percent, started_at, finished_at, " - "duration_seconds, error_text, log_text, bad_blocks " + "duration_seconds, error_text, log_text, bad_blocks, " + "bb_phase, bb_phase_pct " "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 03539c2..a9d0258 100644 --- a/app/static/app.css +++ b/app/static/app.css @@ -2694,3 +2694,80 @@ tr.drawer-row-active { font-variant-numeric: tabular-nums; } + +/* ----------------------------------------------------------------------- + Per-pattern badblocks meters in the drive drawer (1.0.0-44). + Four meters, one per pattern (0xaa / 0x55 / 0xff / 0x00). Each meter + has two halves: write (left) and verify (right), so a glance shows + both which pattern is running and which sub-phase within it. +----------------------------------------------------------------------- */ +.bb-meters { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; + padding: 10px 12px; + background: var(--bg-soft, #161b22); + border-radius: 6px; + margin: 6px 0 8px 0; +} +.bb-meter { + display: flex; + flex-direction: column; + gap: 4px; +} +.bb-meter-label { + font-family: "SF Mono", "Consolas", monospace; + font-size: 10px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: .04em; +} +.bb-meter-current .bb-meter-label { + color: var(--blue, #58a6ff); + font-weight: 600; +} +.bb-meter-done .bb-meter-label { + color: var(--green, #3fb950); +} +.bb-meter-bar { + display: flex; + height: 10px; + background: var(--bg, #0d1117); + border: 1px solid var(--border, #30363d); + border-radius: 3px; + overflow: hidden; + position: relative; +} +.bb-meter-half { + height: 100%; + transition: width .3s ease; +} +.bb-write { + background: var(--blue, #58a6ff); + flex: 0 0 auto; + max-width: 50%; +} +.bb-verify { + background: var(--green, #3fb950); + flex: 0 0 auto; + max-width: 50%; +} +.bb-meter-half-spacer { + flex: 0 0 auto; + width: 1px; + background: var(--border, #30363d); + height: 100%; +} +.bb-meter-done .bb-write, +.bb-meter-done .bb-verify { + opacity: .55; +} +.bb-meter-sub { + display: flex; + justify-content: space-between; + font-family: "SF Mono", "Consolas", monospace; + font-size: 9px; + color: var(--text-muted); +} +.bb-sub-write { color: color-mix(in srgb, var(--blue) 80%, var(--text-muted)); } +.bb-sub-verify { color: color-mix(in srgb, var(--green) 80%, var(--text-muted)); } diff --git a/app/static/app.js b/app/static/app.js index b30398e..c3fb84d 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -1286,6 +1286,49 @@ _drawerRenderEvents(data.events); } + // Render 4 pattern meters for badblocks -w surface_validate. Each + // meter splits write/verify halves so you can see at a glance which + // pattern is current AND whether you're writing or verifying within + // it. phase: 1-8 (1=write 0xaa, 2=verify 0xaa, 3=write 0x55, ...). + function _drawerRenderBadblocksMeters(phase, phasePct) { + if (!phase) return ''; + var p = parseInt(phase, 10); + var pct = parseFloat(phasePct || 0); + var labels = ['0xaa', '0x55', '0xff', '0x00']; + var html = '