@@ -20,7 +20,7 @@ import {
2020 useGetUserAgentMappingsQuery ,
2121} from "@/lib/store" ;
2222import { useLazyGetLogByIdQuery , useLazyGetLogsQuery } from "@/lib/store/apis/logsApi" ;
23- import type { LogEntry , LogFilters , Pagination } from "@/lib/types/logs" ;
23+ import type { DisplayLogEntry , LogEntry , LogFilters , Pagination } from "@/lib/types/logs" ;
2424import { dateUtils } from "@/lib/types/logs" ;
2525import { COMPACT_NUMBER_FORMAT } from "@/lib/utils/numbers" ;
2626import { RbacOperation , RbacResource , useRbac } from "@enterprise/lib" ;
@@ -31,6 +31,10 @@ import { parseAsSafeArrayOf, parseAsSafeString } from "@/lib/queryParamsParser";
3131import { parseAsBoolean , parseAsInteger , parseAsString , useQueryStates } from "nuqs" ;
3232import { useCallback , useEffect , useMemo , useRef , useState } from "react" ;
3333
34+ // A fallback chain is a handful of attempts, so one page covers every realistic
35+ // chain. Capped at the list endpoint's own maximum.
36+ const chainChildrenPageLimit = 1000 ;
37+
3438export default function LogsPage ( ) {
3539 const [ error , setError ] = useState < string | null > ( null ) ;
3640 const [ showEmptyState , setShowEmptyState ] = useState ( false ) ;
@@ -103,6 +107,7 @@ export default function LogsPage() {
103107 cache_hit_types : parseAsSafeArrayOf . withDefault ( [ ] ) ,
104108 metadata_filters : parseAsString . withDefault ( "" ) ,
105109 selected_log : parseAsString . withDefault ( "" ) ,
110+ grouped : parseAsBoolean . withDefault ( false ) ,
106111 } ,
107112 {
108113 history : "push" ,
@@ -114,6 +119,9 @@ export default function LogsPage() {
114119 const selectedLogId = urlState . selected_log || null ;
115120 const activeLogFetchId = useRef < string | null > ( null ) ;
116121 const polling = urlState . polling ;
122+ // Grouped view collapses fallback chains under their root. Disabled while a
123+ // session filter is active — that view is already scoped to one chain/session.
124+ const grouped = urlState . grouped && ! urlState . parent_request_id ;
117125
118126 // Convert URL state to filters and pagination for API calls
119127 const filters : LogFilters = useMemo (
@@ -302,6 +310,7 @@ export default function LogsPage() {
302310 {
303311 filters,
304312 pagination,
313+ rootsOnly : grouped ,
305314 } ,
306315 {
307316 pollingInterval : showEmptyState || polling ? 10000 : 0 ,
@@ -361,6 +370,66 @@ export default function LogsPage() {
361370 [ filters , setFilters ] ,
362371 ) ;
363372
373+ // --- Grouped view: chain expansion state -------------------------------
374+ // Children of an expanded root, keyed by root log id. Loaded lazily through
375+ // the list endpoint with the active filters plus parent_request_id, not the
376+ // sessions endpoint — the sessions endpoint ignores filters, which would show
377+ // rows the filter bar says are excluded. Filtering here keeps the expansion
378+ // consistent with child_count, which the server computes under the same
379+ // filters: every row is either a root or a child, and always matches.
380+ const [ expandedChainIds , setExpandedChainIds ] = useState < Set < string > > ( new Set ( ) ) ;
381+ const [ chainChildren , setChainChildren ] = useState < Record < string , LogEntry [ ] > > ( { } ) ;
382+ const [ loadingChainIds , setLoadingChainIds ] = useState < Set < string > > ( new Set ( ) ) ;
383+ const [ triggerGetChainChildren ] = useLazyGetLogsQuery ( ) ;
384+
385+ // Collapse everything when the page of roots changes — expanded ids from the
386+ // previous page are meaningless and cached children may be stale.
387+ useEffect ( ( ) => {
388+ setExpandedChainIds ( new Set ( ) ) ;
389+ setChainChildren ( { } ) ;
390+ setLoadingChainIds ( new Set ( ) ) ;
391+ } , [ filters , pagination , grouped ] ) ;
392+
393+ const handleToggleChain = useCallback (
394+ ( log : LogEntry ) => {
395+ const isExpanded = expandedChainIds . has ( log . id ) ;
396+ setExpandedChainIds ( ( prev ) => {
397+ const next = new Set ( prev ) ;
398+ if ( next . has ( log . id ) ) {
399+ next . delete ( log . id ) ;
400+ } else {
401+ next . add ( log . id ) ;
402+ }
403+ return next ;
404+ } ) ;
405+ if ( isExpanded || chainChildren [ log . id ] || loadingChainIds . has ( log . id ) ) return ;
406+
407+ setLoadingChainIds ( ( prev ) => new Set ( prev ) . add ( log . id ) ) ;
408+ triggerGetChainChildren ( {
409+ filters : { ...filters , parent_request_id : log . id } ,
410+ pagination : { ...pagination , limit : chainChildrenPageLimit , offset : 0 , sort_by : "timestamp" , order : "asc" } ,
411+ } ) . then ( ( result ) => {
412+ setLoadingChainIds ( ( prev ) => {
413+ const next = new Set ( prev ) ;
414+ next . delete ( log . id ) ;
415+ return next ;
416+ } ) ;
417+ if ( result . data ) {
418+ const children = result . data . logs ;
419+ setChainChildren ( ( prevCache ) => ( { ...prevCache , [ log . id ] : children } ) ) ;
420+ } else if ( result . error ) {
421+ setExpandedChainIds ( ( prev ) => {
422+ const next = new Set ( prev ) ;
423+ next . delete ( log . id ) ;
424+ return next ;
425+ } ) ;
426+ setError ( getErrorMessage ( result . error ) ) ;
427+ }
428+ } ) ;
429+ } ,
430+ [ expandedChainIds , chainChildren , loadingChainIds , triggerGetChainChildren , filters , pagination ] ,
431+ ) ;
432+
364433 const handleDelete = useCallback (
365434 async ( log : LogEntry ) => {
366435 try {
@@ -500,8 +569,8 @@ export default function LogsPage() {
500569 } , [ userAgentMappingsData ?. mappings ] ) ;
501570
502571 const columns = useMemo (
503- ( ) => createColumns ( handleDelete , hasDeleteAccess , metadataKeys , customAppIcons ) ,
504- [ customAppIcons , handleDelete , hasDeleteAccess , metadataKeys ] ,
572+ ( ) => createColumns ( handleDelete , hasDeleteAccess , metadataKeys , customAppIcons , grouped ) ,
573+ [ customAppIcons , handleDelete , hasDeleteAccess , metadataKeys , grouped ] ,
505574 ) ;
506575
507576 const columnIds = useMemo (
@@ -546,12 +615,36 @@ export default function LogsPage() {
546615 paramName : "cols" ,
547616 storageKey : "bifrost.logs.cols" ,
548617 defaultHidden : DEFAULT_HIDDEN_COLUMNS ,
549- fixedColumns : hasDeleteAccess ? { right : [ "actions" ] } : undefined ,
618+ fixedColumns : {
619+ ...( grouped ? { left : [ "expand" ] } : { } ) ,
620+ ...( hasDeleteAccess ? { right : [ "actions" ] } : { } ) ,
621+ } ,
550622 } ) ;
551623
552624 // Navigation for log detail sheet
553625 const logs = logsData ?. logs ?? [ ] ;
554626 const totalItems = logsData ?. stats ?. total_requests ?? 0 ;
627+
628+ // Grouped view: splice loaded children in below their expanded root. Children
629+ // are marked so the table can indent them; they don't affect pagination.
630+ const displayLogs : DisplayLogEntry [ ] = useMemo ( ( ) => {
631+ if ( ! grouped || expandedChainIds . size === 0 ) return logs ;
632+ const out : DisplayLogEntry [ ] = [ ] ;
633+ for ( const log of logs ) {
634+ out . push ( log ) ;
635+ if ( expandedChainIds . has ( log . id ) ) {
636+ for ( const child of chainChildren [ log . id ] ?? [ ] ) {
637+ out . push ( { ...child , __chainChild : true } ) ;
638+ }
639+ }
640+ }
641+ return out ;
642+ } , [ logs , grouped , expandedChainIds , chainChildren ] ) ;
643+
644+ const tableMeta = useMemo (
645+ ( ) => ( { expandedChainIds, loadingChainIds, onToggleChain : handleToggleChain } ) ,
646+ [ expandedChainIds , loadingChainIds , handleToggleChain ] ,
647+ ) ;
555648 const selectedLogFromData = useMemo (
556649 ( ) => ( selectedLogId ? ( logs . find ( ( l ) => l . id === selectedLogId ) ?? null ) : null ) ,
557650 [ selectedLogId , logs ] ,
@@ -595,6 +688,7 @@ export default function LogsPage() {
595688 triggerGetLogs ( {
596689 filters,
597690 pagination : { ...pagination , offset : newOffset } ,
691+ rootsOnly : grouped ,
598692 } ) . then ( ( result ) => {
599693 if ( result . data ?. logs ?. length ) {
600694 const lastLog = result . data . logs [ result . data . logs . length - 1 ] ;
@@ -620,6 +714,7 @@ export default function LogsPage() {
620714 triggerGetLogs ( {
621715 filters,
622716 pagination : { ...pagination , offset : newOffset } ,
717+ rootsOnly : grouped ,
623718 } ) . then ( ( result ) => {
624719 if ( result . data ?. logs ?. length ) {
625720 const firstLog = result . data . logs [ 0 ] ;
@@ -635,7 +730,7 @@ export default function LogsPage() {
635730 }
636731 }
637732 } ,
638- [ selectedLogId , selectedLogIndex , logs , pagination , totalItems , filters , setUrlState , triggerGetLogs ] ,
733+ [ selectedLogId , selectedLogIndex , logs , pagination , totalItems , filters , grouped , setUrlState , triggerGetLogs ] ,
639734 ) ;
640735
641736 return (
@@ -665,6 +760,8 @@ export default function LogsPage() {
665760 loading = { logsIsFetching }
666761 polling = { polling }
667762 onPollToggle = { handlePollToggle }
763+ grouped = { grouped }
764+ onGroupedToggle = { ( enabled ) => setUrlState ( { grouped : enabled , offset : 0 } ) }
668765 period = { period }
669766 onPeriodChange = { handlePeriodChange }
670767 totalLogs = { totalItems }
@@ -736,7 +833,8 @@ export default function LogsPage() {
736833 < div className = "min-h-0 flex-1" >
737834 < LogsDataTable
738835 columns = { columns }
739- data = { logs }
836+ data = { displayLogs }
837+ tableMeta = { tableMeta }
740838 loading = { logsIsFetching }
741839 totalItems = { totalItems }
742840 pagination = { pagination }
0 commit comments