feat: phase caption + bad-block badge + per-pattern history (1.0.0-47)
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:
parent
6b2367b892
commit
383258df97
7 changed files with 156 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"],),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue