From 8173075b67d9525c086b2254736df036d162fd67 Mon Sep 17 00:00:00 2001 From: whyour Date: Thu, 20 Feb 2025 02:18:59 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9A=E6=97=B6=E4=BB=BB=E5=8A=A1=E6=94=AF?= =?UTF-8?q?=E6=8C=81=20@once=20=E5=92=8C=20@boot=20=E4=BB=BB=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- back/api/cron.ts | 48 +++------- back/app.ts | 1 + back/loaders/bootAfter.ts | 8 ++ back/services/cron.ts | 70 +++++++++++---- back/validation/schedule.ts | 36 ++++++++ shell/share.sh | 2 +- shell/update.sh | 2 +- src/locales/en-US.json | 5 +- src/locales/zh-CN.json | 6 +- src/pages/crontab/index.tsx | 10 ++- src/pages/crontab/modal.tsx | 172 +++++++++++++++++++++++------------- 11 files changed, 242 insertions(+), 118 deletions(-) create mode 100644 back/loaders/bootAfter.ts create mode 100644 back/validation/schedule.ts diff --git a/back/api/cron.ts b/back/api/cron.ts index 2ef5c369..1bc99062 100644 --- a/back/api/cron.ts +++ b/back/api/cron.ts @@ -4,7 +4,8 @@ import { Logger } from 'winston'; import CronService from '../services/cron'; import CronViewService from '../services/cronView'; import { celebrate, Joi } from 'celebrate'; -import cron_parser from 'cron-parser'; +import { commonCronSchema } from '../validation/schedule'; + const route = Router(); export default (app: Router) => { @@ -170,27 +171,14 @@ export default (app: Router) => { route.post( '/', celebrate({ - body: Joi.object({ - command: Joi.string().required(), - schedule: Joi.string().required(), - name: Joi.string().optional(), - labels: Joi.array().optional(), - sub_id: Joi.number().optional().allow(null), - extra_schedules: Joi.array().optional().allow(null), - task_before: Joi.string().optional().allow('').allow(null), - task_after: Joi.string().optional().allow('').allow(null), - }), + body: Joi.object(commonCronSchema), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { - if (cron_parser.parseExpression(req.body.schedule).hasNext()) { - const cronService = Container.get(CronService); - const data = await cronService.create(req.body); - return res.send({ code: 200, data }); - } else { - return res.send({ code: 400, message: 'param schedule error' }); - } + const cronService = Container.get(CronService); + const data = await cronService.create(req.body); + return res.send({ code: 200, data }); } catch (e) { return next(e); } @@ -331,30 +319,16 @@ export default (app: Router) => { '/', celebrate({ body: Joi.object({ - labels: Joi.array().optional().allow(null), - command: Joi.string().required(), - schedule: Joi.string().required(), - name: Joi.string().optional().allow(null), - sub_id: Joi.number().optional().allow(null), - extra_schedules: Joi.array().optional().allow(null), - task_before: Joi.string().optional().allow('').allow(null), - task_after: Joi.string().optional().allow('').allow(null), + ...commonCronSchema, id: Joi.number().required(), }), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { - if ( - !req.body.schedule || - cron_parser.parseExpression(req.body.schedule).hasNext() - ) { - const cronService = Container.get(CronService); - const data = await cronService.update(req.body); - return res.send({ code: 200, data }); - } else { - return res.send({ code: 400, message: 'param schedule error' }); - } + const cronService = Container.get(CronService); + const data = await cronService.update(req.body); + return res.send({ code: 200, data }); } catch (e) { return next(e); } @@ -418,7 +392,7 @@ export default (app: Router) => { const logger: Logger = Container.get('logger'); try { const cronService = Container.get(CronService); - const data = await cronService.import_crontab(); + const data = await cronService.importCrontab(); return res.send({ code: 200, data }); } catch (e) { return next(e); diff --git a/back/app.ts b/back/app.ts index ff25583a..1df44c05 100644 --- a/back/app.ts +++ b/back/app.ts @@ -17,6 +17,7 @@ async function startServer() { Logger.debug(`✌️ 后端服务启动成功!`); console.debug(`✌️ 后端服务启动成功!`); process.send?.('ready'); + require('./loaders/bootAfter').default(); }) .on('error', (err) => { Logger.error(err); diff --git a/back/loaders/bootAfter.ts b/back/loaders/bootAfter.ts new file mode 100644 index 00000000..ff0b901d --- /dev/null +++ b/back/loaders/bootAfter.ts @@ -0,0 +1,8 @@ +import Container from 'typedi'; +import CronService from '../services/cron'; + +export default async () => { + const cronService = Container.get(CronService); + + await cronService.bootTask(); +}; diff --git a/back/services/cron.ts b/back/services/cron.ts index 7891e970..e5012486 100644 --- a/back/services/cron.ts +++ b/back/services/cron.ts @@ -35,11 +35,24 @@ export default class CronService { return false; } + private isOnceSchedule(schedule?: string) { + return schedule?.startsWith('@once'); + } + + private isBootSchedule(schedule?: string) { + return schedule?.startsWith('@boot'); + } + + private isSpecialSchedule(schedule?: string) { + return this.isOnceSchedule(schedule) || this.isBootSchedule(schedule); + } + public async create(payload: Crontab): Promise { const tab = new Crontab(payload); tab.saved = false; const doc = await this.insert(tab); - if (this.isNodeCron(doc)) { + + if (this.isNodeCron(doc) && !this.isSpecialSchedule(doc.schedule)) { await cronClient.addCron([ { name: doc.name || '', @@ -50,7 +63,8 @@ export default class CronService { }, ]); } - await this.set_crontab(); + + await this.setCrontab(); return doc; } @@ -63,13 +77,16 @@ export default class CronService { const tab = new Crontab({ ...doc, ...payload }); tab.saved = false; const newDoc = await this.updateDb(tab); + if (doc.isDisabled === 1) { return newDoc; } + if (this.isNodeCron(doc)) { await cronClient.delCron([String(doc.id)]); } - if (this.isNodeCron(newDoc)) { + + if (this.isNodeCron(newDoc) && !this.isSpecialSchedule(newDoc.schedule)) { await cronClient.addCron([ { name: doc.name || '', @@ -80,7 +97,8 @@ export default class CronService { }, ]); } - await this.set_crontab(); + + await this.setCrontab(); return newDoc; } @@ -135,7 +153,7 @@ export default class CronService { public async remove(ids: number[]) { await CrontabModel.destroy({ where: { id: ids } }); await cronClient.delCron(ids.map(String)); - await this.set_crontab(); + await this.setCrontab(); } public async pin(ids: number[]) { @@ -381,7 +399,7 @@ export default class CronService { try { const result = await CrontabModel.findAll(condition); const count = await CrontabModel.count({ where: query }); - return { data: result, total: count }; + return { data: result.map((x) => x.get({ plain: true })), total: count }; } catch (error) { throw error; } @@ -502,7 +520,7 @@ export default class CronService { public async disabled(ids: number[]) { await CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } }); await cronClient.delCron(ids.map(String)); - await this.set_crontab(); + await this.setCrontab(); } public async enabled(ids: number[]) { @@ -518,7 +536,7 @@ export default class CronService { extra_schedules: doc.extra_schedules || [], })); await cronClient.addCron(sixCron); - await this.set_crontab(); + await this.setCrontab(); } public async log(id: number) { @@ -586,7 +604,7 @@ export default class CronService { return crontab_job_string; } - private async set_crontab(data?: { data: Crontab[]; total: number }) { + private async setCrontab(data?: { data: Crontab[]; total: number }) { const tabs = data ?? (await this.crontabs()); var crontab_string = ''; tabs.data.forEach((tab) => { @@ -594,7 +612,8 @@ export default class CronService { if ( tab.isDisabled === 1 || _schedule!.length !== 5 || - tab.extra_schedules?.length + tab.extra_schedules?.length || + this.isSpecialSchedule(tab.schedule) ) { crontab_string += '# '; crontab_string += tab.schedule; @@ -615,7 +634,7 @@ export default class CronService { await CrontabModel.update({ saved: true }, { where: {} }); } - public import_crontab() { + public importCrontab() { exec('crontab -l', (error, stdout, stderr) => { const lines = stdout.split('\n'); const namePrefix = new Date().getTime(); @@ -651,10 +670,15 @@ export default class CronService { public async autosave_crontab() { const tabs = await this.crontabs(); - this.set_crontab(tabs); + this.setCrontab(tabs); - const sixCron = tabs.data - .filter((x) => this.isNodeCron(x) && x.isDisabled !== 1) + const regularCrons = tabs.data + .filter( + (x) => + this.isNodeCron(x) && + x.isDisabled !== 1 && + !this.isSpecialSchedule(x.schedule), + ) .map((doc) => ({ name: doc.name || '', id: String(doc.id), @@ -662,6 +686,22 @@ export default class CronService { command: this.makeCommand(doc), extra_schedules: doc.extra_schedules || [], })); - await cronClient.addCron(sixCron); + await cronClient.addCron(regularCrons); + } + + public async bootTask() { + const tabs = await this.crontabs(); + const bootTasks = tabs.data.filter( + (x) => !x.isDisabled && this.isBootSchedule(x.schedule), + ); + if (bootTasks.length > 0) { + await CrontabModel.update( + { status: CrontabStatus.queued }, + { where: { id: bootTasks.map((t) => t.id!) } }, + ); + for (const task of bootTasks) { + await this.runSingle(task.id!); + } + } } } diff --git a/back/validation/schedule.ts b/back/validation/schedule.ts new file mode 100644 index 00000000..76822941 --- /dev/null +++ b/back/validation/schedule.ts @@ -0,0 +1,36 @@ +import { Joi } from 'celebrate'; +import cron_parser from 'cron-parser'; + +const validateSchedule = (value: string, helpers: any) => { + if (value.startsWith('@once') || value.startsWith('@boot')) { + return value; + } + + try { + if (cron_parser.parseExpression(value).hasNext()) { + return value; + } + } catch (e) { + return helpers.error('any.invalid'); + } + return helpers.error('any.invalid'); +}; + +export const scheduleSchema = Joi.string() + .required() + .custom(validateSchedule) + .messages({ + 'any.invalid': '无效的定时规则', + 'string.empty': '定时规则不能为空', + }); + +export const commonCronSchema = { + name: Joi.string().optional(), + command: Joi.string().required(), + schedule: scheduleSchema, + labels: Joi.array().optional(), + sub_id: Joi.number().optional().allow(null), + extra_schedules: Joi.array().optional().allow(null), + task_before: Joi.string().optional().allow('').allow(null), + task_after: Joi.string().optional().allow('').allow(null), +}; diff --git a/shell/share.sh b/shell/share.sh index 3dbb214d..a63efd72 100755 --- a/shell/share.sh +++ b/shell/share.sh @@ -479,7 +479,7 @@ handle_task_end() { [[ "$diff_time" == 0 ]] && diff_time=1 if [[ $ID ]]; then - local error=$(update_cron "\"$ID\"" "1" "" "$log_path" "$begin_timestamp" "$diff_time") + local error=$(update_cron "\"$ID\"" "1" "$$" "$log_path" "$begin_timestamp" "$diff_time") if [[ $error ]]; then error_message=", 任务状态更新失败(${error})" fi diff --git a/shell/update.sh b/shell/update.sh index f8f067b1..d8cabafd 100755 --- a/shell/update.sh +++ b/shell/update.sh @@ -553,7 +553,7 @@ main() { local end_time=$(format_time "$time_format" "$etime") local end_timestamp=$(format_timestamp "$time_format" "$etime") local diff_time=$(($end_timestamp - $begin_timestamp)) - [[ $ID ]] && update_cron "\"$ID\"" "1" "" "$log_path" "$begin_timestamp" "$diff_time" + [[ $ID ]] && update_cron "\"$ID\"" "1" "$$" "$log_path" "$begin_timestamp" "$diff_time" if [[ "$p1" != "repo" ]] && [[ "$p1" != "raw" ]]; then eval echo -e "\\\n\#\# 执行结束... $end_time 耗时 $diff_time 秒     " $cmd diff --git a/src/locales/en-US.json b/src/locales/en-US.json index a9f5d96a..cdbe2fb3 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -496,5 +496,8 @@ "NPM 镜像源": "NPM Mirror Source", "PyPI 镜像源": "PyPI Mirror Source", "alpine linux 镜像源": "Alpine Linux Mirror Source", - "如果恢复失败,可进入容器执行": "If recovery fails, you can enter the container and execute" + "如果恢复失败,可进入容器执行": "If recovery fails, you can enter the container and execute", + "常规定时": "Normal Timing", + "手动运行": "Manual Run", + "开机运行": "Boot Run" } diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index 19b15619..f947eee3 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -496,5 +496,9 @@ "NPM 镜像源": "NPM 镜像源", "PyPI 镜像源": "PyPI 镜像源", "alpine linux 镜像源": "alpine linux 镜像源", - "如果恢复失败,可进入容器执行": "如果恢复失败,可进入容器执行" + "如果恢复失败,可进入容器执行": "如果恢复失败,可进入容器执行", + "常规定时": "常规定时", + "手动运行": "手动运行", + "开机运行": "开机运行" } + \ No newline at end of file diff --git a/src/pages/crontab/index.tsx b/src/pages/crontab/index.tsx index c2405987..75f4a7ce 100644 --- a/src/pages/crontab/index.tsx +++ b/src/pages/crontab/index.tsx @@ -263,7 +263,9 @@ const Crontab = () => { }, }, render: (text, record) => { - return dayjs(record.nextRunTime).format('YYYY-MM-DD HH:mm:ss'); + return record.nextRunTime + ? dayjs(record.nextRunTime).format('YYYY-MM-DD HH:mm:ss') + : '-'; }, }, { @@ -396,9 +398,13 @@ const Crontab = () => { setValue( data.map((x) => { + const specialSchedules = ['@once', '@boot']; + const nextRunTime = specialSchedules.includes(x.schedule) + ? null + : getCrontabsNextDate(x.schedule, x.extra_schedules); return { ...x, - nextRunTime: getCrontabsNextDate(x.schedule, x.extra_schedules), + nextRunTime, subscription: subscriptionMap?.[x.sub_id], }; }), diff --git a/src/pages/crontab/modal.tsx b/src/pages/crontab/modal.tsx index 04c97d44..0faeb42a 100644 --- a/src/pages/crontab/modal.tsx +++ b/src/pages/crontab/modal.tsx @@ -1,12 +1,30 @@ import intl from 'react-intl-universal'; import React, { useEffect, useState } from 'react'; -import { Modal, message, Input, Form, Button, Space } from 'antd'; +import { Modal, message, Input, Form, Button, Space, Select } from 'antd'; import { request } from '@/utils/http'; import config from '@/utils/config'; import cronParse from 'cron-parser'; import EditableTagGroup from '@/components/tag'; import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons'; +enum ScheduleType { + Normal = 'normal', + Once = 'once', + Boot = 'boot', +} + +const scheduleTypeMap = { + [ScheduleType.Normal]: '', + [ScheduleType.Once]: '@once', + [ScheduleType.Boot]: '@boot', +}; + +const getScheduleType = (schedule?: string): ScheduleType => { + if (schedule?.startsWith('@once')) return ScheduleType.Once; + if (schedule?.startsWith('@boot')) return ScheduleType.Boot; + return ScheduleType.Normal; +}; + const CronModal = ({ cron, handleCancel, @@ -18,15 +36,26 @@ const CronModal = ({ }) => { const [form] = Form.useForm(); const [loading, setLoading] = useState(false); + const [scheduleType, setScheduleType] = useState( + cron ? getScheduleType(cron.schedule) : ScheduleType.Normal, + ); const handleOk = async (values: any) => { setLoading(true); - const method = cron?.id ? 'put' : 'post'; - const payload = { ...values }; - if (cron?.id) { - payload.id = cron.id; - } try { + const method = cron?.id ? 'put' : 'post'; + const payload = { + ...values, + schedule: + scheduleType !== ScheduleType.Normal + ? scheduleTypeMap[scheduleType] + : values.schedule, + }; + + if (cron?.id) { + payload.id = cron.id; + } + const { code, data } = await request[method]( `${config.apiPrefix}crons`, payload, @@ -38,74 +67,53 @@ const CronModal = ({ ); handleCancel(data); } - setLoading(false); } catch (error: any) { + console.error(error); + } finally { setLoading(false); } }; useEffect(() => { form.resetFields(); + setScheduleType(getScheduleType(cron?.schedule)); }, [cron, visible]); - return ( - { - form - .validateFields() - .then((values) => { - handleOk(values); - }) - .catch((info) => { - console.log('Validate Failed:', info); - }); - }} - onCancel={() => handleCancel()} - confirmLoading={loading} - > -
- - - - - - + const handleScheduleTypeChange = (type: ScheduleType) => { + setScheduleType(type); + form.setFieldValue('schedule', ''); + }; + + const renderScheduleOptions = () => ( + + ); + + const renderScheduleFields = () => { + if (scheduleType !== ScheduleType.Normal) return null; + + return ( + <> { + validator: (_, value) => { if (!value || cronParse.parseExpression(value).hasNext()) { return Promise.resolve(); - } else { - return Promise.reject(intl.get('Cron表达式格式有误')); } + return Promise.reject(intl.get('Cron表达式格式有误')); }, }, ]} @@ -136,14 +144,58 @@ const CronModal = ({ ))} add({ schedule: '' })}> - - {intl.get('新增定时规则')} + {intl.get('新增定时规则')} )} + + ); + }; + + return ( + form.validateFields().then(handleOk)} + onCancel={() => handleCancel()} + confirmLoading={loading} + > + + + + + + + + + {renderScheduleOptions()} + + {renderScheduleFields()} @@ -155,7 +207,7 @@ const CronModal = ({ )} rules={[ { - validator(rule, value) { + validator(_, value) { if ( value && (value.includes(' task ') || value.startsWith('task ')) @@ -183,7 +235,7 @@ const CronModal = ({ )} rules={[ { - validator(rule, value) { + validator(_, value) { if ( value && (value.includes(' task ') || value.startsWith('task '))