Explorar o código

Optimize indexing and add post-processing hashing

- Skip already-indexed files in batch mode; allow reindex override for manual indexing
- Switch to streaming file hash calculation (64KB chunks) for memory efficiency
- Add updateFileHash() method to db.service for tracking processed files
- Implement automatic output file hashing in handbrake service after encoding completes
- Pass dataset to handbrake processor so encoded files are hashed and stored in database
- Refactor maintenance button UI from dropdowns to icon buttons across /files, /tasks, /duplicates, /settings pages
- Add FileMaintenanceButtons and TaskMaintenanceButtons components for better UX
- Add WebSocket event listeners for indexing progress notifications
- Enhance duplicate detection and removal logic
Timothy Pomeroy hai 3 semanas
pai
achega
1f041d6460

+ 12 - 2
apps/service/src/app.controller.ts

@@ -478,14 +478,24 @@ export class AppController {
     return { cleared };
   }
 
+  @Post('maintenance/remove-duplicates')
+  removeDuplicateFileEntries() {
+    const removed = this.appService.removeDuplicateFileEntries();
+    return { removed };
+  }
+
   // 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')
+    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

@@ -240,6 +240,10 @@ export class AppService {
     return this.db.clearDestinationFiles(dataset, destination);
   }
 
+  removeDuplicateFileEntries() {
+    return this.db.removeDuplicateFileEntries();
+  }
+
   // Scheduled maintenance
   scheduledTaskCleanup() {
     return this.maintenance.scheduledTaskCleanup();

+ 96 - 37
apps/service/src/db.service.ts

@@ -6,17 +6,6 @@ import { MigrationRunner } from './migration-runner';
 
 @Injectable()
 export class DbService {
-  // List all files
-  listAllFiles() {
-    return this.db.prepare('SELECT * FROM files').all();
-  }
-
-  // List all files for a dataset
-  listFilesForDataset(dataset: string) {
-    return this.db
-      .prepare('SELECT * FROM files WHERE dataset = ?')
-      .all(dataset);
-  }
   private db: Database.Database;
 
   constructor() {
@@ -74,6 +63,18 @@ export class DbService {
     return this.db;
   }
 
+  // List all files
+  listAllFiles() {
+    return this.db.prepare('SELECT * FROM files').all();
+  }
+
+  // List all files for a dataset
+  listFilesForDataset(dataset: string) {
+    return this.db
+      .prepare('SELECT * FROM files WHERE dataset = ?')
+      .all(dataset);
+  }
+
   /**
    * Delete file records older than X days (autoExpireDays from settings)
    * @param days Days to keep (optional, overrides settings)
@@ -699,9 +700,10 @@ export class DbService {
     hash: string,
     fileSize: number,
   ) {
-    // Use destination_path as the primary identifier for destination files
+    // Use destination_path as both input and destination_path for consistency with indexing worker
+    // This prevents duplicates by using the (dataset, input) primary key
     const existing = this.db
-      .prepare('SELECT * FROM files WHERE dataset = ? AND destination_path = ?')
+      .prepare('SELECT * FROM files WHERE dataset = ? AND input = ?')
       .get(dataset, destinationPath) as
       | {
           dataset: string;
@@ -719,18 +721,18 @@ export class DbService {
       this.db
         .prepare(
           `UPDATE files
-           SET hash = ?, file_size = ?, date = ?
-           WHERE dataset = ? AND destination_path = ?`,
+           SET destination_path = ?, hash = ?, file_size = ?, date = ?
+           WHERE dataset = ? AND input = ?`,
         )
-        .run(hash, fileSize, now, dataset, destinationPath);
+        .run(destinationPath, hash, fileSize, now, dataset, destinationPath);
     } else {
-      // For destination files, input is null
+      // Use destination_path as input for the primary key
       this.db
         .prepare(
           `INSERT INTO files (dataset, input, destination_path, hash, file_size, date, status)
-           VALUES (?, NULL, ?, ?, ?, ?, 'indexed')`,
+           VALUES (?, ?, ?, ?, ?, ?, 'indexed')`,
         )
-        .run(dataset, destinationPath, hash, fileSize, now);
+        .run(dataset, destinationPath, destinationPath, hash, fileSize, now);
     }
   }
 
@@ -792,24 +794,6 @@ export class DbService {
     }>;
   }
 
-  /**
-   * Update hash and size for an existing file
-   */
-  updateFileHash(
-    dataset: string,
-    input: string,
-    hash: string,
-    fileSize: number,
-  ) {
-    return this.db
-      .prepare(
-        `UPDATE files
-         SET hash = ?, file_size = ?
-         WHERE dataset = ? AND input = ?`,
-      )
-      .run(hash, fileSize, dataset, input);
-  }
-
   /**
    * Get files in a destination that need hash indexing
    */
@@ -863,4 +847,79 @@ export class DbService {
     const result = this.db.prepare(query).get(...params) as { count: number };
     return result.count;
   }
+
+  /**
+   * Update hash and file_size for a file after processing
+   * Used by handbrake service after encoding completes
+   */
+  updateFileHash(
+    dataset: string,
+    filePath: string,
+    hash: string,
+    fileSize: number,
+  ) {
+    const now = new Date().toISOString();
+    const stmt = this.db.prepare(`
+      UPDATE files
+      SET hash = ?, file_size = ?, date = ?
+      WHERE dataset = ? AND input = ?
+    `);
+    return stmt.run(hash, fileSize, now, dataset, filePath);
+  }
+
+  /**
+   * Remove duplicate file entries
+   * Removes:
+   * 1. Legacy duplicates where input = NULL and a matching entry with input = destination_path exists
+   * 2. Multiple entries with the same output path (keeps the most recent)
+   * 3. Multiple entries with the same destination_path (keeps the most recent)
+   */
+  removeDuplicateFileEntries() {
+    let totalRemoved = 0;
+
+    // 1. Remove legacy duplicates (input IS NULL)
+    const deleteLegacyStmt = this.db.prepare(`
+      DELETE FROM files
+      WHERE input IS NULL
+      AND destination_path IS NOT NULL
+      AND EXISTS (
+        SELECT 1 FROM files f2
+        WHERE f2.dataset = files.dataset
+        AND f2.input = files.destination_path
+        AND f2.destination_path = files.destination_path
+      )
+    `);
+    const legacyResult = deleteLegacyStmt.run();
+    totalRemoved += legacyResult.changes;
+
+    // 2. Remove duplicate entries with same output path (keep most recent)
+    const deleteOutputDupsStmt = this.db.prepare(`
+      DELETE FROM files
+      WHERE rowid NOT IN (
+        SELECT MAX(rowid)
+        FROM files
+        WHERE output IS NOT NULL
+        GROUP BY dataset, output
+      )
+      AND output IS NOT NULL
+    `);
+    const outputDupsResult = deleteOutputDupsStmt.run();
+    totalRemoved += outputDupsResult.changes;
+
+    // 3. Remove duplicate entries with same destination_path (keep most recent)
+    const deleteDestDupsStmt = this.db.prepare(`
+      DELETE FROM files
+      WHERE rowid NOT IN (
+        SELECT MAX(rowid)
+        FROM files
+        WHERE destination_path IS NOT NULL
+        GROUP BY dataset, destination_path
+      )
+      AND destination_path IS NOT NULL
+    `);
+    const destDupsResult = deleteDestDupsStmt.run();
+    totalRemoved += destDupsResult.changes;
+
+    return totalRemoved;
+  }
 }

+ 41 - 2
apps/service/src/handbrake.service.ts

@@ -1,6 +1,8 @@
 import { Injectable, Logger } from '@nestjs/common';
 import { spawn } from 'child_process';
-import { existsSync, mkdirSync, readdirSync } from 'fs';
+import crypto from 'crypto';
+import { createReadStream, existsSync, mkdirSync, readdirSync } from 'fs';
+import { stat } from 'fs/promises';
 import path from 'path';
 import { DbService } from './db.service';
 import { EventsGateway } from './events.gateway';
@@ -112,11 +114,29 @@ export class HandbrakeService {
     return currentPath;
   }
 
+  /**
+   * Hash a file asynchronously using streaming to avoid loading entire file into memory
+   */
+  private async hashFileAsync(filePath: string): Promise<string | null> {
+    return new Promise((resolve) => {
+      const hash = crypto.createHash('sha1');
+      const stream = createReadStream(filePath, { highWaterMark: 64 * 1024 });
+
+      stream.on('data', (chunk) => hash.update(chunk));
+      stream.on('end', () => resolve(hash.digest('hex')));
+      stream.on('error', (error) => {
+        this.logger.warn(`Failed to hash ${filePath}: ${error}`);
+        resolve(null);
+      });
+    });
+  }
+
   processWithHandbrake(
     input: string,
     output: string,
     preset: string,
     taskId?: number,
+    dataset?: string,
   ): Promise<boolean> {
     return new Promise((resolve, reject) => {
       try {
@@ -238,12 +258,31 @@ export class HandbrakeService {
           this.logger.error(str);
         });
 
-        hb.on('close', (code) => {
+        hb.on('close', async (code) => {
           if (code === 0) {
             this.logger.log(
               `Completed "${outputName}" with preset "${preset}"`,
             );
 
+            // Hash the output file if dataset is provided
+            if (dataset && existsSync(actualOutput)) {
+              try {
+                const fileStats = await stat(actualOutput);
+                const hash = await this.hashFileAsync(actualOutput);
+                if (hash) {
+                  this.logger.log(
+                    `Hashing output file: ${actualOutput}, hash: ${hash}, size: ${fileStats.size}`,
+                  );
+                  this.db.updateFileHash(dataset, input, hash, fileStats.size);
+                  this.logger.log(`Updated database with hash for ${input}`);
+                }
+              } catch (hashError) {
+                this.logger.warn(
+                  `Failed to hash output file ${actualOutput}: ${hashError}`,
+                );
+              }
+            }
+
             // Final progress update
             if (taskId) {
               this.db.updateTask(taskId, { progress: 100 });

+ 64 - 13
apps/service/src/indexing-worker.ts

@@ -1,6 +1,6 @@
 import Database from 'better-sqlite3';
 import crypto from 'crypto';
-import fs from 'fs';
+import fs, { createReadStream } from 'fs';
 import fsPromises from 'fs/promises';
 import path from 'path';
 import { parentPort } from 'worker_threads';
@@ -49,15 +49,17 @@ function walkFiles(root: string): string[] {
 }
 
 async function hashFileAsync(filePath: string): Promise<string | null> {
-  try {
-    const data = await fsPromises.readFile(filePath);
+  return new Promise((resolve) => {
     const hash = crypto.createHash('sha1');
-    hash.update(data);
-    return hash.digest('hex');
-  } catch (error) {
-    console.warn(`Worker: Hashing failed for ${filePath}: ${error}`);
-    return null;
-  }
+    const stream = createReadStream(filePath, { highWaterMark: 64 * 1024 }); // 64KB chunks
+
+    stream.on('data', (chunk) => hash.update(chunk));
+    stream.on('end', () => resolve(hash.digest('hex')));
+    stream.on('error', (error) => {
+      console.warn(`Worker: Hashing failed for ${filePath}: ${error}`);
+      resolve(null);
+    });
+  });
 }
 
 async function indexDestination(
@@ -74,8 +76,11 @@ async function indexDestination(
   let db: Database.Database | null = null;
   try {
     db = new Database(dbPath);
+    // Ensure we're using synchronous mode for reliability
+    db.pragma('journal_mode = WAL');
+    db.pragma('synchronous = NORMAL');
 
-    // Clear existing entries if reindexing
+    console.log(`Worker: Database opened successfully at ${dbPath}`);
     if (reindex) {
       const stmt = db.prepare(
         'DELETE FROM files WHERE dataset = ? AND destination_path LIKE ?',
@@ -91,6 +96,11 @@ async function indexDestination(
     const files = walkFiles(destination);
     console.log(`Worker: Found ${files.length} files to index`);
 
+    // Prepare statement to check if file is already indexed
+    const checkIndexedStmt = db.prepare(
+      'SELECT hash, file_size FROM files WHERE dataset = ? AND input = ? AND hash IS NOT NULL AND file_size IS NOT NULL',
+    );
+
     let indexed = 0;
     let skipped = 0;
     let errors = 0;
@@ -140,7 +150,24 @@ async function indexDestination(
               return;
             }
 
+            // Skip already-indexed files during batch indexing (unless reindexing)
+            if (!reindex) {
+              const existing = checkIndexedStmt.get(dataset, filePath) as
+                | { hash: string; file_size: number }
+                | undefined;
+              if (existing && existing.hash && existing.file_size) {
+                console.log(
+                  `Worker: Skipping already-indexed file: ${filePath}`,
+                );
+                skipped++;
+                return;
+              }
+            }
+
             console.log(`Worker: Indexing ${filePath}`);
+
+            // Hash the file (for now still hash the input file)
+            // TODO: When output file support is added, hash output instead
             const hash = await hashFileAsync(filePath);
             if (!hash) {
               console.warn(`Worker: Failed to hash ${filePath}`);
@@ -150,13 +177,37 @@ async function indexDestination(
 
             // Store in database - try upsert first for existing files, then insert-only for new files
             try {
-              const result = upsertStmt.run(dataset, filePath, filePath, hash, stat.size);
+              console.log(
+                `Worker: Attempting to insert/update: dataset=${dataset}, input=${filePath}, hash=${hash}, size=${stat.size}`,
+              );
+              const result = upsertStmt.run(
+                dataset,
+                filePath,
+                filePath,
+                hash,
+                stat.size,
+              );
+              console.log(`Worker: Upsert result: changes=${result.changes}`);
               if (result.changes === 0) {
                 // File wasn't in database, try insert
-                insertStmt.run(dataset, filePath, filePath, hash, stat.size);
+                console.log(
+                  `Worker: Upsert returned 0 changes, trying insert...`,
+                );
+                const insertResult = insertStmt.run(
+                  dataset,
+                  filePath,
+                  filePath,
+                  hash,
+                  stat.size,
+                );
+                console.log(
+                  `Worker: Insert result: changes=${insertResult.changes}`,
+                );
               }
             } catch (dbError) {
-              console.error(`Worker: Database error for ${filePath}: ${dbError}`);
+              console.error(
+                `Worker: Database error for ${filePath}: ${dbError}`,
+              );
               errors++;
               return;
             }

+ 31 - 3
apps/service/src/maintenance.service.ts

@@ -7,6 +7,7 @@ import path from 'path';
 import { Worker } from 'worker_threads';
 import { DatasetsService } from './datasets.service';
 import { DbService } from './db.service';
+import { EventsGateway } from './events.gateway';
 
 @Injectable()
 export class MaintenanceService {
@@ -15,6 +16,7 @@ export class MaintenanceService {
   constructor(
     private readonly db: DbService,
     private readonly datasetsService: DatasetsService,
+    private readonly eventsGateway: EventsGateway,
   ) {}
 
   cleanup(file: string, dirs: string[]) {
@@ -425,11 +427,31 @@ export class MaintenanceService {
               `Indexing progress: ${message.progress}% (${message.indexed} indexed, ${message.skipped} skipped, ${message.errors} errors)`,
             );
             lastProgress = message.progress;
+            // Emit progress event
+            this.eventsGateway?.emitMaintenanceUpdate({
+              type: 'indexing_progress',
+              dataset,
+              destination: destinationPath,
+              progress: message.progress,
+              indexed: message.indexed,
+              skipped: message.skipped,
+              errors: message.errors,
+              total: message.total,
+            });
           }
         } else if (message.type === 'index_result') {
           this.logger.log(
             `Indexing complete: ${message.indexed} indexed, ${message.skipped} skipped, ${message.errors} errors`,
           );
+          // Emit completion event
+          this.eventsGateway?.emitMaintenanceUpdate({
+            type: 'indexing_complete',
+            dataset,
+            destination: destinationPath,
+            indexed: message.indexed,
+            skipped: message.skipped,
+            errors: message.errors,
+          });
           worker.terminate();
           resolve({
             indexed: message.indexed,
@@ -438,6 +460,13 @@ export class MaintenanceService {
           });
         } else if (message.type === 'error') {
           this.logger.error(`Indexing worker error: ${message.error}`);
+          // Emit error event
+          this.eventsGateway?.emitMaintenanceUpdate({
+            type: 'indexing_error',
+            dataset,
+            destination: destinationPath,
+            error: message.error,
+          });
           worker.terminate();
           reject(new Error(message.error));
         }
@@ -523,7 +552,7 @@ export class MaintenanceService {
       const hexHash = hash.digest('hex');
 
       // Update or insert in database
-      const stmt = this.db.db.prepare(`
+      const stmt = this.db.getDb().prepare(`
         INSERT INTO files (dataset, input, destination_path, hash, file_size, date)
         VALUES (?, ?, ?, ?, ?, datetime('now'))
         ON CONFLICT(dataset, input) DO UPDATE SET
@@ -545,8 +574,7 @@ export class MaintenanceService {
         file_size: stat.size,
       };
     } catch (error) {
-      const errorMsg =
-        error instanceof Error ? error.message : String(error);
+      const errorMsg = error instanceof Error ? error.message : String(error);
       this.logger.error(`Failed to index file ${filePath}: ${errorMsg}`);
       return {
         indexed: false,

+ 1 - 0
apps/service/src/task-queue.service.ts

@@ -361,6 +361,7 @@ export class TaskQueueService implements OnModuleInit {
         task.output!,
         task.preset!,
         task.id,
+        task.dataset,
       );
 
       if (success) {

+ 19 - 14
apps/web/src/app/components/FileCrud.tsx

@@ -26,7 +26,7 @@ const initialFileState: FileFormState = {
   input: "",
   output: "",
   date: "",
-  isOpen: false
+  isOpen: false,
 };
 
 export default function FileCrud({ editFile, onEditClose }: FileCrudProps) {
@@ -44,7 +44,7 @@ export default function FileCrud({ editFile, onEditClose }: FileCrudProps) {
         input: editFile.input || "",
         output: editFile.output || "",
         date: editFile.date || "",
-        isOpen: true
+        isOpen: true,
       });
     }
   }, [editFile, isEditing]);
@@ -57,7 +57,7 @@ export default function FileCrud({ editFile, onEditClose }: FileCrudProps) {
     mutationFn: () =>
       post(`/files/${formState.dataset}/${formState.input}`, {
         output: formState.output,
-        date: formState.date
+        date: formState.date,
       }),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ["files"] });
@@ -67,9 +67,9 @@ export default function FileCrud({ editFile, onEditClose }: FileCrudProps) {
       addNotification({
         type: "success",
         title: "File Added",
-        message: `File "${formState.input}" has been added to dataset "${formState.dataset}" successfully.`
+        message: `File "${formState.input}" has been added to dataset "${formState.dataset}" successfully.`,
       });
-    }
+    },
   });
 
   const handleSubmit = (e: React.FormEvent) => {
@@ -172,7 +172,7 @@ export default function FileCrud({ editFile, onEditClose }: FileCrudProps) {
                   ...formState,
                   date: e.target.value
                     ? new Date(e.target.value).toISOString()
-                    : ""
+                    : "",
                 })
               }
             />
@@ -184,13 +184,18 @@ export default function FileCrud({ editFile, onEditClose }: FileCrudProps) {
 
   return (
     <>
-      <button
-        onClick={() => setFormState({ ...formState, isOpen: true })}
-        className="inline-flex items-center rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
-      >
-        <PlusIcon className="h-4 w-4 mr-2" />
-        Add File
-      </button>
+      <div className="relative group">
+        <button
+          onClick={() => setFormState({ ...formState, isOpen: true })}
+          className="p-2 rounded-md text-gray-600 dark:text-gray-400 hover:bg-indigo-100 dark:hover:bg-indigo-900/30 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
+          title="Add File"
+        >
+          <PlusIcon 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">
+          Add File
+        </div>
+      </div>
 
       <SlideInForm
         isOpen={formState.isOpen}
@@ -279,7 +284,7 @@ export default function FileCrud({ editFile, onEditClose }: FileCrudProps) {
                   ...formState,
                   date: e.target.value
                     ? new Date(e.target.value).toISOString()
-                    : ""
+                    : "",
                 })
               }
             />

+ 503 - 0
apps/web/src/app/components/FileMaintenanceButtons.tsx

@@ -0,0 +1,503 @@
+"use client";
+import {
+  DocumentDuplicateIcon,
+  FolderMinusIcon,
+  ScissorsIcon,
+  TrashIcon,
+  XCircleIcon,
+} from "@heroicons/react/24/outline";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useState } from "react";
+import toast from "react-hot-toast";
+import { del, post } from "../../lib/api";
+import { useNotifications } from "./NotificationContext";
+import SlideInForm from "./SlideInForm";
+
+export default function FileMaintenanceButtons() {
+  const queryClient = useQueryClient();
+  const { addNotification } = useNotifications();
+  const [operation, setOperation] = useState<
+    "cleanup" | "purge" | "prune" | "clear" | null
+  >(null);
+  const [file, setFile] = useState("");
+  const [dirs, setDirs] = useState<string[]>([]);
+  const [dayMs, setDayMs] = useState<number | undefined>();
+  const [cleanerMs, setCleanerMs] = useState<number | undefined>();
+
+  const cleanupMutation = useMutation({
+    mutationFn: () => post("/maintenance/cleanup", { file, dirs }),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["files"] });
+      queryClient.invalidateQueries({ queryKey: ["tasks"] });
+      setOperation(null);
+      resetForm();
+      toast.success("Cleanup completed successfully");
+      addNotification({
+        type: "success",
+        title: "Maintenance Complete",
+        message: "File cleanup operation completed successfully.",
+      });
+    },
+    onError: (error: any) => {
+      toast.error(`Cleanup failed: ${error.message}`);
+      addNotification({
+        type: "error",
+        title: "Maintenance Failed",
+        message: `Cleanup operation failed: ${error.message}`,
+      });
+    },
+  });
+
+  const purgeMutation = useMutation({
+    mutationFn: () => post("/maintenance/purge", { dirs, dayMs, cleanerMs }),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["files"] });
+      queryClient.invalidateQueries({ queryKey: ["tasks"] });
+      setOperation(null);
+      resetForm();
+      toast.success("Purge completed successfully");
+      addNotification({
+        type: "success",
+        title: "Maintenance Complete",
+        message: "Directory purge operation completed successfully.",
+      });
+    },
+    onError: (error: any) => {
+      toast.error(`Purge failed: ${error.message}`);
+      addNotification({
+        type: "error",
+        title: "Maintenance Failed",
+        message: `Purge operation failed: ${error.message}`,
+      });
+    },
+  });
+
+  const pruneMutation = useMutation({
+    mutationFn: () => post("/maintenance/prune", { dirs }),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["files"] });
+      queryClient.invalidateQueries({ queryKey: ["tasks"] });
+      setOperation(null);
+      resetForm();
+      toast.success("Prune completed successfully");
+      addNotification({
+        type: "success",
+        title: "Maintenance Complete",
+        message: "Directory prune operation completed successfully.",
+      });
+    },
+    onError: (error: any) => {
+      toast.error(`Prune failed: ${error.message}`);
+      addNotification({
+        type: "error",
+        title: "Maintenance Failed",
+        message: `Prune operation failed: ${error.message}`,
+      });
+    },
+  });
+
+  const clearAllMutation = useMutation({
+    mutationFn: () => del("/files"),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["files"] });
+      queryClient.invalidateQueries({ queryKey: ["tasks"] });
+      setOperation(null);
+      resetForm();
+      toast.success("All files cleared successfully");
+      addNotification({
+        type: "success",
+        title: "Maintenance Complete",
+        message: "All file records have been cleared from the database.",
+      });
+    },
+    onError: (error: any) => {
+      toast.error(`Clear all files failed: ${error.message}`);
+      addNotification({
+        type: "error",
+        title: "Maintenance Failed",
+        message: `Clear all files operation failed: ${error.message}`,
+      });
+    },
+  });
+
+  const removeDuplicatesMutation = useMutation({
+    mutationFn: () => post("/maintenance/remove-duplicates"),
+    onSuccess: (data) => {
+      queryClient.invalidateQueries({ queryKey: ["files"] });
+      queryClient.invalidateQueries({ queryKey: ["all-files"] });
+      toast.success(`Removed ${data.removed} duplicate entries`);
+      addNotification({
+        type: "success",
+        title: "Duplicates Removed",
+        message: `Removed ${data.removed} duplicate file entries from the database.`,
+      });
+    },
+    onError: (error: any) => {
+      toast.error(`Remove duplicates failed: ${error.message}`);
+      addNotification({
+        type: "error",
+        title: "Remove Duplicates Failed",
+        message: `Failed to remove duplicate entries: ${error.message}`,
+      });
+    },
+  });
+
+  const resetForm = () => {
+    setFile("");
+    setDirs([]);
+    setDayMs(undefined);
+    setCleanerMs(undefined);
+  };
+
+  const handleSubmit = (e: React.FormEvent) => {
+    e.preventDefault();
+    if (operation === "cleanup") {
+      cleanupMutation.mutate();
+    } else if (operation === "purge") {
+      purgeMutation.mutate();
+    } else if (operation === "prune") {
+      pruneMutation.mutate();
+    } else if (operation === "clear") {
+      if (file !== "CLEAR") {
+        toast.error("Please type 'CLEAR' to confirm the operation");
+        return;
+      }
+      clearAllMutation.mutate();
+    }
+  };
+
+  const handleClose = () => {
+    setOperation(null);
+    resetForm();
+  };
+
+  const addDir = () => {
+    setDirs([...dirs, ""]);
+  };
+
+  const updateDir = (index: number, value: string) => {
+    const newDirs = [...dirs];
+    newDirs[index] = value;
+    setDirs(newDirs);
+  };
+
+  const removeDir = (index: number) => {
+    setDirs(dirs.filter((_, i) => i !== index));
+  };
+
+  const renderForm = () => {
+    if (operation === "cleanup") {
+      return (
+        <form onSubmit={handleSubmit} className="space-y-4">
+          <div>
+            <label className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
+              File Path
+            </label>
+            <input
+              className="block w-full rounded-md border-gray-300 dark:border-gray-700 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100"
+              placeholder="Path to the file to clean up"
+              value={file}
+              onChange={(e) => setFile(e.target.value)}
+              required
+            />
+          </div>
+          <div>
+            <label className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
+              Directories to Search
+            </label>
+            {dirs.map((dir, index) => (
+              <div key={index} className="flex gap-2 mb-2">
+                <input
+                  className="flex-1 rounded-md border-gray-300 dark:border-gray-700 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100"
+                  placeholder="Directory path"
+                  value={dir}
+                  onChange={(e) => updateDir(index, e.target.value)}
+                  required
+                />
+                <button
+                  type="button"
+                  onClick={() => removeDir(index)}
+                  className="px-2 py-1 text-red-600 hover:text-red-800"
+                >
+                  ×
+                </button>
+              </div>
+            ))}
+            <button
+              type="button"
+              onClick={addDir}
+              className="text-sm text-indigo-600 hover:text-indigo-800"
+            >
+              + Add Directory
+            </button>
+          </div>
+        </form>
+      );
+    } else if (operation === "purge") {
+      return (
+        <form onSubmit={handleSubmit} className="space-y-4">
+          <div>
+            <label className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
+              Directories to Purge
+            </label>
+            {dirs.map((dir, index) => (
+              <div key={index} className="flex gap-2 mb-2">
+                <input
+                  className="flex-1 rounded-md border-gray-300 dark:border-gray-700 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100"
+                  placeholder="Directory path"
+                  value={dir}
+                  onChange={(e) => updateDir(index, e.target.value)}
+                  required
+                />
+                <button
+                  type="button"
+                  onClick={() => removeDir(index)}
+                  className="px-2 py-1 text-red-600 hover:text-red-800"
+                >
+                  ×
+                </button>
+              </div>
+            ))}
+            <button
+              type="button"
+              onClick={addDir}
+              className="text-sm text-indigo-600 hover:text-indigo-800"
+            >
+              + Add Directory
+            </button>
+          </div>
+          <div>
+            <label className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
+              Day Threshold (ms) - Optional
+            </label>
+            <input
+              type="number"
+              className="block w-full rounded-md border-gray-300 dark:border-gray-700 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100"
+              placeholder="Minimum age in milliseconds"
+              value={dayMs || ""}
+              onChange={(e) =>
+                setDayMs(e.target.value ? parseInt(e.target.value) : undefined)
+              }
+            />
+          </div>
+          <div>
+            <label className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
+              Cleaner Threshold (ms) - Optional
+            </label>
+            <input
+              type="number"
+              className="block w-full rounded-md border-gray-300 dark:border-gray-700 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100"
+              placeholder="Threshold for cleaner in milliseconds"
+              value={cleanerMs || ""}
+              onChange={(e) =>
+                setCleanerMs(
+                  e.target.value ? parseInt(e.target.value) : undefined
+                )
+              }
+            />
+          </div>
+        </form>
+      );
+    } else if (operation === "prune") {
+      return (
+        <form onSubmit={handleSubmit} className="space-y-4">
+          <div>
+            <label className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
+              Directories to Prune
+            </label>
+            {dirs.map((dir, index) => (
+              <div key={index} className="flex gap-2 mb-2">
+                <input
+                  className="flex-1 rounded-md border-gray-300 dark:border-gray-700 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100"
+                  placeholder="Directory path"
+                  value={dir}
+                  onChange={(e) => updateDir(index, e.target.value)}
+                  required
+                />
+                <button
+                  type="button"
+                  onClick={() => removeDir(index)}
+                  className="px-2 py-1 text-red-600 hover:text-red-800"
+                >
+                  ×
+                </button>
+              </div>
+            ))}
+            <button
+              type="button"
+              onClick={addDir}
+              className="text-sm text-indigo-600 hover:text-indigo-800"
+            >
+              + Add Directory
+            </button>
+          </div>
+        </form>
+      );
+    } else if (operation === "clear") {
+      return (
+        <div className="space-y-4">
+          <div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
+            <div className="flex">
+              <div className="flex-shrink-0">
+                <svg
+                  className="h-5 w-5 text-red-400"
+                  viewBox="0 0 20 20"
+                  fill="currentColor"
+                >
+                  <path
+                    fillRule="evenodd"
+                    d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
+                    clipRule="evenodd"
+                  />
+                </svg>
+              </div>
+              <div className="ml-3">
+                <h3 className="text-sm font-medium text-red-800 dark:text-red-200">
+                  Warning: Destructive Operation
+                </h3>
+                <div className="mt-2 text-sm text-red-700 dark:text-red-300">
+                  <p>
+                    This will permanently delete ALL file records from the
+                    database. The file watcher will need to rediscover and
+                    re-add all files. This operation cannot be undone.
+                  </p>
+                </div>
+              </div>
+            </div>
+          </div>
+          <div>
+            <label className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
+              Type "CLEAR" to confirm
+            </label>
+            <input
+              className="block w-full rounded-md border-gray-300 dark:border-gray-700 shadow-sm focus:border-red-500 focus:ring-red-500 sm:text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100"
+              placeholder="Type CLEAR to confirm"
+              value={file}
+              onChange={(e) => setFile(e.target.value)}
+              required
+            />
+          </div>
+        </div>
+      );
+    }
+    return null;
+  };
+
+  const getFormTitle = () => {
+    switch (operation) {
+      case "cleanup":
+        return "Cleanup Files";
+      case "purge":
+        return "Purge Directories";
+      case "prune":
+        return "Prune Directories";
+      case "clear":
+        return "Clear All Files";
+      default:
+        return "Maintenance";
+    }
+  };
+
+  const isLoading =
+    cleanupMutation.status === "pending" ||
+    purgeMutation.status === "pending" ||
+    pruneMutation.status === "pending" ||
+    clearAllMutation.status === "pending";
+
+  return (
+    <>
+      <div className="relative group">
+        <button
+          onClick={() => setOperation("cleanup")}
+          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"
+          title="Cleanup 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">
+          Cleanup Files
+        </div>
+      </div>
+
+      <div className="relative group">
+        <button
+          onClick={() => setOperation("purge")}
+          className="p-2 rounded-md text-gray-600 dark:text-gray-400 hover:bg-orange-100 dark:hover:bg-orange-900/30 hover:text-orange-600 dark:hover:text-orange-400 transition-colors"
+          title="Purge Directories"
+        >
+          <FolderMinusIcon 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">
+          Purge Directories
+        </div>
+      </div>
+
+      <div className="relative group">
+        <button
+          onClick={() => setOperation("prune")}
+          className="p-2 rounded-md text-gray-600 dark:text-gray-400 hover:bg-yellow-100 dark:hover:bg-yellow-900/30 hover:text-yellow-600 dark:hover:text-yellow-400 transition-colors"
+          title="Prune Directories"
+        >
+          <ScissorsIcon 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">
+          Prune Directories
+        </div>
+      </div>
+
+      <div className="relative group">
+        <button
+          onClick={() => setOperation("clear")}
+          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"
+          title="Clear All Files"
+        >
+          <XCircleIcon 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">
+          Clear All Files
+        </div>
+      </div>
+
+      <div className="relative group">
+        <button
+          onClick={() => removeDuplicatesMutation.mutate()}
+          disabled={removeDuplicatesMutation.isPending}
+          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 disabled:opacity-50"
+          title="Remove Duplicate Entries"
+        >
+          <DocumentDuplicateIcon 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">
+          Remove Duplicates
+        </div>
+      </div>
+
+      <SlideInForm
+        isOpen={!!operation}
+        onClose={handleClose}
+        title={getFormTitle()}
+        actions={
+          <div className="flex justify-end space-x-3">
+            <button
+              type="button"
+              onClick={handleClose}
+              className="inline-flex items-center rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
+            >
+              Cancel
+            </button>
+            <button
+              type="submit"
+              disabled={isLoading}
+              onClick={handleSubmit}
+              className="inline-flex items-center rounded-md border border-transparent bg-orange-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-orange-700 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2"
+            >
+              {isLoading ? "Running..." : "Execute"}
+            </button>
+          </div>
+        }
+      >
+        {renderForm()}
+      </SlideInForm>
+    </>
+  );
+}

+ 0 - 1
apps/web/src/app/components/Header.tsx

@@ -11,7 +11,6 @@ const nav = [
   { href: "/", label: "Dashboard" },
   { href: "/files", label: "Files" },
   { href: "/duplicates", label: "Duplicates" },
-  { href: "/indexing", label: "Indexing" },
   { href: "/tasks", label: "Tasks" },
   { href: "/settings", label: "Settings" },
 ];

+ 25 - 24
apps/web/src/app/components/MaintenanceDropdown.tsx

@@ -1,8 +1,5 @@
 "use client";
-import {
-  ChevronDownIcon,
-  WrenchScrewdriverIcon
-} from "@heroicons/react/24/outline";
+import { WrenchScrewdriverIcon } from "@heroicons/react/24/outline";
 import { useMutation, useQueryClient } from "@tanstack/react-query";
 import { useState } from "react";
 import toast from "react-hot-toast";
@@ -33,7 +30,7 @@ export default function MaintenanceDropdown() {
       addNotification({
         type: "success",
         title: "Maintenance Complete",
-        message: "File cleanup operation completed successfully."
+        message: "File cleanup operation completed successfully.",
       });
     },
     onError: (error: any) => {
@@ -41,9 +38,9 @@ export default function MaintenanceDropdown() {
       addNotification({
         type: "error",
         title: "Maintenance Failed",
-        message: `Cleanup operation failed: ${error.message}`
+        message: `Cleanup operation failed: ${error.message}`,
       });
-    }
+    },
   });
 
   const purgeMutation = useMutation({
@@ -57,7 +54,7 @@ export default function MaintenanceDropdown() {
       addNotification({
         type: "success",
         title: "Maintenance Complete",
-        message: "Directory purge operation completed successfully."
+        message: "Directory purge operation completed successfully.",
       });
     },
     onError: (error: any) => {
@@ -65,9 +62,9 @@ export default function MaintenanceDropdown() {
       addNotification({
         type: "error",
         title: "Maintenance Failed",
-        message: `Purge operation failed: ${error.message}`
+        message: `Purge operation failed: ${error.message}`,
       });
-    }
+    },
   });
 
   const pruneMutation = useMutation({
@@ -81,7 +78,7 @@ export default function MaintenanceDropdown() {
       addNotification({
         type: "success",
         title: "Maintenance Complete",
-        message: "Directory prune operation completed successfully."
+        message: "Directory prune operation completed successfully.",
       });
     },
     onError: (error: any) => {
@@ -89,9 +86,9 @@ export default function MaintenanceDropdown() {
       addNotification({
         type: "error",
         title: "Maintenance Failed",
-        message: `Prune operation failed: ${error.message}`
+        message: `Prune operation failed: ${error.message}`,
       });
-    }
+    },
   });
 
   const clearAllMutation = useMutation({
@@ -105,7 +102,7 @@ export default function MaintenanceDropdown() {
       addNotification({
         type: "success",
         title: "Maintenance Complete",
-        message: "All file records have been cleared from the database."
+        message: "All file records have been cleared from the database.",
       });
     },
     onError: (error: any) => {
@@ -113,9 +110,9 @@ export default function MaintenanceDropdown() {
       addNotification({
         type: "error",
         title: "Maintenance Failed",
-        message: `Clear all files operation failed: ${error.message}`
+        message: `Clear all files operation failed: ${error.message}`,
       });
-    }
+    },
   });
 
   const resetForm = () => {
@@ -384,14 +381,18 @@ export default function MaintenanceDropdown() {
   return (
     <>
       <div className="relative">
-        <button
-          onClick={() => setIsOpen(!isOpen)}
-          className="inline-flex items-center rounded-md bg-orange-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-orange-700 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2"
-        >
-          <WrenchScrewdriverIcon className="h-4 w-4 mr-2" />
-          Maintenance
-          <ChevronDownIcon className="h-4 w-4 ml-2" />
-        </button>
+        <div className="relative group">
+          <button
+            onClick={() => setIsOpen(!isOpen)}
+            className="p-2 rounded-md text-gray-600 dark:text-gray-400 hover:bg-orange-100 dark:hover:bg-orange-900/30 hover:text-orange-600 dark:hover:text-orange-400 transition-colors"
+            title="Maintenance"
+          >
+            <WrenchScrewdriverIcon 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">
+            Maintenance
+          </div>
+        </div>
         {isOpen && (
           <div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 z-10">
             <div className="py-1">

+ 12 - 7
apps/web/src/app/components/SettingsCrud.tsx

@@ -182,13 +182,18 @@ export default function SettingsCrud({
 
   return (
     <>
-      <button
-        onClick={() => setFormState({ ...formState, isOpen: true })}
-        className="inline-flex items-center rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
-      >
-        <PlusIcon className="h-4 w-4 mr-2" />
-        Add Setting
-      </button>
+      <div className="relative group">
+        <button
+          onClick={() => setFormState({ ...formState, isOpen: true })}
+          className="p-2 rounded-md text-gray-600 dark:text-gray-400 hover:bg-indigo-100 dark:hover:bg-indigo-900/30 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
+          title="Add Setting"
+        >
+          <PlusIcon 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">
+          Add Setting
+        </div>
+      </div>
 
       <SlideInForm
         isOpen={formState.isOpen}

+ 34 - 29
apps/web/src/app/components/TaskCrud.tsx

@@ -34,14 +34,14 @@ const initialTaskState: TaskFormState = {
   output: "",
   preset: "Fast 1080p30",
   priority: 0,
-  isOpen: false
+  isOpen: false,
 };
 
 export default function TaskCrud({
   editTask,
   onEditClose,
   isAdding,
-  onAddClose
+  onAddClose,
 }: TaskCrudProps) {
   const queryClient = useQueryClient();
   const { addNotification } = useNotifications();
@@ -60,7 +60,7 @@ export default function TaskCrud({
         output: editTask.output || "",
         preset: editTask.preset || "",
         priority: editTask.priority || 0,
-        isOpen: true
+        isOpen: true,
       });
     } else if (isAdding) {
       setFormState({ ...initialTaskState, isOpen: true });
@@ -81,7 +81,7 @@ export default function TaskCrud({
         input: formState.input,
         output: formState.output,
         preset: formState.preset,
-        priority: formState.priority
+        priority: formState.priority,
       }),
     onSuccess: () => {
       queryClient.refetchQueries({ queryKey: ["tasks"] });
@@ -91,9 +91,9 @@ export default function TaskCrud({
       addNotification({
         type: "success",
         title: "Task Created",
-        message: `Task has been created successfully.`
+        message: `Task has been created successfully.`,
       });
-    }
+    },
   });
 
   const updateMutation = useMutation({
@@ -106,7 +106,7 @@ export default function TaskCrud({
         input: formState.input,
         output: formState.output,
         preset: formState.preset,
-        priority: formState.priority
+        priority: formState.priority,
       }),
     onSuccess: () => {
       queryClient.refetchQueries({ queryKey: ["tasks"] });
@@ -116,9 +116,9 @@ export default function TaskCrud({
       addNotification({
         type: "success",
         title: "Task Updated",
-        message: `Task has been updated successfully.`
+        message: `Task has been updated successfully.`,
       });
-    }
+    },
   });
 
   const handleSubmit = (e: React.FormEvent) => {
@@ -143,25 +143,30 @@ export default function TaskCrud({
   // If not editing and not adding, render the add button
   if (!isEditing && !isAdding && !formState.isOpen) {
     return (
-      <button
-        onClick={handleAddClick}
-        className="inline-flex items-center rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2"
-      >
-        <svg
-          className="h-4 w-4 mr-2"
-          fill="none"
-          viewBox="0 0 24 24"
-          stroke="currentColor"
+      <div className="relative group">
+        <button
+          onClick={handleAddClick}
+          className="p-2 rounded-md text-gray-600 dark:text-gray-400 hover:bg-green-100 dark:hover:bg-green-900/30 hover:text-green-600 dark:hover:text-green-400 transition-colors"
+          title="Add Task"
         >
-          <path
-            strokeLinecap="round"
-            strokeLinejoin="round"
-            strokeWidth={2}
-            d="M12 4v16m8-8H4"
-          />
-        </svg>
-        Add Task
-      </button>
+          <svg
+            className="h-5 w-5"
+            fill="none"
+            viewBox="0 0 24 24"
+            stroke="currentColor"
+          >
+            <path
+              strokeLinecap="round"
+              strokeLinejoin="round"
+              strokeWidth={2}
+              d="M12 4v16m8-8H4"
+            />
+          </svg>
+        </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">
+          Add Task
+        </div>
+      </div>
     );
   }
 
@@ -211,7 +216,7 @@ export default function TaskCrud({
           onChange={(e) =>
             setFormState({
               ...formState,
-              progress: parseInt(e.target.value) || 0
+              progress: parseInt(e.target.value) || 0,
             })
           }
         />
@@ -280,7 +285,7 @@ export default function TaskCrud({
           onChange={(e) =>
             setFormState({
               ...formState,
-              priority: parseInt(e.target.value) || 0
+              priority: parseInt(e.target.value) || 0,
             })
           }
         />

+ 26 - 25
apps/web/src/app/components/TaskMaintenance.tsx

@@ -1,8 +1,5 @@
 "use client";
-import {
-  ChevronDownIcon,
-  WrenchScrewdriverIcon
-} from "@heroicons/react/24/outline";
+import { WrenchScrewdriverIcon } from "@heroicons/react/24/outline";
 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
 import { useState } from "react";
 import toast from "react-hot-toast";
@@ -25,7 +22,7 @@ export default function TaskMaintenance() {
   const { data: stats } = useQuery({
     queryKey: ["taskStats"],
     queryFn: () => get("/maintenance/tasks/stats"),
-    enabled: operation === "stats"
+    enabled: operation === "stats",
   });
 
   const cleanupMutation = useMutation({
@@ -39,7 +36,7 @@ export default function TaskMaintenance() {
       addNotification({
         type: "success",
         title: "Task Maintenance Complete",
-        message: "Task cleanup operation completed successfully."
+        message: "Task cleanup operation completed successfully.",
       });
     },
     onError: (error: any) => {
@@ -47,9 +44,9 @@ export default function TaskMaintenance() {
       addNotification({
         type: "error",
         title: "Task Maintenance Failed",
-        message: `Task cleanup operation failed: ${error.message}`
+        message: `Task cleanup operation failed: ${error.message}`,
       });
-    }
+    },
   });
 
   const archiveMutation = useMutation({
@@ -62,7 +59,7 @@ export default function TaskMaintenance() {
       addNotification({
         type: "success",
         title: "Task Maintenance Complete",
-        message: "Task archive operation completed successfully."
+        message: "Task archive operation completed successfully.",
       });
     },
     onError: (error: any) => {
@@ -70,9 +67,9 @@ export default function TaskMaintenance() {
       addNotification({
         type: "error",
         title: "Task Maintenance Failed",
-        message: `Task archive operation failed: ${error.message}`
+        message: `Task archive operation failed: ${error.message}`,
       });
-    }
+    },
   });
 
   const purgeMutation = useMutation({
@@ -85,7 +82,7 @@ export default function TaskMaintenance() {
       addNotification({
         type: "success",
         title: "Task Maintenance Complete",
-        message: "All tasks have been purged successfully."
+        message: "All tasks have been purged successfully.",
       });
     },
     onError: (error: any) => {
@@ -93,9 +90,9 @@ export default function TaskMaintenance() {
       addNotification({
         type: "error",
         title: "Task Maintenance Failed",
-        message: `Task purge operation failed: ${error.message}`
+        message: `Task purge operation failed: ${error.message}`,
       });
-    }
+    },
   });
 
   const scheduledMutation = useMutation({
@@ -108,7 +105,7 @@ export default function TaskMaintenance() {
       addNotification({
         type: "success",
         title: "Task Maintenance Complete",
-        message: "Scheduled task cleanup operation completed successfully."
+        message: "Scheduled task cleanup operation completed successfully.",
       });
     },
     onError: (error: any) => {
@@ -116,9 +113,9 @@ export default function TaskMaintenance() {
       addNotification({
         type: "error",
         title: "Task Maintenance Failed",
-        message: `Scheduled cleanup operation failed: ${error.message}`
+        message: `Scheduled cleanup operation failed: ${error.message}`,
       });
-    }
+    },
   });
 
   const resetForm = () => {
@@ -348,14 +345,18 @@ export default function TaskMaintenance() {
   return (
     <>
       <div className="relative">
-        <button
-          onClick={() => setIsOpen(!isOpen)}
-          className="inline-flex items-center rounded-md bg-purple-600 px-4 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"
-        >
-          <WrenchScrewdriverIcon className="h-4 w-4 mr-2" />
-          Task Maintenance
-          <ChevronDownIcon className="h-4 w-4 ml-2" />
-        </button>
+        <div className="relative group">
+          <button
+            onClick={() => setIsOpen(!isOpen)}
+            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="Task Maintenance"
+          >
+            <WrenchScrewdriverIcon 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">
+            Task Maintenance
+          </div>
+        </div>
         {isOpen && (
           <div className="absolute right-0 mt-2 w-56 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 z-10">
             <div className="py-1">

+ 451 - 0
apps/web/src/app/components/TaskMaintenanceButtons.tsx

@@ -0,0 +1,451 @@
+"use client";
+import {
+  ArchiveBoxIcon,
+  ChartBarIcon,
+  ClockIcon,
+  FireIcon,
+  TrashIcon,
+} from "@heroicons/react/24/outline";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useState } from "react";
+import toast from "react-hot-toast";
+import { get, post } from "../../lib/api";
+import { useNotifications } from "./NotificationContext";
+import SlideInForm from "./SlideInForm";
+
+export default function TaskMaintenanceButtons() {
+  const queryClient = useQueryClient();
+  const { addNotification } = useNotifications();
+  const [operation, setOperation] = useState<
+    "cleanup" | "archive" | "purge" | "scheduled" | "stats" | null
+  >(null);
+  const [status, setStatus] = useState<string>("");
+  const [olderThanDays, setOlderThanDays] = useState<number | undefined>();
+  const [daysOld, setDaysOld] = useState<number>(30);
+
+  // Query for task stats
+  const { data: stats } = useQuery({
+    queryKey: ["taskStats"],
+    queryFn: () => get("/maintenance/tasks/stats"),
+    enabled: operation === "stats",
+  });
+
+  const cleanupMutation = useMutation({
+    mutationFn: () =>
+      post("/maintenance/tasks/cleanup", { status, olderThanDays }),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["tasks"] });
+      setOperation(null);
+      resetForm();
+      toast.success("Task cleanup completed successfully");
+      addNotification({
+        type: "success",
+        title: "Task Maintenance Complete",
+        message: "Task cleanup operation completed successfully.",
+      });
+    },
+    onError: (error: any) => {
+      toast.error(`Task cleanup failed: ${error.message}`);
+      addNotification({
+        type: "error",
+        title: "Task Maintenance Failed",
+        message: `Task cleanup operation failed: ${error.message}`,
+      });
+    },
+  });
+
+  const archiveMutation = useMutation({
+    mutationFn: () => post("/maintenance/tasks/archive", { daysOld }),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["tasks"] });
+      setOperation(null);
+      resetForm();
+      toast.success("Task archive completed successfully");
+      addNotification({
+        type: "success",
+        title: "Task Maintenance Complete",
+        message: "Task archive operation completed successfully.",
+      });
+    },
+    onError: (error: any) => {
+      toast.error(`Task archive failed: ${error.message}`);
+      addNotification({
+        type: "error",
+        title: "Task Maintenance Failed",
+        message: `Task archive operation failed: ${error.message}`,
+      });
+    },
+  });
+
+  const purgeMutation = useMutation({
+    mutationFn: () => post("/maintenance/tasks/purge"),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["tasks"] });
+      setOperation(null);
+      resetForm();
+      toast.success("All tasks purged successfully");
+      addNotification({
+        type: "success",
+        title: "Task Maintenance Complete",
+        message: "All tasks have been purged successfully.",
+      });
+    },
+    onError: (error: any) => {
+      toast.error(`Task purge failed: ${error.message}`);
+      addNotification({
+        type: "error",
+        title: "Task Maintenance Failed",
+        message: `Task purge operation failed: ${error.message}`,
+      });
+    },
+  });
+
+  const scheduledMutation = useMutation({
+    mutationFn: () => post("/maintenance/tasks/scheduled-cleanup"),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["tasks"] });
+      setOperation(null);
+      resetForm();
+      toast.success("Scheduled task cleanup completed successfully");
+      addNotification({
+        type: "success",
+        title: "Task Maintenance Complete",
+        message: "Scheduled task cleanup operation completed successfully.",
+      });
+    },
+    onError: (error: any) => {
+      toast.error(`Scheduled cleanup failed: ${error.message}`);
+      addNotification({
+        type: "error",
+        title: "Task Maintenance Failed",
+        message: `Scheduled cleanup operation failed: ${error.message}`,
+      });
+    },
+  });
+
+  const resetForm = () => {
+    setStatus("");
+    setOlderThanDays(undefined);
+    setDaysOld(30);
+  };
+
+  const handleSubmit = (e: React.FormEvent) => {
+    e.preventDefault();
+    if (operation === "cleanup") {
+      cleanupMutation.mutate();
+    } else if (operation === "archive") {
+      archiveMutation.mutate();
+    } else if (operation === "purge") {
+      purgeMutation.mutate();
+    } else if (operation === "scheduled") {
+      scheduledMutation.mutate();
+    }
+  };
+
+  const handleClose = () => {
+    setOperation(null);
+    resetForm();
+  };
+
+  const renderForm = () => {
+    if (operation === "cleanup") {
+      return (
+        <form onSubmit={handleSubmit} className="space-y-4">
+          <div>
+            <label className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
+              Task Status (Optional)
+            </label>
+            <select
+              className="block w-full rounded-md border-gray-300 dark:border-gray-700 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100"
+              value={status}
+              onChange={(e) => setStatus(e.target.value)}
+            >
+              <option value="">All statuses</option>
+              <option value="completed">Completed</option>
+              <option value="failed">Failed</option>
+              <option value="skipped">Skipped</option>
+              <option value="processing">Processing</option>
+              <option value="pending">Pending</option>
+            </select>
+          </div>
+          <div>
+            <label className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
+              Older Than Days (Optional)
+            </label>
+            <input
+              type="number"
+              className="block w-full rounded-md border-gray-300 dark:border-gray-700 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100"
+              placeholder="Delete tasks older than this many days"
+              value={olderThanDays || ""}
+              onChange={(e) =>
+                setOlderThanDays(
+                  e.target.value ? parseInt(e.target.value) : undefined
+                )
+              }
+            />
+            <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
+              If no status is selected, will cleanup completed, failed, and
+              skipped tasks older than 7 days.
+            </p>
+          </div>
+        </form>
+      );
+    } else if (operation === "archive") {
+      return (
+        <form onSubmit={handleSubmit} className="space-y-4">
+          <div>
+            <label className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
+              Days Old
+            </label>
+            <input
+              type="number"
+              className="block w-full rounded-md border-gray-300 dark:border-gray-700 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100"
+              placeholder="Archive tasks older than this many days"
+              value={daysOld}
+              onChange={(e) => setDaysOld(parseInt(e.target.value) || 30)}
+              min="1"
+              required
+            />
+            <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
+              Tasks older than this will be moved to the archive table. Default:
+              30 days.
+            </p>
+          </div>
+        </form>
+      );
+    } else if (operation === "scheduled") {
+      return (
+        <div className="space-y-4">
+          <p className="text-sm text-gray-700 dark:text-gray-200">
+            This will run the same cleanup operations that run automatically
+            every day at 2 AM:
+          </p>
+          <ul className="text-sm text-gray-600 dark:text-gray-400 space-y-1 ml-4">
+            <li>• Archive completed tasks older than 30 days</li>
+            <li>• Delete failed tasks older than 7 days</li>
+            <li>• Delete skipped tasks older than 7 days</li>
+            <li>• Delete completed tasks older than 90 days</li>
+          </ul>
+        </div>
+      );
+    } else if (operation === "stats") {
+      return (
+        <div className="space-y-4">
+          {stats ? (
+            <div className="space-y-3">
+              <div className="flex justify-between items-center p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
+                <span className="font-medium text-gray-900 dark:text-gray-100">
+                  Total Tasks
+                </span>
+                <span className="text-lg font-bold text-indigo-600 dark:text-indigo-400">
+                  {stats.total}
+                </span>
+              </div>
+              {stats.byStatus?.map((status: any) => (
+                <div
+                  key={status.status}
+                  className="flex justify-between items-center p-3 bg-gray-50 dark:bg-gray-800 rounded-md"
+                >
+                  <div>
+                    <span className="font-medium text-gray-900 dark:text-gray-100 capitalize">
+                      {status.status}
+                    </span>
+                    <div className="text-xs text-gray-500 dark:text-gray-400">
+                      Oldest: {new Date(status.oldest).toLocaleDateString()} |
+                      Newest: {new Date(status.newest).toLocaleDateString()}
+                    </div>
+                  </div>
+                  <span className="text-lg font-bold text-indigo-600 dark:text-indigo-400">
+                    {status.count}
+                  </span>
+                </div>
+              ))}
+              {stats.byType?.map((type: any) => (
+                <div
+                  key={type.type}
+                  className="flex justify-between items-center p-3 bg-gray-50 dark:bg-gray-800 rounded-md"
+                >
+                  <span className="font-medium text-gray-900 dark:text-gray-100 capitalize">
+                    {type.type}
+                  </span>
+                  <span className="text-lg font-bold text-indigo-600 dark:text-indigo-400">
+                    {type.count}
+                  </span>
+                </div>
+              ))}
+            </div>
+          ) : (
+            <div className="text-center py-8 text-gray-500 dark:text-gray-400">
+              Loading statistics...
+            </div>
+          )}
+        </div>
+      );
+    } else if (operation === "purge") {
+      return (
+        <div className="space-y-4">
+          <div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
+            <div className="flex">
+              <div className="flex-shrink-0">
+                <svg
+                  className="h-5 w-5 text-red-400"
+                  viewBox="0 0 20 20"
+                  fill="currentColor"
+                >
+                  <path
+                    fillRule="evenodd"
+                    d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
+                    clipRule="evenodd"
+                  />
+                </svg>
+              </div>
+              <div className="ml-3">
+                <h3 className="text-sm font-medium text-red-800 dark:text-red-200">
+                  Warning: Destructive Operation
+                </h3>
+                <div className="mt-2 text-sm text-red-700 dark:text-red-300">
+                  <p>
+                    This will permanently delete ALL task records from the
+                    database. This operation cannot be undone.
+                  </p>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      );
+    }
+    return null;
+  };
+
+  const getFormTitle = () => {
+    switch (operation) {
+      case "cleanup":
+        return "Cleanup Tasks";
+      case "archive":
+        return "Archive Old Tasks";
+      case "purge":
+        return "Purge All Tasks";
+      case "scheduled":
+        return "Run Scheduled Cleanup";
+      case "stats":
+        return "Task Statistics";
+      default:
+        return "Task Maintenance";
+    }
+  };
+
+  const isLoading =
+    cleanupMutation.status === "pending" ||
+    archiveMutation.status === "pending" ||
+    purgeMutation.status === "pending" ||
+    scheduledMutation.status === "pending";
+
+  return (
+    <>
+      <div className="relative group">
+        <button
+          onClick={() => setOperation("stats")}
+          className="p-2 rounded-md text-gray-600 dark:text-gray-400 hover:bg-indigo-100 dark:hover:bg-indigo-900/30 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
+          title="View 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">
+          View Statistics
+        </div>
+      </div>
+
+      <div className="relative group">
+        <button
+          onClick={() => setOperation("cleanup")}
+          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"
+          title="Cleanup Tasks"
+        >
+          <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">
+          Cleanup Tasks
+        </div>
+      </div>
+
+      <div className="relative group">
+        <button
+          onClick={() => setOperation("archive")}
+          className="p-2 rounded-md text-gray-600 dark:text-gray-400 hover:bg-yellow-100 dark:hover:bg-yellow-900/30 hover:text-yellow-600 dark:hover:text-yellow-400 transition-colors"
+          title="Archive Old Tasks"
+        >
+          <ArchiveBoxIcon 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">
+          Archive Old Tasks
+        </div>
+      </div>
+
+      <div className="relative group">
+        <button
+          onClick={() => setOperation("purge")}
+          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"
+          title="Purge All Tasks"
+        >
+          <FireIcon 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">
+          Purge All Tasks
+        </div>
+      </div>
+
+      <div className="relative group">
+        <button
+          onClick={() => setOperation("scheduled")}
+          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="Run Scheduled Cleanup"
+        >
+          <ClockIcon 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">
+          Run Scheduled Cleanup
+        </div>
+      </div>
+
+      <SlideInForm
+        isOpen={!!operation}
+        onClose={handleClose}
+        title={getFormTitle()}
+        actions={
+          operation === "stats" ? (
+            <div className="flex justify-end">
+              <button
+                type="button"
+                onClick={handleClose}
+                className="inline-flex items-center rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
+              >
+                Close
+              </button>
+            </div>
+          ) : (
+            <div className="flex justify-end space-x-3">
+              <button
+                type="button"
+                onClick={handleClose}
+                className="inline-flex items-center rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
+              >
+                Cancel
+              </button>
+              <button
+                type="submit"
+                disabled={isLoading}
+                onClick={handleSubmit}
+                className="inline-flex items-center rounded-md border border-transparent bg-purple-600 px-4 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"
+              >
+                {isLoading ? "Running..." : "Execute"}
+              </button>
+            </div>
+          )
+        }
+      >
+        {renderForm()}
+      </SlideInForm>
+    </>
+  );
+}

+ 92 - 78
apps/web/src/app/components/TaskProcessingControls.tsx

@@ -10,55 +10,55 @@ export default function TaskProcessingControls() {
   const { addNotification } = useNotifications();
   const { data } = useQuery({
     queryKey: ["tasks", "processing-status"],
-    queryFn: () => get("/tasks/processing-status")
+    queryFn: () => get("/tasks/processing-status"),
   });
 
   const startMutation = useMutation({
     mutationFn: () => post("/tasks/start-processing"),
     onSuccess: () => {
       queryClient.invalidateQueries({
-        queryKey: ["tasks", "processing-status"]
+        queryKey: ["tasks", "processing-status"],
       });
       queryClient.invalidateQueries({ queryKey: ["tasks", "queue", "status"] });
       toast.success("Task processing started successfully");
       addNotification({
         type: "success",
         title: "Task Processing Started",
-        message: "The task processing queue has been started successfully."
+        message: "The task processing queue has been started successfully.",
       });
-    }
+    },
   });
 
   const stopMutation = useMutation({
     mutationFn: () => post("/tasks/stop-processing", { graceful: true }),
     onSuccess: () => {
       queryClient.invalidateQueries({
-        queryKey: ["tasks", "processing-status"]
+        queryKey: ["tasks", "processing-status"],
       });
       queryClient.invalidateQueries({ queryKey: ["tasks", "queue", "status"] });
       toast.success("Graceful stop initiated – finishing active tasks");
       addNotification({
         type: "info",
         title: "Graceful Stop Requested",
-        message: "The queue will finish active tasks before stopping."
+        message: "The queue will finish active tasks before stopping.",
       });
-    }
+    },
   });
 
   const hardStopMutation = useMutation({
     mutationFn: () => post("/tasks/stop-processing", { graceful: false }),
     onSuccess: () => {
       queryClient.invalidateQueries({
-        queryKey: ["tasks", "processing-status"]
+        queryKey: ["tasks", "processing-status"],
       });
       queryClient.invalidateQueries({ queryKey: ["tasks", "queue", "status"] });
       toast.success("Task processing stopped immediately");
       addNotification({
         type: "warning",
         title: "Immediate Stop",
-        message: "Queue scheduling and active work halted immediately."
+        message: "Queue scheduling and active work halted immediately.",
       });
-    }
+    },
   });
 
   // Listen for WebSocket events
@@ -72,10 +72,10 @@ export default function TaskProcessingControls() {
       ) {
         // Invalidate and refetch the task processing status
         queryClient.invalidateQueries({
-          queryKey: ["tasks", "processing-status"]
+          queryKey: ["tasks", "processing-status"],
         });
         queryClient.invalidateQueries({
-          queryKey: ["tasks", "queue", "status"]
+          queryKey: ["tasks", "queue", "status"],
         });
       }
     };
@@ -92,75 +92,89 @@ export default function TaskProcessingControls() {
 
   return (
     <div className="flex items-center gap-2">
-      <button
-        className="inline-flex items-center rounded-md bg-green-600 px-3 py-2 text-sm font-medium text-white shadow-sm hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 disabled:opacity-50"
-        onClick={() => startMutation.mutate()}
-        disabled={data?.isProcessing || startMutation.isPending}
-        title="Start task processing"
-      >
-        <svg
-          className="h-4 w-4"
-          fill="none"
-          viewBox="0 0 24 24"
-          stroke="currentColor"
+      <div className="relative group">
+        <button
+          className="p-2 rounded-md text-gray-600 dark:text-gray-400 hover:bg-green-100 dark:hover:bg-green-900/30 hover:text-green-600 dark:hover:text-green-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+          onClick={() => startMutation.mutate()}
+          disabled={data?.isProcessing || startMutation.isPending}
+          title="Start task processing"
         >
-          <path
-            strokeLinecap="round"
-            strokeLinejoin="round"
-            strokeWidth={2}
-            d="M14.828 14.828a4 4 0 01-5.656 0M9 10h1.586a1 1 0 01.707.293l.707.707A1 1 0 0012.414 11H15m-3-3v3m0 0v3m0-3h3m-3 0H9"
-          />
-        </svg>
-        {startMutation.isPending ? "Starting..." : "Start"}
-      </button>
-      <button
-        className="inline-flex items-center rounded-md bg-red-600 px-3 py-2 text-sm font-medium text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 disabled:opacity-50"
-        onClick={() => stopMutation.mutate()}
-        disabled={!data?.isProcessing || stopMutation.isPending}
-        title="Graceful stop (finish active tasks)"
-      >
-        <svg
-          className="h-4 w-4"
-          fill="none"
-          viewBox="0 0 24 24"
-          stroke="currentColor"
+          <svg
+            className="h-5 w-5"
+            fill="none"
+            viewBox="0 0 24 24"
+            stroke="currentColor"
+          >
+            <path
+              strokeLinecap="round"
+              strokeLinejoin="round"
+              strokeWidth={2}
+              d="M14.828 14.828a4 4 0 01-5.656 0M9 10h1.586a1 1 0 01.707.293l.707.707A1 1 0 0012.414 11H15m-3-3v3m0 0v3m0-3h3m-3 0H9"
+            />
+          </svg>
+        </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">
+          {startMutation.isPending ? "Starting..." : "Start"}
+        </div>
+      </div>
+
+      <div className="relative group">
+        <button
+          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"
+          onClick={() => stopMutation.mutate()}
+          disabled={!data?.isProcessing || stopMutation.isPending}
+          title="Graceful stop (finish active tasks)"
         >
-          <path
-            strokeLinecap="round"
-            strokeLinejoin="round"
-            strokeWidth={2}
-            d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
-          />
-          <path
-            strokeLinecap="round"
-            strokeLinejoin="round"
-            strokeWidth={2}
-            d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"
-          />
-        </svg>
-        {stopMutation.isPending ? "Stopping..." : "Graceful Stop"}
-      </button>
-      <button
-        className="inline-flex items-center rounded-md bg-orange-600 px-3 py-2 text-sm font-medium text-white shadow-sm hover:bg-orange-700 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 disabled:opacity-50"
-        onClick={() => hardStopMutation.mutate()}
-        disabled={!data?.isProcessing || hardStopMutation.isPending}
-        title="Immediate stop (cancel scheduling)"
-      >
-        <svg
-          className="h-4 w-4"
-          fill="none"
-          viewBox="0 0 24 24"
-          stroke="currentColor"
+          <svg
+            className="h-5 w-5"
+            fill="none"
+            viewBox="0 0 24 24"
+            stroke="currentColor"
+          >
+            <path
+              strokeLinecap="round"
+              strokeLinejoin="round"
+              strokeWidth={2}
+              d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
+            />
+            <path
+              strokeLinecap="round"
+              strokeLinejoin="round"
+              strokeWidth={2}
+              d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"
+            />
+          </svg>
+        </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">
+          {stopMutation.isPending ? "Stopping..." : "Graceful Stop"}
+        </div>
+      </div>
+
+      <div className="relative group">
+        <button
+          className="p-2 rounded-md text-gray-600 dark:text-gray-400 hover:bg-orange-100 dark:hover:bg-orange-900/30 hover:text-orange-600 dark:hover:text-orange-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+          onClick={() => hardStopMutation.mutate()}
+          disabled={!data?.isProcessing || hardStopMutation.isPending}
+          title="Immediate stop (cancel scheduling)"
         >
-          <path
-            strokeLinecap="round"
-            strokeLinejoin="round"
-            strokeWidth={2}
-            d="M6 18L18 6M6 6l12 12"
-          />
-        </svg>
-        {hardStopMutation.isPending ? "Stopping..." : "Hard Stop"}
-      </button>
+          <svg
+            className="h-5 w-5"
+            fill="none"
+            viewBox="0 0 24 24"
+            stroke="currentColor"
+          >
+            <path
+              strokeLinecap="round"
+              strokeLinejoin="round"
+              strokeWidth={2}
+              d="M6 18L18 6M6 6l12 12"
+            />
+          </svg>
+        </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">
+          {hardStopMutation.isPending ? "Stopping..." : "Hard Stop"}
+        </div>
+      </div>
     </div>
   );
 }

+ 66 - 57
apps/web/src/app/components/WatcherControls.tsx

@@ -10,7 +10,7 @@ export default function WatcherControls() {
   const { addNotification } = useNotifications();
   const { data } = useQuery({
     queryKey: ["watcher", "status"],
-    queryFn: () => get("/watcher/status")
+    queryFn: () => get("/watcher/status"),
   });
 
   const startMutation = useMutation({
@@ -21,12 +21,12 @@ export default function WatcherControls() {
       addNotification({
         type: "success",
         title: "File Watcher Started",
-        message: "The file watcher has been started successfully."
+        message: "The file watcher has been started successfully.",
       });
     },
     onError: () => {
       toast.error("Failed to start file watcher");
-    }
+    },
   });
 
   const stopMutation = useMutation({
@@ -37,12 +37,12 @@ export default function WatcherControls() {
       addNotification({
         type: "success",
         title: "File Watcher Stopped",
-        message: "The file watcher has been stopped successfully."
+        message: "The file watcher has been stopped successfully.",
       });
     },
     onError: () => {
       toast.error("Failed to stop file watcher");
-    }
+    },
   });
 
   // Listen for WebSocket events
@@ -70,60 +70,69 @@ export default function WatcherControls() {
 
   return (
     <div className="flex items-center gap-2">
-      <button
-        className="inline-flex items-center rounded-md bg-green-600 px-3 py-2 text-sm font-medium text-white shadow-sm hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 disabled:opacity-50"
-        onClick={() => startMutation.mutate()}
-        disabled={data?.isWatching || startMutation.isPending}
-        title="Start file watcher"
-      >
-        <svg
-          className="h-4 w-4 mr-1"
-          fill="none"
-          viewBox="0 0 24 24"
-          stroke="currentColor"
+      <div className="relative group">
+        <button
+          className="p-2 rounded-md text-gray-600 dark:text-gray-400 hover:bg-green-100 dark:hover:bg-green-900/30 hover:text-green-600 dark:hover:text-green-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+          onClick={() => startMutation.mutate()}
+          disabled={data?.isWatching || startMutation.isPending}
+          title="Start file watcher"
         >
-          <path
-            strokeLinecap="round"
-            strokeLinejoin="round"
-            strokeWidth={2}
-            d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
-          />
-          <path
-            strokeLinecap="round"
-            strokeLinejoin="round"
-            strokeWidth={2}
-            d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.543-7z"
-          />
-        </svg>
-        {startMutation.isPending ? "Starting..." : "Start Watcher"}
-      </button>
-      <button
-        className="inline-flex items-center rounded-md bg-red-600 px-3 py-2 text-sm font-medium text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 disabled:opacity-50"
-        onClick={() => stopMutation.mutate()}
-        disabled={!data?.isWatching || stopMutation.isPending}
-        title="Stop file watcher"
-      >
-        <svg
-          className="h-4 w-4 mr-1"
-          fill="none"
-          viewBox="0 0 24 24"
-          stroke="currentColor"
+          <svg
+            className="h-5 w-5"
+            fill="none"
+            viewBox="0 0 24 24"
+            stroke="currentColor"
+          >
+            <path
+              strokeLinecap="round"
+              strokeLinejoin="round"
+              strokeWidth={2}
+              d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
+            />
+            <path
+              strokeLinecap="round"
+              strokeLinejoin="round"
+              strokeWidth={2}
+              d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.543-7z"
+            />
+          </svg>
+        </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">
+          {startMutation.isPending ? "Starting..." : "Start Watcher"}
+        </div>
+      </div>
+
+      <div className="relative group">
+        <button
+          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"
+          onClick={() => stopMutation.mutate()}
+          disabled={!data?.isWatching || stopMutation.isPending}
+          title="Stop file watcher"
         >
-          <path
-            strokeLinecap="round"
-            strokeLinejoin="round"
-            strokeWidth={2}
-            d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
-          />
-          <path
-            strokeLinecap="round"
-            strokeLinejoin="round"
-            strokeWidth={2}
-            d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"
-          />
-        </svg>
-        {stopMutation.isPending ? "Stopping..." : "Stop Watcher"}
-      </button>
+          <svg
+            className="h-5 w-5"
+            fill="none"
+            viewBox="0 0 24 24"
+            stroke="currentColor"
+          >
+            <path
+              strokeLinecap="round"
+              strokeLinejoin="round"
+              strokeWidth={2}
+              d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
+            />
+            <path
+              strokeLinecap="round"
+              strokeLinejoin="round"
+              strokeWidth={2}
+              d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"
+            />
+          </svg>
+        </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">
+          {stopMutation.isPending ? "Stopping..." : "Stop Watcher"}
+        </div>
+      </div>
     </div>
   );
 }

+ 562 - 52
apps/web/src/app/duplicates/DuplicateList.tsx

@@ -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>
   );
 }

+ 50 - 31
apps/web/src/app/files/FileList.tsx

@@ -696,8 +696,18 @@ export default function FileList() {
                             </span>
                           </td>
                           <td className="px-4 py-2 text-sm text-gray-900 dark:text-gray-100">
-                            <div className="truncate" title={file.input}>
-                              {file.input.split("/").pop()}
+                            <div
+                              className="truncate"
+                              title={file.output || file.input}
+                            >
+                              {(() => {
+                                const path = file.output || file.input;
+                                const filename = path.split("/").pop() || "";
+                                const lastDot = filename.lastIndexOf(".");
+                                return lastDot > 0
+                                  ? filename.substring(0, lastDot)
+                                  : filename;
+                              })()}
                             </div>
                           </td>
                           <td className="px-4 py-2 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
@@ -771,14 +781,17 @@ export default function FileList() {
                                     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>
+                                    <input
+                                      type="text"
+                                      readOnly
+                                      value={file.input}
+                                      className="text-gray-700 dark:text-gray-300 font-mono text-xs bg-white dark:bg-gray-900 p-2 rounded border flex-1"
+                                    />
                                     <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"
+                                      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 flex-shrink-0"
                                       title="Copy input path"
                                     >
                                       <DocumentDuplicateIcon className="h-4 w-4" />
@@ -792,15 +805,18 @@ export default function FileList() {
                                     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>
+                                    <input
+                                      type="text"
+                                      readOnly
+                                      value={file.output || "No output"}
+                                      className="text-gray-700 dark:text-gray-300 font-mono text-xs bg-white dark:bg-gray-900 p-2 rounded border flex-1"
+                                    />
                                     {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"
+                                        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 flex-shrink-0"
                                         title="Copy output path"
                                       >
                                         <DocumentDuplicateIcon className="h-4 w-4" />
@@ -810,39 +826,42 @@ export default function FileList() {
                                 </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>
+                                <div>
+                                  <div className="font-medium text-gray-900 dark:text-gray-100 mb-1">
+                                    Hash
+                                  </div>
+                                  <div className="flex items-center gap-2">
+                                    <input
+                                      type="text"
+                                      readOnly
+                                      value={file.hash || "No hash"}
+                                      className="text-gray-700 dark:text-gray-300 font-mono text-xs bg-white dark:bg-gray-900 p-2 rounded border flex-1"
+                                    />
+                                    {file.hash && (
                                       <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"
+                                        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 flex-shrink-0"
                                         title="Copy hash"
                                       >
                                         <DocumentDuplicateIcon className="h-4 w-4" />
                                       </button>
-                                    </div>
+                                    )}
                                   </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 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
+                                      ? `${(file.file_size / 1024 / 1024).toFixed(2)} MB (${file.file_size.toLocaleString()} bytes)`
+                                      : "No size"}
+                                  </div>
+                                </div>
                               </div>
                             </td>
                           </tr>

+ 2 - 2
apps/web/src/app/files/page.tsx

@@ -1,5 +1,5 @@
 import FileCrud from "../components/FileCrud";
-import MaintenanceDropdown from "../components/MaintenanceDropdown";
+import FileMaintenanceButtons from "../components/FileMaintenanceButtons";
 import FileList from "./FileList";
 
 export default function FilesPage() {
@@ -35,7 +35,7 @@ export default function FilesPage() {
         </div>
         <div className="flex items-center gap-3">
           <FileCrud />
-          <MaintenanceDropdown />
+          <FileMaintenanceButtons />
         </div>
       </div>
       <div className="bg-white dark:bg-gray-900 rounded-xl shadow-sm ring-1 ring-gray-200 dark:ring-gray-800 overflow-hidden">

+ 15 - 0
apps/web/src/app/globals.css

@@ -1,3 +1,18 @@
 @tailwind base;
 @tailwind components;
 @tailwind utilities;
+
+@keyframes slideInRight {
+  from {
+    transform: translateX(100%);
+  }
+  to {
+    transform: translateX(0);
+  }
+}
+
+@layer utilities {
+  .animate-slide-in-right {
+    animation: slideInRight 300ms ease-out;
+  }
+}

+ 2 - 2
apps/web/src/app/tasks/page.tsx

@@ -1,6 +1,6 @@
 import TaskCrud from "../components/TaskCrud";
 import TaskList from "../components/TaskList";
-import TaskMaintenance from "../components/TaskMaintenance";
+import TaskMaintenanceButtons from "../components/TaskMaintenanceButtons";
 import TaskProcessingControls from "../components/TaskProcessingControls";
 
 export default function TasksPage() {
@@ -36,7 +36,7 @@ export default function TasksPage() {
         </div>
         <div className="flex items-center gap-3">
           <TaskProcessingControls />
-          <TaskMaintenance />
+          <TaskMaintenanceButtons />
           <TaskCrud />
         </div>
       </div>

BIN=BIN
data/database.db


BIN=BIN
data/database.db-shm


BIN=BIN
data/database.db-wal