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 = '