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:
parent
b73b5251ae
commit
c0f9098779
4 changed files with 653 additions and 0 deletions
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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, '&').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();
|
||||||
|
});
|
||||||
|
|
||||||
}());
|
}());
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue