diff --git a/src/pages/ethereum/execution/timings/IndexPage.types.ts b/src/pages/ethereum/execution/timings/IndexPage.types.ts index 3cecef15c..4d1fbc657 100644 --- a/src/pages/ethereum/execution/timings/IndexPage.types.ts +++ b/src/pages/ethereum/execution/timings/IndexPage.types.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; /** * Time range options for engine timing data */ -export type TimeRange = '1hour' | '6hours' | '24hours' | '7days' | '31days'; +export type TimeRange = '1hour' | '6hours' | '24hours' | '7days' | '31days' | '90days' | '180days' | '365days'; /** * Time range configuration with labels and duration in seconds @@ -14,6 +14,9 @@ export const TIME_RANGE_CONFIG: Record; } + // getBlobs data is only aggregated hourly or finer, which is too heavy for long ranges (90d+) + if (!HOURLY_CHART_RANGES.includes(timeRange)) { + return ( +
+ + +
+ ); + } + const { getBlobsByElClient, getBlobsByElClientHourly, getBlobsDurationHistogram } = data; // Check if we should show per-slot charts (only for short time ranges) diff --git a/src/pages/ethereum/execution/timings/components/NewPayloadTab/NewPayloadTab.tsx b/src/pages/ethereum/execution/timings/components/NewPayloadTab/NewPayloadTab.tsx index 9cfc0f12e..e03bf7418 100644 --- a/src/pages/ethereum/execution/timings/components/NewPayloadTab/NewPayloadTab.tsx +++ b/src/pages/ethereum/execution/timings/components/NewPayloadTab/NewPayloadTab.tsx @@ -10,10 +10,15 @@ import { ScatterAndLineChart } from '@/components/Charts/ScatterAndLine'; import { useThemeColors } from '@/hooks/useThemeColors'; import { formatSlot, getExecutionClientColor } from '@/utils'; import { ClientLogo } from '@/components/Ethereum/ClientLogo'; -import type { FctEngineNewPayloadWinrateHourly, IntEngineNewPayloadFastestExecutionByNodeClass } from '@/api/types.gen'; +import type { + FctEngineNewPayloadWinrateHourly, + FctEngineNewPayloadWinrateDaily, + IntEngineNewPayloadFastestExecutionByNodeClass, +} from '@/api/types.gen'; import type { EngineTimingsData } from '../../hooks/useEngineTimingsData'; -import { PER_SLOT_CHART_RANGES, type TimeRange } from '../../IndexPage.types'; +import { PER_SLOT_CHART_RANGES, HOURLY_CHART_RANGES, type TimeRange } from '../../IndexPage.types'; import { ClientVersionBreakdown } from '../ClientVersionBreakdown'; +import { RangeUnavailableNote } from '../RangeUnavailableNote'; export interface NewPayloadTabProps { data: EngineTimingsData; @@ -30,12 +35,16 @@ export function NewPayloadTab({ data, timeRange }: NewPayloadTabProps): JSX.Elem newPayloadByElClient, newPayloadByElClientHourly, winrateHourly, + winrateDaily, winratePerSlot, } = data; // Check if we should show per-slot charts (only for short time ranges) const showPerSlotCharts = PER_SLOT_CHART_RANGES.includes(timeRange); + // Long ranges (90d+) only fetch daily winrate data - hourly-based sections are unavailable + const showHourlyCharts = HOURLY_CHART_RANGES.includes(timeRange); + // Filter to VALID status only for duration-based charts const validPayloadByElClient = newPayloadByElClient.filter(r => r.status?.toUpperCase() === 'VALID'); @@ -454,10 +463,12 @@ export function NewPayloadTab({ data, timeRange }: NewPayloadTabProps): JSX.Elem {/* Execution Winrate — fastest client per slot */} {/* Client Version Breakdown — VALID status only */} @@ -467,14 +478,18 @@ export function NewPayloadTab({ data, timeRange }: NewPayloadTabProps): JSX.Elem anchorId="client-duration" modalSize="full" > - {() => ( - - )} + {() => + showHourlyCharts ? ( + + ) : ( + + ) + } {/* Per-slot charts — short time ranges only */} @@ -570,7 +585,7 @@ export function NewPayloadTab({ data, timeRange }: NewPayloadTabProps): JSX.Elem )} {/* Hourly charts — longer time ranges */} - {!showPerSlotCharts && ( + {!showPerSlotCharts && showHourlyCharts && (
{hourlyTrendSeries.length > 0 && hourlyTrendSeries.some(s => s.data.length > 0) && ( , allClients: string[]) /** Winrate chart + standings for engine_newPayload fastest client per slot */ function WinrateSection({ hourlyRecords, + dailyRecords, perSlotRecords, allClients, timeRange, showPerSlot, + showDaily, }: { hourlyRecords: FctEngineNewPayloadWinrateHourly[]; + dailyRecords: FctEngineNewPayloadWinrateDaily[]; perSlotRecords: IntEngineNewPayloadFastestExecutionByNodeClass[]; allClients: string[]; timeRange: TimeRange; showPerSlot: boolean; + showDaily: boolean; }): JSX.Element | null { const { chartConfig, standings, tooltipLabels } = useMemo(() => { if (showPerSlot) { @@ -928,7 +947,77 @@ function WinrateSection({ }; } - // Hourly mode (24h / 7d) + // Daily mode (90d+): one bar per day, win_count normalized by that day's total + // Ties award full credit to each tied client, so totals may exceed slot counts + if (showDaily) { + if (!dailyRecords.length && !allClients.length) + return { chartConfig: null, standings: [], tooltipLabels: [] as string[] }; + + const byDayAndImpl = new Map>(); + const allImpls = new Set(); + + for (const r of dailyRecords) { + const day = r.day_start_date ?? ''; + const impl = normalizeClient(r.meta_execution_implementation ?? ''); + const count = r.win_count ?? 0; + if (!impl || !day) continue; + + allImpls.add(impl); + if (!byDayAndImpl.has(day)) byDayAndImpl.set(day, new Map()); + const dayMap = byDayAndImpl.get(day)!; + dayMap.set(impl, (dayMap.get(impl) ?? 0) + count); + } + + // YYYY-MM-DD strings sort chronologically + const sortedDays = [...byDayAndImpl.keys()].sort(); + if (!sortedDays.length) return { chartConfig: null, standings: [] }; + + const totalsByDay = new Map(); + for (const day of sortedDays) { + const implMap = byDayAndImpl.get(day)!; + let total = 0; + for (const count of implMap.values()) total += count; + totalsByDay.set(day, total); + } + + const labels = sortedDays.map(day => { + const date = new Date(day + 'T00:00:00Z'); + return date.toLocaleDateString([], { month: 'short', day: 'numeric', timeZone: 'UTC' }); + }); + + const tooltipLabels = sortedDays.map(day => { + const date = new Date(day + 'T00:00:00Z'); + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }); + }); + + const sortedImpls = [...allImpls].sort(); + const series: SeriesData[] = sortedImpls.map((impl, i) => ({ + name: impl, + data: sortedDays.map(day => { + const count = byDayAndImpl.get(day)?.get(impl) ?? 0; + const total = totalsByDay.get(day) ?? 0; + return total > 0 ? Number(((count / total) * 100).toFixed(2)) : null; + }), + color: getExecutionClientColor(impl, i), + seriesType: 'bar' as const, + stack: 'winrate', + })); + + const totalsByImpl = new Map(); + for (const r of dailyRecords) { + const impl = normalizeClient(r.meta_execution_implementation ?? ''); + if (!impl) continue; + totalsByImpl.set(impl, (totalsByImpl.get(impl) ?? 0) + (r.win_count ?? 0)); + } + + return { + chartConfig: { labels, series }, + standings: buildStandings(totalsByImpl, allClients), + tooltipLabels, + }; + } + + // Hourly mode (24h / 7d / 31d) if (!hourlyRecords.length && !allClients.length) return { chartConfig: null, standings: [], tooltipLabels: [] as string[] }; @@ -1014,7 +1103,7 @@ function WinrateSection({ standings: buildStandings(totalsByImpl, allClients), tooltipLabels, }; - }, [hourlyRecords, perSlotRecords, allClients, timeRange, showPerSlot]); + }, [hourlyRecords, dailyRecords, perSlotRecords, allClients, timeRange, showPerSlot, showDaily]); // Custom tooltip formatter that shows full timestamps instead of abbreviated axis labels const winrateTooltipFormatter = useMemo(() => { diff --git a/src/pages/ethereum/execution/timings/components/RangeUnavailableNote/RangeUnavailableNote.tsx b/src/pages/ethereum/execution/timings/components/RangeUnavailableNote/RangeUnavailableNote.tsx new file mode 100644 index 000000000..19b9a5e5d --- /dev/null +++ b/src/pages/ethereum/execution/timings/components/RangeUnavailableNote/RangeUnavailableNote.tsx @@ -0,0 +1,13 @@ +import type { JSX } from 'react'; + +/** + * Empty-state note for sections whose backing data is only aggregated + * at hourly or finer granularity and is too heavy to fetch for long ranges + */ +export function RangeUnavailableNote(): JSX.Element { + return ( +
+ Not available for ranges beyond 31 days +
+ ); +} diff --git a/src/pages/ethereum/execution/timings/components/RangeUnavailableNote/index.ts b/src/pages/ethereum/execution/timings/components/RangeUnavailableNote/index.ts new file mode 100644 index 000000000..3aee352df --- /dev/null +++ b/src/pages/ethereum/execution/timings/components/RangeUnavailableNote/index.ts @@ -0,0 +1 @@ +export { RangeUnavailableNote } from './RangeUnavailableNote'; diff --git a/src/pages/ethereum/execution/timings/hooks/useEngineTimingsData.ts b/src/pages/ethereum/execution/timings/hooks/useEngineTimingsData.ts index 934913314..5cd9ef0e5 100644 --- a/src/pages/ethereum/execution/timings/hooks/useEngineTimingsData.ts +++ b/src/pages/ethereum/execution/timings/hooks/useEngineTimingsData.ts @@ -8,6 +8,7 @@ import { fctEngineGetBlobsByElClientHourlyServiceList, fctEngineGetBlobsDurationChunked50MsServiceList, fctEngineNewPayloadWinrateHourlyServiceList, + fctEngineNewPayloadWinrateDailyServiceList, intEngineNewPayloadFastestExecutionByNodeClassServiceList, } from '@/api/sdk.gen'; import type { @@ -18,11 +19,12 @@ import type { FctEngineGetBlobsByElClientHourly, FctEngineGetBlobsDurationChunked50Ms, FctEngineNewPayloadWinrateHourly, + FctEngineNewPayloadWinrateDaily, IntEngineNewPayloadFastestExecutionByNodeClass, } from '@/api/types.gen'; import { useNetwork } from '@/hooks/useNetwork'; import { fetchAllPages } from '@/utils/api-pagination'; -import { TIME_RANGE_CONFIG, PER_SLOT_CHART_RANGES, type TimeRange } from '../IndexPage.types'; +import { TIME_RANGE_CONFIG, PER_SLOT_CHART_RANGES, HOURLY_CHART_RANGES, type TimeRange } from '../IndexPage.types'; export interface EngineTimingsData { // newPayload per-EL-client aggregations (includes per-slot granularity) @@ -46,6 +48,9 @@ export interface EngineTimingsData { // newPayload winrate (hourly aggregated) winrateHourly: FctEngineNewPayloadWinrateHourly[]; + // newPayload winrate (daily aggregated, for long time ranges) + winrateDaily: FctEngineNewPayloadWinrateDaily[]; + // newPayload winrate (per-slot, for short time ranges) winratePerSlot: IntEngineNewPayloadFastestExecutionByNodeClass[]; } @@ -85,6 +90,25 @@ function getTimeRangeTimestamps(range: TimeRange): { return { hourlyStart, hourlyEnd }; } +/** + * Generate all YYYY-MM-DD UTC date strings in a timestamp range, joined by commas. + * The daily winrate table keys on a string date column whose generated filters + * have no gte/lt variants, so ranges are expressed via day_start_date_in_values. + */ +function generateDateRange(startTs: number, endTs: number): string { + const dates: string[] = []; + const current = new Date(startTs * 1000); + current.setUTCHours(0, 0, 0, 0); + const endDate = new Date(endTs * 1000); + endDate.setUTCHours(23, 59, 59, 999); + + while (current <= endDate) { + dates.push(current.toISOString().slice(0, 10)); + current.setUTCDate(current.getUTCDate() + 1); + } + return dates.join(','); +} + /** * Hook to fetch all engine timing data based on time range. * Uses fetchAllPages to handle pagination automatically for all endpoints. @@ -111,6 +135,10 @@ export function useEngineTimingsData({ // Only fetch per-slot data for short time ranges (per-slot charts not shown for 24h, 7d) const fetchPerSlotData = PER_SLOT_CHART_RANGES.includes(timeRange); + // Hourly and per-slot tables are too heavy for long ranges (90d+), + // which rely solely on the daily winrate aggregation + const fetchHourlyData = HOURLY_CHART_RANGES.includes(timeRange); + const queries = useQueries({ queries: [ // newPayload per-EL-client aggregations (includes per-slot data) @@ -155,7 +183,7 @@ export function useEngineTimingsData({ 'fct_engine_new_payload_by_el_client_hourly', signal ), - enabled: !!currentNetwork, + enabled: !!currentNetwork && fetchHourlyData, placeholderData: keepPreviousData, }, // newPayload duration histogram @@ -223,7 +251,7 @@ export function useEngineTimingsData({ 'fct_engine_get_blobs_by_el_client_hourly', signal ), - enabled: !!currentNetwork && fetchBlobs, + enabled: !!currentNetwork && fetchBlobs && fetchHourlyData, placeholderData: keepPreviousData, }, // getBlobs duration histogram @@ -268,7 +296,28 @@ export function useEngineTimingsData({ 'fct_engine_new_payload_winrate_hourly', signal ), - enabled: !!currentNetwork && !fetchPerSlotData, + enabled: !!currentNetwork && !fetchPerSlotData && fetchHourlyData, + placeholderData: keepPreviousData, + }, + // newPayload winrate (daily) - lightweight aggregation for long ranges (90d+) + // Must apply refNodeFilter to avoid double-counting across node_classes + { + queryKey: ['engine-timings', 'winrate-daily', hourlyStart, hourlyEnd, referenceNodesOnly], + queryFn: ({ signal }) => + fetchAllPages( + fctEngineNewPayloadWinrateDailyServiceList, + { + query: { + day_start_date_in_values: generateDateRange(hourlyStart, hourlyEnd), + order_by: 'day_start_date ASC', + page_size: 10000, + ...refNodeFilter, + }, + }, + 'fct_engine_new_payload_winrate_daily', + signal + ), + enabled: !!currentNetwork && !fetchHourlyData, placeholderData: keepPreviousData, }, // newPayload winrate (per-slot) - raw fastest client per slot for short ranges @@ -304,25 +353,28 @@ export function useEngineTimingsData({ getBlobsByElClientHourlyQuery, getBlobsDurationHistogramQuery, winrateHourlyQuery, + winrateDailyQuery, winratePerSlotQuery, ] = queries; - // Check loading state for newPayload queries (first 3) + // Primary queries drive the page's loading/empty state: the newPayload queries + // for hourly-capable ranges, or the daily winrate (the only data) for long ranges const newPayloadQueries = queries.slice(0, 3); + const primaryQueries = fetchHourlyData ? newPayloadQueries : [winrateDailyQuery]; const blobQueries = queries.slice(3, 6); // Initial loading = no data has ever been fetched yet - const hasAnyNewPayloadData = newPayloadQueries.some(q => q.data !== undefined); - const isLoading = !hasAnyNewPayloadData && newPayloadQueries.some(q => q.isFetching); + const hasAnyPrimaryData = primaryQueries.some(q => q.data !== undefined); + const isLoading = !hasAnyPrimaryData && primaryQueries.some(q => q.isFetching); const isLoadingBlobs = blobQueries.some(q => q.isLoading); // Check for errors (only core queries - winrate is supplementary and shouldn't break the page) - const coreQueries = [...newPayloadQueries, ...blobQueries]; + const coreQueries = [...primaryQueries, ...blobQueries]; const error = coreQueries.find(q => q.error)?.error as Error | null; // Build data object whenever any query has data (stale data is fine during refetch) const freshData: EngineTimingsData | null = - hasAnyNewPayloadData && !error + hasAnyPrimaryData && !error ? { newPayloadByElClient: newPayloadByElClientQuery.data ?? [], newPayloadByElClientHourly: newPayloadByElClientHourlyQuery.data ?? [], @@ -331,6 +383,7 @@ export function useEngineTimingsData({ getBlobsByElClientHourly: getBlobsByElClientHourlyQuery.data ?? [], getBlobsDurationHistogram: getBlobsDurationHistogramQuery.data ?? [], winrateHourly: winrateHourlyQuery.data ?? [], + winrateDaily: winrateDailyQuery.data ?? [], winratePerSlot: winratePerSlotQuery.data ?? [], } : null;