feat(jobs): show current job frequency in edit modal (#3008)

* fix(jobs): reset job schedule edit modal values when closed

* feat(jobs): show job's current frequency

* fix(jobs): reset job schedule edit modal values when cancelled

* chore: rebase

* refactor(jobs): use reducer instead of several react states

* fix(jobs): reset modal state when opening instead of closing the modal

This prevents the modal state from glitching when saving/closing the modal

* feat(jobs): parse job schedule cron string

unavailable locale will fallback to english
pull/2908/head
Danshil Kokil Mungur 2 years ago committed by GitHub
parent 611ceeb5f4
commit 99fc9a2da0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -47,6 +47,7 @@
"cookie-parser": "1.4.6", "cookie-parser": "1.4.6",
"copy-to-clipboard": "3.3.2", "copy-to-clipboard": "3.3.2",
"country-flag-icons": "1.5.5", "country-flag-icons": "1.5.5",
"cronstrue": "^2.11.0",
"csurf": "1.11.0", "csurf": "1.11.0",
"date-fns": "2.29.1", "date-fns": "2.29.1",
"email-templates": "9.0.0", "email-templates": "9.0.0",

@ -14,6 +14,7 @@ interface ScheduledJob {
name: string; name: string;
type: 'process' | 'command'; type: 'process' | 'command';
interval: 'short' | 'long' | 'fixed'; interval: 'short' | 'long' | 'fixed';
cronSchedule: string;
running?: () => boolean; running?: () => boolean;
cancelFn?: () => void; cancelFn?: () => void;
} }
@ -29,6 +30,7 @@ export const startJobs = (): void => {
name: 'Plex Recently Added Scan', name: 'Plex Recently Added Scan',
type: 'process', type: 'process',
interval: 'short', interval: 'short',
cronSchedule: jobs['plex-recently-added-scan'].schedule,
job: schedule.scheduleJob(jobs['plex-recently-added-scan'].schedule, () => { job: schedule.scheduleJob(jobs['plex-recently-added-scan'].schedule, () => {
logger.info('Starting scheduled job: Plex Recently Added Scan', { logger.info('Starting scheduled job: Plex Recently Added Scan', {
label: 'Jobs', label: 'Jobs',
@ -45,6 +47,7 @@ export const startJobs = (): void => {
name: 'Plex Full Library Scan', name: 'Plex Full Library Scan',
type: 'process', type: 'process',
interval: 'long', interval: 'long',
cronSchedule: jobs['plex-full-scan'].schedule,
job: schedule.scheduleJob(jobs['plex-full-scan'].schedule, () => { job: schedule.scheduleJob(jobs['plex-full-scan'].schedule, () => {
logger.info('Starting scheduled job: Plex Full Library Scan', { logger.info('Starting scheduled job: Plex Full Library Scan', {
label: 'Jobs', label: 'Jobs',
@ -61,6 +64,7 @@ export const startJobs = (): void => {
name: 'Plex Watchlist Sync', name: 'Plex Watchlist Sync',
type: 'process', type: 'process',
interval: 'long', interval: 'long',
cronSchedule: jobs['plex-watchlist-sync'].schedule,
job: schedule.scheduleJob(jobs['plex-watchlist-sync'].schedule, () => { job: schedule.scheduleJob(jobs['plex-watchlist-sync'].schedule, () => {
logger.info('Starting scheduled job: Plex Watchlist Sync', { logger.info('Starting scheduled job: Plex Watchlist Sync', {
label: 'Jobs', label: 'Jobs',
@ -75,6 +79,7 @@ export const startJobs = (): void => {
name: 'Radarr Scan', name: 'Radarr Scan',
type: 'process', type: 'process',
interval: 'long', interval: 'long',
cronSchedule: jobs['radarr-scan'].schedule,
job: schedule.scheduleJob(jobs['radarr-scan'].schedule, () => { job: schedule.scheduleJob(jobs['radarr-scan'].schedule, () => {
logger.info('Starting scheduled job: Radarr Scan', { label: 'Jobs' }); logger.info('Starting scheduled job: Radarr Scan', { label: 'Jobs' });
radarrScanner.run(); radarrScanner.run();
@ -89,6 +94,7 @@ export const startJobs = (): void => {
name: 'Sonarr Scan', name: 'Sonarr Scan',
type: 'process', type: 'process',
interval: 'long', interval: 'long',
cronSchedule: jobs['sonarr-scan'].schedule,
job: schedule.scheduleJob(jobs['sonarr-scan'].schedule, () => { job: schedule.scheduleJob(jobs['sonarr-scan'].schedule, () => {
logger.info('Starting scheduled job: Sonarr Scan', { label: 'Jobs' }); logger.info('Starting scheduled job: Sonarr Scan', { label: 'Jobs' });
sonarrScanner.run(); sonarrScanner.run();
@ -103,6 +109,7 @@ export const startJobs = (): void => {
name: 'Download Sync', name: 'Download Sync',
type: 'command', type: 'command',
interval: 'fixed', interval: 'fixed',
cronSchedule: jobs['download-sync'].schedule,
job: schedule.scheduleJob(jobs['download-sync'].schedule, () => { job: schedule.scheduleJob(jobs['download-sync'].schedule, () => {
logger.debug('Starting scheduled job: Download Sync', { logger.debug('Starting scheduled job: Download Sync', {
label: 'Jobs', label: 'Jobs',
@ -117,6 +124,7 @@ export const startJobs = (): void => {
name: 'Download Sync Reset', name: 'Download Sync Reset',
type: 'command', type: 'command',
interval: 'long', interval: 'long',
cronSchedule: jobs['download-sync-reset'].schedule,
job: schedule.scheduleJob(jobs['download-sync-reset'].schedule, () => { job: schedule.scheduleJob(jobs['download-sync-reset'].schedule, () => {
logger.info('Starting scheduled job: Download Sync Reset', { logger.info('Starting scheduled job: Download Sync Reset', {
label: 'Jobs', label: 'Jobs',

@ -433,6 +433,7 @@ settingsRoutes.get('/jobs', (_req, res) => {
name: job.name, name: job.name,
type: job.type, type: job.type,
interval: job.interval, interval: job.interval,
cronSchedule: job.cronSchedule,
nextExecutionTime: job.job.nextInvocation(), nextExecutionTime: job.job.nextInvocation(),
running: job.running ? job.running() : false, running: job.running ? job.running() : false,
})) }))
@ -453,6 +454,7 @@ settingsRoutes.post<{ jobId: string }>('/jobs/:jobId/run', (req, res, next) => {
name: scheduledJob.name, name: scheduledJob.name,
type: scheduledJob.type, type: scheduledJob.type,
interval: scheduledJob.interval, interval: scheduledJob.interval,
cronSchedule: scheduledJob.cronSchedule,
nextExecutionTime: scheduledJob.job.nextInvocation(), nextExecutionTime: scheduledJob.job.nextInvocation(),
running: scheduledJob.running ? scheduledJob.running() : false, running: scheduledJob.running ? scheduledJob.running() : false,
}); });
@ -478,6 +480,7 @@ settingsRoutes.post<{ jobId: string }>(
name: scheduledJob.name, name: scheduledJob.name,
type: scheduledJob.type, type: scheduledJob.type,
interval: scheduledJob.interval, interval: scheduledJob.interval,
cronSchedule: scheduledJob.cronSchedule,
nextExecutionTime: scheduledJob.job.nextInvocation(), nextExecutionTime: scheduledJob.job.nextInvocation(),
running: scheduledJob.running ? scheduledJob.running() : false, running: scheduledJob.running ? scheduledJob.running() : false,
}); });
@ -502,11 +505,14 @@ settingsRoutes.post<{ jobId: string }>(
settings.jobs[scheduledJob.id].schedule = req.body.schedule; settings.jobs[scheduledJob.id].schedule = req.body.schedule;
settings.save(); settings.save();
scheduledJob.cronSchedule = req.body.schedule;
return res.status(200).json({ return res.status(200).json({
id: scheduledJob.id, id: scheduledJob.id,
name: scheduledJob.name, name: scheduledJob.name,
type: scheduledJob.type, type: scheduledJob.type,
interval: scheduledJob.interval, interval: scheduledJob.interval,
cronSchedule: scheduledJob.cronSchedule,
nextExecutionTime: scheduledJob.job.nextInvocation(), nextExecutionTime: scheduledJob.job.nextInvocation(),
running: scheduledJob.running ? scheduledJob.running() : false, running: scheduledJob.running ? scheduledJob.running() : false,
}); });

@ -5,6 +5,7 @@ import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import Modal from '@app/components/Common/Modal'; import Modal from '@app/components/Common/Modal';
import PageTitle from '@app/components/Common/PageTitle'; import PageTitle from '@app/components/Common/PageTitle';
import Table from '@app/components/Common/Table'; import Table from '@app/components/Common/Table';
import useLocale from '@app/hooks/useLocale';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import { formatBytes } from '@app/utils/numberHelpers'; import { formatBytes } from '@app/utils/numberHelpers';
import { Transition } from '@headlessui/react'; import { Transition } from '@headlessui/react';
@ -13,7 +14,8 @@ import { PencilIcon } from '@heroicons/react/solid';
import type { CacheItem } from '@server/interfaces/api/settingsInterfaces'; import type { CacheItem } from '@server/interfaces/api/settingsInterfaces';
import type { JobId } from '@server/lib/settings'; import type { JobId } from '@server/lib/settings';
import axios from 'axios'; import axios from 'axios';
import { Fragment, useState } from 'react'; import cronstrue from 'cronstrue/i18n';
import { Fragment, useReducer, useState } from 'react';
import type { MessageDescriptor } from 'react-intl'; import type { MessageDescriptor } from 'react-intl';
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl'; import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
@ -55,7 +57,8 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages({
editJobSchedule: 'Modify Job', editJobSchedule: 'Modify Job',
jobScheduleEditSaved: 'Job edited successfully!', jobScheduleEditSaved: 'Job edited successfully!',
jobScheduleEditFailed: 'Something went wrong while saving the job.', jobScheduleEditFailed: 'Something went wrong while saving the job.',
editJobSchedulePrompt: 'Frequency', editJobScheduleCurrent: 'Current Frequency',
editJobSchedulePrompt: 'New Frequency',
editJobScheduleSelectorHours: editJobScheduleSelectorHours:
'Every {jobScheduleHours, plural, one {hour} other {{jobScheduleHours} hours}}', 'Every {jobScheduleHours, plural, one {hour} other {{jobScheduleHours} hours}}',
editJobScheduleSelectorMinutes: editJobScheduleSelectorMinutes:
@ -67,12 +70,56 @@ interface Job {
name: string; name: string;
type: 'process' | 'command'; type: 'process' | 'command';
interval: 'short' | 'long' | 'fixed'; interval: 'short' | 'long' | 'fixed';
cronSchedule: string;
nextExecutionTime: string; nextExecutionTime: string;
running: boolean; 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 SettingsJobs = () => {
const intl = useIntl(); const intl = useIntl();
const { locale } = useLocale();
const { addToast } = useToasts(); const { addToast } = useToasts();
const { const {
data, data,
@ -88,15 +135,12 @@ const SettingsJobs = () => {
} }
); );
const [jobEditModal, setJobEditModal] = useState<{ const [jobModalState, dispatch] = useReducer(jobModalReducer, {
isOpen: boolean;
job?: Job;
}>({
isOpen: false, isOpen: false,
scheduleHours: 1,
scheduleMinutes: 5,
}); });
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [jobScheduleMinutes, setJobScheduleMinutes] = useState(5);
const [jobScheduleHours, setJobScheduleHours] = useState(1);
if (!data && !error) { if (!data && !error) {
return <LoadingSpinner />; return <LoadingSpinner />;
@ -146,10 +190,10 @@ const SettingsJobs = () => {
const jobScheduleCron = ['0', '0', '*', '*', '*', '*']; const jobScheduleCron = ['0', '0', '*', '*', '*', '*'];
try { try {
if (jobEditModal.job?.interval === 'short') { if (jobModalState.job?.interval === 'short') {
jobScheduleCron[1] = `*/${jobScheduleMinutes}`; jobScheduleCron[1] = `*/${jobModalState.scheduleMinutes}`;
} else if (jobEditModal.job?.interval === 'long') { } else if (jobModalState.job?.interval === 'long') {
jobScheduleCron[2] = `*/${jobScheduleHours}`; jobScheduleCron[2] = `*/${jobModalState.scheduleHours}`;
} else { } else {
// jobs with interval: fixed should not be editable // jobs with interval: fixed should not be editable
throw new Error(); throw new Error();
@ -157,16 +201,18 @@ const SettingsJobs = () => {
setIsSaving(true); setIsSaving(true);
await axios.post( await axios.post(
`/api/v1/settings/jobs/${jobEditModal.job?.id}/schedule`, `/api/v1/settings/jobs/${jobModalState.job.id}/schedule`,
{ {
schedule: jobScheduleCron.join(' '), schedule: jobScheduleCron.join(' '),
} }
); );
addToast(intl.formatMessage(messages.jobScheduleEditSaved), { addToast(intl.formatMessage(messages.jobScheduleEditSaved), {
appearance: 'success', appearance: 'success',
autoDismiss: true, autoDismiss: true,
}); });
setJobEditModal({ isOpen: false });
dispatch({ type: 'close' });
revalidate(); revalidate();
} catch (e) { } catch (e) {
addToast(intl.formatMessage(messages.jobScheduleEditFailed), { addToast(intl.formatMessage(messages.jobScheduleEditFailed), {
@ -194,7 +240,7 @@ const SettingsJobs = () => {
leave="opacity-100 transition duration-300" leave="opacity-100 transition duration-300"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
show={jobEditModal.isOpen} show={jobModalState.isOpen}
> >
<Modal <Modal
title={intl.formatMessage(messages.editJobSchedule)} title={intl.formatMessage(messages.editJobSchedule)}
@ -203,24 +249,43 @@ const SettingsJobs = () => {
? intl.formatMessage(globalMessages.saving) ? intl.formatMessage(globalMessages.saving)
: intl.formatMessage(globalMessages.save) : intl.formatMessage(globalMessages.save)
} }
onCancel={() => setJobEditModal({ isOpen: false })} onCancel={() => dispatch({ type: 'close' })}
okDisabled={isSaving} okDisabled={isSaving}
onOk={() => scheduleJob()} onOk={() => scheduleJob()}
> >
<div className="section"> <div className="section">
<form> <form className="mb-6">
<div className="form-row pb-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"> <label htmlFor="jobSchedule" className="text-label">
{intl.formatMessage(messages.editJobSchedulePrompt)} {intl.formatMessage(messages.editJobSchedulePrompt)}
</label> </label>
<div className="form-input-area"> <div className="form-input-area">
{jobEditModal.job?.interval === 'short' ? ( {jobModalState.job?.interval === 'short' ? (
<select <select
name="jobScheduleMinutes" name="jobScheduleMinutes"
className="inline" className="inline"
value={jobScheduleMinutes} value={jobModalState.scheduleMinutes}
onChange={(e) => onChange={(e) =>
setJobScheduleMinutes(Number(e.target.value)) dispatch({
type: 'set',
minutes: Number(e.target.value),
})
} }
> >
{[5, 10, 15, 20, 30, 60].map((v) => ( {[5, 10, 15, 20, 30, 60].map((v) => (
@ -238,9 +303,12 @@ const SettingsJobs = () => {
<select <select
name="jobScheduleHours" name="jobScheduleHours"
className="inline" className="inline"
value={jobScheduleHours} value={jobModalState.scheduleHours}
onChange={(e) => onChange={(e) =>
setJobScheduleHours(Number(e.target.value)) dispatch({
type: 'set',
hours: Number(e.target.value),
})
} }
> >
{[1, 2, 3, 4, 6, 8, 12, 24, 48, 72].map((v) => ( {[1, 2, 3, 4, 6, 8, 12, 24, 48, 72].map((v) => (
@ -319,9 +387,7 @@ const SettingsJobs = () => {
<Button <Button
className="mr-2" className="mr-2"
buttonType="warning" buttonType="warning"
onClick={() => onClick={() => dispatch({ type: 'open', job })}
setJobEditModal({ isOpen: true, job: job })
}
> >
<PencilIcon /> <PencilIcon />
<span>{intl.formatMessage(globalMessages.edit)}</span> <span>{intl.formatMessage(globalMessages.edit)}</span>

@ -639,7 +639,8 @@
"components.Settings.SettingsJobsCache.download-sync": "Download Sync", "components.Settings.SettingsJobsCache.download-sync": "Download Sync",
"components.Settings.SettingsJobsCache.download-sync-reset": "Download Sync Reset", "components.Settings.SettingsJobsCache.download-sync-reset": "Download Sync Reset",
"components.Settings.SettingsJobsCache.editJobSchedule": "Modify Job", "components.Settings.SettingsJobsCache.editJobSchedule": "Modify Job",
"components.Settings.SettingsJobsCache.editJobSchedulePrompt": "Frequency", "components.Settings.SettingsJobsCache.editJobScheduleCurrent": "Current Frequency",
"components.Settings.SettingsJobsCache.editJobSchedulePrompt": "New Frequency",
"components.Settings.SettingsJobsCache.editJobScheduleSelectorHours": "Every {jobScheduleHours, plural, one {hour} other {{jobScheduleHours} hours}}", "components.Settings.SettingsJobsCache.editJobScheduleSelectorHours": "Every {jobScheduleHours, plural, one {hour} other {{jobScheduleHours} hours}}",
"components.Settings.SettingsJobsCache.editJobScheduleSelectorMinutes": "Every {jobScheduleMinutes, plural, one {minute} other {{jobScheduleMinutes} minutes}}", "components.Settings.SettingsJobsCache.editJobScheduleSelectorMinutes": "Every {jobScheduleMinutes, plural, one {minute} other {{jobScheduleMinutes} minutes}}",
"components.Settings.SettingsJobsCache.flushcache": "Flush Cache", "components.Settings.SettingsJobsCache.flushcache": "Flush Cache",

@ -4847,6 +4847,11 @@ cron-parser@^3.5.0:
is-nan "^1.3.2" is-nan "^1.3.2"
luxon "^1.26.0" luxon "^1.26.0"
cronstrue@^2.11.0:
version "2.11.0"
resolved "https://registry.yarnpkg.com/cronstrue/-/cronstrue-2.11.0.tgz#18ff1b95a836b9b4e06854f796db2dc8fa98ce41"
integrity sha512-iIBCSis5yqtFYWtJAmNOiwDveFWWIn+8uV5UYuPHYu/Aeu5CSSJepSbaHMyfc+pPFgnsCcGzfPQEo7LSGmWbTg==
cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3: cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3:
version "7.0.3" version "7.0.3"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"

Loading…
Cancel
Save