Pārlūkot izejas kodu

feat: improve files list UI with better display and index action

- Display only filename (not full path) in the files list table
- Add collapsible details showing input, output, hash, and file size
- Add copy buttons for easy copying of input, output, and hash values
- Add "Index File" action to index individual files on demand
- Create new API endpoint POST /files/:dataset/:file/index for single file indexing
- Add indexSingleFile method to compute hash and store in database
- Improve expanded row with better formatting and organized fields
- Display file size in human-readable format (MB) plus bytes
Timothy Pomeroy 3 nedēļas atpakaļ
vecāks
revīzija
820c88b989

+ 8 - 1
apps/service/src/app.controller.ts

@@ -478,7 +478,14 @@ export class AppController {
     return { cleared };
   }
 
-  @Get('config/settings')
+  // Index a single file
+  @Post('files/:dataset/:file/index')
+  async indexSingleFile(
+    @Param('dataset') dataset: string,
+    @Param('file') file: string,
+  ) {
+    return await this.appService.indexSingleFile(dataset, decodeURIComponent(file));
+  }  @Get('config/settings')
   getSettings(
     @Query('key') key?: string,
     @Query('default') defaultValue?: any,

+ 4 - 0
apps/service/src/app.service.ts

@@ -224,6 +224,10 @@ export class AppService {
     );
   }
 
+  async indexSingleFile(dataset: string, filePath: string) {
+    return await this.maintenance.indexSingleFile(dataset, filePath);
+  }
+
   async getIndexedDuplicateStats(dataset?: string) {
     return await this.maintenance.getIndexedDuplicateStats(dataset);
   }

+ 60 - 0
apps/service/src/maintenance.service.ts

@@ -494,4 +494,64 @@ export class MaintenanceService {
       duplicatesByDataset,
     };
   }
+
+  /**
+   * Index a single file - compute hash and store in database
+   */
+  async indexSingleFile(
+    dataset: string,
+    filePath: string,
+  ): Promise<{
+    indexed: boolean;
+    hash?: string;
+    file_size?: number;
+    error?: string;
+  }> {
+    try {
+      const stat = await fsPromises.stat(filePath);
+      if (!stat.isFile()) {
+        return {
+          indexed: false,
+          error: 'Path is not a file',
+        };
+      }
+
+      // Compute hash
+      const data = await fsPromises.readFile(filePath);
+      const hash = crypto.createHash('sha1');
+      hash.update(data);
+      const hexHash = hash.digest('hex');
+
+      // Update or insert in database
+      const stmt = this.db.db.prepare(`
+        INSERT INTO files (dataset, input, destination_path, hash, file_size, date)
+        VALUES (?, ?, ?, ?, ?, datetime('now'))
+        ON CONFLICT(dataset, input) DO UPDATE SET
+          destination_path = excluded.destination_path,
+          hash = excluded.hash,
+          file_size = excluded.file_size,
+          date = excluded.date
+      `);
+
+      stmt.run(dataset, filePath, filePath, hexHash, stat.size);
+
+      this.logger.log(
+        `Indexed single file ${filePath}: hash=${hexHash}, size=${stat.size}`,
+      );
+
+      return {
+        indexed: true,
+        hash: hexHash,
+        file_size: stat.size,
+      };
+    } catch (error) {
+      const errorMsg =
+        error instanceof Error ? error.message : String(error);
+      this.logger.error(`Failed to index file ${filePath}: ${errorMsg}`);
+      return {
+        indexed: false,
+        error: errorMsg,
+      };
+    }
+  }
 }

+ 127 - 10
apps/web/src/app/files/FileList.tsx

@@ -3,6 +3,8 @@
 import {
   ChevronDownIcon,
   ChevronRightIcon,
+  DocumentDuplicateIcon,
+  SparklesIcon,
   TrashIcon,
 } from "@heroicons/react/24/outline";
 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -250,6 +252,34 @@ export default function FileList() {
     },
   });
 
+  const indexMutation = useMutation({
+    mutationFn: ({ dataset, file }: { dataset: string; file: string }) =>
+      post(`/files/${dataset}/${encodeURIComponent(file)}/index`),
+    onSuccess: (result, { file }) => {
+      queryClient.refetchQueries({ queryKey: ["all-files"] });
+      toast.success("File indexed successfully");
+      addNotification({
+        type: "success",
+        title: "File Indexed",
+        message: `File "${file.split("/").pop()}" has been indexed.`,
+      });
+    },
+    onError: (error, { file }) => {
+      console.error("Failed to index file:", error);
+      toast.error("Failed to index file");
+      addNotification({
+        type: "error",
+        title: "Index Failed",
+        message: `Failed to index file "${file.split("/").pop()}". Please try again.`,
+      });
+    },
+  });
+
+  const copyToClipboard = (text: string, label: string) => {
+    navigator.clipboard.writeText(text);
+    toast.success(`${label} copied to clipboard`);
+  };
+
   const handleEdit = (file: any) => {
     setEditFile(file);
   };
@@ -352,6 +382,12 @@ export default function FileList() {
       case "delete":
         handleDeleteClick(file);
         break;
+      case "index":
+        indexMutation.mutate({
+          dataset: file.dataset,
+          file: file.input,
+        });
+        break;
     }
   };
 
