فهرست منبع

Merge branch 'main' of http://git.timandjenni.local/pomeroyt/watch-finished-turbo

Timothy Pomeroy 3 هفته پیش
والد
کامیت
39058862c6

+ 19 - 0
apps/service/dev-restart.sh

@@ -0,0 +1,19 @@
+#!/bin/bash
+
+# Continuous restart wrapper for development
+while true; do
+  echo "[dev-restart] Starting NestJS application..."
+  nest start
+  EXIT_CODE=$?
+  
+  if [ $EXIT_CODE -eq 0 ]; then
+    echo "[dev-restart] Clean exit detected, restarting in 1 second..."
+    sleep 1
+  elif [ $EXIT_CODE -eq 1 ]; then
+    echo "[dev-restart] Restart requested, restarting in 1 second..."
+    sleep 1
+  else
+    echo "[dev-restart] Unexpected exit code $EXIT_CODE, restarting in 3 seconds..."
+    sleep 3
+  fi
+done

+ 8 - 0
apps/service/nodemon-prod.json

@@ -0,0 +1,8 @@
+{
+  "watch": [],
+  "exec": "node dist/main",
+  "signal": "SIGHUP",
+  "env": {
+    "NODE_ENV": "production"
+  }
+}

+ 11 - 0
apps/service/nodemon.json

@@ -0,0 +1,11 @@
+{
+  "watch": ["src"],
+  "ext": "ts",
+  "ignore": ["src/**/*.spec.ts"],
+  "exec": "nest start",
+  "delay": 500,
+  "signal": "SIGHUP",
+  "env": {
+    "NODE_ENV": "development"
+  }
+}

+ 3 - 2
apps/service/package.json

