From c0f9098779e4c6f2ced3e5cd57db14029bc0cc26 Mon Sep 17 00:00:00 2001 From: Brandon Walter <51866976+echoparkbaby@users.noreply.github.com> Date: Tue, 24 Feb 2026 07:22:53 -0500 Subject: [PATCH] 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 --- app/routes.py | 52 ++++++ app/static/app.css | 341 +++++++++++++++++++++++++++++++++++ app/static/app.js | 233 ++++++++++++++++++++++++ app/templates/dashboard.html | 27 +++ 4 files changed, 653 insertions(+) diff --git a/app/routes.py b/app/routes.py index 61cc559..0c6ef07 100644 --- a/app/routes.py +++ b/app/routes.py @@ -249,6 +249,58 @@ async def list_drives(db: aiosqlite.Connection = Depends(get_db)): 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) async def get_drive(drive_id: int, db: aiosqlite.Connection = Depends(get_db)): cur = await db.execute( diff --git a/app/static/app.css b/app/static/app.css index 00d0b26..e19d68d 100644 --- a/app/static/app.css +++ b/app/static/app.css @@ -1937,3 +1937,344 @@ a.header-brand:hover .header-title { outline: 2px solid var(--blue); 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; } +} diff --git a/app/static/app.js b/app/static/app.js index b857cf4..3d5a921 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -69,6 +69,10 @@ restoreCheckboxes(); initElapsedTimers(); initLocationEdits(); + if (_drawerDriveId) { + _drawerHighlightRow(_drawerDriveId); + drawerFetch(_drawerDriveId); + } }); updateCounts(); @@ -842,7 +846,236 @@ if (modal && !modal.hidden) { closeModal(); return; } var bModal = document.getElementById('batch-modal'); if (bModal && !bModal.hidden) { closeBatchModal(); return; } + if (_drawerDriveId) { closeDrawer(); return; } } }); + // ----------------------------------------------------------------------- + // Log Drawer + // ----------------------------------------------------------------------- + + var _drawerDriveId = null; + var _drawerTab = 'burnin'; + + function openDrawer(driveId) { + if (_drawerDriveId === driveId) { closeDrawer(); return; } + _drawerDriveId = driveId; + var drawer = document.getElementById('log-drawer'); + drawer.removeAttribute('hidden'); + document.body.classList.add('drawer-open'); + _drawerHighlightRow(driveId); + drawerFetch(driveId); + } + + function closeDrawer() { + _drawerDriveId = null; + var drawer = document.getElementById('log-drawer'); + drawer.setAttribute('hidden', ''); + document.body.classList.remove('drawer-open'); + document.querySelectorAll('tr.drawer-row-active').forEach(function (r) { + r.classList.remove('drawer-row-active'); + }); + } + + function _drawerHighlightRow(driveId) { + document.querySelectorAll('tr.drawer-row-active').forEach(function (r) { + r.classList.remove('drawer-row-active'); + }); + var row = document.getElementById('drive-' + driveId); + if (row) row.classList.add('drawer-row-active'); + } + + async function drawerFetch(driveId) { + ['burnin', 'smart', 'events'].forEach(function (tab) { + var p = document.getElementById('drawer-panel-' + tab); + if (p && !p.innerHTML.trim()) { + p.innerHTML = '
Loading\u2026
'; + } + }); + try { + var resp = await fetch('/api/v1/drives/' + driveId + '/drawer'); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + var data = await resp.json(); + _drawerRender(data); + } catch (e) { + ['burnin', 'smart', 'events'].forEach(function (tab) { + var p = document.getElementById('drawer-panel-' + tab); + if (p) p.innerHTML = '
Failed to load.
'; + }); + } + } + + function _drawerRender(data) { + var drive = data.drive || {}; + var devnameEl = document.getElementById('drawer-devname'); + var metaEl = document.getElementById('drawer-drive-meta'); + if (devnameEl) devnameEl.textContent = drive.devname || '\u2014'; + if (metaEl) { + var meta = drive.model || ''; + if (drive.serial) meta += ' \u00b7 ' + drive.serial; + metaEl.textContent = meta; + } + _drawerRenderBurnin(data.burnin); + _drawerRenderSmart(data.smart); + _drawerRenderEvents(data.events); + } + + function _drawerRenderBurnin(burnin) { + var panel = document.getElementById('drawer-panel-burnin'); + if (!panel) return; + + if (!burnin) { + panel.innerHTML = '
No burn-in history for this drive.
'; + return; + } + + var html = '
'; + html += '' + _esc(burnin.state.toUpperCase()) + ''; + html += ''; + if (burnin.operator) html += 'by ' + _esc(burnin.operator); + if (burnin.started_at) html += ' \u00b7 ' + _drawerFmtDt(burnin.started_at); + html += '
'; + + html += '
'; + var stages = burnin.stages || []; + if (stages.length) { + stages.forEach(function (s) { + html += '
'; + html += '
'; + html += '' + _drawerStageIcon(s.state) + ''; + html += '' + _esc(_drawerStageName(s.stage_name)) + ''; + if (s.state === 'running') { + html += '' + (s.percent || 0) + '%'; + if (s.started_at) { + html += ''; + } + html += '\u258a'; + } else if (s.finished_at && s.started_at) { + html += '' + _drawerFmtDuration(s.started_at, s.finished_at) + ''; + } + html += '
'; + if (s.error_text) { + html += '
' + _esc(s.error_text) + '
'; + } + html += '
'; + }); + } else { + html += '
No stage data yet.
'; + } + html += '
'; + + var wasAtBottom = panel.scrollHeight - panel.scrollTop <= panel.clientHeight + 5; + panel.innerHTML = html; + tickElapsedTimers(); + var autoScroll = document.getElementById('autoscroll-toggle'); + if (autoScroll && autoScroll.checked && wasAtBottom) { + panel.scrollTop = panel.scrollHeight; + } + } + + function _drawerRenderSmart(smart) { + var panel = document.getElementById('drawer-panel-smart'); + if (!panel) return; + + var html = '
'; + ['short', 'long'].forEach(function (type) { + var t = smart ? smart[type] : null; + var label = type === 'short' ? 'Short SMART' : 'Long SMART'; + html += '
'; + html += '
' + label + '
'; + if (!t || !t.state || t.state === 'idle') { + html += 'Not run'; + } else { + html += '' + _esc(t.state.toUpperCase()) + ''; + if (t.state === 'running') { + html += '
' + + '' + (t.percent || 0) + '%
'; + } + if (t.started_at) html += '
Started: ' + _drawerFmtDt(t.started_at) + '
'; + if (t.finished_at) html += '
Finished: ' + _drawerFmtDt(t.finished_at) + '
'; + if (t.error_text) html += '
' + _esc(t.error_text) + '
'; + } + html += '
'; + }); + html += '
'; + panel.innerHTML = html; + } + + function _drawerRenderEvents(events) { + var panel = document.getElementById('drawer-panel-events'); + if (!panel) return; + + if (!events || events.length === 0) { + panel.innerHTML = '
No events recorded for this drive.
'; + return; + } + + var html = '
'; + events.forEach(function (ev) { + var isErr = (ev.event_type || '').indexOf('fail') !== -1 || (ev.event_type || '').indexOf('stuck') !== -1; + html += '
'; + html += '' + _drawerFmtDt(ev.created_at) + ''; + html += '' + _esc(ev.event_type || '') + ''; + if (ev.message) html += '' + _esc(ev.message) + ''; + if (ev.operator) html += 'by ' + _esc(ev.operator) + ''; + html += '
'; + }); + html += '
'; + panel.innerHTML = html; + } + + function _esc(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + + function _drawerFmtDt(iso) { + if (!iso) return ''; + try { return new Date(iso).toLocaleString(); } catch (e) { return iso; } + } + + function _drawerFmtDuration(startIso, endIso) { + try { + var secs = Math.max(0, Math.floor((new Date(endIso) - new Date(startIso)) / 1000)); + var h = Math.floor(secs / 3600), m = Math.floor((secs % 3600) / 60), s = secs % 60; + if (h > 0) return h + 'h ' + m + 'm'; + if (m > 0) return m + 'm ' + s + 's'; + return s + 's'; + } catch (e) { return ''; } + } + + function _drawerStageName(name) { + return (name || '').replace(/_/g, ' ').replace(/\b\w/g, function (c) { return c.toUpperCase(); }); + } + + function _drawerStageIcon(state) { + return { passed: '\u2713', failed: '\u2715', running: '\u25b6', cancelled: '\u25fc', pending: '\u25cb', skipped: '\u2014' }[state] || '\u25cb'; + } + + // Row click → open drawer (ignore interactive elements) + document.addEventListener('click', function (e) { + if (e.target.closest('button, input, label, a, .drive-location')) return; + var row = e.target.closest('#drives-tbody tr[id^="drive-"]'); + if (!row) return; + openDrawer(row.id.replace('drive-', '')); + }); + + // Tab switching + document.addEventListener('click', function (e) { + var btn = e.target.closest('.drawer-tab'); + if (!btn) return; + _drawerTab = btn.dataset.tab; + document.querySelectorAll('.drawer-tab').forEach(function (b) { + b.classList.toggle('active', b.dataset.tab === _drawerTab); + }); + document.querySelectorAll('.drawer-panel').forEach(function (p) { + p.classList.toggle('active', p.id === 'drawer-panel-' + _drawerTab); + }); + }); + + // Close button + document.addEventListener('click', function (e) { + if (e.target.closest('#drawer-close-btn')) closeDrawer(); + }); + }()); diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 06ec62e..1f08320 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -71,4 +71,31 @@ + + + {% endblock %}