export const dynamic = "force-dynamic"; import { notFound } from "next/navigation"; import Link from "next/link"; import { apiFetch, getMetadataBatchReport, getMetadataBatchResults, getMetadataRefreshReport, getReadingStatusMatchReport, getReadingStatusMatchResults, getReadingStatusPushReport, getReadingStatusPushResults, getDownloadDetectionReport, getDownloadDetectionResults, MetadataBatchReportDto, MetadataBatchResultDto, MetadataRefreshReportDto, ReadingStatusMatchReportDto, ReadingStatusMatchResultDto, ReadingStatusPushReportDto, ReadingStatusPushResultDto, DownloadDetectionReportDto, DownloadDetectionResultDto } from "@/lib/api"; import { JobDetailLive } from "@/app/components/JobDetailLive"; import { getServerTranslations } from "@/lib/i18n/server"; import { JobSummaryBanner } from "./components/JobSummaryBanner"; import { JobOverviewCard } from "./components/JobOverviewCard"; import { JobTimelineCard } from "./components/JobTimelineCard"; import { JobProgressCard, IndexStatsCard, ThumbnailStatsCard } from "./components/JobProgressCard"; import { MetadataBatchReportCard, MetadataBatchResultsCard, MetadataRefreshReportCard, MetadataRefreshChangesCard } from "./components/MetadataReportCards"; import { ReadingStatusMatchReportCard, ReadingStatusMatchResultsCard, ReadingStatusPushReportCard, ReadingStatusPushResultsCard } from "./components/ReadingStatusReportCards"; import { DownloadDetectionReportCard, DownloadDetectionResultsCard, DownloadDetectionErrorsCard } from "./components/DownloadDetectionCards"; import { JobErrorsCard } from "./components/JobErrorsCard"; 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 }, rescan: { label: t("jobType.rescanLabel"), description: t("jobType.rescanDesc"), 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 }, metadata_refresh: { label: t("jobType.metadata_refreshLabel"), description: t("jobType.metadata_refreshDesc"), isThumbnailOnly: false }, reading_status_match: { label: t("jobType.reading_status_matchLabel"), description: t("jobType.reading_status_matchDesc"), isThumbnailOnly: false }, reading_status_push: { label: t("jobType.reading_status_pushLabel"), description: t("jobType.reading_status_pushDesc"), isThumbnailOnly: false }, download_detection: { label: t("jobType.download_detectionLabel"), description: t("jobType.download_detectionDesc"), isThumbnailOnly: false }, }; const isMetadataBatch = job.type === "metadata_batch"; const isMetadataRefresh = job.type === "metadata_refresh"; const isReadingStatusMatch = job.type === "reading_status_match"; const isReadingStatusPush = job.type === "reading_status_push"; const isDownloadDetection = job.type === "download_detection"; let batchReport: MetadataBatchReportDto | null = null; let batchResults: MetadataBatchResultDto[] = []; if (isMetadataBatch) { [batchReport, batchResults] = await Promise.all([ getMetadataBatchReport(id).catch(() => null), getMetadataBatchResults(id).catch(() => []), ]); } let refreshReport: MetadataRefreshReportDto | null = null; if (isMetadataRefresh) { refreshReport = await getMetadataRefreshReport(id).catch(() => null); } let readingStatusReport: ReadingStatusMatchReportDto | null = null; let readingStatusResults: ReadingStatusMatchResultDto[] = []; if (isReadingStatusMatch) { [readingStatusReport, readingStatusResults] = await Promise.all([ getReadingStatusMatchReport(id).catch(() => null), getReadingStatusMatchResults(id).catch(() => []), ]); } let readingStatusPushReport: ReadingStatusPushReportDto | null = null; let readingStatusPushResults: ReadingStatusPushResultDto[] = []; if (isReadingStatusPush) { [readingStatusPushReport, readingStatusPushResults] = await Promise.all([ getReadingStatusPushReport(id).catch(() => null), getReadingStatusPushResults(id).catch(() => []), ]); } let downloadDetectionReport: DownloadDetectionReportDto | null = null; let downloadDetectionResults: DownloadDetectionResultDto[] = []; let downloadDetectionErrors: DownloadDetectionResultDto[] = []; if (isDownloadDetection) { [downloadDetectionReport, downloadDetectionResults, downloadDetectionErrors] = await Promise.all([ getDownloadDetectionReport(id).catch(() => null), getDownloadDetectionResults(id, "found").catch(() => []), getDownloadDetectionResults(id, "error").catch(() => []), ]); } const typeInfo = JOB_TYPE_INFO[job.type] ?? { label: job.type, description: null, isThumbnailOnly: false }; const { isThumbnailOnly } = typeInfo; 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 isTerminal = isCompleted || isFailed || isCancelled; const isExtractingPages = job.status === "extracting_pages"; const isThumbnailPhase = job.status === "generating_thumbnails"; const isPhase2 = isExtractingPages || isThumbnailPhase; const progressTitle = isMetadataBatch ? t("jobDetail.metadataSearch") : isMetadataRefresh ? t("jobDetail.metadataRefresh") : isReadingStatusMatch ? t("jobDetail.readingStatusMatch") : isReadingStatusPush ? t("jobDetail.readingStatusPush") : isDownloadDetection ? t("jobDetail.downloadDetection") : isThumbnailOnly ? t("jobType.thumbnail_rebuild") : isExtractingPages ? t("jobDetail.phase2a") : isThumbnailPhase ? t("jobDetail.phase2b") : t("jobDetail.phase1"); const progressDescription = isMetadataBatch ? t("jobDetail.metadataSearchDesc") : isMetadataRefresh ? t("jobDetail.metadataRefreshDesc") : isReadingStatusMatch ? t("jobDetail.readingStatusMatchDesc") : isReadingStatusPush ? t("jobDetail.readingStatusPushDesc") : isDownloadDetection ? t("jobDetail.downloadDetectionDesc") : isThumbnailOnly ? undefined : isExtractingPages ? t("jobDetail.phase2aDesc") : isThumbnailPhase ? t("jobDetail.phase2bDesc") : t("jobDetail.phase1Desc"); return ( <>
{t("jobDetail.backToJobs")}

{t("jobDetail.title")}

{/* Index Statistics */} {job.stats_json && !isThumbnailOnly && !isMetadataBatch && !isMetadataRefresh && !isReadingStatusMatch && !isReadingStatusPush && !isDownloadDetection && ( )} {/* Thumbnail statistics */} {isThumbnailOnly && isCompleted && job.total_files != null && ( )} {/* Metadata batch */} {isMetadataBatch && batchReport && } {isMetadataRefresh && refreshReport && } {isMetadataRefresh && refreshReport && } {/* Reading status */} {isReadingStatusMatch && readingStatusReport && } {isReadingStatusMatch && } {isReadingStatusPush && readingStatusPushReport && } {isReadingStatusPush && } {/* Download detection */} {isDownloadDetection && downloadDetectionReport && } {isDownloadDetection && } {isDownloadDetection && } {/* Metadata batch results */} {isMetadataBatch && } {/* File errors */}
); }