Full-stack burn-in orchestration dashboard (Stages 1–6d complete): FastAPI backend, SQLite/WAL, SSE live dashboard, mock TrueNAS server, SMTP/webhook notifications, batch burn-in, settings UI, audit log, stats page, cancel SMART/burn-in, drag-to-reorder stages. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
848 lines
30 KiB
JavaScript
848 lines
30 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();
|
|
});
|
|
|
|
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; }
|
|
}
|
|
});
|
|
|
|
}());
|