From a60ec5587a35bb3eb05237e7d56a39ddea4c0cde Mon Sep 17 00:00:00 2001 From: whyour Date: Fri, 18 Sep 2026 02:16:54 +0800 Subject: [PATCH] feat: show today's successful tasks from dashboard (#3072) --- back/api/dashboard.ts | 75 ++++++++++--------- src/locales/en-US.json | 1 + src/locales/zh-CN.json | 1 + src/pages/dashboard/index.tsx | 21 ++++-- .../{failureModal.tsx => taskResultModal.tsx} | 34 ++++++--- test/back/dashboard-failures.test.cjs | 51 ++++++++++++- 6 files changed, 127 insertions(+), 56 deletions(-) rename src/pages/dashboard/{failureModal.tsx => taskResultModal.tsx} (77%) diff --git a/back/api/dashboard.ts b/back/api/dashboard.ts index 52e0b90d..8abaf17a 100644 --- a/back/api/dashboard.ts +++ b/back/api/dashboard.ts @@ -107,42 +107,45 @@ 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); - } - }, - ); + for (const isSuccess of [false, true]) { + route.get( + isSuccess ? '/successes' : '/failures', + async (req: Request, res: Response, next: NextFunction) => { + try { + const countColumn = isSuccess ? 'success_count' : 'fail_count'; + const rows = (await CrontabStatModel.findAll({ + attributes: ['ref_id', [fn('SUM', col(countColumn)), 'result_count']], + where: { date: dayjs().format('YYYY-MM-DD'), [countColumn]: { [Op.gt]: 0 } }, + group: ['ref_id'], + order: [[fn('SUM', col(countColumn)), '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 || '', + [isSuccess ? 'successCount' : 'failCount']: Number(row.result_count), + deleted: !cron, + }; + }), + }); + } catch (e) { + next(e); + } + }, + ); + } route.get( '/trend', diff --git a/src/locales/en-US.json b/src/locales/en-US.json index b4005cb2..e19986f7 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -658,6 +658,7 @@ "已停止": "Stopped", "退出码": "Exit Code", "结束": "End", + "成功次数": "Success count", "失败次数": "Failure count", "最新日志": "Latest log", "加载失败": "Failed to load", diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index 164516c4..0296e618 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -658,6 +658,7 @@ "已停止": "已停止", "退出码": "退出码", "结束": "结束", + "成功次数": "成功次数", "失败次数": "失败次数", "最新日志": "最新日志", "加载失败": "加载失败", diff --git a/src/pages/dashboard/index.tsx b/src/pages/dashboard/index.tsx index d77070de..c7b1229e 100644 --- a/src/pages/dashboard/index.tsx +++ b/src/pages/dashboard/index.tsx @@ -16,7 +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'; +import TaskResultModal from './taskResultModal'; interface Overview { total: number; @@ -94,7 +94,7 @@ const Dashboard = () => { const [labels, setLabels] = useState([]); const [logCron, setLogCron] = useState(null); const [loading, setLoading] = useState(true); - const [showFailures, setShowFailures] = useState(false); + const [result, setResult] = useState<'success' | 'failure' | null>(null); const fetchData = useCallback(async () => { try { @@ -192,16 +192,25 @@ const Dashboard = () => { - } /> + setResult('success')} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setResult('success'); + } + }} + >} /> setShowFailures(true)} + onClick={() => setResult('failure')} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); - setShowFailures(true); + setResult('failure'); } }} >} /> @@ -361,7 +370,7 @@ const Dashboard = () => { - {showFailures && setShowFailures(false)} />} + {result && setResult(null)} />} {logCron && ( void }) { - const [tasks, setTasks] = useState([]); +export default function TaskResultModal({ + result, + onCancel, +}: { + result: 'success' | 'failure'; + onCancel: () => void; +}) { + const isSuccess = result === 'success'; + const [tasks, setTasks] = useState([]); const [loading, setLoading] = useState(true); const [failed, setFailed] = useState(false); const [reload, setReload] = useState(0); - const [logCron, setLogCron] = useState(null); + const [logCron, setLogCron] = useState(null); useEffect(() => { let active = true; setLoading(true); setFailed(false); request - .get(`${config.apiPrefix}dashboard/failures`) + .get( + `${config.apiPrefix}dashboard/${isSuccess ? 'successes' : 'failures'}`, + ) .then((response) => { if (!active) return; if (response.code !== 200) - throw new Error('Failed to load dashboard failures'); + throw new Error('Failed to load dashboard task results'); setTasks(response.data); }) .catch(() => { @@ -41,12 +51,12 @@ export default function FailureModal({ onCancel }: { onCancel: () => void }) { return () => { active = false; }; - }, [reload]); + }, [reload, isSuccess]); return ( <> void }) { } /> ) : ( - + loading={loading} dataSource={tasks} rowKey="id" @@ -84,8 +94,8 @@ export default function FailureModal({ onCancel }: { onCancel: () => void }) { }, { title: intl.get('命令'), dataIndex: 'command', ellipsis: true }, { - title: intl.get('失败次数'), - dataIndex: 'failCount', + title: intl.get(isSuccess ? '成功次数' : '失败次数'), + dataIndex: isSuccess ? 'successCount' : 'failCount', width: 100, }, { diff --git a/test/back/dashboard-failures.test.cjs b/test/back/dashboard-failures.test.cjs index 886b314e..92435792 100644 --- a/test/back/dashboard-failures.test.cjs +++ b/test/back/dashboard-failures.test.cjs @@ -5,7 +5,7 @@ const { Sequelize, DataTypes } = require('sequelize'); const dayjs = require('dayjs'); const express = require('express'); -test('today failures includes recovered and deleted tasks, excluding previous days and successes', async (t) => { +test('today result lists include mixed and deleted tasks and exclude other dates', async (t) => { const db = new Sequelize({ dialect: 'sqlite', storage: ':memory:', @@ -53,11 +53,12 @@ test('today failures includes recovered and deleted tasks, excluding previous da { 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: 4, date: today, fail_count: 3, success_count: 2 }, { ref_id: 3, date: dayjs().subtract(1, 'day').format('YYYY-MM-DD'), fail_count: 10, + success_count: 20, }, ]); const router = express.Router(); @@ -68,6 +69,9 @@ test('today failures includes recovered and deleted tasks, excluding previous da const handler = dashboard.stack.find( (layer) => layer.route?.path === '/failures', ).route.stack[0].handle; + const successHandler = dashboard.stack.find( + (layer) => layer.route?.path === '/successes', + ).route.stack[0].handle; let response; await handler( {}, @@ -100,7 +104,50 @@ test('today failures includes recovered and deleted tasks, excluding previous da }, ], }); + await successHandler( + {}, + { + send: (value) => { + response = value; + }, + }, + (error) => { + throw error; + }, + ); + assert.deepEqual(response, { + code: 200, + data: [ + { + id: 3, + name: 'Successful task', + command: 'task success.js', + successCount: 4, + deleted: false, + }, + { id: 4, name: '任务#4', command: '', successCount: 2, deleted: true }, + { + id: 1, + name: 'Recovered task', + command: 'task recovered.js', + successCount: 1, + deleted: false, + }, + ], + }); await stats.destroy({ where: {} }); + await successHandler( + {}, + { + send: (value) => { + response = value; + }, + }, + (error) => { + throw error; + }, + ); + assert.deepEqual(response, { code: 200, data: [] }); await handler( {}, {