Refactored Import.tsx
This commit is contained in:
@@ -36,24 +36,31 @@ const b = 2;
|
|||||||
// const a = 1, b = 2; // WRONG
|
// const a = 1, b = 2; // WRONG
|
||||||
|
|
||||||
Types and Interfaces
|
Types and Interfaces
|
||||||
Prefer Interfaces Over Type Aliases
|
Prefer Type Aliases Over Interfaces
|
||||||
|
|
||||||
// Good: interface for object shapes
|
// Good: type alias for object shapes
|
||||||
interface User {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
email?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Avoid: type alias for object shapes
|
|
||||||
type User = {
|
type User = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
email?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Type aliases OK for unions, intersections, mapped types
|
// Avoid: interface for object shapes
|
||||||
|
// interface User {
|
||||||
|
// id: string;
|
||||||
|
// name: string;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Type aliases work for everything: objects, unions, intersections, mapped types
|
||||||
type Status = 'active' | 'inactive';
|
type Status = 'active' | 'inactive';
|
||||||
type Combined = TypeA & TypeB;
|
type Combined = TypeA & TypeB;
|
||||||
|
type Handler = (event: Event) => void;
|
||||||
|
|
||||||
|
// Benefits of types over interfaces:
|
||||||
|
// 1. Consistent syntax for all type definitions
|
||||||
|
// 2. Cannot be merged/extended unexpectedly (no declaration merging)
|
||||||
|
// 3. Better for union types and computed properties
|
||||||
|
// 4. Works with utility types more naturally
|
||||||
|
|
||||||
Type Inference
|
Type Inference
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ReactElement, useEffect, useRef, useState } from "react";
|
import { ReactElement, useEffect, useRef, useState } from "react";
|
||||||
import { format } from "date-fns";
|
|
||||||
import { isEmpty } from "lodash";
|
import { isEmpty } from "lodash";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useStore } from "../../store";
|
import { useStore } from "../../store";
|
||||||
@@ -14,30 +13,11 @@ 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 { PastImportsTable } from "./PastImportsTable";
|
||||||
|
import { AlertBanner } from "../shared/AlertBanner";
|
||||||
import { useImportSessionStatus } from "../../hooks/useImportSessionStatus";
|
import { useImportSessionStatus } from "../../hooks/useImportSessionStatus";
|
||||||
import { SETTINGS_SERVICE_BASE_URI } from "../../constants/endpoints";
|
import { SETTINGS_SERVICE_BASE_URI } from "../../constants/endpoints";
|
||||||
|
import type { DirectoryStatus, DirectoryIssue } from "./import.types";
|
||||||
/**
|
|
||||||
* Represents an issue with a configured directory.
|
|
||||||
* @interface DirectoryIssue
|
|
||||||
* @property {string} directory - Path to the directory with issues
|
|
||||||
* @property {string} issue - Description of the issue
|
|
||||||
*/
|
|
||||||
interface DirectoryIssue {
|
|
||||||
directory: string;
|
|
||||||
issue: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Result of directory status check from the backend.
|
|
||||||
* @interface DirectoryStatus
|
|
||||||
* @property {boolean} isValid - Whether all required directories are accessible
|
|
||||||
* @property {DirectoryIssue[]} issues - List of specific issues found
|
|
||||||
*/
|
|
||||||
interface DirectoryStatus {
|
|
||||||
isValid: boolean;
|
|
||||||
issues: DirectoryIssue[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Import page component for managing comic library imports.
|
* Import page component for managing comic library imports.
|
||||||
@@ -59,28 +39,36 @@ export const Import = (): ReactElement => {
|
|||||||
importJobQueue: state.importJobQueue,
|
importJobQueue: state.importJobQueue,
|
||||||
getSocket: state.getSocket,
|
getSocket: state.getSocket,
|
||||||
disconnectSocket: state.disconnectSocket,
|
disconnectSocket: state.disconnectSocket,
|
||||||
})),
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check if required directories exist
|
// Check if required directories exist
|
||||||
const { data: directoryStatus, isLoading: isCheckingDirectories, isError: isDirectoryCheckError, error: directoryError } = useQuery({
|
const {
|
||||||
|
data: directoryStatus,
|
||||||
|
isLoading: isCheckingDirectories,
|
||||||
|
isError: isDirectoryCheckError,
|
||||||
|
error: directoryError,
|
||||||
|
} = useQuery({
|
||||||
queryKey: ["directoryStatus"],
|
queryKey: ["directoryStatus"],
|
||||||
queryFn: async (): Promise<DirectoryStatus> => {
|
queryFn: async (): Promise<DirectoryStatus> => {
|
||||||
const response = await axios.get(`${SETTINGS_SERVICE_BASE_URI}/getDirectoryStatus`);
|
const response = await axios.get(
|
||||||
|
`${SETTINGS_SERVICE_BASE_URI}/getDirectoryStatus`
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
staleTime: 30000, // Cache for 30 seconds
|
staleTime: 30000,
|
||||||
retry: false, // Don't retry on failure - show error immediately
|
retry: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use isValid for quick check, issues array for detailed display
|
// 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 directoryCheckFailed = isDirectoryCheckError;
|
||||||
const hasAllDirectories = directoryCheckFailed ? false : (directoryStatus?.isValid ?? true);
|
const hasAllDirectories = directoryCheckFailed
|
||||||
|
? false
|
||||||
|
: (directoryStatus?.isValid ?? true);
|
||||||
const directoryIssues = directoryStatus?.issues ?? [];
|
const directoryIssues = directoryStatus?.issues ?? [];
|
||||||
|
|
||||||
// Force re-import mutation - re-imports all files regardless of import status
|
// Force re-import mutation
|
||||||
const { mutate: forceReImport, isPending: isForceReImporting } = useMutation({
|
const { mutate: forceReImport, isPending: isForceReImporting } = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const sessionId = localStorage.getItem("sessionId") || "";
|
const sessionId = localStorage.getItem("sessionId") || "";
|
||||||
@@ -97,7 +85,11 @@ export const Import = (): ReactElement => {
|
|||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
console.error("Failed to start force re-import:", error);
|
console.error("Failed to start force re-import:", error);
|
||||||
setImportError(error?.response?.data?.message || error?.message || "Failed to start force re-import. Please try again.");
|
setImportError(
|
||||||
|
error?.response?.data?.message ||
|
||||||
|
error?.message ||
|
||||||
|
"Failed to start force re-import. Please try again."
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -107,14 +99,11 @@ export const Import = (): ReactElement => {
|
|||||||
const hasActiveSession = importSession.isActive;
|
const hasActiveSession = importSession.isActive;
|
||||||
const wasComplete = useRef(false);
|
const wasComplete = useRef(false);
|
||||||
|
|
||||||
// React to importSession.isComplete rather than socket events — more reliable
|
// React to importSession.isComplete for state updates
|
||||||
// since it's derived from the actual GraphQL state, not a raw socket event.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (importSession.isComplete && !wasComplete.current) {
|
if (importSession.isComplete && !wasComplete.current) {
|
||||||
wasComplete.current = true;
|
wasComplete.current = true;
|
||||||
// Small delay so the backend has time to commit job result stats
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// Invalidate the cache to force a fresh fetch of job result statistics
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] });
|
queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] });
|
||||||
refetch();
|
refetch();
|
||||||
}, 1500);
|
}, 1500);
|
||||||
@@ -124,21 +113,23 @@ export const Import = (): ReactElement => {
|
|||||||
}
|
}
|
||||||
}, [importSession.isComplete, refetch, importJobQueue, queryClient]);
|
}, [importSession.isComplete, refetch, importJobQueue, queryClient]);
|
||||||
|
|
||||||
// Listen to socket events to update Past Imports table in real-time
|
// Listen to socket events to update Past Imports table
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const socket = getSocket("/");
|
const socket = getSocket("/");
|
||||||
|
|
||||||
const handleImportCompleted = () => {
|
const handleImportCompleted = () => {
|
||||||
console.log("[Import] IMPORT_SESSION_COMPLETED event - refreshing Past Imports");
|
console.log(
|
||||||
// Small delay to ensure backend has committed the job results
|
"[Import] IMPORT_SESSION_COMPLETED event - refreshing Past Imports"
|
||||||
|
);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] });
|
queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] });
|
||||||
}, 1500);
|
}, 1500);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleQueueDrained = () => {
|
const handleQueueDrained = () => {
|
||||||
console.log("[Import] LS_IMPORT_QUEUE_DRAINED event - refreshing Past Imports");
|
console.log(
|
||||||
// Small delay to ensure backend has committed the job results
|
"[Import] LS_IMPORT_QUEUE_DRAINED event - refreshing Past Imports"
|
||||||
|
);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] });
|
queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] });
|
||||||
}, 1500);
|
}, 1500);
|
||||||
@@ -159,14 +150,15 @@ export const Import = (): ReactElement => {
|
|||||||
const handleForceReImport = async () => {
|
const handleForceReImport = async () => {
|
||||||
setImportError(null);
|
setImportError(null);
|
||||||
|
|
||||||
// Check for missing directories before starting
|
|
||||||
if (!hasAllDirectories) {
|
if (!hasAllDirectories) {
|
||||||
if (directoryCheckFailed) {
|
if (directoryCheckFailed) {
|
||||||
setImportError(
|
setImportError(
|
||||||
"Cannot start import: Failed to verify directory status. Please check that the backend service is running."
|
"Cannot start import: Failed to verify directory status. Please check that the backend service is running."
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const issueDetails = directoryIssues.map(i => `${i.directory}: ${i.issue}`).join(", ");
|
const issueDetails = directoryIssues
|
||||||
|
.map((i) => `${i.directory}: ${i.issue}`)
|
||||||
|
.join(", ");
|
||||||
setImportError(
|
setImportError(
|
||||||
`Cannot start import: ${issueDetails || "Required directories are missing"}. Please check your Docker volume configuration.`
|
`Cannot start import: ${issueDetails || "Required directories are missing"}. Please check your Docker volume configuration.`
|
||||||
);
|
);
|
||||||
@@ -174,7 +166,6 @@ export const Import = (): ReactElement => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for active session before starting using definitive status
|
|
||||||
if (hasActiveSession) {
|
if (hasActiveSession) {
|
||||||
setImportError(
|
setImportError(
|
||||||
`Cannot start import: An import session "${importSession.sessionId}" is already active. Please wait for it to complete.`
|
`Cannot start import: An import session "${importSession.sessionId}" is already active. Please wait for it to complete.`
|
||||||
@@ -182,10 +173,12 @@ export const Import = (): ReactElement => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.confirm(
|
if (
|
||||||
"This will re-import ALL files in your library folder, even those already imported. " +
|
window.confirm(
|
||||||
"This can help fix Elasticsearch indexing issues. Continue?"
|
"This will re-import ALL files in your library folder, even those already imported. " +
|
||||||
)) {
|
"This can help fix Elasticsearch indexing issues. Continue?"
|
||||||
|
)
|
||||||
|
) {
|
||||||
if (importJobQueue.status === "drained") {
|
if (importJobQueue.status === "drained") {
|
||||||
localStorage.removeItem("sessionId");
|
localStorage.removeItem("sessionId");
|
||||||
disconnectSocket("/");
|
disconnectSocket("/");
|
||||||
@@ -201,6 +194,10 @@ export const Import = (): ReactElement => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const canStartImport =
|
||||||
|
!hasActiveSession &&
|
||||||
|
(importJobQueue.status === "drained" || importJobQueue.status === undefined);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<section>
|
<section>
|
||||||
@@ -211,7 +208,6 @@ export const Import = (): ReactElement => {
|
|||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
|
||||||
Import
|
Import
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
|
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
|
||||||
Import comics into the ThreeTwo library.
|
Import comics into the ThreeTwo library.
|
||||||
</p>
|
</p>
|
||||||
@@ -247,97 +243,72 @@ export const Import = (): ReactElement => {
|
|||||||
|
|
||||||
{/* Error Message */}
|
{/* Error Message */}
|
||||||
{importError && (
|
{importError && (
|
||||||
<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="my-6 max-w-screen-lg">
|
||||||
<div className="flex items-start gap-3">
|
<AlertBanner
|
||||||
<span className="w-6 h-6 text-red-600 dark:text-red-400 mt-0.5">
|
severity="error"
|
||||||
<i className="h-6 w-6 icon-[solar--danger-circle-bold]"></i>
|
title="Import Error"
|
||||||
</span>
|
onClose={() => setImportError(null)}
|
||||||
<div className="flex-1">
|
>
|
||||||
<p className="font-semibold text-red-800 dark:text-red-300">
|
{importError}
|
||||||
Import Error
|
</AlertBanner>
|
||||||
</p>
|
|
||||||
<p className="text-sm text-red-700 dark:text-red-400 mt-1">
|
|
||||||
{importError}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setImportError(null)}
|
|
||||||
className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-200"
|
|
||||||
>
|
|
||||||
<span className="w-5 h-5">
|
|
||||||
<i className="h-5 w-5 icon-[solar--close-circle-bold]"></i>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Directory Check Error - shown when API call fails */}
|
{/* Directory Check Error */}
|
||||||
{!isCheckingDirectories && directoryCheckFailed && (
|
{!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="my-6 max-w-screen-lg">
|
||||||
<div className="flex items-start gap-3">
|
<AlertBanner severity="error" title="Failed to Check Directory Status">
|
||||||
<span className="w-6 h-6 text-red-600 dark:text-red-400 mt-0.5">
|
<p>
|
||||||
<i className="h-6 w-6 icon-[solar--danger-circle-bold]"></i>
|
Unable to verify if required directories exist. Import
|
||||||
</span>
|
functionality has been disabled.
|
||||||
<div className="flex-1">
|
</p>
|
||||||
<p className="font-semibold text-red-800 dark:text-red-300">
|
<p className="mt-2">
|
||||||
Failed to Check Directory Status
|
Error: {(directoryError as Error)?.message || "Unknown error"}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-red-700 dark:text-red-400 mt-1">
|
</AlertBanner>
|
||||||
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Directory Status Warning - shown when directories have issues */}
|
{/* Directory Status Warning */}
|
||||||
{!isCheckingDirectories && !directoryCheckFailed && directoryIssues.length > 0 && (
|
{!isCheckingDirectories &&
|
||||||
<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">
|
!directoryCheckFailed &&
|
||||||
<div className="flex items-start gap-3">
|
directoryIssues.length > 0 && (
|
||||||
<span className="w-6 h-6 text-amber-600 dark:text-amber-400 mt-0.5">
|
<div className="my-6 max-w-screen-lg">
|
||||||
<i className="h-6 w-6 icon-[solar--folder-error-bold]"></i>
|
<AlertBanner
|
||||||
</span>
|
severity="warning"
|
||||||
<div className="flex-1">
|
title="Directory Configuration Issues"
|
||||||
<p className="font-semibold text-amber-800 dark:text-amber-300">
|
iconClass="icon-[solar--folder-error-bold]"
|
||||||
Directory Configuration Issues
|
>
|
||||||
|
<p>
|
||||||
|
The following issues were detected with your directory
|
||||||
|
configuration:
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-amber-700 dark:text-amber-400 mt-1">
|
<DirectoryIssuesList issues={directoryIssues} />
|
||||||
The following issues were detected with your directory configuration:
|
<p className="mt-2">
|
||||||
|
Please ensure these directories are mounted correctly in your
|
||||||
|
Docker configuration.
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-disc list-inside text-sm text-amber-700 dark:text-amber-400 mt-2">
|
</AlertBanner>
|
||||||
{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>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Force Re-Import Button - always shown when no import is running */}
|
{/* Force Re-Import Button */}
|
||||||
{!hasActiveSession &&
|
{canStartImport && (
|
||||||
(importJobQueue.status === "drained" || importJobQueue.status === undefined) && (
|
|
||||||
<div className="my-6 max-w-screen-lg">
|
<div className="my-6 max-w-screen-lg">
|
||||||
<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 || !hasAllDirectories}
|
disabled={isForceReImporting || hasActiveSession || !hasAllDirectories}
|
||||||
title={!hasAllDirectories
|
title={
|
||||||
? "Cannot import: Required directories are missing"
|
!hasAllDirectories
|
||||||
: "Re-import all files to fix Elasticsearch indexing issues"}
|
? "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"}
|
||||||
</span>
|
</span>
|
||||||
<span className="w-6 h-6">
|
<span className="w-6 h-6">
|
||||||
<i className="h-6 w-6 icon-[solar--refresh-bold-duotone]"></i>
|
<i className="h-6 w-6 icon-[solar--refresh-bold-duotone]"></i>
|
||||||
@@ -346,87 +317,9 @@ export const Import = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Import activity is now shown in the RealTimeImportStats component above */}
|
{/* Past Imports Table */}
|
||||||
|
|
||||||
{!isLoading && !isEmpty(data?.getJobResultStatistics) && (
|
{!isLoading && !isEmpty(data?.getJobResultStatistics) && (
|
||||||
<div className="max-w-screen-lg">
|
<PastImportsTable data={data!.getJobResultStatistics as any} />
|
||||||
<span className="flex items-center mt-6">
|
|
||||||
<span className="text-xl text-slate-500 dark:text-slate-200 pr-5">
|
|
||||||
Past Imports
|
|
||||||
</span>
|
|
||||||
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div className="overflow-x-auto w-fit mt-4 rounded-lg border border-gray-200">
|
|
||||||
<table className="min-w-full divide-y-2 divide-gray-200 dark:divide-gray-200 text-md">
|
|
||||||
<thead className="ltr:text-left rtl:text-right">
|
|
||||||
<tr>
|
|
||||||
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
|
|
||||||
#
|
|
||||||
</th>
|
|
||||||
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
|
|
||||||
Time Started
|
|
||||||
</th>
|
|
||||||
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
|
|
||||||
Session Id
|
|
||||||
</th>
|
|
||||||
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
|
|
||||||
Imported
|
|
||||||
</th>
|
|
||||||
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
|
|
||||||
Failed
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
|
|
||||||
<tbody className="divide-y divide-gray-200">
|
|
||||||
{data?.getJobResultStatistics.map((jobResult: any, index: number) => {
|
|
||||||
return (
|
|
||||||
<tr key={index}>
|
|
||||||
<td className="whitespace-nowrap px-4 py-2 text-gray-700 dark:text-slate-300 font-medium">
|
|
||||||
{index + 1}
|
|
||||||
</td>
|
|
||||||
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
|
|
||||||
{jobResult.earliestTimestamp && !isNaN(parseInt(jobResult.earliestTimestamp))
|
|
||||||
? format(
|
|
||||||
new Date(parseInt(jobResult.earliestTimestamp)),
|
|
||||||
"EEEE, hh:mma, do LLLL y",
|
|
||||||
)
|
|
||||||
: "N/A"}
|
|
||||||
</td>
|
|
||||||
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
|
|
||||||
<span className="tag is-warning">
|
|
||||||
{jobResult.sessionId}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="whitespace-nowrap px-4 py-2 text-gray-700 dark:text-slate-300">
|
|
||||||
<span className="inline-flex items-center justify-center rounded-full bg-emerald-100 px-2 py-0.5 text-emerald-700">
|
|
||||||
<span className="h-5 w-6">
|
|
||||||
<i className="icon-[solar--check-circle-line-duotone] h-5 w-5"></i>
|
|
||||||
</span>
|
|
||||||
<p className="whitespace-nowrap text-sm">
|
|
||||||
{jobResult.completedJobs}
|
|
||||||
</p>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="whitespace-nowrap px-4 py-2 text-gray-700 dark:text-slate-300">
|
|
||||||
<span className="inline-flex items-center justify-center rounded-full bg-red-100 px-2 py-0.5 text-red-700">
|
|
||||||
<span className="h-5 w-6">
|
|
||||||
<i className="icon-[solar--close-circle-line-duotone] h-5 w-5"></i>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<p className="whitespace-nowrap text-sm">
|
|
||||||
{jobResult.failedJobs}
|
|
||||||
</p>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -434,4 +327,20 @@ export const Import = (): ReactElement => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper component to render directory issues list.
|
||||||
|
*/
|
||||||
|
const DirectoryIssuesList = ({ issues }: { issues: DirectoryIssue[] }): ReactElement => (
|
||||||
|
<ul className="list-disc list-inside mt-2">
|
||||||
|
{issues.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>
|
||||||
|
);
|
||||||
|
|
||||||
export default Import;
|
export default Import;
|
||||||
|
|||||||
103
src/client/components/Import/PastImportsTable.tsx
Normal file
103
src/client/components/Import/PastImportsTable.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Table component displaying historical import sessions.
|
||||||
|
* @module components/Import/PastImportsTable
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ReactElement } from "react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import type { JobResultStatistics } from "./import.types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the PastImportsTable component.
|
||||||
|
*/
|
||||||
|
export type PastImportsTableProps = {
|
||||||
|
/** Array of job result statistics from past imports */
|
||||||
|
data: JobResultStatistics[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays a table of past import sessions with their statistics.
|
||||||
|
*
|
||||||
|
* @param props - Component props
|
||||||
|
* @returns Table element showing import history
|
||||||
|
*/
|
||||||
|
export const PastImportsTable = ({ data }: PastImportsTableProps): ReactElement => {
|
||||||
|
return (
|
||||||
|
<div className="max-w-screen-lg">
|
||||||
|
<span className="flex items-center mt-6">
|
||||||
|
<span className="text-xl text-slate-500 dark:text-slate-200 pr-5">
|
||||||
|
Past Imports
|
||||||
|
</span>
|
||||||
|
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto w-fit mt-4 rounded-lg border border-gray-200">
|
||||||
|
<table className="min-w-full divide-y-2 divide-gray-200 dark:divide-gray-200 text-md">
|
||||||
|
<thead className="ltr:text-left rtl:text-right">
|
||||||
|
<tr>
|
||||||
|
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||||
|
#
|
||||||
|
</th>
|
||||||
|
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||||
|
Time Started
|
||||||
|
</th>
|
||||||
|
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||||
|
Session Id
|
||||||
|
</th>
|
||||||
|
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||||
|
Imported
|
||||||
|
</th>
|
||||||
|
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||||
|
Failed
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{data.map((jobResult, index) => (
|
||||||
|
<tr key={jobResult.sessionId || index}>
|
||||||
|
<td className="whitespace-nowrap px-4 py-2 text-gray-700 dark:text-slate-300 font-medium">
|
||||||
|
{index + 1}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
|
||||||
|
{jobResult.earliestTimestamp &&
|
||||||
|
!isNaN(parseInt(jobResult.earliestTimestamp))
|
||||||
|
? format(
|
||||||
|
new Date(parseInt(jobResult.earliestTimestamp)),
|
||||||
|
"EEEE, hh:mma, do LLLL y"
|
||||||
|
)
|
||||||
|
: "N/A"}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
|
||||||
|
<span className="tag is-warning">{jobResult.sessionId}</span>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-4 py-2 text-gray-700 dark:text-slate-300">
|
||||||
|
<span className="inline-flex items-center justify-center rounded-full bg-emerald-100 px-2 py-0.5 text-emerald-700">
|
||||||
|
<span className="h-5 w-6">
|
||||||
|
<i className="icon-[solar--check-circle-line-duotone] h-5 w-5"></i>
|
||||||
|
</span>
|
||||||
|
<p className="whitespace-nowrap text-sm">
|
||||||
|
{jobResult.completedJobs}
|
||||||
|
</p>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-4 py-2 text-gray-700 dark:text-slate-300">
|
||||||
|
<span className="inline-flex items-center justify-center rounded-full bg-red-100 px-2 py-0.5 text-red-700">
|
||||||
|
<span className="h-5 w-6">
|
||||||
|
<i className="icon-[solar--close-circle-line-duotone] h-5 w-5"></i>
|
||||||
|
</span>
|
||||||
|
<p className="whitespace-nowrap text-sm">
|
||||||
|
{jobResult.failedJobs}
|
||||||
|
</p>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PastImportsTable;
|
||||||
43
src/client/components/Import/import.types.ts
Normal file
43
src/client/components/Import/import.types.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Type definitions for the Import module.
|
||||||
|
* @module components/Import/import.types
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents an issue with a configured directory.
|
||||||
|
*/
|
||||||
|
export type DirectoryIssue = {
|
||||||
|
/** Path to the directory with issues */
|
||||||
|
directory: string;
|
||||||
|
/** Description of the issue */
|
||||||
|
issue: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of directory status check from the backend.
|
||||||
|
*/
|
||||||
|
export type DirectoryStatus = {
|
||||||
|
/** Whether all required directories are accessible */
|
||||||
|
isValid: boolean;
|
||||||
|
/** List of specific issues found */
|
||||||
|
issues: DirectoryIssue[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statistics for a completed import job session.
|
||||||
|
*/
|
||||||
|
export type JobResultStatistics = {
|
||||||
|
/** Unique session identifier */
|
||||||
|
sessionId: string;
|
||||||
|
/** Timestamp of the earliest job in the session (as string for GraphQL compatibility) */
|
||||||
|
earliestTimestamp: string;
|
||||||
|
/** Number of successfully completed jobs */
|
||||||
|
completedJobs: number;
|
||||||
|
/** Number of failed jobs */
|
||||||
|
failedJobs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status of the import job queue.
|
||||||
|
*/
|
||||||
|
export type ImportQueueStatus = "running" | "drained" | undefined;
|
||||||
129
src/client/components/shared/AlertBanner.tsx
Normal file
129
src/client/components/shared/AlertBanner.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Reusable alert banner component for displaying status messages.
|
||||||
|
* @module components/shared/AlertBanner
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ReactElement, ReactNode } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alert severity levels that determine styling.
|
||||||
|
*/
|
||||||
|
export type AlertSeverity = "error" | "warning" | "info" | "success";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the AlertBanner component.
|
||||||
|
*/
|
||||||
|
export type AlertBannerProps = {
|
||||||
|
/** Alert severity level */
|
||||||
|
severity: AlertSeverity;
|
||||||
|
/** Alert title/heading */
|
||||||
|
title: string;
|
||||||
|
/** Alert content - can be string or JSX */
|
||||||
|
children: ReactNode;
|
||||||
|
/** Optional close handler - shows close button when provided */
|
||||||
|
onClose?: () => void;
|
||||||
|
/** Optional custom icon class (defaults based on severity) */
|
||||||
|
iconClass?: string;
|
||||||
|
/** Optional additional CSS classes */
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const severityConfig: Record<
|
||||||
|
AlertSeverity,
|
||||||
|
{
|
||||||
|
border: string;
|
||||||
|
bg: string;
|
||||||
|
titleColor: string;
|
||||||
|
textColor: string;
|
||||||
|
iconColor: string;
|
||||||
|
defaultIcon: string;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
error: {
|
||||||
|
border: "border-red-500",
|
||||||
|
bg: "bg-red-50 dark:bg-red-900/20",
|
||||||
|
titleColor: "text-red-800 dark:text-red-300",
|
||||||
|
textColor: "text-red-700 dark:text-red-400",
|
||||||
|
iconColor: "text-red-600 dark:text-red-400",
|
||||||
|
defaultIcon: "icon-[solar--danger-circle-bold]",
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
border: "border-amber-500",
|
||||||
|
bg: "bg-amber-50 dark:bg-amber-900/20",
|
||||||
|
titleColor: "text-amber-800 dark:text-amber-300",
|
||||||
|
textColor: "text-amber-700 dark:text-amber-400",
|
||||||
|
iconColor: "text-amber-600 dark:text-amber-400",
|
||||||
|
defaultIcon: "icon-[solar--folder-error-bold]",
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
border: "border-blue-500",
|
||||||
|
bg: "bg-blue-50 dark:bg-blue-900/20",
|
||||||
|
titleColor: "text-blue-800 dark:text-blue-300",
|
||||||
|
textColor: "text-blue-700 dark:text-blue-400",
|
||||||
|
iconColor: "text-blue-600 dark:text-blue-400",
|
||||||
|
defaultIcon: "icon-[solar--info-circle-bold]",
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
border: "border-emerald-500",
|
||||||
|
bg: "bg-emerald-50 dark:bg-emerald-900/20",
|
||||||
|
titleColor: "text-emerald-800 dark:text-emerald-300",
|
||||||
|
textColor: "text-emerald-700 dark:text-emerald-400",
|
||||||
|
iconColor: "text-emerald-600 dark:text-emerald-400",
|
||||||
|
defaultIcon: "icon-[solar--check-circle-bold]",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable alert banner component for displaying status messages.
|
||||||
|
*
|
||||||
|
* @param props - Component props
|
||||||
|
* @returns Alert banner element
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <AlertBanner severity="error" title="Import Error" onClose={() => setError(null)}>
|
||||||
|
* Failed to import files. Please try again.
|
||||||
|
* </AlertBanner>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const AlertBanner = ({
|
||||||
|
severity,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
onClose,
|
||||||
|
iconClass,
|
||||||
|
className = "",
|
||||||
|
}: AlertBannerProps): ReactElement => {
|
||||||
|
const config = severityConfig[severity];
|
||||||
|
const icon = iconClass || config.defaultIcon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`rounded-lg border-s-4 ${config.border} ${config.bg} p-4 ${className}`}
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className={`w-6 h-6 ${config.iconColor} mt-0.5`}>
|
||||||
|
<i className={`h-6 w-6 ${icon}`}></i>
|
||||||
|
</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className={`font-semibold ${config.titleColor}`}>{title}</p>
|
||||||
|
<div className={`text-sm ${config.textColor} mt-1`}>{children}</div>
|
||||||
|
</div>
|
||||||
|
{onClose && (
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className={`${config.iconColor} hover:opacity-70`}
|
||||||
|
aria-label="Close alert"
|
||||||
|
>
|
||||||
|
<span className="w-5 h-5">
|
||||||
|
<i className="h-5 w-5 icon-[solar--close-circle-bold]"></i>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AlertBanner;
|
||||||
Reference in New Issue
Block a user