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;