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.
118 lines
3.6 KiB
TypeScript
118 lines
3.6 KiB
TypeScript
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();
|
|
}
|
|
},
|
|
}));
|