feat: phase caption + bad-block badge + per-pattern history (1.0.0-47)
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

Three additions to the surface_validate drawer block:

1. **Phase caption** below the meters: "Pattern 2 of 4 · Verify 0x55
   · 47% within phase". Pure JS — no schema change. Makes the
   visual grammar explicit without needing the operator to mentally
   map phase=4 to "verifying pattern 2".

2. **Bad-block badge** in the vitals row. Green at 0, red at >0.
   The number was already on the stage row but burying it in the
   log felt wrong — surfacing it next to temp/speed/ETA keeps it
   in eye-line during long runs.

3. **Per-pattern duration history** below the caption. New
   bb_phase_history JSON column (idempotent migration) maps
   {phase_num: ts}. Parser stamps the timestamp on every phase
   transition (and on stage entry for phase 1). Drawer diffs
   consecutive write-phase starts to derive "0xaa: 14h 22m"
   for completed patterns. Once one pattern is done you can
   predict the rest without leaving the drawer.

Persistence is idempotent: re-entry of the same phase keeps the
original timestamp so a transient parser reset doesn't blow away
history. JSON parse failures fail gracefully (no row rendered).
This commit is contained in:
Brandon Walter 2026-05-08 23:23:02 -07:00
parent 6b2367b892
commit 383258df97
7 changed files with 156 additions and 2 deletions

View file

@ -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

View file

@ -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,
)

View file

@ -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

View file

@ -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

View file

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

View file

@ -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); }

View file

@ -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 = '<div class="bb-caption">';
html += 'Pattern ' + pattern + ' of 4 · ';
html += subPhase + ' ' + label + ' · ';
html += pct.toFixed(1) + '% within phase';
html += '</div>';
return html;
}
// Per-pattern duration history. Reads bb_phase_history (JSON) and
// emits "0xaa: 14h 22m" rows for completed patterns. Pattern N is
// "complete" when its verify-phase end timestamp is known (= the
// next pattern's write-phase start, or stage.finished_at for the
// final one).
function _drawerRenderBadblocksHistory(stage) {
if (!stage.bb_phase_history) return '';
var hist;
try { hist = JSON.parse(stage.bb_phase_history); }
catch (e) { return ''; }
if (!hist || typeof hist !== 'object') return '';
var labels = ['0xaa', '0x55', '0xff', '0x00'];
var rows = [];
for (var n = 1; n <= 4; n++) {
var writeStart = hist[String(2 * n - 1)];
if (!writeStart) continue;
var endTs = (n < 4) ? hist[String(2 * n + 1)] : stage.finished_at;
if (!endTs) continue;
var elapsedSec = (Date.parse(endTs) - Date.parse(writeStart)) / 1000;
if (elapsedSec <= 0) continue;
rows.push('<span class="bb-hist-row">' +
'<span class="bb-hist-label">' + labels[n - 1] + '</span>' +
'<span class="bb-hist-dur">' + _bbFmtDuration(elapsedSec) + '</span>' +
'</span>');
}
if (!rows.length) return '';
return '<div class="bb-history"><span class="bb-hist-title">Completed patterns</span>' +
rows.join('') + '</div>';
}
// 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
@ -1441,6 +1489,8 @@
if (s.stage_name === 'surface_validate' && s.bb_phase) {
html += _drawerRenderBadblocksVitals(s, _DRAWER_LAST_DRIVE);
html += _drawerRenderBadblocksMeters(s.bb_phase, s.bb_phase_pct);
html += _drawerRenderBadblocksCaption(s.bb_phase, s.bb_phase_pct);
html += _drawerRenderBadblocksHistory(s);
}
// Raw SSH log output (if available)
if (s.log_text) {