Browse Source

commit to main

Timothy Pomeroy 1 month ago
parent
commit
425eeabb0f

+ 1 - 0
apps/service/package.json

@@ -25,6 +25,7 @@
     "@nestjs/core": "^11.0.1",
     "@nestjs/core": "^11.0.1",
     "@nestjs/platform-express": "^11.0.1",
     "@nestjs/platform-express": "^11.0.1",
     "@nestjs/platform-socket.io": "^11.1.11",
     "@nestjs/platform-socket.io": "^11.1.11",
+    "@nestjs/schedule": "^6.1.0",
     "@nestjs/websockets": "^11.1.11",
     "@nestjs/websockets": "^11.1.11",
     "chokidar": "^5.0.0",
     "chokidar": "^5.0.0",
     "reflect-metadata": "^0.2.2",
     "reflect-metadata": "^0.2.2",

+ 2 - 1
apps/service/src/app.module.ts

@@ -1,4 +1,5 @@
 import { Module } from '@nestjs/common';
 import { Module } from '@nestjs/common';
+import { ScheduleModule } from '@nestjs/schedule';
 import { AppController } from './app.controller';
 import { AppController } from './app.controller';
 import { AppService } from './app.service';
 import { AppService } from './app.service';
 import { ConfigService } from './config.service';
 import { ConfigService } from './config.service';
