diff --git a/templates/admin.templ b/templates/admin.templ index b4f2abf9..48260890 100644 --- a/templates/admin.templ +++ b/templates/admin.templ @@ -97,9 +97,14 @@ templ Admin(user User, stats AdminStats) { @Icon("refresh", "h-5 w-5") Scanning Libraries - +
+ + +
diff --git a/templates/admin_templ.go b/templates/admin_templ.go index 77319af0..5770e748 100644 --- a/templates/admin_templ.go +++ b/templates/admin_templ.go @@ -183,7 +183,15 @@ func Admin(user User, stats AdminStats) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "Scanning Libraries
Overall Progress 0%
Starting scan...

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "

Overall Progress 0%
Starting scan...

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -199,7 +207,7 @@ func Admin(user User, stats AdminStats) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "Scan Complete!

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "Scan Complete!
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/web/src/admin.ts b/web/src/admin.ts index b4d74c7c..c1800153 100644 --- a/web/src/admin.ts +++ b/web/src/admin.ts @@ -80,8 +80,14 @@ function showScanProgress( ${libraryNames[jobId]} - - Pending... + + + Pending... + +
@@ -95,6 +101,13 @@ function showScanProgress( ) .join(""); + // The rows are TS-rendered (sanctioned scan-progress island); bind the + // cancel buttons here since inline handlers can't reach module scope. + list.querySelectorAll("[data-cancel-job]").forEach((btn) => { + btn.addEventListener("click", () => cancelScan(btn.dataset.cancelJob || "")); + }); + + setCancelAllVisible(true); pollScanProgress(jobIds, libraryNames); } @@ -106,9 +119,12 @@ function pollScanProgress( ): void { const token = localStorage.getItem("token"); const startTime = Date.now(); + const cancelledJobs = new Set(); + scanPollInterval = setInterval(async () => { let allComplete = true; let totalProgress = 0; + let progressJobs = 0; let totalFiles = 0; let totalNewItems = 0; let totalErrors = 0; @@ -124,12 +140,25 @@ function pollScanProgress( updateLibraryProgress(jobId, status); - totalProgress += status.progress || 0; totalFiles += status.files_scanned || 0; totalNewItems += status.new_items || 0; totalErrors += status.errors || 0; - if (status.status !== "completed" && status.status !== "failed") { + // Cancelled jobs are terminal and keep their last real + // progress; average only jobs that can still advance so the + // bar can reach 100%. + if (status.status === "cancelled") { + cancelledJobs.add(jobId); + } else { + totalProgress += status.progress || 0; + progressJobs++; + } + + if ( + status.status !== "completed" && + status.status !== "failed" && + status.status !== "cancelled" + ) { allComplete = false; } } @@ -139,7 +168,8 @@ function pollScanProgress( } // status.progress arrives 0-1 from the server; render as percent. - const overallProgress = Math.round((totalProgress / jobIds.length) * 100); + const overallProgress = + progressJobs > 0 ? Math.round((totalProgress / progressJobs) * 100) : 0; const progressBar = document.getElementById( "scan-progress-bar", ) as HTMLElement; @@ -158,12 +188,14 @@ function pollScanProgress( if (allComplete) { clearInterval(scanPollInterval!); + setCancelAllVisible(false); showScanResults( jobIds.length, totalFiles, totalNewItems, totalErrors, elapsed, + cancelledJobs.size, ); } }, 2000); @@ -187,9 +219,88 @@ function updateLibraryProgress(jobId: string, status: any): void { running: `Scanning... ${percent}%`, completed: `✓ Complete (${status.new_items || 0} items)`, failed: `✗ Failed`, + cancelled: `✗ Cancelled`, }; statusText.textContent = statusMessages[status.status] || status.status; } + + // Terminal jobs no longer need their per-row cancel button. + if ( + status.status === "completed" || + status.status === "failed" || + status.status === "cancelled" + ) { + document.querySelector(`[data-cancel-job="${jobId}"]`)?.remove(); + } +} + +// Cancel one scan job (POST /api/scanner/cancel/:jobId). The server +// aborts the walk at its next context check and skips the archive pass, +// so cancelling never archives unvisited files. +async function cancelScan(jobId: string): Promise { + const token = localStorage.getItem("token"); + if (!token) return; + + const btn = document.querySelector( + `[data-cancel-job="${jobId}"]`, + ); + if (btn) { + btn.disabled = true; + btn.textContent = "Cancelling..."; + } + + try { + const resp = await fetch(`/api/scanner/cancel/${jobId}`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.error || "Failed to cancel scan"); + } + showToast("Scan cancellation requested", "success"); + } catch (e) { + showToast( + e instanceof Error ? e.message : "Failed to cancel scan", + "error", + ); + if (btn) { + btn.disabled = false; + btn.textContent = "Cancel"; + } + } +} + +// Cancel every active scan (admin): resolves the server's active job +// list so it also covers jobs this page never started (e.g. started +// before a refresh), then cancels each one. +async function cancelAllScans(): Promise { + const token = localStorage.getItem("token"); + if (!token) return; + + try { + const resp = await fetch("/api/scanner/active", { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!resp.ok) { + throw new Error("Failed to list active scans"); + } + const data = await resp.json(); + const jobs = (data.jobs || []) as Array<{ job_id: string }>; + if (jobs.length === 0) return; + + await Promise.allSettled(jobs.map((job) => cancelScan(job.job_id))); + } catch (e) { + showToast( + e instanceof Error ? e.message : "Failed to cancel scans", + "error", + ); + } +} + +function setCancelAllVisible(visible: boolean): void { + const btn = document.getElementById("scan-cancel-all-btn"); + if (btn) btn.classList.toggle("hidden", !visible); } // On page load: if a scan is already running (started before this page @@ -247,6 +358,7 @@ function showScanResults( items: number, errors: number, elapsed: number, + cancelled: number, ): void { const resultsDiv = document.getElementById("scan-results") as HTMLElement; const contentDiv = document.getElementById( @@ -259,6 +371,7 @@ function showScanResults(

• ${libCount} librar${libCount === 1 ? "y" : "ies"} scanned

• ${files} files processed

• ${items} new items added

+ ${cancelled > 0 ? `

• ${cancelled} scan${cancelled === 1 ? "" : "s"} cancelled

` : ""} ${errors > 0 ? `

• ${errors} errors

` : ""}

Completed in ${elapsed} seconds

`; @@ -266,7 +379,12 @@ function showScanResults( resultsDiv.classList.remove("hidden"); const statusText = document.getElementById("scan-status") as HTMLElement; - if (statusText) statusText.textContent = "Scan complete!"; + if (statusText) { + statusText.textContent = + cancelled > 0 + ? "Scan finished (some scans were cancelled)" + : "Scan complete!"; + } } function hideScanProgress(): void { @@ -274,6 +392,7 @@ function hideScanProgress(): void { "scan-progress-container", ) as HTMLElement; if (container) container.classList.add("hidden"); + setCancelAllVisible(false); } async function loadWatchStatus(): Promise { @@ -523,6 +642,7 @@ Alpine.data("admin", () => ({ init() { bindDeleteLibraryModalClose(); }, + cancelAllScans, checkActiveScans, hideScanProgress, loadWatchStatus, diff --git a/web/src/header.ts b/web/src/header.ts index 512df731..0aa5bc0d 100644 --- a/web/src/header.ts +++ b/web/src/header.ts @@ -92,6 +92,11 @@ const initializeScanListener = function (this: any) { this.scanning = false; this.scanProgress = 0; break; + case "scan_cancelled": + // A cancelled scan is not an error: stop the spinner quietly. + this.scanning = false; + this.scanProgress = 0; + break; } }; diff --git a/web/src/setup.ts b/web/src/setup.ts index 8645fe0e..dae5b595 100644 --- a/web/src/setup.ts +++ b/web/src/setup.ts @@ -432,10 +432,15 @@ function initSetup(): void { running: `Scanning... ${Math.round((status.progress || 0) * 100)}%`, completed: `✓ Complete (${status.new_items || 0} items)`, failed: "✗ Failed", + cancelled: "✗ Cancelled", }; job.statusText = statusMessages[status.status] || status.status; - if (status.status !== "completed" && status.status !== "failed") { + if ( + status.status !== "completed" && + status.status !== "failed" && + status.status !== "cancelled" + ) { allComplete = false; } } else {