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 = '