diff --git a/frontend/src/components/pages/topics/Tab.Acl/acl-list.test.tsx b/frontend/src/components/pages/topics/Tab.Acl/acl-list.test.tsx index 2b36dce349..8b45d363dc 100644 --- a/frontend/src/components/pages/topics/Tab.Acl/acl-list.test.tsx +++ b/frontend/src/components/pages/topics/Tab.Acl/acl-list.test.tsx @@ -10,8 +10,18 @@ */ import { render, screen } from '@testing-library/react'; +import { NuqsTestingAdapter } from 'nuqs/adapters/testing'; +import { vi } from 'vitest'; import AclList from './acl-list'; + +vi.mock('@tanstack/react-router', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useLocation: () => ({ searchStr: '' }) }; +}); + +const renderWithAdapter = (ui: React.ReactElement) => render({ui}); + import type { AclStrOperation, AclStrPermission, @@ -26,7 +36,7 @@ describe('AclList', () => { aclResources: [], }; - render(); + renderWithAdapter(); expect(screen.getByText('No data found')).toBeInTheDocument(); }); @@ -50,7 +60,7 @@ describe('AclList', () => { ], } as GetAclOverviewResponse; - render(); + renderWithAdapter(); expect(screen.getByText('Topic')).toBeInTheDocument(); expect(screen.getByText('Test Topic')).toBeInTheDocument(); @@ -60,7 +70,7 @@ describe('AclList', () => { }); test('informs user about missing permission to view ACLs', () => { - render(); + renderWithAdapter(); expect(screen.getByText('You do not have the necessary permissions to view ACLs')).toBeInTheDocument(); }); @@ -70,7 +80,7 @@ describe('AclList', () => { aclResources: [], }; - render(); + renderWithAdapter(); expect(screen.getByText("There's no authorizer configured in your Kafka cluster")).toBeInTheDocument(); }); }); diff --git a/frontend/src/components/pages/topics/Tab.Acl/acl-list.tsx b/frontend/src/components/pages/topics/Tab.Acl/acl-list.tsx index 547481c74c..fb814ec472 100644 --- a/frontend/src/components/pages/topics/Tab.Acl/acl-list.tsx +++ b/frontend/src/components/pages/topics/Tab.Acl/acl-list.tsx @@ -9,8 +9,16 @@ * by the Apache License, Version 2.0 */ -import { Alert, AlertIcon, DataTable } from '@redpanda-data/ui'; +import { + type ColumnDef, + flexRender, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from '@tanstack/react-table'; +import { useUrlTableState } from '../../../../hooks/use-url-table-state'; import type { AclRule, AclStrOperation, @@ -19,91 +27,132 @@ import type { AclStrResourceType, GetAclOverviewResponse, } from '../../../../state/rest-interfaces'; +import { uiSettings } from '../../../../state/ui'; import { toJson } from '../../../../utils/json-utils'; +import { Alert, AlertDescription } from '../../../redpanda-ui/components/alert'; +import { DataTableColumnHeader, DataTablePagination } from '../../../redpanda-ui/components/data-table'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../../redpanda-ui/components/table'; type Acls = GetAclOverviewResponse | null | undefined; -type AclListProps = { - acl: Acls; +type AclFlatResource = { + eqKey: string; + principal: string; + host: string; + operation: AclStrOperation; + permissionType: AclStrPermission; + resourceType: AclStrResourceType; + resourceName: string; + resourcePatternType: AclStrResourcePatternType; + acls: AclRule[]; }; -function flatResourceList(store: Acls) { - const acls = store; - if (!acls || acls.aclResources === null) { +function flatResourceList(store: Acls): AclFlatResource[] { + if (!store || store.aclResources === null) { return []; } - const flatResources = acls.aclResources + return store.aclResources .flatMap((res) => res.acls.map((rule) => ({ ...res, ...rule }))) .map((x) => ({ ...x, eqKey: toJson(x) })); - return flatResources; } -export default ({ acl }: AclListProps) => { +const columns: ColumnDef[] = [ + { + accessorKey: 'resourceType', + header: ({ column }) => , + }, + { + accessorKey: 'permissionType', + header: ({ column }) => , + }, + { + accessorKey: 'principal', + header: ({ column }) => , + }, + { + accessorKey: 'operation', + header: ({ column }) => , + }, + { + accessorKey: 'resourcePatternType', + header: ({ column }) => , + }, + { + accessorKey: 'resourceName', + header: ({ column }) => , + }, + { + accessorKey: 'host', + header: ({ column }) => , + }, +]; + +const AclList = ({ acl }: { acl: Acls }) => { const resources = flatResourceList(acl); + const { sorting, pagination, onSortingChange, onPaginationChange } = useUrlTableState({ + keyPrefix: 'acl', + settings: uiSettings.topicAclList, + rowCount: resources.length, + }); + + const table = useReactTable({ + data: resources, + columns, + state: { sorting, pagination }, + onSortingChange, + onPaginationChange, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + autoResetPageIndex: false, + }); + return ( <> - {acl === null ? ( - - - You do not have the necessary permissions to view ACLs + {acl === null && ( + + You do not have the necessary permissions to view ACLs - ) : null} - {acl?.isAuthorizerEnabled ? null : ( - - - There's no authorizer configured in your Kafka cluster + )} + {acl?.isAuthorizerEnabled === false && ( + + There's no authorizer configured in your Kafka cluster )} - - columns={[ - { - size: 120, - header: 'Resource', - accessorKey: 'resourceType', - }, - { - size: 120, - header: 'Permission', - accessorKey: 'permissionType', - }, - { - header: 'Principal', - accessorKey: 'principal', - }, - { - size: 160, - header: 'Operation', - accessorKey: 'operation', - }, - { - header: 'PatternType', - accessorKey: 'resourcePatternType', - }, - { - header: 'Name', - accessorKey: 'resourceName', - }, - { - size: 120, - header: 'Host', - accessorKey: 'host', - }, - ]} - data={resources} - pagination - sorting - /> + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + + + {table.getRowModel().rows.length === 0 ? ( + + + No data found + + + ) : ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + {flexRender(cell.column.columnDef.cell, cell.getContext())} + ))} + + )) + )} + +
+ ); }; + +export default AclList; diff --git a/frontend/src/components/pages/topics/Tab.Messages/common/delete-records-menu-item.tsx b/frontend/src/components/pages/topics/Tab.Messages/common/delete-records-menu-item.tsx index 2106486e79..36a6eecd7d 100644 --- a/frontend/src/components/pages/topics/Tab.Messages/common/delete-records-menu-item.tsx +++ b/frontend/src/components/pages/topics/Tab.Messages/common/delete-records-menu-item.tsx @@ -9,7 +9,8 @@ * by the Apache License, Version 2.0 */ -import { Button, Tooltip } from '@redpanda-data/ui'; +import { Button } from 'components/redpanda-ui/components/button'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'components/redpanda-ui/components/tooltip'; import type { TopicAction } from '../../../../../state/rest-interfaces'; import { getDeleteErrorText, isDeleteEnabled } from '../helpers'; @@ -22,18 +23,24 @@ export function DeleteRecordsMenuItem( const isEnabled = isDeleteEnabled(isCompacted, allowedActions); const errorText = getDeleteErrorText(isCompacted, allowedActions); - let content: JSX.Element | string = 'Delete Records'; + const button = ( + + ); + if (errorText) { - content = ( - - {content} - + return ( + + + + {button} + + {errorText} + + ); } - return ( - - ); + return button; } diff --git a/frontend/src/components/pages/topics/Tab.Messages/common/message-search-filter-bar.tsx b/frontend/src/components/pages/topics/Tab.Messages/common/message-search-filter-bar.tsx index 3b11ce6985..5603dfde98 100644 --- a/frontend/src/components/pages/topics/Tab.Messages/common/message-search-filter-bar.tsx +++ b/frontend/src/components/pages/topics/Tab.Messages/common/message-search-filter-bar.tsx @@ -9,8 +9,8 @@ * by the Apache License, Version 2.0 */ -import { Box, GridItem, Tag, TagCloseButton, TagLabel } from '@redpanda-data/ui'; -import { SettingsIcon } from 'components/icons'; +import { CloseIcon, SettingsIcon } from 'components/icons'; +import { cn } from 'components/redpanda-ui/lib/utils'; import type { FC } from 'react'; import type { FilterEntry } from '../../../../../state/ui'; @@ -24,47 +24,49 @@ type MessageSearchFilterBarProps = { export const MessageSearchFilterBar: FC = ({ filters, onEdit, onToggle, onRemove }) => { return ( - - +
+
{/* Existing Tags List */} {filters?.map((e) => ( - - { onEdit(e); }} - size={14} - /> - + + + +
))} - - +
+ ); }; diff --git a/frontend/src/components/pages/topics/Tab.Messages/dialogs/save-messages-dialog.tsx b/frontend/src/components/pages/topics/Tab.Messages/dialogs/save-messages-dialog.tsx index ccf5ab6848..e56eb6ca10 100644 --- a/frontend/src/components/pages/topics/Tab.Messages/dialogs/save-messages-dialog.tsx +++ b/frontend/src/components/pages/topics/Tab.Messages/dialogs/save-messages-dialog.tsx @@ -9,18 +9,17 @@ * by the Apache License, Version 2.0 */ +import { Button } from 'components/redpanda-ui/components/button'; +import { Checkbox } from 'components/redpanda-ui/components/checkbox'; import { - Box, - Button, - Checkbox, - Modal, - ModalBody, - ModalContent, - ModalFooter, - ModalHeader, - ModalOverlay, - RadioGroup, -} from '@redpanda-data/ui'; + Dialog, + DialogBody, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from 'components/redpanda-ui/components/dialog'; +import { RadioGroup, RadioGroupItem } from 'components/redpanda-ui/components/radio-group'; import { useState } from 'react'; import type { Payload, TopicMessage } from '../../../../../state/rest-interfaces'; @@ -190,42 +189,48 @@ export const SaveMessagesDialog = ({ }; return ( - 0} onClose={onClose}> - - - {title} - + { + if (!open) onClose(); + }} + open={count > 0} + > + + + {title} + +
Select the format in which you want to save {count === 1 ? 'the message' : 'all messages'}
- - setFormat(value)} - options={[ - { - value: 'json', - label: 'JSON', - }, - { - value: 'csv', - label: 'CSV', - }, - ]} - value={format} +
+ setFormat(val as 'json' | 'csv')} value={format}> +
+ + +
+
+ + +
+
+
+
+ setIncludeRawContent(checked === true)} /> - - setIncludeRawContent(e.target.checked)}> - Include raw data - - - + +
+
+ - - -
-
+ + + ); }; diff --git a/frontend/src/components/pages/topics/Tab.Messages/index.tsx b/frontend/src/components/pages/topics/Tab.Messages/index.tsx index 1b788f1dce..2442f2f49f 100644 --- a/frontend/src/components/pages/topics/Tab.Messages/index.tsx +++ b/frontend/src/components/pages/topics/Tab.Messages/index.tsx @@ -23,28 +23,6 @@ import { } from '../../../../state/ui'; import { uiState } from '../../../../state/ui-state'; import '../../../../utils/array-extensions'; -import { - Alert, - AlertDescription, - AlertTitle, - Badge, - Box, - Button, - Flex, - Grid, - GridItem, - IconButton, - Input, - Menu, - MenuButton, - MenuDivider, - MenuItem, - MenuList, - Spinner, - Switch, - Tooltip, - useToast, -} from '@redpanda-data/ui'; import type { ColumnDef, SortingState } from '@tanstack/react-table'; import { flexRender, @@ -55,7 +33,6 @@ import { useReactTable, } from '@tanstack/react-table'; import { - AlertIcon, CalendarIcon, CodeIcon, DownloadIcon, @@ -71,19 +48,32 @@ import { TabIcon, TimerIcon, } from 'components/icons'; -import { Button as RegistryButton } from 'components/redpanda-ui/components/button'; +import { Alert, AlertDescription, AlertTitle } from 'components/redpanda-ui/components/alert'; +import { Badge } from 'components/redpanda-ui/components/badge'; +import { Button } from 'components/redpanda-ui/components/button'; import { DataTablePagination } from 'components/redpanda-ui/components/data-table'; import { - Select as RegistrySelect, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from 'components/redpanda-ui/components/dropdown-menu'; +import { Input } from 'components/redpanda-ui/components/input'; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from 'components/redpanda-ui/components/select'; +import { Spinner } from 'components/redpanda-ui/components/spinner'; +import { Switch } from 'components/redpanda-ui/components/switch'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'components/redpanda-ui/components/table'; -import { Tooltip as RegistryTooltip, TooltipContent, TooltipTrigger } from 'components/redpanda-ui/components/tooltip'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'components/redpanda-ui/components/tooltip'; import { ChevronDown, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'; import { parseAsBoolean, parseAsInteger, parseAsString, useQueryState } from 'nuqs'; +import { toast } from 'sonner'; import { MessageSearchFilterBar } from './common/message-search-filter-bar'; import { SaveMessagesDialog } from './dialogs/save-messages-dialog'; @@ -115,7 +105,6 @@ import { import { encodeBase64, prettyBytes, prettyMilliseconds } from '../../../../utils/utils'; import { range } from '../../../misc/common'; import RemovableFilter from '../../../misc/removable-filter'; -import { SingleSelect } from '../../../misc/select'; const payloadEncodingPairs = [ { value: PayloadEncoding.UNSPECIFIED, label: 'Automatic' }, @@ -200,56 +189,20 @@ function getPayloadAsString(value: string | Uint8Array | object): string { return JSON.stringify(value, null, 4); } -const defaultSelectChakraStyles = { - control: (provided: Record) => ({ - ...provided, - minWidth: 'max-content', - }), - option: (provided: Record) => ({ - ...provided, - wordBreak: 'keep-all', - whiteSpace: 'nowrap', - }), - menuList: (provided: Record) => ({ - ...provided, - minWidth: 'min-content', - }), -} as const; - -const inlineSelectChakraStyles = { - ...defaultSelectChakraStyles, - control: (provided: Record) => ({ - ...provided, - _hover: { - borderColor: 'transparent', - }, - }), - container: (provided: Record) => ({ - ...provided, - borderColor: 'transparent', - }), -} as const; - -function onCopyValue(original: TopicMessage, toast: ReturnType) { +function onCopyValue(original: TopicMessage) { navigator.clipboard .writeText(getPayloadAsString((original.value.payload ?? original.value.rawBytes) as string | Uint8Array | object)) .then(() => { - toast({ - status: 'success', - description: 'Value copied to clipboard', - }); + toast.success('Value copied to clipboard'); }) .catch(navigatorClipboardErrorHandler); } -function onCopyKey(original: TopicMessage, toast: ReturnType) { +function onCopyKey(original: TopicMessage) { navigator.clipboard .writeText(getPayloadAsString((original.key.payload ?? original.key.rawBytes) as string | Uint8Array | object)) .then(() => { - toast({ - status: 'success', - description: 'Key copied to clipboard', - }); + toast.success('Key copied to clipboard'); }) .catch(navigatorClipboardErrorHandler); } @@ -326,12 +279,52 @@ async function loadLargeMessage({ } } +/** + * A dropdown menu item for the "Add filter" menu. When `disabledReason` is set the item + * renders as a visually-disabled row that shows a tooltip explaining why on hover. + * + * We deliberately do NOT render a disabled `` here: Base UI wraps every + * menu item in a `MotionHighlight` layer that keeps intercepting pointer events even when + * the item is disabled, so a tooltip attached to a wrapping element never receives hover. + * Rendering the disabled state as a plain styled `` (mirroring the menu-item styling) + * lets the tooltip trigger receive hover reliably while the row stays non-interactive. + */ +const AddFilterMenuItem: FC<{ + testId: string; + disabledReason?: string; + onClick: () => void; + children: React.ReactNode; +}> = ({ testId, disabledReason, onClick, children }) => { + if (!disabledReason) { + return ( + + {children} + + ); + } + + return ( + + + + + {children} + + + {disabledReason} + + + ); +}; + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: this is because of the refactoring effort, the scope will be minimised eventually export const TopicMessageView: FC = (props) => { - const toast = useToast(); - const toastRef = useRef(toast); - toastRef.current = toast; - // Zustand store for topic settings const { setSorting, getSorting, setTopicSettings, perTopicSettings, setSearchParams, getSearchParams } = useTopicSettingsStore(); @@ -908,13 +901,7 @@ export const TopicMessageView: FC = (props) => { (err: unknown) => { const shouldReport = isMountedRef.current && !abortController.signal.aborted; if (shouldReport) { - toastRef.current({ - title: 'Failed to load more messages', - description: (err as Error).message, - status: 'error', - duration: 5000, - isClosable: true, - }); + toast.error('Failed to load more messages', { description: (err as Error).message }); } return { type: 'error' as const, shouldReport }; } @@ -1028,8 +1015,8 @@ export const TopicMessageView: FC = (props) => { const onSetDownloadMessages = useCallback((nextMessages: TopicMessage[]) => { setDownloadMessages(nextMessages); }, []); - const handleCopyKey = useCallback((msg: TopicMessage) => onCopyKey(msg, toast), [toast]); - const handleCopyValue = useCallback((msg: TopicMessage) => onCopyValue(msg, toast), [toast]); + const handleCopyKey = useCallback((msg: TopicMessage) => onCopyKey(msg), []); + const handleCopyValue = useCallback((msg: TopicMessage) => onCopyValue(msg), []); const paginationParams = { pageIndex: isOnUnloadedPage ? loadedPages - 1 : boundedLocalPageIndex, @@ -1071,7 +1058,7 @@ export const TopicMessageView: FC = (props) => { key: { header: () => isKeyDeserializerActive ? ( - +
Key{' '} - +
) : ( 'Key' ), @@ -1100,7 +1087,7 @@ export const TopicMessageView: FC = (props) => { value: { header: () => isValueDeserializerActive ? ( - +
Value{' '} - +
) : ( 'Value' ), @@ -1181,56 +1168,52 @@ export const TopicMessageView: FC = (props) => { id: 'action', size: 0, cell: ({ row: { original } }) => ( - - - - - - + + + + + { navigator.clipboard .writeText(getMessageAsString(original)) .then(() => { - toast({ - status: 'success', - description: 'Message copied to clipboard', - }); + toast.success('Message copied to clipboard'); }) .catch(navigatorClipboardErrorHandler); }} > Copy Message - - onCopyKey(original, toast)}> + + onCopyKey(original)}> Copy Key - - onCopyValue(original, toast)}> + + onCopyValue(original)}> Copy Value - - + { navigator.clipboard .writeText(original.timestamp.toString()) .then(() => { - toast({ - status: 'success', - description: 'Epoch Timestamp copied to clipboard', - }); + toast.success('Epoch Timestamp copied to clipboard'); }) .catch(navigatorClipboardErrorHandler); }} > Copy Epoch Timestamp - - + { setDownloadMessages([original]); }} > Save to File - - - +
+ + ), }, ]; @@ -1241,14 +1224,14 @@ export const TopicMessageView: FC = (props) => { enableSorting: false, cell: ({ row }) => row.getCanExpand() ? ( - {row.getIsExpanded() ? : } - + ) : null, }; @@ -1285,55 +1268,68 @@ export const TopicMessageView: FC = (props) => { }); // Search controls derived state - const canUseFilters = (topicPermissions?.canUseSearchFilters ?? true) && !isServerless(); + // Reasons explaining why a filter cannot be added (undefined = enabled). + const partitionFilterDisabledReason = dynamicFilters.includes('partition') + ? 'Partition filter is already added. Use the existing Partition control to filter, or remove it first.' + : undefined; + let jsFilterDisabledReason: string | undefined; + if (isServerless()) { + jsFilterDisabledReason = 'JavaScript filters are not available in Serverless clusters.'; + } else if (!(topicPermissions?.canUseSearchFilters ?? true)) { + jsFilterDisabledReason = "You don't have permission to use search filters on this topic."; + } else if (continuousPaginationEnabled) { + jsFilterDisabledReason = + 'JavaScript filters are not available while continuous pagination is enabled. Turn it off to add a filter.'; + } + const customStartOffsetValid = !Number.isNaN(Number(customStartOffsetValue)); const startOffsetOptions = [ { value: PartitionOffsetOrigin.End, label: ( - +
Latest / Live - +
), }, { value: PartitionOffsetOrigin.EndMinusResults, label: ( - +
{continuousPaginationEnabled ? 'Newest' : `Newest - ${String(maxResults)}`} - +
), }, { value: PartitionOffsetOrigin.Start, label: ( - +
Beginning - +
), }, { value: PartitionOffsetOrigin.Custom, label: ( - +
Offset - +
), }, { value: PartitionOffsetOrigin.Timestamp, label: ( - +
Timestamp - +
), }, ]; @@ -1349,14 +1345,13 @@ export const TopicMessageView: FC = (props) => { // Return JSX for the component return ( <> - - +
+
@@ -1464,59 +1484,56 @@ export const TopicMessageView: FC = (props) => { setPartitionID(DEFAULT_SEARCH_PARAMS.partitionID); }} > - - chakraStyles={inlineSelectChakraStyles} - onChange={(c) => { - setPartitionID(c); - }} - options={[ - { - value: -1, - label: 'All', - }, - ].concat( - range(0, props.topic.partitionCount).map((i) => ({ - value: i, - label: String(i), - })) - )} - value={partitionID} - /> + ), })[filter] )} - - - - Add filter - - - } - isDisabled={dynamicFilters.includes('partition')} +
+ + + + + + addDynamicFilter('partition')} + testId="add-topic-filter-partition" > - Partition - - - } - isDisabled={!canUseFilters} + Partition + + + { const filter = createFilterEntry(); setCurrentJSFilter(filter); }} + testId="add-topic-filter-javascript" > - JavaScript Filter - - -
-
+ JavaScript Filter + + + +
{/* Search Progress Indicator: "Consuming Messages 30/30" */} {Boolean(searchPhase && searchPhase.length > 0) && ( @@ -1534,74 +1551,77 @@ export const TopicMessageView: FC = (props) => { statusText={searchPhase!} /> )} - - - - - } - variant="outline" - /> - - + +
+ + + + + + { - setShowDeserializersModal(true); - }} + onClick={() => setShowDeserializersModal(true)} > Deserialization - - + { - setShowColumnSettingsModal(true); - }} + onClick={() => setShowColumnSettingsModal(true)} > Column settings - - { - setShowPreviewFieldsModal(true); - }} - > + + setShowPreviewFieldsModal(true)}> Preview fields - - -
- + + + +
{/* Refresh Button */} {searchPhase === null && ( - - } - onClick={() => searchFunc('manual')} - variant="outline" - /> - + + + + + + Repeat current search + + )} {searchPhase !== null && ( - - } - onClick={() => { - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - } - }} - variant="solid" - /> - + + + + + + Stop searching + + )} - - +
+
{/* Filter Tags */} = (props) => { }} /> - +
{/* Quick Search */} { setQuickSearch(x.target.value); }} placeholder="Filter table content ..." - px={4} value={quickSearch} /> - +
{searchPhase === null || searchPhase === 'Done' ? ( <> - +
{prettyBytes(bytesConsumed)} - - +
+
{elapsedMs ? prettyMilliseconds(elapsedMs) : ''} - +
) : ( <> @@ -1644,9 +1664,9 @@ export const TopicMessageView: FC = (props) => { Fetching data... )} -
- - +
+
+ {currentJSFilter ? ( = (props) => { {/* Message Table (or error display) */} {fetchError ? ( - - - - - - Backend Error - - Check and modify the request before resubmitting. - -
{(fetchError as Error).message ?? String(fetchError)}
-
- -
-
+ } variant="destructive"> + Backend Error + +
Check and modify the request before resubmitting.
+
+
{(fetchError as Error).message ?? String(fetchError)}
+
+ +
) : ( <> @@ -1714,7 +1729,7 @@ export const TopicMessageView: FC = (props) => { return ( - + ); @@ -1784,7 +1799,7 @@ export const TopicMessageView: FC = (props) => { {/* Rows per page selector */}

Rows per page

- { const newSize = Number(value); uiState.topicSettings.searchParams.pageSize = newSize; @@ -1802,12 +1817,12 @@ export const TopicMessageView: FC = (props) => { ))} - +
{/* Navigation buttons */}
- setPageIndex(0)} @@ -1816,9 +1831,9 @@ export const TopicMessageView: FC = (props) => { > Go to first page - + - setPageIndex(Math.max(0, pageIndex - 1))} @@ -1827,9 +1842,9 @@ export const TopicMessageView: FC = (props) => { > Go to previous page - + - = loadedPages - 1 && !hasMoreData} onClick={() => setPageIndex((prev) => (prev ?? 0) + 1)} @@ -1838,12 +1853,12 @@ export const TopicMessageView: FC = (props) => { > Go to next page - + - +
@@ -1851,19 +1866,18 @@ export const TopicMessageView: FC = (props) => { {/* Virtual page indicator for continuous pagination mode */} {continuousPaginationEnabled && messages.length > 0 && ( - +
Loaded messages {virtualStartIndex + 1}-{virtualStartIndex + messages.length} {` (pages ${windowStartPage + 1}–${windowStartPage + loadedPages} in memory)`} {messageSearch?.nextPageToken ? ' · more available' : ''} - +
)} {/* Warning when filters are active with continuous pagination */} {continuousPaginationEnabled && filters.length > 0 && messages.length > 0 && ( - - + Auto-pagination is disabled when filters are active. Remove filters to enable automatic loading. @@ -1874,14 +1888,14 @@ export const TopicMessageView: FC = (props) => {
- + Loading more messages...
- -
- - - + + + + + + + + + + ); }; diff --git a/frontend/src/components/pages/topics/Tab.Messages/message-display/expanded-message.tsx b/frontend/src/components/pages/topics/Tab.Messages/message-display/expanded-message.tsx index 40411b0dab..50793d651f 100644 --- a/frontend/src/components/pages/topics/Tab.Messages/message-display/expanded-message.tsx +++ b/frontend/src/components/pages/topics/Tab.Messages/message-display/expanded-message.tsx @@ -9,7 +9,6 @@ * by the Apache License, Version 2.0 */ -import { Box, Button, Flex, Tabs as RpTabs, useColorModeValue } from '@redpanda-data/ui'; import React, { type FC, type ReactNode, useCallback } from 'react'; import { MessageHeaders } from './message-headers'; @@ -18,19 +17,21 @@ import { PayloadComponent } from './payload-component'; import { TroubleshootReportViewer } from './troubleshoot-report-viewer'; import type { TopicMessage } from '../../../../../state/rest-interfaces'; import { prettyBytes } from '../../../../../utils/utils'; +import { Button } from '../../../../redpanda-ui/components/button'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../../../redpanda-ui/components/tabs'; const ExpandedMessageFooter: FC<{ children?: ReactNode; onDownloadRecord?: () => void }> = ({ children, onDownloadRecord, }) => ( - +
{children} {Boolean(onDownloadRecord) && ( )} - +
); type ExpandedMessageProps = { @@ -55,7 +56,6 @@ export const ExpandedMessage: FC = React.memo( onCopyKey, onCopyValue, }) => { - const bg = useColorModeValue('gray.50', 'gray.600'); const handleLoadLargeMessage = useCallback( () => onLoadLargeMessage && topicName !== undefined @@ -78,74 +78,50 @@ export const ExpandedMessage: FC = React.memo( }, [msg, onCopyValue]); return ( - +
- - {msg.key === null || msg.key.size === 0 ? 'Key' : `Key (${prettyBytes(msg.key.size)})`} - - ), - isDisabled: msg.key === null || msg.key.size === 0, - component: ( - - - - - {onCopyKey ? ( - - ) : null} - - - ), - }, - { - key: 'value', - name: ( - - {msg.value === null || msg.value.size === 0 ? 'Value' : `Value (${prettyBytes(msg.value.size)})`} - - ), - component: ( - - - - - {onCopyValue ? ( - - ) : null} - - - ), - }, - { - key: 'headers', - name: ( - {msg.headers.length === 0 ? 'Headers' : `Headers (${msg.headers.length})`} - ), - isDisabled: msg.headers.length === 0, - component: ( - - - {onSetDownloadMessages || onDownloadRecord ? ( - - ) : null} - - ), - }, - ]} - variant="fitted" - /> - + + + + {msg.key === null || msg.key.size === 0 ? 'Key' : `Key (${prettyBytes(msg.key.size)})`} + + + {msg.value === null || msg.value.size === 0 ? 'Value' : `Value (${prettyBytes(msg.value.size)})`} + + + {msg.headers.length === 0 ? 'Headers' : `Headers (${msg.headers.length})`} + + + + + + + {onCopyKey ? ( + + ) : null} + + + + + + + {onCopyValue ? ( + + ) : null} + + + + + {onSetDownloadMessages || onDownloadRecord ? ( + + ) : null} + + +
); } ); diff --git a/frontend/src/components/pages/topics/Tab.Messages/message-display/message-headers.tsx b/frontend/src/components/pages/topics/Tab.Messages/message-display/message-headers.tsx index 1403a16a6e..fc74075518 100644 --- a/frontend/src/components/pages/topics/Tab.Messages/message-display/message-headers.tsx +++ b/frontend/src/components/pages/topics/Tab.Messages/message-display/message-headers.tsx @@ -9,11 +9,10 @@ * by the Apache License, Version 2.0 */ -import { Box, DataTable } from '@redpanda-data/ui'; - import type { Payload, TopicMessage } from '../../../../../state/rest-interfaces'; import { Ellipsis, toSafeString } from '../../../../../utils/tsx-utils'; import { KowlJsonView } from '../../../../misc/kowl-json-view'; +import { DataTable } from '../../../../redpanda-ui/components/data-table'; import { renderEmptyIcon } from '../common/empty-icon'; export const MessageHeaders = (props: { msg: TopicMessage }) => { @@ -78,7 +77,7 @@ export const MessageHeaders = (props: { msg: TopicMessage }) => { pagination sorting subComponent={({ row: { original: header } }) => ( - +
{typeof header.value?.payload !== 'object' ? (
{toSafeString(header.value.payload)} @@ -86,7 +85,7 @@ export const MessageHeaders = (props: { msg: TopicMessage }) => { ) : ( )} - +
)} />
diff --git a/frontend/src/components/pages/topics/Tab.Messages/message-display/message-meta-data.tsx b/frontend/src/components/pages/topics/Tab.Messages/message-display/message-meta-data.tsx index 1c3813051b..b6e4de8854 100644 --- a/frontend/src/components/pages/topics/Tab.Messages/message-display/message-meta-data.tsx +++ b/frontend/src/components/pages/topics/Tab.Messages/message-display/message-meta-data.tsx @@ -9,13 +9,13 @@ * by the Apache License, Version 2.0 */ -import { Flex, Text } from '@redpanda-data/ui'; -import React from 'react'; +import type React from 'react'; import { MessageSchema } from './message-schema'; import type { TopicMessage } from '../../../../../state/rest-interfaces'; import { numberToThousandsString } from '../../../../../utils/tsx-utils'; import { prettyBytes, titleCase } from '../../../../../utils/utils'; +import { Text } from '../../../../redpanda-ui/components/typography'; export const MessageMetaData = (props: { msg: TopicMessage }) => { const msg = props.msg; @@ -36,17 +36,13 @@ export const MessageMetaData = (props: { msg: TopicMessage }) => { } return ( - +
{Object.entries(data).map(([k, v]) => ( - - - {k} - - - {v} - - +
+ {k} + {v} +
))} - +
); }; diff --git a/frontend/src/components/pages/topics/Tab.Messages/message-display/payload-component.tsx b/frontend/src/components/pages/topics/Tab.Messages/message-display/payload-component.tsx index a270f0e48e..d5bcb46ee7 100644 --- a/frontend/src/components/pages/topics/Tab.Messages/message-display/payload-component.tsx +++ b/frontend/src/components/pages/topics/Tab.Messages/message-display/payload-component.tsx @@ -9,12 +9,13 @@ * by the Apache License, Version 2.0 */ -import { Button, Flex, useToast } from '@redpanda-data/ui'; import type { ReactNode } from 'react'; import { useMemo, useState } from 'react'; +import { toast } from 'sonner'; import type { Payload } from '../../../../../state/rest-interfaces'; import { KowlJsonView } from '../../../../misc/kowl-json-view'; +import { Button } from '../../../../redpanda-ui/components/button'; import { getControlCharacterName } from '../helpers'; // Regex for checking printable ASCII characters @@ -64,6 +65,7 @@ type PayloadRenderData = | { type: 'json'; content: string | object | null | undefined } | { type: 'error'; content: string }; +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: complex payload parsing function preparePayloadData(payload: Payload): PayloadRenderData { try { if (payload === null || payload === undefined || payload.payload === null || payload.payload === undefined) { @@ -120,41 +122,33 @@ function preparePayloadData(payload: Payload): PayloadRenderData { } } -// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: complex business logic export const PayloadComponent = (p: { payload: Payload; loadLargeMessage: () => Promise }) => { const { payload, loadLargeMessage } = p; - const toast = useToast(); const [isLoadingLargeMessage, setLoadingLargeMessage] = useState(false); const renderData = useMemo(() => preparePayloadData(payload), [payload]); if (payload.isPayloadTooLarge) { return ( - - +
+
Because this message size exceeds the display limit, loading it could cause performance degradation. - +
- +
); } diff --git a/frontend/src/components/pages/topics/Tab.Messages/message-display/troubleshoot-report-viewer.tsx b/frontend/src/components/pages/topics/Tab.Messages/message-display/troubleshoot-report-viewer.tsx index 53a59c6052..280d291607 100644 --- a/frontend/src/components/pages/topics/Tab.Messages/message-display/troubleshoot-report-viewer.tsx +++ b/frontend/src/components/pages/topics/Tab.Messages/message-display/troubleshoot-report-viewer.tsx @@ -9,10 +9,12 @@ * by the Apache License, Version 2.0 */ -import { Alert, AlertDescription, AlertIcon, AlertTitle, Box, Grid, GridItem, Heading } from '@redpanda-data/ui'; -import { useState } from 'react'; +import { AlertTriangle } from 'lucide-react'; +import { Fragment, useState } from 'react'; import type { Payload } from '../../../../../state/rest-interfaces'; +import { Alert, AlertDescription, AlertTitle } from '../../../../redpanda-ui/components/alert'; +import { Heading } from '../../../../redpanda-ui/components/typography'; export const TroubleshootReportViewer = (props: { payload: Payload }) => { const report = props.payload.troubleshootReport; @@ -26,18 +28,11 @@ export const TroubleshootReportViewer = (props: { payload: Payload }) => { } return ( - +
Deserialization Troubleshoot Report - - - Errors were encountered when deserializing this message + } variant="destructive"> + + Errors were encountered when deserializing this message - - - {report.map((e) => ( - <> - - {e.serdeName} - - - {e.message} - - - ))} - - + {show ? ( + +
+ {report.map((e) => ( + +
{e.serdeName}
+
{e.message}
+
+ ))} +
+
+ ) : null}
- +
); }; diff --git a/frontend/src/components/pages/topics/Tab.Messages/modals/deserializers-modal.tsx b/frontend/src/components/pages/topics/Tab.Messages/modals/deserializers-modal.tsx index 3e26fad6ab..19396f001e 100644 --- a/frontend/src/components/pages/topics/Tab.Messages/modals/deserializers-modal.tsx +++ b/frontend/src/components/pages/topics/Tab.Messages/modals/deserializers-modal.tsx @@ -9,23 +9,26 @@ * by the Apache License, Version 2.0 */ +import { Button } from 'components/redpanda-ui/components/button'; import { - Box, - Button, - Modal, - ModalBody, - ModalCloseButton, - ModalContent, - ModalFooter, - ModalHeader, - ModalOverlay, - Text, -} from '@redpanda-data/ui'; + Dialog, + DialogBody, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from 'components/redpanda-ui/components/dialog'; +import { Label } from 'components/redpanda-ui/components/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from 'components/redpanda-ui/components/select'; import type { FC } from 'react'; import { PayloadEncoding } from '../../../../../protogen/redpanda/api/console/v1alpha1/common_pb'; -import { Label } from '../../../../../utils/tsx-utils'; -import { SingleSelect } from '../../../../misc/select'; const payloadEncodingPairs = [ { value: PayloadEncoding.UNSPECIFIED, label: 'Automatic' }, @@ -61,47 +64,67 @@ export const DeserializersModal: FC<{ setKeyDeserializer, setValueDeserializer, }) => ( - { - setShowDialog(false); - }} - > - - - Deserialize - - - - Redpanda attempts to automatically detect a deserialization strategy. You can choose one manually here. - - - - - - - - - - - + + + ); diff --git a/frontend/src/components/pages/topics/TopicConfiguration.scss b/frontend/src/components/pages/topics/TopicConfiguration.scss deleted file mode 100644 index 63247cb980..0000000000 --- a/frontend/src/components/pages/topics/TopicConfiguration.scss +++ /dev/null @@ -1,65 +0,0 @@ -.configGroupTable { - width: 100%; - - display: grid; - /* columns: name, value, isEdited, buttons */ - grid-template-columns: minmax(300px, auto) auto auto 1fr; - align-items: center; - gap: 12px; - - .searchBar { - grid-column-start: span 4; - margin-bottom: 1em; - } - - .configGroupSpacer { - grid-column-start: span 4; - margin: 1em 0em; - height: 1px; - background: hsl(0deg, 0%, 80%); - - &:first-of-type { - display: none; - } - } - - .configGroupTitle { - grid-column-start: span 4; - font-size: 1.5em; - font-weight: 600; - } - - .isEditted { - opacity: .5; - margin-left: 20px; - margin-right: 20px; - } - - .configButtons { - display: inline-flex; - align-items: center; - gap: 12px; - font-size: 18px; - color: hsl(0deg 0% 35%); - - .btnEdit { - display: inline-flex; - font-size: 21px; - padding: 1px; - border-radius: 3px; - cursor: pointer; - - &:not(.disabled):hover { - color: var(--ant-primary-color); - background: rgb(239 239 239); - } - - &.disabled { - cursor: default; - svg { - opacity: 0.5; - } - } - } - } -} diff --git a/frontend/src/components/pages/topics/tab-config.tsx b/frontend/src/components/pages/topics/tab-config.tsx index 336baea853..5ee27f533e 100644 --- a/frontend/src/components/pages/topics/tab-config.tsx +++ b/frontend/src/components/pages/topics/tab-config.tsx @@ -9,7 +9,9 @@ * by the Apache License, Version 2.0 */ -import { Box, Button, Code, CodeBlock, Empty, Flex, Result } from '@redpanda-data/ui'; +import { Button } from 'components/redpanda-ui/components/button'; +import { CodeBlock, Pre } from 'components/redpanda-ui/components/code-block'; +import { Empty, EmptyDescription } from 'components/redpanda-ui/components/empty'; import TopicConfigurationEditor from './topic-configuration'; import { appGlobal } from '../../../state/app-global'; @@ -33,7 +35,11 @@ export function TopicConfiguration(props: { topic: Topic }) { return renderKafkaError(props.topic.topicName, config.error); } if (config === null || config.configEntries.length === 0) { - return ; + return ( + + No config entries + + ); } const entries = config.configEntries; @@ -51,30 +57,26 @@ export function TopicConfiguration(props: { topic: Topic }) { function renderKafkaError(topicName: string, error: KafkaError) { return ( - - - - Redpanda Console received the following error while fetching the configuration for topic{' '} - {topicName} from Kafka: - - } - title="Kafka Error" - /> - - - - - - + + ); } diff --git a/frontend/src/components/pages/topics/tab-consumers.tsx b/frontend/src/components/pages/topics/tab-consumers.tsx index 519608cf50..5121c2d1c6 100644 --- a/frontend/src/components/pages/topics/tab-consumers.tsx +++ b/frontend/src/components/pages/topics/tab-consumers.tsx @@ -9,21 +9,24 @@ * by the Apache License, Version 2.0 */ +import { Link } from '@tanstack/react-router'; +import { + type ColumnDef, + flexRender, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from '@tanstack/react-table'; import { type FC, useEffect } from 'react'; -import type { Topic, TopicConsumer } from '../../../state/rest-interfaces'; - -import '../../../utils/array-extensions'; - -import { DataTable } from '@redpanda-data/ui'; - -import usePaginationParams from '../../../hooks/use-pagination-params'; -import { appGlobal } from '../../../state/app-global'; +import { useUrlTableState } from '../../../hooks/use-url-table-state'; import { api, useApiStoreHook } from '../../../state/backend-api'; -import { uiState } from '../../../state/ui-state'; -import { onPaginationChange } from '../../../utils/pagination'; -import { editQuery } from '../../../utils/query-helper'; +import type { Topic, TopicConsumer } from '../../../state/rest-interfaces'; +import { uiSettings } from '../../../state/ui'; import { DefaultSkeleton } from '../../../utils/tsx-utils'; +import { DataTableColumnHeader, DataTablePagination } from '../../redpanda-ui/components/data-table'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../redpanda-ui/components/table'; type TopicConsumersProps = { topic: Topic }; @@ -34,34 +37,85 @@ export const TopicConsumers: FC = ({ topic }) => { const rawConsumers = useApiStoreHook((s) => s.topicConsumers.get(topic.topicName)); const isLoading = rawConsumers === undefined; - const consumers = rawConsumers ?? []; - const paginationParams = usePaginationParams(consumers.length, uiState.topicSettings.consumerPageSize); + const { sorting, pagination, onSortingChange, onPaginationChange } = useUrlTableState({ + keyPrefix: 'consumer', + settings: uiSettings.topicConsumersList, + rowCount: consumers.length, + enabled: !isLoading, + }); + + const columns: ColumnDef[] = [ + { + accessorKey: 'groupId', + header: ({ column }) => , + cell: ({ row: { original } }) => ( + + {original.groupId} + + ), + }, + { + accessorKey: 'summedLag', + header: ({ column }) => , + }, + ]; + + const table = useReactTable({ + data: consumers, + columns, + state: { sorting, pagination }, + onSortingChange, + onPaginationChange, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + autoResetPageIndex: false, + }); if (isLoading) { return DefaultSkeleton; } return ( - - columns={[ - { size: 1, header: 'Group', accessorKey: 'groupId' }, - { header: 'Lag', accessorKey: 'summedLag' }, - ]} - data={consumers} - onPaginationChange={onPaginationChange(paginationParams, ({ pageSize, pageIndex }) => { - Object.assign(uiState.topicSettings, { consumerPageSize: pageSize }); - editQuery((query) => { - query.page = String(pageIndex); - query.pageSize = String(pageSize); - }); - })} - onRow={(row) => { - appGlobal.historyPush(`/groups/${encodeURIComponent(row.original.groupId)}`); - }} - pagination={paginationParams} - sorting - /> + <> + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + + + {table.getRowModel().rows.length === 0 ? ( + + + No data found + + + ) : ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + {flexRender(cell.column.columnDef.cell, cell.getContext())} + ))} + + )) + )} + +
+ + ); }; diff --git a/frontend/src/components/pages/topics/tab-partitions.tsx b/frontend/src/components/pages/topics/tab-partitions.tsx index eff41e41eb..d8758d98f4 100644 --- a/frontend/src/components/pages/topics/tab-partitions.tsx +++ b/frontend/src/components/pages/topics/tab-partitions.tsx @@ -9,135 +9,151 @@ * by the Apache License, Version 2.0 */ +import { + type ColumnDef, + flexRender, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from '@tanstack/react-table'; +import { AlertTriangle } from 'lucide-react'; import type { FC } from 'react'; -import type { Partition, Topic } from '../../../state/rest-interfaces'; import '../../../utils/array-extensions'; -import { Alert, AlertIcon, Box, DataTable, Flex, Popover, Text } from '@redpanda-data/ui'; -import { WarningIcon } from 'components/icons'; -import { Badge } from 'components/redpanda-ui/components/badge'; -import usePaginationParams from '../../../hooks/use-pagination-params'; +import { useUrlTableState } from '../../../hooks/use-url-table-state'; import { useApiStoreHook } from '../../../state/backend-api'; -import { uiState } from '../../../state/ui-state'; -import { onPaginationChange } from '../../../utils/pagination'; -import { editQuery } from '../../../utils/query-helper'; -import { DefaultSkeleton, InfoText, numberToThousandsString } from '../../../utils/tsx-utils'; +import type { Partition, Topic } from '../../../state/rest-interfaces'; +import { uiSettings } from '../../../state/ui'; +import { DefaultSkeleton, numberToThousandsString } from '../../../utils/tsx-utils'; import { BrokerList } from '../../misc/broker-list'; +import { Alert, AlertDescription } from '../../redpanda-ui/components/alert'; +import { Badge } from '../../redpanda-ui/components/badge'; +import { Button } from '../../redpanda-ui/components/button'; +import { DataTableColumnHeader, DataTablePagination } from '../../redpanda-ui/components/data-table'; +import { Popover, PopoverContent, PopoverTrigger } from '../../redpanda-ui/components/popover'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../redpanda-ui/components/table'; -type TopicPartitionsProps = { - topic: Topic; -}; - -const persistPartitionPageSize = (pageSize: number) => { - uiState.topicSettings.partitionPageSize = pageSize; -}; +type TopicPartitionsProps = { topic: Topic }; export const TopicPartitions: FC = ({ topic }) => { const partitions = useApiStoreHook((s) => s.topicPartitions.get(topic.topicName)); const clusterHealth = useApiStoreHook((s) => s.clusterHealth); - const paginationParams = usePaginationParams(partitions?.length ?? 0, uiState.topicSettings.partitionPageSize); + + // Kept above the early returns so the hook order stays stable; clamping no-ops until partitions load. + const { sorting, pagination, onSortingChange, onPaginationChange } = useUrlTableState({ + keyPrefix: 'partition', + settings: uiSettings.topicPartitionsList, + rowCount: Array.isArray(partitions) ? partitions.length : 0, + enabled: Array.isArray(partitions), + }); if (partitions === undefined) { return DefaultSkeleton; } if (partitions === null) { - return
; // todo: show the error (if one was reported); + return
; } - const leaderLessPartitions = (clusterHealth?.leaderlessPartitions ?? []).find( + const leaderlessPartitions = (clusterHealth?.leaderlessPartitions ?? []).find( ({ topicName }) => topicName === topic.topicName )?.partitionIds; + const underReplicatedPartitions = (clusterHealth?.underReplicatedPartitions ?? []).find( ({ topicName }) => topicName === topic.topicName )?.partitionIds; - let warning: JSX.Element = <>; - if (topic.cleanupPolicy.toLowerCase() === 'compact') { - warning = ( - - - Topic cleanupPolicy is 'compact'. Message Count is an estimate! - - ); - } + const columns: ColumnDef[] = [ + { + accessorKey: 'id', + header: ({ column }) => , + cell: ({ row: { original: partition } }) => ( +
+ {partition.id} + {partition.hasErrors && } + {leaderlessPartitions?.includes(partition.id) && Leaderless} + {underReplicatedPartitions?.includes(partition.id) && ( + Under-replicated + )} +
+ ), + }, + { + accessorKey: 'waterMarkLow', + header: ({ column }) => , + cell: ({ row: { original: partition } }) => numberToThousandsString(partition.waterMarkLow), + }, + { + accessorKey: 'waterMarkHigh', + header: ({ column }) => , + cell: ({ row: { original: partition } }) => numberToThousandsString(partition.waterMarkHigh), + }, + { + id: 'messages', + accessorFn: (partition) => (partition.hasErrors ? null : partition.waterMarkHigh - partition.waterMarkLow), + header: ({ column }) => , + cell: ({ row: { original: partition } }) => + partition.hasErrors ? null : numberToThousandsString(partition.waterMarkHigh - partition.waterMarkLow), + }, + { + id: 'brokers', + header: 'Brokers', + enableSorting: false, + cell: ({ row: { original: partition } }) => , + }, + ]; + + const table = useReactTable({ + data: partitions, + columns, + state: { sorting, pagination }, + onSortingChange, + onPaginationChange, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + autoResetPageIndex: false, + }); return ( <> - {warning} - - columns={[ - { - header: 'Partition ID', - accessorKey: 'id', - cell: ({ row: { original: partition } }) => { - const header = partition.hasErrors ? ( - - {partition.id} - - - - - ) : ( - partition.id - ); - - return ( - - {header} - {leaderLessPartitions?.includes(partition.id) && ( - Leaderless - )} - {underReplicatedPartitions?.includes(partition.id) && ( - Under-replicated - )} - - ); - }, - }, - { - id: 'waterMarkLow', - header: () => ( - - Low - - ), - accessorKey: 'waterMarkLow', - cell: ({ row: { original: partition } }) => numberToThousandsString(partition.waterMarkLow), - }, - { - id: 'waterMarkHigh', - header: () => ( - - High - - ), - accessorKey: 'waterMarkHigh', - cell: ({ row: { original: partition } }) => numberToThousandsString(partition.waterMarkHigh), - }, - { - header: 'Messages', - cell: ({ row: { original: partition } }) => - !partition.hasErrors && numberToThousandsString(partition.waterMarkHigh - partition.waterMarkLow), - }, - { - header: 'Brokers', - cell: ({ row: { original: partition } }) => , - }, - ]} - data={partitions} - // @ts-expect-error - we need to get rid of this enum in DataTable - defaultPageSize={uiState.topicSettings.partitionPageSize} - onPaginationChange={onPaginationChange(paginationParams, ({ pageSize, pageIndex }) => { - persistPartitionPageSize(pageSize); - editQuery((query) => { - query.page = String(pageIndex); - query.pageSize = String(pageSize); - }); - })} - pagination={paginationParams} - sorting - /> + {topic.cleanupPolicy.toLowerCase() === 'compact' && ( + + Topic cleanupPolicy is 'compact'. Message Count is an estimate! + + )} + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + + + {table.getRowModel().rows.length === 0 ? ( + + + No data found + + + ) : ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + {flexRender(cell.column.columnDef.cell, cell.getContext())} + ))} + + )) + )} + +
+ ); }; @@ -148,21 +164,19 @@ const PartitionError: FC<{ partition: Partition }> = ({ partition }) => { } return ( - - {Boolean(partition.partitionError) && {partition.partitionError}} - {Boolean(partition.waterMarksError) && {partition.waterMarksError}} - - } - hideCloseButton - placement="right-start" - size="auto" - title="Partition Error" - > - - - + + + + + +

