diff --git a/apps/backoffice/app/components/JobRow.tsx b/apps/backoffice/app/components/JobRow.tsx
index bafbdb3..f4fea71 100644
--- a/apps/backoffice/app/components/JobRow.tsx
+++ b/apps/backoffice/app/components/JobRow.tsx
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
+import Link from "next/link";
import { JobProgress } from "./JobProgress";
interface JobRowProps {
@@ -32,14 +33,16 @@ export function JobRow({ job, libraryName, highlighted, onCancel }: JobRowProps)
<>
- {job.id.slice(0, 8)}
+
+ {job.id.slice(0, 8)}
+
|
{job.library_id ? libraryName || job.library_id.slice(0, 8) : "—"} |
{job.type} |
{job.status}
{job.error_opt && !}
- {job.status === "running" && (
+ {(job.status === "running" || job.status === "pending") && (
|
{new Date(job.created_at).toLocaleString()} |
- {job.status === "pending" || job.status === "running" ? (
-
- ) : null}
+
+
+ View
+
+ {(job.status === "pending" || job.status === "running") && (
+
+ )}
+
|
{showProgress && (job.status === "running" || job.status === "pending") && (
diff --git a/apps/backoffice/app/globals.css b/apps/backoffice/app/globals.css
index d1c101e..b6f06f8 100644
--- a/apps/backoffice/app/globals.css
+++ b/apps/backoffice/app/globals.css
@@ -1258,3 +1258,334 @@ tr.job-highlighted td {
gap: 8px;
flex-wrap: wrap;
}
+
+/* Job Detail Page Styles */
+.page-header {
+ margin-bottom: 24px;
+}
+
+.back-link {
+ display: inline-block;
+ margin-bottom: 12px;
+ color: hsl(198 78% 37%);
+ text-decoration: none;
+}
+
+.back-link:hover {
+ text-decoration: underline;
+}
+
+.job-detail-grid {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 20px;
+}
+
+@media (min-width: 768px) {
+ .job-detail-grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ .job-progress-detail,
+ .job-statistics,
+ .job-errors,
+ .job-error-message {
+ grid-column: 1 / -1;
+ }
+}
+
+.job-meta {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 12px;
+}
+
+.meta-item {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.meta-label {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ text-transform: uppercase;
+}
+
+.meta-value {
+ font-weight: 600;
+}
+
+.job-type {
+ text-transform: uppercase;
+ font-size: 0.8rem;
+}
+
+.job-type.full_rebuild {
+ color: hsl(280 60% 45%);
+}
+
+.job-type.rebuild {
+ color: hsl(198 78% 37%);
+}
+
+/* Timeline */
+.timeline {
+ position: relative;
+ padding-left: 24px;
+}
+
+.timeline::before {
+ content: '';
+ position: absolute;
+ left: 7px;
+ top: 8px;
+ bottom: 8px;
+ width: 2px;
+ background: var(--line);
+}
+
+.timeline-item {
+ position: relative;
+ padding-bottom: 16px;
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+}
+
+.timeline-item:last-child {
+ padding-bottom: 0;
+}
+
+.timeline-dot {
+ position: absolute;
+ left: -20px;
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ background: var(--line);
+ border: 2px solid var(--card);
+ margin-top: 4px;
+}
+
+.timeline-item.completed .timeline-dot {
+ background: hsl(142 60% 45%);
+}
+
+.timeline-item.active .timeline-dot {
+ background: hsl(198 78% 37%);
+ animation: pulse-dot 2s infinite;
+}
+
+.timeline-item.pending .timeline-dot {
+ background: var(--line);
+}
+
+@keyframes pulse-dot {
+ 0%, 100% { box-shadow: 0 0 0 0 hsl(198 78% 37% / 0.4); }
+ 50% { box-shadow: 0 0 0 6px hsl(198 78% 37% / 0); }
+}
+
+.timeline-content {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.timeline-label {
+ font-weight: 600;
+ font-size: 0.9rem;
+}
+
+.timeline-time {
+ font-size: 0.8rem;
+ color: var(--text-muted);
+}
+
+.duration-badge {
+ margin-top: 16px;
+ padding: 8px 12px;
+ background: hsl(198 52% 90%);
+ border-radius: 6px;
+ font-weight: 600;
+ color: hsl(198 78% 37%);
+}
+
+/* Progress */
+.progress-bar-large {
+ position: relative;
+ height: 32px;
+ background: var(--line);
+ border-radius: 16px;
+ overflow: hidden;
+ margin-bottom: 16px;
+}
+
+.progress-fill {
+ height: 100%;
+ background: linear-gradient(90deg, hsl(198 78% 37%), hsl(192 85% 55%));
+ border-radius: 16px;
+ transition: width 0.3s ease;
+}
+
+.progress-text {
+ position: absolute;
+ right: 12px;
+ top: 50%;
+ transform: translateY(-50%);
+ font-weight: 700;
+ font-size: 0.9rem;
+}
+
+.progress-stats-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 12px;
+ margin-bottom: 16px;
+}
+
+.stat-box {
+ text-align: center;
+ padding: 12px;
+ background: hsl(198 52% 95%);
+ border-radius: 8px;
+}
+
+.stat-box .stat-value {
+ display: block;
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: hsl(198 78% 37%);
+}
+
+.stat-box .stat-label {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+}
+
+.current-file-box {
+ padding: 12px;
+ background: hsl(45 93% 90% / 0.3);
+ border: 1px solid hsl(45 93% 47% / 0.3);
+ border-radius: 8px;
+}
+
+.current-file-box .label {
+ display: block;
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ margin-bottom: 4px;
+}
+
+.current-file-box .file-path {
+ font-size: 0.85rem;
+ word-break: break-all;
+}
+
+/* Statistics */
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 16px;
+ margin-bottom: 16px;
+}
+
+.stat-item {
+ text-align: center;
+}
+
+.stat-number {
+ display: block;
+ font-size: 2rem;
+ font-weight: 700;
+ color: var(--foreground);
+}
+
+.stat-number.success { color: hsl(142 60% 45%); }
+.stat-number.primary { color: hsl(198 78% 37%); }
+.stat-number.warning { color: hsl(45 93% 47%); }
+.stat-number.error { color: hsl(2 72% 48%); }
+
+.speed-stat {
+ text-align: center;
+ padding-top: 12px;
+ border-top: 1px solid var(--line);
+}
+
+.speed-label {
+ font-size: 0.85rem;
+ color: var(--text-muted);
+}
+
+.speed-value {
+ font-weight: 700;
+ font-size: 1.1rem;
+ margin-left: 8px;
+}
+
+/* Errors */
+.errors-list {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.error-item {
+ padding: 12px;
+ background: hsl(2 72% 48% / 0.05);
+ border: 1px solid hsl(2 72% 48% / 0.2);
+ border-radius: 8px;
+}
+
+.error-file {
+ display: block;
+ font-size: 0.8rem;
+ margin-bottom: 4px;
+ word-break: break-all;
+}
+
+.error-message {
+ display: block;
+ font-size: 0.85rem;
+ color: hsl(2 72% 48%);
+ margin-bottom: 4px;
+}
+
+.error-time {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+}
+
+.error-details {
+ padding: 16px;
+ background: hsl(2 72% 48% / 0.05);
+ border: 1px solid hsl(2 72% 48% / 0.2);
+ border-radius: 8px;
+ overflow-x: auto;
+ font-size: 0.85rem;
+}
+
+/* Job list enhancements */
+.job-id-link {
+ text-decoration: none;
+ color: inherit;
+}
+
+.job-id-link:hover code {
+ background: hsl(198 52% 90%);
+ color: hsl(198 78% 37%);
+}
+
+.view-btn {
+ padding: 4px 10px;
+ font-size: 0.8rem;
+ background: hsl(198 78% 37% / 0.1);
+ border: 1px solid hsl(198 78% 37% / 0.3);
+ border-radius: 4px;
+ color: hsl(198 78% 37%);
+ text-decoration: none;
+}
+
+.view-btn:hover {
+ background: hsl(198 78% 37% / 0.2);
+}
diff --git a/apps/backoffice/app/jobs/[id]/page.tsx b/apps/backoffice/app/jobs/[id]/page.tsx
new file mode 100644
index 0000000..47fae87
--- /dev/null
+++ b/apps/backoffice/app/jobs/[id]/page.tsx
@@ -0,0 +1,254 @@
+import { notFound } from "next/navigation";
+import Link from "next/link";
+import { apiFetch } from "../../../lib/api";
+
+interface JobDetailPageProps {
+ params: Promise<{ id: string }>;
+}
+
+interface JobDetails {
+ id: string;
+ library_id: string | null;
+ type: string;
+ status: string;
+ created_at: string;
+ started_at: string | null;
+ finished_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;
+ } | 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(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`;
+}
+
+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 duration = job.started_at
+ ? new Date(job.finished_at || new Date()).getTime() - new Date(job.started_at).getTime()
+ : 0;
+
+ return (
+ <>
+
+ ← Back to jobs
+
Job Details
+
+
+
+ {/* Overview Card */}
+
+
Overview
+
+
+ ID
+ {job.id}
+
+
+ Type
+ {job.type}
+
+
+ Status
+ {job.status}
+
+
+ Library
+ {job.library_id || "All libraries"}
+
+
+
+
+ {/* Timeline Card */}
+
+
Timeline
+
+
+
+
+ Created
+ {new Date(job.created_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 === "success" || job.status === "failed") && (
+
+
Progress
+ {job.total_files && job.total_files > 0 && (
+ <>
+
+
+
{job.progress_percent || 0}%
+
+
+
+ {job.processed_files || 0}
+ Processed
+
+
+ {job.total_files}
+ Total
+
+
+ {job.total_files - (job.processed_files || 0)}
+ Remaining
+
+
+ >
+ )}
+ {job.current_file && (
+
+ Current file:
+ {job.current_file}
+
+ )}
+
+ )}
+
+ {/* Statistics Card */}
+ {job.stats_json && (
+
+
Statistics
+
+
+ {job.stats_json.scanned_files}
+ Scanned
+
+
+ {job.stats_json.indexed_files}
+ Indexed
+
+
+ {job.stats_json.removed_files}
+ Removed
+
+
+ 0 ? 'error' : ''}`}>
+ {job.stats_json.errors}
+
+ Errors
+
+
+ {job.started_at && (
+
+ Speed:
+ {formatSpeed(job.stats_json, duration)}
+
+ )}
+
+ )}
+
+ {/* Errors Card */}
+ {errors.length > 0 && (
+
+
Errors ({errors.length})
+
+ {errors.map((error) => (
+
+ {error.file_path}
+ {error.error_message}
+ {new Date(error.created_at).toLocaleString()}
+
+ ))}
+
+
+ )}
+
+ {/* Error Message */}
+ {job.error_opt && (
+
+
Error
+
{job.error_opt}
+
+ )}
+
+ >
+ );
+}