@@ -40,6 +40,7 @@ import {
4040 MenuDivider ,
4141 MenuItem ,
4242 MenuList ,
43+ Spinner ,
4344 Tooltip ,
4445 useBreakpoint ,
4546 useToast ,
@@ -194,6 +195,22 @@ const defaultSelectChakraStyles = {
194195 } ) ,
195196} as const ;
196197
198+ const maxResultsSelectChakraStyles = {
199+ control : ( provided : Record < string , unknown > ) => ( {
200+ ...provided ,
201+ minWidth : '140px' , // Ensure enough width for "∞ Unlimited"
202+ } ) ,
203+ option : ( provided : Record < string , unknown > ) => ( {
204+ ...provided ,
205+ wordBreak : 'keep-all' ,
206+ whiteSpace : 'nowrap' ,
207+ } ) ,
208+ menuList : ( provided : Record < string , unknown > ) => ( {
209+ ...provided ,
210+ minWidth : '140px' , // Match control width
211+ } ) ,
212+ } as const ;
213+
197214const inlineSelectChakraStyles = {
198215 ...defaultSelectChakraStyles ,
199216 control : ( provided : Record < string , unknown > ) => ( {
@@ -451,8 +468,16 @@ export const TopicMessageView: FC<TopicMessageViewProps> = (props) => {
451468 const [ totalMessagesConsumed , setTotalMessagesConsumed ] = useState ( 0 ) ;
452469 const [ elapsedMs , setElapsedMs ] = useState < number | null > ( null ) ;
453470
471+ // Pagination state
472+ const [ messageSearch , setMessageSearch ] = useState < ReturnType < typeof createMessageSearch > | null > ( null ) ;
473+ const [ isLoadingMore , setIsLoadingMore ] = useState ( false ) ;
474+
454475 const currentSearchRunRef = useRef < string | null > ( null ) ;
455476 const abortControllerRef = useRef < AbortController | null > ( null ) ;
477+ const prevStartOffsetRef = useRef < number > ( startOffset ) ;
478+ const prevMaxResultsRef = useRef < number > ( maxResults ) ;
479+ const prevPageIndexRef = useRef < number > ( pageIndex ) ;
480+ const [ forceRefresh , setForceRefresh ] = useState ( 0 ) ;
456481
457482 // Filter messages based on quick search
458483 const filteredMessages = quickSearch
@@ -483,6 +508,46 @@ export const TopicMessageView: FC<TopicMessageViewProps> = (props) => {
483508 [ ]
484509 ) ;
485510
511+ // Clear sorting when entering unlimited pagination mode
512+ useEffect ( ( ) => {
513+ if ( maxResults === - 1 && sorting . length > 0 ) {
514+ setSortingState ( [ ] ) ;
515+ }
516+ } , [ maxResults , sorting . length , setSortingState ] ) ;
517+
518+ // Reset to page 1 when start offset changes (e.g., switching from Newest to Beginning)
519+ useEffect ( ( ) => {
520+ // Only reset if startOffset actually changed (not on initial mount or re-renders)
521+ if ( prevStartOffsetRef . current !== startOffset ) {
522+ setPageIndex ( 0 ) ;
523+ prevStartOffsetRef . current = startOffset ;
524+ }
525+ } , [ startOffset , setPageIndex ] ) ;
526+
527+ // Reset to page 1 when max results changes (e.g., switching from Unlimited to fixed size)
528+ useEffect ( ( ) => {
529+ // Only reset if maxResults actually changed (not on initial mount or re-renders)
530+ if ( prevMaxResultsRef . current !== maxResults ) {
531+ setPageIndex ( 0 ) ;
532+ prevMaxResultsRef . current = maxResults ;
533+ }
534+ } , [ maxResults , setPageIndex ] ) ;
535+
536+ // Reset maxResults to 50 when switching to Latest/Live from unlimited
537+ const prevStartOffsetForMaxResultsRef = useRef < number > ( startOffset ) ;
538+ useEffect ( ( ) => {
539+ // Only reset if we just switched TO Latest/Live (not already on it)
540+ const justSwitchedToLatestLive =
541+ prevStartOffsetForMaxResultsRef . current !== PartitionOffsetOrigin . End &&
542+ startOffset === PartitionOffsetOrigin . End ;
543+
544+ if ( justSwitchedToLatestLive && maxResults === - 1 ) {
545+ setMaxResults ( 50 ) ;
546+ }
547+
548+ prevStartOffsetForMaxResultsRef . current = startOffset ;
549+ } , [ startOffset , maxResults , setMaxResults ] ) ;
550+
486551 // Convert executeMessageSearch to useCallback
487552 // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: complex business logic
488553 const executeMessageSearch = useCallback (
@@ -520,12 +585,17 @@ export const TopicMessageView: FC<TopicMessageViewProps> = (props) => {
520585 }
521586 }
522587
523- const request = {
524- topicName : props . topic . topicName ,
525- partitionId : partitionID ,
526- startOffset,
527- startTimestamp : currentSearchParams ?. startTimestamp ?? uiState . topicSettings . searchParams . startTimestamp ,
528- maxResults,
588+ // Calculate backend page size: for pagination mode (maxResults === -1),
589+ // fetch exactly 1 page at a time to handle compaction gaps reliably
590+ const backendPageSize = maxResults === - 1 ? pageSize : undefined ;
591+
592+ const request = {
593+ topicName : props . topic . topicName ,
594+ partitionId : partitionID ,
595+ startOffset,
596+ startTimestamp : currentSearchParams ?. startTimestamp ?? uiState . topicSettings . searchParams . startTimestamp ,
597+ maxResults,
598+ pageSize : backendPageSize ,
529599 filterInterpreterCode : encodeBase64 ( sanitizeString ( filterCode ) ) ,
530600 includeRawPayload : true ,
531601 keyDeserializer,
@@ -536,10 +606,11 @@ export const TopicMessageView: FC<TopicMessageViewProps> = (props) => {
536606 setFetchError ( null ) ;
537607 setSearchPhase ( 'Searching...' ) ;
538608
539- const messageSearch = createMessageSearch ( ) ;
609+ const search = createMessageSearch ( ) ;
610+ setMessageSearch ( search ) ;
540611 const startTime = Date . now ( ) ;
541612
542- const result = await messageSearch . startSearch ( request , abortSignal ) . catch ( ( err : Error ) => {
613+ const result = await search . startSearch ( request , abortSignal ) . catch ( ( err : Error ) => {
543614 const msg = err . message ?? String ( err ) ;
544615 // biome-ignore lint/suspicious/noConsole: intentional console usage
545616 console . error ( `error in searchTopicMessages: ${ msg } ` ) ;
@@ -552,8 +623,8 @@ export const TopicMessageView: FC<TopicMessageViewProps> = (props) => {
552623 setMessages ( result ) ;
553624 setSearchPhase ( null ) ;
554625 setElapsedMs ( endTime - startTime ) ;
555- setBytesConsumed ( messageSearch . bytesConsumed ) ;
556- setTotalMessagesConsumed ( messageSearch . totalMessagesConsumed ) ;
626+ setBytesConsumed ( search . bytesConsumed ) ;
627+ setTotalMessagesConsumed ( search . totalMessagesConsumed ) ;
557628
558629 return result ;
559630 } catch ( error : unknown ) {
@@ -569,12 +640,12 @@ export const TopicMessageView: FC<TopicMessageViewProps> = (props) => {
569640 partitionID ,
570641 startOffset ,
571642 maxResults ,
572- getSearchParams ,
573- keyDeserializer ,
574- valueDeserializer ,
575- filters ,
576- ]
577- ) ;
643+ pageSize ,
644+ getSearchParams ,
645+ keyDeserializer ,
646+ valueDeserializer ,
647+ filters ,
648+ ] ) ;
578649
579650 // Convert searchFunc to useCallback
580651 const searchFunc = useCallback (
@@ -628,6 +699,7 @@ export const TopicMessageView: FC<TopicMessageViewProps> = (props) => {
628699 ) ;
629700
630701 // Auto search when parameters change
702+ // biome-ignore lint/correctness/useExhaustiveDependencies: forceRefresh is intentionally watched to trigger forced re-search
631703 useEffect ( ( ) => {
632704 // Set up auto-search with 100ms delay
633705 const timer = setTimeout ( ( ) => {
@@ -637,7 +709,71 @@ export const TopicMessageView: FC<TopicMessageViewProps> = (props) => {
637709 appGlobal . searchMessagesFunc = searchFunc ;
638710
639711 return ( ) => clearTimeout ( timer ) ;
640- } , [ searchFunc ] ) ;
712+ } , [ searchFunc , forceRefresh ] ) ;
713+
714+ // Auto-load more messages when user reaches the last loaded page (pagination mode only)
715+ useEffect ( ( ) => {
716+ // Only auto-load in pagination mode (maxResults === -1) and when filters are not active
717+ if (
718+ maxResults !== - 1 ||
719+ filters . length > 0 ||
720+ ! messageSearch ||
721+ ! messageSearch . nextPageToken ||
722+ isLoadingMore ||
723+ searchPhase
724+ ) {
725+ return ;
726+ }
727+
728+ const totalLoadedPages = Math . ceil ( messages . length / pageSize ) ;
729+ const isOnLastPage = pageIndex >= totalLoadedPages - 1 ;
730+
731+ if ( isOnLastPage && messageSearch . nextPageToken ) {
732+ setIsLoadingMore ( true ) ;
733+ messageSearch
734+ . loadMore ( )
735+ . then ( ( ) => {
736+ setMessages ( [ ...messageSearch . messages ] ) ;
737+ } )
738+ . catch ( ( err ) => {
739+ toast ( {
740+ title : 'Failed to load more messages' ,
741+ description : ( err as Error ) . message ,
742+ status : 'error' ,
743+ duration : 5000 ,
744+ isClosable : true ,
745+ } ) ;
746+ } )
747+ . finally ( ( ) => {
748+ setIsLoadingMore ( false ) ;
749+ } ) ;
750+ }
751+ } , [
752+ pageIndex ,
753+ maxResults ,
754+ filters . length ,
755+ messageSearch ,
756+ isLoadingMore ,
757+ searchPhase ,
758+ messages . length ,
759+ pageSize ,
760+ toast ,
761+ ] ) ;
762+
763+ // Reset pagination when navigating back to page 1 in unlimited mode
764+ // This prevents keeping many pages in memory and triggering excessive requests
765+ useEffect ( ( ) => {
766+ // Check if we're in unlimited mode and user navigated back to page 1 from a higher page
767+ if ( maxResults === - 1 && pageIndex === 0 && prevPageIndexRef . current > 1 && messageSearch ) {
768+ // Clear the message search and state
769+ setMessages ( [ ] ) ;
770+ setMessageSearch ( null ) ;
771+ // Clear the search run ref and trigger a forced refresh
772+ currentSearchRunRef . current = null ;
773+ setForceRefresh ( ( prev ) => prev + 1 ) ;
774+ }
775+ prevPageIndexRef . current = pageIndex ;
776+ } , [ pageIndex , maxResults , messageSearch ] ) ;
641777
642778 // Message Table rendering variables and functions
643779 const paginationParams = {
@@ -670,6 +806,7 @@ export const TopicMessageView: FC<TopicMessageViewProps> = (props) => {
670806 timestamp : {
671807 header : 'Timestamp' ,
672808 accessorKey : 'timestamp' ,
809+ enableSorting : maxResults !== - 1 , // Disable sorting in unlimited pagination mode
673810 cell : ( {
674811 row : {
675812 original : { timestamp } ,
@@ -696,6 +833,7 @@ export const TopicMessageView: FC<TopicMessageViewProps> = (props) => {
696833 ) ,
697834 size : hasKeyTags ? 300 : 1 ,
698835 accessorKey : 'key' ,
836+ enableSorting : maxResults !== - 1 , // Disable sorting in unlimited pagination mode
699837 cell : ( { row : { original } } ) => (
700838 < MessageKeyPreview
701839 msg = { original }
@@ -723,6 +861,7 @@ export const TopicMessageView: FC<TopicMessageViewProps> = (props) => {
723861 'Value'
724862 ) ,
725863 accessorKey : 'value' ,
864+ enableSorting : maxResults !== - 1 , // Disable sorting in unlimited pagination mode
726865 cell : ( { row : { original } } ) => (
727866 < MessagePreview
728867 isCompactTopic = { props . topic . cleanupPolicy . includes ( 'compact' ) }
@@ -861,7 +1000,9 @@ export const TopicMessageView: FC<TopicMessageViewProps> = (props) => {
8611000 label : (
8621001 < Flex alignItems = "center" gap = { 2 } >
8631002 < ReplyIcon />
864- < span data-testid = "start-offset-newest" > { `Newest - ${ String ( maxResults ) } ` } </ span >
1003+ < span data-testid = "start-offset-newest" >
1004+ { maxResults === - 1 ? 'Newest' : `Newest - ${ String ( maxResults ) } ` }
1005+ </ span >
8651006 </ Flex >
8661007 ) ,
8671008 } ,
@@ -942,11 +1083,23 @@ export const TopicMessageView: FC<TopicMessageViewProps> = (props) => {
9421083
9431084 < Label text = "Max Results" >
9441085 < SingleSelect < number >
1086+ chakraStyles = { maxResultsSelectChakraStyles }
9451087 data-testid = "max-results-select"
9461088 onChange = { ( c ) => {
9471089 setMaxResults ( c ) ;
9481090 } }
949- options = { [ 1 , 3 , 5 , 10 , 20 , 50 , 100 , 200 , 500 ] . map ( ( i ) => ( { value : i } ) ) }
1091+ options = { [
1092+ // Only show "Unlimited" option when NOT on "Latest/Live" mode
1093+ ...( startOffset !== PartitionOffsetOrigin . End
1094+ ? [
1095+ {
1096+ value : - 1 ,
1097+ label : '\u221e Unlimited' ,
1098+ } ,
1099+ ]
1100+ : [ ] ) ,
1101+ ...[ 1 , 3 , 5 , 10 , 20 , 50 , 100 , 200 , 500 ] . map ( ( i ) => ( { value : i } ) ) ,
1102+ ] }
9501103 value = { maxResults }
9511104 />
9521105 </ Label >
@@ -1020,10 +1173,10 @@ export const TopicMessageView: FC<TopicMessageViewProps> = (props) => {
10201173 { Boolean ( searchPhase && searchPhase . length > 0 ) && (
10211174 < StatusIndicator
10221175 bytesConsumed = { prettyBytes ( bytesConsumed ) }
1023- fillFactor = { messages . length / maxResults }
1176+ fillFactor = { maxResults === - 1 ? 0 : messages . length / maxResults }
10241177 identityKey = "messageSearch"
10251178 messagesConsumed = { String ( totalMessagesConsumed ) }
1026- progressText = { `${ messages . length } / ${ maxResults } ` }
1179+ progressText = { maxResults === - 1 ? ` ${ messages . length } ` : `${ messages . length } / ${ maxResults } ` }
10271180 // biome-ignore lint/style/noNonNullAssertion: not touching MobX observables
10281181 statusText = { searchPhase ! }
10291182 />
@@ -1197,6 +1350,11 @@ export const TopicMessageView: FC<TopicMessageViewProps> = (props) => {
11971350 }
11981351 ) }
11991352 onSortingChange = { ( newSorting ) => {
1353+ // Don't allow sorting changes in unlimited pagination mode
1354+ if ( maxResults === - 1 ) {
1355+ return ;
1356+ }
1357+
12001358 const updatedSorting : SortingState = typeof newSorting === 'function' ? newSorting ( sorting ) : newSorting ;
12011359 setSortingState ( updatedSorting ) ;
12021360 } }
@@ -1224,6 +1382,25 @@ export const TopicMessageView: FC<TopicMessageViewProps> = (props) => {
12241382 />
12251383 ) }
12261384 />
1385+
1386+ { /* Warning when filters are active with pagination */ }
1387+ { maxResults === - 1 && filters . length > 0 && messages . length > 0 && (
1388+ < Alert mt = { 4 } status = "info" >
1389+ < AlertIcon />
1390+ < AlertDescription >
1391+ Auto-pagination is disabled when filters are active. Remove filters to enable automatic loading.
1392+ </ AlertDescription >
1393+ </ Alert >
1394+ ) }
1395+
1396+ { /* Loading indicator when fetching more pages */ }
1397+ { maxResults === - 1 && isLoadingMore && (
1398+ < Flex justifyContent = "center" mt = { 4 } >
1399+ < Spinner mr = { 2 } size = "sm" />
1400+ < span > Loading more messages...</ span >
1401+ </ Flex >
1402+ ) }
1403+
12271404 < Button
12281405 data-testid = "save-messages-button"
12291406 isDisabled = { messages . length === 0 }
0 commit comments