diff --git a/src/pages/ethereum/slots/DetailPage.tsx b/src/pages/ethereum/slots/DetailPage.tsx index f377fbaca..986752598 100644 --- a/src/pages/ethereum/slots/DetailPage.tsx +++ b/src/pages/ethereum/slots/DetailPage.tsx @@ -1,5 +1,5 @@ -import { type JSX, useEffect } from 'react'; -import { useParams, useNavigate, Link } from '@tanstack/react-router'; +import { type JSX, useCallback, useEffect } from 'react'; +import { useParams, useNavigate, useSearch, Link } from '@tanstack/react-router'; import { useQuery } from '@tanstack/react-query'; import { TabGroup, TabPanel, TabPanels } from '@headlessui/react'; import { ChevronLeftIcon, ChevronRightIcon, QuestionMarkCircleIcon, EyeIcon } from '@heroicons/react/24/outline'; @@ -39,6 +39,7 @@ import { formatGasToMillions, ATTESTATION_DEADLINE_MS } from '@/utils'; import type { ForkVersion } from '@/utils/beacon'; import { SlotDetailSkeleton } from './components/SlotDetailSkeleton'; import { EngineTimingsCard } from './components/EngineTimingsCard'; +import { SlotProgressTimeline } from './components/SlotProgressTimeline'; import { useSlotEngineTimings } from './hooks/useSlotEngineTimings'; /** @@ -47,9 +48,26 @@ import { useSlotEngineTimings } from './hooks/useSlotEngineTimings'; */ export function DetailPage(): JSX.Element { const { slot: slotParam } = useParams({ from: '/ethereum/slots/$slot' }); + const search = useSearch({ from: '/ethereum/slots/$slot' }); const context = Route.useRouteContext(); const navigate = useNavigate(); + // Handle contributor filter change - updates URL params + const handleContributorChange = useCallback( + (contributor: string | undefined) => { + navigate({ + to: '/ethereum/slots/$slot', + params: { slot: slotParam }, + search: { + tab: search.tab, + contributor: contributor || undefined, + }, + replace: true, + }); + }, + [navigate, slotParam, search.tab] + ); + // Redirect to slots index when network changes useNetworkChangeRedirect(context.redirectOnNetworkChange); @@ -118,6 +136,7 @@ export function DetailPage(): JSX.Element { // Tab state management with URL search params const { selectedIndex, onChange } = useTabState([ { id: 'overview' }, + { id: 'timeline' }, { id: 'block' }, { id: 'attestations', anchors: ['missed-attestations'] }, { id: 'propagation' }, @@ -367,6 +386,7 @@ export function DetailPage(): JSX.Element { Overview + Timeline Block Attestations Propagation @@ -494,6 +514,20 @@ export function DetailPage(): JSX.Element { + {/* Timeline Tab - First-seen timing visualization */} + + + + {/* Block Tab - All block data in two-column layout */} diff --git a/src/pages/ethereum/slots/components/SlotProgressTimeline/SlotProgressTimeline.tsx b/src/pages/ethereum/slots/components/SlotProgressTimeline/SlotProgressTimeline.tsx new file mode 100644 index 000000000..fe81d5ff4 --- /dev/null +++ b/src/pages/ethereum/slots/components/SlotProgressTimeline/SlotProgressTimeline.tsx @@ -0,0 +1,319 @@ +import { useCallback, useEffect, useRef, useState, type JSX } from 'react'; +import clsx from 'clsx'; +import { Card } from '@/components/Layout/Card'; +import { ClientLogo } from '@/components/Ethereum/ClientLogo'; +import { SelectMenu } from '@/components/Forms/SelectMenu'; +import { Toggle } from '@/components/Forms/Toggle'; +import type { SlotProgressTimelineProps, TraceSpan } from './SlotProgressTimeline.types'; +import { SPAN_COLORS } from './constants'; +import { formatMs, msToPercent } from './utils'; +import { useTraceSpans } from './useTraceSpans'; +import { TimelineHeader } from './TimelineHeader'; +import { TimelineGrid } from './TimelineGrid'; +import { TimelineTooltip } from './TimelineTooltip'; +import { TimelineLegend } from './TimelineLegend'; + +const ROW_HEIGHT = 28; +const LABEL_WIDTH = 280; + +/** + * SlotProgressTimeline displays a Jaeger/OTLP-style trace view of slot events. + * + * Shows hierarchical spans for: + * - MEV bidding phase + * - Block propagation across the network + * - Block execution (newPayload) on reference nodes + * - Data availability (individual columns/blobs) + * - Attestations + */ +export function SlotProgressTimeline({ + slot, + blockPropagation, + blobPropagation, + dataColumnPropagation, + attestations, + mevBidding, + isLoading = false, + contributor, + onContributorChange, +}: SlotProgressTimelineProps): JSX.Element { + const [hoveredSpan, setHoveredSpan] = useState(null); + const [mousePos, setMousePos] = useState<{ x: number; y: number } | null>(null); + const [collapsedSpans, setCollapsedSpans] = useState>(new Set()); + const [excludeOutliers, setExcludeOutliers] = useState(true); + const hasInitializedCollapsed = useRef(false); + const containerRef = useRef(null); + + // Use contributor from URL params + const selectedUsername = contributor ?? null; + const setSelectedUsername = (username: string | null): void => { + onContributorChange?.(username ?? undefined); + }; + + // Build trace spans from slot data + const { + spans, + availableUsernames, + isLoading: executionLoading, + } = useTraceSpans({ + slot, + blockPropagation, + blobPropagation, + dataColumnPropagation, + attestations, + mevBidding, + selectedUsername, + excludeOutliers, + }); + + // Helper to find all descendant span IDs + const getDescendantIds = useCallback((spanId: string, allSpans: TraceSpan[]): string[] => { + const descendants: string[] = []; + const findChildren = (parentId: string): void => { + for (const span of allSpans) { + if (span.parentId === parentId) { + descendants.push(span.id); + findChildren(span.id); + } + } + }; + findChildren(spanId); + return descendants; + }, []); + + // Toggle collapse with cascading + const toggleCollapse = useCallback( + (spanId: string): void => { + setCollapsedSpans(prev => { + const next = new Set(prev); + if (next.has(spanId)) { + next.delete(spanId); + } else { + next.add(spanId); + const descendantIds = getDescendantIds(spanId, spans); + for (const id of descendantIds) { + next.add(id); + } + } + return next; + }); + }, + [spans, getDescendantIds] + ); + + // Initialize collapsed state from spans' defaultCollapsed property + useEffect(() => { + if (!hasInitializedCollapsed.current && spans.length > 1) { + hasInitializedCollapsed.current = true; + const initial = new Set(); + for (const span of spans) { + if (span.defaultCollapsed && span.collapsible) { + initial.add(span.id); + } + } + setCollapsedSpans(initial); + } + }, [spans]); + + // Reset collapsed state when filter changes + useEffect(() => { + hasInitializedCollapsed.current = false; + }, [selectedUsername]); + + // Handle mouse events for tooltip + const handleMouseEnter = useCallback((span: TraceSpan) => { + setHoveredSpan(span); + }, []); + + const handleMouseLeave = useCallback(() => { + setHoveredSpan(null); + setMousePos(null); + }, []); + + const handleMouseMove = useCallback((e: React.MouseEvent) => { + if (containerRef.current) { + const rect = containerRef.current.getBoundingClientRect(); + setMousePos({ x: e.clientX - rect.left, y: e.clientY - rect.top }); + } + }, []); + + // Loading state + if (isLoading || executionLoading) { + return ( + +
+

Slot Trace

+

Event timeline trace view

+
+
+ + ); + } + + // Empty state + if (spans.length <= 1) { + return ( + +
+

Slot Trace

