Skip to content

Commit e960b1a

Browse files
authored
fix(timings): aggregate all versions in collapsed client row (#441)
* fix(timings): aggregate all versions in collapsed client row The collapsed row previously showed whichever single version had the most observations, which over longer ranges is the oldest commit. It now shows a weighted aggregate across all versions for the selected period, and expanding lists every version sorted newest-first by when it was last seen. * feat(timings): show active window and observations per version Adds an Active column to the client version breakdown showing each version's activity window as a relative range (e.g. "22h – 18h ago", "18h ago – now") with absolute local dates in the tooltip. Restores the observations column on the EL Client Duration table and makes fastest-first the default sort regardless of column visibility.
1 parent 3a4edee commit e960b1a

2 files changed

Lines changed: 170 additions & 19 deletions

File tree

src/pages/ethereum/execution/timings/components/ClientVersionBreakdown/ClientVersionBreakdown.tsx

Lines changed: 170 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ interface ClientVersionData {
3232
max_duration_ms?: number;
3333
avg_returned_count?: number;
3434
status?: string;
35+
slot_start_date_time?: number;
3536
}
3637

3738
interface HourlyClientVersionData {
@@ -44,6 +45,7 @@ interface HourlyClientVersionData {
4445
min_duration_ms?: number;
4546
max_duration_ms?: number;
4647
avg_returned_count?: number;
48+
hour_start_date_time?: number;
4749
}
4850

4951
export interface ClientVersionBreakdownProps {
@@ -85,16 +87,59 @@ interface AggregatedRow {
8587
maxDuration: number;
8688
observations: number;
8789
avgBlobCount?: number;
90+
/** Epoch seconds of the earliest observation for this version within the range (0 if unknown) */
91+
firstSeen: number;
92+
/** Epoch seconds of the most recent observation for this version (0 if unknown) */
93+
lastSeen: number;
8894
}
8995

9096
interface ClientGroupedRow extends AggregatedRow {
91-
otherVersions: AggregatedRow[];
97+
/** All versions for this client, newest first (empty for single-version clients) */
98+
versions: AggregatedRow[];
9299
versionCount: number;
93100
}
94101

95-
type SortField = 'client' | 'version' | 'avgDuration' | 'p50Duration' | 'p95Duration' | 'observations' | 'avgBlobCount';
102+
type SortField =
103+
| 'client'
104+
| 'version'
105+
| 'active'
106+
| 'avgDuration'
107+
| 'p50Duration'
108+
| 'p95Duration'
109+
| 'observations'
110+
| 'avgBlobCount';
96111
type SortDirection = 'asc' | 'desc';
97112

113+
/**
114+
* Format a timestamp as a coarse relative age, e.g. "5h" or "3d" (hourly-bucketed data)
115+
*/
116+
function formatAge(timestamp: number): string {
117+
const diffHours = Math.max(0, (Date.now() / 1000 - timestamp) / 3600);
118+
if (diffHours < 1) return '<1h';
119+
if (diffHours < 48) return `${Math.round(diffHours)}h`;
120+
return `${Math.round(diffHours / 24)}d`;
121+
}
122+
123+
/**
124+
* Format an active window as a relative range, e.g. "6d ago – now" or "6d – 2d ago"
125+
*/
126+
function formatActiveWindow(firstSeen: number, lastSeen: number): string {
127+
const isCurrent = Date.now() / 1000 - lastSeen < 2 * 3600;
128+
if (isCurrent) return `${formatAge(firstSeen)} ago – now`;
129+
if (firstSeen === lastSeen) return `${formatAge(lastSeen)} ago`;
130+
return `${formatAge(firstSeen)}${formatAge(lastSeen)} ago`;
131+
}
132+
133+
/**
134+
* Format an active window as an absolute local date range for tooltips, e.g. "Jul 6, 14:00 – Jul 10, 09:00"
135+
*/
136+
function formatActiveRange(firstSeen: number, lastSeen: number): string {
137+
const options: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' };
138+
const first = new Date(firstSeen * 1000).toLocaleString(undefined, options);
139+
const last = new Date(lastSeen * 1000).toLocaleString(undefined, options);
140+
return first === last ? last : `${first}${last}`;
141+
}
142+
98143
/**
99144
* Get status badge styling
100145
*/
@@ -283,9 +328,9 @@ export function ClientVersionBreakdown({
283328
slot,
284329
durationStatusFilter,
285330
}: ClientVersionBreakdownProps): JSX.Element {
286-
// Default sort by avgDuration (ascending = fastest first) when observations hidden, otherwise by observations
287-
const [sortField, setSortField] = useState<SortField>(hideObservations ? 'avgDuration' : 'observations');
288-
const [sortDirection, setSortDirection] = useState<SortDirection>(hideObservations ? 'asc' : 'desc');
331+
// Default sort by avgDuration ascending fastest client first
332+
const [sortField, setSortField] = useState<SortField>('avgDuration');
333+
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
289334

290335
// Track which clients are expanded (for expandable mode)
291336
const [expandedClients, setExpandedClients] = useState<Set<string>>(new Set());
@@ -321,6 +366,8 @@ export function ClientVersionBreakdown({
321366
maxDuration: number;
322367
totalObservations: number;
323368
totalWeightedBlobCount: number;
369+
firstSeen: number;
370+
lastSeen: number;
324371
}
325372
>();
326373

@@ -331,6 +378,7 @@ export function ClientVersionBreakdown({
331378
const obs = row.observation_count ?? 0;
332379
const minDur = row.min_duration_ms ?? 0;
333380
const maxDur = row.max_duration_ms ?? 0;
381+
const seenAt = row.hour_start_date_time ?? 0;
334382

335383
const existing = map.get(key);
336384
if (existing) {
@@ -345,6 +393,12 @@ export function ClientVersionBreakdown({
345393
}
346394
existing.totalObservations += obs;
347395
existing.totalWeightedBlobCount += (row.avg_returned_count ?? 0) * obs;
396+
if (seenAt > existing.lastSeen) {
397+
existing.lastSeen = seenAt;
398+
}
399+
if (seenAt > 0 && (existing.firstSeen === 0 || seenAt < existing.firstSeen)) {
400+
existing.firstSeen = seenAt;
401+
}
348402
} else {
349403
map.set(key, {
350404
client,
@@ -356,6 +410,8 @@ export function ClientVersionBreakdown({
356410
maxDuration: maxDur,
357411
totalObservations: obs,
358412
totalWeightedBlobCount: (row.avg_returned_count ?? 0) * obs,
413+
firstSeen: seenAt,
414+
lastSeen: seenAt,
359415
});
360416
}
361417
});
@@ -374,6 +430,8 @@ export function ClientVersionBreakdown({
374430
maxDuration: entry.maxDuration,
375431
observations: entry.totalObservations,
376432
avgBlobCount: entry.totalWeightedBlobCount / entry.totalObservations,
433+
firstSeen: entry.firstSeen,
434+
lastSeen: entry.lastSeen,
377435
});
378436
}
379437
});
@@ -401,6 +459,8 @@ export function ClientVersionBreakdown({
401459
maxDuration: number;
402460
totalObservations: number;
403461
totalWeightedBlobCount: number;
462+
firstSeen: number;
463+
lastSeen: number;
404464
}
405465
>();
406466

@@ -411,6 +471,7 @@ export function ClientVersionBreakdown({
411471
const obs = row.observation_count ?? 0;
412472
const minDur = row.min_duration_ms ?? 0;
413473
const maxDur = row.max_duration_ms ?? 0;
474+
const seenAt = row.slot_start_date_time ?? 0;
414475

415476
// Check if this row's status matches the filter for duration calculation
416477
const statusMatches = !durationStatusFilter || row.status?.toUpperCase() === durationStatusFilter.toUpperCase();
@@ -430,6 +491,12 @@ export function ClientVersionBreakdown({
430491
}
431492
existing.totalObservations += obs;
432493
existing.totalWeightedBlobCount += (row.avg_returned_count ?? 0) * obs;
494+
if (seenAt > existing.lastSeen) {
495+
existing.lastSeen = seenAt;
496+
}
497+
if (seenAt > 0 && (existing.firstSeen === 0 || seenAt < existing.firstSeen)) {
498+
existing.firstSeen = seenAt;
499+
}
433500
} else {
434501
map.set(key, {
435502
client,
@@ -440,6 +507,8 @@ export function ClientVersionBreakdown({
440507
maxDuration: statusMatches ? maxDur : 0,
441508
totalObservations: obs,
442509
totalWeightedBlobCount: (row.avg_returned_count ?? 0) * obs,
510+
firstSeen: seenAt,
511+
lastSeen: seenAt,
443512
});
444513
}
445514
});
@@ -459,14 +528,17 @@ export function ClientVersionBreakdown({
459528
maxDuration: entry.maxDuration,
460529
observations: entry.totalObservations,
461530
avgBlobCount: entry.totalWeightedBlobCount / entry.totalObservations,
531+
firstSeen: entry.firstSeen,
532+
lastSeen: entry.lastSeen,
462533
});
463534
}
464535
});
465536

466537
return result;
467538
}, [data, hasHourlyData, hourlyAggregatedData, durationStatusFilter]);
468539

469-
// Group aggregated data by client, showing only the most-observed version per client
540+
// Group aggregated data by client. Multi-version clients get a synthetic summary row
541+
// aggregating all versions over the period; expanding lists each version newest-first.
470542
const clientGroupedData = useMemo(() => {
471543
const clientMap = new Map<string, AggregatedRow[]>();
472544

@@ -482,15 +554,64 @@ export function ClientVersionBreakdown({
482554
const result: ClientGroupedRow[] = [];
483555

484556
clientMap.forEach(versions => {
485-
// Sort by observation count descending — highest count is the "primary" version
486-
const sorted = [...versions].sort((a, b) => b.observations - a.observations);
487-
const primary = sorted[0];
488-
const others = sorted.slice(1);
557+
const sorted = [...versions].sort((a, b) => b.lastSeen - a.lastSeen || b.observations - a.observations);
558+
559+
if (sorted.length === 1) {
560+
result.push({
561+
...sorted[0],
562+
versions: [],
563+
versionCount: 1,
564+
});
565+
return;
566+
}
567+
568+
const summary = sorted.reduce(
569+
(acc, v) => {
570+
const durationObs = v.avgDuration > 0 ? v.observations : 0;
571+
acc.totalWeightedAvg += v.avgDuration * durationObs;
572+
acc.totalWeightedP50 += v.p50Duration * durationObs;
573+
acc.totalWeightedP95 += v.p95Duration * durationObs;
574+
acc.durationObs += durationObs;
575+
if (v.minDuration > 0 && (acc.minDuration === 0 || v.minDuration < acc.minDuration)) {
576+
acc.minDuration = v.minDuration;
577+
}
578+
if (v.maxDuration > acc.maxDuration) {
579+
acc.maxDuration = v.maxDuration;
580+
}
581+
acc.observations += v.observations;
582+
acc.totalWeightedBlobCount += (v.avgBlobCount ?? 0) * v.observations;
583+
if (v.firstSeen > 0 && (acc.firstSeen === 0 || v.firstSeen < acc.firstSeen)) {
584+
acc.firstSeen = v.firstSeen;
585+
}
586+
return acc;
587+
},
588+
{
589+
totalWeightedAvg: 0,
590+
totalWeightedP50: 0,
591+
totalWeightedP95: 0,
592+
durationObs: 0,
593+
minDuration: 0,
594+
maxDuration: 0,
595+
observations: 0,
596+
totalWeightedBlobCount: 0,
597+
firstSeen: 0,
598+
}
599+
);
489600

490601
result.push({
491-
...primary,
492-
otherVersions: others,
493-
versionCount: versions.length,
602+
client: sorted[0].client,
603+
version: sorted[0].version,
604+
avgDuration: summary.durationObs > 0 ? summary.totalWeightedAvg / summary.durationObs : 0,
605+
p50Duration: summary.durationObs > 0 ? summary.totalWeightedP50 / summary.durationObs : 0,
606+
p95Duration: summary.durationObs > 0 ? summary.totalWeightedP95 / summary.durationObs : 0,
607+
minDuration: summary.minDuration,
608+
maxDuration: summary.maxDuration,
609+
observations: summary.observations,
610+
avgBlobCount: summary.observations > 0 ? summary.totalWeightedBlobCount / summary.observations : 0,
611+
firstSeen: summary.firstSeen,
612+
lastSeen: sorted[0].lastSeen,
613+
versions: sorted,
614+
versionCount: sorted.length,
494615
});
495616
});
496617

@@ -502,6 +623,9 @@ export function ClientVersionBreakdown({
502623
return Math.max(...aggregatedData.map(r => r.avgDuration), 0);
503624
}, [aggregatedData]);
504625

626+
// Whether the data carries timestamps for an Active column
627+
const hasActivity = aggregatedData.some(r => r.lastSeen > 0);
628+
505629
// Sort grouped data (one row per client, showing primary version)
506630
const sortedData = useMemo(() => {
507631
return [...clientGroupedData].sort((a, b) => {
@@ -513,6 +637,9 @@ export function ClientVersionBreakdown({
513637
case 'version':
514638
comparison = a.version.localeCompare(b.version);
515639
break;
640+
case 'active':
641+
comparison = a.lastSeen - b.lastSeen || a.firstSeen - b.firstSeen;
642+
break;
516643
case 'avgDuration':
517644
comparison = a.avgDuration - b.avgDuration;
518645
break;
@@ -599,6 +726,12 @@ export function ClientVersionBreakdown({
599726
Version
600727
<SortIcon field="version" />
601728
</th>
729+
{hasActivity && (
730+
<th className={headerClass} onClick={() => handleSort('active')}>
731+
Active
732+
<SortIcon field="active" />
733+
</th>
734+
)}
602735
<th className={clsx(headerClass, 'text-right')} onClick={() => handleSort('avgDuration')}>
603736
Avg (ms)
604737
<SortIcon field="avgDuration" />
@@ -639,6 +772,7 @@ export function ClientVersionBreakdown({
639772
const canExpandNode = expandable && slot !== undefined;
640773
const colCount =
641774
3 +
775+
(hasActivity ? 1 : 0) +
642776
(hasHourlyData ? 2 : 0) +
643777
(hideRange ? 0 : 1) +
644778
(showBlobCount ? 1 : 0) +
@@ -674,6 +808,20 @@ export function ClientVersionBreakdown({
674808

675809
const renderMetricCells = (metricRow: AggregatedRow): JSX.Element => (
676810
<>
811+
{hasActivity && (
812+
<td className="px-3 py-3">
813+
{metricRow.lastSeen > 0 ? (
814+
<span
815+
className="text-sm whitespace-nowrap text-muted"
816+
title={`Active ${formatActiveRange(metricRow.firstSeen, metricRow.lastSeen)} (within the selected range)`}
817+
>
818+
{formatActiveWindow(metricRow.firstSeen, metricRow.lastSeen)}
819+
</span>
820+
) : (
821+
<span className="text-sm text-muted">-</span>
822+
)}
823+
</td>
824+
)}
677825
<td className="px-3 py-3 text-right">
678826
{metricRow.avgDuration > 0 ? (
679827
<span
@@ -767,11 +915,15 @@ export function ClientVersionBreakdown({
767915
</div>
768916
</td>
769917
<td className="px-3 py-3">
770-
<span className="font-mono text-sm text-muted">{row.version}</span>
771-
{hasMultipleVersions && (
772-
<span className="ml-2 rounded-full bg-primary/10 px-1.5 py-0.5 text-xs text-primary">
918+
{hasMultipleVersions ? (
919+
<span
920+
className="rounded-full bg-primary/10 px-1.5 py-0.5 text-xs text-primary"
921+
title={`Aggregate across ${row.versionCount} versions for the selected period`}
922+
>
773923
{row.versionCount} versions
774924
</span>
925+
) : (
926+
<span className="font-mono text-sm text-muted">{row.version}</span>
775927
)}
776928
</td>
777929
{renderMetricCells(row)}
@@ -787,10 +939,10 @@ export function ClientVersionBreakdown({
787939
/>
788940
)}
789941

790-
{/* Group expansion: other version sub-rows */}
942+
{/* Group expansion: per-version sub-rows, newest first */}
791943
{hasMultipleVersions &&
792944
isGroupExpanded &&
793-
row.otherVersions.map((subRow, subIndex) => {
945+
row.versions.map((subRow, subIndex) => {
794946
const subRowKey = `${subRow.client}-${subRow.version}`;
795947
const isSubNodeExpanded = expandedClients.has(subRowKey);
796948

src/pages/ethereum/execution/timings/components/NewPayloadTab/NewPayloadTab.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -471,7 +471,6 @@ export function NewPayloadTab({ data, timeRange }: NewPayloadTabProps): JSX.Elem
471471
<ClientVersionBreakdown
472472
data={validPayloadByElClient}
473473
hourlyData={newPayloadByElClientHourly}
474-
hideObservations
475474
hideRange
476475
noCard
477476
/>

0 commit comments

Comments
 (0)