feat: vital-signs strip above per-pattern meters (1.0.0-46)
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:
parent
1393ba0bc8
commit
6b2367b892
7 changed files with 217 additions and 36 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue