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 {
|
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)
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user