Some checks failed
CI / test (push) Failing after 1m10s
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).
458 lines
17 KiB
TypeScript
458 lines
17 KiB
TypeScript
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>
|
||
)
|
||
}
|