import { notFound } from "next/navigation"; import Link from "next/link"; import { apiFetch, getMetadataBatchReport, getMetadataBatchResults, MetadataBatchReportDto, MetadataBatchResultDto } from "../../../lib/api"; import { Card, CardHeader, CardTitle, CardDescription, CardContent, StatusBadge, JobTypeBadge, StatBox, ProgressBar } from "../../components/ui"; import { getServerTranslations } from "../../../lib/i18n/server"; interface JobDetailPageProps { params: Promise<{ id: string }>; } interface JobDetails { id: string; library_id: string | null; book_id: string | null; type: string; status: string; created_at: string; started_at: string | null; finished_at: string | null; phase2_started_at: string | null; generating_thumbnails_started_at: string | null; current_file: string | null; progress_percent: number | null; processed_files: number | null; total_files: number | null; stats_json: { scanned_files: number; indexed_files: number; removed_files: number; errors: number; warnings: number; } | null; error_opt: string | null; } interface JobError { id: string; file_path: string; error_message: string; created_at: string; } async function getJobDetails(jobId: string): Promise { try { return await apiFetch(`/index/jobs/${jobId}`); } catch { return null; } } async function getJobErrors(jobId: string): Promise { try { return await apiFetch(`/index/jobs/${jobId}/errors`); } catch { return []; } } function formatDuration(start: string, end: string | null): string { const startDate = new Date(start); const endDate = end ? new Date(end) : new Date(); const diff = endDate.getTime() - startDate.getTime(); if (diff < 60000) return `${Math.floor(diff / 1000)}s`; if (diff < 3600000) return `${Math.floor(diff / 60000)}m ${Math.floor((diff % 60000) / 1000)}s`; return `${Math.floor(diff / 3600000)}h ${Math.floor((diff % 3600000) / 60000)}m`; } function formatSpeed(count: number, durationMs: number): string { if (durationMs === 0 || count === 0) return "-"; return `${(count / (durationMs / 1000)).toFixed(1)}/s`; } export default async function JobDetailPage({ params }: JobDetailPageProps) { const { id } = await params; const [job, errors] = await Promise.all([ getJobDetails(id), getJobErrors(id), ]); if (!job) { notFound(); } const { t, locale } = await getServerTranslations(); const JOB_TYPE_INFO: Record = { rebuild: { label: t("jobType.rebuildLabel"), description: t("jobType.rebuildDesc"), isThumbnailOnly: false, }, full_rebuild: { label: t("jobType.full_rebuildLabel"), description: t("jobType.full_rebuildDesc"), isThumbnailOnly: false, }, thumbnail_rebuild: { label: t("jobType.thumbnail_rebuildLabel"), description: t("jobType.thumbnail_rebuildDesc"), isThumbnailOnly: true, }, thumbnail_regenerate: { label: t("jobType.thumbnail_regenerateLabel"), description: t("jobType.thumbnail_regenerateDesc"), isThumbnailOnly: true, }, cbr_to_cbz: { label: t("jobType.cbr_to_cbzLabel"), description: t("jobType.cbr_to_cbzDesc"), isThumbnailOnly: false, }, metadata_batch: { label: t("jobType.metadata_batchLabel"), description: t("jobType.metadata_batchDesc"), isThumbnailOnly: false, }, }; const isMetadataBatch = job.type === "metadata_batch"; // Fetch batch report & results for metadata_batch jobs let batchReport: MetadataBatchReportDto | null = null; let batchResults: MetadataBatchResultDto[] = []; if (isMetadataBatch) { [batchReport, batchResults] = await Promise.all([ getMetadataBatchReport(id).catch(() => null), getMetadataBatchResults(id).catch(() => []), ]); } const typeInfo = JOB_TYPE_INFO[job.type] ?? { label: job.type, description: null, isThumbnailOnly: false, }; const durationMs = job.started_at ? new Date(job.finished_at || new Date()).getTime() - new Date(job.started_at).getTime() : 0; const isCompleted = job.status === "success"; const isFailed = job.status === "failed"; const isCancelled = job.status === "cancelled"; const isExtractingPages = job.status === "extracting_pages"; const isThumbnailPhase = job.status === "generating_thumbnails"; const isPhase2 = isExtractingPages || isThumbnailPhase; const { isThumbnailOnly } = typeInfo; // Which label to use for the progress card const progressTitle = isMetadataBatch ? t("jobDetail.metadataSearch") : isThumbnailOnly ? t("jobType.thumbnail_rebuild") : isExtractingPages ? t("jobDetail.phase2a") : isThumbnailPhase ? t("jobDetail.phase2b") : t("jobDetail.phase1"); const progressDescription = isMetadataBatch ? t("jobDetail.metadataSearchDesc") : isThumbnailOnly ? undefined : isExtractingPages ? t("jobDetail.phase2aDesc") : isThumbnailPhase ? t("jobDetail.phase2bDesc") : t("jobDetail.phase1Desc"); // Speed metric: thumbnail count for thumbnail jobs, scanned files for index jobs const speedCount = isThumbnailOnly ? (job.processed_files ?? 0) : (job.stats_json?.scanned_files ?? 0); const showProgressCard = (isCompleted || isFailed || job.status === "running" || isPhase2) && (job.total_files != null || !!job.current_file); return ( <>
{t("jobDetail.backToJobs")}

{t("jobDetail.title")}

{/* Summary banner — completed */} {isCompleted && job.started_at && (
{t("jobDetail.completedIn", { duration: formatDuration(job.started_at, job.finished_at) })} {isMetadataBatch && batchReport && ( — {batchReport.auto_matched} {t("jobDetail.autoMatched").toLowerCase()}, {batchReport.already_linked} {t("jobDetail.alreadyLinked").toLowerCase()}, {batchReport.no_results} {t("jobDetail.noResults").toLowerCase()}, {batchReport.errors} {t("jobDetail.errors").toLowerCase()} )} {!isMetadataBatch && job.stats_json && ( — {job.stats_json.scanned_files} {t("jobDetail.scanned").toLowerCase()}, {job.stats_json.indexed_files} {t("jobDetail.indexed").toLowerCase()} {job.stats_json.removed_files > 0 && `, ${job.stats_json.removed_files} ${t("jobDetail.removed").toLowerCase()}`} {(job.stats_json.warnings ?? 0) > 0 && `, ${job.stats_json.warnings} ${t("jobDetail.warnings").toLowerCase()}`} {job.stats_json.errors > 0 && `, ${job.stats_json.errors} ${t("jobDetail.errors").toLowerCase()}`} {job.total_files != null && job.total_files > 0 && `, ${job.total_files} ${t("jobType.thumbnail_rebuild").toLowerCase()}`} )} {!isMetadataBatch && !job.stats_json && isThumbnailOnly && job.total_files != null && ( — {job.processed_files ?? job.total_files} {t("jobDetail.generated").toLowerCase()} )}
)} {/* Summary banner — failed */} {isFailed && (
{t("jobDetail.jobFailed")} {job.started_at && ( {t("jobDetail.failedAfter", { duration: formatDuration(job.started_at, job.finished_at) })} )} {job.error_opt && (

{job.error_opt}

)}
)} {/* Summary banner — cancelled */} {isCancelled && (
{t("jobDetail.cancelled")} {job.started_at && ( {t("jobDetail.failedAfter", { duration: formatDuration(job.started_at, job.finished_at) })} )}
)}
{/* Overview Card */} {t("jobDetail.overview")} {typeInfo.description && ( {typeInfo.description} )}
ID {job.id}
{t("jobsList.type")}
{typeInfo.label}
{t("jobsList.status")}
{t("jobDetail.library")} {job.library_id || t("jobDetail.allLibraries")}
{job.book_id && (
{t("jobDetail.book")} {job.book_id.slice(0, 8)}…
)} {job.started_at && (
{t("jobsList.duration")} {formatDuration(job.started_at, job.finished_at)}
)}
{/* Timeline Card */} {t("jobDetail.timeline")}
{/* Vertical line */}
{/* Created */}
{t("jobDetail.created")}

{new Date(job.created_at).toLocaleString(locale)}

{/* Phase 1 start — for index jobs that have two phases */} {job.started_at && job.phase2_started_at && (
{t("jobDetail.phase1")}

{new Date(job.started_at).toLocaleString(locale)}

{t("jobDetail.duration", { duration: formatDuration(job.started_at, job.phase2_started_at) })} {job.stats_json && ( · {job.stats_json.scanned_files} {t("jobDetail.scanned").toLowerCase()}, {job.stats_json.indexed_files} {t("jobDetail.indexed").toLowerCase()} {job.stats_json.removed_files > 0 && `, ${job.stats_json.removed_files} ${t("jobDetail.removed").toLowerCase()}`} {(job.stats_json.warnings ?? 0) > 0 && `, ${job.stats_json.warnings} ${t("jobDetail.warnings").toLowerCase()}`} )}

)} {/* Phase 2a — Extracting pages (index jobs with phase2) */} {job.phase2_started_at && !isThumbnailOnly && (
{t("jobDetail.phase2a")}

{new Date(job.phase2_started_at).toLocaleString(locale)}

{t("jobDetail.duration", { duration: formatDuration(job.phase2_started_at, job.generating_thumbnails_started_at ?? job.finished_at ?? null) })} {!job.generating_thumbnails_started_at && !job.finished_at && isExtractingPages && ( · {t("jobDetail.inProgress")} )}

)} {/* Phase 2b — Generating thumbnails */} {(job.generating_thumbnails_started_at || (job.phase2_started_at && isThumbnailOnly)) && (
{isThumbnailOnly ? t("jobType.thumbnail_rebuild") : t("jobDetail.phase2b")}

{(job.generating_thumbnails_started_at ? new Date(job.generating_thumbnails_started_at) : job.phase2_started_at ? new Date(job.phase2_started_at) : null)?.toLocaleString(locale)}

{(job.generating_thumbnails_started_at || job.finished_at) && (

{t("jobDetail.duration", { duration: formatDuration( job.generating_thumbnails_started_at ?? job.phase2_started_at!, job.finished_at ?? null ) })} {job.total_files != null && job.total_files > 0 && ( · {job.processed_files ?? job.total_files} {t("jobType.thumbnail_rebuild").toLowerCase()} )}

)} {!job.finished_at && isThumbnailPhase && ( {t("jobDetail.inProgress")} )}
)} {/* Started — for jobs without phase2 (cbr_to_cbz, or no phase yet) */} {job.started_at && !job.phase2_started_at && (
{t("jobDetail.started")}

{new Date(job.started_at).toLocaleString(locale)}

)} {/* Pending — not started yet */} {!job.started_at && (
{t("jobDetail.pendingStart")}
)} {/* Finished */} {job.finished_at && (
{isCompleted ? t("jobDetail.finished") : isFailed ? t("jobDetail.failed") : t("jobDetail.cancelled")}

{new Date(job.finished_at).toLocaleString(locale)}

)}
{/* Progress Card */} {showProgressCard && ( {progressTitle} {progressDescription && {progressDescription}} {job.total_files != null && job.total_files > 0 && ( <>
)} {job.current_file && (
{t("jobDetail.currentFile")} {job.current_file}
)}
)} {/* Index Statistics — index jobs only */} {job.stats_json && !isThumbnailOnly && !isMetadataBatch && ( {t("jobDetail.indexStats")} {job.started_at && ( {formatDuration(job.started_at, job.finished_at)} {speedCount > 0 && ` · ${formatSpeed(speedCount, durationMs)} scan rate`} )}
0 ? "warning" : "default"} /> 0 ? "error" : "default"} />
)} {/* Thumbnail statistics — thumbnail-only jobs, completed */} {isThumbnailOnly && isCompleted && job.total_files != null && ( {t("jobDetail.thumbnailStats")} {job.started_at && ( {formatDuration(job.started_at, job.finished_at)} {speedCount > 0 && ` · ${formatSpeed(speedCount, durationMs)} thumbnails/s`} )}
)} {/* Metadata batch report */} {isMetadataBatch && batchReport && ( {t("jobDetail.batchReport")} {t("jobDetail.seriesAnalyzed", { count: String(batchReport.total_series) })}
0 ? "error" : "default"} />
)} {/* Metadata batch results */} {isMetadataBatch && batchResults.length > 0 && ( {t("jobDetail.resultsBySeries")} {t("jobDetail.seriesProcessed", { count: String(batchResults.length) })} {batchResults.map((r) => (
{r.series_name} {r.status === "auto_matched" ? t("jobDetail.autoMatched") : r.status === "already_linked" ? t("jobDetail.alreadyLinked") : r.status === "no_results" ? t("jobDetail.noResults") : r.status === "too_many_results" ? t("jobDetail.tooManyResults") : r.status === "low_confidence" ? t("jobDetail.lowConfidence") : r.status === "error" ? t("common.error") : r.status}
{r.provider_used && ( {r.provider_used}{r.fallback_used ? ` ${t("metadata.fallbackUsed")}` : ""} )} {r.candidates_count > 0 && ( {r.candidates_count} {t("jobDetail.candidates", { plural: r.candidates_count > 1 ? "s" : "" })} )} {r.best_confidence != null && ( {Math.round(r.best_confidence * 100)}% {t("jobDetail.confidence")} )}
{r.best_candidate_json && (

{t("jobDetail.match", { title: (r.best_candidate_json as { title?: string }).title || r.best_candidate_json.toString() })}

)} {r.error_message && (

{r.error_message}

)}
))}
)} {/* File errors */} {errors.length > 0 && ( {t("jobDetail.fileErrors", { count: String(errors.length) })} {t("jobDetail.fileErrorsDesc")} {errors.map((error) => (
{error.file_path}

{error.error_message}

{new Date(error.created_at).toLocaleString(locale)}
))}
)}
); }