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") && ( - ) : 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}
+
+ )} +
+ + ); +}