增加运行实例

This commit is contained in:
whyour 2026-06-10 01:53:10 +08:00
parent 617cf7e5b4
commit 946731ac8d
12 changed files with 1594 additions and 1168 deletions

View File

@ -5,6 +5,10 @@ import CronService from '../services/cron';
import CronViewService from '../services/cronView';
import { celebrate, Joi } from 'celebrate';
import { commonCronSchema } from '../validation/schedule';
import {
RunningInstanceModel,
InstanceStatus,
} from '../data/runningInstance';
const route = Router();
@ -446,6 +450,49 @@ export default (app: Router) => {
},
);
route.get(
'/:id/instances',
celebrate({
params: Joi.object({
id: Joi.number().required(),
}),
}),
async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
try {
const instances = await RunningInstanceModel.findAll({
where: {
cron_id: req.params.id,
status: InstanceStatus.running,
},
order: [['started_at', 'DESC']],
raw: true,
});
return res.send({ code: 200, data: instances });
} catch (e) {
return next(e);
}
},
);
route.post(
'/:id/instances/:instanceId/stop',
celebrate({
params: Joi.object({
id: Joi.number().required(),
instanceId: Joi.number().required(),
}),
}),
async (req: Request<{ id: number; instanceId: number }>, res: Response, next: NextFunction) => {
try {
const cronService = Container.get(CronService);
const data = await cronService.stopInstance(req.params.instanceId);
return res.send(data);
} catch (e) {
return next(e);
}
},
);
route.get(
'/:id/logs',
celebrate({

View File

@ -3,6 +3,10 @@ import { Container } from 'typedi';
import { fn, col, where, Op } from 'sequelize';
import { CrontabModel } from '../data/cron';
import { CrontabStatModel } from '../data/cronStats';
import {
RunningInstanceModel,
InstanceStatus,
} from '../data/runningInstance';
import dayjs from 'dayjs';
import os from 'os';
@ -239,9 +243,9 @@ export default (app: Router) => {
'/runtime',
async (req: Request, res: Response, next: NextFunction) => {
try {
const runningCrons = await CrontabModel.findAll({
const runningInstances = await RunningInstanceModel.findAll({
where: {
status: 0, // running
status: InstanceStatus.running,
},
raw: true,
});
@ -253,15 +257,31 @@ export default (app: Router) => {
raw: true,
});
const running = runningCrons.map((c: any) => ({
id: c.id,
name: c.name || c.command || `任务#${c.id}`,
pid: c.pid,
elapsed: c.last_execution_time
? Math.floor((Date.now() / 1000) - c.last_execution_time)
: 0,
logPath: c.log_path,
}));
// Fetch cron names for running instances
const cronIds = [
...new Set(runningInstances.map((i: any) => i.cron_id)),
];
const crons =
cronIds.length > 0
? await CrontabModel.findAll({
where: { id: cronIds },
raw: true,
})
: [];
const cronMap = new Map(crons.map((c: any) => [c.id, c]));
const now = dayjs().unix();
const running = runningInstances.map((inst: any) => {
const cron = cronMap.get(inst.cron_id);
return {
instanceId: inst.id,
id: inst.cron_id,
name: cron?.name || cron?.command || `任务#${inst.cron_id}`,
pid: inst.pid,
elapsed: inst.started_at ? now - inst.started_at : 0,
logPath: inst.log_path,
};
});
const dayAgo = dayjs().subtract(24, 'hour').unix();
const idleTasks = await CrontabModel.findAll({

View File

@ -0,0 +1,81 @@
import { sequelize } from '.';
import { DataTypes, Model } from 'sequelize';
export enum InstanceStatus {
'running' = 0,
'finished' = 1,
'stopped' = 2,
'error' = 3,
}
export interface RunningInstanceAttributes {
id?: number;
cron_id: number;
pid?: number;
log_path?: string;
started_at: number;
finished_at?: number;
status: InstanceStatus;
exit_code?: number;
}
export class RunningInstance {
id?: number;
cron_id!: number;
pid?: number;
log_path?: string;
started_at!: number;
finished_at?: number;
status!: InstanceStatus;
exit_code?: number;
constructor(options: RunningInstanceAttributes) {
this.id = options.id;
this.cron_id = options.cron_id;
this.pid = options.pid;
this.log_path = options.log_path;
this.started_at = options.started_at;
this.finished_at = options.finished_at;
this.status = options.status;
this.exit_code = options.exit_code;
}
}
export interface RunningInstanceModel
extends Model<RunningInstanceAttributes, RunningInstanceAttributes>,
RunningInstanceAttributes {}
export const RunningInstanceModel = sequelize.define<RunningInstanceModel>(
'RunningInstance',
{
cron_id: {
type: DataTypes.NUMBER,
allowNull: false,
},
pid: {
type: DataTypes.NUMBER,
allowNull: true,
},
log_path: {
type: DataTypes.STRING,
allowNull: true,
},
started_at: {
type: DataTypes.NUMBER,
allowNull: false,
},
finished_at: {
type: DataTypes.NUMBER,
allowNull: true,
},
status: {
type: DataTypes.NUMBER,
allowNull: false,
defaultValue: InstanceStatus.running,
},
exit_code: {
type: DataTypes.NUMBER,
allowNull: true,
},
},
);

View File

@ -7,6 +7,7 @@ import { SystemModel } from '../data/system';
import { SubscriptionModel } from '../data/subscription';
import { CrontabViewModel } from '../data/cronView';
import { CrontabStatModel } from '../data/cronStats';
import { RunningInstanceModel } from '../data/runningInstance';
import { sequelize } from '../data';
export default async () => {
@ -19,6 +20,7 @@ export default async () => {
await SubscriptionModel.sync();
await CrontabViewModel.sync();
await CrontabStatModel.sync();
await RunningInstanceModel.sync();
// 初始化新增字段
const migrations = [

View File

@ -18,6 +18,7 @@ import OpenService from '../services/open';
import { shareStore } from '../shared/store';
import Logger from './logger';
import { AppModel } from '../data/open';
import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance';
export default async () => {
const cronService = Container.get(CronService);
@ -139,6 +140,12 @@ export default async () => {
// 初始化更新所有任务状态为空闲
await CrontabModel.update({ status: CrontabStatus.idle }, { where: {} });
// 清空所有运行中的实例记录(服务重启后进程已不存在)
await RunningInstanceModel.update(
{ status: InstanceStatus.stopped },
{ where: { status: InstanceStatus.running } },
);
// 初始化时执行一次所有的 ql repo 任务
CrontabModel.findAll({
where: {

View File

@ -2,6 +2,10 @@ import { Service, Inject } from 'typedi';
import winston from 'winston';
import config from '../config';
import { Crontab, CrontabModel, CrontabStatus } from '../data/cron';
import {
RunningInstanceModel,
InstanceStatus,
} from '../data/runningInstance';
import { exec, execSync } from 'child_process';
import fs from 'fs/promises';
import CronExpressionParser from 'cron-parser';
@ -189,6 +193,35 @@ export default class CronService {
if (status === CrontabStatus.idle && log_path !== cron.log_path) {
options = omit(options, ['status', 'log_path', 'pid']);
}
// Manage RunningInstance records for status transitions from shell scripts
if (status === CrontabStatus.running) {
// Create a new running instance record
await RunningInstanceModel.create({
cron_id: id,
pid: pid || undefined,
log_path: log_path || undefined,
started_at: last_execution_time || dayjs().unix(),
status: InstanceStatus.running,
});
} else if (status === CrontabStatus.idle) {
// Mark the matching running instance as finished
const finishedAt = dayjs().unix();
await RunningInstanceModel.update(
{
finished_at: finishedAt,
status: InstanceStatus.finished,
},
{
where: {
cron_id: id,
pid: pid || undefined,
status: InstanceStatus.running,
},
},
);
}
await CrontabModel.update(
{ ...pickBy(options, (v) => v === 0 || !!v) },
{ where: { id } },
@ -499,12 +532,53 @@ export default class CronService {
}
}
// Mark all running instances as stopped
const finishedAt = dayjs().unix();
await RunningInstanceModel.update(
{ status: InstanceStatus.stopped, finished_at: finishedAt },
{ where: { cron_id: ids, status: InstanceStatus.running } },
);
await CrontabModel.update(
{ status: CrontabStatus.idle, pid: undefined },
{ where: { id: ids } },
);
}
public async stopInstance(instanceId: number) {
const instance = await RunningInstanceModel.findOne({
where: { id: instanceId, status: InstanceStatus.running },
});
if (!instance) {
return { code: 400, message: '实例不存在或已停止' };
}
if (instance.pid) {
try {
await killTask(instance.pid);
} catch (error) {
this.logger.error(
`[panel][停止实例失败] 实例ID: ${instanceId}, PID: ${instance.pid}, 错误: ${error}`,
);
}
}
await RunningInstanceModel.update(
{ status: InstanceStatus.stopped, finished_at: dayjs().unix() },
{ where: { id: instanceId } },
);
// Check if there are still other running instances for this cron
const otherRunning = await RunningInstanceModel.count({
where: { cron_id: instance.cron_id, status: InstanceStatus.running },
});
if (otherRunning === 0) {
await CrontabModel.update(
{ status: CrontabStatus.idle, pid: undefined },
{ where: { id: instance.cron_id } },
);
}
return { code: 200, message: '实例已停止' };
}
private async runSingle(cronId: number): Promise<number | void> {
return taskLimit.manualRunWithCronLimit(() => {
return new Promise(async (resolve: any) => {
@ -543,6 +617,15 @@ export default class CronService {
{ shell: '/bin/bash' },
);
const startedAt = dayjs().unix();
const instance = await RunningInstanceModel.create({
cron_id: id!,
pid: cp.pid,
log_path: logPath,
started_at: startedAt,
status: InstanceStatus.running,
});
await CrontabModel.update(
{ status: CrontabStatus.running, pid: cp.pid, log_path: logPath },
{ where: { id } },
@ -574,10 +657,26 @@ export default class CronService {
code,
);
await logStreamManager.closeStream(absolutePath);
await CrontabModel.update(
{ status: CrontabStatus.idle, pid: undefined },
{ where: { id } },
const finishedAt = dayjs().unix();
await RunningInstanceModel.update(
{
finished_at: finishedAt,
status: code === 0 ? InstanceStatus.finished : InstanceStatus.error,
exit_code: code ?? undefined,
},
{ where: { id: instance.id } },
);
// Only set cron to idle if no other running instances exist
const otherRunning = await RunningInstanceModel.count({
where: { cron_id: id!, status: InstanceStatus.running },
});
if (otherRunning === 0) {
await CrontabModel.update(
{ status: CrontabStatus.idle, pid: undefined },
{ where: { id } },
);
}
resolve({ ...params, pid: cp.pid, code });
});
});

View File

@ -4,6 +4,11 @@ import Logger from '../loaders/logger';
import { ICron } from '../protos/cron';
import { CrontabModel, CrontabStatus } from '../data/cron';
import { killTask } from '../config/util';
import {
RunningInstanceModel,
InstanceStatus,
} from '../data/runningInstance';
import dayjs from 'dayjs';
export function runCron(cmd: string, cron: ICron): Promise<number | void> {
return taskLimit.runWithCronLimit(cron, () => {
@ -29,6 +34,12 @@ export function runCron(cmd: string, cron: ICron): Promise<number | void> {
`[schedule][停止已运行任务] 任务ID: ${cron.id}, PID: ${existingCron.pid}`,
);
await killTask(existingCron.pid);
// Mark old running instances as stopped
const stoppedAt = dayjs().unix();
await RunningInstanceModel.update(
{ status: InstanceStatus.stopped, finished_at: stoppedAt },
{ where: { cron_id: Number(cron.id), status: InstanceStatus.running } },
);
// Update the status to idle after killing
await CrontabModel.update(
{ status: CrontabStatus.idle, pid: undefined },

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -22,6 +22,7 @@ import {
PlayCircleOutlined,
PauseCircleOutlined,
FullscreenOutlined,
StopOutlined,
} from '@ant-design/icons';
import { CrontabStatus } from './type';
import { diffTime } from '@/utils/date';
@ -46,6 +47,10 @@ const tabList = [
key: 'script',
tab: intl.get('脚本'),
},
{
key: 'runningInstance',
tab: intl.get('运行实例'),
},
];
interface LogItem {
@ -77,6 +82,36 @@ const CronDetailModal = ({
const [currentCron, setCurrentCron] = useState<any>({});
const listRef = useRef<HTMLDivElement>(null);
const tableScrollHeight = useScrollHeight(listRef);
const [runningInstances, setRunningInstances] = useState<any[]>([]);
const needRefreshRef = useRef(false);
const fetchRunningInstances = async () => {
if (!cron.id) return Promise.resolve();
return request
.get(`${config.apiPrefix}crons/${cron.id}/instances`)
.then(({ code, data }) => {
if (code === 200 && data) {
setRunningInstances(data);
}
})
.catch(() => { });
};
useEffect(() => {
fetchRunningInstances();
let timer: ReturnType<typeof setTimeout>;
let cancelled = false;
const poll = async () => {
await fetchRunningInstances();
if (cancelled) return;
timer = setTimeout(poll, 10000);
};
poll();
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [cron.id]);
const contentList: any = {
log: (
@ -115,12 +150,107 @@ const CronDetailModal = ({
}}
/>
),
runningInstance: (
<div ref={listRef}>
<List>
<VirtualList
data={runningInstances}
height={tableScrollHeight}
itemHeight={47}
itemKey="id"
>
{(item) => (
<List.Item
className="log-item"
actions={[
<Tooltip title={intl.get('查看日志')} key="log">
<Button
type="link"
size="small"
icon={<FileOutlined />}
onClick={(e) => {
e.stopPropagation();
viewInstanceLog(item);
}}
/>
</Tooltip>,
<Tooltip title={intl.get('停止')} key="stop">
<Button
type="link"
size="small"
icon={<StopOutlined />}
danger
onClick={(e) => {
e.stopPropagation();
stopRunningInstance(item);
}}
/>
</Tooltip>,
]}
>
<List.Item.Meta
title={
<span>
PID: <Tag color="processing">{item.pid}</Tag>
</span>
}
description={
<span style={{ color: '#999' }}>
{intl.get('启动')}: {dayjs.unix(item.started_at).format('YYYY-MM-DD HH:mm:ss')}
</span>
}
/>
</List.Item>
)}
</VirtualList>
</List>
</div>
),
};
const stopRunningInstance = (instance: any) => {
Modal.confirm({
title: intl.get('确认停止实例'),
content: (
<>
{intl.get('确认停止运行实例')} PID: {instance.pid}{' '}
{intl.get('吗')}
</>
),
onOk() {
request
.post(`${config.apiPrefix}crons/${cron.id}/instances/${instance.id}/stop`)
.then(({ code }) => {
if (code === 200) {
message.success(intl.get('实例已停止'));
needRefreshRef.current = true;
fetchRunningInstances();
setTimeout(() => getLogs(), 1000);
}
});
},
});
};
const viewInstanceLog = (instance: any) => {
if (!instance.log_path) return;
const parts = instance.log_path.split('/');
const filename = parts.pop() || '';
const directory = parts.join('/');
const url = `${config.apiPrefix}logs/detail?file=${filename}&path=${directory}`;
localStorage.setItem('logCron', url);
setLogUrl(url);
request.get(url).then(({ code, data }) => {
if (code === 200) {
setLog(data);
setIsLogModalVisible(true);
}
});
};
const onClickItem = (item: LogItem) => {
const url = `${config.apiPrefix}logs/detail?file=${item.filename}&path=${
item.directory || ''
}`;
const url = `${config.apiPrefix}logs/detail?file=${item.filename}&path=${item.directory || ''
}`;
localStorage.setItem('logCron', url);
setLogUrl(url);
request.get(url).then(({ code, data }) => {
@ -256,9 +386,8 @@ const CronDetailModal = ({
const enabledOrDisabledCron = () => {
Modal.confirm({
title: `确认${
currentCron.isDisabled === 1 ? intl.get('启用') : intl.get('禁用')
}`,
title: `确认${currentCron.isDisabled === 1 ? intl.get('启用') : intl.get('禁用')
}`,
content: (
<>
{intl.get('确认')}
@ -273,8 +402,7 @@ const CronDetailModal = ({
onOk() {
request
.put(
`${config.apiPrefix}crons/${
currentCron.isDisabled === 1 ? 'enable' : 'disable'
`${config.apiPrefix}crons/${currentCron.isDisabled === 1 ? 'enable' : 'disable'
}`,
[currentCron.id],
)
@ -292,9 +420,8 @@ const CronDetailModal = ({
const pinOrUnPinCron = () => {
Modal.confirm({
title: `确认${
currentCron.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')
}`,
title: `确认${currentCron.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')
}`,
content: (
<>
{intl.get('确认')}
@ -309,8 +436,7 @@ const CronDetailModal = ({
onOk() {
request
.put(
`${config.apiPrefix}crons/${
currentCron.isPinned === 1 ? 'unpin' : 'pin'
`${config.apiPrefix}crons/${currentCron.isPinned === 1 ? 'unpin' : 'pin'
}`,
[currentCron.id],
)
@ -441,7 +567,7 @@ const CronDetailModal = ({
open={true}
forceRender
footer={false}
onCancel={() => handleCancel()}
onCancel={() => handleCancel(needRefreshRef.current)}
wrapClassName="crontab-detail"
width={!isPhone ? '80vw' : ''}
>
@ -458,27 +584,27 @@ const CronDetailModal = ({
<div className="cron-detail-info-value">
{(!currentCron.isDisabled ||
currentCron.status !== CrontabStatus.idle) && (
<>
{currentCron.status === CrontabStatus.idle && (
<Tag icon={<ClockCircleOutlined />} color="default">
{intl.get('空闲中')}
</Tag>
)}
{currentCron.status === CrontabStatus.running && (
<Tag
icon={<Loading3QuartersOutlined spin />}
color="processing"
>
{intl.get('运行中')}
</Tag>
)}
{currentCron.status === CrontabStatus.queued && (
<Tag icon={<FieldTimeOutlined />} color="default">
{intl.get('队列中')}
</Tag>
)}
</>
)}
<>
{currentCron.status === CrontabStatus.idle && (
<Tag icon={<ClockCircleOutlined />} color="default">
{intl.get('空闲中')}
</Tag>
)}
{currentCron.status === CrontabStatus.running && (
<Tag
icon={<Loading3QuartersOutlined spin />}
color="processing"
>
{intl.get('运行中')}
</Tag>
)}
{currentCron.status === CrontabStatus.queued && (
<Tag icon={<FieldTimeOutlined />} color="default">
{intl.get('队列中')}
</Tag>
)}
</>
)}
{currentCron.isDisabled === 1 &&
currentCron.status === CrontabStatus.idle && (
<Tag icon={<CloseCircleOutlined />} color="error">
@ -503,8 +629,8 @@ const CronDetailModal = ({
<div className="cron-detail-info-value">
{currentCron.last_execution_time
? dayjs(currentCron.last_execution_time * 1000).format(
'YYYY-MM-DD HH:mm:ss',
)
'YYYY-MM-DD HH:mm:ss',
)
: '-'}
</div>
</div>

View File

@ -365,8 +365,8 @@ const Crontab = () => {
}
};
const getCrons = () => {
setLoading(true);
const getCrons = async (silent?: boolean) => {
if (!silent) setLoading(true);
const { page, size, sorter, filters } = pageConf;
let url = `${config.apiPrefix
}crons?searchValue=${searchText}&page=${page}&size=${size}&filters=${JSON.stringify(
@ -385,7 +385,7 @@ const Crontab = () => {
filterRelation: viewConf.filterRelation || 'and',
})}`;
}
request
return request
.get(url)
.then(async ({ code, data: _data }) => {
if (code === 200) {
@ -419,9 +419,24 @@ const Crontab = () => {
setTotal(total);
}
})
.finally(() => setLoading(false));
.finally(() => !silent && setLoading(false));
};
useEffect(() => {
let timer: ReturnType<typeof setTimeout>;
let cancelled = false;
const poll = async () => {
await getCrons(true);
if (cancelled) return;
timer = setTimeout(poll, 10000);
};
poll();
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [pageConf, viewConf, searchText]);
const addCron = () => {
setEditedCron(null as any);
setIsModalVisible(true);
@ -809,7 +824,7 @@ const Crontab = () => {
setAllSubscriptions(data || []);
}
})
.catch(() => {});
.catch(() => { });
};
useEffect(() => {
@ -1077,8 +1092,11 @@ const Crontab = () => {
)}
{isDetailModalVisible && (
<CronDetailModal
handleCancel={() => {
handleCancel={(needUpdate?: boolean) => {
setIsDetailModalVisible(false);
if (needUpdate) {
getCrons();
}
}}
cron={detailCron}
theme={theme}

View File

@ -46,7 +46,7 @@ interface TopItem {
interface Runtime {
runningCount: number;
queuedCount: number;
running: Array<{ id: number; name: string; pid: number; elapsed: number; logPath: string }>;
running: Array<{ instanceId: number; id: number; name: string; pid: number; elapsed: number; logPath: string }>;
idleTasks: Array<{ id: number; name: string; lastRun: string }>;
}
@ -282,12 +282,15 @@ const Dashboard = () => {
>
<Table
dataSource={runtime?.running || []}
rowKey="id"
rowKey="instanceId"
pagination={runtime && runtime.running.length > 5 ? runtimePagination : false}
size="small"
locale={{ emptyText: <Empty description={intl.get('暂无运行中任务')} /> }}
columns={[
{ title: intl.get('定时任务'), dataIndex: 'name', ellipsis: true },
{ title: intl.get('定时任务'), dataIndex: 'name', ellipsis: true, render: (name: string, record) => {
const sameTaskCount = (runtime?.running || []).filter(r => r.id === record.id).length;
return sameTaskCount > 1 ? <span>{name} <Tag color="processing" style={{ fontSize: 10, lineHeight: '16px' }}>×{sameTaskCount}</Tag></span> : name;
} },
{ title: 'PID', dataIndex: 'pid', width: 80 },
{ title: intl.get('已运行'), dataIndex: 'elapsed', width: 100, render: (v: number) => v ? formatSeconds(v) : '-' },
{ title: intl.get('日志'), dataIndex: 'id', width: 60, render: (id, record) => <a onClick={() => { localStorage.setItem('logCron', String(id)); setLogCron({ id, name: record.name }); }}>{intl.get('查看')}</a> },