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 && (
} onClick={() => campaignsApi.downloadReport(report.campaign_id)}>
@@ -625,35 +691,87 @@ export default function CampaignsPage() {
{report && (
<>
-
-
-
-
+
+ }
+ color="blue"
+ title="子运行"
+ value={`${report.summary.completed_runs}/${report.summary.total_runs}`}
+ subText={`状态:${CAMPAIGN_STATUS[report.status]?.label ?? report.status}`}
+ />
+
+
+ }
+ color={rateColorName(report.summary.overall_pass_rate)}
+ title="整窗通过率"
+ value={report.summary.overall_pass_rate == null ? '—' : (report.summary.overall_pass_rate * 100).toFixed(1)}
+ suffix={report.summary.overall_pass_rate == null ? undefined : '%'}
+ />
+
+
+ }
+ color={rateColorName(report.summary.overall_availability)}
+ title="可用性"
+ value={report.summary.overall_availability == null ? '—' : (report.summary.overall_availability * 100).toFixed(1)}
+ suffix={report.summary.overall_availability == null ? undefined : '%'}
+ />
+
+
+ }
+ color="orange"
+ title="平均时延"
+ value={report.summary.avg_latency_ms == null ? '—' : Math.round(report.summary.avg_latency_ms)}
+ suffix={report.summary.avg_latency_ms == null ? undefined : 'ms'}
/>
-
-
+
窗口 {fmtWindow(report.window_seconds)} · {acceleratedDuration(report.window_seconds, report.time_scale)}
- {' · '}平均时延:{report.summary.avg_latency_ms == null ? '—' : `${Math.round(report.summary.avg_latency_ms)}ms`}
+ {' · '}开始于 {shortDateTime(report.started_at)}
-
时间趋势(通过率随窗口时段)
- {trendData.length > 0 ?
:
}
+
过程时间轴
+
+ {reportTimeline.length > 0 ? (
+
+ ) : (
+
+ 暂无子运行
+
+ )}
+
-
能力汇总(按场景)
-
+
+
+ 时间趋势(通过率 / 可用性)
+
+ {trendData.length > 0
+ ?
+ : }
+
+
+
+ 能力排行(按通过率)
+
+ {capData.length > 0
+ ?
+ : }
+
+
+
-
子运行(点击下钻单次报告)
+
子运行