|
| 1 | +import { useCallback, useEffect, useRef, useState, type JSX } from 'react'; |
| 2 | +import clsx from 'clsx'; |
| 3 | +import { Card } from '@/components/Layout/Card'; |
| 4 | +import { ClientLogo } from '@/components/Ethereum/ClientLogo'; |
| 5 | +import { SelectMenu } from '@/components/Forms/SelectMenu'; |
| 6 | +import { Toggle } from '@/components/Forms/Toggle'; |
| 7 | +import type { SlotProgressTimelineProps, TraceSpan } from './SlotProgressTimeline.types'; |
| 8 | +import { SPAN_COLORS } from './constants'; |
| 9 | +import { formatMs, msToPercent } from './utils'; |
| 10 | +import { useTraceSpans } from './useTraceSpans'; |
| 11 | +import { TimelineHeader } from './TimelineHeader'; |
| 12 | +import { TimelineGrid } from './TimelineGrid'; |
| 13 | +import { TimelineTooltip } from './TimelineTooltip'; |
| 14 | +import { TimelineLegend } from './TimelineLegend'; |
| 15 | + |
| 16 | +const ROW_HEIGHT = 28; |
| 17 | +const LABEL_WIDTH = 280; |
| 18 | + |
| 19 | +/** |
| 20 | + * SlotProgressTimeline displays a Jaeger/OTLP-style trace view of slot events. |
| 21 | + * |
| 22 | + * Shows hierarchical spans for: |
| 23 | + * - MEV bidding phase |
| 24 | + * - Block propagation across the network |
| 25 | + * - Block execution (newPayload) on reference nodes |
| 26 | + * - Data availability (individual columns/blobs) |
| 27 | + * - Attestations |
| 28 | + */ |
| 29 | +export function SlotProgressTimeline({ |
| 30 | + slot, |
| 31 | + blockPropagation, |
| 32 | + blobPropagation, |
| 33 | + dataColumnPropagation, |
| 34 | + attestations, |
| 35 | + mevBidding, |
| 36 | + isLoading = false, |
| 37 | + contributor, |
| 38 | + onContributorChange, |
| 39 | +}: SlotProgressTimelineProps): JSX.Element { |
| 40 | + const [hoveredSpan, setHoveredSpan] = useState<TraceSpan | null>(null); |
| 41 | + const [mousePos, setMousePos] = useState<{ x: number; y: number } | null>(null); |
| 42 | + const [collapsedSpans, setCollapsedSpans] = useState<Set<string>>(new Set()); |
| 43 | + const [excludeOutliers, setExcludeOutliers] = useState(true); |
| 44 | + const hasInitializedCollapsed = useRef(false); |
| 45 | + const containerRef = useRef<HTMLDivElement>(null); |
| 46 | + |
| 47 | + // Use contributor from URL params |
| 48 | + const selectedUsername = contributor ?? null; |
| 49 | + const setSelectedUsername = (username: string | null): void => { |
| 50 | + onContributorChange?.(username ?? undefined); |
| 51 | + }; |
| 52 | + |
| 53 | + // Build trace spans from slot data |
| 54 | + const { |
| 55 | + spans, |
| 56 | + availableUsernames, |
| 57 | + isLoading: executionLoading, |
| 58 | + } = useTraceSpans({ |
| 59 | + slot, |
| 60 | + blockPropagation, |
| 61 | + blobPropagation, |
| 62 | + dataColumnPropagation, |
| 63 | + attestations, |
| 64 | + mevBidding, |
| 65 | + selectedUsername, |
| 66 | + excludeOutliers, |
| 67 | + }); |
| 68 | + |
| 69 | + // Helper to find all descendant span IDs |
| 70 | + const getDescendantIds = useCallback((spanId: string, allSpans: TraceSpan[]): string[] => { |
| 71 | + const descendants: string[] = []; |
| 72 | + const findChildren = (parentId: string): void => { |
| 73 | + for (const span of allSpans) { |
| 74 | + if (span.parentId === parentId) { |
| 75 | + descendants.push(span.id); |
| 76 | + findChildren(span.id); |
| 77 | + } |
| 78 | + } |
| 79 | + }; |
| 80 | + findChildren(spanId); |
| 81 | + return descendants; |
| 82 | + }, []); |
| 83 | + |
| 84 | + // Toggle collapse with cascading |
| 85 | + const toggleCollapse = useCallback( |
| 86 | + (spanId: string): void => { |
| 87 | + setCollapsedSpans(prev => { |
| 88 | + const next = new Set(prev); |
| 89 | + if (next.has(spanId)) { |
| 90 | + next.delete(spanId); |
| 91 | + } else { |
| 92 | + next.add(spanId); |
| 93 | + const descendantIds = getDescendantIds(spanId, spans); |
| 94 | + for (const id of descendantIds) { |
| 95 | + next.add(id); |
| 96 | + } |
| 97 | + } |
| 98 | + return next; |
| 99 | + }); |
| 100 | + }, |
| 101 | + [spans, getDescendantIds] |
| 102 | + ); |
| 103 | + |
| 104 | + // Initialize collapsed state from spans' defaultCollapsed property |
| 105 | + useEffect(() => { |
| 106 | + if (!hasInitializedCollapsed.current && spans.length > 1) { |
| 107 | + hasInitializedCollapsed.current = true; |
| 108 | + const initial = new Set<string>(); |
| 109 | + for (const span of spans) { |
| 110 | + if (span.defaultCollapsed && span.collapsible) { |
| 111 | + initial.add(span.id); |
| 112 | + } |
| 113 | + } |
| 114 | + setCollapsedSpans(initial); |
| 115 | + } |
| 116 | + }, [spans]); |
| 117 | + |
| 118 | + // Reset collapsed state when filter changes |
| 119 | + useEffect(() => { |
| 120 | + hasInitializedCollapsed.current = false; |
| 121 | + }, [selectedUsername]); |
| 122 | + |
| 123 | + // Handle mouse events for tooltip |
| 124 | + const handleMouseEnter = useCallback((span: TraceSpan) => { |
| 125 | + setHoveredSpan(span); |
| 126 | + }, []); |
| 127 | + |
| 128 | + const handleMouseLeave = useCallback(() => { |
| 129 | + setHoveredSpan(null); |
| 130 | + setMousePos(null); |
| 131 | + }, []); |
| 132 | + |
| 133 | + const handleMouseMove = useCallback((e: React.MouseEvent) => { |
| 134 | + if (containerRef.current) { |
| 135 | + const rect = containerRef.current.getBoundingClientRect(); |
| 136 | + setMousePos({ x: e.clientX - rect.left, y: e.clientY - rect.top }); |
| 137 | + } |
| 138 | + }, []); |
| 139 | + |
| 140 | + // Loading state |
| 141 | + if (isLoading || executionLoading) { |
| 142 | + return ( |
| 143 | + <Card> |
| 144 | + <div className="mb-4"> |
| 145 | + <h3 className="text-lg font-semibold text-foreground">Slot Trace</h3> |
| 146 | + <p className="text-sm text-muted">Event timeline trace view</p> |
| 147 | + </div> |
| 148 | + <div className="h-64 animate-shimmer rounded-xs bg-linear-to-r from-border/30 via-surface/50 to-border/30 bg-[length:200%_100%]" /> |
| 149 | + </Card> |
| 150 | + ); |
| 151 | + } |
| 152 | + |
| 153 | + // Empty state |
| 154 | + if (spans.length <= 1) { |
| 155 | + return ( |
| 156 | + <Card> |
| 157 | + <div className="mb-4"> |
| 158 | + <h3 className="text-lg font-semibold text-foreground">Slot Trace</h3> |
| 159 | + <p className="text-sm text-muted">Event timeline trace view</p> |
| 160 | + </div> |
| 161 | + <div className="py-8 text-center"> |
| 162 | + <p className="text-muted">No trace data available for this slot</p> |
| 163 | + </div> |
| 164 | + </Card> |
| 165 | + ); |
| 166 | + } |
| 167 | + |
| 168 | + // Build filter options |
| 169 | + const filterOptions = [ |
| 170 | + { value: '', label: 'All nodes' }, |
| 171 | + ...availableUsernames.map(username => ({ value: username, label: username })), |
| 172 | + ]; |
| 173 | + |
| 174 | + // Filter spans by collapsed state |
| 175 | + const visibleSpans = spans.filter(span => !span.parentId || !collapsedSpans.has(span.parentId)); |
| 176 | + |
| 177 | + return ( |
| 178 | + <Card> |
| 179 | + <div ref={containerRef} className="relative"> |
| 180 | + {/* Header */} |
| 181 | + <div className="mb-4 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"> |
| 182 | + <div> |
| 183 | + <h3 className="text-lg font-semibold text-foreground">Slot Trace</h3> |
| 184 | + <p className="text-sm text-muted"> |
| 185 | + Event timeline showing when each phase completed relative to slot start |
| 186 | + </p> |
| 187 | + </div> |
| 188 | + <div className="flex shrink-0 flex-wrap items-center gap-4"> |
| 189 | + <label className="flex cursor-pointer items-center gap-2"> |
| 190 | + <Toggle |
| 191 | + checked={excludeOutliers} |
| 192 | + onChange={setExcludeOutliers} |
| 193 | + srLabel="Exclude outliers from parent spans" |
| 194 | + size="small" |
| 195 | + /> |
| 196 | + <span className="text-xs text-muted">Exclude outliers</span> |
| 197 | + </label> |
| 198 | + {availableUsernames.length > 0 && ( |
| 199 | + <SelectMenu |
| 200 | + label="Filter by contributor" |
| 201 | + options={filterOptions} |
| 202 | + value={selectedUsername ?? ''} |
| 203 | + onChange={value => setSelectedUsername(value || null)} |
| 204 | + className="w-48" |
| 205 | + /> |
| 206 | + )} |
| 207 | + </div> |
| 208 | + </div> |
| 209 | + |
| 210 | + {/* Scrollable timeline container for mobile */} |
| 211 | + <div className="overflow-x-auto"> |
| 212 | + <div style={{ minWidth: 600 }}> |
| 213 | + <TimelineHeader labelWidth={LABEL_WIDTH} /> |
| 214 | + |
| 215 | + {/* Trace Rows */} |
| 216 | + <div className="relative rounded-xs border border-border bg-surface"> |
| 217 | + <TimelineGrid labelWidth={LABEL_WIDTH} /> |
| 218 | + |
| 219 | + {/* Span rows */} |
| 220 | + {visibleSpans.map(span => { |
| 221 | + const colors = SPAN_COLORS[span.category]; |
| 222 | + const startPercent = msToPercent(span.startMs); |
| 223 | + const endPercent = msToPercent(span.endMs); |
| 224 | + const widthPercent = Math.max(endPercent - startPercent, 0.5); |
| 225 | + const isHovered = hoveredSpan?.id === span.id; |
| 226 | + const duration = span.endMs - span.startMs; |
| 227 | + const isCollapsed = collapsedSpans.has(span.id); |
| 228 | + const childCount = spans.filter(s => s.parentId === span.id).length; |
| 229 | + |
| 230 | + return ( |
| 231 | + <div |
| 232 | + key={span.id} |
| 233 | + className={clsx( |
| 234 | + 'relative flex items-center border-b border-border/30 transition-colors', |
| 235 | + isHovered && 'bg-surface/80' |
| 236 | + )} |
| 237 | + style={{ height: ROW_HEIGHT }} |
| 238 | + onMouseEnter={() => handleMouseEnter(span)} |
| 239 | + onMouseLeave={handleMouseLeave} |
| 240 | + onMouseMove={handleMouseMove} |
| 241 | + > |
| 242 | + {/* Label column */} |
| 243 | + <div |
| 244 | + className={clsx( |
| 245 | + 'flex shrink-0 items-center gap-1 px-2 font-mono text-xs', |
| 246 | + span.collapsible && 'cursor-pointer hover:bg-surface/50' |
| 247 | + )} |
| 248 | + style={{ width: LABEL_WIDTH, paddingLeft: 8 + span.depth * 16 }} |
| 249 | + onClick={span.collapsible ? () => toggleCollapse(span.id) : undefined} |
| 250 | + title={span.collapsible ? (isCollapsed ? `Expand (${childCount} items)` : 'Collapse') : undefined} |
| 251 | + > |
| 252 | + {span.collapsible ? ( |
| 253 | + <span className="text-muted transition-colors group-hover:text-foreground"> |
| 254 | + {isCollapsed ? '▶' : '▼'} |
| 255 | + </span> |
| 256 | + ) : ( |
| 257 | + span.depth > 0 && <span className="text-muted">{'└'}</span> |
| 258 | + )} |
| 259 | + {span.clientName && <ClientLogo client={span.clientName} size={14} className="shrink-0" />} |
| 260 | + <span |
| 261 | + className={clsx('truncate', span.isLate ? 'text-danger' : 'text-foreground')} |
| 262 | + title={span.label} |
| 263 | + > |
| 264 | + {span.label} |
| 265 | + {span.collapsible && isCollapsed && <span className="ml-1 text-muted">({childCount})</span>} |
| 266 | + </span> |
| 267 | + </div> |
| 268 | + |
| 269 | + {/* Timeline column */} |
| 270 | + <div className="relative h-full flex-1"> |
| 271 | + <div |
| 272 | + className={clsx( |
| 273 | + 'absolute top-1 rounded-xs transition-all', |
| 274 | + span.isLate ? 'bg-danger/80' : colors.bg, |
| 275 | + isHovered && 'ring-1 ring-white/20 brightness-110', |
| 276 | + span.collapsible && 'cursor-pointer' |
| 277 | + )} |
| 278 | + style={{ |
| 279 | + left: `${startPercent}%`, |
| 280 | + width: `${widthPercent}%`, |
| 281 | + height: ROW_HEIGHT - 8, |
| 282 | + minWidth: 4, |
| 283 | + }} |
| 284 | + title={span.details} |
| 285 | + onClick={span.collapsible ? () => toggleCollapse(span.id) : undefined} |
| 286 | + /> |
| 287 | + </div> |
| 288 | + |
| 289 | + {/* Duration column - show absolute time for point-in-time events */} |
| 290 | + <div |
| 291 | + className={clsx( |
| 292 | + 'shrink-0 px-2 text-right font-mono text-xs', |
| 293 | + span.isLate ? 'text-danger' : colors.text |
| 294 | + )} |
| 295 | + style={{ width: 80 }} |
| 296 | + > |
| 297 | + {span.isPointInTime ? formatMs(span.startMs) : formatMs(duration)} |
| 298 | + </div> |
| 299 | + </div> |
| 300 | + ); |
| 301 | + })} |
| 302 | + </div> |
| 303 | + </div> |
| 304 | + </div> |
| 305 | + |
| 306 | + {/* Floating Tooltip */} |
| 307 | + {hoveredSpan && mousePos && ( |
| 308 | + <TimelineTooltip |
| 309 | + span={hoveredSpan} |
| 310 | + position={mousePos} |
| 311 | + containerWidth={containerRef.current?.clientWidth ?? 400} |
| 312 | + /> |
| 313 | + )} |
| 314 | + </div> |
| 315 | + |
| 316 | + <TimelineLegend /> |
| 317 | + </Card> |
| 318 | + ); |
| 319 | +} |
0 commit comments