3 Ревизии 99758c1818 ... 687756bab3

Автор SHA1 Съобщение Дата
  Timothy Pomeroy 687756bab3 Update modified components with formatting changes преди 3 седмици
  Timothy Pomeroy 946004eddb Refactor StatsSection into smaller maintainable components преди 3 седмици
  Timothy Pomeroy a6755975c7 Integrate ApiHealth component into stats grid as first item преди 3 седмици

+ 39 - 78
apps/web/src/app/components/ApiHealth.tsx

@@ -1,5 +1,5 @@
 "use client";
-import { ArrowPathIcon } from "@heroicons/react/24/outline";
+import { ArrowPathIcon, ServerIcon } from "@heroicons/react/24/outline";
 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
 import { useState } from "react";
 import toast from "react-hot-toast";
@@ -38,88 +38,49 @@ export default function ApiHealth() {
   });
 
   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="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"
-              }`}
-            >
-              {isHealthy && !error ? "Healthy" : "Unhealthy"}
-            </span>
-          )}
-          <button
-            onClick={() => setShowRestartConfirm(true)}
-            disabled={restartMutation.isPending}
-            className="ml-2 p-1.5 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
-            title="Restart API service"
-          >
-            <ArrowPathIcon
-              className={`w-4 h-4 ${
-                restartMutation.isPending ? "animate-spin" : ""
-              }`}
-            />
-          </button>
-        </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 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>
-        )}
-        {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 className="text-2xl font-bold text-white">
+              {isLoading ? (
+                <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 && (
+              <div className="text-xs text-gray-500 mt-1">
+                {responseTime}ms response
+              </div>
+            )}
           </div>
-        )}
+        </div>
+        <button
+          onClick={() => setShowRestartConfirm(true)}
+          disabled={restartMutation.isPending}
+          className="px-3 py-1 rounded text-xs font-medium transition-colors bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1"
+          title="Restart API service"
+        >
+          <ArrowPathIcon
+            className={`h-3 w-3 ${restartMutation.isPending ? "animate-spin" : ""}`}
+          />
+          Restart
+        </button>
       </div>
 
       {/* Restart Confirmation Dialog */}

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

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

@@ -0,0 +1,155 @@
+"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 } = useQuery({
+    queryKey: ["watcher", "status"],
+    queryFn: () => get("/watcher/status"),
+  });
+
+  const { data: settings, isLoading: settingsLoading } = useQuery({
+    queryKey: ["settings", "datasets"],
+    queryFn: () => get("/config/settings/datasets"),
+  });
+
+  const startWatcherMutation = useMutation({
+    mutationFn: () => post("/watcher/start"),
+    onSuccess: () => {
+      toast.success("File watcher started");
+      queryClient.invalidateQueries({ queryKey: ["watcher", "status"] });
+      setTimeout(() => {
+        queryClient.refetchQueries({ queryKey: ["watcher", "status"] });
+      }, 100);
+    },
+    onError: () => {
+      toast.error("Failed to start file watcher");
+    },
+  });
+
+  const stopWatcherMutation = useMutation({
+    mutationFn: () => post("/watcher/stop"),
+    onSuccess: () => {
+      toast.success("File watcher stopped");
+      queryClient.invalidateQueries({ queryKey: ["watcher", "status"] });
+      setTimeout(() => {
+        queryClient.refetchQueries({ queryKey: ["watcher", "status"] });
+      }, 100);
+    },
+    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>
+  );
+}

+ 13 - 468
apps/web/src/app/components/StatsSection.tsx

@@ -1,138 +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: () => {
-      toast.success("File watcher started");
-      // 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");
-    },
-  });
-
-  const stopWatcherMutation = useMutation({
-    mutationFn: () => post("/watcher/stop"),
-    onSuccess: () => {
-      toast.success("File watcher stopped");
-      // 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");
-    },
-  });
-
-  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) => {
@@ -178,350 +54,19 @@ export default function StatsSection() {
   }, [queryClient]);
 
   return (
-    <div className="space-y-6">
-      {/* API Health Widget */}
-      <ApiHealth />
-
-      {/* Stats Grid */}
+    <div className="space-y-0">
       <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>
+        {/* 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>
-        </div>
+        {/* Files Processed Card */}
+        <FilesProcessedCard />
       </div>
     </div>
   );

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

@@ -0,0 +1,174 @@
+"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 } =
+    useQuery({
+      queryKey: ["tasks", "processing-status"],
+      queryFn: () => get("/tasks/processing-status"),
+    });
+
+  const { data: queueStatus } = useQuery({
+    queryKey: ["tasks", "queue", "status"],
+    queryFn: () => get("/tasks/queue/status"),
+  });
+
+  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 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>
+  );
+}

+ 7 - 7
apps/web/src/app/components/WatcherStatus.tsx

@@ -11,17 +11,17 @@ 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({
@@ -31,14 +31,14 @@ export default function WatcherStatus() {
       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"),
@@ -47,14 +47,14 @@ export default function WatcherStatus() {
       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

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

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

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

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