feat(campaigns): add Campaign management page with dual-axis report
Register a keep-alive "评估活动" tab that creates campaigns (target, window, time_scale, static plan), lists them with live progress and pass-rate, and opens a report drawer with a time-trend line, capability summary, and drill-down into child Runs.
This commit is contained in:
parent
f433ebb970
commit
c82532398b
@ -11,6 +11,7 @@ import {
|
||||
RobotOutlined,
|
||||
FolderOpenOutlined,
|
||||
CloudServerOutlined,
|
||||
ScheduleOutlined,
|
||||
LogoutOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import TabBar from './components/TabBar'
|
||||
@ -28,6 +29,7 @@ const TargetsPage = lazy(() => import('./pages/Targets'))
|
||||
const ScenariosPage = lazy(() => import('./pages/Scenarios'))
|
||||
const RunsPage = lazy(() => import('./pages/Runs'))
|
||||
const ReportsPage = lazy(() => import('./pages/Reports'))
|
||||
const CampaignsPage = lazy(() => import('./pages/Campaigns'))
|
||||
const OpenClawPage = lazy(() => import('./pages/OpenClaw'))
|
||||
const FilesPage = lazy(() => import('./pages/Files'))
|
||||
const ModelConfigsPage = lazy(() => import('./pages/ModelConfigs'))
|
||||
@ -57,6 +59,7 @@ const routeConfigs: RouteConfig[] = [
|
||||
{ path: '/models', name: '模型配置', icon: <CloudServerOutlined />, component: () => <PageLoader><ModelConfigsPage /></PageLoader> },
|
||||
{ path: '/scenarios', name: '评测场景', icon: <FileTextOutlined />, component: () => <PageLoader><ScenariosPage /></PageLoader> },
|
||||
{ path: '/runs', name: '评测执行', icon: <PlayCircleOutlined />, component: () => <PageLoader><RunsPage /></PageLoader> },
|
||||
{ path: '/campaigns', name: '评估活动', icon: <ScheduleOutlined />, component: () => <PageLoader><CampaignsPage /></PageLoader> },
|
||||
{ path: '/reports', name: '评测报告', icon: <BarChartOutlined />, component: () => <PageLoader><ReportsPage /></PageLoader> },
|
||||
{ path: '/openclaw', name: 'AI 助手', icon: <RobotOutlined />, component: () => <PageLoader><OpenClawPage /></PageLoader> },
|
||||
{ path: '/files', name: '原始文件', icon: <FolderOpenOutlined />, component: () => <PageLoader><FilesPage /></PageLoader> },
|
||||
|
||||
@ -142,6 +142,7 @@ export interface Run {
|
||||
target_id: string
|
||||
scenario_id: string
|
||||
scenario_version?: number
|
||||
campaign_id?: string | null
|
||||
status: string
|
||||
triggered_by?: RunTrigger
|
||||
scenario_name?: string | null
|
||||
@ -283,6 +284,98 @@ export const statsApi = {
|
||||
trend: (days?: number) => api.get<TrendPoint[]>('/stats/trend', { params: { days } }),
|
||||
}
|
||||
|
||||
// ── Campaigns(评估活动) ──────────────────────────────────────────
|
||||
|
||||
export interface CampaignPlanEntry {
|
||||
scenario_id: string
|
||||
offset_seconds: number
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface CampaignProgress {
|
||||
current_offset_seconds: number
|
||||
spawned_runs: number
|
||||
completed_runs: number
|
||||
}
|
||||
|
||||
export interface Campaign {
|
||||
id: string
|
||||
name: string
|
||||
target_id: string
|
||||
window_seconds: number
|
||||
time_scale: number
|
||||
plan: CampaignPlanEntry[]
|
||||
status: string
|
||||
started_at: string | null
|
||||
completed_at: string | null
|
||||
summary: Record<string, unknown> | null
|
||||
progress?: CampaignProgress
|
||||
}
|
||||
|
||||
export interface CampaignTrendBucket {
|
||||
bucket_index: number
|
||||
start_seconds: number
|
||||
end_seconds: number
|
||||
run_count: number
|
||||
pass_rate: number | null
|
||||
availability: number | null
|
||||
avg_latency_ms: number | null
|
||||
}
|
||||
|
||||
export interface CampaignCapability {
|
||||
scenario_id: string
|
||||
scenario_name: string
|
||||
run_count: number
|
||||
pass_rate: number | null
|
||||
availability: number | null
|
||||
avg_latency_ms: number | null
|
||||
}
|
||||
|
||||
export interface CampaignReport {
|
||||
campaign_id: string
|
||||
name: string
|
||||
target_id: string
|
||||
status: string
|
||||
window_seconds: number
|
||||
time_scale: number
|
||||
started_at: string | null
|
||||
completed_at: string | null
|
||||
summary: {
|
||||
total_runs: number
|
||||
completed_runs: number
|
||||
overall_pass_rate: number | null
|
||||
overall_availability: number | null
|
||||
avg_latency_ms: number | null
|
||||
}
|
||||
time_trend: CampaignTrendBucket[]
|
||||
capability_summary: CampaignCapability[]
|
||||
}
|
||||
|
||||
export interface CreateCampaignPayload {
|
||||
name: string
|
||||
target_id: string
|
||||
window_seconds: number
|
||||
time_scale: number
|
||||
plan: CampaignPlanEntry[]
|
||||
}
|
||||
|
||||
export const campaignsApi = {
|
||||
list: () => api.get<Campaign[]>('/campaigns'),
|
||||
get: (id: string) => api.get<Campaign>(`/campaigns/${id}`),
|
||||
create: (data: CreateCampaignPayload) => api.post<Campaign>('/campaigns', data),
|
||||
cancel: (id: string) => api.post<Campaign>(`/campaigns/${id}/cancel`),
|
||||
report: (id: string) => api.get<CampaignReport>(`/campaigns/${id}/report`),
|
||||
downloadReport: async (id: string) => {
|
||||
const res = await api.get(`/campaigns/${id}/report/markdown`, { responseType: 'blob' })
|
||||
const url = URL.createObjectURL(res.data as Blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `campaign-report-${id.slice(0, 8)}.md`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
},
|
||||
}
|
||||
|
||||
// ── File Management ──────────────────────────────────────────────
|
||||
|
||||
export interface FileCategory {
|
||||
|
||||
431
frontend/web/src/pages/Campaigns.tsx
Normal file
431
frontend/web/src/pages/Campaigns.tsx
Normal file
@ -0,0 +1,431 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Button, Table, Tag, Modal, Form, Select, InputNumber, Input, Space,
|
||||
Popconfirm, Drawer, Row, Col, Statistic, Progress, Empty, Spin, message,
|
||||
} from 'antd'
|
||||
import {
|
||||
PlusOutlined, ReloadOutlined, StopOutlined, BarChartOutlined,
|
||||
FileMarkdownOutlined, MinusCircleOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Line } from '@ant-design/charts'
|
||||
import PageWrapper from '../components/PageWrapper'
|
||||
import {
|
||||
campaignsApi, targetsApi, scenariosApi, runsApi,
|
||||
type Campaign, type CampaignReport, type Target, type Scenario, type Run,
|
||||
} from '../api'
|
||||
import { passRateColor } from '../utils/colors'
|
||||
import { shortDateTime } from '../utils/date'
|
||||
import { useOnTabActive } from '../hooks/useOnTabActive'
|
||||
import { colors, statusColors, statusLabels } from '../tokens'
|
||||
|
||||
const CAMPAIGN_STATUS: Record<string, { label: string; color: string }> = {
|
||||
planned: { label: '计划中', color: 'default' },
|
||||
running: { label: '进行中', color: 'processing' },
|
||||
completed: { label: '已完成', color: 'success' },
|
||||
cancelled: { label: '已取消', color: 'default' },
|
||||
failed: { label: '失败', color: 'error' },
|
||||
}
|
||||
|
||||
const WINDOW_OPTIONS = [6, 12, 24, 48, 72].map((h) => ({ label: `${h} 小时`, value: h * 3600 }))
|
||||
|
||||
function fmtWindow(seconds: number): string {
|
||||
if (seconds % 3600 === 0) return `${seconds / 3600}h`
|
||||
if (seconds % 60 === 0) return `${seconds / 60}m`
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
function fmtPct(v: number | null | undefined): string {
|
||||
return v == null ? '—' : `${(v * 100).toFixed(1)}%`
|
||||
}
|
||||
|
||||
interface PlanFormEntry {
|
||||
scenario_id?: string
|
||||
offset_hours?: number
|
||||
count?: number
|
||||
}
|
||||
|
||||
export default function CampaignsPage() {
|
||||
const navigate = useNavigate()
|
||||
const [campaigns, setCampaigns] = useState<Campaign[]>([])
|
||||
const [targets, setTargets] = useState<Target[]>([])
|
||||
const [scenarios, setScenarios] = useState<Scenario[]>([])
|
||||
const [reportMap, setReportMap] = useState<Record<string, CampaignReport>>({})
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const [reportOpen, setReportOpen] = useState(false)
|
||||
const [reportLoading, setReportLoading] = useState(false)
|
||||
const [report, setReport] = useState<CampaignReport | null>(null)
|
||||
const [reportRuns, setReportRuns] = useState<Run[]>([])
|
||||
|
||||
const targetName = (id: string) => targets.find((t) => t.id === id)?.name ?? id.slice(0, 8)
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [c, t, s] = await Promise.all([
|
||||
campaignsApi.list(), targetsApi.list(), scenariosApi.list(),
|
||||
])
|
||||
setCampaigns(c.data)
|
||||
setTargets(t.data)
|
||||
setScenarios(s.data)
|
||||
// Fetch each campaign's aggregate report so the list can show progress
|
||||
// and overall pass_rate at a glance (activity counts are small).
|
||||
const reports = await Promise.all(
|
||||
c.data.map((camp) =>
|
||||
campaignsApi.report(camp.id).then((r) => r.data).catch(() => null),
|
||||
),
|
||||
)
|
||||
const map: Record<string, CampaignReport> = {}
|
||||
reports.forEach((r) => { if (r) map[r.campaign_id] = r })
|
||||
setReportMap(map)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { loadData() }, [])
|
||||
useOnTabActive('/campaigns', loadData)
|
||||
|
||||
const openCreate = () => {
|
||||
form.setFieldsValue({
|
||||
name: '', target_id: undefined, window_seconds: 24 * 3600, time_scale: 1,
|
||||
plan: [{ scenario_id: undefined, offset_hours: 0, count: 1 }],
|
||||
})
|
||||
setCreateOpen(true)
|
||||
}
|
||||
|
||||
const submitCreate = async () => {
|
||||
const values = await form.validateFields()
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await campaignsApi.create({
|
||||
name: values.name,
|
||||
target_id: values.target_id,
|
||||
window_seconds: values.window_seconds,
|
||||
time_scale: values.time_scale,
|
||||
plan: (values.plan as PlanFormEntry[]).map((e) => ({
|
||||
scenario_id: e.scenario_id as string,
|
||||
offset_seconds: Math.round((e.offset_hours ?? 0) * 3600),
|
||||
count: e.count ?? 1,
|
||||
})),
|
||||
})
|
||||
message.success('评估活动已创建并开始调度')
|
||||
setCreateOpen(false)
|
||||
loadData()
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const cancelCampaign = async (id: string) => {
|
||||
await campaignsApi.cancel(id)
|
||||
message.success('活动已取消')
|
||||
loadData()
|
||||
}
|
||||
|
||||
const openReport = async (campaign: Campaign) => {
|
||||
setReportOpen(true)
|
||||
setReportLoading(true)
|
||||
setReport(null)
|
||||
setReportRuns([])
|
||||
try {
|
||||
const [rep, runs] = await Promise.all([
|
||||
campaignsApi.report(campaign.id),
|
||||
runsApi.list(),
|
||||
])
|
||||
setReport(rep.data)
|
||||
setReportRuns(runs.data.filter((r) => r.campaign_id === campaign.id))
|
||||
} finally {
|
||||
setReportLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: '名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '评测对象', key: 'target', render: (_: unknown, c: Campaign) => targetName(c.target_id) },
|
||||
{ title: '窗口', key: 'window', render: (_: unknown, c: Campaign) => fmtWindow(c.window_seconds) },
|
||||
{
|
||||
title: '倍速', key: 'scale',
|
||||
render: (_: unknown, c: Campaign) => (c.time_scale === 1 ? '实时' : `×${c.time_scale}`),
|
||||
},
|
||||
{
|
||||
title: '状态', key: 'status',
|
||||
render: (_: unknown, c: Campaign) => {
|
||||
const meta = CAMPAIGN_STATUS[c.status] ?? { label: c.status, color: 'default' }
|
||||
return <Tag color={meta.color}>{meta.label}</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '进度', key: 'progress',
|
||||
render: (_: unknown, c: Campaign) => {
|
||||
const rep = reportMap[c.id]
|
||||
if (!rep) return '—'
|
||||
return `${rep.summary.completed_runs}/${rep.summary.total_runs}`
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '整窗通过率', key: 'pass_rate',
|
||||
render: (_: unknown, c: Campaign) => {
|
||||
const rate = reportMap[c.id]?.summary.overall_pass_rate
|
||||
if (rate == null) return '—'
|
||||
return (
|
||||
<Progress
|
||||
percent={Math.round(rate * 100)} size="small" style={{ width: 120 }}
|
||||
strokeColor={passRateColor(rate)}
|
||||
/>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'actions',
|
||||
render: (_: unknown, c: Campaign) => (
|
||||
<Space>
|
||||
<Button size="small" icon={<BarChartOutlined />} onClick={() => openReport(c)}>报告</Button>
|
||||
{(c.status === 'planned' || c.status === 'running') && (
|
||||
<Popconfirm title="取消该活动?已完成的子运行会保留。" onConfirm={() => cancelCampaign(c.id)}>
|
||||
<Button size="small" danger icon={<StopOutlined />}>取消</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
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 trendConfig = {
|
||||
data: trendData,
|
||||
xField: 'slot',
|
||||
yField: 'value',
|
||||
smooth: true,
|
||||
height: 220,
|
||||
yAxis: { label: { formatter: (v: string) => `${v}%` }, min: 0, max: 100 },
|
||||
point: { size: 3 },
|
||||
color: colors.primary,
|
||||
tooltip: {
|
||||
formatter: (d: { value: number; runs: number }) => ({
|
||||
name: '通过率', 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)} />
|
||||
),
|
||||
},
|
||||
{ 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',
|
||||
render: (_: unknown, r: Run) => scenarios.find((s) => s.id === r.scenario_id)?.name ?? r.scenario_id.slice(0, 8),
|
||||
},
|
||||
{
|
||||
title: '状态', key: 'status',
|
||||
render: (_: unknown, r: Run) => (
|
||||
<span style={{ color: statusColors[r.status] ?? colors.textMuted }}>
|
||||
{statusLabels[r.status] ?? r.status}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '通过率', key: 'pass_rate',
|
||||
render: (_: unknown, r: Run) => {
|
||||
const rate = (r.summary as { pass_rate?: number } | null)?.pass_rate
|
||||
return rate == null ? '—' : fmtPct(rate)
|
||||
},
|
||||
},
|
||||
{ title: '时间', key: 'time', render: (_: unknown, r: Run) => shortDateTime(r.started_at) },
|
||||
{
|
||||
title: '', key: 'drill',
|
||||
render: (_: unknown, r: Run) => (
|
||||
<Button size="small" type="link" onClick={() => navigate(`/reports?run=${r.id}`)}>查看报告</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<PageWrapper
|
||||
title="评估活动"
|
||||
description="在一个服务周期窗口内按计划持续评测同一对象,聚合成周期报告"
|
||||
inline
|
||||
fullHeight
|
||||
extra={
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={loadData} />
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>新建活动</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<div style={{ height: '100%', overflowY: 'auto', padding: '0 16px 16px' }}>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={campaigns}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
locale={{ emptyText: <Empty description="还没有评估活动" /> }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 创建活动 */}
|
||||
<Modal
|
||||
title="新建评估活动"
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={submitCreate}
|
||||
confirmLoading={submitting}
|
||||
width={640}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="活动名称" rules={[{ required: true, message: '请输入名称' }]}>
|
||||
<Input placeholder="如:数字员工 24h 服务周期" />
|
||||
</Form.Item>
|
||||
<Form.Item name="target_id" label="评测对象" rules={[{ required: true, message: '请选择对象' }]}>
|
||||
<Select
|
||||
placeholder="选择评测对象"
|
||||
options={targets.map((t) => ({ label: t.name, value: t.id }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="window_seconds" label="窗口长度" rules={[{ required: true }]}>
|
||||
<Select options={WINDOW_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="time_scale"
|
||||
label="时间倍速"
|
||||
tooltip="1=真实墙钟(正式线);开发线可设大倍速把窗口压缩成几分钟"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<InputNumber min={0.001} step={1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<div style={{ marginBottom: 8, color: colors.textSecondary }}>活动计划(在窗口的哪些时段跑哪个场景、跑几次)</div>
|
||||
<Form.List name="plan" rules={[{ validator: async (_, plan) => { if (!plan || plan.length < 1) return Promise.reject(new Error('至少一条计划条目')) } }]}>
|
||||
{(fields, { add, remove }, { errors }) => (
|
||||
<>
|
||||
{fields.map((field) => (
|
||||
<Space key={field.key} align="baseline" style={{ display: 'flex', marginBottom: 8 }}>
|
||||
<Form.Item
|
||||
name={[field.name, 'scenario_id']}
|
||||
rules={[{ required: true, message: '选择场景' }]}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Select
|
||||
placeholder="场景"
|
||||
style={{ width: 220 }}
|
||||
options={scenarios.map((s) => ({ label: s.name, value: s.id }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name={[field.name, 'offset_hours']} style={{ marginBottom: 0 }}>
|
||||
<InputNumber min={0} step={0.5} addonAfter="h偏移" style={{ width: 130 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name={[field.name, 'count']} style={{ marginBottom: 0 }}>
|
||||
<InputNumber min={1} addonAfter="次" style={{ width: 100 }} />
|
||||
</Form.Item>
|
||||
{fields.length > 1 && (
|
||||
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ color: colors.textMuted }} />
|
||||
)}
|
||||
</Space>
|
||||
))}
|
||||
<Button type="dashed" onClick={() => add({ scenario_id: undefined, offset_hours: 0, count: 1 })} block icon={<PlusOutlined />}>
|
||||
添加计划条目
|
||||
</Button>
|
||||
<Form.ErrorList errors={errors} />
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 周期报告 */}
|
||||
<Drawer
|
||||
title={report ? `活动报告 — ${report.name}` : '活动报告'}
|
||||
open={reportOpen}
|
||||
onClose={() => setReportOpen(false)}
|
||||
width={760}
|
||||
extra={
|
||||
report && (
|
||||
<Button icon={<FileMarkdownOutlined />} onClick={() => campaignsApi.downloadReport(report.campaign_id)}>
|
||||
导出 Markdown
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<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) }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={8}><Statistic title="可用性" value={fmtPct(report.summary.overall_availability)} /></Col>
|
||||
</Row>
|
||||
<div style={{ marginTop: 8, color: colors.textSecondary }}>
|
||||
平均时延:{report.summary.avg_latency_ms == null ? '—' : `${Math.round(report.summary.avg_latency_ms)}ms`}
|
||||
</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}
|
||||
/>
|
||||
|
||||
<h4 style={{ marginTop: 24 }}>子运行(点击下钻单次报告)</h4>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={reportRuns}
|
||||
columns={runColumns}
|
||||
locale={{ emptyText: <Empty description="暂无子运行" /> }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Spin>
|
||||
</Drawer>
|
||||
</PageWrapper>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user