diff --git a/src/client/components/Import/Import.tsx b/src/client/components/Import/Import.tsx
index 9025e10..e86a488 100644
--- a/src/client/components/Import/Import.tsx
+++ b/src/client/components/Import/Import.tsx
@@ -7,9 +7,10 @@ import { useShallow } from "zustand/react/shallow";
import axios from "axios";
import {
useGetJobResultStatisticsQuery,
- useGetImportStatisticsQuery,
+ useGetCachedImportStatisticsQuery,
useStartIncrementalImportMutation
} from "../../graphql/generated";
+import { RealTimeImportStats } from "./RealTimeImportStats";
interface ImportProps {
path: string;
@@ -22,7 +23,6 @@ interface ImportProps {
export const Import = (props: ImportProps): ReactElement => {
const queryClient = useQueryClient();
const [socketReconnectTrigger, setSocketReconnectTrigger] = useState(0);
- const [showPreview, setShowPreview] = useState(false);
const { importJobQueue, getSocket, disconnectSocket } = useStore(
useShallow((state) => ({
importJobQueue: state.importJobQueue,
@@ -31,23 +31,10 @@ export const Import = (props: ImportProps): ReactElement => {
})),
);
- const {
- data: importStats,
- isLoading: isLoadingStats,
- refetch: refetchStats
- } = useGetImportStatisticsQuery(
- {},
- {
- enabled: showPreview,
- refetchOnWindowFocus: false,
- }
- );
-
const { mutate: startIncrementalImport, isPending: isStartingImport } = useStartIncrementalImportMutation({
onSuccess: (data) => {
if (data.startIncrementalImport.success) {
importJobQueue.setStatus("running");
- setShowPreview(false);
}
},
});
@@ -64,6 +51,21 @@ export const Import = (props: ImportProps): ReactElement => {
});
const { data, isError, isLoading, refetch } = useGetJobResultStatisticsQuery();
+
+ // Get cached import statistics to determine if Start Import button should be shown
+ const { data: cachedStats } = useGetCachedImportStatisticsQuery(
+ {},
+ {
+ refetchOnWindowFocus: false,
+ refetchInterval: false,
+ }
+ );
+
+ // Determine if we should show the Start Import button
+ const hasNewFiles = cachedStats?.getCachedImportStatistics?.success &&
+ cachedStats.getCachedImportStatistics.stats &&
+ cachedStats.getCachedImportStatistics.stats.newFiles > 0 &&
+ cachedStats.getCachedImportStatistics.stats.pendingFiles === 0;
useEffect(() => {
const socket = getSocket("/");
@@ -94,11 +96,6 @@ export const Import = (props: ImportProps): ReactElement => {
);
};
- const handleShowPreview = () => {
- setShowPreview(true);
- refetchStats();
- };
-
/**
* Starts smart import, resetting session if queue was drained
*/
@@ -188,6 +185,11 @@ export const Import = (props: ImportProps): ReactElement => {
+ {/* Real-Time Import Statistics Widget */}
+
+
+
+
{
- {!showPreview && (importJobQueue.status === "drained" || importJobQueue.status === undefined) && (
-
+ {/* Start Smart Import Button - shown when there are new files and no import is running */}
+ {hasNewFiles &&
+ (importJobQueue.status === "drained" || importJobQueue.status === undefined) && (
+
- Preview Import
+
+ {isStartingImport ? "Starting Import..." : `Start Smart Import (${cachedStats?.getCachedImportStatistics?.stats?.newFiles} new files)`}
+
-
+
)}
- {/* Import Preview Panel */}
- {showPreview && !isLoadingStats && importStats?.getImportStatistics && (
-
-
-
- Import Preview
-
-
-
-
-
-
-
- Directory: {importStats.getImportStatistics.directory}
-
-
-
-
-
-
- {importStats.getImportStatistics.stats.totalLocalFiles}
-
-
- Total Files
-
-
-
-
-
- {importStats.getImportStatistics.stats.newFiles}
-
-
- New Comics
-
-
-
-
-
- {importStats.getImportStatistics.stats.alreadyImported}
-
-
- Already Imported
-
-
-
-
-
- {(() => {
- const percentage = importStats.getImportStatistics.stats.percentageImported;
- const numValue = typeof percentage === 'number' ? percentage : parseFloat(percentage);
- return !isNaN(numValue) ? numValue.toFixed(1) : '0.0';
- })()}%
-
-
- Already in Library
-
-
-
-
- {importStats.getImportStatistics.stats.newFiles > 0 && (
-
-
-
- Ready to import {importStats.getImportStatistics.stats.newFiles} new comic{importStats.getImportStatistics.stats.newFiles !== 1 ? 's' : ''}!
-
-
- {importStats.getImportStatistics.stats.alreadyImported} comic{importStats.getImportStatistics.stats.alreadyImported !== 1 ? 's' : ''} will be skipped (already in library).
-
-
-
- )}
-
- {importStats.getImportStatistics.stats.newFiles === 0 && (
-
-
-
- No new comics to import!
-
-
- All {importStats.getImportStatistics.stats.totalLocalFiles} comic{importStats.getImportStatistics.stats.totalLocalFiles !== 1 ? 's' : ''} in the directory {importStats.getImportStatistics.stats.totalLocalFiles !== 1 ? 'are' : 'is'} already in your library.
-
-
-
- )}
-
-
- {importStats.getImportStatistics.stats.newFiles > 0 && (
-
-
- {isStartingImport ? "Starting..." : "Start Smart Import"}
-
-
-
-
-
- )}
- setShowPreview(false)}
- >
- Cancel
-
-
-
-
- )}
-
- {showPreview && isLoadingStats && (
-
-
-
- Analyzing comics folder...
-
-
- )}
{(importJobQueue.status === "running" ||
importJobQueue.status === "paused") && (
diff --git a/src/client/components/Import/RealTimeImportStats.tsx b/src/client/components/Import/RealTimeImportStats.tsx
new file mode 100644
index 0000000..df2f71a
--- /dev/null
+++ b/src/client/components/Import/RealTimeImportStats.tsx
@@ -0,0 +1,290 @@
+import React, { ReactElement, useEffect, useState } from "react";
+import { useGetCachedImportStatisticsQuery } from "../../graphql/generated";
+import { useStore } from "../../store";
+import { format } from "date-fns";
+
+interface ImportStatsData {
+ totalLocalFiles: number;
+ alreadyImported: number;
+ newFiles: number;
+ percentageImported: string;
+ pendingFiles: number;
+ lastUpdated: string;
+}
+
+/**
+ * Real-time import statistics widget
+ * Displays live statistics from the file watcher and updates via Socket.IO
+ */
+export const RealTimeImportStats = (): ReactElement => {
+ const [stats, setStats] = useState
(null);
+ const [isConnected, setIsConnected] = useState(false);
+ const getSocket = useStore((state) => state.getSocket);
+
+ // Fetch initial cached statistics
+ const {
+ data: cachedStats,
+ isLoading,
+ error,
+ refetch,
+ } = useGetCachedImportStatisticsQuery(
+ {},
+ {
+ refetchOnWindowFocus: false,
+ refetchInterval: false,
+ }
+ );
+
+ // Set initial stats from GraphQL query
+ useEffect(() => {
+ if (cachedStats?.getCachedImportStatistics?.success && cachedStats.getCachedImportStatistics.stats) {
+ setStats({
+ totalLocalFiles: cachedStats.getCachedImportStatistics.stats.totalLocalFiles,
+ alreadyImported: cachedStats.getCachedImportStatistics.stats.alreadyImported,
+ newFiles: cachedStats.getCachedImportStatistics.stats.newFiles,
+ percentageImported: cachedStats.getCachedImportStatistics.stats.percentageImported,
+ pendingFiles: cachedStats.getCachedImportStatistics.stats.pendingFiles,
+ lastUpdated: cachedStats.getCachedImportStatistics.lastUpdated || new Date().toISOString(),
+ });
+ }
+ }, [cachedStats]);
+
+ // Setup Socket.IO listener for real-time updates
+ useEffect(() => {
+ const socket = getSocket("/");
+
+ const handleConnect = () => {
+ setIsConnected(true);
+ console.log("Real-time import stats: Socket connected");
+ };
+
+ const handleDisconnect = () => {
+ setIsConnected(false);
+ console.log("Real-time import stats: Socket disconnected");
+ };
+
+ const handleStatsUpdate = (data: any) => {
+ console.log("Real-time import stats update received:", data);
+ if (data.stats) {
+ setStats({
+ totalLocalFiles: data.stats.totalLocalFiles,
+ alreadyImported: data.stats.alreadyImported,
+ newFiles: data.stats.newFiles,
+ percentageImported: data.stats.percentageImported,
+ pendingFiles: data.stats.pendingFiles || 0,
+ lastUpdated: data.lastUpdated || new Date().toISOString(),
+ });
+ }
+ };
+
+ socket.on("connect", handleConnect);
+ socket.on("disconnect", handleDisconnect);
+ socket.on("IMPORT_STATISTICS_UPDATED", handleStatsUpdate);
+
+ // Check initial connection state
+ if (socket.connected) {
+ setIsConnected(true);
+ }
+
+ return () => {
+ socket.off("connect", handleConnect);
+ socket.off("disconnect", handleDisconnect);
+ socket.off("IMPORT_STATISTICS_UPDATED", handleStatsUpdate);
+ };
+ }, [getSocket]);
+
+ if (isLoading) {
+ return (
+
+
+
+
+ Loading statistics...
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
+
+
+
+ Error loading statistics
+
+
+
+ );
+ }
+
+ // Handle cache not initialized or no stats available
+ if (!stats || cachedStats?.getCachedImportStatistics?.success === false) {
+ return (
+
+
+
+
+
+
+
+
+ Statistics Cache Initializing
+
+
+ {cachedStats?.getCachedImportStatistics?.message || "The file watcher is starting up. Statistics will appear shortly."}
+
+
+
+
refetch()}
+ className="flex items-center gap-1 px-3 py-2 text-sm rounded-lg border border-yellow-400 bg-yellow-100 dark:bg-yellow-800 text-yellow-700 dark:text-yellow-200 hover:bg-yellow-200 dark:hover:bg-yellow-700 transition-colors"
+ title="Retry"
+ >
+
+
+
+ Retry
+
+
+
+ );
+ }
+
+ const percentageValue = typeof stats.percentageImported === 'number'
+ ? stats.percentageImported
+ : parseFloat(stats.percentageImported);
+
+ return (
+
+ {/* Header with connection status */}
+
+
+
+
+
+ Real-Time Folder Statistics
+
+
+
+
+ {isConnected ? "Live" : "Offline"}
+
+
+
+
+ {/* Statistics Grid */}
+
+
+
+ {stats.totalLocalFiles}
+
+
+ Total Files
+
+
+
+
+
+ {stats.newFiles}
+
+
+ New Files
+
+
+
+
+
+ {stats.alreadyImported}
+
+
+ Imported
+
+
+
+
+
+ {stats.pendingFiles}
+
+
+ Pending
+
+
+
+
+
+ {!isNaN(percentageValue) ? percentageValue.toFixed(1) : '0.0'}%
+
+
+ In Library
+
+
+
+
+ {/* Last Updated */}
+
+
+
+
+
+ Last updated: {format(new Date(stats.lastUpdated), "MMM d, yyyy 'at' h:mm:ss a")}
+
+ refetch()}
+ className="flex items-center gap-1 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
+ title="Refresh statistics"
+ >
+
+
+
+ Refresh
+
+
+
+ {/* Info message about pending files */}
+ {stats.pendingFiles > 0 && (
+
+
+ {stats.pendingFiles} file{stats.pendingFiles !== 1 ? 's are' : ' is'} being stabilized before import (write in progress).
+
+
+ )}
+
+ {/* Info message about new files */}
+ {stats.newFiles > 0 && stats.pendingFiles === 0 && (
+
+
+ {stats.newFiles} new file{stats.newFiles !== 1 ? 's are' : ' is'} ready to be imported.
+
+
+ )}
+
+ {/* All caught up message */}
+ {stats.newFiles === 0 && stats.pendingFiles === 0 && stats.totalLocalFiles > 0 && (
+
+
+
+
+
+ All files in the folder are already imported!
+
+
+ )}
+
+ );
+};
+
+export default RealTimeImportStats;
diff --git a/src/client/graphql/generated.ts b/src/client/graphql/generated.ts
index 76d49f0..895ef23 100644
--- a/src/client/graphql/generated.ts
+++ b/src/client/graphql/generated.ts
@@ -52,6 +52,23 @@ export type AutoMergeSettingsInput = {
onMetadataUpdate?: InputMaybe;
};
+export type CachedImportStatistics = {
+ __typename?: 'CachedImportStatistics';
+ lastUpdated?: Maybe;
+ message?: Maybe;
+ stats?: Maybe;
+ success: Scalars['Boolean']['output'];
+};
+
+export type CachedImportStats = {
+ __typename?: 'CachedImportStats';
+ alreadyImported: Scalars['Int']['output'];
+ newFiles: Scalars['Int']['output'];
+ pendingFiles: Scalars['Int']['output'];
+ percentageImported: Scalars['String']['output'];
+ totalLocalFiles: Scalars['Int']['output'];
+};
+
export type CanonicalMetadata = {
__typename?: 'CanonicalMetadata';
ageRating?: Maybe;
@@ -615,6 +632,7 @@ export type Query = {
comics: ComicConnection;
/** Fetch resource from Metron API */
fetchMetronResource: MetronResponse;
+ getCachedImportStatistics: CachedImportStatistics;
getComicBookGroups: Array;
getComicBooks: ComicBooksResult;
/** Get generic ComicVine resource (issues, volumes, etc.) */
@@ -1084,6 +1102,11 @@ export type GetImportStatisticsQueryVariables = Exact<{
export type GetImportStatisticsQuery = { __typename?: 'Query', getImportStatistics: { __typename?: 'ImportStatistics', success: boolean, directory: string, stats: { __typename?: 'ImportStats', totalLocalFiles: number, alreadyImported: number, newFiles: number, percentageImported: string } } };
+export type GetCachedImportStatisticsQueryVariables = Exact<{ [key: string]: never; }>;
+
+
+export type GetCachedImportStatisticsQuery = { __typename?: 'Query', getCachedImportStatistics: { __typename?: 'CachedImportStatistics', success: boolean, message?: string | null, lastUpdated?: string | null, stats?: { __typename?: 'CachedImportStats', totalLocalFiles: number, alreadyImported: number, newFiles: number, percentageImported: string, pendingFiles: number } | null } };
+
export type StartNewImportMutationVariables = Exact<{
sessionId: Scalars['String']['input'];
}>;
@@ -1884,6 +1907,65 @@ useInfiniteGetImportStatisticsQuery.getKey = (variables?: GetImportStatisticsQue
useGetImportStatisticsQuery.fetcher = (variables?: GetImportStatisticsQueryVariables, options?: RequestInit['headers']) => fetcher(GetImportStatisticsDocument, variables, options);
+export const GetCachedImportStatisticsDocument = `
+ query GetCachedImportStatistics {
+ getCachedImportStatistics {
+ success
+ message
+ stats {
+ totalLocalFiles
+ alreadyImported
+ newFiles
+ percentageImported
+ pendingFiles
+ }
+ lastUpdated
+ }
+}
+ `;
+
+export const useGetCachedImportStatisticsQuery = <
+ TData = GetCachedImportStatisticsQuery,
+ TError = unknown
+ >(
+ variables?: GetCachedImportStatisticsQueryVariables,
+ options?: Omit, 'queryKey'> & { queryKey?: UseQueryOptions['queryKey'] }
+ ) => {
+
+ return useQuery(
+ {
+ queryKey: variables === undefined ? ['GetCachedImportStatistics'] : ['GetCachedImportStatistics', variables],
+ queryFn: fetcher(GetCachedImportStatisticsDocument, variables),
+ ...options
+ }
+ )};
+
+useGetCachedImportStatisticsQuery.getKey = (variables?: GetCachedImportStatisticsQueryVariables) => variables === undefined ? ['GetCachedImportStatistics'] : ['GetCachedImportStatistics', variables];
+
+export const useInfiniteGetCachedImportStatisticsQuery = <
+ TData = InfiniteData,
+ TError = unknown
+ >(
+ variables: GetCachedImportStatisticsQueryVariables,
+ options: Omit, 'queryKey'> & { queryKey?: UseInfiniteQueryOptions['queryKey'] }
+ ) => {
+
+ return useInfiniteQuery(
+ (() => {
+ const { queryKey: optionsQueryKey, ...restOptions } = options;
+ return {
+ queryKey: optionsQueryKey ?? variables === undefined ? ['GetCachedImportStatistics.infinite'] : ['GetCachedImportStatistics.infinite', variables],
+ queryFn: (metaData) => fetcher(GetCachedImportStatisticsDocument, {...variables, ...(metaData.pageParam ?? {})})(),
+ ...restOptions
+ }
+ })()
+ )};
+
+useInfiniteGetCachedImportStatisticsQuery.getKey = (variables?: GetCachedImportStatisticsQueryVariables) => variables === undefined ? ['GetCachedImportStatistics.infinite'] : ['GetCachedImportStatistics.infinite', variables];
+
+
+useGetCachedImportStatisticsQuery.fetcher = (variables?: GetCachedImportStatisticsQueryVariables, options?: RequestInit['headers']) => fetcher(GetCachedImportStatisticsDocument, variables, options);
+
export const StartNewImportDocument = `
mutation StartNewImport($sessionId: String!) {
startNewImport(sessionId: $sessionId) {
diff --git a/src/client/graphql/queries/import.graphql b/src/client/graphql/queries/import.graphql
index c896f96..e8e0206 100644
--- a/src/client/graphql/queries/import.graphql
+++ b/src/client/graphql/queries/import.graphql
@@ -11,6 +11,21 @@ query GetImportStatistics($directoryPath: String) {
}
}
+query GetCachedImportStatistics {
+ getCachedImportStatistics {
+ success
+ message
+ stats {
+ totalLocalFiles
+ alreadyImported
+ newFiles
+ percentageImported
+ pendingFiles
+ }
+ lastUpdated
+ }
+}
+
mutation StartNewImport($sessionId: String!) {
startNewImport(sessionId: $sessionId) {
success
diff --git a/src/client/store/index.ts b/src/client/store/index.ts
index 7e70b6b..aa3c3a0 100644
--- a/src/client/store/index.ts
+++ b/src/client/store/index.ts
@@ -49,7 +49,9 @@ export const useStore = create((set, get) => ({
getSocket: (namespace = "/") => {
const ns = namespace === "/" ? "" : namespace;
const existing = get().socketInstances[namespace];
- if (existing?.connected) return existing;
+ // Return existing socket if it exists, regardless of connection state
+ // This prevents creating duplicate sockets during connection phase
+ if (existing) return existing;
const sessionId = localStorage.getItem("sessionId");
const socket = io(`${SOCKET_BASE_URI}${ns}`, {