Skip to content

Commit b63dbea

Browse files
committed
feat: add unlimited pagination UI
- Add pageToken/nextPageToken to message search state - Auto-load next page when user reaches last loaded page - Reset pagination when navigating back to page 1 - Disable sorting in unlimited mode (maxResults=-1) - Hide unlimited option in Latest/Live mode - Auto-reset to 50 when switching to Latest/Live from unlimited
1 parent 264d816 commit b63dbea

File tree

3 files changed

+230
-23
lines changed

3 files changed

+230
-23
lines changed

backend/pkg/console/page_token_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright 2022 Redpanda Data, Inc.
1+
// Copyright 2026 Redpanda Data, Inc.
22
//
33
// Use of this software is governed by the Business Source License
44
// included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md

frontend/src/components/pages/topics/Tab.Messages/index.tsx

Lines changed: 198 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
197214
const 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

Comments
 (0)