refactor(ui): extract SectionHeader + drop deprecated CronPoolMonitor (audit P2)
All checks were successful
CI / test (push) Successful in 3m57s
All checks were successful
CI / test (push) Successful in 3m57s
UI/UX 盘点 P2 + 清理: - 新增 SectionHeader 共享组件,替换 DecisionProcess/ConfigSnapshots(主列表+快照 对比)/EvalReport 自管 header 的重复(返回+标题+右侧操作区) - 删除已 DEPRECATED 的 CronPoolMonitor 页面及其测试(导航早已移除,监控职责 已由 TaskQueueMonitor 承担) tsc 0 错误 vitest 16 passed(CronPoolMonitor 3 测试随之删除)
This commit is contained in:
parent
fa5e3b8d5d
commit
47804f2df3
29
frontend/web/src/components/SectionHeader.tsx
Normal file
29
frontend/web/src/components/SectionHeader.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { Button } from 'antd'
|
||||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
||||
import type { ReactNode } from 'react'
|
||||
import { colors } from '../tokens'
|
||||
|
||||
interface SectionHeaderProps {
|
||||
title: ReactNode
|
||||
/** 提供时显示"返回"按钮(子视图/抽屉 tab 环境可省)。 */
|
||||
onBack?: () => void
|
||||
/** 返回按钮文案(默认"返回")。 */
|
||||
backLabel?: string
|
||||
/** 右侧操作区(筛选/刷新/导出等)。 */
|
||||
actions?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* 内容区子视图的标准横排头部:可选返回按钮 + 标题 + 右侧操作区。
|
||||
* 供智能评估等 Drawer/Tabs 内容组件复用,消除各自手写 header 的重复。
|
||||
*/
|
||||
export default function SectionHeader({ title, onBack, backLabel = '返回', actions }: SectionHeaderProps) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
{onBack && <Button icon={<ArrowLeftOutlined />} onClick={onBack}>{backLabel}</Button>}
|
||||
<span style={{ fontSize: 16, fontWeight: 600, color: colors.text }}>{title}</span>
|
||||
<div style={{ flex: 1 }} />
|
||||
{actions}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -4,6 +4,7 @@ import {
|
||||
} from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { ArrowLeftOutlined, DiffOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import SectionHeader from '../SectionHeader'
|
||||
import { intelligentEvalsApi, type ConfigSnapshot, type ConfigSnapshotComparison } from '../../api'
|
||||
import { colors } from '../../tokens'
|
||||
import { formatDateTime } from '../../utils/date'
|
||||
@ -182,10 +183,7 @@ export default function ConfigSnapshots({ evalId, onBack }: ConfigSnapshotsProps
|
||||
if (compareMode && comparison) {
|
||||
return (
|
||||
<div style={{ height: '100%', overflowY: 'auto', padding: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => setCompareMode(false)}>返回</Button>
|
||||
<span style={{ fontSize: 16, fontWeight: 600 }}>快照对比</span>
|
||||
</div>
|
||||
<SectionHeader title="快照对比" onBack={() => setCompareMode(false)} />
|
||||
|
||||
<Card size="small" title="对比信息" style={{ marginBottom: 16 }}>
|
||||
<Descriptions column={2} size="small">
|
||||
@ -241,19 +239,22 @@ export default function ConfigSnapshots({ evalId, onBack }: ConfigSnapshotsProps
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', overflowY: 'auto', padding: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
|
||||
{onBack && <Button icon={<ArrowLeftOutlined />} onClick={onBack}>返回</Button>}
|
||||
<span style={{ fontSize: 16, fontWeight: 600 }}>配置历史</span>
|
||||
<div style={{ flex: 1 }} />
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void loadSnapshots()}>刷新</Button>
|
||||
<Button
|
||||
icon={<DiffOutlined />}
|
||||
disabled={selectedForCompare.length !== 2}
|
||||
onClick={handleCompare}
|
||||
>
|
||||
对比选中
|
||||
</Button>
|
||||
</div>
|
||||
<SectionHeader
|
||||
title="配置历史"
|
||||
onBack={onBack}
|
||||
actions={(
|
||||
<>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void loadSnapshots()}>刷新</Button>
|
||||
<Button
|
||||
icon={<DiffOutlined />}
|
||||
disabled={selectedForCompare.length !== 2}
|
||||
onClick={handleCompare}
|
||||
>
|
||||
对比选中
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
|
||||
@ -3,7 +3,8 @@ import {
|
||||
Button, Card, Empty, Select, Table, Tag, Timeline, message,
|
||||
} from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { ArrowLeftOutlined, DownloadOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import { DownloadOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import SectionHeader from '../SectionHeader'
|
||||
import { intelligentEvalsApi, type DecisionLog } from '../../api'
|
||||
import { colors } from '../../tokens'
|
||||
import { formatDateTime } from '../../utils/date'
|
||||
@ -106,24 +107,27 @@ export default function DecisionProcess({ evalId, onBack }: DecisionProcessProps
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', overflowY: 'auto', padding: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
|
||||
{onBack && <Button icon={<ArrowLeftOutlined />} onClick={onBack}>返回</Button>}
|
||||
<span style={{ fontSize: 16, fontWeight: 600 }}>决策过程</span>
|
||||
<div style={{ flex: 1 }} />
|
||||
<Select
|
||||
placeholder="筛选决策类型"
|
||||
allowClear
|
||||
style={{ width: 160 }}
|
||||
onChange={(val) => setFilterType(val ?? null)}
|
||||
options={[
|
||||
{ label: '执行会话', value: 'execute_session' },
|
||||
{ label: '等待', value: 'wait' },
|
||||
{ label: '开始分析', value: 'start_analysis' },
|
||||
]}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void loadLogs()}>刷新</Button>
|
||||
<Button icon={<DownloadOutlined />} onClick={handleExport}>导出</Button>
|
||||
</div>
|
||||
<SectionHeader
|
||||
title="决策过程"
|
||||
onBack={onBack}
|
||||
actions={(
|
||||
<>
|
||||
<Select
|
||||
placeholder="筛选决策类型"
|
||||
allowClear
|
||||
style={{ width: 160 }}
|
||||
onChange={(val) => setFilterType(val ?? null)}
|
||||
options={[
|
||||
{ label: '执行会话', value: 'execute_session' },
|
||||
{ label: '等待', value: 'wait' },
|
||||
{ label: '开始分析', value: 'start_analysis' },
|
||||
]}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void loadLogs()}>刷新</Button>
|
||||
<Button icon={<DownloadOutlined />} onClick={handleExport}>导出</Button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Card size="small" title="决策时间线" style={{ marginBottom: 16 }}>
|
||||
{loading ? (
|
||||
|
||||
@ -3,11 +3,11 @@ import {
|
||||
Button, Card, Col, Collapse, Empty, Row, Space, Spin, Tag, message,
|
||||
} from 'antd'
|
||||
import {
|
||||
ArrowLeftOutlined, BulbOutlined, DownloadOutlined, MessageOutlined,
|
||||
StarOutlined, WarningOutlined,
|
||||
BulbOutlined, DownloadOutlined, MessageOutlined, StarOutlined, WarningOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Bar } from '@ant-design/charts'
|
||||
import ChatBubble from '../ChatBubble'
|
||||
import SectionHeader from '../SectionHeader'
|
||||
import {
|
||||
intelligentEvalsApi,
|
||||
type IntelligentEval,
|
||||
@ -175,18 +175,16 @@ export default function EvalReport({ ev, onBack }: EvalReportProps) {
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', overflowY: 'auto', padding: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
{onBack && <Button icon={<ArrowLeftOutlined />} onClick={onBack}>返回详情</Button>}
|
||||
<span style={{ fontSize: 16, fontWeight: 600, color: colors.text }}>
|
||||
{ev.name} · 评估报告
|
||||
</span>
|
||||
<div style={{ flex: 1 }} />
|
||||
{report && (
|
||||
<SectionHeader
|
||||
title={`${ev.name} · 评估报告`}
|
||||
onBack={onBack}
|
||||
backLabel="返回详情"
|
||||
actions={report && (
|
||||
<Button icon={<DownloadOutlined />} loading={exporting} onClick={exportMarkdown}>
|
||||
导出 Markdown
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
|
||||
{reportLoading && <Spin />}
|
||||
|
||||
|
||||
@ -1,126 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
openclawCronPoolApi: {
|
||||
getStatus: vi.fn(),
|
||||
getMetrics: vi.fn(),
|
||||
getAlerts: vi.fn(),
|
||||
scale: vi.fn(),
|
||||
resolveAlert: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('antd', async () => {
|
||||
const actual = await vi.importActual<typeof import('antd')>('antd')
|
||||
return {
|
||||
...actual,
|
||||
message: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn() },
|
||||
}
|
||||
})
|
||||
|
||||
import { openclawCronPoolApi } from '../api'
|
||||
import CronPoolMonitor from './CronPoolMonitor'
|
||||
|
||||
const statusResp = {
|
||||
data: { pool: { total: 5, idle: 3, busy: 2, stuck: 0, min_size: 5, max_size: 20 } },
|
||||
}
|
||||
const metricsResp = { data: { metrics: { pool_utilization: 0.4, task_backlog: 0, stuck_rate: 0 } } }
|
||||
const alertsResp = { data: { alerts: [] } }
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
})
|
||||
vi.useFakeTimers()
|
||||
vi.mocked(openclawCronPoolApi.getStatus).mockResolvedValue(statusResp as never)
|
||||
vi.mocked(openclawCronPoolApi.getMetrics).mockResolvedValue(metricsResp as never)
|
||||
vi.mocked(openclawCronPoolApi.getAlerts).mockResolvedValue(alertsResp as never)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
cleanup()
|
||||
})
|
||||
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
describe('CronPoolMonitor polling lifecycle', () => {
|
||||
it('polls every 5 seconds while mounted', async () => {
|
||||
render(<CronPoolMonitor />)
|
||||
await settle()
|
||||
expect(openclawCronPoolApi.getStatus).toHaveBeenCalledTimes(1)
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
})
|
||||
await settle()
|
||||
expect(openclawCronPoolApi.getStatus).toHaveBeenCalledTimes(2)
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
})
|
||||
await settle()
|
||||
expect(openclawCronPoolApi.getStatus).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('clears the polling interval on unmount', async () => {
|
||||
const { unmount } = render(<CronPoolMonitor />)
|
||||
await settle()
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
})
|
||||
await settle()
|
||||
const before = vi.mocked(openclawCronPoolApi.getStatus).mock.calls.length
|
||||
unmount()
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30000)
|
||||
})
|
||||
await settle()
|
||||
const after = vi.mocked(openclawCronPoolApi.getStatus).mock.calls.length
|
||||
expect(after).toBe(before)
|
||||
})
|
||||
it('pauses polling while the document is hidden and resumes on visible', async () => {
|
||||
// Default starting state is visible.
|
||||
vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible')
|
||||
|
||||
render(<CronPoolMonitor />)
|
||||
await settle()
|
||||
const initial = vi.mocked(openclawCronPoolApi.getStatus).mock.calls.length
|
||||
expect(initial).toBeGreaterThanOrEqual(1)
|
||||
|
||||
// While hidden, advancing the clock must NOT trigger another poll.
|
||||
vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden')
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(15000)
|
||||
})
|
||||
await settle()
|
||||
const afterHidden = vi.mocked(openclawCronPoolApi.getStatus).mock.calls.length
|
||||
expect(afterHidden).toBe(initial)
|
||||
|
||||
// Returning to visible resumes polling and triggers an immediate reload.
|
||||
vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible')
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
await settle()
|
||||
const afterVisible = vi.mocked(openclawCronPoolApi.getStatus).mock.calls.length
|
||||
expect(afterVisible).toBeGreaterThan(initial)
|
||||
})
|
||||
})
|
||||
@ -1,222 +0,0 @@
|
||||
// DEPRECATED (ADR-0009): 智能评估已改为触发式执行,cron 池不再使用(worker cron 已禁用)。
|
||||
// 本页已从导航移除,遗留保留仅供回溯。监控职责由 TaskQueueMonitor(任务队列页)承担。
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Alert, Button, Card, Descriptions, Empty, InputNumber, Space, Statistic, Table, Tag, message,
|
||||
} from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { ReloadOutlined, WarningOutlined } from '@ant-design/icons'
|
||||
import { openclawCronPoolApi, type CronPoolAlert, type CronPoolMetrics, type CronPoolStatus } from '../api'
|
||||
import { usePolling } from '../hooks/usePolling'
|
||||
import { colors } from '../tokens'
|
||||
import { formatDateTime } from '../utils/date'
|
||||
|
||||
export default function CronPoolMonitor() {
|
||||
const [status, setStatus] = useState<CronPoolStatus | null>(null)
|
||||
const [metrics, setMetrics] = useState<CronPoolMetrics | null>(null)
|
||||
const [alerts, setAlerts] = useState<CronPoolAlert[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [scaleTarget, setScaleTarget] = useState<number>(5)
|
||||
const [scaleBusy, setScaleBusy] = useState(false)
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [statusRes, metricsRes, alertsRes] = await Promise.all([
|
||||
openclawCronPoolApi.getStatus(),
|
||||
openclawCronPoolApi.getMetrics(),
|
||||
openclawCronPoolApi.getAlerts(50),
|
||||
])
|
||||
setStatus(statusRes.data.pool)
|
||||
setMetrics(metricsRes.data.metrics)
|
||||
setAlerts(alertsRes.data.alerts)
|
||||
} catch {
|
||||
message.error('加载数据失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Initial fetch (usePolling owns the 5s interval + visibility pause).
|
||||
useEffect(() => {
|
||||
void loadData()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// S6: defer the recurring 5s poll to the shared `usePolling` hook. The
|
||||
// default pauses the timer while the document is hidden (background tab /
|
||||
// minimised window) and re-fetches once on the way back to `visible`.
|
||||
usePolling(() => { void loadData() }, 5000, true)
|
||||
|
||||
const handleScale = async () => {
|
||||
setScaleBusy(true)
|
||||
try {
|
||||
await openclawCronPoolApi.scale(scaleTarget)
|
||||
message.success('扩缩容成功')
|
||||
await loadData()
|
||||
} catch {
|
||||
message.error('扩缩容失败')
|
||||
} finally {
|
||||
setScaleBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResolveAlert = async (alertId: string) => {
|
||||
try {
|
||||
await openclawCronPoolApi.resolveAlert(alertId)
|
||||
message.success('已解决告警')
|
||||
await loadData()
|
||||
} catch {
|
||||
message.error('解决告警失败')
|
||||
}
|
||||
}
|
||||
|
||||
const alertColumns: ColumnsType<CronPoolAlert> = [
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 180,
|
||||
render: (val: string) => formatDateTime(val),
|
||||
},
|
||||
{
|
||||
title: '级别',
|
||||
dataIndex: 'severity',
|
||||
key: 'severity',
|
||||
width: 100,
|
||||
render: (val: string) => (
|
||||
<Tag color={val === 'critical' ? 'red' : val === 'warning' ? 'orange' : 'default'}>
|
||||
{val === 'critical' ? '严重' : val === 'warning' ? '警告' : val}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'alert_type',
|
||||
key: 'alert_type',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '消息',
|
||||
dataIndex: 'message',
|
||||
key: 'message',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (_, record) => (
|
||||
record.resolved_at ? (
|
||||
<Tag color="green">已解决</Tag>
|
||||
) : (
|
||||
<Button size="small" type="primary" onClick={() => handleResolveAlert(record.id)}>
|
||||
解决
|
||||
</Button>
|
||||
)
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const unresolvedAlerts = alerts.filter((a) => !a.resolved_at)
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', overflowY: 'auto', padding: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
|
||||
<span style={{ fontSize: 18, fontWeight: 600 }}>Cron 池监控</span>
|
||||
<div style={{ flex: 1 }} />
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void loadData()} loading={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{unresolvedAlerts.length > 0 && (
|
||||
<Alert
|
||||
style={{ marginBottom: 16 }}
|
||||
type="warning"
|
||||
showIcon
|
||||
icon={<WarningOutlined />}
|
||||
message={`有 ${unresolvedAlerts.length} 个未解决的告警`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card size="small" title="池状态" style={{ marginBottom: 16 }}>
|
||||
{status ? (
|
||||
<div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 16 }}>
|
||||
<Statistic title="总数" value={status.total} />
|
||||
<Statistic title="空闲" value={status.idle} valueStyle={{ color: '#52c41a' }} />
|
||||
<Statistic title="忙碌" value={status.busy} valueStyle={{ color: colors.primary }} />
|
||||
<Statistic title="卡死" value={status.stuck} valueStyle={{ color: status.stuck > 0 ? '#ff4d4f' : undefined }} />
|
||||
</div>
|
||||
<Descriptions size="small" column={2}>
|
||||
<Descriptions.Item label="最小池大小">{status.min_size}</Descriptions.Item>
|
||||
<Descriptions.Item label="最大池大小">{status.max_size}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Space>
|
||||
<InputNumber
|
||||
min={status.min_size}
|
||||
max={status.max_size}
|
||||
value={scaleTarget}
|
||||
onChange={(val) => val && setScaleTarget(val)}
|
||||
/>
|
||||
<Button type="primary" onClick={handleScale} loading={scaleBusy}>
|
||||
手动扩缩容
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Empty description="加载中..." />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="监控指标" style={{ marginBottom: 16 }}>
|
||||
{metrics ? (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 16 }}>
|
||||
<Statistic
|
||||
title="池使用率"
|
||||
value={(metrics.pool_utilization * 100).toFixed(1)}
|
||||
suffix="%"
|
||||
valueStyle={{
|
||||
color: metrics.pool_utilization > 0.9 ? '#ff4d4f' : metrics.pool_utilization > 0.7 ? colors.warning : undefined,
|
||||
}}
|
||||
/>
|
||||
<Statistic title="任务积压" value={metrics.task_backlog} />
|
||||
<Statistic
|
||||
title="卡死率"
|
||||
value={(metrics.stuck_rate * 100).toFixed(1)}
|
||||
suffix="%"
|
||||
valueStyle={{ color: metrics.stuck_rate > 0.1 ? '#ff4d4f' : undefined }}
|
||||
/>
|
||||
<Statistic
|
||||
title="平均处理时间"
|
||||
value={metrics.avg_processing_time_seconds ? (metrics.avg_processing_time_seconds / 60).toFixed(1) : '—'}
|
||||
suffix={metrics.avg_processing_time_seconds ? '分钟' : ''}
|
||||
/>
|
||||
<Statistic
|
||||
title="评估完成率"
|
||||
value={(metrics.eval_completion_rate * 100).toFixed(1)}
|
||||
suffix="%"
|
||||
/>
|
||||
<Statistic title="更新时间" value={formatDateTime(metrics.timestamp)} />
|
||||
</div>
|
||||
) : (
|
||||
<Empty description="加载中..." />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="告警历史">
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={alerts}
|
||||
columns={alertColumns}
|
||||
pagination={false}
|
||||
locale={{ emptyText: <Empty description="暂无告警" /> }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user