Refactored Import.tsx
This commit is contained in:
@@ -36,24 +36,31 @@ const b = 2;
|
||||
// const a = 1, b = 2; // WRONG
|
||||
|
||||
Types and Interfaces
|
||||
Prefer Interfaces Over Type Aliases
|
||||
Prefer Type Aliases Over Interfaces
|
||||
|
||||
// Good: interface for object shapes
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
// Avoid: type alias for object shapes
|
||||
// Good: type alias for object shapes
|
||||
type User = {
|
||||
id: 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 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
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
*/
|
||||
|
||||
import { ReactElement, useEffect, useRef, useState } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { isEmpty } from "lodash";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useStore } from "../../store";
|
||||
@@ -14,30 +13,11 @@ import { useShallow } from "zustand/react/shallow";
|
||||
import axios from "axios";
|
||||
import { useGetJobResultStatisticsQuery } from "../../graphql/generated";
|
||||
import { RealTimeImportStats } from "./RealTimeImportStats";
|
||||
import { PastImportsTable } from "./PastImportsTable";
|
||||
import { AlertBanner } from "../shared/AlertBanner";
|
||||
import { useImportSessionStatus } from "../../hooks/useImportSessionStatus";
|
||||
import { SETTINGS_SERVICE_BASE_URI } from "../../constants/endpoints";
|
||||
|
||||
/**
|
||||
* 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 type { DirectoryStatus, DirectoryIssue } from "./import.types";
|
||||
|
||||
/**
|
||||
* Import page component for managing comic library imports.
|
||||
@@ -59,28 +39,36 @@ export const Import = (): ReactElement => {
|
||||
importJobQueue: state.importJobQueue,
|
||||
getSocket: state.getSocket,
|
||||
disconnectSocket: state.disconnectSocket,
|
||||
})),
|
||||
}))
|
||||
);
|
||||
|
||||
// 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"],
|
||||
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;
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 30000, // Cache for 30 seconds
|
||||
retry: false, // Don't retry on failure - show error immediately
|
||||
staleTime: 30000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// 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 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
|
||||
const { mutate: forceReImport, isPending: isForceReImporting } = useMutation({
|
||||
mutationFn: async () => {
|
||||
const sessionId = localStorage.getItem("sessionId") || "";
|
||||
@@ -97,7 +85,11 @@ export const Import = (): ReactElement => {
|
||||
},
|
||||
onError: (error: any) => {
|
||||
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 wasComplete = useRef(false);
|
||||
|
||||
// React to importSession.isComplete rather than socket events — more reliable
|
||||
// since it's derived from the actual GraphQL state, not a raw socket event.
|
||||
// React to importSession.isComplete for state updates
|
||||
useEffect(() => {
|
||||
if (importSession.isComplete && !wasComplete.current) {
|
||||
wasComplete.current = true;
|
||||
// Small delay so the backend has time to commit job result stats
|
||||
setTimeout(() => {
|
||||
// Invalidate the cache to force a fresh fetch of job result statistics
|
||||
queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] });
|
||||
refetch();
|
||||
}, 1500);
|
||||
@@ -124,21 +113,23 @@ export const Import = (): ReactElement => {
|
||||
}
|
||||
}, [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(() => {
|
||||
const socket = getSocket("/");
|
||||
|
||||
const handleImportCompleted = () => {
|
||||
console.log("[Import] IMPORT_SESSION_COMPLETED event - refreshing Past Imports");
|
||||
// Small delay to ensure backend has committed the job results
|
||||
console.log(
|
||||
"[Import] IMPORT_SESSION_COMPLETED event - refreshing Past Imports"
|
||||
);
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] });
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const handleQueueDrained = () => {
|
||||
console.log("[Import] LS_IMPORT_QUEUE_DRAINED event - refreshing Past Imports");
|
||||
// Small delay to ensure backend has committed the job results
|
||||
console.log(
|
||||
"[Import] LS_IMPORT_QUEUE_DRAINED event - refreshing Past Imports"
|
||||
);
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] });
|
||||
}, 1500);
|
||||
@@ -159,14 +150,15 @@ 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(", ");
|
||||
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.`
|
||||
);
|
||||
@@ -174,7 +166,6 @@ export const Import = (): ReactElement => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for active session before starting using definitive status
|
||||
if (hasActiveSession) {
|
||||
setImportError(
|
||||
`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;
|
||||
}
|
||||
|
||||
if (window.confirm(
|
||||
"This will re-import ALL files in your library folder, even those already imported. " +
|
||||
"This can help fix Elasticsearch indexing issues. Continue?"
|
||||
)) {
|
||||
if (
|
||||
window.confirm(
|
||||
"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") {
|
||||
localStorage.removeItem("sessionId");
|
||||
disconnectSocket("/");
|
||||
@@ -201,6 +194,10 @@ export const Import = (): ReactElement => {
|
||||
}
|
||||
};
|
||||
|
||||
const canStartImport =
|
||||
!hasActiveSession &&
|
||||
(importJobQueue.status === "drained" || importJobQueue.status === undefined);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<section>
|
||||
@@ -211,7 +208,6 @@ export const Import = (): ReactElement => {
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
|
||||
Import
|
||||
</h1>
|
||||
|
||||
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
|
||||
Import comics into the ThreeTwo library.
|
||||
</p>
|
||||
@@ -247,97 +243,72 @@ export const Import = (): ReactElement => {
|
||||
|
||||
{/* Error Message */}
|
||||
{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="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">
|
||||
Import Error
|
||||
</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 className="my-6 max-w-screen-lg">
|
||||
<AlertBanner
|
||||
severity="error"
|
||||
title="Import Error"
|
||||
onClose={() => setImportError(null)}
|
||||
>
|
||||
{importError}
|
||||
</AlertBanner>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Directory Check Error - shown when API call fails */}
|
||||
{/* Directory Check Error */}
|
||||
{!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 className="my-6 max-w-screen-lg">
|
||||
<AlertBanner severity="error" title="Failed to Check Directory Status">
|
||||
<p>
|
||||
Unable to verify if required directories exist. Import
|
||||
functionality has been disabled.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
Error: {(directoryError as Error)?.message || "Unknown error"}
|
||||
</p>
|
||||
</AlertBanner>
|
||||
</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
|
||||
{/* Directory Status Warning */}
|
||||
{!isCheckingDirectories &&
|
||||
!directoryCheckFailed &&
|
||||
directoryIssues.length > 0 && (
|
||||
<div className="my-6 max-w-screen-lg">
|
||||
<AlertBanner
|
||||
severity="warning"
|
||||
title="Directory Configuration Issues"
|
||||
iconClass="icon-[solar--folder-error-bold]"
|
||||
>
|
||||
<p>
|
||||
The following issues were detected with your directory
|
||||
configuration:
|
||||
</p>
|
||||
<p className="text-sm text-amber-700 dark:text-amber-400 mt-1">
|
||||
The following issues were detected with your directory configuration:
|
||||
<DirectoryIssuesList issues={directoryIssues} />
|
||||
<p className="mt-2">
|
||||
Please ensure these directories are mounted correctly in your
|
||||
Docker 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>
|
||||
</AlertBanner>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Force Re-Import Button - always shown when no import is running */}
|
||||
{!hasActiveSession &&
|
||||
(importJobQueue.status === "drained" || importJobQueue.status === undefined) && (
|
||||
{/* Force Re-Import Button */}
|
||||
{canStartImport && (
|
||||
<div className="my-6 max-w-screen-lg">
|
||||
<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 || !hasAllDirectories}
|
||||
title={!hasAllDirectories
|
||||
? "Cannot import: Required directories are missing"
|
||||
: "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">
|
||||
{isForceReImporting ? "Starting Re-Import..." : "Force Re-Import All Files"}
|
||||
{isForceReImporting
|
||||
? "Starting Re-Import..."
|
||||
: "Force Re-Import All Files"}
|
||||
</span>
|
||||
<span className="w-6 h-6">
|
||||
<i className="h-6 w-6 icon-[solar--refresh-bold-duotone]"></i>
|
||||
@@ -346,87 +317,9 @@ export const Import = (): ReactElement => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import activity is now shown in the RealTimeImportStats component above */}
|
||||
|
||||
{/* Past Imports Table */}
|
||||
{!isLoading && !isEmpty(data?.getJobResultStatistics) && (
|
||||
<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?.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>
|
||||
<PastImportsTable data={data!.getJobResultStatistics as any} />
|
||||
)}
|
||||
</div>
|
||||
</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;
|
||||
|
||||
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