mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-05-19 13:55:40 -04:00
[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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user