feat(intelligent-eval): add cron pool monitoring UI (ticket 10)

- Add openclawCronPoolApi with all cron pool API calls
- Add CronPoolMonitor page with pool status, metrics, and alerts
- Implement real-time refresh (5 second polling)
- Implement manual scaling with target size input
- Add alert history table with resolve functionality
- Add route /cron-pool for cron pool monitoring page
- Pass TypeScript type checking

All 853 tests passing.
This commit is contained in:
sinohqb 2026-08-12 11:05:51 +08:00
parent ee639afb0d
commit 244feae505
3 changed files with 269 additions and 0 deletions

View File

@ -38,6 +38,7 @@ const OpenClawPage = lazy(() => import('./pages/OpenClaw'))
const FilesPage = lazy(() => import('./pages/Files'))
const ModelConfigsPage = lazy(() => import('./pages/ModelConfigs'))
const IntelligentEvalsPage = lazy(() => import('./pages/IntelligentEvals'))
const CronPoolMonitorPage = lazy(() => import('./pages/CronPoolMonitor'))
function PageLoader({ children }: { children: ReactNode }) {
return (
@ -67,6 +68,7 @@ const routeConfigs: RouteConfig[] = [
{ path: '/campaigns', name: '评估活动', icon: <ScheduleOutlined />, component: () => <PageLoader><CampaignsPage /></PageLoader> },
{ path: '/reports', name: '评测报告', icon: <BarChartOutlined />, component: () => <PageLoader><ReportsPage /></PageLoader> },
{ path: '/intelligent-evals', name: '智能评估', icon: <BulbOutlined />, component: () => <PageLoader><IntelligentEvalsPage /></PageLoader> },
{ path: '/cron-pool', name: 'Cron 池监控', icon: <DashboardOutlined />, component: () => <PageLoader><CronPoolMonitorPage /></PageLoader> },
{ path: '/models', name: '模型配置', icon: <CloudServerOutlined />, component: () => <PageLoader><ModelConfigsPage /></PageLoader> },
{ path: '/files', name: '原始文件', icon: <FolderOpenOutlined />, component: () => <PageLoader><FilesPage /></PageLoader> },
]

View File

@ -792,6 +792,59 @@ export const intelligentEvalsApi = {
api.get<{ logs: DecisionLog[] }>(`/intelligent-evals/${id}/decision-logs`),
}
// ── OpenClaw Cron Pool ──────────────────────────────────────────────
export interface CronPoolStatus {
total: number
idle: number
busy: number
stuck: number
min_size: number
max_size: number
}
export interface CronPoolMetrics {
pool_utilization: number
task_backlog: number
stuck_rate: number
avg_processing_time_seconds: number | null
eval_completion_rate: number
timestamp: string
}
export interface CronPoolAlert {
id: string
alert_type: string
severity: string
message: string
metric_value: number
threshold: number
created_at: string
resolved_at: string | null
webhook_sent: boolean
}
export const openclawCronPoolApi = {
getStatus: () => api.get<{ pool: CronPoolStatus }>('/openclaw/cron-pool'),
scale: (targetSize: number) =>
api.post<{ success: boolean; scaled_up?: number; scaled_down?: number; current_size: number }>(
'/openclaw/cron-pool/scale',
{ target_size: targetSize },
),
sync: () => api.post<{ success: boolean; synced: number }>('/openclaw/cron-pool/sync'),
autoScale: () =>
api.post<{ success: boolean; scaled_up: number; scaled_down: number }>('/openclaw/cron-pool/auto-scale'),
getMetrics: () => api.get<{ metrics: CronPoolMetrics }>('/openclaw/cron-pool/metrics'),
checkAlerts: () =>
api.post<{ success: boolean; alerts_triggered: number; alerts: CronPoolAlert[] }>('/openclaw/cron-pool/check-alerts'),
getAlerts: (limit = 100, unresolvedOnly = false) =>
api.get<{ alerts: CronPoolAlert[] }>('/openclaw/cron-pool/alerts', {
params: { limit, unresolved_only: unresolvedOnly },
}),
resolveAlert: (alertId: string) =>
api.post<{ success: boolean }>(`/openclaw/cron-pool/alerts/${alertId}/resolve`),
}
// ── File Management ──────────────────────────────────────────────
export interface FileCategory {

View File

@ -0,0 +1,214 @@
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 { 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)
}
}
useEffect(() => {
void loadData()
const interval = setInterval(() => void loadData(), 5000)
return () => clearInterval(interval)
}, [])
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>
)
}