@@ -7,12 +7,12 @@
   "license": "UNLICENSED",
   "scripts": {
     "build": "nest build",
-    "dev": "nest start --watch",
+    "dev": "nodemon",
     "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
     "start": "node dist/main",
     "start:dev": "nest start --watch",
     "start:debug": "nest start --debug --watch",
-    "start:prod": "node dist/main",
+    "start:prod": "pm2-runtime dist/main.js --name service",
     "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
     "test": "jest",
     "test:watch": "jest --watch",
@@ -47,6 +47,7 @@
     "eslint-plugin-prettier": "^5.2.2",
     "globals": "^16.0.0",
     "jest": "^30.0.0",
+    "pm2": "^5.4.3",
     "prettier": "^3.4.2",
     "socket.io-client": "^4.8.3",
     "source-map-support": "^0.5.21",

+ 48 - 0
apps/service/src/app.controller.ts

@@ -3,6 +3,7 @@ import {
   Controller,
   Delete,
   Get,
+  INestApplication,
   Param,
   Post,
   Put,
@@ -22,11 +23,21 @@ interface FileRecord {
 
 @Controller()
 export class AppController {
+  private app: INestApplication | null = null;
+
   constructor(
     private readonly appService: AppService,
     private readonly eventsGateway: EventsGateway,
   ) {}
 
+  /**
+   * Set the NestJS application instance for graceful shutdown
+   * This is called from main.ts after app creation
+   */
+  setApp(app: INestApplication) {
+    this.app = app;
+  }
+
   // List available datasets
   @Get('files')
   listDatasets() {
@@ -71,6 +82,43 @@ export class AppController {
     return { status: 'healthy', datetime: new Date().toISOString() };
   }
 
+  @Post('restart')
+  async restart() {
+    if (!this.app) {
+      return {
+        success: false,
+        message: 'Application not properly initialized',
+      };
+    }
+
+    // Schedule restart for next tick to allow response to complete
+    setImmediate(async () => {
+      try {
+        await this.app!.close();
+        if (process.env.NODE_ENV === 'production') {
+          // In prod with pm2, exit to trigger restart
+          process.exit(0);
+        } else {
+          // In dev with nodemon, send SIGHUP to parent
+          process.kill(process.ppid, 'SIGHUP');
+        }
+      } catch (error) {
+        console.error('Error during restart:', error);
+        if (process.env.NODE_ENV === 'production') {
+          process.exit(0);
+        } else {
+          process.kill(process.ppid, 'SIGHUP');
+        }
+      }
+    });
+
+    return {
+      success: true,
+      message: 'API service restarting...',
+      datetime: new Date().toISOString(),
+    };
+  }
+
   // --- Unified files CRUD endpoints below ---
 
   // Create a file record

+ 5 - 0
apps/service/src/main.ts

@@ -1,4 +1,5 @@
 import { NestFactory } from '@nestjs/core';
+import { AppController } from './app.controller';
 import { AppModule } from './app.module';
 
 async function bootstrap() {
@@ -7,6 +8,10 @@ async function bootstrap() {
   // Enable CORS for WebSocket connections
   app.enableCors();
 
+  // Set app instance on controller for restart functionality
+  const appController = app.get(AppController);
+  appController.setApp(app);
+
   const port = process.env.PORT ? Number(process.env.PORT) : 3001;
   console.log(`Starting API service with WebSocket support on port ${port}`);
   await app.listen(port);

+ 11 - 2
apps/service/src/watcher.service.ts

@@ -517,8 +517,17 @@ export class WatcherService implements OnModuleDestroy {
   }
 
   async stop() {
-    if (this.watcher && this.isWatching) {
-      await this.watcher.close();
+    // If status shows we're watching, force stop regardless of watcher object state
+    if (this.isWatching) {
+      if (this.watcher) {
+        try {
+          await this.watcher.close();
+        } catch (error) {
+          this.logger.warn(`Error closing watcher: ${error.message}`);
+        }
+      }
+
+      this.watcher = null;
       this.isWatching = false;
       this.eventsGateway.emitWatcherUpdate({ type: 'stopped' });
 

+ 1 - 0
apps/web/package.json

@@ -6,6 +6,7 @@
     "dev": "next dev",
     "build": "next build",
     "start": "next start",
+    "start:prod": "next start",
     "lint": "eslint",
     "test": "jest",
     "test:watch": "jest --watch",

+ 120 - 67
apps/web/src/app/components/ApiHealth.tsx

@@ -1,10 +1,15 @@
 "use client";
-import { useQuery } from "@tanstack/react-query";
+import { ArrowPathIcon, ServerIcon } from "@heroicons/react/24/outline";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
 import { useState } from "react";
-import { get } from "../../lib/api";
+import toast from "react-hot-toast";
+import { get, post } from "../../lib/api";
 
 export default function ApiHealth() {
   const [responseTime, setResponseTime] = useState<number | null>(null);
+  const [showRestartConfirm, setShowRestartConfirm] = useState(false);
+  const [isRestarting, setIsRestarting] = useState(false);
+  const queryClient = useQueryClient();
 
   const { data, isLoading, error } = useQuery({
     queryKey: ["api", "health"],
@@ -15,81 +20,129 @@ export default function ApiHealth() {
       setResponseTime(Math.round(endTime - startTime));
       return result;
     },
-    refetchInterval: 30000 // Check every 30 seconds
+    refetchInterval: 30000, // Check every 30 seconds
+    retry: 3, // Retry failed requests 3 times
+    retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), // Exponential backoff
+    enabled: !isRestarting, // Pause health checks during restart
   });
 
+  const restartMutation = useMutation({
+    mutationFn: () => post("/restart", {}),
+    onMutate: () => {
+      // Set restarting state and pause all queries
+      setIsRestarting(true);
+      queryClient.cancelQueries(); // Cancel all in-flight queries
+    },
+    onSuccess: () => {
+      toast.success("API service restarting...");
+      setShowRestartConfirm(false);
+      // Wait for server to come back, then resume
+      attemptReconnect();
+    },
+    onError: (error: any) => {
+      // Expected to fail since server is restarting
+      toast.success("API service restart initiated...");
+      setShowRestartConfirm(false);
+      // Still try to reconnect
+      attemptReconnect();
+    },
+  });
+
+  const attemptReconnect = () => {
+    let attempts = 0;
+    const maxAttempts = 10;
+    const checkHealth = async () => {
+      attempts++;
+      try {
+        await get("/health");
+        // Success! Server is back
+        setIsRestarting(false);
+        toast.success("API service reconnected");
+        queryClient.invalidateQueries(); // Refresh all data
+      } catch (error) {
+        if (attempts < maxAttempts) {
+          // Try again with exponential backoff
+          setTimeout(
+            checkHealth,
+            Math.min(1000 * Math.pow(1.5, attempts), 10000)
+          );
+        } else {
+          setIsRestarting(false);
+          toast.error("Failed to reconnect to API service");
+          queryClient.invalidateQueries();
+        }
+      }
+    };
+    // Start checking after 2 seconds
+    setTimeout(checkHealth, 2000);
+  };
+
   const isHealthy = data?.status === "healthy";
-  const lastChecked = data?.datetime
-    ? new Date(data.datetime).toLocaleTimeString()
-    : null;
 
   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">API Health</h3>
+    <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 justify-between">
+        <div className="flex items-center gap-x-3">
+          <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-500/20 ring-1 ring-blue-500/30">
+            <ServerIcon className="h-6 w-6 text-blue-400" />
+          </div>
+          <div>
+            <div className="text-2xl font-bold text-white">
+              {isLoading || isRestarting ? (
+                <div className="flex justify-center">
+                  <div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
+                </div>
+              ) : (
+                <span
+                  className={
+                    isHealthy && !error ? "text-green-400" : "text-red-400"
+                  }
+                >
+                  {isHealthy && !error ? "Healthy" : "Issues"}
+                </span>
+              )}
+            </div>
+            <div className="text-sm font-medium text-gray-400">API Health</div>
+            {responseTime !== null && !isRestarting && (
+              <div className="text-xs text-gray-500 mt-1">
+                {responseTime}ms response
+              </div>
+            )}
+            {isRestarting && (
+              <div className="text-xs text-blue-400 mt-1">Restarting...</div>
+            )}
+          </div>
+        </div>
         <div className="flex items-center gap-2">
-          {isLoading ? (
-            <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-indigo-600 dark:border-indigo-400"></div>
-          ) : (
-            <span
-              className={`px-2 py-1 rounded text-xs font-medium ${
-                isHealthy && !error
-                  ? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
-                  : "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
-              }`}
+          {!showRestartConfirm ? (
+            <button
+              onClick={() => setShowRestartConfirm(true)}
+              disabled={isRestarting}
+              className="flex items-center gap-1.5 rounded-lg bg-blue-500/20 px-3 py-1.5 text-xs font-medium text-blue-400 ring-1 ring-blue-500/30 transition-all hover:bg-blue-500/30 hover:ring-blue-500/50 disabled:opacity-50 disabled:cursor-not-allowed"
+              title="Restart API service"
             >
-              {isHealthy && !error ? "Healthy" : "Unhealthy"}
-            </span>
+              <ArrowPathIcon className="h-4 w-4" />
+              Restart
+            </button>
+          ) : (
+            <div className="flex items-center gap-2">
+              <button
+                onClick={() => restartMutation.mutate()}
+                disabled={isRestarting}
+                className="rounded-lg bg-red-500/20 px-3 py-1.5 text-xs font-medium text-red-400 ring-1 ring-red-500/30 transition-all hover:bg-red-500/30 hover:ring-red-500/50 disabled:opacity-50"
+              >
+                Confirm
+              </button>
+              <button
+                onClick={() => setShowRestartConfirm(false)}
+                className="rounded-lg bg-gray-500/20 px-3 py-1.5 text-xs font-medium text-gray-400 ring-1 ring-gray-500/30 transition-all hover:bg-gray-500/30 hover:ring-gray-500/50"
+              >
+                Cancel
+              </button>
+            </div>
           )}
         </div>
       </div>
-
-      <div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm">
-        <div>
-          <span className="font-medium text-gray-700 dark:text-gray-300">
-            Status:
-          </span>
-          <span
-            className={`ml-2 ${isHealthy && !error ? "text-green-600" : "text-red-600"}`}
-          >
-            {isLoading
-              ? "Checking..."
-              : isHealthy && !error
-                ? "Operational"
-                : "Issues Detected"}
-          </span>
-        </div>
-        {responseTime !== null && (
-          <div>
-            <span className="font-medium text-gray-700 dark:text-gray-300">
-              Response Time:
-            </span>
-            <span className="ml-2 text-gray-900 dark:text-gray-100">
-              {responseTime}ms
-            </span>
-          </div>
-        )}
-        {lastChecked && (
-          <div>
-            <span className="font-medium text-gray-700 dark:text-gray-300">
-              Last Checked:
-            </span>
-            <span className="ml-2 text-gray-900 dark:text-gray-100">
-              {lastChecked}
-            </span>
-          </div>
-        )}
-        {error && (
-          <div className="md:col-span-2">
-            <span className="font-medium text-gray-700 dark:text-gray-300">
-              Error:
-            </span>
-            <span className="ml-2 text-red-600 text-xs">
-              {error.message || "Connection failed"}
-            </span>
-          </div>
-        )}
-      </div>
     </div>
   );
 }

+ 16 - 0
apps/web/src/app/components/Card.tsx

@@ -0,0 +1,16 @@
+import React from "react";
+
+interface CardProps {
+  children: React.ReactNode;
+  className?: string;
+}
+
+export default function Card({ children, className = "" }: CardProps) {
+  return (
+    <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 ${className}`}
+    >
+      {children}
+    </div>
+  );
+}

+ 159 - 0
apps/web/src/app/components/FileWatcherCard.tsx

@@ -0,0 +1,159 @@
+"use client";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import toast from "react-hot-toast";
+import { get, post } from "../../lib/api";
+import Card from "./Card";
+
+export default function FileWatcherCard() {
+  const queryClient = useQueryClient();
+
+  const {
+    data: watcherStatus,
+    isLoading: watcherLoading,
+    refetch: refetchWatcherStatus,
+  } = useQuery({
+    queryKey: ["watcher", "status"],
+    queryFn: () => get("/watcher/status"),
+    staleTime: 0, // Always consider data stale
+    gcTime: 0, // Don't cache
+  });
+
+  const { data: settings, isLoading: settingsLoading } = useQuery({
+    queryKey: ["settings", "datasets"],
+    queryFn: () => get("/config/settings/datasets"),
+  });
+
+  const startWatcherMutation = useMutation({
+    mutationFn: () => post("/watcher/start"),
+    onSuccess: async () => {
+      // Small delay to let backend update state
+      await new Promise((resolve) => setTimeout(resolve, 200));
+      await refetchWatcherStatus();
+      toast.success("File watcher started");
+    },
+    onError: () => {
+      toast.error("Failed to start file watcher");
+    },
+  });
+
+  const stopWatcherMutation = useMutation({
+    mutationFn: () => post("/watcher/stop"),
+    onSuccess: async () => {
+      // Small delay to let backend update state
+      await new Promise((resolve) => setTimeout(resolve, 200));
+      await refetchWatcherStatus();
+      toast.success("File watcher stopped");
+    },
+    onError: () => {
+      toast.error("Failed to stop file watcher");
+    },
+  });
+
+  const activeWatchers = settings
+    ? Object.values(settings).filter((dataset: any) => dataset.enabled === true)
+        .length
+    : 0;
+
+  const isWatcherActive = watcherStatus?.isWatching;
+
+  return (
+    <Card>
+      <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`}
+          >
+            {startWatcherMutation.isPending || stopWatcherMutation.isPending
+              ? "..."
+              : isWatcherActive
+                ? "Stop"
+                : "Start"}
+          </button>
+        </div>
+      </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>
+      )}
+    </Card>
+  );
+}

+ 110 - 0
apps/web/src/app/components/FilesProcessedCard.tsx

@@ -0,0 +1,110 @@
+"use client";
+import { useQuery } from "@tanstack/react-query";
+import { get } from "../../lib/api";
+import Card from "./Card";
+
+export default function FilesProcessedCard() {
+  const { data: filesSuccessful, isLoading: filesSuccessfulLoading } = useQuery(
+    {
+      queryKey: ["files-stats-successful"],
+      queryFn: () => get("/files/stats/successful"),
+    }
+  );
+
+  const { data: tasks } = useQuery({
+    queryKey: ["tasks"],
+    queryFn: () => get("/tasks"),
+  });
+
+  const filesProcessed = filesSuccessful || 0;
+
+  return (
+    <Card>
+      <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">
+          <svg
+            className="h-6 w-6 text-rose-400"
+            fill="none"
+            viewBox="0 0 24 24"
+            strokeWidth="1.5"
+            stroke="currentColor"
+          >
+            <path
+              strokeLinecap="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"
+            />
+          </svg>
+        </div>
+        <div className="flex-1">
+          <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>
+            ) : (
+              filesProcessed.toLocaleString()
+            )}
+          </div>
+          <div className="text-sm font-medium text-gray-400">
+            Files Processed
+          </div>
+          {/* Current Task Progress */}
+          {tasks && tasks.length > 0 && (
+            <div className="mt-3 pt-3 border-t border-white/10">
+              {(() => {
+                const processingTask = tasks.find(
+                  (t: any) => t.status === "processing"
+                );
+                if (!processingTask) return null;
+
+                const progress = processingTask.progress || 0;
+                const fileName = processingTask.input
+                  ? processingTask.input.split("/").pop()
+                  : "Unknown file";
+
+                return (
+                  <div>
+                    <div className="flex items-center gap-2 mb-2">
+                      <svg
+                        className="h-4 w-4 text-gray-400 flex-shrink-0"
+                        fill="none"
+                        viewBox="0 0 24 24"
+                        strokeWidth="1.5"
+                        stroke="currentColor"
+                      >
+                        <title>{fileName}</title>
+                        <path
+                          strokeLinecap="round"
+                          strokeLinejoin="round"
+                          d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
+                        />
+                      </svg>
+                      <span
+                        className="text-xs text-gray-400 truncate"
+                        title={fileName}
+                      >
+                        Processing...
+                      </span>
+                    </div>
+                    <div className="flex items-center gap-2">
+                      <div className="flex-1 bg-white/10 rounded-full h-2 overflow-hidden">
+                        <div
+                          className="h-full bg-gradient-to-r from-rose-400 to-rose-500 transition-all duration-300"
+                          style={{ width: `${progress}%` }}
+                        />
+                      </div>
+                      <div className="text-xs font-medium text-rose-400 w-8 text-right">
+                        {progress}%
+                      </div>
+                    </div>
+                  </div>
+                );
+              })()}
+            </div>
+          )}
+        </div>
+      </div>
+    </Card>
+  );
+}

+ 17 - 453
apps/web/src/app/components/StatsSection.tsx

@@ -1,129 +1,14 @@
 "use client";
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useQueryClient } from "@tanstack/react-query";
 import { useEffect } from "react";
-import toast from "react-hot-toast";
-import { get, post } from "../../lib/api";
+import ApiHealth from "./ApiHealth";
+import FileWatcherCard from "./FileWatcherCard";
+import TaskProcessingCard from "./TaskProcessingCard";
+import FilesProcessedCard from "./FilesProcessedCard";
 
 export default function StatsSection() {
   const queryClient = useQueryClient();
 
-  const { data: tasks, isLoading: _tasksLoading } = useQuery({
-    queryKey: ["tasks"],
-    queryFn: () => get("/tasks")
-  });
-
-  const { data: filesSuccessful, isLoading: filesSuccessfulLoading } = useQuery(
-    {
-      queryKey: ["files-stats-successful"],
-      queryFn: () => get("/files/stats/successful")
-    }
-  );
-
-  const { data: filesProcessedTotal, isLoading: filesProcessedLoading } =
-    useQuery({
-      queryKey: ["files-stats-processed"],
-      queryFn: () => get("/files/stats/processed")
-    });
-
-  const { data: _datasets, isLoading: _datasetsLoading } = useQuery({
-    queryKey: ["datasets"],
-    queryFn: () => get("/files")
-  });
-
-  const { data: settings, isLoading: settingsLoading } = useQuery({
-    queryKey: ["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 filesProcessed = filesSuccessful || 0;
-  const totalProcessed = filesProcessedTotal || 0;
-  const successRate =
-    totalProcessed > 0
-      ? Math.round((filesProcessed / totalProcessed) * 100)
-      : 0;
-  const activeWatchers = settings
-    ? Object.values(settings).filter((dataset: any) => dataset.enabled === true)
-        .length
-    : 0;
-
-  const isApiHealthy = apiHealth?.status === "healthy";
-  const isWatcherActive = watcherStatus?.isWatching;
-  const isTaskProcessingActive = taskProcessingStatus?.isProcessing;
-
   // Listen for WebSocket updates to refresh stats
   useEffect(() => {
     const handleTaskUpdate = (event: CustomEvent) => {
@@ -136,10 +21,10 @@ export default function StatsSection() {
       ) {
         queryClient.invalidateQueries({ queryKey: ["tasks"] });
         queryClient.invalidateQueries({
-          queryKey: ["tasks", "processing-status"]
+          queryKey: ["tasks", "processing-status"],
         });
         queryClient.invalidateQueries({
-          queryKey: ["tasks", "queue", "status"]
+          queryKey: ["tasks", "queue", "status"],
         });
       }
     };
@@ -169,340 +54,19 @@ export default function StatsSection() {
   }, [queryClient]);
 
   return (
-    <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="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`}
-            >
-              {startWatcherMutation.isPending || stopWatcherMutation.isPending
-                ? "..."
-                : isWatcherActive
-                  ? "Stop"
-                  : "Start"}
-            </button>
-          </div>
-        </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>
-
-      {/* 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="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`}
-            >
-              {startTaskProcessingMutation.isPending ||
-              stopTaskProcessingMutation.isPending
-                ? "..."
-                : isTaskProcessingActive
-                  ? "Stop"
-                  : "Start"}
-            </button>
-          </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>
-              <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>
-
-      {/* 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="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">
-            <svg
-              className="h-6 w-6 text-amber-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">
-              {apiHealthLoading ? (
-                <div className="flex justify-center">
-                  <div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
-                </div>
-              ) : (
-                <span
-                  className={isApiHealthy ? "text-green-400" : "text-red-400"}
-                >
-                  {isApiHealthy ? "Healthy" : "Issues"}
-                </span>
-              )}
-            </div>
-            <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 className="space-y-0">
+      <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
+        {/* API Health Widget */}
+        <ApiHealth />
 
-      {/* 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="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">
-            <svg
-              className="h-6 w-6 text-rose-400"
-              fill="none"
-              viewBox="0 0 24 24"
-              strokeWidth="1.5"
-              stroke="currentColor"
-            >
-              <path
-                strokeLinecap="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"
-              />
-            </svg>
-          </div>
-          <div className="flex-1">
-            <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>
-              ) : (
-                filesProcessed.toLocaleString()
-              )}
-            </div>
-            <div className="text-sm font-medium text-gray-400">
-              Files Processed
-            </div>
-            {/* Current Task Progress */}
-            {tasks && tasks.length > 0 && (
-              <div className="mt-3 pt-3 border-t border-white/10">
-                {(() => {
-                  const processingTask = tasks.find(
-                    (t: any) => t.status === "processing"
-                  );
-                  if (!processingTask) return null;
+        {/* File Watcher Card */}
+        <FileWatcherCard />
 
-                  const progress = processingTask.progress || 0;
-                  const fileName = processingTask.input
-                    ? processingTask.input.split("/").pop()
-                    : "Unknown file";
+        {/* Task Processing Card */}
+        <TaskProcessingCard />
 
-                  return (
-                    <div>
-                      <div className="flex items-center gap-2 mb-2">
-                        <svg
-                          className="h-4 w-4 text-gray-400 flex-shrink-0"
-                          fill="none"
-                          viewBox="0 0 24 24"
-                          strokeWidth="1.5"
-                          stroke="currentColor"
-                        >
-                          <title>{fileName}</title>
-                          <path
-                            strokeLinecap="round"
-                            strokeLinejoin="round"
-                            d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
-                          />
-                        </svg>
-                        <span
-                          className="text-xs text-gray-400 truncate"
-                          title={fileName}
-                        >
-                          Processing...
-                        </span>
-                      </div>
-                      <div className="flex items-center gap-2">
-                        <div className="flex-1 bg-white/10 rounded-full h-2 overflow-hidden">
-                          <div
-                            className="h-full bg-gradient-to-r from-rose-400 to-rose-500 transition-all duration-300"
-                            style={{ width: `${progress}%` }}
-                          />
-                        </div>
-                        <div className="text-xs font-medium text-rose-400 w-8 text-right">
-                          {progress}%
-                        </div>
-                      </div>
-                    </div>
-                  );
-                })()}
-              </div>
-            )}
-          </div>
-        </div>
+        {/* Files Processed Card */}
+        <FilesProcessedCard />
       </div>
     </div>
   );

+ 179 - 0
apps/web/src/app/components/TaskProcessingCard.tsx

@@ -0,0 +1,179 @@
+"use client";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import toast from "react-hot-toast";
+import { get, post } from "../../lib/api";
+import Card from "./Card";
+
+export default function TaskProcessingCard() {
+  const queryClient = useQueryClient();
+
+  const { data: filesSuccessful, isLoading: filesSuccessfulLoading } = useQuery(
+    {
+      queryKey: ["files-stats-successful"],
+      queryFn: () => get("/files/stats/successful"),
+    }
+  );
+
+  const { data: filesProcessedTotal, isLoading: filesProcessedLoading } =
+    useQuery({
+      queryKey: ["files-stats-processed"],
+      queryFn: () => get("/files/stats/processed"),
+    });
+
+  const {
+    data: taskProcessingStatus,
+    isLoading: taskProcessingLoading,
+    refetch: refetchTaskProcessingStatus,
+  } = useQuery({
+    queryKey: ["tasks", "processing-status"],
+    queryFn: () => get("/tasks/processing-status"),
+    staleTime: 0,
+    gcTime: 0,
+  });
+
+  const { data: queueStatus } = useQuery({
+    queryKey: ["tasks", "queue", "status"],
+    queryFn: () => get("/tasks/queue/status"),
+  });
+
+  const startTaskProcessingMutation = useMutation({
+    mutationFn: () => post("/tasks/start-processing"),
+    onSuccess: async () => {
+      // Small delay to let backend update state
+      await new Promise((resolve) => setTimeout(resolve, 200));
+      await refetchTaskProcessingStatus();
+      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: async () => {
+      // Small delay to let backend update state
+      await new Promise((resolve) => setTimeout(resolve, 200));
+      await refetchTaskProcessingStatus();
+      queryClient.invalidateQueries({ queryKey: ["tasks", "queue", "status"] });
+      toast.success("Task processing stopped");
+    },
+    onError: () => {
+      toast.error("Failed to stop task processing");
+    },
+  });
+
+  const filesProcessed = filesSuccessful || 0;
+  const totalProcessed = filesProcessedTotal || 0;
+  const successRate =
+    totalProcessed > 0
+      ? Math.round((filesProcessed / totalProcessed) * 100)
+      : 0;
+  const isTaskProcessingActive = taskProcessingStatus?.isProcessing;
+
+  return (
+    <Card>
+      <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`}
+          >
+            {startTaskProcessingMutation.isPending ||
+            stopTaskProcessingMutation.isPending
+              ? "..."
+              : isTaskProcessingActive
+                ? "Stop"
+                : "Start"}
+          </button>
+        </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>
+            <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>
+      )}
+    </Card>
+  );
+}

+ 10 - 2
apps/web/src/app/components/WatcherControls.tsx

@@ -16,13 +16,17 @@ export default function WatcherControls() {
   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.",
       });
+      // Invalidate and refetch to ensure status updates immediately
+      queryClient.invalidateQueries({ queryKey: ["watcher", "status"] });
+      setTimeout(() => {
+        queryClient.refetchQueries({ queryKey: ["watcher", "status"] });
+      }, 100);
     },
     onError: () => {
       toast.error("Failed to start file watcher");
@@ -32,13 +36,17 @@ export default function WatcherControls() {
   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.",
       });
+      // Invalidate and refetch to ensure status updates immediately
+      queryClient.invalidateQueries({ queryKey: ["watcher", "status"] });
+      setTimeout(() => {
+        queryClient.refetchQueries({ queryKey: ["watcher", "status"] });
+      }, 100);
     },
     onError: () => {
       toast.error("Failed to stop file watcher");

+ 17 - 9
apps/web/src/app/components/WatcherStatus.tsx

@@ -11,42 +11,50 @@ export default function WatcherStatus() {
   const { addNotification } = useNotifications();
   const { data, isLoading, error } = useQuery({
     queryKey: ["watcher", "status"],
-    queryFn: () => get("/watcher/status")
+    queryFn: () => get("/watcher/status"),
   });
 
   const { data: _datasets, isLoading: _datasetsLoading } = useQuery({
     queryKey: ["datasets"],
-    queryFn: () => get("/files")
+    queryFn: () => get("/files"),
   });
 
   const { data: settings } = useQuery({
     queryKey: ["settings", "datasets"],
-    queryFn: () => get("/config/settings/datasets")
+    queryFn: () => get("/config/settings/datasets"),
   });
 
   const startMutation = useMutation({
     mutationFn: () => post("/watcher/start"),
     onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ["watcher", "status"] });
       toast.success("Watcher started successfully");
       addNotification({
         type: "success",
         title: "Watcher Started",
-        message: "The file watcher has been started successfully."
+        message: "The file watcher has been started successfully.",
       });
-    }
+      // Invalidate and refetch to ensure status updates immediately
+      queryClient.invalidateQueries({ queryKey: ["watcher", "status"] });
+      setTimeout(() => {
+        queryClient.refetchQueries({ queryKey: ["watcher", "status"] });
+      }, 100);
+    },
   });
   const stopMutation = useMutation({
     mutationFn: () => post("/watcher/stop"),
     onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ["watcher", "status"] });
       toast.success("Watcher stopped successfully");
       addNotification({
         type: "success",
         title: "Watcher Stopped",
-        message: "The file watcher has been stopped successfully."
+        message: "The file watcher has been stopped successfully.",
       });
-    }
+      // Invalidate and refetch to ensure status updates immediately
+      queryClient.invalidateQueries({ queryKey: ["watcher", "status"] });
+      setTimeout(() => {
+        queryClient.refetchQueries({ queryKey: ["watcher", "status"] });
+      }, 100);
+    },
   });
 
   // Listen for WebSocket events

+ 4 - 2
apps/web/src/app/duplicates/DuplicateList.tsx

@@ -106,9 +106,11 @@ export default function DuplicateList() {
         if (
           value &&
           typeof value === "object" &&
-          typeof value.destination === "string"
+          "destination" in value &&
+          typeof (value as any).destination === "string"
         ) {
-          if (value.destination.trim()) return value.destination as string;
+          if ((value as any).destination.trim())
+            return (value as any).destination as string;
         }
       }
       return undefined;

+ 4 - 2
apps/web/src/app/indexing/page.tsx

@@ -58,9 +58,11 @@ export default function IndexManagementPage() {
         if (
           value &&
           typeof value === "object" &&
-          typeof value.destination === "string"
+          "destination" in value &&
+          typeof (value as any).destination === "string"
         ) {
-          if (value.destination.trim()) return value.destination as string;
+          if ((value as any).destination.trim())
+            return (value as any).destination as string;
         }
       }
       return undefined;

+ 1 - 0
package.json

@@ -5,6 +5,7 @@
     "build": "turbo run build",
     "dev": "turbo run dev",
     "start": "turbo run start",
+    "start:prod": "turbo run start:prod",
     "lint": "turbo run lint",
     "format": "prettier --write \"**/*.{ts,tsx,md}\"",
     "check-types": "turbo run check-types",

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 459 - 2
pnpm-lock.yaml


+ 5 - 0
turbo.json

@@ -21,6 +21,11 @@
       "dependsOn": ["build"],
       "cache": false,
       "persistent": true
+    },
+    "start:prod": {
+      "dependsOn": ["build"],
+      "cache": false,
+      "persistent": true
     }
   }
 }

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است