AgentEvalTool/frontend/web/src/components/CampaignRunTimeline.tsx
sinohqb 3aac5e8068 feat(campaign): drawer create form and scenario-lane run timeline
Create surface moves from a 640px modal to a 920px two-column drawer:
basic info + time/speed on the left, plan preview and a grid-aligned
plan editor (headers, searchable scenario selects, scrollable entries)
on the right, so many-entry plans stay editable. The expanded-row
process timeline switches from a single crowded axis to per-scenario
lanes with a now-line for active campaigns.
2026-08-02 00:58:03 +08:00

144 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
<div>
<div style={{ position: 'relative', height: 16, marginLeft: GUTTER, marginRight: TRACK_RIGHT }}>
{ticks.map((t) => (
<span
key={t}
style={{
position: 'absolute', left: `${t * 100}%`, transform: 'translateX(-50%)',
fontSize: fontSizes.meta, color: colors.textMuted, whiteSpace: 'nowrap',
}}
>
{fmtTick(t * window)}
</span>
))}
</div>
<div style={{ position: 'relative' }}>
{nowPct != null && (
<div style={{
position: 'absolute', top: 0, bottom: 0,
left: GUTTER, right: TRACK_RIGHT, pointerEvents: 'none',
}}>
<div style={{
position: 'absolute', left: `${nowPct}%`, top: -2, bottom: -2,
width: 0, borderLeft: `1px dashed ${colors.primary}`,
}} />
</div>
)}
{lanes.map((lane) => (
<div key={lane.key} style={{ display: 'flex', alignItems: 'center', height: LANE_HEIGHT }}>
<div
title={lane.name}
style={{
width: GUTTER, flexShrink: 0, paddingRight: 10,
fontSize: fontSizes.body, color: colors.text,
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}
>
{lane.name}
</div>
<div style={{ position: 'relative', flex: 1, height: '100%', marginRight: TRACK_RIGHT }}>
<div style={{
position: 'absolute', left: 0, right: 0, top: '50%',
height: 2, marginTop: -1, background: colors.border,
}} />
{lane.items.map((e) => (
<Tooltip
key={e.run_id}
title={
<div style={{ fontSize: 12 }}>
<div>{e.scenario_name} · {statusLabels[e.status] ?? e.status}</div>
<div>{e.pass_rate == null ? '—' : `${(e.pass_rate * 100).toFixed(1)}%`}</div>
<div>{e.avg_latency_ms == null ? '—' : `${Math.round(e.avg_latency_ms)}ms`}</div>
<div>{shortDateTime(e.started_at)}</div>
</div>
}
>
<span
onClick={() => 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',
}}
/>
</Tooltip>
))}
</div>
</div>
))}
</div>
<div style={{
display: 'flex', flexWrap: 'wrap', gap: 12, marginTop: 6,
fontSize: fontSizes.meta, color: colors.textSecondary,
}}>
{presentStatuses.map((s) => (
<span key={s} style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<span style={{
width: 10, height: 10, borderRadius: '50%',
background: statusColors[s] ?? colors.textMuted,
}} />
{statusLabels[s] ?? s}
</span>
))}
{nowPct != null && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<span style={{ width: 0, height: 10, borderLeft: `1px dashed ${colors.primary}` }} />
</span>
)}
<span style={{ marginLeft: 'auto', color: colors.textMuted }}></span>
</div>
</div>
)
}