Skip to content

Commit 9a705e0

Browse files
committed
Show unconsumed partitions in consumer groups
1 parent 8d17cb6 commit 9a705e0

8 files changed

Lines changed: 343 additions & 27 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: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ func TestGetConsumerGroupOffsets_NonExistentTopicInMetadata(t *testing.T) {
184184
req.NoError(err, "Should not error when one topic is deleted")
185185

186186
// Expected result: only the existing topic, with 1 partition and offset at position 1
187+
groupOffset := int64(1)
187188
expected := map[string][]GroupTopicOffsets{
188189
groupID: {
189190
{
@@ -194,7 +195,7 @@ func TestGetConsumerGroupOffsets_NonExistentTopicInMetadata(t *testing.T) {
194195
PartitionOffsets: []PartitionOffsets{
195196
{
196197
PartitionID: 0,
197-
GroupOffset: 1,
198+
GroupOffset: &groupOffset,
198199
HighWaterMark: 1,
199200
Lag: 0,
200201
},
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: 33 additions & 15 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.lag !== 0 || e.isUnconsumed)
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}>
@@ -375,7 +379,7 @@ const GroupByTopics = (groupProps: {
375379
<IconButton
376380
disabledReason={cannotEditGroupReason(groupProps.group, featurePatchGroup)}
377381
onClick={(e) => {
378-
groupProps.onEditOffsets(g.partitions);
382+
groupProps.onEditOffsets(consumedPartitions);
379383
e.stopPropagation();
380384
}}
381385
>
@@ -384,7 +388,7 @@ const GroupByTopics = (groupProps: {
384388
<IconButton
385389
disabledReason={cannotDeleteGroupOffsetsReason(groupProps.group, featureDeleteGroupOffsets)}
386390
onClick={(e) => {
387-
groupProps.onDeleteOffsets(g.partitions, 'topic');
391+
groupProps.onDeleteOffsets(consumedPartitions, 'topic');
388392
e.stopPropagation();
389393
}}
390394
>
@@ -409,13 +413,14 @@ const GroupByTopics = (groupProps: {
409413
<DataTable<{
410414
topicName: string;
411415
partitionId: number;
412-
groupOffset: number;
413-
highWaterMark: number;
414-
lag: number;
416+
groupOffset: number | null;
417+
highWaterMark: number | null;
418+
lag: number | null;
415419
assignedMember: GroupMemberDescription | undefined;
416420
id: string | undefined;
417421
clientId: string | undefined;
418422
host: string | undefined;
423+
isUnconsumed: boolean;
419424
}>
420425
columns={[
421426
{
@@ -458,19 +463,22 @@ const GroupByTopics = (groupProps: {
458463
size: 120,
459464
header: 'Log End Offset',
460465
accessorKey: 'highWaterMark',
461-
cell: ({ row: { original } }) => numberToThousandsString(original.highWaterMark),
466+
cell: ({ row: { original } }) =>
467+
original.highWaterMark !== null ? numberToThousandsString(original.highWaterMark) : '—',
462468
},
463469
{
464470
size: 120,
465471
header: 'Group Offset',
466472
accessorKey: 'groupOffset',
467-
cell: ({ row: { original } }) => numberToThousandsString(original.groupOffset),
473+
cell: ({ row: { original } }) =>
474+
original.groupOffset !== null ? numberToThousandsString(original.groupOffset) : '—',
468475
},
469476
{
470477
size: 80,
471478
header: 'Lag',
472479
accessorKey: 'lag',
473-
cell: ({ row: { original } }) => ShortNum({ value: original.lag, tooltip: true }),
480+
cell: ({ row: { original } }) =>
481+
original.lag !== null ? ShortNum({ value: original.lag, tooltip: true }) : '—',
474482
},
475483
{
476484
size: 1,
@@ -479,13 +487,23 @@ const GroupByTopics = (groupProps: {
479487
cell: ({ row: { original } }) => (
480488
<Flex gap={1} pr={2}>
481489
<IconButton
482-
disabledReason={cannotEditGroupReason(groupProps.group, featurePatchGroup)}
490+
data-testid={`partition-edit-${original.partitionId}`}
491+
disabledReason={
492+
original.isUnconsumed
493+
? 'No committed offset'
494+
: cannotEditGroupReason(groupProps.group, featurePatchGroup)
495+
}
483496
onClick={() => groupProps.onEditOffsets([original])}
484497
>
485498
<EditIcon />
486499
</IconButton>
487500
<IconButton
488-
disabledReason={cannotDeleteGroupOffsetsReason(groupProps.group, featureDeleteGroupOffsets)}
501+
data-testid={`partition-delete-${original.partitionId}`}
502+
disabledReason={
503+
original.isUnconsumed
504+
? 'No committed offset'
505+
: cannotDeleteGroupOffsetsReason(groupProps.group, featureDeleteGroupOffsets)
506+
}
489507
onClick={() => groupProps.onDeleteOffsets([original], 'partition')}
490508
>
491509
<TrashIcon />

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;

frontend/src/utils/tsx-utils.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -665,18 +665,21 @@ export function IconButton(p: {
665665
onClick?: React.MouseEventHandler<HTMLElement>;
666666
children?: React.ReactNode;
667667
disabledReason?: string;
668+
'data-testid'?: string;
668669
}) {
669670
if (!p.disabledReason) {
670671
return (
671-
<button className="iconButton" onClick={p.onClick} type="button">
672+
<button className="iconButton" data-testid={p['data-testid']} onClick={p.onClick} type="button">
672673
{p.children}
673674
</button>
674675
);
675676
}
676677

677678
return (
678679
<Tooltip hasArrow label={p.disabledReason} placement="top">
679-
<span className="iconButton disabled">{p.children}</span>
680+
<span className="iconButton disabled" data-testid={p['data-testid']}>
681+
{p.children}
682+
</span>
680683
</Tooltip>
681684
);
682685
}

0 commit comments

Comments
 (0)