feat(admin): cancel buttons for in-flight scans + cancelled state UI

- per-row Cancel button in the scan-progress card; listeners are bound
  after the TS island renders its rows (module scope has no inline
  handlers), and the button is removed once its job turns terminal
- cancel-all icon button in the card header: resolves the server's
  active job list first, so it also covers scans this page never
  started (e.g. started before a refresh), then cancels each one
- polling treats cancelled as terminal, averages overall progress
  only over jobs that can still advance (so the bar can reach 100%),
  and the results panel reports cancelled scans instead of claiming
  "Scan Complete!"
- sidebar spinner resets on the scan_cancelled websocket event (same
  quiet handling as scan_error)
- setup wizard shows "Cancelled" as a terminal status in its poller

No new innerHTML assignment sites - rendering-ownership baseline
unchanged.
This commit is contained in:
John O'Keefe committed 2026-10-18 18:02:34 -04:00
1 parent 5b81e19f2f
commit e1153b2c4b
5 files changed
+156 -13

No files matched your search

+8 -3
View File
@@ -97,9 +97,14 @@ templ Admin(user User, stats AdminStats) {
@Icon("refresh", "h-5 w-5")
Scanning Libraries
</h3>
<button @click="hideScanProgress()" class="icon-btn" aria-label="Close">
@Icon("close", "h-5 w-5")
</button>
<div class="flex items-center gap-2">
<button id="scan-cancel-all-btn" @click="cancelAllScans()" class="hidden icon-btn" aria-label="Cancel all scans" title="Cancel all scans">
@Icon("x-circle", "h-5 w-5")
</button>
<button @click="hideScanProgress()" class="icon-btn" aria-label="Close">
@Icon("close", "h-5 w-5")
</button>
</div>
</div>
<div class="mb-4">
<div class="flex justify-between text-sm mb-2">
+11 -3
View File
@@ -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</h3><button @click=\"hideScanProgress()\" class=\"icon-btn\" aria-label=\"Close\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "Scanning Libraries</h3><div class=\"flex items-center gap-2\"><button id=\"scan-cancel-all-btn\" @click=\"cancelAllScans()\" class=\"hidden icon-btn\" aria-label=\"Cancel all scans\" title=\"Cancel all scans\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("x-circle", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</button> <button @click=\"hideScanProgress()\" class=\"icon-btn\" aria-label=\"Close\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -191,7 +199,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, 18, "</button></div><div class=\"mb-4\"><div class=\"flex justify-between text-sm mb-2\"><span style=\"color: var(--text-secondary)\">Overall Progress</span> <span id=\"scan-progress-text\" style=\"color: var(--text-primary)\">0%</span></div><div class=\"w-full rounded-full h-3\" style=\"background-color: var(--surface-hover);\"><div id=\"scan-progress-bar\" class=\"h-3 rounded-full transition-all duration-500\" style=\"width: 0%; background-color: var(--accent);\"></div></div><div id=\"scan-status\" class=\"text-sm mt-2\" style=\"color: var(--text-secondary)\">Starting scan...</div></div><div id=\"library-progress-list\" class=\"space-y-3\"></div><div id=\"scan-results\" class=\"hidden mt-6 p-4 rounded-xl border\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><h4 class=\"font-semibold mb-2 flex items-center gap-2\" style=\"color: var(--status-success);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</button></div></div><div class=\"mb-4\"><div class=\"flex justify-between text-sm mb-2\"><span style=\"color: var(--text-secondary)\">Overall Progress</span> <span id=\"scan-progress-text\" style=\"color: var(--text-primary)\">0%</span></div><div class=\"w-full rounded-full h-3\" style=\"background-color: var(--surface-hover);\"><div id=\"scan-progress-bar\" class=\"h-3 rounded-full transition-all duration-500\" style=\"width: 0%; background-color: var(--accent);\"></div></div><div id=\"scan-status\" class=\"text-sm mt-2\" style=\"color: var(--text-secondary)\">Starting scan...</div></div><div id=\"library-progress-list\" class=\"space-y-3\"></div><div id=\"scan-results\" class=\"hidden mt-6 p-4 rounded-xl border\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><h4 class=\"font-semibold mb-2 flex items-center gap-2\" style=\"color: var(--status-success);\">")
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!</h4><div id=\"scan-results-content\" style=\"color: var(--text-secondary)\"></div><div class=\"mt-4 flex gap-2\"><button @click=\"window.location.reload()\" class=\"btn btn-primary\">Refresh to View Books</button> <button @click=\"hideScanProgress()\" class=\"btn btn-secondary\">Dismiss</button></div></div></div></div></main></body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "Scan Complete!</h4><div id=\"scan-results-content\" style=\"color: var(--text-secondary)\"></div><div class=\"mt-4 flex gap-2\"><button @click=\"window.location.reload()\" class=\"btn btn-primary\">Refresh to View Books</button> <button @click=\"hideScanProgress()\" class=\"btn btn-secondary\">Dismiss</button></div></div></div></div></main></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+126 -6
View File
@@ -80,8 +80,14 @@ function showScanProgress(
<span class="font-medium" style="color: var(--text-primary)">
${libraryNames[jobId]}
</span>
<span id="status-${jobId}" class="text-sm" style="color: var(--text-secondary)">
Pending...
<span class="flex items-center gap-2">
<span id="status-${jobId}" class="text-sm" style="color: var(--text-secondary)">
Pending...
</span>
<button data-cancel-job="${jobId}"
class="text-xs px-2 py-0.5 rounded border shrink-0"
style="color: var(--text-secondary); border-color: var(--border);"
title="Cancel this scan">Cancel</button>
</span>
</div>
<div class="w-full bg-gray-700 rounded-full h-2">
@@ -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<HTMLButtonElement>("[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<string>();
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<void> {
const token = localStorage.getItem("token");
if (!token) return;
const btn = document.querySelector<HTMLButtonElement>(
`[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<void> {
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(
<p>• ${libCount} librar${libCount === 1 ? "y" : "ies"} scanned</p>
<p>• ${files} files processed</p>
<p>• ${items} new items added</p>
${cancelled > 0 ? `<p>• ${cancelled} scan${cancelled === 1 ? "" : "s"} cancelled</p>` : ""}
${errors > 0 ? `<p style="color: var(--accent);">• ${errors} errors</p>` : ""}
<p style="color: var(--text-secondary)">Completed in ${elapsed} seconds</p>
`;
@@ -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<void> {
@@ -523,6 +642,7 @@ Alpine.data("admin", () => ({
init() {
bindDeleteLibraryModalClose();
},
cancelAllScans,
checkActiveScans,
hideScanProgress,
loadWatchStatus,
+5
View File
@@ -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;
}
};
+6 -1
View File
@@ -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 {