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)
This commit is contained in:
sinohqb 2026-07-31 17:03:10 +08:00
parent b295f7370d
commit 67a574da18
2 changed files with 63 additions and 2 deletions

View File

@ -20,6 +20,8 @@ export interface TimelineMarker {
interface WindowTimelineProps { interface WindowTimelineProps {
windowSeconds: number windowSeconds: number
markers: TimelineMarker[] markers: TimelineMarker[]
/** colorKey → explicit colour; keys not present fall back to the auto palette. */
colorMap?: Record<string, string>
/** colorKey → legend display name; falls back to the raw key. */ /** colorKey → legend display name; falls back to the raw key. */
colorLabels?: Record<string, string> colorLabels?: Record<string, string>
showLegend?: boolean showLegend?: boolean
@ -40,11 +42,12 @@ function fmtTick(seconds: number): string {
} }
export default function WindowTimeline({ export default function WindowTimeline({
windowSeconds, markers, colorLabels, showLegend = false, height = 64, windowSeconds, markers, colorMap, colorLabels, showLegend = false, height = 64,
}: WindowTimelineProps) { }: WindowTimelineProps) {
const window = windowSeconds > 0 ? windowSeconds : 1 const window = windowSeconds > 0 ? windowSeconds : 1
const uniqueKeys = Array.from(new Set(markers.map((m) => m.colorKey))).sort() 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 leftPct = (offset: number) => `${Math.min(1, Math.max(0, offset / window)) * 100}%`
const ticks = Array.from({ length: TICK_COUNT + 1 }, (_, i) => i / TICK_COUNT) const ticks = Array.from({ length: TICK_COUNT + 1 }, (_, i) => i / TICK_COUNT)

View File

@ -13,6 +13,7 @@ import PageWrapper from '../components/PageWrapper'
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,
type CampaignTimelineEntry,
} from '../api' } from '../api'
import { passRateColor } from '../utils/colors' import { passRateColor } from '../utils/colors'
import { shortDateTime } from '../utils/date' import { shortDateTime } from '../utils/date'
@ -88,6 +89,9 @@ export default function CampaignsPage() {
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 [expandedIds, setExpandedIds] = useState<string[]>([])
const [timelines, setTimelines] = useState<Record<string, CampaignTimelineEntry[]>>({})
const targetName = (id: string) => targets.find((t) => t.id === id)?.name ?? id.slice(0, 8) const targetName = (id: string) => targets.find((t) => t.id === id)?.name ?? id.slice(0, 8)
const { data, loading, reload } = useResource<CampaignsListsData>( const { data, loading, reload } = useResource<CampaignsListsData>(
@ -171,6 +175,16 @@ export default function CampaignsPage() {
reload() 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) => { const fetchReport = async (campaignId: string, silent = false) => {
if (!silent) { if (!silent) {
setReportLoading(true) setReportLoading(true)
@ -205,6 +219,17 @@ export default function CampaignsPage() {
activeKey === '/campaigns' && reportOpen && !!reportId && reportCampaignActive, 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 = [ const columns = [
{ title: '名称', dataIndex: 'name', key: 'name' }, { title: '名称', dataIndex: 'name', key: 'name' },
{ title: '评测对象', key: 'target', render: (_: unknown, c: CampaignListItem) => targetName(c.target_id) }, { 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 <Spin />
if (entries.length === 0) return <Empty description="暂无子运行" image={Empty.PRESENTED_IMAGE_SIMPLE} />
const markers: TimelineMarker[] = entries.map((e) => ({
key: e.run_id,
offsetSeconds: e.offset_seconds,
colorKey: e.status,
tooltip: (
<div style={{ fontSize: 12 }}>
<div>{e.scenario_name}</div>
<div>{fmtPct(e.pass_rate)}</div>
<div>{e.avg_latency_ms == null ? '—' : `${Math.round(e.avg_latency_ms)}ms`}</div>
</div>
),
onClick: () => navigate(`/reports?run=${e.run_id}`),
}))
return (
<WindowTimeline
windowSeconds={c.window_seconds}
markers={markers}
colorMap={statusColors}
colorLabels={statusLabels}
showLegend
/>
)
}
return ( return (
<PageWrapper <PageWrapper
title="评估活动" title="评估活动"
@ -357,6 +410,11 @@ export default function CampaignsPage() {
dataSource={campaigns} dataSource={campaigns}
columns={columns} columns={columns}
pagination={false} pagination={false}
expandable={{
expandedRowKeys: expandedIds,
onExpand: onExpandRow,
expandedRowRender: renderCampaignTimeline,
}}
locale={{ emptyText: <Empty description="还没有评估活动" /> }} locale={{ emptyText: <Empty description="还没有评估活动" /> }}
/> />
</div> </div>