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:
parent
ee639afb0d
commit
244feae505
@ -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> },
|
||||
]
|
||||
|
||||
@ -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 {
|
||||
|
||||
214
frontend/web/src/pages/CronPoolMonitor.tsx
Normal file
214
frontend/web/src/pages/CronPoolMonitor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user