From 67a574da18c7a1724f02a030bb69a4acb97beda4 Mon Sep 17 00:00:00 2001 From: sinohqb Date: Fri, 31 Jul 2026 17:03:10 +0800 Subject: [PATCH] feat(campaign): expandable row process timeline Each campaign row expands to a per-Run process timeline: on expand it fetches GET /campaigns/{id}/timeline and places each child Run on the shared WindowTimeline by accelerated window offset, coloured by run status, with scenario/pass-rate/latency tooltips and click-through to the run report. Running campaigns refresh on the existing 5s poll; terminal ones fetch once. WindowTimeline gains a colorMap prop for semantic status colours. (v0.6 ticket 10) --- .../web/src/components/WindowTimeline.tsx | 7 ++- frontend/web/src/pages/Campaigns.tsx | 58 +++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/frontend/web/src/components/WindowTimeline.tsx b/frontend/web/src/components/WindowTimeline.tsx index 91a20ca..873870a 100644 --- a/frontend/web/src/components/WindowTimeline.tsx +++ b/frontend/web/src/components/WindowTimeline.tsx @@ -20,6 +20,8 @@ export interface TimelineMarker { interface WindowTimelineProps { windowSeconds: number markers: TimelineMarker[] + /** colorKey → explicit colour; keys not present fall back to the auto palette. */ + colorMap?: Record /** colorKey → legend display name; falls back to the raw key. */ colorLabels?: Record showLegend?: boolean @@ -40,11 +42,12 @@ function fmtTick(seconds: number): string { } export default function WindowTimeline({ - windowSeconds, markers, colorLabels, showLegend = false, height = 64, + windowSeconds, markers, colorMap, colorLabels, showLegend = false, height = 64, }: WindowTimelineProps) { const window = windowSeconds > 0 ? windowSeconds : 1 const uniqueKeys = Array.from(new Set(markers.map((m) => m.colorKey))).sort() - const colorOf = (key: string) => PALETTE[Math.max(0, uniqueKeys.indexOf(key)) % PALETTE.length] + const colorOf = (key: string) => + colorMap?.[key] ?? PALETTE[Math.max(0, uniqueKeys.indexOf(key)) % PALETTE.length] const leftPct = (offset: number) => `${Math.min(1, Math.max(0, offset / window)) * 100}%` const ticks = Array.from({ length: TICK_COUNT + 1 }, (_, i) => i / TICK_COUNT) diff --git a/frontend/web/src/pages/Campaigns.tsx b/frontend/web/src/pages/Campaigns.tsx index 0a45ffa..6593a85 100644 --- a/frontend/web/src/pages/Campaigns.tsx +++ b/frontend/web/src/pages/Campaigns.tsx @@ -13,6 +13,7 @@ import PageWrapper from '../components/PageWrapper' import { campaignsApi, targetsApi, scenariosApi, runsApi, type CampaignListItem, type CampaignReport, type Target, type Scenario, type Run, + type CampaignTimelineEntry, } from '../api' import { passRateColor } from '../utils/colors' import { shortDateTime } from '../utils/date' @@ -88,6 +89,9 @@ export default function CampaignsPage() { const [report, setReport] = useState(null) const [reportRuns, setReportRuns] = useState([]) + const [expandedIds, setExpandedIds] = useState([]) + const [timelines, setTimelines] = useState>({}) + const targetName = (id: string) => targets.find((t) => t.id === id)?.name ?? id.slice(0, 8) const { data, loading, reload } = useResource( @@ -171,6 +175,16 @@ export default function CampaignsPage() { reload() } + const fetchTimeline = async (campaignId: string) => { + const res = await campaignsApi.timeline(campaignId) + setTimelines((prev) => ({ ...prev, [campaignId]: res.data.entries })) + } + + const onExpandRow = (expanded: boolean, record: CampaignListItem) => { + setExpandedIds((prev) => (expanded ? [...prev, record.id] : prev.filter((x) => x !== record.id))) + if (expanded) void fetchTimeline(record.id) + } + const fetchReport = async (campaignId: string, silent = false) => { if (!silent) { setReportLoading(true) @@ -205,6 +219,17 @@ export default function CampaignsPage() { activeKey === '/campaigns' && reportOpen && !!reportId && reportCampaignActive, ) + // Grow the expanded timeline of any still-running campaign as new child Runs + // spawn. Completed/cancelled campaigns are fetched once on expand. + const activeExpandedIds = expandedIds.filter( + (id) => campaigns.some((c) => c.id === id && isActiveStatus(c.status)), + ) + usePolling( + () => { activeExpandedIds.forEach((id) => void fetchTimeline(id)) }, + POLL_INTERVAL_MS, + activeKey === '/campaigns' && activeExpandedIds.length > 0, + ) + const columns = [ { title: '名称', dataIndex: 'name', key: 'name' }, { title: '评测对象', key: 'target', render: (_: unknown, c: CampaignListItem) => targetName(c.target_id) }, @@ -337,6 +362,34 @@ export default function CampaignsPage() { }, ] + const renderCampaignTimeline = (c: CampaignListItem) => { + 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 ( + + ) + } + return ( }} />