feat(issues): Processing Issues page retry UI with a sequential client queue
Per issue card: a Retry button — error-severity issues previously had no action at all (only warning/info got Dismiss). Right of the Errors stat card: Retry all, which builds a queue of every issue currently on the page and runs them strictly one at a time through the per-issue retry endpoint. The browser is the queue — resource use is bounded to a single book in flight, and no batch endpoint exists to maintain. While the queue runs, the button shows an n/m counter and cards tint via client-local state classes (the sanctioned island shape; the cards themselves stay templ-rendered). After the queue drains, the page swaps in the new /admin/libraries/:id/issues/content templ partial — the unlinked-books htmx.ajax pattern with a reload fallback — so resolved issues drop off server-side while failures remain listed, with a summary toast and per-item error toasts carrying the real failure reasons. CSP discipline holds throughout: Alpine @click component methods, no inline handlers, no js:-prefixed hx-vals.
This commit is contained in:
1 parent
0f1d0abbb0
commit
248bb7816d
7 files changed
+579
-248
No files matched your search
@@ -22,6 +22,7 @@ import "./library-switcher";
|
||||
import "./linking";
|
||||
import "./login";
|
||||
import "./password_validation";
|
||||
import "./processing_issues";
|
||||
import "./profile";
|
||||
import "./profile-modal";
|
||||
import "./register";
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Alpine } from "./alpine";
|
||||
import { showToast } from "./toast";
|
||||
import { getToken } from "./storage";
|
||||
|
||||
// Processing Issues retry queue (2026-10). The "Retry all" button is a
|
||||
// CLIENT-side queue over the single-issue retry endpoint: one book in
|
||||
// flight at a time through the individual scanner (RescanMediaItem /
|
||||
// single-file import), never a whole-library rescan — that resource
|
||||
// trade is the whole point. Issue cards are templ-rendered; the queue
|
||||
// counter and per-card tint below are pure client-local state (the
|
||||
// sanctioned island shape; after the queue drains the templ partial
|
||||
// swap restores server truth).
|
||||
|
||||
interface RetryResult {
|
||||
ok: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function retryIssue(libraryId: string, issueId: string): Promise<RetryResult> {
|
||||
const token = getToken();
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(`/api/libraries/${libraryId}/issues/${issueId}/retry`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
} catch (err) {
|
||||
return { ok: false, error: (err as Error).message };
|
||||
}
|
||||
const body = (await resp.json().catch(() => ({}))) as {
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
};
|
||||
return { ok: resp.ok, message: body.message, error: body.error };
|
||||
}
|
||||
|
||||
Alpine.data("processingIssues", () => ({
|
||||
queueRunning: false,
|
||||
queueDone: 0,
|
||||
queueTotal: 0,
|
||||
libraryId: "",
|
||||
|
||||
init(): void {
|
||||
this.libraryId = (this.$el as HTMLElement).dataset.libraryId || "";
|
||||
},
|
||||
|
||||
issueIdsOnPage(): string[] {
|
||||
return Array.from(document.querySelectorAll<HTMLElement>("#issues-content [data-issue-id]"))
|
||||
.map((el) => el.dataset.issueId || "")
|
||||
.filter((id) => id !== "");
|
||||
},
|
||||
|
||||
async retryOne(issueId: string): Promise<void> {
|
||||
await this.runQueue([issueId]);
|
||||
},
|
||||
|
||||
async retryAll(): Promise<void> {
|
||||
await this.runQueue(this.issueIdsOnPage());
|
||||
},
|
||||
|
||||
async runQueue(issueIds: string[]): Promise<void> {
|
||||
if (this.queueRunning || issueIds.length === 0 || !this.libraryId) return;
|
||||
this.queueRunning = true;
|
||||
this.queueDone = 0;
|
||||
this.queueTotal = issueIds.length;
|
||||
let failed = 0;
|
||||
|
||||
for (const issueId of issueIds) {
|
||||
const card = document.querySelector(
|
||||
`#issues-content [data-issue-id="${CSS.escape(issueId)}"]`,
|
||||
);
|
||||
card?.classList.add("issue-retrying");
|
||||
const result = await retryIssue(this.libraryId, issueId);
|
||||
card?.classList.remove("issue-retrying");
|
||||
if (result.ok) {
|
||||
card?.classList.add("issue-retry-done");
|
||||
} else {
|
||||
failed++;
|
||||
card?.classList.add("issue-retry-failed");
|
||||
showToast(result.error || `Retry failed for ${issueId.slice(0, 8)}`, "error");
|
||||
}
|
||||
this.queueDone++;
|
||||
}
|
||||
|
||||
this.queueRunning = false;
|
||||
if (failed === 0) {
|
||||
showToast(
|
||||
`Retried ${issueIds.length} item${issueIds.length === 1 ? "" : "s"} successfully`,
|
||||
"success",
|
||||
);
|
||||
} else {
|
||||
showToast(
|
||||
`Retried ${issueIds.length - failed} of ${issueIds.length}; ${failed} still failing`,
|
||||
"warning",
|
||||
);
|
||||
}
|
||||
this.refreshList();
|
||||
},
|
||||
|
||||
// Server truth after the queue drains: resolved issues drop off the
|
||||
// ledger. Swap the templ partial (rendering ownership: the list is
|
||||
// server data), falling back to a reload when htmx is unavailable.
|
||||
refreshList(): void {
|
||||
if (window.htmx) {
|
||||
window.htmx
|
||||
.ajax("GET", `/admin/libraries/${this.libraryId}/issues/content`, {
|
||||
target: "#issues-content",
|
||||
swap: "outerHTML",
|
||||
})
|
||||
.catch(() => window.location.reload());
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
},
|
||||
}));
|
||||
Reference in new issue
Block a user