增加任务统计

This commit is contained in:
whyour
2026-06-01 18:20:18 +08:00
parent c0b7527148
commit e8ac195c96
18 changed files with 1484 additions and 60 deletions
+7 -1
View File
@@ -1,5 +1,5 @@
import intl from 'react-intl-universal';
import { SettingOutlined } from '@ant-design/icons';
import { SettingOutlined, DashboardOutlined } from '@ant-design/icons';
import IconFont from '@/components/iconfont';
import { BasicLayoutProps } from '@ant-design/pro-layout';
@@ -24,6 +24,12 @@ export default {
hideInMenu: true,
component: '@/pages/error/index',
},
{
path: '/dashboard',
name: intl.get('统计面板'),
icon: <DashboardOutlined />,
component: '@/pages/dashboard/index',
},
{
path: '/crontab',
name: intl.get('定时任务'),
+1
View File
@@ -212,6 +212,7 @@ body {
&.crontab-wrapper,
&.log-wrapper,
&.env-wrapper,
&.dashboard-wrapper,
&.config-wrapper {
.CodeMirror {
width: calc(100vw - 24px);
+30 -1
View File
@@ -556,5 +556,34 @@
"批量": "Batch",
"全局SSH私钥": "Global SSH Private Key",
"用于访问所有私有仓库的全局SSH私钥": "Global SSH private key for accessing all private repositories",
"请输入完整的SSH私钥内容": "Please enter the complete SSH private key content"
"请输入完整的SSH私钥内容": "Please enter the complete SSH private key content",
"统计面板": "Dashboard",
"总任务": "Total Tasks",
"今日执行": "Today Runs",
"今日成功": "Success",
"今日失败": "Failed",
"成功率": "Success Rate",
"平均耗时": "Avg Time",
"近 7 日趋势": "7-Day Trend",
"今日耗时 Top 5": "Top 5 by Time",
"今日执行次数 Top 5": "Top 5 by Runs",
"实时运行态": "Runtime",
"系统资源": "System",
"排队中": "Queued",
"暂无运行中任务": "No running tasks",
"暂无数据": "No data",
"总执行": "Total",
"次数": "Runs",
"最长单次": "Max Time",
"已运行": "Elapsed",
"系统运行": "System Uptime",
"内存使用": "Memory",
"堆内存": "Heap",
"CPU 核心": "CPU Cores",
"负载 1m": "Load 1m",
"平台": "Platform",
"24小时未运行": "Idle 24h+",
"标签统计": "Label Stats",
"任务数": "Tasks",
"内存": "Memory"
}
+30 -1
View File
@@ -556,5 +556,34 @@
"批量": "批量",
"全局SSH私钥": "全局SSH私钥",
"用于访问所有私有仓库的全局SSH私钥": "用于访问所有私有仓库的全局SSH私钥",
"请输入完整的SSH私钥内容": "请输入完整的SSH私钥内容"
"请输入完整的SSH私钥内容": "请输入完整的SSH私钥内容",
"统计面板": "统计面板",
"总任务": "总任务",
"今日执行": "今日执行",
"今日成功": "今日成功",
"今日失败": "今日失败",
"成功率": "成功率",
"平均耗时": "平均耗时",
"近 7 日趋势": "近 7 日趋势",
"今日耗时 Top 5": "今日耗时 Top 5",
"今日执行次数 Top 5": "今日执行次数 Top 5",
"实时运行态": "实时运行态",
"系统资源": "系统资源",
"排队中": "排队中",
"暂无运行中任务": "暂无运行中任务",
"暂无数据": "暂无数据",
"总执行": "总执行",
"次数": "次数",
"最长单次": "最长单次",
"已运行": "已运行",
"系统运行": "系统运行",
"内存使用": "内存使用",
"堆内存": "堆内存",
"CPU 核心": "CPU 核心",
"负载 1m": "负载 1m",
"平台": "平台",
"24小时未运行": "24小时未运行",
"标签统计": "标签统计",
"任务数": "任务数",
"内存": "内存"
}
+359
View File
@@ -0,0 +1,359 @@
import { useEffect, useState, useCallback } from 'react';
import { Card, Col, Row, Statistic, Table, Tag, Spin, Empty } from 'antd';
import {
CheckCircleOutlined,
CloseCircleOutlined,
ClockCircleOutlined,
ThunderboltOutlined,
StopOutlined,
BarChartOutlined,
} from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-layout';
import { useOutletContext } from '@umijs/max';
import { Area, Gauge } from '@ant-design/plots';
import intl from 'react-intl-universal';
import { SharedContext } from '@/layouts';
import { request } from '@/utils/http';
import CronLogModal from '../crontab/logModal';
interface Overview {
total: number;
enabled: number;
disabled: number;
todayRuns: number;
todaySuccess: number;
todayFail: number;
successRate: string;
avgTime: number;
}
interface TrendItem {
date: string;
total: number;
success: number;
fail: number;
}
interface TopItem {
rank: number;
name: string;
avgTime?: number;
maxTime?: number;
runCount?: number;
successRate?: string;
}
interface Runtime {
runningCount: number;
queuedCount: number;
running: Array<{ id: number; name: string; pid: number; elapsed: number; logPath: string }>;
idleTasks: Array<{ id: number; name: string; lastRun: string }>;
}
interface SystemInfo {
platform: string;
uptime: number;
memTotal: number;
memFree: number;
memUsagePercent: string;
heapUsed: number;
heapTotal: number;
loadAvg: number[];
cpus: number;
}
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
};
const formatSeconds = (s: number) => {
if (s < 60) return `${s}s`;
if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`;
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
return `${h}h ${m}m`;
};
const REFRESH_INTERVAL = 30000;
const Dashboard = () => {
const { headerStyle, theme } = useOutletContext<SharedContext>();
const isDark = theme === 'vs-dark';
const [overview, setOverview] = useState<Overview | null>(null);
const [trend, setTrend] = useState<TrendItem[]>([]);
const [topTime, setTopTime] = useState<TopItem[]>([]);
const [topCount, setTopCount] = useState<TopItem[]>([]);
const [runtime, setRuntime] = useState<Runtime | null>(null);
const [system, setSystem] = useState<SystemInfo | null>(null);
const [labels, setLabels] = useState<any[]>([]);
const [logCron, setLogCron] = useState<any>(null);
const [loading, setLoading] = useState(true);
const fetchData = useCallback(async () => {
try {
const [overviewRes, trendRes, topTimeRes, topCountRes, runtimeRes, systemRes, labelsRes] =
await Promise.allSettled([
request.get('/api/dashboard/overview'),
request.get('/api/dashboard/trend'),
request.get('/api/dashboard/top-time'),
request.get('/api/dashboard/top-count'),
request.get('/api/dashboard/runtime'),
request.get('/api/dashboard/system'),
request.get('/api/dashboard/labels'),
]);
if (overviewRes.status === 'fulfilled' && overviewRes.value.code === 200)
setOverview(overviewRes.value.data);
if (trendRes.status === 'fulfilled' && trendRes.value.code === 200)
setTrend(trendRes.value.data);
if (topTimeRes.status === 'fulfilled' && topTimeRes.value.code === 200)
setTopTime(topTimeRes.value.data);
if (topCountRes.status === 'fulfilled' && topCountRes.value.code === 200)
setTopCount(topCountRes.value.data);
if (runtimeRes.status === 'fulfilled' && runtimeRes.value.code === 200)
setRuntime(runtimeRes.value.data);
if (systemRes.status === 'fulfilled' && systemRes.value.code === 200)
setSystem(systemRes.value.data);
if (labelsRes.status === 'fulfilled' && labelsRes.value.code === 200)
setLabels(labelsRes.value.data);
} catch (e) {
console.error('[dashboard] fetch error', e);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
let timer: ReturnType<typeof setTimeout>;
const poll = async () => {
await fetchData();
timer = setTimeout(poll, REFRESH_INTERVAL);
};
fetchData();
timer = setTimeout(poll, REFRESH_INTERVAL);
return () => clearTimeout(timer);
}, [fetchData]);
const trendConfig = {
data: trend.flatMap((d) => [
{ date: d.date, value: d.total, type: intl.get('总执行') },
{ date: d.date, value: d.success, type: intl.get('成功') },
{ date: d.date, value: d.fail, type: intl.get('失败') },
]),
xField: 'date',
yField: 'value',
seriesField: 'type',
smooth: true,
height: 260,
color: ['#1677ff', '#52c41a', '#ff4d4f'],
legend: { position: 'top' as const },
theme: isDark ? 'dark' : 'light',
xAxis: {
label: { style: { fill: isDark ? '#aaa' : '#333' } },
},
yAxis: {
label: { style: { fill: isDark ? '#aaa' : '#333' } },
grid: { line: { style: { stroke: isDark ? '#333' : '#eee' } } },
},
};
const runtimePagination = {
pageSize: 5,
showSizeChanger: false,
showTotal: (total: number) => `${total}`,
};
if (loading) return <Spin size="large" style={{ display: 'block', marginTop: 100 }} />;
return (
<PageContainer
title={intl.get('统计面板')}
header={{ style: headerStyle }}
className={'ql-container-wrapper dashboard-wrapper'}
>
<Row gutter={[16, 16]}>
<Col xs={12} sm={8} md={6} lg={3}>
<Card size="small"><Statistic title={intl.get('总任务')} value={overview?.total || 0} prefix={<BarChartOutlined />} /></Card>
</Col>
<Col xs={12} sm={8} md={6} lg={3}>
<Card size="small"><Statistic title={intl.get('已启用')} value={overview?.enabled || 0} valueStyle={{ color: '#1677ff' }} prefix={<CheckCircleOutlined />} /></Card>
</Col>
<Col xs={12} sm={8} md={6} lg={3}>
<Card size="small"><Statistic title={intl.get('今日执行')} value={overview?.todayRuns || 0} valueStyle={{ color: '#1677ff' }} prefix={<ThunderboltOutlined />} /></Card>
</Col>
<Col xs={12} sm={8} md={6} lg={3}>
<Card size="small"><Statistic title={intl.get('成功率')} value={`${overview?.successRate || '0'}%`} valueStyle={{ color: '#52c41a' }} /></Card>
</Col>
<Col xs={12} sm={8} md={6} lg={3}>
<Card size="small"><Statistic title={intl.get('今日成功')} value={overview?.todaySuccess || 0} valueStyle={{ color: '#52c41a' }} prefix={<CheckCircleOutlined />} /></Card>
</Col>
<Col xs={12} sm={8} md={6} lg={3}>
<Card size="small"><Statistic title={intl.get('今日失败')} value={overview?.todayFail || 0} valueStyle={{ color: '#ff4d4f' }} prefix={<CloseCircleOutlined />} /></Card>
</Col>
<Col xs={12} sm={8} md={6} lg={3}>
<Card size="small"><Statistic title={intl.get('平均耗时')} value={overview?.avgTime ? `${(overview.avgTime / 1000).toFixed(1)}s` : '-'} prefix={<ClockCircleOutlined />} /></Card>
</Col>
<Col xs={12} sm={8} md={6} lg={3}>
<Card size="small"><Statistic title={intl.get('已禁用')} value={overview?.disabled || 0} prefix={<StopOutlined />} /></Card>
</Col>
</Row>
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col span={24}>
<Card title={intl.get('近 7 日趋势')} size="small">
{trend.length > 0 ? <Area {...trendConfig} /> : <Empty description={intl.get('暂无数据')} />}
</Card>
</Col>
</Row>
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col xs={24} lg={12}>
<Card title={intl.get('今日耗时 Top 5')} size="small">
<Table
dataSource={topTime}
rowKey="rank"
pagination={false}
size="small"
columns={[
{ title: '#', dataIndex: 'rank', width: 40 },
{ title: intl.get('定时任务'), dataIndex: 'name', ellipsis: true },
{ title: intl.get('平均耗时'), dataIndex: 'avgTime', width: 100, render: (v: number) => v ? `${(v / 1000).toFixed(1)}s` : '-' },
{ title: intl.get('最长单次'), dataIndex: 'maxTime', width: 100, render: (v: number) => v ? `${(v / 1000).toFixed(1)}s` : '-' },
]}
/>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card title={intl.get('今日执行次数 Top 5')} size="small">
<Table
dataSource={topCount}
rowKey="rank"
pagination={false}
size="small"
columns={[
{ title: '#', dataIndex: 'rank', width: 40 },
{ title: intl.get('定时任务'), dataIndex: 'name', ellipsis: true },
{ title: intl.get('次数'), dataIndex: 'runCount', width: 60 },
{ title: intl.get('平均耗时'), dataIndex: 'avgTime', width: 100, render: (v: number) => v ? `${(v / 1000).toFixed(1)}s` : '-' },
{ title: intl.get('成功率'), dataIndex: 'successRate', width: 80, render: (v: string) => `${v}%` },
]}
/>
</Card>
</Col>
</Row>
{labels.length > 0 && (
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col span={24}>
<Card title={intl.get('标签统计')} size="small">
<Table
dataSource={labels}
rowKey="label"
pagination={false}
size="small"
columns={[
{ title: intl.get('标签'), dataIndex: 'label', width: 150, render: (v: string) => <Tag>{v}</Tag> },
{ title: intl.get('任务数'), dataIndex: 'count', width: 80 },
{ title: intl.get('今日执行'), dataIndex: 'todayRuns', width: 100 },
{ title: intl.get('成功率'), dataIndex: 'successRate', width: 100, render: (v: string) => `${v}%` },
{ title: intl.get('平均耗时'), dataIndex: 'avgTime', width: 120, render: (v: number) => v ? `${(v / 1000).toFixed(1)}s` : '-' },
]}
/>
</Card>
</Col>
</Row>
)}
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col xs={24} lg={12}>
<Card
title={intl.get('实时运行态')}
size="small"
extra={
<>
<Tag color="processing">{intl.get('运行中')} {runtime?.runningCount || 0}</Tag>
<Tag color="warning">{intl.get('排队中')} {runtime?.queuedCount || 0}</Tag>
</>
}
>
<Table
dataSource={runtime?.running || []}
rowKey="id"
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: '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> },
]}
/>
{runtime?.idleTasks && runtime.idleTasks.length > 0 && (
<>
<div style={{ marginTop: 12, fontSize: 13, color: '#ff7a00' }}>
{intl.get('24小时未运行')} ({runtime.idleTasks.length})
</div>
<Table
dataSource={runtime.idleTasks}
rowKey="id"
pagination={false}
size="small"
showHeader={false}
columns={[
{ dataIndex: 'name', ellipsis: true },
{ dataIndex: 'lastRun', width: 110, render: (v: string) => <span style={{ color: '#999' }}>{v}</span> },
]}
/>
</>
)}
</Card>
</Col>
<Col xs={24} lg={12}>
<Card title={intl.get('系统资源')} size="small">
{system && (
<Row gutter={[16, 16]} align="middle">
<Col span={12}>
<Gauge
height={300}
data={{
target: Number(system.memUsagePercent),
total: 100,
name: '内存使用率',
}}
style={{
textY: '70%',
textContent: (target: number, total: number) => {
return `${intl.get('内存')}${target}%`;
},
}}
/>
</Col>
<Col span={12}>
<Statistic title={intl.get('系统运行')} value={formatSeconds(system.uptime)} />
<Statistic title={intl.get('堆内存')} value={`${system.heapUsed} MB`} valueStyle={{ fontSize: 16 }} />
<div style={{ marginTop: 8, color: '#888', fontSize: 12 }}>
{intl.get('负载 1m')}: {system.loadAvg?.[0] || '-'} | CPU: {system.cpus} {intl.get('核心')} | {system.platform}
</div>
</Col>
</Row>
)}
</Card>
</Col>
</Row>
{logCron && (
<CronLogModal
cron={logCron}
handleCancel={() => setLogCron(null)}
/>
)}
</PageContainer>
);
};
export default Dashboard;
+2 -2
View File
@@ -109,7 +109,7 @@ const Login = () => {
),
});
reloadUser(true);
history.push('/crontab');
history.push('/dashboard');
} else if (code === 410) {
setWaitTime(data);
} else if (code === 420) {
@@ -136,7 +136,7 @@ const Login = () => {
useEffect(() => {
const isAuth = localStorage.getItem(config.authKey);
if (isAuth) {
history.push('/crontab');
history.push('/dashboard');
}
}, []);