"use client"; import { useState, useEffect } from "react"; import { useTranslation } from "../../lib/i18n/context"; import { JobRow } from "./JobRow"; const PAGE_SIZE = 25; interface Job { id: string; library_id: string | null; type: string; status: string; created_at: string; started_at: string | null; finished_at: string | null; error_opt: string | null; stats_json: { scanned_files: number; indexed_files: number; removed_files: number; errors: number; refreshed?: number; } | null; progress_percent: number | null; processed_files: number | null; total_files: number | null; } interface JobsListProps { initialJobs: Job[]; libraries: Map; highlightJobId?: string; } 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`; } export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListProps) { const { t, locale } = useTranslation(); const [jobs, setJobs] = useState(initialJobs); const initialPage = highlightJobId ? Math.ceil((initialJobs.findIndex(j => j.id === highlightJobId) + 1) / PAGE_SIZE) || 1 : 1; const [currentPage, setCurrentPage] = useState(initialPage); const formatDate = (dateStr: string): string => { const date = new Date(dateStr); if (isNaN(date.getTime())) return dateStr; const loc = locale === "fr" ? "fr-FR" : "en-US"; return date.toLocaleString(loc, { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit", }); }; const totalPages = Math.ceil(jobs.length / PAGE_SIZE); const pageStart = (currentPage - 1) * PAGE_SIZE; const visibleJobs = jobs.slice(pageStart, pageStart + PAGE_SIZE); // Refresh jobs list via SSE useEffect(() => { const eventSource = new EventSource("/api/jobs/stream"); eventSource.onmessage = (event) => { try { const data = JSON.parse(event.data); if (Array.isArray(data)) { setJobs(data); } } catch (error) { console.error("Failed to parse SSE data:", error); } }; eventSource.onerror = (err) => { console.error("SSE error:", err); eventSource.close(); }; return () => { eventSource.close(); }; }, []); const handleCancel = async (id: string) => { const response = await fetch(`/api/jobs/${id}/cancel`, { method: "POST" }); if (response.ok) { setJobs(jobs.map(job => job.id === id ? { ...job, status: "cancelled" } : job )); } else { const data = await response.json().catch(() => ({})); console.error("Failed to cancel job:", data?.error ?? response.status); } }; const refreshJobs = async () => { try { const res = await fetch("/api/jobs/list"); if (res.ok) { const data = await res.json(); if (Array.isArray(data)) setJobs(data); } } catch { /* SSE will catch up */ } }; const handleReplay = async (id: string) => { const response = await fetch(`/api/jobs/${id}/replay`, { method: "POST" }); if (response.ok) { await refreshJobs(); } else { const data = await response.json().catch(() => ({})); console.error("Failed to replay job:", data?.error ?? response.status); } }; return (
{visibleJobs.map((job) => ( ))}
{t("jobsList.id")} {t("jobsList.library")} {t("jobsList.type")} {t("jobsList.status")} {t("jobsList.stats")} {t("jobsList.duration")} {t("jobsList.created")} {t("jobsList.actions")}
{totalPages > 1 && (
{t("pagination.range", { start: pageStart + 1, end: Math.min(pageStart + PAGE_SIZE, jobs.length), total: jobs.length, })}
{Array.from({ length: totalPages }, (_, i) => i + 1) .filter(p => p === 1 || p === totalPages || Math.abs(p - currentPage) <= 1) .reduce<(number | "…")[]>((acc, p, i, arr) => { if (i > 0 && p - (arr[i - 1] as number) > 1) acc.push("…"); acc.push(p); return acc; }, []) .map((p, i) => p === "…" ? ( ) : ( ) )}
)}
); }