/** * useScanProgress Hook * * Custom React hook for managing scan progress state. * Uses TanStack Query's useScanStatus for polling (NO manual setInterval). * * @layer Presentation */ import { useState, useCallback, useEffect } from 'react'; import { infoCenterApi } from '@/infrastructure/http/api/info-center'; import { useScanStatus } from './useScanStatus'; import type { ScanJobStatus } from './useScanStatus'; /** Polling interval for scan status (ms) */ const SCAN_POLL_INTERVAL = 30000; // 30 seconds /** LocalStorage key for persisting active scan job */ const SCAN_JOB_STORAGE_KEY = 'archer_active_scan_job'; /** * Scan Status * * Represents the current scan/analysis status for "Rescan All" functionality */ export type ScanStatus = 'idle' | 'scanning' | 'completed' | 'error'; /** * Scan Progress State * * Tracks progress of "Rescan All" operation */ export interface ScanProgress { status: ScanStatus; /** Scan job ID from API (used for polling) */ jobId?: string; /** Total pages discovered by Scout */ totalPages: number; /** Pages successfully analyzed */ completedPages: number; /** Pages that failed analysis */ failedPages: number; /** Progress percentage (0-100) */ progress: number; /** Current status message from API */ currentPage?: string; startedAt?: Date; completedAt?: Date; errorMessage?: string; } /** * useScanProgress Hook Options */ export interface UseScanProgressOptions { /** * Tenant ID for checking active scans */ tenantId?: string; /** * Callback to refresh data after scan completes */ onScanComplete?: () => void; /** * Enable/disable polling (e.g., only poll when on relevant tab) * @default true */ enabled?: boolean; } /** * useScanProgress Hook Return Type */ export interface UseScanProgressReturn { /** * Current scan progress state */ scanProgress: ScanProgress; /** * Start a full site scan */ startScan: (tenantId: string) => Promise; /** * Check for active scans and resume polling if found */ checkActiveScans: (tenantId: string) => Promise; } /** * Save active scan job to localStorage (for redundancy) */ function saveActiveScanJob(tenantId: string, jobId: string): void { const data = { tenantId, jobId, startedAt: new Date().toISOString() }; localStorage.setItem(SCAN_JOB_STORAGE_KEY, JSON.stringify(data)); } /** * Clear active scan job from localStorage */ function clearActiveScanJob(): void { localStorage.removeItem(SCAN_JOB_STORAGE_KEY); } /** * Map API scan status to local scan status */ function mapApiStatusToLocal(apiStatus: ScanJobStatus['status']): ScanStatus { if (apiStatus === 'COMPLETED') { return 'completed'; } else if (apiStatus === 'FAILED' || apiStatus === 'CANCELLED') { return 'error'; } else { return 'scanning'; } } /** * Custom hook for managing scan progress polling * * Handles full site scan workflow: * 1. Trigger scan via API * 2. Poll for progress updates (using TanStack Query - NO manual setInterval) * 3. Stop polling when complete/failed * 4. Restore active scans after page refresh * * @param options - Hook options * @returns Scan progress state and control functions * * @example * ```tsx * function ScanButton({ tenantId }: { tenantId: string }) { * const { scanProgress, startScan } = useScanProgress({ * tenantId, * onScanComplete: () => { * console.log('Scan complete, refreshing data...'); * }, * }); * * return ( *
* * {scanProgress.status === 'scanning' && ( *
Progress: {scanProgress.progress}%
* )} *
* ); * } * ``` */ export function useScanProgress(options: UseScanProgressOptions = {}): UseScanProgressReturn { const { tenantId, onScanComplete, enabled = true } = options; // State const [scanProgress, setScanProgress] = useState({ status: 'idle', totalPages: 0, completedPages: 0, failedPages: 0, progress: 0, }); // Use TanStack Query for polling (NO manual setInterval!) const { status: apiStatus, isActive, } = useScanStatus({ tenantId, jobId: scanProgress.jobId, enabled: enabled && !!scanProgress.jobId, pollingInterval: SCAN_POLL_INTERVAL, onComplete: (status) => { console.log('[useScanProgress] Scan completed:', status); clearActiveScanJob(); // Update state with final status setScanProgress((prev) => ({ ...prev, status: 'completed', totalPages: status.pagesDiscovered, completedPages: status.pagesAnalyzed, failedPages: status.pagesFailed, progress: status.progress ?? 100, currentPage: status.message, completedAt: status.completedAt ? new Date(status.completedAt) : new Date(), })); // Trigger refresh callback if (onScanComplete) { onScanComplete(); } // Reset to idle after delay setTimeout(() => { setScanProgress({ status: 'idle', totalPages: 0, completedPages: 0, failedPages: 0, progress: 0, }); }, 5000); }, onError: (status) => { console.error('[useScanProgress] Scan failed/cancelled:', status); clearActiveScanJob(); // Update state with error setScanProgress((prev) => ({ ...prev, status: 'error', errorMessage: status.errorMessage ?? 'Scan failed or was cancelled', completedAt: status.completedAt ? new Date(status.completedAt) : new Date(), })); // Reset to idle after delay setTimeout(() => { setScanProgress({ status: 'idle', totalPages: 0, completedPages: 0, failedPages: 0, progress: 0, }); }, 5000); }, }); // Update scan progress when API status changes useEffect(() => { if (apiStatus && isActive) { setScanProgress((prev) => ({ ...prev, status: mapApiStatusToLocal(apiStatus.status), jobId: apiStatus.jobId, totalPages: apiStatus.pagesDiscovered, completedPages: apiStatus.pagesAnalyzed, failedPages: apiStatus.pagesFailed, progress: apiStatus.progress ?? 0, currentPage: apiStatus.message, completedAt: apiStatus.completedAt ? new Date(apiStatus.completedAt) : undefined, })); } }, [apiStatus, isActive]); /** * Check for active scans from API */ const checkActiveScans = useCallback(async (tid: string) => { console.log('[useScanProgress] Checking for active scans for tenant:', tid); try { const activeJobs = await infoCenterApi.getActiveScans(tid); console.log('[useScanProgress] Active jobs from API:', activeJobs); if (activeJobs.length > 0) { const activeJob = activeJobs[0]; // Take the most recent active job console.log('[useScanProgress] Found active job, resuming polling:', activeJob); // Set scanning state (polling will start automatically via useScanStatus) setScanProgress({ status: 'scanning', jobId: activeJob.jobId, totalPages: activeJob.pagesDiscovered, completedPages: activeJob.pagesAnalyzed, failedPages: activeJob.pagesFailed, progress: activeJob.progress, startedAt: activeJob.startedAt ? new Date(activeJob.startedAt) : new Date(activeJob.createdAt), currentPage: activeJob.message, }); // Save to localStorage for redundancy saveActiveScanJob(tid, activeJob.jobId); } } catch (error) { console.error('[useScanProgress] Error checking active scans:', error); } }, []); /** * Start a full site scan */ const startScan = useCallback(async (tid: string) => { // Prevent multiple concurrent scans if (scanProgress.status === 'scanning') { console.warn('Scan already in progress'); return; } // Set scanning state immediately setScanProgress({ status: 'scanning', totalPages: 0, completedPages: 0, failedPages: 0, progress: 0, startedAt: new Date(), currentPage: 'Initiating full site scan...', }); try { // Trigger full site scan via API const scanJob = await infoCenterApi.triggerSiteScan(tid, { priority: 'HIGH', forceRescan: true, clearExisting: false, }); console.log('Site scan job created:', scanJob); // Persist scan job to localStorage for restoration after page refresh saveActiveScanJob(tid, scanJob.jobId); // Update state with job info (polling will start automatically via useScanStatus) setScanProgress((prev) => ({ ...prev, jobId: scanJob.jobId, currentPage: scanJob.message, })); } catch (error) { console.error('Error triggering site scan:', error); setScanProgress({ status: 'error', totalPages: 0, completedPages: 0, failedPages: 0, progress: 0, errorMessage: error instanceof Error ? error.message : 'Failed to trigger site scan', completedAt: new Date(), }); // Reset to idle after showing error setTimeout(() => { setScanProgress({ status: 'idle', totalPages: 0, completedPages: 0, failedPages: 0, progress: 0, }); }, 5000); } }, [scanProgress.status]); // Check for active scans on mount (if tenantId provided and enabled) useEffect(() => { if (tenantId && enabled) { // Only check if we're not already polling if (!scanProgress.jobId) { checkActiveScans(tenantId); } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [tenantId, enabled]); // Intentionally omit checkActiveScans to prevent re-running return { scanProgress, startScan, checkActiveScans, }; }