feat: per-pattern badblocks meters in drive drawer (1.0.0-44)
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:
parent
4922b19a9f
commit
30062affc2
8 changed files with 257 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"],),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)); }
|
||||
|
|
|
|||
|
|
@ -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 = '<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) {
|
||||
var panel = document.getElementById('drawer-panel-burnin');
|
||||
if (!panel) return;
|
||||
|
|
@ -1323,6 +1366,10 @@
|
|||
if (s.error_text) {
|
||||
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)
|
||||
if (s.log_text) {
|
||||
var logHtml = _esc(s.log_text)
|
||||
|
|
|
|||
100
tests/test_bb_phase_persistence.py
Normal file
100
tests/test_bb_phase_persistence.py
Normal 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()
|
||||
Loading…
Add table
Reference in a new issue