diff --git a/back/config/util.ts b/back/config/util.ts index dc6fd26c..7d61f74f 100644 --- a/back/config/util.ts +++ b/back/config/util.ts @@ -417,6 +417,27 @@ export async function getPid(cmd: string) { return pid ? Number(pid) : undefined; } +export async function getAllPids(cmd: string): Promise { + const taskCommand = `ps -eo pid,command | grep "${cmd}" | grep -v grep | awk '{print $1}'`; + const pidsStr = await promiseExec(taskCommand); + if (!pidsStr) return []; + return pidsStr + .split('\n') + .map((p) => Number(p.trim())) + .filter((p) => !isNaN(p) && p > 0); +} + +export async function killAllTasks(cmd: string): Promise { + const pids = await getAllPids(cmd); + for (const pid of pids) { + try { + await killTask(pid); + } catch (error) { + // Ignore errors if process already terminated + } + } +} + interface IVersion { version: string; changeLogLink: string; diff --git a/back/data/cron.ts b/back/data/cron.ts index 422b58e4..f2093798 100644 --- a/back/data/cron.ts +++ b/back/data/cron.ts @@ -22,6 +22,7 @@ export class Crontab { task_before?: string; task_after?: string; log_name?: string; + allow_multiple_instances?: 1 | 0; constructor(options: Crontab) { this.name = options.name; @@ -47,6 +48,7 @@ export class Crontab { this.task_before = options.task_before; this.task_after = options.task_after; this.log_name = options.log_name; + this.allow_multiple_instances = options.allow_multiple_instances || 0; } } @@ -87,4 +89,5 @@ export const CrontabModel = sequelize.define('Crontab', { task_before: DataTypes.STRING, task_after: DataTypes.STRING, log_name: DataTypes.STRING, + allow_multiple_instances: DataTypes.NUMBER, }); diff --git a/back/services/cron.ts b/back/services/cron.ts index cd2975bd..ad08998c 100644 --- a/back/services/cron.ts +++ b/back/services/cron.ts @@ -9,6 +9,7 @@ import { getFileContentByName, fileExist, killTask, + killAllTasks, getUniqPath, safeJSONParse, isDemoEnv, @@ -28,7 +29,7 @@ import { logStreamManager } from '../shared/logStreamManager'; @Service() export default class CronService { - constructor(@Inject('logger') private logger: winston.Logger) { } + constructor(@Inject('logger') private logger: winston.Logger) {} private isNodeCron(cron: Crontab) { const { schedule, extra_schedules } = cron; @@ -57,7 +58,9 @@ export default class CronService { } let uniqPath = await getUniqPath(command, `${id}`); if (log_name) { - const normalizedLogName = log_name.startsWith('/') ? log_name : path.join(config.logPath, log_name); + const normalizedLogName = log_name.startsWith('/') + ? log_name + : path.join(config.logPath, log_name); if (normalizedLogName.startsWith(config.logPath)) { uniqPath = log_name; } @@ -162,7 +165,7 @@ export default class CronService { let cron; try { cron = await this.getDb({ id }); - } catch (err) { } + } catch (err) {} if (!cron) { continue; } @@ -462,12 +465,17 @@ export default class CronService { public async stop(ids: number[]) { const docs = await CrontabModel.findAll({ where: { id: ids } }); for (const doc of docs) { - if (doc.pid) { - try { - await killTask(doc.pid); - } catch (error) { - this.logger.error(error); - } + // Kill all running instances of this task + try { + const command = this.makeCommand(doc); + await killAllTasks(command); + this.logger.info( + `[panel][停止所有运行中的任务实例] 任务ID: ${doc.id}, 命令: ${command}`, + ); + } catch (error) { + this.logger.error( + `[panel][停止任务失败] 任务ID: ${doc.id}, 错误: ${error}`, + ); } } @@ -498,7 +506,10 @@ export default class CronService { let { id, command, log_name } = cron; - const uniqPath = log_name === '/dev/null' ? (await getUniqPath(command, `${id}`)) : log_name; + const uniqPath = + log_name === '/dev/null' + ? await getUniqPath(command, `${id}`) + : log_name; const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS'); const logDirPath = path.resolve(config.logPath, `${uniqPath}`); await fs.mkdir(logDirPath, { recursive: true }); diff --git a/back/shared/runCron.ts b/back/shared/runCron.ts index 72b6e646..31f3ada4 100644 --- a/back/shared/runCron.ts +++ b/back/shared/runCron.ts @@ -8,12 +8,18 @@ import { killTask } from '../config/util'; export function runCron(cmd: string, cron: ICron): Promise { return taskLimit.runWithCronLimit(cron, () => { return new Promise(async (resolve: any) => { - // Check if the cron is already running and stop it + // Check if the cron is already running and stop it (only if multiple instances are not allowed) try { const existingCron = await CrontabModel.findOne({ where: { id: Number(cron.id) }, }); + + // Default to single instance mode (0) for backward compatibility + const allowMultipleInstances = + existingCron?.allow_multiple_instances === 1; + if ( + !allowMultipleInstances && existingCron && existingCron.pid && (existingCron.status === CrontabStatus.running || diff --git a/back/validation/schedule.ts b/back/validation/schedule.ts index 60d82bcd..475859a1 100644 --- a/back/validation/schedule.ts +++ b/back/validation/schedule.ts @@ -64,7 +64,11 @@ export const commonCronSchema = { return value; } - if (!/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w.-]+\/)*[\w.-]+\/?$/.test(value)) { + if ( + !/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w.-]+\/)*[\w.-]+\/?$/.test( + value, + ) + ) { return helpers.error('string.pattern.base'); } if (value.length > 100) { @@ -77,4 +81,5 @@ export const commonCronSchema = { 'string.max': '日志名称不能超过100个字符', 'string.unsafePath': '绝对路径必须在日志目录内或使用 /dev/null', }), + allow_multiple_instances: Joi.number().optional().valid(0, 1), };