Skip to content

Commit b95af3d

Browse files
authored
refactor: redesign SlotProgressTimeline as Jaeger-style trace visualization (#377)
Break down the 1390-line monolithic component into 11 focused modules with clear separation of concerns. New architecture: - SlotProgressTimeline.tsx (319 lines): Main component & state orchestration - useTraceSpans.ts (919 lines): All span-building logic extracted into custom hook - TimelineHeader, TimelineGrid, TimelineLegend: Reusable UI components - TimelineTooltip, TimelineRow: Extracted sub-components for future optimization - constants.ts, utils.ts, types.ts: Shared utilities and type definitions Features: - Exclude outliers toggle (default: ON) for cleaner parent span calculations - Point-in-time display: individual node observations now show absolute time instead of fake durations - Contributor filtering with URL parameter persistence - Outlier detection via IQR-based statistical analysis - Classification-based node coloring (internal=teal, individual=violet) Add Timeline tab to slot detail page with integrated contributor filter that persists to URL. Always fetch blob and data column data (handles Fulu fork edge cases).
1 parent 0673db5 commit b95af3d

File tree

14 files changed

+1790
-12
lines changed

14 files changed

+1790
-12
lines changed

src/pages/ethereum/slots/DetailPage.tsx

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { type JSX, useEffect } from 'react';
2-
import { useParams, useNavigate, Link } from '@tanstack/react-router';
1+
import { type JSX, useCallback, useEffect } from 'react';
2+
import { useParams, useNavigate, useSearch, Link } from '@tanstack/react-router';
33
import { useQuery } from '@tanstack/react-query';
44
import { TabGroup, TabPanel, TabPanels } from '@headlessui/react';
55
import { ChevronLeftIcon, ChevronRightIcon, QuestionMarkCircleIcon, EyeIcon } from '@heroicons/react/24/outline';
@@ -39,6 +39,7 @@ import { formatGasToMillions, ATTESTATION_DEADLINE_MS } from '@/utils';
3939
import type { ForkVersion } from '@/utils/beacon';
4040
import { SlotDetailSkeleton } from './components/SlotDetailSkeleton';
4141
import { EngineTimingsCard } from './components/EngineTimingsCard';
42+
import { SlotProgressTimeline } from './components/SlotProgressTimeline';
4243
import { useSlotEngineTimings } from './hooks/useSlotEngineTimings';
4344

4445
/**
@@ -47,9 +48,26 @@ import { useSlotEngineTimings } from './hooks/useSlotEngineTimings';
4748
*/
4849
export function DetailPage(): JSX.Element {
4950
const { slot: slotParam } = useParams({ from: '/ethereum/slots/$slot' });
51+
const search = useSearch({ from: '/ethereum/slots/$slot' });
5052
const context = Route.useRouteContext();
5153
const navigate = useNavigate();
5254

55+
// Handle contributor filter change - updates URL params
56+
const handleContributorChange = useCallback(
57+
(contributor: string | undefined) => {
58+
navigate({
59+
to: '/ethereum/slots/$slot',
60+
params: { slot: slotParam },
61+
search: {
62+
tab: search.tab,
63+
contributor: contributor || undefined,
64+
},
65+
replace: true,
66+
});
67+
},
68+
[navigate, slotParam, search.tab]
69+
);
70+
5371
// Redirect to slots index when network changes
5472
useNetworkChangeRedirect(context.redirectOnNetworkChange);
5573

@@ -118,6 +136,7 @@ export function DetailPage(): JSX.Element {
118136
// Tab state management with URL search params
119137
const { selectedIndex, onChange } = useTabState([
120138
{ id: 'overview' },
139+
{ id: 'timeline' },
121140
{ id: 'block' },
122141
{ id: 'attestations', anchors: ['missed-attestations'] },
123142
{ id: 'propagation' },
@@ -367,6 +386,7 @@ export function DetailPage(): JSX.Element {
367386
<TabGroup selectedIndex={selectedIndex} onChange={onChange}>
368387
<ScrollableTabs>
369388
<Tab>Overview</Tab>
389+
<Tab>Timeline</Tab>
370390
<Tab>Block</Tab>
371391
<Tab>Attestations</Tab>
372392
<Tab>Propagation</Tab>
@@ -494,6 +514,20 @@ export function DetailPage(): JSX.Element {
494514
</div>
495515
</TabPanel>
496516

517+
{/* Timeline Tab - First-seen timing visualization */}
518+
<TabPanel>
519+
<SlotProgressTimeline
520+
slot={slot}
521+
blockPropagation={data.blockPropagation}
522+
blobPropagation={data.blobPropagation}
523+
dataColumnPropagation={data.dataColumnPropagation}
524+
attestations={data.attestations}
525+
mevBidding={data.mevBidding}
526+
contributor={search.contributor}
527+
onContributorChange={handleContributorChange}
528+
/>
529+
</TabPanel>
530+
497531
{/* Block Tab - All block data in two-column layout */}
498532
<TabPanel>
499533
<Card>
Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
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

Comments
 (0)