Skip to content

Commit 99fc9a2

Browse files
authored
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
1 parent 611ceeb commit 99fc9a2

File tree

6 files changed

+114
-27
lines changed

6 files changed

+114
-27
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"cookie-parser": "1.4.6",
4848
"copy-to-clipboard": "3.3.2",
4949
"country-flag-icons": "1.5.5",
50+
"cronstrue": "^2.11.0",
5051
"csurf": "1.11.0",
5152
"date-fns": "2.29.1",
5253
"email-templates": "9.0.0",

server/job/schedule.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ interface ScheduledJob {
1414
name: string;
1515
type: 'process' | 'command';
1616
interval: 'short' | 'long' | 'fixed';
17+
cronSchedule: string;
1718
running?: () => boolean;
1819
cancelFn?: () => void;
1920
}
@@ -29,6 +30,7 @@ export const startJobs = (): void => {
2930
name: 'Plex Recently Added Scan',
3031
type: 'process',
3132
interval: 'short',
33+
cronSchedule: jobs['plex-recently-added-scan'].schedule,
3234
job: schedule.scheduleJob(jobs['plex-recently-added-scan'].schedule, () => {
3335
logger.info('Starting scheduled job: Plex Recently Added Scan', {
3436
label: 'Jobs',
@@ -45,6 +47,7 @@ export const startJobs = (): void => {
4547
name: 'Plex Full Library Scan',
4648
type: 'process',
4749
interval: 'long',
50+
cronSchedule: jobs['plex-full-scan'].schedule,
4851
job: schedule.scheduleJob(jobs['plex-full-scan'].schedule, () => {
4952
logger.info('Starting scheduled job: Plex Full Library Scan', {
5053
label: 'Jobs',
@@ -61,6 +64,7 @@ export const startJobs = (): void => {
6164
name: 'Plex Watchlist Sync',
6265
type: 'process',
6366
interval: 'long',
67+
cronSchedule: jobs['plex-watchlist-sync'].schedule,
6468
job: schedule.scheduleJob(jobs['plex-watchlist-sync'].schedule, () => {
6569
logger.info('Starting scheduled job: Plex Watchlist Sync', {
6670
label: 'Jobs',
@@ -75,6 +79,7 @@ export const startJobs = (): void => {
7579
name: 'Radarr Scan',
7680
type: 'process',
7781
interval: 'long',
82+
cronSchedule: jobs['radarr-scan'].schedule,
7883
job: schedule.scheduleJob(jobs['radarr-scan'].schedule, () => {
7984
logger.info('Starting scheduled job: Radarr Scan', { label: 'Jobs' });
8085
radarrScanner.run();
@@ -89,6 +94,7 @@ export const startJobs = (): void => {
8994
name: 'Sonarr Scan',
9095
type: 'process',
9196
interval: 'long',
97+
cronSchedule: jobs['sonarr-scan'].schedule,
9298
job: schedule.scheduleJob(jobs['sonarr-scan'].schedule, () => {
9399
logger.info('Starting scheduled job: Sonarr Scan', { label: 'Jobs' });
94100
sonarrScanner.run();
@@ -103,6 +109,7 @@ export const startJobs = (): void => {
103109
name: 'Download Sync',
104110
type: 'command',
105111
interval: 'fixed',
112+
cronSchedule: jobs['download-sync'].schedule,
106113
job: schedule.scheduleJob(jobs['download-sync'].schedule, () => {
107114
logger.debug('Starting scheduled job: Download Sync', {
108115
label: 'Jobs',
@@ -117,6 +124,7 @@ export const startJobs = (): void => {
117124
name: 'Download Sync Reset',
118125
type: 'command',
119126
interval: 'long',
127+
cronSchedule: jobs['download-sync-reset'].schedule,
120128
job: schedule.scheduleJob(jobs['download-sync-reset'].schedule, () => {
121129
logger.info('Starting scheduled job: Download Sync Reset', {
122130
label: 'Jobs',

server/routes/settings/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,7 @@ settingsRoutes.get('/jobs', (_req, res) => {
433433
name: job.name,
434434
type: job.type,
435435
interval: job.interval,
436+
cronSchedule: job.cronSchedule,
436437
nextExecutionTime: job.job.nextInvocation(),
437438
running: job.running ? job.running() : false,
438439
}))
@@ -453,6 +454,7 @@ settingsRoutes.post<{ jobId: string }>('/jobs/:jobId/run', (req, res, next) => {
453454
name: scheduledJob.name,
454455
type: scheduledJob.type,
455456
interval: scheduledJob.interval,
457+
cronSchedule: scheduledJob.cronSchedule,
456458
nextExecutionTime: scheduledJob.job.nextInvocation(),
457459
running: scheduledJob.running ? scheduledJob.running() : false,
458460
});
@@ -478,6 +480,7 @@ settingsRoutes.post<{ jobId: string }>(
478480
name: scheduledJob.name,
479481
type: scheduledJob.type,
480482
interval: scheduledJob.interval,
483+
cronSchedule: scheduledJob.cronSchedule,
481484
nextExecutionTime: scheduledJob.job.nextInvocation(),
482485
running: scheduledJob.running ? scheduledJob.running() : false,
483486
});
@@ -502,11 +505,14 @@ settingsRoutes.post<{ jobId: string }>(
502505
settings.jobs[scheduledJob.id].schedule = req.body.schedule;
503506
settings.save();
504507

508+
scheduledJob.cronSchedule = req.body.schedule;
509+
505510
return res.status(200).json({
506511
id: scheduledJob.id,
507512
name: scheduledJob.name,
508513
type: scheduledJob.type,
509514
interval: scheduledJob.interval,
515+
cronSchedule: scheduledJob.cronSchedule,
510516
nextExecutionTime: scheduledJob.job.nextInvocation(),
511517
running: scheduledJob.running ? scheduledJob.running() : false,
512518
});

src/components/Settings/SettingsJobsCache/index.tsx

Lines changed: 92 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import LoadingSpinner from '@app/components/Common/LoadingSpinner';
55
import Modal from '@app/components/Common/Modal';
66
import PageTitle from '@app/components/Common/PageTitle';
77
import Table from '@app/components/Common/Table';
8+
import useLocale from '@app/hooks/useLocale';
89
import globalMessages from '@app/i18n/globalMessages';
910
import { formatBytes } from '@app/utils/numberHelpers';
1011
import { Transition } from '@headlessui/react';
@@ -13,7 +14,8 @@ import { PencilIcon } from '@heroicons/react/solid';
1314
import type { CacheItem } from '@server/interfaces/api/settingsInterfaces';
1415
import type { JobId } from '@server/lib/settings';
1516
import axios from 'axios';
16-
import { Fragment, useState } from 'react';
17+
import cronstrue from 'cronstrue/i18n';
18+
import { Fragment, useReducer, useState } from 'react';
1719
import type { MessageDescriptor } from 'react-intl';
1820
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';
1921
import { useToasts } from 'react-toast-notifications';
@@ -55,7 +57,8 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages({
5557
editJobSchedule: 'Modify Job',
5658
jobScheduleEditSaved: 'Job edited successfully!',
5759
jobScheduleEditFailed: 'Something went wrong while saving the job.',
58-
editJobSchedulePrompt: 'Frequency',
60+
editJobScheduleCurrent: 'Current Frequency',
61+
editJobSchedulePrompt: 'New Frequency',
5962
editJobScheduleSelectorHours:
6063
'Every {jobScheduleHours, plural, one {hour} other {{jobScheduleHours} hours}}',
6164
editJobScheduleSelectorMinutes:
@@ -67,12 +70,56 @@ interface Job {
6770
name: string;
6871
type: 'process' | 'command';
6972
interval: 'short' | 'long' | 'fixed';
73+
cronSchedule: string;
7074
nextExecutionTime: string;
7175
running: boolean;
7276
}
7377

78+
type JobModalState = {
79+
isOpen?: boolean;
80+
job?: Job;
81+
scheduleHours: number;
82+
scheduleMinutes: number;
83+
};
84+
85+
type JobModalAction =
86+
| { type: 'set'; hours?: number; minutes?: number }
87+
| {
88+
type: 'close';
89+
}
90+
| { type: 'open'; job?: Job };
91+
92+
const jobModalReducer = (
93+
state: JobModalState,
94+
action: JobModalAction
95+
): JobModalState => {
96+
switch (action.type) {
97+
case 'close':
98+
return {
99+
...state,
100+
isOpen: false,
101+
};
102+
103+
case 'open':
104+
return {
105+
isOpen: true,
106+
job: action.job,
107+
scheduleHours: 1,
108+
scheduleMinutes: 5,
109+
};
110+
111+
case 'set':
112+
return {
113+
...state,
114+
scheduleHours: action.hours ?? state.scheduleHours,
115+
scheduleMinutes: action.minutes ?? state.scheduleMinutes,
116+
};
117+
}
118+
};
119+
74120
const SettingsJobs = () => {
75121
const intl = useIntl();
122+
const { locale } = useLocale();
76123
const { addToast } = useToasts();
77124
const {
78125
data,
@@ -88,15 +135,12 @@ const SettingsJobs = () => {
88135
}
89136
);
90137

91-
const [jobEditModal, setJobEditModal] = useState<{
92-
isOpen: boolean;
93-
job?: Job;
94-
}>({
138+
const [jobModalState, dispatch] = useReducer(jobModalReducer, {
95139
isOpen: false,
140+
scheduleHours: 1,
141+
scheduleMinutes: 5,
96142
});
97143
const [isSaving, setIsSaving] = useState(false);
98-
const [jobScheduleMinutes, setJobScheduleMinutes] = useState(5);
99-
const [jobScheduleHours, setJobScheduleHours] = useState(1);
100144

101145
if (!data && !error) {
102146
return <LoadingSpinner />;
@@ -146,27 +190,29 @@ const SettingsJobs = () => {
146190
const jobScheduleCron = ['0', '0', '*', '*', '*', '*'];
147191

148192
try {
149-
if (jobEditModal.job?.interval === 'short') {
150-
jobScheduleCron[1] = `*/${jobScheduleMinutes}`;
151-
} else if (jobEditModal.job?.interval === 'long') {
152-
jobScheduleCron[2] = `*/${jobScheduleHours}`;
193+
if (jobModalState.job?.interval === 'short') {
194+
jobScheduleCron[1] = `*/${jobModalState.scheduleMinutes}`;
195+
} else if (jobModalState.job?.interval === 'long') {
196+
jobScheduleCron[2] = `*/${jobModalState.scheduleHours}`;
153197
} else {
154198
// jobs with interval: fixed should not be editable
155199
throw new Error();
156200
}
157201

158202
setIsSaving(true);
159203
await axios.post(
160-
`/api/v1/settings/jobs/${jobEditModal.job?.id}/schedule`,
204+
`/api/v1/settings/jobs/${jobModalState.job.id}/schedule`,
161205
{
162206
schedule: jobScheduleCron.join(' '),
163207
}
164208
);
209+
165210
addToast(intl.formatMessage(messages.jobScheduleEditSaved), {
166211
appearance: 'success',
167212
autoDismiss: true,
168213
});
169-
setJobEditModal({ isOpen: false });
214+
215+
dispatch({ type: 'close' });
170216
revalidate();
171217
} catch (e) {
172218
addToast(intl.formatMessage(messages.jobScheduleEditFailed), {
@@ -194,7 +240,7 @@ const SettingsJobs = () => {
194240
leave="opacity-100 transition duration-300"
195241
leaveFrom="opacity-100"
196242
leaveTo="opacity-0"
197-
show={jobEditModal.isOpen}
243+
show={jobModalState.isOpen}
198244
>
199245
<Modal
200246
title={intl.formatMessage(messages.editJobSchedule)}
@@ -203,24 +249,43 @@ const SettingsJobs = () => {
203249
? intl.formatMessage(globalMessages.saving)
204250
: intl.formatMessage(globalMessages.save)
205251
}
206-
onCancel={() => setJobEditModal({ isOpen: false })}
252+
onCancel={() => dispatch({ type: 'close' })}
207253
okDisabled={isSaving}
208254
onOk={() => scheduleJob()}
209255
>
210256
<div className="section">
211-
<form>
212-
<div className="form-row pb-6">
257+
<form className="mb-6">
258+
<div className="form-row">
259+
<label className="text-label">
260+
{intl.formatMessage(messages.editJobScheduleCurrent)}
261+
</label>
262+
<div className="form-input-area mt-2 mb-1">
263+
<div>
264+
{jobModalState.job &&
265+
cronstrue.toString(jobModalState.job.cronSchedule, {
266+
locale,
267+
})}
268+
</div>
269+
<div className="text-sm text-gray-500">
270+
{jobModalState.job?.cronSchedule}
271+
</div>
272+
</div>
273+
</div>
274+
<div className="form-row">
213275
<label htmlFor="jobSchedule" className="text-label">
214276
{intl.formatMessage(messages.editJobSchedulePrompt)}
215277
</label>
216278
<div className="form-input-area">
217-
{jobEditModal.job?.interval === 'short' ? (
279+
{jobModalState.job?.interval === 'short' ? (
218280
<select
219281
name="jobScheduleMinutes"
220282
className="inline"
221-
value={jobScheduleMinutes}
283+
value={jobModalState.scheduleMinutes}
222284
onChange={(e) =>
223-
setJobScheduleMinutes(Number(e.target.value))
285+
dispatch({
286+
type: 'set',
287+
minutes: Number(e.target.value),
288+
})
224289
}
225290
>
226291
{[5, 10, 15, 20, 30, 60].map((v) => (
@@ -238,9 +303,12 @@ const SettingsJobs = () => {
238303
<select
239304
name="jobScheduleHours"
240305
className="inline"
241-
value={jobScheduleHours}
306+
value={jobModalState.scheduleHours}
242307
onChange={(e) =>
243-
setJobScheduleHours(Number(e.target.value))
308+
dispatch({
309+
type: 'set',
310+
hours: Number(e.target.value),
311+
})
244312
}
245313
>
246314
{[1, 2, 3, 4, 6, 8, 12, 24, 48, 72].map((v) => (
@@ -319,9 +387,7 @@ const SettingsJobs = () => {
319387
<Button
320388
className="mr-2"
321389
buttonType="warning"
322-
onClick={() =>
323-
setJobEditModal({ isOpen: true, job: job })
324-
}
390+
onClick={() => dispatch({ type: 'open', job })}
325391
>
326392
<PencilIcon />
327393
<span>{intl.formatMessage(globalMessages.edit)}</span>

src/i18n/locale/en.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -639,7 +639,8 @@
639639
"components.Settings.SettingsJobsCache.download-sync": "Download Sync",
640640
"components.Settings.SettingsJobsCache.download-sync-reset": "Download Sync Reset",
641641
"components.Settings.SettingsJobsCache.editJobSchedule": "Modify Job",
642-
"components.Settings.SettingsJobsCache.editJobSchedulePrompt": "Frequency",
642+
"components.Settings.SettingsJobsCache.editJobScheduleCurrent": "Current Frequency",
643+
"components.Settings.SettingsJobsCache.editJobSchedulePrompt": "New Frequency",
643644
"components.Settings.SettingsJobsCache.editJobScheduleSelectorHours": "Every {jobScheduleHours, plural, one {hour} other {{jobScheduleHours} hours}}",
644645
"components.Settings.SettingsJobsCache.editJobScheduleSelectorMinutes": "Every {jobScheduleMinutes, plural, one {minute} other {{jobScheduleMinutes} minutes}}",
645646
"components.Settings.SettingsJobsCache.flushcache": "Flush Cache",

yarn.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4847,6 +4847,11 @@ cron-parser@^3.5.0:
48474847
is-nan "^1.3.2"
48484848
luxon "^1.26.0"
48494849

4850+
cronstrue@^2.11.0:
4851+
version "2.11.0"
4852+
resolved "https://registry.yarnpkg.com/cronstrue/-/cronstrue-2.11.0.tgz#18ff1b95a836b9b4e06854f796db2dc8fa98ce41"
4853+
integrity sha512-iIBCSis5yqtFYWtJAmNOiwDveFWWIn+8uV5UYuPHYu/Aeu5CSSJepSbaHMyfc+pPFgnsCcGzfPQEo7LSGmWbTg==
4854+
48504855
cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3:
48514856
version "7.0.3"
48524857
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"

0 commit comments

Comments
 (0)