From f5c6b854027c164371a12a49d62b7c00cd9cd5bc Mon Sep 17 00:00:00 2001 From: Brandon Walter <51866976+echoparkbaby@users.noreply.github.com> Date: Fri, 8 May 2026 23:48:04 -0700 Subject: [PATCH] feat: client-side column sorting with SSE re-apply (1.0.0-48) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clickable headers on Drive / Serial / Size / Temp / Health / Short SMART / Long SMART / Burn-In. Click cycles asc → desc → cleared, with a small ▲/▼ indicator next to the active column. Sort state lives in localStorage so it survives reload AND every SSE-driven tbody refresh (HTMX swaps `#drives-table-wrap` innerHTML on each `drives-update` event). The htmx:afterSwap hook re-applies the sort and re-paints indicators. Sortable values are emitted as data-sort-* attributes on each : - raw devname / serial / size_bytes / temperature_c - numeric priority maps for SMART health, SMART test states, and burn-in state (so "running" sorts ahead of "passed" regardless of alphabetical order) Empty values always sink to the bottom regardless of direction so "sort by temp asc" doesn't put a missing-temp drive on top. --- app/config.py | 2 +- app/static/app.css | 31 +++++ app/static/app.js | 145 ++++++++++++++++----- app/templates/components/drives_table.html | 30 +++-- 4 files changed, 167 insertions(+), 41 deletions(-) diff --git a/app/config.py b/app/config.py index 43e493a..318507e 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-47" + app_version: str = "1.0.0-48" # ---- Authentication (1.0.0-22) ---- # session_secret: HMAC key for signing session cookies. Empty = generate diff --git a/app/static/app.css b/app/static/app.css index 2b788b0..1b325eb 100644 --- a/app/static/app.css +++ b/app/static/app.css @@ -2865,3 +2865,34 @@ tr.drawer-row-active { /* Bad-block counter colour states inside the vitals row */ .bb-vital-good { color: var(--green, #3fb950); } .bb-vital-bad { color: var(--red, #f85149); } + +/* ----------------------------------------------------------------------- + Column sort (1.0.0-48). Click a sortable TH to cycle asc → desc → + cleared. Indicator arrow appears next to the column label. +----------------------------------------------------------------------- */ +th.sortable { + cursor: pointer; + user-select: none; + position: relative; +} +th.sortable:hover { color: var(--text); } +th.sortable::after { + content: ""; + display: inline-block; + width: 0; + height: 0; + margin-left: 4px; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + vertical-align: middle; + opacity: 0; +} +th.sortable:hover::after { opacity: 0.4; border-bottom: 5px solid currentColor; } +th.sort-asc::after { + opacity: 1; + border-bottom: 5px solid var(--blue, #58a6ff); +} +th.sort-desc::after { + opacity: 1; + border-top: 5px solid var(--blue, #58a6ff); +} diff --git a/app/static/app.js b/app/static/app.js index d6bd602..7207da1 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -79,12 +79,86 @@ initElapsedTimers(); initUnlockCountdowns(); initLocationEdits(); + applySort(); // SSE swap replaces #drives-tbody — re-apply persisted sort + paintSortIndicators(); if (_drawerDriveId) { _drawerHighlightRow(_drawerDriveId); drawerFetch(_drawerDriveId); } }); + // --------------------------------------------------------------- + // Column sorting (client-side, persisted in localStorage so it + // survives reload AND survives every SSE-driven tbody refresh). + // --------------------------------------------------------------- + var SORT_KEY = 'nasburnin.sort'; + function getSort() { + try { + var raw = localStorage.getItem(SORT_KEY); + if (!raw) return null; + var p = JSON.parse(raw); + if (p && p.col && (p.dir === 'asc' || p.dir === 'desc')) return p; + } catch (e) {} + return null; + } + function setSort(col, dir) { + if (!col) localStorage.removeItem(SORT_KEY); + else localStorage.setItem(SORT_KEY, JSON.stringify({col: col, dir: dir})); + } + function applySort() { + var s = getSort(); + var tbody = document.getElementById('drives-tbody'); + if (!tbody || !s) return; + var rows = Array.from(tbody.querySelectorAll('tr[id^="drive-"]')); + if (!rows.length) return; + var attr = 'data-sort-' + s.col; + var dirMul = s.dir === 'asc' ? 1 : -1; + rows.sort(function (a, b) { + var av = a.getAttribute(attr); + var bv = b.getAttribute(attr); + // Empty values always sink to the bottom regardless of direction. + var aEmpty = av === null || av === ''; + var bEmpty = bv === null || bv === ''; + if (aEmpty && !bEmpty) return 1; + if (!aEmpty && bEmpty) return -1; + if (aEmpty && bEmpty) return 0; + // Numeric comparison if both parse cleanly, else string. + var an = parseFloat(av), bn = parseFloat(bv); + if (!isNaN(an) && !isNaN(bn) && String(an) === av && String(bn) === bv) { + return (an - bn) * dirMul; + } + return av.localeCompare(bv) * dirMul; + }); + rows.forEach(function (r) { tbody.appendChild(r); }); + } + function paintSortIndicators() { + var s = getSort(); + document.querySelectorAll('th.sortable').forEach(function (th) { + th.classList.remove('sort-asc', 'sort-desc'); + if (s && th.dataset.sortKey === s.col) { + th.classList.add(s.dir === 'asc' ? 'sort-asc' : 'sort-desc'); + } + }); + } + document.addEventListener('click', function (e) { + var th = e.target.closest('th.sortable'); + if (!th) return; + var col = th.dataset.sortKey; + var s = getSort(); + var dir = 'asc'; + if (s && s.col === col) { + // Click cycle: asc → desc → cleared + if (s.dir === 'asc') dir = 'desc'; + else { setSort(null); applySort(); paintSortIndicators(); return; } + } + setSort(col, dir); + applySort(); + paintSortIndicators(); + }); + // Initial paint on page load (HTML is already rendered server-side). + applySort(); + paintSortIndicators(); + updateCounts(); // ----------------------------------------------------------------------- @@ -1299,8 +1373,47 @@ var phasePct = parseFloat(stage.bb_phase_pct || 0); var overallPct = ((phase - 1) * 100 + phasePct) / 8; // 0..100 var html = '
'; + var dateOpts = { + weekday: 'short', month: 'short', day: 'numeric', + hour: 'numeric', minute: '2-digit', + }; - // Temperature with hot/warm/cool colour + // Start (wall-clock, with date) + if (stage.started_at) { + var startMs = Date.parse(stage.started_at); + var startStr = new Date(startMs).toLocaleString(undefined, dateOpts); + html += '
'; + html += 'Start'; + html += '' + startStr + ''; + html += '
'; + + // Elapsed + var elapsedSec = Math.max(0, (Date.now() - startMs) / 1000); + html += '
'; + html += 'Elapsed'; + html += '' + _bbFmtDuration(elapsedSec) + ''; + html += '
'; + + // ETA + Finish — only once we have measurable progress, so the + // first samples don't paint a "47 days" estimate. + if (overallPct >= 0.5) { + var totalSec = elapsedSec * (100 / overallPct); + var remainingSec = Math.max(0, totalSec - elapsedSec); + html += '
'; + html += 'ETA'; + html += '' + _bbFmtDuration(remainingSec) + ''; + html += '
'; + + var finishStr = new Date(Date.now() + remainingSec * 1000) + .toLocaleString(undefined, dateOpts); + html += '
'; + html += 'Finish'; + html += '' + finishStr + ''; + html += '
'; + } + } + + // Temp with hot/warm/cool colour if (drive && typeof drive.temperature_c === 'number') { var tc = drive.temperature_c; var tClass = 'temp-cool'; @@ -1312,36 +1425,6 @@ html += '
'; } - // Live throughput in MB/s, NULL until second progress sample arrives - if (typeof stage.bb_mbps === 'number' && stage.bb_mbps > 0) { - html += '
'; - html += 'Speed'; - html += '' + stage.bb_mbps.toFixed(0) + ' MB/s'; - html += '
'; - } - - // 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 += '
'; - html += 'Elapsed'; - html += '' + _bbFmtDuration(elapsedSec) + ''; - html += '
'; - - // 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 += '
'; - html += 'ETA'; - html += '' + _bbFmtDuration(remainingSec) + ''; - html += '
'; - } - } - html += ''; return html; } diff --git a/app/templates/components/drives_table.html b/app/templates/components/drives_table.html index 64dc651..907a827 100644 --- a/app/templates/components/drives_table.html +++ b/app/templates/components/drives_table.html @@ -63,14 +63,14 @@ - Drive - Serial - Size - Temp - Health - Short SMART - Long SMART - Burn-In + Drive + Serial + Size + Temp + Health + Short SMART + Long SMART + Burn-In Actions @@ -89,7 +89,19 @@ {%- set smart_done = (drive.smart_short and drive.smart_short.state in ('passed','failed','aborted')) or (drive.smart_long and drive.smart_long.state in ('passed','failed','aborted')) %} {%- set can_reset = (bi_done or smart_done) and not bi_active and not short_busy and not long_busy and not pool_locked %} - + {%- set short_state = drive.smart_short.state if drive.smart_short else 'idle' %} + {%- set long_state = drive.smart_long.state if drive.smart_long else 'idle' %} + {%- set burnin_state = drive.burnin.state if drive.burnin else '' %} + {%- if selectable %}