309 lines
13 KiB
TypeScript
309 lines
13 KiB
TypeScript
import { ReactElement, useEffect, useRef, useState } from "react";
|
|
import { format } from "date-fns";
|
|
import { isEmpty } from "lodash";
|
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { useStore } from "../../store";
|
|
import { useShallow } from "zustand/react/shallow";
|
|
import axios from "axios";
|
|
import { useGetJobResultStatisticsQuery } from "../../graphql/generated";
|
|
import { RealTimeImportStats } from "./RealTimeImportStats";
|
|
import { useImportSessionStatus } from "../../hooks/useImportSessionStatus";
|
|
|
|
export const Import = (): ReactElement => {
|
|
const [importError, setImportError] = useState<string | null>(null);
|
|
const queryClient = useQueryClient();
|
|
const { importJobQueue, getSocket, disconnectSocket } = useStore(
|
|
useShallow((state) => ({
|
|
importJobQueue: state.importJobQueue,
|
|
getSocket: state.getSocket,
|
|
disconnectSocket: state.disconnectSocket,
|
|
})),
|
|
);
|
|
|
|
// Force re-import mutation - re-imports all files regardless of import status
|
|
const { mutate: forceReImport, isPending: isForceReImporting } = useMutation({
|
|
mutationFn: async () => {
|
|
const sessionId = localStorage.getItem("sessionId") || "";
|
|
return await axios.request({
|
|
url: `http://localhost:3000/api/library/forceReImport`,
|
|
method: "POST",
|
|
data: { sessionId },
|
|
});
|
|
},
|
|
onSuccess: (response) => {
|
|
console.log("Force re-import initiated:", response.data);
|
|
importJobQueue.setStatus("running");
|
|
setImportError(null);
|
|
},
|
|
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.");
|
|
},
|
|
});
|
|
|
|
const { data, isLoading, refetch } = useGetJobResultStatisticsQuery();
|
|
|
|
const importSession = useImportSessionStatus();
|
|
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.
|
|
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);
|
|
importJobQueue.setStatus("drained");
|
|
} else if (!importSession.isComplete) {
|
|
wasComplete.current = false;
|
|
}
|
|
}, [importSession.isComplete, refetch, importJobQueue, queryClient]);
|
|
|
|
// Listen to socket events to update Past Imports table in real-time
|
|
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
|
|
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
|
|
setTimeout(() => {
|
|
queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] });
|
|
}, 1500);
|
|
};
|
|
|
|
socket.on("IMPORT_SESSION_COMPLETED", handleImportCompleted);
|
|
socket.on("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
|
|
|
return () => {
|
|
socket.off("IMPORT_SESSION_COMPLETED", handleImportCompleted);
|
|
socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
|
};
|
|
}, [getSocket, queryClient]);
|
|
|
|
/**
|
|
* Handles force re-import - re-imports all files to fix indexing issues
|
|
*/
|
|
const handleForceReImport = async () => {
|
|
setImportError(null);
|
|
|
|
// 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.`
|
|
);
|
|
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 (importJobQueue.status === "drained") {
|
|
localStorage.removeItem("sessionId");
|
|
disconnectSocket("/");
|
|
setTimeout(() => {
|
|
getSocket("/");
|
|
setTimeout(() => {
|
|
forceReImport();
|
|
}, 500);
|
|
}, 100);
|
|
} else {
|
|
forceReImport();
|
|
}
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<section>
|
|
<header className="bg-slate-200 dark:bg-slate-500">
|
|
<div className="mx-auto max-w-screen-xl px-4 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
|
|
<div className="sm:flex sm:items-center sm:justify-between">
|
|
<div className="text-center sm:text-left">
|
|
<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>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
|
<article
|
|
role="alert"
|
|
className="rounded-lg max-w-screen-md border-s-4 border-blue-500 bg-blue-50 p-4 dark:border-s-4 dark:border-blue-600 dark:bg-blue-300 dark:text-slate-600"
|
|
>
|
|
<div>
|
|
<p>
|
|
Importing will add comics identified from the mapped folder into
|
|
ThreeTwo's database.
|
|
</p>
|
|
<p>
|
|
Metadata from ComicInfo.xml, if present, will also be extracted.
|
|
</p>
|
|
<p>
|
|
This process could take a while, if you have a lot of comics, or
|
|
are importing over a network connection.
|
|
</p>
|
|
</div>
|
|
</article>
|
|
|
|
{/* Import Statistics */}
|
|
<div className="my-6 max-w-screen-lg">
|
|
<RealTimeImportStats />
|
|
</div>
|
|
|
|
{/* 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>
|
|
)}
|
|
|
|
{/* Force Re-Import Button - always shown when no import is running */}
|
|
{!hasActiveSession &&
|
|
(importJobQueue.status === "drained" || importJobQueue.status === undefined) && (
|
|
<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}
|
|
title="Re-import all files to fix Elasticsearch indexing issues"
|
|
>
|
|
<span className="text-md font-medium">
|
|
{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>
|
|
</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Import activity is now shown in the RealTimeImportStats component above */}
|
|
|
|
{!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>
|
|
)}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Import;
|