Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
769 changes: 398 additions & 371 deletions frontend/src/components/pages/consumers/group-details.tsx

Large diffs are not rendered by default.

460 changes: 315 additions & 145 deletions frontend/src/components/pages/consumers/group-list.tsx

Large diffs are not rendered by default.

1,031 changes: 502 additions & 529 deletions frontend/src/components/pages/consumers/modals.tsx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -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<StateIconKey, ReactNode> = {
stable: <CheckCircleIcon className="text-success" size={16} />,
completingrebalance: <HourglassIcon className="text-success" size={16} />,
preparingrebalance: <HourglassIcon className="text-warning" size={16} />,
empty: <WarningIcon className="text-warning" size={16} />,
dead: <FlameIcon className="text-destructive" size={16} />,
unknown: <HelpIcon size={16} />,
};

export const consumerGroupStateNames: Record<StateIconKey, string> = {
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<StateIconKey, string> = {
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 (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<span className="inline-flex items-center gap-2">
{stateIcons[key]}
<span>{state}</span>
</span>
}
/>
<TooltipContent>{stateDescriptions[key]}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
63 changes: 63 additions & 0 deletions frontend/src/components/ui/disabled-reason-button.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<DisabledReasonButton onClick={() => (clicked = true)} testId="edit">
Edit
</DisabledReasonButton>
);

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(
<DisabledReasonButton iconOnly reason="No committed offset" testId="edit">
Edit
</DisabledReasonButton>
);

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(
<DisabledReasonButton onClick={() => (clicked = true)} reason="No committed offset" testId="edit">
Edit
</DisabledReasonButton>
);

await user.click(screen.getByTestId('edit'));

expect(clicked).toBe(false);
});
});
81 changes: 81 additions & 0 deletions frontend/src/components/ui/disabled-reason-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* 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 } 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<ButtonProps, 'variant' | 'size' | 'className'> & {
reason?: string;
testId?: string;
onClick?: (e: React.MouseEvent<HTMLElement>) => 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 `<span>`
* (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 (
<Button className={className} data-testid={testId} onClick={onClick} size={size} variant={variant}>
{children}
</Button>
);
}

// 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 ? (
<span
className={cn(
'inline-flex h-8 w-8 cursor-not-allowed items-center justify-center text-muted-foreground opacity-50',
className
)}
data-testid={testId}
>
{children}
</span>
) : (
<Button
aria-disabled
className={cn('cursor-not-allowed opacity-50', className)}
data-testid={testId}
onClick={(e) => e.preventDefault()}
size={size}
variant={variant}
>
{children}
</Button>
);

return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={trigger} />
<TooltipContent role="tooltip">{reason}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
51 changes: 51 additions & 0 deletions frontend/src/react-query/api/consumer-group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,57 @@ export const useLegacyListConsumerGroupsQuery = (options?: { enabled?: boolean }
};
};

/**
* 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 (same reason as {@link useLegacyListConsumerGroupsQuery}):
* authorization is only possible with Console v3 and above.
* TODO: Remove once Console v3 is released.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it looks like we are still using this despite console v3 being released a long time ago, should we clean it up?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a separate ticket for this.

*/
export const useLegacyListConsumerGroupsFullQuery = (options?: { enabled?: boolean }) => {
const result = useTanstackQuery<GetConsumerGroupsResponse>({
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 ?? [],
},
};
};

export type GetConsumerGroupResponse = {
consumerGroup: GroupDescription;
};
Expand Down
1 change: 1 addition & 0 deletions frontend/src/routes/groups/$groupId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import GroupDetails from '../../components/pages/consumers/group-details';
const searchSchema = z.object({
q: fallback(z.string().optional(), undefined),
withLag: fallback(z.coerce.boolean().optional(), false),
tab: fallback(z.enum(['topics', 'acl']).optional(), 'topics'),
});

export type GroupSearchParams = z.infer<typeof searchSchema>;
Expand Down
6 changes: 1 addition & 5 deletions frontend/src/routes/groups/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,5 @@ export const Route = createFileRoute('/groups/')({
title: 'Consumer Groups',
icon: FilterIcon,
},
component: GroupListWrapper,
component: GroupList,
});

function GroupListWrapper() {
return <GroupList matchedPath="/groups" />;
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,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<string, number> | 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
*/
Expand Down Expand Up @@ -67,8 +81,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,
} as any,

/* Configure projects */
Expand Down
Loading
Loading