@@ -11,7 +12,7 @@ import { TaskQueueService } from './task-queue.service';
 import { WatcherService } from './watcher.service';
 import { WatcherService } from './watcher.service';
 
 
 @Module({
 @Module({
-  imports: [],
+  imports: [ScheduleModule.forRoot()],
   controllers: [AppController],
   controllers: [AppController],
   providers: [
   providers: [
     AppService,
     AppService,

+ 26 - 2
apps/service/src/config.service.spec.ts

@@ -1,6 +1,5 @@
 import { Test, TestingModule } from '@nestjs/testing';
 import { Test, TestingModule } from '@nestjs/testing';
 import Database from 'better-sqlite3';
 import Database from 'better-sqlite3';
-import path from 'path';
 import { ConfigService } from './config.service';
 import { ConfigService } from './config.service';
 
 
 // Mock better-sqlite3
 // Mock better-sqlite3
@@ -39,8 +38,33 @@ describe('ConfigService', () => {
 
 
   describe('constructor', () => {
   describe('constructor', () => {
     it('should create database and table on initialization', () => {
     it('should create database and table on initialization', () => {
+      // Find project root by traversing up from current directory until we find the root package.json
+      let projectRoot = process.cwd();
+      while (projectRoot !== path.dirname(projectRoot)) {
+        if (
+          require('fs').existsSync(
+            require('path').join(projectRoot, 'package.json'),
+          )
+        ) {
+          try {
+            const pkg = JSON.parse(
+              require('fs').readFileSync(
+                require('path').join(projectRoot, 'package.json'),
+                'utf-8',
+              ),
+            );
+            if (pkg.name === 'watch-finished-turbo') {
+              break;
+            }
+          } catch (e) {
+            // ignore
+          }
+        }
+        projectRoot = require('path').dirname(projectRoot);
+      }
+
       expect(Database).toHaveBeenCalledWith(
       expect(Database).toHaveBeenCalledWith(
-        path.resolve(process.cwd(), 'data', 'database.db'),
+        require('path').resolve(projectRoot, 'data', 'database.db'),
       );
       );
       expect(mockDb.exec).toHaveBeenCalledWith(
       expect(mockDb.exec).toHaveBeenCalledWith(
         expect.stringContaining('CREATE TABLE IF NOT EXISTS settings'),
         expect.stringContaining('CREATE TABLE IF NOT EXISTS settings'),

+ 25 - 2
apps/service/src/config.service.ts

@@ -1,14 +1,37 @@
 import { Injectable } from '@nestjs/common';
 import { Injectable } from '@nestjs/common';
 import Database from 'better-sqlite3';
 import Database from 'better-sqlite3';
+import fs from 'fs';
 import path from 'path';
 import path from 'path';
 
 
 @Injectable()
 @Injectable()
 export class ConfigService {
 export class ConfigService {
-  private dataDir = path.resolve(process.cwd(), 'data');
-  private unifiedDbPath = path.resolve(this.dataDir, 'database.db');
+  private dataDir: string;
+  private unifiedDbPath: string;
 
 
   constructor() {
   constructor() {
+    // Find project root by traversing up from current directory until we find the root package.json
+    let projectRoot = process.cwd();
+    while (projectRoot !== path.dirname(projectRoot)) {
+      if (fs.existsSync(path.join(projectRoot, 'package.json'))) {
+        try {
+          const pkg = JSON.parse(
+            fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf-8'),
+          );
+          if (pkg.name === 'watch-finished-turbo') {
+            break;
+          }
+        } catch (e) {
+          // ignore
+        }
+      }
+      projectRoot = path.dirname(projectRoot);
+    }
+
+    this.dataDir = path.resolve(projectRoot, 'data');
+    this.unifiedDbPath = path.resolve(this.dataDir, 'database.db');
+
     console.log('ConfigService: Database path:', this.unifiedDbPath);
     console.log('ConfigService: Database path:', this.unifiedDbPath);
+    console.log('ConfigService: Project root:', projectRoot);
     console.log('ConfigService: Current working directory:', process.cwd());
     console.log('ConfigService: Current working directory:', process.cwd());
 
 
     // Ensure database and tables exist
     // Ensure database and tables exist

+ 47 - 5
apps/service/src/maintenance.service.ts

@@ -1,4 +1,5 @@
 import { Injectable, Logger } from '@nestjs/common';
 import { Injectable, Logger } from '@nestjs/common';
+import { Cron, CronExpression } from '@nestjs/schedule';
 import fs from 'fs';
 import fs from 'fs';
 import { DbService } from './db.service';
 import { DbService } from './db.service';
 
 
@@ -41,18 +42,59 @@ export class MaintenanceService {
   }
   }
 
 
   prune(dirs: string[]) {
   prune(dirs: string[]) {
-    this.logger.log('Checking for any "processed" files that need pruning.');
+    this.logger.log('Checking for any files that no longer exist on disk.');
     for (let i = 0, l = dirs.length; i < l; i++) {
     for (let i = 0, l = dirs.length; i < l; i++) {
       const dir = dirs[i];
       const dir = dirs[i];
       const dataset = dir.replace(/.*\/(.*)/, '$1');
       const dataset = dir.replace(/.*\/(.*)/, '$1');
-      const files = this.db.getFilesByStatus(dataset, 'success');
-      for (const file of files as { input: string }[]) {
+
+      // Get all files for this dataset (not just successful ones)
+      const allFiles = this.db.getAllFiles(dataset);
+      for (const file of allFiles as { input: string; status: string }[]) {
         const exists = fs.existsSync(file.input);
         const exists = fs.existsSync(file.input);
         if (!exists) {
         if (!exists) {
-          this.logger.log(`Pruning "${file.input}" (${new Date()})`);
-          this.db.removeFile(dataset, file.input, false);
+          // Only soft delete if not already marked as deleted
+          if (file.status !== 'deleted') {
+            this.logger.log(
+              `Marking missing file as deleted: "${file.input}" (${new Date()})`,
+            );
+            this.db.removeFile(dataset, file.input, true); // soft delete
+          }
         }
         }
       }
       }
     }
     }
   }
   }
+
+  // Scheduled task cleanup - runs periodically to prevent task table from growing too large
+  @Cron(CronExpression.EVERY_DAY_AT_2AM) // Run daily at 2 AM
+  scheduledTaskCleanup() {
+    this.logger.log('Running scheduled task cleanup');
+
+    try {
+      // Archive completed tasks older than 30 days
+      const archiveResult = this.db.archiveOldTasks(30);
+      this.logger.log(`Archived ${archiveResult?.changes || 0} old tasks`);
+
+      // Delete failed tasks older than 7 days
+      const failedResult = this.db.deleteTasksByStatus('failed', 7);
+      this.logger.log(
+        `Deleted ${failedResult.changes} failed tasks older than 7 days`,
+      );
+
+      // Delete skipped tasks older than 7 days
+      const skippedResult = this.db.deleteTasksByStatus('skipped', 7);
+      this.logger.log(
+        `Deleted ${skippedResult.changes} skipped tasks older than 7 days`,
+      );
+
+      // Keep completed tasks for 90 days, then archive them
+      const completedResult = this.db.deleteTasksByStatus('completed', 90);
+      this.logger.log(
+        `Deleted ${completedResult.changes} completed tasks older than 90 days`,
+      );
+    } catch (error) {
+      this.logger.error(
+        `Error during scheduled task cleanup: ${error.message}`,
+      );
+    }
+  }
 }
 }

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

@@ -234,7 +234,7 @@ export class TaskQueueService implements OnModuleInit {
       if (task.output && fs.existsSync(task.output)) {
       if (task.output && fs.existsSync(task.output)) {
         // Check if this task was requeued (has retry_count > 0 or was manually requeued)
         // Check if this task was requeued (has retry_count > 0 or was manually requeued)
         const wasRequeued =
         const wasRequeued =
-          (task.retry_count || 0) > 0 || task.status === 'requeued';
+          (task.retry_count || 0) > 0 || (task.priority || 0) > 0;
 
 
         if (!wasRequeued) {
         if (!wasRequeued) {
           // Skip processing - file already exists
           // Skip processing - file already exists

File diff suppressed because it is too large
+ 0 - 0
apps/web/playwright-report/index.html


+ 0 - 12
apps/web/src/app/components/ClientHomeWidgets.tsx

@@ -3,23 +3,11 @@ import dynamic from "next/dynamic";
 import { Suspense } from "react";
 import { Suspense } from "react";
 import LoadingCard from "./Loading";
 import LoadingCard from "./Loading";
 
 
-const WatcherStatus = dynamic(() => import("./WatcherStatus"), { ssr: false });
-const ApiHealth = dynamic(() => import("./ApiHealth"), { ssr: false });
 const TaskList = dynamic(() => import("./TaskList"), { ssr: false });
 const TaskList = dynamic(() => import("./TaskList"), { ssr: false });
 
 
 export default function ClientHomeWidgets() {
 export default function ClientHomeWidgets() {
   return (
   return (
     <>
     <>
-      <div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
-        <Suspense
-          fallback={<LoadingCard message="Loading watcher status..." />}
-        >
-          <WatcherStatus />
-        </Suspense>
-        <Suspense fallback={<LoadingCard message="Loading API health..." />}>
-          <ApiHealth />
-        </Suspense>
-      </div>
       <Suspense fallback={<LoadingCard message="Loading tasks..." />}>
       <Suspense fallback={<LoadingCard message="Loading tasks..." />}>
         <TaskList />
         <TaskList />
       </Suspense>
       </Suspense>

+ 91 - 3
apps/web/src/app/components/MaintenanceDropdown.tsx

@@ -6,7 +6,7 @@ import {
 import { useMutation, useQueryClient } from "@tanstack/react-query";
 import { useMutation, useQueryClient } from "@tanstack/react-query";
 import { useState } from "react";
 import { useState } from "react";
 import toast from "react-hot-toast";
 import toast from "react-hot-toast";
-import { post } from "../../lib/api";
+import { del, post } from "../../lib/api";
 import { useNotifications } from "./NotificationContext";
 import { useNotifications } from "./NotificationContext";
 import SlideInForm from "./SlideInForm";
 import SlideInForm from "./SlideInForm";
 
 
@@ -15,7 +15,7 @@ export default function MaintenanceDropdown() {
   const { addNotification } = useNotifications();
   const { addNotification } = useNotifications();
   const [isOpen, setIsOpen] = useState(false);
   const [isOpen, setIsOpen] = useState(false);
   const [operation, setOperation] = useState<
   const [operation, setOperation] = useState<
-    "cleanup" | "purge" | "prune" | null
+    "cleanup" | "purge" | "prune" | "clear" | null
   >(null);
   >(null);
   const [file, setFile] = useState("");
   const [file, setFile] = useState("");
   const [dirs, setDirs] = useState<string[]>([]);
   const [dirs, setDirs] = useState<string[]>([]);
@@ -94,6 +94,30 @@ export default function MaintenanceDropdown() {
     }
     }
   });
   });
 
 
+  const clearAllMutation = useMutation({
+    mutationFn: () => del("/files"),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["files"] });
+      queryClient.invalidateQueries({ queryKey: ["tasks"] });
+      setIsOpen(false);
+      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 resetForm = () => {
   const resetForm = () => {
     setOperation(null);
     setOperation(null);
     setFile("");
     setFile("");
@@ -110,6 +134,12 @@ export default function MaintenanceDropdown() {
       purgeMutation.mutate();
       purgeMutation.mutate();
     } else if (operation === "prune") {
     } else if (operation === "prune") {
       pruneMutation.mutate();
       pruneMutation.mutate();
+    } else if (operation === "clear") {
+      if (file !== "CLEAR") {
+        toast.error("Please type 'CLEAR' to confirm the operation");
+        return;
+      }
+      clearAllMutation.mutate();
     }
     }
   };
   };
 
 
@@ -280,6 +310,52 @@ export default function MaintenanceDropdown() {
           </div>
           </div>
         </form>
         </form>
       );
       );
+    } else if (operation === "clear") {
+      return (
+        <div className="space-y-4">
+          <div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md 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;
     return null;
   };
   };
@@ -292,6 +368,8 @@ export default function MaintenanceDropdown() {
         return "Purge Directories";
         return "Purge Directories";
       case "prune":
       case "prune":
         return "Prune Directories";
         return "Prune Directories";
+      case "clear":
+        return "Clear All Files";
       default:
       default:
         return "Maintenance";
         return "Maintenance";
     }
     }
@@ -300,7 +378,8 @@ export default function MaintenanceDropdown() {
   const isLoading =
   const isLoading =
     cleanupMutation.status === "pending" ||
     cleanupMutation.status === "pending" ||
     purgeMutation.status === "pending" ||
     purgeMutation.status === "pending" ||
-    pruneMutation.status === "pending";
+    pruneMutation.status === "pending" ||
+    clearAllMutation.status === "pending";
 
 
   return (
   return (
     <>
     <>
@@ -343,6 +422,15 @@ export default function MaintenanceDropdown() {
               >
               >
                 Prune Directories
                 Prune Directories
               </button>
               </button>
+              <button
+                onClick={() => {
+                  setOperation("clear");
+                  setIsOpen(false);
+                }}
+                className="block w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-gray-100 dark:hover:bg-gray-700"
+              >
+                Clear All Files
+              </button>
             </div>
             </div>
           </div>
           </div>
         )}
         )}

+ 59 - 1
apps/web/src/app/components/NotificationContext.tsx

@@ -1,5 +1,12 @@
 "use client";
 "use client";
-import { createContext, ReactNode, useContext, useState } from "react";
+import {
+  createContext,
+  ReactNode,
+  useContext,
+  useEffect,
+  useState
+} from "react";
+import toast from "react-hot-toast";
 
 
 export interface Notification {
 export interface Notification {
   id: string;
   id: string;
@@ -29,6 +36,57 @@ const NotificationContext = createContext<NotificationContextType | undefined>(
 export function NotificationProvider({ children }: { children: ReactNode }) {
 export function NotificationProvider({ children }: { children: ReactNode }) {
   const [notifications, setNotifications] = useState<Notification[]>([]);
   const [notifications, setNotifications] = useState<Notification[]>([]);
 
 
+  // Listen for WebSocket events and add notifications
+  useEffect(() => {
+    const handleFileUpdate = (event: CustomEvent) => {
+      const data = event.detail;
+      if (data.type === "add") {
+        // New file detected and task created
+        const notification = {
+          type: "info" as const,
+          title: "New File Detected",
+          message: `File "${data.file}" in dataset "${data.dataset}" has been detected and queued for processing.`
+        };
+        addNotification(notification);
+        toast.success(`New file detected: ${data.file}`, {
+          duration: 5000
+        });
+      }
+    };
+
+    const handleTaskUpdate = (event: CustomEvent) => {
+      const data = event.detail;
+      // For now, we'll focus on file creation notifications
+      // Task status updates can be added later if needed
+      if (data.type === "created" || data.type === "started") {
+        // This might be redundant with fileUpdate, but could be useful for manual task creation
+        const notification = {
+          type: "info" as const,
+          title: "Task Created",
+          message: `New processing task started for "${data.input}".`
+        };
+        addNotification(notification);
+        toast.success(`Task started for: ${data.input}`, {
+          duration: 5000
+        });
+      }
+    };
+
+    window.addEventListener("fileUpdate", handleFileUpdate as EventListener);
+    window.addEventListener("taskUpdate", handleTaskUpdate as EventListener);
+
+    return () => {
+      window.removeEventListener(
+        "fileUpdate",
+        handleFileUpdate as EventListener
+      );
+      window.removeEventListener(
+        "taskUpdate",
+        handleTaskUpdate as EventListener
+      );
+    };
+  }, []);
+
   const addNotification = (
   const addNotification = (
     notification: Omit<Notification, "id" | "timestamp" | "read">
     notification: Omit<Notification, "id" | "timestamp" | "read">
   ) => {
   ) => {

+ 288 - 68
apps/web/src/app/components/StatsSection.tsx

@@ -1,8 +1,11 @@
 "use client";
 "use client";
-import { useQuery } from "@tanstack/react-query";
-import { get } from "../../lib/api";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import toast from "react-hot-toast";
+import { get, post } from "../../lib/api";
 
 
 export default function StatsSection() {
 export default function StatsSection() {
+  const queryClient = useQueryClient();
+
   const { data: tasks, isLoading: tasksLoading } = useQuery({
   const { data: tasks, isLoading: tasksLoading } = useQuery({
     queryKey: ["tasks"],
     queryKey: ["tasks"],
     queryFn: () => get("/tasks")
     queryFn: () => get("/tasks")
@@ -31,6 +34,79 @@ export default function StatsSection() {
     queryFn: () => get("/config/settings/datasets")
     queryFn: () => get("/config/settings/datasets")
   });
   });
 
 
+  const { data: watcherStatus, isLoading: watcherLoading } = useQuery({
+    queryKey: ["watcher", "status"],
+    queryFn: () => get("/watcher/status")
+  });
+
+  const { data: taskProcessingStatus, isLoading: taskProcessingLoading } =
+    useQuery({
+      queryKey: ["tasks", "processing-status"],
+      queryFn: () => get("/tasks/processing-status")
+    });
+
+  const { data: queueStatus, isLoading: queueLoading } = useQuery({
+    queryKey: ["tasks", "queue", "status"],
+    queryFn: () => get("/tasks/queue/status")
+  });
+
+  const { data: apiHealth, isLoading: apiHealthLoading } = useQuery({
+    queryKey: ["api", "health"],
+    queryFn: () => get("/health"),
+    refetchInterval: 30000
+  });
+
+  // Mutations for controlling services
+  const startWatcherMutation = useMutation({
+    mutationFn: () => post("/watcher/start"),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["watcher", "status"] });
+      toast.success("File watcher started");
+    },
+    onError: () => {
+      toast.error("Failed to start file watcher");
+    }
+  });
+
+  const stopWatcherMutation = useMutation({
+    mutationFn: () => post("/watcher/stop"),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["watcher", "status"] });
+      toast.success("File watcher stopped");
+    },
+    onError: () => {
+      toast.error("Failed to stop file watcher");
+    }
+  });
+
+  const startTaskProcessingMutation = useMutation({
+    mutationFn: () => post("/tasks/start-processing"),
+    onSuccess: () => {
+      queryClient.invalidateQueries({
+        queryKey: ["tasks", "processing-status"]
+      });
+      queryClient.invalidateQueries({ queryKey: ["tasks", "queue", "status"] });
+      toast.success("Task processing started");
+    },
+    onError: () => {
+      toast.error("Failed to start task processing");
+    }
+  });
+
+  const stopTaskProcessingMutation = useMutation({
+    mutationFn: () => post("/tasks/stop-processing"),
+    onSuccess: () => {
+      queryClient.invalidateQueries({
+        queryKey: ["tasks", "processing-status"]
+      });
+      queryClient.invalidateQueries({ queryKey: ["tasks", "queue", "status"] });
+      toast.success("Task processing stopped");
+    },
+    onError: () => {
+      toast.error("Failed to stop task processing");
+    }
+  });
+
   const tasksRunning = tasks?.length || 0;
   const tasksRunning = tasks?.length || 0;
   const filesProcessed = filesSuccessful || 0;
   const filesProcessed = filesSuccessful || 0;
   const totalProcessed = filesProcessedTotal || 0;
   const totalProcessed = filesProcessedTotal || 0;
@@ -43,81 +119,217 @@ export default function StatsSection() {
         .length
         .length
     : 0;
     : 0;
 
 
+  const isApiHealthy = apiHealth?.status === "healthy";
+  const isWatcherActive = watcherStatus?.isWatching;
+  const isTaskProcessingActive = taskProcessingStatus?.isProcessing;
+
   return (
   return (
     <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
     <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
+      {/* File Watcher */}
       <div className="group relative overflow-hidden rounded-2xl bg-white/5 p-8 ring-1 ring-white/10 transition-all duration-300 hover:bg-white/10 hover:ring-white/20">
       <div className="group relative overflow-hidden rounded-2xl bg-white/5 p-8 ring-1 ring-white/10 transition-all duration-300 hover:bg-white/10 hover:ring-white/20">
-        <div className="flex items-center gap-x-3">
-          <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-indigo-500/20 ring-1 ring-indigo-500/30">
-            <svg
-              className="h-6 w-6 text-indigo-400"
-              fill="none"
-              viewBox="0 0 24 24"
-              strokeWidth="1.5"
-              stroke="currentColor"
+        <div className="flex items-center justify-between">
+          <div className="flex items-center gap-x-3">
+            <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-indigo-500/20 ring-1 ring-indigo-500/30">
+              <svg
+                className="h-6 w-6 text-indigo-400"
+                fill="none"
+                viewBox="0 0 24 24"
+                strokeWidth="1.5"
+                stroke="currentColor"
+              >
+                <path
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                  d="M2.457 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"
+                />
+                <path
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                  d="M12 9a3 3 0 100 6 3 3 0 000-6z"
+                />
+              </svg>
+            </div>
+            <div>
+              <div className="text-2xl font-bold text-white">
+                {watcherLoading ? (
+                  <div className="flex justify-center">
+                    <div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
+                  </div>
+                ) : (
+                  <span
+                    className={
+                      isWatcherActive ? "text-green-400" : "text-red-400"
+                    }
+                  >
+                    {isWatcherActive ? "Active" : "Idle"}
+                  </span>
+                )}
+              </div>
+              <div className="text-sm font-medium text-gray-400">
+                File Watcher
+              </div>
+              <div className="text-xs text-gray-500 mt-1">
+                {settingsLoading ? "..." : `${activeWatchers} datasets`}
+              </div>
+            </div>
+          </div>
+          <div className="flex gap-2">
+            <button
+              onClick={() =>
+                isWatcherActive
+                  ? stopWatcherMutation.mutate()
+                  : startWatcherMutation.mutate()
+              }
+              disabled={
+                watcherLoading ||
+                startWatcherMutation.isPending ||
+                stopWatcherMutation.isPending
+              }
+              className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
+                isWatcherActive
+                  ? "bg-red-600 hover:bg-red-700 text-white"
+                  : "bg-green-600 hover:bg-green-700 text-white"
+              } disabled:opacity-50`}
             >
             >
-              <path
-                strokeLinecap="round"
-                strokeLinejoin="round"
-                d="M2.457 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"
-              />
-              <path
-                strokeLinecap="round"
-                strokeLinejoin="round"
-                d="M12 9a3 3 0 100 6 3 3 0 000-6z"
-              />
-            </svg>
+              {startWatcherMutation.isPending || stopWatcherMutation.isPending
+                ? "..."
+                : isWatcherActive
+                  ? "Stop"
+                  : "Start"}
+            </button>
           </div>
           </div>
-          <div>
-            <div className="text-2xl font-bold text-white">
-              {datasetsLoading || settingsLoading ? (
-                <div className="flex justify-center">
-                  <div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
-                </div>
-              ) : (
-                activeWatchers
+        </div>
+        {watcherStatus?.watches && watcherStatus.watches.length > 0 && (
+          <div className="mt-4 pt-4 border-t border-white/10">
+            <div className="text-xs text-gray-400 mb-2">Watching:</div>
+            <div className="flex flex-wrap gap-1">
+              {watcherStatus.watches
+                .slice(0, 3)
+                .map((watch: any, index: number) => (
+                  <span
+                    key={index}
+                    className="text-xs bg-white/10 px-2 py-1 rounded text-gray-300"
+                  >
+                    {typeof watch === "string"
+                      ? watch.split("/").pop()
+                      : watch.path?.split("/").pop() || "Unknown"}
+                  </span>
+                ))}
+              {watcherStatus.watches.length > 3 && (
+                <span className="text-xs text-gray-500">
+                  +{watcherStatus.watches.length - 3} more
+                </span>
               )}
               )}
             </div>
             </div>
-            <div className="text-sm font-medium text-gray-400">
-              Active Watchers
-            </div>
           </div>
           </div>
-        </div>
+        )}
       </div>
       </div>
 
 
+      {/* Task Processing */}
       <div className="group relative overflow-hidden rounded-2xl bg-white/5 p-8 ring-1 ring-white/10 transition-all duration-300 hover:bg-white/10 hover:ring-white/20">
       <div className="group relative overflow-hidden rounded-2xl bg-white/5 p-8 ring-1 ring-white/10 transition-all duration-300 hover:bg-white/10 hover:ring-white/20">
-        <div className="flex items-center gap-x-3">
-          <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-500/20 ring-1 ring-emerald-500/30">
-            <svg
-              className="h-6 w-6 text-emerald-400"
-              fill="none"
-              viewBox="0 0 24 24"
-              strokeWidth="1.5"
-              stroke="currentColor"
+        <div className="flex items-center justify-between">
+          <div className="flex items-center gap-x-3">
+            <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-500/20 ring-1 ring-emerald-500/30">
+              <svg
+                className="h-6 w-6 text-emerald-400"
+                fill="none"
+                viewBox="0 0 24 24"
+                strokeWidth="1.5"
+                stroke="currentColor"
+              >
+                <path
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                  d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
+                />
+              </svg>
+            </div>
+            <div>
+              <div className="text-2xl font-bold text-white">
+                {taskProcessingLoading ? (
+                  <div className="flex justify-center">
+                    <div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
+                  </div>
+                ) : (
+                  <span
+                    className={
+                      isTaskProcessingActive ? "text-green-400" : "text-red-400"
+                    }
+                  >
+                    {isTaskProcessingActive ? "Active" : "Idle"}
+                  </span>
+                )}
+              </div>
+              <div className="text-sm font-medium text-gray-400">
+                Task Processing
+              </div>
+              <div className="text-xs text-gray-500 mt-1">
+                {filesSuccessfulLoading || filesProcessedLoading
+                  ? "..."
+                  : `${successRate}% success`}
+              </div>
+            </div>
+          </div>
+          <div className="flex gap-2">
+            <button
+              onClick={() =>
+                isTaskProcessingActive
+                  ? stopTaskProcessingMutation.mutate()
+                  : startTaskProcessingMutation.mutate()
+              }
+              disabled={
+                taskProcessingLoading ||
+                startTaskProcessingMutation.isPending ||
+                stopTaskProcessingMutation.isPending
+              }
+              className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
+                isTaskProcessingActive
+                  ? "bg-red-600 hover:bg-red-700 text-white"
+                  : "bg-green-600 hover:bg-green-700 text-white"
+              } disabled:opacity-50`}
             >
             >
-              <path
-                strokeLinecap="round"
-                strokeLinejoin="round"
-                d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
-              />
-            </svg>
+              {startTaskProcessingMutation.isPending ||
+              stopTaskProcessingMutation.isPending
+                ? "..."
+                : isTaskProcessingActive
+                  ? "Stop"
+                  : "Start"}
+            </button>
           </div>
           </div>
-          <div>
-            <div className="text-2xl font-bold text-white">
-              {filesSuccessfulLoading ? (
-                <div className="flex justify-center">
-                  <div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
+        </div>
+        {queueStatus && (
+          <div className="mt-4 pt-4 border-t border-white/10">
+            <div className="grid grid-cols-4 gap-2 text-xs">
+              <div className="text-center">
+                <div className="text-gray-400">Pending</div>
+                <div className="text-white font-medium">
+                  {queueStatus.pending || 0}
                 </div>
                 </div>
-              ) : (
-                filesProcessed.toLocaleString()
-              )}
-            </div>
-            <div className="text-sm font-medium text-gray-400">
-              Files Processed
+              </div>
+              <div className="text-center">
+                <div className="text-gray-400">Active</div>
+                <div className="text-white font-medium">
+                  {queueStatus.processing || 0}
+                </div>
+              </div>
+              <div className="text-center">
+                <div className="text-gray-400">Done</div>
+                <div className="text-white font-medium">
+                  {queueStatus.completed || 0}
+                </div>
+              </div>
+              <div className="text-center">
+                <div className="text-gray-400">Failed</div>
+                <div className="text-white font-medium">
+                  {queueStatus.failed || 0}
+                </div>
+              </div>
             </div>
             </div>
           </div>
           </div>
-        </div>
+        )}
       </div>
       </div>
 
 
+      {/* API Health */}
       <div className="group relative overflow-hidden rounded-2xl bg-white/5 p-8 ring-1 ring-white/10 transition-all duration-300 hover:bg-white/10 hover:ring-white/20">
       <div className="group relative overflow-hidden rounded-2xl bg-white/5 p-8 ring-1 ring-white/10 transition-all duration-300 hover:bg-white/10 hover:ring-white/20">
         <div className="flex items-center gap-x-3">
         <div className="flex items-center gap-x-3">
           <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-500/20 ring-1 ring-amber-500/30">
           <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-500/20 ring-1 ring-amber-500/30">
@@ -131,27 +343,35 @@ export default function StatsSection() {
               <path
               <path
                 strokeLinecap="round"
                 strokeLinecap="round"
                 strokeLinejoin="round"
                 strokeLinejoin="round"
-                d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
+                d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
               />
               />
             </svg>
             </svg>
           </div>
           </div>
           <div>
           <div>
             <div className="text-2xl font-bold text-white">
             <div className="text-2xl font-bold text-white">
-              {filesSuccessfulLoading || filesProcessedLoading ? (
+              {apiHealthLoading ? (
                 <div className="flex justify-center">
                 <div className="flex justify-center">
                   <div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
                   <div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
                 </div>
                 </div>
               ) : (
               ) : (
-                `${successRate}%`
+                <span
+                  className={isApiHealthy ? "text-green-400" : "text-red-400"}
+                >
+                  {isApiHealthy ? "Healthy" : "Issues"}
+                </span>
               )}
               )}
             </div>
             </div>
-            <div className="text-sm font-medium text-gray-400">
-              Success Rate
+            <div className="text-sm font-medium text-gray-400">API Health</div>
+            <div className="text-xs text-gray-500 mt-1">
+              {apiHealth?.datetime
+                ? new Date(apiHealth.datetime).toLocaleTimeString()
+                : "Checking..."}
             </div>
             </div>
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>
 
 
+      {/* Files Processed */}
       <div className="group relative overflow-hidden rounded-2xl bg-white/5 p-8 ring-1 ring-white/10 transition-all duration-300 hover:bg-white/10 hover:ring-white/20">
       <div className="group relative overflow-hidden rounded-2xl bg-white/5 p-8 ring-1 ring-white/10 transition-all duration-300 hover:bg-white/10 hover:ring-white/20">
         <div className="flex items-center gap-x-3">
         <div className="flex items-center gap-x-3">
           <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-rose-500/20 ring-1 ring-rose-500/30">
           <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-rose-500/20 ring-1 ring-rose-500/30">
@@ -165,22 +385,22 @@ export default function StatsSection() {
               <path
               <path
                 strokeLinecap="round"
                 strokeLinecap="round"
                 strokeLinejoin="round"
                 strokeLinejoin="round"
-                d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z"
+                d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
               />
               />
             </svg>
             </svg>
           </div>
           </div>
           <div>
           <div>
             <div className="text-2xl font-bold text-white">
             <div className="text-2xl font-bold text-white">
-              {tasksLoading ? (
+              {filesSuccessfulLoading ? (
                 <div className="flex justify-center">
                 <div className="flex justify-center">
                   <div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
                   <div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
                 </div>
                 </div>
               ) : (
               ) : (
-                tasksRunning
+                filesProcessed.toLocaleString()
               )}
               )}
             </div>
             </div>
             <div className="text-sm font-medium text-gray-400">
             <div className="text-sm font-medium text-gray-400">
-              Tasks Running
+              Files Processed
             </div>
             </div>
           </div>
           </div>
         </div>
         </div>

+ 226 - 44
apps/web/src/app/components/TaskCrud.tsx

@@ -22,59 +22,103 @@ export default function TaskCrud({
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { addNotification } = useNotifications();
   const { addNotification } = useNotifications();
   const [isOpen, setIsOpen] = useState(false);
   const [isOpen, setIsOpen] = useState(false);
-  const [type, setType] = useState("");
+  const [task, setTask] = useState("");
   const [status, setStatus] = useState("");
   const [status, setStatus] = useState("");
-  const [progress, setProgress] = useState("");
+  const [progress, setProgress] = useState(0);
+  const [dataset, setDataset] = useState("");
+  const [input, setInput] = useState("");
+  const [output, setOutput] = useState("");
+  const [preset, setPreset] = useState("");
+  const [priority, setPriority] = useState(0);
 
 
-  const isEditing =
-    !!editTask && Object.keys(editTask).length > 0 && editTask.type !== "";
+  const isEditing = !!editTask && Object.keys(editTask).length > 0;
 
 
   useEffect(() => {
   useEffect(() => {
     if (isEditing && editTask) {
     if (isEditing && editTask) {
-      setType(editTask.type || "");
+      setTask(editTask.task || "handbrake");
       setStatus(editTask.status || "");
       setStatus(editTask.status || "");
-      setProgress(editTask.progress || "");
+      setProgress(editTask.progress || 0);
+      setDataset(editTask.dataset || "");
+      setInput(editTask.input || "");
+      setOutput(editTask.output || "");
+      setPreset(editTask.preset || "");
+      setPriority(editTask.priority || 0);
       setIsOpen(true);
       setIsOpen(true);
     } else if (isAdding) {
     } else if (isAdding) {
-      setType("");
-      setStatus("");
-      setProgress("");
+      setTask("handbrake");
+      setStatus("pending");
+      setProgress(0);
+      setDataset("");
+      setInput("");
+      setOutput("");
+      setPreset("Fast 1080p30");
+      setPriority(0);
       setIsOpen(true);
       setIsOpen(true);
     }
     }
   }, [editTask, isEditing, isAdding]);
   }, [editTask, isEditing, isAdding]);
 
 
   const createMutation = useMutation({
   const createMutation = useMutation({
-    mutationFn: () => post(`/tasks`, { type, status, progress }),
+    mutationFn: () =>
+      post(`/tasks`, {
+        task,
+        status,
+        progress,
+        dataset,
+        input,
+        output,
+        preset,
+        priority
+      }),
     onSuccess: () => {
     onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ["tasks"] });
-      setType("");
-      setStatus("");
-      setProgress("");
+      queryClient.refetchQueries({ queryKey: ["tasks"] });
+      setTask("handbrake");
+      setStatus("pending");
+      setProgress(0);
+      setDataset("");
+      setInput("");
+      setOutput("");
+      setPreset("Fast 1080p30");
+      setPriority(0);
       setIsOpen(false);
       setIsOpen(false);
-      if (onEditClose) onEditClose();
+      if (onAddClose) onAddClose();
       toast.success("Task created successfully");
       toast.success("Task created successfully");
       addNotification({
       addNotification({
         type: "success",
         type: "success",
         title: "Task Created",
         title: "Task Created",
-        message: `Task "${type}" has been created successfully.`
+        message: `Task has been created successfully.`
       });
       });
     }
     }
   });
   });
 
 
   const updateMutation = useMutation({
   const updateMutation = useMutation({
-    mutationFn: () => put(`/tasks/${editTask.id}`, { type, status, progress }),
+    mutationFn: () =>
+      put(`/tasks/${editTask.id}`, {
+        task,
+        status,
+        progress,
+        dataset,
+        input,
+        output,
+        preset,
+        priority
+      }),
     onSuccess: () => {
     onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ["tasks"] });
-      setType("");
-      setStatus("");
-      setProgress("");
+      queryClient.refetchQueries({ queryKey: ["tasks"] });
+      setTask("handbrake");
+      setStatus("pending");
+      setProgress(0);
+      setDataset("");
+      setInput("");
+      setOutput("");
+      setPreset("Fast 1080p30");
+      setPriority(0);
       setIsOpen(false);
       setIsOpen(false);
       if (onEditClose) onEditClose();
       if (onEditClose) onEditClose();
       toast.success("Task updated successfully");
       toast.success("Task updated successfully");
       addNotification({
       addNotification({
         type: "success",
         type: "success",
         title: "Task Updated",
         title: "Task Updated",
-        message: `Task "${type}" has been updated successfully.`
+        message: `Task has been updated successfully.`
       });
       });
     }
     }
   });
   });
@@ -90,17 +134,27 @@ export default function TaskCrud({
 
 
   const handleClose = () => {
   const handleClose = () => {
     setIsOpen(false);
     setIsOpen(false);
-    setType("");
-    setStatus("");
-    setProgress("");
+    setTask("handbrake");
+    setStatus("pending");
+    setProgress(0);
+    setDataset("");
+    setInput("");
+    setOutput("");
+    setPreset("Fast 1080p30");
+    setPriority(0);
     if (onEditClose) onEditClose();
     if (onEditClose) onEditClose();
     if (onAddClose) onAddClose();
     if (onAddClose) onAddClose();
   };
   };
 
 
   const handleAddClick = () => {
   const handleAddClick = () => {
-    setType("");
-    setStatus("");
-    setProgress("");
+    setTask("handbrake");
+    setStatus("pending");
+    setProgress(0);
+    setDataset("");
+    setInput("");
+    setOutput("");
+    setPreset("Fast 1080p30");
+    setPriority(0);
     setIsOpen(true);
     setIsOpen(true);
   };
   };
 
 
@@ -157,13 +211,13 @@ export default function TaskCrud({
         <form onSubmit={handleSubmit} className="space-y-4">
         <form onSubmit={handleSubmit} className="space-y-4">
           <div>
           <div>
             <label className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
             <label className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
-              Type
+              Task Type
             </label>
             </label>
             <input
             <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"
               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="Type"
-              value={type}
-              onChange={(e) => setType(e.target.value)}
+              placeholder="Task Type"
+              value={task}
+              onChange={(e) => setTask(e.target.value)}
               required
               required
             />
             />
           </div>
           </div>
@@ -171,22 +225,86 @@ export default function TaskCrud({
             <label className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
             <label className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
               Status
               Status
             </label>
             </label>
-            <input
+            <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"
               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="Status"
               value={status}
               value={status}
               onChange={(e) => setStatus(e.target.value)}
               onChange={(e) => setStatus(e.target.value)}
-            />
+            >
+              <option value="pending">Pending</option>
+              <option value="processing">Processing</option>
+              <option value="completed">Completed</option>
+              <option value="failed">Failed</option>
+              <option value="skipped">Skipped</option>
+            </select>
           </div>
           </div>
           <div>
           <div>
             <label className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
             <label className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
               Progress
               Progress
             </label>
             </label>
             <input
             <input
+              type="number"
+              min="0"
+              max="100"
               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"
               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="Progress"
               placeholder="Progress"
               value={progress}
               value={progress}
-              onChange={(e) => setProgress(e.target.value)}
+              onChange={(e) => setProgress(parseInt(e.target.value) || 0)}
+            />
+          </div>
+          <div>
+            <label className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
+              Dataset
+            </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="Dataset"
+              value={dataset}
+              onChange={(e) => setDataset(e.target.value)}
+            />
+          </div>
+          <div>
+            <label className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
+              Input File
+            </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="Input File Path"
+              value={input}
+              onChange={(e) => setInput(e.target.value)}
+            />
+          </div>
+          <div>
+            <label className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
+              Output File
+            </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="Output File Path"
+              value={output}
+              onChange={(e) => setOutput(e.target.value)}
+            />
+          </div>
+          <div>
+            <label className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
+              Preset
+            </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="HandBrake Preset"
+              value={preset}
+              onChange={(e) => setPreset(e.target.value)}
+            />
+          </div>
+          <div>
+            <label className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
+              Priority
+            </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="Priority"
+              value={priority}
+              onChange={(e) => setPriority(parseInt(e.target.value) || 0)}
             />
             />
           </div>
           </div>
         </form>
         </form>
@@ -221,13 +339,13 @@ export default function TaskCrud({
       <form onSubmit={handleSubmit} className="space-y-4">
       <form onSubmit={handleSubmit} className="space-y-4">
         <div>
         <div>
           <label className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
           <label className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
-            Type
+            Task Type
           </label>
           </label>
           <input
           <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"
             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="Type"
-            value={type}
-            onChange={(e) => setType(e.target.value)}
+            placeholder="Task Type"
+            value={task}
+            onChange={(e) => setTask(e.target.value)}
             required
             required
           />
           />
         </div>
         </div>
@@ -235,22 +353,86 @@ export default function TaskCrud({
           <label className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
           <label className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
             Status
             Status
           </label>
           </label>
-          <input
+          <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"
             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="Status"
             value={status}
             value={status}
             onChange={(e) => setStatus(e.target.value)}
             onChange={(e) => setStatus(e.target.value)}
-          />
+          >
+            <option value="pending">Pending</option>
+            <option value="processing">Processing</option>
+            <option value="completed">Completed</option>
+            <option value="failed">Failed</option>
+            <option value="skipped">Skipped</option>
+          </select>
         </div>
         </div>
         <div>
         <div>
           <label className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
           <label className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
             Progress
             Progress
           </label>
           </label>
           <input
           <input
+            type="number"
+            min="0"
+            max="100"
             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"
             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="Progress"
             placeholder="Progress"
             value={progress}
             value={progress}
-            onChange={(e) => setProgress(e.target.value)}
+            onChange={(e) => setProgress(parseInt(e.target.value) || 0)}
+          />
+        </div>
+        <div>
+          <label className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
+            Dataset
+          </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="Dataset"
+            value={dataset}
+            onChange={(e) => setDataset(e.target.value)}
+          />
+        </div>
+        <div>
+          <label className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
+            Input File
+          </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="Input File Path"
+            value={input}
+            onChange={(e) => setInput(e.target.value)}
+          />
+        </div>
+        <div>
+          <label className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
+            Output File
+          </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="Output File Path"
+            value={output}
+            onChange={(e) => setOutput(e.target.value)}
+          />
+        </div>
+        <div>
+          <label className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
+            Preset
+          </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="HandBrake Preset"
+            value={preset}
+            onChange={(e) => setPreset(e.target.value)}
+          />
+        </div>
+        <div>
+          <label className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
+            Priority
+          </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="Priority"
+            value={priority}
+            onChange={(e) => setPriority(parseInt(e.target.value) || 0)}
           />
           />
         </div>
         </div>
       </form>
       </form>

+ 452 - 0
apps/web/src/app/components/TaskMaintenance.tsx

@@ -0,0 +1,452 @@
+"use client";
+import {
+  ChevronDownIcon,
+  WrenchScrewdriverIcon
+} 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 TaskMaintenance() {
+  const queryClient = useQueryClient();
+  const { addNotification } = useNotifications();
+  const [isOpen, setIsOpen] = useState(false);
+  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"] });
+      setIsOpen(false);
+      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"] });
+      setIsOpen(false);
+      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"] });
+      setIsOpen(false);
+      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"] });
+      setIsOpen(false);
+      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 = () => {
+    setOperation(null);
+    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 = () => {
+    setIsOpen(false);
+    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-gray-700 dark:text-gray-300">
+                    {status.count}
+                  </span>
+                </div>
+              ))}
+            </div>
+          ) : (
+            <div className="text-center py-4">
+              <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600 mx-auto"></div>
+              <p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
+                Loading task statistics...
+              </p>
+            </div>
+          )}
+        </div>
+      );
+    } else if (operation === "purge") {
+      return (
+        <div className="space-y-4">
+          <div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
+            <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: This action cannot be undone
+                </h3>
+                <div className="mt-2 text-sm text-red-700 dark:text-red-300">
+                  <p>
+                    This will permanently delete ALL tasks from the database,
+                    including pending, processing, completed, failed, and
+                    skipped tasks. This action cannot be reversed.
+                  </p>
+                </div>
+              </div>
+            </div>
+          </div>
+          <div className="text-sm text-gray-600 dark:text-gray-400">
+            <p>
+              Are you sure you want to purge all tasks? Type "PURGE" below to
+              confirm:
+            </p>
+            <input
+              type="text"
+              className="mt-2 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 PURGE to confirm"
+              onChange={(e) => {
+                // We could add confirmation logic here if needed
+              }}
+            />
+          </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">
+        <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>
+        {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">
+              <button
+                onClick={() => {
+                  setOperation("stats");
+                  setIsOpen(false);
+                }}
+                className="block w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
+              >
+                View Statistics
+              </button>
+              <button
+                onClick={() => {
+                  setOperation("cleanup");
+                  setIsOpen(false);
+                }}
+                className="block w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
+              >
+                Cleanup Tasks
+              </button>
+              <button
+                onClick={() => {
+                  setOperation("archive");
+                  setIsOpen(false);
+                }}
+                className="block w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
+              >
+                Archive Old Tasks
+              </button>
+              <button
+                onClick={() => {
+                  setOperation("purge");
+                  setIsOpen(false);
+                }}
+                className="block w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
+              >
+                Purge All Tasks
+              </button>
+              <button
+                onClick={() => {
+                  setOperation("scheduled");
+                  setIsOpen(false);
+                }}
+                className="block w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
+              >
+                Run Scheduled Cleanup
+              </button>
+            </div>
+          </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>
+    </>
+  );
+}

+ 129 - 0
apps/web/src/app/components/TaskProcessingControls.tsx

@@ -0,0 +1,129 @@
+"use client";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useEffect } from "react";
+import toast from "react-hot-toast";
+import { get, post } from "../../lib/api";
+import { useNotifications } from "./NotificationContext";
+
+export default function TaskProcessingControls() {
+  const queryClient = useQueryClient();
+  const { addNotification } = useNotifications();
+  const { data } = useQuery({
+    queryKey: ["tasks", "processing-status"],
+    queryFn: () => get("/tasks/processing-status")
+  });
+
+  const startMutation = useMutation({
+    mutationFn: () => post("/tasks/start-processing"),
+    onSuccess: () => {
+      queryClient.invalidateQueries({
+        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."
+      });
+    }
+  });
+
+  const stopMutation = useMutation({
+    mutationFn: () => post("/tasks/stop-processing"),
+    onSuccess: () => {
+      queryClient.invalidateQueries({
+        queryKey: ["tasks", "processing-status"]
+      });
+      queryClient.invalidateQueries({ queryKey: ["tasks", "queue", "status"] });
+      toast.success("Task processing stopped successfully");
+      addNotification({
+        type: "success",
+        title: "Task Processing Stopped",
+        message: "The task processing queue has been stopped successfully."
+      });
+    }
+  });
+
+  // Listen for WebSocket events
+  useEffect(() => {
+    const handleTaskUpdate = (event: CustomEvent) => {
+      const updateData = event.detail;
+      if (
+        updateData.type === "started" ||
+        updateData.type === "stopped" ||
+        updateData.type === "progress"
+      ) {
+        // Invalidate and refetch the task processing status
+        queryClient.invalidateQueries({
+          queryKey: ["tasks", "processing-status"]
+        });
+        queryClient.invalidateQueries({
+          queryKey: ["tasks", "queue", "status"]
+        });
+      }
+    };
+
+    window.addEventListener("taskUpdate", handleTaskUpdate as EventListener);
+
+    return () => {
+      window.removeEventListener(
+        "taskUpdate",
+        handleTaskUpdate as EventListener
+      );
+    };
+  }, [queryClient]);
+
+  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"
+        >
+          <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="Stop task processing"
+      >
+        <svg
+          className="h-4 w-4"
+          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>
+        {stopMutation.isPending ? "Stopping..." : "Stop"}
+      </button>
+    </div>
+  );
+}

+ 211 - 0
apps/web/src/app/components/TaskProcessingStatus.tsx

@@ -0,0 +1,211 @@
+"use client";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useEffect } from "react";
+import toast from "react-hot-toast";
+import { get, post } from "../../lib/api";
+import LoadingCard from "./Loading";
+import { useNotifications } from "./NotificationContext";
+
+export default function TaskProcessingStatus() {
+  const queryClient = useQueryClient();
+  const { addNotification } = useNotifications();
+  const { data, isLoading, error } = useQuery({
+    queryKey: ["tasks", "processing-status"],
+    queryFn: () => get("/tasks/processing-status")
+  });
+
+  const { data: queueStatus } = useQuery({
+    queryKey: ["tasks", "queue", "status"],
+    queryFn: () => get("/tasks/queue/status")
+  });
+
+  const startMutation = useMutation({
+    mutationFn: () => post("/tasks/start-processing"),
+    onSuccess: () => {
+      queryClient.invalidateQueries({
+        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."
+      });
+    }
+  });
+
+  const stopMutation = useMutation({
+    mutationFn: () => post("/tasks/stop-processing"),
+    onSuccess: () => {
+      queryClient.invalidateQueries({
+        queryKey: ["tasks", "processing-status"]
+      });
+      queryClient.invalidateQueries({ queryKey: ["tasks", "queue", "status"] });
+      toast.success("Task processing stopped successfully");
+      addNotification({
+        type: "success",
+        title: "Task Processing Stopped",
+        message: "The task processing queue has been stopped successfully."
+      });
+    }
+  });
+
+  // Listen for WebSocket events
+  useEffect(() => {
+    const handleTaskUpdate = (event: CustomEvent) => {
+      const updateData = event.detail;
+      if (
+        updateData.type === "started" ||
+        updateData.type === "stopped" ||
+        updateData.type === "progress"
+      ) {
+        // Invalidate and refetch the task processing status
+        queryClient.invalidateQueries({
+          queryKey: ["tasks", "processing-status"]
+        });
+        queryClient.invalidateQueries({
+          queryKey: ["tasks", "queue", "status"]
+        });
+      }
+    };
+
+    window.addEventListener("taskUpdate", handleTaskUpdate as EventListener);
+
+    return () => {
+      window.removeEventListener(
+        "taskUpdate",
+        handleTaskUpdate as EventListener
+      );
+    };
+  }, [queryClient]);
+
+  if (isLoading)
+    return <LoadingCard message="Loading task processing status..." />;
+  if (error) {
+    return (
+      <div className="mb-6 p-4 border rounded bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800">
+        <div className="text-center">
+          <div className="mb-4">
+            <svg
+              className="mx-auto h-12 w-12 text-red-400"
+              fill="none"
+              viewBox="0 0 24 24"
+              stroke="currentColor"
+            >
+              <path
+                strokeLinecap="round"
+                strokeLinejoin="round"
+                strokeWidth={2}
+                d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
+              />
+            </svg>
+          </div>
+          <h3 className="font-semibold text-red-800 dark:text-red-200 mb-2">
+            Failed to load task processing status
+          </h3>
+          <p className="text-sm text-red-600 dark:text-red-400 mb-4">
+            Unable to connect to the task processing service.
+          </p>
+          {error && (
+            <p className="text-xs text-red-500 dark:text-red-300 mb-4">
+              Error: {error.message}
+            </p>
+          )}
+          <button
+            onClick={() =>
+              queryClient.invalidateQueries({
+                queryKey: ["tasks", "processing-status"]
+              })
+            }
+            className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-red-700 dark:text-red-300 bg-red-100 dark:bg-red-900 hover:bg-red-200 dark:hover:bg-red-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
+          >
+            Retry
+          </button>
+        </div>
+      </div>
+    );
+  }
+
+  return (
+    <div className="mb-6 p-4 border rounded bg-gray-50 dark:bg-gray-800">
+      <div className="flex items-center justify-between mb-4">
+        <h3 className="font-semibold">Task Processing</h3>
+        <div className="flex gap-2">
+          <button
+            className="px-3 py-1 rounded bg-green-600 text-white disabled:opacity-50 text-sm"
+            onClick={() => startMutation.mutate()}
+            disabled={data?.isProcessing || startMutation.isPending}
+          >
+            {startMutation.isPending ? "Starting..." : "Start"}
+          </button>
+          <button
+            className="px-3 py-1 rounded bg-red-600 text-white disabled:opacity-50 text-sm"
+            onClick={() => stopMutation.mutate()}
+            disabled={!data?.isProcessing || stopMutation.isPending}
+          >
+            {stopMutation.isPending ? "Stopping..." : "Stop"}
+          </button>
+        </div>
+      </div>
+
+      <div className="grid grid-cols-2 gap-4 mb-4 text-sm">
+        <div>
+          <span className="font-medium text-gray-700 dark:text-gray-300">
+            Status:
+          </span>
+          <span
+            className={`ml-2 ${data?.isProcessing ? "text-green-600" : "text-red-600"}`}
+          >
+            {data?.isProcessing ? "Processing" : "Idle"}
+          </span>
+        </div>
+        <div>
+          <span className="font-medium text-gray-700 dark:text-gray-300">
+            Active Tasks:
+          </span>
+          <span className="ml-2 text-gray-900 dark:text-gray-100">
+            {queueStatus?.activeTasks || 0}
+          </span>
+        </div>
+      </div>
+
+      {queueStatus && (
+        <div className="grid grid-cols-4 gap-4 text-sm">
+          <div>
+            <span className="font-medium text-gray-700 dark:text-gray-300">
+              Pending:
+            </span>
+            <span className="ml-2 text-gray-900 dark:text-gray-100">
+              {queueStatus.pending || 0}
+            </span>
+          </div>
+          <div>
+            <span className="font-medium text-gray-700 dark:text-gray-300">
+              Processing:
+            </span>
+            <span className="ml-2 text-gray-900 dark:text-gray-100">
+              {queueStatus.processing || 0}
+            </span>
+          </div>
+          <div>
+            <span className="font-medium text-gray-700 dark:text-gray-300">
+              Completed:
+            </span>
+            <span className="ml-2 text-gray-900 dark:text-gray-100">
+              {queueStatus.completed || 0}
+            </span>
+          </div>
+          <div>
+            <span className="font-medium text-gray-700 dark:text-gray-300">
+              Failed:
+            </span>
+            <span className="ml-2 text-gray-900 dark:text-gray-100">
+              {queueStatus.failed || 0}
+            </span>
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}

+ 129 - 0
apps/web/src/app/components/WatcherControls.tsx

@@ -0,0 +1,129 @@
+"use client";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useEffect } from "react";
+import toast from "react-hot-toast";
+import { get, post } from "../../lib/api";
+import { useNotifications } from "./NotificationContext";
+
+export default function WatcherControls() {
+  const queryClient = useQueryClient();
+  const { addNotification } = useNotifications();
+  const { data } = useQuery({
+    queryKey: ["watcher", "status"],
+    queryFn: () => get("/watcher/status")
+  });
+
+  const startMutation = useMutation({
+    mutationFn: () => post("/watcher/start"),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["watcher", "status"] });
+      toast.success("File watcher started successfully");
+      addNotification({
+        type: "success",
+        title: "File Watcher Started",
+        message: "The file watcher has been started successfully."
+      });
+    },
+    onError: () => {
+      toast.error("Failed to start file watcher");
+    }
+  });
+
+  const stopMutation = useMutation({
+    mutationFn: () => post("/watcher/stop"),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["watcher", "status"] });
+      toast.success("File watcher stopped successfully");
+      addNotification({
+        type: "success",
+        title: "File Watcher Stopped",
+        message: "The file watcher has been stopped successfully."
+      });
+    },
+    onError: () => {
+      toast.error("Failed to stop file watcher");
+    }
+  });
+
+  // Listen for WebSocket events
+  useEffect(() => {
+    const handleWatcherUpdate = (event: CustomEvent) => {
+      const updateData = event.detail;
+      if (updateData.type === "started" || updateData.type === "stopped") {
+        // Invalidate and refetch the watcher status
+        queryClient.invalidateQueries({ queryKey: ["watcher", "status"] });
+      }
+    };
+
+    window.addEventListener(
+      "watcherUpdate",
+      handleWatcherUpdate as EventListener
+    );
+
+    return () => {
+      window.removeEventListener(
+        "watcherUpdate",
+        handleWatcherUpdate as EventListener
+      );
+    };
+  }, [queryClient]);
+
+  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"
+        >
+          <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"
+        >
+          <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>
+    </div>
+  );
+}

+ 7 - 3
apps/web/src/app/page.tsx

@@ -1,4 +1,3 @@
-import ClientHomeWidgets from "./components/ClientHomeWidgets";
 import StatsSection from "./components/StatsSection";
 import StatsSection from "./components/StatsSection";
 
 
 export default function Page() {
 export default function Page() {
@@ -52,6 +51,12 @@ export default function Page() {
               >
               >
                 View Files
                 View Files
               </a>
               </a>
+              <a
+                href="/tasks"
+                className="rounded-md bg-white/10 px-6 py-3 text-sm font-semibold text-white shadow-sm hover:bg-white/20 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white/20 transition-colors duration-200"
+              >
+                View Tasks
+              </a>
               <a
               <a
                 href="/settings"
                 href="/settings"
                 className="text-sm font-semibold leading-6 text-gray-300 hover:text-white transition-colors duration-200"
                 className="text-sm font-semibold leading-6 text-gray-300 hover:text-white transition-colors duration-200"
@@ -70,8 +75,7 @@ export default function Page() {
 
 
       {/* Main Content */}
       {/* Main Content */}
       <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
       <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
-        {/* Custom Home Widgets */}
-        <ClientHomeWidgets />
+        {/* No additional content needed - everything is in the hero section */}
       </div>
       </div>
     </>
     </>
   );
   );

+ 5 - 1
apps/web/src/app/settings/page.tsx

@@ -1,5 +1,6 @@
 import SettingsCrud from "../components/SettingsCrud";
 import SettingsCrud from "../components/SettingsCrud";
 import SettingsList from "../components/SettingsList";
 import SettingsList from "../components/SettingsList";
+import WatcherControls from "../components/WatcherControls";
 
 
 export default function SettingsPage() {
 export default function SettingsPage() {
   return (
   return (
@@ -39,7 +40,10 @@ export default function SettingsPage() {
             </p>
             </p>
           </div>
           </div>
         </div>
         </div>
-        <SettingsCrud />
+        <div className="flex items-center gap-3">
+          <WatcherControls />
+          <SettingsCrud />
+        </div>
       </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">
       <div className="bg-white dark:bg-gray-900 rounded-xl shadow-sm ring-1 ring-gray-200 dark:ring-gray-800 overflow-hidden">
         <SettingsList />
         <SettingsList />

+ 7 - 1
apps/web/src/app/tasks/page.tsx

@@ -1,5 +1,7 @@
 import TaskCrud from "../components/TaskCrud";
 import TaskCrud from "../components/TaskCrud";
 import TaskList from "../components/TaskList";
 import TaskList from "../components/TaskList";
+import TaskMaintenance from "../components/TaskMaintenance";
+import TaskProcessingControls from "../components/TaskProcessingControls";
 
 
 export default function TasksPage() {
 export default function TasksPage() {
   return (
   return (
@@ -32,7 +34,11 @@ export default function TasksPage() {
             </p>
             </p>
           </div>
           </div>
         </div>
         </div>
-        <TaskCrud />
+        <div className="flex items-center gap-3">
+          <TaskProcessingControls />
+          <TaskMaintenance />
+          <TaskCrud />
+        </div>
       </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">
       <div className="bg-white dark:bg-gray-900 rounded-xl shadow-sm ring-1 ring-gray-200 dark:ring-gray-800 overflow-hidden">
         <TaskList context="tasks" />
         <TaskList context="tasks" />

+ 1 - 2
apps/web/src/lib/api.ts

@@ -1,5 +1,4 @@
-export const API_BASE =
-  process.env.NEXT_PUBLIC_WATCH_FINISHED_API || "http://localhost:3001";
+export const API_BASE = process.env.NEXT_PUBLIC_WATCH_FINISHED_API || "/api";
 
 
 function buildUrl(path: string, params?: any) {
 function buildUrl(path: string, params?: any) {
   const url = new URL(path, API_BASE);
   const url = new URL(path, API_BASE);

+ 33 - 2
apps/web/test-results/.last-run.json

@@ -1,4 +1,35 @@
 {
 {
-  "status": "passed",
-  "failedTests": []
+  "status": "failed",
+  "failedTests": [
+    "1ea1b1599cbc08f16936-7e9dad27cb31a4238e03",
+    "1ea1b1599cbc08f16936-766d41d6aaa6e161aa8a",
+    "1ea1b1599cbc08f16936-94c88542a72aa516506a",
+    "1ea1b1599cbc08f16936-0b5bae55070ac4603459",
+    "1ea1b1599cbc08f16936-57f634beec175133011d",
+    "1ea1b1599cbc08f16936-ddf62c66a8cb139b1735",
+    "1ea1b1599cbc08f16936-a53ecc7dfde56aeff08b",
+    "1ea1b1599cbc08f16936-f734aa7b6d7523a008b5",
+    "1ea1b1599cbc08f16936-2cc4d4639a0120c71aef",
+    "1ea1b1599cbc08f16936-d368412776c9ba34ac9b",
+    "1ea1b1599cbc08f16936-28d326f3174e1f4b9464",
+    "1ea1b1599cbc08f16936-618ef07e12bed483cd65",
+    "1ea1b1599cbc08f16936-edee4208bf7d97922a32",
+    "1ea1b1599cbc08f16936-88adf3fc65830852c426",
+    "1ea1b1599cbc08f16936-b2abec1cd1a2249f950b",
+    "1ea1b1599cbc08f16936-500f987bd47e080e069b",
+    "1ea1b1599cbc08f16936-246f21aeaf22929bb8e5",
+    "1ea1b1599cbc08f16936-774c4d83f49256babebc",
+    "1ea1b1599cbc08f16936-175d5c874e8e9c1620b6",
+    "1ea1b1599cbc08f16936-889928ae842c95359d16",
+    "1ea1b1599cbc08f16936-d8e52b7ab80e09ae53c1",
+    "1ea1b1599cbc08f16936-c6836d51eb7e3e4431c9",
+    "1ea1b1599cbc08f16936-d260ae5033e5516eb9f2",
+    "1ea1b1599cbc08f16936-b589b3a4581ecf2cc143",
+    "1ea1b1599cbc08f16936-bf8825c1ca834de24bb6",
+    "1ea1b1599cbc08f16936-7b73f770dd2bbca72a31",
+    "1ea1b1599cbc08f16936-e65bcbc31baf049ad2f9",
+    "1ea1b1599cbc08f16936-da3a0d3f9ee7050c7111",
+    "1ea1b1599cbc08f16936-70f346812991844def0b",
+    "1ea1b1599cbc08f16936-0f580f5812f5881777c1"
+  ]
 }
 }

BIN
data/database.db


+ 35 - 0
pnpm-lock.yaml

@@ -104,6 +104,9 @@ importers:
       '@nestjs/platform-socket.io':
       '@nestjs/platform-socket.io':
         specifier: ^11.1.11
         specifier: ^11.1.11
         version: 11.1.11(@nestjs/common@11.1.11(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.11)(rxjs@7.8.2)
         version: 11.1.11(@nestjs/common@11.1.11(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.11)(rxjs@7.8.2)
+      '@nestjs/schedule':
+        specifier: ^6.1.0
+        version: 6.1.0(@nestjs/common@11.1.11(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.11(@nestjs/common@11.1.11(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.11)(@nestjs/websockets@11.1.11)(reflect-metadata@0.2.2)(rxjs@7.8.2))
       '@nestjs/websockets':
       '@nestjs/websockets':
         specifier: ^11.1.11
         specifier: ^11.1.11
         version: 11.1.11(@nestjs/common@11.1.11(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.11)(@nestjs/platform-socket.io@11.1.11)(reflect-metadata@0.2.2)(rxjs@7.8.2)
         version: 11.1.11(@nestjs/common@11.1.11(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.11)(@nestjs/platform-socket.io@11.1.11)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -1312,6 +1315,12 @@ packages:
       '@nestjs/websockets': ^11.0.0
       '@nestjs/websockets': ^11.0.0
       rxjs: ^7.1.0
       rxjs: ^7.1.0
 
 
+  '@nestjs/schedule@6.1.0':
+    resolution: {integrity: sha512-W25Ydc933Gzb1/oo7+bWzzDiOissE+h/dhIAPugA39b9MuIzBbLybuXpc1AjoQLczO3v0ldmxaffVl87W0uqoQ==}
+    peerDependencies:
+      '@nestjs/common': ^10.0.0 || ^11.0.0
+      '@nestjs/core': ^10.0.0 || ^11.0.0
+
   '@nestjs/schematics@11.0.9':
   '@nestjs/schematics@11.0.9':
     resolution: {integrity: sha512-0NfPbPlEaGwIT8/TCThxLzrlz3yzDNkfRNpbL7FiplKq3w4qXpJg0JYwqgMEJnLQZm3L/L/5XjoyfJHUO3qX9g==}
     resolution: {integrity: sha512-0NfPbPlEaGwIT8/TCThxLzrlz3yzDNkfRNpbL7FiplKq3w4qXpJg0JYwqgMEJnLQZm3L/L/5XjoyfJHUO3qX9g==}
     peerDependencies:
     peerDependencies:
@@ -1797,6 +1806,9 @@ packages:
   '@types/json5@0.0.29':
   '@types/json5@0.0.29':
     resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
     resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
 
 
+  '@types/luxon@3.7.1':
+    resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==}
+
   '@types/methods@1.1.4':
   '@types/methods@1.1.4':
     resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==}
     resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==}
 
 
@@ -2538,6 +2550,10 @@ packages:
   create-require@1.1.1:
   create-require@1.1.1:
     resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
     resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
 
 
+  cron@4.3.5:
+    resolution: {integrity: sha512-hKPP7fq1+OfyCqoePkKfVq7tNAdFwiQORr4lZUHwrf0tebC65fYEeWgOrXOL6prn1/fegGOdTfrM6e34PJfksg==}
+    engines: {node: '>=18.x'}
+
   cross-spawn@7.0.6:
   cross-spawn@7.0.6:
     resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
     resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
     engines: {node: '>= 8'}
     engines: {node: '>= 8'}
@@ -3853,6 +3869,10 @@ packages:
   lru-cache@5.1.1:
   lru-cache@5.1.1:
     resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
     resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
 
 
+  luxon@3.7.2:
+    resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
+    engines: {node: '>=12'}
+
   lz-string@1.5.0:
   lz-string@1.5.0:
     resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
     resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
     hasBin: true
     hasBin: true
@@ -6379,6 +6399,12 @@ snapshots:
       - supports-color
       - supports-color
       - utf-8-validate
       - utf-8-validate
 
 
+  '@nestjs/schedule@6.1.0(@nestjs/common@11.1.11(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.11(@nestjs/common@11.1.11(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.11)(@nestjs/websockets@11.1.11)(reflect-metadata@0.2.2)(rxjs@7.8.2))':
+    dependencies:
+      '@nestjs/common': 11.1.11(reflect-metadata@0.2.2)(rxjs@7.8.2)
+      '@nestjs/core': 11.1.11(@nestjs/common@11.1.11(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.11)(@nestjs/websockets@11.1.11)(reflect-metadata@0.2.2)(rxjs@7.8.2)
+      cron: 4.3.5
+
   '@nestjs/schematics@11.0.9(chokidar@4.0.3)(typescript@5.9.3)':
   '@nestjs/schematics@11.0.9(chokidar@4.0.3)(typescript@5.9.3)':
     dependencies:
     dependencies:
       '@angular-devkit/core': 19.2.17(chokidar@4.0.3)
       '@angular-devkit/core': 19.2.17(chokidar@4.0.3)
@@ -6815,6 +6841,8 @@ snapshots:
 
 
   '@types/json5@0.0.29': {}
   '@types/json5@0.0.29': {}
 
 
+  '@types/luxon@3.7.1': {}
+
   '@types/methods@1.1.4': {}
   '@types/methods@1.1.4': {}
 
 
   '@types/node@20.19.27':
   '@types/node@20.19.27':
@@ -7662,6 +7690,11 @@ snapshots:
 
 
   create-require@1.1.1: {}
   create-require@1.1.1: {}
 
 
+  cron@4.3.5:
+    dependencies:
+      '@types/luxon': 3.7.1
+      luxon: 3.7.2
+
   cross-spawn@7.0.6:
   cross-spawn@7.0.6:
     dependencies:
     dependencies:
       path-key: 3.1.1
       path-key: 3.1.1
@@ -9520,6 +9553,8 @@ snapshots:
     dependencies:
     dependencies:
       yallist: 3.1.1
       yallist: 3.1.1
 
 
+  luxon@3.7.2: {}
+
   lz-string@1.5.0: {}
   lz-string@1.5.0: {}
 
 
   magic-string@0.30.17:
   magic-string@0.30.17:

Some files were not shown because too many files changed in this diff