[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>
This commit is contained in:
Matthew Yung
2024-07-17 07:41:06 -07:00
committed by GitHub
parent 8abff38aae
commit 0bb073e8bb
5 changed files with 139 additions and 32 deletions

View File

@@ -21,6 +21,7 @@ interface JobProps {
className?: string;
isChild?: boolean;
progress: JobProgressEvent | null;
eta: number;
}
const JobIcon: Record<string, Icon> = {
@@ -33,10 +34,9 @@ const JobIcon: Record<string, Icon> = {
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
}

View File

@@ -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<HTMLLIElement> {
name: string;
icon?: string | ForwardRefExoticComponent<any>;
// 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<HTMLLIElement, JobContainerProps>((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<number | undefined>(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 (
<li
@@ -54,7 +109,6 @@ const JobContainer = forwardRef<HTMLLIElement, JobContainerProps>((props, ref) =
<p className="w-fit max-w-[83%] truncate pl-1.5 font-semibold">{name}</p>
</Tooltip>
{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<HTMLLIElement, JobContainerProps>((props, ref) =
<TextItem
onClick={textItem?.onClick}
className={clsx(
// index > 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<HTMLLIElement, JobContainerProps>((props, ref) =
</Fragment>
);
})}
{status == 'Running' && (
<div className="text-[0.8rem] text-gray-400 opacity-60">
{currentETA !== undefined
? formatETA(currentETA)
: 'Unable to calculate estimated completion time'}
</div>
)}
</TextLine>
</Tooltip>
);

View File

@@ -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 (
<ul className="relative overflow-visible">
<div className="row absolute right-3 top-3 z-50 flex space-x-1">
@@ -105,19 +115,29 @@ export default function ({ group, progress }: JobGroupProps) {
</JobContainer>
{showChildJobs && (
<div>
{jobs.map((job) => (
<Job
isChild={jobs.length > 1}
key={job.id}
job={job}
progress={progress[job.id] ?? null}
/>
))}
{jobs.map((job) => {
const diff = calculateETA(job);
return (
<Job
isChild={jobs.length > 1}
key={job.id}
job={job}
progress={progress[job.id] ?? null}
eta={diff}
/>
);
})}
</div>
)}
</>
) : (
<Job job={jobs[0]!} progress={progress[jobs[0]!.id] || null} />
// add eta for individual jobs
<Job
job={jobs[0]!}
progress={progress[jobs[0]!.id] || null}
eta={calculateETA(jobs[0]!)}
/>
)}
</ul>
);

View File

@@ -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<null>[] = [];
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 (

View File

@@ -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",