From 34bc18cb25b9a9d1ac8860afef9ba33c88317088 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 06:51:31 +0000 Subject: [PATCH] Add statistics panel: backend models, services, APIs and frontend page Agent-Logs-Url: https://github.com/whyour/qinglong/sessions/3db54913-03d2-4721-b720-8ccbf8d0f00e Co-authored-by: whyour <22700758+whyour@users.noreply.github.com> --- back/api/cron.ts | 53 +++++ back/data/cronLog.ts | 31 +++ back/loaders/db.ts | 2 + back/services/cron.ts | 12 + back/services/cronStats.ts | 164 +++++++++++++ src/layouts/defaultProps.tsx | 8 +- src/locales/en-US.json | 19 ++ src/locales/zh-CN.json | 19 ++ src/pages/statistics/index.less | 17 ++ src/pages/statistics/index.tsx | 402 ++++++++++++++++++++++++++++++++ src/utils/config.ts | 1 + 11 files changed, 727 insertions(+), 1 deletion(-) create mode 100644 back/data/cronLog.ts create mode 100644 back/services/cronStats.ts create mode 100644 src/pages/statistics/index.less create mode 100644 src/pages/statistics/index.tsx diff --git a/back/api/cron.ts b/back/api/cron.ts index 1bc99062..6c2c2165 100644 --- a/back/api/cron.ts +++ b/back/api/cron.ts @@ -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'; @@ -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 { diff --git a/back/data/cronLog.ts b/back/data/cronLog.ts new file mode 100644 index 00000000..2bab0f28 --- /dev/null +++ b/back/data/cronLog.ts @@ -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 {} +export const CronLogModel = sequelize.define( + 'CronLog', + { + cron_id: DataTypes.NUMBER, + cron_name: DataTypes.STRING, + start_time: DataTypes.NUMBER, + duration: DataTypes.NUMBER, + }, + { + indexes: [{ fields: ['cron_id'] }, { fields: ['start_time'] }], + }, +); diff --git a/back/loaders/db.ts b/back/loaders/db.ts index 49fefb6b..9efc9dfb 100644 --- a/back/loaders/db.ts +++ b/back/loaders/db.ts @@ -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 () => { @@ -17,6 +18,7 @@ export default async () => { await EnvModel.sync(); await SubscriptionModel.sync(); await CrontabViewModel.sync(); + await CronLogModel.sync(); // 初始化新增字段 const migrations = [ diff --git a/back/services/cron.ts b/back/services/cron.ts index 94cdd95a..79224581 100644 --- a/back/services/cron.ts +++ b/back/services/cron.ts @@ -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'; @@ -176,6 +177,17 @@ export default class CronService { { ...pickBy(options, (v) => v === 0 || !!v) }, { where: { id } }, ); + + if (status === CrontabStatus.idle && last_running_time > 0) { + await CronLogModel.create( + new CronLog({ + cron_id: id, + cron_name: cron.name || cron.command || '', + start_time: last_execution_time, + duration: last_running_time, + }), + ); + } } } diff --git a/back/services/cronStats.ts b/back/services/cronStats.ts new file mode 100644 index 00000000..953e4481 --- /dev/null +++ b/back/services/cronStats.ts @@ -0,0 +1,164 @@ +import { Service, Inject } from 'typedi'; +import winston from 'winston'; +import { CrontabModel, CrontabStatus } from '../data/cron'; +import { CronLogModel } from '../data/cronLog'; +import { Op } from 'sequelize'; +import dayjs from 'dayjs'; + +@Service() +export default class CronStatsService { + constructor(@Inject('logger') private logger: winston.Logger) {} + + public async stats() { + const todayStart = dayjs().startOf('day').unix(); + const todayEnd = dayjs().endOf('day').unix(); + + const [allCrons, todayLogs] = await Promise.all([ + CrontabModel.findAll({ where: {} }), + CronLogModel.findAll({ + where: { + start_time: { [Op.between]: [todayStart, todayEnd] }, + }, + }), + ]); + + const total = allCrons.length; + const enabled = allCrons.filter((c) => c.isDisabled !== 1).length; + const disabled = allCrons.filter((c) => c.isDisabled === 1).length; + + const todayCount = todayLogs.length; + const todayTotalDuration = todayLogs.reduce( + (sum, l) => 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 todayStart = dayjs().startOf('day').unix(); + const todayEnd = dayjs().endOf('day').unix(); + + const logs = await CronLogModel.findAll({ + where: { + start_time: { [Op.between]: [todayStart, todayEnd] }, + }, + }); + + const grouped: Record< + number, + { cron_id: number; cron_name: string; durations: number[] } + > = {}; + + 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); + } + + const result = Object.values(grouped) + .map((g) => { + const avgDuration = Math.round( + g.durations.reduce((a, b) => a + b, 0) / g.durations.length, + ); + const maxDuration = Math.max(...g.durations); + return { + cron_id: g.cron_id, + cron_name: g.cron_name, + count: g.durations.length, + avgDuration, + maxDuration, + }; + }) + .sort((a, b) => b.avgDuration - a.avgDuration) + .slice(0, limit); + + return result; + } + + public async topCount(limit = 5) { + const todayStart = dayjs().startOf('day').unix(); + const todayEnd = dayjs().endOf('day').unix(); + + const logs = await CronLogModel.findAll({ + where: { + start_time: { [Op.between]: [todayStart, todayEnd] }, + }, + }); + + const grouped: Record< + number, + { cron_id: number; cron_name: string; durations: number[] } + > = {}; + + 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); + } + + const result = Object.values(grouped) + .map((g) => { + const avgDuration = Math.round( + g.durations.reduce((a, b) => a + b, 0) / g.durations.length, + ); + return { + cron_id: g.cron_id, + cron_name: g.cron_name, + count: g.durations.length, + avgDuration, + }; + }) + .sort((a, b) => b.count - a.count) + .slice(0, limit); + + return result; + } +} diff --git a/src/layouts/defaultProps.tsx b/src/layouts/defaultProps.tsx index c747b11e..f50d8b9f 100644 --- a/src/layouts/defaultProps.tsx +++ b/src/layouts/defaultProps.tsx @@ -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'; @@ -30,6 +30,12 @@ export default { icon: , component: '@/pages/crontab/index', }, + { + path: '/statistics', + name: intl.get('统计面板'), + icon: , + component: '@/pages/statistics/index', + }, { path: '/subscription', name: intl.get('订阅管理'), diff --git a/src/locales/en-US.json b/src/locales/en-US.json index 0a1a2a2a..9dc4444f 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -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", diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index 448e140c..ab43c84c 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -18,6 +18,25 @@ "青龙": "青龙", "返回首页": "返回首页", "保存": "保存", + "统计面板": "统计面板", + "总体概览": "总体概览", + "总任务数量": "总任务数量", + "启用任务数": "启用任务数", + "禁用任务数": "禁用任务数", + "今日总执行次数": "今日总执行次数", + "今日平均耗时(秒)": "今日平均耗时(秒)", + "近7日执行趋势": "近7日执行趋势", + "今日平均耗时 Top 5": "今日平均耗时 Top 5", + "今日执行次数 Top 5": "今日执行次数 Top 5", + "排名": "排名", + "任务名称": "任务名称", + "平均耗时(秒)": "平均耗时(秒)", + "最长单次(秒)": "最长单次(秒)", + "今日执行次数": "今日执行次数", + "今日暂无执行记录": "今日暂无执行记录", + "暂无数据": "暂无数据", + "次": "次", + "刷新": "刷新", "日志": "日志", "脚本": "脚本", "确认保存文件": "确认保存文件", diff --git a/src/pages/statistics/index.less b/src/pages/statistics/index.less new file mode 100644 index 00000000..774cb2dd --- /dev/null +++ b/src/pages/statistics/index.less @@ -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; +} diff --git a/src/pages/statistics/index.tsx b/src/pages/statistics/index.tsx new file mode 100644 index 00000000..fe1a8a0c --- /dev/null +++ b/src/pages/statistics/index.tsx @@ -0,0 +1,402 @@ +import { SharedContext } from '@/layouts'; +import config from '@/utils/config'; +import { request } from '@/utils/http'; +import { BarChartOutlined, ReloadOutlined } from '@ant-design/icons'; +import { PageContainer } from '@ant-design/pro-layout'; +import { useOutletContext } from '@umijs/max'; +import { + Button, + Card, + Col, + Row, + Statistic, + Table, + Tooltip, + Typography, +} from 'antd'; +import { ColumnProps } from 'antd/lib/table'; +import React, { useEffect, useState } from 'react'; +import intl from 'react-intl-universal'; +import './index.less'; + +const { Title } = Typography; + +interface StatsData { + total: number; + enabled: number; + disabled: number; + today: { + count: number; + avgDuration: number; + }; +} + +interface TrendItem { + date: string; + count: number; +} + +interface TopDurationItem { + cron_id: number; + cron_name: string; + count: number; + avgDuration: number; + maxDuration: number; +} + +interface TopCountItem { + cron_id: number; + cron_name: string; + count: number; + avgDuration: number; +} + +const TrendChart = ({ data }: { data: TrendItem[] }) => { + if (!data || data.length === 0) { + return ( +
+ {intl.get('暂无数据')} +
+ ); + } + + const width = 600; + const height = 200; + const paddingLeft = 40; + const paddingRight = 20; + const paddingTop = 20; + const paddingBottom = 40; + + const chartWidth = width - paddingLeft - paddingRight; + const chartHeight = height - paddingTop - paddingBottom; + + const maxCount = Math.max(...data.map((d) => d.count), 1); + + const points = data.map((d, i) => ({ + x: paddingLeft + (i / (data.length - 1)) * chartWidth, + y: paddingTop + chartHeight - (d.count / maxCount) * chartHeight, + ...d, + })); + + const pathD = points + .map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`) + .join(' '); + + const areaD = + pathD + + ` L ${points[points.length - 1].x.toFixed(1)} ${(paddingTop + chartHeight).toFixed(1)}` + + ` L ${points[0].x.toFixed(1)} ${(paddingTop + chartHeight).toFixed(1)} Z`; + + const yTicks = [0, Math.ceil(maxCount / 2), maxCount]; + + return ( +
+ + {/* Grid lines */} + {yTicks.map((tick) => { + const y = + paddingTop + chartHeight - (tick / maxCount) * chartHeight; + return ( + + + + {tick} + + + ); + })} + + {/* Area fill */} + + + {/* Line */} + + + {/* Points */} + {points.map((p, i) => ( + + + + ))} + + {/* X axis labels */} + {points.map((p, i) => ( + + {p.date} + + ))} + + {/* Axes */} + + + +
+ ); +}; + +const Statistics = () => { + const { headerStyle, isPhone } = useOutletContext(); + const [stats, setStats] = useState(null); + const [trend, setTrend] = useState([]); + const [topDuration, setTopDuration] = useState([]); + const [topCount, setTopCount] = useState([]); + const [loading, setLoading] = useState(true); + + const loadAll = async () => { + setLoading(true); + try { + const [ + statsRes, + trendRes, + topDurationRes, + topCountRes, + ] = await Promise.all([ + request.get(`${config.apiPrefix}crons/stats`), + request.get(`${config.apiPrefix}crons/stats/trend`), + request.get(`${config.apiPrefix}crons/stats/top-duration`), + request.get(`${config.apiPrefix}crons/stats/top-count`), + ]); + if (statsRes.code === 200) setStats(statsRes.data); + if (trendRes.code === 200) setTrend(trendRes.data); + if (topDurationRes.code === 200) setTopDuration(topDurationRes.data); + if (topCountRes.code === 200) setTopCount(topCountRes.data); + } catch (e) { + console.error(e); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadAll(); + }, []); + + const topDurationColumns: ColumnProps[] = [ + { + title: intl.get('排名'), + key: 'rank', + width: 60, + render: (_: any, __: any, index: number) => index + 1, + }, + { + title: intl.get('任务名称'), + dataIndex: 'cron_name', + key: 'cron_name', + ellipsis: true, + }, + { + title: intl.get('平均耗时(秒)'), + dataIndex: 'avgDuration', + key: 'avgDuration', + width: 120, + render: (v: number) => `${v}s`, + }, + { + title: intl.get('最长单次(秒)'), + dataIndex: 'maxDuration', + key: 'maxDuration', + width: 120, + render: (v: number) => `${v}s`, + }, + ]; + + const topCountColumns: ColumnProps[] = [ + { + title: intl.get('排名'), + key: 'rank', + width: 60, + render: (_: any, __: any, index: number) => index + 1, + }, + { + title: intl.get('任务名称'), + dataIndex: 'cron_name', + key: 'cron_name', + ellipsis: true, + }, + { + title: intl.get('今日执行次数'), + dataIndex: 'count', + key: 'count', + width: 120, + }, + { + title: intl.get('平均耗时(秒)'), + dataIndex: 'avgDuration', + key: 'avgDuration', + width: 120, + render: (v: number) => `${v}s`, + }, + ]; + + return ( + + + {intl.get('统计面板')} + + } + extra={[ + , + ]} + > + {/* Section 1: Overview Cards */} + + + + + + + + + + + + + + + + + + + + + {/* Section 2: 7-day Trend */} + + + + + {/* Section 3 & 4: Top Tables */} + + + + + + + + +
+ + + + + ); +}; + +export default Statistics; diff --git a/src/utils/config.ts b/src/utils/config.ts index b529a7d0..8090b99b 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -504,6 +504,7 @@ export default { '/login': intl.get('登录'), '/initialization': intl.get('初始化'), '/crontab': intl.get('定时任务'), + '/statistics': intl.get('统计面板'), '/env': intl.get('环境变量'), '/subscription': intl.get('订阅管理'), '/config': intl.get('配置文件'),