Adds a Terminal tab to the log drawer with a full PTY session bridged
over WebSocket to the TrueNAS SSH host. xterm.js loaded lazily on
first tab open. Supports resize, paste, full color, and reconnect.
- app/terminal.py: asyncssh PTY ↔ WebSocket bridge
- routes.py: @router.websocket("/ws/terminal")
- dashboard.html: Terminal tab + drawer panel
- app.js: xterm.js lazy load, init, onData, resize observer, reconnect
- app.css: terminal panel styles (no padding, overflow hidden)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1303 lines
47 KiB
JavaScript
1303 lines
47 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, '&').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);
|
|
});
|
|
// Terminal tab: init/fit on activation; hide autoscroll (N/A for terminal)
|
|
var asl = document.querySelector('.autoscroll-label');
|
|
if (_drawerTab === 'terminal') {
|
|
if (asl) asl.style.visibility = 'hidden';
|
|
openTerminalTab();
|
|
} else {
|
|
if (asl) asl.style.visibility = '';
|
|
}
|
|
});
|
|
|
|
// 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'); });
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Live Terminal (xterm.js + SSH WebSocket)
|
|
// -----------------------------------------------------------------------
|
|
|
|
var _xtermReady = false; // xterm.js + FitAddon libraries loaded
|
|
var _terminal = null; // xterm.js Terminal instance
|
|
var _termFit = null; // FitAddon instance
|
|
var _termWs = null; // active WebSocket (null = disconnected)
|
|
|
|
function _loadXtermLibs(cb) {
|
|
var link = document.createElement('link');
|
|
link.rel = 'stylesheet';
|
|
link.href = 'https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css';
|
|
document.head.appendChild(link);
|
|
|
|
var s1 = document.createElement('script');
|
|
s1.src = 'https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js';
|
|
s1.onload = function () {
|
|
var s2 = document.createElement('script');
|
|
s2.src = 'https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js';
|
|
s2.onload = cb;
|
|
document.head.appendChild(s2);
|
|
};
|
|
document.head.appendChild(s1);
|
|
}
|
|
|
|
function openTerminalTab() {
|
|
var panel = document.getElementById('drawer-panel-terminal');
|
|
if (!panel) return;
|
|
|
|
if (!_xtermReady) {
|
|
panel.innerHTML = '<div class="drawer-loading">Loading terminal\u2026</div>';
|
|
_loadXtermLibs(function () {
|
|
_xtermReady = true;
|
|
_termInit(panel);
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!_terminal) {
|
|
_termInit(panel);
|
|
return;
|
|
}
|
|
|
|
// Already initialised — refit to current panel dimensions
|
|
setTimeout(function () {
|
|
if (_termFit) try { _termFit.fit(); } catch (_) {}
|
|
}, 30);
|
|
}
|
|
|
|
function _termInit(panel) {
|
|
panel.innerHTML = '';
|
|
|
|
var term = new Terminal({
|
|
cursorBlink: true,
|
|
fontSize: 13,
|
|
fontFamily: '"SF Mono","Fira Code",Consolas,"DejaVu Sans Mono",monospace',
|
|
theme: {
|
|
background: '#0d1117',
|
|
foreground: '#e6edf3',
|
|
cursor: '#58a6ff',
|
|
cursorAccent: '#0d1117',
|
|
selectionBackground: 'rgba(88,166,255,0.25)',
|
|
black: '#484f58', red: '#ff7b72', green: '#3fb950', yellow: '#d29922',
|
|
blue: '#58a6ff', magenta: '#bc8cff', cyan: '#39c5cf', white: '#b1bac4',
|
|
brightBlack: '#6e7681', brightRed: '#ffa198', brightGreen: '#56d364',
|
|
brightYellow: '#e3b341', brightBlue: '#79c0ff', brightMagenta: '#d2a8ff',
|
|
brightCyan: '#56d4dd', brightWhite: '#f0f6fc',
|
|
},
|
|
scrollback: 2000,
|
|
allowProposedApi: true,
|
|
});
|
|
|
|
var fit = new FitAddon.FitAddon();
|
|
term.loadAddon(fit);
|
|
term.open(panel);
|
|
|
|
_terminal = term;
|
|
_termFit = fit;
|
|
|
|
// Initial fit after the panel is visible
|
|
setTimeout(function () {
|
|
if (_termFit) try { _termFit.fit(); } catch (_) {}
|
|
}, 30);
|
|
|
|
// Forward all keystrokes → SSH (onData registered once here)
|
|
term.onData(function (data) {
|
|
if (_termWs && _termWs.readyState === 1) {
|
|
_termWs.send(new TextEncoder().encode(data));
|
|
}
|
|
});
|
|
|
|
// Refit + notify server on resize
|
|
new ResizeObserver(function () {
|
|
if (!_termFit) return;
|
|
try { _termFit.fit(); } catch (_) {}
|
|
if (_termWs && _termWs.readyState === 1 && _terminal) {
|
|
_termWs.send(JSON.stringify({ type: 'resize', cols: _terminal.cols, rows: _terminal.rows }));
|
|
}
|
|
}).observe(panel);
|
|
|
|
_termConnect();
|
|
}
|
|
|
|
function _termConnect() {
|
|
if (_termWs && _termWs.readyState <= 1) return; // already open or connecting
|
|
|
|
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
var ws = new WebSocket(proto + '//' + location.host + '/ws/terminal');
|
|
ws.binaryType = 'arraybuffer';
|
|
_termWs = ws;
|
|
|
|
ws.onopen = function () {
|
|
_termHideReconnect();
|
|
if (_terminal && ws.readyState === 1) {
|
|
ws.send(JSON.stringify({ type: 'resize', cols: _terminal.cols, rows: _terminal.rows }));
|
|
}
|
|
};
|
|
|
|
ws.onmessage = function (e) {
|
|
if (!_terminal) return;
|
|
_terminal.write(e.data instanceof ArrayBuffer ? new Uint8Array(e.data) : e.data);
|
|
};
|
|
|
|
ws.onclose = function () {
|
|
if (_terminal) _terminal.write('\r\n\x1b[33m\u2500\u2500 disconnected \u2500\u2500\x1b[0m\r\n');
|
|
_termShowReconnect();
|
|
};
|
|
|
|
ws.onerror = function () { /* onclose fires too */ };
|
|
}
|
|
|
|
function _termShowReconnect() {
|
|
var panel = document.getElementById('drawer-panel-terminal');
|
|
if (!panel || panel.querySelector('.term-reconnect-bar')) return;
|
|
var bar = document.createElement('div');
|
|
bar.className = 'term-reconnect-bar';
|
|
bar.innerHTML = '<span>Connection closed</span>'
|
|
+ '<button class="btn-secondary">\u21ba Reconnect</button>';
|
|
bar.querySelector('button').onclick = function () {
|
|
bar.remove();
|
|
_termConnect();
|
|
};
|
|
panel.appendChild(bar);
|
|
}
|
|
|
|
function _termHideReconnect() {
|
|
var bar = document.querySelector('.term-reconnect-bar');
|
|
if (bar) bar.remove();
|
|
}
|
|
|
|
}());
|