Commit a4285bee authored by ThinhNC's avatar ThinhNC

Merge fix/export-custom-filename: resolve false disconnect status on job...

Merge fix/export-custom-filename: resolve false disconnect status on job completion and display status badge in header
parents 78d89caa 8cad79b1
Pipeline #53752 passed with stage
in 1 minute and 23 seconds
...@@ -38,6 +38,7 @@ import { ...@@ -38,6 +38,7 @@ import {
Activity, Activity,
AlertCircle, AlertCircle,
ArrowLeft, ArrowLeft,
CheckCircle2,
Clock, Clock,
Copy, Copy,
Download, Download,
...@@ -48,6 +49,7 @@ import { ...@@ -48,6 +49,7 @@ import {
Layers, Layers,
ListOrdered, ListOrdered,
Loader2, Loader2,
Pause,
RefreshCw, RefreshCw,
RotateCcw, RotateCcw,
Search, Search,
...@@ -114,9 +116,18 @@ export default function CrawlJobDetailPage({ params }: PageProps) { ...@@ -114,9 +116,18 @@ export default function CrawlJobDetailPage({ params }: PageProps) {
const wasRunningRef = useRef<boolean>(false); const wasRunningRef = useRef<boolean>(false);
const hasNotifiedRef = useRef<boolean>(false); const hasNotifiedRef = useRef<boolean>(false);
const isJobInitialActive = !initialJob || initialJob.status === "RUNNING" || initialJob.status === "PROCESSING_EXPORT" || initialJob.status === "PENDING"; const isJobInitialActive =
!initialJob ||
initialJob.status === "RUNNING" ||
initialJob.status === "PROCESSING_EXPORT" ||
initialJob.status === "PENDING" ||
initialJob.status === "QUEUED";
const isJobActive = liveProgressJob const isJobActive = liveProgressJob
? liveProgressJob.status === "RUNNING" || liveProgressJob.status === "PROCESSING_EXPORT" || liveProgressJob.status === "PENDING" ? liveProgressJob.status === "RUNNING" ||
liveProgressJob.status === "PROCESSING_EXPORT" ||
liveProgressJob.status === "PENDING" ||
liveProgressJob.status === "QUEUED"
: isJobInitialActive; : isJobInitialActive;
const { const {
...@@ -135,8 +146,17 @@ export default function CrawlJobDetailPage({ params }: PageProps) { ...@@ -135,8 +146,17 @@ export default function CrawlJobDetailPage({ params }: PageProps) {
wasRunningRef.current = true; wasRunningRef.current = true;
} }
}, },
onDone: () => { onDone: (doneData) => {
refetchJob(); if (doneData?.status) {
setLiveProgressJob((prev) =>
prev ? { ...prev, status: doneData.status as CrawlJob["status"] } : null
);
}
refetchJob().then((res) => {
if (res.data) {
setLiveProgressJob(null);
}
});
if (job) { if (job) {
notifyJobCompletion({ notifyJobCompletion({
jobId: job.id, jobId: job.id,
...@@ -316,13 +336,50 @@ export default function CrawlJobDetailPage({ params }: PageProps) { ...@@ -316,13 +336,50 @@ export default function CrawlJobDetailPage({ params }: PageProps) {
{job?.mode || "SCRAPE"} {job?.mode || "SCRAPE"}
</Badge> </Badge>
{/* SSE Live Status Badge with 3-state Exponential Backoff: Green / Yellow / Red */} {/* Job Status Badge right in header for immediate clarity */}
{job?.status && (
<Badge
variant={
job.status === "COMPLETED"
? "info"
: job.status === "RUNNING" || job.status === "PROCESSING_EXPORT"
? "default"
: job.status === "FAILED"
? "destructive"
: job.status === "CANCELED"
? "warning"
: "secondary"
}
className="text-xs font-bold rounded-full px-3 py-1 leading-none inline-flex items-center gap-1.5"
>
{(job.status === "RUNNING" || job.status === "PROCESSING_EXPORT") && (
<span className="h-2 w-2 animate-pulse rounded-full bg-emerald-500" />
)}
{job.status === "COMPLETED" && (
<CheckCircle2 className="h-3 w-3 text-cyan-600 dark:text-cyan-400" />
)}
{job.status === "FAILED" && (
<AlertCircle className="h-3 w-3 text-rose-500" />
)}
{job.status === "CANCELED" && (
<Pause className="h-3 w-3 text-amber-500" />
)}
{(job.status === "PENDING" || job.status === "QUEUED") && (
<Clock className="h-3 w-3 text-muted-foreground" />
)}
<span>{getStatusText(job.status)}</span>
</Badge>
)}
{/* SSE Live Status Badge with 3-state Exponential Backoff: Green / Yellow / Red (only shown while job is active) */}
{isJobActive && (
<SseStatusBadge <SseStatusBadge
status={sseStatus} status={sseStatus}
retryCount={sseRetryCount} retryCount={sseRetryCount}
nextRetryDelayMs={sseNextRetryDelayMs} nextRetryDelayMs={sseNextRetryDelayMs}
onReconnect={reconnectSse} onReconnect={reconnectSse}
/> />
)}
{/* Quota Exemption Badge if failed due to connection loss */} {/* Quota Exemption Badge if failed due to connection loss */}
{isConnectionWaived && ( {isConnectionWaived && (
......
...@@ -73,7 +73,12 @@ export function SseStatusBadge({ ...@@ -73,7 +73,12 @@ export function SseStatusBadge({
); );
} }
// ERROR or closed unexpectedly // Stream is closed normally or inactive - do not display error badge
if (status === "CLOSED") {
return null;
}
// ERROR or disconnected unexpectedly
return ( return (
<div <div
className={cn( className={cn(
......
...@@ -60,6 +60,10 @@ export function useCrawlJob(id: string) { ...@@ -60,6 +60,10 @@ export function useCrawlJob(id: string) {
queryFn: () => crawlJobService.getJobById(id), queryFn: () => crawlJobService.getJobById(id),
enabled: Boolean(id), enabled: Boolean(id),
staleTime: 4000, staleTime: 4000,
refetchInterval: (q) => {
const job = q.state.data as CrawlJob | undefined;
return job && ACTIVE_STATUSES.includes(job.status) ? 4000 : false;
},
}); });
} }
......
...@@ -84,6 +84,7 @@ export function useEventSource<T = unknown>( ...@@ -84,6 +84,7 @@ export function useEventSource<T = unknown>(
const retryCountRef = useRef(0); const retryCountRef = useRef(0);
const retryTimerRef = useRef<NodeJS.Timeout | null>(null); const retryTimerRef = useRef<NodeJS.Timeout | null>(null);
const connectRef = useRef<() => void>(() => {}); const connectRef = useRef<() => void>(() => {});
const isDoneRef = useRef(false);
// Safe ref callbacks to avoid unnecessary reconnections // Safe ref callbacks to avoid unnecessary reconnections
const callbacksRef = useRef({ onInitial, onProgress, onDone, onMessage, onError }); const callbacksRef = useRef({ onInitial, onProgress, onDone, onMessage, onError });
...@@ -106,9 +107,12 @@ export function useEventSource<T = unknown>( ...@@ -106,9 +107,12 @@ export function useEventSource<T = unknown>(
cleanUp(); cleanUp();
if (!url || !enabled || typeof window === "undefined") { if (!url || !enabled || typeof window === "undefined") {
setStatus("CLOSED");
return; return;
} }
isDoneRef.current = false;
try { try {
const es = new EventSource(url, { withCredentials }); const es = new EventSource(url, { withCredentials });
eventSourceRef.current = es; eventSourceRef.current = es;
...@@ -149,6 +153,7 @@ export function useEventSource<T = unknown>( ...@@ -149,6 +153,7 @@ export function useEventSource<T = unknown>(
// Handle custom SSE event: "done" // Handle custom SSE event: "done"
es.addEventListener("done", (e: MessageEvent) => { es.addEventListener("done", (e: MessageEvent) => {
isDoneRef.current = true;
try { try {
const parsed = JSON.parse(e.data); const parsed = JSON.parse(e.data);
setLastEventName("done"); setLastEventName("done");
...@@ -174,6 +179,12 @@ export function useEventSource<T = unknown>( ...@@ -174,6 +179,12 @@ export function useEventSource<T = unknown>(
}; };
es.onerror = (err) => { es.onerror = (err) => {
// If stream ended naturally via "done" event, ignore server connection close
if (isDoneRef.current) {
es.close();
return;
}
setError(err); setError(err);
callbacksRef.current.onError?.(err); callbacksRef.current.onError?.(err);
...@@ -225,6 +236,7 @@ export function useEventSource<T = unknown>( ...@@ -225,6 +236,7 @@ export function useEventSource<T = unknown>(
}, [connect, cleanUp]); }, [connect, cleanUp]);
const reconnect = useCallback(() => { const reconnect = useCallback(() => {
isDoneRef.current = false;
retryCountRef.current = 0; retryCountRef.current = 0;
setRetryCount(0); setRetryCount(0);
setNextRetryDelayMs(0); setNextRetryDelayMs(0);
...@@ -233,6 +245,7 @@ export function useEventSource<T = unknown>( ...@@ -233,6 +245,7 @@ export function useEventSource<T = unknown>(
}, [connect]); }, [connect]);
const close = useCallback(() => { const close = useCallback(() => {
isDoneRef.current = true;
cleanUp(); cleanUp();
setStatus("CLOSED"); setStatus("CLOSED");
}, [cleanUp]); }, [cleanUp]);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment