diff --git a/apps/api/src/index_jobs.rs b/apps/api/src/index_jobs.rs index d9b12e3..c761114 100644 --- a/apps/api/src/index_jobs.rs +++ b/apps/api/src/index_jobs.rs @@ -55,12 +55,16 @@ pub struct IndexJobDetailResponse { pub id: Uuid, #[schema(value_type = Option)] pub library_id: Option, + #[schema(value_type = Option)] + pub book_id: Option, pub r#type: String, pub status: String, #[schema(value_type = Option)] pub started_at: Option>, #[schema(value_type = Option)] pub finished_at: Option>, + #[schema(value_type = Option)] + pub phase2_started_at: Option>, pub stats_json: Option, pub error_opt: Option, #[schema(value_type = String)] @@ -314,10 +318,12 @@ fn map_row_detail(row: sqlx::postgres::PgRow) -> IndexJobDetailResponse { IndexJobDetailResponse { id: row.get("id"), library_id: row.get("library_id"), + book_id: row.try_get("book_id").ok().flatten(), r#type: row.get("type"), status: row.get("status"), started_at: row.get("started_at"), finished_at: row.get("finished_at"), + phase2_started_at: row.try_get("phase2_started_at").ok().flatten(), stats_json: row.get("stats_json"), error_opt: row.get("error_opt"), created_at: row.get("created_at"), @@ -374,8 +380,8 @@ pub async fn get_job_details( id: axum::extract::Path, ) -> Result, ApiError> { let row = sqlx::query( - "SELECT id, library_id, type, status, started_at, finished_at, stats_json, error_opt, created_at, - current_file, progress_percent, total_files, processed_files + "SELECT id, library_id, book_id, type, status, started_at, finished_at, phase2_started_at, + stats_json, error_opt, created_at, current_file, progress_percent, total_files, processed_files FROM index_jobs WHERE id = $1" ) .bind(id.0) diff --git a/apps/backoffice/app/components/JobRow.tsx b/apps/backoffice/app/components/JobRow.tsx index a328fd9..c38c8fc 100644 --- a/apps/backoffice/app/components/JobRow.tsx +++ b/apps/backoffice/app/components/JobRow.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import Link from "next/link"; import { JobProgress } from "./JobProgress"; -import { StatusBadge, Button, MiniProgressBar } from "./ui"; +import { StatusBadge, JobTypeBadge, Button, MiniProgressBar } from "./ui"; interface JobRowProps { job: { @@ -93,8 +93,8 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo {job.library_id ? libraryName || job.library_id.slice(0, 8) : "—"} - - {job.type === "cbr_to_cbz" ? "CBR → CBZ" : job.type} + +
diff --git a/apps/backoffice/app/components/ui/Badge.tsx b/apps/backoffice/app/components/ui/Badge.tsx index 8963b80..75ae296 100644 --- a/apps/backoffice/app/components/ui/Badge.tsx +++ b/apps/backoffice/app/components/ui/Badge.tsx @@ -94,8 +94,11 @@ const jobTypeVariants: Record = { }; const jobTypeLabels: Record = { + rebuild: "Index", + full_rebuild: "Full Index", thumbnail_rebuild: "Thumbnails", - thumbnail_regenerate: "Regenerate", + thumbnail_regenerate: "Regen. Thumbnails", + cbr_to_cbz: "CBR → CBZ", }; interface JobTypeBadgeProps { diff --git a/apps/backoffice/app/jobs/[id]/page.tsx b/apps/backoffice/app/jobs/[id]/page.tsx index 7b0c6f0..fe44093 100644 --- a/apps/backoffice/app/jobs/[id]/page.tsx +++ b/apps/backoffice/app/jobs/[id]/page.tsx @@ -1,9 +1,9 @@ import { notFound } from "next/navigation"; import Link from "next/link"; import { apiFetch } from "../../../lib/api"; -import { +import { Card, CardHeader, CardTitle, CardDescription, CardContent, - StatusBadge, JobTypeBadge, StatBox, ProgressBar + StatusBadge, JobTypeBadge, StatBox, ProgressBar } from "../../components/ui"; interface JobDetailPageProps { @@ -13,11 +13,13 @@ interface JobDetailPageProps { 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; current_file: string | null; progress_percent: number | null; processed_files: number | null; @@ -38,6 +40,34 @@ interface JobError { 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}`); @@ -58,16 +88,15 @@ 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(stats: { scanned_files: number } | null, duration: number): string { - if (!stats || duration === 0) return "-"; - const filesPerSecond = stats.scanned_files / (duration / 1000); - return `${filesPerSecond.toFixed(1)} f/s`; +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) { @@ -81,15 +110,49 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) { notFound(); } - const duration = job.started_at + 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 isThumbnailPhase = job.status === "generating_thumbnails"; + const { isThumbnailOnly } = typeInfo; + + // Which label to use for the progress card + const progressTitle = isThumbnailOnly + ? "Thumbnails" + : isThumbnailPhase + ? "Phase 2 — Thumbnails" + : "Phase 1 — Discovery"; + + const progressDescription = isThumbnailOnly + ? undefined + : 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" || isThumbnailPhase) && + (job.total_files != null || !!job.current_file); + return ( <>
- @@ -100,11 +163,72 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {

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.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} + )}
@@ -113,16 +237,38 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
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)} + +
+ )} @@ -131,101 +277,194 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) { Timeline - -
-
-
- Created -

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

+ +
+ {/* 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`} + + )} +

+
+
+ )} + + {/* Phase 2 start — for index jobs that have two phases */} + {job.phase2_started_at && ( +
+
+
+ + {isThumbnailOnly ? "Thumbnails" : "Phase 2 — Thumbnails"} + +

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

+ {job.finished_at && ( +

+ Duration: {formatDuration(job.phase2_started_at, job.finished_at)} + {job.total_files != null && job.total_files > 0 && ( + + · {job.processed_files ?? job.total_files} thumbnails + + )} +

+ )} +
+
+ )} + + {/* 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()}

+
+
+ )}
-
-
-
- Started -

- {job.started_at ? new Date(job.started_at).toLocaleString() : "Pending..."} -

-
-
-
-
-
- Finished -

- {job.finished_at - ? new Date(job.finished_at).toLocaleString() - : job.started_at - ? "Running..." - : "Waiting..." - } -

-
-
- {job.started_at && ( -
- Duration: {formatDuration(job.started_at, job.finished_at)} -
- )} {/* Progress Card */} - {(job.status === "running" || job.status === "generating_thumbnails" || job.status === "success" || job.status === "failed") && ( + {showProgressCard && ( - {job.status === "generating_thumbnails" ? "Thumbnails" : "Progress"} + {progressTitle} + {progressDescription && {progressDescription}} {job.total_files != null && job.total_files > 0 && ( <>
- - - + + +
)} {job.current_file && (
- Current file: - {job.current_file} + Current file + {job.current_file}
)}
)} - {/* Statistics Card */} - {job.stats_json && ( + {/* Index Statistics — index jobs only */} + {job.stats_json && !isThumbnailOnly && ( - Statistics + Index statistics + {job.started_at && ( + + {formatDuration(job.started_at, job.finished_at)} + {speedCount > 0 && ` · ${formatSpeed(speedCount, durationMs)} scan rate`} + + )} -
+
0 ? "error" : "default"} />
- {job.started_at && ( -
- Speed: - {formatSpeed(job.stats_json, duration)} -
- )} )} - {/* Errors Card */} + {/* 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 && ( - Errors ({errors.length}) - Errors encountered during job execution + File errors ({errors.length}) + Errors encountered while processing individual files {errors.map((error) => ( @@ -238,19 +477,6 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) { )} - - {/* Error Message */} - {job.error_opt && ( - - - Error - Job failed with error - - -
{job.error_opt}
-
-
- )}
); diff --git a/apps/indexer/src/job.rs b/apps/indexer/src/job.rs index db051a6..1b38126 100644 --- a/apps/indexer/src/job.rs +++ b/apps/indexer/src/job.rs @@ -157,7 +157,7 @@ pub async fn process_job( // Thumbnail rebuild: generate thumbnails for books missing them if job_type == "thumbnail_rebuild" { sqlx::query( - "UPDATE index_jobs SET status = 'generating_thumbnails', started_at = NOW() WHERE id = $1", + "UPDATE index_jobs SET status = 'generating_thumbnails', started_at = NOW(), phase2_started_at = NOW() WHERE id = $1", ) .bind(job_id) .execute(&state.pool) @@ -178,7 +178,7 @@ pub async fn process_job( // Thumbnail regenerate: clear all thumbnails then re-generate if job_type == "thumbnail_regenerate" { sqlx::query( - "UPDATE index_jobs SET status = 'generating_thumbnails', started_at = NOW() WHERE id = $1", + "UPDATE index_jobs SET status = 'generating_thumbnails', started_at = NOW(), phase2_started_at = NOW() WHERE id = $1", ) .bind(job_id) .execute(&state.pool) @@ -320,7 +320,7 @@ pub async fn process_job( // Phase 2: Analysis (extract page_count + thumbnails for new/updated books) sqlx::query( - "UPDATE index_jobs SET status = 'generating_thumbnails', stats_json = $2, current_file = NULL, processed_files = $3 WHERE id = $1", + "UPDATE index_jobs SET status = 'generating_thumbnails', phase2_started_at = NOW(), stats_json = $2, current_file = NULL, processed_files = $3 WHERE id = $1", ) .bind(job_id) .bind(serde_json::to_value(&stats)?) diff --git a/infra/migrations/0015_index_job_phase2_started_at.sql b/infra/migrations/0015_index_job_phase2_started_at.sql new file mode 100644 index 0000000..4550289 --- /dev/null +++ b/infra/migrations/0015_index_job_phase2_started_at.sql @@ -0,0 +1,2 @@ +ALTER TABLE index_jobs + ADD COLUMN phase2_started_at TIMESTAMPTZ;