(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 = '
Loading\u2026
'; } }); 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 = '
Failed to load.
'; }); } } 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 = '
No burn-in history for this drive.
'; return; } var html = '
'; html += '' + _esc(burnin.state.toUpperCase()) + ''; html += ''; if (burnin.operator) html += 'by ' + _esc(burnin.operator); if (burnin.started_at) html += ' \u00b7 ' + _drawerFmtDt(burnin.started_at); html += '
'; html += '
'; var stages = burnin.stages || []; if (stages.length) { stages.forEach(function (s) { html += '
'; html += '
'; html += '' + _drawerStageIcon(s.state) + ''; html += '' + _esc(_drawerStageName(s.stage_name)) + ''; if (s.state === 'running') { html += '' + (s.percent || 0) + '%'; if (s.started_at) { html += ''; } html += '\u258a'; } else if (s.finished_at && s.started_at) { html += '' + _drawerFmtDuration(s.started_at, s.finished_at) + ''; } html += '
'; if (s.error_text) { html += '
' + _esc(s.error_text) + '
'; } // Raw SSH log output (if available) if (s.log_text) { var logHtml = _esc(s.log_text) .replace(/^(\d+)\s*$/gm, '$1 ← BAD BLOCK') .replace(/\[WARNING\][^\n]*/g, '$&'); html += '
' + logHtml + '
'; } // Bad block count badge if (s.bad_blocks && s.bad_blocks > 0) { html += '
' + s.bad_blocks + ' bad block(s) found
'; } html += '
'; }); } else { html += '
No stage data yet.
'; } html += '
'; 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 = '
'; ['short', 'long'].forEach(function (type) { var t = smart ? smart[type] : null; var label = type === 'short' ? 'Short SMART' : 'Long SMART'; html += '
'; html += '
' + label + '
'; if (!t || !t.state || t.state === 'idle') { html += 'Not run'; } else { html += '' + _esc(t.state.toUpperCase()) + ''; if (t.state === 'running') { html += '
' + '' + (t.percent || 0) + '%
'; } if (t.started_at) html += '
Started: ' + _drawerFmtDt(t.started_at) + '
'; if (t.finished_at) html += '
Finished: ' + _drawerFmtDt(t.finished_at) + '
'; if (t.error_text) html += '
' + _esc(t.error_text) + '
'; // Raw smartctl output (SSH mode) if (t.raw_output) { html += '
' + _esc(t.raw_output) + '
'; } } html += '
'; }); html += '
'; // SMART attribute table (from SSH attribute parse) var attrs = smart && smart.attrs; if (attrs) { html += '
'; html += '
SMART Attributes
'; if (attrs.failures && attrs.failures.length) { html += '
✕ Failures: ' + _esc(attrs.failures.join('; ')) + '
'; } if (attrs.warnings && attrs.warnings.length) { html += '
⚠ Warnings: ' + _esc(attrs.warnings.join('; ')) + '
'; } 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 += '
'; html += '' + id + ' ' + _esc(entry.name) + ''; html += '' + raw + ''; html += '
'; }); html += '
'; } panel.innerHTML = html; } function _drawerRenderEvents(events) { var panel = document.getElementById('drawer-panel-events'); if (!panel) return; if (!events || events.length === 0) { panel.innerHTML = '
No events recorded for this drive.
'; return; } var html = '
'; events.forEach(function (ev) { var isErr = (ev.event_type || '').indexOf('fail') !== -1 || (ev.event_type || '').indexOf('stuck') !== -1; html += '
'; html += '' + _drawerFmtDt(ev.created_at) + ''; html += '' + _esc(ev.event_type || '') + ''; if (ev.message) html += '' + _esc(ev.message) + ''; if (ev.operator) html += 'by ' + _esc(ev.operator) + ''; html += '
'; }); html += '
'; panel.innerHTML = html; } function _esc(s) { return String(s == null ? '' : s) .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'); }); }); }());