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:
parent
b295f7370d
commit
67a574da18
@ -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<string, string>
|
||||
/** colorKey → legend display name; falls back to the raw key. */
|
||||
colorLabels?: Record<string, string>
|
||||
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)
|
||||
|
||||
@ -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<CampaignReport | null>(null)
|
||||
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 { data, loading, reload } = useResource<CampaignsListsData>(
|
||||
@ -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 <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 (
|
||||
<PageWrapper
|
||||
title="评估活动"
|
||||
@ -357,6 +410,11 @@ export default function CampaignsPage() {
|
||||
dataSource={campaigns}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
expandable={{
|
||||
expandedRowKeys: expandedIds,
|
||||
onExpand: onExpandRow,
|
||||
expandedRowRender: renderCampaignTimeline,
|
||||
}}
|
||||
locale={{ emptyText: <Empty description="还没有评估活动" /> }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user