TaskList.tsx 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892
  1. "use client";
  2. import {
  3. ChevronDownIcon,
  4. ChevronRightIcon,
  5. TrashIcon
  6. } from "@heroicons/react/24/outline";
  7. import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
  8. import React, { useEffect, useMemo, useState } from "react";
  9. import toast from "react-hot-toast";
  10. import { del, get } from "../../lib/api";
  11. import ConfirmationDialog from "./ConfirmationDialog";
  12. import LoadingCard from "./Loading";
  13. import { useNotifications } from "./NotificationContext";
  14. import Pagination from "./Pagination";
  15. import TaskCrud from "./TaskCrud";
  16. export default function TaskList({
  17. limit = 10,
  18. context = "homepage"
  19. }: {
  20. limit?: number;
  21. context?: "homepage" | "tasks";
  22. }) {
  23. const queryClient = useQueryClient();
  24. const { addNotification } = useNotifications();
  25. const { data, isLoading, error } = useQuery({
  26. queryKey: ["tasks"],
  27. queryFn: () => get("/tasks")
  28. });
  29. // Listen for WebSocket events
  30. useEffect(() => {
  31. const handleTaskUpdate = (event: CustomEvent) => {
  32. const taskData = event.detail;
  33. console.log("[TaskList] Received task update:", taskData);
  34. // For progress updates, update the cache directly instead of refetching
  35. if (taskData.type === "progress" && taskData.taskId !== undefined) {
  36. console.log(
  37. `[TaskList] Updating progress for task ${taskData.taskId} to ${taskData.progress}%`
  38. );
  39. queryClient.setQueryData(["tasks"], (oldData: any) => {
  40. if (!oldData) {
  41. console.log("[TaskList] No cached task data available");
  42. return oldData;
  43. }
  44. const updated = oldData.map((task: any) =>
  45. task.id === taskData.taskId
  46. ? { ...task, progress: taskData.progress }
  47. : task
  48. );
  49. console.log(
  50. "[TaskList] Updated task cache:",
  51. updated.find((t: any) => t.id === taskData.taskId)
  52. );
  53. return updated;
  54. });
  55. } else {
  56. console.log("[TaskList] Non-progress update, refetching tasks");
  57. // For other task updates (created, completed, failed), do a full refetch
  58. queryClient.refetchQueries({ queryKey: ["tasks"] });
  59. }
  60. };
  61. const handleFileUpdate = (event: CustomEvent) => {
  62. const _fileData = event.detail;
  63. // Refetch tasks when file updates occur (e.g., new files detected)
  64. queryClient.refetchQueries({ queryKey: ["tasks"] });
  65. };
  66. window.addEventListener("taskUpdate", handleTaskUpdate as EventListener);
  67. window.addEventListener("fileUpdate", handleFileUpdate as EventListener);
  68. return () => {
  69. window.removeEventListener(
  70. "taskUpdate",
  71. handleTaskUpdate as EventListener
  72. );
  73. window.removeEventListener(
  74. "fileUpdate",
  75. handleFileUpdate as EventListener
  76. );
  77. };
  78. }, [queryClient]);
  79. const [editTask, setEditTask] = useState<any>(null);
  80. // Confirmation dialog and batch selection state
  81. const [deleteConfirm, setDeleteConfirm] = useState<{
  82. isOpen: boolean;
  83. task?: any;
  84. isBatch: boolean;
  85. selectedTasks?: any[];
  86. }>({ isOpen: false, isBatch: false });
  87. const [selectedTasks, setSelectedTasks] = useState<Set<string>>(new Set());
  88. // Expanded rows state
  89. const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
  90. // Dataset and status filter state - initialize from localStorage
  91. const [enabledDatasets, setEnabledDatasets] = useState<Set<string>>(() => {
  92. if (typeof window !== "undefined") {
  93. const saved = localStorage.getItem("taskList:enabledDatasets");
  94. return saved ? new Set(JSON.parse(saved)) : new Set();
  95. }
  96. return new Set();
  97. });
  98. const [enabledStatuses, setEnabledStatuses] = useState<Set<string>>(() => {
  99. if (typeof window !== "undefined") {
  100. const saved = localStorage.getItem("taskList:enabledStatuses");
  101. return saved
  102. ? new Set(JSON.parse(saved))
  103. : new Set(["pending", "processing", "failed", "skipped"]);
  104. }
  105. return new Set(["pending", "processing", "failed", "skipped"]);
  106. });
  107. // State for filters and search - initialize from localStorage
  108. const [searchTerm, setSearchTerm] = useState(() => {
  109. if (typeof window !== "undefined") {
  110. return localStorage.getItem("taskList:searchTerm") || "";
  111. }
  112. return "";
  113. });
  114. const [sortField, setSortField] = useState<
  115. "id" | "status" | "progress" | "dataset" | "updated_at"
  116. >(() => {
  117. if (typeof window !== "undefined") {
  118. const saved = localStorage.getItem("taskList:sortField");
  119. return (
  120. (saved as "id" | "status" | "progress" | "dataset" | "updated_at") ||
  121. "updated_at"
  122. );
  123. }
  124. return "updated_at";
  125. });
  126. const [sortDirection, setSortDirection] = useState<"asc" | "desc">(() => {
  127. if (typeof window !== "undefined") {
  128. const saved = localStorage.getItem("taskList:sortDirection");
  129. return (saved as "asc" | "desc") || "desc";
  130. }
  131. return "desc";
  132. });
  133. // Pagination state
  134. const [currentPage, setCurrentPage] = useState(1);
  135. const [pageSize, setPageSize] = useState(() => {
  136. if (typeof window !== "undefined") {
  137. const saved = localStorage.getItem("taskList:pageSize");
  138. return saved ? parseInt(saved) : 25;
  139. }
  140. return 25;
  141. });
  142. // Save filters to localStorage
  143. useEffect(() => {
  144. if (typeof window !== "undefined") {
  145. localStorage.setItem(
  146. "taskList:enabledDatasets",
  147. JSON.stringify(Array.from(enabledDatasets))
  148. );
  149. }
  150. }, [enabledDatasets]);
  151. useEffect(() => {
  152. if (typeof window !== "undefined") {
  153. localStorage.setItem(
  154. "taskList:enabledStatuses",
  155. JSON.stringify(Array.from(enabledStatuses))
  156. );
  157. }
  158. }, [enabledStatuses]);
  159. useEffect(() => {
  160. if (typeof window !== "undefined") {
  161. localStorage.setItem("taskList:searchTerm", searchTerm);
  162. }
  163. }, [searchTerm]);
  164. useEffect(() => {
  165. if (typeof window !== "undefined") {
  166. localStorage.setItem("taskList:sortField", sortField);
  167. }
  168. }, [sortField]);
  169. useEffect(() => {
  170. if (typeof window !== "undefined") {
  171. localStorage.setItem("taskList:sortDirection", sortDirection);
  172. }
  173. }, [sortDirection]);
  174. useEffect(() => {
  175. if (typeof window !== "undefined") {
  176. localStorage.setItem("taskList:pageSize", pageSize.toString());
  177. }
  178. }, [pageSize]);
  179. const deleteMutation = useMutation({
  180. mutationFn: (params: { task?: string; tasks?: any[] }) => {
  181. if (params.tasks) {
  182. // Batch delete
  183. return Promise.all(
  184. params.tasks.map((task) => del(`/tasks/${task.id}`))
  185. );
  186. } else if (params.task) {
  187. // Single delete
  188. return del(`/tasks/${params.task}`);
  189. }
  190. throw new Error("Invalid delete parameters");
  191. },
  192. onSuccess: (_, params) => {
  193. queryClient.refetchQueries({ queryKey: ["tasks"] });
  194. if (params.tasks) {
  195. toast.success(`${params.tasks.length} tasks deleted successfully`);
  196. addNotification({
  197. type: "success",
  198. title: "Tasks Deleted",
  199. message: `${params.tasks.length} tasks have been deleted successfully.`
  200. });
  201. setSelectedTasks(new Set()); // Clear selection after batch delete
  202. } else {
  203. toast.success("Task deleted successfully");
  204. addNotification({
  205. type: "success",
  206. title: "Task Deleted",
  207. message: `Task with ID "${params.task}" has been deleted successfully.`
  208. });
  209. }
  210. },
  211. onError: (error, _params) => {
  212. console.error("Failed to delete task(s):", error);
  213. toast.error("Failed to delete task(s)");
  214. addNotification({
  215. type: "error",
  216. title: "Delete Failed",
  217. message: `Failed to delete task(s). Please try again.`
  218. });
  219. }
  220. });
  221. const handleEdit = (task: any) => {
  222. setEditTask(task);
  223. };
  224. const handleEditClose = () => {
  225. setEditTask(null);
  226. };
  227. // Initialize enabled datasets when data is loaded
  228. useEffect(() => {
  229. if (data && data.length > 0 && enabledDatasets.size === 0) {
  230. const datasets = data
  231. .map((task: any) => task.dataset)
  232. .filter((dataset: any): dataset is string => Boolean(dataset));
  233. setEnabledDatasets(new Set(datasets));
  234. }
  235. }, [data, enabledDatasets.size]);
  236. // Confirmation dialog handlers
  237. const handleDeleteClick = (task: any) => {
  238. setDeleteConfirm({
  239. isOpen: true,
  240. task,
  241. isBatch: false
  242. });
  243. };
  244. const handleBatchDeleteClick = () => {
  245. const tasksToDelete = displayTasks.filter((task) =>
  246. selectedTasks.has(task.id.toString())
  247. );
  248. setDeleteConfirm({
  249. isOpen: true,
  250. isBatch: true,
  251. selectedTasks: tasksToDelete
  252. });
  253. };
  254. const handleDeleteConfirm = () => {
  255. if (deleteConfirm.isBatch && deleteConfirm.selectedTasks) {
  256. deleteMutation.mutate({ tasks: deleteConfirm.selectedTasks });
  257. } else if (deleteConfirm.task) {
  258. deleteMutation.mutate({ task: deleteConfirm.task.id.toString() });
  259. }
  260. setDeleteConfirm({ isOpen: false, isBatch: false });
  261. };
  262. const handleDeleteCancel = () => {
  263. setDeleteConfirm({ isOpen: false, isBatch: false });
  264. };
  265. // Batch selection handlers
  266. const handleSelectAll = () => {
  267. if (selectedTasks.size === displayTasks.length) {
  268. setSelectedTasks(new Set());
  269. } else {
  270. const allIds = new Set(displayTasks.map((task) => task.id.toString()));
  271. setSelectedTasks(allIds);
  272. }
  273. };
  274. const handleSelectTask = (task: any) => {
  275. const taskId = task.id.toString();
  276. const newSelected = new Set(selectedTasks);
  277. if (newSelected.has(taskId)) {
  278. newSelected.delete(taskId);
  279. } else {
  280. newSelected.add(taskId);
  281. }
  282. setSelectedTasks(newSelected);
  283. };
  284. // Expanded row handlers
  285. const handleToggleExpanded = (taskId: string) => {
  286. const newExpanded = new Set(expandedRows);
  287. if (newExpanded.has(taskId)) {
  288. newExpanded.delete(taskId);
  289. } else {
  290. newExpanded.add(taskId);
  291. }
  292. setExpandedRows(newExpanded);
  293. };
  294. // Dataset and status filter handlers
  295. const toggleDataset = (datasetName: string) => {
  296. const newEnabled = new Set(enabledDatasets);
  297. if (newEnabled.has(datasetName)) {
  298. newEnabled.delete(datasetName);
  299. } else {
  300. newEnabled.add(datasetName);
  301. }
  302. setEnabledDatasets(newEnabled);
  303. };
  304. const toggleStatus = (statusName: string) => {
  305. const newEnabled = new Set(enabledStatuses);
  306. if (newEnabled.has(statusName)) {
  307. newEnabled.delete(statusName);
  308. } else {
  309. newEnabled.add(statusName);
  310. }
  311. setEnabledStatuses(newEnabled);
  312. };
  313. const truncateText = (text: string, maxLength: number = 30) => {
  314. return text && text.length > maxLength
  315. ? text.substring(0, maxLength) + "..."
  316. : text;
  317. };
  318. const handleSort = (
  319. field: "id" | "status" | "progress" | "dataset" | "updated_at"
  320. ) => {
  321. if (sortField === field) {
  322. setSortDirection(sortDirection === "asc" ? "desc" : "asc");
  323. } else {
  324. setSortField(field);
  325. setSortDirection("asc");
  326. }
  327. };
  328. // Filter and sort data
  329. const filteredAndSortedData = useMemo(() => {
  330. if (!data || !Array.isArray(data)) return [];
  331. const filtered = data.filter(
  332. (task: any) =>
  333. enabledDatasets.has(task.dataset || "") &&
  334. enabledStatuses.has(task.status || "") &&
  335. (task.id.toString().toLowerCase().includes(searchTerm.toLowerCase()) ||
  336. (task.status &&
  337. task.status.toLowerCase().includes(searchTerm.toLowerCase())) ||
  338. (task.progress &&
  339. task.progress
  340. .toString()
  341. .toLowerCase()
  342. .includes(searchTerm.toLowerCase())) ||
  343. (task.dataset &&
  344. task.dataset.toLowerCase().includes(searchTerm.toLowerCase())) ||
  345. (task.input &&
  346. task.input.toLowerCase().includes(searchTerm.toLowerCase())) ||
  347. (task.output &&
  348. task.output.toLowerCase().includes(searchTerm.toLowerCase())) ||
  349. (task.error_message &&
  350. task.error_message
  351. .toLowerCase()
  352. .includes(searchTerm.toLowerCase())))
  353. );
  354. filtered.sort((a: any, b: any) => {
  355. let aValue = a[sortField];
  356. let bValue = b[sortField];
  357. if (typeof aValue === "string") {
  358. aValue = aValue.toLowerCase();
  359. bValue = bValue.toLowerCase();
  360. }
  361. if (aValue < bValue) return sortDirection === "asc" ? -1 : 1;
  362. if (aValue > bValue) return sortDirection === "asc" ? 1 : -1;
  363. return 0;
  364. });
  365. return filtered;
  366. }, [
  367. data,
  368. enabledDatasets,
  369. enabledStatuses,
  370. searchTerm,
  371. sortField,
  372. sortDirection
  373. ]);
  374. // Handle pagination
  375. const paginatedData = useMemo(() => {
  376. if (context === "homepage") {
  377. // For homepage, just apply limit
  378. return limit
  379. ? filteredAndSortedData.slice(0, limit)
  380. : filteredAndSortedData;
  381. } else {
  382. // For tasks page, apply pagination
  383. const startIndex = (currentPage - 1) * pageSize;
  384. const endIndex = startIndex + pageSize;
  385. return filteredAndSortedData.slice(startIndex, endIndex);
  386. }
  387. }, [filteredAndSortedData, context, limit, currentPage, pageSize]);
  388. // Reset to page 1 when filters change
  389. useEffect(() => {
  390. setCurrentPage(1);
  391. }, [enabledDatasets, enabledStatuses, searchTerm, sortField, sortDirection]);
  392. const displayTasks = paginatedData;
  393. // Show loading state if data is still being fetched or if data is undefined
  394. if (isLoading || !data) return <LoadingCard message="Loading tasks..." />;
  395. if (error) {
  396. return (
  397. <div className="text-center p-8 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
  398. <div className="mb-6">
  399. <svg
  400. className="mx-auto h-16 w-16 text-red-400"
  401. fill="none"
  402. viewBox="0 0 24 24"
  403. stroke="currentColor"
  404. >
  405. <path
  406. strokeLinecap="round"
  407. strokeLinejoin="round"
  408. strokeWidth={2}
  409. d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
  410. />
  411. </svg>
  412. </div>
  413. <h2 className="text-xl font-semibold text-red-800 dark:text-red-200 mb-4">
  414. Failed to load tasks
  415. </h2>
  416. <p className="text-gray-600 dark:text-gray-400 mb-6">
  417. There was an error loading the tasks data.
  418. </p>
  419. <button
  420. onClick={() => queryClient.refetchQueries({ queryKey: ["tasks"] })}
  421. className="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
  422. >
  423. Try Again
  424. </button>
  425. </div>
  426. );
  427. }
  428. return (
  429. <>
  430. <div className="mb-6 p-4 border rounded bg-gray-50 dark:bg-gray-800">
  431. <div className="flex items-center justify-between mb-4">
  432. <h3 className="font-semibold">Tasks</h3>
  433. <div className="flex items-center gap-2">
  434. <span className="text-sm text-gray-600 dark:text-gray-400">
  435. Total: {filteredAndSortedData.length} tasks
  436. </span>
  437. {context === "homepage" ? (
  438. <a
  439. href="/tasks"
  440. className="inline-flex items-center rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
  441. >
  442. <svg
  443. className="h-4 w-4 mr-2"
  444. fill="none"
  445. viewBox="0 0 24 24"
  446. stroke="currentColor"
  447. >
  448. <path
  449. strokeLinecap="round"
  450. strokeLinejoin="round"
  451. strokeWidth={2}
  452. d="M9 5l7 7-7 7"
  453. />
  454. </svg>
  455. View All Tasks
  456. </a>
  457. ) : null}
  458. </div>
  459. </div>
  460. {/* Filter Controls - Only show on tasks page */}
  461. {context === "tasks" && (
  462. <div className="mb-4 space-y-4">
  463. {/* Search */}
  464. <div className="flex items-center gap-4">
  465. <div className="flex-1">
  466. <input
  467. type="text"
  468. placeholder="Search tasks..."
  469. value={searchTerm}
  470. onChange={(e) => setSearchTerm(e.target.value)}
  471. className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
  472. />
  473. </div>
  474. </div>
  475. {/* Dataset Toggles */}
  476. <div className="flex flex-wrap gap-2">
  477. <span className="text-sm text-gray-600 dark:text-gray-400 mr-2">
  478. Datasets:
  479. </span>
  480. {Array.from(enabledDatasets).map((datasetName) => (
  481. <label key={datasetName} className="flex items-center gap-2">
  482. <input
  483. type="checkbox"
  484. checked={enabledDatasets.has(datasetName)}
  485. onChange={() => toggleDataset(datasetName)}
  486. className="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
  487. />
  488. <span className="text-sm text-gray-700 dark:text-gray-300">
  489. {datasetName}
  490. </span>
  491. </label>
  492. ))}
  493. </div>
  494. {/* Status Toggles */}
  495. <div className="flex flex-wrap gap-2">
  496. <span className="text-sm text-gray-600 dark:text-gray-400 mr-2">
  497. Status:
  498. </span>
  499. {["pending", "processing", "completed", "failed", "skipped"].map(
  500. (statusName) => (
  501. <label key={statusName} className="flex items-center gap-2">
  502. <input
  503. type="checkbox"
  504. checked={enabledStatuses.has(statusName)}
  505. onChange={() => toggleStatus(statusName)}
  506. className="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
  507. />
  508. <span className="text-sm text-gray-700 dark:text-gray-300 capitalize">
  509. {statusName}
  510. </span>
  511. </label>
  512. )
  513. )}
  514. </div>
  515. </div>
  516. )}
  517. {/* Batch Actions */}
  518. {selectedTasks.size > 0 && (
  519. <div className="mb-4 flex items-center gap-2">
  520. <span className="text-sm text-gray-700 dark:text-gray-300">
  521. {selectedTasks.size} task{selectedTasks.size !== 1 ? "s" : ""}{" "}
  522. selected
  523. </span>
  524. <button
  525. onClick={handleBatchDeleteClick}
  526. className="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
  527. >
  528. <TrashIcon className="h-4 w-4 mr-2" />
  529. Delete Selected
  530. </button>
  531. <button
  532. onClick={() => setSelectedTasks(new Set())}
  533. className="inline-flex items-center px-3 py-2 border border-gray-300 dark:border-gray-600 text-sm font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
  534. >
  535. Clear Selection
  536. </button>
  537. </div>
  538. )}
  539. <div className="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-sm">
  540. <table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
  541. <thead className="bg-gray-50 dark:bg-gray-800">
  542. <tr>
  543. {context === "tasks" && (
  544. <th
  545. scope="col"
  546. className="px-4 py-3 text-left text-xs font-semibold text-gray-700 dark:text-gray-200 uppercase tracking-wider"
  547. >
  548. {/* Expand/collapse column */}
  549. </th>
  550. )}
  551. {context === "tasks" && (
  552. <th
  553. scope="col"
  554. className="px-4 py-3 text-left text-xs font-semibold text-gray-700 dark:text-gray-200 uppercase tracking-wider"
  555. >
  556. <input
  557. type="checkbox"
  558. checked={
  559. selectedTasks.size === displayTasks.length &&
  560. displayTasks.length > 0
  561. }
  562. onChange={handleSelectAll}
  563. className="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
  564. />
  565. </th>
  566. )}
  567. <th
  568. scope="col"
  569. className={`px-4 py-3 text-left text-xs font-semibold text-gray-700 dark:text-gray-200 uppercase tracking-wider ${
  570. context === "tasks"
  571. ? "cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700"
  572. : ""
  573. }`}
  574. onClick={
  575. context === "tasks" ? () => handleSort("id") : undefined
  576. }
  577. >
  578. ID{" "}
  579. {context === "tasks" &&
  580. sortField === "id" &&
  581. (sortDirection === "asc" ? "↑" : "↓")}
  582. </th>
  583. <th
  584. scope="col"
  585. className={`px-4 py-3 text-left text-xs font-semibold text-gray-700 dark:text-gray-200 uppercase tracking-wider ${
  586. context === "tasks"
  587. ? "cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700"
  588. : ""
  589. }`}
  590. onClick={
  591. context === "tasks" ? () => handleSort("status") : undefined
  592. }
  593. >
  594. Status{" "}
  595. {context === "tasks" &&
  596. sortField === "status" &&
  597. (sortDirection === "asc" ? "↑" : "↓")}
  598. </th>
  599. <th
  600. scope="col"
  601. className={`px-4 py-3 text-left text-xs font-semibold text-gray-700 dark:text-gray-200 uppercase tracking-wider ${
  602. context === "tasks"
  603. ? "cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700"
  604. : ""
  605. }`}
  606. onClick={
  607. context === "tasks"
  608. ? () => handleSort("progress")
  609. : undefined
  610. }
  611. >
  612. Progress{" "}
  613. {context === "tasks" &&
  614. sortField === "progress" &&
  615. (sortDirection === "asc" ? "↑" : "↓")}
  616. </th>
  617. <th
  618. scope="col"
  619. className={`px-4 py-3 text-left text-xs font-semibold text-gray-700 dark:text-gray-200 uppercase tracking-wider ${
  620. context === "tasks"
  621. ? "cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700"
  622. : ""
  623. }`}
  624. onClick={
  625. context === "tasks"
  626. ? () => handleSort("dataset")
  627. : undefined
  628. }
  629. >
  630. Dataset{" "}
  631. {context === "tasks" &&
  632. sortField === "dataset" &&
  633. (sortDirection === "asc" ? "↑" : "↓")}
  634. </th>
  635. <th
  636. scope="col"
  637. className="px-4 py-3 text-left text-xs font-semibold text-gray-700 dark:text-gray-200 uppercase tracking-wider"
  638. >
  639. Preset
  640. </th>
  641. {context === "tasks" && (
  642. <>
  643. <th
  644. scope="col"
  645. className="px-4 py-3 text-left text-xs font-semibold text-gray-700 dark:text-gray-200 uppercase tracking-wider"
  646. >
  647. Input
  648. </th>
  649. </>
  650. )}
  651. {context === "tasks" && (
  652. <th
  653. scope="col"
  654. className="px-4 py-3 text-right text-xs font-semibold text-gray-700 dark:text-gray-200 uppercase tracking-wider"
  655. >
  656. Actions
  657. </th>
  658. )}
  659. </tr>
  660. </thead>
  661. <tbody className="divide-y divide-gray-200 dark:divide-gray-800">
  662. {displayTasks.length > 0 ? (
  663. displayTasks.map((task: any) => {
  664. const taskId = task.id.toString();
  665. const isExpanded = expandedRows.has(taskId);
  666. return (
  667. <React.Fragment key={task.id}>
  668. <tr>
  669. {context === "tasks" && (
  670. <td className="px-4 py-2 whitespace-nowrap">
  671. <button
  672. onClick={() => handleToggleExpanded(taskId)}
  673. className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
  674. >
  675. {isExpanded ? (
  676. <ChevronDownIcon className="h-4 w-4" />
  677. ) : (
  678. <ChevronRightIcon className="h-4 w-4" />
  679. )}
  680. </button>
  681. </td>
  682. )}
  683. {context === "tasks" && (
  684. <td className="px-4 py-2 whitespace-nowrap">
  685. <input
  686. type="checkbox"
  687. checked={selectedTasks.has(task.id.toString())}
  688. onChange={() => handleSelectTask(task)}
  689. className="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
  690. />
  691. </td>
  692. )}
  693. <td className="px-4 py-2 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
  694. {task.id}
  695. </td>
  696. <td className="px-4 py-2 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
  697. {truncateText(task.status)}
  698. </td>
  699. <td className="px-4 py-2 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
  700. {task.progress !== null &&
  701. task.progress !== undefined ? (
  702. <div className="flex items-center gap-2">
  703. <div className="flex-1 min-w-[60px]">
  704. <div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
  705. <div
  706. className="h-full bg-indigo-600 transition-all duration-300"
  707. style={{ width: `${task.progress}%` }}
  708. />
  709. </div>
  710. </div>
  711. <span className="text-xs font-medium tabular-nums">
  712. {task.progress}%
  713. </span>
  714. </div>
  715. ) : (
  716. "-"
  717. )}
  718. </td>
  719. <td className="px-4 py-2 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
  720. {truncateText(task.dataset ?? "-")}
  721. </td>
  722. <td className="px-4 py-2 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
  723. {truncateText(task.preset ?? "-")}
  724. </td>
  725. {context === "tasks" && (
  726. <>
  727. <td className="px-4 py-2 text-sm text-gray-900 dark:text-gray-100 max-w-xs">
  728. <div className="truncate" title={task.input}>
  729. {task.input
  730. ? truncateText(task.input, 30)
  731. : "-"}
  732. </div>
  733. </td>
  734. </>
  735. )}
  736. {context === "tasks" && (
  737. <td className="px-4 py-2 whitespace-nowrap text-sm text-right">
  738. <button
  739. className="inline-flex items-center rounded-md bg-yellow-500 px-3 py-1 text-xs font-medium text-white shadow-sm hover:bg-yellow-600 focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:ring-offset-2 mr-2"
  740. onClick={() => handleEdit(task)}
  741. >
  742. Edit
  743. </button>
  744. <button
  745. className="inline-flex items-center rounded-md bg-red-600 px-3 py-1 text-xs font-medium text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
  746. onClick={() => handleDeleteClick(task)}
  747. >
  748. Delete
  749. </button>
  750. </td>
  751. )}
  752. </tr>
  753. {context === "tasks" && isExpanded && (
  754. <tr
  755. key={`${task.id}-expanded`}
  756. className="bg-gray-50 dark:bg-gray-800"
  757. >
  758. <td colSpan={7} className="px-4 py-3">
  759. <div className="space-y-3 text-sm">
  760. <div>
  761. <div className="font-medium text-gray-900 dark:text-gray-100 mb-1">
  762. Output
  763. </div>
  764. <div className="text-gray-700 dark:text-gray-300 font-mono text-xs bg-white dark:bg-gray-900 p-2 rounded border max-h-32 overflow-y-auto">
  765. {task.output || "No output available"}
  766. </div>
  767. </div>
  768. <div>
  769. <div className="font-medium text-gray-900 dark:text-gray-100 mb-1">
  770. Created
  771. </div>
  772. <div className="text-gray-700 dark:text-gray-300 font-mono text-xs bg-white dark:bg-gray-900 p-2 rounded border">
  773. {task.created_at
  774. ? new Date(task.created_at).toLocaleString()
  775. : "N/A"}
  776. </div>
  777. </div>
  778. <div>
  779. <div className="font-medium text-gray-900 dark:text-gray-100 mb-1">
  780. Error
  781. </div>
  782. <div className="text-gray-700 dark:text-gray-300 font-mono text-xs bg-white dark:bg-gray-900 p-2 rounded border max-h-32 overflow-y-auto">
  783. {task.error_message || "No error"}
  784. </div>
  785. </div>
  786. </div>
  787. </td>
  788. </tr>
  789. )}
  790. </React.Fragment>
  791. );
  792. })
  793. ) : (
  794. <tr>
  795. <td
  796. colSpan={context === "tasks" ? 8 : 6}
  797. className="px-4 py-4 text-center text-gray-500 dark:text-gray-400"
  798. >
  799. No tasks found matching your filters.
  800. </td>
  801. </tr>
  802. )}
  803. </tbody>
  804. </table>
  805. </div>
  806. </div>
  807. {context === "tasks" && (
  808. <Pagination
  809. currentPage={currentPage}
  810. totalItems={filteredAndSortedData.length}
  811. pageSize={pageSize}
  812. onPageChange={setCurrentPage}
  813. onPageSizeChange={(newPageSize) => {
  814. setPageSize(newPageSize);
  815. setCurrentPage(1);
  816. }}
  817. />
  818. )}
  819. {context === "tasks" && editTask && (
  820. <TaskCrud editTask={editTask} onEditClose={handleEditClose} />
  821. )}
  822. <ConfirmationDialog
  823. isOpen={deleteConfirm.isOpen}
  824. title={deleteConfirm.isBatch ? "Delete Selected Tasks" : "Delete Task"}
  825. message={
  826. deleteConfirm.isBatch
  827. ? `Are you sure you want to delete ${deleteConfirm.selectedTasks?.length} selected task(s)? This action cannot be undone.`
  828. : `Are you sure you want to delete task with ID "${deleteConfirm.task?.id}"? This action cannot be undone.`
  829. }
  830. confirmText="Delete"
  831. cancelText="Cancel"
  832. type="danger"
  833. onConfirm={handleDeleteConfirm}
  834. onClose={handleDeleteCancel}
  835. isLoading={deleteMutation.isPending}
  836. />
  837. </>
  838. );
  839. }