@@ -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
3738interface 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
4951export 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
9096interface 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' ;
96111type 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
0 commit comments