Fixes to the import error checks
This commit is contained in:
211
plans/import-directory-status.md
Normal file
211
plans/import-directory-status.md
Normal 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
|
||||||
@@ -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 {};
|
export {};
|
||||||
|
|||||||
@@ -1,13 +1,24 @@
|
|||||||
import { ReactElement, useEffect, useRef, useState } from "react";
|
import { ReactElement, useEffect, useRef, useState } from "react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { isEmpty } from "lodash";
|
import { isEmpty } from "lodash";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useStore } from "../../store";
|
import { useStore } from "../../store";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { useGetJobResultStatisticsQuery } from "../../graphql/generated";
|
import { useGetJobResultStatisticsQuery } from "../../graphql/generated";
|
||||||
import { RealTimeImportStats } from "./RealTimeImportStats";
|
import { RealTimeImportStats } from "./RealTimeImportStats";
|
||||||
import { useImportSessionStatus } from "../../hooks/useImportSessionStatus";
|
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 => {
|
export const Import = (): ReactElement => {
|
||||||
const [importError, setImportError] = useState<string | null>(null);
|
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
|
// Force re-import mutation - re-imports all files regardless of import status
|
||||||
const { mutate: forceReImport, isPending: isForceReImporting } = useMutation({
|
const { mutate: forceReImport, isPending: isForceReImporting } = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
@@ -99,6 +128,21 @@ export const Import = (): ReactElement => {
|
|||||||
const handleForceReImport = async () => {
|
const handleForceReImport = async () => {
|
||||||
setImportError(null);
|
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
|
// Check for active session before starting using definitive status
|
||||||
if (hasActiveSession) {
|
if (hasActiveSession) {
|
||||||
setImportError(
|
setImportError(
|
||||||
@@ -197,6 +241,58 @@ export const Import = (): ReactElement => {
|
|||||||
</div>
|
</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 */}
|
{/* Force Re-Import Button - always shown when no import is running */}
|
||||||
{!hasActiveSession &&
|
{!hasActiveSession &&
|
||||||
(importJobQueue.status === "drained" || importJobQueue.status === undefined) && (
|
(importJobQueue.status === "drained" || importJobQueue.status === undefined) && (
|
||||||
@@ -204,8 +300,10 @@ export const Import = (): ReactElement => {
|
|||||||
<button
|
<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"
|
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}
|
onClick={handleForceReImport}
|
||||||
disabled={isForceReImporting || hasActiveSession}
|
disabled={isForceReImporting || hasActiveSession || !hasAllDirectories}
|
||||||
title="Re-import all files to fix Elasticsearch indexing issues"
|
title={!hasAllDirectories
|
||||||
|
? "Cannot import: Required directories are missing"
|
||||||
|
: "Re-import all files to fix Elasticsearch indexing issues"}
|
||||||
>
|
>
|
||||||
<span className="text-md font-medium">
|
<span className="text-md font-medium">
|
||||||
{isForceReImporting ? "Starting Re-Import..." : "Force Re-Import All Files"}
|
{isForceReImporting ? "Starting Re-Import..." : "Force Re-Import All Files"}
|
||||||
|
|||||||
@@ -11,19 +11,57 @@ import { useShallow } from "zustand/react/shallow";
|
|||||||
import { useImportSessionStatus } from "../../hooks/useImportSessionStatus";
|
import { useImportSessionStatus } from "../../hooks/useImportSessionStatus";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Import statistics with card-based layout and progress bar.
|
* RealTimeImportStats component displays import statistics with a 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.
|
* 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 => {
|
export const RealTimeImportStats = (): ReactElement => {
|
||||||
|
/** Current import error message to display, or null if no error */
|
||||||
const [importError, setImportError] = useState<string | null>(null);
|
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);
|
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<{
|
const [socketImport, setSocketImport] = useState<{
|
||||||
|
/** Whether import is currently in progress */
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
/** Number of successfully completed import jobs */
|
||||||
completed: number;
|
completed: number;
|
||||||
|
/** Total number of jobs in the import queue */
|
||||||
total: number;
|
total: number;
|
||||||
|
/** Number of failed import jobs */
|
||||||
failed: number;
|
failed: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { getSocket, disconnectSocket, importJobQueue } = useStore(
|
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 },
|
{ 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>;
|
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 isFirstImport = stats.alreadyImported === 0;
|
||||||
const buttonText = isFirstImport
|
const buttonText = isFirstImport
|
||||||
? `Start Import (${stats.newFiles} files)`
|
? `Start Import (${stats.newFiles} files)`
|
||||||
|
|||||||
Reference in New Issue
Block a user