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..b76d83ea5f 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 - + + + } /> + + + + + {group.coordinatorId} + + } - value={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..2581f13c82 100644 --- a/frontend/src/components/pages/consumers/group-list.tsx +++ b/frontend/src/components/pages/consumers/group-list.tsx @@ -9,179 +9,282 @@ * by the Apache License, Version 2.0 */ -import { DataTable, Flex, SearchField, Tag, Text } from '@redpanda-data/ui'; +import { DataTable, Flex, Grid, 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 GroupList: FC = () => { + useLayoutEffect(() => { + setPageHeader('Consumer Groups', [{ title: 'Consumer Groups', linkTo: '/groups' }]); + }, []); -const GroupListContent: FC = () => { - const consumerGroups = useApiStoreHook((s) => s.consumerGroups); - const [quickSearch, setQuickSearch] = useQueryState('q', parseAsString.withDefault('')); + 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} + + Protocol: {protocol} {groupIdEl} - + ); }; 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/redpanda-ui/components/stat.tsx b/frontend/src/components/redpanda-ui/components/stat.tsx new file mode 100644 index 0000000000..e5ef70d66c --- /dev/null +++ b/frontend/src/components/redpanda-ui/components/stat.tsx @@ -0,0 +1,170 @@ +import { cva, type VariantProps } from 'class-variance-authority'; +import { ArrowDown, ArrowUp, ArrowUpRight, Minus } from 'lucide-react'; +import React from 'react'; + +import { cn, type SharedProps } from '../lib/utils'; + +export const statValueVariants = cva('leading-none', { + variants: { + size: { + sm: 'text-sm', + md: 'text-base', + lg: 'font-bold text-2xl tracking-tighter', + }, + tone: { + default: 'text-foreground', + muted: 'text-muted-foreground', + success: 'text-success', + warning: 'text-warning', + destructive: 'text-destructive', + }, + mono: { + true: 'font-mono tabular-nums', + false: '', + }, + }, + defaultVariants: { + size: 'md', + tone: 'default', + mono: false, + }, +}); + +export type StatDeltaDirection = 'up' | 'down' | 'neutral'; + +export interface StatDelta { + /** Formatted change text, e.g. "+12%" or "-3.2k". */ + value: string; + /** Drives the icon and default color. */ + direction: StatDeltaDirection; + /** Override the semantic tone of the delta. Defaults to direction-based color. */ + tone?: 'default' | 'muted' | 'success' | 'warning' | 'destructive'; +} + +const deltaIcons: Record> = { + up: ArrowUp, + down: ArrowDown, + neutral: Minus, +}; + +const deltaToneByDirection: Record> = { + up: 'success', + down: 'destructive', + neutral: 'muted', +}; + +const deltaToneClasses: Record, string> = { + default: 'text-foreground', + muted: 'text-muted-foreground', + success: 'text-success', + warning: 'text-warning', + destructive: 'text-destructive', +}; + +export interface StatProps + extends Omit, 'children'>, + VariantProps, + SharedProps { + /** Caption rendered above the value, uppercased and muted. */ + label: string; + value: React.ReactNode; + /** Secondary line rendered below the value (e.g. "6 partitions"), muted and smaller. */ + sublabel?: React.ReactNode; + delta?: StatDelta; + /** Turns the label into a link. Pass a single link element with no children — label text and trailing arrow are injected and link styling merged in. */ + labelLink?: React.ReactElement<{ className?: string }>; +} + +// Mirrors `labelStrongXSmall` typography so a linked label is visually identical to a plain one. +const LABEL_LINK_CLASSNAME = + 'inline-flex items-center gap-1 font-semibold text-body-sm text-muted-foreground uppercase transition-colors hover:text-foreground'; + +export const Stat = React.forwardRef( + ({ className, label, value, sublabel, size, tone, mono, delta, labelLink, testId, ...props }, ref) => { + const deltaTone = delta ? (delta.tone ?? deltaToneByDirection[delta.direction]) : undefined; + const DeltaIcon = delta ? deltaIcons[delta.direction] : undefined; + + const labelNode = labelLink ? ( + React.cloneElement( + labelLink, + { className: cn(LABEL_LINK_CLASSNAME, labelLink.props.className) }, + <> + {label} +