feat(campaign): rebuild report drawer with timeline and ranked charts

Report drawer widens to 1040px with a four-card metric row (rate-graded
colours), the embedded scenario-lane process timeline with now-line,
a dual-series pass-rate/availability trend next to a horizontal
capability ranking bar chart, and a filterable/sortable sub-run table
with a latency column. Chart configs move to the charts v2 scale/axis
API — the old yAxis key was dead v1 config.
This commit is contained in:
sinohqb 2026-08-03 00:23:57 +08:00
parent 76ff184cae
commit f08d74fd75

View File

@ -2,14 +2,16 @@ import { useState, type ReactNode } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { import {
Button, Table, Tag, Form, Select, InputNumber, Input, Space, Tooltip, 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' } from 'antd'
import { import {
PlusOutlined, ReloadOutlined, StopOutlined, BarChartOutlined, PlusOutlined, ReloadOutlined, StopOutlined, BarChartOutlined,
FileMarkdownOutlined, MinusCircleOutlined, QuestionCircleOutlined, FileMarkdownOutlined, MinusCircleOutlined, QuestionCircleOutlined,
RocketOutlined, CheckCircleOutlined, SafetyOutlined, ClockCircleOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import { Line } from '@ant-design/charts' import { Line, Bar } from '@ant-design/charts'
import PageWrapper from '../components/PageWrapper' import PageWrapper from '../components/PageWrapper'
import StatCard from '../components/StatCard'
import { import {
campaignsApi, targetsApi, scenariosApi, runsApi, campaignsApi, targetsApi, scenariosApi, runsApi,
type CampaignListItem, type CampaignReport, type Target, type Scenario, type Run, 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' const isActiveStatus = (status: string) => status === 'planned' || status === 'running'
/** How far an active campaign's window has progressed, in window seconds. */ /** 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 if (!isActiveStatus(c.status) || !c.started_at) return null
const elapsed = (Date.now() - toDate(c.started_at).getTime()) / 1000 const elapsed = (Date.now() - toDate(c.started_at).getTime()) / 1000
return Math.min(Math.max(elapsed * c.time_scale, 0), c.window_seconds) 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 }) { function SectionTitle({ children }: { children: ReactNode }) {
return <div style={{ fontWeight: 600, fontSize: 13, margin: '4px 0 12px' }}>{children}</div> return <div style={{ fontWeight: 600, fontSize: 13, margin: '16px 0 12px' }}>{children}</div>
} }
function fmtWindow(seconds: number): string { function fmtWindow(seconds: number): string {
@ -100,6 +114,7 @@ export default function CampaignsPage() {
const [reportLoading, setReportLoading] = useState(false) const [reportLoading, setReportLoading] = useState(false)
const [report, setReport] = useState<CampaignReport | null>(null) const [report, setReport] = useState<CampaignReport | null>(null)
const [reportRuns, setReportRuns] = useState<Run[]>([]) const [reportRuns, setReportRuns] = useState<Run[]>([])
const [reportTimeline, setReportTimeline] = useState<CampaignTimelineEntry[]>([])
const [expandedIds, setExpandedIds] = useState<string[]>([]) const [expandedIds, setExpandedIds] = useState<string[]>([])
const [timelines, setTimelines] = useState<Record<string, CampaignTimelineEntry[]>>({}) const [timelines, setTimelines] = useState<Record<string, CampaignTimelineEntry[]>>({})
@ -200,14 +215,17 @@ export default function CampaignsPage() {
setReportLoading(true) setReportLoading(true)
setReport(null) setReport(null)
setReportRuns([]) setReportRuns([])
setReportTimeline([])
} }
try { try {
const [rep, runs] = await Promise.all([ const [rep, runs, tl] = await Promise.all([
campaignsApi.report(campaignId), campaignsApi.report(campaignId),
runsApi.list(), runsApi.list(),
campaignsApi.timeline(campaignId),
]) ])
setReport(rep.data) setReport(rep.data)
setReportRuns(runs.data.filter((r) => r.campaign_id === campaignId)) setReportRuns(runs.data.filter((r) => r.campaign_id === campaignId))
setReportTimeline(tl.data.entries)
} finally { } finally {
if (!silent) setReportLoading(false) if (!silent) setReportLoading(false)
} }
@ -287,53 +305,81 @@ export default function CampaignsPage() {
}, },
] ]
const trendData = (report?.time_trend ?? []) const trendData = (report?.time_trend ?? []).flatMap((b) => {
.filter((b) => b.pass_rate != null) const slot = `${(b.start_seconds / 3600).toFixed(1)}h`
.map((b) => ({ const rows: { slot: string; value: number; type: string; runs: number }[] = []
slot: `${(b.start_seconds / 3600).toFixed(1)}h`, if (b.pass_rate != null) {
value: Math.round((b.pass_rate as number) * 100), rows.push({ slot, value: Math.round(b.pass_rate * 100), type: '通过率', runs: b.run_count })
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 = { const trendConfig = {
data: trendData, data: trendData,
xField: 'slot', xField: 'slot',
yField: 'value', yField: 'value',
seriesField: 'type',
smooth: true, smooth: true,
height: 220, height: 260,
yAxis: { label: { formatter: (v: string) => `${v}%` }, min: 0, max: 100 }, scale: {
y: { domain: [0, 100] },
color: { domain: ['通过率', '可用性'], range: [colors.primary, '#52c41a'] },
},
axis: { y: { labelFormatter: (v: string) => `${v}%` } },
point: { size: 3 }, point: { size: 3 },
color: colors.primary,
tooltip: { tooltip: {
formatter: (d: { value: number; runs: number }) => ({ formatter: (d: { value: number; runs: number; type: string }) => ({
name: '通过率', value: `${d.value}%${d.runs} 次)`, name: d.type,
value: `${d.value}%${d.runs} 次)`,
}), }),
}, },
} }
const capabilityColumns = [ // Ascending sort: after the transpose the best scenario lands on top.
{ title: '场景', dataIndex: 'scenario_name', key: 'scenario_name' }, const capData = (report?.capability_summary ?? [])
{ title: '运行数', dataIndex: 'run_count', key: 'run_count' }, .filter((c) => c.pass_rate != null)
{ .slice()
title: '通过率', key: 'pass_rate', .sort((a, b) => (a.pass_rate as number) - (b.pass_rate as number))
render: (_: unknown, r: { pass_rate: number | null }) => .map((c) => ({
r.pass_rate == null ? '—' : ( name: c.scenario_name,
<Progress percent={Math.round(r.pass_rate * 100)} size="small" style={{ width: 100 }} value: Math.round((c.pass_rate as number) * 100),
strokeColor={passRateColor(r.pass_rate)} /> 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 = [ const runColumns = [
{ title: '子运行', dataIndex: 'id', key: 'id', render: (id: string) => <code>{id.slice(0, 8)}</code> }, { title: '子运行', dataIndex: 'id', key: 'id', render: (id: string) => <code>{id.slice(0, 8)}</code> },
{ {
title: '场景', key: 'scenario', 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) => ( render: (_: unknown, r: Run) => (
<Space size={4}> <Space size={4}>
<span>{scenarios.find((s) => s.id === r.scenario_id)?.name ?? r.scenario_id.slice(0, 8)}</span> <span>{scenarios.find((s) => s.id === r.scenario_id)?.name ?? r.scenario_id.slice(0, 8)}</span>
@ -350,6 +396,11 @@ export default function CampaignsPage() {
}, },
{ {
title: '状态', key: 'status', 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) => ( render: (_: unknown, r: Run) => (
<span style={{ color: statusColors[r.status] ?? colors.textMuted }}> <span style={{ color: statusColors[r.status] ?? colors.textMuted }}>
{statusLabels[r.status] ?? r.status} {statusLabels[r.status] ?? r.status}
@ -358,12 +409,27 @@ export default function CampaignsPage() {
}, },
{ {
title: '通过率', key: 'pass_rate', title: '通过率', key: 'pass_rate',
sorter: (a: Run, b: Run) => (a.summary?.pass_rate ?? -1) - (b.summary?.pass_rate ?? -1),
render: (_: unknown, r: Run) => { render: (_: unknown, r: Run) => {
const rate = r.summary?.pass_rate const rate = r.summary?.pass_rate
return rate == null ? '—' : fmtPct(rate) if (rate == null) return '—'
return <span style={{ color: passRateColor(rate), fontWeight: 500 }}>{fmtPct(rate)}</span>
}, },
}, },
{ 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', title: '', key: 'drill',
render: (_: unknown, r: Run) => ( render: (_: unknown, r: Run) => (
@ -613,7 +679,7 @@ export default function CampaignsPage() {
title={report ? `活动报告 — ${report.name}` : '活动报告'} title={report ? `活动报告 — ${report.name}` : '活动报告'}
open={reportOpen} open={reportOpen}
onClose={() => setReportOpen(false)} onClose={() => setReportOpen(false)}
width={760} width={1040}
extra={ extra={
report && ( report && (
<Button icon={<FileMarkdownOutlined />} onClick={() => campaignsApi.downloadReport(report.campaign_id)}> <Button icon={<FileMarkdownOutlined />} onClick={() => campaignsApi.downloadReport(report.campaign_id)}>
@ -625,35 +691,87 @@ export default function CampaignsPage() {
<Spin spinning={reportLoading}> <Spin spinning={reportLoading}>
{report && ( {report && (
<> <>
<Row gutter={16}> <Row gutter={12}>
<Col span={8}><Statistic title="子运行" value={`${report.summary.completed_runs}/${report.summary.total_runs}`} /></Col> <Col span={6}>
<Col span={8}> <StatCard
<Statistic icon={<RocketOutlined />}
title="整窗通过率" color="blue"
value={fmtPct(report.summary.overall_pass_rate)} title="子运行"
valueStyle={{ color: report.summary.overall_pass_rate == null ? undefined : passRateColor(report.summary.overall_pass_rate) }} value={`${report.summary.completed_runs}/${report.summary.total_runs}`}
subText={`状态:${CAMPAIGN_STATUS[report.status]?.label ?? report.status}`}
/>
</Col>
<Col span={6}>
<StatCard
icon={<CheckCircleOutlined />}
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 : '%'}
/>
</Col>
<Col span={6}>
<StatCard
icon={<SafetyOutlined />}
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 : '%'}
/>
</Col>
<Col span={6}>
<StatCard
icon={<ClockCircleOutlined />}
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'}
/> />
</Col> </Col>
<Col span={8}><Statistic title="可用性" value={fmtPct(report.summary.overall_availability)} /></Col>
</Row> </Row>
<div style={{ marginTop: 8, color: colors.textSecondary }}> <div style={{ margin: '8px 0 4px', fontSize: 12, color: colors.textSecondary }}>
{fmtWindow(report.window_seconds)} · {acceleratedDuration(report.window_seconds, report.time_scale)} {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)}
</div> </div>
<h4 style={{ marginTop: 24 }}></h4> <SectionTitle></SectionTitle>
{trendData.length > 0 ? <Line {...trendConfig} /> : <Empty description="暂无已完成子运行" />} <div style={{
border: `1px solid ${colors.border}`, borderRadius: 8,
padding: '12px 8px 8px', marginBottom: 16,
}}>
{reportTimeline.length > 0 ? (
<CampaignRunTimeline
windowSeconds={report.window_seconds}
entries={reportTimeline}
nowOffsetSeconds={nowOffsetFor(report)}
/>
) : (
<div style={{ textAlign: 'center', color: colors.textMuted, padding: '12px 0' }}>
</div>
)}
</div>
<h4 style={{ marginTop: 24 }}></h4> <Row gutter={16}>
<Table <Col span={12}>
rowKey="scenario_id" <SectionTitle> / </SectionTitle>
size="small" <div style={{ border: `1px solid ${colors.border}`, borderRadius: 8, padding: 12 }}>
pagination={false} {trendData.length > 0
dataSource={report.capability_summary} ? <Line {...trendConfig} />
columns={capabilityColumns} : <Empty description="暂无已完成子运行" image={Empty.PRESENTED_IMAGE_SIMPLE} />}
/> </div>
</Col>
<Col span={12}>
<SectionTitle></SectionTitle>
<div style={{ border: `1px solid ${colors.border}`, borderRadius: 8, padding: 12 }}>
{capData.length > 0
? <Bar {...capConfig} />
: <Empty description="暂无已完成场景数据" image={Empty.PRESENTED_IMAGE_SIMPLE} />}
</div>
</Col>
</Row>
<h4 style={{ marginTop: 24 }}></h4> <SectionTitle></SectionTitle>
<Table <Table
rowKey="id" rowKey="id"
size="small" size="small"