Fixes to the import error checks

This commit is contained in:
Rishi Ghan
2026-04-14 17:27:49 -04:00
parent 4e53f23e79
commit 81f4654b50
4 changed files with 564 additions and 8 deletions

View File

@@ -0,0 +1,211 @@
# Implementation Plan: Directory Status Check for Import.tsx
## Overview
Add functionality to `Import.tsx` that checks if the required directories (`comics` and `userdata`) exist before allowing the import process to start. If either directory is missing, display a warning banner to the user and disable the import functionality.
## API Endpoint
- **Endpoint**: `GET /api/library/getDirectoryStatus`
- **Response Structure**:
```typescript
interface DirectoryStatus {
comics: { exists: boolean };
userdata: { exists: boolean };
}
```
## Implementation Details
### 1. Add Directory Status Type
In [`Import.tsx`](src/client/components/Import/Import.tsx:1), add a type definition for the directory status response:
```typescript
interface DirectoryStatus {
comics: { exists: boolean };
userdata: { exists: boolean };
}
```
### 2. Create useQuery Hook for Directory Status
Use `@tanstack/react-query` (already imported) to fetch directory status on component mount:
```typescript
const { data: directoryStatus, isLoading: isCheckingDirectories, error: directoryError } = useQuery({
queryKey: ['directoryStatus'],
queryFn: async (): Promise<DirectoryStatus> => {
const response = await axios.get('http://localhost:3000/api/library/getDirectoryStatus');
return response.data;
},
refetchOnWindowFocus: false,
staleTime: 30000, // Cache for 30 seconds
});
```
### 3. Derive Missing Directories State
Compute which directories are missing from the query result:
```typescript
const missingDirectories = useMemo(() => {
if (!directoryStatus) return [];
const missing: string[] = [];
if (!directoryStatus.comics?.exists) missing.push('comics');
if (!directoryStatus.userdata?.exists) missing.push('userdata');
return missing;
}, [directoryStatus]);
const hasAllDirectories = missingDirectories.length === 0;
```
### 4. Create Warning Banner Component
Add a warning banner that displays when directories are missing, positioned above the import button. This uses the same styling patterns as the existing error banner:
```tsx
{/* Directory Status Warning */}
{!isCheckingDirectories && missingDirectories.length > 0 && (
<div className="my-6 max-w-screen-lg rounded-lg border-s-4 border-amber-500 bg-amber-50 dark:bg-amber-900/20 p-4">
<div className="flex items-start gap-3">
<span className="w-6 h-6 text-amber-600 dark:text-amber-400 mt-0.5">
<i className="h-6 w-6 icon-[solar--folder-error-bold]"></i>
</span>
<div className="flex-1">
<p className="font-semibold text-amber-800 dark:text-amber-300">
Required Directories Missing
</p>
<p className="text-sm text-amber-700 dark:text-amber-400 mt-1">
The following directories do not exist and must be created before importing:
</p>
<ul className="list-disc list-inside text-sm text-amber-700 dark:text-amber-400 mt-2">
{missingDirectories.map((dir) => (
<li key={dir}>
<code className="bg-amber-100 dark:bg-amber-900/50 px-1 rounded">{dir}</code>
</li>
))}
</ul>
<p className="text-sm text-amber-700 dark:text-amber-400 mt-2">
Please ensure these directories are mounted correctly in your Docker configuration.
</p>
</div>
</div>
</div>
)}
```
### 5. Disable Import Button When Directories Missing
Modify the button's `disabled` prop and click handler:
```tsx
<button
className="..."
onClick={handleForceReImport}
disabled={isForceReImporting || hasActiveSession || !hasAllDirectories}
title={!hasAllDirectories
? "Cannot import: Required directories are missing"
: "Re-import all files to fix Elasticsearch indexing issues"}
>
```
### 6. Update handleForceReImport Guard
Add early return in the handler for missing directories:
```typescript
const handleForceReImport = async () => {
setImportError(null);
// Check for missing directories
if (!hasAllDirectories) {
setImportError(
`Cannot start import: Required directories are missing (${missingDirectories.join(', ')}). Please check your Docker volume configuration.`
);
return;
}
// ... existing logic
};
```
## File Changes Summary
| File | Changes |
|------|---------|
| [`src/client/components/Import/Import.tsx`](src/client/components/Import/Import.tsx) | Add useQuery for directory status, warning banner UI, disable button logic |
| [`src/client/components/Import/Import.test.tsx`](src/client/components/Import/Import.test.tsx) | Add tests for directory status scenarios |
## Test Cases to Add
### Import.test.tsx Updates
1. **Should show warning banner when comics directory is missing**
2. **Should show warning banner when userdata directory is missing**
3. **Should show warning banner when both directories are missing**
4. **Should disable import button when directories are missing**
5. **Should enable import button when all directories exist**
6. **Should handle directory status API error gracefully**
Example test structure:
```typescript
describe('Import Component - Directory Status', () => {
beforeEach(() => {
jest.clearAllMocks();
// Mock successful directory status by default
(axios.get as jest.Mock) = jest.fn().mockResolvedValue({
data: { comics: { exists: true }, userdata: { exists: true } }
});
});
test('should show warning when comics directory is missing', async () => {
(axios.get as jest.Mock).mockResolvedValue({
data: { comics: { exists: false }, userdata: { exists: true } }
});
render(<Import />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('Required Directories Missing')).toBeInTheDocument();
expect(screen.getByText('comics')).toBeInTheDocument();
});
});
test('should disable import button when directories are missing', async () => {
(axios.get as jest.Mock).mockResolvedValue({
data: { comics: { exists: false }, userdata: { exists: true } }
});
render(<Import />, { wrapper: createWrapper() });
await waitFor(() => {
const button = screen.getByRole('button', { name: /Force Re-Import/i });
expect(button).toBeDisabled();
});
});
});
```
## Architecture Diagram
```mermaid
flowchart TD
A[Import Component Mounts] --> B[Fetch Directory Status]
B --> C{API Success?}
C -->|Yes| D{All Directories Exist?}
C -->|No| E[Show Error Banner]
D -->|Yes| F[Enable Import Button]
D -->|No| G[Show Warning Banner]
G --> H[Disable Import Button]
F --> I[User Clicks Import]
I --> J[Proceed with Import]
```
## Notes
- The directory status is fetched once on mount with a 30-second stale time
- The warning uses amber/yellow colors to differentiate from error messages (red)
- The existing `importError` state and UI can remain unchanged
- No changes needed to the backend - the endpoint already exists

