Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions back/api/cron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Container } from 'typedi';
import { Logger } from 'winston';
import CronService from '../services/cron';
import CronViewService from '../services/cronView';
import CronStatsService from '../services/cronStats';
import { celebrate, Joi } from 'celebrate';
import { commonCronSchema } from '../validation/schedule';

Expand Down Expand Up @@ -141,6 +142,58 @@ export default (app: Router) => {
},
);

route.get(
'/stats',
async (req: Request, res: Response, next: NextFunction) => {
try {
const cronStatsService = Container.get(CronStatsService);
const data = await cronStatsService.stats();
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);

route.get(
'/stats/trend',
async (req: Request, res: Response, next: NextFunction) => {
try {
const cronStatsService = Container.get(CronStatsService);
const data = await cronStatsService.trend();
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);

route.get(
'/stats/top-duration',
async (req: Request, res: Response, next: NextFunction) => {
try {
const cronStatsService = Container.get(CronStatsService);
const data = await cronStatsService.topDuration();
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);

route.get(
'/stats/top-count',
async (req: Request, res: Response, next: NextFunction) => {
try {
const cronStatsService = Container.get(CronStatsService);
const data = await cronStatsService.topCount();
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);

route.get('/', async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
Expand Down
31 changes: 31 additions & 0 deletions back/data/cronLog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { sequelize } from '.';
import { DataTypes, Model } from 'sequelize';

export class CronLog {
id?: number;
cron_id: number;
cron_name: string;
start_time: number;
duration: number;

constructor(options: CronLog) {
this.cron_id = options.cron_id;
this.cron_name = options.cron_name;
this.start_time = options.start_time;
this.duration = options.duration;
}
}

export interface CronLogInstance extends Model<CronLog, CronLog>, CronLog {}
export const CronLogModel = sequelize.define<CronLogInstance>(
'CronLog',
{
cron_id: DataTypes.NUMBER,
cron_name: DataTypes.STRING,
start_time: DataTypes.NUMBER,
duration: DataTypes.NUMBER,
},
{
indexes: [{ fields: ['cron_id'] }, { fields: ['start_time'] }],
},
);
2 changes: 2 additions & 0 deletions back/loaders/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { AppModel } from '../data/open';
import { SystemModel } from '../data/system';
import { SubscriptionModel } from '../data/subscription';
import { CrontabViewModel } from '../data/cronView';
import { CronLogModel } from '../data/cronLog';
import { sequelize } from '../data';

export default async () => {
Expand All @@ -17,6 +18,7 @@ export default async () => {
await EnvModel.sync();
await SubscriptionModel.sync();
await CrontabViewModel.sync();
await CronLogModel.sync();

// 初始化新增字段
const migrations = [
Expand Down
13 changes: 13 additions & 0 deletions back/services/cron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Service, Inject } from 'typedi';
import winston from 'winston';
import config from '../config';
import { Crontab, CrontabModel, CrontabStatus } from '../data/cron';
import { CronLog, CronLogModel } from '../data/cronLog';
import { exec, execSync } from 'child_process';
import fs from 'fs/promises';
import CronExpressionParser from 'cron-parser';
Expand Down Expand Up @@ -176,6 +177,18 @@ export default class CronService {
{ ...pickBy(options, (v) => v === 0 || !!v) },
{ where: { id } },
);

if (status === CrontabStatus.idle && last_running_time > 0) {
const cronName = (cron.name || cron.command || '').substring(0, 255);
await CronLogModel.create(
new CronLog({
cron_id: id,
cron_name: cronName,
start_time: last_execution_time,
duration: last_running_time,
}),
);
}
}
}

Expand Down
137 changes: 137 additions & 0 deletions back/services/cronStats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { Service, Inject } from 'typedi';
import winston from 'winston';
import { CrontabModel } from '../data/cron';
import { CronLog, CronLogModel } from '../data/cronLog';
import { Op } from 'sequelize';
import dayjs from 'dayjs';

type GroupedLog = {
cron_id: number;
cron_name: string;
durations: number[];
};

@Service()
export default class CronStatsService {
constructor(@Inject('logger') private logger: winston.Logger) {}

private groupLogsByCronId(logs: CronLog[]): Record<number, GroupedLog> {
const grouped: Record<number, GroupedLog> = {};
for (const log of logs) {
if (!grouped[log.cron_id]) {
grouped[log.cron_id] = {
cron_id: log.cron_id,
cron_name: log.cron_name,
durations: [],
};
}
grouped[log.cron_id].durations.push(log.duration);
}
return grouped;
}

private avgOf(nums: number[]): number {
if (nums.length === 0) return 0;
return Math.round(nums.reduce((a, b) => a + b, 0) / nums.length);
}

private getTodayRange() {
return {
start: dayjs().startOf('day').unix(),
end: dayjs().endOf('day').unix(),
};
}

public async stats() {
const { start, end } = this.getTodayRange();

const [allCrons, todayLogs] = await Promise.all([
CrontabModel.findAll({ where: {} }),
CronLogModel.findAll({
where: { start_time: { [Op.between]: [start, end] } },
}),
]);

const total = allCrons.length;
const enabled = allCrons.filter((c: any) => c.isDisabled !== 1).length;
const disabled = allCrons.filter((c: any) => c.isDisabled === 1).length;

const todayCount = todayLogs.length;
const todayTotalDuration = todayLogs.reduce(
(sum: number, l: any) => sum + (l.duration || 0),
0,
);
const todayAvgDuration =
todayCount > 0 ? Math.round(todayTotalDuration / todayCount) : 0;

return {
total,
enabled,
disabled,
today: {
count: todayCount,
avgDuration: todayAvgDuration,
},
};
}

public async trend() {
const days = 7;
const result: Array<{ date: string; count: number }> = [];

for (let i = days - 1; i >= 0; i--) {
const dayStart = dayjs().subtract(i, 'day').startOf('day').unix();
const dayEnd = dayjs().subtract(i, 'day').endOf('day').unix();
const date = dayjs().subtract(i, 'day').format('MM-DD');

const logs = await CronLogModel.findAll({
where: { start_time: { [Op.between]: [dayStart, dayEnd] } },
});

result.push({ date, count: logs.length });
}

return result;
}

public async topDuration(limit = 5) {
const { start, end } = this.getTodayRange();

const logs = await CronLogModel.findAll({
where: { start_time: { [Op.between]: [start, end] } },
});

const grouped = this.groupLogsByCronId(logs as any);

return Object.values(grouped)
.map((g) => ({
cron_id: g.cron_id,
cron_name: g.cron_name,
count: g.durations.length,
avgDuration: this.avgOf(g.durations),
maxDuration: Math.max(...g.durations),
}))
.sort((a, b) => b.avgDuration - a.avgDuration)
.slice(0, limit);
}

public async topCount(limit = 5) {
const { start, end } = this.getTodayRange();

const logs = await CronLogModel.findAll({
where: { start_time: { [Op.between]: [start, end] } },
});

const grouped = this.groupLogsByCronId(logs as any);

return Object.values(grouped)
.map((g) => ({
cron_id: g.cron_id,
cron_name: g.cron_name,
count: g.durations.length,
avgDuration: this.avgOf(g.durations),
}))
.sort((a, b) => b.count - a.count)
.slice(0, limit);
}
}
8 changes: 7 additions & 1 deletion src/layouts/defaultProps.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import intl from 'react-intl-universal';
import { SettingOutlined } from '@ant-design/icons';
import { BarChartOutlined, SettingOutlined } from '@ant-design/icons';
import IconFont from '@/components/iconfont';
import { BasicLayoutProps } from '@ant-design/pro-layout';

Expand Down Expand Up @@ -30,6 +30,12 @@ export default {
icon: <IconFont type="ql-icon-crontab" />,
component: '@/pages/crontab/index',
},
{
path: '/statistics',
name: intl.get('统计面板'),
icon: <BarChartOutlined />,
component: '@/pages/statistics/index',
},
{
path: '/subscription',
name: intl.get('订阅管理'),
Expand Down
19 changes: 19 additions & 0 deletions src/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,25 @@
"青龙": "Qinglong",
"返回首页": "Return to Home",
"保存": "Save",
"统计面板": "Statistics",
"总体概览": "Overview",
"总任务数量": "Total Tasks",
"启用任务数": "Enabled Tasks",
"禁用任务数": "Disabled Tasks",
"今日总执行次数": "Today's Executions",
"今日平均耗时(秒)": "Today's Avg Duration (s)",
"近7日执行趋势": "7-Day Execution Trend",
"今日平均耗时 Top 5": "Top 5 Slowest Today",
"今日执行次数 Top 5": "Top 5 Most Frequent Today",
"排名": "Rank",
"任务名称": "Task Name",
"平均耗时(秒)": "Avg Duration (s)",
"最长单次(秒)": "Max Duration (s)",
"今日执行次数": "Today's Count",
"今日暂无执行记录": "No execution records today",
"暂无数据": "No data",
"次": "times",
"刷新": "Refresh",
"日志": "Log",
"脚本": "Script",
"确认保存文件": "Confirm to Save File",
Expand Down
19 changes: 19 additions & 0 deletions src/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,25 @@
"青龙": "青龙",
"返回首页": "返回首页",
"保存": "保存",
"统计面板": "统计面板",
"总体概览": "总体概览",
"总任务数量": "总任务数量",
"启用任务数": "启用任务数",
"禁用任务数": "禁用任务数",
"今日总执行次数": "今日总执行次数",
"今日平均耗时(秒)": "今日平均耗时(秒)",
"近7日执行趋势": "近7日执行趋势",
"今日平均耗时 Top 5": "今日平均耗时 Top 5",
"今日执行次数 Top 5": "今日执行次数 Top 5",
"排名": "排名",
"任务名称": "任务名称",
"平均耗时(秒)": "平均耗时(秒)",
"最长单次(秒)": "最长单次(秒)",
"今日执行次数": "今日执行次数",
"今日暂无执行记录": "今日暂无执行记录",
"暂无数据": "暂无数据",
"次": "次",
"刷新": "刷新",
"日志": "日志",
"脚本": "脚本",
"确认保存文件": "确认保存文件",
Expand Down
17 changes: 17 additions & 0 deletions src/pages/statistics/index.less
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
.stats-section {
margin-bottom: 16px;
}

.trend-chart-wrapper {
width: 100%;
overflow: hidden;
}

.trend-chart-empty {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
color: #999;
font-size: 14px;
}
Loading
Loading