feat: per-pattern badblocks meters in drive drawer (1.0.0-44)
Some checks are pending
Security scan / pip-audit (push) Waiting to run
Security scan / bandit (push) Waiting to run
Security scan / gitleaks (push) Waiting to run
Security scan / mypy (push) Waiting to run

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.
This commit is contained in:
Brandon Walter 2026-05-08 22:34:35 -07:00
parent 4922b19a9f
commit 30062affc2
8 changed files with 257 additions and 3 deletions

View file

@ -190,6 +190,21 @@ async def _update_stage_bad_blocks(job_id: int, stage_name: str, count: int) ->
await db.commit() 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: async def _store_smart_attrs(drive_id: int, attrs: dict) -> None:
"""Persist latest SMART attribute dict to drives.smart_attrs (JSON).""" """Persist latest SMART attribute dict to drives.smart_attrs (JSON)."""
# Convert int keys to str for JSON serialisation # Convert int keys to str for JSON serialisation

View file

@ -121,6 +121,7 @@ from ._common import (
_store_smart_attrs, _store_smart_attrs,
_store_smart_raw_output, _store_smart_raw_output,
_update_stage_bad_blocks, _update_stage_bad_blocks,
_update_stage_bb_phase,
_update_stage_percent, _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 # and comparing" headers (which advance the phase
# counter), not just the percent-done lines. # counter), not just the percent-done lines.
prev = progress.overall_pct prev = progress.overall_pct
prev_phase = progress.phase
progress.update(line) 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( await _update_stage_percent(
job_id, "surface_validate", progress.overall_pct, 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 _update_stage_bad_blocks(job_id, "surface_validate", bad_blocks_total)
await _recalculate_progress(job_id) await _recalculate_progress(job_id)
_push_update() _push_update()

View file

@ -86,7 +86,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-43" app_version: str = "1.0.0-44"
# ---- 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

View file

@ -93,6 +93,12 @@ _MIGRATIONS = [
"ALTER TABLE drives ADD COLUMN pool_name TEXT", "ALTER TABLE drives ADD COLUMN pool_name TEXT",
"ALTER TABLE drives ADD COLUMN pool_role TEXT", "ALTER TABLE drives ADD COLUMN pool_role TEXT",
"ALTER TABLE drives ADD COLUMN pool_seen_at 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. # 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, # Closes the read-then-insert race in burnin.start_job — without this,
# two concurrent /api/v1/burnin/start requests for the same drive could # two concurrent /api/v1/burnin/start requests for the same drive could

View file

@ -57,7 +57,8 @@ async def drive_drawer(drive_id: int, db: aiosqlite.Connection = Depends(get_db)
job = dict(job_row) job = dict(job_row)
cur = await db.execute( cur = await db.execute(
"SELECT id, stage_name, state, percent, started_at, finished_at, " "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", "FROM burnin_stages WHERE burnin_job_id=? ORDER BY id",
(job_row["id"],), (job_row["id"],),
) )

View file

@ -2694,3 +2694,80 @@ tr.drawer-row-active {
font-variant-numeric: tabular-nums; 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)); }

View file

@ -1286,6 +1286,49 @@
_drawerRenderEvents(data.events); _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 = '<div class="bb-meters">';
for (var i = 0; i < 4; i++) {
var writePhase = i * 2 + 1;
var verifyPhase = writePhase + 1;
var writeFill, verifyFill;
if (p > verifyPhase) {
writeFill = 100; verifyFill = 100;
} else if (p === verifyPhase) {
writeFill = 100; verifyFill = pct;
} else if (p === writePhase) {
writeFill = pct; verifyFill = 0;
} else {
writeFill = 0; verifyFill = 0;
}
var classes = 'bb-meter';
if (p === writePhase || p === verifyPhase) classes += ' bb-meter-current';
if (p > verifyPhase) classes += ' bb-meter-done';
html += '<div class="' + classes + '">';
html += '<div class="bb-meter-label">' + labels[i] + '</div>';
html += '<div class="bb-meter-bar">';
html += '<div class="bb-meter-half bb-write" style="width:' + writeFill.toFixed(1) + '%"></div>';
html += '<div class="bb-meter-half-spacer"></div>';
html += '<div class="bb-meter-half bb-verify" style="width:' + verifyFill.toFixed(1) + '%"></div>';
html += '</div>';
html += '<div class="bb-meter-sub">';
html += '<span class="bb-sub-write">W ' + Math.round(writeFill) + '%</span>';
html += '<span class="bb-sub-verify">V ' + Math.round(verifyFill) + '%</span>';
html += '</div>';
html += '</div>';
}
html += '</div>';
return html;
}
function _drawerRenderBurnin(burnin) { function _drawerRenderBurnin(burnin) {
var panel = document.getElementById('drawer-panel-burnin'); var panel = document.getElementById('drawer-panel-burnin');
if (!panel) return; if (!panel) return;
@ -1323,6 +1366,10 @@
if (s.error_text) { if (s.error_text) {
html += '<div class="stage-error-line">' + _esc(s.error_text) + '</div>'; html += '<div class="stage-error-line">' + _esc(s.error_text) + '</div>';
} }
// Per-pattern meters for badblocks surface_validate
if (s.stage_name === 'surface_validate' && s.bb_phase) {
html += _drawerRenderBadblocksMeters(s.bb_phase, s.bb_phase_pct);
}
// Raw SSH log output (if available) // Raw SSH log output (if available)
if (s.log_text) { if (s.log_text) {
var logHtml = _esc(s.log_text) var logHtml = _esc(s.log_text)

View file

@ -0,0 +1,100 @@
"""Verifies _update_stage_bb_phase actually writes to burnin_stages
and the migration adds the columns idempotently.
The drive-drawer's 4-meter UI depends on these columns being populated
on every parser tick. If a future refactor drops the call or breaks
the migration, this test catches it before users see the meters
go blank.
Run inside the container image so app deps are present.
"""
from __future__ import annotations
import os
import tempfile
import unittest
import aiosqlite
async def _setup_db_with_stage() -> str:
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
from app.config import settings
settings.db_path = path
from app.database import init_db
await init_db()
async with aiosqlite.connect(path) as db:
await db.execute(
"INSERT INTO drives "
"(truenas_disk_id, devname, serial, model, size_bytes, "
" temperature_c, smart_health, last_seen_at, last_polled_at) "
"VALUES ('id-1', 'sda', 'SER1', 'TestModel', 14000000000000, "
" 30, 'PASSED', '2026-05-09T00:00:00+00:00', "
" '2026-05-09T00:00:00+00:00')"
)
await db.execute(
"INSERT INTO burnin_jobs "
"(drive_id, profile, state, operator, created_at) "
"VALUES (1, 'surface', 'running', 'op', "
" '2026-05-09T00:00:00+00:00')"
)
await db.execute(
"INSERT INTO burnin_stages "
"(burnin_job_id, stage_name, state) "
"VALUES (1, 'surface_validate', 'running')"
)
await db.commit()
return path
class TestBBPhasePersistence(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.path = await _setup_db_with_stage()
async def asyncTearDown(self):
try:
os.unlink(self.path)
except OSError:
pass
async def test_columns_exist_after_init(self):
async with aiosqlite.connect(self.path) as db:
cur = await db.execute("PRAGMA table_info(burnin_stages)")
cols = {r[1] for r in await cur.fetchall()}
self.assertIn("bb_phase", cols)
self.assertIn("bb_phase_pct", cols)
async def test_update_writes_phase_and_pct(self):
from app.burnin._common import _update_stage_bb_phase
await _update_stage_bb_phase(1, "surface_validate", 3, 47.5)
async with aiosqlite.connect(self.path) as db:
cur = await db.execute(
"SELECT bb_phase, bb_phase_pct FROM burnin_stages "
"WHERE burnin_job_id=1 AND stage_name='surface_validate'"
)
row = await cur.fetchone()
self.assertEqual(row[0], 3)
self.assertAlmostEqual(row[1], 47.5)
async def test_update_overwrites(self):
"""Each tick should replace the previous value, not accumulate."""
from app.burnin._common import _update_stage_bb_phase
await _update_stage_bb_phase(1, "surface_validate", 1, 10.0)
await _update_stage_bb_phase(1, "surface_validate", 2, 80.0)
async with aiosqlite.connect(self.path) as db:
cur = await db.execute(
"SELECT bb_phase, bb_phase_pct FROM burnin_stages "
"WHERE burnin_job_id=1 AND stage_name='surface_validate'"
)
row = await cur.fetchone()
self.assertEqual(row[0], 2)
self.assertAlmostEqual(row[1], 80.0)
if __name__ == "__main__":
unittest.main()