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:
1 parent
5b81e19f2f
commit
e1153b2c4b
5 files changed
+156
-13
No files matched your search
@@ -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">
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user