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 = { 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(null) const [reportRuns, setReportRuns] = useState([]) const targetName = (id: string) => targets.find((t) => t.id === id)?.name ?? id.slice(0, 8) const { data, loading, reload } = useResource( 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 {meta.label} }, }, { 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 ( ) }, }, { title: '操作', key: 'actions', render: (_: unknown, c: CampaignListItem) => ( {isActiveStatus(c.status) && ( cancelCampaign(c.id)}> )} ), }, ] 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 ? '—' : ( ), }, { 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) => {id.slice(0, 8)} }, { title: '场景', key: 'scenario', render: (_: unknown, r: Run) => ( {scenarios.find((s) => s.id === r.scenario_id)?.name ?? r.scenario_id.slice(0, 8)} {r.scenario_version != null && v{r.scenario_version}} ), }, { title: '来源', key: 'trigger', render: (_: unknown, r: Run) => { const t = r.triggered_by ?? '' return {triggerLabels[t] ?? t} }, }, { title: '状态', key: 'status', render: (_: unknown, r: Run) => ( {statusLabels[r.status] ?? r.status} ), }, { 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) => ( ), }, ] return ( } >
}} /> {/* 创建活动 */} setCreateOpen(false)} onOk={submitCreate} confirmLoading={submitting} width={640} destroyOnClose >
活动计划(在窗口的哪些时段跑哪个场景、跑几次)
{ if (!plan || plan.length < 1) return Promise.reject(new Error('至少一条计划条目')) } }]}> {(fields, { add, remove }, { errors }) => ( <> {fields.map((field) => (

子运行(点击下钻单次报告)

}} /> )} ) }