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)
|
ssh_key: str = "" # PEM private key content (paste full key including headers)
|
||||||
|
|
||||||
# Application version — used by the /api/v1/updates/check endpoint
|
# 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) ----
|
# ---- Authentication (1.0.0-22) ----
|
||||||
# session_secret: HMAC key for signing session cookies. Empty = generate
|
# 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 */
|
/* Bad-block counter colour states inside the vitals row */
|
||||||
.bb-vital-good { color: var(--green, #3fb950); }
|
.bb-vital-good { color: var(--green, #3fb950); }
|
||||||
.bb-vital-bad { color: var(--red, #f85149); }
|
.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();
|
initElapsedTimers();
|
||||||
initUnlockCountdowns();
|
initUnlockCountdowns();
|
||||||
initLocationEdits();
|
initLocationEdits();
|
||||||
|
applySort(); // SSE swap replaces #drives-tbody — re-apply persisted sort
|
||||||
|
paintSortIndicators();
|
||||||
if (_drawerDriveId) {
|
if (_drawerDriveId) {
|
||||||
_drawerHighlightRow(_drawerDriveId);
|
_drawerHighlightRow(_drawerDriveId);
|
||||||
drawerFetch(_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();
|
updateCounts();
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
@ -1299,8 +1373,47 @@
|
||||||
var phasePct = parseFloat(stage.bb_phase_pct || 0);
|
var phasePct = parseFloat(stage.bb_phase_pct || 0);
|
||||||
var overallPct = ((phase - 1) * 100 + phasePct) / 8; // 0..100
|
var overallPct = ((phase - 1) * 100 + phasePct) / 8; // 0..100
|
||||||
var html = '<div class="bb-vitals">';
|
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') {
|
if (drive && typeof drive.temperature_c === 'number') {
|
||||||
var tc = drive.temperature_c;
|
var tc = drive.temperature_c;
|
||||||
var tClass = 'temp-cool';
|
var tClass = 'temp-cool';
|
||||||
|
|
@ -1312,36 +1425,6 @@
|
||||||
html += '</div>';
|
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>';
|
html += '</div>';
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,14 +63,14 @@
|
||||||
<th class="col-check">
|
<th class="col-check">
|
||||||
<input type="checkbox" id="select-all-cb" class="drive-cb" title="Select all idle drives">
|
<input type="checkbox" id="select-all-cb" class="drive-cb" title="Select all idle drives">
|
||||||
</th>
|
</th>
|
||||||
<th class="col-drive">Drive</th>
|
<th class="col-drive sortable" data-sort-key="drive">Drive</th>
|
||||||
<th class="col-serial">Serial</th>
|
<th class="col-serial sortable" data-sort-key="serial">Serial</th>
|
||||||
<th class="col-size">Size</th>
|
<th class="col-size sortable" data-sort-key="size">Size</th>
|
||||||
<th class="col-temp">Temp</th>
|
<th class="col-temp sortable" data-sort-key="temp">Temp</th>
|
||||||
<th class="col-health">Health</th>
|
<th class="col-health sortable" data-sort-key="health">Health</th>
|
||||||
<th class="col-smart">Short SMART</th>
|
<th class="col-smart sortable" data-sort-key="short">Short SMART</th>
|
||||||
<th class="col-smart">Long SMART</th>
|
<th class="col-smart sortable" data-sort-key="long">Long SMART</th>
|
||||||
<th class="col-burnin">Burn-In</th>
|
<th class="col-burnin sortable" data-sort-key="burnin">Burn-In</th>
|
||||||
<th class="col-actions">Actions</th>
|
<th class="col-actions">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
@ -89,7 +89,19 @@
|
||||||
{%- set smart_done = (drive.smart_short and drive.smart_short.state in ('passed','failed','aborted'))
|
{%- 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')) %}
|
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 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">
|
<td class="col-check">
|
||||||
{%- if selectable %}
|
{%- if selectable %}
|
||||||
<input type="checkbox" class="drive-checkbox" data-drive-id="{{ drive.id }}">
|
<input type="checkbox" class="drive-checkbox" data-drive-id="{{ drive.id }}">
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue