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.
394 lines
13 KiB
394 lines
13 KiB
import { PlayIcon, StopIcon, TrashIcon } from '@heroicons/react/outline';
|
|
import { PencilIcon } from '@heroicons/react/solid';
|
|
import axios from 'axios';
|
|
import React, { useState } from 'react';
|
|
import {
|
|
defineMessages,
|
|
FormattedRelativeTime,
|
|
MessageDescriptor,
|
|
useIntl,
|
|
} from 'react-intl';
|
|
import { useToasts } from 'react-toast-notifications';
|
|
import useSWR from 'swr';
|
|
import { CacheItem } from '../../../../server/interfaces/api/settingsInterfaces';
|
|
import { JobId } from '../../../../server/lib/settings';
|
|
import Spinner from '../../../assets/spinner.svg';
|
|
import globalMessages from '../../../i18n/globalMessages';
|
|
import { formatBytes } from '../../../utils/numberHelpers';
|
|
import Badge from '../../Common/Badge';
|
|
import Button from '../../Common/Button';
|
|
import LoadingSpinner from '../../Common/LoadingSpinner';
|
|
import Modal from '../../Common/Modal';
|
|
import PageTitle from '../../Common/PageTitle';
|
|
import Table from '../../Common/Table';
|
|
import Transition from '../../Transition';
|
|
|
|
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',
|
|
'radarr-scan': 'Radarr Scan',
|
|
'sonarr-scan': 'Sonarr Scan',
|
|
'download-sync': 'Download Sync',
|
|
'download-sync-reset': 'Download Sync Reset',
|
|
editJobSchedule: 'Modify Job',
|
|
jobScheduleEditSaved: 'Job edited successfully!',
|
|
jobScheduleEditFailed: 'Something went wrong while saving the job.',
|
|
editJobSchedulePrompt: 'Frequency',
|
|
editJobScheduleSelectorHours:
|
|
'Every {jobScheduleHours, plural, one {hour} other {{jobScheduleHours} hours}}',
|
|
editJobScheduleSelectorMinutes:
|
|
'Every {jobScheduleMinutes, plural, one {minute} other {{jobScheduleMinutes} minutes}}',
|
|
});
|
|
|
|
interface Job {
|
|
id: JobId;
|
|
name: string;
|
|
type: 'process' | 'command';
|
|
interval: 'short' | 'long' | 'fixed';
|
|
nextExecutionTime: string;
|
|
running: boolean;
|
|
}
|
|
|
|
const SettingsJobs: React.FC = () => {
|
|
const intl = useIntl();
|
|
const { addToast } = useToasts();
|
|
const {
|
|
data,
|
|
error,
|
|
mutate: revalidate,
|
|
} = useSWR<Job[]>('/api/v1/settings/jobs', {
|
|
refreshInterval: 5000,
|
|
});
|
|
const { data: cacheData, mutate: cacheRevalidate } = useSWR<CacheItem[]>(
|
|
'/api/v1/settings/cache',
|
|
{
|
|
refreshInterval: 10000,
|
|
}
|
|
);
|
|
|
|
const [jobEditModal, setJobEditModal] = useState<{
|
|
isOpen: boolean;
|
|
job?: Job;
|
|
}>({
|
|
isOpen: false,
|
|
});
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [jobScheduleMinutes, setJobScheduleMinutes] = useState(5);
|
|
const [jobScheduleHours, setJobScheduleHours] = useState(1);
|
|
|
|
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 (jobEditModal.job?.interval === 'short') {
|
|
jobScheduleCron[1] = `*/${jobScheduleMinutes}`;
|
|
} else if (jobEditModal.job?.interval === 'long') {
|
|
jobScheduleCron[2] = `*/${jobScheduleHours}`;
|
|
} else {
|
|
// jobs with interval: fixed should not be editable
|
|
throw new Error();
|
|
}
|
|
|
|
setIsSaving(true);
|
|
await axios.post(
|
|
`/api/v1/settings/jobs/${jobEditModal.job?.id}/schedule`,
|
|
{
|
|
schedule: jobScheduleCron.join(' '),
|
|
}
|
|
);
|
|
addToast(intl.formatMessage(messages.jobScheduleEditSaved), {
|
|
appearance: 'success',
|
|
autoDismiss: true,
|
|
});
|
|
setJobEditModal({ isOpen: false });
|
|
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
|
|
enter="opacity-0 transition duration-300"
|
|
enterFrom="opacity-0"
|
|
enterTo="opacity-100"
|
|
leave="opacity-100 transition duration-300"
|
|
leaveFrom="opacity-100"
|
|
leaveTo="opacity-0"
|
|
show={jobEditModal.isOpen}
|
|
>
|
|
<Modal
|
|
title={intl.formatMessage(messages.editJobSchedule)}
|
|
okText={
|
|
isSaving
|
|
? intl.formatMessage(globalMessages.saving)
|
|
: intl.formatMessage(globalMessages.save)
|
|
}
|
|
iconSvg={<PencilIcon />}
|
|
onCancel={() => setJobEditModal({ isOpen: false })}
|
|
okDisabled={isSaving}
|
|
onOk={() => scheduleJob()}
|
|
>
|
|
<div className="section">
|
|
<form>
|
|
<div className="pb-6 form-row">
|
|
<label htmlFor="jobSchedule" className="text-label">
|
|
{intl.formatMessage(messages.editJobSchedulePrompt)}
|
|
</label>
|
|
<div className="form-input">
|
|
{jobEditModal.job?.interval === 'short' ? (
|
|
<select
|
|
name="jobScheduleMinutes"
|
|
className="inline"
|
|
value={jobScheduleMinutes}
|
|
onChange={(e) =>
|
|
setJobScheduleMinutes(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={jobScheduleHours}
|
|
onChange={(e) =>
|
|
setJobScheduleHours(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="w-5 h-5 ml-2" />}
|
|
</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={() =>
|
|
setJobEditModal({ isOpen: true, job: job })
|
|
}
|
|
>
|
|
<PencilIcon />
|
|
{intl.formatMessage(globalMessages.edit)}
|
|
</Button>
|
|
)}
|
|
{job.running ? (
|
|
<Button buttonType="danger" onClick={() => cancelJob(job)}>
|
|
<StopIcon />
|
|
<span>{intl.formatMessage(messages.canceljob)}</span>
|
|
</Button>
|
|
) : (
|
|
<Button buttonType="primary" onClick={() => runJob(job)}>
|
|
<PlayIcon className="w-5 h-5 mr-1" />
|
|
<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?.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>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default SettingsJobs;
|