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()
|
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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"],),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)); }
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
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