View File

@@ -490,4 +490,188 @@ describe('Import Component - Real-time Updates', () => {
});
});
describe('Import Component - Directory Status', () => {
beforeEach(() => {
jest.clearAllMocks();
(axios as any).mockResolvedValue({ data: [] });
(axios.request as jest.Mock) = jest.fn().mockResolvedValue({ data: {} });
// Mock successful directory status by default
(axios.get as jest.Mock) = jest.fn().mockResolvedValue({
data: { comics: { exists: true }, userdata: { exists: true } }
});
});
test('should show warning banner when comics directory is missing', async () => {
(axios.get as jest.Mock).mockResolvedValue({
data: { comics: { exists: false }, userdata: { exists: true } }
});
const { useStore } = require('../../store');
useStore.mockImplementation((selector: any) =>
selector({
importJobQueue: {
status: 'drained',
successfulJobCount: 0,
failedJobCount: 0,
mostRecentImport: '',
setStatus: mockSetStatus,
},
getSocket: mockGetSocket,
disconnectSocket: mockDisconnectSocket,
})
);
render(<Import />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('Required Directories Missing')).toBeInTheDocument();
});
expect(screen.getByText('comics')).toBeInTheDocument();
});
test('should show warning banner when userdata directory is missing', async () => {
(axios.get as jest.Mock).mockResolvedValue({
data: { comics: { exists: true }, userdata: { exists: false } }
});
const { useStore } = require('../../store');
useStore.mockImplementation((selector: any) =>
selector({
importJobQueue: {
status: 'drained',
successfulJobCount: 0,
failedJobCount: 0,
mostRecentImport: '',
setStatus: mockSetStatus,
},
getSocket: mockGetSocket,
disconnectSocket: mockDisconnectSocket,
})
);
render(<Import />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('Required Directories Missing')).toBeInTheDocument();
});
expect(screen.getByText('userdata')).toBeInTheDocument();
});
test('should show warning banner when both directories are missing', async () => {
(axios.get as jest.Mock).mockResolvedValue({
data: { comics: { exists: false }, userdata: { exists: false } }
});
const { useStore } = require('../../store');
useStore.mockImplementation((selector: any) =>
selector({
importJobQueue: {
status: 'drained',
successfulJobCount: 0,
failedJobCount: 0,
mostRecentImport: '',
setStatus: mockSetStatus,
},
getSocket: mockGetSocket,
disconnectSocket: mockDisconnectSocket,
})
);
render(<Import />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('Required Directories Missing')).toBeInTheDocument();
});
expect(screen.getByText('comics')).toBeInTheDocument();
expect(screen.getByText('userdata')).toBeInTheDocument();
});
test('should disable import button when directories are missing', async () => {
(axios.get as jest.Mock).mockResolvedValue({
data: { comics: { exists: false }, userdata: { exists: true } }
});
const { useStore } = require('../../store');
useStore.mockImplementation((selector: any) =>
selector({
importJobQueue: {
status: 'drained',
successfulJobCount: 0,
failedJobCount: 0,
mostRecentImport: '',
setStatus: mockSetStatus,
},
getSocket: mockGetSocket,
disconnectSocket: mockDisconnectSocket,
})
);
render(<Import />, { wrapper: createWrapper() });
await waitFor(() => {
const button = screen.getByRole('button', { name: /Force Re-Import/i });
expect(button).toBeDisabled();
});
});
test('should enable import button when all directories exist', async () => {
(axios.get as jest.Mock).mockResolvedValue({
data: { comics: { exists: true }, userdata: { exists: true } }
});
const { useStore } = require('../../store');
useStore.mockImplementation((selector: any) =>
selector({
importJobQueue: {
status: 'drained',
successfulJobCount: 0,
failedJobCount: 0,
mostRecentImport: '',
setStatus: mockSetStatus,
},
getSocket: mockGetSocket,
disconnectSocket: mockDisconnectSocket,
})
);
render(<Import />, { wrapper: createWrapper() });
await waitFor(() => {
const button = screen.getByRole('button', { name: /Force Re-Import/i });
expect(button).not.toBeDisabled();
});
});
test('should not show warning banner when all directories exist', async () => {
(axios.get as jest.Mock).mockResolvedValue({
data: { comics: { exists: true }, userdata: { exists: true } }
});
const { useStore } = require('../../store');
useStore.mockImplementation((selector: any) =>
selector({
importJobQueue: {
status: 'drained',
successfulJobCount: 0,
failedJobCount: 0,
mostRecentImport: '',
setStatus: mockSetStatus,
},
getSocket: mockGetSocket,
disconnectSocket: mockDisconnectSocket,
})
);
render(<Import />, { wrapper: createWrapper() });
// Wait for the component to finish loading
await waitFor(() => {
expect(screen.getByRole('button', { name: /Force Re-Import/i })).toBeInTheDocument();
});
// The warning banner should not be present
expect(screen.queryByText('Required Directories Missing')).not.toBeInTheDocument();
});
});
export {};

