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
+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