diff --git a/frontend/web/src/pages/Campaigns.tsx b/frontend/web/src/pages/Campaigns.tsx index fb87ca3..5ccefcb 100644 --- a/frontend/web/src/pages/Campaigns.tsx +++ b/frontend/web/src/pages/Campaigns.tsx @@ -2,14 +2,16 @@ import { useState, type ReactNode } from 'react' import { useNavigate } from 'react-router-dom' import { Button, Table, Tag, Form, Select, InputNumber, Input, Space, Tooltip, - Popconfirm, Drawer, Row, Col, Statistic, Progress, Empty, Spin, message, Switch, + Popconfirm, Drawer, Row, Col, Progress, Empty, Spin, message, Switch, } from 'antd' import { PlusOutlined, ReloadOutlined, StopOutlined, BarChartOutlined, FileMarkdownOutlined, MinusCircleOutlined, QuestionCircleOutlined, + RocketOutlined, CheckCircleOutlined, SafetyOutlined, ClockCircleOutlined, } from '@ant-design/icons' -import { Line } from '@ant-design/charts' +import { Line, Bar } from '@ant-design/charts' import PageWrapper from '../components/PageWrapper' +import StatCard from '../components/StatCard' import { campaignsApi, targetsApi, scenariosApi, runsApi, type CampaignListItem, type CampaignReport, type Target, type Scenario, type Run, @@ -50,14 +52,26 @@ const POLL_INTERVAL_MS = 5000 const isActiveStatus = (status: string) => status === 'planned' || status === 'running' /** How far an active campaign's window has progressed, in window seconds. */ -function nowOffsetFor(c: CampaignListItem): number | null { +function nowOffsetFor(c: { + status: string + started_at: string | null + time_scale: number + window_seconds: number +}): number | null { if (!isActiveStatus(c.status) || !c.started_at) return null const elapsed = (Date.now() - toDate(c.started_at).getTime()) / 1000 return Math.min(Math.max(elapsed * c.time_scale, 0), c.window_seconds) } +function rateColorName(rate: number | null): 'blue' | 'green' | 'orange' | 'red' { + if (rate == null) return 'blue' + if (rate >= 0.8) return 'green' + if (rate >= 0.6) return 'orange' + return 'red' +} + function SectionTitle({ children }: { children: ReactNode }) { - return
{children}
+ return
{children}
} function fmtWindow(seconds: number): string { @@ -100,6 +114,7 @@ export default function CampaignsPage() { const [reportLoading, setReportLoading] = useState(false) const [report, setReport] = useState(null) const [reportRuns, setReportRuns] = useState([]) + const [reportTimeline, setReportTimeline] = useState([]) const [expandedIds, setExpandedIds] = useState([]) const [timelines, setTimelines] = useState>({}) @@ -200,14 +215,17 @@ export default function CampaignsPage() { setReportLoading(true) setReport(null) setReportRuns([]) + setReportTimeline([]) } try { - const [rep, runs] = await Promise.all([ + const [rep, runs, tl] = await Promise.all([ campaignsApi.report(campaignId), runsApi.list(), + campaignsApi.timeline(campaignId), ]) setReport(rep.data) setReportRuns(runs.data.filter((r) => r.campaign_id === campaignId)) + setReportTimeline(tl.data.entries) } finally { if (!silent) setReportLoading(false) } @@ -287,53 +305,81 @@ export default function CampaignsPage() { }, ] - 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 trendData = (report?.time_trend ?? []).flatMap((b) => { + const slot = `${(b.start_seconds / 3600).toFixed(1)}h` + const rows: { slot: string; value: number; type: string; runs: number }[] = [] + if (b.pass_rate != null) { + rows.push({ slot, value: Math.round(b.pass_rate * 100), type: '通过率', runs: b.run_count }) + } + if (b.availability != null) { + rows.push({ slot, value: Math.round(b.availability * 100), type: '可用性', runs: b.run_count }) + } + return rows + }) const trendConfig = { data: trendData, xField: 'slot', yField: 'value', + seriesField: 'type', smooth: true, - height: 220, - yAxis: { label: { formatter: (v: string) => `${v}%` }, min: 0, max: 100 }, + height: 260, + scale: { + y: { domain: [0, 100] }, + color: { domain: ['通过率', '可用性'], range: [colors.primary, '#52c41a'] }, + }, + axis: { y: { labelFormatter: (v: string) => `${v}%` } }, point: { size: 3 }, - color: colors.primary, tooltip: { - formatter: (d: { value: number; runs: number }) => ({ - name: '通过率', value: `${d.value}%(${d.runs} 次)`, + formatter: (d: { value: number; runs: number; type: string }) => ({ + name: d.type, + 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 ? '—' : ( - - ), + // Ascending sort: after the transpose the best scenario lands on top. + const capData = (report?.capability_summary ?? []) + .filter((c) => c.pass_rate != null) + .slice() + .sort((a, b) => (a.pass_rate as number) - (b.pass_rate as number)) + .map((c) => ({ + name: c.scenario_name, + value: Math.round((c.pass_rate as number) * 100), + runs: c.run_count, + availability: c.availability, + latency: c.avg_latency_ms, + })) + + const capConfig = { + data: capData, + xField: 'name', + yField: 'value', + coordinate: { transform: [{ type: 'transpose' }] }, + height: 260, + color: colors.primary, + scale: { y: { domain: [0, 100] } }, + axis: { y: { labelFormatter: (v: string) => `${v}%` } }, + tooltip: { + formatter: (d: { + name: string; value: number; runs: number + availability: number | null; latency: number | null + }) => ({ + name: d.name, + value: `${d.value}% · ${d.runs} 次 · 可用性 ${fmtPct(d.availability)} · 时延 ${d.latency == null ? '—' : `${Math.round(d.latency)}ms`}`, + }), }, - { 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', + filters: Array.from(new Set(reportRuns.map((r) => r.scenario_id))).map((id) => ({ + text: scenarios.find((s) => s.id === id)?.name ?? id.slice(0, 8), + value: id, + })), + onFilter: (v: React.Key | boolean, r: Run) => r.scenario_id === v, render: (_: unknown, r: Run) => ( {scenarios.find((s) => s.id === r.scenario_id)?.name ?? r.scenario_id.slice(0, 8)} @@ -350,6 +396,11 @@ export default function CampaignsPage() { }, { title: '状态', key: 'status', + filters: Array.from(new Set(reportRuns.map((r) => r.status))).map((s) => ({ + text: statusLabels[s] ?? s, + value: s, + })), + onFilter: (v: React.Key | boolean, r: Run) => r.status === v, render: (_: unknown, r: Run) => ( {statusLabels[r.status] ?? r.status} @@ -358,12 +409,27 @@ export default function CampaignsPage() { }, { title: '通过率', key: 'pass_rate', + sorter: (a: Run, b: Run) => (a.summary?.pass_rate ?? -1) - (b.summary?.pass_rate ?? -1), render: (_: unknown, r: Run) => { const rate = r.summary?.pass_rate - return rate == null ? '—' : fmtPct(rate) + if (rate == null) return '—' + return {fmtPct(rate)} }, }, - { title: '时间', key: 'time', render: (_: unknown, r: Run) => shortDateTime(r.started_at) }, + { + title: '时延', key: 'latency', + sorter: (a: Run, b: Run) => (a.summary?.avg_latency_ms ?? -1) - (b.summary?.avg_latency_ms ?? -1), + render: (_: unknown, r: Run) => { + const ms = r.summary?.avg_latency_ms + return ms == null ? '—' : `${Math.round(ms)}ms` + }, + }, + { + title: '时间', key: 'time', + sorter: (a: Run, b: Run) => (a.started_at ?? '').localeCompare(b.started_at ?? ''), + defaultSortOrder: 'descend' as const, + render: (_: unknown, r: Run) => shortDateTime(r.started_at), + }, { title: '', key: 'drill', render: (_: unknown, r: Run) => ( @@ -613,7 +679,7 @@ export default function CampaignsPage() { title={report ? `活动报告 — ${report.name}` : '活动报告'} open={reportOpen} onClose={() => setReportOpen(false)} - width={760} + width={1040} extra={ report && (