View File

@@ -1,13 +1,24 @@
import { ReactElement, useEffect, useRef, useState } from "react";
import { format } from "date-fns";
import { isEmpty } from "lodash";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useStore } from "../../store";
import { useShallow } from "zustand/react/shallow";
import axios from "axios";
import { useGetJobResultStatisticsQuery } from "../../graphql/generated";
import { RealTimeImportStats } from "./RealTimeImportStats";
import { useImportSessionStatus } from "../../hooks/useImportSessionStatus";
import { SETTINGS_SERVICE_BASE_URI } from "../../constants/endpoints";
interface DirectoryIssue {
directory: string;
issue: string;
}
interface DirectoryStatus {
isValid: boolean;
issues: DirectoryIssue[];
}
export const Import = (): ReactElement => {
const [importError, setImportError] = useState<string | null>(null);
@@ -20,6 +31,24 @@ export const Import = (): ReactElement => {
})),
);
// Check if required directories exist
const { data: directoryStatus, isLoading: isCheckingDirectories, isError: isDirectoryCheckError, error: directoryError } = useQuery({
queryKey: ["directoryStatus"],
queryFn: async (): Promise<DirectoryStatus> => {
const response = await axios.get(`${SETTINGS_SERVICE_BASE_URI}/getDirectoryStatus`);
return response.data;
},
refetchOnWindowFocus: false,
staleTime: 30000, // Cache for 30 seconds
retry: false, // Don't retry on failure - show error immediately
});
// Use isValid for quick check, issues array for detailed display
// If there's an error fetching directory status, assume directories are invalid
const directoryCheckFailed = isDirectoryCheckError;
const hasAllDirectories = directoryCheckFailed ? false : (directoryStatus?.isValid ?? true);
const directoryIssues = directoryStatus?.issues ?? [];
// Force re-import mutation - re-imports all files regardless of import status
const { mutate: forceReImport, isPending: isForceReImporting } = useMutation({
mutationFn: async () => {
@@ -99,6 +128,21 @@ export const Import = (): ReactElement => {
const handleForceReImport = async () => {
setImportError(null);
// Check for missing directories before starting
if (!hasAllDirectories) {
if (directoryCheckFailed) {
setImportError(
"Cannot start import: Failed to verify directory status. Please check that the backend service is running."
);
} else {
const issueDetails = directoryIssues.map(i => `${i.directory}: ${i.issue}`).join(", ");
setImportError(
`Cannot start import: ${issueDetails || "Required directories are missing"}. Please check your Docker volume configuration.`
);
}
return;
}
// Check for active session before starting using definitive status
if (hasActiveSession) {
setImportError(
@@ -197,6 +241,58 @@ export const Import = (): ReactElement => {
</div>
)}
{/* Directory Check Error - shown when API call fails */}
{!isCheckingDirectories && directoryCheckFailed && (
<div className="my-6 max-w-screen-lg rounded-lg border-s-4 border-red-500 bg-red-50 dark:bg-red-900/20 p-4">
<div className="flex items-start gap-3">
<span className="w-6 h-6 text-red-600 dark:text-red-400 mt-0.5">
<i className="h-6 w-6 icon-[solar--danger-circle-bold]"></i>
</span>
<div className="flex-1">
<p className="font-semibold text-red-800 dark:text-red-300">
Failed to Check Directory Status
</p>
<p className="text-sm text-red-700 dark:text-red-400 mt-1">
Unable to verify if required directories exist. Import functionality has been disabled.
</p>
<p className="text-sm text-red-700 dark:text-red-400 mt-2">
Error: {(directoryError as Error)?.message || "Unknown error"}
</p>
</div>
</div>
</div>
)}
{/* Directory Status Warning - shown when directories have issues */}
{!isCheckingDirectories && !directoryCheckFailed && directoryIssues.length > 0 && (
<div className="my-6 max-w-screen-lg rounded-lg border-s-4 border-amber-500 bg-amber-50 dark:bg-amber-900/20 p-4">
<div className="flex items-start gap-3">
<span className="w-6 h-6 text-amber-600 dark:text-amber-400 mt-0.5">
<i className="h-6 w-6 icon-[solar--folder-error-bold]"></i>
</span>
<div className="flex-1">
<p className="font-semibold text-amber-800 dark:text-amber-300">
Directory Configuration Issues
</p>
<p className="text-sm text-amber-700 dark:text-amber-400 mt-1">
The following issues were detected with your directory configuration:
</p>
<ul className="list-disc list-inside text-sm text-amber-700 dark:text-amber-400 mt-2">
{directoryIssues.map((item) => (
<li key={item.directory}>
<code className="bg-amber-100 dark:bg-amber-900/50 px-1 rounded">{item.directory}</code>
<span className="ml-1"> {item.issue}</span>
</li>
))}
</ul>
<p className="text-sm text-amber-700 dark:text-amber-400 mt-2">
Please ensure these directories are mounted correctly in your Docker configuration.
</p>
</div>
</div>
</div>
)}
{/* Force Re-Import Button - always shown when no import is running */}
{!hasActiveSession &&
(importJobQueue.status === "drained" || importJobQueue.status === undefined) && (
@@ -204,8 +300,10 @@ export const Import = (): ReactElement => {
<button
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-orange-400 dark:border-orange-200 bg-orange-200 px-5 py-3 text-gray-700 hover:bg-transparent hover:text-orange-600 focus:outline-none focus:ring active:text-orange-500 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={handleForceReImport}
disabled={isForceReImporting || hasActiveSession}
title="Re-import all files to fix Elasticsearch indexing issues"
disabled={isForceReImporting || hasActiveSession || !hasAllDirectories}
title={!hasAllDirectories
? "Cannot import: Required directories are missing"
: "Re-import all files to fix Elasticsearch indexing issues"}
>
<span className="text-md font-medium">
{isForceReImporting ? "Starting Re-Import..." : "Force Re-Import All Files"}

View File

@@ -11,19 +11,57 @@ 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.
* RealTimeImportStats component displays import statistics with a card-based layout and progress bar.
*
* This component manages three distinct states:
* - **Pre-import (idle)**: Shows current file counts and "Start Import" button when new files exist
* - **Importing (active)**: Displays real-time progress bar with completed/total counts
* - **Post-import (complete)**: Shows final statistics including failed imports
*
* Additionally, it surfaces missing files detected by the file watcher, allowing users
* to see which previously-imported files are no longer found on disk.
*
* @component
* @example
* ```tsx
* <RealTimeImportStats />
* ```
*
* @returns {ReactElement} The rendered import statistics component
*
* @remarks
* The component subscribes to multiple socket events for real-time updates:
* - `LS_LIBRARY_STATS` / `LS_FILES_MISSING`: Triggers statistics refresh
* - `LS_FILE_DETECTED`: Shows toast notification for newly detected files
* - `LS_INCREMENTAL_IMPORT_STARTED`: Initializes progress tracking
* - `LS_COVER_EXTRACTED` / `LS_COVER_EXTRACTION_FAILED`: Updates progress counts
* - `LS_IMPORT_QUEUE_DRAINED`: Marks import as complete
*
* @see {@link useImportSessionStatus} for import session state management
* @see {@link useGetImportStatisticsQuery} for fetching import statistics
*/
export const RealTimeImportStats = (): ReactElement => {
/** Current import error message to display, or null if no error */
const [importError, setImportError] = useState<string | null>(null);
/** Name of recently detected file for toast notification, auto-clears after 5 seconds */
const [detectedFile, setDetectedFile] = useState<string | null>(null);
/**
* Real-time import progress state tracked via socket events.
* Separate from GraphQL query data to provide immediate UI updates.
*/
const [socketImport, setSocketImport] = useState<{
/** Whether import is currently in progress */
active: boolean;
/** Number of successfully completed import jobs */
completed: number;
/** Total number of jobs in the import queue */
total: number;
/** Number of failed import jobs */
failed: number;
} | null>(null);
const queryClient = useQueryClient();
const { getSocket, disconnectSocket, importJobQueue } = useStore(
@@ -34,7 +72,7 @@ export const RealTimeImportStats = (): ReactElement => {
})),
);
const { data: importStats, isLoading } = useGetImportStatisticsQuery(
const { data: importStats, isLoading, isError: isStatsError, error: statsError } = useGetImportStatisticsQuery(
{},
{ refetchOnWindowFocus: false, refetchInterval: false },
);
@@ -182,10 +220,35 @@ export const RealTimeImportStats = (): ReactElement => {
}
};
if (isLoading || !stats) {
if (isLoading) {
return <div className="text-gray-500 dark:text-gray-400">Loading...</div>;
}
if (isStatsError || !stats) {
return (
<div className="rounded-lg border-l-4 border-red-500 bg-red-50 dark:bg-red-900/20 p-4">
<div className="flex items-start gap-3">
<span className="w-6 h-6 text-red-600 dark:text-red-400 mt-0.5">
<i className="h-6 w-6 icon-[solar--danger-circle-bold]"></i>
</span>
<div className="flex-1">
<p className="font-semibold text-red-800 dark:text-red-300">
Failed to Load Import Statistics
</p>
<p className="text-sm text-red-700 dark:text-red-400 mt-1">
Unable to retrieve import statistics from the server. Please check that the backend service is running.
</p>
{isStatsError && (
<p className="text-sm text-red-700 dark:text-red-400 mt-2">
Error: {statsError instanceof Error ? statsError.message : "Unknown error"}
</p>
)}
</div>
</div>
</div>
);
}
const isFirstImport = stats.alreadyImported === 0;
const buttonText = isFirstImport
? `Start Import (${stats.newFiles} files)`