From 82614ca4410782a12d65b4c0a6526ff064be1241 Mon Sep 17 00:00:00 2001 From: Danshil Kokil Mungur Date: Fri, 15 Oct 2021 16:23:39 +0400 Subject: [PATCH] feat(jobs): allow modifying job schedules (#1440) * feat(jobs): backend implementation * feat(jobs): initial frontend implementation * feat(jobs): store job settings as Record * feat(jobs): use heroicons/react instead of inline svgs * feat(jobs): use presets instead of cron expressions * feat(jobs): ran `yarn i18n:extract` * feat(jobs): suggested changes - use job ids in settings - add intervalDuration to jobs to allow choosing only minutes or hours for the job schedule - move job schedule defaults to settings.json - better TS types for jobs in settings cache component - make suggested changes to wording - plural form for label when job schedule can be defined in minutes - add fixed job interval duration - add predefined interval choices for minutes and hours - add new schema for job to overseerr api * feat(jobs): required change for CI to not fail * feat(jobs): suggested changes * fix(jobs): revert offending type refactor --- overseerr-api.yml | 106 ++++++------ server/job/schedule.ts | 34 ++-- server/lib/settings.ts | 41 +++++ server/routes/settings/index.ts | 38 ++++- .../Settings/SettingsJobsCache/index.tsx | 151 +++++++++++++++++- src/i18n/locale/en.json | 6 + 6 files changed, 310 insertions(+), 66 deletions(-) diff --git a/overseerr-api.yml b/overseerr-api.yml index 0a1ef5be..63638eea 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -1278,6 +1278,27 @@ components: allowSelfSigned: type: boolean example: false + Job: + type: object + properties: + id: + type: string + example: job-name + type: + type: string + enum: [process, command] + interval: + type: string + enum: [short, long, fixed] + name: + type: string + example: A Job Name + nextExecutionTime: + type: string + example: '2020-09-02T05:02:23.000Z' + running: + type: boolean + example: false PersonDetail: type: object properties: @@ -2214,23 +2235,7 @@ paths: schema: type: array items: - type: object - properties: - id: - type: string - example: job-name - name: - type: string - example: A Job Name - type: - type: string - enum: [process, command] - nextExecutionTime: - type: string - example: '2020-09-02T05:02:23.000Z' - running: - type: boolean - example: false + $ref: '#/components/schemas/Job' /settings/jobs/{jobId}/run: post: summary: Invoke a specific job @@ -2249,23 +2254,7 @@ paths: content: application/json: schema: - type: object - properties: - id: - type: string - example: job-name - type: - type: string - enum: [process, command] - name: - type: string - example: A Job Name - nextExecutionTime: - type: string - example: '2020-09-02T05:02:23.000Z' - running: - type: boolean - example: false + $ref: '#/components/schemas/Job' /settings/jobs/{jobId}/cancel: post: summary: Cancel a specific job @@ -2284,23 +2273,36 @@ paths: content: application/json: schema: - type: object - properties: - id: - type: string - example: job-name - type: - type: string - enum: [process, command] - name: - type: string - example: A Job Name - nextExecutionTime: - type: string - example: '2020-09-02T05:02:23.000Z' - running: - type: boolean - example: false + $ref: '#/components/schemas/Job' + /settings/jobs/{jobId}/schedule: + post: + summary: Modify job schedule + description: Re-registers the job with the schedule specified. Will return the job in JSON format. + tags: + - settings + parameters: + - in: path + name: jobId + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + schedule: + type: string + example: '0 */5 * * * *' + responses: + '200': + description: Rescheduled job + content: + application/json: + schema: + $ref: '#/components/schemas/Job' /settings/cache: get: summary: Get a list of active caches @@ -2398,7 +2400,7 @@ paths: example: Server ready on port 5055 timestamp: type: string - example: 2020-12-15T16:20:00.069Z + example: '2020-12-15T16:20:00.069Z' /settings/notifications/email: get: summary: Get email notification settings diff --git a/server/job/schedule.ts b/server/job/schedule.ts index 1e3665b8..568b28c9 100644 --- a/server/job/schedule.ts +++ b/server/job/schedule.ts @@ -1,15 +1,17 @@ import schedule from 'node-schedule'; -import logger from '../logger'; import downloadTracker from '../lib/downloadtracker'; import { plexFullScanner, plexRecentScanner } from '../lib/scanners/plex'; import { radarrScanner } from '../lib/scanners/radarr'; import { sonarrScanner } from '../lib/scanners/sonarr'; +import { getSettings, JobId } from '../lib/settings'; +import logger from '../logger'; interface ScheduledJob { - id: string; + id: JobId; job: schedule.Job; name: string; type: 'process' | 'command'; + interval: 'short' | 'long' | 'fixed'; running?: () => boolean; cancelFn?: () => void; } @@ -17,12 +19,15 @@ interface ScheduledJob { export const scheduledJobs: ScheduledJob[] = []; export const startJobs = (): void => { + const jobs = getSettings().jobs; + // Run recently added plex scan every 5 minutes scheduledJobs.push({ id: 'plex-recently-added-scan', name: 'Plex Recently Added Scan', type: 'process', - job: schedule.scheduleJob('0 */5 * * * *', () => { + interval: 'short', + job: schedule.scheduleJob(jobs['plex-recently-added-scan'].schedule, () => { logger.info('Starting scheduled job: Plex Recently Added Scan', { label: 'Jobs', }); @@ -37,7 +42,8 @@ export const startJobs = (): void => { id: 'plex-full-scan', name: 'Plex Full Library Scan', type: 'process', - job: schedule.scheduleJob('0 0 3 * * *', () => { + interval: 'long', + job: schedule.scheduleJob(jobs['plex-full-scan'].schedule, () => { logger.info('Starting scheduled job: Plex Full Library Scan', { label: 'Jobs', }); @@ -52,7 +58,8 @@ export const startJobs = (): void => { id: 'radarr-scan', name: 'Radarr Scan', type: 'process', - job: schedule.scheduleJob('0 0 4 * * *', () => { + interval: 'long', + job: schedule.scheduleJob(jobs['radarr-scan'].schedule, () => { logger.info('Starting scheduled job: Radarr Scan', { label: 'Jobs' }); radarrScanner.run(); }), @@ -65,7 +72,8 @@ export const startJobs = (): void => { id: 'sonarr-scan', name: 'Sonarr Scan', type: 'process', - job: schedule.scheduleJob('0 30 4 * * *', () => { + interval: 'long', + job: schedule.scheduleJob(jobs['sonarr-scan'].schedule, () => { logger.info('Starting scheduled job: Sonarr Scan', { label: 'Jobs' }); sonarrScanner.run(); }), @@ -73,23 +81,27 @@ export const startJobs = (): void => { cancelFn: () => sonarrScanner.cancel(), }); - // Run download sync + // Run download sync every minute scheduledJobs.push({ id: 'download-sync', name: 'Download Sync', type: 'command', - job: schedule.scheduleJob('0 * * * * *', () => { - logger.debug('Starting scheduled job: Download Sync', { label: 'Jobs' }); + interval: 'fixed', + job: schedule.scheduleJob(jobs['download-sync'].schedule, () => { + logger.debug('Starting scheduled job: Download Sync', { + label: 'Jobs', + }); downloadTracker.updateDownloads(); }), }); - // Reset download sync + // Reset download sync everyday at 01:00 am scheduledJobs.push({ id: 'download-sync-reset', name: 'Download Sync Reset', type: 'command', - job: schedule.scheduleJob('0 0 1 * * *', () => { + interval: 'long', + job: schedule.scheduleJob(jobs['download-sync-reset'].schedule, () => { logger.info('Starting scheduled job: Download Sync Reset', { label: 'Jobs', }); diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 8ece986e..b4729e58 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -215,6 +215,18 @@ interface NotificationSettings { agents: NotificationAgents; } +interface JobSettings { + schedule: string; +} + +export type JobId = + | 'plex-recently-added-scan' + | 'plex-full-scan' + | 'radarr-scan' + | 'sonarr-scan' + | 'download-sync' + | 'download-sync-reset'; + interface AllSettings { clientId: string; vapidPublic: string; @@ -225,6 +237,7 @@ interface AllSettings { sonarr: SonarrSettings[]; public: PublicSettings; notifications: NotificationSettings; + jobs: Record; } const SETTINGS_PATH = process.env.CONFIG_DIRECTORY @@ -346,6 +359,26 @@ class Settings { }, }, }, + jobs: { + 'plex-recently-added-scan': { + schedule: '0 */5 * * * *', + }, + 'plex-full-scan': { + schedule: '0 0 3 * * *', + }, + 'radarr-scan': { + schedule: '0 0 4 * * *', + }, + 'sonarr-scan': { + schedule: '0 30 4 * * *', + }, + 'download-sync': { + schedule: '0 * * * * *', + }, + 'download-sync-reset': { + schedule: '0 0 1 * * *', + }, + }, }; if (initialSettings) { this.data = merge(this.data, initialSettings); @@ -428,6 +461,14 @@ class Settings { this.data.notifications = data; } + get jobs(): Record { + return this.data.jobs; + } + + set jobs(data: Record) { + this.data.jobs = data; + } + get clientId(): string { if (!this.data.clientId) { this.data.clientId = randomUUID(); diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index bf8cfcdc..f58edb74 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -2,6 +2,7 @@ import { Router } from 'express'; import rateLimit from 'express-rate-limit'; import fs from 'fs'; import { merge, omit } from 'lodash'; +import { rescheduleJob } from 'node-schedule'; import path from 'path'; import { getRepository } from 'typeorm'; import { URL } from 'url'; @@ -49,7 +50,7 @@ settingsRoutes.get('/main', (req, res, next) => { const settings = getSettings(); if (!req.user) { - return next({ status: 500, message: 'User missing from request' }); + return next({ status: 400, message: 'User missing from request' }); } res.status(200).json(filteredMainSettings(req.user, settings.main)); @@ -310,6 +311,7 @@ settingsRoutes.get('/jobs', (_req, res) => { id: job.id, name: job.name, type: job.type, + interval: job.interval, nextExecutionTime: job.job.nextInvocation(), running: job.running ? job.running() : false, })) @@ -329,6 +331,7 @@ settingsRoutes.post<{ jobId: string }>('/jobs/:jobId/run', (req, res, next) => { id: scheduledJob.id, name: scheduledJob.name, type: scheduledJob.type, + interval: scheduledJob.interval, nextExecutionTime: scheduledJob.job.nextInvocation(), running: scheduledJob.running ? scheduledJob.running() : false, }); @@ -353,12 +356,45 @@ settingsRoutes.post<{ jobId: string }>( id: scheduledJob.id, name: scheduledJob.name, type: scheduledJob.type, + interval: scheduledJob.interval, nextExecutionTime: scheduledJob.job.nextInvocation(), running: scheduledJob.running ? scheduledJob.running() : false, }); } ); +settingsRoutes.post<{ jobId: string }>( + '/jobs/:jobId/schedule', + (req, res, next) => { + const scheduledJob = scheduledJobs.find( + (job) => job.id === req.params.jobId + ); + + if (!scheduledJob) { + return next({ status: 404, message: 'Job not found' }); + } + + const result = rescheduleJob(scheduledJob.job, req.body.schedule); + const settings = getSettings(); + + if (result) { + settings.jobs[scheduledJob.id].schedule = req.body.schedule; + settings.save(); + + return res.status(200).json({ + id: scheduledJob.id, + name: scheduledJob.name, + type: scheduledJob.type, + interval: scheduledJob.interval, + nextExecutionTime: scheduledJob.job.nextInvocation(), + running: scheduledJob.running ? scheduledJob.running() : false, + }); + } else { + return next({ status: 400, message: 'Invalid job schedule' }); + } + } +); + settingsRoutes.get('/cache', (req, res) => { const caches = cacheManager.getAllCaches(); diff --git a/src/components/Settings/SettingsJobsCache/index.tsx b/src/components/Settings/SettingsJobsCache/index.tsx index a621228b..c0e50e02 100644 --- a/src/components/Settings/SettingsJobsCache/index.tsx +++ b/src/components/Settings/SettingsJobsCache/index.tsx @@ -1,6 +1,7 @@ import { PlayIcon, StopIcon, TrashIcon } from '@heroicons/react/outline'; +import { PencilIcon } from '@heroicons/react/solid'; import axios from 'axios'; -import React from 'react'; +import React, { useState } from 'react'; import { defineMessages, FormattedRelativeTime, @@ -10,14 +11,17 @@ import { 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', @@ -51,12 +55,21 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages({ '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: string; + id: JobId; name: string; type: 'process' | 'command'; + interval: 'short' | 'long' | 'fixed'; nextExecutionTime: string; running: boolean; } @@ -74,6 +87,16 @@ const SettingsJobs: React.FC = () => { } ); + 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 ; } @@ -118,6 +141,42 @@ const SettingsJobs: React.FC = () => { 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 ( <> { intl.formatMessage(globalMessages.settings), ]} /> + + } + onCancel={() => setJobEditModal({ isOpen: false })} + okDisabled={isSaving} + onOk={() => scheduleJob()} + > +
+
+
+ +
+ {jobEditModal.job?.interval === 'short' ? ( + + ) : ( + + )} +
+
+
+
+
+
+

{intl.formatMessage(messages.jobs)}

@@ -179,6 +314,18 @@ const SettingsJobs: React.FC = () => {

+ {job.interval !== 'fixed' && ( + + )} {job.running ? (