/** * useScanStatus Hook * * TanStack Query hook for polling scan job status. * Uses TanStack Query's built-in refetchInterval for automatic polling - NO manual setInterval. * * @layer Presentation */ import { useQuery } from '@tanstack/react-query'; import { queryKeys } from '@/lib/query-keys'; import { infoCenterApi } from '@/infrastructure/http/api/info-center'; /** * Scan Job Status from API * * Direct mapping from API response */ export interface ScanJobStatus { jobId: string; tenantId: string; status: 'QUEUED' | 'DISCOVERING' | 'RUNNING' | 'COMPLETED' | 'FAILED' | 'CANCELLED'; pagesDiscovered: number; pagesAnalyzed: number; pagesFailed: number; priority: 'LOW' | 'NORMAL' | 'HIGH'; progress: number; createdAt: string; startedAt: string | null; completedAt: string | null; errorMessage: string | null; message: string; } /** * useScanStatus Hook Options */ export interface UseScanStatusOptions { /** * Tenant ID for the scan job */ tenantId?: string; /** * Scan job ID to poll */ jobId?: string; /** * Enable/disable polling * @default true (when tenantId and jobId are provided) */ enabled?: boolean; /** * Polling interval in milliseconds * @default 30000 (30 seconds) */ pollingInterval?: number; /** * Callback when scan completes */ onComplete?: (status: ScanJobStatus) => void; /** * Callback when scan fails/cancels */ onError?: (status: ScanJobStatus) => void; } /** * useScanStatus Hook Return Type */ export interface UseScanStatusReturn { /** * Scan job status */ status: ScanJobStatus | undefined; /** * Loading state (first fetch) */ isLoading: boolean; /** * Error state */ error: Error | null; /** * Refetch function */ refetch: () => void; /** * Is the scan still active (QUEUED, DISCOVERING, RUNNING)? */ isActive: boolean; /** * Is the scan complete? */ isComplete: boolean; /** * Is the scan failed/cancelled? */ isFailed: boolean; } /** * Poll scan job status using TanStack Query * * Uses TanStack Query's built-in refetchInterval for automatic polling. * Stops polling when job is complete/failed/cancelled (isActive = false). * * Benefits over manual setInterval: * - Automatic cleanup (no memory leaks) * - Window focus awareness (pauses when tab not focused) * - Request deduplication (multiple components = 1 request) * - Error retry with exponential backoff * - Respects enabled flag * * @param options - Hook options * @returns Scan status with loading/error states * * @example * ```tsx * function ScanProgress({ tenantId, jobId }: { tenantId: string; jobId: string }) { * const { status, isLoading, isActive, isComplete } = useScanStatus({ * tenantId, * jobId, * onComplete: () => { * console.log('Scan finished!'); * }, * }); * * if (isLoading) return ; * if (!status) return null; * * return ( *
*
Status: {status.status}
*
Progress: {status.progress}%
*
Pages: {status.pagesAnalyzed} / {status.pagesDiscovered}
* {isComplete &&
Scan complete!
} *
* ); * } * ``` */ export function useScanStatus(options: UseScanStatusOptions = {}): UseScanStatusReturn { const { tenantId, jobId, enabled, pollingInterval = 30000, // 30 seconds default (scans take minutes) onComplete, onError, } = options; // Query with automatic polling via refetchInterval const query = useQuery({ queryKey: queryKeys.infoCenter.scanStatus(tenantId ?? '', jobId ?? ''), queryFn: async () => { if (!tenantId || !jobId) { throw new Error('tenantId and jobId are required'); } const result = await infoCenterApi.getScanStatus(tenantId, jobId); return result; }, enabled: enabled !== undefined ? enabled : !!(tenantId && jobId), // TanStack Query handles polling - NO manual setInterval needed! refetchInterval: (query) => { const status = query.state.data; // Stop polling if job is complete/failed/cancelled if (status && ( status.status === 'COMPLETED' || status.status === 'FAILED' || status.status === 'CANCELLED' )) { return false; // Stop polling } // Continue polling at specified interval return pollingInterval; }, refetchIntervalInBackground: false, // Pause polling when window not focused (battery savings) retry: (failureCount, error) => { // Stop retrying if job not found (404) if ((error as any)?.status === 404) { return false; } // Retry up to 3 times for other errors return failureCount < 3; }, staleTime: 0, // Always fetch fresh data when polling gcTime: 5 * 60 * 1000, // 5 minutes }); // Compute derived states const isActive = query.data ? ( query.data.status === 'QUEUED' || query.data.status === 'DISCOVERING' || query.data.status === 'RUNNING' ) : false; const isComplete = query.data?.status === 'COMPLETED'; const isFailed = query.data ? ( query.data.status === 'FAILED' || query.data.status === 'CANCELLED' ) : false; // Trigger callbacks when status changes if (query.data) { if (isComplete && onComplete) { // Use setTimeout to avoid setState during render setTimeout(() => onComplete(query.data!), 0); } if (isFailed && onError) { // Use setTimeout to avoid setState during render setTimeout(() => onError(query.data!), 0); } } return { status: query.data, isLoading: query.isLoading, error: query.error as Error | null, refetch: query.refetch, isActive, isComplete, isFailed, }; }