Partition Error

+
+ {Boolean(partition.partitionError) &&

{partition.partitionError}

} + {Boolean(partition.waterMarksError) &&

{partition.waterMarksError}

} +
+
); }; diff --git a/frontend/src/components/pages/topics/topic-configuration.test.tsx b/frontend/src/components/pages/topics/topic-configuration.test.tsx index 55b9574a4b..45f680073c 100644 --- a/frontend/src/components/pages/topics/topic-configuration.test.tsx +++ b/frontend/src/components/pages/topics/topic-configuration.test.tsx @@ -1,62 +1,131 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, within } from '@testing-library/react'; +import { vi } from 'vitest'; import ConfigurationEditor from './topic-configuration'; + +vi.mock('@tanstack/react-router', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useSearch: () => ({}), + useNavigate: () => vi.fn(), + }; +}); + +const mockIsFeatureFlagEnabled = vi.fn<(flag: string) => boolean>(); +vi.mock('../../../config', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isServerless: () => false, + isFeatureFlagEnabled: (flag: string) => mockIsFeatureFlagEnabled(flag), + }; +}); + import type { ConfigEntryExtended } from '../../../state/rest-interfaces'; +const makeEntry = (overrides: Partial & { category: string }): ConfigEntryExtended => ({ + name: 'test.option', + value: '', + source: '', + type: 'STRING', + isExplicitlySet: false, + isDefaultValue: false, + isReadOnly: false, + isSensitive: false, + synonyms: [], + currentValue: '', + ...overrides, +}); + describe('TopicConfiguration', () => { - test('renders groups in the correct order', () => { - // Generate an out of order set of test options - const entries: ConfigEntryExtended[] = [ - 'Retention', - 'Tiered Storage', - 'Storage Internals', - 'Compression', - 'Compaction', - 'Replication', - 'Iceberg', - '', // unknown options should appear at the end as 'Other' - 'Message Handling', - 'Write Caching', - 'Schema Registry and Validation', - ].map((category) => ({ - name: 'test.option', - category, - value: '', - source: '', - type: 'STRING', - isExplicitlySet: false, - isDefaultValue: false, - isReadOnly: false, - isSensitive: false, - synonyms: [], - currentValue: '', - })); - - const { container } = render( - { - // no op - test callback - }} - targetTopic="" - /> - ); - expect(screen.getByTestId('config-group-table')).toBeVisible(); - - const groups = container.querySelectorAll('.configGroupTitle'); - - expect([ - 'Retention', - 'Compaction', - 'Replication', - 'Tiered Storage', - 'Write Caching', - 'Iceberg', - 'Schema Registry and Validation', - 'Message Handling', - 'Compression', - 'Storage Internals', - 'Other', - ]).toEqual(Array.from(groups).map((g) => g.textContent)); + describe('legacy layout (enableNewTopicPage off)', () => { + beforeEach(() => mockIsFeatureFlagEnabled.mockReturnValue(false)); + + test('renders groups in the correct order', () => { + // Generate an out of order set of test options + const entries: ConfigEntryExtended[] = [ + 'Retention', + 'Tiered Storage', + 'Storage Internals', + 'Compression', + 'Compaction', + 'Replication', + 'Iceberg', + '', // unknown options should appear at the end as 'Other' + 'Message Handling', + 'Write Caching', + 'Schema Registry and Validation', + ].map((category) => makeEntry({ category })); + + const { container } = render( + { + // no op - test callback + }} + targetTopic="" + /> + ); + expect(screen.getByTestId('config-group-table')).toBeVisible(); + + const groups = container.querySelectorAll('.configGroupTitle'); + + expect(Array.from(groups).map((g) => g.textContent)).toEqual([ + 'Retention', + 'Compaction', + 'Replication', + 'Tiered Storage', + 'Write Caching', + 'Iceberg', + 'Schema Registry and Validation', + 'Message Handling', + 'Compression', + 'Storage Internals', + 'Other', + ]); + }); + }); + + describe('grouped layout (enableNewTopicPage on)', () => { + beforeEach(() => mockIsFeatureFlagEnabled.mockReturnValue(true)); + + test('renders a sidebar and titled sections, preserving backend categories and collapsing only unmapped ones into Other', () => { + const entries: ConfigEntryExtended[] = [ + makeEntry({ name: 'retention.ms', category: 'Retention', isExplicitlySet: true }), + makeEntry({ name: 'cleanup.policy', category: 'Compaction' }), + makeEntry({ name: 'redpanda.iceberg.mode', category: 'Iceberg' }), + makeEntry({ name: 'some.unknown.option', category: 'Totally Unknown' }), + ]; + + render( + { + // no op - test callback + }} + targetTopic="my-topic" + /> + ); + + // Sidebar lists each visible category; known backend categories like 'Iceberg' are + // preserved, and only genuinely unmapped categories collapse into 'Other'. + const nav = screen.getByRole('navigation', { name: 'Configuration categories' }); + expect(within(nav).getByText('Retention')).toBeVisible(); + expect(within(nav).getByText('Compaction')).toBeVisible(); + expect(within(nav).getByText('Iceberg')).toBeVisible(); + expect(within(nav).getByText('Other')).toBeVisible(); + + // Each visible category renders as a titled section. + const retentionSection = screen.getByRole('heading', { name: 'Retention' }).closest('section') as HTMLElement; + expect(retentionSection).toBeVisible(); + + // Only modified rows get a badge; the explicitly-set retention.ms row shows 'Modified', + // and the default cleanup.policy row shows no badge. + expect(within(retentionSection).getByText('Modified')).toBeVisible(); + const compactionSection = screen.getByRole('heading', { name: 'Compaction' }).closest('section') as HTMLElement; + expect(within(compactionSection).queryByText('Modified')).not.toBeInTheDocument(); + expect(within(compactionSection).queryByText('Default')).not.toBeInTheDocument(); + }); }); }); diff --git a/frontend/src/components/pages/topics/topic-configuration.tsx b/frontend/src/components/pages/topics/topic-configuration.tsx index c3e0dc708f..a135af1b30 100644 --- a/frontend/src/components/pages/topics/topic-configuration.tsx +++ b/frontend/src/components/pages/topics/topic-configuration.tsx @@ -1,43 +1,50 @@ +import { useNavigate, useSearch } from '@tanstack/react-router'; +import { Alert, AlertDescription } from 'components/redpanda-ui/components/alert'; +import { Badge } from 'components/redpanda-ui/components/badge'; +import { Button } from 'components/redpanda-ui/components/button'; import { - Alert, - AlertIcon, - Box, - Button, - Flex, - FormField, - Icon, - Input, - Modal, - ModalBody, - ModalContent, - ModalFooter, - ModalHeader, - ModalOverlay, - PasswordInput, - Popover, - RadioGroup, - SearchField, - Text, - Tooltip, - useToast, -} from '@redpanda-data/ui'; -import { EditIcon, InfoIcon } from 'components/icons'; -import type { FC } from 'react'; -import { useState } from 'react'; + Dialog, + DialogBody, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from 'components/redpanda-ui/components/dialog'; +import { Empty, EmptyDescription } from 'components/redpanda-ui/components/empty'; +import { Input } from 'components/redpanda-ui/components/input'; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from 'components/redpanda-ui/components/input-group'; +import { Label } from 'components/redpanda-ui/components/label'; +import { Popover, PopoverContent, PopoverTrigger } from 'components/redpanda-ui/components/popover'; +import { RadioGroup, RadioGroupItem } from 'components/redpanda-ui/components/radio-group'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from 'components/redpanda-ui/components/select'; +import { Slider } from 'components/redpanda-ui/components/slider'; +import { ToggleGroup, ToggleGroupItem } from 'components/redpanda-ui/components/toggle-group'; +import { Tooltip, TooltipContent, TooltipTrigger } from 'components/redpanda-ui/components/tooltip'; +import { Pencil as EditIcon, Info as InfoIcon, Search, X as XIcon } from 'lucide-react'; +import type { FC, ReactNode } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { Controller, type SubmitHandler, useForm, useWatch } from 'react-hook-form'; +import { toast } from 'sonner'; -import { DataSizeSelect, DurationSelect, NumInput, RatioInput } from './CreateTopicModal/create-topic-modal'; -import type { ConfigEntryExtended } from '../../../state/rest-interfaces'; +import { isFeatureFlagEnabled, isServerless } from '../../../config'; +import { api, useApiStoreHook } from '../../../state/backend-api'; +import type { ConfigEntryExtended, ConfigEntrySynonym } from '../../../state/rest-interfaces'; import { entryHasInfiniteValue, formatConfigValue, getInfiniteValueForEntry, } from '../../../utils/formatters/config-value-formatter'; -import './TopicConfiguration.scss'; - -import { isServerless } from '../../../config'; -import { api, useApiStoreHook } from '../../../state/backend-api'; -import { SingleSelect } from '../../misc/select'; type ConfigurationEditorProps = { targetTopic: string; // topic name, or null if default configs @@ -50,13 +57,67 @@ type Inputs = { customValue: string | number | undefined | null; }; +// ── Shared config helpers ─────────────────────────────────────────────────────── + +const SOURCE_PRIORITY_ORDER = [ + 'DYNAMIC_TOPIC_CONFIG', + 'DYNAMIC_BROKER_CONFIG', + 'DYNAMIC_DEFAULT_BROKER_CONFIG', + 'STATIC_BROKER_CONFIG', + 'DEFAULT_CONFIG', +]; + +/** Highest-priority synonym that represents the inherited (non topic-level) default. */ +function getDefaultConfigSynonym(entry: ConfigEntryExtended): ConfigEntrySynonym | undefined { + return entry.synonyms + ?.filter(({ source }) => source !== 'DYNAMIC_TOPIC_CONFIG') + .sort((a, b) => SOURCE_PRIORITY_ORDER.indexOf(a.source) - SOURCE_PRIORITY_ORDER.indexOf(b.source))[0]; +} + +/** A config is "modified" when this topic explicitly overrides the cluster/broker default. */ +function isConfigModified(entry: ConfigEntryExtended): boolean { + return entry.isExplicitlySet; +} + +/** First option for enum-style editors, used as the fallback when there's no value. */ +function getFirstSelectOption(entry: ConfigEntryExtended): string | undefined { + if (entry.frontendFormat === 'BOOLEAN') { + return 'false'; + } + if (entry.frontendFormat === 'SELECT') { + return entry.enumValues?.[0]; + } + return; +} + +// Curated categories + order for the grouped layout. Anything the backend tags +// with a category outside this set falls back to "Other". +const CONFIG_CATEGORIES = [ + { name: 'Retention', blurb: 'How long and how much data this topic retains.' }, + { name: 'Compaction', blurb: 'Log cleanup and key-based compaction behavior.' }, + { name: 'Replication', blurb: 'Durability and in-sync replica requirements.' }, + { name: 'Tiered Storage', blurb: 'Offloading topic data to object storage.' }, + { name: 'Write Caching', blurb: 'Write acknowledgement and caching behavior.' }, + { name: 'Iceberg', blurb: 'Apache Iceberg table integration for this topic.' }, + { name: 'Schema Registry and Validation', blurb: 'Schema ID validation for keys and values.' }, + { name: 'Message Handling', blurb: 'Message size, timestamps, and conversion behavior.' }, + { name: 'Compression', blurb: 'Message compression behavior.' }, + { name: 'Storage Internals', blurb: 'Low-level segment and index storage settings.' }, + { name: 'Other', blurb: 'Additional topic configuration.' }, +] as const; + +const ALLOWED_CATEGORIES = new Set(CONFIG_CATEGORIES.map((c) => c.name)); + +function categoryForEntry(entry: ConfigEntryExtended): string { + return entry.category && ALLOWED_CATEGORIES.has(entry.category) ? entry.category : 'Other'; +} + const ConfigEditorForm: FC<{ editedEntry: ConfigEntryExtended; onClose: () => void; onSuccess: () => void; targetTopic: string; }> = ({ editedEntry, onClose, targetTopic, onSuccess }) => { - const toast = useToast(); const [globalError, setGlobalError] = useState(null); const defaultValueType = (() => { @@ -65,12 +126,20 @@ const ConfigEditorForm: FC<{ } return entryHasInfiniteValue(editedEntry) ? 'infinite' : 'custom'; })(); - const defaultCustomValue = + const defaultConfigSynonym = getDefaultConfigSynonym(editedEntry); + const explicitCustomValue = editedEntry.isExplicitlySet && !entryHasInfiniteValue(editedEntry) ? editedEntry.value : ''; + // Seed Custom from the resolved/inherited value (explicit override → current effective + // value → inherited default) so opening Custom and saving without touching the control + // doesn't silently overwrite a non-default BOOLEAN/SELECT value. Only fall back to the + // first enum option when nothing is resolved, so the dropdown still shows a concrete choice. + const resolvedValue = explicitCustomValue || editedEntry.value || defaultConfigSynonym?.value || ''; + const defaultCustomValue = resolvedValue || getFirstSelectOption(editedEntry) || ''; const { control, handleSubmit, + setValue, formState: { isSubmitting }, } = useForm({ defaultValues: { @@ -121,14 +190,7 @@ const ConfigEditorForm: FC<{ value: configValue, }, ]); - toast({ - status: 'success', - description: ( - - Config {editedEntry.name} updated - - ), - }); + toast.success(`Config ${editedEntry.name} updated`); onSuccess(); onClose(); } catch (err) { @@ -140,40 +202,52 @@ const ConfigEditorForm: FC<{ const valueType = useWatch({ control, name: 'valueType' }); - const SOURCE_PRIORITY_ORDER = [ - 'DYNAMIC_TOPIC_CONFIG', - 'DYNAMIC_BROKER_CONFIG', - 'DYNAMIC_DEFAULT_BROKER_CONFIG', - 'STATIC_BROKER_CONFIG', - 'DEFAULT_CONFIG', - ]; - - const defaultConfigSynonym = editedEntry.synonyms - ?.filter(({ source }) => source !== 'DYNAMIC_TOPIC_CONFIG') - .sort((a, b) => SOURCE_PRIORITY_ORDER.indexOf(a.source) - SOURCE_PRIORITY_ORDER.indexOf(b.source))[0]; + // Route "Reset to default" through the normal submit path (which DELETEs for the + // 'default' value type) instead of firing an out-of-band DELETE. This shares the + // single pending/disabled state with Save/Cancel, so reset can't race a concurrent + // Save and the buttons disable together while the mutation is in flight. + const handleReset = () => { + setValue('valueType', 'default'); + void handleSubmit(onSubmit)(); + }; return ( - -
- - - {`Edit ${editedEntry.name}`} - - {editedEntry.documentation} - - - + { + if (!open) onClose(); + }} + open + > + + + + {`Edit ${editedEntry.name}`} + + +

{editedEntry.documentation}

+ +
+
+ ( - + + {valueTypeOptions.map((opt) => ( +
+ + +
+ ))} +
)} /> - +
{valueType === 'custom' && ( - - +
+ +
)} /> - - +
+
)} - {/*It's not possible to show default value until we get it always from the BE.*/} - {/*Currently we only retrieve the current value and not default if it's set to custom/infinite*/} {valueType === 'default' && defaultConfigSynonym && ( - +
The default value is{' '} - - {/*{JSON.stringify(editedEntry)}*/} - {formatConfigValue(editedEntry.name, defaultConfigSynonym.value, 'friendly')} - - . This is inherited from {defaultConfigSynonym.source}. - + {formatConfigValue(editedEntry.name, defaultConfigSynonym.value, 'friendly')}. This + is inherited from {defaultConfigSynonym.source}. +
)} - +
{Boolean(globalError) && ( - - - {globalError} + + {globalError} )} -
- + + + {editedEntry.isExplicitlySet ? ( + + ) : null} - - -
- -
+ + + + ); }; -const ConfigurationEditor: FC = (props) => { - const [filter, setFilter] = useState(''); +const ConfigurationEditorLegacy: FC = (props) => { + const navigate = useNavigate({ from: '/topics/$topicName/' }); + const { configFilter = '' } = useSearch({ from: '/topics/$topicName/' }); const [editedEntry, setEditedEntry] = useState(null); const topicPermissions = useApiStoreHook((s) => s.topicPermissions.get(props.targetTopic)); + const setFilter = (value: string) => { + navigate({ search: (prev) => ({ ...prev, configFilter: value || undefined }), replace: true }); + }; + const editConfig = (configEntry: ConfigEntryExtended) => { setEditedEntry(configEntry); }; @@ -236,8 +316,11 @@ const ConfigurationEditor: FC = (props) => { const hasEditPermissions = topic ? (topicPermissions?.canEditTopicConfig ?? true) : true; let entries = props.entries; - if (filter) { - entries = entries.filter((x) => x.name.includes(filter) || (x.value ?? '').includes(filter)); + if (configFilter) { + // Match name/documentation only — never config values. `configFilter` is URL-backed, + // so matching `x.value` would leak sensitive/internal values into browser history and + // shareable links. + entries = entries.filter((x) => x.name.includes(configFilter) || (x.documentation ?? '').includes(configFilter)); } const entryOrder = { @@ -281,7 +364,7 @@ const ConfigurationEditor: FC = (props) => { categories.sort((a, b) => displayOrder.indexOf(a.key ?? '') - displayOrder.indexOf(b.key ?? '')); return ( - +
{editedEntry !== null && ( = (props) => { targetTopic={props.targetTopic} /> )} -
- +
+
+ + + + + setFilter(e.target.value)} placeholder="Filter" value={configFilter} /> + +
{categories.map((x) => ( = (props) => { /> ))}
- +
+ ); +}; + +// ── Grouped, navigable layout (behind the `enableNewTopicPage` feature flag) ───── + +type ConfigSection = { + name: string; + blurb: string; + rows: ConfigEntryExtended[]; + modifiedCount: number; +}; + +const ConfigurationEditorGrouped: FC = (props) => { + const navigate = useNavigate({ from: '/topics/$topicName/' }); + const { configFilter = '', configScope = 'all' } = useSearch({ from: '/topics/$topicName/' }); + const scope = configScope; + const [editedEntry, setEditedEntry] = useState(null); + const [activeCategory, setActiveCategory] = useState(null); + const topicPermissions = useApiStoreHook((s) => s.topicPermissions.get(props.targetTopic)); + + // The sections render in their own scroll container; clicking a sidebar category + // scrolls to its section *within this panel* (not by filtering, and without + // scrolling the whole page). + const sectionsContainerRef = useRef(null); + const sectionRefs = useRef>({}); + + const scrollToCategory = (name: string) => { + const container = sectionsContainerRef.current; + const section = sectionRefs.current[name]; + if (!(container && section)) { + return; + } + const top = section.getBoundingClientRect().top - container.getBoundingClientRect().top + container.scrollTop; + container.scrollTo({ top, behavior: 'smooth' }); + setActiveCategory(name); + }; + + const topic = props.targetTopic; + const hasEditPermissions = topic ? (topicPermissions?.canEditTopicConfig ?? true) : true; + + const setFilter = (value: string) => { + navigate({ search: (prev) => ({ ...prev, configFilter: value || undefined }), replace: true }); + }; + + const setScope = (value: 'all' | 'modified') => { + navigate({ search: (prev) => ({ ...prev, configScope: value === 'all' ? undefined : value }), replace: true }); + }; + + const query = configFilter.toLowerCase(); + const totalModifiedCount = useMemo(() => props.entries.filter(isConfigModified).length, [props.entries]); + + const sections = useMemo(() => { + const matchesQuery = (e: ConfigEntryExtended) => + !query || e.name.toLowerCase().includes(query) || (e.documentation ?? '').toLowerCase().includes(query); + + return CONFIG_CATEGORIES.map(({ name, blurb }) => { + const rows = props.entries.filter( + (e) => categoryForEntry(e) === name && matchesQuery(e) && (scope === 'all' || isConfigModified(e)) + ); + return { name, blurb, rows, modifiedCount: rows.filter(isConfigModified).length }; + }).filter((s) => s.rows.length > 0); + }, [props.entries, query, scope]); + + // Sidebar is a stable index of the topic's categories — not subject to the + // search/scope filter, so it never reflows as you type or toggle Modified. + const sidebarCategories = useMemo( + () => + CONFIG_CATEGORIES.map(({ name }) => { + const categoryEntries = props.entries.filter((e) => categoryForEntry(e) === name); + return { name, count: categoryEntries.length, modifiedCount: categoryEntries.filter(isConfigModified).length }; + }).filter((c) => c.count > 0), + [props.entries] + ); + + // Scroll-spy: highlight the sidebar category whose section is at the top of the + // scroll container as the user scrolls. Re-attaches whenever the rendered section + // set changes so the observer always tracks the current section elements. + // biome-ignore lint/correctness/useExhaustiveDependencies: re-run on section set change + useEffect(() => { + const container = sectionsContainerRef.current; + if (!container) { + return; + } + const observer = new IntersectionObserver( + (observed) => { + const topmost = observed + .filter((e) => e.isIntersecting) + .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0]; + if (topmost) { + setActiveCategory(topmost.target.getAttribute('data-category')); + } + }, + { root: container, rootMargin: '0px 0px -70% 0px', threshold: 0 } + ); + for (const el of Object.values(sectionRefs.current)) { + if (el) { + observer.observe(el); + } + } + return () => observer.disconnect(); + }, [sections]); + + return ( +
+ {editedEntry !== null && ( + setEditedEntry(null)} + onSuccess={() => props.onForceRefresh()} + targetTopic={props.targetTopic} + /> + )} + + + +
+
+
+ + + + + setFilter(e.target.value)} + placeholder="Filter" + value={configFilter} + /> + {configFilter ? ( + + setFilter('')} size="icon-xs"> + + + + ) : null} + +
+ { + if (v) { + setScope(v as 'all' | 'modified'); + } + }} + type="single" + value={scope} + > + All + + Modified + {totalModifiedCount > 0 && ( + + {totalModifiedCount} + + )} + + +
+ +
+ {sections.length === 0 ? ( + + No configuration entries match your filters + + ) : ( + sections.map((s) => ( +
{ + sectionRefs.current[s.name] = el; + }} + > +
+

+ {s.name} +

+

{s.blurb}

+
+
+ {s.rows.map((entry) => ( + + ))} +
+
+ )) + )} +
+
+
); }; +const ConfigRow: FC<{ + entry: ConfigEntryExtended; + hasEditPermissions: boolean; + onEditEntry: (entry: ConfigEntryExtended) => void; +}> = ({ entry, hasEditPermissions, onEditEntry }) => { + const { canEdit, reason: nonEdittableReason } = isTopicConfigEdittable(entry, hasEditPermissions); + const modified = isConfigModified(entry); + const friendlyValue = formatConfigValue(entry.name, entry.value, 'friendly'); + const defaultSynonym = getDefaultConfigSynonym(entry); + const defaultValue = defaultSynonym ? formatConfigValue(entry.name, defaultSynonym.value, 'friendly') : null; + + const valueButton = ( + + ); + + return ( +
+
+
+ {entry.documentation ? ( + + + + + +
+

{entry.name}

+

{entry.documentation}

+

{getConfigDescription(entry.source)}

+
+
+
+ ) : ( + {entry.name} + )} + {modified ? ( + + Modified + + ) : null} +
+ {modified && defaultValue !== null && ( +

+ Default: {defaultValue} +

+ )} +
+
+ {canEdit ? ( + valueButton + ) : ( + + {valueButton} + {nonEdittableReason} + + )} +
+
+ ); +}; + +const ConfigurationEditor: FC = (props) => + isFeatureFlagEnabled('enableNewTopicPage') ? ( + + ) : ( + + ); + export default ConfigurationEditor; const ConfigGroup = (p: { @@ -319,8 +723,8 @@ const ConfigGroup = (p: { hasEditPermissions: boolean; }) => ( <> -
- {Boolean(p.groupName) &&
{p.groupName}
} +
+ {Boolean(p.groupName) &&
{p.groupName}
} {p.entries.map((e) => ( { + if (canEdit) { + p.onEditEntry(p.entry); + } + }} + type="button" + > + + + ); + return ( <> - - {p.entry.name} - +
+ {p.entry.name} +
- {friendlyValue} + {friendlyValue} - {Boolean(entry.isExplicitlySet) && 'Custom'} + {Boolean(entry.isExplicitlySet) && 'Custom'} - - - - + + {canEdit ? ( + editButton + ) : ( + + {editButton} + {nonEdittableReason} + + )} {Boolean(entry.documentation) && ( - - - {entry.name} - - {entry.documentation} - {getConfigDescription(entry.source)} - - } - hideCloseButton - size="lg" - > - - - + + + + + +
+

{entry.name}

+

{entry.documentation}

+

{getConfigDescription(entry.source)}

+
+
)}
@@ -429,51 +841,41 @@ export const ConfigEntryEditorController = (p: { switch (entry.frontendFormat) { case 'BOOLEAN': return ( - - onChange={onChange} - options={[ - { value: 'false' as T, label: 'False' }, - { value: 'true' as T, label: 'True' }, - ]} - value={value} - /> + ); case 'SELECT': return ( - ({ ...base, minWidth: '240px' }) }} - className={p.className} - onChange={onChange} - options={ - entry.enumValues?.map((enumValue) => ({ - value: enumValue as T, - label: enumValue, - })) ?? [] - } - value={value} - /> + ); case 'BYTE_SIZE': - return ( - onChange(Math.round(e) as T)} - valueBytes={Number(value ?? 0)} - /> - ); + return onChange(Math.round(e) as T)} valueBytes={Number(value ?? 0)} />; + case 'DURATION': - return ( - onChange(Math.round(e) as T)} - valueMilliseconds={Number(value ?? 0)} - /> - ); + return onChange(Math.round(e) as T)} valueMilliseconds={Number(value ?? 0)} />; case 'PASSWORD': - return onChange(x.target.value as T)} value={value ?? ''} />; + return onChange(x.target.value as T)} type="password" value={String(value ?? '')} />; case 'RATIO': return onChange(x as T)} value={Number(value || entry.value)} />; @@ -483,6 +885,7 @@ export const ConfigEntryEditorController = (p: { case 'DECIMAL': return onChange(e as T)} value={Number(value)} />; + default: return onChange(e.target.value as T)} value={String(value)} />; } @@ -501,3 +904,176 @@ function getConfigDescription(source: string): string { return ''; } } + +// ── Local input helpers ──────────────────────────────────────────────────────── + +function NumInput(p: { + value: number | undefined; + onChange: (n: number | undefined) => void; + placeholder?: string; + min?: number; + max?: number; + disabled?: boolean; + addonAfter?: ReactNode; +}) { + const [editValue, setEditValue] = useState(p.value === undefined ? undefined : String(p.value)); + useEffect(() => setEditValue(p.value === undefined ? undefined : String(p.value)), [p.value]); + + const commit = (x: number | undefined) => { + if (p.disabled) return; + let v = x; + if (v !== undefined && p.min !== undefined && v < p.min) v = p.min; + if (v !== undefined && p.max !== undefined && v > p.max) v = p.max; + setEditValue(v === undefined ? undefined : String(v)); + p.onChange?.(v); + }; + + const input = ( + { + if (!editValue) { + commit(undefined); + setEditValue(''); + return; + } + const n = Number(editValue); + if (!Number.isFinite(n)) { + commit(undefined); + setEditValue(''); + return; + } + commit(n); + }} + onChange={(e) => { + setEditValue(e.target.value); + const n = Number(e.target.value); + if (e.target.value !== '' && !Number.isNaN(n)) p.onChange?.(n); + else p.onChange?.(undefined); + }} + onWheel={(e) => commit(Math.round((p.value ?? 0) - Math.sign(e.deltaY)))} + placeholder={p.placeholder} + spellCheck={false} + value={p.disabled && p.placeholder && p.value === undefined ? '' : (editValue ?? '')} + /> + ); + + if (!p.addonAfter) return input; + return ( +
+ {input} + {p.addonAfter} +
+ ); +} + +function UnitInput({ + baseValue, + unitFactors, + onChange, +}: { + baseValue: number; + unitFactors: Readonly>; + onChange: (v: number) => void; +}) { + const getInitialUnit = (): U => { + const pairs = (Object.entries(unitFactors) as [U, number][]) + .map(([unit, factor]) => ({ unit, text: String(baseValue / factor) })) + .sort((a, b) => a.text.length - b.text.length); + return pairs[0].unit; + }; + + const [unit, setUnit] = useState(getInitialUnit); + const unitValue = baseValue / unitFactors[unit]; + + return ( + setUnit(u as U)} value={unit}> + + + + + {(Object.keys(unitFactors) as U[]).map((u) => ( + + {u} + + ))} + + + } + min={0} + onChange={(x) => onChange((x ?? 0) * unitFactors[unit])} + value={unitValue} + /> + ); +} + +const dataSizeFactors = { + Bytes: 1, + KiB: 1024, + MiB: 1024 * 1024, + GiB: 1024 * 1024 * 1024, + TiB: 1024 * 1024 * 1024 * 1024, +} as const; + +const durationFactors = { + ms: 1, + seconds: 1000, + minutes: 60_000, + hours: 3_600_000, + days: 86_400_000, +} as const; + +function DataSizeSelect(p: { valueBytes: number; onChange: (v: number) => void }) { + return ; +} + +function DurationSelect(p: { valueMilliseconds: number; onChange: (v: number) => void }) { + return ; +} + +function RatioInput(p: { value: number; onChange: (ratio: number) => void }) { + const pct = Math.round(p.value * 100); + return ( +
+
+ + p.onChange(values[0] / 100)} + step={1} + value={[pct]} + /> +
+
+ +
+ { + if (e.target.value === '') return; + const n = Number(e.target.value); + if (!Number.isNaN(n) && n >= 0 && n <= 100) p.onChange(n / 100); + }} + type="number" + value={pct} + /> + + % + +
+
+
+ ); +} diff --git a/frontend/src/components/pages/topics/topic-details.tsx b/frontend/src/components/pages/topics/topic-details.tsx index 8e23c5c500..bf53dfbd4a 100644 --- a/frontend/src/components/pages/topics/topic-details.tsx +++ b/frontend/src/components/pages/topics/topic-details.tsx @@ -17,8 +17,19 @@ import type { ConfigEntry, Topic, TopicAction } from '../../../state/rest-interf import { uiSettings } from '../../../state/ui'; import { uiState } from '../../../state/ui-state'; import '../../../utils/array-extensions'; -import { Box, Button, Code, Flex, Popover, Result, Tooltip } from '@redpanda-data/ui'; import { ErrorIcon, LockIcon, WarningIcon } from 'components/icons'; +import { Badge } from 'components/redpanda-ui/components/badge'; +import { Button } from 'components/redpanda-ui/components/button'; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyTitle, +} from 'components/redpanda-ui/components/empty'; +import { Popover, PopoverContent, PopoverTrigger } from 'components/redpanda-ui/components/popover'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from 'components/redpanda-ui/components/tabs'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'components/redpanda-ui/components/tooltip'; import DeleteRecordsModal from './DeleteRecordsModal/delete-records-modal'; import { TopicQuickInfoStatistic } from './quick-info'; @@ -34,104 +45,87 @@ import { isServerless } from '../../../config'; import { AppFeatures } from '../../../utils/env'; import { DefaultSkeleton } from '../../../utils/tsx-utils'; import PageContent from '../../misc/page-content'; -import Section from '../../misc/section'; -import Tabs from '../../misc/tabs/tabs'; import { PageComponent, type PageInitHelper } from '../page'; const TopicTabIds = ['messages', 'consumers', 'partitions', 'configuration', 'documentation', 'topicacl'] as const; export type TopicTabId = (typeof TopicTabIds)[number]; -// A tab (specifying title+content) that disable/lock itself if the user doesn't have some required permissions. -class TopicTab { - readonly topicGetter: () => Topic | undefined | null; +type TopicTabProps = { + topic: Topic; id: TopicTabId; - private readonly requiredPermission: TopicAction; + requiredPermission: TopicAction; titleText: React.ReactNode; - private readonly contentFunc: (topic: Topic) => React.ReactNode; - private readonly disableHooks?: ((topic: Topic) => React.ReactNode | undefined)[]; - - // biome-ignore lint/nursery/useMaxParams: Legacy class with many constructor parameters - constructor( - topicGetter: () => Topic | undefined | null, - id: TopicTabId, - requiredPermission: TopicAction, - titleText: React.ReactNode, - contentFunc: (topic: Topic) => React.ReactNode, - disableHooks?: ((topic: Topic) => React.ReactNode | undefined)[] - ) { - this.topicGetter = topicGetter; - this.id = id; - this.requiredPermission = requiredPermission; - this.titleText = titleText; - this.contentFunc = contentFunc; - this.disableHooks = disableHooks; - } - - get isEnabled(): boolean { - const topic = this.topicGetter(); + disableHooks?: ((topic: Topic) => React.ReactNode | undefined)[]; + children: (topic: Topic) => React.ReactNode; +}; - if (topic && this.disableHooks) { - for (const h of this.disableHooks) { - if (h(topic)) { - return false; - } +// Context controls whether TopicTab renders its trigger or its content panel. +// The same elements are rendered twice — once inside and +// once outside — so each instance only renders the relevant part. +const TopicTabModeCtx = React.createContext<'trigger' | 'content'>('content'); + +const TopicTab: React.FC = ({ topic, id, requiredPermission, titleText, disableHooks, children }) => { + const mode = React.useContext(TopicTabModeCtx); + + let customTitle: React.ReactNode | undefined; + if (disableHooks) { + for (const h of disableHooks) { + const result = h(topic); + if (result) { + customTitle = result; + break; } } - - if (!topic) { - return true; // no data yet - } - if (!topic.allowedActions || topic.allowedActions[0] === 'all') { - return true; // Redpanda Console free version - } - - return topic.allowedActions.includes(this.requiredPermission); - } - - get isDisabled(): boolean { - return !this.isEnabled; } - get title(): React.ReactNode { - if (this.isEnabled) { - return this.titleText; - } - - const topic = this.topicGetter(); - if (topic && this.disableHooks) { - for (const h of this.disableHooks) { - const replacementTitle = h(topic); - if (replacementTitle) { - return replacementTitle; - } - } - } + const hasPermission = + !topic.allowedActions || topic.allowedActions[0] === 'all' || topic.allowedActions.includes(requiredPermission); + const isDisabled = !!customTitle || !hasPermission; + + const title = + customTitle ?? + (hasPermission ? ( + titleText + ) : ( + + +
+ {titleText} +
+
+ {`You're missing the required permission '${requiredPermission}' to view this tab`} +
+ )); + if (mode === 'trigger') { return ( - -
- {this.titleText} -
-
+ {title} + ); } - get content(): React.ReactNode { - const topic = this.topicGetter(); - if (topic) { - return this.contentFunc(topic); - } - return null; - } -} + return ( + + {children(topic)} + + ); +}; const mkDocuTip = (text: string, icon?: JSX.Element) => ( - - {icon ?? null}Documentation - + + + + {icon ?? null}Documentation + + {text} + + ); const warnIcon = ( @@ -220,6 +214,8 @@ const TopicDetailsContent = ({ topic, topicName }: { topic: Topic; topicName: st setTimeout(() => topicConfig && addBaseFavs(topicConfig)); + const modifiedConfigCount = topicConfig?.filter((e) => e.isExplicitlySet).length ?? 0; + const leaderLessPartitionIds = (api.clusterHealth?.leaderlessPartitions ?? []).find( ({ topicName: tn }) => tn === topicName )?.partitionIds; @@ -227,117 +223,147 @@ const TopicDetailsContent = ({ topic, topicName }: { topic: Topic; topicName: st ({ topicName: tn }) => tn === topicName )?.partitionIds; - const topicTabs: TopicTab[] = [ - new TopicTab( - () => topic, - 'messages', - 'viewMessages', - 'Messages', - (t) => refreshTopicData(topicName, force)} topic={t} /> - ), - new TopicTab( - () => topic, - 'consumers', - 'viewConsumers', - 'Consumers', - (t) => - ), - new TopicTab( - () => topic, - 'partitions', - 'viewPartitions', - - Partitions - {!!leaderLessPartitionIds && ( - - - - - - )} - {!!underReplicatedPartitionIds && ( - - - - - - )} - , - (t) => - ), - new TopicTab( - () => topic, - 'configuration', - 'viewConfig', - 'Configuration', - (t) => - ), - new TopicTab( - () => topic, - 'topicacl', - 'seeTopic', - 'ACL', - () => , - [ - () => { - if ( - AppFeatures.SINGLE_SIGN_ON && - api.userData !== null && - api.userData !== undefined && - !api.userData.canListAcls - ) { - return ( - -
- {' '} - ACL -
-
- ); - } - return; - }, - ] - ), - new TopicTab( - () => topic, - 'documentation', - 'seeTopic', - 'Documentation', - (t) => , - [ - (t) => (t.documentation === 'NOT_CONFIGURED' ? mkDocuTip('Topic documentation is not configured') : null), - (t) => - t.documentation === 'NOT_EXISTENT' - ? mkDocuTip('Documentation for this topic was not found in the configured repository', warnIcon) - : null, - ] - ), + const aclDisableHooks: ((topic: Topic) => React.ReactNode | undefined)[] = [ + () => { + if ( + AppFeatures.SINGLE_SIGN_ON && + api.userData !== null && + api.userData !== undefined && + !api.userData.canListAcls + ) { + return ( + + +
+ ACL +
+
+ You need the cluster-permission 'viewAcl' to view this tab +
+ ); + } + }, ]; - if (isServerless()) { - topicTabs.splice( - topicTabs.findIndex((t) => t.id === 'documentation'), - 1 - ); - } + const docuDisableHooks: ((topic: Topic) => React.ReactNode | undefined)[] = [ + (t) => (t.documentation === 'NOT_CONFIGURED' ? mkDocuTip('Topic documentation is not configured') : null), + (t) => + t.documentation === 'NOT_EXISTENT' + ? mkDocuTip('Documentation for this topic was not found in the configured repository', warnIcon) + : null, + ]; - const selectedTabId = getSelectedTabId(topicTabs); + const enabledTabIds = new Set( + [ + isTopicTabEnabled(topic, 'viewMessages') && 'messages', + isTopicTabEnabled(topic, 'viewConsumers') && 'consumers', + isTopicTabEnabled(topic, 'viewPartitions') && 'partitions', + isTopicTabEnabled(topic, 'viewConfig') && 'configuration', + isTopicTabEnabled(topic, 'seeTopic', aclDisableHooks) && 'topicacl', + !isServerless() && isTopicTabEnabled(topic, 'seeTopic', docuDisableHooks) && 'documentation', + ].filter(Boolean) as TopicTabId[] + ); + + const selectedTabId = getSelectedTabId(enabledTabIds); + + const tabElements = ( + <> + + {(t) => ( + refreshTopicData(topicName, force)} topic={t} /> + )} + + + {(t) => } + + + Partitions + {!!leaderLessPartitionIds && ( + + + +
+ +
+
+ + {`This topic has ${leaderLessPartitionIds.length} ${leaderLessPartitionIds.length === 1 ? 'a leaderless partition' : 'leaderless partitions'}`} + +
+
+ )} + {!!underReplicatedPartitionIds && ( + + + +
+ +
+
+ + {`This topic has ${underReplicatedPartitionIds.length} ${underReplicatedPartitionIds.length === 1 ? 'an under-replicated partition' : 'under-replicated partitions'}`} + +
+
+ )} +
+ } + topic={topic} + > + {(t) => } + + + Configuration + {modifiedConfigCount > 0 ? ( + + {modifiedConfigCount} + + ) : null} + + } + topic={topic} + > + {(t) => } + + + {() => } + + {!isServerless() && ( + + {(t) => } + + )} + + ); const setTabPage = (activeKey: string): void => { uiSettings.topicDetailsActiveTabKey = activeKey as TopicTabId; const loc = appGlobal.location; loc.hash = String(activeKey); - appGlobal.historyReplace(`${loc.pathname}#${loc.hash}`); + // Preserve the search string so URL-backed tab state (e.g. Configuration's + // configFilter/configScope) survives switching tabs and coming back. + appGlobal.historyReplace(`${loc.pathname}${loc.searchStr}#${loc.hash}`); refreshTopicData(topicName, false); }; @@ -345,38 +371,32 @@ const TopicDetailsContent = ({ topic, topicName }: { topic: Topic; topicName: st return ( <> - {Boolean(uiSettings.topicDetailsShowStatisticsBar) && } - - - - {DeleteRecordsMenuItem(topic.cleanupPolicy === 'compact', topic.allowedActions, () => { - setDeleteRecordsModalAlive(true); - })} - - - {/* Tabs: Messages, Configuration */} -
- ({ - key: id, - disabled: isDisabled, - title, - content, - }))} - /> -
+
+ {Boolean(uiSettings.topicDetailsShowStatisticsBar) && } +
+ + {DeleteRecordsMenuItem(topic.cleanupPolicy === 'compact', topic.allowedActions, () => { + setDeleteRecordsModalAlive(true); + })} +
+
+ + + + + {tabElements} + + + {tabElements} +
{Boolean(deleteRecordsModalAlive) && ( React.ReactNode | undefined)[] +): boolean { + if (disableHooks) { + for (const h of disableHooks) { + if (h(topic)) return false; + } + } + return ( + !topic.allowedActions || topic.allowedActions[0] === 'all' || topic.allowedActions.includes(requiredPermission) + ); +} + +function getSelectedTabId(enabledTabIds: Set): TopicTabId { function computeTabId() { // use url anchor if possible let key = appGlobal.location.hash.replace('#', ''); @@ -434,33 +469,31 @@ function getSelectedTabId(topicTabs: TopicTab[]): TopicTabId { return key as TopicTabId; } - // default to partitions return 'messages'; } const id = computeTabId(); - if (topicTabs.first((t) => t.id === id)?.isEnabled) { + if (enabledTabIds.has(id)) { return id; } - return topicTabs.first((t) => t?.isEnabled)?.id ?? 'messages'; + return TopicTabIds.find((t) => enabledTabIds.has(t)) ?? 'messages'; } function topicNotFound(name: string) { return ( - appGlobal.historyPush('/topics')} variant="solid"> + + + 404 + + The topic {name} does not exist. + + + + - } - status={404} - title="404" - userMessage={ -
- The topic {name} does not exist. -
- } - /> +
+
); } diff --git a/frontend/src/components/redpanda-ui/components/data-table/index.tsx b/frontend/src/components/redpanda-ui/components/data-table/index.tsx index 54eed39cbb..687898c300 100644 --- a/frontend/src/components/redpanda-ui/components/data-table/index.tsx +++ b/frontend/src/components/redpanda-ui/components/data-table/index.tsx @@ -219,7 +219,7 @@ export function DataTablePagination({ pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS, }: DataTablePaginationProps) { return ( -
+
{table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s) selected.
diff --git a/frontend/src/hooks/use-url-table-state.ts b/frontend/src/hooks/use-url-table-state.ts new file mode 100644 index 0000000000..be0947d29e --- /dev/null +++ b/frontend/src/hooks/use-url-table-state.ts @@ -0,0 +1,126 @@ +import type { OnChangeFn, PaginationState, SortingState, Updater } from '@tanstack/react-table'; +import { DEFAULT_TABLE_PAGE_SIZE } from 'components/constants'; +import { parseAsBoolean, parseAsInteger, parseAsString, useQueryState } from 'nuqs'; +import { useEffect } from 'react'; + +import { useQueryStateWithCallback } from './use-query-state-with-callback'; + +/** + * Shape of the per-list ui settings slice this hook reads defaults from and + * writes back to (e.g. `uiSettings.topicPartitionsList`). + */ +type TableSettings = { + pageSize: number; + sortId: string; + sortDesc: boolean; +}; + +type UseUrlTableStateParams = { + /** Prefix for the URL query params (e.g. `consumer` -> `consumerPage`, `consumerPageSize`, ...). */ + keyPrefix: string; + /** ui settings slice used for default page size / sorting and synced on change. */ + settings: TableSettings; + /** Total number of rows; used to clamp a stale `pageIndex` back into range. */ + rowCount: number; + /** + * Whether clamping is active. Pass `false` while data is still loading so a + * transient `rowCount` of 0 does not reset the page index. Defaults to `true`. + */ + enabled?: boolean; +}; + +type UseUrlTableStateResult = { + sorting: SortingState; + pagination: PaginationState; + onSortingChange: OnChangeFn; + onPaginationChange: OnChangeFn; +}; + +/** + * URL-backed sorting + pagination state for TanStack Table tables. + * + * Keeps page index, page size and sorting in the URL search query (prefixed by + * `keyPrefix`) so the view is shareable and survives reloads, while syncing page + * size and sorting back to the provided ui settings slice. + * + * Because tables are configured with `autoResetPageIndex: false`, a stale + * `?{prefix}Page=` from a shared link can point past the last page and render an + * empty table even when rows exist. This hook clamps the effective page index + * into range and repairs the URL once data is available (`enabled`). + */ +export function useUrlTableState({ + keyPrefix, + settings, + rowCount, + enabled = true, +}: UseUrlTableStateParams): UseUrlTableStateResult { + const [pageIndex, setPageIndex] = useQueryState(`${keyPrefix}Page`, parseAsInteger.withDefault(0)); + + const [pageSize, setPageSize] = useQueryStateWithCallback( + { + onUpdate: (val) => { + settings.pageSize = val; + }, + getDefaultValue: () => settings.pageSize, + }, + `${keyPrefix}PageSize`, + parseAsInteger.withDefault(settings.pageSize || DEFAULT_TABLE_PAGE_SIZE) + ); + + const [sortId, setSortId] = useQueryStateWithCallback( + { + onUpdate: (val) => { + settings.sortId = val; + }, + getDefaultValue: () => settings.sortId, + }, + `${keyPrefix}SortId`, + parseAsString.withDefault(settings.sortId ?? '') + ); + + const [sortDesc, setSortDesc] = useQueryStateWithCallback( + { + onUpdate: (val) => { + settings.sortDesc = val; + }, + getDefaultValue: () => settings.sortDesc, + }, + `${keyPrefix}SortDesc`, + parseAsBoolean.withDefault(settings.sortDesc ?? false) + ); + + // Clamp a stale/out-of-range page index into a valid range. Effective value is + // used for rendering immediately; the effect below repairs the URL. + const lastPageIndex = enabled && rowCount > 0 ? Math.ceil(rowCount / pageSize) - 1 : 0; + const clampedPageIndex = enabled ? Math.max(0, Math.min(pageIndex, lastPageIndex)) : pageIndex; + + useEffect(() => { + if (enabled && clampedPageIndex !== pageIndex) { + setPageIndex(clampedPageIndex); + } + }, [enabled, clampedPageIndex, pageIndex, setPageIndex]); + + const sorting: SortingState = sortId ? [{ id: sortId, desc: sortDesc }] : []; + + const onSortingChange: OnChangeFn = (updater: Updater) => { + const next = typeof updater === 'function' ? updater(sorting) : updater; + if (next.length > 0) { + setSortId(next[0].id); + setSortDesc(next[0].desc); + } else { + setSortId(''); + setSortDesc(false); + } + setPageIndex(0); + }; + + const pagination: PaginationState = { pageIndex: clampedPageIndex, pageSize }; + + const onPaginationChange: OnChangeFn = (updater: Updater) => { + const next = typeof updater === 'function' ? updater(pagination) : updater; + setPageIndex(next.pageIndex); + setPageSize(next.pageSize); + }; + + return { sorting, pagination, onSortingChange, onPaginationChange }; +} diff --git a/frontend/src/routes/topics/$topicName/index.tsx b/frontend/src/routes/topics/$topicName/index.tsx index 9763eabd3c..e74a7a3533 100644 --- a/frontend/src/routes/topics/$topicName/index.tsx +++ b/frontend/src/routes/topics/$topicName/index.tsx @@ -19,6 +19,8 @@ import TopicDetails from '../../../components/pages/topics/topic-details'; const searchSchema = z.object({ pageSize: fallback(z.number().int().positive().optional(), DEFAULT_TABLE_PAGE_SIZE), page: fallback(z.number().int().nonnegative().optional(), 0), + configFilter: fallback(z.string().optional(), undefined), + configScope: fallback(z.enum(['all', 'modified']).optional(), undefined), }); export const Route = createFileRoute('/topics/$topicName/')({ diff --git a/frontend/src/state/ui.ts b/frontend/src/state/ui.ts index 36995f80ce..a9fb2fe465 100644 --- a/frontend/src/state/ui.ts +++ b/frontend/src/state/ui.ts @@ -264,6 +264,24 @@ type UISettings = { configViewType: 'structured' | 'table'; }; + topicConsumersList: { + pageSize: number; + sortId: string; + sortDesc: boolean; + }; + + topicPartitionsList: { + pageSize: number; + sortId: string; + sortDesc: boolean; + }; + + topicAclList: { + pageSize: number; + sortId: string; + sortDesc: boolean; + }; + clusterOverview: { connectorsList: { quickSearch: string; @@ -443,6 +461,24 @@ const defaultUiSettings: UISettings = { configViewType: 'structured' as 'structured' | 'table', }, + topicConsumersList: { + pageSize: 20, + sortId: '', + sortDesc: false, + }, + + topicPartitionsList: { + pageSize: 20, + sortId: '', + sortDesc: false, + }, + + topicAclList: { + pageSize: 20, + sortId: '', + sortDesc: false, + }, + clusterOverview: { connectorsList: { quickSearch: '', diff --git a/frontend/tests/test-variant-console/topics/topic-navigation.spec.ts b/frontend/tests/test-variant-console/topics/topic-navigation.spec.ts index bac2463416..32932b9610 100644 --- a/frontend/tests/test-variant-console/topics/topic-navigation.spec.ts +++ b/frontend/tests/test-variant-console/topics/topic-navigation.spec.ts @@ -86,16 +86,17 @@ test.describe('Topic Details - Navigation and Tabs', () => { await expect(page.getByRole('tablist')).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId('config-group-table')).toBeVisible({ timeout: 15_000 }); - // Verify that configuration groups are present - // Note: This test is flexible and will pass even if new groups are added - const configGroups = page.locator('.configGroupTitle'); - const groupCount = await configGroups.count(); + // The grouped layout shows a category sidebar plus titled sections. + // Note: This test is flexible and will pass even if new categories are added. + const sidebar = page.getByRole('navigation', { name: 'Configuration categories' }); + await expect(sidebar).toBeVisible(); + const categoryCount = await sidebar.getByRole('button').count(); - // At least some configuration groups should be visible - expect(groupCount).toBeGreaterThan(0); + // At least some configuration categories should be visible + expect(categoryCount).toBeGreaterThan(0); // Verify Retention group is present (a core group that should always exist) - await expect(page.locator('.configGroupTitle').filter({ hasText: 'Retention' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Retention' })).toBeVisible(); }); await topicPage.deleteTopic(topicName); diff --git a/frontend/tests/test-variant-console/utils/topic-page.ts b/frontend/tests/test-variant-console/utils/topic-page.ts index dd2c4285e9..bce394065e 100644 --- a/frontend/tests/test-variant-console/utils/topic-page.ts +++ b/frontend/tests/test-variant-console/utils/topic-page.ts @@ -177,11 +177,14 @@ export class TopicPage { } async verifyConfigurationGroup(groupName: string) { - await expect(this.page.locator('.configGroupTitle').filter({ hasText: groupName })).toBeVisible(); + await expect(this.page.getByRole('heading', { name: groupName })).toBeVisible(); } async getConfigurationGroups(): Promise { - return await this.page.locator('.configGroupTitle').allTextContents(); + return await this.page + .getByRole('navigation', { name: 'Configuration categories' }) + .getByRole('button') + .allTextContents(); } /**