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:
John O'Keefe committed 2026-10-27 13:22:54 -04:00
1 parent 0f1d0abbb0
commit 248bb7816d
7 files changed
+579 -248

No files matched your search

+117
View File
@@ -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();
}
},
}));