import Spinner from '@app/assets/spinner.svg'; import Badge from '@app/components/Common/Badge'; import Button from '@app/components/Common/Button'; import LoadingSpinner from '@app/components/Common/LoadingSpinner'; import Modal from '@app/components/Common/Modal'; import PageTitle from '@app/components/Common/PageTitle'; import Table from '@app/components/Common/Table'; import useLocale from '@app/hooks/useLocale'; import globalMessages from '@app/i18n/globalMessages'; import { formatBytes } from '@app/utils/numberHelpers'; import { Transition } from '@headlessui/react'; import { PlayIcon, StopIcon, TrashIcon } from '@heroicons/react/outline'; import { PencilIcon } from '@heroicons/react/solid'; import type { CacheItem, CacheResponse, } from '@server/interfaces/api/settingsInterfaces'; import type { JobId } from '@server/lib/settings'; import axios from 'axios'; import cronstrue from 'cronstrue/i18n'; import { Fragment, useReducer, useState } from 'react'; import type { MessageDescriptor } from 'react-intl'; import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import useSWR from 'swr'; const messages: { [messageName: string]: MessageDescriptor } = defineMessages({ jobsandcache: 'Jobs & Cache', jobs: 'Jobs', jobsDescription: 'Overseerr performs certain maintenance tasks as regularly-scheduled jobs, but they can also be manually triggered below. Manually running a job will not alter its schedule.', jobname: 'Job Name', jobtype: 'Type', nextexecution: 'Next Execution', runnow: 'Run Now', canceljob: 'Cancel Job', jobstarted: '{jobname} started.', jobcancelled: '{jobname} canceled.', process: 'Process', command: 'Command', cache: 'Cache', cacheDescription: 'Overseerr caches requests to external API endpoints to optimize performance and avoid making unnecessary API calls.', cacheflushed: '{cachename} cache flushed.', cachename: 'Cache Name', cachehits: 'Hits', cachemisses: 'Misses', cachekeys: 'Total Keys', cacheksize: 'Key Size', cachevsize: 'Value Size', flushcache: 'Flush Cache', unknownJob: 'Unknown Job', 'plex-recently-added-scan': 'Plex Recently Added Scan', 'plex-full-scan': 'Plex Full Library Scan', 'plex-watchlist-sync': 'Plex Watchlist Sync', 'radarr-scan': 'Radarr Scan', 'sonarr-scan': 'Sonarr Scan', 'download-sync': 'Download Sync', 'download-sync-reset': 'Download Sync Reset', 'image-cache-cleanup': 'Image Cache Cleanup', editJobSchedule: 'Modify Job', jobScheduleEditSaved: 'Job edited successfully!', jobScheduleEditFailed: 'Something went wrong while saving the job.', editJobScheduleCurrent: 'Current Frequency', editJobSchedulePrompt: 'New Frequency', editJobScheduleSelectorHours: 'Every {jobScheduleHours, plural, one {hour} other {{jobScheduleHours} hours}}', editJobScheduleSelectorMinutes: 'Every {jobScheduleMinutes, plural, one {minute} other {{jobScheduleMinutes} minutes}}', imagecache: 'Image Cache', imagecacheDescription: 'When enabled in settings, Overseerr will proxy and cache images from pre-configured external sources. Cached images are saved into your config folder. You can find the files in {appDataPath}/cache/images.', imagecachecount: 'Images Cached', imagecachesize: 'Total Cache Size', }); interface Job { id: JobId; name: string; type: 'process' | 'command'; interval: 'short' | 'long' | 'fixed'; cronSchedule: string; nextExecutionTime: string; running: boolean; } type JobModalState = { isOpen?: boolean; job?: Job; scheduleHours: number; scheduleMinutes: number; }; type JobModalAction = | { type: 'set'; hours?: number; minutes?: number } | { type: 'close'; } | { type: 'open'; job?: Job }; const jobModalReducer = ( state: JobModalState, action: JobModalAction ): JobModalState => { switch (action.type) { case 'close': return { ...state, isOpen: false, }; case 'open': return { isOpen: true, job: action.job, scheduleHours: 1, scheduleMinutes: 5, }; case 'set': return { ...state, scheduleHours: action.hours ?? state.scheduleHours, scheduleMinutes: action.minutes ?? state.scheduleMinutes, }; } }; const SettingsJobs = () => { const intl = useIntl(); const { locale } = useLocale(); const { addToast } = useToasts(); const { data, error, mutate: revalidate, } = useSWR('/api/v1/settings/jobs', { refreshInterval: 5000, }); const { data: appData } = useSWR('/api/v1/status/appdata'); const { data: cacheData, mutate: cacheRevalidate } = useSWR( '/api/v1/settings/cache', { refreshInterval: 10000, } ); const [jobModalState, dispatch] = useReducer(jobModalReducer, { isOpen: false, scheduleHours: 1, scheduleMinutes: 5, }); const [isSaving, setIsSaving] = useState(false); if (!data && !error) { return ; } const runJob = async (job: Job) => { await axios.post(`/api/v1/settings/jobs/${job.id}/run`); addToast( intl.formatMessage(messages.jobstarted, { jobname: intl.formatMessage(messages[job.id] ?? messages.unknownJob), }), { appearance: 'success', autoDismiss: true, } ); revalidate(); }; const cancelJob = async (job: Job) => { await axios.post(`/api/v1/settings/jobs/${job.id}/cancel`); addToast( intl.formatMessage(messages.jobcancelled, { jobname: intl.formatMessage(messages[job.id] ?? messages.unknownJob), }), { appearance: 'error', autoDismiss: true, } ); revalidate(); }; const flushCache = async (cache: CacheItem) => { await axios.post(`/api/v1/settings/cache/${cache.id}/flush`); addToast( intl.formatMessage(messages.cacheflushed, { cachename: cache.name }), { appearance: 'success', autoDismiss: true, } ); cacheRevalidate(); }; const scheduleJob = async () => { const jobScheduleCron = ['0', '0', '*', '*', '*', '*']; try { if (jobModalState.job?.interval === 'short') { jobScheduleCron[1] = `*/${jobModalState.scheduleMinutes}`; } else if (jobModalState.job?.interval === 'long') { jobScheduleCron[2] = `*/${jobModalState.scheduleHours}`; } else { // jobs with interval: fixed should not be editable throw new Error(); } setIsSaving(true); await axios.post( `/api/v1/settings/jobs/${jobModalState.job.id}/schedule`, { schedule: jobScheduleCron.join(' '), } ); addToast(intl.formatMessage(messages.jobScheduleEditSaved), { appearance: 'success', autoDismiss: true, }); dispatch({ type: 'close' }); revalidate(); } catch (e) { addToast(intl.formatMessage(messages.jobScheduleEditFailed), { appearance: 'error', autoDismiss: true, }); } finally { setIsSaving(false); } }; return ( <> dispatch({ type: 'close' })} okDisabled={isSaving} onOk={() => scheduleJob()} >
{jobModalState.job && cronstrue.toString(jobModalState.job.cronSchedule, { locale, })}
{jobModalState.job?.cronSchedule}
{jobModalState.job?.interval === 'short' ? ( ) : ( )}

{intl.formatMessage(messages.jobs)}

{intl.formatMessage(messages.jobsDescription)}

{intl.formatMessage(messages.jobname)}{intl.formatMessage(messages.jobtype)}{intl.formatMessage(messages.nextexecution)} {data?.map((job) => (
{intl.formatMessage( messages[job.id] ?? messages.unknownJob )} {job.running && }
{job.type === 'process' ? intl.formatMessage(messages.process) : intl.formatMessage(messages.command)}
{job.interval !== 'fixed' && ( )} {job.running ? ( ) : ( )} ))}

{intl.formatMessage(messages.cache)}

{intl.formatMessage(messages.cacheDescription)}

{intl.formatMessage(messages.cachename)}{intl.formatMessage(messages.cachehits)}{intl.formatMessage(messages.cachemisses)}{intl.formatMessage(messages.cachekeys)}{intl.formatMessage(messages.cacheksize)}{intl.formatMessage(messages.cachevsize)} {cacheData?.apiCaches.map((cache) => ( {cache.name}{intl.formatNumber(cache.stats.hits)}{intl.formatNumber(cache.stats.misses)}{intl.formatNumber(cache.stats.keys)}{formatBytes(cache.stats.ksize)}{formatBytes(cache.stats.vsize)} ))}

{intl.formatMessage(messages.imagecache)}

{intl.formatMessage(messages.imagecacheDescription, { code: (msg: React.ReactNode) => ( {msg} ), appDataPath: appData ? appData.appDataPath : '/app/config', })}

{intl.formatMessage(messages.cachename)} {intl.formatMessage(messages.imagecachecount)} {intl.formatMessage(messages.imagecachesize)} The Movie Database (tmdb) {intl.formatNumber(cacheData?.imageCache.tmdb.imageCount ?? 0)} {formatBytes(cacheData?.imageCache.tmdb.size ?? 0)}
); }; export default SettingsJobs;