feat: vital-signs strip above per-pattern meters (1.0.0-46)
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

The drawer's surface_validate area now leads with a row of operator
vitals computed from data already in the response:

- Temp: drive temperature with cool/warm/hot colour (≥48 red, ≥42 yellow)
- Speed: live MB/s, NULL until second progress sample arrives
- Elapsed: time since stage started_at
- ETA: extrapolated from overall progress; suppressed under 0.5%
  to avoid the "47 days remaining" artefact early in pattern 1

Live MB/s comes from a new bb_mbps column on burnin_stages, computed
in the badblocks parser as (delta_overall_pct / 800) * size_bytes / dt.
Skipped on phase transitions (per-phase pct resets) and sub-second
samples (noisy).

Drawer endpoint now passes drive.temperature_c through; JS stashes
the latest drive object in _DRAWER_LAST_DRIVE so the burn-in renderer
can pull it for the vitals row without changing call signatures.

Tightened table CSS in this same session is unrelated and shipped
already in earlier rounds via the bind-mounted app.css.
This commit is contained in:
Brandon Walter 2026-05-08 23:13:58 -07:00
parent 1393ba0bc8
commit 6b2367b892
7 changed files with 217 additions and 36 deletions

View file

@ -205,6 +205,22 @@ async def _update_stage_bb_phase(
await db.commit()
async def _update_stage_bb_mbps(
job_id: int, stage_name: str, mbps: float,
) -> None:
"""Persist live throughput for the surface_validate meter strip.
Computed from delta_overall_pct between successive badblocks
progress lines, scaled by drive size_bytes / 800 (8 phases × 100)."""
async with _db() as db:
await db.execute("PRAGMA journal_mode=WAL")
await db.execute(
"UPDATE burnin_stages SET bb_mbps=? "
"WHERE burnin_job_id=? AND stage_name=?",
(mbps, 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

@ -121,6 +121,7 @@ from ._common import (
_store_smart_attrs,
_store_smart_raw_output,
_update_stage_bad_blocks,
_update_stage_bb_mbps,
_update_stage_bb_phase,
_update_stage_percent,
)
@ -466,6 +467,17 @@ async def _stage_surface_validate_ssh(job_id: int, devname: str, drive_id: int)
"""Run badblocks over SSH, streaming output to stage log."""
from app import ssh_client
# Pull drive size for the throughput calculation. Each badblocks
# phase covers the full disk once, so 1% overall progress = size/800
# bytes (8 phases × 100). NULL-safe: if size lookup fails we just
# skip the MB/s update.
drive_size_bytes: int | None = None
async with _db() as db:
cur = await db.execute("SELECT size_bytes FROM drives WHERE id=?", (drive_id,))
row = await cur.fetchone()
if row and row[0]:
drive_size_bytes = int(row[0])
await _append_stage_log(
job_id, "surface_validate",
f"[START] badblocks -wsv -b {settings.surface_validate_block_size} "
@ -495,6 +507,12 @@ async def _stage_surface_validate_ssh(job_id: int, devname: str, drive_id: int)
pid_seen = False
progress = _BadblocksProgress()
# Throughput tracker — store (overall_pct, monotonic_ts)
# of the previous progress sample so we can compute MB/s
# from the delta on each new sample.
last_pct_sample: float = progress.overall_pct
last_pct_ts: float = time.monotonic()
# Seed bb_phase=1, bb_phase_pct=0 immediately so the
# drawer's per-pattern meters have something to render
# before badblocks emits its first "X% done" line. On a
@ -546,6 +564,28 @@ async def _stage_surface_validate_ssh(job_id: int, devname: str, drive_id: int)
progress.phase, progress.phase_pct,
)
await _update_stage_bad_blocks(job_id, "surface_validate", bad_blocks_total)
# Throughput: only on real percent advances
# (skip header-only ticks where pct didn't move).
# Skip phase transitions too — the per-phase
# pct resets and would produce a negative delta.
if (
drive_size_bytes
and pct_changed
and not phase_changed
and progress.overall_pct > last_pct_sample
):
now_ts = time.monotonic()
dt = now_ts - last_pct_ts
if dt >= 1.0: # avoid noisy sub-second samples
d_pct = progress.overall_pct - last_pct_sample
# Each 1% overall = drive_size / 800 bytes
bytes_done = (d_pct / 800.0) * drive_size_bytes
mbps = bytes_done / dt / 1_000_000
await _update_stage_bb_mbps(
job_id, "surface_validate", mbps,
)
last_pct_sample = progress.overall_pct
last_pct_ts = now_ts
await _recalculate_progress(job_id)
_push_update()
else:

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-45"
app_version: str = "1.0.0-46"
# ---- Authentication (1.0.0-22) ----
# session_secret: HMAC key for signing session cookies. Empty = generate

View file

@ -99,6 +99,12 @@ _MIGRATIONS = [
# 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-46: live write/read throughput for the per-pattern meters.
# Computed from successive `XX% done` lines in badblocks output:
# delta_bytes = (overall_pct_delta / 800) * drive_size_bytes.
# 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-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_phase, bb_phase_pct, bb_mbps "
"FROM burnin_stages WHERE burnin_job_id=? ORDER BY id",
(job_row["id"],),
)
@ -102,11 +102,12 @@ async def drive_drawer(drive_id: int, db: aiosqlite.Connection = Depends(get_db)
return {
"drive": {
"id": drive.id,
"devname": drive.devname,
"serial": drive.serial,
"model": drive.model,
"size_bytes": drive.size_bytes,
"id": drive.id,
"devname": drive.devname,
"serial": drive.serial,
"model": drive.model,
"size_bytes": drive.size_bytes,
"temperature_c": drive.temperature_c,
},
"burnin": burnin_job,
"smart": {

View file

@ -244,7 +244,7 @@ thead {
}
th {
padding: 9px 14px;
padding: 6px 8px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
@ -256,9 +256,10 @@ th {
}
td {
padding: 10px 14px;
padding: 7px 8px;
border-bottom: 1px solid var(--border);
vertical-align: middle;
line-height: 1.3;
}
tr:last-child td {
@ -276,17 +277,15 @@ tr:hover td {
/* -----------------------------------------------------------------------
Column widths
----------------------------------------------------------------------- */
.col-drive { min-width: 180px; }
.col-serial { min-width: 110px; }
.col-size { min-width: 70px; text-align: right; }
.col-temp { min-width: 75px; text-align: right; }
.col-health { min-width: 85px; }
.col-smart { min-width: 95px; }
/* Tighter horizontal padding on the SMART columns they hold short
pills ("Passed"/"—") or a progress bar, so the default 14px gutter
wastes space on 13" laptops. */
th.col-smart, td.col-smart { padding-left: 6px; padding-right: 6px; }
.col-actions { min-width: 170px; }
.col-drive { min-width: 160px; }
.col-serial { min-width: 95px; }
.col-size { min-width: 60px; text-align: right; }
.col-temp { min-width: 60px; text-align: right; }
.col-health { min-width: 70px; }
.col-smart { min-width: 80px; }
/* Tighter SMART columns — they hold short pills or a progress bar. */
th.col-smart, td.col-smart { padding-left: 5px; padding-right: 5px; }
.col-actions { min-width: 150px; }
/* -----------------------------------------------------------------------
Drive cell
@ -295,14 +294,23 @@ th.col-smart, td.col-smart { padding-left: 6px; padding-right: 6px; }
display: block;
font-weight: 500;
color: var(--text-strong);
font-size: 14px;
font-size: 13px;
line-height: 1.25;
}
.drive-model {
display: block;
font-size: 11px;
display: inline;
font-size: 10px;
color: var(--text-muted);
margin-top: 1px;
margin-top: 0;
line-height: 1.25;
}
/* Separator between model and location when both are present on the
same line. ::after on .drive-model puts a thin dot between them. */
.drive-model + .drive-location::before {
content: " · ";
color: var(--border);
margin: 0 2px;
}
/* -----------------------------------------------------------------------
@ -425,7 +433,7 @@ th.col-smart, td.col-smart { padding-left: 6px; padding-right: 6px; }
/* -----------------------------------------------------------------------
Burn-in column
----------------------------------------------------------------------- */
.col-burnin { min-width: 160px; }
.col-burnin { min-width: 130px; }
.burnin-cell { min-width: 140px; }
@ -1180,9 +1188,9 @@ a.stat-card:hover {
Checkbox column
----------------------------------------------------------------------- */
.col-check {
width: 36px;
min-width: 36px;
padding: 10px 8px 10px 14px;
width: 32px;
min-width: 32px;
padding: 7px 4px 7px 8px;
}
.drive-checkbox, #select-all-cb {
@ -1196,18 +1204,15 @@ a.stat-card:hover {
Drive location inline edit
----------------------------------------------------------------------- */
.drive-location {
display: block;
display: inline;
font-size: 10px;
color: var(--text-muted);
margin-top: 2px;
margin-top: 0;
cursor: pointer;
border-radius: 3px;
padding: 1px 3px;
padding: 0 3px;
line-height: 1.1;
transition: background 0.1s;
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.drive-location:hover { background: var(--border); color: var(--text); }
@ -2771,3 +2776,44 @@ tr.drawer-row-active {
}
.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)); }
/* -----------------------------------------------------------------------
Surface-scan vital-signs row in the drawer (1.0.0-46).
Sits directly above the per-pattern meters. Temperature with
green/yellow/red colour, live MB/s, elapsed, ETA all derived
from data already in the drawer payload.
----------------------------------------------------------------------- */
.bb-vitals {
display: flex;
gap: 14px;
flex-wrap: wrap;
padding: 8px 12px 4px 12px;
background: var(--bg-soft, #161b22);
border-radius: 6px 6px 0 0;
margin: 6px 0 0 0;
border-bottom: 1px solid var(--border, #30363d);
}
/* When vitals lead, suppress the meter strip's top radius + margin so
they read as one stacked unit. */
.bb-vitals + .bb-meters {
border-radius: 0 0 6px 6px;
margin-top: 0;
}
.bb-vital {
display: flex;
flex-direction: column;
gap: 1px;
font-family: "SF Mono", "Consolas", monospace;
}
.bb-vital-label {
font-size: 9px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: .04em;
}
.bb-vital-value {
font-size: 13px;
color: var(--text-strong, #f0f6fc);
font-weight: 500;
font-variant-numeric: tabular-nums;
}

View file

@ -1271,8 +1271,14 @@
}
}
// Stash the last drive object so the burn-in panel renderer can
// pull temperature_c into the vital-signs row without having to
// pass it through the Burn-In renderer's signature.
var _DRAWER_LAST_DRIVE = null;
function _drawerRender(data) {
var drive = data.drive || {};
_DRAWER_LAST_DRIVE = drive;
var devnameEl = document.getElementById('drawer-devname');
var metaEl = document.getElementById('drawer-drive-meta');
if (devnameEl) devnameEl.textContent = drive.devname || '\u2014';
@ -1286,6 +1292,70 @@
_drawerRenderEvents(data.events);
}
// Vital-signs row above the meters: drive temp, live throughput,
// elapsed time, ETA. Computed from data already in the drawer payload.
function _drawerRenderBadblocksVitals(stage, drive) {
var phase = parseInt(stage.bb_phase, 10) || 1;
var phasePct = parseFloat(stage.bb_phase_pct || 0);
var overallPct = ((phase - 1) * 100 + phasePct) / 8; // 0..100
var html = '<div class="bb-vitals">';
// Temperature with hot/warm/cool colour
if (drive && typeof drive.temperature_c === 'number') {
var tc = drive.temperature_c;
var tClass = 'temp-cool';
if (tc >= 48) tClass = 'temp-hot';
else if (tc >= 42) tClass = 'temp-warm';
html += '<div class="bb-vital">';
html += '<span class="bb-vital-label">Temp</span>';
html += '<span class="bb-vital-value temp ' + tClass + '">' + tc + '°C</span>';
html += '</div>';
}
// Live throughput in MB/s, NULL until second progress sample arrives
if (typeof stage.bb_mbps === 'number' && stage.bb_mbps > 0) {
html += '<div class="bb-vital">';
html += '<span class="bb-vital-label">Speed</span>';
html += '<span class="bb-vital-value">' + stage.bb_mbps.toFixed(0) + ' MB/s</span>';
html += '</div>';
}
// Elapsed since stage started
if (stage.started_at) {
var startMs = Date.parse(stage.started_at);
var elapsedSec = Math.max(0, (Date.now() - startMs) / 1000);
html += '<div class="bb-vital">';
html += '<span class="bb-vital-label">Elapsed</span>';
html += '<span class="bb-vital-value">' + _bbFmtDuration(elapsedSec) + '</span>';
html += '</div>';
// ETA = (elapsed / overallPct) * 100 - elapsed; only if we have
// measurable progress (>= 0.5% so the first few samples don't
// produce a "47 days" early estimate that scares the operator).
if (overallPct >= 0.5) {
var totalSec = elapsedSec * (100 / overallPct);
var remainingSec = Math.max(0, totalSec - elapsedSec);
html += '<div class="bb-vital">';
html += '<span class="bb-vital-label">ETA</span>';
html += '<span class="bb-vital-value">' + _bbFmtDuration(remainingSec) + '</span>';
html += '</div>';
}
}
html += '</div>';
return html;
}
function _bbFmtDuration(sec) {
sec = Math.floor(sec);
var d = Math.floor(sec / 86400);
var h = Math.floor((sec % 86400) / 3600);
var m = Math.floor((sec % 3600) / 60);
if (d > 0) return d + 'd ' + h + 'h';
if (h > 0) return h + 'h ' + m + 'm';
return m + 'm';
}
// 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
@ -1366,8 +1436,10 @@
if (s.error_text) {
html += '<div class="stage-error-line">' + _esc(s.error_text) + '</div>';
}
// Per-pattern meters for badblocks surface_validate
// Per-pattern meters for badblocks surface_validate, plus the
// vital-signs row above (temp / speed / elapsed / ETA).
if (s.stage_name === 'surface_validate' && s.bb_phase) {
html += _drawerRenderBadblocksVitals(s, _DRAWER_LAST_DRIVE);
html += _drawerRenderBadblocksMeters(s.bb_phase, s.bb_phase_pct);
}
// Raw SSH log output (if available)