feat: client-side column sorting with SSE re-apply (1.0.0-48)
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 <tr>: - 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.
This commit is contained in:
parent
383258df97
commit
f5c6b85402
4 changed files with 167 additions and 41 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = '<div class="bb-vitals">';
|
||||
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 += '<div class="bb-vital">';
|
||||
html += '<span class="bb-vital-label">Start</span>';
|
||||
html += '<span class="bb-vital-value">' + startStr + '</span>';
|
||||
html += '</div>';
|
||||
|
||||
// Elapsed
|
||||
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 + 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 += '<div class="bb-vital">';
|
||||
html += '<span class="bb-vital-label">ETA</span>';
|
||||
html += '<span class="bb-vital-value">' + _bbFmtDuration(remainingSec) + '</span>';
|
||||
html += '</div>';
|
||||
|
||||
var finishStr = new Date(Date.now() + remainingSec * 1000)
|
||||
.toLocaleString(undefined, dateOpts);
|
||||
html += '<div class="bb-vital">';
|
||||
html += '<span class="bb-vital-label">Finish</span>';
|
||||
html += '<span class="bb-vital-value">' + finishStr + '</span>';
|
||||
html += '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// 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 += '</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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,14 +63,14 @@
|
|||
<th class="col-check">
|
||||
<input type="checkbox" id="select-all-cb" class="drive-cb" title="Select all idle drives">
|
||||
</th>
|
||||
<th class="col-drive">Drive</th>
|
||||
<th class="col-serial">Serial</th>
|
||||
<th class="col-size">Size</th>
|
||||
<th class="col-temp">Temp</th>
|
||||
<th class="col-health">Health</th>
|
||||
<th class="col-smart">Short SMART</th>
|
||||
<th class="col-smart">Long SMART</th>
|
||||
<th class="col-burnin">Burn-In</th>
|
||||
<th class="col-drive sortable" data-sort-key="drive">Drive</th>
|
||||
<th class="col-serial sortable" data-sort-key="serial">Serial</th>
|
||||
<th class="col-size sortable" data-sort-key="size">Size</th>
|
||||
<th class="col-temp sortable" data-sort-key="temp">Temp</th>
|
||||
<th class="col-health sortable" data-sort-key="health">Health</th>
|
||||
<th class="col-smart sortable" data-sort-key="short">Short SMART</th>
|
||||
<th class="col-smart sortable" data-sort-key="long">Long SMART</th>
|
||||
<th class="col-burnin sortable" data-sort-key="burnin">Burn-In</th>
|
||||
<th class="col-actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -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 %}
|
||||
<tr data-status="{{ drive.status }}" id="drive-{{ drive.id }}">
|
||||
{%- 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 '' %}
|
||||
<tr data-status="{{ drive.status }}" id="drive-{{ drive.id }}"
|
||||
data-sort-drive="{{ drive.devname }}"
|
||||
data-sort-serial="{{ (drive.serial or '') | lower }}"
|
||||
data-sort-size="{{ drive.size_bytes or 0 }}"
|
||||
data-sort-temp="{{ drive.temperature_c if drive.temperature_c is not none else '' }}"
|
||||
data-sort-health="{{ {'PASSED': 1, 'WARNING': 2, 'FAILED': 3, 'UNKNOWN': 4}.get(drive.smart_health, 9) }}"
|
||||
data-sort-short="{{ {'running': 1, 'failed': 2, 'aborted': 3, 'passed': 4, 'idle': 5}.get(short_state, 9) }}"
|
||||
data-sort-long="{{ {'running': 1, 'failed': 2, 'aborted': 3, 'passed': 4, 'idle': 5}.get(long_state, 9) }}"
|
||||
data-sort-burnin="{{ {'running': 1, 'queued': 2, 'failed': 3, 'unknown': 4, 'cancelled': 5, 'passed': 6}.get(burnin_state, 9) }}"
|
||||
>
|
||||
<td class="col-check">
|
||||
{%- if selectable %}
|
||||
<input type="checkbox" class="drive-checkbox" data-drive-id="{{ drive.id }}">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue