truenas-burnin/app/static/app.js
Brandon Walter 22ed2c6e12 fix: JS syntax error breaking all buttons; add settings restart banner
app.js: stages.forEach callback in _drawerRenderBurnin was missing its
closing });, causing a syntax error that prevented the entire script
from loading — all click handlers (Short/Long SMART, Burn-In, cancel)
were unregistered as a result.

settings.html: add a prominent yellow restart banner with the docker
command (docker compose restart app) that appears after saving any
system settings that require a container restart to take effect.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 08:57:57 -05:00

1144 lines
42 KiB
JavaScript

(function () {
'use strict';
// -----------------------------------------------------------------------
// 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();
initLocationEdits();
if (_drawerDriveId) {
_drawerHighlightRow(_drawerDriveId);
drawerFetch(_drawerDriveId);
}
});
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('TrueNAS 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 job-alert SSE events for browser notifications
document.addEventListener('htmx:sseMessage', function (e) {
if (!e.detail || e.detail.type !== 'job-alert') return;
try {
handleJobAlert(JSON.parse(e.detail.data));
} catch (_) {}
});
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();
// -----------------------------------------------------------------------
// 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 = localStorage.getItem('burnin_operator') || '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 = localStorage.getItem('burnin_operator') || '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 = localStorage.getItem('burnin_operator') || '';
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) {
showToast(data.detail || 'Failed to start burn-in', 'error');
return;
}
closeModal();
showToast('Burn-in queued: ' + _stageLabel(), 'success');
} catch (err) {
showToast('Network error', 'error');
}
}
// -----------------------------------------------------------------------
// 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 = localStorage.getItem('burnin_operator') || '';
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) {
showToast(data.detail || 'Failed to queue batch', 'error');
if (btn) btn.disabled = false;
return;
}
closeBatchModal();
checkedDriveIds.clear();
updateBatchBar();
var queued = (data.queued || []).length;
var errors = (data.errors || []).length;
var msg = queued + ' burn-in(s) queued';
if (errors) msg += ', ' + errors + ' skipped (already active)';
showToast(msg, errors && !queued ? 'error' : 'success');
} 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 = localStorage.getItem('burnin_operator') || '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; }
// 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;
}
// 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 === 'operator-input' || id === 'confirm-serial') validateModal();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
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>';
});
}
}
function _drawerRender(data) {
var drive = data.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);
}
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>';
if (s.error_text) {
html += '<div class="stage-error-line">' + _esc(s.error_text) + '</div>';
}
// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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'); });
});
}());