import { ReactElement, useEffect, useState } from "react"; import { Link } from "react-router-dom"; import { useQueryClient } from "@tanstack/react-query"; import { useGetImportStatisticsQuery, useGetWantedComicsQuery, useStartIncrementalImportMutation, } from "../../graphql/generated"; import { useStore } from "../../store"; import { useShallow } from "zustand/react/shallow"; import { useImportSessionStatus } from "../../hooks/useImportSessionStatus"; /** * Import statistics with card-based layout and progress bar. * Three states: pre-import (idle), importing (active), and post-import (complete). * Also surfaces missing files detected by the file watcher. */ export const RealTimeImportStats = (): ReactElement => { const [importError, setImportError] = useState(null); const [detectedFile, setDetectedFile] = useState(null); const [socketImport, setSocketImport] = useState<{ active: boolean; completed: number; total: number; failed: number; } | null>(null); const queryClient = useQueryClient(); const { getSocket, disconnectSocket, importJobQueue } = useStore( useShallow((state) => ({ getSocket: state.getSocket, disconnectSocket: state.disconnectSocket, importJobQueue: state.importJobQueue, })), ); const { data: importStats, isLoading } = useGetImportStatisticsQuery( {}, { refetchOnWindowFocus: false, refetchInterval: false }, ); const stats = importStats?.getImportStatistics?.stats; // File list for the detail panel — only fetched when there are missing files const { data: missingComicsData } = useGetWantedComicsQuery( { paginationOptions: { limit: 3, page: 1 }, predicate: { "importStatus.isRawFileMissing": true }, }, { refetchOnWindowFocus: false, refetchInterval: false, enabled: (stats?.missingFiles ?? 0) > 0, }, ); const missingDocs = missingComicsData?.getComicBooks?.docs ?? []; const getMissingComicLabel = (comic: any): string => { const series = comic.canonicalMetadata?.series?.value ?? comic.inferredMetadata?.issue?.name; const issueNum = comic.canonicalMetadata?.issueNumber?.value ?? comic.inferredMetadata?.issue?.number; if (series && issueNum) return `${series} #${issueNum}`; if (series) return series; return comic.rawFileDetails?.name ?? comic.id; }; const importSession = useImportSessionStatus(); const { mutate: startIncrementalImport, isPending: isStartingImport } = useStartIncrementalImportMutation({ onSuccess: (data) => { if (data.startIncrementalImport.success) { importJobQueue.setStatus("running"); setImportError(null); } }, onError: (error: any) => { setImportError( error?.message || "Failed to start import. Please try again.", ); }, }); const hasNewFiles = stats && stats.newFiles > 0; const missingCount = stats?.missingFiles ?? 0; // LS_LIBRARY_STATISTICS fires after every filesystem change and every import job completion. // Invalidating GetImportStatistics covers: total files, imported, new files, and missing count. // Invalidating GetWantedComics refreshes the missing file name list in the detail panel. useEffect(() => { const socket = getSocket("/"); const handleStatsChange = () => { queryClient.invalidateQueries({ queryKey: ["GetImportStatistics"] }); queryClient.invalidateQueries({ queryKey: ["GetWantedComics"] }); }; const handleFileDetected = (payload: { filePath: string }) => { handleStatsChange(); const name = payload.filePath.split("/").pop() ?? payload.filePath; setDetectedFile(name); setTimeout(() => setDetectedFile(null), 5000); }; const handleImportStarted = () => { setSocketImport({ active: true, completed: 0, total: 0, failed: 0 }); }; const handleCoverExtracted = (payload: { completedJobCount: number; totalJobCount: number; importResult: unknown; }) => { setSocketImport((prev) => ({ active: true, completed: payload.completedJobCount, total: payload.totalJobCount, failed: prev?.failed ?? 0, })); }; const handleCoverExtractionFailed = (payload: { failedJobCount: number; importResult: unknown; }) => { setSocketImport((prev) => prev ? { ...prev, failed: payload.failedJobCount } : null, ); }; const handleQueueDrained = () => { setSocketImport((prev) => (prev ? { ...prev, active: false } : null)); handleStatsChange(); }; socket.on("LS_LIBRARY_STATS", handleStatsChange); socket.on("LS_FILES_MISSING", handleStatsChange); socket.on("LS_FILE_DETECTED", handleFileDetected); socket.on("LS_INCREMENTAL_IMPORT_STARTED", handleImportStarted); socket.on("LS_COVER_EXTRACTED", handleCoverExtracted); socket.on("LS_COVER_EXTRACTION_FAILED", handleCoverExtractionFailed); socket.on("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained); return () => { socket.off("LS_LIBRARY_STATS", handleStatsChange); socket.off("LS_FILES_MISSING", handleStatsChange); socket.off("LS_FILE_DETECTED", handleFileDetected); socket.off("LS_INCREMENTAL_IMPORT_STARTED", handleImportStarted); socket.off("LS_COVER_EXTRACTED", handleCoverExtracted); socket.off("LS_COVER_EXTRACTION_FAILED", handleCoverExtractionFailed); socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained); }; }, [getSocket, queryClient]); const handleStartImport = async () => { setImportError(null); if (importSession.isActive) { setImportError( `Cannot start import: An import session "${importSession.sessionId}" is already active. Please wait for it to complete.`, ); return; } if (importJobQueue.status === "drained") { localStorage.removeItem("sessionId"); disconnectSocket("/"); setTimeout(() => { getSocket("/"); setTimeout(() => { const sessionId = localStorage.getItem("sessionId") || ""; startIncrementalImport({ sessionId }); }, 500); }, 100); } else { const sessionId = localStorage.getItem("sessionId") || ""; startIncrementalImport({ sessionId }); } }; if (isLoading || !stats) { return
Loading...
; } const isFirstImport = stats.alreadyImported === 0; const buttonText = isFirstImport ? `Start Import (${stats.newFiles} files)` : `Start Incremental Import (${stats.newFiles} new files)`; // Determine what to show in each card based on current phase const sessionStats = importSession.stats; const hasSessionStats = importSession.isActive && sessionStats !== null; const totalFiles = stats.totalLocalFiles; const importedCount = stats.alreadyImported; const failedCount = hasSessionStats ? sessionStats!.filesFailed : 0; const showProgressBar = socketImport !== null; const socketProgressPct = socketImport && socketImport.total > 0 ? Math.round((socketImport.completed / socketImport.total) * 100) : 0; const showFailedCard = hasSessionStats && failedCount > 0; const showMissingCard = missingCount > 0; return (
{/* Error Message */} {importError && (

Import Error

{importError}

)} {/* File detected toast */} {detectedFile && (

New file detected: {detectedFile}

)} {/* Start Import button — only when idle with new files */} {hasNewFiles && !importSession.isActive && ( )} {/* Progress bar — shown while importing and once complete */} {showProgressBar && (
{socketImport!.active ? `Importing ${socketImport!.completed} / ${socketImport!.total}` : `${socketImport!.completed} / ${socketImport!.total} imported`} {socketProgressPct}% complete
{socketImport!.active && (
)}
)} {/* Stats cards */}
{/* Total files */}
{totalFiles}
in import folder
{/* Imported */}
{importedCount}
{importSession.isActive ? "imported so far" : "imported in database"}
{/* Failed — only shown after a session with failures */} {showFailedCard && (
{failedCount}
failed
)} {/* Missing files — shown when watcher detects moved/deleted files */} {showMissingCard && (
{missingCount}
missing
)}
{/* Missing files detail panel */} {showMissingCard && (

{missingCount} {missingCount === 1 ? "file" : "files"} missing

These files were previously imported but can no longer be found on disk. Move them back to restore access.

{missingDocs.length > 0 && (
    {missingDocs.map((comic, i) => (
  • {getMissingComicLabel(comic)} is missing
  • ))} {missingCount > 3 && (
  • and {missingCount - 3} more.
  • )}
)} View Missing Files In Library
)}
); }; export default RealTimeImportStats;