|
|
@@ -2,20 +2,22 @@
|
|
|
|
|
|
import {
|
|
|
ArrowPathIcon,
|
|
|
+ ChartBarIcon,
|
|
|
CheckCircleIcon,
|
|
|
EyeSlashIcon,
|
|
|
FolderIcon,
|
|
|
Squares2X2Icon,
|
|
|
TrashIcon,
|
|
|
+ XMarkIcon,
|
|
|
} from "@heroicons/react/24/outline";
|
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
|
-import Link from "next/link";
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
|
import toast from "react-hot-toast";
|
|
|
-import { get, post } from "../../lib/api";
|
|
|
+import { del, get, post } from "../../lib/api";
|
|
|
import ConfirmationDialog from "../components/ConfirmationDialog";
|
|
|
import LoadingCard from "../components/Loading";
|
|
|
import { useNotifications } from "../components/NotificationContext";
|
|
|
+import PathAutocomplete from "../components/PathAutocomplete";
|
|
|
import { useAppContext } from "../providers/AppContext";
|
|
|
|
|
|
interface DuplicateGroup {
|
|
|
@@ -31,6 +33,21 @@ interface DuplicateGroup {
|
|
|
note?: string;
|
|
|
}
|
|
|
|
|
|
+interface IndexStats {
|
|
|
+ totalDuplicates: number;
|
|
|
+ duplicatesByDataset: Array<{
|
|
|
+ dataset: string;
|
|
|
+ hash: string;
|
|
|
+ file_size: number;
|
|
|
+ file_count: number;
|
|
|
+ files: string[];
|
|
|
+ }>;
|
|
|
+}
|
|
|
+
|
|
|
+interface IndexCount {
|
|
|
+ count: number;
|
|
|
+}
|
|
|
+
|
|
|
type SortField = "dataset" | "count" | "size" | "created_at";
|
|
|
|
|
|
type DeleteSelection = {
|
|
|
@@ -38,12 +55,12 @@ type DeleteSelection = {
|
|
|
groups: Array<{ id: number; files: string[] }>;
|
|
|
};
|
|
|
|
|
|
-function formatBytes(bytes: number) {
|
|
|
+const formatBytes = (bytes: number) => {
|
|
|
if (!bytes) return "0 B";
|
|
|
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
|
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
|
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
|
|
|
-}
|
|
|
+};
|
|
|
|
|
|
function makeFileKey(groupId: number, path: string) {
|
|
|
return `${groupId}::${encodeURIComponent(path)}`;
|
|
|
@@ -56,9 +73,189 @@ function parseFileKey(key: string) {
|
|
|
|
|
|
export default function DuplicateList() {
|
|
|
const queryClient = useQueryClient();
|
|
|
- const { datasets } = useAppContext();
|
|
|
+ const { datasets, datasetsConfig } = useAppContext();
|
|
|
const { addNotification } = useNotifications();
|
|
|
|
|
|
+ // Modal states
|
|
|
+ const [showIndexModal, setShowIndexModal] = useState(false);
|
|
|
+ const [showStatsModal, setShowStatsModal] = useState(false);
|
|
|
+
|
|
|
+ // Index management states
|
|
|
+ const [selectedDataset, setSelectedDataset] = useState<string>("");
|
|
|
+ const [destinationPath, setDestinationPath] = useState<string>("");
|
|
|
+ const [defaultDestination, setDefaultDestination] = useState<string>("");
|
|
|
+ const [batchSize, setBatchSize] = useState<number>(100);
|
|
|
+
|
|
|
+ const datasetNames = datasets
|
|
|
+ ? datasets.map((p: string) => p.split("/").pop()).filter(Boolean)
|
|
|
+ : [];
|
|
|
+
|
|
|
+ // Auto-populate destination path from dataset configuration when a dataset is selected
|
|
|
+ useEffect(() => {
|
|
|
+ if (!selectedDataset || !datasetsConfig) return;
|
|
|
+
|
|
|
+ const cfg = datasetsConfig[selectedDataset];
|
|
|
+ if (!cfg) return;
|
|
|
+
|
|
|
+ const tryFindDestination = (obj: any): string | undefined => {
|
|
|
+ if (!obj || typeof obj !== "object") return undefined;
|
|
|
+ if (typeof obj.destination === "string" && obj.destination.trim()) {
|
|
|
+ return obj.destination as string;
|
|
|
+ }
|
|
|
+ for (const value of Object.values(obj)) {
|
|
|
+ if (
|
|
|
+ value &&
|
|
|
+ typeof value === "object" &&
|
|
|
+ typeof value.destination === "string"
|
|
|
+ ) {
|
|
|
+ if (value.destination.trim()) return value.destination as string;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return undefined;
|
|
|
+ };
|
|
|
+
|
|
|
+ const destination = tryFindDestination(cfg);
|
|
|
+ if (destination) {
|
|
|
+ setDefaultDestination(destination);
|
|
|
+ if (!destinationPath || destinationPath === defaultDestination) {
|
|
|
+ setDestinationPath(destination);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }, [selectedDataset, datasetsConfig]);
|
|
|
+
|
|
|
+ // Get index count for selected dataset
|
|
|
+ const {
|
|
|
+ data: indexCount,
|
|
|
+ isLoading: isLoadingCount,
|
|
|
+ refetch: refetchCount,
|
|
|
+ } = useQuery<IndexCount>({
|
|
|
+ queryKey: ["index-count", selectedDataset],
|
|
|
+ queryFn: async () =>
|
|
|
+ selectedDataset
|
|
|
+ ? get("/maintenance/index/count", { dataset: selectedDataset })
|
|
|
+ : { count: 0 },
|
|
|
+ enabled: !!selectedDataset && showIndexModal,
|
|
|
+ });
|
|
|
+
|
|
|
+ // Get duplicate stats
|
|
|
+ const {
|
|
|
+ data: stats,
|
|
|
+ isLoading: isLoadingStats,
|
|
|
+ refetch: refetchStats,
|
|
|
+ } = useQuery<IndexStats>({
|
|
|
+ queryKey: ["index-stats"],
|
|
|
+ queryFn: async () => get("/maintenance/index/stats"),
|
|
|
+ enabled: showStatsModal,
|
|
|
+ });
|
|
|
+
|
|
|
+ // Index destination mutation
|
|
|
+ const indexMutation = useMutation({
|
|
|
+ mutationFn: async ({
|
|
|
+ dataset,
|
|
|
+ destination,
|
|
|
+ reindex,
|
|
|
+ }: {
|
|
|
+ dataset: string;
|
|
|
+ destination: string;
|
|
|
+ reindex: boolean;
|
|
|
+ }) =>
|
|
|
+ post("/maintenance/index/destination", {
|
|
|
+ dataset,
|
|
|
+ destination,
|
|
|
+ reindex,
|
|
|
+ batchSize,
|
|
|
+ }),
|
|
|
+ onSuccess: (data) => {
|
|
|
+ setShowIndexModal(false);
|
|
|
+ toast.success(
|
|
|
+ `✅ Indexed: ${data.indexed}, Skipped: ${data.skipped}, Errors: ${data.errors}`
|
|
|
+ );
|
|
|
+ addNotification({
|
|
|
+ type: "success",
|
|
|
+ title: "Indexing Complete",
|
|
|
+ message: `Indexed ${data.indexed} files, skipped ${data.skipped}, errors ${data.errors}`,
|
|
|
+ });
|
|
|
+ refetchCount();
|
|
|
+ refetchStats();
|
|
|
+ queryClient.invalidateQueries({ queryKey: ["all-files"] });
|
|
|
+ queryClient.invalidateQueries({ queryKey: ["files"] });
|
|
|
+ },
|
|
|
+ onError: (err: any) => {
|
|
|
+ console.error(err);
|
|
|
+ toast.error("Failed to index destination");
|
|
|
+ addNotification({
|
|
|
+ type: "error",
|
|
|
+ title: "Indexing Failed",
|
|
|
+ message: err.message || "Failed to index destination",
|
|
|
+ });
|
|
|
+ },
|
|
|
+ });
|
|
|
+
|
|
|
+ // Clear index mutation
|
|
|
+ const clearMutation = useMutation({
|
|
|
+ mutationFn: async (dataset: string) => del(`/maintenance/index/${dataset}`),
|
|
|
+ onSuccess: (data) => {
|
|
|
+ setShowIndexModal(false);
|
|
|
+ toast.success(`🗑️ Cleared ${data.cleared} index entries`);
|
|
|
+ addNotification({
|
|
|
+ type: "success",
|
|
|
+ title: "Index Cleared",
|
|
|
+ message: `Cleared ${data.cleared} index entries from ${selectedDataset}`,
|
|
|
+ });
|
|
|
+ refetchCount();
|
|
|
+ refetchStats();
|
|
|
+ queryClient.invalidateQueries({ queryKey: ["all-files"] });
|
|
|
+ queryClient.invalidateQueries({ queryKey: ["files"] });
|
|
|
+ },
|
|
|
+ onError: (err: any) => {
|
|
|
+ console.error(err);
|
|
|
+ toast.error("Failed to clear index");
|
|
|
+ addNotification({
|
|
|
+ type: "error",
|
|
|
+ title: "Clear Index Failed",
|
|
|
+ message: err.message || "Failed to clear index",
|
|
|
+ });
|
|
|
+ },
|
|
|
+ });
|
|
|
+
|
|
|
+ const handleIndex = (reindex: boolean) => {
|
|
|
+ if (!selectedDataset) {
|
|
|
+ toast.error("Please select a dataset");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (!destinationPath) {
|
|
|
+ toast.error("Please enter a destination path");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ toast.loading(
|
|
|
+ `Starting ${reindex ? "re-index" : "index"} of ${selectedDataset}...`,
|
|
|
+ { duration: 2000 }
|
|
|
+ );
|
|
|
+ addNotification({
|
|
|
+ type: "info",
|
|
|
+ title: "Indexing Started",
|
|
|
+ message: `${reindex ? "Re-indexing" : "Indexing"} ${selectedDataset} at ${destinationPath}`,
|
|
|
+ });
|
|
|
+
|
|
|
+ indexMutation.mutate({
|
|
|
+ dataset: selectedDataset,
|
|
|
+ destination: destinationPath,
|
|
|
+ reindex,
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleClear = () => {
|
|
|
+ if (!selectedDataset) {
|
|
|
+ toast.error("Please select a dataset");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (confirm(`Clear all index entries for ${selectedDataset}?`)) {
|
|
|
+ clearMutation.mutate(selectedDataset);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
const {
|
|
|
data: duplicateGroups,
|
|
|
isLoading,
|
|
|
@@ -93,6 +290,55 @@ export default function DuplicateList() {
|
|
|
}
|
|
|
}, [datasets]);
|
|
|
|
|
|
+ // Listen for indexing events from WebSocket
|
|
|
+ useEffect(() => {
|
|
|
+ const handleMaintenanceUpdate = (event: CustomEvent) => {
|
|
|
+ const data = event.detail;
|
|
|
+ if (data.type === "indexing_progress") {
|
|
|
+ // Show progress toast
|
|
|
+ toast.loading(
|
|
|
+ `Indexing ${data.dataset}: ${data.progress}% (${data.indexed} files)`,
|
|
|
+ { id: `indexing-${data.dataset}` }
|
|
|
+ );
|
|
|
+ } else if (data.type === "indexing_complete") {
|
|
|
+ // Dismiss progress toast and show success
|
|
|
+ toast.dismiss(`indexing-${data.dataset}`);
|
|
|
+ toast.success(
|
|
|
+ `Indexing complete: ${data.indexed} indexed, ${data.skipped} skipped, ${data.errors} errors`
|
|
|
+ );
|
|
|
+ addNotification({
|
|
|
+ type: "success",
|
|
|
+ title: "Indexing Complete",
|
|
|
+ message: `${data.dataset}: ${data.indexed} files indexed`,
|
|
|
+ });
|
|
|
+ refetchCount();
|
|
|
+ refetchStats();
|
|
|
+ queryClient.invalidateQueries({ queryKey: ["all-files"] });
|
|
|
+ queryClient.invalidateQueries({ queryKey: ["files"] });
|
|
|
+ } else if (data.type === "indexing_error") {
|
|
|
+ toast.dismiss(`indexing-${data.dataset}`);
|
|
|
+ toast.error(`Indexing failed: ${data.error}`);
|
|
|
+ addNotification({
|
|
|
+ type: "error",
|
|
|
+ title: "Indexing Failed",
|
|
|
+ message: `${data.dataset}: ${data.error}`,
|
|
|
+ });
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ window.addEventListener(
|
|
|
+ "maintenanceUpdate",
|
|
|
+ handleMaintenanceUpdate as EventListener
|
|
|
+ );
|
|
|
+
|
|
|
+ return () => {
|
|
|
+ window.removeEventListener(
|
|
|
+ "maintenanceUpdate",
|
|
|
+ handleMaintenanceUpdate as EventListener
|
|
|
+ );
|
|
|
+ };
|
|
|
+ }, [queryClient, refetchCount, refetchStats, addNotification]);
|
|
|
+
|
|
|
const [scanController, setScanController] = useState<AbortController | null>(
|
|
|
null
|
|
|
);
|
|
|
@@ -311,59 +557,103 @@ export default function DuplicateList() {
|
|
|
</p>
|
|
|
</div>
|
|
|
<div className="flex flex-wrap gap-2">
|
|
|
- <Link
|
|
|
- href="/indexing"
|
|
|
- className="inline-flex items-center gap-2 rounded-md bg-purple-600 px-3 py-2 text-sm font-medium text-white shadow-sm hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2"
|
|
|
- >
|
|
|
- <FolderIcon className="h-4 w-4" />
|
|
|
- Manage Index
|
|
|
- </Link>
|
|
|
- <button
|
|
|
- onClick={() => {
|
|
|
- addNotification({
|
|
|
- type: "info",
|
|
|
- title: "Scan Started",
|
|
|
- message: "Scanning for duplicates in /Volumes/Shares/.Private",
|
|
|
- });
|
|
|
- const controller = new AbortController();
|
|
|
- setScanController(controller);
|
|
|
- scanMutation.mutate({ signal: controller.signal });
|
|
|
- }}
|
|
|
- disabled={scanMutation.isPending}
|
|
|
- className="inline-flex items-center gap-2 rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
|
- >
|
|
|
- <ArrowPathIcon
|
|
|
- className={`h-4 w-4 ${scanMutation.isPending ? "animate-spin" : ""}`}
|
|
|
- />
|
|
|
- {scanMutation.isPending ? "Scanning..." : "Rescan"}
|
|
|
- </button>
|
|
|
- {scanController && (
|
|
|
+ <div className="relative group">
|
|
|
+ <button
|
|
|
+ onClick={() => setShowIndexModal(true)}
|
|
|
+ className="p-2 rounded-md text-gray-600 dark:text-gray-400 hover:bg-purple-100 dark:hover:bg-purple-900/30 hover:text-purple-600 dark:hover:text-purple-400 transition-colors"
|
|
|
+ title="Manage Index"
|
|
|
+ >
|
|
|
+ <FolderIcon className="h-5 w-5" />
|
|
|
+ </button>
|
|
|
+ <div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-2 py-1 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 text-xs rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
|
|
|
+ Manage Index
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="relative group">
|
|
|
+ <button
|
|
|
+ onClick={() => setShowStatsModal(true)}
|
|
|
+ className="p-2 rounded-md text-gray-600 dark:text-gray-400 hover:bg-amber-100 dark:hover:bg-amber-900/30 hover:text-amber-600 dark:hover:text-amber-400 transition-colors"
|
|
|
+ title="Duplicate Statistics"
|
|
|
+ >
|
|
|
+ <ChartBarIcon className="h-5 w-5" />
|
|
|
+ </button>
|
|
|
+ <div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-2 py-1 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 text-xs rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
|
|
|
+ Duplicate Statistics
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="relative group">
|
|
|
<button
|
|
|
onClick={() => {
|
|
|
- scanController.abort();
|
|
|
- setScanController(null);
|
|
|
+ addNotification({
|
|
|
+ type: "info",
|
|
|
+ title: "Scan Started",
|
|
|
+ message:
|
|
|
+ "Scanning for duplicates in /Volumes/Shares/.Private",
|
|
|
+ });
|
|
|
+ const controller = new AbortController();
|
|
|
+ setScanController(controller);
|
|
|
+ scanMutation.mutate({ signal: controller.signal });
|
|
|
}}
|
|
|
- className="inline-flex items-center gap-2 rounded-md bg-gray-600 px-3 py-2 text-sm font-medium text-white shadow-sm hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
|
|
|
+ disabled={scanMutation.isPending}
|
|
|
+ className="p-2 rounded-md text-gray-600 dark:text-gray-400 hover:bg-blue-100 dark:hover:bg-blue-900/30 hover:text-blue-600 dark:hover:text-blue-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
|
+ title="Rescan for duplicates"
|
|
|
>
|
|
|
- Cancel Scan
|
|
|
+ <ArrowPathIcon
|
|
|
+ className={`h-5 w-5 ${scanMutation.isPending ? "animate-spin" : ""}`}
|
|
|
+ />
|
|
|
</button>
|
|
|
+ <div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-2 py-1 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 text-xs rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
|
|
|
+ {scanMutation.isPending ? "Scanning..." : "Rescan"}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {scanController && (
|
|
|
+ <div className="relative group">
|
|
|
+ <button
|
|
|
+ onClick={() => {
|
|
|
+ scanController.abort();
|
|
|
+ setScanController(null);
|
|
|
+ }}
|
|
|
+ className="p-2 rounded-md text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
|
|
|
+ title="Cancel scan"
|
|
|
+ >
|
|
|
+ <XMarkIcon className="h-5 w-5" />
|
|
|
+ </button>
|
|
|
+ <div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-2 py-1 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 text-xs rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
|
|
|
+ Cancel Scan
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
)}
|
|
|
- <button
|
|
|
- onClick={markSelectedNotDuplicate}
|
|
|
- disabled={selectedGroups.size === 0}
|
|
|
- className="inline-flex items-center gap-2 rounded-md bg-emerald-600 px-3 py-2 text-sm font-medium text-white shadow-sm disabled:opacity-60 hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
|
|
|
- >
|
|
|
- <CheckCircleIcon className="h-4 w-4" />
|
|
|
- Mark Not Duplicate
|
|
|
- </button>
|
|
|
- <button
|
|
|
- onClick={handleBatchDelete}
|
|
|
- disabled={selectedFiles.size === 0}
|
|
|
- className="inline-flex items-center gap-2 rounded-md bg-red-600 px-3 py-2 text-sm font-medium text-white shadow-sm disabled:opacity-60 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
|
|
|
- >
|
|
|
- <TrashIcon className="h-4 w-4" />
|
|
|
- Delete Selected Files
|
|
|
- </button>
|
|
|
+
|
|
|
+ <div className="relative group">
|
|
|
+ <button
|
|
|
+ onClick={markSelectedNotDuplicate}
|
|
|
+ disabled={selectedGroups.size === 0}
|
|
|
+ className="p-2 rounded-md text-gray-600 dark:text-gray-400 hover:bg-emerald-100 dark:hover:bg-emerald-900/30 hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
|
+ title="Mark selected as not duplicate"
|
|
|
+ >
|
|
|
+ <CheckCircleIcon className="h-5 w-5" />
|
|
|
+ </button>
|
|
|
+ <div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-2 py-1 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 text-xs rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
|
|
|
+ Mark Not Duplicate
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="relative group">
|
|
|
+ <button
|
|
|
+ onClick={handleBatchDelete}
|
|
|
+ disabled={selectedFiles.size === 0}
|
|
|
+ className="p-2 rounded-md text-gray-600 dark:text-gray-400 hover:bg-red-100 dark:hover:bg-red-900/30 hover:text-red-600 dark:hover:text-red-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
|
+ title="Delete selected files"
|
|
|
+ >
|
|
|
+ <TrashIcon className="h-5 w-5" />
|
|
|
+ </button>
|
|
|
+ <div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-2 py-1 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 text-xs rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
|
|
|
+ Delete Selected Files
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
@@ -609,6 +899,226 @@ export default function DuplicateList() {
|
|
|
onConfirm={confirmDelete}
|
|
|
onClose={() => setDeleteSelection({ isOpen: false, groups: [] })}
|
|
|
/>
|
|
|
+
|
|
|
+ {/* Index Management Slide-over */}
|
|
|
+ {showIndexModal && (
|
|
|
+ <div className="fixed inset-0 z-50 overflow-hidden">
|
|
|
+ <div className="absolute inset-0 overflow-hidden">
|
|
|
+ <div
|
|
|
+ className="absolute inset-0 bg-gray-500 bg-opacity-75 dark:bg-gray-900 dark:bg-opacity-75 transition-opacity"
|
|
|
+ onClick={() => setShowIndexModal(false)}
|
|
|
+ />
|
|
|
+ <div className="fixed inset-y-0 right-0 pl-10 max-w-full flex">
|
|
|
+ <div className="w-screen max-w-md bg-white dark:bg-gray-800 shadow-xl transform transition-all animate-slide-in-right">
|
|
|
+ <div className="h-full flex flex-col overflow-y-scroll">
|
|
|
+ {/* Header */}
|
|
|
+ <div className="bg-white dark:bg-gray-800 px-4 py-6 sm:px-6">
|
|
|
+ <div className="flex items-center justify-between">
|
|
|
+ <h2 className="text-lg leading-6 font-medium text-gray-900 dark:text-gray-100">
|
|
|
+ Index Management
|
|
|
+ </h2>
|
|
|
+ <button
|
|
|
+ onClick={() => setShowIndexModal(false)}
|
|
|
+ className="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300 transition-colors"
|
|
|
+ >
|
|
|
+ <XMarkIcon className="h-6 w-6" />
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Content */}
|
|
|
+ <div className="flex-1 px-4 py-6 sm:px-6 space-y-6">
|
|
|
+ <div>
|
|
|
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
|
+ Dataset
|
|
|
+ </label>
|
|
|
+ <select
|
|
|
+ value={selectedDataset}
|
|
|
+ onChange={(e) => setSelectedDataset(e.target.value)}
|
|
|
+ className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-gray-100"
|
|
|
+ >
|
|
|
+ <option value="">Select a dataset...</option>
|
|
|
+ {datasetNames.map((name) => (
|
|
|
+ <option key={name} value={name}>
|
|
|
+ {name}
|
|
|
+ </option>
|
|
|
+ ))}
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div>
|
|
|
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
|
+ Destination Path
|
|
|
+ </label>
|
|
|
+ <PathAutocomplete
|
|
|
+ value={destinationPath}
|
|
|
+ onChange={setDestinationPath}
|
|
|
+ placeholder="/path/to/destination"
|
|
|
+ defaultPath={defaultDestination}
|
|
|
+ className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-gray-100"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div>
|
|
|
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
|
+ Batch Size
|
|
|
+ </label>
|
|
|
+ <input
|
|
|
+ type="number"
|
|
|
+ value={batchSize}
|
|
|
+ onChange={(e) => setBatchSize(parseInt(e.target.value))}
|
|
|
+ min="10"
|
|
|
+ max="1000"
|
|
|
+ className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-gray-100"
|
|
|
+ />
|
|
|
+ <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
|
+ Number of files to process at once
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {selectedDataset && !isLoadingCount && (
|
|
|
+ <div className="flex justify-between items-center p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
|
|
+ <span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
|
+ Indexed Files
|
|
|
+ </span>
|
|
|
+ <span className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
|
|
+ {indexCount?.count || 0}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Footer */}
|
|
|
+ <div className="bg-gray-50 dark:bg-gray-700 px-4 py-4 sm:px-6 space-y-3">
|
|
|
+ <button
|
|
|
+ onClick={() => handleIndex(false)}
|
|
|
+ disabled={indexMutation.isPending}
|
|
|
+ className="w-full inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
|
+ >
|
|
|
+ <FolderIcon className="h-5 w-5 mr-2" />
|
|
|
+ Index
|
|
|
+ </button>
|
|
|
+
|
|
|
+ <button
|
|
|
+ onClick={() => handleIndex(true)}
|
|
|
+ disabled={indexMutation.isPending}
|
|
|
+ className="w-full inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-orange-600 hover:bg-orange-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-orange-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
|
+ >
|
|
|
+ <ArrowPathIcon className="h-5 w-5 mr-2" />
|
|
|
+ Re-index
|
|
|
+ </button>
|
|
|
+
|
|
|
+ <button
|
|
|
+ onClick={handleClear}
|
|
|
+ disabled={clearMutation.isPending || !selectedDataset}
|
|
|
+ className="w-full inline-flex items-center justify-center px-4 py-2 border border-gray-300 dark:border-gray-600 text-sm font-medium rounded-md shadow-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
|
+ >
|
|
|
+ <TrashIcon className="h-5 w-5 mr-2" />
|
|
|
+ Clear Index
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* Duplicate Statistics Slide-over */}
|
|
|
+ {showStatsModal && (
|
|
|
+ <div className="fixed inset-0 z-50 overflow-hidden">
|
|
|
+ <div className="absolute inset-0 overflow-hidden">
|
|
|
+ <div
|
|
|
+ className="absolute inset-0 bg-gray-500 bg-opacity-75 dark:bg-gray-900 dark:bg-opacity-75 transition-opacity"
|
|
|
+ onClick={() => setShowStatsModal(false)}
|
|
|
+ />
|
|
|
+ <div className="fixed inset-y-0 right-0 pl-10 max-w-full flex">
|
|
|
+ <div className="w-screen max-w-md bg-white dark:bg-gray-800 shadow-xl transform transition-all animate-slide-in-right">
|
|
|
+ <div className="h-full flex flex-col overflow-y-scroll">
|
|
|
+ {/* Header */}
|
|
|
+ <div className="bg-white dark:bg-gray-800 px-4 py-6 sm:px-6">
|
|
|
+ <div className="flex items-center justify-between">
|
|
|
+ <h2 className="text-lg leading-6 font-medium text-gray-900 dark:text-gray-100 flex items-center">
|
|
|
+ <ChartBarIcon className="h-5 w-5 mr-2" />
|
|
|
+ Duplicate Statistics
|
|
|
+ </h2>
|
|
|
+ <button
|
|
|
+ onClick={() => setShowStatsModal(false)}
|
|
|
+ className="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300 transition-colors"
|
|
|
+ >
|
|
|
+ <XMarkIcon className="h-6 w-6" />
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Content */}
|
|
|
+ <div className="flex-1 px-4 py-6 sm:px-6">
|
|
|
+ {isLoadingStats ? (
|
|
|
+ <LoadingCard message="Loading duplicate stats..." />
|
|
|
+ ) : stats && stats.totalDuplicates > 0 ? (
|
|
|
+ <div className="space-y-4">
|
|
|
+ <div className="flex justify-between items-center p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg border border-yellow-200 dark:border-yellow-800">
|
|
|
+ <span className="text-sm font-medium text-yellow-800 dark:text-yellow-300">
|
|
|
+ Total Duplicate Groups
|
|
|
+ </span>
|
|
|
+ <span className="text-2xl font-bold text-yellow-600 dark:text-yellow-400">
|
|
|
+ {stats.totalDuplicates}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="space-y-3 max-h-96 overflow-y-auto pr-2">
|
|
|
+ {stats.duplicatesByDataset
|
|
|
+ .slice(0, 10)
|
|
|
+ .map((dup, idx) => (
|
|
|
+ <div
|
|
|
+ key={idx}
|
|
|
+ className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600"
|
|
|
+ >
|
|
|
+ <div className="flex justify-between items-start mb-2">
|
|
|
+ <span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
|
+ [{dup.dataset}] {dup.file_count} files
|
|
|
+ </span>
|
|
|
+ <span className="text-sm text-gray-500 dark:text-gray-400">
|
|
|
+ {formatBytes(dup.file_size)}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <div className="text-xs text-gray-500 dark:text-gray-400 font-mono mb-2">
|
|
|
+ Hash: {dup.hash.substring(0, 32)}...
|
|
|
+ </div>
|
|
|
+ <div className="space-y-1">
|
|
|
+ {dup.files.map((file, fileIdx) => (
|
|
|
+ <div
|
|
|
+ key={fileIdx}
|
|
|
+ className="text-xs text-gray-600 dark:text-gray-400 truncate"
|
|
|
+ title={file}
|
|
|
+ >
|
|
|
+ • {file}
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {stats.duplicatesByDataset.length > 10 && (
|
|
|
+ <p className="text-sm text-gray-500 dark:text-gray-400 text-center">
|
|
|
+ ... and {stats.duplicatesByDataset.length - 10} more
|
|
|
+ duplicate groups
|
|
|
+ </p>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ ) : (
|
|
|
+ <div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
|
|
+ <p>No duplicates found in indexed files</p>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
</div>
|
|
|
);
|
|
|
}
|