All checks were successful
CI / test (push) Successful in 3m57s
- usePolling gains a pauseWhenHidden option (default true): the timer pauses while the document is hidden (background tab / minimised) and resumes with an immediate reload on returning to visible. Callers keep owning their initial fetch, so no duplicate first request. - CronPoolMonitor now delegates its 5s poll to the shared usePolling hook (initial load stays in its own useEffect); removes the bespoke setInterval + manual visibility gap. - CronPoolMonitor.test: visibility guard now passes (3/3), removing the previous it.fails. Frontend suite: 18/18; tsc clean; backend 878. Resolves scan §6.3.
221 lines
7.4 KiB
TypeScript
221 lines
7.4 KiB
TypeScript
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>
|
|
)
|
|
}
|