+

Event timeline trace view

+
+
+

No trace data available for this slot

+
+
+ ); + } + + // Build filter options + const filterOptions = [ + { value: '', label: 'All nodes' }, + ...availableUsernames.map(username => ({ value: username, label: username })), + ]; + + // Filter spans by collapsed state + const visibleSpans = spans.filter(span => !span.parentId || !collapsedSpans.has(span.parentId)); + + return ( + +
+ {/* Header */} +
+
+

Slot Trace

+

+ Event timeline showing when each phase completed relative to slot start +

+
+
+ + {availableUsernames.length > 0 && ( + setSelectedUsername(value || null)} + className="w-48" + /> + )} +
+
+ + {/* Scrollable timeline container for mobile */} +
+
+ + + {/* Trace Rows */} +
+ + + {/* Span rows */} + {visibleSpans.map(span => { + const colors = SPAN_COLORS[span.category]; + const startPercent = msToPercent(span.startMs); + const endPercent = msToPercent(span.endMs); + const widthPercent = Math.max(endPercent - startPercent, 0.5); + const isHovered = hoveredSpan?.id === span.id; + const duration = span.endMs - span.startMs; + const isCollapsed = collapsedSpans.has(span.id); + const childCount = spans.filter(s => s.parentId === span.id).length; + + return ( +
handleMouseEnter(span)} + onMouseLeave={handleMouseLeave} + onMouseMove={handleMouseMove} + > + {/* Label column */} +
toggleCollapse(span.id) : undefined} + title={span.collapsible ? (isCollapsed ? `Expand (${childCount} items)` : 'Collapse') : undefined} + > + {span.collapsible ? ( + + {isCollapsed ? '▶' : '▼'} + + ) : ( + span.depth > 0 && {'└'} + )} + {span.clientName && } + + {span.label} + {span.collapsible && isCollapsed && ({childCount})} + +
+ + {/* Timeline column */} +
+
toggleCollapse(span.id) : undefined} + /> +
+ + {/* Duration column - show absolute time for point-in-time events */} +
+ {span.isPointInTime ? formatMs(span.startMs) : formatMs(duration)} +
+
+ ); + })} +
+
+
+ + {/* Floating Tooltip */} + {hoveredSpan && mousePos && ( + + )} +
+ + + + ); +} diff --git a/src/pages/ethereum/slots/components/SlotProgressTimeline/SlotProgressTimeline.types.ts b/src/pages/ethereum/slots/components/SlotProgressTimeline/SlotProgressTimeline.types.ts new file mode 100644 index 000000000..47ca8a261 --- /dev/null +++ b/src/pages/ethereum/slots/components/SlotProgressTimeline/SlotProgressTimeline.types.ts @@ -0,0 +1,91 @@ +import type { + FctBlockFirstSeenByNode, + FctBlockBlobFirstSeenByNode, + FctBlockDataColumnSidecarFirstSeenByNode, + FctAttestationFirstSeenChunked50Ms, + FctMevBidHighestValueByBuilderChunked50Ms, +} from '@/api/types.gen'; + +/** + * Props for the SlotProgressTimeline component. + */ +export interface SlotProgressTimelineProps { + /** Slot number for fetching raw execution data */ + slot: number; + /** Block propagation data for first-seen times */ + blockPropagation: FctBlockFirstSeenByNode[]; + /** Blob propagation data (pre-Fulu) */ + blobPropagation: FctBlockBlobFirstSeenByNode[]; + /** Data column propagation data (Fulu+) */ + dataColumnPropagation: FctBlockDataColumnSidecarFirstSeenByNode[]; + /** Attestation timing data */ + attestations: FctAttestationFirstSeenChunked50Ms[]; + /** MEV bid timing data (50ms chunks) */ + mevBidding: FctMevBidHighestValueByBuilderChunked50Ms[]; + /** Whether data is still loading */ + isLoading?: boolean; + /** Selected contributor username from URL params */ + contributor?: string; + /** Callback when contributor filter changes */ + onContributorChange?: (contributor: string | undefined) => void; +} + +/** + * Represents a span in the trace timeline. + * Similar to OpenTelemetry/Jaeger trace spans. + */ +export interface TraceSpan { + id: string; + label: string; + /** Start time in milliseconds relative to slot start */ + startMs: number; + /** End time in milliseconds relative to slot start */ + endMs: number; + /** Category for color coding */ + category: + | 'slot' + | 'propagation' + | 'country' + | 'internal' + | 'individual' + | 'mev' + | 'mev-builder' + | 'execution' + | 'execution-client' + | 'execution-node' + | 'data-availability' + | 'column' + | 'attestation'; + /** Nesting depth (0 = root) */ + depth: number; + /** Additional details for tooltip */ + details?: string; + /** Whether this span arrived late (after attestation deadline) */ + isLate?: boolean; + /** Node count for DA items */ + nodeCount?: number; + /** Parent span ID for collapsible grouping */ + parentId?: string; + /** Whether this span can be collapsed to hide children */ + collapsible?: boolean; + /** Whether this span should be collapsed by default */ + defaultCollapsed?: boolean; + /** Client name for rendering logo */ + clientName?: string; + /** Client version string */ + clientVersion?: string; + /** Node ID for propagation nodes */ + nodeId?: string; + /** Username for linking to Xatu contributor page */ + username?: string; + /** Node name (meta_client_name) for filtering */ + nodeName?: string; + /** Country name */ + country?: string; + /** City name */ + city?: string; + /** Classification (canonical, forked, etc) */ + classification?: string; + /** Whether this is a point-in-time event (show absolute time, not duration) */ + isPointInTime?: boolean; +} diff --git a/src/pages/ethereum/slots/components/SlotProgressTimeline/TimelineGrid.tsx b/src/pages/ethereum/slots/components/SlotProgressTimeline/TimelineGrid.tsx new file mode 100644 index 000000000..c8201d8d7 --- /dev/null +++ b/src/pages/ethereum/slots/components/SlotProgressTimeline/TimelineGrid.tsx @@ -0,0 +1,25 @@ +import type { JSX } from 'react'; +import clsx from 'clsx'; + +interface TimelineGridProps { + labelWidth: number; +} + +/** + * Background grid lines showing time intervals and attestation deadline. + */ +export function TimelineGrid({ labelWidth }: TimelineGridProps): JSX.Element { + return ( +
+ {[0, 2, 4, 6, 8, 10, 12].map(sec => ( +
+ ))} + {/* Attestation deadline highlight */} +
+
+ ); +} diff --git a/src/pages/ethereum/slots/components/SlotProgressTimeline/TimelineHeader.tsx b/src/pages/ethereum/slots/components/SlotProgressTimeline/TimelineHeader.tsx new file mode 100644 index 000000000..d575867cb --- /dev/null +++ b/src/pages/ethereum/slots/components/SlotProgressTimeline/TimelineHeader.tsx @@ -0,0 +1,28 @@ +import type { JSX } from 'react'; + +interface TimelineHeaderProps { + labelWidth: number; +} + +/** + * Header row showing time markers (0s to 12s). + */ +export function TimelineHeader({ labelWidth }: TimelineHeaderProps): JSX.Element { + return ( +
+
+
+
+ 0s + 2s + 4s + 6s + 8s + 10s + 12s +
+
+
+
+ ); +} diff --git a/src/pages/ethereum/slots/components/SlotProgressTimeline/TimelineLegend.tsx b/src/pages/ethereum/slots/components/SlotProgressTimeline/TimelineLegend.tsx new file mode 100644 index 000000000..2555b21fa --- /dev/null +++ b/src/pages/ethereum/slots/components/SlotProgressTimeline/TimelineLegend.tsx @@ -0,0 +1,47 @@ +import type { JSX } from 'react'; + +/** + * Legend showing the meaning of colors in the timeline. + */ +export function TimelineLegend(): JSX.Element { + return ( +
+
+
+ MEV Builders +
+
+
+ Block Propagation +
+
+
+ Internal +
+
+
+ Individual +
+
+
+ EIP7870 Execution +
+
+
+ Data Availability +
+
+
+ Attestations +
+
+
+ Late ({'>'} 4s) +
+
+
+ Attestation Deadline (4s) +
+
+ ); +} diff --git a/src/pages/ethereum/slots/components/SlotProgressTimeline/TimelineRow.tsx b/src/pages/ethereum/slots/components/SlotProgressTimeline/TimelineRow.tsx new file mode 100644 index 000000000..8fb92dc9d --- /dev/null +++ b/src/pages/ethereum/slots/components/SlotProgressTimeline/TimelineRow.tsx @@ -0,0 +1,104 @@ +import type { JSX } from 'react'; +import clsx from 'clsx'; +import { ClientLogo } from '@/components/Ethereum/ClientLogo'; +import { SPAN_COLORS } from './constants'; +import { formatMs, msToPercent } from './utils'; +import type { TraceSpan } from './SlotProgressTimeline.types'; + +interface TimelineRowProps { + span: TraceSpan; + rowHeight: number; + labelWidth: number; + isHovered: boolean; + isCollapsed: boolean; + childCount: number; + onMouseEnter: () => void; + onMouseLeave: () => void; + onMouseMove: (e: React.MouseEvent) => void; + onToggleCollapse: () => void; +} + +/** + * Renders a single row in the timeline trace view. + */ +export function TimelineRow({ + span, + rowHeight, + labelWidth, + isHovered, + isCollapsed, + childCount, + onMouseEnter, + onMouseLeave, + onMouseMove, + onToggleCollapse, +}: TimelineRowProps): JSX.Element { + const colors = SPAN_COLORS[span.category]; + const startPercent = msToPercent(span.startMs); + const endPercent = msToPercent(span.endMs); + const widthPercent = Math.max(endPercent - startPercent, 0.5); + const duration = span.endMs - span.startMs; + + return ( +
+ {/* Label column */} +
+ {span.collapsible ? ( + {isCollapsed ? '▶' : '▼'} + ) : ( + span.depth > 0 && {'└'} + )} + {span.clientName && } + + {span.label} + {span.collapsible && isCollapsed && ({childCount})} + +
+ + {/* Timeline column */} +
+
+
+ + {/* Duration column */} +
+ {span.category === 'column' ? formatMs(span.startMs) : formatMs(duration)} +
+
+ ); +} diff --git a/src/pages/ethereum/slots/components/SlotProgressTimeline/TimelineTooltip.tsx b/src/pages/ethereum/slots/components/SlotProgressTimeline/TimelineTooltip.tsx new file mode 100644 index 000000000..82637bb6c --- /dev/null +++ b/src/pages/ethereum/slots/components/SlotProgressTimeline/TimelineTooltip.tsx @@ -0,0 +1,101 @@ +import type { JSX } from 'react'; +import clsx from 'clsx'; +import { ClientLogo } from '@/components/Ethereum/ClientLogo'; +import { formatMs } from './utils'; +import type { TraceSpan } from './SlotProgressTimeline.types'; + +interface TimelineTooltipProps { + span: TraceSpan; + position: { x: number; y: number }; + containerWidth: number; +} + +/** + * Floating tooltip that displays span details on hover. + */ +export function TimelineTooltip({ span, position, containerWidth }: TimelineTooltipProps): JSX.Element { + return ( +
+ {/* Header with label and late badge */} +
+ {span.clientName && } + {span.label} + {span.isLate && ( + LATE + )} +
+ + {/* Timing row */} +
+ + {formatMs(span.startMs)} → {formatMs(span.endMs)} + + + Δ {formatMs(span.endMs - span.startMs)} + +
+ + {/* Details grid */} +
+ {/* Location */} + {(span.city || span.country) && ( + <> + Location + {span.city ? `${span.city}, ${span.country}` : span.country} + + )} + + {/* Client implementation + version */} + {span.clientName && ( + <> + Client + + {span.clientName} + {span.clientVersion && v{span.clientVersion}} + + + )} + + {/* Classification */} + {span.classification && ( + <> + Classification + {span.classification} + + )} + + {/* Node count (for DA items) */} + {span.nodeCount !== undefined && ( + <> + Items + {span.nodeCount} + + )} + + {/* Username/contributor */} + {span.username && ( + <> + Contributor + {span.username} + + )} +
+ + {/* Node name (full path) */} + {span.nodeName && ( +
+ {span.nodeName} +
+ )} + + {/* Node ID */} + {span.nodeId &&
ID: {span.nodeId}
} +
+ ); +} diff --git a/src/pages/ethereum/slots/components/SlotProgressTimeline/constants.ts b/src/pages/ethereum/slots/components/SlotProgressTimeline/constants.ts new file mode 100644 index 000000000..fc2b7900b --- /dev/null +++ b/src/pages/ethereum/slots/components/SlotProgressTimeline/constants.ts @@ -0,0 +1,31 @@ +/** + * Constants and color configuration for the SlotProgressTimeline component. + */ + +import type { TraceSpan } from './SlotProgressTimeline.types'; + +/** Slot duration in milliseconds (12 seconds) */ +export const SLOT_DURATION_MS = 12_000; + +/** Maximum reasonable seen_slot_start_diff value (60 seconds - anything beyond is likely bad data) */ +export const MAX_REASONABLE_SEEN_TIME_MS = 60_000; + +/** + * Color configuration for span categories. + */ +export const SPAN_COLORS: Record = { + slot: { bg: 'bg-slate-600', border: 'border-slate-700', text: 'text-slate-400' }, + propagation: { bg: 'bg-blue-500', border: 'border-blue-600', text: 'text-blue-500' }, + country: { bg: 'bg-blue-300', border: 'border-blue-400', text: 'text-blue-300' }, + // Classification colors for block propagation nodes + internal: { bg: 'bg-teal-400', border: 'border-teal-500', text: 'text-teal-400' }, + individual: { bg: 'bg-violet-400', border: 'border-violet-500', text: 'text-violet-400' }, + mev: { bg: 'bg-pink-500', border: 'border-pink-600', text: 'text-pink-500' }, + 'mev-builder': { bg: 'bg-pink-400', border: 'border-pink-500', text: 'text-pink-400' }, + execution: { bg: 'bg-amber-500', border: 'border-amber-600', text: 'text-amber-500' }, + 'execution-client': { bg: 'bg-amber-400', border: 'border-amber-500', text: 'text-amber-400' }, + 'execution-node': { bg: 'bg-amber-300', border: 'border-amber-400', text: 'text-amber-300' }, + 'data-availability': { bg: 'bg-purple-500', border: 'border-purple-600', text: 'text-purple-500' }, + column: { bg: 'bg-purple-400', border: 'border-purple-500', text: 'text-purple-400' }, + attestation: { bg: 'bg-emerald-500', border: 'border-emerald-600', text: 'text-emerald-500' }, +}; diff --git a/src/pages/ethereum/slots/components/SlotProgressTimeline/index.ts b/src/pages/ethereum/slots/components/SlotProgressTimeline/index.ts new file mode 100644 index 000000000..113f7ded9 --- /dev/null +++ b/src/pages/ethereum/slots/components/SlotProgressTimeline/index.ts @@ -0,0 +1,2 @@ +export { SlotProgressTimeline } from './SlotProgressTimeline'; +export type { SlotProgressTimelineProps } from './SlotProgressTimeline.types'; diff --git a/src/pages/ethereum/slots/components/SlotProgressTimeline/useTraceSpans.ts b/src/pages/ethereum/slots/components/SlotProgressTimeline/useTraceSpans.ts new file mode 100644 index 000000000..80073b019 --- /dev/null +++ b/src/pages/ethereum/slots/components/SlotProgressTimeline/useTraceSpans.ts @@ -0,0 +1,923 @@ +import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { ATTESTATION_DEADLINE_MS } from '@/utils'; +import { intEngineNewPayloadServiceListOptions } from '@/api/@tanstack/react-query.gen'; +import { extractClusterFromNodeName } from '@/constants/eip7870'; +import type { + FctBlockFirstSeenByNode, + FctBlockBlobFirstSeenByNode, + FctBlockDataColumnSidecarFirstSeenByNode, + FctAttestationFirstSeenChunked50Ms, + FctMevBidHighestValueByBuilderChunked50Ms, +} from '@/api/types.gen'; +import { SLOT_DURATION_MS, MAX_REASONABLE_SEEN_TIME_MS } from './constants'; +import { formatMs, classificationToCategory, getShortNodeId, filterOutliers } from './utils'; +import type { TraceSpan } from './SlotProgressTimeline.types'; + +interface UseTraceSpansOptions { + slot: number; + blockPropagation: FctBlockFirstSeenByNode[]; + blobPropagation: FctBlockBlobFirstSeenByNode[]; + dataColumnPropagation: FctBlockDataColumnSidecarFirstSeenByNode[]; + attestations: FctAttestationFirstSeenChunked50Ms[]; + mevBidding: FctMevBidHighestValueByBuilderChunked50Ms[]; + selectedUsername: string | null; + excludeOutliers: boolean; +} + +interface UseTraceSpansResult { + spans: TraceSpan[]; + availableUsernames: string[]; + selectedNodeNames: Set | null; + isLoading: boolean; +} + +/** + * Hook that builds trace spans from slot data. + * Handles fetching execution data and transforming all data sources into TraceSpan format. + */ +export function useTraceSpans({ + slot, + blockPropagation, + blobPropagation, + dataColumnPropagation, + attestations, + mevBidding, + selectedUsername, + excludeOutliers, +}: UseTraceSpansOptions): UseTraceSpansResult { + // Fetch raw execution data to get individual node timings + const { data: rawExecutionData, isLoading: executionLoading } = useQuery({ + ...intEngineNewPayloadServiceListOptions({ + query: { + slot_eq: slot, + node_class_eq: 'eip7870-block-builder', + page_size: 100, + }, + }), + enabled: slot > 0, + }); + + const rawExecutionNodes = useMemo(() => rawExecutionData?.int_engine_new_payload ?? [], [rawExecutionData]); + + // Extract unique usernames for filter dropdown + const availableUsernames = useMemo(() => { + const usernames = new Set(); + for (const node of blockPropagation) { + if (node.username) { + usernames.add(node.username); + } + } + return Array.from(usernames).sort(); + }, [blockPropagation]); + + // Get node names for the selected username (for filtering execution/DA data) + const selectedNodeNames = useMemo(() => { + if (!selectedUsername) return null; + const names = new Set(); + for (const node of blockPropagation) { + if (node.username === selectedUsername && node.meta_client_name) { + names.add(node.meta_client_name); + } + } + return names; + }, [selectedUsername, blockPropagation]); + + // Build trace spans from slot data + const spans = useMemo(() => { + const result: TraceSpan[] = []; + + // Root span: Slot (full 12s duration) + result.push({ + id: 'slot', + label: 'Slot', + startMs: 0, + endMs: SLOT_DURATION_MS, + category: 'slot', + depth: 0, + details: 'Full slot duration (12 seconds)', + }); + + // Block arrival timing - calculate first + let blockFirstSeenMs: number | null = null; + let blockLastSeenMs: number | null = null; + let blockLastSeenMsForParent: number | null = null; + + if (blockPropagation.length > 0) { + const validTimes = blockPropagation + .map(node => node.seen_slot_start_diff ?? Infinity) + .filter(v => v !== Infinity && v >= 0 && v <= MAX_REASONABLE_SEEN_TIME_MS); + + if (validTimes.length > 0) { + blockFirstSeenMs = Math.min(...validTimes); + blockLastSeenMs = Math.max(...validTimes); + + if (excludeOutliers) { + const filteredTimes = filterOutliers(validTimes); + blockLastSeenMsForParent = filteredTimes.length > 0 ? Math.max(...filteredTimes) : blockLastSeenMs; + } else { + blockLastSeenMsForParent = blockLastSeenMs; + } + } + } + + // MEV Builders + buildMevSpans(result, mevBidding, blockFirstSeenMs); + + // Calculate execution data + const { executionClientData, maxExecutionEnd } = calculateExecutionData( + rawExecutionNodes, + blockFirstSeenMs, + blockPropagation, + selectedNodeNames + ); + + // Create geo lookup map + const nodeGeoMap = buildNodeGeoMap(blockPropagation); + + // Block Propagation + buildPropagationSpans( + result, + blockPropagation, + selectedUsername, + blockFirstSeenMs, + blockLastSeenMs, + blockLastSeenMsForParent, + excludeOutliers + ); + + // EIP7870 Execution + buildExecutionSpans(result, executionClientData, maxExecutionEnd, selectedUsername, nodeGeoMap, excludeOutliers); + + // Data availability + buildDataAvailabilitySpans( + result, + dataColumnPropagation, + blobPropagation, + selectedUsername, + selectedNodeNames, + nodeGeoMap, + excludeOutliers + ); + + // Attestations + buildAttestationSpans(result, attestations); + + return result; + }, [ + blockPropagation, + blobPropagation, + dataColumnPropagation, + attestations, + mevBidding, + rawExecutionNodes, + selectedUsername, + selectedNodeNames, + excludeOutliers, + ]); + + return { + spans, + availableUsernames, + selectedNodeNames, + isLoading: executionLoading, + }; +} + +// Helper functions for building specific span types + +function buildMevSpans( + result: TraceSpan[], + mevBidding: FctMevBidHighestValueByBuilderChunked50Ms[], + blockFirstSeenMs: number | null +): void { + if (mevBidding.length === 0) return; + + const validBids = mevBidding.filter( + bid => bid.chunk_slot_start_diff !== undefined && bid.chunk_slot_start_diff !== null + ); + + if (validBids.length === 0) return; + + const sortedBids = [...validBids].sort((a, b) => (a.chunk_slot_start_diff ?? 0) - (b.chunk_slot_start_diff ?? 0)); + + const firstBidTime = sortedBids[0].chunk_slot_start_diff ?? 0; + const lastBidTime = sortedBids[sortedBids.length - 1].chunk_slot_start_diff ?? 0; + const mevEndTime = blockFirstSeenMs !== null ? blockFirstSeenMs : lastBidTime + 50; + + const bidsByBuilder = new Map(); + for (const bid of validBids) { + const builderKey = bid.builder_pubkey ?? 'Unknown'; + const existing = bidsByBuilder.get(builderKey); + const bidTime = bid.chunk_slot_start_diff ?? 0; + const bidValue = bid.value ?? '0'; + + if (existing) { + existing.count++; + existing.firstTime = Math.min(existing.firstTime, bidTime); + existing.lastTime = Math.max(existing.lastTime, bidTime); + if (BigInt(bidValue) > BigInt(existing.maxValue)) { + existing.maxValue = bidValue; + } + } else { + bidsByBuilder.set(builderKey, { + count: 1, + firstTime: bidTime, + lastTime: bidTime, + maxValue: bidValue, + }); + } + } + + result.push({ + id: 'mev-builders', + label: 'MEV Builders', + startMs: Math.min(firstBidTime, 0), + endMs: mevEndTime, + category: 'mev', + depth: 1, + details: `${validBids.length} bids from ${bidsByBuilder.size} builder${bidsByBuilder.size !== 1 ? 's' : ''} (${formatMs(firstBidTime)} to ${formatMs(lastBidTime)})`, + isLate: false, + collapsible: bidsByBuilder.size > 1, + defaultCollapsed: true, + }); + + if (bidsByBuilder.size > 1) { + for (const [pubkey, data] of bidsByBuilder) { + const shortPubkey = pubkey.length > 12 ? `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}` : pubkey; + + result.push({ + id: `mev-builder-${pubkey}`, + label: shortPubkey, + startMs: Math.min(data.firstTime, 0), + endMs: data.lastTime + 50, + category: 'mev-builder', + depth: 2, + details: `Builder ${shortPubkey}: ${data.count} bid${data.count !== 1 ? 's' : ''} from ${formatMs(data.firstTime)} to ${formatMs(data.lastTime)}`, + parentId: 'mev-builders', + }); + } + } +} + +interface ExecutionNodeData { + nodeName: string; + cluster: string; + startMs: number; + endMs: number; + duration: number; + status: string; +} + +interface ExecutionClientData { + impl: string; + nodes: ExecutionNodeData[]; + minStart: number; + maxEnd: number; +} + +function calculateExecutionData( + rawExecutionNodes: Array<{ + status?: string; + duration_ms?: number; + meta_client_name?: string; + meta_execution_implementation?: string; + }>, + blockFirstSeenMs: number | null, + blockPropagation: FctBlockFirstSeenByNode[], + selectedNodeNames: Set | null +): { executionClientData: ExecutionClientData[]; maxExecutionEnd: number } { + let maxExecutionEnd = blockFirstSeenMs ?? 0; + const executionClientData: ExecutionClientData[] = []; + + if (rawExecutionNodes.length === 0 || blockFirstSeenMs === null) { + return { executionClientData, maxExecutionEnd }; + } + + const nodeSeenTimeMap = new Map(); + for (const propNode of blockPropagation) { + const nodeName = propNode.meta_client_name; + const seenTime = propNode.seen_slot_start_diff; + if ( + nodeName && + seenTime !== undefined && + seenTime !== null && + seenTime >= 0 && + seenTime <= MAX_REASONABLE_SEEN_TIME_MS + ) { + nodeSeenTimeMap.set(nodeName, seenTime); + } + } + + const clientsByImpl = new Map(); + + for (const node of rawExecutionNodes) { + if (node.status?.toUpperCase() !== 'VALID') continue; + if (!node.duration_ms || node.duration_ms <= 0) continue; + + const nodeName = node.meta_client_name ?? 'unknown'; + + if (selectedNodeNames && !selectedNodeNames.has(nodeName)) continue; + + const nodeBlockSeenMs = nodeSeenTimeMap.get(nodeName) ?? blockFirstSeenMs; + const nodeStartMs = nodeBlockSeenMs; + const nodeEndMs = nodeStartMs + node.duration_ms; + + const impl = node.meta_execution_implementation ?? 'Unknown'; + const cluster = extractClusterFromNodeName(nodeName) ?? 'other'; + + if (!clientsByImpl.has(impl)) { + clientsByImpl.set(impl, []); + } + clientsByImpl.get(impl)!.push({ + nodeName, + cluster, + startMs: nodeStartMs, + endMs: nodeEndMs, + duration: node.duration_ms, + status: node.status ?? 'UNKNOWN', + }); + } + + if (clientsByImpl.size > 0) { + for (const [impl, nodes] of clientsByImpl) { + let clientMinStart = Infinity; + let clientMaxEnd = 0; + + for (const node of nodes) { + clientMinStart = Math.min(clientMinStart, node.startMs); + clientMaxEnd = Math.max(clientMaxEnd, node.endMs); + } + + executionClientData.push({ + impl, + nodes, + minStart: clientMinStart, + maxEnd: clientMaxEnd, + }); + maxExecutionEnd = Math.max(maxExecutionEnd, clientMaxEnd); + } + } + + return { executionClientData, maxExecutionEnd }; +} + +function buildNodeGeoMap( + blockPropagation: FctBlockFirstSeenByNode[] +): Map { + const nodeGeoMap = new Map(); + for (const node of blockPropagation) { + if (node.meta_client_name) { + nodeGeoMap.set(node.meta_client_name, { + country: node.meta_client_geo_country || 'Unknown', + city: (node as { meta_client_geo_city?: string }).meta_client_geo_city || '', + clientImpl: node.meta_consensus_implementation || 'unknown', + clientVersion: (node as { meta_client_version?: string }).meta_client_version || '', + }); + } + } + return nodeGeoMap; +} + +interface NodeData { + name: string; + username: string; + seenTime: number; + classification: string; + clientImpl: string; + clientVersion: string; + nodeId: string; + country: string; + city: string; +} + +function buildPropagationSpans( + result: TraceSpan[], + blockPropagation: FctBlockFirstSeenByNode[], + selectedUsername: string | null, + blockFirstSeenMs: number | null, + blockLastSeenMs: number | null, + blockLastSeenMsForParent: number | null, + excludeOutliers: boolean +): void { + if (blockFirstSeenMs === null || blockLastSeenMs === null) return; + + const filteredPropagation = selectedUsername + ? blockPropagation.filter(node => node.username === selectedUsername) + : blockPropagation; + + const allNodes: NodeData[] = []; + let filteredFirstSeen = Infinity; + let filteredLastSeen = 0; + + for (const node of filteredPropagation) { + const seenTime = node.seen_slot_start_diff; + if (seenTime === undefined || seenTime === null || seenTime < 0 || seenTime > MAX_REASONABLE_SEEN_TIME_MS) continue; + + filteredFirstSeen = Math.min(filteredFirstSeen, seenTime); + filteredLastSeen = Math.max(filteredLastSeen, seenTime); + + const country = node.meta_client_geo_country || 'Unknown'; + const city = (node as { meta_client_geo_city?: string }).meta_client_geo_city || ''; + const nodeName = node.meta_client_name || 'unknown'; + const username = node.username || nodeName; + const classification = node.classification || 'unclassified'; + const clientImpl = node.meta_consensus_implementation || 'unknown'; + const clientVersion = (node as { meta_client_version?: string }).meta_client_version || ''; + const nodeId = node.node_id || ''; + + allNodes.push({ + name: nodeName, + username, + seenTime, + classification, + clientImpl, + clientVersion, + nodeId, + country, + city, + }); + } + + const propagationStart = selectedUsername && filteredFirstSeen !== Infinity ? filteredFirstSeen : blockFirstSeenMs; + + let propagationEndForParent: number; + if (selectedUsername && filteredLastSeen > 0) { + if (excludeOutliers) { + const filteredTimes = filterOutliers(allNodes.map(n => n.seenTime)); + propagationEndForParent = filteredTimes.length > 0 ? Math.max(...filteredTimes) : filteredLastSeen; + } else { + propagationEndForParent = filteredLastSeen; + } + } else { + propagationEndForParent = blockLastSeenMsForParent ?? blockLastSeenMs; + } + const propagationEnd = propagationEndForParent; + + if (selectedUsername && allNodes.length > 0) { + // Simplified per-node view when filtering by contributor + result.push({ + id: 'propagation', + label: 'Block Propagation', + startMs: propagationStart, + endMs: propagationEnd, + category: 'propagation', + depth: 1, + details: `${selectedUsername}: ${allNodes.length} node${allNodes.length !== 1 ? 's' : ''} saw block`, + isLate: propagationEnd > ATTESTATION_DEADLINE_MS, + collapsible: allNodes.length > 0, + defaultCollapsed: false, + }); + + const sortedNodes = [...allNodes].sort((a, b) => a.seenTime - b.seenTime); + for (const nodeData of sortedNodes) { + const shortId = getShortNodeId(nodeData.name); + const location = nodeData.city || nodeData.country; + result.push({ + id: `prop-node-${nodeData.name}`, + label: `${location} (${shortId})`, + startMs: nodeData.seenTime, + endMs: nodeData.seenTime + 30, + category: 'country', + depth: 2, + isLate: nodeData.seenTime > ATTESTATION_DEADLINE_MS, + isPointInTime: true, + parentId: 'propagation', + clientName: nodeData.clientImpl, + clientVersion: nodeData.clientVersion, + nodeId: nodeData.nodeId, + username: nodeData.username, + nodeName: nodeData.name, + country: nodeData.country, + city: nodeData.city, + classification: nodeData.classification, + }); + } + } else if (allNodes.length > 0) { + // Default hierarchical view: Country → Node + type CountryData = { + firstSeen: number; + lastSeen: number; + nodeCount: number; + allTimes: number[]; + nodes: NodeData[]; + }; + + const propagationByCountry = new Map(); + + for (const nodeData of allNodes) { + let countryEntry = propagationByCountry.get(nodeData.country); + if (!countryEntry) { + countryEntry = { + firstSeen: nodeData.seenTime, + lastSeen: nodeData.seenTime, + nodeCount: 0, + allTimes: [], + nodes: [], + }; + propagationByCountry.set(nodeData.country, countryEntry); + } + + countryEntry.firstSeen = Math.min(countryEntry.firstSeen, nodeData.seenTime); + countryEntry.lastSeen = Math.max(countryEntry.lastSeen, nodeData.seenTime); + countryEntry.allTimes.push(nodeData.seenTime); + countryEntry.nodeCount++; + countryEntry.nodes.push(nodeData); + } + + const sortedCountries = Array.from(propagationByCountry.entries()).sort((a, b) => a[1].firstSeen - b[1].firstSeen); + + result.push({ + id: 'propagation', + label: 'Block Propagation', + startMs: propagationStart, + endMs: propagationEnd, + category: 'propagation', + depth: 1, + details: `Block propagated to ${allNodes.length} nodes in ${propagationByCountry.size} countries`, + isLate: propagationEnd > ATTESTATION_DEADLINE_MS, + collapsible: sortedCountries.length >= 1, + defaultCollapsed: false, + }); + + for (const [country, countryData] of sortedCountries) { + const countryId = `country-${country}`; + + let countryEndMs: number; + if (countryData.nodeCount > 1) { + if (excludeOutliers) { + const filteredTimes = filterOutliers(countryData.allTimes); + countryEndMs = filteredTimes.length > 0 ? Math.max(...filteredTimes) : countryData.lastSeen; + } else { + countryEndMs = countryData.lastSeen; + } + } else { + countryEndMs = countryData.firstSeen + 50; + } + + result.push({ + id: countryId, + label: country, + startMs: countryData.firstSeen, + endMs: countryEndMs, + category: 'country', + depth: 2, + details: + countryData.nodeCount > 1 + ? `${country}: ${formatMs(countryData.firstSeen)} → ${formatMs(countryData.lastSeen)} (${countryData.nodeCount} nodes)` + : `${country}: first seen at ${formatMs(countryData.firstSeen)}`, + isLate: countryEndMs > ATTESTATION_DEADLINE_MS, + parentId: 'propagation', + nodeCount: countryData.nodeCount, + collapsible: true, + defaultCollapsed: true, + }); + + const sortedNodes = [...countryData.nodes].sort((a, b) => a.seenTime - b.seenTime); + for (const nodeData of sortedNodes) { + result.push({ + id: `node-${country}-${nodeData.name}`, + label: nodeData.username, + startMs: nodeData.seenTime, + endMs: nodeData.seenTime + 30, + category: classificationToCategory(nodeData.classification), + depth: 3, + details: `${nodeData.username}: seen at ${formatMs(nodeData.seenTime)} (${nodeData.classification})`, + isLate: nodeData.seenTime > ATTESTATION_DEADLINE_MS, + isPointInTime: true, + parentId: countryId, + clientName: nodeData.clientImpl, + nodeId: nodeData.nodeId, + username: nodeData.username, + nodeName: nodeData.name, + classification: nodeData.classification, + }); + } + } + } +} + +function buildExecutionSpans( + result: TraceSpan[], + executionClientData: ExecutionClientData[], + maxExecutionEnd: number, + selectedUsername: string | null, + nodeGeoMap: Map, + excludeOutliers: boolean +): void { + if (executionClientData.length === 0) return; + + const executionMinStart = Math.min(...executionClientData.map(c => c.minStart)); + const totalNodes = executionClientData.reduce((sum, c) => sum + c.nodes.length, 0); + + const allExecNodes = executionClientData.flatMap(c => c.nodes.map(n => ({ ...n, impl: c.impl }))); + + let executionEndForParent = maxExecutionEnd; + if (excludeOutliers && allExecNodes.length >= 4) { + const allEndTimes = allExecNodes.map(n => n.endMs); + const filteredEndTimes = filterOutliers(allEndTimes); + if (filteredEndTimes.length > 0) { + executionEndForParent = Math.max(...filteredEndTimes); + } + } + + result.push({ + id: 'execution', + label: 'EIP7870 Execution', + startMs: executionMinStart, + endMs: executionEndForParent, + category: 'execution', + depth: 1, + details: selectedUsername + ? `${selectedUsername}: ${totalNodes} node${totalNodes !== 1 ? 's' : ''} executed block` + : `EIP7870 reference nodes: ${formatMs(executionMinStart)} → ${formatMs(maxExecutionEnd)} (${totalNodes} nodes, ${executionClientData.length} clients)`, + isLate: executionEndForParent > ATTESTATION_DEADLINE_MS, + collapsible: totalNodes > 0, + defaultCollapsed: false, + }); + + if (selectedUsername) { + const sortedNodes = [...allExecNodes].sort((a, b) => a.startMs - b.startMs); + for (const nodeData of sortedNodes) { + const shortId = getShortNodeId(nodeData.nodeName); + const geo = nodeGeoMap.get(nodeData.nodeName) || { + country: 'Unknown', + city: '', + clientImpl: 'unknown', + clientVersion: '', + }; + const location = geo.city ? `${geo.city}, ${geo.country}` : geo.country; + + result.push({ + id: `exec-node-${nodeData.nodeName}`, + label: `${location} (${shortId})`, + startMs: nodeData.startMs, + endMs: nodeData.endMs, + category: 'execution-client', + depth: 2, + isLate: nodeData.endMs > ATTESTATION_DEADLINE_MS, + parentId: 'execution', + clientName: nodeData.impl, + clientVersion: geo.clientVersion, + nodeName: nodeData.nodeName, + country: geo.country, + city: geo.city, + }); + } + } else { + const sortedNodes = [...allExecNodes].sort((a, b) => a.startMs - b.startMs); + for (const nodeData of sortedNodes) { + const geo = nodeGeoMap.get(nodeData.nodeName); + const location = geo ? (geo.city ? `${geo.city}, ${geo.country}` : geo.country) : nodeData.cluster; + + result.push({ + id: `exec-node-${nodeData.nodeName}`, + label: `${location} (${nodeData.impl})`, + startMs: nodeData.startMs, + endMs: nodeData.endMs, + category: 'execution-client', + depth: 2, + isLate: nodeData.endMs > ATTESTATION_DEADLINE_MS, + parentId: 'execution', + clientName: nodeData.impl, + clientVersion: geo?.clientVersion, + nodeName: nodeData.nodeName, + country: geo?.country, + city: geo?.city, + }); + } + } +} + +function buildDataAvailabilitySpans( + result: TraceSpan[], + dataColumnPropagation: FctBlockDataColumnSidecarFirstSeenByNode[], + blobPropagation: FctBlockBlobFirstSeenByNode[], + selectedUsername: string | null, + selectedNodeNames: Set | null, + nodeGeoMap: Map, + excludeOutliers: boolean +): void { + const daData = dataColumnPropagation.length > 0 ? dataColumnPropagation : blobPropagation; + const isDataColumns = dataColumnPropagation.length > 0; + + if (daData.length === 0) return; + + const filteredDaData = selectedNodeNames + ? daData.filter(item => item.meta_client_name && selectedNodeNames.has(item.meta_client_name)) + : daData; + + if (selectedUsername && selectedNodeNames) { + // Group by node, then by column + const byNode = new Map< + string, + { + columns: Map; + firstSeen: number; + lastSeen: number; + columnCount: number; + country: string; + city: string; + clientImpl: string; + clientVersion: string; + } + >(); + + for (const item of filteredDaData) { + const nodeName = item.meta_client_name ?? 'unknown'; + const index = + 'column_index' in item ? (item.column_index ?? 0) : 'blob_index' in item ? (item.blob_index ?? 0) : 0; + const seenTime = item.seen_slot_start_diff ?? Infinity; + if (seenTime === Infinity || seenTime < 0 || seenTime > MAX_REASONABLE_SEEN_TIME_MS) continue; + + let nodeEntry = byNode.get(nodeName); + if (!nodeEntry) { + const geo = nodeGeoMap.get(nodeName) || { + country: 'Unknown', + city: '', + clientImpl: 'unknown', + clientVersion: '', + }; + nodeEntry = { + columns: new Map(), + firstSeen: Infinity, + lastSeen: 0, + columnCount: 0, + country: geo.country, + city: geo.city, + clientImpl: geo.clientImpl, + clientVersion: geo.clientVersion, + }; + byNode.set(nodeName, nodeEntry); + } + + if (!nodeEntry.columns.has(index)) { + nodeEntry.columns.set(index, seenTime); + nodeEntry.columnCount++; + } + nodeEntry.firstSeen = Math.min(nodeEntry.firstSeen, seenTime); + nodeEntry.lastSeen = Math.max(nodeEntry.lastSeen, seenTime); + } + + if (byNode.size > 0) { + const sortedNodes = Array.from(byNode.entries()).sort((a, b) => a[1].firstSeen - b[1].firstSeen); + const daStartMs = Math.min(...sortedNodes.map(([, d]) => d.firstSeen)); + const daEndMsRaw = Math.max(...sortedNodes.map(([, d]) => d.lastSeen)); + + let daEndMs = daEndMsRaw; + if (excludeOutliers && sortedNodes.length >= 4) { + const allLastSeens = sortedNodes.map(([, d]) => d.lastSeen); + const filteredLastSeens = filterOutliers(allLastSeens); + if (filteredLastSeens.length > 0) { + daEndMs = Math.max(...filteredLastSeens); + } + } + + result.push({ + id: 'data-availability', + label: isDataColumns ? 'Data Columns' : 'Blobs', + startMs: daStartMs, + endMs: daEndMs, + category: 'data-availability', + depth: 1, + details: `${selectedUsername}: ${sortedNodes.length} node${sortedNodes.length !== 1 ? 's' : ''} receiving ${isDataColumns ? 'columns' : 'blobs'}`, + isLate: daEndMs > ATTESTATION_DEADLINE_MS, + collapsible: true, + defaultCollapsed: false, + }); + + for (const [nodeName, nodeData] of sortedNodes) { + const shortId = getShortNodeId(nodeName); + const location = nodeData.city ? `${nodeData.city}, ${nodeData.country}` : nodeData.country; + const nodeId = `da-node-${nodeName}`; + + result.push({ + id: nodeId, + label: `${location} (${shortId})`, + startMs: nodeData.firstSeen, + endMs: nodeData.lastSeen, + category: 'column', + depth: 2, + isLate: nodeData.lastSeen > ATTESTATION_DEADLINE_MS, + parentId: 'data-availability', + collapsible: true, + defaultCollapsed: true, + nodeName, + country: nodeData.country, + city: nodeData.city, + nodeCount: nodeData.columnCount, + clientName: nodeData.clientImpl, + clientVersion: nodeData.clientVersion, + }); + + const sortedColumns = Array.from(nodeData.columns.entries()).sort((a, b) => a[1] - b[1]); + for (const [colIndex, colTime] of sortedColumns) { + result.push({ + id: `da-${nodeName}-col-${colIndex}`, + label: `${isDataColumns ? 'Col' : 'Blob'} ${colIndex}`, + startMs: colTime, + endMs: colTime + 30, + category: 'column', + depth: 3, + details: `${isDataColumns ? 'Column' : 'Blob'} ${colIndex} seen at ${formatMs(colTime)}`, + isLate: colTime > ATTESTATION_DEADLINE_MS, + isPointInTime: true, + parentId: nodeId, + }); + } + } + } + } else { + // Default view: group by column index + const itemsByIndex = new Map(); + + for (const item of filteredDaData) { + const index = + 'column_index' in item ? (item.column_index ?? 0) : 'blob_index' in item ? (item.blob_index ?? 0) : 0; + const seenTime = item.seen_slot_start_diff ?? Infinity; + if (seenTime !== Infinity && seenTime >= 0 && seenTime <= MAX_REASONABLE_SEEN_TIME_MS) { + const existing = itemsByIndex.get(index) || []; + existing.push(seenTime); + itemsByIndex.set(index, existing); + } + } + + if (itemsByIndex.size > 0) { + const columnFirstSeen = Array.from(itemsByIndex.entries()) + .map(([index, times]) => ({ + index, + firstSeenMs: Math.min(...times), + nodeCount: times.length, + })) + .sort((a, b) => a.index - b.index); + + const daStartMs = Math.min(...columnFirstSeen.map(c => c.firstSeenMs)); + const daEndMsRaw = Math.max(...columnFirstSeen.map(c => c.firstSeenMs)); + + let daEndMs = daEndMsRaw; + if (excludeOutliers && columnFirstSeen.length >= 4) { + const allFirstSeens = columnFirstSeen.map(c => c.firstSeenMs); + const filteredFirstSeens = filterOutliers(allFirstSeens); + if (filteredFirstSeens.length > 0) { + daEndMs = Math.max(...filteredFirstSeens); + } + } + + result.push({ + id: 'data-availability', + label: isDataColumns ? 'Data Columns' : 'Blobs', + startMs: daStartMs, + endMs: daEndMs, + category: 'data-availability', + depth: 1, + details: `${columnFirstSeen.length} ${isDataColumns ? 'columns' : 'blobs'} available from ${formatMs(daStartMs)} to ${formatMs(daEndMsRaw)}`, + isLate: daEndMs > ATTESTATION_DEADLINE_MS, + collapsible: true, + defaultCollapsed: true, + }); + + for (const col of columnFirstSeen) { + result.push({ + id: `column-${col.index}`, + label: `${isDataColumns ? 'Col' : 'Blob'} ${col.index}`, + startMs: col.firstSeenMs, + endMs: col.firstSeenMs + 50, + category: 'column', + depth: 2, + details: `${isDataColumns ? 'Column' : 'Blob'} ${col.index} first seen at ${formatMs(col.firstSeenMs)} (${col.nodeCount} nodes)`, + isLate: col.firstSeenMs > ATTESTATION_DEADLINE_MS, + isPointInTime: true, + nodeCount: col.nodeCount, + parentId: 'data-availability', + }); + } + } + } +} + +function buildAttestationSpans(result: TraceSpan[], attestations: FctAttestationFirstSeenChunked50Ms[]): void { + if (attestations.length === 0) return; + + const sortedAttestations = [...attestations].sort( + (a, b) => (a.chunk_slot_start_diff ?? Infinity) - (b.chunk_slot_start_diff ?? Infinity) + ); + const firstAttestation = sortedAttestations.find(a => (a.attestation_count ?? 0) > 0); + const lastAttestation = sortedAttestations.filter(a => (a.attestation_count ?? 0) > 0).pop(); + + if (firstAttestation && firstAttestation.chunk_slot_start_diff !== undefined) { + const firstAttTime = firstAttestation.chunk_slot_start_diff; + const lastAttTime = lastAttestation?.chunk_slot_start_diff ?? firstAttTime; + const totalAttestations = sortedAttestations.reduce((sum, a) => sum + (a.attestation_count ?? 0), 0); + + result.push({ + id: 'attestations', + label: 'Attestations (network)', + startMs: firstAttTime, + endMs: Math.max(lastAttTime + 50, firstAttTime + 100), + category: 'attestation', + depth: 1, + details: `${totalAttestations.toLocaleString()} attestations observed across all nodes (${formatMs(firstAttTime)} → ${formatMs(lastAttTime)})`, + isLate: firstAttTime > ATTESTATION_DEADLINE_MS + 1000, + }); + } +} diff --git a/src/pages/ethereum/slots/components/SlotProgressTimeline/utils.ts b/src/pages/ethereum/slots/components/SlotProgressTimeline/utils.ts new file mode 100644 index 000000000..a0e11d0cf --- /dev/null +++ b/src/pages/ethereum/slots/components/SlotProgressTimeline/utils.ts @@ -0,0 +1,74 @@ +/** + * Utility functions for the SlotProgressTimeline component. + */ + +import { SLOT_DURATION_MS } from './constants'; +import type { TraceSpan } from './SlotProgressTimeline.types'; + +/** Format milliseconds to readable string */ +export function formatMs(ms: number): string { + if (ms >= 1000) { + return `${(ms / 1000).toFixed(2)}s`; + } + return `${Math.round(ms)}ms`; +} + +/** Calculate percentage position on timeline */ +export function msToPercent(ms: number): number { + return Math.min(Math.max((ms / SLOT_DURATION_MS) * 100, 0), 100); +} + +/** Map classification string to category for color coding */ +export function classificationToCategory(classification: string): TraceSpan['category'] { + const lower = classification.toLowerCase(); + if (lower === 'internal') return 'internal'; + if (lower === 'individual') return 'individual'; + // Default to individual for unknown classifications + return 'individual'; +} + +/** Extract a short, unique node identifier from the full node name */ +export function getShortNodeId(nodeName: string): string { + // Node names are typically like: pub-asn-city/username/hashed-abc123 + // We want to extract just the unique part (hashed-abc123 or just abc123) + const parts = nodeName.split('/'); + const lastPart = parts[parts.length - 1] || nodeName; + // If it starts with 'hashed-', just show the hash + if (lastPart.startsWith('hashed-')) { + return lastPart.slice(7); // Remove 'hashed-' prefix + } + return lastPart; +} + +/** + * Calculate IQR-based bounds for outlier detection. + * Returns [lowerBound, upperBound] where values outside this range are outliers. + * Uses 1.5 * IQR which is the standard Tukey fence for moderate outliers. + */ +export function calculateOutlierBounds(values: number[]): { lower: number; upper: number } | null { + if (values.length < 4) return null; // Need at least 4 values for meaningful IQR + + const sorted = [...values].sort((a, b) => a - b); + const q1Index = Math.floor(sorted.length * 0.25); + const q3Index = Math.floor(sorted.length * 0.75); + const q1 = sorted[q1Index]; + const q3 = sorted[q3Index]; + const iqr = q3 - q1; + + // Use 1.5 * IQR as the standard fence + return { + lower: q1 - 1.5 * iqr, + upper: q3 + 1.5 * iqr, + }; +} + +/** + * Filter values to exclude outliers based on IQR. + * Returns only values within the acceptable range. + */ +export function filterOutliers(values: number[]): number[] { + const bounds = calculateOutlierBounds(values); + if (!bounds) return values; // Not enough data for outlier detection + + return values.filter(v => v >= bounds.lower && v <= bounds.upper); +} diff --git a/src/pages/ethereum/slots/hooks/useSlotDetailData/useSlotDetailData.ts b/src/pages/ethereum/slots/hooks/useSlotDetailData/useSlotDetailData.ts index fc7a3e1d2..b9304be6d 100644 --- a/src/pages/ethereum/slots/hooks/useSlotDetailData/useSlotDetailData.ts +++ b/src/pages/ethereum/slots/hooks/useSlotDetailData/useSlotDetailData.ts @@ -20,7 +20,7 @@ import { fctBlockProposerEntityServiceListOptions, } from '@/api/@tanstack/react-query.gen'; import { useNetwork } from '@/hooks/useNetwork'; -import { slotToTimestamp, getForkForSlot } from '@/utils/beacon'; +import { slotToTimestamp } from '@/utils/beacon'; import type { FctBlock, FctBlockHead, @@ -108,10 +108,6 @@ export function useSlotDetailData(slot: number): UseSlotDetailDataResult { // Convert slot to timestamp for querying const slotTimestamp = currentNetwork ? slotToTimestamp(slot, currentNetwork.genesis_time) : 0; - // Determine if this is a Fulu+ slot (needs data column data instead of blob data) - const forkVersion = getForkForSlot(slot, currentNetwork); - const isFuluOrLater = forkVersion === 'fulu'; - const queries = useQueries({ queries: [ // Block head data (canonical blocks only) @@ -169,7 +165,7 @@ export function useSlotDetailData(slot: number): UseSlotDetailDataResult { }), enabled: !!currentNetwork && slotTimestamp > 0, }, - // Blob propagation data (pre-Fulu) + // Blob propagation data (pre-Fulu, but always fetch to handle edge cases) { ...fctBlockBlobFirstSeenByNodeServiceListOptions({ query: { @@ -177,9 +173,9 @@ export function useSlotDetailData(slot: number): UseSlotDetailDataResult { page_size: 10000, }, }), - enabled: !!currentNetwork && slotTimestamp > 0 && !isFuluOrLater, + enabled: !!currentNetwork && slotTimestamp > 0, }, - // Data column propagation data (Fulu+) + // Data column propagation data (Fulu+, but always fetch to handle edge cases) { ...fctBlockDataColumnSidecarFirstSeenByNodeServiceListOptions({ query: { @@ -187,7 +183,7 @@ export function useSlotDetailData(slot: number): UseSlotDetailDataResult { page_size: 10000, }, }), - enabled: !!currentNetwork && slotTimestamp > 0 && isFuluOrLater, + enabled: !!currentNetwork && slotTimestamp > 0, }, // Attestation data { diff --git a/src/routes/ethereum/slots/$slot.tsx b/src/routes/ethereum/slots/$slot.tsx index 8b4e8d065..25b5bcc5d 100644 --- a/src/routes/ethereum/slots/$slot.tsx +++ b/src/routes/ethereum/slots/$slot.tsx @@ -3,7 +3,10 @@ import { z } from 'zod'; import { DetailPage } from '@/pages/ethereum/slots'; const slotSearchSchema = z.object({ - tab: z.enum(['overview', 'block', 'attestations', 'propagation', 'blobs', 'execution', 'mev']).default('overview'), + tab: z + .enum(['overview', 'timeline', 'block', 'attestations', 'propagation', 'blobs', 'execution', 'mev']) + .default('overview'), + contributor: z.string().optional(), }); export const Route = createFileRoute('/ethereum/slots/$slot')({