diff --git a/frontend/src/components/pages/consumers/column-meta.ts b/frontend/src/components/pages/consumers/column-meta.ts new file mode 100644 index 0000000000..b0eb342d81 --- /dev/null +++ b/frontend/src/components/pages/consumers/column-meta.ts @@ -0,0 +1,21 @@ +/** + * Copyright 2025 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 type { VariantProps } from 'class-variance-authority'; +import type { tableHeadVariants } from 'components/redpanda-ui/components/table'; + +type TableHeadVariants = VariantProps; + +/** `columnDef.meta` shape shared by the consumer group tables, derived from the registry's TableHead variants. */ +export type ColumnMeta = { + align?: TableHeadVariants['align']; + headWidth?: TableHeadVariants['width']; +}; diff --git a/frontend/src/components/pages/consumers/group-details.tsx b/frontend/src/components/pages/consumers/group-details.tsx index 055af978af..5220ffd84a 100644 --- a/frontend/src/components/pages/consumers/group-details.tsx +++ b/frontend/src/components/pages/consumers/group-details.tsx @@ -10,47 +10,52 @@ */ import { - Accordion, - Checkbox, - CopyButton, - DataTable, - Empty, - Flex, - Grid, - GridItem, - Popover, - SearchField, - Section, - Tabs, - Text, -} from '@redpanda-data/ui'; -import { - CheckCircleIcon, - EditIcon, - FlameIcon, - HelpIcon, - HourglassIcon, - SkipIcon, - TrashIcon, - WarningIcon, -} from 'components/icons'; -import React, { type JSX, useMemo, useState } from 'react'; - + type ColumnDef, + flexRender, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + type PaginationState, + type SortingState, + useReactTable, +} from '@tanstack/react-table'; +import { EditIcon, SkipIcon, TrashIcon } from 'components/icons'; +import { Search, X } from 'lucide-react'; +import { useId, useMemo, useState } from 'react'; + +import type { ColumnMeta } from './column-meta'; import { DeleteOffsetsModal, EditOffsetsModal, type GroupDeletingMode, type GroupOffset } from './modals'; import { appGlobal } from '../../../state/app-global'; import { api, useApiStoreHook } from '../../../state/backend-api'; import type { GroupDescription, GroupMemberDescription } from '../../../state/rest-interfaces'; import { useSupportedFeaturesStore } from '../../../state/supported-features'; -import { Button, DefaultSkeleton, IconButton, numberToThousandsString } from '../../../utils/tsx-utils'; +import { DefaultSkeleton, numberToThousandsString } from '../../../utils/tsx-utils'; +import { DEFAULT_TABLE_PAGE_SIZE } from '../../constants'; import PageContent from '../../misc/page-content'; import { ShortNum } from '../../misc/short-num'; -import { Statistic } from '../../misc/statistic'; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../../redpanda-ui/components/accordion'; +import { Button } from '../../redpanda-ui/components/button'; +import { Card, CardContent } from '../../redpanda-ui/components/card'; +import { Checkbox } from '../../redpanda-ui/components/checkbox'; +import { CopyButton } from '../../redpanda-ui/components/copy-button'; +import { DataTableColumnHeader, DataTablePagination } from '../../redpanda-ui/components/data-table'; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '../../redpanda-ui/components/empty'; +import { Input, InputEnd, InputStart } from '../../redpanda-ui/components/input'; +import { Label } from '../../redpanda-ui/components/label'; +import { Stat } from '../../redpanda-ui/components/stat'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../redpanda-ui/components/table'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../redpanda-ui/components/tabs'; +import { ConsumerGroupStateCell } from '../../ui/consumer-group/consumer-group-state-cell'; +import { DisabledReasonButton } from '../../ui/disabled-reason-button'; import { PageComponent, type PageInitHelper } from '../page'; import AclList from '../topics/Tab.Acl/acl-list'; +type GroupTab = 'topics' | 'acl'; + type GroupSearchParams = { q?: string; withLag?: boolean; + tab?: GroupTab; }; const DEFAULT_MATCH_ALL_REGEX = /.*/s; @@ -135,7 +140,9 @@ const GroupDetailsMain = ({ groupId, search, onSearchChange }: GroupDetailsProps const setDeletingMode = (v: GroupDeletingMode) => setDeletingState((prev) => ({ ...prev, mode: v })); const setDeletingOffsets = (v: GroupOffset[] | null) => setDeletingState((prev) => ({ ...prev, offsets: v })); const [quickSearch, setQuickSearch] = useState(search?.q ?? ''); - const [showWithLagOnly, setShowWithLagOnly] = useState(search?.withLag ?? false); + const showWithLagOnly = search?.withLag ?? false; + const activeTab: GroupTab = search?.tab ?? 'topics'; + const withLagCheckboxId = useId(); const groupId2 = decodeURIComponent(groupId); const consumerGroupsSize = useApiStoreHook((s) => s.consumerGroups.size); @@ -191,105 +198,122 @@ const GroupDetailsMain = ({ groupId, search, onSearchChange }: GroupDetailsProps return ( - - - - + + + {/* Statistics Card */} -
-
- - } /> - - - - - Coordinator ID - - } - value={group.coordinatorId} - /> - - -
-
+ + + } /> + + + + + {group.coordinatorId} + + + } + /> + + + {/* Main Card */} -
- {/* View Buttons */} - - - { - setQuickSearch(filterText); - onSearchChange({ q: filterText }); - }} - width={300} - /> - { - setShowWithLagOnly(e.target.checked); - onSearchChange({ withLag: e.target.checked }); - }} - > - Only show topics with lag - - - - { - setDeletingMode(mode); - setDeletingOffsets(offsets); - }} - onEditOffsets={(g) => { - editGroup(); - setEditedTopic(g[0].topicName); - if (g.length === 1) { - setEditedPartition(g[0].partitionId); - } else { - setEditedPartition(null); - } + onSearchChange({ tab: value as GroupTab })} value={activeTab}> + + + Topics + + + ACL + + + + +
+ { + setQuickSearch(e.target.value); + onSearchChange({ q: e.target.value }); + }} + placeholder="Filter by member" + size="sm" + value={quickSearch} + > + + + + {quickSearch !== '' && ( + +
+ size="icon-xs" + variant="ghost" + > + + + + )} + +
+ onSearchChange({ withLag: checked === true })} + /> + +
+ + + { + setDeletingMode(mode); + setDeletingOffsets(offsets); + }} + onEditOffsets={(g) => { + editGroup(); + setEditedTopic(g[0].topicName); + if (g.length === 1) { + setEditedPartition(g[0].partitionId); + } else { + setEditedPartition(null); + } + }} + onlyShowPartitionsWithLag={showWithLagOnly} + quickSearch={quickSearch} + /> + + + + + + {/* Modals */} void; + onDeleteOffsets: (offsets: GroupOffset[], mode: GroupDeletingMode) => void; +}; + +const PartitionTable = ({ + partitions, + group, + featurePatchGroup, + featureDeleteGroupOffsets, + onEditOffsets, + onDeleteOffsets, +}: PartitionTableProps) => { + const [sorting, setSorting] = useState([]); + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: DEFAULT_TABLE_PAGE_SIZE }); + + const columns: ColumnDef[] = [ + { + accessorKey: 'partitionId', + header: ({ column }) => , + meta: { headWidth: 'sm' as const }, + }, + { + accessorKey: 'id', + header: 'Assigned Member', + enableSorting: false, + meta: { headWidth: 'full' as const }, + cell: ({ row: { original } }) => + original.assignedMember ? ( + renderMergedID(original.id, original.clientId) + ) : ( + + No assigned member + + ), + }, + { + accessorKey: 'host', + header: 'Host', + enableSorting: false, + cell: ({ row: { original } }) => + original.host ?? ( + + + + ), + }, + { + accessorKey: 'highWaterMark', + header: ({ column }) => , + meta: { headWidth: 'sm' as const }, + cell: ({ row: { original } }) => + original.highWaterMark !== null ? numberToThousandsString(original.highWaterMark) : '—', + }, + { + accessorKey: 'groupOffset', + header: ({ column }) => , + meta: { headWidth: 'sm' as const }, + cell: ({ row: { original } }) => + original.groupOffset !== null ? numberToThousandsString(original.groupOffset) : '—', + }, + { + accessorKey: 'lag', + header: ({ column }) => , + meta: { headWidth: 'sm' as const }, + cell: ({ row: { original } }) => (original.lag !== null ? : '—'), + }, + { + id: 'action', + header: '', + enableSorting: false, + meta: { align: 'right' as const, headWidth: 'fit' as const }, + cell: ({ row: { original } }) => ( +
+ onEditOffsets([original])} + reason={cannotEditGroupReason(group, featurePatchGroup, original.isUnconsumed ? [] : undefined)} + testId={`partition-edit-${original.partitionId}`} + > + + + onDeleteOffsets([original], 'partition')} + reason={cannotDeleteGroupOffsetsReason( + group, + featureDeleteGroupOffsets, + original.isUnconsumed ? [] : undefined + )} + testId={`partition-delete-${original.partitionId}`} + > + + +
+ ), + }, + ]; + + const table = useReactTable({ + data: partitions, + columns, + state: { sorting, pagination }, + onSortingChange: setSorting, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); + + return ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const meta = header.column.columnDef.meta as ColumnMeta | undefined; + return ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ); + })} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => { + const meta = cell.column.columnDef.meta as ColumnMeta | undefined; + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + })} + + ))} + +
+ {table.getPageCount() > 1 && } +
+ ); +}; + const GroupByTopics = (groupProps: { group: GroupDescription; onlyShowPartitionsWithLag: boolean; @@ -332,7 +520,7 @@ const GroupByTopics = (groupProps: { m.assignments.map((as) => ({ member: m, topicName: as.topicName, partitions: as.partitionIds ?? [] })) ); - const lagsFlat = topicLags.flatMap((topicLag) => + const lagsFlat: PartitionRow[] = topicLags.flatMap((topicLag) => topicLag.partitionOffsets.map((partLag) => { const assignedMember = allAssignments.find( (e) => e.topicName === topicLag.topic && e.partitions.includes(partLag.partitionId) @@ -359,199 +547,103 @@ const GroupByTopics = (groupProps: { .sort((a, b) => a.key.localeCompare(b.key)) .map((x) => ({ topicName: x.key, partitions: x.items })); - const topicEntries = lagGroupsByTopic.map((g) => { - const totalLagAll = g.partitions.sum((c) => c.lag ?? 0); - const partitionsAssigned = g.partitions.filter((c) => c.assignedMember).length; + const topicEntries = lagGroupsByTopic + .map((g) => { + const totalLagAll = g.partitions.sum((c) => c.lag ?? 0); + const partitionsAssigned = g.partitions.filter((c) => c.assignedMember).length; - const partitions = groupProps.onlyShowPartitionsWithLag - ? g.partitions.filter((e) => e.isUnconsumed || (e.lag !== null && e.lag !== 0)) - : g.partitions; + const partitions = groupProps.onlyShowPartitionsWithLag + ? g.partitions.filter((e) => e.isUnconsumed || (e.lag !== null && e.lag !== 0)) + : g.partitions; - if (partitions.length === 0) { - return null; - } + if (partitions.length === 0) { + return null; + } - const consumedPartitions = g.partitions.filter((p) => !p.isUnconsumed); - - return { - heading: ( - - - {/* Title */} - - {g.topicName} - - - - { - groupProps.onEditOffsets(consumedPartitions); - e.stopPropagation(); - }} - > - - - { - groupProps.onDeleteOffsets(consumedPartitions, 'topic'); - e.stopPropagation(); - }} - > - - - - - - Lag: {numberToThousandsString(totalLagAll)} - Assigned partitions: {partitionsAssigned} - - - - ), - description: ( - - columns={[ - { - size: 100, - header: 'Partition', - accessorKey: 'partitionId', - }, - { - size: Number.POSITIVE_INFINITY, - header: 'Assigned Member', - accessorKey: 'id', - cell: ({ - row: { - original: { assignedMember, id, clientId }, - }, - }) => - assignedMember ? ( - renderMergedID(id, clientId) - ) : ( - - No assigned member - - ), - }, - { - header: 'Host', - accessorKey: 'host', - cell: ({ - row: { - original: { host }, - }, - }) => - host ?? ( - - - - ), - }, - { - size: 120, - header: 'Log End Offset', - accessorKey: 'highWaterMark', - cell: ({ row: { original } }) => - original.highWaterMark !== null ? numberToThousandsString(original.highWaterMark) : '—', - }, - { - size: 120, - header: 'Group Offset', - accessorKey: 'groupOffset', - cell: ({ row: { original } }) => - original.groupOffset !== null ? numberToThousandsString(original.groupOffset) : '—', - }, - { - size: 80, - header: 'Lag', - accessorKey: 'lag', - cell: ({ row: { original } }) => - original.lag !== null ? ShortNum({ value: original.lag, tooltip: true }) : '—', - }, - { - size: 1, - header: '', - id: 'action', - cell: ({ row: { original } }) => ( - - groupProps.onEditOffsets([original])} - > - - - groupProps.onDeleteOffsets([original], 'partition')} - > - - - - ), - }, - ]} - data={partitions} - pagination - sorting - /> - ), - }; - }); + const consumedPartitions = g.partitions.filter((p) => !p.isUnconsumed); - const defaultExpand: number | undefined = - lagGroupsByTopic.length === 1 - ? 0 // only one -> expand - : undefined; // more than one -> collapse + return { topicName: g.topicName, partitions, totalLagAll, partitionsAssigned, consumedPartitions }; + }) + .filterNull(); - const nullEntries = topicEntries.filter((e) => e === null).length; - if (topicEntries.length === 0 || topicEntries.length === nullEntries) { + if (topicEntries.length === 0) { return ( - All {topicEntries.length} topics have been filtered (no lag on any partition). - ) : ( - 'No data found' - ) - } - /> +
+ + + + + + {groupProps.onlyShowPartitionsWithLag ? 'No topics with lag' : 'No data found'} + + {groupProps.onlyShowPartitionsWithLag + ? `All ${lagGroupsByTopic.length} topics have been filtered (no lag on any partition).` + : 'This consumer group has no committed topic offsets.'} + + + +
); } - return ; + // Only one topic -> expand it by default; otherwise leave all collapsed. + const defaultOpen = topicEntries.length === 1 ? [topicEntries[0].topicName] : []; + + return ( + + {topicEntries.map((entry) => ( + + +
+ {entry.topicName} +
+ Lag: {numberToThousandsString(entry.totalLagAll)} + Assigned partitions: {entry.partitionsAssigned} +
+
+
+ + {/* Topic-level actions live in the content (not nested inside the trigger button). */} +
+ groupProps.onEditOffsets(entry.consumedPartitions)} + reason={cannotEditGroupReason(groupProps.group, featurePatchGroup, entry.consumedPartitions)} + > + + + groupProps.onDeleteOffsets(entry.consumedPartitions, 'topic')} + reason={cannotDeleteGroupOffsetsReason( + groupProps.group, + featureDeleteGroupOffsets, + entry.consumedPartitions + )} + > + + + +
+ +
+
+ ))} +
+ ); }; const renderMergedID = (id?: string, clientId?: string) => { @@ -574,73 +666,6 @@ const renderMergedID = (id?: string, clientId?: string) => { return null; }; -type StateIcon = 'stable' | 'completingrebalance' | 'preparingrebalance' | 'empty' | 'dead' | 'unknown'; - -const stateIcons = new Map([ - ['stable', ], - ['completingrebalance', ], - ['preparingrebalance', ], - ['empty', ], - ['dead', ], - ['unknown', ], -]); - -const stateIconNames: Record = { - stable: 'Stable', - completingrebalance: 'Completing Rebalance', - preparingrebalance: 'Preparing Rebalance', - empty: 'Empty', - dead: 'Dead', - unknown: 'Unknown', -}; - -const stateIconDescriptions: Record = { - stable: 'Consumer group has members which have been assigned partitions', - completingrebalance: 'Kafka is assigning partitions to group members', - preparingrebalance: 'A reassignment of partitions is required, members have been asked to stop consuming', - empty: 'Consumer group exists, but does not have any members', - dead: 'Consumer group does not have any members and its metadata has been removed', - unknown: 'Group state is not known', -}; - -const consumerGroupStateTable = ( - - {Array.from(stateIcons.entries()).map(([key, icon]) => ( - - {/* Icon column */} - - {icon} {stateIconNames[key]} - - - {/* Description column */} - {stateIconDescriptions[key]} - - ))} - -); - -export const GroupState = (p: { group: GroupDescription }) => { - const state = p.group.state.toLowerCase(); - const icon = stateIcons.get(state as StateIcon); - - return ( - - - {icon} - {p.group.state} - - - ); -}; -const ProtocolType = (p: { group: GroupDescription }) => { - const protocol = p.group.protocolType; - if (protocol === 'consumer') { - return null; - } - - return ; -}; - function cannotEditGroupReason( group: GroupDescription, featurePatchGroup: boolean, @@ -653,7 +678,7 @@ function cannotEditGroupReason( return "You don't have 'editConsumerGroup' permissions for this group"; } if (group.isInUse) { - return 'Consumer groups with active members cannot be edited'; + return 'Offsets can only be edited while the group is Empty with no connected members.'; } if (!featurePatchGroup) { return 'This cluster does not support editing group offsets'; @@ -665,7 +690,7 @@ function cannotDeleteGroupReason(group: GroupDescription, featureDeleteGroup: bo return "You don't have 'deleteConsumerGroup' permissions for this group"; } if (group.isInUse) { - return 'Consumer groups with active members cannot be deleted'; + return 'A consumer group can only be deleted while it is Empty with no connected members.'; } if (!featureDeleteGroup) { return 'This cluster does not support deleting groups'; @@ -684,7 +709,7 @@ function cannotDeleteGroupOffsetsReason( return "You don't have 'deleteConsumerGroup' permissions for this group"; } if (group.isInUse) { - return 'Consumer groups with active members cannot be deleted'; + return 'Offsets can only be deleted while the group is Empty with no connected members.'; } if (!featureDeleteGroupOffsets) { return 'This cluster does not support deleting group offsets'; diff --git a/frontend/src/components/pages/consumers/group-list.tsx b/frontend/src/components/pages/consumers/group-list.tsx index 88dfb43021..da02e53c47 100644 --- a/frontend/src/components/pages/consumers/group-list.tsx +++ b/frontend/src/components/pages/consumers/group-list.tsx @@ -9,179 +9,340 @@ * by the Apache License, Version 2.0 */ -import { DataTable, Flex, SearchField, Tag, Text } from '@redpanda-data/ui'; import { Link } from '@tanstack/react-router'; -import { parseAsString, useQueryState } from 'nuqs'; +import { + type ColumnDef, + type ColumnFiltersState, + flexRender, + getCoreRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + type PaginationState, + type Row, + type SortingState, + type Updater, + useReactTable, +} from '@tanstack/react-table'; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from 'components/redpanda-ui/components/empty'; +import { ListLayout, ListLayoutFilters, ListLayoutPagination } from 'components/redpanda-ui/components/list-layout'; +import { Search, UsersIcon, X } from 'lucide-react'; +import { parseAsArrayOf, parseAsInteger, parseAsString, useQueryState } from 'nuqs'; import type { FC } from 'react'; -import { useEffect } from 'react'; +import { useEffect, useLayoutEffect, useMemo } from 'react'; +import { useLegacyListConsumerGroupsFullQuery } from 'react-query/api/consumer-group'; -import { GroupState } from './group-details'; +import type { ColumnMeta } from './column-meta'; import { appGlobal } from '../../../state/app-global'; -import { api, useApiStoreHook } from '../../../state/backend-api'; import type { GroupDescription } from '../../../state/rest-interfaces'; -import { DefaultSkeleton } from '../../../utils/tsx-utils'; +import { setPageHeader } from '../../../state/ui-state'; +import { DEFAULT_TABLE_PAGE_SIZE } from '../../constants'; import { BrokerList } from '../../misc/broker-list'; -import PageContent from '../../misc/page-content'; -import Section from '../../misc/section'; import { ShortNum } from '../../misc/short-num'; -import { Statistic } from '../../misc/statistic'; -import { PageComponent, type PageInitHelper } from '../page'; +import { Alert, AlertDescription, AlertTitle } from '../../redpanda-ui/components/alert'; +import { Badge } from '../../redpanda-ui/components/badge'; +import { Button } from '../../redpanda-ui/components/button'; +import { + DataTableColumnHeader, + DataTableFacetedFilter, + DataTablePagination, +} from '../../redpanda-ui/components/data-table'; +import { Input, InputEnd, InputStart } from '../../redpanda-ui/components/input'; +import { Skeleton } from '../../redpanda-ui/components/skeleton'; +import { Stat } from '../../redpanda-ui/components/stat'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../redpanda-ui/components/table'; +import { + ConsumerGroupStateCell, + consumerGroupStateFilterOptions, +} from '../../ui/consumer-group/consumer-group-state-cell'; -class GroupList extends PageComponent { - initPage(p: PageInitHelper): void { - p.title = 'Consumer Groups'; - p.addBreadcrumb('Consumer Groups', '/groups'); - - this.refreshData(true); - appGlobal.onRefresh = () => this.refreshData(true); +const groupIdFilterFn = (row: Row, _columnId: string, filterValue: string) => { + if (!filterValue) { + return true; } - - refreshData(force: boolean) { - api.refreshConsumerGroups(force); + const group = row.original; + try { + const re = new RegExp(filterValue, 'i'); + return re.test(group.groupId) || re.test(group.protocol); + } catch { + const term = filterValue.toLowerCase(); + return group.groupId.toLowerCase().includes(term) || group.protocol.toLowerCase().includes(term); } +}; - render() { - return ; +const stateFilterFn = (row: Row, columnId: string, filterValues: string[]) => { + if (!filterValues?.length) { + return true; } -} + return filterValues.includes(String(row.getValue(columnId))); +}; -const GroupListContent: FC = () => { - const consumerGroups = useApiStoreHook((s) => s.consumerGroups); - const [quickSearch, setQuickSearch] = useQueryState('q', parseAsString.withDefault('')); +const GroupList: FC = () => { + useLayoutEffect(() => { + setPageHeader('Consumer Groups', [{ title: 'Consumer Groups', linkTo: '/groups' }]); + }, []); + + const { data, isLoading, isError, error, refetch } = useLegacyListConsumerGroupsFullQuery(); + const consumerGroups = data.consumerGroups; useEffect(() => { - api.refreshConsumerGroups(true); - appGlobal.onRefresh = () => api.refreshConsumerGroups(true); - }, []); + appGlobal.onRefresh = () => { + refetch(); + }; + }, [refetch]); - if (!consumerGroups) { - return DefaultSkeleton; - } + const [searchValue, setSearchValue] = useQueryState('q', parseAsString.withDefault('')); + const [stateFilter, setStateFilter] = useQueryState('state', parseAsArrayOf(parseAsString).withDefault([])); + const [pageIndex, setPageIndex] = useQueryState('page', parseAsInteger.withDefault(0)); + const [pageSize, setPageSize] = useQueryState('pageSize', parseAsInteger.withDefault(DEFAULT_TABLE_PAGE_SIZE)); + const [sortId, setSortId] = useQueryState('sortId', parseAsString.withDefault('')); + const [sortDesc, setSortDesc] = useQueryState('sortDesc', parseAsString.withDefault('')); - let groups = Array.from(consumerGroups.values()); + const sorting: SortingState = sortId ? [{ id: sortId, desc: sortDesc === 'true' }] : []; - try { - const quickSearchRegExp = new RegExp(quickSearch, 'i'); - groups = groups.filter( - (groupDescription) => - groupDescription.groupId.match(quickSearchRegExp) || groupDescription.protocol.match(quickSearchRegExp) - ); - } catch (_e) { - // biome-ignore lint/suspicious/noConsole: intentional console usage - console.warn('Invalid expression'); - } + const handleSortingChange = (updater: Updater) => { + const next = typeof updater === 'function' ? updater(sorting) : updater; + if (next.length > 0) { + setSortId(next[0].id); + setSortDesc(next[0].desc ? 'true' : 'false'); + } else { + setSortId(''); + setSortDesc(''); + } + void setPageIndex(0); + }; - const stateGroups = groups.groupInto((g) => g.state); + const columnFilters: ColumnFiltersState = [ + ...(searchValue ? [{ id: 'groupId', value: searchValue }] : []), + ...(stateFilter.length ? [{ id: 'state', value: stateFilter }] : []), + ]; - return ( - -
- - -
- {stateGroups.map((g) => ( - - ))} - -
- -
-
) => { + const next = typeof updater === 'function' ? updater(columnFilters) : updater; + const nameFilter = next.find((f) => f.id === 'groupId'); + const stateColumnFilter = next.find((f) => f.id === 'state'); + setSearchValue((nameFilter?.value as string) || null); + setStateFilter((stateColumnFilter?.value as string[])?.length ? (stateColumnFilter?.value as string[]) : null); + void setPageIndex(0); + }; + + const pagination: PaginationState = { pageIndex, pageSize }; + + const handlePaginationChange = (updater: Updater) => { + const next = typeof updater === 'function' ? updater(pagination) : updater; + void setPageIndex(next.pageIndex); + void setPageSize(next.pageSize); + }; + + const statistics = useMemo(() => { + const byState = new Map(); + for (const group of consumerGroups) { + byState.set(group.state, (byState.get(group.state) ?? 0) + 1); + } + return { + total: consumerGroups.length, + byState: Array.from(byState.entries()).map(([state, count]) => ({ state, count })), + }; + }, [consumerGroups]); + + const columns: ColumnDef[] = [ + { + accessorKey: 'state', + header: ({ column }) => , + filterFn: stateFilterFn, + meta: { headWidth: 'md' as const }, + cell: ({ row: { original: group } }) => , + }, + { + accessorKey: 'groupId', + header: ({ column }) => , + filterFn: groupIdFilterFn, + meta: { headWidth: 'full' as const }, + cell: ({ row: { original: group } }) => ( + - setQuickSearch(x || null)} - width="350px" - /> -
- - columns={[ - { - header: 'State', - accessorKey: 'state', - size: 130, - cell: ({ row: { original } }) => , - }, - { - header: 'ID', - accessorKey: 'groupId', - cell: ({ row: { original } }) => ( - - - - ), - size: Number.POSITIVE_INFINITY, - }, - { - header: 'Coordinator', - accessorKey: 'coordinatorId', - size: 1, - cell: ({ row: { original } }) => , - }, - { - header: 'Protocol', - accessorKey: 'protocol', - size: 1, - }, - { - header: 'Members', - accessorKey: 'members', - size: 1, - cell: ({ row: { original } }) => original.members.length, - }, - { - header: 'Offset Lag (Sum)', - accessorKey: 'lagSum', - cell: ({ row: { original } }) => ShortNum({ value: original.lagSum }), - }, - ]} - data={groups} - pagination - sorting - /> -
-
- ); -}; + {group.protocolType !== 'consumer' && Protocol: {group.protocolType}} + {group.groupId} + + ), + }, + { + accessorKey: 'coordinatorId', + header: ({ column }) => , + enableColumnFilter: false, + meta: { headWidth: 'sm' as const }, + cell: ({ row: { original: group } }) => , + }, + { + accessorKey: 'protocol', + header: ({ column }) => , + enableColumnFilter: false, + meta: { headWidth: 'sm' as const }, + }, + { + id: 'members', + accessorFn: (group) => group.members.length, + header: ({ column }) => , + enableColumnFilter: false, + meta: { headWidth: 'sm' as const }, + }, + { + accessorKey: 'lagSum', + header: ({ column }) => , + enableColumnFilter: false, + meta: { headWidth: 'sm' as const }, + cell: ({ row: { original: group } }) => , + }, + ]; -const GroupId = (p: { group: GroupDescription }) => { - const protocol = p.group.protocolType; + const table = useReactTable({ + data: consumerGroups, + columns, + state: { sorting, pagination, columnFilters }, + onSortingChange: handleSortingChange, + onPaginationChange: handlePaginationChange, + onColumnFiltersChange: handleColumnFiltersChange, + autoResetPageIndex: false, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: getFacetedUniqueValues(), + getPaginationRowModel: getPaginationRowModel(), + }); - const groupIdEl = ( - - {p.group.groupId} - - ); + const groupIdFilter = (table.getColumn('groupId')?.getFilterValue() as string) ?? ''; - if (protocol === 'consumer') { - return groupIdEl; + if (isError && error) { + return ( + + Failed to load consumer groups + {(error as Error).message} + + ); } + const renderBody = () => { + if (isLoading) { + return [0, 1, 2, 3, 4].map((i) => ( + + {columns.map((_col, colIdx) => ( + + + + ))} + + )); + } + + if (table.getRowModel().rows.length) { + return table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => { + const meta = cell.column.columnDef.meta as ColumnMeta | undefined; + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + })} + + )); + } + + const isFiltered = columnFilters.length > 0; + return ( + + + + + + + + {isFiltered ? 'No consumer groups match your search' : 'No consumer groups yet'} + + {isFiltered + ? 'Try adjusting your search term or filters.' + : 'Consumer groups appear here once clients start consuming from your topics.'} + + + + + + ); + }; + return ( - - Protocol: {protocol} - {groupIdEl} - + +
+ + {statistics.byState.map(({ state, count }) => ( + + ))} +
+ + + table.getColumn('groupId')?.setFilterValue(e.target.value || undefined)} + placeholder="Filter by group ID (regexp)..." + size="sm" + testId="search-field-input" + value={groupIdFilter} + > + + + + {groupIdFilter !== '' && ( + + + + )} + + + + + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const meta = header.column.columnDef.meta as ColumnMeta | undefined; + return ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ); + })} + + ))} + + {renderBody()} +
+ + + + +
); }; diff --git a/frontend/src/components/pages/consumers/modals.tsx b/frontend/src/components/pages/consumers/modals.tsx index dc9a97015b..94678732a8 100644 --- a/frontend/src/components/pages/consumers/modals.tsx +++ b/frontend/src/components/pages/consumers/modals.tsx @@ -10,32 +10,16 @@ */ import { - Accordion, - Box, - Button, - createStandaloneToast, - DataTable, - Flex, - FormLabel, - HStack, - List, - ListItem, - Modal, - ModalBody, - ModalContent, - ModalFooter, - ModalHeader, - ModalOverlay, - NumberInput, - Radio, - redpandaTheme, - redpandaToastOptions, - Text, - Tooltip, - UnorderedList, -} from '@redpanda-data/ui'; + type ColumnDef, + flexRender, + getCoreRowModel, + getSortedRowModel, + type SortingState, + useReactTable, +} from '@tanstack/react-table'; import { ChevronLeftIcon, ChevronRightIcon, SkipIcon, TrashIcon, WarningIcon } from 'components/icons'; -import { Component } from 'react'; +import { Component, type ReactNode, useRef, useState } from 'react'; +import { toast as sonnerToast } from 'sonner'; import { appGlobal } from '../../../state/app-global'; import { api } from '../../../state/backend-api'; @@ -47,10 +31,58 @@ import type { TopicOffset, } from '../../../state/rest-interfaces'; import { toJson } from '../../../utils/json-utils'; -import { InfoText, numberToThousandsString } from '../../../utils/tsx-utils'; +import { numberToThousandsString } from '../../../utils/tsx-utils'; import { showErrorModal } from '../../misc/error-modal'; import { KowlTimePicker } from '../../misc/kowl-time-picker'; -import { SingleSelect } from '../../misc/select'; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../../redpanda-ui/components/accordion'; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '../../redpanda-ui/components/alert-dialog'; +import { Button as UiButton } from '../../redpanda-ui/components/button'; +import { DataTableColumnHeader } from '../../redpanda-ui/components/data-table'; +import { + Dialog, + DialogBody, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '../../redpanda-ui/components/dialog'; +import { Input } from '../../redpanda-ui/components/input'; +import { Label } from '../../redpanda-ui/components/label'; +import { RadioGroup, RadioGroupItem } from '../../redpanda-ui/components/radio-group'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../redpanda-ui/components/select'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../redpanda-ui/components/table'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../redpanda-ui/components/tooltip'; +import { InlineCode } from '../../redpanda-ui/components/typography'; + +const ALL_SENTINEL = '__all__'; + +const STRATEGY_LABELS: Record = { + startOffset: 'Earliest', + endOffset: 'Latest', + shiftBy: 'Shift By', + time: 'Specific Time', + otherGroup: 'Other Consumer Group', +}; + +/** Inline text with an explanatory tooltip on hover (replaces the legacy InfoText). */ +const InfoTooltip = ({ text, children }: { text: string; children: ReactNode }) => ( + + + {children}} + /> + {text} + + +); type EditOptions = 'startOffset' | 'endOffset' | 'time' | 'otherGroup' | 'shiftBy'; @@ -76,15 +108,6 @@ export type GroupOffset = { newOffset?: number | Date | PartitionOffset; }; -const { ToastContainer, toast } = createStandaloneToast({ - theme: redpandaTheme, - defaultOptions: { - ...redpandaToastOptions.defaultOptions, - isClosable: false, - duration: 2000, - }, -}); - type EditOffsetsModalState = { page: 0 | 1; selectedOption: EditOptions; @@ -150,35 +173,33 @@ export class EditOffsetsModal extends Component<{ this.offsetsByTopic = offsets.groupInto((x) => x.topicName).map((g) => ({ topicName: g.key, items: g.items })); return ( - <> - - { - // no op - modal is controlled by parent component - }} - > - - - Edit consumer group - - - - You are editing a group with {this.offsetsByTopic.length}{' '} - {this.offsetsByTopic.length === 1 ? 'topic' : 'topics'} and {offsets.length}{' '} - {offsets.length === 1 ? 'partition' : 'partitions'}. - - - - {/* Content */} -
- {this.state.page === 0 ?
{this.page1()}
:
{this.page2()}
} -
-
- {this.footer()} -
-
- + { + if (!(open || this.state.isApplyingEdit || this.state.isLoadingTimestamps)) { + this.props.onClose(); + } + }} + open={visible} + > + + + Edit consumer group + + +

+ You are editing a group with {this.offsetsByTopic.length}{' '} + {this.offsetsByTopic.length === 1 ? 'topic' : 'topics'} and {offsets.length}{' '} + {offsets.length === 1 ? 'partition' : 'partitions'}. +

+ + {/* Content */} +
+ {this.state.page === 0 ?
{this.page1()}
:
{this.page2()}
} +
+
+ {this.footer()} +
+
); } @@ -186,87 +207,78 @@ export class EditOffsetsModal extends Component<{ const topicChoices = this.props.offsets?.groupInto((x) => x.topicName).map((x) => x.key) ?? []; const otherConsumerGroups = [...api.consumerGroups.values()].filter((g) => g.groupId !== this.props.group.groupId); + const partitionOptions = + this.props.offsets + ?.filter((x) => x.topicName === this.state.selectedTopic) + ?.sort((a, b) => a.partitionId - b.partitionId) ?? []; + return ( - - - - Topic - - onChange={(v) => { - this.setState({ selectedTopic: v }); - }} - options={[ - { - value: null, - label: 'All Topics', - }, - ...topicChoices.map((x) => ({ - value: x, - label: x, - })), - ]} - value={this.state.selectedTopic} - /> - +
+
+
+ + +
+ {this.state.selectedTopic !== null && ( - - Partition - { - this.setState({ selectedPartition: v }); - }} - options={[ - { - value: null, - label: 'All Partitions', - }, - ...(this.props.offsets - ?.filter((x) => x.topicName === this.state.selectedTopic) - ?.sort((a, b) => a.partitionId - b.partitionId) - ?.map((x: GroupOffset) => ({ - value: x.partitionId, - label: x.partitionId.toString(), - })) ?? []), - ]} - value={this.state.selectedPartition} - /> - +
+ + +
)} - - Strategy - { - this.setState({ selectedOption: v as EditOptions }); - }} - options={[ - { - value: 'startOffset', - label: 'Earliest', - }, - { - value: 'endOffset', - label: 'Latest', - }, - { - value: 'shiftBy', - label: 'Shift By', - }, - { - value: 'time', - label: 'Specific Time', - }, - { - value: 'otherGroup', - label: 'Other Consumer Group', - }, - ]} + +
+ + +
+
- +

{ ( { @@ -278,11 +290,11 @@ export class EditOffsetsModal extends Component<{ } as Record )[this.state.selectedOption] } - +

{this.state.selectedOption === 'time' && ( - - Timestamp +
+ { @@ -290,155 +302,93 @@ export class EditOffsetsModal extends Component<{ }} valueUtcMs={this.state.timestampUtcMs} /> - +
)} {this.state.selectedOption === 'shiftBy' && ( - - Shift by - + + { if (Number.isNaN(this.state.offsetShiftByValue)) { this.setState({ offsetShiftByValueAsString: '0', offsetShiftByValue: 0 }); } }} - onChange={(valueAsString, valueAsNumber) => { - // entering '-' or '.' without any digits will set the value to -Number.MAX_SAFE_INTEGER - // we want to prevent this and set the value to 0 instead in onBlur - if (valueAsNumber !== -Number.MAX_SAFE_INTEGER) { - this.setState({ offsetShiftByValueAsString: valueAsString, offsetShiftByValue: valueAsNumber }); - } + onChange={(e) => { + const valueAsString = e.target.value; + const valueAsNumber = valueAsString === '' ? Number.NaN : Number(valueAsString); + this.setState({ offsetShiftByValueAsString: valueAsString, offsetShiftByValue: valueAsNumber }); }} + type="number" value={this.state.offsetShiftByValueAsString} /> - +
)} {this.state.selectedOption === 'otherGroup' && ( - -
+ + + this.setState({ otherGroupCopyMode: v as 'all' | 'onlyExisting' })} + value={this.state.otherGroupCopyMode} + > + +
-
+ + + + )} -
+ ); } page2() { + const topics = this.offsetsByTopic.filter( + ({ topicName }) => this.state.selectedTopic === null || topicName === this.state.selectedTopic + ); + return ( -
- this.state.selectedTopic === null || topicName === this.state.selectedTopic) - .map(({ topicName, items }) => ({ - heading: ( - - {/* Title */} - - {topicName} - - - {items.length} Partitions - - - ), - description: ( - - columns={[ - { - size: 130, - header: 'Partition', - accessorKey: 'partitionId', - }, - { - size: 150, - header: 'Offset Before', - accessorKey: 'offset', - cell: ({ - row: { - original: { offset }, - }, - }) => - offset === null || offset === undefined ? ( - - - - - - ) : ( - numberToThousandsString(offset) - ), - }, - { - header: 'Offset After', - id: 'offsetAfter', - size: Number.POSITIVE_INFINITY, - cell: ({ row: { original } }) => ( - - ), - }, - ]} - data={items} - defaultPageSize={100} - pagination - size="sm" - sorting - /> - ), - }))} - /> +
+ + {topics.map(({ topicName, items }) => ( + + +
+ {topicName} + {items.length} Partitions +
+
+ + + +
+ ))} +
); } @@ -488,20 +438,12 @@ export class EditOffsetsModal extends Component<{ // Fetch offset for each partition setTimeout(async () => { const toastMsg = 'Fetching offsets for timestamp'; - const toastRef = toast({ - status: 'loading', - description: `${toastMsg}...`, - duration: null, - }); + const toastId = sonnerToast.loading(`${toastMsg}...`); let offsetsForTimestamp: TopicOffset[]; try { offsetsForTimestamp = await api.getTopicOffsetsByTimestamp(requiredTopics, this.state.timestampUtcMs); - toast.update(toastRef, { - status: 'success', - duration: 2000, - description: `${toastMsg} - done`, - }); + sonnerToast.success(`${toastMsg} - done`, { id: toastId }); } catch (err) { showErrorModal( 'Failed to fetch offsets for timestamp', @@ -511,11 +453,7 @@ export class EditOffsetsModal extends Component<{ , toJson({ errors: err, request: requiredTopics }, 4) ); - toast.update(toastRef, { - status: 'error', - duration: 2000, - description: `${toastMsg} - failed`, - }); + sonnerToast.error(`${toastMsg} - failed`, { id: toastId }); return; } @@ -595,56 +533,43 @@ export class EditOffsetsModal extends Component<{ if (this.state.page === 0) { return ( - - - - - + Review + + +
); } return ( - - - - - - - + Apply + + ); } @@ -684,11 +609,7 @@ export class EditOffsetsModal extends Component<{ this.setState({ isApplyingEdit: true }); const toastMsg = 'Applying offsets'; - const toastRef = toast({ - status: 'loading', - description: `${toastMsg}...`, - duration: null, - }); + const toastId = sonnerToast.loading(`${toastMsg}...`); const topics = createEditRequest(offsets); try { const editResponse = await api.editConsumerGroupOffsets(group.groupId, topics); @@ -704,19 +625,11 @@ export class EditOffsetsModal extends Component<{ throw new Error(`Apply offsets failed with ${errors.length} errors`); } - toast.update(toastRef, { - status: 'success', - duration: 2000, - description: `${toastMsg} - done`, - }); + sonnerToast.success(`${toastMsg} - done`, { id: toastId }); } catch (err) { // biome-ignore lint/suspicious/noConsole: intentional console usage console.error('failed to apply offset edit', err); - toast.update(toastRef, { - status: 'error', - duration: 2000, - description: `${toastMsg} - failed`, - }); + sonnerToast.error(`${toastMsg} - failed`, { id: toastId }); showErrorModal( 'Apply editted offsets', @@ -744,11 +657,18 @@ class ColAfter extends Component<{ // No change if (val === null) { return ( - - - - - + + + + + + } + /> + Offset will not be changed + + ); } @@ -789,23 +709,23 @@ class ColAfter extends Component<{ // use 'latest' const partition = api.topicPartitions.get(record.topicName)?.first((p) => p.id === record.partitionId); return ( -
- } - iconColor="orangered" - iconSize="18px" - maxWidth="350px" - tooltip={ -
- There is no offset for this partition at or after the given timestamp ( - {new Date(this.props.selectedTime ?? 0).toLocaleString()}). As a fallback, the last - offset in that partition will be used. -
- } - > - {numberToThousandsString(partition?.waterMarkHigh ?? -1)} -
-
+ + + + + {numberToThousandsString(partition?.waterMarkHigh ?? -1)} + + } + /> + + There is no offset for this partition at or after the given timestamp ( + {new Date(this.props.selectedTime ?? 0).toLocaleString()}). As a fallback, the last offset + in that partition will be used. + + + ); } } @@ -851,6 +771,88 @@ class ColAfter extends Component<{ } } +/** Page-2 preview of a single topic's partition offsets (Before/After). */ +const OffsetPreviewTable = ({ items, selectedTime }: { items: GroupOffset[]; selectedTime: number }) => { + const [sorting, setSorting] = useState([]); + + const columns: ColumnDef[] = [ + { + accessorKey: 'partitionId', + header: ({ column }) => , + meta: { headWidth: 'sm' as const }, + }, + { + accessorKey: 'offset', + header: ({ column }) => , + meta: { headWidth: 'md' as const }, + cell: ({ + row: { + original: { offset }, + }, + }) => + offset === null || offset === undefined ? ( + + + + + + } + /> + The group does not have an offset for this partition yet + + + ) : ( + numberToThousandsString(offset) + ), + }, + { + id: 'offsetAfter', + header: 'Offset After', + enableSorting: false, + cell: ({ row: { original } }) => , + }, + ]; + + const table = useReactTable({ + data: items, + columns, + state: { sorting }, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }); + + return ( + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const meta = header.column.columnDef.meta as { headWidth?: 'sm' | 'md' | 'full' } | undefined; + return ( + + {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())} + ))} + + ))} + +
+ ); +}; + export type GroupDeletingMode = 'group' | 'topic' | 'partition'; // Why do we pass 'mode'? // It is the users "intent" (where he clicked). @@ -860,184 +862,155 @@ export type GroupDeletingMode = 'group' | 'topic' | 'partition'; // - user clicks 'delete' on the topic // - dialog would show "you want to delete ALL offsets for this group" // which is technically correct, but might give the impression of deleting more than he wanted -export class DeleteOffsetsModal extends Component<{ +export const DeleteOffsetsModal = (props: { group: GroupDescription; mode: GroupDeletingMode; offsets: GroupOffset[] | null; onClose: () => void; - onInit: () => void; + onInit?: () => void; disabledReason?: string; -}> { - lastOffsets!: GroupOffset[]; +}) => { + const { group, mode, offsets, onClose } = props; + const [isDeleting, setIsDeleting] = useState(false); + // Keep the last non-null offsets so the dialog content doesn't flash empty during the close animation. + const lastOffsetsRef = useRef([]); + if (offsets) { + lastOffsetsRef.current = offsets; + } - render() { - const { group, mode } = this.props; - let offsets = this.props.offsets; + const visible = Boolean(offsets); + const activeOffsets = offsets ?? lastOffsetsRef.current; + const offsetsByTopic = activeOffsets.groupInto((x) => x.topicName).map((g) => ({ topicName: g.key, items: g.items })); + const singlePartition = activeOffsets.length === 1; - const visible = Boolean(offsets); - if (offsets) { - this.lastOffsets = offsets; + const handleDelete = async () => { + setIsDeleting(true); + const toastId = sonnerToast.loading('Deleting offsets...'); + try { + if (mode === 'group') { + await api.deleteConsumerGroup(group.groupId); + } else { + const deleteRequest = createDeleteRequest(activeOffsets); + const deleteResponse = await api.deleteConsumerGroupOffsets(group.groupId, deleteRequest); + const errors = deleteResponse + .map((t) => ({ + ...t, + partitions: t.partitions.filter((x) => x.error), + })) + .filter((t) => t.partitions.length > 0); + if (errors.length > 0) { + // biome-ignore lint/suspicious/noConsole: intentional console usage + console.error('backend returned errors for deleteOffsets', { + request: deleteRequest, + errors, + }); + throw new Error(`Delete offsets failed with ${errors.length} errors`); + } + } + + sonnerToast.success('Deleting offsets - done', { id: toastId }); + + const remainingOffsets = group.topicOffsets.sum((t) => t.partitionOffsets.length) - activeOffsets.length; + onClose(); + if (remainingOffsets === 0) { + // Group is fully deleted, go back to list + appGlobal.historyReplace('/groups'); + } + } catch (err) { + // biome-ignore lint/suspicious/noConsole: intentional console usage + console.error(err); + sonnerToast.error(`Could not delete selected offsets in consumer group ${group.groupId} - ${toJson(err, 4)}`, { + id: toastId, + }); + } finally { + setIsDeleting(false); + api.refreshConsumerGroups(true); } - offsets = offsets ?? this.lastOffsets; + }; - const offsetsByTopic = offsets?.groupInto((x) => x.topicName).map((g) => ({ topicName: g.key, items: g.items })); - const singlePartition = offsets?.length === 1; + const leadText = + mode === 'group' + ? 'This action will delete the following consumer group:' + : mode === 'topic' + ? 'Group offsets will be deleted for topic:' + : 'Group offsets will be deleted for partition:'; + + return ( + { + if (!(open || isDeleting)) { + onClose(); + } + }} + open={visible} + > + + + + {mode === 'group' ? 'Delete consumer group' : 'Delete consumer group offsets'} + + {leadText} + + +
+
+ +
+
+ {mode === 'group' && ( + <> +

+ Name: {group.groupId} +

+

+ Partitions: {activeOffsets.length} +

+

+ Topics: {offsetsByTopic.length} +

+

Are you sure?

+ + )} + + {mode === 'topic' && ( + <> +

+ Topic: {offsetsByTopic[0]?.topicName} +

+

+ {activeOffsets.length} {singlePartition ? 'Partition' : 'Partitions'} +

+ + )} + + {mode === 'partition' && ( + <> +

+ Topic: {offsetsByTopic[0]?.topicName} +

+

+ Partition: {offsetsByTopic[0]?.items[0].partitionId} +

+ + )} +
+
- return ( - - - - {mode === 'group' ? 'Delete consumer group' : 'Delete consumer group offsets'} - - -
- {/* @ts-ignore */} - -
- - {Boolean(visible) && ( - - {mode === 'group' && ( - - - This action will delete the following consumer group: - - - - - - Name: - {' '} - {group.groupId} - - - - - Partitions: - {' '} - {offsets.length} - - - - - Topics: - {' '} - {offsetsByTopic.length} - - - Are you sure? - - - )} - - {mode === 'topic' && ( - - Group offsets will be deleted for topic: - - - Topic: {offsetsByTopic[0].topicName} - - - {offsets.length} {singlePartition ? 'Partition' : 'Partitions'} - - - - )} - - {mode === 'partition' && ( - - Group offsets will be deleted for partition: - - - Topic: {offsetsByTopic[0].topicName} - - - Partition: {offsetsByTopic[0].items[0].partitionId} - - - - )} - - )} - -
-
- - - -
-
- ); - } -} + + Cancel + + Delete + + +
+
+ ); +}; // Utility functions function createEditRequest(offsets: GroupOffset[]): EditConsumerGroupOffsetsTopic[] { diff --git a/frontend/src/components/ui/consumer-group/consumer-group-state-cell.tsx b/frontend/src/components/ui/consumer-group/consumer-group-state-cell.tsx new file mode 100644 index 0000000000..c68330706e --- /dev/null +++ b/frontend/src/components/ui/consumer-group/consumer-group-state-cell.tsx @@ -0,0 +1,87 @@ +/** + * Copyright 2026 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 { CheckCircleIcon, FlameIcon, HelpIcon, HourglassIcon, WarningIcon } from 'components/icons'; +import type { ReactNode } from 'react'; + +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../redpanda-ui/components/tooltip'; + +type StateIconKey = 'stable' | 'completingrebalance' | 'preparingrebalance' | 'empty' | 'dead' | 'unknown'; + +const stateIcons: Record = { + stable: , + completingrebalance: , + preparingrebalance: , + empty: , + dead: , + unknown: , +}; + +export const consumerGroupStateNames: Record = { + stable: 'Stable', + completingrebalance: 'Completing Rebalance', + preparingrebalance: 'Preparing Rebalance', + empty: 'Empty', + dead: 'Dead', + unknown: 'Unknown', +}; + +/** + * All possible consumer group states, used to populate the State faceted filter so every + * option is available even when no group is currently in that state. `value` is the raw + * state string returned by the backend (must match `GroupDescription.state` exactly). + */ +export const consumerGroupStateFilterOptions: { label: string; value: string }[] = [ + { label: 'Stable', value: 'Stable' }, + { label: 'Completing Rebalance', value: 'CompletingRebalance' }, + { label: 'Preparing Rebalance', value: 'PreparingRebalance' }, + { label: 'Empty', value: 'Empty' }, + { label: 'Dead', value: 'Dead' }, + { label: 'Unknown', value: 'Unknown' }, +]; + +const stateDescriptions: Record = { + stable: 'Consumer group has members which have been assigned partitions', + completingrebalance: 'Kafka is assigning partitions to group members', + preparingrebalance: 'A reassignment of partitions is required, members have been asked to stop consuming', + empty: 'Consumer group exists, but does not have any members', + dead: 'Consumer group does not have any members and its metadata has been removed', + unknown: 'Group state is not known', +}; + +const normalizeStateKey = (state: string): StateIconKey => { + const key = state.toLowerCase().replace(/\s+/g, '') as StateIconKey; + return key in stateIcons ? key : 'unknown'; +}; + +/** + * Renders a consumer group's state as an icon + label, with a tooltip describing what + * the state means. Shared between the consumer groups list and detail pages. + */ +export const ConsumerGroupStateCell = ({ state }: { state: string }) => { + const key = normalizeStateKey(state); + + return ( + + + + {stateIcons[key]} + {state} + + } + /> + {stateDescriptions[key]} + + + ); +}; diff --git a/frontend/src/components/ui/disabled-reason-button.test.tsx b/frontend/src/components/ui/disabled-reason-button.test.tsx new file mode 100644 index 0000000000..629ce5454b --- /dev/null +++ b/frontend/src/components/ui/disabled-reason-button.test.tsx @@ -0,0 +1,63 @@ +/** + * Copyright 2025 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 { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, test } from 'vitest'; + +import { DisabledReasonButton } from './disabled-reason-button'; + +describe('DisabledReasonButton', () => { + test('renders an enabled button when no reason is given', async () => { + const user = userEvent.setup(); + let clicked = false; + + render( + (clicked = true)} testId="edit"> + Edit + + ); + + await user.click(screen.getByTestId('edit')); + + expect(clicked).toBe(true); + }); + + test('exposes the reason via a tooltip on hover when disabled', async () => { + const user = userEvent.setup(); + + render( + + Edit + + ); + + await user.hover(screen.getByTestId('edit')); + + // role=tooltip is what the E2E assertions rely on; Base UI's popup does not set it itself. + await waitFor(() => expect(screen.getByRole('tooltip')).toHaveTextContent('No committed offset')); + }); + + test('does not fire onClick when disabled', async () => { + const user = userEvent.setup(); + let clicked = false; + + render( + (clicked = true)} reason="No committed offset" testId="edit"> + Edit + + ); + + await user.click(screen.getByTestId('edit')); + + expect(clicked).toBe(false); + }); +}); diff --git a/frontend/src/components/ui/disabled-reason-button.tsx b/frontend/src/components/ui/disabled-reason-button.tsx new file mode 100644 index 0000000000..765d9601a9 --- /dev/null +++ b/frontend/src/components/ui/disabled-reason-button.tsx @@ -0,0 +1,83 @@ +/** + * Copyright 2025 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 { Button, type ButtonProps, buttonVariants } from 'components/redpanda-ui/components/button'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'components/redpanda-ui/components/tooltip'; +import { cn } from 'components/redpanda-ui/lib/utils'; +import type { ReactNode } from 'react'; + +type DisabledReasonButtonProps = Pick & { + reason?: string; + testId?: string; + onClick?: (e: React.MouseEvent) => void; + children: ReactNode; + iconOnly?: boolean; +}; + +/** + * Renders a button that is disabled with an explanatory tooltip when `reason` is set. + * For `iconOnly` (table/heading action) buttons the disabled state renders as a `` + * (matching the legacy IconButton behavior the tests rely on); otherwise a disabled Button. + */ +export const DisabledReasonButton = ({ + reason, + testId, + onClick, + children, + variant = 'ghost', + size = 'icon-sm', + iconOnly = false, + className, +}: DisabledReasonButtonProps) => { + if (!reason) { + return ( + + ); + } + + // Use a hoverable element (span / aria-disabled button) rather than a real `disabled` + // button — disabled elements don't emit pointer events, so the tooltip would never show. + const trigger = iconOnly ? ( + + {children} + + ) : ( + + ); + + return ( + + + + {reason} + + + ); +}; diff --git a/frontend/src/react-query/api/consumer-group.tsx b/frontend/src/react-query/api/consumer-group.tsx new file mode 100644 index 0000000000..cdf42893a2 --- /dev/null +++ b/frontend/src/react-query/api/consumer-group.tsx @@ -0,0 +1,57 @@ +import { useQuery as useTanstackQuery } from '@tanstack/react-query'; +import { config } from 'config'; +import type { GroupDescription } from 'state/rest-interfaces'; + +export type GetConsumerGroupsResponse = { + consumerGroups: GroupDescription[]; +}; + +/** + * Lists consumer groups with full {@link GroupDescription} details (state, members, + * coordinator, topic offsets) and the frontend-derived fields the list table needs. + * + * Uses the legacy REST API because authorization is only possible with Console v3 and above. + * TODO: Remove once Console v3 is released. + */ +export const useLegacyListConsumerGroupsFullQuery = (options?: { enabled?: boolean }) => { + const result = useTanstackQuery({ + queryKey: ['consumer-groups', 'full'], + queryFn: async () => { + const headers: HeadersInit = {}; + if (config.jwt) { + headers.Authorization = `Bearer ${config.jwt}`; + } + + const response = await config.fetch(`${config.restBasePath}/consumer-groups`, { + method: 'GET', + headers, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch consumer groups: ${response.statusText}`); + } + + const data: GetConsumerGroupsResponse = await response.json(); + + // Enrich with the frontend-only fields (mirrors addFrontendFieldsForConsumerGroup in backend-api.ts). + for (const group of data.consumerGroups ?? []) { + group.lagSum = group.topicOffsets.sum((o) => o.summedLag); + group.isInUse = group.state.toLowerCase() !== 'empty'; + if (group.allowedActions && !group.allowedActions.includes('all')) { + group.noEditPerms = !group.allowedActions.includes('editConsumerGroup'); + group.noDeletePerms = !group.allowedActions.includes('deleteConsumerGroup'); + } + } + + return data; + }, + enabled: options?.enabled, + }); + + return { + ...result, + data: { + consumerGroups: result.data?.consumerGroups ?? [], + }, + }; +}; diff --git a/frontend/src/routes/groups/$groupId.tsx b/frontend/src/routes/groups/$groupId.tsx index 76a9e173e9..7a2b8cb540 100644 --- a/frontend/src/routes/groups/$groupId.tsx +++ b/frontend/src/routes/groups/$groupId.tsx @@ -18,6 +18,7 @@ import GroupDetails from '../../components/pages/consumers/group-details'; const searchSchema = z.object({ q: z.string().optional().catch(undefined), withLag: z.coerce.boolean().optional().catch(false), + tab: z.enum(['topics', 'acl']).optional().catch('topics'), }); export type GroupSearchParams = z.infer; diff --git a/frontend/src/routes/groups/index.tsx b/frontend/src/routes/groups/index.tsx index 32e621e7b7..19c28cb07e 100644 --- a/frontend/src/routes/groups/index.tsx +++ b/frontend/src/routes/groups/index.tsx @@ -19,9 +19,5 @@ export const Route = createFileRoute('/groups/')({ title: 'Consumer Groups', icon: FilterIcon, }, - component: GroupListWrapper, + component: GroupList, }); - -function GroupListWrapper() { - return ; -} diff --git a/frontend/tests/test-variant-console-enterprise/playwright.config.ts b/frontend/tests/test-variant-console-enterprise/playwright.config.ts index 7915492fd8..d3360c8261 100644 --- a/frontend/tests/test-variant-console-enterprise/playwright.config.ts +++ b/frontend/tests/test-variant-console-enterprise/playwright.config.ts @@ -10,6 +10,20 @@ const reporters = process.env.CI ? [['github' as const], ['html' as const, { outputFolder: 'playwright-report' }]] : [['list' as const], ['html' as const, { outputFolder: 'playwright-report' }]]; +// Resolve the shadow (destination) backend host port. Local runs remap every +// port dynamically via E2E_PORTS_OVERRIDE (set by run-variant.mjs), so the dest +// backend is NOT on the static 3101 — read its actual port from the override. +// CI keeps the static variant.json ports (no override), so 3101 is correct there. +const portsOverride: Record | null = (() => { + try { + return process.env.E2E_PORTS_OVERRIDE ? JSON.parse(process.env.E2E_PORTS_OVERRIDE) : null; + } catch { + return null; + } +})(); +const shadowBackendPort = portsOverride?.backendDest ?? 3101; +const shadowBackendURL = process.env.REACT_APP_SHADOW_ORIGIN ?? `http://localhost:${shadowBackendPort}`; + /** * Playwright Test configuration for Enterprise (console-enterprise) variant */ @@ -69,8 +83,8 @@ const config = defineConfig({ screenshot: 'off', video: 'off', - /* Shadowlink destination backend URL (port 3101) */ - shadowBackendURL: 'http://localhost:3101', + /* Shadowlink destination backend URL (dynamic port locally, 3101 in CI) */ + shadowBackendURL, }, /* Configure projects */ diff --git a/frontend/tests/test-variant-console/consumers/consumer-group-unconsumed-partitions.spec.ts b/frontend/tests/test-variant-console/consumers/consumer-group-unconsumed-partitions.spec.ts index 5c045fbf75..2d3f7865a7 100644 --- a/frontend/tests/test-variant-console/consumers/consumer-group-unconsumed-partitions.spec.ts +++ b/frontend/tests/test-variant-console/consumers/consumer-group-unconsumed-partitions.spec.ts @@ -18,7 +18,7 @@ import { execRpk, getRedpandaContainerId } from '../../shared/rpk.utils'; * - Partition 1: offset deleted → shows "—" for Group Offset and Lag * - Partition 2: never consumed → shows "—" for Group Offset and Lag * - Edit / Delete buttons on rows 1 and 2 are disabled with tooltip - * "No committed offset". + * "No committed offsets". */ const TOPIC_NAME = `e2e-unconsumed-${Date.now()}`; @@ -49,6 +49,21 @@ function rowByPartition(page: Page, partitionId: number) { .first(); } +/** Must match the disabled reason rendered by `group-details.tsx`. */ +const NO_COMMITTED_OFFSETS = 'No committed offsets'; + +/** + * Hover a partition's Edit button and assert it is disabled with the "no committed offsets" tooltip. + * Closes the tooltip afterwards so the next `getByRole('tooltip')` lookup stays unambiguous. + */ +async function expectNoCommittedOffsetTooltip(page: Page, partitionId: number) { + await page.getByTestId(`partition-edit-${partitionId}`).hover(); + await expect(page.getByRole('tooltip')).toHaveText(NO_COMMITTED_OFFSETS); + + await page.mouse.move(0, 0); + await expect(page.getByRole('tooltip')).toBeHidden(); +} + test.describe('Consumer Group Details - Unconsumed Partitions', () => { test.beforeAll(async () => { const { exec } = await import('node:child_process'); @@ -87,13 +102,13 @@ test.describe('Consumer Group Details - Unconsumed Partitions', () => { await test.step('Navigate to consumer group details page', async () => { await page.goto(`/groups/${GROUP_NAME}`); // Wait for the group name heading to appear - await expect(page.getByText(GROUP_NAME).first()).toBeVisible({ timeout: 15_000 }); + await expect(page.getByText(GROUP_NAME).first()).toBeVisible(); }); await test.step('Ensure the topic accordion is expanded', async () => { // With a single topic the accordion auto-expands, but click if collapsed const accordionButton = page.getByRole('button', { name: new RegExp(TOPIC_NAME) }); - await accordionButton.waitFor({ state: 'visible', timeout: 15_000 }); + await expect(accordionButton).toBeVisible(); const isExpanded = await accordionButton.getAttribute('aria-expanded'); if (isExpanded !== 'true') { @@ -101,12 +116,12 @@ test.describe('Consumer Group Details - Unconsumed Partitions', () => { } // Wait for the data table to appear - await expect(page.getByRole('table').first()).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole('table').first()).toBeVisible(); }); await test.step('All 3 partitions are visible in the table', async () => { for (const partitionId of [0, 1, 2]) { - await expect(rowByPartition(page, partitionId)).toBeVisible({ timeout: 10_000 }); + await expect(rowByPartition(page, partitionId)).toBeVisible(); } }); @@ -120,29 +135,22 @@ test.describe('Consumer Group Details - Unconsumed Partitions', () => { await test.step('Partition 1 shows "—" for Group Offset (offset deleted)', async () => { const row = rowByPartition(page, 1); - await expect(row.locator(`td:nth-child(${COL.GROUP_OFFSET + 1})`)).toHaveText('—', { timeout: 5000 }); - await expect(row.locator(`td:nth-child(${COL.LAG + 1})`)).toHaveText('—', { timeout: 5000 }); + await expect(row.locator(`td:nth-child(${COL.GROUP_OFFSET + 1})`)).toHaveText('—'); + await expect(row.locator(`td:nth-child(${COL.LAG + 1})`)).toHaveText('—'); }); await test.step('Partition 2 shows "—" for Group Offset (never consumed)', async () => { const row = rowByPartition(page, 2); - await expect(row.locator(`td:nth-child(${COL.GROUP_OFFSET + 1})`)).toHaveText('—', { timeout: 5000 }); - await expect(row.locator(`td:nth-child(${COL.LAG + 1})`)).toHaveText('—', { timeout: 5000 }); + await expect(row.locator(`td:nth-child(${COL.GROUP_OFFSET + 1})`)).toHaveText('—'); + await expect(row.locator(`td:nth-child(${COL.LAG + 1})`)).toHaveText('—'); }); await test.step('Edit button on partition 1 (offset deleted) is disabled with tooltip', async () => { - const btn = page.getByTestId('partition-edit-1'); - await expect(btn).toBeVisible({ timeout: 5000 }); - await btn.hover(); - await expect(page.getByRole('tooltip', { name: 'No committed offset' }).first()).toBeVisible({ timeout: 5000 }); - await page.mouse.move(0, 0); + await expectNoCommittedOffsetTooltip(page, 1); }); await test.step('Edit button on partition 2 (never consumed) is disabled with tooltip', async () => { - const btn = page.getByTestId('partition-edit-2'); - await expect(btn).toBeVisible({ timeout: 5000 }); - await btn.hover(); - await expect(page.getByRole('tooltip', { name: 'No committed offset' }).first()).toBeVisible({ timeout: 5000 }); + await expectNoCommittedOffsetTooltip(page, 2); }); }); });