import { notFound } from "next/navigation"; import Link from "next/link"; import { apiFetch } from "../../../lib/api"; import { Card, CardHeader, CardTitle, CardDescription, CardContent, StatusBadge, JobTypeBadge, StatBox, ProgressBar } from "../../components/ui"; 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; } const JOB_TYPE_INFO: Record = { rebuild: { label: "Incremental index", description: "Scans for new/modified files, analyzes them and generates missing thumbnails.", isThumbnailOnly: false, }, full_rebuild: { label: "Full re-index", description: "Clears all existing data then performs a complete re-scan, re-analysis and thumbnail generation.", isThumbnailOnly: false, }, thumbnail_rebuild: { label: "Thumbnail rebuild", description: "Generates thumbnails only for books that are missing one. Existing thumbnails are preserved.", isThumbnailOnly: true, }, thumbnail_regenerate: { label: "Thumbnail regeneration", description: "Regenerates all thumbnails from scratch, replacing existing ones.", isThumbnailOnly: true, }, cbr_to_cbz: { label: "CBR → CBZ conversion", description: "Converts a CBR archive to the open CBZ format.", isThumbnailOnly: false, }, }; 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 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 = isThumbnailOnly ? "Thumbnails" : isExtractingPages ? "Phase 2 — Extracting pages" : isThumbnailPhase ? "Phase 2 — Thumbnails" : "Phase 1 — Discovery"; const progressDescription = isThumbnailOnly ? undefined : isExtractingPages ? "Extracting first page from each archive (page count + raw image)" : isThumbnailPhase ? "Generating thumbnails for the analyzed books" : "Scanning and indexing files in the library"; // 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 ( <>
Back to jobs

Job Details

{/* Summary banner — completed */} {isCompleted && job.started_at && (
Completed in {formatDuration(job.started_at, job.finished_at)} {job.stats_json && ( — {job.stats_json.scanned_files} scanned, {job.stats_json.indexed_files} indexed {job.stats_json.removed_files > 0 && `, ${job.stats_json.removed_files} removed`} {(job.stats_json.warnings ?? 0) > 0 && `, ${job.stats_json.warnings} warnings`} {job.stats_json.errors > 0 && `, ${job.stats_json.errors} errors`} {job.total_files != null && job.total_files > 0 && `, ${job.total_files} thumbnails`} )} {!job.stats_json && isThumbnailOnly && job.total_files != null && ( — {job.processed_files ?? job.total_files} thumbnails generated )}
)} {/* Summary banner — failed */} {isFailed && (
Job failed {job.started_at && ( after {formatDuration(job.started_at, job.finished_at)} )} {job.error_opt && (

{job.error_opt}

)}
)} {/* Summary banner — cancelled */} {isCancelled && (
Cancelled {job.started_at && ( after {formatDuration(job.started_at, job.finished_at)} )}
)}
{/* Overview Card */} Overview {typeInfo.description && ( {typeInfo.description} )}
ID {job.id}
Type
{typeInfo.label}
Status
Library {job.library_id || "All libraries"}
{job.book_id && (
Book {job.book_id.slice(0, 8)}…
)} {job.started_at && (
Duration {formatDuration(job.started_at, job.finished_at)}
)}
{/* Timeline Card */} Timeline
{/* Vertical line */}
{/* Created */}
Created

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

{/* Phase 1 start — for index jobs that have two phases */} {job.started_at && job.phase2_started_at && (
Phase 1 — Discovery

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

Duration: {formatDuration(job.started_at, job.phase2_started_at)} {job.stats_json && ( · {job.stats_json.scanned_files} scanned, {job.stats_json.indexed_files} indexed {job.stats_json.removed_files > 0 && `, ${job.stats_json.removed_files} removed`} {(job.stats_json.warnings ?? 0) > 0 && `, ${job.stats_json.warnings} warn`} )}

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

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

Duration: {formatDuration(job.phase2_started_at, job.generating_thumbnails_started_at ?? job.finished_at ?? null)} {!job.generating_thumbnails_started_at && !job.finished_at && isExtractingPages && ( · in progress )}

)} {/* Phase 2b — Generating thumbnails */} {(job.generating_thumbnails_started_at || (job.phase2_started_at && isThumbnailOnly)) && (
{isThumbnailOnly ? "Thumbnails" : "Phase 2b — Generating thumbnails"}

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

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

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} thumbnails )}

)} {!job.finished_at && isThumbnailPhase && ( in progress )}
)} {/* Started — for jobs without phase2 (cbr_to_cbz, or no phase yet) */} {job.started_at && !job.phase2_started_at && (
Started

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

)} {/* Pending — not started yet */} {!job.started_at && (
Waiting to start…
)} {/* Finished */} {job.finished_at && (
{isCompleted ? "Completed" : isFailed ? "Failed" : "Cancelled"}

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

)}
{/* Progress Card */} {showProgressCard && ( {progressTitle} {progressDescription && {progressDescription}} {job.total_files != null && job.total_files > 0 && ( <>
)} {job.current_file && (
Current file {job.current_file}
)}
)} {/* Index Statistics — index jobs only */} {job.stats_json && !isThumbnailOnly && ( Index statistics {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 && ( Thumbnail statistics {job.started_at && ( {formatDuration(job.started_at, job.finished_at)} {speedCount > 0 && ` · ${formatSpeed(speedCount, durationMs)} thumbnails/s`} )}
)} {/* File errors */} {errors.length > 0 && ( File errors ({errors.length}) Errors encountered while processing individual files {errors.map((error) => (
{error.file_path}

{error.error_message}

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