Allow multi selecting jobs and deleting them in one go
This commit is contained in:
@@ -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<Set<string>>(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: (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
onChange={toggleAll}
|
||||
disabled={isDeleting}
|
||||
className="cursor-pointer accent-blue-500"
|
||||
/>
|
||||
),
|
||||
key: 'select',
|
||||
className: 'w-8',
|
||||
render: row => (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(row.id)}
|
||||
onChange={() => 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 (
|
||||
<div>
|
||||
{(selectedIds.size > 0 || isDeleting) && (
|
||||
<div className="fixed top-16 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 px-4 py-2 bg-gray-800 rounded-lg border border-gray-700 shadow-lg">
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<CgSpinner className="inline animate-spin text-red-400" />
|
||||
<span className="text-sm text-gray-300">
|
||||
Deleting {deleteProgress.done} / {deleteProgress.total}...
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm text-gray-300 flex-1">
|
||||
{selectedIds.size} job{selectedIds.size === 1 ? '' : 's'} selected
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
className="text-xs text-gray-300 bg-gray-700 hover:bg-gray-600 px-2 py-1 rounded"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<button
|
||||
onClick={onMassDelete}
|
||||
className="text-xs text-white bg-red-600 hover:bg-red-700 px-2 py-1 rounded flex items-center gap-1"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
Delete Selected
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{Object.keys(jobsDict)
|
||||
.sort()
|
||||
.filter(key => key !== 'Idle')
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user