@@ -601,7 +637,7 @@ export default function FileList() {
                   className="px-4 py-3 text-left text-xs font-semibold text-gray-700 dark:text-gray-200 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700"
                   onClick={() => handleSort("input")}
                 >
-                  Input{" "}
+                  File{" "}
                   {sortField === "input" &&
                     (sortDirection === "asc" ? "↑" : "↓")}
                 </th>
@@ -659,9 +695,9 @@ export default function FileList() {
                               {file.dataset}
                             </span>
                           </td>
-                          <td className="px-4 py-2 text-sm text-gray-900 dark:text-gray-100 max-w-xs">
+                          <td className="px-4 py-2 text-sm text-gray-900 dark:text-gray-100">
                             <div className="truncate" title={file.input}>
-                              {file.input}
+                              {file.input.split("/").pop()}
                             </div>
                           </td>
                           <td className="px-4 py-2 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
@@ -688,8 +724,17 @@ export default function FileList() {
                                   <ChevronDownIcon className="h-4 w-4" />
                                 </button>
                                 {openDropdown === fileId && (
-                                  <div className="dropdown-container absolute right-0 mt-1 w-32 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 z-10">
+                                  <div className="dropdown-container absolute right-0 mt-1 w-40 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 z-10">
                                     <div className="py-1">
+                                      <button
+                                        onClick={() =>
+                                          handleDropdownAction("index", file)
+                                        }
+                                        className="block w-full text-left px-3 py-2 text-xs text-blue-600 dark:text-blue-400 hover:bg-gray-100 dark:hover:bg-gray-700"
+                                      >
+                                        <SparklesIcon className="h-4 w-4 inline mr-2" />
+                                        Index File
+                                      </button>
                                       <button
                                         onClick={() =>
                                           handleDropdownAction("edit", file)
@@ -718,14 +763,86 @@ export default function FileList() {
                             key={`${fileId}-expanded`}
                             className="bg-gray-50 dark:bg-gray-800"
                           >
-                            <td colSpan={6} className="px-4 py-3">
-                              <div className="text-sm">
-                                <div className="font-medium text-gray-900 dark:text-gray-100 mb-2">
-                                  Output
+                            <td colSpan={6} className="px-4 py-4">
+                              <div className="space-y-4 text-sm">
+                                {/* Input */}
+                                <div>
+                                  <div className="font-medium text-gray-900 dark:text-gray-100 mb-1">
+                                    Input
+                                  </div>
+                                  <div className="flex items-center gap-2">
+                                    <div className="text-gray-700 dark:text-gray-300 font-mono text-xs bg-white dark:bg-gray-900 p-2 rounded border flex-1 truncate">
+                                      {file.input}
+                                    </div>
+                                    <button
+                                      onClick={() =>
+                                        copyToClipboard(file.input, "Input")
+                                      }
+                                      className="inline-flex items-center px-2 py-1 text-xs text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
+                                      title="Copy input path"
+                                    >
+                                      <DocumentDuplicateIcon className="h-4 w-4" />
+                                    </button>
+                                  </div>
                                 </div>
-                                <div className="text-gray-700 dark:text-gray-300 font-mono text-xs bg-white dark:bg-gray-900 p-2 rounded border">
-                                  {file.output || "No output available"}
+
+                                {/* Output */}
+                                <div>
+                                  <div className="font-medium text-gray-900 dark:text-gray-100 mb-1">
+                                    Output
+                                  </div>
+                                  <div className="flex items-center gap-2">
+                                    <div className="text-gray-700 dark:text-gray-300 font-mono text-xs bg-white dark:bg-gray-900 p-2 rounded border flex-1 truncate">
+                                      {file.output || "No output"}
+                                    </div>
+                                    {file.output && (
+                                      <button
+                                        onClick={() =>
+                                          copyToClipboard(file.output, "Output")
+                                        }
+                                        className="inline-flex items-center px-2 py-1 text-xs text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
+                                        title="Copy output path"
+                                      >
+                                        <DocumentDuplicateIcon className="h-4 w-4" />
+                                      </button>
+                                    )}
+                                  </div>
                                 </div>
+
+                                {/* Hash */}
+                                {file.hash && (
+                                  <div>
+                                    <div className="font-medium text-gray-900 dark:text-gray-100 mb-1">
+                                      Hash
+                                    </div>
+                                    <div className="flex items-center gap-2">
+                                      <div className="text-gray-700 dark:text-gray-300 font-mono text-xs bg-white dark:bg-gray-900 p-2 rounded border flex-1 truncate">
+                                        {file.hash}
+                                      </div>
+                                      <button
+                                        onClick={() =>
+                                          copyToClipboard(file.hash, "Hash")
+                                        }
+                                        className="inline-flex items-center px-2 py-1 text-xs text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
+                                        title="Copy hash"
+                                      >
+                                        <DocumentDuplicateIcon className="h-4 w-4" />
+                                      </button>
+                                    </div>
+                                  </div>
+                                )}
+
+                                {/* File Size */}
+                                {file.file_size && (
+                                  <div>
+                                    <div className="font-medium text-gray-900 dark:text-gray-100 mb-1">
+                                      File Size
+                                    </div>
+                                    <div className="text-gray-700 dark:text-gray-300 font-mono text-xs bg-white dark:bg-gray-900 p-2 rounded border">
+                                      {(file.file_size / 1024 / 1024).toFixed(2)} MB ({file.file_size.toLocaleString()} bytes)
+                                    </div>
+                                  </div>
+                                )}
                               </div>
                             </td>
                           </tr>