From 34cd134a59f8c760cf6b80434756625bacb20062 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1n=20Vor=C4=8D=C3=A1k?= Date: Fri, 5 Jun 2026 18:20:40 +0200 Subject: [PATCH 01/16] Move components to UI registry for topic detail --- .../pages/topics/Tab.Acl/acl-list.test.tsx | 18 +- .../pages/topics/Tab.Acl/acl-list.tsx | 238 +++++--- .../common/delete-records-menu-item.tsx | 29 +- .../common/message-search-filter-bar.tsx | 39 +- .../dialogs/save-messages-dialog.tsx | 87 +-- .../pages/topics/Tab.Messages/index.tsx | 547 ++++++++---------- .../message-display/expanded-message.tsx | 110 ++-- .../message-display/message-headers.tsx | 7 +- .../modals/deserializers-modal.tsx | 131 +++-- .../components/pages/topics/tab-config.tsx | 52 +- .../components/pages/topics/tab-consumers.tsx | 164 +++++- .../pages/topics/tab-partitions.tsx | 292 ++++++---- .../pages/topics/topic-configuration.test.tsx | 15 +- .../pages/topics/topic-configuration.tsx | 504 +++++++++++----- .../components/pages/topics/topic-details.tsx | 470 +++++++-------- .../components/data-table/index.tsx | 94 +-- .../src/routes/topics/$topicName/index.tsx | 1 + frontend/src/state/ui.ts | 36 ++ 18 files changed, 1662 insertions(+), 1172 deletions(-) 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..438f984e93 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,20 @@ * by the Apache License, Version 2.0 */ -import { Alert, AlertIcon, DataTable } from '@redpanda-data/ui'; +import { + type ColumnDef, + flexRender, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + type PaginationState, + type SortingState, + type Updater, + useReactTable, +} from '@tanstack/react-table'; +import { parseAsBoolean, parseAsInteger, parseAsString, useQueryState } from 'nuqs'; +import { useQueryStateWithCallback } from '../../../../hooks/use-query-state-with-callback'; import type { AclRule, AclStrOperation, @@ -19,91 +31,183 @@ import type { AclStrResourceType, GetAclOverviewResponse, } from '../../../../state/rest-interfaces'; +import { uiSettings } from '../../../../state/ui'; import { toJson } from '../../../../utils/json-utils'; +import { DEFAULT_TABLE_PAGE_SIZE } from '../../../constants'; +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 [pageIndex, setPageIndex] = useQueryState('aclPage', parseAsInteger.withDefault(0)); + + const [pageSize, setPageSize] = useQueryStateWithCallback( + { + onUpdate: (val) => { + uiSettings.topicAclList.pageSize = val; + }, + getDefaultValue: () => uiSettings.topicAclList.pageSize, + }, + 'aclPageSize', + parseAsInteger.withDefault(DEFAULT_TABLE_PAGE_SIZE) + ); + + const [sortId, setSortId] = useQueryStateWithCallback( + { + onUpdate: (val) => { + uiSettings.topicAclList.sortId = val; + }, + getDefaultValue: () => uiSettings.topicAclList.sortId, + }, + 'aclSortId', + parseAsString.withDefault('') + ); + + const [sortDesc, setSortDesc] = useQueryStateWithCallback( + { + onUpdate: (val) => { + uiSettings.topicAclList.sortDesc = val; + }, + getDefaultValue: () => uiSettings.topicAclList.sortDesc, + }, + 'aclSortDesc', + parseAsBoolean.withDefault(false) + ); + + const sorting: SortingState = sortId ? [{ id: sortId, desc: sortDesc }] : []; + const pagination: PaginationState = { pageIndex, pageSize }; + + const handleSortingChange = (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); + } + void setPageIndex(0); + }; + + const handlePaginationChange = (updater: Updater) => { + const next = typeof updater === 'function' ? updater(pagination) : updater; + void setPageIndex(next.pageIndex); + setPageSize(next.pageSize); + }; + + const table = useReactTable({ + data: resources, + columns, + state: { sorting, pagination }, + onSortingChange: handleSortingChange, + onPaginationChange: handlePaginationChange, + 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..cedc8368c1 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,7 +9,6 @@ * by the Apache License, Version 2.0 */ -import { Box, GridItem, Tag, TagCloseButton, TagLabel } from '@redpanda-data/ui'; import { SettingsIcon } from 'components/icons'; import type { FC } from 'react'; @@ -24,11 +23,11 @@ type MessageSearchFilterBarProps = { export const MessageSearchFilterBar: FC = ({ filters, onEdit, onToggle, onRemove }) => { return ( - - +
+
{/* Existing Tags List */} {filters?.map((e) => ( - = ({ filter }} size={14} /> - onToggle(e.id)} - px="6px" - textDecoration={e.isActive ? '' : 'line-through'} + style={{ textDecoration: e.isActive ? '' : 'line-through' }} + type="button" > {e.name || e.code || 'New Filter'} - - + +
))} - - +
+ ); }; 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..21807c104f 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); } @@ -328,10 +281,6 @@ async function loadLargeMessage({ // 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 +857,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 +971,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 +1014,7 @@ export const TopicMessageView: FC = (props) => { key: { header: () => isKeyDeserializerActive ? ( - +
Key{' '} - +
) : ( 'Key' ), @@ -1100,7 +1043,7 @@ export const TopicMessageView: FC = (props) => { value: { header: () => isValueDeserializerActive ? ( - +
Value{' '} - +
) : ( 'Value' ), @@ -1181,56 +1124,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 +1180,14 @@ export const TopicMessageView: FC = (props) => { enableSorting: false, cell: ({ row }) => row.getCanExpand() ? ( - {row.getIsExpanded() ? : } - + ) : null, }; @@ -1292,48 +1231,48 @@ export const TopicMessageView: FC = (props) => { { 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 +1288,13 @@ export const TopicMessageView: FC = (props) => { // Return JSX for the component return ( <> - - +
+
@@ -1464,59 +1427,54 @@ 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')} + disabled={dynamicFilters.includes('partition')} onClick={() => addDynamicFilter('partition')} > - Partition - - - Partition + + + } - isDisabled={!canUseFilters} + disabled={!canUseFilters} onClick={() => { const filter = createFilterEntry(); setCurrentJSFilter(filter); }} > - JavaScript Filter - - - - + JavaScript Filter + + + +
{/* Search Progress Indicator: "Consuming Messages 30/30" */} {Boolean(searchPhase && searchPhase.length > 0) && ( @@ -1534,74 +1492,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 +1605,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 +1670,7 @@ export const TopicMessageView: FC = (props) => { return ( - + ); @@ -1784,7 +1740,7 @@ export const TopicMessageView: FC = (props) => { {/* Rows per page selector */}

Rows per page

- { const newSize = Number(value); uiState.topicSettings.searchParams.pageSize = newSize; @@ -1802,12 +1758,12 @@ export const TopicMessageView: FC = (props) => { ))} - +
{/* Navigation buttons */}
- setPageIndex(0)} @@ -1816,9 +1772,9 @@ export const TopicMessageView: FC = (props) => { > Go to first page - + - setPageIndex(Math.max(0, pageIndex - 1))} @@ -1827,9 +1783,9 @@ export const TopicMessageView: FC = (props) => { > Go to previous page - + - = loadedPages - 1 && !hasMoreData} onClick={() => setPageIndex((prev) => (prev ?? 0) + 1)} @@ -1838,12 +1794,12 @@ export const TopicMessageView: FC = (props) => { > Go to next page - + - +
@@ -1851,19 +1807,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 +1829,14 @@ export const TopicMessageView: FC = (props) => {
- + Loading more messages...
- ) : 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/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/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..fbc61aa74d 100644 --- a/frontend/src/components/pages/topics/tab-consumers.tsx +++ b/frontend/src/components/pages/topics/tab-consumers.tsx @@ -9,21 +9,29 @@ * by the Apache License, Version 2.0 */ +import { Link } from '@tanstack/react-router'; +import { + type ColumnDef, + flexRender, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + type PaginationState, + type SortingState, + type Updater, + useReactTable, +} from '@tanstack/react-table'; +import { parseAsBoolean, parseAsInteger, parseAsString, useQueryState } from 'nuqs'; 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 { useQueryStateWithCallback } from '../../../hooks/use-query-state-with-callback'; 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 { DEFAULT_TABLE_PAGE_SIZE } from '../../constants'; +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 +42,126 @@ 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 [pageIndex, setPageIndex] = useQueryState('consumerPage', parseAsInteger.withDefault(0)); + + const [pageSize, setPageSize] = useQueryStateWithCallback( + { + onUpdate: (val) => { + uiSettings.topicConsumersList.pageSize = val; + }, + getDefaultValue: () => uiSettings.topicConsumersList.pageSize, + }, + 'consumerPageSize', + parseAsInteger.withDefault(DEFAULT_TABLE_PAGE_SIZE) + ); + + const [sortId, setSortId] = useQueryStateWithCallback( + { + onUpdate: (val) => { + uiSettings.topicConsumersList.sortId = val; + }, + getDefaultValue: () => uiSettings.topicConsumersList.sortId, + }, + 'consumerSortId', + parseAsString.withDefault('') + ); + + const [sortDesc, setSortDesc] = useQueryStateWithCallback( + { + onUpdate: (val) => { + uiSettings.topicConsumersList.sortDesc = val; + }, + getDefaultValue: () => uiSettings.topicConsumersList.sortDesc, + }, + 'consumerSortDesc', + parseAsBoolean.withDefault(false) + ); + + const sorting: SortingState = sortId ? [{ id: sortId, desc: sortDesc }] : []; + const pagination: PaginationState = { pageIndex, pageSize }; + + const handleSortingChange = (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); + } + void setPageIndex(0); + }; + + const handlePaginationChange = (updater: Updater) => { + const next = typeof updater === 'function' ? updater(pagination) : updater; + void setPageIndex(next.pageIndex); + setPageSize(next.pageSize); + }; + + 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: handleSortingChange, + onPaginationChange: handlePaginationChange, + 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.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..dc16d6b60d 100644 --- a/frontend/src/components/pages/topics/tab-partitions.tsx +++ b/frontend/src/components/pages/topics/tab-partitions.tsx @@ -9,135 +9,195 @@ * by the Apache License, Version 2.0 */ +import { + type ColumnDef, + flexRender, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + type PaginationState, + type SortingState, + type Updater, + useReactTable, +} from '@tanstack/react-table'; +import { AlertTriangle } from 'lucide-react'; +import { parseAsBoolean, parseAsInteger, parseAsString, useQueryState } from 'nuqs'; 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 { useQueryStateWithCallback } from '../../../hooks/use-query-state-with-callback'; 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 { DEFAULT_TABLE_PAGE_SIZE } from '../../constants'; import { BrokerList } from '../../misc/broker-list'; +import { Alert, AlertDescription } from '../../redpanda-ui/components/alert'; +import { Badge } from '../../redpanda-ui/components/badge'; +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); + + const [pageIndex, setPageIndex] = useQueryState('partitionPage', parseAsInteger.withDefault(0)); + + const [pageSize, setPageSize] = useQueryStateWithCallback( + { + onUpdate: (val) => { + uiSettings.topicPartitionsList.pageSize = val; + }, + getDefaultValue: () => uiSettings.topicPartitionsList.pageSize, + }, + 'partitionPageSize', + parseAsInteger.withDefault(DEFAULT_TABLE_PAGE_SIZE) + ); + + const [sortId, setSortId] = useQueryStateWithCallback( + { + onUpdate: (val) => { + uiSettings.topicPartitionsList.sortId = val; + }, + getDefaultValue: () => uiSettings.topicPartitionsList.sortId, + }, + 'partitionSortId', + parseAsString.withDefault('') + ); + + const [sortDesc, setSortDesc] = useQueryStateWithCallback( + { + onUpdate: (val) => { + uiSettings.topicPartitionsList.sortDesc = val; + }, + getDefaultValue: () => uiSettings.topicPartitionsList.sortDesc, + }, + 'partitionSortDesc', + parseAsBoolean.withDefault(false) + ); 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 sorting: SortingState = sortId ? [{ id: sortId, desc: sortDesc }] : []; + const pagination: PaginationState = { pageIndex, pageSize }; + + const handleSortingChange = (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); + } + void setPageIndex(0); + }; + + const handlePaginationChange = (updater: Updater) => { + const next = typeof updater === 'function' ? updater(pagination) : updater; + void setPageIndex(next.pageIndex); + setPageSize(next.pageSize); + }; + + 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: handleSortingChange, + onPaginationChange: handlePaginationChange, + 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.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + {flexRender(cell.column.columnDef.cell, cell.getContext())} + ))} + + ))} + +
+ ); }; @@ -148,21 +208,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..900835e129 100644 --- a/frontend/src/components/pages/topics/topic-configuration.test.tsx +++ b/frontend/src/components/pages/topics/topic-configuration.test.tsx @@ -1,6 +1,17 @@ import { render, screen } 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(), + }; +}); + import type { ConfigEntryExtended } from '../../../state/rest-interfaces'; describe('TopicConfiguration', () => { @@ -45,7 +56,7 @@ describe('TopicConfiguration', () => { const groups = container.querySelectorAll('.configGroupTitle'); - expect([ + expect(Array.from(groups).map((g) => g.textContent)).toEqual([ 'Retention', 'Compaction', 'Replication', @@ -57,6 +68,6 @@ describe('TopicConfiguration', () => { 'Compression', 'Storage Internals', 'Other', - ]).toEqual(Array.from(groups).map((g) => g.textContent)); + ]); }); }); diff --git a/frontend/src/components/pages/topics/topic-configuration.tsx b/frontend/src/components/pages/topics/topic-configuration.tsx index c3e0dc708f..642881723b 100644 --- a/frontend/src/components/pages/topics/topic-configuration.tsx +++ b/frontend/src/components/pages/topics/topic-configuration.tsx @@ -1,43 +1,42 @@ +import { useNavigate, useSearch } from '@tanstack/react-router'; +import { Alert, AlertDescription } from 'components/redpanda-ui/components/alert'; +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 { Input } from 'components/redpanda-ui/components/input'; +import { InputGroup, InputGroupAddon, 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 { Tooltip, TooltipContent, TooltipTrigger } from 'components/redpanda-ui/components/tooltip'; +import { Pencil as EditIcon, Info as InfoIcon, Search } from 'lucide-react'; +import type { FC, ReactNode } from 'react'; +import { useEffect, 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 { isServerless } from '../../../config'; +import { api, useApiStoreHook } from '../../../state/backend-api'; import type { ConfigEntryExtended } 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 @@ -56,7 +55,6 @@ const ConfigEditorForm: FC<{ onSuccess: () => void; targetTopic: string; }> = ({ editedEntry, onClose, targetTopic, onSuccess }) => { - const toast = useToast(); const [globalError, setGlobalError] = useState(null); const defaultValueType = (() => { @@ -121,14 +119,7 @@ const ConfigEditorForm: FC<{ value: configValue, }, ]); - toast({ - status: 'success', - description: ( - - Config {editedEntry.name} updated - - ), - }); + toast.success(`Config ${editedEntry.name} updated`); onSuccess(); onClose(); } catch (err) { @@ -153,27 +144,42 @@ const ConfigEditorForm: FC<{ .sort((a, b) => SOURCE_PRIORITY_ORDER.indexOf(a.source) - SOURCE_PRIORITY_ORDER.indexOf(b.source))[0]; 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} )} -
- + + - - -
- -
+ + + + ); }; const ConfigurationEditor: FC = (props) => { - const [filter, setFilter] = useState(''); + 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 +242,8 @@ 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) { + entries = entries.filter((x) => x.name.includes(configFilter) || (x.value ?? '').includes(configFilter)); } const entryOrder = { @@ -281,7 +287,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) => { /> ))}
- +
); }; @@ -319,8 +335,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} - - - {friendlyValue} - - {Boolean(entry.isExplicitlySet) && 'Custom'} - - - - - +
+ {p.entry.name} +
+ + {friendlyValue} + + {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 +453,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 +497,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 +516,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..46c1668f51 100644 --- a/frontend/src/components/pages/topics/topic-details.tsx +++ b/frontend/src/components/pages/topics/topic-details.tsx @@ -17,8 +17,18 @@ 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 { 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 +44,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 = ( @@ -227,110 +220,124 @@ 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) => } + + + {(t) => } + + + {() => } + + {!isServerless() && ( + + {(t) => } + + )} + + ); const setTabPage = (activeKey: string): void => { uiSettings.topicDetailsActiveTabKey = activeKey as TopicTabId; @@ -345,38 +352,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 +450,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 3f9b39e1a0..478e2d306f 100644 --- a/frontend/src/components/redpanda-ui/components/data-table/index.tsx +++ b/frontend/src/components/redpanda-ui/components/data-table/index.tsx @@ -237,51 +237,55 @@ export function DataTablePagination({ table, testId }: DataTablePaginatio
-
- Page {table.getPageCount() === 0 ? 0 : table.getState().pagination.pageIndex + 1} of {table.getPageCount()} -
-
- - - - -
+ {table.getPageCount() > 1 && ( + <> +
+ Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()} +
+
+ + + + +
+ + )}
); diff --git a/frontend/src/routes/topics/$topicName/index.tsx b/frontend/src/routes/topics/$topicName/index.tsx index 9763eabd3c..eae65dd03a 100644 --- a/frontend/src/routes/topics/$topicName/index.tsx +++ b/frontend/src/routes/topics/$topicName/index.tsx @@ -19,6 +19,7 @@ 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), }); 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: '', From 4d64615237fc5a9baa8967551a90218e55c24895 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1n=20Vor=C4=8D=C3=A1k?= Date: Mon, 8 Jun 2026 10:47:33 +0200 Subject: [PATCH 02/16] test(e2e): capture backend container logs when start() fails in shadowlink setup startBackendServerWithConfig swallowed the real crash reason: when the backend container exits during the testcontainers wait strategy, .start() throws before containerId is assigned, so the diagnostic docker-logs block was guarded out and only a bare 409 surfaced. Recover the container ID from the error message (mirrors startBackendServer) and dump exit code, state, and logs on failure. --- frontend/tests/shared/global-setup.mjs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/frontend/tests/shared/global-setup.mjs b/frontend/tests/shared/global-setup.mjs index 7994cf801b..453e53ed57 100644 --- a/frontend/tests/shared/global-setup.mjs +++ b/frontend/tests/shared/global-setup.mjs @@ -940,13 +940,30 @@ async function startBackendServerWithConfig( } catch (error) { console.error(`Failed to start backend on port ${externalPort}:`, error.message); + // When the container crashes during the testcontainers wait strategy, .start() + // throws before containerId is assigned. The error message ("container is + // not running") still contains the ID, so recover it to dump diagnostics. + if (!containerId) { + const containerIdMatch = error.message.match(CONTAINER_ID_REGEX); + if (containerIdMatch) { + containerId = containerIdMatch[1]; + state.backendId = containerId; + console.log(`Recovered container ID from error message: ${containerId}`); + } else { + console.log('Could not extract container ID from error message - no logs available'); + } + } + if (containerId) { try { const { stdout: logs } = await execAsync(`docker logs ${containerId} 2>&1`); const { stdout: inspect } = await execAsync(`docker inspect ${containerId}`); const inspectJson = JSON.parse(inspect); + console.error(`Container ${containerId} exit code:`, inspectJson[0].State.ExitCode); console.error('Container state:', JSON.stringify(inspectJson[0].State, null, 2)); - console.error('Container logs:', logs); + console.error('=== CONTAINER LOGS START ==='); + console.error(logs || '(no logs)'); + console.error('=== CONTAINER LOGS END ==='); } catch (logError) { console.error('Could not fetch container diagnostics:', logError.message); } From c27da9cc3f218aab6447a6ffea4ea6070f48af40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1n=20Vor=C4=8D=C3=A1k?= Date: Mon, 8 Jun 2026 11:06:04 +0200 Subject: [PATCH 03/16] test(e2e): buffer backend logs via log consumer to survive container removal The previous recovery found the crashed container ID but `docker logs` failed because testcontainers removes a container once its wait strategy fails. Attach a withLogConsumer that buffers output while the container is alive, and dump that buffer on failure so the actual backend crash reason is visible. --- frontend/tests/shared/global-setup.mjs | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/frontend/tests/shared/global-setup.mjs b/frontend/tests/shared/global-setup.mjs index 453e53ed57..515c4155b3 100644 --- a/frontend/tests/shared/global-setup.mjs +++ b/frontend/tests/shared/global-setup.mjs @@ -909,6 +909,16 @@ async function startBackendServerWithConfig( }); } + // Capture container output as it is produced. When .start() fails its wait + // strategy, testcontainers stops AND removes the container, so a later + // `docker logs` returns "No such container". A log consumer buffers the + // output while the container is alive, so we still have it on failure. + const capturedLogs = []; + const logConsumer = (stream) => { + stream.on('data', (line) => capturedLogs.push(line.toString())); + stream.on('err', (line) => capturedLogs.push(line.toString())); + }; + let containerId; try { const backend = await new GenericContainer(imageTag) @@ -916,6 +926,7 @@ async function startBackendServerWithConfig( .withNetworkAliases(networkAlias) .withExposedPorts({ container: 3000, host: externalPort }) .withBindMounts(bindMounts) + .withLogConsumer(logConsumer) .withCommand(['--config.filepath=/etc/console/config.yaml']) .start(); @@ -954,21 +965,24 @@ async function startBackendServerWithConfig( } } + // Try live `docker inspect`/`docker logs` first (works if the container + // still exists), then always fall back to the buffered log consumer output + // which survives even after testcontainers removes the crashed container. if (containerId) { try { - const { stdout: logs } = await execAsync(`docker logs ${containerId} 2>&1`); const { stdout: inspect } = await execAsync(`docker inspect ${containerId}`); const inspectJson = JSON.parse(inspect); console.error(`Container ${containerId} exit code:`, inspectJson[0].State.ExitCode); console.error('Container state:', JSON.stringify(inspectJson[0].State, null, 2)); - console.error('=== CONTAINER LOGS START ==='); - console.error(logs || '(no logs)'); - console.error('=== CONTAINER LOGS END ==='); - } catch (logError) { - console.error('Could not fetch container diagnostics:', logError.message); + } catch (inspectError) { + console.error('Could not inspect container (likely already removed):', inspectError.message); } } + console.error('=== CONTAINER LOGS START ==='); + console.error(capturedLogs.length > 0 ? capturedLogs.join('') : '(no logs captured)'); + console.error('=== CONTAINER LOGS END ==='); + throw error; } } From c32443fd13d20240341a2bc40a0237566bb2163b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1n=20Vor=C4=8D=C3=A1k?= Date: Mon, 8 Jun 2026 14:09:41 +0200 Subject: [PATCH 04/16] Add empty placeholderst ot he tab-consumers and tab-partitions --- .../components/pages/topics/tab-consumers.tsx | 20 +++++++++++++------ .../pages/topics/tab-partitions.tsx | 20 +++++++++++++------ 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/pages/topics/tab-consumers.tsx b/frontend/src/components/pages/topics/tab-consumers.tsx index fbc61aa74d..d53e773925 100644 --- a/frontend/src/components/pages/topics/tab-consumers.tsx +++ b/frontend/src/components/pages/topics/tab-consumers.tsx @@ -152,13 +152,21 @@ export const TopicConsumers: FC = ({ topic }) => { ))} - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - {flexRender(cell.column.columnDef.cell, cell.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 dc16d6b60d..2ab64cdb67 100644 --- a/frontend/src/components/pages/topics/tab-partitions.tsx +++ b/frontend/src/components/pages/topics/tab-partitions.tsx @@ -188,13 +188,21 @@ export const TopicPartitions: FC = ({ topic }) => { ))} - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - {flexRender(cell.column.columnDef.cell, cell.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())} + ))} + + )) + )} From 50ec78545bf1b27a71c1758dd619bfbdc30f0b07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1n=20Vor=C4=8D=C3=A1k?= Date: Mon, 8 Jun 2026 14:53:31 +0200 Subject: [PATCH 05/16] Migrate expanded message components to UI registry Remove @redpanda-data/ui from the expanded-message chain: expanded-message, message-meta-data, payload-component, and troubleshoot-report-viewer now use registry components and Tailwind. Replace useToast with sonner and useColorModeValue with theme tokens. --- .../message-display/expanded-message.tsx | 10 ++- .../message-display/message-meta-data.tsx | 20 +++--- .../message-display/payload-component.tsx | 26 +++----- .../troubleshoot-report-viewer.tsx | 64 ++++++------------- 4 files changed, 43 insertions(+), 77 deletions(-) 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 778540a770..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, Flex, useColorModeValue } from '@redpanda-data/ui'; import React, { type FC, type ReactNode, useCallback } from 'react'; import { MessageHeaders } from './message-headers'; @@ -25,14 +24,14 @@ const ExpandedMessageFooter: FC<{ children?: ReactNode; onDownloadRecord?: () => children, onDownloadRecord, }) => ( - +
{children} {Boolean(onDownloadRecord) && ( )} - +
); type ExpandedMessageProps = { @@ -57,7 +56,6 @@ export const ExpandedMessage: FC = React.memo( onCopyKey, onCopyValue, }) => { - const bg = useColorModeValue('gray.50', 'gray.600'); const handleLoadLargeMessage = useCallback( () => onLoadLargeMessage && topicName !== undefined @@ -80,7 +78,7 @@ export const ExpandedMessage: FC = React.memo( }, [msg, onCopyValue]); return ( - +
@@ -123,7 +121,7 @@ export const ExpandedMessage: FC = React.memo( ) : null} - +
); } ); 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}
- +
); }; From bc3b0544949672aef7254097920ed284be7af43b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1n=20Vor=C4=8D=C3=A1k?= Date: Tue, 9 Jun 2026 14:14:40 +0200 Subject: [PATCH 06/16] Add grouped, navigable topic Configuration tab behind enableNewTopicPage Behind the existing new-topic-page flag, render topic configs grouped by category with a sidebar that filters to a category, an All/Modified scope toggle (URL-backed) with a clear-able search, click-to-reveal descriptions, and reset moved into the edit dialog. Show a modified-count badge on the Configuration tab title and per category. Default enum/boolean editors to their first option when a config has no value. --- .../pages/topics/topic-configuration.test.tsx | 166 +++++--- .../pages/topics/topic-configuration.tsx | 365 +++++++++++++++++- .../components/pages/topics/topic-details.tsx | 21 +- .../src/routes/topics/$topicName/index.tsx | 1 + 4 files changed, 478 insertions(+), 75 deletions(-) diff --git a/frontend/src/components/pages/topics/topic-configuration.test.tsx b/frontend/src/components/pages/topics/topic-configuration.test.tsx index 900835e129..80b0cfb856 100644 --- a/frontend/src/components/pages/topics/topic-configuration.test.tsx +++ b/frontend/src/components/pages/topics/topic-configuration.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, within } from '@testing-library/react'; import { vi } from 'vitest'; import ConfigurationEditor from './topic-configuration'; @@ -12,62 +12,118 @@ vi.mock('@tanstack/react-router', async (importOriginal) => { }; }); +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(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('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, collapsing unmapped categories 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' }), + ]; + + render( + { + // no op - test callback + }} + targetTopic="my-topic" + /> + ); + + // Sidebar lists each visible category; unmapped 'Iceberg' collapses 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('Other')).toBeVisible(); + expect(within(nav).queryByText('Iceberg')).not.toBeInTheDocument(); + + // 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 642881723b..fe145f0887 100644 --- a/frontend/src/components/pages/topics/topic-configuration.tsx +++ b/frontend/src/components/pages/topics/topic-configuration.tsx @@ -1,5 +1,6 @@ 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 { Dialog, @@ -9,8 +10,14 @@ import { 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, InputGroupInput } from 'components/redpanda-ui/components/input-group'; +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'; @@ -22,16 +29,17 @@ import { 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 } from 'lucide-react'; +import { Pencil as EditIcon, Info as InfoIcon, Search, X as XIcon } from 'lucide-react'; import type { FC, ReactNode } from 'react'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Controller, type SubmitHandler, useForm, useWatch } from 'react-hook-form'; import { toast } from 'sonner'; -import { isServerless } from '../../../config'; +import { isFeatureFlagEnabled, isServerless } from '../../../config'; import { api, useApiStoreHook } from '../../../state/backend-api'; -import type { ConfigEntryExtended } from '../../../state/rest-interfaces'; +import type { ConfigEntryExtended, ConfigEntrySynonym } from '../../../state/rest-interfaces'; import { entryHasInfiniteValue, formatConfigValue, @@ -49,6 +57,56 @@ 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: '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; @@ -63,8 +121,11 @@ const ConfigEditorForm: FC<{ } return entryHasInfiniteValue(editedEntry) ? 'infinite' : 'custom'; })(); - const defaultCustomValue = + const explicitCustomValue = editedEntry.isExplicitlySet && !entryHasInfiniteValue(editedEntry) ? editedEntry.value : ''; + // For enum-style configs (boolean/select), fall back to the first option when there's + // no value so the dropdown shows a concrete choice instead of an empty box. + const defaultCustomValue = explicitCustomValue || getFirstSelectOption(editedEntry) || ''; const { control, @@ -131,17 +192,19 @@ 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 = getDefaultConfigSynonym(editedEntry); - 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]; + const handleReset = async () => { + setGlobalError(null); + try { + await api.changeTopicConfig(targetTopic, [{ key: editedEntry.name, op: 'DELETE', value: undefined }]); + toast.success(`Config ${editedEntry.name} reset to default`); + onSuccess(); + onClose(); + } catch (err) { + setGlobalError(err instanceof Error ? err.message : String(err)); + } + }; return ( + {editedEntry.isExplicitlySet ? ( + + ) : null} + ); + })} + + + +
+
+
+ + + + + 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} + + )} + + +
+ + {visibleSections.length === 0 ? ( + + No configuration entries match your filters + + ) : ( + visibleSections.map((s) => ( +
+
+

+ {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: { diff --git a/frontend/src/components/pages/topics/topic-details.tsx b/frontend/src/components/pages/topics/topic-details.tsx index 46c1668f51..4e16ab0e12 100644 --- a/frontend/src/components/pages/topics/topic-details.tsx +++ b/frontend/src/components/pages/topics/topic-details.tsx @@ -18,6 +18,7 @@ import { uiSettings } from '../../../state/ui'; import { uiState } from '../../../state/ui-state'; import '../../../utils/array-extensions'; 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, @@ -110,7 +111,7 @@ const TopicTab: React.FC = ({ topic, id, requiredPermission, titl } return ( - + {children(topic)} ); @@ -213,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; @@ -313,7 +316,21 @@ const TopicDetailsContent = ({ topic, topicName }: { topic: Topic; topicName: st > {(t) => } - + + Configuration + {modifiedConfigCount > 0 ? ( + + {modifiedConfigCount} + + ) : null} + + } + topic={topic} + > {(t) => } Date: Tue, 9 Jun 2026 14:31:35 +0200 Subject: [PATCH 07/16] test(e2e): assert grouped Configuration layout (sidebar + section headings) The new-topic-page Configuration tab replaced the flat '.configGroupTitle' list with a category sidebar and titled sections. Update the navigation spec and topic-page helpers to match (navigation landmark + category buttons + section headings). --- .../topics/topic-navigation.spec.ts | 15 ++++++++------- .../test-variant-console/utils/topic-page.ts | 7 +++++-- 2 files changed, 13 insertions(+), 9 deletions(-) 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(); } /** From 4932bced81444f0bff780b35ce68950fadbc6a46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1n=20Vor=C4=8D=C3=A1k?= Date: Tue, 9 Jun 2026 15:15:42 +0200 Subject: [PATCH 08/16] Remove TopicConfiguration.scss --- .../pages/topics/TopicConfiguration.scss | 65 ------------------- 1 file changed, 65 deletions(-) delete mode 100644 frontend/src/components/pages/topics/TopicConfiguration.scss 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; - } - } - } - } -} From ed337aa56b3addb8e4a16b86c69d6c688934a8bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1n=20Vor=C4=8D=C3=A1k?= Date: Mon, 15 Jun 2026 15:43:13 +0200 Subject: [PATCH 09/16] Addressed comments from the PR --- .../pages/topics/Tab.Acl/acl-list.tsx | 71 ++-------- .../components/pages/topics/tab-consumers.tsx | 72 ++-------- .../pages/topics/tab-partitions.tsx | 75 ++--------- .../pages/topics/topic-configuration.test.tsx | 8 +- .../pages/topics/topic-configuration.tsx | 43 +++--- .../components/pages/topics/topic-details.tsx | 4 +- frontend/src/hooks/use-url-table-state.ts | 126 ++++++++++++++++++ frontend/tests/shared/global-setup.mjs | 39 +----- 8 files changed, 192 insertions(+), 246 deletions(-) create mode 100644 frontend/src/hooks/use-url-table-state.ts 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 438f984e93..fb814ec472 100644 --- a/frontend/src/components/pages/topics/Tab.Acl/acl-list.tsx +++ b/frontend/src/components/pages/topics/Tab.Acl/acl-list.tsx @@ -15,14 +15,10 @@ import { getCoreRowModel, getPaginationRowModel, getSortedRowModel, - type PaginationState, - type SortingState, - type Updater, useReactTable, } from '@tanstack/react-table'; -import { parseAsBoolean, parseAsInteger, parseAsString, useQueryState } from 'nuqs'; -import { useQueryStateWithCallback } from '../../../../hooks/use-query-state-with-callback'; +import { useUrlTableState } from '../../../../hooks/use-url-table-state'; import type { AclRule, AclStrOperation, @@ -33,7 +29,6 @@ import type { } from '../../../../state/rest-interfaces'; import { uiSettings } from '../../../../state/ui'; import { toJson } from '../../../../utils/json-utils'; -import { DEFAULT_TABLE_PAGE_SIZE } from '../../../constants'; 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'; @@ -95,68 +90,18 @@ const columns: ColumnDef[] = [ const AclList = ({ acl }: { acl: Acls }) => { const resources = flatResourceList(acl); - const [pageIndex, setPageIndex] = useQueryState('aclPage', parseAsInteger.withDefault(0)); - - const [pageSize, setPageSize] = useQueryStateWithCallback( - { - onUpdate: (val) => { - uiSettings.topicAclList.pageSize = val; - }, - getDefaultValue: () => uiSettings.topicAclList.pageSize, - }, - 'aclPageSize', - parseAsInteger.withDefault(DEFAULT_TABLE_PAGE_SIZE) - ); - - const [sortId, setSortId] = useQueryStateWithCallback( - { - onUpdate: (val) => { - uiSettings.topicAclList.sortId = val; - }, - getDefaultValue: () => uiSettings.topicAclList.sortId, - }, - 'aclSortId', - parseAsString.withDefault('') - ); - - const [sortDesc, setSortDesc] = useQueryStateWithCallback( - { - onUpdate: (val) => { - uiSettings.topicAclList.sortDesc = val; - }, - getDefaultValue: () => uiSettings.topicAclList.sortDesc, - }, - 'aclSortDesc', - parseAsBoolean.withDefault(false) - ); - - const sorting: SortingState = sortId ? [{ id: sortId, desc: sortDesc }] : []; - const pagination: PaginationState = { pageIndex, pageSize }; - - const handleSortingChange = (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); - } - void setPageIndex(0); - }; - - const handlePaginationChange = (updater: Updater) => { - const next = typeof updater === 'function' ? updater(pagination) : updater; - void setPageIndex(next.pageIndex); - setPageSize(next.pageSize); - }; + const { sorting, pagination, onSortingChange, onPaginationChange } = useUrlTableState({ + keyPrefix: 'acl', + settings: uiSettings.topicAclList, + rowCount: resources.length, + }); const table = useReactTable({ data: resources, columns, state: { sorting, pagination }, - onSortingChange: handleSortingChange, - onPaginationChange: handlePaginationChange, + onSortingChange, + onPaginationChange, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getPaginationRowModel: getPaginationRowModel(), diff --git a/frontend/src/components/pages/topics/tab-consumers.tsx b/frontend/src/components/pages/topics/tab-consumers.tsx index d53e773925..5121c2d1c6 100644 --- a/frontend/src/components/pages/topics/tab-consumers.tsx +++ b/frontend/src/components/pages/topics/tab-consumers.tsx @@ -16,20 +16,15 @@ import { getCoreRowModel, getPaginationRowModel, getSortedRowModel, - type PaginationState, - type SortingState, - type Updater, useReactTable, } from '@tanstack/react-table'; -import { parseAsBoolean, parseAsInteger, parseAsString, useQueryState } from 'nuqs'; import { type FC, useEffect } from 'react'; -import { useQueryStateWithCallback } from '../../../hooks/use-query-state-with-callback'; +import { useUrlTableState } from '../../../hooks/use-url-table-state'; import { api, useApiStoreHook } from '../../../state/backend-api'; import type { Topic, TopicConsumer } from '../../../state/rest-interfaces'; import { uiSettings } from '../../../state/ui'; import { DefaultSkeleton } from '../../../utils/tsx-utils'; -import { DEFAULT_TABLE_PAGE_SIZE } from '../../constants'; import { DataTableColumnHeader, DataTablePagination } from '../../redpanda-ui/components/data-table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../redpanda-ui/components/table'; @@ -44,61 +39,12 @@ export const TopicConsumers: FC = ({ topic }) => { const isLoading = rawConsumers === undefined; const consumers = rawConsumers ?? []; - const [pageIndex, setPageIndex] = useQueryState('consumerPage', parseAsInteger.withDefault(0)); - - const [pageSize, setPageSize] = useQueryStateWithCallback( - { - onUpdate: (val) => { - uiSettings.topicConsumersList.pageSize = val; - }, - getDefaultValue: () => uiSettings.topicConsumersList.pageSize, - }, - 'consumerPageSize', - parseAsInteger.withDefault(DEFAULT_TABLE_PAGE_SIZE) - ); - - const [sortId, setSortId] = useQueryStateWithCallback( - { - onUpdate: (val) => { - uiSettings.topicConsumersList.sortId = val; - }, - getDefaultValue: () => uiSettings.topicConsumersList.sortId, - }, - 'consumerSortId', - parseAsString.withDefault('') - ); - - const [sortDesc, setSortDesc] = useQueryStateWithCallback( - { - onUpdate: (val) => { - uiSettings.topicConsumersList.sortDesc = val; - }, - getDefaultValue: () => uiSettings.topicConsumersList.sortDesc, - }, - 'consumerSortDesc', - parseAsBoolean.withDefault(false) - ); - - const sorting: SortingState = sortId ? [{ id: sortId, desc: sortDesc }] : []; - const pagination: PaginationState = { pageIndex, pageSize }; - - const handleSortingChange = (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); - } - void setPageIndex(0); - }; - - const handlePaginationChange = (updater: Updater) => { - const next = typeof updater === 'function' ? updater(pagination) : updater; - void setPageIndex(next.pageIndex); - setPageSize(next.pageSize); - }; + const { sorting, pagination, onSortingChange, onPaginationChange } = useUrlTableState({ + keyPrefix: 'consumer', + settings: uiSettings.topicConsumersList, + rowCount: consumers.length, + enabled: !isLoading, + }); const columns: ColumnDef[] = [ { @@ -125,8 +71,8 @@ export const TopicConsumers: FC = ({ topic }) => { data: consumers, columns, state: { sorting, pagination }, - onSortingChange: handleSortingChange, - onPaginationChange: handlePaginationChange, + onSortingChange, + onPaginationChange, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getPaginationRowModel: getPaginationRowModel(), diff --git a/frontend/src/components/pages/topics/tab-partitions.tsx b/frontend/src/components/pages/topics/tab-partitions.tsx index 2ab64cdb67..6b9abbd08a 100644 --- a/frontend/src/components/pages/topics/tab-partitions.tsx +++ b/frontend/src/components/pages/topics/tab-partitions.tsx @@ -15,23 +15,18 @@ import { getCoreRowModel, getPaginationRowModel, getSortedRowModel, - type PaginationState, - type SortingState, - type Updater, useReactTable, } from '@tanstack/react-table'; import { AlertTriangle } from 'lucide-react'; -import { parseAsBoolean, parseAsInteger, parseAsString, useQueryState } from 'nuqs'; import type { FC } from 'react'; import '../../../utils/array-extensions'; -import { useQueryStateWithCallback } from '../../../hooks/use-query-state-with-callback'; +import { useUrlTableState } from '../../../hooks/use-url-table-state'; import { useApiStoreHook } from '../../../state/backend-api'; import type { Partition, Topic } from '../../../state/rest-interfaces'; import { uiSettings } from '../../../state/ui'; import { DefaultSkeleton, numberToThousandsString } from '../../../utils/tsx-utils'; -import { DEFAULT_TABLE_PAGE_SIZE } from '../../constants'; import { BrokerList } from '../../misc/broker-list'; import { Alert, AlertDescription } from '../../redpanda-ui/components/alert'; import { Badge } from '../../redpanda-ui/components/badge'; @@ -45,40 +40,13 @@ export const TopicPartitions: FC = ({ topic }) => { const partitions = useApiStoreHook((s) => s.topicPartitions.get(topic.topicName)); const clusterHealth = useApiStoreHook((s) => s.clusterHealth); - const [pageIndex, setPageIndex] = useQueryState('partitionPage', parseAsInteger.withDefault(0)); - - const [pageSize, setPageSize] = useQueryStateWithCallback( - { - onUpdate: (val) => { - uiSettings.topicPartitionsList.pageSize = val; - }, - getDefaultValue: () => uiSettings.topicPartitionsList.pageSize, - }, - 'partitionPageSize', - parseAsInteger.withDefault(DEFAULT_TABLE_PAGE_SIZE) - ); - - const [sortId, setSortId] = useQueryStateWithCallback( - { - onUpdate: (val) => { - uiSettings.topicPartitionsList.sortId = val; - }, - getDefaultValue: () => uiSettings.topicPartitionsList.sortId, - }, - 'partitionSortId', - parseAsString.withDefault('') - ); - - const [sortDesc, setSortDesc] = useQueryStateWithCallback( - { - onUpdate: (val) => { - uiSettings.topicPartitionsList.sortDesc = val; - }, - getDefaultValue: () => uiSettings.topicPartitionsList.sortDesc, - }, - 'partitionSortDesc', - parseAsBoolean.withDefault(false) - ); + // 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; @@ -95,27 +63,6 @@ export const TopicPartitions: FC = ({ topic }) => { ({ topicName }) => topicName === topic.topicName )?.partitionIds; - const sorting: SortingState = sortId ? [{ id: sortId, desc: sortDesc }] : []; - const pagination: PaginationState = { pageIndex, pageSize }; - - const handleSortingChange = (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); - } - void setPageIndex(0); - }; - - const handlePaginationChange = (updater: Updater) => { - const next = typeof updater === 'function' ? updater(pagination) : updater; - void setPageIndex(next.pageIndex); - setPageSize(next.pageSize); - }; - const columns: ColumnDef[] = [ { accessorKey: 'id', @@ -160,8 +107,8 @@ export const TopicPartitions: FC = ({ topic }) => { data: partitions, columns, state: { sorting, pagination }, - onSortingChange: handleSortingChange, - onPaginationChange: handlePaginationChange, + onSortingChange, + onPaginationChange, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getPaginationRowModel: getPaginationRowModel(), @@ -218,7 +165,7 @@ const PartitionError: FC<{ partition: Partition }> = ({ partition }) => { return ( - diff --git a/frontend/src/components/pages/topics/topic-configuration.test.tsx b/frontend/src/components/pages/topics/topic-configuration.test.tsx index 80b0cfb856..45f680073c 100644 --- a/frontend/src/components/pages/topics/topic-configuration.test.tsx +++ b/frontend/src/components/pages/topics/topic-configuration.test.tsx @@ -90,11 +90,12 @@ describe('TopicConfiguration', () => { describe('grouped layout (enableNewTopicPage on)', () => { beforeEach(() => mockIsFeatureFlagEnabled.mockReturnValue(true)); - test('renders a sidebar and titled sections, collapsing unmapped categories into Other', () => { + 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( @@ -107,12 +108,13 @@ describe('TopicConfiguration', () => { /> ); - // Sidebar lists each visible category; unmapped 'Iceberg' collapses into 'Other'. + // 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(); - expect(within(nav).queryByText('Iceberg')).not.toBeInTheDocument(); // Each visible category renders as a titled section. const retentionSection = screen.getByRole('heading', { name: 'Retention' }).closest('section') as HTMLElement; diff --git a/frontend/src/components/pages/topics/topic-configuration.tsx b/frontend/src/components/pages/topics/topic-configuration.tsx index fe145f0887..f83bcdc444 100644 --- a/frontend/src/components/pages/topics/topic-configuration.tsx +++ b/frontend/src/components/pages/topics/topic-configuration.tsx @@ -98,6 +98,11 @@ const CONFIG_CATEGORIES = [ { 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; @@ -121,15 +126,20 @@ const ConfigEditorForm: FC<{ } return entryHasInfiniteValue(editedEntry) ? 'infinite' : 'custom'; })(); + const defaultConfigSynonym = getDefaultConfigSynonym(editedEntry); const explicitCustomValue = editedEntry.isExplicitlySet && !entryHasInfiniteValue(editedEntry) ? editedEntry.value : ''; - // For enum-style configs (boolean/select), fall back to the first option when there's - // no value so the dropdown shows a concrete choice instead of an empty box. - const defaultCustomValue = explicitCustomValue || getFirstSelectOption(editedEntry) || ''; + // 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: { @@ -192,18 +202,13 @@ const ConfigEditorForm: FC<{ const valueType = useWatch({ control, name: 'valueType' }); - const defaultConfigSynonym = getDefaultConfigSynonym(editedEntry); - - const handleReset = async () => { - setGlobalError(null); - try { - await api.changeTopicConfig(targetTopic, [{ key: editedEntry.name, op: 'DELETE', value: undefined }]); - toast.success(`Config ${editedEntry.name} reset to default`); - onSuccess(); - onClose(); - } catch (err) { - setGlobalError(err instanceof Error ? err.message : String(err)); - } + // 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 ( @@ -269,11 +274,12 @@ const ConfigEditorForm: FC<{ {editedEntry.isExplicitlySet ? ( - ) : null} - - - - - - )} +
+ Page {table.getPageCount() === 0 ? 0 : table.getState().pagination.pageIndex + 1} of {table.getPageCount()} +
+
+ + + + +
); From 16deb8f10ceb34d64599f3febcebb690a51ce0b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1n=20Vor=C4=8D=C3=A1k?= Date: Tue, 16 Jun 2026 10:00:23 +0200 Subject: [PATCH 11/16] fix(ui): make DataTablePagination span full width so controls align right --- .../src/components/redpanda-ui/components/data-table/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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.
From 0872b734c9e51e4c7d84dc7f1fa144b8e4ce726a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1n=20Vor=C4=8D=C3=A1k?= Date: Tue, 16 Jun 2026 10:15:19 +0200 Subject: [PATCH 12/16] fix(topics): make config category sidebar scroll to sections instead of filtering --- .../pages/topics/topic-configuration.tsx | 120 +++++++++++++----- 1 file changed, 85 insertions(+), 35 deletions(-) diff --git a/frontend/src/components/pages/topics/topic-configuration.tsx b/frontend/src/components/pages/topics/topic-configuration.tsx index f83bcdc444..a135af1b30 100644 --- a/frontend/src/components/pages/topics/topic-configuration.tsx +++ b/frontend/src/components/pages/topics/topic-configuration.tsx @@ -33,7 +33,7 @@ import { ToggleGroup, ToggleGroupItem } from 'components/redpanda-ui/components/ 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, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { Controller, type SubmitHandler, useForm, useWatch } from 'react-hook-form'; import { toast } from 'sonner'; @@ -417,9 +417,26 @@ const ConfigurationEditorGrouped: FC = (props) => { const { configFilter = '', configScope = 'all' } = useSearch({ from: '/topics/$topicName/' }); const scope = configScope; const [editedEntry, setEditedEntry] = useState(null); - const [selectedCategory, setSelectedCategory] = 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; @@ -457,9 +474,33 @@ const ConfigurationEditorGrouped: FC = (props) => { [props.entries] ); - // The sidebar acts as a category filter: when a category is selected, only its - // section is shown. Clicking the active category again clears the filter. - const visibleSections = selectedCategory ? sections.filter((s) => s.name === selectedCategory) : sections; + // 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 (
@@ -475,15 +516,15 @@ const ConfigurationEditorGrouped: FC = (props) => { -
+
@@ -545,32 +586,41 @@ const ConfigurationEditorGrouped: FC = (props) => {
- {visibleSections.length === 0 ? ( - - No configuration entries match your filters - - ) : ( - visibleSections.map((s) => ( -
-
-

- {s.name} -

-

{s.blurb}

-
-
- {s.rows.map((entry) => ( - - ))} -
-
- )) - )} +
+ {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) => ( + + ))} +
+
+ )) + )} +
); From 25c78b07449f6f4cbbe40390dcf527ab35941092 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1n=20Vor=C4=8D=C3=A1k?= Date: Tue, 16 Jun 2026 10:17:29 +0200 Subject: [PATCH 13/16] fix(topics): use registry Button for partition error popover trigger --- frontend/src/components/pages/topics/tab-partitions.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/pages/topics/tab-partitions.tsx b/frontend/src/components/pages/topics/tab-partitions.tsx index 6b9abbd08a..d8758d98f4 100644 --- a/frontend/src/components/pages/topics/tab-partitions.tsx +++ b/frontend/src/components/pages/topics/tab-partitions.tsx @@ -30,6 +30,7 @@ import { DefaultSkeleton, numberToThousandsString } from '../../../utils/tsx-uti 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'; @@ -165,9 +166,9 @@ const PartitionError: FC<{ partition: Partition }> = ({ partition }) => { return ( - +

Partition Error

From 1fd08a72ae0a0442f2c4af4a86fc138f6cdebe07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1n=20Vor=C4=8D=C3=A1k?= Date: Tue, 16 Jun 2026 17:18:00 +0200 Subject: [PATCH 14/16] fix(topics): migrate JS filter modal to UI registry and polish message filters - migrate javascript-filter-modal to registry Dialog/Input/Label/typography - show disabled-reason tooltips on Add filter menu items (partition/JS) - disable JS filter while continuous pagination is enabled - show "All" in partition dropdown when partition is -1 - restyle message filter tags with Tailwind (lost .filterTag legacy class) --- .../common/message-search-filter-bar.tsx | 29 +-- .../pages/topics/Tab.Messages/index.tsx | 79 +++++++- .../Tab.Messages/javascript-filter-modal.tsx | 184 ++++++++++-------- 3 files changed, 187 insertions(+), 105 deletions(-) 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 cedc8368c1..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,7 +9,8 @@ * by the Apache License, Version 2.0 */ -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,38 +25,44 @@ type MessageSearchFilterBarProps = { export const MessageSearchFilterBar: FC = ({ filters, onEdit, onToggle, onRemove }) => { return (
-
+
{/* Existing Tags List */} {filters?.map((e) => (
- { onEdit(e); }} - size={14} - /> + type="button" + > + +
))} diff --git a/frontend/src/components/pages/topics/Tab.Messages/index.tsx b/frontend/src/components/pages/topics/Tab.Messages/index.tsx index 21807c104f..2442f2f49f 100644 --- a/frontend/src/components/pages/topics/Tab.Messages/index.tsx +++ b/frontend/src/components/pages/topics/Tab.Messages/index.tsx @@ -279,6 +279,50 @@ 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) => { // Zustand store for topic settings @@ -1224,7 +1268,20 @@ 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 = [ @@ -1429,7 +1486,9 @@ export const TopicMessageView: FC = (props) => { > { - setFilter((prev) => ({ ...prev, name: e.target.value })); + { + if (!open) { + onClose(); + } + }} + open + > + + + JavaScript filtering + + + Write JavaScript code to filter your records. + +
+ + { + setFilter((prev) => ({ ...prev, name: e.target.value })); + }} + placeholder="This name will appear in the filter bar" + value={filter.name} + /> +
+ +
+
+ +
+ { + setFilter((prev) => ({ ...prev, code, transpiledCode: transpiled })); }} - placeholder="This name will appear in the filter bar" - value={filter.name} + value={filter.code} /> - - - - - - - { - setFilter((prev) => ({ ...prev, code, transpiledCode: transpiled })); - }} - value={filter.code} - /> - - +
- - return true allows messages, return false discards them. + - Available params are offset, partitionID (number), key (any),{' '} - value (any), and headers (object), keySchemaID (number) and{' '} - valueSchemaID (number) + return true allows messages, return false discards + them. + + + Available params are offset, partitionID (number),{' '} + key (any), value (any),{' '} + headers (object), keySchemaID (number) and{' '} + valueSchemaID (number). Multiple active filters are combined with 'and'. - - - - Examples - + +
+ +
+ + Examples + + - value != null skips records without value + value != null skips records without value. - if (key == 'example') return true - only returns messages where keys equal 'example' in their string presentation (after - decoding) + if (key == 'example') return true only returns messages where keys equal{' '} + 'example' in their string presentation (after decoding). - - - - - - - - - - - - + +
+
+
+ + + + +
+
); }; From 49355796f15f91fcd22c5e578dfc46627b96984f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1n=20Vor=C4=8D=C3=A1k?= Date: Tue, 23 Jun 2026 10:27:06 +0200 Subject: [PATCH 15/16] Improves padding in topic configuration modal --- frontend/src/components/pages/topics/topic-configuration.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/pages/topics/topic-configuration.tsx b/frontend/src/components/pages/topics/topic-configuration.tsx index a135af1b30..053ef067b8 100644 --- a/frontend/src/components/pages/topics/topic-configuration.tsx +++ b/frontend/src/components/pages/topics/topic-configuration.tsx @@ -224,7 +224,7 @@ const ConfigEditorForm: FC<{ {`Edit ${editedEntry.name}`} -

{editedEntry.documentation}

+

{editedEntry.documentation}

From 6d11f2c0c08fa82dec05e193de56f901e810d5c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1n=20Vor=C4=8D=C3=A1k?= Date: Tue, 23 Jun 2026 10:50:34 +0200 Subject: [PATCH 16/16] Remove enableNewTopicPage FF --- frontend/src/components/constants.ts | 1 - .../pages/topics/topic-configuration.test.tsx | 54 +- .../pages/topics/topic-configuration.tsx | 206 +------ .../components/pages/topics/topic-list.tsx | 531 ------------------ frontend/src/routes/topics/index.tsx | 6 +- 5 files changed, 6 insertions(+), 792 deletions(-) delete mode 100644 frontend/src/components/pages/topics/topic-list.tsx diff --git a/frontend/src/components/constants.ts b/frontend/src/components/constants.ts index 84fb982dd7..84fb5590a8 100644 --- a/frontend/src/components/constants.ts +++ b/frontend/src/components/constants.ts @@ -19,7 +19,6 @@ export const FEATURE_FLAGS = { enableConnectSlashMenu: false, enableNewSecurityPage: true, enableTeamsBridge: false, - enableNewTopicPage: true, }; // Cloud-managed tag keys for service account integration diff --git a/frontend/src/components/pages/topics/topic-configuration.test.tsx b/frontend/src/components/pages/topics/topic-configuration.test.tsx index 45f680073c..e569e00595 100644 --- a/frontend/src/components/pages/topics/topic-configuration.test.tsx +++ b/frontend/src/components/pages/topics/topic-configuration.test.tsx @@ -12,13 +12,11 @@ vi.mock('@tanstack/react-router', async (importOriginal) => { }; }); -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), }; }); @@ -39,57 +37,7 @@ const makeEntry = (overrides: Partial & { category: string }); describe('TopicConfiguration', () => { - 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)); - + describe('grouped layout', () => { 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 }), diff --git a/frontend/src/components/pages/topics/topic-configuration.tsx b/frontend/src/components/pages/topics/topic-configuration.tsx index 053ef067b8..26edd2b88a 100644 --- a/frontend/src/components/pages/topics/topic-configuration.tsx +++ b/frontend/src/components/pages/topics/topic-configuration.tsx @@ -31,13 +31,13 @@ import { 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 { Pencil as EditIcon, 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 { isFeatureFlagEnabled, isServerless } from '../../../config'; +import { isServerless } from '../../../config'; import { api, useApiStoreHook } from '../../../state/backend-api'; import type { ConfigEntryExtended, ConfigEntrySynonym } from '../../../state/rest-interfaces'; import { @@ -298,112 +298,7 @@ const ConfigEditorForm: FC<{ ); }; -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); - }; - - const topic = props.targetTopic; - const hasEditPermissions = topic ? (topicPermissions?.canEditTopicConfig ?? true) : true; - - let entries = props.entries; - 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 = { - retention: -3, - cleanup: -2, - }; - - entries = entries.slice().sort((a, b) => { - for (const [e, order] of Object.entries(entryOrder)) { - if (a.name.includes(e) && !b.name.includes(e)) { - return order; - } - if (b.name.includes(e) && !a.name.includes(e)) { - return -order; - } - } - return 0; - }); - - const categories = entries.groupInto((x) => x.category); - for (const e of categories) { - if (!e.key) { - e.key = 'Other'; - } - } - - const displayOrder = [ - 'Retention', - 'Compaction', - 'Replication', - 'Tiered Storage', - 'Write Caching', - 'Iceberg', - 'Schema Registry and Validation', - 'Message Handling', - 'Compression', - 'Storage Internals', - 'Other', - ]; - - categories.sort((a, b) => displayOrder.indexOf(a.key ?? '') - displayOrder.indexOf(b.key ?? '')); - - return ( -
- {editedEntry !== null && ( - { - setEditedEntry(null); - }} - onSuccess={() => { - props.onForceRefresh(); - }} - targetTopic={props.targetTopic} - /> - )} -
-
- - - - - setFilter(e.target.value)} placeholder="Filter" value={configFilter} /> - -
- {categories.map((x) => ( - - ))} -
-
- ); -}; - -// ── Grouped, navigable layout (behind the `enableNewTopicPage` feature flag) ───── +// ── Grouped, navigable layout ───── type ConfigSection = { name: string; @@ -412,7 +307,7 @@ type ConfigSection = { modifiedCount: number; }; -const ConfigurationEditorGrouped: FC = (props) => { +const ConfigurationEditor: FC = (props) => { const navigate = useNavigate({ from: '/topics/$topicName/' }); const { configFilter = '', configScope = 'all' } = useSearch({ from: '/topics/$topicName/' }); const scope = configScope; @@ -707,101 +602,8 @@ const ConfigRow: FC<{ ); }; -const ConfigurationEditor: FC = (props) => - isFeatureFlagEnabled('enableNewTopicPage') ? ( - - ) : ( - - ); - export default ConfigurationEditor; -const ConfigGroup = (p: { - groupName?: string; - onEditEntry: (configEntry: ConfigEntryExtended) => void; - entries: ConfigEntryExtended[]; - hasEditPermissions: boolean; -}) => ( - <> -
- {Boolean(p.groupName) &&
{p.groupName}
} - {p.entries.map((e) => ( - - ))} - -); - -const ConfigEntryComponent = (p: { - onEditEntry: (configEntry: ConfigEntryExtended) => void; - entry: ConfigEntryExtended; - hasEditPermissions: boolean; -}) => { - const { canEdit, reason: nonEdittableReason } = isTopicConfigEdittable(p.entry, p.hasEditPermissions); - - const entry = p.entry; - const friendlyValue = formatConfigValue(entry.name, entry.value, 'friendly'); - - const editButton = ( - - ); - - return ( - <> -
- {p.entry.name} -
- - {friendlyValue} - - {Boolean(entry.isExplicitlySet) && 'Custom'} - - - {canEdit ? ( - editButton - ) : ( - - {editButton} - {nonEdittableReason} - - )} - {Boolean(entry.documentation) && ( - - - - - -
-

{entry.name}

-

{entry.documentation}

-

{getConfigDescription(entry.source)}

-
-
-
- )} -
- - ); -}; - function isTopicConfigEdittable( entry: ConfigEntryExtended, hasEditPermissions: boolean diff --git a/frontend/src/components/pages/topics/topic-list.tsx b/frontend/src/components/pages/topics/topic-list.tsx deleted file mode 100644 index 055050fdf5..0000000000 --- a/frontend/src/components/pages/topics/topic-list.tsx +++ /dev/null @@ -1,531 +0,0 @@ -/** - * Copyright 2022 Redpanda Data, Inc. - * - * Use of this software is governed by the Business Source License - * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md - * - * As of the Change Date specified in that file, in accordance with - * the Business Source License, use of this software will be governed - * by the Apache License, Version 2.0 - */ - -import { - Alert, - AlertDialog, - AlertDialogBody, - AlertDialogContent, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogOverlay, - AlertIcon, - Box, - Button, - Checkbox, - DataTable, - Flex, - Icon, - Popover, - SearchField, - Text, - Tooltip, - useToast, -} from '@redpanda-data/ui'; -import { Link } from '@tanstack/react-router'; -import { BanIcon, CheckIcon, ErrorIcon, EyeOffIcon, TrashIcon, WarningIcon } from 'components/icons'; -import { AnimatePresence, motion } from 'framer-motion'; -import { useQueryStateWithCallback } from 'hooks/use-query-state-with-callback'; -import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs'; -import React, { type FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useLegacyListTopicsQuery } from 'react-query/api/topic'; - -import { CreateTopicModal } from './CreateTopicModal/create-topic-modal'; -import colors from '../../../colors'; -import usePaginationParams from '../../../hooks/use-pagination-params'; -import { appGlobal } from '../../../state/app-global'; -import { api } from '../../../state/backend-api'; -import { type Topic, TopicActions } from '../../../state/rest-interfaces'; -import { uiSettings } from '../../../state/ui'; -import { setPageHeader } from '../../../state/ui-state'; -import { onPaginationChange } from '../../../utils/pagination'; -import { editQuery } from '../../../utils/query-helper'; -import { Code, DefaultSkeleton, QuickTable } from '../../../utils/tsx-utils'; -import { renderLogDirSummary } from '../../misc/common'; -import PageContent from '../../misc/page-content'; -import Section from '../../misc/section'; -import { Statistic } from '../../misc/statistic'; - -// Regex for quick search filtering -const QUICK_SEARCH_REGEX_CACHE = new Map(); - -const TopicList: FC = () => { - useEffect(() => { - setPageHeader('Topics', [{ title: 'Topics', linkTo: '/topics' }]); - }, []); - - const [localSearchValue, setLocalSearchValue] = useQueryState('q', parseAsString.withDefault('')); - - const [showInternalTopics, setShowInternalTopics] = useQueryStateWithCallback( - { - onUpdate: (val) => { - uiSettings.topicList.hideInternalTopics = val; - }, - getDefaultValue: () => uiSettings.topicList.hideInternalTopics, - }, - 'showInternal', - parseAsBoolean - ); - - const { data, isLoading, isError, refetch: refetchTopics } = useLegacyListTopicsQuery(); - const [topicToDelete, setTopicToDelete] = useState(null); - const [isCreateTopicModalOpen, setIsCreateTopicModalOpen] = useState(false); - - const refreshData = useCallback(() => { - api.refreshClusterOverview(); - api.refreshClusterHealth().catch(() => { - // Error handling managed by API layer - }); - - refetchTopics(); - }, [refetchTopics]); - - useEffect(() => { - appGlobal.onRefresh = refreshData; - }, [refreshData]); - - const topics = useMemo(() => { - let filteredTopics = data.topics ?? []; - if (!showInternalTopics) { - filteredTopics = filteredTopics.filter((x) => !(x.isInternal || x.topicName.startsWith('_'))); - } - - const searchQuery = localSearchValue; - if (searchQuery) { - try { - let quickSearchRegExp = QUICK_SEARCH_REGEX_CACHE.get(searchQuery); - if (!quickSearchRegExp) { - quickSearchRegExp = new RegExp(searchQuery, 'i'); - QUICK_SEARCH_REGEX_CACHE.set(searchQuery, quickSearchRegExp); - } - filteredTopics = filteredTopics.filter((topic) => Boolean(topic.topicName.match(quickSearchRegExp))); - } catch (_e) { - // biome-ignore lint/suspicious/noConsole: intentional console usage - console.warn('Invalid expression'); - const searchLower = searchQuery.toLowerCase(); - filteredTopics = filteredTopics.filter((topic) => topic.topicName.toLowerCase().includes(searchLower)); - } - } - - return filteredTopics; - }, [data.topics, showInternalTopics, localSearchValue]); - - const statistics = useMemo(() => { - const partitionCount = topics.sum((x) => x.partitionCount); - const replicaCount = topics.sum((x) => x.partitionCount * x.replicationFactor); - - return { - partitionCount, - replicaCount, - topicCount: topics.length, - }; - }, [topics]); - - if (isLoading) { - return DefaultSkeleton; - } - - if (isError) { - return
Error
; - } - - return ( - -
- - - - - -
- -
- -
-
-
- - - - {Boolean(localSearchValue) && ( - - - - {topics.length} - {' '} - {topics.length === 1 ? 'result' : 'results'} - - - )} - - - { - setShowInternalTopics(x.target.checked); - }} - > - Show internal topics - - - setIsCreateTopicModalOpen(false)} /> -
- - { - setTopicToDelete(record); - }} - topics={topics} - /> - -
- - setTopicToDelete(null)} - onFinish={async () => { - setTopicToDelete(null); - await refreshData(); - }} - topicToDelete={topicToDelete} - /> -
- ); -}; - -const TopicsTable: FC<{ topics: Topic[]; onDelete: (record: Topic) => void }> = ({ topics, onDelete }) => { - const paginationParams = usePaginationParams(topics.length, uiSettings.topicList.pageSize); - - return ( -
- - columns={[ - { - header: 'Name', - accessorKey: 'topicName', - cell: ({ row: { original: topic } }) => { - const leaderLessPartitions = (api.clusterHealth?.leaderlessPartitions ?? []).find( - ({ topicName }) => topicName === topic.topicName - )?.partitionIds; - const underReplicatedPartitions = (api.clusterHealth?.underReplicatedPartitions ?? []).find( - ({ topicName }) => topicName === topic.topicName - )?.partitionIds; - - return ( - - - - - {!!leaderLessPartitions && ( - - - - - - )} - {!!underReplicatedPartitions && ( - - - - - - )} - - ); - }, - size: Number.POSITIVE_INFINITY, - }, - { - header: 'Partitions', - accessorKey: 'partitionCount', - enableResizing: true, - cell: ({ row: { original: topic } }) => topic.partitionCount, - }, - { - header: 'Replicas', - accessorKey: 'replicationFactor', - }, - { - header: 'CleanupPolicy', - accessorKey: 'cleanupPolicy', - }, - { - header: 'Size', - accessorKey: 'logDirSummary.totalSizeBytes', - cell: ({ row: { original: topic } }) => renderLogDirSummary(topic.logDirSummary), - }, - { - id: 'action', - header: '', - cell: ({ row: { original: record } }) => ( - - - - - - ), - }, - ]} - data={topics} - onPaginationChange={onPaginationChange(paginationParams, ({ pageSize, pageIndex }) => { - Object.assign(uiSettings.topicList, { pageSize }); - editQuery((query) => { - query.page = String(pageIndex); - query.pageSize = String(pageSize); - }); - })} - pagination={paginationParams} - sorting={true} - /> -
- ); -}; - -const iconAllowed = ( - - - -); -const iconForbidden = ( - - - -); -const iconClosedEye = ( - - - -); - -const TopicName = ({ topic }: { topic: Topic }) => { - const actions = topic.allowedActions; - - if (!actions || actions[0] === 'all') { - return topic.topicName; // happens in non-business version - } - - let missing = 0; - for (const a of TopicActions) { - if (!actions.includes(a)) { - missing += 1; - } - } - - if (missing === 0) { - return topic.topicName; // everything is allowed - } - - // There's at least one action the user can't do - // Show a table of what they can't do - const popoverContent = ( -
-
- You're missing permissions to view -
- one more aspects of this topic. -
- {QuickTable( - TopicActions.map((a) => ({ - key: a, - value: actions.includes(a) ? iconAllowed : iconForbidden, - })), - { - gapWidth: '6px', - gapHeight: '2px', - keyAlign: 'right', - keyStyle: { fontSize: '86%', fontWeight: 700, textTransform: 'capitalize' }, - tableStyle: { margin: 'auto' }, - } - )} -
- ); - - return ( - - - - {topic.topicName} - {iconClosedEye} - - - - ); -}; - -function ConfirmDeletionModal({ - topicToDelete, - onFinish, - onCancel, -}: { - topicToDelete: Topic | null; - onFinish: () => void; - onCancel: () => void; -}) { - const [deletionPending, setDeletionPending] = useState(false); - const [error, setError] = useState(null); - const toast = useToast(); - const cancelRef = useRef(null); - - const cleanup = () => { - setDeletionPending(false); - setError(null); - }; - - const finish = () => { - onFinish(); - cleanup(); - - toast({ - title: 'Topic Deleted', - description: ( - - Topic {topicToDelete?.topicName} deleted - - ), - status: 'success', - }); - }; - - const cancel = () => { - onCancel(); - cleanup(); - }; - - return ( - - - - Delete Topic - - - {Boolean(error) && ( - - - {`An error occurred: ${typeof error === 'string' ? error : (error?.message ?? 'Unknown error')}`} - - )} - {Boolean(topicToDelete?.isInternal) && ( - - - This is an internal topic, deleting it might have unintended side-effects! - - )} - - Are you sure you want to delete topic {topicToDelete?.topicName}?
- This action cannot be undone. -
-
- - - - - -
-
-
- ); -} - -function DeleteDisabledTooltip(props: { topic: Topic; children: JSX.Element }): JSX.Element { - const deleteButton = props.children; - - const wrap = (button: JSX.Element, message: string) => ( - - {React.cloneElement(button, { - disabled: true, - className: `${button.props.className ?? ''} disabled`, - onClick: undefined, - })} - - ); - - return ( - <> - {hasDeletePrivilege() - ? deleteButton - : wrap(deleteButton, "You don't have 'deleteTopic' permission for this topic.")} - - ); -} - -function hasDeletePrivilege() { - // TODO - we will provide ACL for this - return true; -} - -export default TopicList; diff --git a/frontend/src/routes/topics/index.tsx b/frontend/src/routes/topics/index.tsx index 2a11e14ee0..f5d40bf21c 100644 --- a/frontend/src/routes/topics/index.tsx +++ b/frontend/src/routes/topics/index.tsx @@ -11,12 +11,8 @@ import { createFileRoute } from '@tanstack/react-router'; import { CollectionIcon } from 'components/icons'; -import { isFeatureFlagEnabled } from 'config'; -import TopicListLegacy from '../../components/pages/topics/topic-list'; -import TopicListNew from '../../components/pages/topics/topic-list-new'; - -const TopicList = () => (isFeatureFlagEnabled('enableNewTopicPage') ? : ); +import TopicList from '../../components/pages/topics/topic-list-new'; export const Route = createFileRoute('/topics/')({ staticData: {