From 0bb073e8bbc4987d2ed8469a7bd21543ae75357a Mon Sep 17 00:00:00 2001 From: Matthew Yung <117509016+myung03@users.noreply.github.com> Date: Wed, 17 Jul 2024 07:41:06 -0700 Subject: [PATCH] [ENG-1238] Improving job manager feedback and usability (#2607) * calculate jobs eta * calculate eta for single + group jobs * fix issue with clearing running job groups * clear completed with error tasks * error handling for ETA * fix typeerrors and minor styling for ETA * fix clearing all jobs & types * Update JobGroup.tsx * Update JobGroup.tsx --------- Co-authored-by: ameer2468 <33054370+ameer2468@users.noreply.github.com> --- .../Layout/Sidebar/JobManager/Job.tsx | 6 +- .../Sidebar/JobManager/JobContainer.tsx | 74 +++++++++++++++++-- .../Layout/Sidebar/JobManager/JobGroup.tsx | 40 +++++++--- .../Layout/Sidebar/JobManager/index.tsx | 49 +++++++++--- interface/locales/en/common.json | 2 +- 5 files changed, 139 insertions(+), 32 deletions(-) diff --git a/interface/app/$libraryId/Layout/Sidebar/JobManager/Job.tsx b/interface/app/$libraryId/Layout/Sidebar/JobManager/Job.tsx index 19d9f40af..f667688f6 100644 --- a/interface/app/$libraryId/Layout/Sidebar/JobManager/Job.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/JobManager/Job.tsx @@ -21,6 +21,7 @@ interface JobProps { className?: string; isChild?: boolean; progress: JobProgressEvent | null; + eta: number; } const JobIcon: Record = { @@ -33,10 +34,9 @@ const JobIcon: Record = { ObjectValidator: Fingerprint }; -function Job({ job, className, isChild, progress }: JobProps) { +function Job({ job, className, isChild, progress, eta }: JobProps) { const jobData = useJobInfo(job, progress); const { t } = useLocale(); - // I don't like sending TSX as a prop due to lack of hot-reload, but it's the only way to get the error log to show up if (job.status === 'CompletedWithErrors') { const JobError = ( @@ -72,6 +72,8 @@ function Job({ job, className, isChild, progress }: JobProps) { className={className} name={jobData.name} icon={JobIcon[job.name]} + eta={eta} + status={job.status} textItems={ ['Queued'].includes(job.status) ? [[{ text: job.status }]] : jobData.textItems } diff --git a/interface/app/$libraryId/Layout/Sidebar/JobManager/JobContainer.tsx b/interface/app/$libraryId/Layout/Sidebar/JobManager/JobContainer.tsx index f7c5e558c..b95804309 100644 --- a/interface/app/$libraryId/Layout/Sidebar/JobManager/JobContainer.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/JobManager/JobContainer.tsx @@ -1,5 +1,13 @@ import clsx from 'clsx'; -import { forwardRef, ForwardRefExoticComponent, Fragment, HTMLAttributes, ReactNode } from 'react'; +import { + forwardRef, + ForwardRefExoticComponent, + Fragment, + HTMLAttributes, + ReactNode, + useEffect, + useState +} from 'react'; import { TextItems } from '@sd/client'; import { Tooltip, tw } from '@sd/ui'; @@ -8,22 +16,69 @@ import classes from './Job.module.scss'; interface JobContainerProps extends HTMLAttributes { name: string; icon?: string | ForwardRefExoticComponent; - // Array of arrays of TextItems, where each array of TextItems is a truncated line of text. textItems?: TextItems; isChild?: boolean; children?: ReactNode; + eta?: number; + status?: string; } -const CIRCLE_ICON_CLASS = `relative flex-shrink-0 top-1 z-20 mr-3 h-7 w-7 rounded-full bg-app-button p-[5.5px]`; -const IMG_ICON_CLASS = `relative left-[-2px] top-1 z-10 mr-2 h-8 w-8`; +const CIRCLE_ICON_CLASS = + 'relative flex-shrink-0 top-1 z-20 mr-3 h-7 w-7 rounded-full bg-app-button p-[5.5px]'; +const IMG_ICON_CLASS = 'relative left-[-2px] top-1 z-10 mr-2 h-8 w-8'; const MetaContainer = tw.div`flex w-full overflow-hidden flex-col`; const TextLine = tw.div`mt-[2px] gap-1 text-ink-faint truncate mr-8 pl-1.5`; const TextItem = tw.span`truncate`; +const formatETA = (eta: number): string => { + const seconds = Math.floor((eta / 1000) % 60); + const minutes = Math.floor((eta / (1000 * 60)) % 60); + const hours = Math.floor((eta / (1000 * 60 * 60)) % 24); + const days = Math.floor(eta / (1000 * 60 * 60 * 24)); + + let formattedETA = ''; + + if (days > 0) formattedETA += `${days} day${days > 1 ? 's' : ''} `; + if (hours > 0) formattedETA += `${hours} hour${hours > 1 ? 's' : ''} `; + if (minutes > 0) formattedETA += `${minutes} minute${minutes > 1 ? 's' : ''} `; + if (seconds > 0 || formattedETA === '') + formattedETA += `${seconds} second${seconds != 1 ? 's' : ''} `; + + return formattedETA.trim() + ' remaining'; +}; + // Job container consolidates the common layout of a job item, used for regular jobs (Job.tsx) and grouped jobs (JobGroup.tsx). const JobContainer = forwardRef((props, ref) => { - const { name, icon: Icon, textItems, isChild, children, className, ...restProps } = props; + const { + name, + icon: Icon, + textItems, + isChild, + children, + className, + eta, + status, + ...restProps + } = props; + const [currentETA, setCurrentETA] = useState(eta); + + useEffect(() => { + if (currentETA !== undefined && currentETA > 0) { + const interval = setInterval(() => { + setCurrentETA((prevETA) => { + if (prevETA === undefined || prevETA <= 1000) return 0; + return prevETA - 1000; + }); + }, 1000); + + return () => clearInterval(interval); + } + }, [currentETA]); + + useEffect(() => { + setCurrentETA(eta); + }, [eta]); return (
  • ((props, ref) =

    {name}

    {textItems?.map((item, index) => { - // filter out undefined text so we don't render empty TextItems const filteredItems = item.filter((i) => i?.text); const popoverText = filteredItems.map((i) => i?.text).join(' • '); @@ -73,7 +127,6 @@ const JobContainer = forwardRef((props, ref) = 0 && 'px-1.5 py-0.5 italic', 'tabular-nums', textItem?.onClick && '-ml-1.5 rounded-md hover:bg-app-button/50' @@ -94,6 +147,13 @@ const JobContainer = forwardRef((props, ref) = ); })} + {status == 'Running' && ( +
    + {currentETA !== undefined + ? formatETA(currentETA) + : 'Unable to calculate estimated completion time'} +
    + )} ); diff --git a/interface/app/$libraryId/Layout/Sidebar/JobManager/JobGroup.tsx b/interface/app/$libraryId/Layout/Sidebar/JobManager/JobGroup.tsx index b68449c8e..aa5541f4e 100644 --- a/interface/app/$libraryId/Layout/Sidebar/JobManager/JobGroup.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/JobManager/JobGroup.tsx @@ -30,7 +30,7 @@ export default function ({ group, progress }: JobGroupProps) { const [showChildJobs, setShowChildJobs] = useState(false); - const runningJob = jobs.find((job) => job.status === 'Running'); + const runningJob = jobs.find((job: { status: string }) => job.status === 'Running'); const tasks = getTotalTasks(jobs); const totalGroupTime = useTotalElapsedTimeText(jobs); @@ -43,6 +43,16 @@ export default function ({ group, progress }: JobGroupProps) { if (jobs.length === 0) return <>; const { t } = useLocale(); + const calculateETA = (job: Report) => { + let diff = 0; + if (job.created_at && job.estimated_completion) { + const start = new Date(job.created_at); + const end = new Date(job.estimated_completion); + diff = Math.abs(end.getTime() - start.getTime()); + } + return diff; + }; + return (
      @@ -105,19 +115,29 @@ export default function ({ group, progress }: JobGroupProps) { {showChildJobs && (
      - {jobs.map((job) => ( - 1} - key={job.id} - job={job} - progress={progress[job.id] ?? null} - /> - ))} + {jobs.map((job) => { + const diff = calculateETA(job); + + return ( + 1} + key={job.id} + job={job} + progress={progress[job.id] ?? null} + eta={diff} + /> + ); + })}
      )} ) : ( - + // add eta for individual jobs + )}
    ); diff --git a/interface/app/$libraryId/Layout/Sidebar/JobManager/index.tsx b/interface/app/$libraryId/Layout/Sidebar/JobManager/index.tsx index df6a965e1..9bf6a85d0 100644 --- a/interface/app/$libraryId/Layout/Sidebar/JobManager/index.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/JobManager/index.tsx @@ -4,6 +4,7 @@ import dayjs from 'dayjs'; import { useState } from 'react'; import { JobGroup as IJobGroup, + Report, useJobProgress, useLibraryMutation, useLibraryQuery @@ -60,25 +61,49 @@ export function JobManager() { const { t } = useLocale(); - const clearAllJobs = useLibraryMutation(['jobs.clearAll'], { - onError: () => { - toast.error({ - title: t('error'), - body: t('failed_to_clear_all_jobs') + const clearJob = useLibraryMutation(['jobs.clear']); + + const clearAllJobsHandler = async () => { + try { + const clearPromises: Promise[] = []; + jobGroups.data?.forEach((group: IJobGroup) => { + if (group.jobs.length > 1) { + let allComplete = true; + group.jobs.forEach((job: Report) => { + if (job.status !== 'Completed' && job.status !== 'CompletedWithErrors') { + allComplete = false; + } + }); + if (allComplete) { + group.jobs.forEach((job: Report) => { + clearPromises.push(clearJob.mutateAsync(job.id)); + }); + } + } else { + if ( + group.status === 'Completed' || + group.status === 'CompletedWithErrors' || + group.status === 'Canceled' || + group.status === 'Failed' + ) { + clearPromises.push(clearJob.mutateAsync(group.id)); + } + } }); - }, - onSuccess: () => { - queryClient.invalidateQueries(['jobs.reports ']); + await Promise.all(clearPromises); + setToggleConfirmation((t) => !t); toast.success({ title: t('success'), body: t('all_jobs_have_been_cleared') }); + queryClient.invalidateQueries(['jobs.reports']); + } catch (error) { + toast.error({ + title: t('error'), + body: t('failed_to_clear_all_jobs') + }); } - }); - - const clearAllJobsHandler = () => { - clearAllJobs.mutate(null); }; return ( diff --git a/interface/locales/en/common.json b/interface/locales/en/common.json index 14630e917..eaf2e95c2 100644 --- a/interface/locales/en/common.json +++ b/interface/locales/en/common.json @@ -27,7 +27,7 @@ "advanced_settings": "Advanced settings", "album": "Album", "alias": "Alias", - "all_jobs_have_been_cleared": "All jobs have been cleared.", + "all_jobs_have_been_cleared": "All completed jobs have been cleared.", "alpha_release_description": "We are delighted for you to try Spacedrive, now in Alpha release, showcasing exciting new features. As with any initial release, this version may contain some bugs. We kindly request your assistance in reporting any issues you encounter on our Discord channel. Your valuable feedback will greatly contribute to enhancing the user experience.", "alpha_release_title": "Alpha Release", "app_crashed": "APP CRASHED",