feat: add log drawer (Stage 7a)

Click any drive row to slide up a drawer with three tabs:
- Burn-In: stage timeline with state icons, elapsed timers, error lines in red
- SMART: short and long test status, timestamps, progress
- Events: last 50 audit events for the drive (newest first)

Drawer auto-refreshes on every SSE poll cycle. Row highlights blue
while drawer is open. Clicking same row or pressing Esc closes it.
Auto-scroll toggle keeps burn-in tab pinned to bottom during active runs.

New API: GET /api/v1/drives/{id}/drawer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Brandon Walter 2026-02-24 07:22:53 -05:00
parent b73b5251ae
commit c0f9098779
4 changed files with 653 additions and 0 deletions

View file

@ -249,6 +249,58 @@ async def list_drives(db: aiosqlite.Connection = Depends(get_db)):
return [_row_to_drive(r) for r in rows] return [_row_to_drive(r) for r in rows]
@router.get("/api/v1/drives/{drive_id}/drawer")
async def drive_drawer(drive_id: int, db: aiosqlite.Connection = Depends(get_db)):
"""Data for the log drawer — latest burn-in job + stages, SMART tests, audit events."""
cur = await db.execute(_DRIVES_QUERY.format(where="WHERE d.id = ?"), (drive_id,))
row = await cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Drive not found")
drive = _row_to_drive(row)
# Latest burn-in job + its stages
cur = await db.execute(
"SELECT * FROM burnin_jobs WHERE drive_id=? ORDER BY id DESC LIMIT 1",
(drive_id,),
)
job_row = await cur.fetchone()
burnin = None
if job_row:
job = dict(job_row)
cur = await db.execute(
"SELECT * FROM burnin_stages WHERE burnin_job_id=? ORDER BY id",
(job_row["id"],),
)
job["stages"] = [dict(r) for r in await cur.fetchall()]
burnin = job
# Last 50 audit events for this drive (newest first)
cur = await db.execute("""
SELECT id, event_type, operator, message, created_at
FROM audit_events
WHERE drive_id = ?
ORDER BY id DESC
LIMIT 50
""", (drive_id,))
events = [dict(r) for r in await cur.fetchall()]
return {
"drive": {
"id": drive.id,
"devname": drive.devname,
"serial": drive.serial,
"model": drive.model,
"size_bytes": drive.size_bytes,
},
"burnin": burnin,
"smart": {
"short": drive.smart_short.model_dump() if drive.smart_short else None,
"long": drive.smart_long.model_dump() if drive.smart_long else None,
},
"events": events,
}
@router.get("/api/v1/drives/{drive_id}", response_model=DriveResponse) @router.get("/api/v1/drives/{drive_id}", response_model=DriveResponse)
async def get_drive(drive_id: int, db: aiosqlite.Connection = Depends(get_db)): async def get_drive(drive_id: int, db: aiosqlite.Connection = Depends(get_db)):
cur = await db.execute( cur = await db.execute(

View file

@ -1937,3 +1937,344 @@ a.header-brand:hover .header-title {
outline: 2px solid var(--blue); outline: 2px solid var(--blue);
outline-offset: 2px; outline-offset: 2px;
} }
/* -----------------------------------------------------------------------
Log Drawer
----------------------------------------------------------------------- */
.log-drawer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 45vh;
min-height: 260px;
background: var(--bg-card);
border-top: 2px solid var(--border);
z-index: 150;
display: flex;
flex-direction: column;
box-shadow: 0 -6px 32px rgba(0,0,0,0.5);
animation: drawer-slide-up 0.18s ease;
}
.log-drawer[hidden] { display: none; }
@keyframes drawer-slide-up {
from { transform: translateY(100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
/* Shrink table when drawer is open */
body.drawer-open .table-wrap {
max-height: calc(100vh - 205px - 45vh);
}
/* Drawer header */
.drawer-header {
display: flex;
align-items: center;
gap: 14px;
padding: 7px 16px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
background: var(--bg);
}
.drawer-drive-info {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 80px;
}
.drawer-devname {
font-size: 13px;
font-weight: 600;
color: var(--text-strong);
font-family: "SF Mono", "Cascadia Code", monospace;
}
.drawer-drive-meta {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 240px;
}
/* Tabs */
.drawer-tabs {
display: flex;
gap: 2px;
}
.drawer-tab {
background: none;
border: 1px solid transparent;
border-radius: 5px;
color: var(--text-muted);
cursor: pointer;
font-size: 12px;
font-family: inherit;
font-weight: 500;
padding: 4px 12px;
transition: color 0.12s, background 0.12s;
}
.drawer-tab:hover {
color: var(--text);
background: var(--bg-card);
}
.drawer-tab.active {
color: var(--text-strong);
background: var(--bg-card);
border-color: var(--border);
}
/* Controls */
.drawer-controls {
display: flex;
align-items: center;
gap: 12px;
margin-left: auto;
flex-shrink: 0;
}
.autoscroll-label {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
color: var(--text-muted);
cursor: pointer;
user-select: none;
}
.autoscroll-label input { accent-color: var(--blue); cursor: pointer; }
.drawer-close {
background: none;
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-muted);
cursor: pointer;
font-size: 12px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
transition: color 0.12s, border-color 0.12s;
}
.drawer-close:hover { color: var(--text); border-color: var(--text-muted); }
/* Body + panels */
.drawer-body {
flex: 1;
overflow: hidden;
position: relative;
}
.drawer-panel {
display: none;
height: 100%;
overflow-y: auto;
padding: 12px 16px 20px;
}
.drawer-panel.active { display: block; }
.drawer-loading,
.drawer-empty {
color: var(--text-muted);
font-size: 13px;
padding: 28px 0;
text-align: center;
}
/* Clickable rows */
#drives-tbody tr[id^="drive-"] { cursor: pointer; }
/* Active row highlight */
tr.drawer-row-active {
background: rgba(88, 166, 255, 0.07) !important;
outline: 1px solid var(--blue-bd);
outline-offset: -1px;
}
/* ---- Burn-In tab ---- */
.drawer-job-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.drawer-job-meta {
font-size: 12px;
color: var(--text-muted);
}
.drawer-stages {
display: flex;
flex-direction: column;
gap: 6px;
}
.drawer-stage {
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
}
.stage-row-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
font-size: 13px;
}
.stage-running .stage-row-header { background: var(--blue-bg); }
.stage-passed .stage-row-header { background: var(--green-bg); }
.stage-failed .stage-row-header { background: var(--red-bg); }
.stage-icon {
font-size: 12px;
width: 16px;
text-align: center;
flex-shrink: 0;
}
.stage-running .stage-icon { color: var(--blue); }
.stage-passed .stage-icon { color: var(--green); }
.stage-failed .stage-icon { color: var(--red); }
.stage-cancelled .stage-icon,
.stage-pending .stage-icon { color: var(--gray); }
.stage-name-label {
font-size: 13px;
font-weight: 500;
color: var(--text);
flex: 1;
}
.stage-pct {
font-size: 12px;
color: var(--blue);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.stage-duration {
font-size: 11px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.stage-cursor {
color: var(--blue);
font-size: 14px;
animation: blink 1s step-end infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.stage-error-line {
padding: 7px 12px;
font-size: 12px;
color: var(--red);
font-family: "SF Mono", "Cascadia Code", monospace;
background: var(--red-bg);
border-top: 1px solid var(--red-bd);
white-space: pre-wrap;
word-break: break-word;
}
/* ---- SMART tab ---- */
.drawer-smart-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.smart-card {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 8px;
}
.smart-card-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
}
.smart-progress {
display: flex;
align-items: center;
gap: 8px;
}
.smart-progress .progress-bar { flex: 1; }
.smart-detail {
font-size: 12px;
color: var(--text-muted);
}
/* ---- Events tab ---- */
.drawer-events {
display: flex;
flex-direction: column;
}
.drawer-event {
display: flex;
align-items: baseline;
gap: 10px;
padding: 7px 0;
border-bottom: 1px solid var(--border);
font-size: 12px;
}
.drawer-event:last-child { border-bottom: none; }
.event-time {
color: var(--text-muted);
font-size: 11px;
white-space: nowrap;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.event-type {
color: var(--blue);
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
}
.event-message {
color: var(--text);
flex: 1;
}
.event-operator {
color: var(--text-muted);
font-size: 11px;
white-space: nowrap;
flex-shrink: 0;
}
.drawer-event.event-error .event-type { color: var(--red); }
.drawer-event.event-error .event-message { color: var(--red); }
@media (max-width: 600px) {
.drawer-smart-grid { grid-template-columns: 1fr; }
.drawer-drive-meta { display: none; }
}

View file

@ -69,6 +69,10 @@
restoreCheckboxes(); restoreCheckboxes();
initElapsedTimers(); initElapsedTimers();
initLocationEdits(); initLocationEdits();
if (_drawerDriveId) {
_drawerHighlightRow(_drawerDriveId);
drawerFetch(_drawerDriveId);
}
}); });
updateCounts(); updateCounts();
@ -842,7 +846,236 @@
if (modal && !modal.hidden) { closeModal(); return; } if (modal && !modal.hidden) { closeModal(); return; }
var bModal = document.getElementById('batch-modal'); var bModal = document.getElementById('batch-modal');
if (bModal && !bModal.hidden) { closeBatchModal(); return; } 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>';
}
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;
}
}
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>';
}
html += '</div>';
});
html += '</div>';
panel.innerHTML = html;
}
function _drawerRenderEvents(events) {
var panel = document.getElementById('drawer-panel-events');
if (!panel) return;
if (!events || events.length === 0) {
panel.innerHTML = '<div class="drawer-empty">No events recorded for this drive.</div>';
return;
}
var html = '<div class="drawer-events">';
events.forEach(function (ev) {
var isErr = (ev.event_type || '').indexOf('fail') !== -1 || (ev.event_type || '').indexOf('stuck') !== -1;
html += '<div class="drawer-event' + (isErr ? ' event-error' : '') + '">';
html += '<span class="event-time">' + _drawerFmtDt(ev.created_at) + '</span>';
html += '<span class="event-type">' + _esc(ev.event_type || '') + '</span>';
if (ev.message) html += '<span class="event-message">' + _esc(ev.message) + '</span>';
if (ev.operator) html += '<span class="event-operator">by ' + _esc(ev.operator) + '</span>';
html += '</div>';
});
html += '</div>';
panel.innerHTML = html;
}
function _esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function _drawerFmtDt(iso) {
if (!iso) return '';
try { return new Date(iso).toLocaleString(); } catch (e) { return iso; }
}
function _drawerFmtDuration(startIso, endIso) {
try {
var secs = Math.max(0, Math.floor((new Date(endIso) - new Date(startIso)) / 1000));
var h = Math.floor(secs / 3600), m = Math.floor((secs % 3600) / 60), s = secs % 60;
if (h > 0) return h + 'h ' + m + 'm';
if (m > 0) return m + 'm ' + s + 's';
return s + 's';
} catch (e) { return ''; }
}
function _drawerStageName(name) {
return (name || '').replace(/_/g, ' ').replace(/\b\w/g, function (c) { return c.toUpperCase(); });
}
function _drawerStageIcon(state) {
return { passed: '\u2713', failed: '\u2715', running: '\u25b6', cancelled: '\u25fc', pending: '\u25cb', skipped: '\u2014' }[state] || '\u25cb';
}
// Row click → open drawer (ignore interactive elements)
document.addEventListener('click', function (e) {
if (e.target.closest('button, input, label, a, .drive-location')) return;
var row = e.target.closest('#drives-tbody tr[id^="drive-"]');
if (!row) return;
openDrawer(row.id.replace('drive-', ''));
});
// Tab switching
document.addEventListener('click', function (e) {
var btn = e.target.closest('.drawer-tab');
if (!btn) return;
_drawerTab = btn.dataset.tab;
document.querySelectorAll('.drawer-tab').forEach(function (b) {
b.classList.toggle('active', b.dataset.tab === _drawerTab);
});
document.querySelectorAll('.drawer-panel').forEach(function (p) {
p.classList.toggle('active', p.id === 'drawer-panel-' + _drawerTab);
});
});
// Close button
document.addEventListener('click', function (e) {
if (e.target.closest('#drawer-close-btn')) closeDrawer();
});
}()); }());

View file

@ -71,4 +71,31 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Log Drawer (fixed, lives outside SSE swap area) -->
<div id="log-drawer" class="log-drawer" hidden>
<div class="drawer-header">
<div class="drawer-drive-info">
<span class="drawer-devname" id="drawer-devname"></span>
<span class="drawer-drive-meta" id="drawer-drive-meta"></span>
</div>
<nav class="drawer-tabs">
<button class="drawer-tab active" data-tab="burnin">Burn-In</button>
<button class="drawer-tab" data-tab="smart">SMART</button>
<button class="drawer-tab" data-tab="events">Events</button>
</nav>
<div class="drawer-controls">
<label class="autoscroll-label">
<input type="checkbox" id="autoscroll-toggle" checked>
<span>Auto-scroll</span>
</label>
<button class="drawer-close" id="drawer-close-btn" title="Close (Esc)"></button>
</div>
</div>
<div class="drawer-body">
<div class="drawer-panel active" id="drawer-panel-burnin"></div>
<div class="drawer-panel" id="drawer-panel-smart"></div>
<div class="drawer-panel" id="drawer-panel-events"></div>
</div>
</div>
{% endblock %} {% endblock %}