From 0f788923aef28e3a87fa68cfa15a761d9d499d6c Mon Sep 17 00:00:00 2001 From: Jaret Burkett Date: Sat, 15 Aug 2026 10:33:40 -0600 Subject: [PATCH] Animate action bar so starting a job has a 'working' indicator. --- ui/src/components/JobActionBar.tsx | 205 +++++++++++++++++++++-------- 1 file changed, 149 insertions(+), 56 deletions(-) diff --git a/ui/src/components/JobActionBar.tsx b/ui/src/components/JobActionBar.tsx index b63d60e..cfc3a6b 100644 --- a/ui/src/components/JobActionBar.tsx +++ b/ui/src/components/JobActionBar.tsx @@ -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(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(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, 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 = ; + const menuSpinner = ; + const menuPending = pending === 'save' || pending === 'sample' || pending === 'markStopped'; + return (
{canStart && ( )} {canRemoveFromQueue && ( )} {canStop && ( )} {!hideView && ( @@ -112,7 +186,9 @@ export default function JobActionBar({ )}
- - - + {menuPending ? spinner : } )} {job.job_type === 'train' && canStop && ( - +
{ - 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, + ); }} > - + {pending === 'save' ? menuSpinner : } Save Next Step
)} {job.job_type === 'train' && canStop && ( - +
{ - 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, + ); }} > - + {pending === 'sample' ? menuSpinner : } Sample Next Step
)} - +
{ + 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(); + }), }); }} > - + {pending === 'markStopped' ? menuSpinner : } Mark as Stopped