diff --git a/back/api/dashboard.ts b/back/api/dashboard.ts index 5e9438a3..52e0b90d 100644 --- a/back/api/dashboard.ts +++ b/back/api/dashboard.ts @@ -107,6 +107,43 @@ export default (app: Router) => { }, ); + route.get( + '/failures', + async (req: Request, res: Response, next: NextFunction) => { + try { + const rows = (await CrontabStatModel.findAll({ + attributes: ['ref_id', [fn('SUM', col('fail_count')), 'fail_count']], + where: { date: dayjs().format('YYYY-MM-DD'), fail_count: { [Op.gt]: 0 } }, + group: ['ref_id'], + order: [[fn('SUM', col('fail_count')), 'DESC'], ['ref_id', 'ASC']], + raw: true, + })) as any[]; + const crons = rows.length > 0 ? await CrontabModel.findAll({ + attributes: ['id', 'name', 'command'], + where: { id: { [Op.in]: rows.map((row) => Number(row.ref_id)) } }, + raw: true, + }) : []; + const cronMap = new Map(crons.map((cron) => [cron.id, cron])); + res.send({ + code: 200, + data: rows.map((row) => { + const id = Number(row.ref_id); + const cron = cronMap.get(id); + return { + id, + name: cron?.name || cron?.command || tf('任务#%s', id), + command: cron?.command || '', + failCount: Number(row.fail_count), + deleted: !cron, + }; + }), + }); + } catch (e) { + next(e); + } + }, + ); + route.get( '/trend', async (req: Request, res: Response, next: NextFunction) => { diff --git a/src/locales/en-US.json b/src/locales/en-US.json index ec5a7127..5d6d11e7 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -657,5 +657,9 @@ "已完成": "Completed", "已停止": "Stopped", "退出码": "Exit Code", - "结束": "End" + "结束": "End", + "失败次数": "Failure count", + "最新日志": "Latest log", + "加载失败": "Failed to load", + "重试": "Retry" } diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index ae12cf39..b0493a74 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -657,5 +657,9 @@ "已完成": "已完成", "已停止": "已停止", "退出码": "退出码", - "结束": "结束" + "结束": "结束", + "失败次数": "失败次数", + "最新日志": "最新日志", + "加载失败": "加载失败", + "重试": "重试" } diff --git a/src/pages/dashboard/failureModal.tsx b/src/pages/dashboard/failureModal.tsx new file mode 100644 index 00000000..a9d0f472 --- /dev/null +++ b/src/pages/dashboard/failureModal.tsx @@ -0,0 +1,117 @@ +import { useEffect, useState } from 'react'; +import { Alert, Button, Modal, Table, Tag } from 'antd'; +import intl from 'react-intl-universal'; +import { request } from '@/utils/http'; +import config from '@/utils/config'; +import CronLogModal from '../crontab/logModal'; + +interface FailedTask { + id: number; + name: string; + command: string; + failCount: number; + deleted: boolean; +} + +export default function FailureModal({ onCancel }: { onCancel: () => void }) { + const [tasks, setTasks] = useState([]); + const [loading, setLoading] = useState(true); + const [failed, setFailed] = useState(false); + const [reload, setReload] = useState(0); + const [logCron, setLogCron] = useState(null); + + useEffect(() => { + let active = true; + setLoading(true); + setFailed(false); + request + .get(`${config.apiPrefix}dashboard/failures`) + .then((response) => { + if (!active) return; + if (response.code !== 200) + throw new Error('Failed to load dashboard failures'); + setTasks(response.data); + }) + .catch(() => { + if (active) setFailed(true); + }) + .finally(() => { + if (active) setLoading(false); + }); + return () => { + active = false; + }; + }, [reload]); + + return ( + <> + + {failed ? ( + setReload((value) => value + 1)}> + {intl.get('重试')} + + } + /> + ) : ( + + loading={loading} + dataSource={tasks} + rowKey="id" + size="small" + scroll={{ x: 650 }} + pagination={{ pageSize: 10, showSizeChanger: false }} + columns={[ + { + title: intl.get('定时任务'), + dataIndex: 'name', + ellipsis: true, + render: (name, task) => ( + <> + {name} {task.deleted && {intl.get('已删除')}} + + ), + }, + { title: intl.get('命令'), dataIndex: 'command', ellipsis: true }, + { + title: intl.get('失败次数'), + dataIndex: 'failCount', + width: 100, + }, + { + title: intl.get('日志'), + width: 120, + render: (_, task) => ( + + ), + }, + ]} + /> + )} + + {logCron && ( + setLogCron(null)} /> + )} + + ); +} diff --git a/src/pages/dashboard/index.tsx b/src/pages/dashboard/index.tsx index 6f428f95..d77070de 100644 --- a/src/pages/dashboard/index.tsx +++ b/src/pages/dashboard/index.tsx @@ -16,6 +16,7 @@ import { SharedContext } from '@/layouts'; import { request } from '@/utils/http'; import config from '@/utils/config'; import CronLogModal from '../crontab/logModal'; +import FailureModal from './failureModal'; interface Overview { total: number; @@ -93,6 +94,7 @@ const Dashboard = () => { const [labels, setLabels] = useState([]); const [logCron, setLogCron] = useState(null); const [loading, setLoading] = useState(true); + const [showFailures, setShowFailures] = useState(false); const fetchData = useCallback(async () => { try { @@ -193,7 +195,16 @@ const Dashboard = () => { } /> - } /> + setShowFailures(true)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setShowFailures(true); + } + }} + >} /> } /> @@ -350,6 +361,7 @@ const Dashboard = () => { + {showFailures && setShowFailures(false)} />} {logCron && ( { + const db = new Sequelize({ + dialect: 'sqlite', + storage: ':memory:', + logging: false, + }); + t.after(() => db.close()); + const crons = db.define('Cron', { + name: DataTypes.STRING, + command: DataTypes.STRING, + }); + const stats = db.define('Stat', { + ref_id: DataTypes.INTEGER, + date: DataTypes.STRING, + fail_count: DataTypes.INTEGER, + success_count: DataTypes.INTEGER, + }); + const replacements = { + '../../back/data/cron': { CrontabModel: crons }, + '../../back/data/cronStats': { CrontabStatModel: stats }, + '../../back/data/runningInstance': {}, + '../../back/shared/i18n': { tf: (format, id) => format.replace('%s', id) }, + }; + for (const [modulePath, exports] of Object.entries(replacements)) { + const resolved = require.resolve(modulePath); + const original = require.cache[resolved]; + require.cache[resolved] = { + id: resolved, + filename: resolved, + loaded: true, + exports, + }; + t.after(() => { + if (original) require.cache[resolved] = original; + else delete require.cache[resolved]; + }); + } + await db.sync(); + await crons.bulkCreate([ + { id: 1, name: 'Recovered task', command: 'task recovered.js' }, + { id: 2, name: '', command: 'task unnamed.js' }, + { id: 3, name: 'Successful task', command: 'task success.js' }, + ]); + const today = dayjs().format('YYYY-MM-DD'); + await stats.bulkCreate([ + { ref_id: 1, date: today, fail_count: 2, success_count: 1 }, + { ref_id: 2, date: today, fail_count: 1, success_count: 0 }, + { ref_id: 3, date: today, fail_count: 0, success_count: 4 }, + { ref_id: 4, date: today, fail_count: 3, success_count: 0 }, + { + ref_id: 3, + date: dayjs().subtract(1, 'day').format('YYYY-MM-DD'), + fail_count: 10, + }, + ]); + const router = express.Router(); + require('../../back/api/dashboard').default(router); + const dashboard = router.stack.find( + (layer) => layer.name === 'router', + ).handle; + const handler = dashboard.stack.find( + (layer) => layer.route?.path === '/failures', + ).route.stack[0].handle; + let response; + await handler( + {}, + { + send: (value) => { + response = value; + }, + }, + (error) => { + throw error; + }, + ); + assert.deepEqual(response, { + code: 200, + data: [ + { id: 4, name: '任务#4', command: '', failCount: 3, deleted: true }, + { + id: 1, + name: 'Recovered task', + command: 'task recovered.js', + failCount: 2, + deleted: false, + }, + { + id: 2, + name: 'task unnamed.js', + command: 'task unnamed.js', + failCount: 1, + deleted: false, + }, + ], + }); + await stats.destroy({ where: {} }); + await handler( + {}, + { + send: (value) => { + response = value; + }, + }, + (error) => { + throw error; + }, + ); + assert.deepEqual(response, { code: 200, data: [] }); + await stats.drop(); + let forwarded; + await handler( + {}, + { send: () => assert.fail('must forward database errors') }, + (error) => { + forwarded = error; + }, + ); + assert.ok(forwarded instanceof Error); +});