diff --git a/ui/src/components/JobsTable.tsx b/ui/src/components/JobsTable.tsx index 4c570cc..c52034c 100644 --- a/ui/src/components/JobsTable.tsx +++ b/ui/src/components/JobsTable.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import useJobsList from '@/hooks/useJobsList'; import Link from 'next/link'; import UniversalTable, { TableColumn } from '@/components/UniversalTable'; @@ -10,6 +10,9 @@ import classNames from 'classnames'; import { startQueue, stopQueue } from '@/utils/queue'; import { CgSpinner } from 'react-icons/cg'; import useGPUInfo from '@/hooks/useGPUInfo'; +import { openConfirm } from '@/components/ConfirmModal'; +import { deleteJob, stopJob } from '@/utils/jobs'; +import { Trash2 } from 'lucide-react'; interface JobsTableProps { autoStartQueue?: boolean; @@ -21,13 +24,103 @@ export default function JobsTable({ onlyActive = false, job_type = null }: JobsT const { jobs, status, refreshJobs } = useJobsList({ onlyActive, reloadInterval: 5000, job_type }); const { queues, status: queueStatus, refreshQueues } = useQueueList(); const { gpuList, isGPUInfoLoaded } = useGPUInfo(); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [deleteProgress, setDeleteProgress] = useState<{ done: number; total: number } | null>(null); const refresh = () => { refreshJobs(); refreshQueues(); }; + const isDeleting = deleteProgress !== null; + const allSelected = jobs.length > 0 && jobs.every(job => selectedIds.has(job.id)); + + const toggleRow = (id: string) => { + setSelectedIds(prev => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }; + + const toggleAll = () => { + setSelectedIds(allSelected ? new Set() : new Set(jobs.map(job => job.id))); + }; + + const onMassDelete = () => { + const jobsToDelete = jobs.filter(job => selectedIds.has(job.id)); + if (jobsToDelete.length === 0) return; + const runningCount = jobsToDelete.filter(job => job.status === 'running').length; + let message = `Are you sure you want to delete ${jobsToDelete.length} job${ + jobsToDelete.length === 1 ? '' : 's' + }? This will also permanently remove them from your disk.`; + if (runningCount > 0) { + message += ` WARNING: ${runningCount} of them ${ + runningCount === 1 ? 'is' : 'are' + } currently running and will be stopped first.`; + } + openConfirm({ + title: 'Delete Jobs', + message: message, + type: 'warning', + confirmText: 'Delete', + onConfirm: async () => { + setDeleteProgress({ done: 0, total: jobsToDelete.length }); + for (let i = 0; i < jobsToDelete.length; i++) { + const job = jobsToDelete[i]; + try { + if (job.status === 'running') { + try { + await stopJob(job.id); + } catch (e) { + console.error('Error stopping job before deleting:', e); + } + } + await deleteJob(job.id); + setSelectedIds(prev => { + const next = new Set(prev); + next.delete(job.id); + return next; + }); + } catch (e) { + console.error('Error deleting job:', job.name, e); + } + setDeleteProgress({ done: i + 1, total: jobsToDelete.length }); + refreshJobs(); + } + setDeleteProgress(null); + refresh(); + }, + }); + }; + const columns: TableColumn[] = [ + { + title: ( + + ), + key: 'select', + className: 'w-8', + render: row => ( + toggleRow(row.id)} + disabled={isDeleting} + className="cursor-pointer accent-blue-500" + /> + ), + }, { title: 'Name', key: 'name', @@ -151,6 +244,37 @@ export default function JobsTable({ onlyActive = false, job_type = null }: JobsT return (
+ {(selectedIds.size > 0 || isDeleting) && ( +
+ {isDeleting ? ( + <> + + + Deleting {deleteProgress.done} / {deleteProgress.total}... + + + ) : ( + <> + + {selectedIds.size} job{selectedIds.size === 1 ? '' : 's'} selected + + + + + )} +
+ )} {Object.keys(jobsDict) .sort() .filter(key => key !== 'Idle') diff --git a/ui/src/components/UniversalTable.tsx b/ui/src/components/UniversalTable.tsx index 036e371..fdab484 100644 --- a/ui/src/components/UniversalTable.tsx +++ b/ui/src/components/UniversalTable.tsx @@ -2,7 +2,7 @@ import Loading from './Loading'; import classNames from 'classnames'; export interface TableColumn { - title: string; + title: React.ReactNode; key: string; render?: (row: any) => React.ReactNode; className?: string;