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:
parent
76ff184cae
commit
f08d74fd75
@ -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"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user