Skip to content

Commit c21853d

Browse files
authored
Merge pull request #2466 from redpanda-data/UX-407-show-unconsumed-partitions-in-consumer-groups
Show unconsumed partitions in consumer groups
2 parents 3c734f5 + dbb14dd commit c21853d

8 files changed

Lines changed: 361 additions & 31 deletions

File tree

backend/pkg/console/consumer_group_offsets.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ type PartitionOffsets struct {
3535
// Error will be set when the high water mark could not be fetched
3636
Error string `json:"error,omitempty"`
3737
PartitionID int32 `json:"partitionId"`
38-
GroupOffset int64 `json:"groupOffset"`
38+
GroupOffset *int64 `json:"groupOffset"` // nil when no committed offset exists for this partition
3939
HighWaterMark int64 `json:"highWaterMark"`
4040
Lag int64 `json:"lag"`
4141
}
@@ -183,6 +183,10 @@ func (s *Service) getConsumerGroupOffsets(ctx context.Context, adminCl *kadm.Cli
183183

184184
groupOffset, hasGroupOffset := partitionOffsets[pID]
185185
if !hasGroupOffset {
186+
t.PartitionOffsets = append(t.PartitionOffsets, PartitionOffsets{
187+
PartitionID: pID,
188+
HighWaterMark: watermark.HighWaterMark,
189+
})
186190
continue
187191
}
188192
t.PartitionsWithOffset++
@@ -193,7 +197,7 @@ func (s *Service) getConsumerGroupOffsets(ctx context.Context, adminCl *kadm.Cli
193197
t.SummedLag += lag
194198
t.PartitionOffsets = append(t.PartitionOffsets, PartitionOffsets{
195199
PartitionID: pID,
196-
GroupOffset: groupOffset,
200+
GroupOffset: &groupOffset,
197201
HighWaterMark: watermark.HighWaterMark,
198202
Lag: lag,
199203
})

backend/pkg/console/consumer_group_offsets_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ func TestGetConsumerGroupOffsets_NonExistentTopicInMetadata(t *testing.T) {
194194
PartitionOffsets: []PartitionOffsets{
195195
{
196196
PartitionID: 0,
197-
GroupOffset: 1,
197+
GroupOffset: new(int64(1)),
198198
HighWaterMark: 1,
199199
Lag: 0,
200200
},
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/**
2+
* Copyright 2026 Redpanda Data, Inc.
3+
*
4+
* Use of this software is governed by the Business Source License
5+
* included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md
6+
*
7+
* As of the Change Date specified in that file, in accordance with
8+
* the Business Source License, use of this software will be governed
9+
* by the Apache License, Version 2.0
10+
*/
11+
12+
import { screen, waitFor } from 'test-utils';
13+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
14+
15+
const { refreshConsumerGroupMock, refreshConsumerGroupAclsMock } = vi.hoisted(() => ({
16+
refreshConsumerGroupMock: vi.fn(),
17+
refreshConsumerGroupAclsMock: vi.fn(),
18+
}));
19+
20+
vi.mock('state/ui-state', () => ({
21+
setPageHeader: vi.fn(),
22+
uiState: {
23+
pageTitle: '',
24+
pageBreadcrumbs: [],
25+
},
26+
}));
27+
28+
vi.mock('state/app-global', () => ({
29+
appGlobal: {
30+
onRefresh: null,
31+
historyPush: vi.fn(),
32+
},
33+
}));
34+
35+
vi.mock('state/backend-api', async (importOriginal) => {
36+
const actual = await importOriginal<typeof import('state/backend-api')>();
37+
return {
38+
...actual,
39+
api: {
40+
...actual.api,
41+
refreshConsumerGroup: refreshConsumerGroupMock,
42+
refreshConsumerGroupAcls: refreshConsumerGroupAclsMock,
43+
refreshPartitionsForTopic: vi.fn(),
44+
},
45+
};
46+
});
47+
48+
vi.mock('config', async (importOriginal) => {
49+
const actual = await importOriginal<typeof import('config')>();
50+
return {
51+
...actual,
52+
config: { jwt: '' },
53+
isFeatureFlagEnabled: vi.fn(() => false),
54+
};
55+
});
56+
57+
import { useApiStore } from 'state/backend-api';
58+
import type { GroupDescription } from 'state/rest-interfaces';
59+
import { useSupportedFeaturesStore } from 'state/supported-features';
60+
import { renderWithFileRoutes } from 'test-utils';
61+
62+
import GroupDetails from './group-details';
63+
64+
const TOPIC = 'test-topic';
65+
const GROUP_ID = 'test-group';
66+
67+
// Backend now returns all partitions in partitionOffsets.
68+
// Partition 0: committed offset 5. Partitions 1 and 2: no committed offset (groupOffset: null).
69+
const mockGroup: GroupDescription = {
70+
groupId: GROUP_ID,
71+
state: 'Empty',
72+
protocol: '',
73+
protocolType: 'consumer',
74+
members: [],
75+
coordinatorId: 0,
76+
topicOffsets: [
77+
{
78+
topic: TOPIC,
79+
summedLag: 1,
80+
partitionCount: 3,
81+
partitionsWithOffset: 1,
82+
partitionOffsets: [
83+
{ partitionId: 0, groupOffset: 5, error: undefined, highWaterMark: 6, lag: 1 },
84+
{ partitionId: 1, groupOffset: null, error: undefined, highWaterMark: 5, lag: 0 },
85+
{ partitionId: 2, groupOffset: null, error: undefined, highWaterMark: 3, lag: 0 },
86+
],
87+
},
88+
],
89+
allowedActions: null,
90+
lagSum: 1,
91+
isInUse: false,
92+
noEditPerms: false,
93+
noDeletePerms: false,
94+
};
95+
96+
const renderGroupDetails = () =>
97+
renderWithFileRoutes(
98+
<GroupDetails groupId={GROUP_ID} matchedPath={`/groups/${GROUP_ID}`} onSearchChange={() => {}} search={{}} />
99+
);
100+
101+
describe('GroupDetails - unconsumed partitions', () => {
102+
beforeEach(() => {
103+
useApiStore.setState({
104+
consumerGroups: new Map([[GROUP_ID, mockGroup]]),
105+
consumerGroupAcls: new Map(),
106+
});
107+
useSupportedFeaturesStore.setState({ patchGroup: true, deleteGroup: true, deleteGroupOffsets: true });
108+
});
109+
110+
afterEach(() => {
111+
useApiStore.setState({ consumerGroups: new Map(), consumerGroupAcls: new Map() });
112+
});
113+
114+
test('edit button is enabled for consumed partition and disabled for unconsumed partitions', async () => {
115+
renderGroupDetails();
116+
117+
await waitFor(() => {
118+
expect(screen.getByTestId('partition-edit-0')).toBeInTheDocument();
119+
});
120+
121+
// Partition 0 has a committed offset → edit button renders as <button>
122+
expect(screen.getByTestId('partition-edit-0').tagName).toBe('BUTTON');
123+
124+
// Partitions 1 and 2 have no committed offset → edit button renders as disabled <span>
125+
expect(screen.getByTestId('partition-edit-1').tagName).toBe('SPAN');
126+
expect(screen.getByTestId('partition-edit-2').tagName).toBe('SPAN');
127+
});
128+
129+
test('unconsumed partitions render "—" for group offset and lag', async () => {
130+
renderGroupDetails();
131+
132+
await waitFor(() => {
133+
expect(screen.getByTestId('partition-edit-1')).toBeInTheDocument();
134+
});
135+
136+
// Each unconsumed partition (1 and 2) shows "—" for both group offset and lag = 4 dashes minimum
137+
const dashes = screen.getAllByText('—');
138+
expect(dashes.length).toBeGreaterThanOrEqual(4);
139+
});
140+
});

frontend/src/components/pages/consumers/group-details.tsx

Lines changed: 52 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -330,18 +330,18 @@ const GroupByTopics = (groupProps: {
330330
const assignedMember = allAssignments.find(
331331
(e) => e.topicName === topicLag.topic && e.partitions.includes(partLag.partitionId)
332332
);
333-
333+
const isUnconsumed = partLag.groupOffset === null;
334334
return {
335335
topicName: topicLag.topic,
336336
partitionId: partLag.partitionId,
337337
groupOffset: partLag.groupOffset,
338-
highWaterMark: partLag.highWaterMark,
339-
lag: partLag.lag,
340-
338+
highWaterMark: partLag.highWaterMark as number | null,
339+
lag: isUnconsumed ? null : (partLag.lag as number | null),
341340
assignedMember: assignedMember?.member,
342341
id: assignedMember?.member.id,
343342
clientId: assignedMember?.member.clientId,
344343
host: assignedMember?.member.clientHost,
344+
isUnconsumed,
345345
};
346346
})
347347
);
@@ -356,12 +356,16 @@ const GroupByTopics = (groupProps: {
356356
const totalLagAll = g.partitions.sum((c) => c.lag ?? 0);
357357
const partitionsAssigned = g.partitions.filter((c) => c.assignedMember).length;
358358

359-
const partitions = groupProps.onlyShowPartitionsWithLag ? g.partitions.filter((e) => e.lag !== 0) : g.partitions;
359+
const partitions = groupProps.onlyShowPartitionsWithLag
360+
? g.partitions.filter((e) => e.isUnconsumed || (e.lag !== null && e.lag !== 0))
361+
: g.partitions;
360362

361363
if (partitions.length === 0) {
362364
return null;
363365
}
364366

367+
const consumedPartitions = g.partitions.filter((p) => !p.isUnconsumed);
368+
365369
return {
366370
heading: (
367371
<Flex flexDirection="column" gap={4}>
@@ -373,18 +377,22 @@ const GroupByTopics = (groupProps: {
373377

374378
<Flex gap={2}>
375379
<IconButton
376-
disabledReason={cannotEditGroupReason(groupProps.group, featurePatchGroup)}
380+
disabledReason={cannotEditGroupReason(groupProps.group, featurePatchGroup, consumedPartitions)}
377381
onClick={(e) => {
378-
groupProps.onEditOffsets(g.partitions);
382+
groupProps.onEditOffsets(consumedPartitions);
379383
e.stopPropagation();
380384
}}
381385
>
382386
<EditIcon />
383387
</IconButton>
384388
<IconButton
385-
disabledReason={cannotDeleteGroupOffsetsReason(groupProps.group, featureDeleteGroupOffsets)}
389+
disabledReason={cannotDeleteGroupOffsetsReason(
390+
groupProps.group,
391+
featureDeleteGroupOffsets,
392+
consumedPartitions
393+
)}
386394
onClick={(e) => {
387-
groupProps.onDeleteOffsets(g.partitions, 'topic');
395+
groupProps.onDeleteOffsets(consumedPartitions, 'topic');
388396
e.stopPropagation();
389397
}}
390398
>
@@ -409,13 +417,14 @@ const GroupByTopics = (groupProps: {
409417
<DataTable<{
410418
topicName: string;
411419
partitionId: number;
412-
groupOffset: number;
413-
highWaterMark: number;
414-
lag: number;
420+
groupOffset: number | null;
421+
highWaterMark: number | null;
422+
lag: number | null;
415423
assignedMember: GroupMemberDescription | undefined;
416424
id: string | undefined;
417425
clientId: string | undefined;
418426
host: string | undefined;
427+
isUnconsumed: boolean;
419428
}>
420429
columns={[
421430
{
@@ -458,19 +467,22 @@ const GroupByTopics = (groupProps: {
458467
size: 120,
459468
header: 'Log End Offset',
460469
accessorKey: 'highWaterMark',
461-
cell: ({ row: { original } }) => numberToThousandsString(original.highWaterMark),
470+
cell: ({ row: { original } }) =>
471+
original.highWaterMark !== null ? numberToThousandsString(original.highWaterMark) : '—',
462472
},
463473
{
464474
size: 120,
465475
header: 'Group Offset',
466476
accessorKey: 'groupOffset',
467-
cell: ({ row: { original } }) => numberToThousandsString(original.groupOffset),
477+
cell: ({ row: { original } }) =>
478+
original.groupOffset !== null ? numberToThousandsString(original.groupOffset) : '—',
468479
},
469480
{
470481
size: 80,
471482
header: 'Lag',
472483
accessorKey: 'lag',
473-
cell: ({ row: { original } }) => ShortNum({ value: original.lag, tooltip: true }),
484+
cell: ({ row: { original } }) =>
485+
original.lag !== null ? ShortNum({ value: original.lag, tooltip: true }) : '—',
474486
},
475487
{
476488
size: 1,
@@ -479,13 +491,23 @@ const GroupByTopics = (groupProps: {
479491
cell: ({ row: { original } }) => (
480492
<Flex gap={1} pr={2}>
481493
<IconButton
482-
disabledReason={cannotEditGroupReason(groupProps.group, featurePatchGroup)}
494+
data-testid={`partition-edit-${original.partitionId}`}
495+
disabledReason={cannotEditGroupReason(
496+
groupProps.group,
497+
featurePatchGroup,
498+
original.isUnconsumed ? [] : undefined
499+
)}
483500
onClick={() => groupProps.onEditOffsets([original])}
484501
>
485502
<EditIcon />
486503
</IconButton>
487504
<IconButton
488-
disabledReason={cannotDeleteGroupOffsetsReason(groupProps.group, featureDeleteGroupOffsets)}
505+
data-testid={`partition-delete-${original.partitionId}`}
506+
disabledReason={cannotDeleteGroupOffsetsReason(
507+
groupProps.group,
508+
featureDeleteGroupOffsets,
509+
original.isUnconsumed ? [] : undefined
510+
)}
489511
onClick={() => groupProps.onDeleteOffsets([original], 'partition')}
490512
>
491513
<TrashIcon />
@@ -612,7 +634,14 @@ const ProtocolType = (p: { group: GroupDescription }) => {
612634
return <Statistic title="Protocol" value={protocol} />;
613635
};
614636

615-
function cannotEditGroupReason(group: GroupDescription, featurePatchGroup: boolean): string | undefined {
637+
function cannotEditGroupReason(
638+
group: GroupDescription,
639+
featurePatchGroup: boolean,
640+
consumedPartitions?: readonly unknown[]
641+
): string | undefined {
642+
if (consumedPartitions !== undefined && consumedPartitions.length === 0) {
643+
return 'No committed offsets';
644+
}
616645
if (group.noEditPerms) {
617646
return "You don't have 'editConsumerGroup' permissions for this group";
618647
}
@@ -638,8 +667,12 @@ function cannotDeleteGroupReason(group: GroupDescription, featureDeleteGroup: bo
638667

639668
function cannotDeleteGroupOffsetsReason(
640669
group: GroupDescription,
641-
featureDeleteGroupOffsets: boolean
670+
featureDeleteGroupOffsets: boolean,
671+
consumedPartitions?: readonly unknown[]
642672
): string | undefined {
673+
if (consumedPartitions !== undefined && consumedPartitions.length === 0) {
674+
return 'No committed offsets';
675+
}
643676
if (group.noEditPerms) {
644677
return "You don't have 'deleteConsumerGroup' permissions for this group";
645678
}

frontend/src/components/pages/consumers/modals.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -537,7 +537,7 @@ export class EditOffsetsModal extends Component<{
537537
const getOffset = (topicName: string, partitionId: number): number | undefined =>
538538
other.topicOffsets
539539
.first((t) => t.topic === topicName)
540-
?.partitionOffsets.first((p) => p.partitionId === partitionId)?.groupOffset;
540+
?.partitionOffsets.first((p) => p.partitionId === partitionId)?.groupOffset ?? undefined;
541541

542542
const currentOffsets = this.props.offsets;
543543
const alreadyExists = (topicName: string, partitionId: number): boolean =>
@@ -553,11 +553,13 @@ export class EditOffsetsModal extends Component<{
553553
// Extend our offsets with any offsets that our group currently doesn't have
554554
if (this.state.otherGroupCopyMode === 'all') {
555555
const otherFlat = other.topicOffsets.flatMap((x) =>
556-
x.partitionOffsets.flatMap((p) => ({
557-
topicName: x.topic,
558-
partitionId: p.partitionId,
559-
offset: p.groupOffset,
560-
}))
556+
x.partitionOffsets
557+
.filter((p) => p.groupOffset !== null)
558+
.flatMap((p) => ({
559+
topicName: x.topic,
560+
partitionId: p.partitionId,
561+
offset: p.groupOffset as number,
562+
}))
561563
);
562564

563565
for (const o of otherFlat) {

frontend/src/state/rest-interfaces.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -366,7 +366,7 @@ export type GroupTopicOffsets = {
366366
// PartitionOffset describes the kafka lag for a partition for a single consumer group
367367
export type GroupPartitionOffset = {
368368
partitionId: number;
369-
groupOffset: number;
369+
groupOffset: number | null; // null when no committed offset exists for this partition
370370

371371
error: string | undefined; // Error will be set when the high water mark could not be fetched
372372
highWaterMark: number;

0 commit comments

Comments
 (0)