You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
534 lines
18 KiB
534 lines
18 KiB
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/24/outline';
|
|
import { PencilIcon } from '@heroicons/react/24/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',
|
|
'availability-sync': 'Media Availability 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}}',
|
|
editJobScheduleSelectorSeconds:
|
|
'Every {jobScheduleSeconds, plural, one {second} other {{jobScheduleSeconds} seconds}}',
|
|
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 <code>{appDataPath}/cache/images</code>.',
|
|
imagecachecount: 'Images Cached',
|
|
imagecachesize: 'Total Cache Size',
|
|
});
|
|
|
|
interface Job {
|
|
id: JobId;
|
|
name: string;
|
|
type: 'process' | 'command';
|
|
interval: 'seconds' | 'minutes' | 'hours' | 'fixed';
|
|
cronSchedule: string;
|
|
nextExecutionTime: string;
|
|
running: boolean;
|
|
}
|
|
|
|
type JobModalState = {
|
|
isOpen?: boolean;
|
|
job?: Job;
|
|
scheduleHours: number;
|
|
scheduleMinutes: number;
|
|
scheduleSeconds: number;
|
|
};
|
|
|
|
type JobModalAction =
|
|
| { type: 'set'; hours?: number; minutes?: number; seconds?: 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,
|
|
scheduleSeconds: 30,
|
|
};
|
|
|
|
case 'set':
|
|
return {
|
|
...state,
|
|
scheduleHours: action.hours ?? state.scheduleHours,
|
|
scheduleMinutes: action.minutes ?? state.scheduleMinutes,
|
|
scheduleSeconds: action.seconds ?? state.scheduleSeconds,
|
|
};
|
|
}
|
|
};
|
|
|
|
const SettingsJobs = () => {
|
|
const intl = useIntl();
|
|
const { locale } = useLocale();
|
|
const { addToast } = useToasts();
|
|
const {
|
|
data,
|
|
error,
|
|
mutate: revalidate,
|
|
} = useSWR<Job[]>('/api/v1/settings/jobs', {
|
|
refreshInterval: 5000,
|
|
});
|
|
const { data: appData } = useSWR('/api/v1/status/appdata');
|
|
const { data: cacheData, mutate: cacheRevalidate } = useSWR<CacheResponse>(
|
|
'/api/v1/settings/cache',
|
|
{
|
|
refreshInterval: 10000,
|
|
}
|
|
);
|
|
|
|
const [jobModalState, dispatch] = useReducer(jobModalReducer, {
|
|
isOpen: false,
|
|
scheduleHours: 1,
|
|
scheduleMinutes: 5,
|
|
scheduleSeconds: 30,
|
|
});
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
|
|
if (!data && !error) {
|
|
return <LoadingSpinner />;
|
|
}
|
|
|
|
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 === 'seconds') {
|
|
jobScheduleCron.splice(0, 2, `*/${jobModalState.scheduleSeconds}`, '*');
|
|
} else if (jobModalState.job?.interval === 'minutes') {
|
|
jobScheduleCron[1] = `*/${jobModalState.scheduleMinutes}`;
|
|
} else if (jobModalState.job?.interval === 'hours') {
|
|
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 (
|
|
<>
|
|
<PageTitle
|
|
title={[
|
|
intl.formatMessage(messages.jobsandcache),
|
|
intl.formatMessage(globalMessages.settings),
|
|
]}
|
|
/>
|
|
<Transition
|
|
as={Fragment}
|
|
enter="transition-opacity duration-300"
|
|
enterFrom="opacity-0"
|
|
enterTo="opacity-100"
|
|
leave="transition-opacity duration-300"
|
|
leaveFrom="opacity-100"
|
|
leaveTo="opacity-0"
|
|
show={jobModalState.isOpen}
|
|
>
|
|
<Modal
|
|
title={intl.formatMessage(messages.editJobSchedule)}
|
|
okText={
|
|
isSaving
|
|
? intl.formatMessage(globalMessages.saving)
|
|
: intl.formatMessage(globalMessages.save)
|
|
}
|
|
onCancel={() => dispatch({ type: 'close' })}
|
|
okDisabled={isSaving}
|
|
onOk={() => scheduleJob()}
|
|
>
|
|
<div className="section">
|
|
<form className="mb-6">
|
|
<div className="form-row">
|
|
<label className="text-label">
|
|
{intl.formatMessage(messages.editJobScheduleCurrent)}
|
|
</label>
|
|
<div className="form-input-area mt-2 mb-1">
|
|
<div>
|
|
{jobModalState.job &&
|
|
cronstrue.toString(jobModalState.job.cronSchedule, {
|
|
locale,
|
|
})}
|
|
</div>
|
|
<div className="text-sm text-gray-500">
|
|
{jobModalState.job?.cronSchedule}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="form-row">
|
|
<label htmlFor="jobSchedule" className="text-label">
|
|
{intl.formatMessage(messages.editJobSchedulePrompt)}
|
|
</label>
|
|
<div className="form-input-area">
|
|
{jobModalState.job?.interval === 'seconds' ? (
|
|
<select
|
|
name="jobScheduleSeconds"
|
|
className="inline"
|
|
value={jobModalState.scheduleSeconds}
|
|
onChange={(e) =>
|
|
dispatch({
|
|
type: 'set',
|
|
seconds: Number(e.target.value),
|
|
})
|
|
}
|
|
>
|
|
{[30, 45, 60].map((v) => (
|
|
<option value={v} key={`jobScheduleSeconds-${v}`}>
|
|
{intl.formatMessage(
|
|
messages.editJobScheduleSelectorSeconds,
|
|
{
|
|
jobScheduleSeconds: v,
|
|
}
|
|
)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
) : jobModalState.job?.interval === 'minutes' ? (
|
|
<select
|
|
name="jobScheduleMinutes"
|
|
className="inline"
|
|
value={jobModalState.scheduleMinutes}
|
|
onChange={(e) =>
|
|
dispatch({
|
|
type: 'set',
|
|
minutes: Number(e.target.value),
|
|
})
|
|
}
|
|
>
|
|
{[5, 10, 15, 20, 30, 60].map((v) => (
|
|
<option value={v} key={`jobScheduleMinutes-${v}`}>
|
|
{intl.formatMessage(
|
|
messages.editJobScheduleSelectorMinutes,
|
|
{
|
|
jobScheduleMinutes: v,
|
|
}
|
|
)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
) : (
|
|
<select
|
|
name="jobScheduleHours"
|
|
className="inline"
|
|
value={jobModalState.scheduleHours}
|
|
onChange={(e) =>
|
|
dispatch({
|
|
type: 'set',
|
|
hours: Number(e.target.value),
|
|
})
|
|
}
|
|
>
|
|
{[1, 2, 3, 4, 6, 8, 12, 24, 48, 72].map((v) => (
|
|
<option value={v} key={`jobScheduleHours-${v}`}>
|
|
{intl.formatMessage(
|
|
messages.editJobScheduleSelectorHours,
|
|
{
|
|
jobScheduleHours: v,
|
|
}
|
|
)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</Modal>
|
|
</Transition>
|
|
|
|
<div className="mb-6">
|
|
<h3 className="heading">{intl.formatMessage(messages.jobs)}</h3>
|
|
<p className="description">
|
|
{intl.formatMessage(messages.jobsDescription)}
|
|
</p>
|
|
</div>
|
|
<div className="section">
|
|
<Table>
|
|
<thead>
|
|
<tr>
|
|
<Table.TH>{intl.formatMessage(messages.jobname)}</Table.TH>
|
|
<Table.TH>{intl.formatMessage(messages.jobtype)}</Table.TH>
|
|
<Table.TH>{intl.formatMessage(messages.nextexecution)}</Table.TH>
|
|
<Table.TH></Table.TH>
|
|
</tr>
|
|
</thead>
|
|
<Table.TBody>
|
|
{data?.map((job) => (
|
|
<tr key={`job-list-${job.id}`}>
|
|
<Table.TD>
|
|
<div className="flex items-center text-sm leading-5 text-white">
|
|
<span>
|
|
{intl.formatMessage(
|
|
messages[job.id] ?? messages.unknownJob
|
|
)}
|
|
</span>
|
|
{job.running && <Spinner className="ml-2 h-5 w-5" />}
|
|
</div>
|
|
</Table.TD>
|
|
<Table.TD>
|
|
<Badge
|
|
badgeType={job.type === 'process' ? 'primary' : 'warning'}
|
|
className="uppercase"
|
|
>
|
|
{job.type === 'process'
|
|
? intl.formatMessage(messages.process)
|
|
: intl.formatMessage(messages.command)}
|
|
</Badge>
|
|
</Table.TD>
|
|
<Table.TD>
|
|
<div className="text-sm leading-5 text-white">
|
|
<FormattedRelativeTime
|
|
value={Math.floor(
|
|
(new Date(job.nextExecutionTime).getTime() -
|
|
Date.now()) /
|
|
1000
|
|
)}
|
|
updateIntervalInSeconds={1}
|
|
numeric="auto"
|
|
/>
|
|
</div>
|
|
</Table.TD>
|
|
<Table.TD alignText="right">
|
|
{job.interval !== 'fixed' && (
|
|
<Button
|
|
className="mr-2"
|
|
buttonType="warning"
|
|
onClick={() => dispatch({ type: 'open', job })}
|
|
>
|
|
<PencilIcon />
|
|
<span>{intl.formatMessage(globalMessages.edit)}</span>
|
|
</Button>
|
|
)}
|
|
{job.running ? (
|
|
<Button buttonType="danger" onClick={() => cancelJob(job)}>
|
|
<StopIcon />
|
|
<span>{intl.formatMessage(messages.canceljob)}</span>
|
|
</Button>
|
|
) : (
|
|
<Button buttonType="primary" onClick={() => runJob(job)}>
|
|
<PlayIcon />
|
|
<span>{intl.formatMessage(messages.runnow)}</span>
|
|
</Button>
|
|
)}
|
|
</Table.TD>
|
|
</tr>
|
|
))}
|
|
</Table.TBody>
|
|
</Table>
|
|
</div>
|
|
<div>
|
|
<h3 className="heading">{intl.formatMessage(messages.cache)}</h3>
|
|
<p className="description">
|
|
{intl.formatMessage(messages.cacheDescription)}
|
|
</p>
|
|
</div>
|
|
<div className="section">
|
|
<Table>
|
|
<thead>
|
|
<tr>
|
|
<Table.TH>{intl.formatMessage(messages.cachename)}</Table.TH>
|
|
<Table.TH>{intl.formatMessage(messages.cachehits)}</Table.TH>
|
|
<Table.TH>{intl.formatMessage(messages.cachemisses)}</Table.TH>
|
|
<Table.TH>{intl.formatMessage(messages.cachekeys)}</Table.TH>
|
|
<Table.TH>{intl.formatMessage(messages.cacheksize)}</Table.TH>
|
|
<Table.TH>{intl.formatMessage(messages.cachevsize)}</Table.TH>
|
|
<Table.TH></Table.TH>
|
|
</tr>
|
|
</thead>
|
|
<Table.TBody>
|
|
{cacheData?.apiCaches.map((cache) => (
|
|
<tr key={`cache-list-${cache.id}`}>
|
|
<Table.TD>{cache.name}</Table.TD>
|
|
<Table.TD>{intl.formatNumber(cache.stats.hits)}</Table.TD>
|
|
<Table.TD>{intl.formatNumber(cache.stats.misses)}</Table.TD>
|
|
<Table.TD>{intl.formatNumber(cache.stats.keys)}</Table.TD>
|
|
<Table.TD>{formatBytes(cache.stats.ksize)}</Table.TD>
|
|
<Table.TD>{formatBytes(cache.stats.vsize)}</Table.TD>
|
|
<Table.TD alignText="right">
|
|
<Button buttonType="danger" onClick={() => flushCache(cache)}>
|
|
<TrashIcon />
|
|
<span>{intl.formatMessage(messages.flushcache)}</span>
|
|
</Button>
|
|
</Table.TD>
|
|
</tr>
|
|
))}
|
|
</Table.TBody>
|
|
</Table>
|
|
</div>
|
|
<div>
|
|
<h3 className="heading">{intl.formatMessage(messages.imagecache)}</h3>
|
|
<p className="description">
|
|
{intl.formatMessage(messages.imagecacheDescription, {
|
|
code: (msg: React.ReactNode) => (
|
|
<code className="bg-opacity-50">{msg}</code>
|
|
),
|
|
appDataPath: appData ? appData.appDataPath : '/app/config',
|
|
})}
|
|
</p>
|
|
</div>
|
|
<div className="section">
|
|
<Table>
|
|
<thead>
|
|
<tr>
|
|
<Table.TH>{intl.formatMessage(messages.cachename)}</Table.TH>
|
|
<Table.TH>
|
|
{intl.formatMessage(messages.imagecachecount)}
|
|
</Table.TH>
|
|
<Table.TH>{intl.formatMessage(messages.imagecachesize)}</Table.TH>
|
|
</tr>
|
|
</thead>
|
|
<Table.TBody>
|
|
<tr>
|
|
<Table.TD>The Movie Database (tmdb)</Table.TD>
|
|
<Table.TD>
|
|
{intl.formatNumber(cacheData?.imageCache.tmdb.imageCount ?? 0)}
|
|
</Table.TD>
|
|
<Table.TD>
|
|
{formatBytes(cacheData?.imageCache.tmdb.size ?? 0)}
|
|
</Table.TD>
|
|
</tr>
|
|
</Table.TBody>
|
|
</Table>
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default SettingsJobs;
|