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 {
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 <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 {
@ -100,6 +114,7 @@ export default function CampaignsPage() {
const [reportLoading, setReportLoading] = useState(false)
const [report, setReport] = useState<CampaignReport | null>(null)
const [reportRuns, setReportRuns] = useState<Run[]>([])
const [reportTimeline, setReportTimeline] = useState<CampaignTimelineEntry[]>([])
const [expandedIds, setExpandedIds] = useState<string[]>([])
const [timelines, setTimelines] = useState<Record<string, CampaignTimelineEntry[]>>({})
@ -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 ? '—' : (
<Progress percent={Math.round(r.pass_rate * 100)} size="small" style={{ width: 100 }}
strokeColor={passRateColor(r.pass_rate)} />
),
// 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) => <code>{id.slice(0, 8)}</code> },
{
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) => (
<Space size={4}>
<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',
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) => (
<span style={{ color: statusColors[r.status] ?? colors.textMuted }}>
{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 <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',
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 && (
<Button icon={<FileMarkdownOutlined />} onClick={() => campaignsApi.downloadReport(report.campaign_id)}>
@ -625,35 +691,87 @@ export default function CampaignsPage() {
<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) }}
<Row gutter={12}>
<Col span={6}>
<StatCard
icon={<RocketOutlined />}
color="blue"
title="子运行"
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 span={8}><Statistic title="可用性" value={fmtPct(report.summary.overall_availability)} /></Col>
</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)}
{' · '}{report.summary.avg_latency_ms == null ? '—' : `${Math.round(report.summary.avg_latency_ms)}ms`}
{' · '} {shortDateTime(report.started_at)}
</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}
<SectionTitle></SectionTitle>
<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}>
<Col span={12}>
<SectionTitle> / </SectionTitle>
<div style={{ border: `1px solid ${colors.border}`, borderRadius: 8, padding: 12 }}>
{trendData.length > 0
? <Line {...trendConfig} />
: <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>
<SectionTitle></SectionTitle>
<Table
rowKey="id"
size="small"