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 useJobsList from '@/hooks/useJobsList';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import UniversalTable, { TableColumn } from '@/components/UniversalTable';
|
import UniversalTable, { TableColumn } from '@/components/UniversalTable';
|
||||||
@@ -10,6 +10,9 @@ import classNames from 'classnames';
|
|||||||
import { startQueue, stopQueue } from '@/utils/queue';
|
import { startQueue, stopQueue } from '@/utils/queue';
|
||||||
import { CgSpinner } from 'react-icons/cg';
|
import { CgSpinner } from 'react-icons/cg';
|
||||||
import useGPUInfo from '@/hooks/useGPUInfo';
|
import useGPUInfo from '@/hooks/useGPUInfo';
|
||||||
|
import { openConfirm } from '@/components/ConfirmModal';
|
||||||
|
import { deleteJob, stopJob } from '@/utils/jobs';
|
||||||
|
import { Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
interface JobsTableProps {
|
interface JobsTableProps {
|
||||||
autoStartQueue?: boolean;
|
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 { jobs, status, refreshJobs } = useJobsList({ onlyActive, reloadInterval: 5000, job_type });
|
||||||
const { queues, status: queueStatus, refreshQueues } = useQueueList();
|
const { queues, status: queueStatus, refreshQueues } = useQueueList();
|
||||||
const { gpuList, isGPUInfoLoaded } = useGPUInfo();
|
const { gpuList, isGPUInfoLoaded } = useGPUInfo();
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
|
const [deleteProgress, setDeleteProgress] = useState<{ done: number; total: number } | null>(null);
|
||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
refreshJobs();
|
refreshJobs();
|
||||||
refreshQueues();
|
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[] = [
|
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',
|
title: 'Name',
|
||||||
key: 'name',
|
key: 'name',
|
||||||
@@ -151,6 +244,37 @@ export default function JobsTable({ onlyActive = false, job_type = null }: JobsT
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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)
|
{Object.keys(jobsDict)
|
||||||
.sort()
|
.sort()
|
||||||
.filter(key => key !== 'Idle')
|
.filter(key => key !== 'Idle')
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Loading from './Loading';
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
export interface TableColumn {
|
export interface TableColumn {
|
||||||
title: string;
|
title: React.ReactNode;
|
||||||
key: string;
|
key: string;
|
||||||
render?: (row: any) => React.ReactNode;
|
render?: (row: any) => React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user