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.
144 lines
5.5 KiB
TypeScript
144 lines
5.5 KiB
TypeScript
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>
|
||
)
|
||
}
|