From 3daa49ae6c1a6fee5514a09f6f7b2f22df8a060c Mon Sep 17 00:00:00 2001 From: Froidefond Julien Date: Sat, 21 Mar 2026 06:52:57 +0100 Subject: [PATCH] feat: add live refresh to job detail page via SSE The job detail page was only server-rendered with no live updates, unlike the jobs list page. Add a lightweight JobDetailLive client component that subscribes to the existing SSE endpoint and calls router.refresh() on each update, keeping the page in sync while a job is running. Co-Authored-By: Claude Opus 4.6 --- .../app/components/JobDetailLive.tsx | 44 +++++++++++++++++++ apps/backoffice/app/jobs/[id]/page.tsx | 5 +++ 2 files changed, 49 insertions(+) create mode 100644 apps/backoffice/app/components/JobDetailLive.tsx diff --git a/apps/backoffice/app/components/JobDetailLive.tsx b/apps/backoffice/app/components/JobDetailLive.tsx new file mode 100644 index 0000000..67731ef --- /dev/null +++ b/apps/backoffice/app/components/JobDetailLive.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { useRouter } from "next/navigation"; + +interface JobDetailLiveProps { + jobId: string; + isTerminal: boolean; +} + +export function JobDetailLive({ jobId, isTerminal }: JobDetailLiveProps) { + const router = useRouter(); + const isTerminalRef = useRef(isTerminal); + isTerminalRef.current = isTerminal; + + useEffect(() => { + if (isTerminalRef.current) return; + + const eventSource = new EventSource(`/api/jobs/${jobId}/stream`); + + eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + router.refresh(); + + if (data.status === "success" || data.status === "failed" || data.status === "cancelled") { + eventSource.close(); + } + } catch { + // ignore parse errors + } + }; + + eventSource.onerror = () => { + eventSource.close(); + }; + + return () => { + eventSource.close(); + }; + }, [jobId, router]); + + return null; +} diff --git a/apps/backoffice/app/jobs/[id]/page.tsx b/apps/backoffice/app/jobs/[id]/page.tsx index 4bbf325..95b371d 100644 --- a/apps/backoffice/app/jobs/[id]/page.tsx +++ b/apps/backoffice/app/jobs/[id]/page.tsx @@ -1,3 +1,5 @@ +export const dynamic = "force-dynamic"; + import { notFound } from "next/navigation"; import Link from "next/link"; import { apiFetch, getMetadataBatchReport, getMetadataBatchResults, getMetadataRefreshReport, MetadataBatchReportDto, MetadataBatchResultDto, MetadataRefreshReportDto } from "../../../lib/api"; @@ -5,6 +7,7 @@ import { Card, CardHeader, CardTitle, CardDescription, CardContent, StatusBadge, JobTypeBadge, StatBox, ProgressBar } from "../../components/ui"; +import { JobDetailLive } from "../../components/JobDetailLive"; import { getServerTranslations } from "../../../lib/i18n/server"; interface JobDetailPageProps { @@ -158,6 +161,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) { const isCompleted = job.status === "success"; const isFailed = job.status === "failed"; const isCancelled = job.status === "cancelled"; + const isTerminal = isCompleted || isFailed || isCancelled; const isExtractingPages = job.status === "extracting_pages"; const isThumbnailPhase = job.status === "generating_thumbnails"; const isPhase2 = isExtractingPages || isThumbnailPhase; @@ -199,6 +203,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) { return ( <> +