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)') + }, + }]} + > + + + + ({ label: s.name, value: s.id }))} + /> + + + + + + + +
+ {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)') - }, - }]} - > - - - - ({ label: s.name, value: s.id }))} - /> - - - - - - - - {fields.length > 1 && ( - remove(field.name)} style={{ color: colors.textMuted }} /> - )} - - ))} - - - - )} - -
+ {/* 周期报告 */}