When a stage ends in failed/cancelled/unknown the drawer now shows a coloured "Reason" pill at the top of that stage's section. Three sources, in order of preference: 1. stage.error_text (the canonical, when set) 2. job.error_text (backfilled in the drawer endpoint when stage's own is empty — catches orphan rows from hard crashes like the pre-busy-timeout DB-locked failures) 3. Heuristic: if log_text is tiny (<500 bytes, just the START banner) AND no real badblocks progress was recorded, label as "Stopped without recording an error — likely cause: SSH connection drop or container restart while this stage was running." This catches the fingerprint of a deploy-during-burn-in killing the SSH session. Otherwise: "No error message recorded." so there's never a blank where the operator expects to see why something broke. Red styling for failed, yellow for cancelled/unknown. Replaces the inline stage-error-line for terminal states; the existing stage-error-line still renders for non-terminal contexts.
1782 lines
68 KiB
JavaScript
1782 lines
68 KiB
JavaScript
(function () {
|
|
'use strict';
|
|
|
|
// Default operator name — prefer the logged-in user (rendered into a
|
|
// <meta> by layout.html), fall back to the localStorage memory of the
|
|
// last-typed value, and empty string as last resort.
|
|
function defaultOperator() {
|
|
var meta = document.querySelector('meta[name="default-operator"]');
|
|
if (meta && meta.content) return meta.content;
|
|
return localStorage.getItem('burnin_operator') || '';
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Filter bar + stats bar
|
|
// -----------------------------------------------------------------------
|
|
|
|
var activeFilter = 'all';
|
|
|
|
function getRows() {
|
|
return Array.from(document.querySelectorAll('#drives-tbody tr[data-status]'));
|
|
}
|
|
|
|
function updateCounts() {
|
|
var rows = getRows();
|
|
var counts = { all: rows.length, running: 0, failed: 0, passed: 0, idle: 0 };
|
|
rows.forEach(function (r) {
|
|
var s = r.dataset.status;
|
|
if (s && Object.prototype.hasOwnProperty.call(counts, s)) counts[s]++;
|
|
});
|
|
|
|
// Update filter bar badges
|
|
document.querySelectorAll('.filter-btn[data-filter]').forEach(function (btn) {
|
|
var badge = btn.querySelector('.badge');
|
|
if (badge) badge.textContent = counts[btn.dataset.filter] != null ? counts[btn.dataset.filter] : 0;
|
|
});
|
|
|
|
// Update stats bar
|
|
['all', 'running', 'failed', 'passed', 'idle'].forEach(function (s) {
|
|
var el = document.getElementById('stat-' + s);
|
|
if (el) el.textContent = counts[s] != null ? counts[s] : 0;
|
|
});
|
|
|
|
// Show/hide failed banner
|
|
var banner = document.getElementById('failed-banner');
|
|
if (banner) {
|
|
var failedCount = counts.failed || 0;
|
|
banner.hidden = failedCount === 0;
|
|
var fc = banner.querySelector('.failed-count');
|
|
if (fc) fc.textContent = failedCount;
|
|
}
|
|
|
|
// Show/hide "Cancel All Burn-Ins" button based on whether any .btn-cancel exist
|
|
var cancelAllBtn = document.getElementById('cancel-all-btn');
|
|
if (cancelAllBtn) {
|
|
var hasCancelable = document.querySelectorAll('.btn-cancel[data-job-id]').length > 0;
|
|
cancelAllBtn.hidden = !hasCancelable;
|
|
}
|
|
}
|
|
|
|
function applyFilter(filter) {
|
|
activeFilter = filter;
|
|
getRows().forEach(function (row) {
|
|
row.style.display = (filter === 'all' || row.dataset.status === filter) ? '' : 'none';
|
|
});
|
|
document.querySelectorAll('.filter-btn[data-filter]').forEach(function (btn) {
|
|
btn.classList.toggle('active', btn.dataset.filter === filter);
|
|
});
|
|
updateCounts();
|
|
}
|
|
|
|
document.addEventListener('click', function (e) {
|
|
var btn = e.target.closest('.filter-btn[data-filter]');
|
|
if (btn) applyFilter(btn.dataset.filter);
|
|
});
|
|
|
|
document.addEventListener('htmx:afterSwap', function () {
|
|
applyFilter(activeFilter);
|
|
restoreCheckboxes();
|
|
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();
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Toast notifications
|
|
// -----------------------------------------------------------------------
|
|
|
|
function showToast(msg, type) {
|
|
type = type || 'info';
|
|
var container = document.getElementById('toast-container');
|
|
if (!container) return;
|
|
var el = document.createElement('div');
|
|
el.className = 'toast toast-' + type;
|
|
el.textContent = msg;
|
|
container.appendChild(el);
|
|
setTimeout(function () { el.remove(); }, 5000);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Browser push notifications
|
|
// -----------------------------------------------------------------------
|
|
|
|
function updateNotifBtn() {
|
|
var btn = document.getElementById('notif-btn');
|
|
if (!btn) return;
|
|
var perm = Notification.permission;
|
|
btn.classList.remove('notif-active', 'notif-denied');
|
|
if (perm === 'granted') {
|
|
btn.classList.add('notif-active');
|
|
btn.title = 'Notifications enabled';
|
|
} else if (perm === 'denied') {
|
|
btn.classList.add('notif-denied');
|
|
btn.title = 'Notifications blocked — allow in browser settings';
|
|
} else {
|
|
btn.title = 'Enable browser notifications';
|
|
}
|
|
}
|
|
|
|
if ('Notification' in window) {
|
|
updateNotifBtn();
|
|
document.addEventListener('click', function (e) {
|
|
if (!e.target.closest('#notif-btn')) return;
|
|
if (Notification.permission === 'denied') {
|
|
showToast('Notifications blocked — allow in browser settings', 'error');
|
|
return;
|
|
}
|
|
Notification.requestPermission().then(function (perm) {
|
|
updateNotifBtn();
|
|
if (perm === 'granted') {
|
|
showToast('Browser notifications enabled', 'success');
|
|
new Notification('NAS Burn-In', {
|
|
body: 'You will be notified when burn-in jobs complete.',
|
|
});
|
|
}
|
|
});
|
|
});
|
|
} else {
|
|
var nb = document.getElementById('notif-btn');
|
|
if (nb) nb.style.display = 'none';
|
|
}
|
|
|
|
// Handle SSE events
|
|
document.addEventListener('htmx:sseMessage', function (e) {
|
|
if (!e.detail) return;
|
|
if (e.detail.type === 'job-alert') {
|
|
try { handleJobAlert(JSON.parse(e.detail.data)); } catch (_) {}
|
|
} else if (e.detail.type === 'system-sensors') {
|
|
try { handleSystemSensors(JSON.parse(e.detail.data)); } catch (_) {}
|
|
}
|
|
});
|
|
|
|
function handleSystemSensors(data) {
|
|
var st = data.system_temps || {};
|
|
var tp = data.thermal_pressure || 'ok';
|
|
var warn = data.temp_warn_c || 46;
|
|
var crit = data.temp_crit_c || 55;
|
|
|
|
function tempClass(c) {
|
|
if (c == null) return '';
|
|
return c >= crit ? 'temp-hot' : c >= warn ? 'temp-warm' : 'temp-cool';
|
|
}
|
|
|
|
// CPU chip
|
|
var cpuChip = document.getElementById('sensor-cpu');
|
|
var cpuVal = document.getElementById('sensor-cpu-val');
|
|
if (cpuVal && st.cpu_c != null) {
|
|
if (cpuChip) cpuChip.hidden = false;
|
|
cpuVal.textContent = st.cpu_c + '°';
|
|
cpuVal.className = 'stat-sensor-val ' + tempClass(st.cpu_c);
|
|
}
|
|
|
|
// PCH chip
|
|
var pchChip = document.getElementById('sensor-pch');
|
|
var pchVal = document.getElementById('sensor-pch-val');
|
|
if (pchVal && st.pch_c != null) {
|
|
if (pchChip) pchChip.hidden = false;
|
|
pchVal.textContent = st.pch_c + '°';
|
|
pchVal.className = 'stat-sensor-val ' + tempClass(st.pch_c);
|
|
}
|
|
|
|
// Thermal pressure chip
|
|
var tChip = document.getElementById('sensor-thermal');
|
|
var tVal = document.getElementById('sensor-thermal-val');
|
|
if (tChip && tVal) {
|
|
if (tp === 'warn' || tp === 'crit') {
|
|
tChip.hidden = false;
|
|
tChip.className = 'stat-sensor stat-sensor-thermal stat-sensor-thermal-' + tp;
|
|
tVal.textContent = tp === 'warn' ? 'WARM' : 'HOT';
|
|
} else {
|
|
tChip.hidden = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleJobAlert(data) {
|
|
var isPass = data.state === 'passed';
|
|
var icon = isPass ? '✓' : '✕';
|
|
var title = icon + ' ' + (data.devname || 'Drive') + ' — Burn-In ' + (data.state || '').toUpperCase();
|
|
var bodyText = (data.model || '') + (data.serial ? ' · ' + data.serial : '');
|
|
if (!isPass && data.error_text) bodyText += '\n' + data.error_text;
|
|
|
|
showToast(title + (data.error_text ? ' · ' + data.error_text : ''), isPass ? 'success' : 'error');
|
|
|
|
if (Notification.permission === 'granted') {
|
|
try {
|
|
new Notification(title, { body: bodyText || undefined });
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Elapsed time timers
|
|
// -----------------------------------------------------------------------
|
|
|
|
var _elapsedInterval = null;
|
|
|
|
function formatElapsed(seconds) {
|
|
if (seconds < 0) return '';
|
|
var h = Math.floor(seconds / 3600);
|
|
var m = Math.floor((seconds % 3600) / 60);
|
|
var s = seconds % 60;
|
|
if (h > 0) return h + 'h ' + m + 'm';
|
|
if (m > 0) return m + 'm ' + s + 's';
|
|
return s + 's';
|
|
}
|
|
|
|
function tickElapsedTimers() {
|
|
var now = Date.now();
|
|
document.querySelectorAll('.elapsed-timer[data-started]').forEach(function (el) {
|
|
var started = new Date(el.dataset.started).getTime();
|
|
if (isNaN(started)) return;
|
|
var elapsed = Math.floor((now - started) / 1000);
|
|
el.textContent = formatElapsed(elapsed);
|
|
});
|
|
}
|
|
|
|
function initElapsedTimers() {
|
|
if (_elapsedInterval) return; // Already running
|
|
var timers = document.querySelectorAll('.elapsed-timer[data-started]');
|
|
if (timers.length === 0) return;
|
|
_elapsedInterval = setInterval(function () {
|
|
var remaining = document.querySelectorAll('.elapsed-timer[data-started]');
|
|
if (remaining.length === 0) {
|
|
clearInterval(_elapsedInterval);
|
|
_elapsedInterval = null;
|
|
return;
|
|
}
|
|
tickElapsedTimers();
|
|
}, 1000);
|
|
tickElapsedTimers();
|
|
}
|
|
|
|
initElapsedTimers();
|
|
|
|
// Live countdown for pool-drive unlock TTL — runs once per second; ticker
|
|
// self-stops when no .unlock-countdown spans remain on the page.
|
|
var _unlockTickInterval = null;
|
|
function tickUnlockCountdowns() {
|
|
var spans = document.querySelectorAll('.unlock-countdown[data-expires]');
|
|
if (spans.length === 0) {
|
|
if (_unlockTickInterval) {
|
|
clearInterval(_unlockTickInterval);
|
|
_unlockTickInterval = null;
|
|
}
|
|
return;
|
|
}
|
|
var nowSec = Date.now() / 1000;
|
|
spans.forEach(function (el) {
|
|
var exp = parseFloat(el.dataset.expires);
|
|
if (!exp || isNaN(exp)) return;
|
|
var rem = Math.max(0, exp - nowSec);
|
|
if (rem <= 0) {
|
|
el.textContent = 'expired';
|
|
el.className = 'unlock-countdown unlock-countdown-expired';
|
|
return;
|
|
}
|
|
var m = Math.floor(rem / 60);
|
|
var s = Math.floor(rem % 60);
|
|
el.textContent = '\u{1F513} ' + m + ':' + (s < 10 ? '0' : '') + s;
|
|
});
|
|
}
|
|
function initUnlockCountdowns() {
|
|
if (_unlockTickInterval) return;
|
|
if (document.querySelectorAll('.unlock-countdown[data-expires]').length === 0) return;
|
|
_unlockTickInterval = setInterval(tickUnlockCountdowns, 1000);
|
|
tickUnlockCountdowns();
|
|
}
|
|
initUnlockCountdowns();
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Inline location / notes edit
|
|
// -----------------------------------------------------------------------
|
|
|
|
function initLocationEdits() {
|
|
document.querySelectorAll('.drive-location').forEach(function (el) {
|
|
if (el._locationInited) return;
|
|
el._locationInited = true;
|
|
|
|
el.addEventListener('click', function (evt) {
|
|
evt.stopPropagation();
|
|
var driveId = el.dataset.driveId;
|
|
var current = el.classList.contains('drive-location-empty') ? '' : el.textContent.trim();
|
|
|
|
var input = document.createElement('input');
|
|
input.type = 'text';
|
|
input.className = 'drive-location-input';
|
|
input.value = current;
|
|
input.placeholder = 'e.g. Bay 3 Shelf 2';
|
|
input.maxLength = 64;
|
|
|
|
el.replaceWith(input);
|
|
input.focus();
|
|
input.select();
|
|
|
|
async function save() {
|
|
var newVal = input.value.trim();
|
|
try {
|
|
var resp = await fetch('/api/v1/drives/' + driveId, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ location: newVal || null }),
|
|
});
|
|
if (!resp.ok) throw new Error('save failed');
|
|
} catch (_) {
|
|
showToast('Failed to save location', 'error');
|
|
}
|
|
// The SSE update will replace the whole row; nothing more needed
|
|
}
|
|
|
|
function cancel() {
|
|
var span = document.createElement('span');
|
|
span.className = 'drive-location' + (current ? '' : ' drive-location-empty');
|
|
span.dataset.driveId = driveId;
|
|
span.dataset.field = 'location';
|
|
span.title = current ? 'Click to edit location' : 'Click to set location';
|
|
span.textContent = current || '+ location';
|
|
input.replaceWith(span);
|
|
initLocationEdits(); // re-attach listener
|
|
}
|
|
|
|
input.addEventListener('blur', function () { save(); });
|
|
input.addEventListener('keydown', function (e) {
|
|
if (e.key === 'Enter') { input.blur(); }
|
|
if (e.key === 'Escape') { cancel(); }
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
initLocationEdits();
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Stage drag-and-drop reordering
|
|
// -----------------------------------------------------------------------
|
|
|
|
function initStageDrag(listId) {
|
|
var list = document.getElementById(listId);
|
|
if (!list || list._dragInited) return;
|
|
list._dragInited = true;
|
|
|
|
var draggingEl = null;
|
|
|
|
list.addEventListener('dragstart', function (e) {
|
|
draggingEl = e.target.closest('.stage-check');
|
|
if (!draggingEl) return;
|
|
draggingEl.classList.add('dragging');
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
});
|
|
|
|
list.addEventListener('dragend', function () {
|
|
if (draggingEl) draggingEl.classList.remove('dragging');
|
|
list.querySelectorAll('.stage-check.drag-over').forEach(function (el) {
|
|
el.classList.remove('drag-over');
|
|
});
|
|
draggingEl = null;
|
|
});
|
|
|
|
list.addEventListener('dragover', function (e) {
|
|
e.preventDefault();
|
|
if (!draggingEl) return;
|
|
var target = e.target.closest('.stage-check');
|
|
if (!target || target === draggingEl) return;
|
|
var rect = target.getBoundingClientRect();
|
|
var midY = rect.top + rect.height / 2;
|
|
if (e.clientY < midY) {
|
|
list.insertBefore(draggingEl, target);
|
|
} else {
|
|
list.insertBefore(draggingEl, target.nextSibling);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Map checkbox id → backend stage name
|
|
var _STAGE_ID_MAP = {
|
|
'stage-surface': 'surface_validate',
|
|
'stage-short': 'short_smart',
|
|
'stage-long': 'long_smart',
|
|
'batch-stage-surface': 'surface_validate',
|
|
'batch-stage-short': 'short_smart',
|
|
'batch-stage-long': 'long_smart',
|
|
};
|
|
|
|
// Read DOM order of checked stages from the given list element
|
|
function getStageOrder(listId) {
|
|
var items = Array.from(document.querySelectorAll('#' + listId + ' .stage-check'));
|
|
var order = [];
|
|
items.forEach(function (item) {
|
|
var cb = item.querySelector('input[type=checkbox]');
|
|
if (cb && cb.checked && _STAGE_ID_MAP[cb.id]) {
|
|
order.push(_STAGE_ID_MAP[cb.id]);
|
|
}
|
|
});
|
|
return order;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Standalone SMART test
|
|
// -----------------------------------------------------------------------
|
|
|
|
async function startSmartTest(btn) {
|
|
var driveId = btn.dataset.driveId;
|
|
var testType = btn.dataset.testType;
|
|
var operator = defaultOperator() || 'unknown';
|
|
|
|
btn.disabled = true;
|
|
try {
|
|
var resp = await fetch('/api/v1/drives/' + driveId + '/smart/start', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ type: testType, operator: operator }),
|
|
});
|
|
var data = await resp.json();
|
|
if (!resp.ok) {
|
|
showToast(data.detail || 'Failed to start test', 'error');
|
|
btn.disabled = false;
|
|
} else {
|
|
var label = testType === 'SHORT' ? 'Short' : 'Long';
|
|
showToast(label + ' SMART test started on ' + data.devname, 'success');
|
|
}
|
|
} catch (err) {
|
|
showToast('Network error', 'error');
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Cancel standalone SMART test
|
|
// -----------------------------------------------------------------------
|
|
|
|
async function cancelSmartTest(btn) {
|
|
var driveId = btn.dataset.driveId;
|
|
var testType = btn.dataset.testType; // 'short' or 'long'
|
|
var label = testType === 'short' ? 'Short' : 'Long';
|
|
|
|
if (!confirm('Cancel the ' + label + ' SMART test? This cannot be undone.')) return;
|
|
|
|
btn.disabled = true;
|
|
try {
|
|
var resp = await fetch('/api/v1/drives/' + driveId + '/smart/cancel', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ type: testType }),
|
|
});
|
|
var data = await resp.json();
|
|
if (!resp.ok) {
|
|
showToast(data.detail || 'Failed to cancel test', 'error');
|
|
btn.disabled = false;
|
|
} else {
|
|
var label = testType === 'short' ? 'Short' : 'Long';
|
|
showToast(label + ' SMART test cancelled on ' + (data.devname || ''), 'info');
|
|
}
|
|
} catch (err) {
|
|
showToast('Network error', 'error');
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Cancel ALL running/queued burn-in jobs
|
|
// -----------------------------------------------------------------------
|
|
|
|
async function cancelAllBurnins() {
|
|
var cancelBtns = Array.from(document.querySelectorAll('.btn-cancel[data-job-id]'));
|
|
if (cancelBtns.length === 0) {
|
|
showToast('No active burn-in jobs to cancel', 'info');
|
|
return;
|
|
}
|
|
if (!confirm('Cancel ALL ' + cancelBtns.length + ' active burn-in job(s)? This cannot be undone.')) return;
|
|
var operator = defaultOperator() || 'unknown';
|
|
var count = 0;
|
|
for (var i = 0; i < cancelBtns.length; i++) {
|
|
var jobId = cancelBtns[i].dataset.jobId;
|
|
try {
|
|
var resp = await fetch('/api/v1/burnin/' + jobId + '/cancel', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ operator: operator }),
|
|
});
|
|
if (resp.ok) count++;
|
|
} catch (_) {}
|
|
}
|
|
showToast(count + ' burn-in job(s) cancelled', 'info');
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Single drive Burn-In modal
|
|
// -----------------------------------------------------------------------
|
|
|
|
var modalDriveId = null;
|
|
var modalSerial = null;
|
|
|
|
function _stageLabel() {
|
|
// Read labels in DOM order from stage-order-list
|
|
var items = Array.from(document.querySelectorAll('#stage-order-list .stage-check'));
|
|
var labelMap = {
|
|
'stage-surface': 'Surface',
|
|
'stage-short': 'Short SMART',
|
|
'stage-long': 'Long SMART',
|
|
};
|
|
var parts = [];
|
|
items.forEach(function (item) {
|
|
var cb = item.querySelector('input[type=checkbox]');
|
|
if (cb && cb.checked && labelMap[cb.id]) parts.push(labelMap[cb.id]);
|
|
});
|
|
return parts.length ? parts.join(' + ') : 'No stages';
|
|
}
|
|
|
|
function handleStageChange() {
|
|
var surfaceChecked = document.getElementById('stage-surface') && document.getElementById('stage-surface').checked;
|
|
var warning = document.getElementById('surface-warning');
|
|
var serialField = document.getElementById('serial-field');
|
|
if (warning) warning.style.display = surfaceChecked ? '' : 'none';
|
|
if (serialField) serialField.style.display = surfaceChecked ? '' : 'none';
|
|
// Update title
|
|
var title = document.getElementById('modal-title');
|
|
if (title) title.textContent = 'Burn-In — ' + _stageLabel();
|
|
validateModal();
|
|
}
|
|
|
|
function openModal(btn) {
|
|
modalDriveId = btn.dataset.driveId;
|
|
modalSerial = btn.dataset.serial || '';
|
|
|
|
document.getElementById('modal-devname').textContent = btn.dataset.devname || '—';
|
|
document.getElementById('modal-model').textContent = btn.dataset.model || '—';
|
|
document.getElementById('modal-serial-display').textContent = btn.dataset.serial || '—';
|
|
document.getElementById('modal-size').textContent = btn.dataset.size || '—';
|
|
|
|
var healthEl = document.getElementById('modal-health');
|
|
var health = btn.dataset.health || 'UNKNOWN';
|
|
healthEl.textContent = health;
|
|
healthEl.className = 'chip chip-' + health.toLowerCase();
|
|
|
|
// Reset stage checkboxes to all-on (keep user's drag order)
|
|
['stage-surface', 'stage-short', 'stage-long'].forEach(function (id) {
|
|
var el = document.getElementById(id);
|
|
if (el) el.checked = true;
|
|
});
|
|
|
|
document.getElementById('confirm-serial').value = '';
|
|
document.getElementById('confirm-hint').textContent = 'Expected: ' + modalSerial;
|
|
|
|
var savedOp = defaultOperator();
|
|
document.getElementById('operator-input').value = savedOp;
|
|
|
|
// Init drag on first open (list is in static DOM)
|
|
initStageDrag('stage-order-list');
|
|
|
|
handleStageChange(); // sets warning visibility + title + validates
|
|
|
|
document.getElementById('start-modal').removeAttribute('hidden');
|
|
setTimeout(function () {
|
|
document.getElementById('operator-input').focus();
|
|
}, 50);
|
|
}
|
|
|
|
function closeModal() {
|
|
document.getElementById('start-modal').setAttribute('hidden', '');
|
|
modalDriveId = null;
|
|
modalSerial = null;
|
|
}
|
|
|
|
function validateModal() {
|
|
var operator = (document.getElementById('operator-input').value || '').trim();
|
|
var surfaceChecked = document.getElementById('stage-surface') && document.getElementById('stage-surface').checked;
|
|
var shortChecked = document.getElementById('stage-short') && document.getElementById('stage-short').checked;
|
|
var longChecked = document.getElementById('stage-long') && document.getElementById('stage-long').checked;
|
|
var anyStage = surfaceChecked || shortChecked || longChecked;
|
|
|
|
var valid;
|
|
if (surfaceChecked) {
|
|
var typed = (document.getElementById('confirm-serial').value || '').trim();
|
|
valid = operator.length > 0 && typed === modalSerial && modalSerial !== '' && anyStage;
|
|
} else {
|
|
valid = operator.length > 0 && anyStage;
|
|
}
|
|
document.getElementById('modal-start-btn').disabled = !valid;
|
|
}
|
|
|
|
async function submitStart() {
|
|
var operator = (document.getElementById('operator-input').value || '').trim();
|
|
localStorage.setItem('burnin_operator', operator);
|
|
|
|
var runSurface = document.getElementById('stage-surface').checked;
|
|
var runShort = document.getElementById('stage-short').checked;
|
|
var runLong = document.getElementById('stage-long').checked;
|
|
var stageOrder = getStageOrder('stage-order-list');
|
|
|
|
try {
|
|
var resp = await fetch('/api/v1/burnin/start', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
drive_ids: [parseInt(modalDriveId, 10)],
|
|
operator: operator,
|
|
run_surface: runSurface,
|
|
run_short: runShort,
|
|
run_long: runLong,
|
|
stage_order: stageOrder,
|
|
}),
|
|
});
|
|
|
|
var data = await resp.json();
|
|
if (!resp.ok) {
|
|
// detail may be the structured pool-locked object {drive_id,
|
|
// pool_name, pool_role, pool_locked: true, error: "..."}.
|
|
// The user already opened the start modal, so the unlock TTL must
|
|
// have just expired between modal-open and submit. Auto-flip to
|
|
// the unlock modal for that drive.
|
|
if (_handlePoolLockedError(data.detail)) {
|
|
closeModal();
|
|
return;
|
|
}
|
|
showToast(_extractErrorMessage(data.detail) || 'Failed to start burn-in', 'error');
|
|
return;
|
|
}
|
|
|
|
closeModal();
|
|
showToast('Burn-in queued: ' + _stageLabel(), 'success');
|
|
} catch (err) {
|
|
showToast('Network error', 'error');
|
|
}
|
|
}
|
|
|
|
// Helpers shared between single-drive and batch start error paths.
|
|
// Backend returns either a string (legacy errors) or, for pool-locked
|
|
// drives, an object: {drive_id, error, pool_name, pool_role, pool_locked}.
|
|
function _extractErrorMessage(detail) {
|
|
if (!detail) return null;
|
|
if (typeof detail === 'string') return detail;
|
|
if (typeof detail === 'object' && detail.error) return detail.error;
|
|
return null;
|
|
}
|
|
// Returns true if it handled a pool-locked error by opening the unlock
|
|
// modal for the offending drive. Caller should bail out.
|
|
function _handlePoolLockedError(detail) {
|
|
if (!detail || typeof detail !== 'object' || !detail.pool_locked) return false;
|
|
var driveId = detail.drive_id;
|
|
if (driveId == null) return false;
|
|
var btn = document.querySelector('.btn-unlock[data-drive-id="' + driveId + '"]');
|
|
if (btn) {
|
|
// openUnlockModal closes any other open modals as a side effect of
|
|
// calling its own close handlers; we still need to close the
|
|
// start/batch modal explicitly in the caller, since openUnlockModal
|
|
// doesn't know which one is open.
|
|
openUnlockModal(btn);
|
|
return true;
|
|
}
|
|
// Unlock button not in the DOM (drive row may have refreshed).
|
|
// Surface a descriptive toast instead of [object Object].
|
|
showToast(
|
|
(detail.error || 'Drive is pool-locked') +
|
|
' Reload the page and click Unlock on the drive row.',
|
|
'error',
|
|
);
|
|
return true;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Change-password modal
|
|
// -----------------------------------------------------------------------
|
|
|
|
function openPasswordModal() {
|
|
var m = document.getElementById('password-modal');
|
|
if (!m) return;
|
|
document.getElementById('pw-current').value = '';
|
|
document.getElementById('pw-new').value = '';
|
|
document.getElementById('pw-confirm').value = '';
|
|
document.getElementById('pw-hint').textContent = '';
|
|
document.getElementById('password-modal-submit-btn').disabled = true;
|
|
m.removeAttribute('hidden');
|
|
setTimeout(function () { document.getElementById('pw-current').focus(); }, 50);
|
|
}
|
|
function closePasswordModal() {
|
|
var m = document.getElementById('password-modal');
|
|
if (m) m.setAttribute('hidden', '');
|
|
}
|
|
function validatePasswordModal() {
|
|
var cur = document.getElementById('pw-current').value;
|
|
var nw = document.getElementById('pw-new').value;
|
|
var cf = document.getElementById('pw-confirm').value;
|
|
var hint = document.getElementById('pw-hint');
|
|
var ok = cur.length > 0 && nw.length >= 8 && nw === cf;
|
|
if (nw.length > 0 && nw.length < 8) hint.textContent = 'Min 8 characters.';
|
|
else if (nw.length >= 8 && cf.length > 0 && nw !== cf) hint.textContent = "Passwords don't match.";
|
|
else hint.textContent = '';
|
|
document.getElementById('password-modal-submit-btn').disabled = !ok;
|
|
}
|
|
async function submitPasswordChange() {
|
|
var btn = document.getElementById('password-modal-submit-btn');
|
|
btn.disabled = true;
|
|
var fd = new FormData();
|
|
fd.append('current_password', document.getElementById('pw-current').value);
|
|
fd.append('new_password', document.getElementById('pw-new').value);
|
|
fd.append('confirm_password', document.getElementById('pw-confirm').value);
|
|
try {
|
|
var resp = await fetch('/api/v1/auth/change-password', {
|
|
method: 'POST',
|
|
body: fd,
|
|
});
|
|
var data = await resp.json().catch(function () { return {}; });
|
|
if (!resp.ok) {
|
|
showToast(data.detail || 'Password change failed', 'error');
|
|
btn.disabled = false;
|
|
return;
|
|
}
|
|
closePasswordModal();
|
|
showToast('Password updated.', 'success');
|
|
} catch (err) {
|
|
showToast('Network error', 'error');
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Pool-drive Unlock modal
|
|
// -----------------------------------------------------------------------
|
|
|
|
var unlockDriveId = null;
|
|
var unlockExpectedToken = null;
|
|
|
|
function openUnlockModal(btn) {
|
|
unlockDriveId = btn.dataset.driveId;
|
|
var poolName = btn.dataset.poolName || '';
|
|
var poolRole = btn.dataset.poolRole || 'data';
|
|
var isBoot = btn.dataset.isBootPool === '1';
|
|
var isExported = btn.dataset.isExported === '1';
|
|
var isMounted = btn.dataset.isMounted === '1';
|
|
if (isBoot) unlockExpectedToken = 'DESTROY BOOT POOL';
|
|
else if (isExported) unlockExpectedToken = 'DESTROY EXPORTED POOL';
|
|
else if (isMounted) unlockExpectedToken = 'DESTROY MOUNTED FILESYSTEM';
|
|
else unlockExpectedToken = poolName;
|
|
|
|
document.getElementById('unlock-devname').textContent = btn.dataset.devname || '—';
|
|
document.getElementById('unlock-model').textContent = btn.dataset.model || '—';
|
|
document.getElementById('unlock-serial').textContent = btn.dataset.serial || '—';
|
|
document.getElementById('unlock-size').textContent = btn.dataset.size || '—';
|
|
|
|
var chip = document.getElementById('unlock-pool-chip');
|
|
if (isExported) {
|
|
chip.textContent = 'exported ZFS';
|
|
chip.className = 'chip chip-aborted';
|
|
} else if (isMounted) {
|
|
chip.textContent = 'mounted FS';
|
|
chip.className = 'chip chip-aborted';
|
|
} else {
|
|
chip.textContent = poolName + ' · ' + poolRole;
|
|
chip.className = 'chip ' + (isBoot ? 'chip-failed' : 'chip-aborted');
|
|
}
|
|
|
|
var titleEl = document.getElementById('unlock-modal-title');
|
|
var warnTitle = document.getElementById('unlock-warning-title');
|
|
var warnBody = document.getElementById('unlock-warning-body');
|
|
if (isBoot) {
|
|
titleEl.textContent = 'Unlock BOOT POOL drive';
|
|
warnTitle.textContent = 'This is a TrueNAS BOOT drive.';
|
|
warnBody.textContent =
|
|
'Running burn-in on this drive will destroy the operating system on it. ' +
|
|
'If this drive is half of a mirrored boot pool, the system will continue running on the other mirror, ' +
|
|
'but you must already have a replacement plan. Proceeding without one bricks the host.';
|
|
} else if (isExported) {
|
|
titleEl.textContent = 'Unlock drive with EXPORTED ZFS data';
|
|
warnTitle.textContent = 'This drive carries ZFS data from a previously-imported pool.';
|
|
warnBody.textContent =
|
|
"TrueNAS isn't using this pool right now, but the drive still holds the labels and data. " +
|
|
'Burning it in will silently destroy whatever pool that data belongs to — including ' +
|
|
'pools that another system may be relying on. Confirm you have already evacuated or ' +
|
|
'reassigned the pool before continuing.';
|
|
} else if (isMounted) {
|
|
titleEl.textContent = 'Unlock drive with MOUNTED filesystem';
|
|
warnTitle.textContent = 'This drive has a non-ZFS filesystem currently mounted.';
|
|
warnBody.textContent =
|
|
'findmnt reports a partition on this drive is mounted right now. Burning it in will ' +
|
|
'destroy whatever data is on that filesystem and almost certainly leave the mount ' +
|
|
'point in a broken state. Unmount it first, or confirm you really mean to wipe it.';
|
|
} else {
|
|
titleEl.textContent = 'Unlock pool drive';
|
|
warnTitle.textContent = "This drive belongs to zpool '" + poolName + "'.";
|
|
warnBody.textContent =
|
|
'Running a destructive burn-in stage will overwrite all data on this drive ' +
|
|
'and almost certainly destroy the pool. Only proceed if you have already ' +
|
|
'removed this drive from the pool, or if you are intentionally decommissioning the pool.';
|
|
}
|
|
document.getElementById('unlock-confirm-token').textContent = unlockExpectedToken;
|
|
document.getElementById('unlock-confirm-hint').textContent = 'Expected: ' + unlockExpectedToken;
|
|
|
|
document.getElementById('unlock-confirm-input').value = '';
|
|
document.getElementById('unlock-reason-input').value = '';
|
|
var savedOp = defaultOperator();
|
|
document.getElementById('unlock-operator-input').value = savedOp;
|
|
validateUnlockModal();
|
|
|
|
document.getElementById('unlock-modal').removeAttribute('hidden');
|
|
setTimeout(function () {
|
|
document.getElementById('unlock-operator-input').focus();
|
|
}, 50);
|
|
}
|
|
|
|
function closeUnlockModal() {
|
|
document.getElementById('unlock-modal').setAttribute('hidden', '');
|
|
unlockDriveId = null;
|
|
unlockExpectedToken = null;
|
|
}
|
|
|
|
function validateUnlockModal() {
|
|
var op = (document.getElementById('unlock-operator-input').value || '').trim();
|
|
var rsn = (document.getElementById('unlock-reason-input').value || '').trim();
|
|
var tok = (document.getElementById('unlock-confirm-input').value || '').trim();
|
|
var ok = op.length > 0 && rsn.length >= 5 && tok === unlockExpectedToken;
|
|
document.getElementById('unlock-modal-submit-btn').disabled = !ok;
|
|
}
|
|
|
|
async function submitUnlock() {
|
|
var op = (document.getElementById('unlock-operator-input').value || '').trim();
|
|
var rsn = (document.getElementById('unlock-reason-input').value || '').trim();
|
|
var tok = (document.getElementById('unlock-confirm-input').value || '').trim();
|
|
localStorage.setItem('burnin_operator', op);
|
|
|
|
var btn = document.getElementById('unlock-modal-submit-btn');
|
|
btn.disabled = true;
|
|
|
|
try {
|
|
var resp = await fetch('/api/v1/drives/' + unlockDriveId + '/unlock', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
confirm_token: tok,
|
|
operator: op,
|
|
reason: rsn,
|
|
}),
|
|
});
|
|
var data = await resp.json();
|
|
if (!resp.ok) {
|
|
showToast(data.detail || 'Unlock failed', 'error');
|
|
btn.disabled = false;
|
|
return;
|
|
}
|
|
closeUnlockModal();
|
|
showToast('Unlocked for 10 minutes — start burn-in now to use it.', 'success');
|
|
// Force a drive list refresh so the row flips from Unlock → Burn-In
|
|
if (typeof refreshDrives === 'function') refreshDrives();
|
|
} catch (err) {
|
|
showToast('Network error', 'error');
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Batch Burn-In
|
|
// -----------------------------------------------------------------------
|
|
|
|
var checkedDriveIds = new Set();
|
|
|
|
function updateBatchBar() {
|
|
var bar = document.getElementById('batch-bar');
|
|
if (!bar) return;
|
|
var count = checkedDriveIds.size;
|
|
bar.hidden = count === 0;
|
|
var countEl = document.getElementById('batch-count');
|
|
if (countEl) countEl.textContent = count;
|
|
}
|
|
|
|
function restoreCheckboxes() {
|
|
// Re-check boxes that were checked before the SSE swap
|
|
document.querySelectorAll('.drive-checkbox').forEach(function (cb) {
|
|
cb.checked = checkedDriveIds.has(cb.dataset.driveId);
|
|
});
|
|
|
|
// Update select-all state
|
|
var selectAll = document.getElementById('select-all-cb');
|
|
if (selectAll) {
|
|
var allBoxes = document.querySelectorAll('.drive-checkbox');
|
|
selectAll.checked = allBoxes.length > 0 && Array.from(allBoxes).every(function (c) { return c.checked; });
|
|
selectAll.indeterminate = checkedDriveIds.size > 0 && !selectAll.checked;
|
|
}
|
|
|
|
updateBatchBar();
|
|
}
|
|
|
|
// Toggle individual checkbox
|
|
document.addEventListener('change', function (e) {
|
|
if (e.target.classList.contains('drive-checkbox')) {
|
|
var id = e.target.dataset.driveId;
|
|
if (e.target.checked) {
|
|
checkedDriveIds.add(id);
|
|
} else {
|
|
checkedDriveIds.delete(id);
|
|
}
|
|
updateBatchBar();
|
|
// Update select-all indeterminate state
|
|
var selectAll = document.getElementById('select-all-cb');
|
|
if (selectAll) {
|
|
var allBoxes = Array.from(document.querySelectorAll('.drive-checkbox'));
|
|
selectAll.checked = allBoxes.length > 0 && allBoxes.every(function (c) { return c.checked; });
|
|
selectAll.indeterminate = checkedDriveIds.size > 0 && !selectAll.checked;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Select-all checkbox
|
|
if (e.target.id === 'select-all-cb') {
|
|
var boxes = document.querySelectorAll('.drive-checkbox');
|
|
boxes.forEach(function (cb) {
|
|
cb.checked = e.target.checked;
|
|
if (e.target.checked) {
|
|
checkedDriveIds.add(cb.dataset.driveId);
|
|
} else {
|
|
checkedDriveIds.delete(cb.dataset.driveId);
|
|
}
|
|
});
|
|
updateBatchBar();
|
|
return;
|
|
}
|
|
|
|
// Batch modal inputs validation
|
|
if (['batch-confirm-cb', 'batch-stage-surface', 'batch-stage-short', 'batch-stage-long'].indexOf(e.target.id) !== -1) {
|
|
validateBatchModal();
|
|
}
|
|
});
|
|
|
|
// Batch bar buttons
|
|
document.addEventListener('click', function (e) {
|
|
if (e.target.id === 'batch-start-btn' || e.target.closest('#batch-start-btn')) {
|
|
openBatchModal();
|
|
return;
|
|
}
|
|
if (e.target.id === 'batch-clear-btn') {
|
|
checkedDriveIds.clear();
|
|
document.querySelectorAll('.drive-checkbox').forEach(function (cb) { cb.checked = false; });
|
|
var sa = document.getElementById('select-all-cb');
|
|
if (sa) { sa.checked = false; sa.indeterminate = false; }
|
|
updateBatchBar();
|
|
return;
|
|
}
|
|
});
|
|
|
|
function openBatchModal() {
|
|
var modal = document.getElementById('batch-modal');
|
|
if (!modal) return;
|
|
var savedOp = defaultOperator();
|
|
document.getElementById('batch-operator-input').value = savedOp;
|
|
document.getElementById('batch-confirm-cb').checked = false;
|
|
// Reset stages to all-on (keep user's drag order)
|
|
['batch-stage-surface', 'batch-stage-short', 'batch-stage-long'].forEach(function (id) {
|
|
var el = document.getElementById(id);
|
|
if (el) el.checked = true;
|
|
});
|
|
var countEls = document.querySelectorAll('#batch-modal-count, #batch-modal-count-btn');
|
|
countEls.forEach(function (el) { el.textContent = checkedDriveIds.size; });
|
|
// Init drag on first open
|
|
initStageDrag('batch-stage-order-list');
|
|
validateBatchModal();
|
|
modal.removeAttribute('hidden');
|
|
setTimeout(function () {
|
|
document.getElementById('batch-operator-input').focus();
|
|
}, 50);
|
|
}
|
|
|
|
function closeBatchModal() {
|
|
var modal = document.getElementById('batch-modal');
|
|
if (modal) modal.setAttribute('hidden', '');
|
|
}
|
|
|
|
function validateBatchModal() {
|
|
var operator = (document.getElementById('batch-operator-input').value || '').trim();
|
|
var surfaceEl = document.getElementById('batch-stage-surface');
|
|
var surfaceChecked = surfaceEl && surfaceEl.checked;
|
|
|
|
// Show/hide destructive warning and confirm checkbox based on surface selection
|
|
var warning = document.getElementById('batch-surface-warning');
|
|
var confirmWrap = document.getElementById('batch-confirm-wrap');
|
|
if (warning) warning.style.display = surfaceChecked ? '' : 'none';
|
|
if (confirmWrap) confirmWrap.style.display = surfaceChecked ? '' : 'none';
|
|
|
|
var shortEl = document.getElementById('batch-stage-short');
|
|
var longEl = document.getElementById('batch-stage-long');
|
|
var anyStage = surfaceChecked ||
|
|
(shortEl && shortEl.checked) ||
|
|
(longEl && longEl.checked);
|
|
|
|
var valid;
|
|
if (surfaceChecked) {
|
|
var confirmed = document.getElementById('batch-confirm-cb').checked;
|
|
valid = operator.length > 0 && confirmed && anyStage;
|
|
} else {
|
|
valid = operator.length > 0 && anyStage;
|
|
}
|
|
|
|
var btn = document.getElementById('batch-modal-start-btn');
|
|
if (btn) btn.disabled = !valid;
|
|
}
|
|
|
|
document.addEventListener('input', function (e) {
|
|
if (e.target.id === 'operator-input' || e.target.id === 'confirm-serial') validateModal();
|
|
if (e.target.id === 'batch-operator-input') validateBatchModal();
|
|
});
|
|
|
|
async function submitBatchStart() {
|
|
var operator = (document.getElementById('batch-operator-input').value || '').trim();
|
|
localStorage.setItem('burnin_operator', operator);
|
|
|
|
var ids = Array.from(checkedDriveIds).map(function (id) { return parseInt(id, 10); });
|
|
if (ids.length === 0) return;
|
|
|
|
var btn = document.getElementById('batch-modal-start-btn');
|
|
if (btn) btn.disabled = true;
|
|
|
|
var runSurface = document.getElementById('batch-stage-surface').checked;
|
|
var runShort = document.getElementById('batch-stage-short').checked;
|
|
var runLong = document.getElementById('batch-stage-long').checked;
|
|
var stageOrder = getStageOrder('batch-stage-order-list');
|
|
|
|
try {
|
|
var resp = await fetch('/api/v1/burnin/start', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
drive_ids: ids,
|
|
operator: operator,
|
|
run_surface: runSurface,
|
|
run_short: runShort,
|
|
run_long: runLong,
|
|
stage_order: stageOrder,
|
|
}),
|
|
});
|
|
var data = await resp.json();
|
|
if (!resp.ok) {
|
|
if (_handlePoolLockedError(data.detail)) {
|
|
closeBatchModal();
|
|
return;
|
|
}
|
|
showToast(_extractErrorMessage(data.detail) || 'Failed to queue batch', 'error');
|
|
if (btn) btn.disabled = false;
|
|
return;
|
|
}
|
|
|
|
closeBatchModal();
|
|
checkedDriveIds.clear();
|
|
updateBatchBar();
|
|
var queued = (data.queued || []).length;
|
|
var allErrors = data.errors || [];
|
|
var poolLocked = allErrors.filter(function (e) { return e && e.pool_locked; });
|
|
var alreadyActive = allErrors.length - poolLocked.length;
|
|
|
|
var parts = [queued + ' burn-in(s) queued'];
|
|
if (alreadyActive) parts.push(alreadyActive + ' skipped (already active)');
|
|
if (poolLocked.length) {
|
|
parts.push(poolLocked.length + ' pool-locked (use Unlock on each row)');
|
|
}
|
|
var tone = (queued === 0 && allErrors.length) ? 'error' : 'success';
|
|
showToast(parts.join(', '), tone);
|
|
} catch (err) {
|
|
showToast('Network error', 'error');
|
|
if (btn) btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Cancel burn-in (individual)
|
|
// -----------------------------------------------------------------------
|
|
|
|
async function cancelBurnin(btn) {
|
|
var jobId = btn.dataset.jobId;
|
|
var operator = defaultOperator() || 'unknown';
|
|
|
|
if (!confirm('Cancel this burn-in job? This cannot be undone.')) return;
|
|
|
|
btn.disabled = true;
|
|
try {
|
|
var resp = await fetch('/api/v1/burnin/' + jobId + '/cancel', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ operator: operator }),
|
|
});
|
|
|
|
if (resp.ok) {
|
|
showToast('Burn-in cancelled', 'info');
|
|
} else {
|
|
var data = await resp.json();
|
|
showToast(data.detail || 'Failed to cancel', 'error');
|
|
btn.disabled = false;
|
|
}
|
|
} catch (err) {
|
|
showToast('Network error', 'error');
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Delegated event handlers (work after SSE swaps)
|
|
// -----------------------------------------------------------------------
|
|
|
|
document.addEventListener('click', function (e) {
|
|
// Short / Long SMART start buttons
|
|
var smartBtn = e.target.closest('.btn-smart-short, .btn-smart-long');
|
|
if (smartBtn && !smartBtn.disabled) { startSmartTest(smartBtn); return; }
|
|
|
|
// Cancel SMART test buttons
|
|
var cancelSmartBtn = e.target.closest('.btn-cancel-smart');
|
|
if (cancelSmartBtn && !cancelSmartBtn.disabled) { cancelSmartTest(cancelSmartBtn); return; }
|
|
|
|
// Change password header link
|
|
if (e.target.id === 'open-password-modal' || e.target.closest('#open-password-modal')) {
|
|
e.preventDefault();
|
|
openPasswordModal();
|
|
return;
|
|
}
|
|
if (e.target.closest('#password-modal-close-btn') ||
|
|
e.target.closest('#password-modal-cancel-btn')) {
|
|
closePasswordModal();
|
|
return;
|
|
}
|
|
if (e.target.id === 'password-modal') { closePasswordModal(); return; }
|
|
if (e.target.id === 'password-modal-submit-btn') { submitPasswordChange(); return; }
|
|
|
|
// Pool-drive unlock button (single drive)
|
|
var unlockBtn = e.target.closest('.btn-unlock');
|
|
if (unlockBtn && !unlockBtn.disabled) { openUnlockModal(unlockBtn); return; }
|
|
|
|
// Burn-in start button (single drive)
|
|
var startBtn = e.target.closest('.btn-start');
|
|
if (startBtn && !startBtn.disabled) { openModal(startBtn); return; }
|
|
|
|
// Cancel burn-in button (individual)
|
|
var cancelBtn = e.target.closest('.btn-cancel');
|
|
if (cancelBtn) { cancelBurnin(cancelBtn); return; }
|
|
|
|
// Cancel ALL running burn-ins
|
|
if (e.target.id === 'cancel-all-btn' || e.target.closest('#cancel-all-btn')) {
|
|
cancelAllBurnins();
|
|
return;
|
|
}
|
|
|
|
// Single-drive modal close
|
|
if (e.target.closest('#modal-close-btn') || e.target.closest('#modal-cancel-btn')) {
|
|
closeModal();
|
|
return;
|
|
}
|
|
if (e.target.id === 'start-modal') {
|
|
closeModal();
|
|
return;
|
|
}
|
|
if (e.target.id === 'modal-start-btn') {
|
|
submitStart();
|
|
return;
|
|
}
|
|
|
|
// Unlock modal
|
|
if (e.target.closest('#unlock-modal-close-btn') || e.target.closest('#unlock-modal-cancel-btn')) {
|
|
closeUnlockModal();
|
|
return;
|
|
}
|
|
if (e.target.id === 'unlock-modal') { closeUnlockModal(); return; }
|
|
if (e.target.id === 'unlock-modal-submit-btn') { submitUnlock(); return; }
|
|
|
|
// Batch modal close
|
|
if (e.target.closest('#batch-modal-close-btn') || e.target.closest('#batch-modal-cancel-btn')) {
|
|
closeBatchModal();
|
|
return;
|
|
}
|
|
if (e.target.id === 'batch-modal') {
|
|
closeBatchModal();
|
|
return;
|
|
}
|
|
if (e.target.id === 'batch-modal-start-btn') {
|
|
submitBatchStart();
|
|
return;
|
|
}
|
|
});
|
|
|
|
document.addEventListener('input', function (e) {
|
|
var id = e.target.id;
|
|
if (id === 'pw-current' || id === 'pw-new' || id === 'pw-confirm') validatePasswordModal();
|
|
if (id === 'unlock-operator-input' || id === 'unlock-reason-input' ||
|
|
id === 'unlock-confirm-input') validateUnlockModal();
|
|
if (id === 'operator-input' || id === 'confirm-serial') validateModal();
|
|
});
|
|
|
|
document.addEventListener('keydown', function (e) {
|
|
if (e.key === 'Escape') {
|
|
var pwModal = document.getElementById('password-modal');
|
|
if (pwModal && !pwModal.hidden) { closePasswordModal(); return; }
|
|
var uModal = document.getElementById('unlock-modal');
|
|
if (uModal && !uModal.hidden) { closeUnlockModal(); return; }
|
|
var modal = document.getElementById('start-modal');
|
|
if (modal && !modal.hidden) { closeModal(); return; }
|
|
var bModal = document.getElementById('batch-modal');
|
|
if (bModal && !bModal.hidden) { closeBatchModal(); return; }
|
|
if (_drawerDriveId) { closeDrawer(); return; }
|
|
}
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Log Drawer
|
|
// -----------------------------------------------------------------------
|
|
|
|
var _drawerDriveId = null;
|
|
var _drawerTab = 'burnin';
|
|
|
|
function openDrawer(driveId) {
|
|
if (_drawerDriveId === driveId) { closeDrawer(); return; }
|
|
_drawerDriveId = driveId;
|
|
var drawer = document.getElementById('log-drawer');
|
|
drawer.removeAttribute('hidden');
|
|
document.body.classList.add('drawer-open');
|
|
_drawerHighlightRow(driveId);
|
|
drawerFetch(driveId);
|
|
}
|
|
|
|
function closeDrawer() {
|
|
_drawerDriveId = null;
|
|
var drawer = document.getElementById('log-drawer');
|
|
drawer.setAttribute('hidden', '');
|
|
document.body.classList.remove('drawer-open');
|
|
document.querySelectorAll('tr.drawer-row-active').forEach(function (r) {
|
|
r.classList.remove('drawer-row-active');
|
|
});
|
|
}
|
|
|
|
function _drawerHighlightRow(driveId) {
|
|
document.querySelectorAll('tr.drawer-row-active').forEach(function (r) {
|
|
r.classList.remove('drawer-row-active');
|
|
});
|
|
var row = document.getElementById('drive-' + driveId);
|
|
if (row) row.classList.add('drawer-row-active');
|
|
}
|
|
|
|
async function drawerFetch(driveId) {
|
|
['burnin', 'smart', 'events'].forEach(function (tab) {
|
|
var p = document.getElementById('drawer-panel-' + tab);
|
|
if (p && !p.innerHTML.trim()) {
|
|
p.innerHTML = '<div class="drawer-loading">Loading\u2026</div>';
|
|
}
|
|
});
|
|
try {
|
|
var resp = await fetch('/api/v1/drives/' + driveId + '/drawer');
|
|
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
|
var data = await resp.json();
|
|
_drawerRender(data);
|
|
} catch (e) {
|
|
['burnin', 'smart', 'events'].forEach(function (tab) {
|
|
var p = document.getElementById('drawer-panel-' + tab);
|
|
if (p) p.innerHTML = '<div class="drawer-loading" style="color:var(--red)">Failed to load.</div>';
|
|
});
|
|
}
|
|
}
|
|
|
|
// 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';
|
|
if (metaEl) {
|
|
var meta = drive.model || '';
|
|
if (drive.serial) meta += ' \u00b7 ' + drive.serial;
|
|
metaEl.textContent = meta;
|
|
}
|
|
_drawerRenderBurnin(data.burnin);
|
|
_drawerRenderSmart(data.smart);
|
|
_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 = '<div class="bb-vitals">';
|
|
var dateOpts = {
|
|
weekday: 'short', month: 'short', day: 'numeric',
|
|
hour: 'numeric', minute: '2-digit',
|
|
};
|
|
|
|
// 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';
|
|
if (tc >= 48) tClass = 'temp-hot';
|
|
else if (tc >= 42) tClass = 'temp-warm';
|
|
html += '<div class="bb-vital">';
|
|
html += '<span class="bb-vital-label">Temp</span>';
|
|
html += '<span class="bb-vital-value temp ' + tClass + '">' + tc + '°C</span>';
|
|
html += '</div>';
|
|
}
|
|
|
|
html += '</div>';
|
|
return html;
|
|
}
|
|
|
|
function _bbFmtDuration(sec) {
|
|
sec = Math.floor(sec);
|
|
var d = Math.floor(sec / 86400);
|
|
var h = Math.floor((sec % 86400) / 3600);
|
|
var m = Math.floor((sec % 3600) / 60);
|
|
if (d > 0) return d + 'd ' + h + 'h';
|
|
if (h > 0) return h + 'h ' + m + 'm';
|
|
return m + 'm';
|
|
}
|
|
|
|
// Phase caption — explicit text below the meters: e.g.
|
|
// "Pattern 2 of 4 · Verify 0x55 · 47% within phase".
|
|
function _drawerRenderBadblocksCaption(phase, phasePct) {
|
|
if (!phase) return '';
|
|
var p = parseInt(phase, 10);
|
|
var pct = parseFloat(phasePct || 0);
|
|
var labels = ['0xaa', '0x55', '0xff', '0x00'];
|
|
var pattern = Math.ceil(p / 2);
|
|
var subPhase = (p % 2 === 1) ? 'Write' : 'Verify';
|
|
var label = labels[pattern - 1];
|
|
var html = '<div class="bb-caption">';
|
|
html += 'Pattern ' + pattern + ' of 4 · ';
|
|
html += subPhase + ' ' + label + ' · ';
|
|
html += pct.toFixed(1) + '% within phase';
|
|
html += '</div>';
|
|
return html;
|
|
}
|
|
|
|
// Per-pattern duration history. Reads bb_phase_history (JSON) and
|
|
// emits "0xaa: 14h 22m" rows for completed patterns. Pattern N is
|
|
// "complete" when its verify-phase end timestamp is known (= the
|
|
// next pattern's write-phase start, or stage.finished_at for the
|
|
// final one).
|
|
function _drawerRenderBadblocksHistory(stage) {
|
|
if (!stage.bb_phase_history) return '';
|
|
var hist;
|
|
try { hist = JSON.parse(stage.bb_phase_history); }
|
|
catch (e) { return ''; }
|
|
if (!hist || typeof hist !== 'object') return '';
|
|
var labels = ['0xaa', '0x55', '0xff', '0x00'];
|
|
var rows = [];
|
|
for (var n = 1; n <= 4; n++) {
|
|
var writeStart = hist[String(2 * n - 1)];
|
|
if (!writeStart) continue;
|
|
var endTs = (n < 4) ? hist[String(2 * n + 1)] : stage.finished_at;
|
|
if (!endTs) continue;
|
|
var elapsedSec = (Date.parse(endTs) - Date.parse(writeStart)) / 1000;
|
|
if (elapsedSec <= 0) continue;
|
|
rows.push('<span class="bb-hist-row">' +
|
|
'<span class="bb-hist-label">' + labels[n - 1] + '</span>' +
|
|
'<span class="bb-hist-dur">' + _bbFmtDuration(elapsedSec) + '</span>' +
|
|
'</span>');
|
|
}
|
|
if (!rows.length) return '';
|
|
return '<div class="bb-history"><span class="bb-hist-title">Completed patterns</span>' +
|
|
rows.join('') + '</div>';
|
|
}
|
|
|
|
// Render 4 pattern meters for badblocks -w surface_validate. Each
|
|
// meter splits write/verify halves so you can see at a glance which
|
|
// pattern is current AND whether you're writing or verifying within
|
|
// it. phase: 1-8 (1=write 0xaa, 2=verify 0xaa, 3=write 0x55, ...).
|
|
function _drawerRenderBadblocksMeters(phase, phasePct) {
|
|
if (!phase) return '';
|
|
var p = parseInt(phase, 10);
|
|
var pct = parseFloat(phasePct || 0);
|
|
var labels = ['0xaa', '0x55', '0xff', '0x00'];
|
|
var html = '<div class="bb-meters">';
|
|
for (var i = 0; i < 4; i++) {
|
|
var writePhase = i * 2 + 1;
|
|
var verifyPhase = writePhase + 1;
|
|
var writeFill, verifyFill;
|
|
if (p > verifyPhase) {
|
|
writeFill = 100; verifyFill = 100;
|
|
} else if (p === verifyPhase) {
|
|
writeFill = 100; verifyFill = pct;
|
|
} else if (p === writePhase) {
|
|
writeFill = pct; verifyFill = 0;
|
|
} else {
|
|
writeFill = 0; verifyFill = 0;
|
|
}
|
|
var classes = 'bb-meter';
|
|
if (p === writePhase || p === verifyPhase) classes += ' bb-meter-current';
|
|
if (p > verifyPhase) classes += ' bb-meter-done';
|
|
html += '<div class="' + classes + '">';
|
|
html += '<div class="bb-meter-label">' + labels[i] + '</div>';
|
|
html += '<div class="bb-meter-bar">';
|
|
html += '<div class="bb-meter-half bb-write" style="width:' + writeFill.toFixed(1) + '%"></div>';
|
|
html += '<div class="bb-meter-half-spacer"></div>';
|
|
html += '<div class="bb-meter-half bb-verify" style="width:' + verifyFill.toFixed(1) + '%"></div>';
|
|
html += '</div>';
|
|
html += '<div class="bb-meter-sub">';
|
|
html += '<span class="bb-sub-write">W ' + Math.round(writeFill) + '%</span>';
|
|
html += '<span class="bb-sub-verify">V ' + Math.round(verifyFill) + '%</span>';
|
|
html += '</div>';
|
|
html += '</div>';
|
|
}
|
|
html += '</div>';
|
|
return html;
|
|
}
|
|
|
|
function _drawerRenderBurnin(burnin) {
|
|
var panel = document.getElementById('drawer-panel-burnin');
|
|
if (!panel) return;
|
|
|
|
if (!burnin) {
|
|
panel.innerHTML = '<div class="drawer-empty">No burn-in history for this drive.</div>';
|
|
return;
|
|
}
|
|
|
|
var html = '<div class="drawer-job-header">';
|
|
html += '<span class="chip chip-' + _esc(burnin.state) + '">' + _esc(burnin.state.toUpperCase()) + '</span>';
|
|
html += '<span class="drawer-job-meta">';
|
|
if (burnin.operator) html += 'by ' + _esc(burnin.operator);
|
|
if (burnin.started_at) html += ' \u00b7 ' + _drawerFmtDt(burnin.started_at);
|
|
html += '</span></div>';
|
|
|
|
html += '<div class="drawer-stages">';
|
|
var stages = burnin.stages || [];
|
|
if (stages.length) {
|
|
stages.forEach(function (s) {
|
|
html += '<div class="drawer-stage stage-' + _esc(s.state) + '">';
|
|
html += '<div class="stage-row-header">';
|
|
html += '<span class="stage-icon">' + _drawerStageIcon(s.state) + '</span>';
|
|
html += '<span class="stage-name-label">' + _esc(_drawerStageName(s.stage_name)) + '</span>';
|
|
if (s.state === 'running') {
|
|
html += '<span class="stage-pct">' + (s.percent || 0) + '%</span>';
|
|
if (s.started_at) {
|
|
html += '<span class="elapsed-timer" data-started="' + _esc(s.started_at) + '"></span>';
|
|
}
|
|
html += '<span class="stage-cursor">\u258a</span>';
|
|
} else if (s.finished_at && s.started_at) {
|
|
html += '<span class="stage-duration">' + _drawerFmtDuration(s.started_at, s.finished_at) + '</span>';
|
|
}
|
|
html += '</div>';
|
|
// Prominent "Why it failed" block at the top of failed/cancelled/
|
|
// unknown stages. Falls back to a heuristic when no error was
|
|
// recorded — e.g. a tiny log + no badblocks progress + terminal
|
|
// state means the stage was killed externally (SSH disconnect or
|
|
// container restart) before it could record an error.
|
|
if (s.state === 'failed' || s.state === 'cancelled' || s.state === 'unknown') {
|
|
var reason = s.error_text;
|
|
if (!reason) {
|
|
var logLen = (s.log_text || '').length;
|
|
var noBbProgress = !s.bb_phase || (s.bb_phase === 1 && (parseFloat(s.bb_phase_pct || 0) < 0.1));
|
|
if (logLen < 500 && noBbProgress) {
|
|
reason = 'Stopped without recording an error — likely cause: SSH connection drop or container restart while this stage was running.';
|
|
} else {
|
|
reason = 'No error message recorded.';
|
|
}
|
|
}
|
|
html += '<div class="stage-reason stage-reason-' + _esc(s.state) + '">';
|
|
html += '<span class="stage-reason-label">Reason</span>';
|
|
html += '<span class="stage-reason-text">' + _esc(reason) + '</span>';
|
|
html += '</div>';
|
|
} else if (s.error_text) {
|
|
html += '<div class="stage-error-line">' + _esc(s.error_text) + '</div>';
|
|
}
|
|
// Per-pattern meters for badblocks surface_validate, plus the
|
|
// vital-signs row above (temp / speed / elapsed / ETA).
|
|
if (s.stage_name === 'surface_validate' && s.bb_phase) {
|
|
html += _drawerRenderBadblocksVitals(s, _DRAWER_LAST_DRIVE);
|
|
html += _drawerRenderBadblocksMeters(s.bb_phase, s.bb_phase_pct);
|
|
html += _drawerRenderBadblocksCaption(s.bb_phase, s.bb_phase_pct);
|
|
html += _drawerRenderBadblocksHistory(s);
|
|
}
|
|
// Raw SSH log output (if available)
|
|
if (s.log_text) {
|
|
var logHtml = _esc(s.log_text)
|
|
.replace(/^(\d+)\s*$/gm, '<span class="log-bad-block">$1 ← BAD BLOCK</span>')
|
|
.replace(/\[WARNING\][^\n]*/g, '<span class="log-warn">$&</span>');
|
|
html += '<pre class="stage-log">' + logHtml + '</pre>';
|
|
}
|
|
// Bad block count badge
|
|
if (s.bad_blocks && s.bad_blocks > 0) {
|
|
html += '<div class="stage-error-line">' + s.bad_blocks + ' bad block(s) found</div>';
|
|
}
|
|
html += '</div>';
|
|
});
|
|
} else {
|
|
html += '<div class="drawer-empty">No stage data yet.</div>';
|
|
}
|
|
html += '</div>';
|
|
|
|
var wasAtBottom = panel.scrollHeight - panel.scrollTop <= panel.clientHeight + 5;
|
|
panel.innerHTML = html;
|
|
tickElapsedTimers();
|
|
var autoScroll = document.getElementById('autoscroll-toggle');
|
|
if (autoScroll && autoScroll.checked && wasAtBottom) {
|
|
panel.scrollTop = panel.scrollHeight;
|
|
}
|
|
}
|
|
|
|
// Monitored SMART attributes for inline colouring
|
|
var _SMART_CRITICAL = {5: true, 197: true, 198: true};
|
|
var _SMART_WARN = {10: true, 188: true, 199: true};
|
|
|
|
function _drawerRenderSmart(smart) {
|
|
var panel = document.getElementById('drawer-panel-smart');
|
|
if (!panel) return;
|
|
|
|
var html = '<div class="drawer-smart-grid">';
|
|
['short', 'long'].forEach(function (type) {
|
|
var t = smart ? smart[type] : null;
|
|
var label = type === 'short' ? 'Short SMART' : 'Long SMART';
|
|
html += '<div class="smart-card">';
|
|
html += '<div class="smart-card-label">' + label + '</div>';
|
|
if (!t || !t.state || t.state === 'idle') {
|
|
html += '<span class="chip chip-unknown">Not run</span>';
|
|
} else {
|
|
html += '<span class="chip chip-' + _esc(t.state) + '">' + _esc(t.state.toUpperCase()) + '</span>';
|
|
if (t.state === 'running') {
|
|
html += '<div class="smart-progress"><div class="progress-bar"><div class="progress-fill" style="width:' + (t.percent || 0) + '%"></div></div>'
|
|
+ '<span style="font-size:12px;color:var(--blue)">' + (t.percent || 0) + '%</span></div>';
|
|
}
|
|
if (t.started_at) html += '<div class="smart-detail">Started: ' + _drawerFmtDt(t.started_at) + '</div>';
|
|
if (t.finished_at) html += '<div class="smart-detail">Finished: ' + _drawerFmtDt(t.finished_at) + '</div>';
|
|
if (t.error_text) html += '<div class="stage-error-line">' + _esc(t.error_text) + '</div>';
|
|
// Raw smartctl output (SSH mode)
|
|
if (t.raw_output) {
|
|
html += '<pre class="smart-attr-raw-output">' + _esc(t.raw_output) + '</pre>';
|
|
}
|
|
}
|
|
html += '</div>';
|
|
});
|
|
html += '</div>';
|
|
|
|
// SMART attribute table (from SSH attribute parse)
|
|
var attrs = smart && smart.attrs;
|
|
if (attrs) {
|
|
html += '<div class="smart-attrs">';
|
|
html += '<div class="smart-attrs-title">SMART Attributes</div>';
|
|
if (attrs.failures && attrs.failures.length) {
|
|
html += '<div class="stage-error-line" style="margin-bottom:6px">✕ Failures: ' + _esc(attrs.failures.join('; ')) + '</div>';
|
|
}
|
|
if (attrs.warnings && attrs.warnings.length) {
|
|
html += '<div class="stage-error-line" style="color:var(--yellow);margin-bottom:6px">⚠ Warnings: ' + _esc(attrs.warnings.join('; ')) + '</div>';
|
|
}
|
|
var attrMap = attrs.attrs || {};
|
|
var monitoredIds = [5, 10, 188, 197, 198, 199];
|
|
monitoredIds.forEach(function (id) {
|
|
var entry = attrMap[String(id)];
|
|
if (!entry) return;
|
|
var raw = entry.raw;
|
|
var cls = raw > 0 ? (_SMART_CRITICAL[id] ? 'attr-fail' : 'attr-warn') : 'attr-ok';
|
|
html += '<div class="smart-attr-row">';
|
|
html += '<span class="smart-attr-name">' + id + ' ' + _esc(entry.name) + '</span>';
|
|
html += '<span class="smart-attr-val ' + cls + '">' + raw + '</span>';
|
|
html += '</div>';
|
|
});
|
|
html += '</div>';
|
|
}
|
|
|
|
panel.innerHTML = html;
|
|
}
|
|
|
|
function _drawerRenderEvents(events) {
|
|
var panel = document.getElementById('drawer-panel-events');
|
|
if (!panel) return;
|
|
|
|
if (!events || events.length === 0) {
|
|
panel.innerHTML = '<div class="drawer-empty">No events recorded for this drive.</div>';
|
|
return;
|
|
}
|
|
|
|
var html = '<div class="drawer-events">';
|
|
events.forEach(function (ev) {
|
|
var isErr = (ev.event_type || '').indexOf('fail') !== -1 || (ev.event_type || '').indexOf('stuck') !== -1;
|
|
html += '<div class="drawer-event' + (isErr ? ' event-error' : '') + '">';
|
|
html += '<span class="event-time">' + _drawerFmtDt(ev.created_at) + '</span>';
|
|
html += '<span class="event-type">' + _esc(ev.event_type || '') + '</span>';
|
|
if (ev.message) html += '<span class="event-message">' + _esc(ev.message) + '</span>';
|
|
if (ev.operator) html += '<span class="event-operator">by ' + _esc(ev.operator) + '</span>';
|
|
html += '</div>';
|
|
});
|
|
html += '</div>';
|
|
panel.innerHTML = html;
|
|
}
|
|
|
|
function _esc(s) {
|
|
return String(s == null ? '' : s)
|
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
function _drawerFmtDt(iso) {
|
|
if (!iso) return '';
|
|
try { return new Date(iso).toLocaleString(); } catch (e) { return iso; }
|
|
}
|
|
|
|
function _drawerFmtDuration(startIso, endIso) {
|
|
try {
|
|
var secs = Math.max(0, Math.floor((new Date(endIso) - new Date(startIso)) / 1000));
|
|
var h = Math.floor(secs / 3600), m = Math.floor((secs % 3600) / 60), s = secs % 60;
|
|
if (h > 0) return h + 'h ' + m + 'm';
|
|
if (m > 0) return m + 'm ' + s + 's';
|
|
return s + 's';
|
|
} catch (e) { return ''; }
|
|
}
|
|
|
|
function _drawerStageName(name) {
|
|
return (name || '').replace(/_/g, ' ').replace(/\b\w/g, function (c) { return c.toUpperCase(); });
|
|
}
|
|
|
|
function _drawerStageIcon(state) {
|
|
return { passed: '\u2713', failed: '\u2715', running: '\u25b6', cancelled: '\u25fc', pending: '\u25cb', skipped: '\u2014' }[state] || '\u25cb';
|
|
}
|
|
|
|
// Row click → open drawer (ignore interactive elements)
|
|
document.addEventListener('click', function (e) {
|
|
if (e.target.closest('button, input, label, a, .drive-location')) return;
|
|
var row = e.target.closest('#drives-tbody tr[id^="drive-"]');
|
|
if (!row) return;
|
|
openDrawer(row.id.replace('drive-', ''));
|
|
});
|
|
|
|
// Tab switching
|
|
document.addEventListener('click', function (e) {
|
|
var btn = e.target.closest('.drawer-tab');
|
|
if (!btn) return;
|
|
_drawerTab = btn.dataset.tab;
|
|
document.querySelectorAll('.drawer-tab').forEach(function (b) {
|
|
b.classList.toggle('active', b.dataset.tab === _drawerTab);
|
|
});
|
|
document.querySelectorAll('.drawer-panel').forEach(function (p) {
|
|
p.classList.toggle('active', p.id === 'drawer-panel-' + _drawerTab);
|
|
});
|
|
});
|
|
|
|
// Close button
|
|
document.addEventListener('click', function (e) {
|
|
if (e.target.closest('#drawer-close-btn')) closeDrawer();
|
|
});
|
|
|
|
// Reset button — clears SMART state for a drive
|
|
document.addEventListener('click', function (e) {
|
|
var btn = e.target.closest('.btn-reset');
|
|
if (!btn) return;
|
|
var driveId = btn.dataset.driveId;
|
|
if (!driveId) return;
|
|
var operator = (window._operator || 'operator');
|
|
fetch('/api/v1/drives/' + driveId + '/reset', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ operator: operator }),
|
|
}).then(function (r) {
|
|
if (!r.ok) return r.json().then(function (d) { showToast(d.detail || 'Reset failed', 'error'); });
|
|
showToast('Drive reset — state cleared', 'success');
|
|
}).catch(function () { showToast('Network error', 'error'); });
|
|
});
|
|
|
|
}());
|