Animate action bar so starting a job has a 'working' indicator.
This commit is contained in:
@@ -1,9 +1,19 @@
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Eye, Trash2, Pen, Play, Pause, Cog, X, Copy, Save, OctagonX, Image } from 'lucide-react';
|
||||
import { LuLoader } from 'react-icons/lu';
|
||||
import { Button } from '@headlessui/react';
|
||||
import { openConfirm } from '@/components/ConfirmModal';
|
||||
import { Job } from '@prisma/client';
|
||||
import { startJob, stopJob, deleteJob, getAvaliableJobActions, markJobAsStopped, saveJobNow, sampleJobNow } from '@/utils/jobs';
|
||||
import {
|
||||
startJob,
|
||||
stopJob,
|
||||
deleteJob,
|
||||
getAvaliableJobActions,
|
||||
markJobAsStopped,
|
||||
saveJobNow,
|
||||
sampleJobNow,
|
||||
} from '@/utils/jobs';
|
||||
import { startQueue } from '@/utils/queue';
|
||||
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react';
|
||||
import { redirect } from 'next/navigation';
|
||||
@@ -21,6 +31,12 @@ interface JobActionBarProps {
|
||||
menuAnchor?: 'bottom' | 'top end';
|
||||
}
|
||||
|
||||
type PendingAction = 'start' | 'remove' | 'stop' | 'delete' | 'save' | 'sample' | 'markStopped';
|
||||
|
||||
// If the job never reports a state change (e.g. the request silently failed
|
||||
// server-side), unlock the bar after this long so it can't stay stuck.
|
||||
const PENDING_TIMEOUT_MS = 30_000;
|
||||
|
||||
export default function JobActionBar({
|
||||
job,
|
||||
onRefresh,
|
||||
@@ -34,55 +50,113 @@ export default function JobActionBar({
|
||||
|
||||
if (!afterDelete) afterDelete = onRefresh;
|
||||
|
||||
// The action currently in flight. While set, every other action is disabled
|
||||
// and the pending one shows a spinner. It is cleared once the job comes back
|
||||
// in a different state (status / stop flag), on error, or after a timeout.
|
||||
const [pending, setPending] = useState<PendingAction | null>(null);
|
||||
// Job state key captured when the pending action was fired. null means the
|
||||
// action does not change job state and clears as soon as the request resolves.
|
||||
const pendingStateKeyRef = useRef<string | null>(null);
|
||||
const jobStateKey = `${job.status}|${job.stop ? 1 : 0}`;
|
||||
|
||||
const clearPending = () => {
|
||||
pendingStateKeyRef.current = null;
|
||||
setPending(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (pending && pendingStateKeyRef.current !== null && pendingStateKeyRef.current !== jobStateKey) {
|
||||
clearPending();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [jobStateKey, pending]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pending) return;
|
||||
const timer = setTimeout(clearPending, PENDING_TIMEOUT_MS);
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pending]);
|
||||
|
||||
const runAction = async (action: PendingAction, fn: () => Promise<void>, waitForJobUpdate = true) => {
|
||||
if (pending) return;
|
||||
pendingStateKeyRef.current = waitForJobUpdate ? jobStateKey : null;
|
||||
setPending(action);
|
||||
try {
|
||||
await fn();
|
||||
if (!waitForJobUpdate) clearPending();
|
||||
} catch (e) {
|
||||
console.error(`Error running job action "${action}":`, e);
|
||||
clearPending();
|
||||
}
|
||||
};
|
||||
|
||||
const isBusy = pending !== null;
|
||||
const iconSizeClass = 'w-5 h-5 sm:w-6 sm:h-6';
|
||||
const actionButtonClass = 'ml-1 sm:ml-2 opacity-100 disabled:opacity-40 disabled:cursor-not-allowed';
|
||||
const menuItemClass = 'px-4 py-1 rounded flex items-center gap-2';
|
||||
const menuItemEnabledClass = `${menuItemClass} cursor-pointer hover:bg-gray-800`;
|
||||
const menuItemDisabledClass = `${menuItemClass} opacity-40 cursor-not-allowed`;
|
||||
const spinner = <LuLoader className={`${iconSizeClass} animate-spin`} />;
|
||||
const menuSpinner = <LuLoader className="w-4 h-4 animate-spin" />;
|
||||
const menuPending = pending === 'save' || pending === 'sample' || pending === 'markStopped';
|
||||
|
||||
return (
|
||||
<div className={`flex items-center flex-shrink-0 ${className ?? ''}`}>
|
||||
{canStart && (
|
||||
<Button
|
||||
onClick={async () => {
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
if (!canStart) return;
|
||||
await startJob(job.id);
|
||||
// start the queue as well
|
||||
if (autoStartQueue) {
|
||||
await startQueue(job.gpu_ids);
|
||||
}
|
||||
if (onRefresh) onRefresh();
|
||||
runAction('start', async () => {
|
||||
await startJob(job.id);
|
||||
// start the queue as well
|
||||
if (autoStartQueue) {
|
||||
await startQueue(job.gpu_ids);
|
||||
}
|
||||
if (onRefresh) onRefresh();
|
||||
});
|
||||
}}
|
||||
className={`ml-1 sm:ml-2 opacity-100`}
|
||||
className={actionButtonClass}
|
||||
>
|
||||
<Play className={iconSizeClass} />
|
||||
{pending === 'start' ? spinner : <Play className={iconSizeClass} />}
|
||||
</Button>
|
||||
)}
|
||||
{canRemoveFromQueue && (
|
||||
<Button
|
||||
onClick={async () => {
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
if (!canRemoveFromQueue) return;
|
||||
await markJobAsStopped(job.id);
|
||||
if (onRefresh) onRefresh();
|
||||
runAction('remove', async () => {
|
||||
await markJobAsStopped(job.id);
|
||||
if (onRefresh) onRefresh();
|
||||
});
|
||||
}}
|
||||
className={`ml-1 sm:ml-2 opacity-100`}
|
||||
className={actionButtonClass}
|
||||
>
|
||||
<X className={iconSizeClass} />
|
||||
{pending === 'remove' ? spinner : <X className={iconSizeClass} />}
|
||||
</Button>
|
||||
)}
|
||||
{canStop && (
|
||||
<Button
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
if (!canStop) return;
|
||||
if (!canStop || isBusy) return;
|
||||
openConfirm({
|
||||
title: 'Stop Job',
|
||||
message: `Are you sure you want to stop the job "${job.name}"? You CAN resume later.`,
|
||||
type: 'info',
|
||||
confirmText: 'Stop',
|
||||
onConfirm: async () => {
|
||||
await stopJob(job.id);
|
||||
if (onRefresh) onRefresh();
|
||||
},
|
||||
onConfirm: () =>
|
||||
runAction('stop', async () => {
|
||||
await stopJob(job.id);
|
||||
if (onRefresh) onRefresh();
|
||||
}),
|
||||
});
|
||||
}}
|
||||
className={`ml-1 sm:ml-2 opacity-100`}
|
||||
className={actionButtonClass}
|
||||
>
|
||||
<Pause className={iconSizeClass} />
|
||||
{pending === 'stop' ? spinner : <Pause className={iconSizeClass} />}
|
||||
</Button>
|
||||
)}
|
||||
{!hideView && (
|
||||
@@ -112,7 +186,9 @@ export default function JobActionBar({
|
||||
</Link>
|
||||
)}
|
||||
<Button
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
if (isBusy) return;
|
||||
let message = `Are you sure you want to delete the job "${job.name}"? This will also permanently remove it from your disk.`;
|
||||
if (job.status === 'running') {
|
||||
message += ' WARNING: The job is currently running. You should stop it first if you can.';
|
||||
@@ -122,28 +198,27 @@ export default function JobActionBar({
|
||||
message: message,
|
||||
type: 'warning',
|
||||
confirmText: 'Delete',
|
||||
onConfirm: async () => {
|
||||
if (job.status === 'running') {
|
||||
try {
|
||||
await stopJob(job.id);
|
||||
} catch (e) {
|
||||
console.error('Error stopping job before deleting:', e);
|
||||
onConfirm: () =>
|
||||
runAction('delete', async () => {
|
||||
if (job.status === 'running') {
|
||||
try {
|
||||
await stopJob(job.id);
|
||||
} catch (e) {
|
||||
console.error('Error stopping job before deleting:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
await deleteJob(job.id);
|
||||
if (afterDelete) afterDelete();
|
||||
},
|
||||
await deleteJob(job.id);
|
||||
if (afterDelete) afterDelete();
|
||||
}),
|
||||
});
|
||||
}}
|
||||
className={`ml-1 sm:ml-2 opacity-100`}
|
||||
className={actionButtonClass}
|
||||
>
|
||||
<Trash2 className={iconSizeClass} />
|
||||
{pending === 'delete' ? spinner : <Trash2 className={iconSizeClass} />}
|
||||
</Button>
|
||||
<div className="border-r border-1 border-gray-700 ml-1 sm:ml-2 inline"></div>
|
||||
<Menu>
|
||||
<MenuButton className={'ml-1 sm:ml-2'}>
|
||||
<Cog className={iconSizeClass} />
|
||||
</MenuButton>
|
||||
<MenuButton className={'ml-1 sm:ml-2'}>{menuPending ? spinner : <Cog className={iconSizeClass} />}</MenuButton>
|
||||
<MenuItems
|
||||
anchor={{ to: menuAnchor, gap: 16 }}
|
||||
className="bg-gray-900 border border-gray-700 rounded shadow-lg w-60 px-2 py-2 z-50"
|
||||
@@ -160,51 +235,69 @@ export default function JobActionBar({
|
||||
</MenuItem>
|
||||
)}
|
||||
{job.job_type === 'train' && canStop && (
|
||||
<MenuItem>
|
||||
<MenuItem disabled={isBusy}>
|
||||
<div
|
||||
className="cursor-pointer px-4 py-1 hover:bg-gray-800 rounded flex items-center gap-2"
|
||||
onClick={async () => {
|
||||
await saveJobNow(job.id);
|
||||
if (onRefresh) onRefresh();
|
||||
className={isBusy ? menuItemDisabledClass : menuItemEnabledClass}
|
||||
onClick={() => {
|
||||
if (isBusy) return;
|
||||
// does not change job state, so release as soon as the request resolves
|
||||
runAction(
|
||||
'save',
|
||||
async () => {
|
||||
await saveJobNow(job.id);
|
||||
if (onRefresh) onRefresh();
|
||||
},
|
||||
false,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{pending === 'save' ? menuSpinner : <Save className="w-4 h-4" />}
|
||||
Save Next Step
|
||||
</div>
|
||||
</MenuItem>
|
||||
)}
|
||||
{job.job_type === 'train' && canStop && (
|
||||
<MenuItem>
|
||||
<MenuItem disabled={isBusy}>
|
||||
<div
|
||||
className="cursor-pointer px-4 py-1 hover:bg-gray-800 rounded flex items-center gap-2"
|
||||
onClick={async () => {
|
||||
await sampleJobNow(job.id);
|
||||
if (onRefresh) onRefresh();
|
||||
className={isBusy ? menuItemDisabledClass : menuItemEnabledClass}
|
||||
onClick={() => {
|
||||
if (isBusy) return;
|
||||
// does not change job state, so release as soon as the request resolves
|
||||
runAction(
|
||||
'sample',
|
||||
async () => {
|
||||
await sampleJobNow(job.id);
|
||||
if (onRefresh) onRefresh();
|
||||
},
|
||||
false,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Image className="w-4 h-4" />
|
||||
{pending === 'sample' ? menuSpinner : <Image className="w-4 h-4" />}
|
||||
Sample Next Step
|
||||
</div>
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem>
|
||||
<MenuItem disabled={isBusy}>
|
||||
<div
|
||||
className="cursor-pointer px-4 py-1 hover:bg-gray-800 rounded flex items-center gap-2"
|
||||
className={isBusy ? menuItemDisabledClass : menuItemEnabledClass}
|
||||
onClick={() => {
|
||||
if (isBusy) return;
|
||||
let message = `Are you sure you want to mark this job as stopped? This will set the job status to 'stopped' if the status is hung. Only do this if you are 100% sure the job is stopped. This will NOT stop the job.`;
|
||||
openConfirm({
|
||||
title: 'Mark Job as Stopped',
|
||||
message: message,
|
||||
type: 'warning',
|
||||
confirmText: 'Mark as Stopped',
|
||||
onConfirm: async () => {
|
||||
await markJobAsStopped(job.id);
|
||||
onRefresh && onRefresh();
|
||||
},
|
||||
onConfirm: () =>
|
||||
runAction('markStopped', async () => {
|
||||
await markJobAsStopped(job.id);
|
||||
onRefresh && onRefresh();
|
||||
}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<OctagonX className="w-4 h-4" />
|
||||
{pending === 'markStopped' ? menuSpinner : <OctagonX className="w-4 h-4" />}
|
||||
Mark as Stopped
|
||||
</div>
|
||||
</MenuItem>
|
||||
|
||||
Reference in New Issue
Block a user