/** * IndexingProgress - Displays job progress with polling. * * Features: * - Polls /api/jobs/:id at regular intervals * - Shows running/completed/failed states * - Displays elapsed time and summary on completion * - Compact and expanded variants */ import { AlertCircleIcon, CheckCircle2Icon, FileTextIcon, Loader2Icon, TimerIcon, } from "lucide-react"; import { useEffect, useState } from "react"; import { apiFetch } from "../hooks/use-api"; import { cn } from "../lib/utils"; import { Badge } from "./ui/badge"; export interface JobStatus { id: string; type: "add" | "sync"; status: "running" | "completed" | "failed"; createdAt: number; result?: SyncResult; error?: string; } interface SyncResult { collections: CollectionResult[]; totalDurationMs: number; totalFilesProcessed: number; totalFilesAdded: number; totalFilesUpdated: number; totalFilesErrored: number; totalFilesSkipped: number; } interface CollectionResult { collection: string; filesProcessed: number; filesAdded: number; filesUpdated: number; filesUnchanged: number; filesErrored: number; durationMs: number; } function isNoopSyncResult(result: SyncResult | undefined): boolean { if (!result) { return false; } return ( result.totalFilesProcessed === 0 && result.totalFilesAdded === 0 && result.totalFilesUpdated === 0 && result.totalFilesErrored === 0 ); } export interface IndexingProgressProps { /** Job ID to poll */ jobId: string; /** Called when job completes successfully */ onComplete?: (result: SyncResult) => void; /** Called when job fails */ onError?: (error: string) => void; /** Compact mode for inline display */ compact?: boolean; /** Additional CSS classes */ className?: string; } function formatElapsed(ms: number): string { const seconds = Math.floor(ms / 1000); if (seconds < 60) { return `${seconds}s`; } const minutes = Math.floor(seconds / 60); const remainingSeconds = seconds % 60; return `${minutes}m ${remainingSeconds}s`; } function formatDuration(ms: number): string { if (ms < 1000) { return `${ms}ms`; } return `${(ms / 1000).toFixed(1)}s`; } export function IndexingProgress({ jobId, onComplete, onError, compact = false, className, }: IndexingProgressProps) { const [status, setStatus] = useState(null); const [elapsed, setElapsed] = useState(0); const [fetchError, setFetchError] = useState(null); useEffect(() => { // Each effect owns its requests and timers, including across quick restarts. const controller = new AbortController(); let pollTimeout: ReturnType | undefined; const startTime = Date.now(); const elapsedInterval = setInterval(() => { setElapsed(Date.now() - startTime); }, 1000); setStatus(null); setFetchError(null); setElapsed(0); const poll = async () => { if (controller.signal.aborted) { return; } const { data, error: err } = await apiFetch( `/api/jobs/${encodeURIComponent(jobId)}`, { signal: controller.signal } ); // Abort may race with response parsing; never publish or reschedule it. if (controller.signal.aborted) { return; } if (err || !data) { setFetchError(err ?? "No response from server"); clearInterval(elapsedInterval); return; } setStatus(data); setFetchError(null); if (data.status === "running") { pollTimeout = setTimeout(() => { void poll(); }, 1000); return; } clearInterval(elapsedInterval); if (data.status === "completed" && data.result) { onComplete?.(data.result); } else if (data.status === "failed") { onError?.(data.error ?? "Unknown error"); } }; void poll(); return () => { controller.abort(); clearTimeout(pollTimeout); clearInterval(elapsedInterval); }; }, [jobId, onComplete, onError]); // Loading/error before first status if (!status && !fetchError) { return (
Loading...
); } if (fetchError) { return (
{fetchError}
); } // Compact mode if (compact) { if (status?.status === "running") { return (
Indexing... {formatElapsed(elapsed)}
); } if (status?.status === "completed") { const result = status.result; return (
{isNoopSyncResult(result) ? "Up to date" : `${result?.totalFilesProcessed ?? 0} files in ${formatDuration(result?.totalDurationMs ?? 0)}`}
); } if (status?.status === "failed") { return (
Failed
); } } // Expanded mode (default) return (
{/* Status header */}
{status?.status === "running" && ( <> Indexing in progress )} {status?.status === "completed" && ( <> Indexing complete )} {status?.status === "failed" && ( <> Indexing failed )}
{status?.status === "running" ? formatElapsed(elapsed) : formatDuration(status?.result?.totalDurationMs ?? elapsed)}
{/* Error message */} {status?.status === "failed" && status.error && (
{status.error}
)} {/* Success summary */} {status?.status === "completed" && status.result && (
{isNoopSyncResult(status.result) && (
No file changes found. Workspace is already up to date.
)}
{status.result.totalFilesProcessed} processed {status.result.totalFilesAdded > 0 && ( +{status.result.totalFilesAdded} added )} {status.result.totalFilesUpdated > 0 && ( {status.result.totalFilesUpdated} updated )} {status.result.totalFilesErrored > 0 && ( {status.result.totalFilesErrored} errors )}
{/* Per-collection breakdown */} {status.result.collections.length > 1 && (
{status.result.collections.map((col) => (
{col.collection} {col.filesProcessed} files ยท{" "} {formatDuration(col.durationMs)}
))}
)}
)}
); }