From 99fc9a2da01b1628d5f849ce56f016c0ab26c3db Mon Sep 17 00:00:00 2001 From: Danshil Kokil Mungur Date: Mon, 12 Sep 2022 05:14:27 +0400 Subject: [PATCH] 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 --- package.json | 1 + server/job/schedule.ts | 8 ++ server/routes/settings/index.ts | 6 + .../Settings/SettingsJobsCache/index.tsx | 118 ++++++++++++++---- src/i18n/locale/en.json | 3 +- yarn.lock | 5 + 6 files changed, 114 insertions(+), 27 deletions(-) diff --git a/package.json b/package.json index 91c4884a..f739ebfb 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "cookie-parser": "1.4.6", "copy-to-clipboard": "3.3.2", "country-flag-icons": "1.5.5", + "cronstrue": "^2.11.0", "csurf": "1.11.0", "date-fns": "2.29.1", "email-templates": "9.0.0", diff --git a/server/job/schedule.ts b/server/job/schedule.ts index 07b2fa25..29dabc13 100644 --- a/server/job/schedule.ts +++ b/server/job/schedule.ts @@ -14,6 +14,7 @@ interface ScheduledJob { name: string; type: 'process' | 'command'; interval: 'short' | 'long' | 'fixed'; + cronSchedule: string; running?: () => boolean; cancelFn?: () => void; } @@ -29,6 +30,7 @@ export const startJobs = (): void => { name: 'Plex Recently Added Scan', type: 'process', interval: 'short', + cronSchedule: jobs['plex-recently-added-scan'].schedule, job: schedule.scheduleJob(jobs['plex-recently-added-scan'].schedule, () => { logger.info('Starting scheduled job: Plex Recently Added Scan', { label: 'Jobs', @@ -45,6 +47,7 @@ export const startJobs = (): void => { name: 'Plex Full Library Scan', type: 'process', interval: 'long', + cronSchedule: jobs['plex-full-scan'].schedule, job: schedule.scheduleJob(jobs['plex-full-scan'].schedule, () => { logger.info('Starting scheduled job: Plex Full Library Scan', { label: 'Jobs', @@ -61,6 +64,7 @@ export const startJobs = (): void => { name: 'Plex Watchlist Sync', type: 'process', interval: 'long', + cronSchedule: jobs['plex-watchlist-sync'].schedule, job: schedule.scheduleJob(jobs['plex-watchlist-sync'].schedule, () => { logger.info('Starting scheduled job: Plex Watchlist Sync', { label: 'Jobs', @@ -75,6 +79,7 @@ export const startJobs = (): void => { name: 'Radarr Scan', type: 'process', interval: 'long', + cronSchedule: jobs['radarr-scan'].schedule, job: schedule.scheduleJob(jobs['radarr-scan'].schedule, () => { logger.info('Starting scheduled job: Radarr Scan', { label: 'Jobs' }); radarrScanner.run(); @@ -89,6 +94,7 @@ export const startJobs = (): void => { name: 'Sonarr Scan', type: 'process', interval: 'long', + cronSchedule: jobs['sonarr-scan'].schedule, job: schedule.scheduleJob(jobs['sonarr-scan'].schedule, () => { logger.info('Starting scheduled job: Sonarr Scan', { label: 'Jobs' }); sonarrScanner.run(); @@ -103,6 +109,7 @@ export const startJobs = (): void => { name: 'Download Sync', type: 'command', interval: 'fixed', + cronSchedule: jobs['download-sync'].schedule, job: schedule.scheduleJob(jobs['download-sync'].schedule, () => { logger.debug('Starting scheduled job: Download Sync', { label: 'Jobs', @@ -117,6 +124,7 @@ export const startJobs = (): void => { name: 'Download Sync Reset', type: 'command', interval: 'long', + cronSchedule: jobs['download-sync-reset'].schedule, job: schedule.scheduleJob(jobs['download-sync-reset'].schedule, () => { logger.info('Starting scheduled job: Download Sync Reset', { label: 'Jobs', diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 40514775..de23d4b8 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -433,6 +433,7 @@ settingsRoutes.get('/jobs', (_req, res) => { name: job.name, type: job.type, interval: job.interval, + cronSchedule: job.cronSchedule, nextExecutionTime: job.job.nextInvocation(), running: job.running ? job.running() : false, })) @@ -453,6 +454,7 @@ settingsRoutes.post<{ jobId: string }>('/jobs/:jobId/run', (req, res, next) => { name: scheduledJob.name, type: scheduledJob.type, interval: scheduledJob.interval, + cronSchedule: scheduledJob.cronSchedule, nextExecutionTime: scheduledJob.job.nextInvocation(), running: scheduledJob.running ? scheduledJob.running() : false, }); @@ -478,6 +480,7 @@ settingsRoutes.post<{ jobId: string }>( name: scheduledJob.name, type: scheduledJob.type, interval: scheduledJob.interval, + cronSchedule: scheduledJob.cronSchedule, nextExecutionTime: scheduledJob.job.nextInvocation(), running: scheduledJob.running ? scheduledJob.running() : false, }); @@ -502,11 +505,14 @@ settingsRoutes.post<{ jobId: string }>( settings.jobs[scheduledJob.id].schedule = req.body.schedule; settings.save(); + scheduledJob.cronSchedule = req.body.schedule; + return res.status(200).json({ id: scheduledJob.id, name: scheduledJob.name, type: scheduledJob.type, interval: scheduledJob.interval, + cronSchedule: scheduledJob.cronSchedule, nextExecutionTime: scheduledJob.job.nextInvocation(), running: scheduledJob.running ? scheduledJob.running() : false, }); diff --git a/src/components/Settings/SettingsJobsCache/index.tsx b/src/components/Settings/SettingsJobsCache/index.tsx index 5d73895f..d9b31bd0 100644 --- a/src/components/Settings/SettingsJobsCache/index.tsx +++ b/src/components/Settings/SettingsJobsCache/index.tsx @@ -5,6 +5,7 @@ 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'; @@ -13,7 +14,8 @@ import { PencilIcon } from '@heroicons/react/solid'; import type { CacheItem } from '@server/interfaces/api/settingsInterfaces'; import type { JobId } from '@server/lib/settings'; 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 { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; @@ -55,7 +57,8 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages({ editJobSchedule: 'Modify Job', jobScheduleEditSaved: 'Job edited successfully!', jobScheduleEditFailed: 'Something went wrong while saving the job.', - editJobSchedulePrompt: 'Frequency', + editJobScheduleCurrent: 'Current Frequency', + editJobSchedulePrompt: 'New Frequency', editJobScheduleSelectorHours: 'Every {jobScheduleHours, plural, one {hour} other {{jobScheduleHours} hours}}', editJobScheduleSelectorMinutes: @@ -67,12 +70,56 @@ interface Job { 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, @@ -88,15 +135,12 @@ const SettingsJobs = () => { } ); - const [jobEditModal, setJobEditModal] = useState<{ - isOpen: boolean; - job?: Job; - }>({ + const [jobModalState, dispatch] = useReducer(jobModalReducer, { isOpen: false, + scheduleHours: 1, + scheduleMinutes: 5, }); const [isSaving, setIsSaving] = useState(false); - const [jobScheduleMinutes, setJobScheduleMinutes] = useState(5); - const [jobScheduleHours, setJobScheduleHours] = useState(1); if (!data && !error) { return ; @@ -146,10 +190,10 @@ const SettingsJobs = () => { const jobScheduleCron = ['0', '0', '*', '*', '*', '*']; try { - if (jobEditModal.job?.interval === 'short') { - jobScheduleCron[1] = `*/${jobScheduleMinutes}`; - } else if (jobEditModal.job?.interval === 'long') { - jobScheduleCron[2] = `*/${jobScheduleHours}`; + 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(); @@ -157,16 +201,18 @@ const SettingsJobs = () => { setIsSaving(true); await axios.post( - `/api/v1/settings/jobs/${jobEditModal.job?.id}/schedule`, + `/api/v1/settings/jobs/${jobModalState.job.id}/schedule`, { schedule: jobScheduleCron.join(' '), } ); + addToast(intl.formatMessage(messages.jobScheduleEditSaved), { appearance: 'success', autoDismiss: true, }); - setJobEditModal({ isOpen: false }); + + dispatch({ type: 'close' }); revalidate(); } catch (e) { addToast(intl.formatMessage(messages.jobScheduleEditFailed), { @@ -194,7 +240,7 @@ const SettingsJobs = () => { leave="opacity-100 transition duration-300" leaveFrom="opacity-100" leaveTo="opacity-0" - show={jobEditModal.isOpen} + show={jobModalState.isOpen} > { ? intl.formatMessage(globalMessages.saving) : intl.formatMessage(globalMessages.save) } - onCancel={() => setJobEditModal({ isOpen: false })} + onCancel={() => dispatch({ type: 'close' })} okDisabled={isSaving} onOk={() => scheduleJob()} >
-
-
+ +
+ +
+
+ {jobModalState.job && + cronstrue.toString(jobModalState.job.cronSchedule, { + locale, + })} +
+
+ {jobModalState.job?.cronSchedule} +
+
+
+
- {jobEditModal.job?.interval === 'short' ? ( + {jobModalState.job?.interval === 'short' ? ( - 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) => ( @@ -319,9 +387,7 @@ const SettingsJobs = () => {