Files
rclone-ui/src/components/JobDetailsDrawer.tsx
T
2026-02-05 20:37:04 +03:00

462 lines
22 KiB
TypeScript

import {
Alert,
Button,
Chip,
Drawer,
DrawerBody,
DrawerContent,
DrawerHeader,
Progress,
Spinner,
Tooltip,
cn,
} from '@heroui/react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { message } from '@tauri-apps/plugin-dialog'
import { platform } from '@tauri-apps/plugin-os'
import { ExternalLinkIcon, RefreshCwIcon, SearchCheckIcon, SquareIcon } from 'lucide-react'
import { useMemo, useState } from 'react'
import { formatBytes } from '../../lib/format'
import notify from '../../lib/notify'
import { startBatch } from '../../lib/rclone/api'
import rclone from '../../lib/rclone/client'
import type { JobItem } from '../../types/jobs'
export default function JobDetailsDrawer({
isOpen,
onClose,
selectedJob,
onSelectJob,
}: {
isOpen: boolean
onClose: () => void
selectedJob: JobItem
onSelectJob?: (job: JobItem) => void
}) {
const queryClient = useQueryClient()
const [retryStatus, setRetryStatus] = useState<
Map<string, { status: 'pending' | 'started' | 'error'; jobId?: number; error?: string }>
>(new Map())
const jobStatusQuery = useQuery({
queryKey: ['jobs', 'status', selectedJob.id],
queryFn: async () =>
rclone('/job/status', {
params: {
query: {
jobid: selectedJob.id,
},
},
}),
enabled: !!selectedJob.id,
refetchInterval: 1000,
})
const mainError = useMemo(() => jobStatusQuery.data?.error || null, [jobStatusQuery.data])
const otherErrors = useMemo(
() =>
!!jobStatusQuery.data?.output &&
typeof jobStatusQuery.data?.output === 'object' &&
'results' in jobStatusQuery.data.output &&
Array.isArray(jobStatusQuery.data.output.results)
? (jobStatusQuery.data.output.results
.map((item: any) => {
if (!item.error) return null
return { error: item.error, input: item.input }
})
.filter(Boolean) as { error: string; input: object }[])
: [],
[jobStatusQuery.data]
)
const transferredQuery = useQuery({
queryKey: ['jobs', 'transferring', selectedJob.id],
queryFn: async () =>
rclone('/core/transferred', {
params: {
query: {
group: `job/${selectedJob.id}`,
},
},
}),
enabled: !!selectedJob.id,
refetchInterval: 1000,
})
const jobGroupStatsQuery = useQuery({
queryKey: ['jobs', 'group', 'stats', selectedJob.id],
queryFn: async () =>
rclone('/core/stats', {
params: {
query: {
group: `job/${selectedJob.id}`,
},
},
}),
enabled: !!selectedJob.id,
refetchInterval: 1000,
})
const stopJobMutation = useMutation({
mutationFn: async (jobId: number) => {
await rclone('/job/stopgroup', {
params: {
query: {
group: `job/${jobId}`,
},
},
})
},
onSuccess: async () => {
queryClient.refetchQueries({ queryKey: ['transfers', 'list', 'all'] })
await notify({
title: 'Job stopped',
body: 'It will still appear as active, with declining transfer speeds, until rclone cleans it up',
})
},
onError: async (error) => {
console.error('Failed to stop job:', error)
await message(error instanceof Error ? error.message : 'Unknown error occurred', {
title: 'Could not stop job',
kind: 'error',
})
},
})
const retryMutation = useMutation({
mutationFn: async ({
key,
input,
}: { key: string; input: { _path: string } & Record<string, any> }) => {
setRetryStatus((prev) => new Map(prev).set(key, { status: 'pending' }))
const jobId = await startBatch([input])
return { key, jobId }
},
onSuccess: ({ key, jobId }) => {
setRetryStatus((prev) => new Map(prev).set(key, { status: 'started', jobId }))
queryClient.refetchQueries({ queryKey: ['transfers', 'list', 'all'] })
},
onError: (error, { key }) => {
console.error('Failed to retry transfer:', error)
setRetryStatus((prev) =>
new Map(prev).set(key, {
status: 'error',
error: error instanceof Error ? error.message : 'Unknown error',
})
)
},
})
const transferred = useMemo(
() => transferredQuery.data?.transferred || [],
[transferredQuery.data]
)
const transferring = useMemo(
() => jobGroupStatsQuery.data?.transferring || [],
[jobGroupStatsQuery.data]
)
const checking = useMemo(
() => jobGroupStatsQuery.data?.checking || [],
[jobGroupStatsQuery.data]
)
const errorInputMap = useMemo(() => {
const map = new Map<string, { _path: string } & Record<string, any>>()
for (const err of otherErrors) {
const input = err.input as { srcRemote?: string; _path: string } & Record<string, any>
if (input?.srcRemote) {
map.set(input.srcRemote, input)
}
}
return map
}, [otherErrors])
return (
<Drawer isOpen={isOpen} placement="bottom" size="2xl" onClose={onClose}>
<DrawerContent
className={cn(
'bg-content1/80 backdrop-blur-md dark:bg-content1/90',
platform() === 'macos' ? 'pt-6' : undefined
)}
>
<DrawerHeader className="flex flex-row items-center gap-2">
Job Details #{selectedJob.id}{' '}
{selectedJob.type === 'inactive' ? (
<Chip color="primary" size="sm">
FINISHED
</Chip>
) : (
<Tooltip content="Stop job" placement="right" color="foreground">
<Button
isIconOnly={true}
color="danger"
size="sm"
variant="light"
onPress={() => {
stopJobMutation.mutate(selectedJob.id)
}}
>
<SquareIcon fill="currentColor" className="w-5 rounded-small" />
</Button>
</Tooltip>
)}
</DrawerHeader>
<DrawerBody className="pb-10">
{mainError || otherErrors.length > 0 ? (
<Alert color="danger" variant="faded" hideIcon={true}>
{mainError}
{otherErrors.length > 0 ? (
<pre className="break-all whitespace-pre-wrap">
{JSON.stringify(otherErrors, null, 2)}
</pre>
) : null}
</Alert>
) : null}
{selectedJob.isDryRun && (
<Alert color="warning" variant="faded">
This is a dry-run operation. No files were actually transferred.
</Alert>
)}
{checking.length > 0 ? (
<div className="flex flex-col gap-2 pb-4">
<div className="flex flex-row items-center justify-between gap-2">
<h3 className="flex flex-row items-center gap-2 text-lg font-medium">
<SearchCheckIcon className="w-5 h-5 text-warning" />
Checking
</h3>
{jobGroupStatsQuery.isLoading ? (
<Spinner />
) : (
<Chip size="sm" color="warning" variant="flat">
{checking.length} item{checking.length === 1 ? '' : 's'}
</Chip>
)}
</div>
<p className="text-sm text-default-500">
Files are being verified before transfer. This can take a while for
large directories.
</p>
{checking.map((item, itemIndex) => {
const size = item.size ? formatBytes(item.size) : 'Unknown size'
return (
<div
key={item.name || itemIndex}
className="flex flex-row items-center justify-between gap-2 pb-2 border-b border-divider"
>
<p className="flex-1 line-clamp-1 min-w-80">{item.name}</p>
<p className="text-sm tabular-nums text-default-500">
{size}
</p>
</div>
)
})}
</div>
) : null}
<div className="flex flex-col gap-2">
<div className="flex flex-row items-center justify-between gap-2">
<h3 className="text-lg font-medium">Transferring</h3>
{jobGroupStatsQuery.isLoading ? (
<Spinner />
) : (
`${transferring.length} items`
)}
</div>
{!jobGroupStatsQuery.isLoading && transferring.length === 0 ? (
<p>No items transferring</p>
) : null}
{transferring.map((item, itemIndex) => {
// biome-ignore lint/style/useExplicitLengthCheck: <not relevant>
const size = item.size ? formatBytes(item.size) : '0 B'
const bytes = item.bytes ? formatBytes(item.bytes) : '0 B'
const speed = item.speed ? formatBytes(item.speed) : '0 B/s'
return (
<div
key={item.name || itemIndex}
className="flex flex-row items-center justify-between gap-5 pb-2 border-b border-divider"
>
<p className="flex-1 line-clamp-1 min-w-80">{item.name}</p>
<p className="w-24 tabular-nums shrink-0 whitespace-nowrap">
{speed}/s
</p>
<Tooltip
content={`Transferred ${bytes} of ${size}`}
color="foreground"
placement="bottom"
size="lg"
>
<Progress
value={item.percentage}
classNames={{
base: 'overflow-hidden rounded-full',
}}
/>
</Tooltip>
</div>
)
})}
</div>
<div className="flex flex-col gap-8 pt-6 ">
<div className="flex flex-col gap-2">
<div className="flex flex-row items-center justify-between gap-2 pb-2">
<h3 className="text-lg font-medium">Transferred</h3>
{transferredQuery.isLoading ? (
<Spinner />
) : (
`${transferred.length} items`
)}
</div>
{!transferredQuery.isLoading && transferred.length === 0 ? (
<p>No items transferred</p>
) : null}
{transferred.map((item, itemIndex) => {
const itemKey = item.name || `item-${itemIndex}`
const itemRetryStatus = retryStatus.get(itemKey)
const inputForRetry = item.name
? errorInputMap.get(item.name)
: undefined
const canRetry =
item.error && inputForRetry && !itemRetryStatus?.status
return (
<div
key={itemKey}
className="flex flex-row items-center justify-between gap-2 pb-2 border-b border-divider"
>
<p className="flex-1 line-clamp-1">{item.name}</p>
{itemRetryStatus?.status === 'pending' && (
<div className="flex items-center gap-2 text-default-500">
<Spinner size="sm" />
<span className="text-sm">Retrying...</span>
</div>
)}
{itemRetryStatus?.status === 'started' &&
itemRetryStatus.jobId && (
<div className="flex items-center gap-2">
<Chip size="sm" color="success" variant="flat">
Job #{itemRetryStatus.jobId}
</Chip>
{onSelectJob && (
<Tooltip
content="View new job"
color="foreground"
>
<Button
isIconOnly={true}
size="sm"
variant="light"
onPress={() => {
onClose()
setTimeout(() => {
onSelectJob({
id: itemRetryStatus.jobId!,
type: 'active',
bytes: 0,
totalBytes: 0,
speed: 0,
done: false,
progress: 0,
hasError: false,
sources: [],
isChecking: false,
checkingCount: 0,
})
}, 300)
}}
>
<ExternalLinkIcon className="w-4 h-4" />
</Button>
</Tooltip>
)}
</div>
)}
{itemRetryStatus?.status === 'error' && (
<div className="flex items-center gap-2">
<span className="text-sm text-danger">
{itemRetryStatus.error || 'Retry failed'}
</span>
{inputForRetry && (
<Tooltip
content="Retry again"
color="foreground"
>
<Button
isIconOnly={true}
size="sm"
color="warning"
variant="flat"
onPress={() => {
setRetryStatus((prev) => {
const newMap = new Map(prev)
newMap.delete(itemKey)
return newMap
})
retryMutation.mutate({
key: itemKey,
input: inputForRetry,
})
}}
>
<RefreshCwIcon className="w-4 h-4" />
</Button>
</Tooltip>
)}
</div>
)}
{!itemRetryStatus && item.error && (
<div className="flex items-center gap-2">
<span className="text-sm text-danger line-clamp-1 max-w-64">
{item.error}
</span>
{canRetry && inputForRetry && (
<Tooltip
content="Retry this file"
color="foreground"
>
<Button
isIconOnly={true}
size="sm"
color="danger"
variant="flat"
onPress={() =>
retryMutation.mutate({
key: itemKey,
input: inputForRetry,
})
}
>
<RefreshCwIcon className="w-4 h-4" />
</Button>
</Tooltip>
)}
</div>
)}
{!itemRetryStatus && !item.error && item.completed_at && (
<p className="text-sm text-default-500">
{new Date(item.completed_at).toLocaleString()}
</p>
)}
</div>
)
})}
</div>
</div>
</DrawerBody>
</DrawerContent>
</Drawer>
)
}