fix(web): scan progress — correct percentages, no layout shift, refresh-survivable

Bug: the admin 'Scanning Libraries' widget fed the server's 0-1
progress straight into percent strings and bar widths, so the overall
and per-library percentages capped at '1%' and the bars never filled.
Both now scale by 100 (matching the setup page, which was already
correct).

Bug: scan state only reached clients via websocket events, so a page
opened (or refreshed) mid-scan showed nothing until the next tick —
and the admin widget never re-attached at all. The worker now exposes
active jobs (with their library_id so rows keep their names), served
as GET /api/scanner/active (admin, re-attaches the widget + polling)
and GET /api/scanner/active-summary (any user, restores the sidebar
indicator on load). Idle hides both, exactly as before.

Annoyance: the sidebar's 'Scanning… N%' was a block element between
the logo and the nav, shoving every menu entry down while a scan ran.
The percent now lives inside the fixed-height logo row next to the
spinner (spinner last, so growing digits extend leftward); nothing
below it moves.
This commit is contained in:
John O'Keefe committed 2026-10-15 08:29:04 -04:00
1 parent 9bc149f3d9
commit fd6f0e3353
11 files changed
+245 -38

No files matched your search

+59 -3
View File
@@ -138,7 +138,8 @@ function pollScanProgress(
}
}
const overallProgress = Math.round(totalProgress / jobIds.length);
// status.progress arrives 0-1 from the server; render as percent.
const overallProgress = Math.round((totalProgress / jobIds.length) * 100);
const progressBar = document.getElementById(
"scan-progress-bar",
) as HTMLElement;
@@ -172,14 +173,18 @@ function updateLibraryProgress(jobId: string, status: any): void {
const bar = document.getElementById(`bar-${jobId}`) as HTMLElement;
const statusText = document.getElementById(`status-${jobId}`) as HTMLElement;
// status.progress arrives 0-1 from the server; render as percent,
// otherwise the bar sits at sub-1% forever and never fills.
const percent = Math.round((status.progress || 0) * 100);
if (bar) {
bar.style.width = (status.progress || 0) + "%";
bar.style.width = percent + "%";
}
if (statusText) {
const statusMessages: Record<string, string> = {
pending: "Pending...",
running: `Scanning... ${status.progress || 0}%`,
running: `Scanning... ${percent}%`,
completed: `✓ Complete (${status.new_items || 0} items)`,
failed: `✗ Failed`,
};
@@ -187,6 +192,55 @@ function updateLibraryProgress(jobId: string, status: any): void {
}
}
// On page load: if a scan is already running (started before this page
// was opened, or the page was refreshed mid-scan), surface the progress
// widget and re-attach polling. Websocket start-events only reach pages
// that were already open when the scan began.
async function checkActiveScans(): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
try {
const resp = await fetch("/api/scanner/active", {
headers: { Authorization: `Bearer ${token}` },
});
if (!resp.ok) return;
const data = await resp.json();
const jobs = (data.jobs || []) as Array<{
job_id: string;
library_id?: string;
}>;
if (jobs.length === 0) return;
// Label rows by library name where we can resolve one.
const libraryNames: Record<string, string> = {};
try {
const libsResp = await fetch("/api/libraries", {
headers: { Authorization: `Bearer ${token}` },
});
if (libsResp.ok) {
const libsData = await libsResp.json();
for (const lib of libsData.data || []) {
libraryNames[lib.id] = lib.name;
}
}
} catch {
// Names fall back to ids below.
}
const jobIds = jobs.map((job) => job.job_id);
const names: Record<string, string> = {};
for (const job of jobs) {
names[job.job_id] =
(job.library_id && libraryNames[job.library_id]) ||
`Library ${(job.library_id || job.job_id).slice(0, 8)}`;
}
showScanProgress(jobIds, names);
pollScanProgress(jobIds, names);
} catch (error) {
console.error("Failed to check for active scans:", error);
}
}
function showScanResults(
libCount: number,
files: number,
@@ -389,6 +443,7 @@ document.querySelectorAll<HTMLElement>("[data-merge-source]").forEach((el) => {
});
export {
checkActiveScans,
hideScanProgress,
loadWatchStatus,
scanAllLibraries,
@@ -396,6 +451,7 @@ export {
};
Alpine.data("admin", () => ({
checkActiveScans,
hideScanProgress,
loadWatchStatus,
scanAllLibraries,
+23
View File
@@ -41,6 +41,29 @@ const restoreLibrarySelection = (): void => {
};
const initializeScanListener = function (this: any) {
// Restore state on page load: a scan that started before this page
// was opened (or a refresh mid-scan) emits no websocket event until
// its next progress tick, so ask the server directly first.
const restoreActiveScan = async () => {
try {
const token = localStorage.getItem("token");
if (!token) return;
const resp = await fetch("/api/scanner/active-summary", {
headers: { Authorization: `Bearer ${token}` },
});
if (!resp.ok) return;
const data = await resp.json();
if (data.scanning) {
this.scanning = true;
this.scanProgress = Math.round((data.progress || 0) * 100);
}
} catch {
// Offline or endpoint unavailable — websocket events still drive
// the state once they arrive.
}
};
restoreActiveScan();
const handler = (message: any) => {
switch (message.type) {
case "scan_progress":