diff --git a/app/burnin/_common.py b/app/burnin/_common.py index 154e8e7..805a948 100644 --- a/app/burnin/_common.py +++ b/app/burnin/_common.py @@ -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 diff --git a/app/burnin/stages.py b/app/burnin/stages.py index a342762..5989b0d 100644 --- a/app/burnin/stages.py +++ b/app/burnin/stages.py @@ -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: diff --git a/app/config.py b/app/config.py index fd95468..1c31724 100644 --- a/app/config.py +++ b/app/config.py @@ -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 diff --git a/app/database.py b/app/database.py index edcad2f..d6dc63f 100644 --- a/app/database.py +++ b/app/database.py @@ -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 diff --git a/app/routes/drives.py b/app/routes/drives.py index 8d6e154..2ac8f9c 100644 --- a/app/routes/drives.py +++ b/app/routes/drives.py @@ -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": { diff --git a/app/static/app.css b/app/static/app.css index a9d0258..57e58e2 100644 --- a/app/static/app.css +++ b/app/static/app.css @@ -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; +} diff --git a/app/static/app.js b/app/static/app.js index c3fb84d..869bc9f 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -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 = '