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"; 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: "Indexation incrémentale", description: "Scanne les fichiers nouveaux/modifiés, les analyse et génère les miniatures manquantes.", isThumbnailOnly: false, }, full_rebuild: { label: "Réindexation complète", description: "Supprime toutes les données existantes puis effectue un scan complet, une ré-analyse et la génération des miniatures.", isThumbnailOnly: false, }, thumbnail_rebuild: { label: "Reconstruction des miniatures", description: "Génère les miniatures uniquement pour les livres qui n'en ont pas. Les miniatures existantes sont conservées.", isThumbnailOnly: true, }, thumbnail_regenerate: { label: "Regénération des miniatures", description: "Regénère toutes les miniatures depuis zéro, en remplaçant les existantes.", isThumbnailOnly: true, }, cbr_to_cbz: { label: "Conversion CBR → CBZ", description: "Convertit une archive CBR au format ouvert CBZ.", isThumbnailOnly: false, }, metadata_batch: { label: "Métadonnées en lot", description: "Recherche les métadonnées auprès des fournisseurs externes pour toutes les séries de la bibliothèque et applique automatiquement les correspondances à 100% de confiance.", 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 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 ? "Recherche de métadonnées" : isThumbnailOnly ? "Miniatures" : isExtractingPages ? "Phase 2 — Extraction des pages" : isThumbnailPhase ? "Phase 2 — Miniatures" : "Phase 1 — Découverte"; const progressDescription = isMetadataBatch ? "Recherche auprès des fournisseurs externes pour chaque série" : isThumbnailOnly ? undefined : isExtractingPages ? "Extraction de la première page de chaque archive (nombre de pages + image brute)" : isThumbnailPhase ? "Génération des miniatures pour les livres analysés" : "Scan et indexation des fichiers de la bibliothèque"; // 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 ( <>
Retour aux tâches

Détails de la tâche

{/* Summary banner — completed */} {isCompleted && job.started_at && (
Terminé en {formatDuration(job.started_at, job.finished_at)} {isMetadataBatch && batchReport && ( — {batchReport.auto_matched} auto-associées, {batchReport.already_linked} déjà liées, {batchReport.no_results} aucun résultat, {batchReport.errors} erreurs )} {!isMetadataBatch && job.stats_json && ( — {job.stats_json.scanned_files} scannés, {job.stats_json.indexed_files} indexés {job.stats_json.removed_files > 0 && `, ${job.stats_json.removed_files} supprimés`} {(job.stats_json.warnings ?? 0) > 0 && `, ${job.stats_json.warnings} avertissements`} {job.stats_json.errors > 0 && `, ${job.stats_json.errors} erreurs`} {job.total_files != null && job.total_files > 0 && `, ${job.total_files} miniatures`} )} {!isMetadataBatch && !job.stats_json && isThumbnailOnly && job.total_files != null && ( — {job.processed_files ?? job.total_files} miniatures générées )}
)} {/* Summary banner — failed */} {isFailed && (
Tâche échouée {job.started_at && ( après {formatDuration(job.started_at, job.finished_at)} )} {job.error_opt && (

{job.error_opt}

)}
)} {/* Summary banner — cancelled */} {isCancelled && (
Annulé {job.started_at && ( après {formatDuration(job.started_at, job.finished_at)} )}
)}
{/* Overview Card */} Aperçu {typeInfo.description && ( {typeInfo.description} )}
ID {job.id}
Type
{typeInfo.label}
Statut
Bibliothèque {job.library_id || "Toutes les bibliothèques"}
{job.book_id && (
Livre {job.book_id.slice(0, 8)}…
)} {job.started_at && (
Durée {formatDuration(job.started_at, job.finished_at)}
)}
{/* Timeline Card */} Chronologie
{/* Vertical line */}
{/* Created */}
Créé

{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 — Découverte

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

Durée : {formatDuration(job.started_at, job.phase2_started_at)} {job.stats_json && ( · {job.stats_json.scanned_files} scannés, {job.stats_json.indexed_files} indexés {job.stats_json.removed_files > 0 && `, ${job.stats_json.removed_files} supprimés`} {(job.stats_json.warnings ?? 0) > 0 && `, ${job.stats_json.warnings} avert.`} )}

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

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

Durée : {formatDuration(job.phase2_started_at, job.generating_thumbnails_started_at ?? job.finished_at ?? null)} {!job.generating_thumbnails_started_at && !job.finished_at && isExtractingPages && ( · en cours )}

)} {/* Phase 2b — Generating thumbnails */} {(job.generating_thumbnails_started_at || (job.phase2_started_at && isThumbnailOnly)) && (
{isThumbnailOnly ? "Miniatures" : "Phase 2b — Génération des miniatures"}

{(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) && (

Durée : {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} miniatures )}

)} {!job.finished_at && isThumbnailPhase && ( en cours )}
)} {/* Started — for jobs without phase2 (cbr_to_cbz, or no phase yet) */} {job.started_at && !job.phase2_started_at && (
Démarré

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

)} {/* Pending — not started yet */} {!job.started_at && (
En attente de démarrage…
)} {/* Finished */} {job.finished_at && (
{isCompleted ? "Terminé" : isFailed ? "Échoué" : "Annulé"}

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

)}
{/* Progress Card */} {showProgressCard && ( {progressTitle} {progressDescription && {progressDescription}} {job.total_files != null && job.total_files > 0 && ( <>
)} {job.current_file && (
Fichier en cours {job.current_file}
)}
)} {/* Index Statistics — index jobs only */} {job.stats_json && !isThumbnailOnly && !isMetadataBatch && ( Statistiques d'indexation {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 && ( Statistiques des miniatures {job.started_at && ( {formatDuration(job.started_at, job.finished_at)} {speedCount > 0 && ` · ${formatSpeed(speedCount, durationMs)} thumbnails/s`} )}
)} {/* Metadata batch report */} {isMetadataBatch && batchReport && ( Rapport du lot {batchReport.total_series} séries analysées
0 ? "error" : "default"} />
)} {/* Metadata batch results */} {isMetadataBatch && batchResults.length > 0 && ( Résultats par série {batchResults.length} séries traitées {batchResults.map((r) => (
{r.series_name} {r.status === "auto_matched" ? "Auto-associé" : r.status === "already_linked" ? "Déjà lié" : r.status === "no_results" ? "Aucun résultat" : r.status === "too_many_results" ? "Trop de résultats" : r.status === "low_confidence" ? "Confiance faible" : r.status === "error" ? "Erreur" : r.status}
{r.provider_used && ( {r.provider_used}{r.fallback_used ? " (secours)" : ""} )} {r.candidates_count > 0 && ( {r.candidates_count} candidat{r.candidates_count > 1 ? "s" : ""} )} {r.best_confidence != null && ( {Math.round(r.best_confidence * 100)}% confiance )}
{r.best_candidate_json && (

Correspondance : {(r.best_candidate_json as { title?: string }).title || r.best_candidate_json.toString()}

)} {r.error_message && (

{r.error_message}

)}
))}
)} {/* File errors */} {errors.length > 0 && ( Erreurs de fichiers ({errors.length}) Erreurs rencontrées lors du traitement des fichiers {errors.map((error) => (
{error.file_path}

{error.error_message}

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