AgentEvalTool/frontend/web/src/pages/Campaigns.tsx
sinohqb 050c674ee2
Some checks failed
CI / test (push) Failing after 1m10s
refactor(frontend): extract useResource/usePolling shared hooks
Seven pages repeated the same load-on-mount + loading + try/finally +
reload-button skeleton, each re-implementing tab-active refresh, silent
polling, and (in two places) a hand-rolled requestId race guard. Extract two
composable hooks: useResource(fetcher, {tabPath, deps}) owning data/loading/
reload with a built-in race guard and auto tab-active refresh, and
usePolling(fn, ms, enabled) replacing the hand-written setInterval effects.
Migrate all seven pages onto them; Targets/Scenarios/ModelConfigs also gain a
uniform tab-active refresh they previously lacked. Verified via tsc --noEmit
and npm run build (no frontend test runner exists).
2026-07-31 11:01:24 +08:00

458 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Button, Table, Tag, Modal, Form, Select, InputNumber, Input, Space,
Popconfirm, Drawer, Row, Col, Statistic, Progress, Empty, Spin, message,
} from 'antd'
import {
PlusOutlined, ReloadOutlined, StopOutlined, BarChartOutlined,
FileMarkdownOutlined, MinusCircleOutlined,
} from '@ant-design/icons'
import { Line } from '@ant-design/charts'
import PageWrapper from '../components/PageWrapper'
import {
campaignsApi, targetsApi, scenariosApi, runsApi,
type CampaignListItem, type CampaignReport, type Target, type Scenario, type Run,
} from '../api'
import { passRateColor } from '../utils/colors'
import { shortDateTime } from '../utils/date'
import { useResource } from '../hooks/useResource'
import { usePolling } from '../hooks/usePolling'
import { useTabStore } from '../stores/tabStore'
import { colors, statusColors, statusLabels, triggerColors, triggerLabels } from '../tokens'
const CAMPAIGN_STATUS: Record<string, { label: string; color: string }> = {
planned: { label: '计划中', color: 'default' },
running: { label: '进行中', color: 'processing' },
completed: { label: '已完成', color: 'success' },
cancelled: { label: '已取消', color: 'default' },
failed: { label: '失败', color: 'error' },
}
const WINDOW_OPTIONS = [6, 12, 24, 48, 72].map((h) => ({ label: `${h} 小时`, value: h * 3600 }))
const POLL_INTERVAL_MS = 5000
const isActiveStatus = (status: string) => status === 'planned' || status === 'running'
function fmtWindow(seconds: number): string {
if (seconds % 3600 === 0) return `${seconds / 3600}h`
if (seconds % 60 === 0) return `${seconds / 60}m`
return `${seconds}s`
}
function fmtPct(v: number | null | undefined): string {
return v == null ? '—' : `${(v * 100).toFixed(1)}%`
}
interface PlanFormEntry {
scenario_id?: string
offset_hours?: number
count?: number
}
interface CampaignsListsData {
campaigns: CampaignListItem[]
targets: Target[]
scenarios: Scenario[]
}
export default function CampaignsPage() {
const navigate = useNavigate()
const activeKey = useTabStore((s) => s.activeKey)
const [createOpen, setCreateOpen] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [form] = Form.useForm()
const [reportOpen, setReportOpen] = useState(false)
const [reportLoading, setReportLoading] = useState(false)
const [report, setReport] = useState<CampaignReport | null>(null)
const [reportRuns, setReportRuns] = useState<Run[]>([])
const targetName = (id: string) => targets.find((t) => t.id === id)?.name ?? id.slice(0, 8)
const { data, loading, reload } = useResource<CampaignsListsData>(
async () => {
const [c, t, s] = await Promise.all([campaignsApi.list(), targetsApi.list(), scenariosApi.list()])
return { campaigns: c.data, targets: t.data, scenarios: s.data }
},
{ tabPath: '/campaigns' },
)
const campaigns = data?.campaigns ?? []
const targets = data?.targets ?? []
const scenarios = data?.scenarios ?? []
// Poll the list while this tab is active and a campaign is still working —
// compressed dev-line campaigns change fast. Stop once all are terminal.
const hasActiveCampaign = campaigns.some((c) => isActiveStatus(c.status))
usePolling(
() => void reload(true),
POLL_INTERVAL_MS,
activeKey === '/campaigns' && hasActiveCampaign,
)
const openCreate = () => {
form.setFieldsValue({
name: '', target_id: undefined, window_seconds: 24 * 3600, time_scale: 1,
plan: [{ scenario_id: undefined, offset_hours: 0, count: 1 }],
})
setCreateOpen(true)
}
const submitCreate = async () => {
const values = await form.validateFields()
setSubmitting(true)
try {
await campaignsApi.create({
name: values.name,
target_id: values.target_id,
window_seconds: values.window_seconds,
time_scale: values.time_scale,
plan: (values.plan as PlanFormEntry[]).map((e) => ({
scenario_id: e.scenario_id as string,
offset_seconds: Math.round((e.offset_hours ?? 0) * 3600),
count: e.count ?? 1,
})),
})
message.success('评估活动已创建并开始调度')
setCreateOpen(false)
reload()
} finally {
setSubmitting(false)
}
}
const cancelCampaign = async (id: string) => {
await campaignsApi.cancel(id)
message.success('活动已取消')
reload()
}
const fetchReport = async (campaignId: string, silent = false) => {
if (!silent) {
setReportLoading(true)
setReport(null)
setReportRuns([])
}
try {
const [rep, runs] = await Promise.all([
campaignsApi.report(campaignId),
runsApi.list(),
])
setReport(rep.data)
setReportRuns(runs.data.filter((r) => r.campaign_id === campaignId))
} finally {
if (!silent) setReportLoading(false)
}
}
const openReport = (campaign: CampaignListItem) => {
setReportOpen(true)
fetchReport(campaign.id)
}
// Keep the open report drawer live while its campaign is still running.
const reportId = report?.campaign_id ?? null
const reportCampaignActive = campaigns.some(
(c) => c.id === reportId && isActiveStatus(c.status),
)
usePolling(
() => { if (reportId) void fetchReport(reportId, true) },
POLL_INTERVAL_MS,
activeKey === '/campaigns' && reportOpen && !!reportId && reportCampaignActive,
)
const columns = [
{ title: '名称', dataIndex: 'name', key: 'name' },
{ title: '评测对象', key: 'target', render: (_: unknown, c: CampaignListItem) => targetName(c.target_id) },
{ title: '窗口', key: 'window', render: (_: unknown, c: CampaignListItem) => fmtWindow(c.window_seconds) },
{
title: '倍速', key: 'scale',
render: (_: unknown, c: CampaignListItem) => (c.time_scale === 1 ? '实时' : `×${c.time_scale}`),
},
{
title: '状态', key: 'status',
render: (_: unknown, c: CampaignListItem) => {
const meta = CAMPAIGN_STATUS[c.status] ?? { label: c.status, color: 'default' }
return <Tag color={meta.color}>{meta.label}</Tag>
},
},
{
title: '进度', key: 'progress',
render: (_: unknown, c: CampaignListItem) => `${c.progress.completed_runs}/${c.progress.planned_total}`,
},
{
title: '整窗通过率', key: 'pass_rate',
render: (_: unknown, c: CampaignListItem) => {
const rate = c.progress.overall_pass_rate
if (rate == null) return '—'
return (
<Progress
percent={Math.round(rate * 100)} size="small" style={{ width: 120 }}
strokeColor={passRateColor(rate)}
/>
)
},
},
{
title: '操作', key: 'actions',
render: (_: unknown, c: CampaignListItem) => (
<Space>
<Button size="small" icon={<BarChartOutlined />} onClick={() => openReport(c)}></Button>
{isActiveStatus(c.status) && (
<Popconfirm title="取消该活动?已完成的子运行会保留。" onConfirm={() => cancelCampaign(c.id)}>
<Button size="small" danger icon={<StopOutlined />}></Button>
</Popconfirm>
)}
</Space>
),
},
]
const trendData = (report?.time_trend ?? [])
.filter((b) => b.pass_rate != null)
.map((b) => ({
slot: `${(b.start_seconds / 3600).toFixed(1)}h`,
value: Math.round((b.pass_rate as number) * 100),
runs: b.run_count,
}))
const trendConfig = {
data: trendData,
xField: 'slot',
yField: 'value',
smooth: true,
height: 220,
yAxis: { label: { formatter: (v: string) => `${v}%` }, min: 0, max: 100 },
point: { size: 3 },
color: colors.primary,
tooltip: {
formatter: (d: { value: number; runs: number }) => ({
name: '通过率', value: `${d.value}%${d.runs} 次)`,
}),
},
}
const capabilityColumns = [
{ title: '场景', dataIndex: 'scenario_name', key: 'scenario_name' },
{ title: '运行数', dataIndex: 'run_count', key: 'run_count' },
{
title: '通过率', key: 'pass_rate',
render: (_: unknown, r: { pass_rate: number | null }) =>
r.pass_rate == null ? '—' : (
<Progress percent={Math.round(r.pass_rate * 100)} size="small" style={{ width: 100 }}
strokeColor={passRateColor(r.pass_rate)} />
),
},
{ title: '可用性', key: 'availability', render: (_: unknown, r: { availability: number | null }) => fmtPct(r.availability) },
{
title: '时延', key: 'latency',
render: (_: unknown, r: { avg_latency_ms: number | null }) =>
r.avg_latency_ms == null ? '—' : `${Math.round(r.avg_latency_ms)}ms`,
},
]
const runColumns = [
{ title: '子运行', dataIndex: 'id', key: 'id', render: (id: string) => <code>{id.slice(0, 8)}</code> },
{
title: '场景', key: 'scenario',
render: (_: unknown, r: Run) => (
<Space size={4}>
<span>{scenarios.find((s) => s.id === r.scenario_id)?.name ?? r.scenario_id.slice(0, 8)}</span>
{r.scenario_version != null && <Tag>v{r.scenario_version}</Tag>}
</Space>
),
},
{
title: '来源', key: 'trigger',
render: (_: unknown, r: Run) => {
const t = r.triggered_by ?? ''
return <Tag color={triggerColors[t] ?? 'default'}>{triggerLabels[t] ?? t}</Tag>
},
},
{
title: '状态', key: 'status',
render: (_: unknown, r: Run) => (
<span style={{ color: statusColors[r.status] ?? colors.textMuted }}>
{statusLabels[r.status] ?? r.status}
</span>
),
},
{
title: '通过率', key: 'pass_rate',
render: (_: unknown, r: Run) => {
const rate = r.summary?.pass_rate
return rate == null ? '—' : fmtPct(rate)
},
},
{ title: '时间', key: 'time', render: (_: unknown, r: Run) => shortDateTime(r.started_at) },
{
title: '', key: 'drill',
render: (_: unknown, r: Run) => (
<Button size="small" type="link" onClick={() => navigate(`/reports?run=${r.id}`)}></Button>
),
},
]
return (
<PageWrapper
title="评估活动"
description="在一个服务周期窗口内按计划持续评测同一对象,聚合成周期报告"
inline
fullHeight
extra={
<Space>
<Button icon={<ReloadOutlined />} onClick={() => reload()} />
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}></Button>
</Space>
}
>
<div style={{ height: '100%', overflowY: 'auto', padding: '0 16px 16px' }}>
<Table
rowKey="id"
loading={loading}
dataSource={campaigns}
columns={columns}
pagination={false}
locale={{ emptyText: <Empty description="还没有评估活动" /> }}
/>
</div>
{/* 创建活动 */}
<Modal
title="新建评估活动"
open={createOpen}
onCancel={() => setCreateOpen(false)}
onOk={submitCreate}
confirmLoading={submitting}
width={640}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="活动名称" rules={[{ required: true, message: '请输入名称' }]}>
<Input placeholder="如:数字员工 24h 服务周期" />
</Form.Item>
<Form.Item name="target_id" label="评测对象" rules={[{ required: true, message: '请选择对象' }]}>
<Select
placeholder="选择评测对象"
options={targets.map((t) => ({ label: t.name, value: t.id }))}
/>
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="window_seconds" label="窗口长度" rules={[{ required: true }]}>
<Select options={WINDOW_OPTIONS} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="time_scale"
label="时间倍速"
tooltip="1=真实墙钟(正式线);开发线可设大倍速把窗口压缩成几分钟"
rules={[{ required: true }]}
>
<InputNumber min={0.001} step={1} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<div style={{ marginBottom: 8, color: colors.textSecondary }}></div>
<Form.List name="plan" rules={[{ validator: async (_, plan) => { if (!plan || plan.length < 1) return Promise.reject(new Error('至少一条计划条目')) } }]}>
{(fields, { add, remove }, { errors }) => (
<>
{fields.map((field) => (
<Space key={field.key} align="baseline" style={{ display: 'flex', marginBottom: 8 }}>
<Form.Item
name={[field.name, 'scenario_id']}
rules={[{ required: true, message: '选择场景' }]}
style={{ marginBottom: 0 }}
>
<Select
placeholder="场景"
style={{ width: 220 }}
options={scenarios.map((s) => ({ label: s.name, value: s.id }))}
/>
</Form.Item>
<Form.Item name={[field.name, 'offset_hours']} style={{ marginBottom: 0 }}>
<InputNumber min={0} step={0.5} addonAfter="h偏移" style={{ width: 130 }} />
</Form.Item>
<Form.Item name={[field.name, 'count']} style={{ marginBottom: 0 }}>
<InputNumber min={1} addonAfter="次" style={{ width: 100 }} />
</Form.Item>
{fields.length > 1 && (
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ color: colors.textMuted }} />
)}
</Space>
))}
<Button type="dashed" onClick={() => add({ scenario_id: undefined, offset_hours: 0, count: 1 })} block icon={<PlusOutlined />}>
</Button>
<Form.ErrorList errors={errors} />
</>
)}
</Form.List>
</Form>
</Modal>
{/* 周期报告 */}
<Drawer
title={report ? `活动报告 — ${report.name}` : '活动报告'}
open={reportOpen}
onClose={() => setReportOpen(false)}
width={760}
extra={
report && (
<Button icon={<FileMarkdownOutlined />} onClick={() => campaignsApi.downloadReport(report.campaign_id)}>
Markdown
</Button>
)
}
>
<Spin spinning={reportLoading}>
{report && (
<>
<Row gutter={16}>
<Col span={8}><Statistic title="子运行" value={`${report.summary.completed_runs}/${report.summary.total_runs}`} /></Col>
<Col span={8}>
<Statistic
title="整窗通过率"
value={fmtPct(report.summary.overall_pass_rate)}
valueStyle={{ color: report.summary.overall_pass_rate == null ? undefined : passRateColor(report.summary.overall_pass_rate) }}
/>
</Col>
<Col span={8}><Statistic title="可用性" value={fmtPct(report.summary.overall_availability)} /></Col>
</Row>
<div style={{ marginTop: 8, color: colors.textSecondary }}>
{report.summary.avg_latency_ms == null ? '—' : `${Math.round(report.summary.avg_latency_ms)}ms`}
</div>
<h4 style={{ marginTop: 24 }}></h4>
{trendData.length > 0 ? <Line {...trendConfig} /> : <Empty description="暂无已完成子运行" />}
<h4 style={{ marginTop: 24 }}></h4>
<Table
rowKey="scenario_id"
size="small"
pagination={false}
dataSource={report.capability_summary}
columns={capabilityColumns}
/>
<h4 style={{ marginTop: 24 }}></h4>
<Table
rowKey="id"
size="small"
pagination={false}
dataSource={reportRuns}
columns={runColumns}
locale={{ emptyText: <Empty description="暂无子运行" /> }}
/>
</>
)}
</Spin>
</Drawer>
</PageWrapper>
)
}