Substantial feature + reliability sweep. Each version below was developed,
tested live against the maple/TrueNAS deployment, and Codex-reviewed
before bundling.
1.0.0-13 — asyncssh proc.kill() doesn't actually kill the remote process
(sshd ignores SSH signal-channel requests by default), so a cancel of a
long-running badblocks left the remote process running and proc.wait()
hanging — pinning the asyncio.Semaphore slot forever.
* Wrap long-lived commands in `sh -c 'echo PID:$$; exec <cmd>'` to
capture the remote PID; store in burnin._remote_pids[job_id].
* burnin._kill_remote_process(job_id) opens a fresh SSH session and
issues `kill -9 <pid>` — sshd honours that.
* Bound proc.wait() with asyncio.wait_for(timeout=15).
* burnin._active_tasks tracks every _run_job task so cancel_job and
check_stuck_jobs can actually cancel the asyncio task (was DB-only
before). Also fixes the documented asyncio.create_task GC gotcha
(weak refs only).
* _run_job finalizer reads current state and skips the write if state
!= 'running' so cancelled/unknown aren't clobbered.
1.0.0-14 — poller._upsert_drive ON CONFLICT only refreshed temperature/
health/poll timestamps; devname/serial/model/size_bytes were stuck at
first-INSERT values forever. After kernel SCSI re-enumeration two
drives could both show as `sda`. Fixed by updating all six fields.
Also added 7-day stale filter to _DRIVES_QUERY so removed drives drop
off the dashboard while audit/burnin_jobs FKs stay intact.
1.0.0-15/-16 — pool-membership lock.
* ssh_client.get_pool_membership() runs `zpool list -vHP` and parses
the flattened TrueNAS output (container vdevs + their device children
both appear at depth 1; section markers cache/log/spare/special/dedup
switch the role).
* ssh_client.get_zfs_member_drives() runs `lsblk -no NAME,FSTYPE -l`
to detect drives carrying ZFS labels not in any active pool — they
get pool_name='(exported)', pool_role='exported'.
* Three idempotent ALTER TABLE migrations on drives:
pool_name/pool_role/pool_seen_at.
* burnin.start_job raises PoolMemberError if pool_name IS NOT NULL and
the drive isn't in burnin._unlock_grants. Routes layer maps to 409
with structured detail {pool_name, pool_role, pool_locked: true} so
the frontend can render an unlock affordance.
* POST /api/v1/drives/{id}/unlock accepts {confirm_token, operator,
reason}. Token is the pool name for active pools, "DESTROY BOOT POOL"
for boot-pool, "DESTROY EXPORTED POOL" for exported. Reason >= 5
chars. TTL = UNLOCK_TTL_SECONDS = 600. Audit event types:
pool_drive_unlocked / boot_pool_drive_unlocked /
exported_pool_drive_unlocked.
* Grants are in-memory only — container restart wipes them.
* UI: lock icon (yellow/red/orange), pool pill, conditional Unlock vs
Burn-In button. modal_unlock.html with type-to-confirm field.
Live unlock countdown via tickUnlockCountdowns() in app.js.
* Daily report: red banner listing every unlock event from the last
24h, with operator + reason + timestamp.
1.0.0-17 — Codex review fail-open + XSS + structured-error fixes.
* ssh_client.get_pool_membership / get_zfs_member_drives now return
None on failure (vs {} for 'definitely empty'). poller passes
update_pool=False to _upsert_drive on detection failure, preserving
existing pool columns instead of clearing them. Without this fix a
1-second SSH blip silently unlocked every drive.
* mailer._build_unlock_banner_html escapes every interpolated field
via html.escape() (was '<' only). Time filter switched to
julianday() — string >= against datetime('now', '-1 day') compared
formats with different separators ('T' vs ' ') and timezone
suffixes, causing subtle off-by-N-hour inclusion.
* app.js submitStart/submitBatchStart now detect the structured
pool_locked 409 detail and auto-open the unlock modal for the
offending drive (was [object Object] in toast).
1.0.0-18 — Codex grant-binding + commit-ordering fixes.
* Unlock grants bound to the (pool_name, pool_role) observed at unlock
time. _UnlockGrant dataclass; _is_unlocked and unlock_expiry
invalidate the grant if the live row's pool identity has changed.
Prevents an 'exported' unlock from carrying over when the drive
turns out to be in active 'tank' or 'boot-pool'.
* grant_pool_unlock now writes to _unlock_grants only AFTER db.commit()
succeeds — previously a failed audit insert left an unaudited grant
armed.
1.0.0-19 — Codex race + cancellation classification + test scaffold.
* Partial unique index uniq_active_burnin_per_drive ON burnin_jobs
(drive_id) WHERE state IN ('queued','running'). INSERT now wraps in
try/except aiosqlite.IntegrityError -> ValueError so the read-then-
insert race in start_job can't produce two queued rows for the same
drive.
* _run_job tracks was_cancelled flag; on bare task.cancel() (shutdown,
future code paths) where DB state is still 'running', finalizer
writes 'unknown' instead of mis-classifying as 'failed'.
* tests/ stdlib unittest scaffold:
- test_pool_parser.py (21 tests): mirror/raidz/draid container vdevs,
single-disk depth-1, plural section markers, partition stripping,
sdaa-style names, multi-pool, role reset between pools.
- test_unlock_flow.py (18 tests): token validation per pool kind,
identity-binding invalidation, TTL expiry, audit-commit-then-arm
ordering, unique-active-burnin partial index.
Run via `python -m unittest discover tests/`. No new dependencies.
1.0.0-20 — Spearfoot-inspired badblocks tunables.
* surface_validate_block_size (-b, default 4096), surface_validate_
block_buffer (-c, default 64), surface_validate_passes (-p, default
1) exposed in Settings UI; persist via settings_store.json.
Validation: block size must be a power of 2 between 512 and
1048576. Defaults preserve existing behaviour. Bumping to 8192/64/1
roughly halves runtime on multi-TB HDDs at ~2x RAM cost.
1.0.0-21 — SMART overall-health column actually populated.
* /api/v2.0/disk doesn't expose smart_health, so every drive defaulted
to UNKNOWN forever (only burn-in stages ever wrote a real value).
* ssh_client.get_smart_health_map([devnames]) runs `smartctl -H` for
all drives in a single SSH session, deterministically delimited with
@@devname@@ ... @@END@@ markers. Returns {devname: PASSED|FAILED|
UNKNOWN} or None on SSH failure.
* poller calls it every 5th cycle (~1 min at default 12s interval),
caches in _state['smart_health_cache'] so transient failures preserve
the previous values.
* Dashboard CSS: col-smart min-width 150 -> 95, horizontal padding 14
-> 6 so Short/Long SMART columns fit comfortably on a 13-inch
display.
* 5 additional parser tests (44 total, all passing).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1416 lines
53 KiB
JavaScript
1416 lines
53 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();
|
|
initUnlockCountdowns();
|
|
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 SSE events
|
|
document.addEventListener('htmx:sseMessage', function (e) {
|
|
if (!e.detail) return;
|
|
if (e.detail.type === 'job-alert') {
|
|
try { handleJobAlert(JSON.parse(e.detail.data)); } catch (_) {}
|
|
} else if (e.detail.type === 'system-sensors') {
|
|
try { handleSystemSensors(JSON.parse(e.detail.data)); } catch (_) {}
|
|
}
|
|
});
|
|
|
|
function handleSystemSensors(data) {
|
|
var st = data.system_temps || {};
|
|
var tp = data.thermal_pressure || 'ok';
|
|
var warn = data.temp_warn_c || 46;
|
|
var crit = data.temp_crit_c || 55;
|
|
|
|
function tempClass(c) {
|
|
if (c == null) return '';
|
|
return c >= crit ? 'temp-hot' : c >= warn ? 'temp-warm' : 'temp-cool';
|
|
}
|
|
|
|
// CPU chip
|
|
var cpuChip = document.getElementById('sensor-cpu');
|
|
var cpuVal = document.getElementById('sensor-cpu-val');
|
|
if (cpuVal && st.cpu_c != null) {
|
|
if (cpuChip) cpuChip.hidden = false;
|
|
cpuVal.textContent = st.cpu_c + '°';
|
|
cpuVal.className = 'stat-sensor-val ' + tempClass(st.cpu_c);
|
|
}
|
|
|
|
// PCH chip
|
|
var pchChip = document.getElementById('sensor-pch');
|
|
var pchVal = document.getElementById('sensor-pch-val');
|
|
if (pchVal && st.pch_c != null) {
|
|
if (pchChip) pchChip.hidden = false;
|
|
pchVal.textContent = st.pch_c + '°';
|
|
pchVal.className = 'stat-sensor-val ' + tempClass(st.pch_c);
|
|
}
|
|
|
|
// Thermal pressure chip
|
|
var tChip = document.getElementById('sensor-thermal');
|
|
var tVal = document.getElementById('sensor-thermal-val');
|
|
if (tChip && tVal) {
|
|
if (tp === 'warn' || tp === 'crit') {
|
|
tChip.hidden = false;
|
|
tChip.className = 'stat-sensor stat-sensor-thermal stat-sensor-thermal-' + tp;
|
|
tVal.textContent = tp === 'warn' ? 'WARM' : 'HOT';
|
|
} else {
|
|
tChip.hidden = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleJobAlert(data) {
|
|
var isPass = data.state === 'passed';
|
|
var icon = isPass ? '✓' : '✕';
|
|
var title = icon + ' ' + (data.devname || 'Drive') + ' — Burn-In ' + (data.state || '').toUpperCase();
|
|
var bodyText = (data.model || '') + (data.serial ? ' · ' + data.serial : '');
|
|
if (!isPass && data.error_text) bodyText += '\n' + data.error_text;
|
|
|
|
showToast(title + (data.error_text ? ' · ' + data.error_text : ''), isPass ? 'success' : 'error');
|
|
|
|
if (Notification.permission === 'granted') {
|
|
try {
|
|
new Notification(title, { body: bodyText || undefined });
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Elapsed time timers
|
|
// -----------------------------------------------------------------------
|
|
|
|
var _elapsedInterval = null;
|
|
|
|
function formatElapsed(seconds) {
|
|
if (seconds < 0) return '';
|
|
var h = Math.floor(seconds / 3600);
|
|
var m = Math.floor((seconds % 3600) / 60);
|
|
var s = seconds % 60;
|
|
if (h > 0) return h + 'h ' + m + 'm';
|
|
if (m > 0) return m + 'm ' + s + 's';
|
|
return s + 's';
|
|
}
|
|
|
|
function tickElapsedTimers() {
|
|
var now = Date.now();
|
|
document.querySelectorAll('.elapsed-timer[data-started]').forEach(function (el) {
|
|
var started = new Date(el.dataset.started).getTime();
|
|
if (isNaN(started)) return;
|
|
var elapsed = Math.floor((now - started) / 1000);
|
|
el.textContent = formatElapsed(elapsed);
|
|
});
|
|
}
|
|
|
|
function initElapsedTimers() {
|
|
if (_elapsedInterval) return; // Already running
|
|
var timers = document.querySelectorAll('.elapsed-timer[data-started]');
|
|
if (timers.length === 0) return;
|
|
_elapsedInterval = setInterval(function () {
|
|
var remaining = document.querySelectorAll('.elapsed-timer[data-started]');
|
|
if (remaining.length === 0) {
|
|
clearInterval(_elapsedInterval);
|
|
_elapsedInterval = null;
|
|
return;
|
|
}
|
|
tickElapsedTimers();
|
|
}, 1000);
|
|
tickElapsedTimers();
|
|
}
|
|
|
|
initElapsedTimers();
|
|
|
|
// Live countdown for pool-drive unlock TTL — runs once per second; ticker
|
|
// self-stops when no .unlock-countdown spans remain on the page.
|
|
var _unlockTickInterval = null;
|
|
function tickUnlockCountdowns() {
|
|
var spans = document.querySelectorAll('.unlock-countdown[data-expires]');
|
|
if (spans.length === 0) {
|
|
if (_unlockTickInterval) {
|
|
clearInterval(_unlockTickInterval);
|
|
_unlockTickInterval = null;
|
|
}
|
|
return;
|
|
}
|
|
var nowSec = Date.now() / 1000;
|
|
spans.forEach(function (el) {
|
|
var exp = parseFloat(el.dataset.expires);
|
|
if (!exp || isNaN(exp)) return;
|
|
var rem = Math.max(0, exp - nowSec);
|
|
if (rem <= 0) {
|
|
el.textContent = 'expired';
|
|
el.className = 'unlock-countdown unlock-countdown-expired';
|
|
return;
|
|
}
|
|
var m = Math.floor(rem / 60);
|
|
var s = Math.floor(rem % 60);
|
|
el.textContent = '\u{1F513} ' + m + ':' + (s < 10 ? '0' : '') + s;
|
|
});
|
|
}
|
|
function initUnlockCountdowns() {
|
|
if (_unlockTickInterval) return;
|
|
if (document.querySelectorAll('.unlock-countdown[data-expires]').length === 0) return;
|
|
_unlockTickInterval = setInterval(tickUnlockCountdowns, 1000);
|
|
tickUnlockCountdowns();
|
|
}
|
|
initUnlockCountdowns();
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Inline location / notes edit
|
|
// -----------------------------------------------------------------------
|
|
|
|
function initLocationEdits() {
|
|
document.querySelectorAll('.drive-location').forEach(function (el) {
|
|
if (el._locationInited) return;
|
|
el._locationInited = true;
|
|
|
|
el.addEventListener('click', function (evt) {
|
|
evt.stopPropagation();
|
|
var driveId = el.dataset.driveId;
|
|
var current = el.classList.contains('drive-location-empty') ? '' : el.textContent.trim();
|
|
|
|
var input = document.createElement('input');
|
|
input.type = 'text';
|
|
input.className = 'drive-location-input';
|
|
input.value = current;
|
|
input.placeholder = 'e.g. Bay 3 Shelf 2';
|
|
input.maxLength = 64;
|
|
|
|
el.replaceWith(input);
|
|
input.focus();
|
|
input.select();
|
|
|
|
async function save() {
|
|
var newVal = input.value.trim();
|
|
try {
|
|
var resp = await fetch('/api/v1/drives/' + driveId, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ location: newVal || null }),
|
|
});
|
|
if (!resp.ok) throw new Error('save failed');
|
|
} catch (_) {
|
|
showToast('Failed to save location', 'error');
|
|
}
|
|
// The SSE update will replace the whole row; nothing more needed
|
|
}
|
|
|
|
function cancel() {
|
|
var span = document.createElement('span');
|
|
span.className = 'drive-location' + (current ? '' : ' drive-location-empty');
|
|
span.dataset.driveId = driveId;
|
|
span.dataset.field = 'location';
|
|
span.title = current ? 'Click to edit location' : 'Click to set location';
|
|
span.textContent = current || '+ location';
|
|
input.replaceWith(span);
|
|
initLocationEdits(); // re-attach listener
|
|
}
|
|
|
|
input.addEventListener('blur', function () { save(); });
|
|
input.addEventListener('keydown', function (e) {
|
|
if (e.key === 'Enter') { input.blur(); }
|
|
if (e.key === 'Escape') { cancel(); }
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
initLocationEdits();
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Stage drag-and-drop reordering
|
|
// -----------------------------------------------------------------------
|
|
|
|
function initStageDrag(listId) {
|
|
var list = document.getElementById(listId);
|
|
if (!list || list._dragInited) return;
|
|
list._dragInited = true;
|
|
|
|
var draggingEl = null;
|
|
|
|
list.addEventListener('dragstart', function (e) {
|
|
draggingEl = e.target.closest('.stage-check');
|
|
if (!draggingEl) return;
|
|
draggingEl.classList.add('dragging');
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
});
|
|
|
|
list.addEventListener('dragend', function () {
|
|
if (draggingEl) draggingEl.classList.remove('dragging');
|
|
list.querySelectorAll('.stage-check.drag-over').forEach(function (el) {
|
|
el.classList.remove('drag-over');
|
|
});
|
|
draggingEl = null;
|
|
});
|
|
|
|
list.addEventListener('dragover', function (e) {
|
|
e.preventDefault();
|
|
if (!draggingEl) return;
|
|
var target = e.target.closest('.stage-check');
|
|
if (!target || target === draggingEl) return;
|
|
var rect = target.getBoundingClientRect();
|
|
var midY = rect.top + rect.height / 2;
|
|
if (e.clientY < midY) {
|
|
list.insertBefore(draggingEl, target);
|
|
} else {
|
|
list.insertBefore(draggingEl, target.nextSibling);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Map checkbox id → backend stage name
|
|
var _STAGE_ID_MAP = {
|
|
'stage-surface': 'surface_validate',
|
|
'stage-short': 'short_smart',
|
|
'stage-long': 'long_smart',
|
|
'batch-stage-surface': 'surface_validate',
|
|
'batch-stage-short': 'short_smart',
|
|
'batch-stage-long': 'long_smart',
|
|
};
|
|
|
|
// Read DOM order of checked stages from the given list element
|
|
function getStageOrder(listId) {
|
|
var items = Array.from(document.querySelectorAll('#' + listId + ' .stage-check'));
|
|
var order = [];
|
|
items.forEach(function (item) {
|
|
var cb = item.querySelector('input[type=checkbox]');
|
|
if (cb && cb.checked && _STAGE_ID_MAP[cb.id]) {
|
|
order.push(_STAGE_ID_MAP[cb.id]);
|
|
}
|
|
});
|
|
return order;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Standalone SMART test
|
|
// -----------------------------------------------------------------------
|
|
|
|
async function startSmartTest(btn) {
|
|
var driveId = btn.dataset.driveId;
|
|
var testType = btn.dataset.testType;
|
|
var operator = 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) {
|
|
// detail may be the structured pool-locked object {drive_id,
|
|
// pool_name, pool_role, pool_locked: true, error: "..."}.
|
|
// The user already opened the start modal, so the unlock TTL must
|
|
// have just expired between modal-open and submit. Auto-flip to
|
|
// the unlock modal for that drive.
|
|
if (_handlePoolLockedError(data.detail)) {
|
|
closeModal();
|
|
return;
|
|
}
|
|
showToast(_extractErrorMessage(data.detail) || 'Failed to start burn-in', 'error');
|
|
return;
|
|
}
|
|
|
|
closeModal();
|
|
showToast('Burn-in queued: ' + _stageLabel(), 'success');
|
|
} catch (err) {
|
|
showToast('Network error', 'error');
|
|
}
|
|
}
|
|
|
|
// Helpers shared between single-drive and batch start error paths.
|
|
// Backend returns either a string (legacy errors) or, for pool-locked
|
|
// drives, an object: {drive_id, error, pool_name, pool_role, pool_locked}.
|
|
function _extractErrorMessage(detail) {
|
|
if (!detail) return null;
|
|
if (typeof detail === 'string') return detail;
|
|
if (typeof detail === 'object' && detail.error) return detail.error;
|
|
return null;
|
|
}
|
|
// Returns true if it handled a pool-locked error by opening the unlock
|
|
// modal for the offending drive. Caller should bail out.
|
|
function _handlePoolLockedError(detail) {
|
|
if (!detail || typeof detail !== 'object' || !detail.pool_locked) return false;
|
|
var driveId = detail.drive_id;
|
|
if (driveId == null) return false;
|
|
var btn = document.querySelector('.btn-unlock[data-drive-id="' + driveId + '"]');
|
|
if (btn) {
|
|
// openUnlockModal closes any other open modals as a side effect of
|
|
// calling its own close handlers; we still need to close the
|
|
// start/batch modal explicitly in the caller, since openUnlockModal
|
|
// doesn't know which one is open.
|
|
openUnlockModal(btn);
|
|
return true;
|
|
}
|
|
// Unlock button not in the DOM (drive row may have refreshed).
|
|
// Surface a descriptive toast instead of [object Object].
|
|
showToast(
|
|
(detail.error || 'Drive is pool-locked') +
|
|
' Reload the page and click Unlock on the drive row.',
|
|
'error',
|
|
);
|
|
return true;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Pool-drive Unlock modal
|
|
// -----------------------------------------------------------------------
|
|
|
|
var unlockDriveId = null;
|
|
var unlockExpectedToken = null;
|
|
|
|
function openUnlockModal(btn) {
|
|
unlockDriveId = btn.dataset.driveId;
|
|
var poolName = btn.dataset.poolName || '';
|
|
var poolRole = btn.dataset.poolRole || 'data';
|
|
var isBoot = btn.dataset.isBootPool === '1';
|
|
var isExported = btn.dataset.isExported === '1';
|
|
if (isBoot) unlockExpectedToken = 'DESTROY BOOT POOL';
|
|
else if (isExported) unlockExpectedToken = 'DESTROY EXPORTED POOL';
|
|
else unlockExpectedToken = poolName;
|
|
|
|
document.getElementById('unlock-devname').textContent = btn.dataset.devname || '—';
|
|
document.getElementById('unlock-model').textContent = btn.dataset.model || '—';
|
|
document.getElementById('unlock-serial').textContent = btn.dataset.serial || '—';
|
|
document.getElementById('unlock-size').textContent = btn.dataset.size || '—';
|
|
|
|
var chip = document.getElementById('unlock-pool-chip');
|
|
if (isExported) {
|
|
chip.textContent = 'exported ZFS';
|
|
chip.className = 'chip chip-aborted';
|
|
} else {
|
|
chip.textContent = poolName + ' · ' + poolRole;
|
|
chip.className = 'chip ' + (isBoot ? 'chip-failed' : 'chip-aborted');
|
|
}
|
|
|
|
var titleEl = document.getElementById('unlock-modal-title');
|
|
var warnTitle = document.getElementById('unlock-warning-title');
|
|
var warnBody = document.getElementById('unlock-warning-body');
|
|
if (isBoot) {
|
|
titleEl.textContent = 'Unlock BOOT POOL drive';
|
|
warnTitle.textContent = 'This is a TrueNAS BOOT drive.';
|
|
warnBody.textContent =
|
|
'Running burn-in on this drive will destroy the operating system on it. ' +
|
|
'If this drive is half of a mirrored boot pool, the system will continue running on the other mirror, ' +
|
|
'but you must already have a replacement plan. Proceeding without one bricks the host.';
|
|
} else if (isExported) {
|
|
titleEl.textContent = 'Unlock drive with EXPORTED ZFS data';
|
|
warnTitle.textContent = 'This drive carries ZFS data from a previously-imported pool.';
|
|
warnBody.textContent =
|
|
"TrueNAS isn't using this pool right now, but the drive still holds the labels and data. " +
|
|
'Burning it in will silently destroy whatever pool that data belongs to — including ' +
|
|
'pools that another system may be relying on. Confirm you have already evacuated or ' +
|
|
'reassigned the pool before continuing.';
|
|
} else {
|
|
titleEl.textContent = 'Unlock pool drive';
|
|
warnTitle.textContent = "This drive belongs to zpool '" + poolName + "'.";
|
|
warnBody.textContent =
|
|
'Running a destructive burn-in stage will overwrite all data on this drive ' +
|
|
'and almost certainly destroy the pool. Only proceed if you have already ' +
|
|
'removed this drive from the pool, or if you are intentionally decommissioning the pool.';
|
|
}
|
|
document.getElementById('unlock-confirm-token').textContent = unlockExpectedToken;
|
|
document.getElementById('unlock-confirm-hint').textContent = 'Expected: ' + unlockExpectedToken;
|
|
|
|
document.getElementById('unlock-confirm-input').value = '';
|
|
document.getElementById('unlock-reason-input').value = '';
|
|
var savedOp = localStorage.getItem('burnin_operator') || '';
|
|
document.getElementById('unlock-operator-input').value = savedOp;
|
|
validateUnlockModal();
|
|
|
|
document.getElementById('unlock-modal').removeAttribute('hidden');
|
|
setTimeout(function () {
|
|
document.getElementById('unlock-operator-input').focus();
|
|
}, 50);
|
|
}
|
|
|
|
function closeUnlockModal() {
|
|
document.getElementById('unlock-modal').setAttribute('hidden', '');
|
|
unlockDriveId = null;
|
|
unlockExpectedToken = null;
|
|
}
|
|
|
|
function validateUnlockModal() {
|
|
var op = (document.getElementById('unlock-operator-input').value || '').trim();
|
|
var rsn = (document.getElementById('unlock-reason-input').value || '').trim();
|
|
var tok = (document.getElementById('unlock-confirm-input').value || '').trim();
|
|
var ok = op.length > 0 && rsn.length >= 5 && tok === unlockExpectedToken;
|
|
document.getElementById('unlock-modal-submit-btn').disabled = !ok;
|
|
}
|
|
|
|
async function submitUnlock() {
|
|
var op = (document.getElementById('unlock-operator-input').value || '').trim();
|
|
var rsn = (document.getElementById('unlock-reason-input').value || '').trim();
|
|
var tok = (document.getElementById('unlock-confirm-input').value || '').trim();
|
|
localStorage.setItem('burnin_operator', op);
|
|
|
|
var btn = document.getElementById('unlock-modal-submit-btn');
|
|
btn.disabled = true;
|
|
|
|
try {
|
|
var resp = await fetch('/api/v1/drives/' + unlockDriveId + '/unlock', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
confirm_token: tok,
|
|
operator: op,
|
|
reason: rsn,
|
|
}),
|
|
});
|
|
var data = await resp.json();
|
|
if (!resp.ok) {
|
|
showToast(data.detail || 'Unlock failed', 'error');
|
|
btn.disabled = false;
|
|
return;
|
|
}
|
|
closeUnlockModal();
|
|
showToast('Unlocked for 10 minutes — start burn-in now to use it.', 'success');
|
|
// Force a drive list refresh so the row flips from Unlock → Burn-In
|
|
if (typeof refreshDrives === 'function') refreshDrives();
|
|
} catch (err) {
|
|
showToast('Network error', 'error');
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Batch Burn-In
|
|
// -----------------------------------------------------------------------
|
|
|
|
var checkedDriveIds = new Set();
|
|
|
|
function updateBatchBar() {
|
|
var bar = document.getElementById('batch-bar');
|
|
if (!bar) return;
|
|
var count = checkedDriveIds.size;
|
|
bar.hidden = count === 0;
|
|
var countEl = document.getElementById('batch-count');
|
|
if (countEl) countEl.textContent = count;
|
|
}
|
|
|
|
function restoreCheckboxes() {
|
|
// Re-check boxes that were checked before the SSE swap
|
|
document.querySelectorAll('.drive-checkbox').forEach(function (cb) {
|
|
cb.checked = checkedDriveIds.has(cb.dataset.driveId);
|
|
});
|
|
|
|
// Update select-all state
|
|
var selectAll = document.getElementById('select-all-cb');
|
|
if (selectAll) {
|
|
var allBoxes = document.querySelectorAll('.drive-checkbox');
|
|
selectAll.checked = allBoxes.length > 0 && Array.from(allBoxes).every(function (c) { return c.checked; });
|
|
selectAll.indeterminate = checkedDriveIds.size > 0 && !selectAll.checked;
|
|
}
|
|
|
|
updateBatchBar();
|
|
}
|
|
|
|
// Toggle individual checkbox
|
|
document.addEventListener('change', function (e) {
|
|
if (e.target.classList.contains('drive-checkbox')) {
|
|
var id = e.target.dataset.driveId;
|
|
if (e.target.checked) {
|
|
checkedDriveIds.add(id);
|
|
} else {
|
|
checkedDriveIds.delete(id);
|
|
}
|
|
updateBatchBar();
|
|
// Update select-all indeterminate state
|
|
var selectAll = document.getElementById('select-all-cb');
|
|
if (selectAll) {
|
|
var allBoxes = Array.from(document.querySelectorAll('.drive-checkbox'));
|
|
selectAll.checked = allBoxes.length > 0 && allBoxes.every(function (c) { return c.checked; });
|
|
selectAll.indeterminate = checkedDriveIds.size > 0 && !selectAll.checked;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Select-all checkbox
|
|
if (e.target.id === 'select-all-cb') {
|
|
var boxes = document.querySelectorAll('.drive-checkbox');
|
|
boxes.forEach(function (cb) {
|
|
cb.checked = e.target.checked;
|
|
if (e.target.checked) {
|
|
checkedDriveIds.add(cb.dataset.driveId);
|
|
} else {
|
|
checkedDriveIds.delete(cb.dataset.driveId);
|
|
}
|
|
});
|
|
updateBatchBar();
|
|
return;
|
|
}
|
|
|
|
// Batch modal inputs validation
|
|
if (['batch-confirm-cb', 'batch-stage-surface', 'batch-stage-short', 'batch-stage-long'].indexOf(e.target.id) !== -1) {
|
|
validateBatchModal();
|
|
}
|
|
});
|
|
|
|
// Batch bar buttons
|
|
document.addEventListener('click', function (e) {
|
|
if (e.target.id === 'batch-start-btn' || e.target.closest('#batch-start-btn')) {
|
|
openBatchModal();
|
|
return;
|
|
}
|
|
if (e.target.id === 'batch-clear-btn') {
|
|
checkedDriveIds.clear();
|
|
document.querySelectorAll('.drive-checkbox').forEach(function (cb) { cb.checked = false; });
|
|
var sa = document.getElementById('select-all-cb');
|
|
if (sa) { sa.checked = false; sa.indeterminate = false; }
|
|
updateBatchBar();
|
|
return;
|
|
}
|
|
});
|
|
|
|
function openBatchModal() {
|
|
var modal = document.getElementById('batch-modal');
|
|
if (!modal) return;
|
|
var savedOp = 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) {
|
|
if (_handlePoolLockedError(data.detail)) {
|
|
closeBatchModal();
|
|
return;
|
|
}
|
|
showToast(_extractErrorMessage(data.detail) || 'Failed to queue batch', 'error');
|
|
if (btn) btn.disabled = false;
|
|
return;
|
|
}
|
|
|
|
closeBatchModal();
|
|
checkedDriveIds.clear();
|
|
updateBatchBar();
|
|
var queued = (data.queued || []).length;
|
|
var allErrors = data.errors || [];
|
|
var poolLocked = allErrors.filter(function (e) { return e && e.pool_locked; });
|
|
var alreadyActive = allErrors.length - poolLocked.length;
|
|
|
|
var parts = [queued + ' burn-in(s) queued'];
|
|
if (alreadyActive) parts.push(alreadyActive + ' skipped (already active)');
|
|
if (poolLocked.length) {
|
|
parts.push(poolLocked.length + ' pool-locked (use Unlock on each row)');
|
|
}
|
|
var tone = (queued === 0 && allErrors.length) ? 'error' : 'success';
|
|
showToast(parts.join(', '), tone);
|
|
} catch (err) {
|
|
showToast('Network error', 'error');
|
|
if (btn) btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Cancel burn-in (individual)
|
|
// -----------------------------------------------------------------------
|
|
|
|
async function cancelBurnin(btn) {
|
|
var jobId = btn.dataset.jobId;
|
|
var operator = 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; }
|
|
|
|
// Pool-drive unlock button (single drive)
|
|
var unlockBtn = e.target.closest('.btn-unlock');
|
|
if (unlockBtn && !unlockBtn.disabled) { openUnlockModal(unlockBtn); return; }
|
|
|
|
// Burn-in start button (single drive)
|
|
var startBtn = e.target.closest('.btn-start');
|
|
if (startBtn && !startBtn.disabled) { openModal(startBtn); return; }
|
|
|
|
// Cancel burn-in button (individual)
|
|
var cancelBtn = e.target.closest('.btn-cancel');
|
|
if (cancelBtn) { cancelBurnin(cancelBtn); return; }
|
|
|
|
// Cancel ALL running burn-ins
|
|
if (e.target.id === 'cancel-all-btn' || e.target.closest('#cancel-all-btn')) {
|
|
cancelAllBurnins();
|
|
return;
|
|
}
|
|
|
|
// Single-drive modal close
|
|
if (e.target.closest('#modal-close-btn') || e.target.closest('#modal-cancel-btn')) {
|
|
closeModal();
|
|
return;
|
|
}
|
|
if (e.target.id === 'start-modal') {
|
|
closeModal();
|
|
return;
|
|
}
|
|
if (e.target.id === 'modal-start-btn') {
|
|
submitStart();
|
|
return;
|
|
}
|
|
|
|
// Unlock modal
|
|
if (e.target.closest('#unlock-modal-close-btn') || e.target.closest('#unlock-modal-cancel-btn')) {
|
|
closeUnlockModal();
|
|
return;
|
|
}
|
|
if (e.target.id === 'unlock-modal') { closeUnlockModal(); return; }
|
|
if (e.target.id === 'unlock-modal-submit-btn') { submitUnlock(); return; }
|
|
|
|
// Batch modal close
|
|
if (e.target.closest('#batch-modal-close-btn') || e.target.closest('#batch-modal-cancel-btn')) {
|
|
closeBatchModal();
|
|
return;
|
|
}
|
|
if (e.target.id === 'batch-modal') {
|
|
closeBatchModal();
|
|
return;
|
|
}
|
|
if (e.target.id === 'batch-modal-start-btn') {
|
|
submitBatchStart();
|
|
return;
|
|
}
|
|
});
|
|
|
|
document.addEventListener('input', function (e) {
|
|
var id = e.target.id;
|
|
if (id === 'unlock-operator-input' || id === 'unlock-reason-input' ||
|
|
id === 'unlock-confirm-input') validateUnlockModal();
|
|
if (id === 'operator-input' || id === 'confirm-serial') validateModal();
|
|
});
|
|
|
|
document.addEventListener('keydown', function (e) {
|
|
if (e.key === 'Escape') {
|
|
var uModal = document.getElementById('unlock-modal');
|
|
if (uModal && !uModal.hidden) { closeUnlockModal(); return; }
|
|
var modal = document.getElementById('start-modal');
|
|
if (modal && !modal.hidden) { closeModal(); return; }
|
|
var bModal = document.getElementById('batch-modal');
|
|
if (bModal && !bModal.hidden) { closeBatchModal(); return; }
|
|
if (_drawerDriveId) { closeDrawer(); return; }
|
|
}
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Log Drawer
|
|
// -----------------------------------------------------------------------
|
|
|
|
var _drawerDriveId = null;
|
|
var _drawerTab = 'burnin';
|
|
|
|
function openDrawer(driveId) {
|
|
if (_drawerDriveId === driveId) { closeDrawer(); return; }
|
|
_drawerDriveId = driveId;
|
|
var drawer = document.getElementById('log-drawer');
|
|
drawer.removeAttribute('hidden');
|
|
document.body.classList.add('drawer-open');
|
|
_drawerHighlightRow(driveId);
|
|
drawerFetch(driveId);
|
|
}
|
|
|
|
function closeDrawer() {
|
|
_drawerDriveId = null;
|
|
var drawer = document.getElementById('log-drawer');
|
|
drawer.setAttribute('hidden', '');
|
|
document.body.classList.remove('drawer-open');
|
|
document.querySelectorAll('tr.drawer-row-active').forEach(function (r) {
|
|
r.classList.remove('drawer-row-active');
|
|
});
|
|
}
|
|
|
|
function _drawerHighlightRow(driveId) {
|
|
document.querySelectorAll('tr.drawer-row-active').forEach(function (r) {
|
|
r.classList.remove('drawer-row-active');
|
|
});
|
|
var row = document.getElementById('drive-' + driveId);
|
|
if (row) row.classList.add('drawer-row-active');
|
|
}
|
|
|
|
async function drawerFetch(driveId) {
|
|
['burnin', 'smart', 'events'].forEach(function (tab) {
|
|
var p = document.getElementById('drawer-panel-' + tab);
|
|
if (p && !p.innerHTML.trim()) {
|
|
p.innerHTML = '<div class="drawer-loading">Loading\u2026</div>';
|
|
}
|
|
});
|
|
try {
|
|
var resp = await fetch('/api/v1/drives/' + driveId + '/drawer');
|
|
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
|
var data = await resp.json();
|
|
_drawerRender(data);
|
|
} catch (e) {
|
|
['burnin', 'smart', 'events'].forEach(function (tab) {
|
|
var p = document.getElementById('drawer-panel-' + tab);
|
|
if (p) p.innerHTML = '<div class="drawer-loading" style="color:var(--red)">Failed to load.</div>';
|
|
});
|
|
}
|
|
}
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
// 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'); });
|
|
});
|
|
|
|
}());
|