feat: add replay button on completed jobs in the jobs table

Shows a "Replay" button on non-active jobs that re-creates a new job
of the same type and library. Supports all replayable job types:
rebuild, full_rebuild, rescan, scan, thumbnail_rebuild,
thumbnail_regenerate, metadata_batch, metadata_refresh.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-24 21:18:12 +01:00
parent 2febab2c39
commit 6a838fb840
5 changed files with 66 additions and 2 deletions

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { apiFetch, IndexJobDto, rebuildIndex, rebuildThumbnails, regenerateThumbnails, startMetadataBatch, startMetadataRefresh } from "@/lib/api";
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
try {
const job = await apiFetch<IndexJobDto>(`/index/jobs/${id}`);
const libraryId = job.library_id ?? undefined;
switch (job.type) {
case "rebuild":
return NextResponse.json(await rebuildIndex(libraryId));
case "full_rebuild":
return NextResponse.json(await rebuildIndex(libraryId, true));
case "rescan":
return NextResponse.json(await rebuildIndex(libraryId, false, true));
case "scan":
return NextResponse.json(await rebuildIndex(libraryId));
case "thumbnail_rebuild":
return NextResponse.json(await rebuildThumbnails(libraryId));
case "thumbnail_regenerate":
return NextResponse.json(await regenerateThumbnails(libraryId));
case "metadata_batch":
if (!libraryId) return NextResponse.json({ error: "Library ID required for metadata batch" }, { status: 400 });
return NextResponse.json(await startMetadataBatch(libraryId));
case "metadata_refresh":
if (!libraryId) return NextResponse.json({ error: "Library ID required for metadata refresh" }, { status: 400 });
return NextResponse.json(await startMetadataRefresh(libraryId));
default:
return NextResponse.json({ error: `Cannot replay job type: ${job.type}` }, { status: 400 });
}
} catch (error) {
return NextResponse.json({ error: "Failed to replay job" }, { status: 500 });
}
}

View File

@@ -30,11 +30,14 @@ interface JobRowProps {
libraryName: string | undefined;
highlighted?: boolean;
onCancel: (id: string) => void;
onReplay: (id: string) => void;
formatDate: (date: string) => string;
formatDuration: (start: string, end: string | null) => string;
}
export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, formatDuration }: JobRowProps) {
const REPLAYABLE_TYPES = new Set(["rebuild", "full_rebuild", "rescan", "scan", "thumbnail_rebuild", "thumbnail_regenerate", "metadata_batch", "metadata_refresh"]);
export function JobRow({ job, libraryName, highlighted, onCancel, onReplay, formatDate, formatDuration }: JobRowProps) {
const { t } = useTranslation();
const isActive = job.status === "running" || job.status === "pending" || job.status === "extracting_pages" || job.status === "generating_thumbnails";
const [showProgress, setShowProgress] = useState(highlighted || isActive);
@@ -213,7 +216,7 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
</svg>
{t("jobRow.view")}
</Link>
{(job.status === "pending" || job.status === "running" || job.status === "extracting_pages" || job.status === "generating_thumbnails") && (
{isActive && (
<Button
variant="danger"
size="xs"
@@ -225,6 +228,18 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
{t("common.cancel")}
</Button>
)}
{!isActive && REPLAYABLE_TYPES.has(job.type) && (
<Button
variant="secondary"
size="xs"
onClick={() => onReplay(job.id)}
>
<svg className="w-3.5 h-3.5 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{t("jobRow.replay")}
</Button>
)}
</div>
</td>
</tr>

View File

@@ -95,6 +95,14 @@ export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListPro
}
};
const handleReplay = async (id: string) => {
const response = await fetch(`/api/jobs/${id}/replay`, { method: "POST" });
if (!response.ok) {
const data = await response.json().catch(() => ({}));
console.error("Failed to replay job:", data?.error ?? response.status);
}
};
return (
<div className="bg-card rounded-xl shadow-sm border border-border/60 overflow-hidden">
<div className="overflow-x-auto">
@@ -119,6 +127,7 @@ export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListPro
libraryName={job.library_id ? libraries.get(job.library_id) : undefined}
highlighted={job.id === highlightJobId}
onCancel={handleCancel}
onReplay={handleReplay}
formatDate={formatDate}
formatDuration={formatDuration}
/>