scheduler
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import { Select, SelectItem } from '@heroui/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { FolderOpenIcon } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { formatErrorMessage } from '../../lib/errors'
|
||||
import { probeRcloneBinaryOrThrow } from '../../lib/rclone/common'
|
||||
import { listDownloadedVersions } from '../../lib/rclone/versions'
|
||||
|
||||
export const APP_DEFAULT_BINARY = 'app-default'
|
||||
const CUSTOM_BINARY = 'custom'
|
||||
|
||||
/**
|
||||
* Picker for the rclone binary a scheduled task runs with: "App default", any downloaded
|
||||
* managed version, or a custom path (probed before acceptance).
|
||||
*/
|
||||
export default function BinarySelect({
|
||||
value,
|
||||
onChange,
|
||||
onError,
|
||||
label = 'rclone binary',
|
||||
}: {
|
||||
value: string
|
||||
onChange: (path: string) => void
|
||||
onError?: (message: string) => void
|
||||
label?: string
|
||||
}) {
|
||||
const versionsQuery = useQuery({
|
||||
queryKey: ['rclone', 'downloaded-versions'],
|
||||
queryFn: listDownloadedVersions,
|
||||
})
|
||||
|
||||
const knownBinaryPaths = useMemo(
|
||||
() => (versionsQuery.data ?? []).map((version) => version.path),
|
||||
[versionsQuery.data]
|
||||
)
|
||||
const isCustomBinary = value !== APP_DEFAULT_BINARY && !knownBinaryPaths.includes(value)
|
||||
|
||||
const binaryOptions = useMemo(
|
||||
() => [
|
||||
{ key: APP_DEFAULT_BINARY, label: 'App default', description: undefined },
|
||||
...(versionsQuery.data ?? []).map((version) => ({
|
||||
key: version.path,
|
||||
label: `v${version.version}`,
|
||||
description: version.path,
|
||||
})),
|
||||
{
|
||||
key: CUSTOM_BINARY,
|
||||
label: isCustomBinary ? 'Custom binary' : 'Custom…',
|
||||
description: isCustomBinary ? value : undefined,
|
||||
},
|
||||
],
|
||||
[versionsQuery.data, isCustomBinary, value]
|
||||
)
|
||||
|
||||
const pickCustomBinary = async () => {
|
||||
const selected = await open({
|
||||
title: 'Select rclone binary',
|
||||
multiple: false,
|
||||
directory: false,
|
||||
})
|
||||
if (!selected) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await probeRcloneBinaryOrThrow(selected)
|
||||
} catch (error) {
|
||||
onError?.(formatErrorMessage(error, 'The selected file is not a valid rclone binary'))
|
||||
return
|
||||
}
|
||||
onChange(selected)
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
label={label}
|
||||
labelPlacement="outside"
|
||||
selectedKeys={[isCustomBinary ? CUSTOM_BINARY : value]}
|
||||
onSelectionChange={(keys) => {
|
||||
const key = Array.from(keys)[0]
|
||||
if (key === CUSTOM_BINARY) {
|
||||
pickCustomBinary()
|
||||
} else if (typeof key === 'string' && key) {
|
||||
onChange(key)
|
||||
}
|
||||
}}
|
||||
items={binaryOptions}
|
||||
>
|
||||
{(option) => (
|
||||
<SelectItem
|
||||
key={option.key}
|
||||
description={option.description}
|
||||
startContent={
|
||||
option.key === CUSTOM_BINARY ? (
|
||||
<FolderOpenIcon className="w-3.5 h-3.5" />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
)}
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Select, SelectItem } from '@heroui/react'
|
||||
import { LockIcon } from 'lucide-react'
|
||||
import type { ConfigFile } from '../../types/config'
|
||||
|
||||
/** Picker for the config file a scheduled task runs with (lock icon = encrypted config). */
|
||||
export default function ConfigSelect({
|
||||
configFiles,
|
||||
value,
|
||||
onChange,
|
||||
label = 'Config file',
|
||||
placeholder,
|
||||
}: {
|
||||
configFiles: ConfigFile[]
|
||||
value: string | null
|
||||
onChange: (id: string) => void
|
||||
label?: string
|
||||
placeholder?: string
|
||||
}) {
|
||||
return (
|
||||
<Select
|
||||
label={label}
|
||||
labelPlacement="outside"
|
||||
selectedKeys={value ? [value] : []}
|
||||
onSelectionChange={(keys) => {
|
||||
const id = Array.from(keys)[0]
|
||||
if (typeof id === 'string') {
|
||||
onChange(id)
|
||||
}
|
||||
}}
|
||||
items={configFiles.filter((config) => !!config.id)}
|
||||
placeholder={placeholder ?? 'Select a config'}
|
||||
>
|
||||
{(config) => (
|
||||
<SelectItem
|
||||
key={config.id!}
|
||||
startContent={
|
||||
config.isEncrypted ? (
|
||||
<LockIcon className="w-3.5 h-3.5 text-warning" />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{config.label || config.id}
|
||||
</SelectItem>
|
||||
)}
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
/** The scheduled runner cannot prompt for passwords — surface this before the task is saved. */
|
||||
export function configPasswordMissing(configFiles: ConfigFile[], configId: string | null): boolean {
|
||||
const config = configFiles.find((c) => c.id === configId)
|
||||
return !!config?.isEncrypted && !config.pass && !config.passCommand
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import { startTransition, useCallback, useEffect, useMemo, useRef, useState } fr
|
||||
interface CronEditorProps {
|
||||
expression: string | null
|
||||
onChange: (newExpression: string | null) => void
|
||||
/** Platform-specific validation error from scheduler_validate_cron. */
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
interface CronFieldProps {
|
||||
@@ -18,7 +20,7 @@ interface CronFieldProps {
|
||||
|
||||
const DEFAULT_OPTIONS = ['*', '*/5', '*/10', '*/15', '*/30']
|
||||
|
||||
export default function CronEditor({ expression, onChange }: CronEditorProps) {
|
||||
export default function CronEditor({ expression, onChange, error }: CronEditorProps) {
|
||||
const [cronExpression, setCronExpression] = useState(expression)
|
||||
|
||||
const [minute, hour, dayOfMonth, month, dayOfWeek] = useMemo(
|
||||
@@ -31,8 +33,7 @@ export default function CronEditor({ expression, onChange }: CronEditorProps) {
|
||||
let description: string
|
||||
try {
|
||||
description = cronstrue.toString(cronExpression)
|
||||
description +=
|
||||
'. Tasks are triggered when the UI is running, if the active config is the same.'
|
||||
description += '. Runs on a system schedule, even when the app is closed.'
|
||||
} catch {
|
||||
description = 'Invalid cron expression'
|
||||
}
|
||||
@@ -60,6 +61,14 @@ export default function CronEditor({ expression, onChange }: CronEditorProps) {
|
||||
onChange(cronExpression)
|
||||
}, [cronExpression, onChange])
|
||||
|
||||
// Re-sync when the parent changes `expression` out from under us (the edit drawer coercing a
|
||||
// cleared field to '* * * * *', or an Advanced-section reset to null). Without this the
|
||||
// control keeps its stale internal value — showing "not scheduled" while the parent saves a
|
||||
// real cron. Echoes of our own emitted value set the same string and no-op.
|
||||
useEffect(() => {
|
||||
setCronExpression(expression)
|
||||
}, [expression])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full gap-2">
|
||||
<Input
|
||||
@@ -118,6 +127,7 @@ export default function CronEditor({ expression, onChange }: CronEditorProps) {
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-neutral-500">{readableDescription}</div>
|
||||
{!!error && <div className="text-sm text-danger-500">{error}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,18 +8,33 @@ import {
|
||||
DrawerContent,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
Input,
|
||||
ScrollShadow,
|
||||
Switch,
|
||||
Tab,
|
||||
Tabs,
|
||||
cn,
|
||||
} from '@heroui/react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import CronExpressionParser from 'cron-parser'
|
||||
import { format } from 'date-fns'
|
||||
import { format, formatDistance } from 'date-fns'
|
||||
import { CalendarClockIcon } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { formatErrorMessage } from '../../lib/errors'
|
||||
import { buildReadablePath } from '../../lib/format'
|
||||
import { useNow } from '../../lib/hooks'
|
||||
import {
|
||||
DEFAULT_MAX_RUN_HOURS,
|
||||
MAX_RUN_HOURS_LIMIT,
|
||||
schedulerReadHistory,
|
||||
schedulerReadLog,
|
||||
updateScheduledTask as schedulerUpdateTask,
|
||||
schedulerValidateCron,
|
||||
} from '../../lib/scheduler'
|
||||
import { useHostStore } from '../../store/host'
|
||||
import type { ScheduledTask } from '../../types/schedules'
|
||||
import BinarySelect from './BinarySelect'
|
||||
import ConfigSelect, { configPasswordMissing as isConfigPasswordMissing } from './ConfigSelect'
|
||||
import CronEditor from './CronEditor'
|
||||
|
||||
export default function ScheduleEditDrawer({
|
||||
@@ -31,15 +46,39 @@ export default function ScheduleEditDrawer({
|
||||
onClose: () => void
|
||||
selectedTask: ScheduledTask
|
||||
}) {
|
||||
const updateScheduledTask = useHostStore((state) => state.updateScheduledTask)
|
||||
const queryClient = useQueryClient()
|
||||
const configFiles = useHostStore((state) => state.configFiles)
|
||||
|
||||
const [cronExpression, setCronExpression] = useState(selectedTask.cron)
|
||||
const [configId, setConfigId] = useState(selectedTask.configId)
|
||||
const [binaryPath, setBinaryPath] = useState(selectedTask.binaryPath)
|
||||
const [isEnabled, setIsEnabled] = useState(selectedTask.isEnabled)
|
||||
const [verboseLogging, setVerboseLogging] = useState(selectedTask.verboseLogging ?? false)
|
||||
const [runMode, setRunMode] = useState<'system' | 'user'>(selectedTask.runMode ?? 'user')
|
||||
const [maxRunHours, setMaxRunHours] = useState(
|
||||
String(selectedTask.maxRunHours ?? DEFAULT_MAX_RUN_HOURS)
|
||||
)
|
||||
const [logView, setLogView] = useState<'runner' | 'daemon'>('runner')
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setCronExpression(selectedTask.cron)
|
||||
setConfigId(selectedTask.configId)
|
||||
setBinaryPath(selectedTask.binaryPath)
|
||||
setIsEnabled(selectedTask.isEnabled)
|
||||
setVerboseLogging(selectedTask.verboseLogging ?? false)
|
||||
setRunMode(selectedTask.runMode ?? 'user')
|
||||
setMaxRunHours(String(selectedTask.maxRunHours ?? DEFAULT_MAX_RUN_HOURS))
|
||||
setSaveError(null)
|
||||
}
|
||||
}, [isOpen, selectedTask.cron])
|
||||
}, [isOpen, selectedTask])
|
||||
|
||||
const maxRunHoursNumber = Number(maxRunHours)
|
||||
const maxRunHoursInvalid =
|
||||
!Number.isInteger(maxRunHoursNumber) ||
|
||||
maxRunHoursNumber < 1 ||
|
||||
maxRunHoursNumber > MAX_RUN_HOURS_LIMIT
|
||||
|
||||
const source = useMemo(
|
||||
() =>
|
||||
@@ -56,32 +95,99 @@ export default function ScheduleEditDrawer({
|
||||
// current time on every open (and kept fresh while open) — paused while closed.
|
||||
const now = useNow(isOpen ? 30_000 : null)
|
||||
|
||||
const upcomingRuns = useMemo(() => {
|
||||
try {
|
||||
const parsed = CronExpressionParser.parse(cronExpression, {
|
||||
currentDate: new Date(now),
|
||||
})
|
||||
const runs: Date[] = []
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (parsed.hasNext()) {
|
||||
runs.push(parsed.next().toDate())
|
||||
}
|
||||
}
|
||||
return runs
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}, [cronExpression, now])
|
||||
const cronValidation = useQuery({
|
||||
queryKey: ['scheduler', 'validate-cron', cronExpression],
|
||||
queryFn: () => schedulerValidateCron(cronExpression),
|
||||
enabled: isOpen && !!cronExpression,
|
||||
// The response carries the next-runs preview anchored at fetch time; without refetching,
|
||||
// a frequent schedule (e.g. every minute) drains all 5 entries past `now` while the
|
||||
// drawer sits open.
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
const cronError =
|
||||
cronValidation.data && !cronValidation.data.valid
|
||||
? (cronValidation.data.error ?? 'Invalid cron expression')
|
||||
: null
|
||||
|
||||
const hasChanges = useMemo(
|
||||
() => cronExpression !== selectedTask.cron,
|
||||
[cronExpression, selectedTask.cron]
|
||||
const historyQuery = useQuery({
|
||||
queryKey: ['scheduler', 'history', selectedTask.id],
|
||||
queryFn: () => schedulerReadHistory(selectedTask.id, 10),
|
||||
enabled: isOpen,
|
||||
refetchInterval: 15_000,
|
||||
})
|
||||
|
||||
const finishedRuns = useMemo(
|
||||
() => (historyQuery.data ?? []).filter((line) => line.event === 'finished'),
|
||||
[historyQuery.data]
|
||||
)
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
updateScheduledTask(selectedTask.id, { cron: cronExpression })
|
||||
onClose()
|
||||
}, [selectedTask.id, cronExpression, updateScheduledTask, onClose])
|
||||
const selectedConfig = useMemo(
|
||||
() => configFiles.find((config) => config.id === configId) ?? null,
|
||||
[configFiles, configId]
|
||||
)
|
||||
const configMissing = !selectedConfig
|
||||
const configPasswordMissing = isConfigPasswordMissing(configFiles, configId)
|
||||
|
||||
// From the validation query, i.e. computed in Rust by the runner's own cron matcher — a JS
|
||||
// library here can (and did) predict fires real cron never performs (dom/dow star flag).
|
||||
// The `now` filter keeps the list fresh between refetches of the 30s-anchored query.
|
||||
const upcomingRuns = useMemo(
|
||||
() =>
|
||||
(cronValidation.data?.nextRuns ?? [])
|
||||
.map((run) => new Date(run))
|
||||
.filter((run) => run.getTime() > now),
|
||||
[cronValidation.data, now]
|
||||
)
|
||||
|
||||
const hasChanges = useMemo(
|
||||
() =>
|
||||
cronExpression !== selectedTask.cron ||
|
||||
configId !== selectedTask.configId ||
|
||||
binaryPath !== selectedTask.binaryPath ||
|
||||
isEnabled !== selectedTask.isEnabled ||
|
||||
verboseLogging !== (selectedTask.verboseLogging ?? false) ||
|
||||
runMode !== (selectedTask.runMode ?? 'user') ||
|
||||
maxRunHoursNumber !== (selectedTask.maxRunHours ?? DEFAULT_MAX_RUN_HOURS),
|
||||
[
|
||||
cronExpression,
|
||||
configId,
|
||||
binaryPath,
|
||||
isEnabled,
|
||||
verboseLogging,
|
||||
runMode,
|
||||
maxRunHoursNumber,
|
||||
selectedTask,
|
||||
]
|
||||
)
|
||||
|
||||
const logQuery = useQuery({
|
||||
queryKey: ['scheduler', 'log', selectedTask.id, logView],
|
||||
queryFn: () => schedulerReadLog(selectedTask.id, logView),
|
||||
enabled: isOpen,
|
||||
refetchInterval: 5_000,
|
||||
})
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
setSaveError(null)
|
||||
await schedulerUpdateTask(selectedTask.id, {
|
||||
cron: cronExpression,
|
||||
configId,
|
||||
binaryPath,
|
||||
isEnabled,
|
||||
verboseLogging,
|
||||
runMode,
|
||||
maxRunHours: maxRunHoursNumber,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['scheduler'] })
|
||||
onClose()
|
||||
},
|
||||
onError: (error) => {
|
||||
setSaveError(formatErrorMessage(error, 'Failed to save the schedule'))
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
@@ -126,14 +232,21 @@ export default function ScheduleEditDrawer({
|
||||
<DrawerBody className="py-0">
|
||||
<ScrollShadow size={30} visibility="top">
|
||||
<div className="flex flex-col gap-6 pt-6 pb-10">
|
||||
{selectedTask.lastRunError && (
|
||||
{!!selectedTask.registrationError && (
|
||||
<Alert
|
||||
color="danger"
|
||||
variant="faded"
|
||||
title="Last Run Error"
|
||||
title="Not registered with the system scheduler"
|
||||
>
|
||||
<pre className="text-sm break-all whitespace-pre-wrap">
|
||||
{selectedTask.lastRunError}
|
||||
{selectedTask.registrationError}
|
||||
</pre>
|
||||
</Alert>
|
||||
)}
|
||||
{!!saveError && (
|
||||
<Alert color="danger" variant="faded" title="Save failed">
|
||||
<pre className="text-sm break-all whitespace-pre-wrap">
|
||||
{saveError}
|
||||
</pre>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -159,46 +272,98 @@ export default function ScheduleEditDrawer({
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm text-foreground-500">
|
||||
Last Run
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{selectedTask.lastRun
|
||||
? format(
|
||||
new Date(selectedTask.lastRun),
|
||||
'PPpp'
|
||||
)
|
||||
: 'Never'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm text-foreground-500">
|
||||
Status
|
||||
</p>
|
||||
<Chip
|
||||
size="sm"
|
||||
variant="flat"
|
||||
color={
|
||||
selectedTask.isRunning
|
||||
? 'success'
|
||||
: selectedTask.isEnabled
|
||||
? 'primary'
|
||||
: 'warning'
|
||||
}
|
||||
>
|
||||
{selectedTask.isRunning
|
||||
? 'Running'
|
||||
: selectedTask.isEnabled
|
||||
? 'Enabled'
|
||||
: 'Paused'}
|
||||
</Chip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="text-lg font-medium">Execution</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<ConfigSelect
|
||||
configFiles={configFiles}
|
||||
value={configId}
|
||||
onChange={setConfigId}
|
||||
placeholder={
|
||||
configMissing
|
||||
? 'Config no longer exists'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<BinarySelect
|
||||
value={binaryPath}
|
||||
onChange={(path) => {
|
||||
setSaveError(null)
|
||||
setBinaryPath(path)
|
||||
}}
|
||||
onError={setSaveError}
|
||||
/>
|
||||
</div>
|
||||
{configMissing && (
|
||||
<Alert color="danger" variant="faded" title="">
|
||||
The config this task used no longer exists — pick
|
||||
another one.
|
||||
</Alert>
|
||||
)}
|
||||
{configPasswordMissing && (
|
||||
<Alert
|
||||
color="warning"
|
||||
variant="faded"
|
||||
title="Encrypted config without a saved password"
|
||||
>
|
||||
This config is encrypted and has no saved password
|
||||
or password command. The scheduled runner cannot
|
||||
prompt for it, so runs will fail until you save the
|
||||
password in Settings → Config.
|
||||
</Alert>
|
||||
)}
|
||||
<Switch
|
||||
size="sm"
|
||||
color="primary"
|
||||
isSelected={isEnabled}
|
||||
onValueChange={setIsEnabled}
|
||||
data-focus-visible="false"
|
||||
>
|
||||
Enabled
|
||||
</Switch>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-small">Run mode</span>
|
||||
<Tabs
|
||||
size="sm"
|
||||
selectedKey={runMode}
|
||||
onSelectionChange={(key) =>
|
||||
setRunMode(key as 'system' | 'user')
|
||||
}
|
||||
data-focus-visible="false"
|
||||
>
|
||||
<Tab key="user" title="User" />
|
||||
<Tab key="system" title="System" />
|
||||
</Tabs>
|
||||
<span className="text-tiny text-default-400">
|
||||
{runMode === 'user'
|
||||
? 'Runs only while you are logged in, inside your session — OS keychain passwords and session-mounted drives work; fires while logged out are skipped. On macOS it runs as Rclone UI, so protected folders work once you grant the app access.'
|
||||
: 'Runs even while logged out, but outside your login session — no OS keychain or session-mounted drives, and protected folders on macOS need Full Disk Access for cron.'}
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
size="sm"
|
||||
color="primary"
|
||||
isSelected={verboseLogging}
|
||||
onValueChange={setVerboseLogging}
|
||||
data-focus-visible="false"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-small">Verbose logging</span>
|
||||
<span className="text-tiny text-default-400">
|
||||
Log individual transfers to the rclone log
|
||||
(grows faster)
|
||||
</span>
|
||||
</div>
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="flex items-center gap-2 text-lg font-medium">
|
||||
<CalendarClockIcon className="w-5 h-5" />
|
||||
@@ -246,8 +411,125 @@ export default function ScheduleEditDrawer({
|
||||
onChange={(expr) =>
|
||||
setCronExpression(expr || '* * * * *')
|
||||
}
|
||||
error={cronError}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="text-lg font-medium">Advanced</h3>
|
||||
<Input
|
||||
type="number"
|
||||
label="Max run time (hours)"
|
||||
labelPlacement="outside"
|
||||
min={1}
|
||||
max={MAX_RUN_HOURS_LIMIT}
|
||||
value={maxRunHours}
|
||||
onValueChange={setMaxRunHours}
|
||||
isInvalid={maxRunHoursInvalid}
|
||||
errorMessage={`Enter a whole number of hours between 1 and ${MAX_RUN_HOURS_LIMIT}`}
|
||||
description="A run still going after this long is stopped and marked failed."
|
||||
className="max-w-64"
|
||||
data-focus-visible="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="text-lg font-medium">Run History</h3>
|
||||
{finishedRuns.length > 0 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{finishedRuns.map((run) =>
|
||||
run.event === 'finished' ? (
|
||||
<div
|
||||
key={run.runId}
|
||||
className="flex items-center gap-3 text-sm"
|
||||
>
|
||||
<Chip
|
||||
size="sm"
|
||||
variant="flat"
|
||||
color={
|
||||
run.success
|
||||
? 'success'
|
||||
: 'danger'
|
||||
}
|
||||
>
|
||||
{run.success ? 'OK' : 'Failed'}
|
||||
</Chip>
|
||||
<span>
|
||||
{formatDistance(
|
||||
new Date(run.ts),
|
||||
new Date(now),
|
||||
{ addSuffix: true }
|
||||
)}
|
||||
</span>
|
||||
<span className="text-foreground-500">
|
||||
{Math.round(run.durationMs / 1000)}s
|
||||
</span>
|
||||
{!!run.error && (
|
||||
<span className="text-danger-500 line-clamp-1">
|
||||
{run.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-foreground-500">
|
||||
This task hasn't run yet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium">Logs</h3>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={
|
||||
logView === 'runner' ? 'solid' : 'light'
|
||||
}
|
||||
color={
|
||||
logView === 'runner' ? 'primary' : 'default'
|
||||
}
|
||||
onPress={() => setLogView('runner')}
|
||||
data-focus-visible="false"
|
||||
>
|
||||
Runner
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={
|
||||
logView === 'daemon' ? 'solid' : 'light'
|
||||
}
|
||||
color={
|
||||
logView === 'daemon' ? 'primary' : 'default'
|
||||
}
|
||||
onPress={() => setLogView('daemon')}
|
||||
data-focus-visible="false"
|
||||
>
|
||||
rclone
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{logQuery.data?.truncated && (
|
||||
<p className="text-tiny text-default-400">
|
||||
Showing the last 64 KB — older lines are on disk.
|
||||
</p>
|
||||
)}
|
||||
<pre className="p-3 overflow-auto font-mono whitespace-pre-wrap rounded-medium bg-content2 text-tiny max-h-64">
|
||||
{logQuery.data?.content ||
|
||||
(logView === 'runner'
|
||||
? 'No runner output yet.'
|
||||
: 'No rclone output yet.')}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollShadow>
|
||||
</DrawerBody>
|
||||
@@ -262,8 +544,14 @@ export default function ScheduleEditDrawer({
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
isDisabled={!hasChanges}
|
||||
onPress={handleSave}
|
||||
isDisabled={
|
||||
!hasChanges ||
|
||||
!!cronError ||
|
||||
configMissing ||
|
||||
maxRunHoursInvalid
|
||||
}
|
||||
isLoading={saveMutation.isPending}
|
||||
onPress={() => saveMutation.mutate()}
|
||||
data-focus-visible="false"
|
||||
>
|
||||
SAVE CHANGES
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { Button, cn } from '@heroui/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ChevronDownIcon, SlidersHorizontalIcon } from 'lucide-react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { LOCAL_HOST_ID } from '../../../lib/hosts'
|
||||
import { schedulerValidateCron, useSchedulerSupported } from '../../../lib/scheduler'
|
||||
import { useHostStore } from '../../../store/host'
|
||||
import { usePersistedStore } from '../../../store/persisted'
|
||||
import BinarySelect, { APP_DEFAULT_BINARY } from '../BinarySelect'
|
||||
import ConfigSelect, { configPasswordMissing } from '../ConfigSelect'
|
||||
import CronEditor from '../CronEditor'
|
||||
|
||||
export interface AdvancedSchedule {
|
||||
cronExpression: string | null
|
||||
setCronExpression: (expr: string | null) => void
|
||||
binaryPath: string
|
||||
setBinaryPath: (path: string) => void
|
||||
/** null = use the active config at creation time. */
|
||||
configId: string | null
|
||||
setConfigId: (id: string) => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
/** Page-lifted state for the Advanced section: the schedule the page's create flow reads. */
|
||||
export function useAdvancedSchedule(): AdvancedSchedule {
|
||||
const [cronExpression, setCronExpression] = useState<string | null>(null)
|
||||
const [binaryPath, setBinaryPath] = useState<string>(APP_DEFAULT_BINARY)
|
||||
const [configId, setConfigId] = useState<string | null>(null)
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setCronExpression(null)
|
||||
setBinaryPath(APP_DEFAULT_BINARY)
|
||||
setConfigId(null)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
cronExpression,
|
||||
setCronExpression,
|
||||
binaryPath,
|
||||
setBinaryPath,
|
||||
configId,
|
||||
setConfigId,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsible "Advanced" block rendered under the path inputs on the operation pages: cron
|
||||
* schedule plus the binary and config the scheduled task will run with. These apply to the
|
||||
* SCHEDULED task only (live runs go through the app's shared daemon), so the section hides
|
||||
* entirely where scheduling can't work (sandboxed installs, remote hosts).
|
||||
*/
|
||||
export default function AdvancedScheduleSection({ advanced }: { advanced: AdvancedSchedule }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [pickerError, setPickerError] = useState<string | null>(null)
|
||||
|
||||
const currentHostId = usePersistedStore((state) => state.currentHostId) ?? LOCAL_HOST_ID
|
||||
const supportQuery = useSchedulerSupported()
|
||||
const configFiles = useHostStore((state) => state.configFiles)
|
||||
const activeConfigId = useHostStore((state) => state.activeConfigId)
|
||||
|
||||
const cronValidation = useQuery({
|
||||
queryKey: ['scheduler', 'validate-cron', advanced.cronExpression],
|
||||
queryFn: () => schedulerValidateCron(advanced.cronExpression ?? ''),
|
||||
enabled: expanded && !!advanced.cronExpression,
|
||||
})
|
||||
const cronError =
|
||||
advanced.cronExpression && cronValidation.data && !cronValidation.data.valid
|
||||
? (cronValidation.data.error ?? 'Invalid cron expression')
|
||||
: null
|
||||
|
||||
if (currentHostId !== LOCAL_HOST_ID || !(supportQuery.data?.supported ?? false)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const effectiveConfigId = advanced.configId ?? activeConfigId
|
||||
const passwordMissing = configPasswordMissing(configFiles, effectiveConfigId)
|
||||
const hasSchedule = !!advanced.cronExpression
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 px-4">
|
||||
<Button
|
||||
variant="light"
|
||||
size="sm"
|
||||
className="self-start"
|
||||
startContent={<SlidersHorizontalIcon className="w-4 h-4" />}
|
||||
endContent={
|
||||
<ChevronDownIcon
|
||||
className={cn('w-4 h-4 transition-transform', expanded && 'rotate-180')}
|
||||
/>
|
||||
}
|
||||
onPress={() => setExpanded(!expanded)}
|
||||
data-focus-visible="false"
|
||||
>
|
||||
Advanced{hasSchedule ? ' — scheduled' : ''}
|
||||
</Button>
|
||||
|
||||
{expanded && (
|
||||
<div className="flex flex-col gap-4 p-4 border rounded-medium border-divider bg-content2/50">
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-semibold uppercase text-default-500">Schedule</p>
|
||||
<CronEditor
|
||||
expression={advanced.cronExpression}
|
||||
onChange={advanced.setCronExpression}
|
||||
error={cronError}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<ConfigSelect
|
||||
configFiles={configFiles}
|
||||
value={effectiveConfigId}
|
||||
onChange={advanced.setConfigId}
|
||||
/>
|
||||
<BinarySelect
|
||||
value={advanced.binaryPath}
|
||||
onChange={(path) => {
|
||||
setPickerError(null)
|
||||
advanced.setBinaryPath(path)
|
||||
}}
|
||||
onError={setPickerError}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-tiny text-default-400">
|
||||
The config and binary apply to the scheduled task; immediate runs use the
|
||||
app's active config and binary.
|
||||
</p>
|
||||
{!!pickerError && <p className="text-sm text-danger-500">{pickerError}</p>}
|
||||
{passwordMissing && (
|
||||
<p className="text-sm text-warning-600">
|
||||
This config is encrypted with no saved password — scheduled runs will
|
||||
fail until you save it in Settings → Config.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -11,7 +11,10 @@ import { platform } from '@tauri-apps/plugin-os'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { ClockIcon, EyeIcon } from 'lucide-react'
|
||||
import { type ComponentProps, type ReactNode, useCallback, useMemo } from 'react'
|
||||
import { LOCAL_HOST_ID } from '../../../lib/hosts'
|
||||
import { useSchedulerSupported } from '../../../lib/scheduler'
|
||||
import { openWindow } from '../../../lib/window'
|
||||
import { usePersistedStore } from '../../../store/persisted'
|
||||
import type { Template } from '../../../types/template'
|
||||
import CommandInfoButton from '../CommandInfoButton'
|
||||
import CommandsDropdown from '../CommandsDropdown'
|
||||
@@ -72,6 +75,20 @@ export default function OperationFooter({
|
||||
}) {
|
||||
const dropdownShadow = useMemo(() => (platform() === 'windows' ? 'none' : undefined), [])
|
||||
|
||||
// Scheduling is OS-native and local-host-only; hide the affordance where it can't work
|
||||
// (sandboxed installs, remote hosts).
|
||||
const currentHostId = usePersistedStore((state) => state.currentHostId) ?? LOCAL_HOST_ID
|
||||
const supportQuery = useSchedulerSupported()
|
||||
// Treat unresolved support as unavailable (matches AdvancedScheduleSection and Schedules.tsx)
|
||||
// — otherwise the Schedule button is enabled while the only cron-entry UI is still hidden.
|
||||
const schedulingAvailable =
|
||||
currentHostId === LOCAL_HOST_ID && (supportQuery.data?.supported ?? false)
|
||||
const scheduleTooltip = schedulingAvailable
|
||||
? 'Schedule task'
|
||||
: currentHostId !== LOCAL_HOST_ID
|
||||
? 'Scheduling is only available on your local machine'
|
||||
: (supportQuery.data?.reason ?? 'Scheduling is not available on this system')
|
||||
|
||||
const handleStartPress = useCallback(() => {
|
||||
setTimeout(() => onStart(), 100)
|
||||
}, [onStart])
|
||||
@@ -194,16 +211,19 @@ export default function OperationFooter({
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Tooltip content="Schedule task" placement="top" size="lg" color="foreground">
|
||||
<Button
|
||||
size="lg"
|
||||
type="button"
|
||||
color="primary"
|
||||
isIconOnly={true}
|
||||
onPress={handleSchedulePress}
|
||||
>
|
||||
<ClockIcon className="size-6" />
|
||||
</Button>
|
||||
<Tooltip content={scheduleTooltip} placement="top" size="lg" color="foreground">
|
||||
<div>
|
||||
<Button
|
||||
size="lg"
|
||||
type="button"
|
||||
color="primary"
|
||||
isIconOnly={true}
|
||||
isDisabled={!schedulingAvailable}
|
||||
onPress={handleSchedulePress}
|
||||
>
|
||||
<ClockIcon className="size-6" />
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<CommandInfoButton content={helpContent} />
|
||||
<CommandsDropdown currentCommand={operation} />
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Accordion, AccordionItem, Avatar } from '@heroui/react'
|
||||
import {
|
||||
ClockIcon,
|
||||
CopyIcon,
|
||||
DiamondPercentIcon,
|
||||
FilterIcon,
|
||||
@@ -14,7 +13,7 @@ import ShowMoreOptionsBanner from '../ShowMoreOptionsBanner'
|
||||
|
||||
// Avatar/indicator/title per option category — exactly what each page's accordion rendered.
|
||||
export const CATEGORY_META: Record<
|
||||
'copy' | 'sync' | 'move' | 'bisync' | 'filters' | 'cron' | 'config' | 'remotes',
|
||||
'copy' | 'sync' | 'move' | 'bisync' | 'filters' | 'config' | 'remotes',
|
||||
{
|
||||
title: string
|
||||
icon: ComponentType<{ className?: string }>
|
||||
@@ -33,7 +32,6 @@ export const CATEGORY_META: Record<
|
||||
avatarIconClassName: 'text-success-foreground',
|
||||
},
|
||||
filters: { title: 'Filters', icon: FilterIcon, avatarColor: 'danger' },
|
||||
cron: { title: 'Cron', icon: ClockIcon, avatarColor: 'warning' },
|
||||
config: { title: 'Config', icon: WrenchIcon, avatarColor: 'default' },
|
||||
remotes: { title: 'Remotes', icon: ServerIcon, avatarClassName: 'bg-fuchsia-500' },
|
||||
}
|
||||
@@ -50,7 +48,7 @@ export interface OptionsAccordionItemDef {
|
||||
/**
|
||||
* The option-group accordion shared by the operation pages: item scaffolding (Avatar,
|
||||
* indicator, title) comes from CATEGORY_META; each item's content (OptionsSection /
|
||||
* CronEditor / RemoteOptionsSection) stays page-supplied. `banner` wraps the accordion in the
|
||||
* RemoteOptionsSection) stays page-supplied. `banner` wraps the accordion in the
|
||||
* relative div with the ShowMoreOptionsBanner (Copy/Sync/Move); Bisync/Delete/Purge omit it.
|
||||
*/
|
||||
export default function OptionsAccordion({
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import cronstrue from 'cronstrue'
|
||||
import { onErrorDialog } from '../../../lib/errors'
|
||||
import notify from '../../../lib/notify'
|
||||
import { useHostStore } from '../../../store/host'
|
||||
import { notify } from '../../../lib/notifications'
|
||||
import { createScheduledTask } from '../../../lib/scheduler'
|
||||
import type { ScheduledTask } from '../../../types/schedules'
|
||||
|
||||
/**
|
||||
* The schedule mutation shared by the operation pages: page-specific validation (path checks,
|
||||
* the Copy/Move multi-source license gate) → cron validation → native name prompt →
|
||||
* addScheduledTask with the page-built args. `buildArgs` must return the EXACT persisted args
|
||||
* shape the page's start function takes — main.ts replays these verbatim.
|
||||
* the Copy/Move multi-source license gate) → per-platform cron validation → native name prompt →
|
||||
* createScheduledTask, which persists the task and registers it with the OS scheduler. The
|
||||
* headless runner replays the pre-serialized requests built from `buildArgs()` output.
|
||||
*/
|
||||
export function useScheduleTask<O extends ScheduledTask['operation']>({
|
||||
operation,
|
||||
cronExpression,
|
||||
configId,
|
||||
binaryPath,
|
||||
buildArgs,
|
||||
validate,
|
||||
}: {
|
||||
operation: O
|
||||
cronExpression: string | null
|
||||
/** From the page's Advanced section; omitted/null = active config. */
|
||||
configId?: string | null
|
||||
/** From the page's Advanced section; omitted = 'app-default'. */
|
||||
binaryPath?: string
|
||||
buildArgs: () => Extract<ScheduledTask, { operation: O }>['args']
|
||||
validate?: () => void
|
||||
}) {
|
||||
@@ -31,12 +36,6 @@ export function useScheduleTask<O extends ScheduledTask['operation']>({
|
||||
throw new Error('Please enter a cron expression')
|
||||
}
|
||||
|
||||
try {
|
||||
cronstrue.toString(cronExpression)
|
||||
} catch {
|
||||
throw new Error('Invalid cron expression')
|
||||
}
|
||||
|
||||
const name = await invoke<string | null>('prompt', {
|
||||
title: 'Schedule Name',
|
||||
message: 'Enter a name for this schedule',
|
||||
@@ -47,11 +46,13 @@ export function useScheduleTask<O extends ScheduledTask['operation']>({
|
||||
throw new Error('Schedule name is required')
|
||||
}
|
||||
|
||||
useHostStore.getState().addScheduledTask({
|
||||
await createScheduledTask({
|
||||
name,
|
||||
operation,
|
||||
cron: cronExpression,
|
||||
args: buildArgs(),
|
||||
configId: configId ?? undefined,
|
||||
binaryPath,
|
||||
})
|
||||
},
|
||||
onSuccess: async () => {
|
||||
|
||||
+12
-12
@@ -8,12 +8,14 @@ import { getOptionsSubtitle } from '../../lib/flags'
|
||||
import { useFlags } from '../../lib/hooks'
|
||||
import { startBisync } from '../../lib/rclone/api'
|
||||
import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants'
|
||||
import CronEditor from '../components/CronEditor'
|
||||
import OperationWindowContent from '../components/OperationWindowContent'
|
||||
import OperationWindowFooter from '../components/OperationWindowFooter'
|
||||
import OptionsSection from '../components/OptionsSection'
|
||||
import { PathFinder } from '../components/PathFinder'
|
||||
import RemoteOptionsSection from '../components/RemoteOptionsSection'
|
||||
import AdvancedScheduleSection, {
|
||||
useAdvancedSchedule,
|
||||
} from '../components/operation/AdvancedScheduleSection'
|
||||
import OperationFooter from '../components/operation/OperationFooter'
|
||||
import OptionsAccordion, {
|
||||
type OptionsAccordionItemDef,
|
||||
@@ -101,7 +103,7 @@ export default function Bisync() {
|
||||
|
||||
const [outerBisyncOptions, setOuterBisyncOptions] = useState<Record<string, boolean>>({})
|
||||
|
||||
const [cronExpression, setCronExpression] = useState<string | null>(null)
|
||||
const advanced = useAdvancedSchedule()
|
||||
|
||||
const selectedRemotes = useMemo(() => [source, dest].filter(Boolean), [source, dest])
|
||||
|
||||
@@ -139,7 +141,7 @@ export default function Bisync() {
|
||||
return startBisync(buildStartArgs())
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (cronExpression) {
|
||||
if (advanced.cronExpression) {
|
||||
scheduleTaskMutation.mutate()
|
||||
}
|
||||
},
|
||||
@@ -151,7 +153,9 @@ export default function Bisync() {
|
||||
|
||||
const scheduleTaskMutation = useScheduleTask({
|
||||
operation: 'bisync',
|
||||
cronExpression,
|
||||
cronExpression: advanced.cronExpression,
|
||||
configId: advanced.configId,
|
||||
binaryPath: advanced.binaryPath,
|
||||
validate: () => {
|
||||
if (!source || !dest) {
|
||||
throw new Error('Please select both a source and destination path')
|
||||
@@ -166,9 +170,9 @@ export default function Bisync() {
|
||||
if (!dest) return 'Please select a destination path'
|
||||
if (source === dest) return 'Source and destination cannot be the same'
|
||||
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
|
||||
if (cronExpression) return 'START AND SCHEDULE BISYNC'
|
||||
if (advanced.cronExpression) return 'START AND SCHEDULE BISYNC'
|
||||
return 'START BISYNC'
|
||||
}, [startBisyncMutation.isPending, source, dest, jsonError, cronExpression])
|
||||
}, [startBisyncMutation.isPending, source, dest, jsonError, advanced.cronExpression])
|
||||
|
||||
const buttonIcon = useMemo(() => {
|
||||
if (startBisyncMutation.isPending) return
|
||||
@@ -316,11 +320,6 @@ export default function Bisync() {
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'cron',
|
||||
category: 'cron',
|
||||
children: <CronEditor expression={cronExpression} onChange={setCronExpression} />,
|
||||
},
|
||||
{
|
||||
key: 'config',
|
||||
category: 'config',
|
||||
@@ -368,7 +367,6 @@ export default function Bisync() {
|
||||
copyFlags,
|
||||
filterGroup,
|
||||
filterFlags,
|
||||
cronExpression,
|
||||
configGroup,
|
||||
configFlags,
|
||||
selectedRemotes,
|
||||
@@ -426,6 +424,8 @@ export default function Bisync() {
|
||||
setDestPath={setDest}
|
||||
/>
|
||||
|
||||
<AdvancedScheduleSection advanced={advanced} />
|
||||
|
||||
<OptionsAccordion items={accordionItems} />
|
||||
</OperationWindowContent>
|
||||
|
||||
|
||||
+17
-17
@@ -8,12 +8,14 @@ import { useFlags } from '../../lib/hooks'
|
||||
import { startCopy, startDryRun } from '../../lib/rclone/api'
|
||||
import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants'
|
||||
import { usePersistedStore } from '../../store/persisted'
|
||||
import CronEditor from '../components/CronEditor'
|
||||
import OperationWindowContent from '../components/OperationWindowContent'
|
||||
import OperationWindowFooter from '../components/OperationWindowFooter'
|
||||
import OptionsSection from '../components/OptionsSection'
|
||||
import { MultiPathFinder } from '../components/PathFinder'
|
||||
import RemoteOptionsSection from '../components/RemoteOptionsSection'
|
||||
import AdvancedScheduleSection, {
|
||||
useAdvancedSchedule,
|
||||
} from '../components/operation/AdvancedScheduleSection'
|
||||
import OperationFooter from '../components/operation/OperationFooter'
|
||||
import OptionsAccordion, {
|
||||
type OptionsAccordionItemDef,
|
||||
@@ -40,7 +42,7 @@ Expand the accordion sections to customize your copy operation. Tap any chip on
|
||||
|
||||
• Filters — Include or exclude files by pattern, limit by size (max_size, min_size) or age (max_age, min_age).
|
||||
|
||||
• Cron — Schedule this copy to run automatically at set intervals. The schedule only triggers while the app is running.
|
||||
• Cron — Schedule this copy to run automatically at set intervals. It runs on a system schedule, even when the app is closed.
|
||||
|
||||
• Config — Performance tuning: parallel transfers, checkers, buffer_size, bandwidth limits (bwlimit), and fast_list for faster directory listings on supported remotes.
|
||||
|
||||
@@ -84,7 +86,7 @@ export default function Copy() {
|
||||
const filterGroup = optionGroups.filter
|
||||
const configGroup = optionGroups.config
|
||||
|
||||
const [cronExpression, setCronExpression] = useState<string | null>(null)
|
||||
const advanced = useAdvancedSchedule()
|
||||
|
||||
const selectedRemotes = useMemo(
|
||||
() => [...(sources || []), dest].filter(Boolean),
|
||||
@@ -111,7 +113,7 @@ export default function Copy() {
|
||||
return startCopy(buildArgs())
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (cronExpression) {
|
||||
if (advanced.cronExpression) {
|
||||
scheduleTaskMutation.mutate()
|
||||
}
|
||||
},
|
||||
@@ -123,7 +125,9 @@ export default function Copy() {
|
||||
|
||||
const scheduleTaskMutation = useScheduleTask({
|
||||
operation: 'copy',
|
||||
cronExpression,
|
||||
cronExpression: advanced.cronExpression,
|
||||
configId: advanced.configId,
|
||||
binaryPath: advanced.binaryPath,
|
||||
validate: () => {
|
||||
if (!sources || sources.length === 0 || !dest) {
|
||||
throw new Error('Please select both a source and destination path')
|
||||
@@ -160,9 +164,9 @@ export default function Copy() {
|
||||
if (!dest) return 'Please select a destination path'
|
||||
if (sources.some((s) => s === dest)) return 'Source and destination cannot be the same'
|
||||
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
|
||||
if (cronExpression) return 'START AND SCHEDULE COPY'
|
||||
if (advanced.cronExpression) return 'START AND SCHEDULE COPY'
|
||||
return 'START COPY'
|
||||
}, [startCopyMutation.isPending, sources, dest, jsonError, cronExpression])
|
||||
}, [startCopyMutation.isPending, sources, dest, jsonError, advanced.cronExpression])
|
||||
|
||||
const buttonIcon = useMemo(() => {
|
||||
if (startCopyMutation.isPending) return
|
||||
@@ -204,11 +208,6 @@ export default function Copy() {
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'cron',
|
||||
category: 'cron',
|
||||
children: <CronEditor expression={cronExpression} onChange={setCronExpression} />,
|
||||
},
|
||||
{
|
||||
key: 'config',
|
||||
category: 'config',
|
||||
@@ -258,7 +257,6 @@ export default function Copy() {
|
||||
copyFlags,
|
||||
filterFlags,
|
||||
configFlags,
|
||||
cronExpression,
|
||||
selectedRemotes,
|
||||
]
|
||||
)
|
||||
@@ -284,21 +282,21 @@ export default function Copy() {
|
||||
const handleResetOptions = useCallback(() => {
|
||||
startTransition(() => {
|
||||
resetJson()
|
||||
setCronExpression(null)
|
||||
advanced.reset()
|
||||
startCopyMutation.reset()
|
||||
})
|
||||
}, [resetJson, startCopyMutation.reset])
|
||||
}, [advanced.reset, resetJson, startCopyMutation.reset])
|
||||
|
||||
const handleResetAll = useCallback(() => {
|
||||
startTransition(() => {
|
||||
resetJson()
|
||||
resetLocks()
|
||||
setCronExpression(null)
|
||||
advanced.reset()
|
||||
setSources(undefined)
|
||||
setDest(undefined)
|
||||
startCopyMutation.reset()
|
||||
})
|
||||
}, [resetJson, resetLocks, startCopyMutation.reset])
|
||||
}, [advanced.reset, resetJson, resetLocks, startCopyMutation.reset])
|
||||
|
||||
useEffect(() => {
|
||||
console.log('[Copy] remoteOptions', remotesGroup.options)
|
||||
@@ -317,6 +315,8 @@ export default function Copy() {
|
||||
setDestPath={setDest}
|
||||
/>
|
||||
|
||||
<AdvancedScheduleSection advanced={advanced} />
|
||||
|
||||
<OptionsAccordion banner={true} items={accordionItems} />
|
||||
</OperationWindowContent>
|
||||
|
||||
|
||||
+19
-18
@@ -7,14 +7,16 @@ import { onErrorDialog } from '../../lib/errors'
|
||||
import { getOptionsSubtitle } from '../../lib/flags'
|
||||
import { getRemoteName } from '../../lib/format'
|
||||
import { useFlags, useRemoteConfig } from '../../lib/hooks'
|
||||
import notify from '../../lib/notify'
|
||||
import { notify } from '../../lib/notifications'
|
||||
import { startDelete, startDryRun } from '../../lib/rclone/api'
|
||||
import { RCLONE_CONFIG_DEFAULTS, SUPPORTS_PURGE } from '../../lib/rclone/constants'
|
||||
import CronEditor from '../components/CronEditor'
|
||||
import OperationWindowContent from '../components/OperationWindowContent'
|
||||
import OperationWindowFooter from '../components/OperationWindowFooter'
|
||||
import OptionsSection from '../components/OptionsSection'
|
||||
import { PathField } from '../components/PathFinder'
|
||||
import AdvancedScheduleSection, {
|
||||
useAdvancedSchedule,
|
||||
} from '../components/operation/AdvancedScheduleSection'
|
||||
import OperationFooter from '../components/operation/OperationFooter'
|
||||
import OptionsAccordion, {
|
||||
type OptionsAccordionItemDef,
|
||||
@@ -43,7 +45,7 @@ Expand the accordion sections to customize your delete operation. Tap any chip o
|
||||
|
||||
• Config — Performance tuning: parallel checkers, and other global rclone settings.
|
||||
|
||||
• Cron — Schedule this delete to run automatically at set intervals. Useful for automated cleanup tasks. The schedule only triggers while the app is running.
|
||||
• Cron — Schedule this delete to run automatically at set intervals. Useful for automated cleanup tasks. It runs on a system schedule, even when the app is closed.
|
||||
|
||||
3. USE TEMPLATES (Optional)
|
||||
Tap the folder icon in the bottom bar to load or save option presets. Templates let you quickly apply common filter configurations for recurring cleanup tasks.
|
||||
@@ -59,7 +61,7 @@ export default function Delete() {
|
||||
searchParams.get('initialSource') ? searchParams.get('initialSource')! : undefined
|
||||
)
|
||||
|
||||
const [cronExpression, setCronExpression] = useState<string | null>(null)
|
||||
const advanced = useAdvancedSchedule()
|
||||
|
||||
const {
|
||||
jsonError,
|
||||
@@ -108,7 +110,7 @@ export default function Delete() {
|
||||
title: 'Success',
|
||||
body: 'Delete task started',
|
||||
})
|
||||
if (cronExpression) {
|
||||
if (advanced.cronExpression) {
|
||||
scheduleTaskMutation.mutate()
|
||||
}
|
||||
},
|
||||
@@ -119,7 +121,9 @@ export default function Delete() {
|
||||
|
||||
const scheduleTaskMutation = useScheduleTask({
|
||||
operation: 'delete',
|
||||
cronExpression,
|
||||
cronExpression: advanced.cronExpression,
|
||||
configId: advanced.configId,
|
||||
binaryPath: advanced.binaryPath,
|
||||
validate: () => {
|
||||
if (!sourceFs) {
|
||||
throw new Error('Please select a source path to delete')
|
||||
@@ -147,9 +151,9 @@ export default function Delete() {
|
||||
if (startDeleteMutation.isPending) return 'STARTING...'
|
||||
if (!sourceFs || sourceFs.length === 0) return 'Please select a source path'
|
||||
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
|
||||
if (cronExpression) return 'START AND SCHEDULE DELETE'
|
||||
if (advanced.cronExpression) return 'START AND SCHEDULE DELETE'
|
||||
return 'START DELETE'
|
||||
}, [startDeleteMutation.isPending, sourceFs, jsonError, cronExpression])
|
||||
}, [startDeleteMutation.isPending, sourceFs, jsonError, advanced.cronExpression])
|
||||
|
||||
const buttonIcon = useMemo(() => {
|
||||
if (startDeleteMutation.isPending) return
|
||||
@@ -190,13 +194,8 @@ export default function Delete() {
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'cron',
|
||||
category: 'cron',
|
||||
children: <CronEditor expression={cronExpression} onChange={setCronExpression} />,
|
||||
},
|
||||
],
|
||||
[filterGroup, configGroup, globalFlags, filterFlags, configFlags, cronExpression]
|
||||
[filterGroup, configGroup, globalFlags, filterFlags, configFlags]
|
||||
)
|
||||
|
||||
const handleStart = useCallback(
|
||||
@@ -222,20 +221,20 @@ export default function Delete() {
|
||||
const handleResetOptions = useCallback(() => {
|
||||
startTransition(() => {
|
||||
resetJson()
|
||||
setCronExpression(null)
|
||||
advanced.reset()
|
||||
startDeleteMutation.reset()
|
||||
})
|
||||
}, [resetJson, startDeleteMutation.reset])
|
||||
}, [advanced.reset, resetJson, startDeleteMutation.reset])
|
||||
|
||||
const handleResetAll = useCallback(() => {
|
||||
startTransition(() => {
|
||||
resetJson()
|
||||
resetLocks()
|
||||
setCronExpression(null)
|
||||
advanced.reset()
|
||||
setSourceFs(undefined)
|
||||
startDeleteMutation.reset()
|
||||
})
|
||||
}, [resetJson, resetLocks, startDeleteMutation.reset])
|
||||
}, [advanced.reset, resetJson, resetLocks, startDeleteMutation.reset])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen gap-10">
|
||||
@@ -264,6 +263,8 @@ export default function Delete() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<AdvancedScheduleSection advanced={advanced} />
|
||||
|
||||
<OptionsAccordion items={accordionItems} />
|
||||
</OperationWindowContent>
|
||||
|
||||
|
||||
+17
-17
@@ -8,12 +8,14 @@ import { useFlags } from '../../lib/hooks'
|
||||
import { startDryRun, startMove } from '../../lib/rclone/api'
|
||||
import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants'
|
||||
import { usePersistedStore } from '../../store/persisted'
|
||||
import CronEditor from '../components/CronEditor'
|
||||
import OperationWindowContent from '../components/OperationWindowContent'
|
||||
import OperationWindowFooter from '../components/OperationWindowFooter'
|
||||
import OptionsSection from '../components/OptionsSection'
|
||||
import { MultiPathFinder } from '../components/PathFinder'
|
||||
import RemoteOptionsSection from '../components/RemoteOptionsSection'
|
||||
import AdvancedScheduleSection, {
|
||||
useAdvancedSchedule,
|
||||
} from '../components/operation/AdvancedScheduleSection'
|
||||
import OperationFooter from '../components/operation/OperationFooter'
|
||||
import OptionsAccordion, {
|
||||
type OptionsAccordionItemDef,
|
||||
@@ -44,7 +46,7 @@ Expand the accordion sections to customize your move operation. Tap any chip on
|
||||
|
||||
• Filters — Include or exclude files by pattern, limit by size (max_size, min_size) or age (max_age, min_age).
|
||||
|
||||
• Cron — Schedule this move to run automatically at set intervals. The schedule only triggers while the app is running.
|
||||
• Cron — Schedule this move to run automatically at set intervals. It runs on a system schedule, even when the app is closed.
|
||||
|
||||
• Config — Performance tuning: parallel transfers, checkers, buffer_size, bandwidth limits (bwlimit), and fast_list for faster directory listings on supported remotes.
|
||||
|
||||
@@ -88,7 +90,7 @@ export default function Move() {
|
||||
const filterGroup = optionGroups.filter
|
||||
const configGroup = optionGroups.config
|
||||
|
||||
const [cronExpression, setCronExpression] = useState<string | null>(null)
|
||||
const advanced = useAdvancedSchedule()
|
||||
|
||||
const selectedRemotes = useMemo(
|
||||
() => [...(sources || []), dest].filter(Boolean),
|
||||
@@ -115,7 +117,7 @@ export default function Move() {
|
||||
return startMove(buildArgs())
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (cronExpression) {
|
||||
if (advanced.cronExpression) {
|
||||
scheduleTaskMutation.mutate()
|
||||
}
|
||||
},
|
||||
@@ -127,7 +129,9 @@ export default function Move() {
|
||||
|
||||
const scheduleTaskMutation = useScheduleTask({
|
||||
operation: 'move',
|
||||
cronExpression,
|
||||
cronExpression: advanced.cronExpression,
|
||||
configId: advanced.configId,
|
||||
binaryPath: advanced.binaryPath,
|
||||
validate: () => {
|
||||
if (!sources || sources.length === 0 || !dest) {
|
||||
throw new Error('Please select both a source and destination path')
|
||||
@@ -164,9 +168,9 @@ export default function Move() {
|
||||
if (!dest) return 'Please select a destination path'
|
||||
if (sources.some((s) => s === dest)) return 'Source and destination cannot be the same'
|
||||
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
|
||||
if (cronExpression) return 'START AND SCHEDULE MOVE'
|
||||
if (advanced.cronExpression) return 'START AND SCHEDULE MOVE'
|
||||
return 'START MOVE'
|
||||
}, [startMoveMutation.isPending, sources, dest, jsonError, cronExpression])
|
||||
}, [startMoveMutation.isPending, sources, dest, jsonError, advanced.cronExpression])
|
||||
|
||||
const buttonIcon = useMemo(() => {
|
||||
if (startMoveMutation.isPending) return
|
||||
@@ -208,11 +212,6 @@ export default function Move() {
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'cron',
|
||||
category: 'cron',
|
||||
children: <CronEditor expression={cronExpression} onChange={setCronExpression} />,
|
||||
},
|
||||
{
|
||||
key: 'config',
|
||||
category: 'config',
|
||||
@@ -262,7 +261,6 @@ export default function Move() {
|
||||
filterFlags,
|
||||
configFlags,
|
||||
copyFlags,
|
||||
cronExpression,
|
||||
selectedRemotes,
|
||||
]
|
||||
)
|
||||
@@ -288,21 +286,21 @@ export default function Move() {
|
||||
const handleResetOptions = useCallback(() => {
|
||||
startTransition(() => {
|
||||
resetJson()
|
||||
setCronExpression(null)
|
||||
advanced.reset()
|
||||
startMoveMutation.reset()
|
||||
})
|
||||
}, [resetJson, startMoveMutation.reset])
|
||||
}, [advanced.reset, resetJson, startMoveMutation.reset])
|
||||
|
||||
const handleResetAll = useCallback(() => {
|
||||
startTransition(() => {
|
||||
resetJson()
|
||||
resetLocks()
|
||||
setCronExpression(null)
|
||||
advanced.reset()
|
||||
setSources(undefined)
|
||||
setDest(undefined)
|
||||
startMoveMutation.reset()
|
||||
})
|
||||
}, [resetJson, resetLocks, startMoveMutation.reset])
|
||||
}, [advanced.reset, resetJson, resetLocks, startMoveMutation.reset])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen gap-10">
|
||||
@@ -316,6 +314,8 @@ export default function Move() {
|
||||
setDestPath={setDest}
|
||||
/>
|
||||
|
||||
<AdvancedScheduleSection advanced={advanced} />
|
||||
|
||||
<OptionsAccordion banner={true} items={accordionItems} />
|
||||
</OperationWindowContent>
|
||||
|
||||
|
||||
+19
-18
@@ -7,11 +7,13 @@ import { getOptionsSubtitle } from '../../lib/flags'
|
||||
import { useFlags } from '../../lib/hooks'
|
||||
import { startPurge } from '../../lib/rclone/api'
|
||||
import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants'
|
||||
import CronEditor from '../components/CronEditor'
|
||||
import OperationWindowContent from '../components/OperationWindowContent'
|
||||
import OperationWindowFooter from '../components/OperationWindowFooter'
|
||||
import OptionsSection from '../components/OptionsSection'
|
||||
import { PathField } from '../components/PathFinder'
|
||||
import AdvancedScheduleSection, {
|
||||
useAdvancedSchedule,
|
||||
} from '../components/operation/AdvancedScheduleSection'
|
||||
import OperationFooter from '../components/operation/OperationFooter'
|
||||
import OptionsAccordion, {
|
||||
type OptionsAccordionItemDef,
|
||||
@@ -21,7 +23,7 @@ import { useScheduleTask } from '../components/operation/useScheduleTask'
|
||||
|
||||
const PATH_ALLOWED_KEYS: ('LOCAL_FS' | 'FAVORITES' | 'REMOTES')[] = ['REMOTES', 'FAVORITES']
|
||||
|
||||
const DEFAULT_EXPANDED_KEYS = ['config', 'cron']
|
||||
const DEFAULT_EXPANDED_KEYS = ['config']
|
||||
|
||||
const HELP_CONTENT = `Removes a path and ALL of its contents.
|
||||
|
||||
@@ -41,7 +43,7 @@ Expand the accordion sections to customize your purge operation. Tap any chip on
|
||||
|
||||
• Config — The "checkers" option controls concurrency for backends that don't support server-side purge. Other global rclone settings are also available here.
|
||||
|
||||
• Cron — Schedule this purge to run automatically at set intervals. Useful for automated cleanup of temporary folders. The schedule only triggers while the app is running.
|
||||
• Cron — Schedule this purge to run automatically at set intervals. Useful for automated cleanup of temporary folders. It runs on a system schedule, even when the app is closed.
|
||||
|
||||
3. USE TEMPLATES (Optional)
|
||||
Tap the folder icon in the bottom bar to load or save option presets.
|
||||
@@ -57,7 +59,7 @@ export default function Purge() {
|
||||
searchParams.get('initialSource') ? searchParams.get('initialSource')! : undefined
|
||||
)
|
||||
|
||||
const [cronExpression, setCronExpression] = useState<string | null>(null)
|
||||
const advanced = useAdvancedSchedule()
|
||||
|
||||
const {
|
||||
jsonError,
|
||||
@@ -88,7 +90,7 @@ export default function Purge() {
|
||||
return startPurge(buildArgs())
|
||||
},
|
||||
onSuccess: async () => {
|
||||
if (cronExpression) {
|
||||
if (advanced.cronExpression) {
|
||||
scheduleTaskMutation.mutate()
|
||||
}
|
||||
},
|
||||
@@ -99,7 +101,9 @@ export default function Purge() {
|
||||
|
||||
const scheduleTaskMutation = useScheduleTask({
|
||||
operation: 'purge',
|
||||
cronExpression,
|
||||
cronExpression: advanced.cronExpression,
|
||||
configId: advanced.configId,
|
||||
binaryPath: advanced.binaryPath,
|
||||
validate: () => {
|
||||
if (!source) {
|
||||
throw new Error('Please select a source path to purge')
|
||||
@@ -112,9 +116,9 @@ export default function Purge() {
|
||||
if (startPurgeMutation.isPending) return 'STARTING...'
|
||||
if (!source) return 'Please select a source path'
|
||||
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
|
||||
if (cronExpression) return 'START AND SCHEDULE PURGE'
|
||||
if (advanced.cronExpression) return 'START AND SCHEDULE PURGE'
|
||||
return 'START PURGE'
|
||||
}, [startPurgeMutation.isPending, source, jsonError, cronExpression])
|
||||
}, [startPurgeMutation.isPending, source, jsonError, advanced.cronExpression])
|
||||
|
||||
const buttonIcon = useMemo(() => {
|
||||
if (startPurgeMutation.isPending) return
|
||||
@@ -140,13 +144,8 @@ export default function Purge() {
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'cron',
|
||||
category: 'cron',
|
||||
children: <CronEditor expression={cronExpression} onChange={setCronExpression} />,
|
||||
},
|
||||
],
|
||||
[configGroup, globalFlags, configFlags, cronExpression]
|
||||
[configGroup, globalFlags, configFlags]
|
||||
)
|
||||
|
||||
const handleStart = useCallback(() => startPurgeMutation.mutate(), [startPurgeMutation.mutate])
|
||||
@@ -167,20 +166,20 @@ export default function Purge() {
|
||||
const handleResetOptions = useCallback(() => {
|
||||
startTransition(() => {
|
||||
resetJson()
|
||||
setCronExpression(null)
|
||||
advanced.reset()
|
||||
startPurgeMutation.reset()
|
||||
})
|
||||
}, [resetJson, startPurgeMutation.reset])
|
||||
}, [advanced.reset, resetJson, startPurgeMutation.reset])
|
||||
|
||||
const handleResetAll = useCallback(() => {
|
||||
startTransition(() => {
|
||||
resetJson()
|
||||
resetLocks()
|
||||
setCronExpression(null)
|
||||
advanced.reset()
|
||||
setSource(undefined)
|
||||
startPurgeMutation.reset()
|
||||
})
|
||||
}, [resetJson, resetLocks, startPurgeMutation.reset])
|
||||
}, [advanced.reset, resetJson, resetLocks, startPurgeMutation.reset])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen gap-10">
|
||||
@@ -197,6 +196,8 @@ export default function Purge() {
|
||||
showFiles={false}
|
||||
/>
|
||||
|
||||
<AdvancedScheduleSection advanced={advanced} />
|
||||
|
||||
<OptionsAccordion
|
||||
defaultExpandedKeys={DEFAULT_EXPANDED_KEYS}
|
||||
items={accordionItems}
|
||||
|
||||
+256
-60
@@ -1,24 +1,69 @@
|
||||
import { Card, CardBody, CardHeader, Input, Tooltip, useDisclosure } from '@heroui/react'
|
||||
import { Alert, Card, CardBody, CardHeader, Input, Tooltip, useDisclosure } from '@heroui/react'
|
||||
import { Button, Chip } from '@heroui/react'
|
||||
import { ask } from '@tauri-apps/plugin-dialog'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { ask, message } from '@tauri-apps/plugin-dialog'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import CronExpressionParser from 'cron-parser'
|
||||
import cronstrue from 'cronstrue'
|
||||
import { formatDistance } from 'date-fns'
|
||||
import { AlertCircleIcon, Clock7Icon, PauseIcon, PlayIcon, Trash2Icon } from 'lucide-react'
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
Clock7Icon,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
StethoscopeIcon,
|
||||
Trash2Icon,
|
||||
ZapIcon,
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { formatErrorMessage, onErrorDialog } from '../../lib/errors'
|
||||
import { buildReadablePath } from '../../lib/format'
|
||||
import { useNow } from '../../lib/hooks'
|
||||
import { LOCAL_HOST_ID } from '../../lib/hosts'
|
||||
import {
|
||||
type SchedulerTaskStatus,
|
||||
schedulerDoctor,
|
||||
removeScheduledTask as schedulerRemoveTask,
|
||||
schedulerRunNow,
|
||||
schedulerStatus,
|
||||
updateScheduledTask as schedulerUpdateTask,
|
||||
schedulerValidateCron,
|
||||
setScheduledTaskEnabled,
|
||||
useSchedulerSupported,
|
||||
} from '../../lib/scheduler'
|
||||
import { useHostStore } from '../../store/host'
|
||||
import { usePersistedStore } from '../../store/persisted'
|
||||
import type { ScheduledTask } from '../../types/schedules'
|
||||
import CommandsDropdown from '../components/CommandsDropdown'
|
||||
import ScheduleEditDrawer from '../components/ScheduleEditDrawer'
|
||||
|
||||
export default function Schedules() {
|
||||
const scheduledTasks = useHostStore((state) => state.scheduledTasks)
|
||||
const currentHostId = usePersistedStore((state) => state.currentHostId) ?? LOCAL_HOST_ID
|
||||
const isLocalHost = currentHostId === LOCAL_HOST_ID
|
||||
|
||||
const supportQuery = useSchedulerSupported()
|
||||
const schedulingAvailable = isLocalHost && (supportQuery.data?.supported ?? false)
|
||||
|
||||
const unavailableReason = isLocalHost
|
||||
? (supportQuery.data?.reason ?? 'Scheduling is not available on this system.')
|
||||
: 'Scheduling runs on your local machine only — switch to the local host to manage these tasks.'
|
||||
|
||||
const [selectedTask, setSelectedTask] = useState<ScheduledTask | null>(null)
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ['scheduler', 'status'],
|
||||
queryFn: () => schedulerStatus(LOCAL_HOST_ID),
|
||||
enabled: schedulingAvailable,
|
||||
refetchInterval: 5_000,
|
||||
refetchOnWindowFocus: true,
|
||||
})
|
||||
|
||||
const statusMap = useMemo(
|
||||
() => new Map((statusQuery.data ?? []).map((status) => [status.taskId, status])),
|
||||
[statusQuery.data]
|
||||
)
|
||||
|
||||
const handleOpenDrawer = useCallback(
|
||||
(task: ScheduledTask) => {
|
||||
setSelectedTask(task)
|
||||
@@ -27,13 +72,38 @@ export default function Schedules() {
|
||||
[onOpen]
|
||||
)
|
||||
|
||||
const doctorMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const checks = await schedulerDoctor()
|
||||
const report = checks
|
||||
.map(
|
||||
(check) =>
|
||||
`${check.ok ? '✓' : '✗'} ${check.name}: ${check.detail}${check.fix ? `\n → ${check.fix}` : ''}`
|
||||
)
|
||||
.join('\n\n')
|
||||
const hasFailure = checks.some((check) => !check.ok)
|
||||
await message(report, {
|
||||
title: 'Scheduling diagnostics',
|
||||
kind: hasFailure ? 'warning' : 'info',
|
||||
})
|
||||
},
|
||||
onError: async (error) => {
|
||||
await message(formatErrorMessage(error, 'Diagnostics failed'), {
|
||||
title: 'Scheduling diagnostics',
|
||||
kind: 'error',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
if (scheduledTasks.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-screen gap-8">
|
||||
<h1 className="max-w-md text-2xl font-bold text-center">
|
||||
You can schedule tasks to run later, when the UI is in the background.
|
||||
{schedulingAvailable || supportQuery.isLoading
|
||||
? 'You can schedule tasks to run automatically, even while the app is closed.'
|
||||
: unavailableReason}
|
||||
</h1>
|
||||
<CommandsDropdown title="New scheduled task" />
|
||||
{schedulingAvailable && <CommandsDropdown title="New scheduled task" />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -43,8 +113,36 @@ export default function Schedules() {
|
||||
{platform() === 'macos' && (
|
||||
<div className="w-full h-10 border-b bg-content1 border-divider" />
|
||||
)}
|
||||
{!schedulingAvailable && !supportQuery.isLoading && (
|
||||
<Alert
|
||||
color="warning"
|
||||
title={unavailableReason}
|
||||
radius="none"
|
||||
classNames={{ base: 'flex-shrink-0' }}
|
||||
/>
|
||||
)}
|
||||
{isLocalHost && (
|
||||
<div className="flex justify-end flex-shrink-0 px-2 py-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
startContent={<StethoscopeIcon className="w-4 h-4" />}
|
||||
isLoading={doctorMutation.isPending}
|
||||
onPress={() => doctorMutation.mutate()}
|
||||
data-focus-visible="false"
|
||||
>
|
||||
Diagnostics
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{scheduledTasks.map((task) => (
|
||||
<TaskCard key={task.id} task={task} onOpenDrawer={handleOpenDrawer} />
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
status={statusMap.get(task.id)}
|
||||
schedulingAvailable={schedulingAvailable}
|
||||
onOpenDrawer={handleOpenDrawer}
|
||||
/>
|
||||
))}
|
||||
{selectedTask && (
|
||||
<ScheduleEditDrawer isOpen={isOpen} onClose={onClose} selectedTask={selectedTask} />
|
||||
@@ -55,15 +153,19 @@ export default function Schedules() {
|
||||
|
||||
function TaskCard({
|
||||
task,
|
||||
status,
|
||||
schedulingAvailable,
|
||||
onOpenDrawer,
|
||||
}: { task: ScheduledTask; onOpenDrawer: (task: ScheduledTask) => void }) {
|
||||
const [isBusy, setIsBusy] = useState(false)
|
||||
}: {
|
||||
task: ScheduledTask
|
||||
status?: SchedulerTaskStatus
|
||||
schedulingAvailable: boolean
|
||||
onOpenDrawer: (task: ScheduledTask) => void
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const [isEditingName, setIsEditingName] = useState(false)
|
||||
const [editingName, setEditingName] = useState(task.name)
|
||||
|
||||
const removeScheduledTask = useHostStore((state) => state.removeScheduledTask)
|
||||
const updateScheduledTask = useHostStore((state) => state.updateScheduledTask)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditingName) {
|
||||
setEditingName(task.name)
|
||||
@@ -74,10 +176,19 @@ function TaskCard({
|
||||
// their last dep change (e.g. a past occurrence kept showing as the "next run" forever).
|
||||
const now = useNow()
|
||||
|
||||
// Next-run preview comes from Rust (the runner's own cron matcher) — JS cron libraries
|
||||
// disagree with real cron on dom/dow star semantics, so computing it here could predict
|
||||
// fires the native schedule never performs. The query returns the next 5; the memo picks
|
||||
// the first still in the future so the label stays fresh between refetches.
|
||||
const nextRunsQuery = useQuery({
|
||||
queryKey: ['scheduler', 'validate-cron', task.cron],
|
||||
queryFn: () => schedulerValidateCron(task.cron),
|
||||
refetchInterval: 60_000,
|
||||
})
|
||||
const nextRun = useMemo(() => {
|
||||
const parsed = CronExpressionParser.parse(task.cron, { currentDate: new Date(now) })
|
||||
return parsed.hasNext() ? parsed.next().toDate() : null
|
||||
}, [task.cron, now])
|
||||
const upcoming = nextRunsQuery.data?.nextRuns ?? []
|
||||
return upcoming.map((run) => new Date(run)).find((run) => run.getTime() > now) ?? null
|
||||
}, [nextRunsQuery.data, now])
|
||||
|
||||
const source = useMemo(
|
||||
() => ('source' in task.args ? task.args.source : task.args.sources[0]),
|
||||
@@ -85,25 +196,85 @@ function TaskCard({
|
||||
)
|
||||
|
||||
const nextRunLabel = useMemo(() => {
|
||||
if (!task.isEnabled || !schedulingAvailable) {
|
||||
return 'Paused'
|
||||
}
|
||||
if (nextRun) {
|
||||
const distance = formatDistance(nextRun, new Date(now), { addSuffix: true })
|
||||
return distance.charAt(0).toUpperCase() + distance.slice(1)
|
||||
}
|
||||
return 'Never'
|
||||
}, [nextRun, now])
|
||||
}, [nextRun, now, task.isEnabled, schedulingAvailable])
|
||||
|
||||
const isRunning = status?.running ?? false
|
||||
const lastFinished = status?.lastFinished
|
||||
|
||||
const lastRunLabel = useMemo(() => {
|
||||
if (task.isRunning) {
|
||||
if (isRunning) {
|
||||
return 'Running now'
|
||||
}
|
||||
if (task.lastRun) {
|
||||
const distance = formatDistance(new Date(task.lastRun), new Date(now), {
|
||||
if (lastFinished) {
|
||||
const distance = formatDistance(new Date(lastFinished.ts), new Date(now), {
|
||||
addSuffix: true,
|
||||
})
|
||||
return distance.charAt(0).toUpperCase() + distance.slice(1)
|
||||
}
|
||||
return 'Never'
|
||||
}, [task.isRunning, task.lastRun, now])
|
||||
}, [isRunning, lastFinished, now])
|
||||
|
||||
const invalidateScheduler = () => queryClient.invalidateQueries({ queryKey: ['scheduler'] })
|
||||
|
||||
const runNowMutation = useMutation({
|
||||
mutationFn: () => schedulerRunNow(task.id),
|
||||
onSuccess: invalidateScheduler,
|
||||
onError: onErrorDialog('Run now', 'Failed to start the task', { capture: false }),
|
||||
})
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (task.isEnabled) {
|
||||
const answer = await ask('Are you sure you want to disable this task?')
|
||||
if (!answer) {
|
||||
return
|
||||
}
|
||||
await setScheduledTaskEnabled(task.id, false)
|
||||
} else {
|
||||
await setScheduledTaskEnabled(task.id, true)
|
||||
}
|
||||
},
|
||||
onSuccess: invalidateScheduler,
|
||||
onError: onErrorDialog('Schedule', 'Failed to update the task', { capture: false }),
|
||||
})
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const answer = await ask('Are you sure you want to remove this task?')
|
||||
if (!answer) {
|
||||
return
|
||||
}
|
||||
await schedulerRemoveTask(task.id)
|
||||
},
|
||||
onSuccess: invalidateScheduler,
|
||||
onError: onErrorDialog('Schedule', 'Failed to remove the task', { capture: false }),
|
||||
})
|
||||
|
||||
const commitName = (name: string | undefined) => {
|
||||
setIsEditingName(false)
|
||||
if (name === task.name) {
|
||||
return
|
||||
}
|
||||
schedulerUpdateTask(task.id, { name }).catch((error) => {
|
||||
console.error('[Schedules] rename failed', error)
|
||||
})
|
||||
}
|
||||
|
||||
const errorLine = task.registrationError
|
||||
? `Not scheduled: ${task.registrationError}`
|
||||
: status?.warning
|
||||
? status.warning
|
||||
: !isRunning && lastFinished && !lastFinished.success
|
||||
? lastFinished.error || 'The last run failed'
|
||||
: null
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -136,6 +307,25 @@ function TaskCard({
|
||||
>
|
||||
{task.operation.toUpperCase()}
|
||||
</Chip>
|
||||
{task.runMode === 'system' && (
|
||||
<Tooltip
|
||||
content="Runs even while logged out — without your session's keychain, mounted drives, or (on macOS) protected folders"
|
||||
placement="bottom"
|
||||
size="lg"
|
||||
color="foreground"
|
||||
>
|
||||
<Chip
|
||||
isCloseable={false}
|
||||
size="lg"
|
||||
variant="flat"
|
||||
radius="sm"
|
||||
color="secondary"
|
||||
className="h-10"
|
||||
>
|
||||
SYSTEM
|
||||
</Chip>
|
||||
</Tooltip>
|
||||
)}
|
||||
<div className="flex flex-col gap-0">
|
||||
<Tooltip
|
||||
content="Tap to edit the name"
|
||||
@@ -166,13 +356,11 @@ function TaskCard({
|
||||
e.currentTarget.select()
|
||||
}}
|
||||
onBlur={() => {
|
||||
updateScheduledTask(task.id, { name: editingName })
|
||||
setIsEditingName(false)
|
||||
commitName(editingName)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
updateScheduledTask(task.id, { name: editingName })
|
||||
setIsEditingName(false)
|
||||
commitName(editingName)
|
||||
e.currentTarget.blur()
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditingName(task.name)
|
||||
@@ -195,10 +383,10 @@ function TaskCard({
|
||||
<div className="flex flex-col items-center justify-center gap-0.5">
|
||||
<Tooltip
|
||||
content={
|
||||
task.isRunning
|
||||
isRunning
|
||||
? undefined
|
||||
: task.lastRun
|
||||
? new Date(task.lastRun).toLocaleDateString('en-US', {
|
||||
: lastFinished
|
||||
? new Date(lastFinished.ts).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
weekday: 'short',
|
||||
@@ -211,7 +399,7 @@ function TaskCard({
|
||||
placement="bottom"
|
||||
size="lg"
|
||||
color="foreground"
|
||||
isDisabled={task.isRunning}
|
||||
isDisabled={isRunning}
|
||||
>
|
||||
<Chip
|
||||
isCloseable={false}
|
||||
@@ -219,9 +407,9 @@ function TaskCard({
|
||||
variant="flat"
|
||||
radius="sm"
|
||||
color={
|
||||
task.isRunning
|
||||
isRunning
|
||||
? 'success'
|
||||
: task.lastRunError
|
||||
: lastFinished && !lastFinished.success
|
||||
? 'danger'
|
||||
: 'default'
|
||||
}
|
||||
@@ -250,7 +438,11 @@ function TaskCard({
|
||||
size="lg"
|
||||
variant="flat"
|
||||
radius="sm"
|
||||
color={'primary'}
|
||||
color={
|
||||
task.isEnabled && schedulingAvailable
|
||||
? 'primary'
|
||||
: 'default'
|
||||
}
|
||||
>
|
||||
{nextRunLabel}
|
||||
</Chip>
|
||||
@@ -259,25 +451,30 @@ function TaskCard({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row justify-end gap-2">
|
||||
<Tooltip content="Run now" placement="bottom" size="lg" color="foreground">
|
||||
<Button
|
||||
isIconOnly={true}
|
||||
color="success"
|
||||
variant="flat"
|
||||
isDisabled={
|
||||
!schedulingAvailable ||
|
||||
!task.isEnabled ||
|
||||
isRunning ||
|
||||
runNowMutation.isPending
|
||||
}
|
||||
size="sm"
|
||||
onPress={() => runNowMutation.mutate()}
|
||||
data-focus-visible="false"
|
||||
>
|
||||
<ZapIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
isIconOnly={true}
|
||||
color={task.isEnabled ? 'primary' : 'warning'}
|
||||
isDisabled={isBusy}
|
||||
isDisabled={!schedulingAvailable || toggleMutation.isPending}
|
||||
size="sm"
|
||||
onPress={async () => {
|
||||
setIsBusy(true)
|
||||
if (task.isEnabled) {
|
||||
const answer = await ask(
|
||||
'Are you sure you want to disable this task? This will not stop the current run.'
|
||||
)
|
||||
if (answer) {
|
||||
updateScheduledTask(task.id, { isEnabled: false })
|
||||
}
|
||||
} else {
|
||||
updateScheduledTask(task.id, { isEnabled: true })
|
||||
}
|
||||
setIsBusy(false)
|
||||
}}
|
||||
onPress={() => toggleMutation.mutate()}
|
||||
data-focus-visible="false"
|
||||
>
|
||||
{task.isEnabled ? (
|
||||
@@ -289,18 +486,9 @@ function TaskCard({
|
||||
<Button
|
||||
isIconOnly={true}
|
||||
color="danger"
|
||||
isDisabled={isBusy}
|
||||
isDisabled={removeMutation.isPending}
|
||||
size="sm"
|
||||
onPress={async () => {
|
||||
setIsBusy(true)
|
||||
const answer = await ask(
|
||||
'Are you sure you want to remove this task?'
|
||||
)
|
||||
if (answer) {
|
||||
removeScheduledTask(task.id)
|
||||
}
|
||||
setIsBusy(false)
|
||||
}}
|
||||
onPress={() => removeMutation.mutate()}
|
||||
data-focus-visible="false"
|
||||
>
|
||||
<Trash2Icon className="w-4 h-4" />
|
||||
@@ -310,16 +498,16 @@ function TaskCard({
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="flex flex-row items-center justify-start gap-1 text-sm font-bold">
|
||||
{task.lastRunError ? (
|
||||
{errorLine ? (
|
||||
<>
|
||||
<AlertCircleIcon className="w-4 h-4 text-danger-600" />
|
||||
<p className="text-sm font-bold text-danger-600">{task.lastRunError}</p>
|
||||
<p className="text-sm font-bold text-danger-600">{errorLine}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Clock7Icon className="w-4 h-4" />
|
||||
<p className="text-sm font-bold truncate">
|
||||
{cronstrue.toString(task.cron)}.
|
||||
{safeCronDescription(task.cron)}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
@@ -328,3 +516,11 @@ function TaskCard({
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function safeCronDescription(cron: string) {
|
||||
try {
|
||||
return `${cronstrue.toString(cron)}.`
|
||||
} catch {
|
||||
return cron
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { MIN_RCLONE_VERSION } from '../../../lib/rclone/constants'
|
||||
import {
|
||||
type DownloadProgress,
|
||||
type DownloadedVersion,
|
||||
activateRclonePath,
|
||||
deleteVersion,
|
||||
downloadVersion,
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
listDownloadedVersions,
|
||||
setPathIntegration,
|
||||
} from '../../../lib/rclone/versions'
|
||||
import { useHostStore } from '../../../store/host'
|
||||
import { usePersistedStore } from '../../../store/persisted'
|
||||
import BaseSection from './BaseSection'
|
||||
|
||||
@@ -117,6 +119,25 @@ export default function BinarySection() {
|
||||
},
|
||||
})
|
||||
|
||||
const scheduledTasks = useHostStore((state) => state.scheduledTasks)
|
||||
|
||||
const handleDeleteVersion = async (v: DownloadedVersion) => {
|
||||
// Schedules can pin a specific downloaded version by absolute path — deleting it would
|
||||
// make every later run fail with "binary not found". Being the active global binary is
|
||||
// not the only way a version can be in use. (Schedules are local-host-only, so the host
|
||||
// store is authoritative here.)
|
||||
const pinnedBy = scheduledTasks.filter((task) => task.binaryPath === v.path)
|
||||
if (pinnedBy.length > 0) {
|
||||
const names = pinnedBy.map((task) => task.name || task.operation).join(', ')
|
||||
await message(
|
||||
`This version is used by ${pinnedBy.length} scheduled task(s): ${names}. Change their rclone binary in the schedule settings first.`,
|
||||
{ title: 'Version in use', kind: 'warning' }
|
||||
)
|
||||
return
|
||||
}
|
||||
deleteMutation.mutate(v.version)
|
||||
}
|
||||
|
||||
const downloadedVersions = downloadedQuery.data ?? []
|
||||
const downloadedSet = useMemo(
|
||||
() => new Set(downloadedVersions.map((v) => v.version)),
|
||||
@@ -191,9 +212,7 @@ export default function BinarySection() {
|
||||
actionLabel="Use"
|
||||
isActivating={activateMutation.isPending}
|
||||
onActivate={() => activateMutation.mutate({ path: v.path })}
|
||||
onDelete={
|
||||
isActive ? undefined : () => deleteMutation.mutate(v.version)
|
||||
}
|
||||
onDelete={isActive ? undefined : () => handleDeleteVersion(v)}
|
||||
isDeleting={
|
||||
deleteMutation.isPending &&
|
||||
deleteMutation.variables === v.version
|
||||
|
||||
+17
-17
@@ -7,12 +7,14 @@ import { getOptionsSubtitle } from '../../lib/flags'
|
||||
import { useFlags } from '../../lib/hooks'
|
||||
import { startDryRun, startSync } from '../../lib/rclone/api'
|
||||
import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants'
|
||||
import CronEditor from '../components/CronEditor'
|
||||
import OperationWindowContent from '../components/OperationWindowContent'
|
||||
import OperationWindowFooter from '../components/OperationWindowFooter'
|
||||
import OptionsSection from '../components/OptionsSection'
|
||||
import { PathFinder } from '../components/PathFinder'
|
||||
import RemoteOptionsSection from '../components/RemoteOptionsSection'
|
||||
import AdvancedScheduleSection, {
|
||||
useAdvancedSchedule,
|
||||
} from '../components/operation/AdvancedScheduleSection'
|
||||
import OperationFooter from '../components/operation/OperationFooter'
|
||||
import OptionsAccordion, {
|
||||
type OptionsAccordionItemDef,
|
||||
@@ -69,7 +71,7 @@ Expand the accordion sections to customize your sync operation. Tap any chip on
|
||||
|
||||
• Filters — Include or exclude files by pattern, limit by size (max_size, min_size) or age (max_age, min_age).
|
||||
|
||||
• Cron — Schedule this sync to run automatically at set intervals. The schedule only triggers while the app is running.
|
||||
• Cron — Schedule this sync to run automatically at set intervals. It runs on a system schedule, even when the app is closed.
|
||||
|
||||
• Config — Performance tuning: parallel transfers, checkers, buffer_size, bandwidth limits (bwlimit), and fast_list for faster directory listings on supported remotes.
|
||||
|
||||
@@ -113,7 +115,7 @@ export default function Sync() {
|
||||
const filterGroup = optionGroups.filter
|
||||
const configGroup = optionGroups.config
|
||||
|
||||
const [cronExpression, setCronExpression] = useState<string | null>(null)
|
||||
const advanced = useAdvancedSchedule()
|
||||
|
||||
const selectedRemotes = useMemo(() => [source, dest].filter(Boolean), [source, dest])
|
||||
|
||||
@@ -137,7 +139,7 @@ export default function Sync() {
|
||||
return startSync(buildArgs())
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (cronExpression) {
|
||||
if (advanced.cronExpression) {
|
||||
scheduleTaskMutation.mutate()
|
||||
}
|
||||
},
|
||||
@@ -146,7 +148,9 @@ export default function Sync() {
|
||||
|
||||
const scheduleTaskMutation = useScheduleTask({
|
||||
operation: 'sync',
|
||||
cronExpression,
|
||||
cronExpression: advanced.cronExpression,
|
||||
configId: advanced.configId,
|
||||
binaryPath: advanced.binaryPath,
|
||||
validate: () => {
|
||||
if (!source || !dest) {
|
||||
throw new Error('Please select both a source and destination path')
|
||||
@@ -179,9 +183,9 @@ export default function Sync() {
|
||||
if (!dest) return 'Please select a destination path'
|
||||
if (source === dest) return 'Source and destination cannot be the same'
|
||||
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
|
||||
if (cronExpression) return 'START AND SCHEDULE SYNC'
|
||||
if (advanced.cronExpression) return 'START AND SCHEDULE SYNC'
|
||||
return 'START SYNC'
|
||||
}, [startSyncMutation.isPending, source, dest, jsonError, cronExpression])
|
||||
}, [startSyncMutation.isPending, source, dest, jsonError, advanced.cronExpression])
|
||||
|
||||
const buttonIcon = useMemo(() => {
|
||||
if (startSyncMutation.isPending) return
|
||||
@@ -222,11 +226,6 @@ export default function Sync() {
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'cron',
|
||||
category: 'cron',
|
||||
children: <CronEditor expression={cronExpression} onChange={setCronExpression} />,
|
||||
},
|
||||
{
|
||||
key: 'config',
|
||||
category: 'config',
|
||||
@@ -275,7 +274,6 @@ export default function Sync() {
|
||||
syncFlags,
|
||||
filterFlags,
|
||||
configFlags,
|
||||
cronExpression,
|
||||
selectedRemotes,
|
||||
remotesGroup,
|
||||
]
|
||||
@@ -302,21 +300,21 @@ export default function Sync() {
|
||||
const handleResetOptions = useCallback(() => {
|
||||
startTransition(() => {
|
||||
resetJson()
|
||||
setCronExpression(null)
|
||||
advanced.reset()
|
||||
startSyncMutation.reset()
|
||||
})
|
||||
}, [resetJson, startSyncMutation.reset])
|
||||
}, [advanced.reset, resetJson, startSyncMutation.reset])
|
||||
|
||||
const handleResetAll = useCallback(() => {
|
||||
startTransition(() => {
|
||||
resetJson()
|
||||
resetLocks()
|
||||
setCronExpression(null)
|
||||
advanced.reset()
|
||||
setDest(undefined)
|
||||
setSource(undefined)
|
||||
startSyncMutation.reset()
|
||||
})
|
||||
}, [resetJson, resetLocks, startSyncMutation.reset])
|
||||
}, [advanced.reset, resetJson, resetLocks, startSyncMutation.reset])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen gap-10">
|
||||
@@ -332,6 +330,8 @@ export default function Sync() {
|
||||
destOptions={DEST_OPTIONS}
|
||||
/>
|
||||
|
||||
<AdvancedScheduleSection advanced={advanced} />
|
||||
|
||||
<OptionsAccordion banner={true} items={accordionItems} />
|
||||
</OperationWindowContent>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user