diff --git a/frontend/web/src/components/CampaignRunTimeline.tsx b/frontend/web/src/components/CampaignRunTimeline.tsx
new file mode 100644
index 0000000..edb6207
--- /dev/null
+++ b/frontend/web/src/components/CampaignRunTimeline.tsx
@@ -0,0 +1,143 @@
+import { Tooltip } from 'antd'
+import { useNavigate } from 'react-router-dom'
+import type { CampaignTimelineEntry } from '../api'
+import { colors, fontSizes, statusColors, statusLabels } from '../tokens'
+import { fmtTick } from './WindowTimeline'
+import { shortDateTime } from '../utils/date'
+
+// Expanded-row view of a campaign: one lane per scenario under a shared window
+// axis, each child Run a status-coloured node. For still-active campaigns a
+// dashed line marks how far the window has progressed (nowOffsetSeconds).
+
+interface CampaignRunTimelineProps {
+ windowSeconds: number
+ entries: CampaignTimelineEntry[]
+ nowOffsetSeconds?: number | null
+}
+
+const TICK_COUNT = 6
+const LANE_HEIGHT = 30
+const GUTTER = 140
+const TRACK_RIGHT = 12
+
+export default function CampaignRunTimeline({
+ windowSeconds, entries, nowOffsetSeconds,
+}: CampaignRunTimelineProps) {
+ const navigate = useNavigate()
+ const window = windowSeconds > 0 ? windowSeconds : 1
+ const clampPct = (offset: number) => Math.min(1, Math.max(0, offset / window)) * 100
+
+ // Entries arrive sorted by offset — lanes appear in chronological first-use order.
+ const lanes: { key: string; name: string; items: CampaignTimelineEntry[] }[] = []
+ for (const e of entries) {
+ let lane = lanes.find((l) => l.key === e.scenario_id)
+ if (!lane) {
+ lane = { key: e.scenario_id, name: e.scenario_name, items: [] }
+ lanes.push(lane)
+ }
+ lane.items.push(e)
+ }
+
+ const presentStatuses = Array.from(new Set(entries.map((e) => e.status))).sort()
+ const ticks = Array.from({ length: TICK_COUNT + 1 }, (_, i) => i / TICK_COUNT)
+ const nowPct = nowOffsetSeconds == null ? null : clampPct(nowOffsetSeconds)
+
+ return (
+
+
+ {ticks.map((t) => (
+
+ {fmtTick(t * window)}
+
+ ))}
+
+
+
+ {nowPct != null && (
+
+ )}
+ {lanes.map((lane) => (
+
+
+ {lane.name}
+
+
+
+ {lane.items.map((e) => (
+
+ {e.scenario_name} · {statusLabels[e.status] ?? e.status}
+ 通过率:{e.pass_rate == null ? '—' : `${(e.pass_rate * 100).toFixed(1)}%`}
+ 时延:{e.avg_latency_ms == null ? '—' : `${Math.round(e.avg_latency_ms)}ms`}
+ 开始:{shortDateTime(e.started_at)}
+
+ }
+ >
+
navigate(`/reports?run=${e.run_id}`)}
+ style={{
+ position: 'absolute', left: `${clampPct(e.offset_seconds)}%`, top: '50%',
+ transform: 'translate(-50%, -50%)',
+ width: 12, height: 12, borderRadius: '50%',
+ background: statusColors[e.status] ?? colors.textMuted,
+ border: '2px solid #fff', boxShadow: '0 0 0 1px rgba(0,0,0,0.1)',
+ cursor: 'pointer',
+ }}
+ />
+
+ ))}
+
+
+ ))}
+
+
+
+ {presentStatuses.map((s) => (
+
+
+ {statusLabels[s] ?? s}
+
+ ))}
+ {nowPct != null && (
+
+
+ 当前进度
+
+ )}
+ 点击节点查看单次报告
+
+
+ )
+}
diff --git a/frontend/web/src/components/WindowTimeline.tsx b/frontend/web/src/components/WindowTimeline.tsx
index 873870a..15bf84f 100644
--- a/frontend/web/src/components/WindowTimeline.tsx
+++ b/frontend/web/src/components/WindowTimeline.tsx
@@ -35,7 +35,7 @@ const PALETTE = [
const TICK_COUNT = 6
-function fmtTick(seconds: number): string {
+export function fmtTick(seconds: number): string {
if (seconds < 3600) return `${Math.round(seconds / 60)}m`
const h = seconds / 3600
return `${Number.isInteger(h) ? h : h.toFixed(1)}h`
diff --git a/frontend/web/src/pages/Campaigns.tsx b/frontend/web/src/pages/Campaigns.tsx
index 6593a85..a0c0661 100644
--- a/frontend/web/src/pages/Campaigns.tsx
+++ b/frontend/web/src/pages/Campaigns.tsx
@@ -1,12 +1,12 @@
-import { useState } from 'react'
+import { useState, type ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import {
- Button, Table, Tag, Modal, Form, Select, InputNumber, Input, Space,
+ Button, Table, Tag, Form, Select, InputNumber, Input, Space, Tooltip,
Popconfirm, Drawer, Row, Col, Statistic, Progress, Empty, Spin, message, Switch,
} from 'antd'
import {
PlusOutlined, ReloadOutlined, StopOutlined, BarChartOutlined,
- FileMarkdownOutlined, MinusCircleOutlined,
+ FileMarkdownOutlined, MinusCircleOutlined, QuestionCircleOutlined,
} from '@ant-design/icons'
import { Line } from '@ant-design/charts'
import PageWrapper from '../components/PageWrapper'
@@ -16,9 +16,10 @@ import {
type CampaignTimelineEntry,
} from '../api'
import { passRateColor } from '../utils/colors'
-import { shortDateTime } from '../utils/date'
+import { shortDateTime, toDate } from '../utils/date'
import { deriveTimeScale, acceleratedDuration, formatScale } from '../utils/campaignTime'
import WindowTimeline, { type TimelineMarker } from '../components/WindowTimeline'
+import CampaignRunTimeline from '../components/CampaignRunTimeline'
import { useResource } from '../hooks/useResource'
import { usePolling } from '../hooks/usePolling'
import { useTabStore } from '../stores/tabStore'
@@ -48,6 +49,17 @@ 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 {
+ 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 SectionTitle({ children }: { children: ReactNode }) {
+ return {children}
+}
+
function fmtWindow(seconds: number): string {
if (seconds % 3600 === 0) return `${seconds / 3600}h`
if (seconds % 60 === 0) return `${seconds / 60}m`
@@ -366,26 +378,11 @@ export default function CampaignsPage() {
const entries = timelines[c.id]
if (entries === undefined) return
if (entries.length === 0) return
- const markers: TimelineMarker[] = entries.map((e) => ({
- key: e.run_id,
- offsetSeconds: e.offset_seconds,
- colorKey: e.status,
- tooltip: (
-
-
{e.scenario_name}
-
通过率:{fmtPct(e.pass_rate)}
-
时延:{e.avg_latency_ms == null ? '—' : `${Math.round(e.avg_latency_ms)}ms`}
-
- ),
- onClick: () => navigate(`/reports?run=${e.run_id}`),
- }))
return (
-
)
}
@@ -420,32 +417,46 @@ export default function CampaignsPage() {
{/* 创建活动 */}
- setCreateOpen(false)}
- onOk={submitCreate}
- confirmLoading={submitting}
- width={640}
+ onClose={() => setCreateOpen(false)}
+ width={920}
destroyOnClose
+ footer={
+
+
+
+
+ }
>
-
-
-
-
-
-
-
+
+
+ 基本信息
+
+
+
+
+
+
+ 时间与速度
+
-
-
+
+
+ {
+ if (form.getFieldValue('realtime')) return
+ if (!v || v <= 0) throw new Error('请输入目标耗时')
+ const unit = (form.getFieldValue('target_unit') as number | undefined) ?? 60
+ const win = (form.getFieldValue('window_seconds') as number | undefined) ?? 0
+ if (v * unit > win) throw new Error('目标耗时不能超过窗口长度(否则倍速 < 1)')
+ },
+ }]}
+ >
+
+
+
+
+
+
+
+
+ {wRealtime
+ ? `实时模式:按真实墙钟推进,计划贯穿整个 ${fmtWindow(wWindow)} 窗口`
+ : (
+ <>
+ 窗口 {fmtWindow(wWindow)} · 倍速 {formatScale(derivedScale)}
+ {' · '}{acceleratedDuration(wWindow, derivedScale)}
+ >
+ )}
+
+
+
+
+ 计划预览
+
+ {planMarkers.length > 0 ? (
+
+ ) : (
+
+ 选择场景并设置开始时间后,将在此按时间轴预览计划
+
+ )}
+
+
+ 计划条目
+
+ 在窗口的哪些时间点跑哪个场景、跑几次
+
+
+ 场景
+
+ 开始时间{' '}
+
+
+
+
+ 执行次数
+
+
+ { if (!plan || plan.length < 1) return Promise.reject(new Error('至少一条计划条目')) } }]}>
+ {(fields, { add, remove }, { errors }) => (
+ <>
+
+ {fields.map((field) => (
+
+
+
+
+
+
+
+
+
+
+ {fields.length > 1 && (
+ remove(field.name)} style={{ color: colors.textMuted }} />
+ )}
+
+
+ ))}
+
+
+
+ >
+ )}
+
-
-
-
- {
- if (form.getFieldValue('realtime')) return
- if (!v || v <= 0) throw new Error('请输入目标耗时')
- const unit = (form.getFieldValue('target_unit') as number | undefined) ?? 60
- const win = (form.getFieldValue('window_seconds') as number | undefined) ?? 0
- if (v * unit > win) throw new Error('目标耗时不能超过窗口长度(否则倍速 < 1)')
- },
- }]}
- >
-
-
-
-
-
-
-
-
-
- 倍速 {wRealtime ? '×1(实时)' : formatScale(derivedScale)}
- {' · '}
- {acceleratedDuration(wWindow, derivedScale)}
-
-
- 计划预览
-
- {planMarkers.length > 0 ? (
-
- ) : (
-
- 选择场景并设置偏移后,将在此按时间轴预览计划
-
- )}
-
-
- 活动计划(在窗口的哪些时段跑哪个场景、跑几次)
- { if (!plan || plan.length < 1) return Promise.reject(new Error('至少一条计划条目')) } }]}>
- {(fields, { add, remove }, { errors }) => (
- <>
- {fields.map((field) => (
-
-
-
-
-
-
-
-
-
- {fields.length > 1 && (
- remove(field.name)} style={{ color: colors.textMuted }} />
- )}
-
- ))}
-
-
- >
- )}
-
-
+
{/* 周期报告 */}