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:
38
apps/backoffice/app/api/jobs/[id]/replay/route.ts
Normal file
38
apps/backoffice/app/api/jobs/[id]/replay/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user