From 4071551c1a02c11ee283d5b37170ef92d4d5a774 Mon Sep 17 00:00:00 2001 From: Michael Edgar Date: Tue, 5 May 2026 10:19:46 -0400 Subject: [PATCH] Fixes and Enhancements to group reset functionality Assisted-by: IBM Bob Signed-off-by: Michael Edgar --- api/src/main/webui/src/api/types.ts | 31 +-- .../groups/ResetOffset/DryRunResults.tsx | 45 ++-- .../ResetOffset/OffsetValueSelector.tsx | 202 +++++++++++------- .../groups/ResetOffset/ResetOffsetModal.tsx | 171 ++++++++------- .../ResetOffset/TopicPartitionSelector.tsx | 142 +++++------- .../kafka/groups/ResetOffset/types.ts | 30 +++ api/src/main/webui/src/i18n/messages/en.json | 55 ++--- .../webui/src/pages/kafka/KafkaLayout.tsx | 15 +- .../src/pages/kafka/groups/GroupsPage.tsx | 11 + .../kafka/groups/detail/GroupDetailPage.tsx | 17 +- .../webui/src/utils/cliCommandGenerator.ts | 17 +- .../main/webui/src/utils/offsetValidation.ts | 77 +++---- 12 files changed, 423 insertions(+), 390 deletions(-) create mode 100644 api/src/main/webui/src/components/kafka/groups/ResetOffset/types.ts diff --git a/api/src/main/webui/src/api/types.ts b/api/src/main/webui/src/api/types.ts index ad6dd1b8a..4812b70e3 100644 --- a/api/src/main/webui/src/api/types.ts +++ b/api/src/main/webui/src/api/types.ts @@ -759,12 +759,7 @@ export interface UserResponse { data: KafkaUser; } -// Reset Offset types -export type OffsetValue = 'custom' | 'latest' | 'earliest' | 'specificDateTime' | 'delete'; -export type DateTimeFormat = 'ISO' | 'Epoch'; -export type TopicSelection = 'allTopics' | 'selectedTopic'; -export type PartitionSelection = 'allPartitions' | 'selectedPartition'; - +// Reset Offset API types export interface OffsetResetRequest { topicId: string; partition?: number; @@ -778,28 +773,4 @@ export interface OffsetResetResult { partition: number; offset: number | null; metadata?: string; -} - -export interface ResetOffsetFormState { - topicSelection: TopicSelection; - selectedTopicId?: string; - selectedTopicName?: string; - partitionSelection: PartitionSelection; - selectedPartition?: number; - offsetValue: OffsetValue; - customOffset?: number; - dateTime?: string; - dateTimeFormat: DateTimeFormat; -} - -export type ResetOffsetErrorType = - | 'CustomOffsetError' - | 'PartitionError' - | 'KafkaError' - | 'SpecificDateTimeNotValidError' - | 'GeneralError'; - -export interface ResetOffsetError { - type: ResetOffsetErrorType; - message: string; } \ No newline at end of file diff --git a/api/src/main/webui/src/components/kafka/groups/ResetOffset/DryRunResults.tsx b/api/src/main/webui/src/components/kafka/groups/ResetOffset/DryRunResults.tsx index 398b7085e..07aa1e3ff 100644 --- a/api/src/main/webui/src/components/kafka/groups/ResetOffset/DryRunResults.tsx +++ b/api/src/main/webui/src/components/kafka/groups/ResetOffset/DryRunResults.tsx @@ -10,6 +10,9 @@ import { Button, Alert, AlertVariant, + ModalHeader, + ModalBody, + ModalFooter, } from '@patternfly/react-core'; import { Table, @@ -19,25 +22,31 @@ import { Tbody, Td, } from '@patternfly/react-table'; -import { OffsetResetResult } from '@/api/types'; +import { OffsetAndMetadata, OffsetResetResult } from '@/api/types'; +import { CliCommandDisplay } from './CliCommandDisplay'; interface DryRunResultsProps { isOpen: boolean; results: OffsetResetResult[]; + currentOffsets?: OffsetAndMetadata[] | null; + command: string; onClose: () => void; - onApply: () => void; - isApplying?: boolean; } export function DryRunResults({ isOpen, results, + currentOffsets, + command, onClose, - onApply, - isApplying = false, }: DryRunResultsProps) { const { t } = useTranslation(); + const currentOffsetsByPartition = (currentOffsets || []).reduce((acc, offset) => { + acc[`${offset.topicName}-${offset.partition}`] = offset.offset; + return acc; + }, {} as Record); + // Group results by topic const resultsByTopic = results.reduce((acc, result) => { const topicName = result.topicName; @@ -50,12 +59,12 @@ export function DryRunResults({ return ( -
+ + {t('groups.resetOffset.dryRunResults.partition')} + {t('groups.resetOffset.dryRunResults.currentOffset')} {t('groups.resetOffset.dryRunResults.newOffset')} @@ -88,6 +98,9 @@ export function DryRunResults({ {result.partition} + + {currentOffsetsByPartition[`${result.topicName}-${result.partition}`] ?? t('common.notAvailable')} + {result.offset !== null ? result.offset @@ -100,24 +113,16 @@ export function DryRunResults({
)) )} - -
- + + + -
+
); } \ No newline at end of file diff --git a/api/src/main/webui/src/components/kafka/groups/ResetOffset/OffsetValueSelector.tsx b/api/src/main/webui/src/components/kafka/groups/ResetOffset/OffsetValueSelector.tsx index 2d87ba89d..f1b550e7d 100644 --- a/api/src/main/webui/src/components/kafka/groups/ResetOffset/OffsetValueSelector.tsx +++ b/api/src/main/webui/src/components/kafka/groups/ResetOffset/OffsetValueSelector.tsx @@ -13,26 +13,33 @@ import { SelectOption, MenuToggle, MenuContainer, - Radio, HelperText, HelperTextItem, FormHelperText, + Button, + FormSelect, + FormSelectOption, } from '@patternfly/react-core'; import { ExclamationCircleIcon } from '@patternfly/react-icons'; import { useState, useRef } from 'react'; -import { OffsetValue, DateTimeFormat, TopicSelection, PartitionSelection } from '@/api/types'; +import { formatDateTime } from '@/utils/dateTime'; +import { + OffsetValue, + TopicSelection, + PartitionSelection, +} from './types'; interface OffsetValueSelectorProps { - offsetValue: OffsetValue; + offsetValue?: OffsetValue; customOffset?: number; dateTime?: string; - dateTimeFormat: DateTimeFormat; + dateTimeDisplayMode?: 'utc' | 'local'; topicSelection: TopicSelection; partitionSelection: PartitionSelection; - onOffsetValueChange: (value: OffsetValue) => void; + onOffsetValueChange: (value?: OffsetValue) => void; onCustomOffsetChange: (value: number) => void; onDateTimeChange: (value: string) => void; - onDateTimeFormatChange: (format: DateTimeFormat) => void; + onDateTimeDisplayModeChange: (mode: 'utc' | 'local') => void; errors?: { customOffset?: string; dateTime?: string; @@ -43,13 +50,13 @@ export function OffsetValueSelector({ offsetValue, customOffset, dateTime, - dateTimeFormat, + dateTimeDisplayMode = 'utc', topicSelection, partitionSelection, onOffsetValueChange, onCustomOffsetChange, onDateTimeChange, - onDateTimeFormatChange, + onDateTimeDisplayModeChange, errors, }: OffsetValueSelectorProps) { const { t } = useTranslation(); @@ -76,8 +83,12 @@ export function OffsetValueSelector({ { value: 'earliest', label: t('groups.resetOffset.offset.earliest') }, { value: 'latest', label: t('groups.resetOffset.offset.latest') }, { - value: 'specificDateTime', - label: t('groups.resetOffset.offset.specificDateTime'), + value: 'dateTimeIso', + label: t('groups.resetOffset.offset.dateTimeIso'), + }, + { + value: 'dateTimeEpoch', + label: t('groups.resetOffset.offset.dateTimeEpoch'), } ); @@ -109,6 +120,7 @@ export function OffsetValueSelector({ onClick={() => setIsOffsetSelectOpen(!isOffsetSelectOpen)} isExpanded={isOffsetSelectOpen} isFullWidth + isPlaceholder={!offsetValue} > {selectedOffsetLabel} @@ -129,6 +141,44 @@ export function OffsetValueSelector({ ); + const formatLocalDateTime = (value: Date | string) => + formatDateTime({ + value, + format: "yyyy-MM-dd'T'HH:mm:ss.SSSXXX", + }); + + const handleUseCurrentDateTime = () => { + if (offsetValue === 'dateTimeEpoch') { + onDateTimeChange(Date.now().toString()); + return; + } + + if (offsetValue === 'dateTimeIso') { + const now = new Date(); + onDateTimeChange( + dateTimeDisplayMode === 'local' + ? formatLocalDateTime(now) + : now.toISOString() + ); + } + }; + + const handleDateTimeDisplayModeChange = (mode: 'utc' | 'local') => { + if (dateTime) { + const parsedDate = new Date(dateTime); + + if (!isNaN(parsedDate.getTime())) { + onDateTimeChange( + mode === 'local' + ? formatLocalDateTime(parsedDate) + : parsedDate.toISOString() + ); + } + } + + onDateTimeDisplayModeChange(mode); + }; + return ( <> )} - {offsetValue === 'specificDateTime' && ( - <> - - onDateTimeFormatChange('ISO')} - /> - onDateTimeFormatChange('Epoch')} - /> - - - - onDateTimeChange(value)} - placeholder={ - dateTimeFormat === 'ISO' - ? t('groups.resetOffset.dateTimePlaceholder') - : t('groups.resetOffset.epochPlaceholder') - } - validated={errors?.dateTime ? 'error' : 'default'} - /> - {!errors?.dateTime && ( - - + {(offsetValue === 'dateTimeIso' || offsetValue === 'dateTimeEpoch') && ( + +
+ {offsetValue === 'dateTimeIso' && ( +
+ + handleDateTimeDisplayModeChange(value as 'utc' | 'local') + } + aria-label={t('groups.resetOffset.timeZoneMode')} + > + + + +
+ )} +
+ onDateTimeChange(value)} + placeholder={ + offsetValue === 'dateTimeIso' + ? t('groups.resetOffset.dateTimePlaceholder') + : t('groups.resetOffset.epochPlaceholder') + } + validated={errors?.dateTime ? 'error' : 'default'} + /> +
+ +
+ {!errors?.dateTime && ( + + + {offsetValue === 'dateTimeIso' ? ( - {dateTimeFormat === 'ISO' - ? t('groups.resetOffset.dateTimeHelper') - : t('groups.resetOffset.epochHelper')} + {dateTimeDisplayMode === 'local' + ? t('groups.resetOffset.dateTimeHelperLocal') + : t('groups.resetOffset.dateTimeHelperUtc')} - - - )} - {errors?.dateTime && ( - - - } variant="error"> - {errors.dateTime} + ) : ( + + {t('groups.resetOffset.epochHelper')} - - - )} -
- + )} +
+
+ )} + {errors?.dateTime && ( + + + } variant="error"> + {errors.dateTime} + + + + )} +
)} ); diff --git a/api/src/main/webui/src/components/kafka/groups/ResetOffset/ResetOffsetModal.tsx b/api/src/main/webui/src/components/kafka/groups/ResetOffset/ResetOffsetModal.tsx index 21a324ae6..8c1e0e532 100644 --- a/api/src/main/webui/src/components/kafka/groups/ResetOffset/ResetOffsetModal.tsx +++ b/api/src/main/webui/src/components/kafka/groups/ResetOffset/ResetOffsetModal.tsx @@ -13,19 +13,14 @@ import { FormSection, Alert, AlertVariant, + ModalHeader, + ModalBody, + ModalFooter, } from '@patternfly/react-core'; -import { - ResetOffsetFormState, - TopicSelection, - PartitionSelection, - OffsetValue, - DateTimeFormat, - Group, - OffsetResetResult, -} from '@/api/types'; +import { Group, OffsetResetResult } from '@/api/types'; +import { OffsetValue, ResetOffsetFormState } from './types'; import { TopicPartitionSelector } from './TopicPartitionSelector'; import { OffsetValueSelector } from './OffsetValueSelector'; -import { CliCommandDisplay } from './CliCommandDisplay'; import { DryRunResults } from './DryRunResults'; import { useResetGroupOffsets } from '@/api/hooks/useGroups'; import { @@ -34,13 +29,15 @@ import { isFormValid, } from '@/utils/offsetValidation'; import { generateCliCommand } from '@/utils/cliCommandGenerator'; +import { ApiError } from '@/api/client'; +import { WrenchIcon } from '@patternfly/react-icons'; interface ResetOffsetModalProps { isOpen: boolean; group: Group; kafkaId: string; onClose: () => void; - onSuccess?: () => void; + onSuccess?: (message?: string) => void; } export function ResetOffsetModal({ @@ -54,16 +51,12 @@ export function ResetOffsetModal({ const resetMutation = useResetGroupOffsets(kafkaId, group.id); // Form state - const [formState, setFormState] = useState({ - topicSelection: 'allTopics', - partitionSelection: 'allPartitions', - offsetValue: 'latest', - dateTimeFormat: 'ISO', - }); + const [formState, setFormState] = useState({}); // Dry run state const [showDryRun, setShowDryRun] = useState(false); const [dryRunResults, setDryRunResults] = useState([]); + const [responseError, setResponseError] = useState(); // Derive topics from group offsets const topics = useMemo(() => { @@ -123,6 +116,14 @@ export function ResetOffsetModal({ const formIsValid = isFormValid(formState, selectedTopicPartitions); + const resetModalState = () => { + setFormState({}); + setShowDryRun(false); + setDryRunResults([]); + setResponseError(undefined); + resetMutation.reset(); + }; + // Generate CLI command const cliCommand = useMemo(() => { const selectedTopic = topics.find((t) => t.id === formState.selectedTopicId); @@ -133,48 +134,42 @@ export function ResetOffsetModal({ ); }, [group.attributes.groupId, formState, topics]); - // Handlers - const handleTopicSelectionChange = (selection: TopicSelection) => { - setFormState((prev) => ({ - ...prev, - topicSelection: selection, - selectedTopicId: undefined, - selectedTopicName: undefined, - selectedPartition: undefined, - })); - }; + const getResponseErrorMessage = ( + responseErrors?: Array<{ title: string; detail?: string }> + ) => responseErrors?.map((error) => error.detail || error.title).join(' ') || 'Unknown error'; - const handlePartitionSelectionChange = (selection: PartitionSelection) => { - setFormState((prev) => ({ - ...prev, - partitionSelection: selection, - selectedPartition: undefined, - })); - }; + // Handlers + const handleTopicChange = (topicId?: string) => { + if (!topicId) { + setFormState((prev) => ({ + ...prev, + selectedTopicId: undefined, + selectedPartition: undefined, + })); + return; + } - const handleTopicChange = (topicId: string) => { - const topic = topics.find((t) => t.id === topicId); setFormState((prev) => ({ ...prev, selectedTopicId: topicId, - selectedTopicName: topic?.name, selectedPartition: undefined, })); }; - const handlePartitionChange = (partition: number) => { + const handlePartitionChange = (partition?: number) => { setFormState((prev) => ({ ...prev, selectedPartition: partition, })); }; - const handleOffsetValueChange = (value: OffsetValue) => { + const handleOffsetValueChange = (value?: OffsetValue) => { setFormState((prev) => ({ ...prev, offsetValue: value, customOffset: undefined, dateTime: undefined, + dateTimeDisplayMode: value === 'dateTimeIso' ? 'utc' : undefined, })); }; @@ -192,25 +187,30 @@ export function ResetOffsetModal({ })); }; - const handleDateTimeFormatChange = (format: DateTimeFormat) => { + const handleDateTimeDisplayModeChange = (mode: 'utc' | 'local') => { setFormState((prev) => ({ ...prev, - dateTimeFormat: format, - dateTime: undefined, + dateTimeDisplayMode: mode, })); }; const handleDryRun = async () => { const requests = generateOffsetRequests(formState, allPartitions); - + setResponseError(undefined); + try { const response = await resetMutation.mutateAsync({ offsets: requests, dryRun: true, }); + if (response.errors?.length) { + setResponseError(getResponseErrorMessage(response.errors)); + setShowDryRun(false); + return; + } + if (response.data) { - // Extract dry run results from response const results = response.data.attributes.offsets || []; setDryRunResults(results); setShowDryRun(true); @@ -222,60 +222,64 @@ export function ResetOffsetModal({ const handleApply = async () => { const requests = generateOffsetRequests(formState, allPartitions); - + setResponseError(undefined); + setShowDryRun(false); + try { - await resetMutation.mutateAsync({ + const response = await resetMutation.mutateAsync({ offsets: requests, dryRun: false, }); - onSuccess?.(); + if (response.errors?.length) { + setResponseError(getResponseErrorMessage(response.errors)); + return; + } + + resetModalState(); onClose(); + onSuccess?.(t('groups.resetOffset.success', { groupId: group.attributes.groupId })); } catch (error) { console.error('Reset failed:', error); } }; - const handleReset = () => { - setFormState({ - topicSelection: 'allTopics', - partitionSelection: 'allPartitions', - offsetValue: 'latest', - dateTimeFormat: 'ISO', - }); - }; - return ( <> { + resetModalState(); + onClose(); + }} > -
- {resetMutation.isError && ( + + + {(responseError || resetMutation.isError) && ( - {resetMutation.error?.message || 'Unknown error'} + {responseError ?? + getResponseErrorMessage((resetMutation.error as ApiError)?.errors) ?? + resetMutation.error?.message ?? + 'Unknown error'} )} -
- + + @@ -286,13 +290,13 @@ export function ResetOffsetModal({ offsetValue={formState.offsetValue} customOffset={formState.customOffset} dateTime={formState.dateTime} - dateTimeFormat={formState.dateTimeFormat} - topicSelection={formState.topicSelection} - partitionSelection={formState.partitionSelection} + dateTimeDisplayMode={formState.dateTimeDisplayMode} + topicSelection={formState.selectedTopicId ? 'selectedTopic' : 'allTopics'} + partitionSelection={formState.selectedPartition !== undefined ? 'selectedPartition' : 'allPartitions'} onOffsetValueChange={handleOffsetValueChange} onCustomOffsetChange={handleCustomOffsetChange} onDateTimeChange={handleDateTimeChange} - onDateTimeFormatChange={handleDateTimeFormatChange} + onDateTimeDisplayModeChange={handleDateTimeDisplayModeChange} errors={{ customOffset: errors.CustomOffsetError, dateTime: errors.SpecificDateTimeNotValidError, @@ -300,11 +304,9 @@ export function ResetOffsetModal({ /> - -
- -
+ + - - -
+
setShowDryRun(false)} - onApply={handleApply} - isApplying={resetMutation.isPending} /> ); diff --git a/api/src/main/webui/src/components/kafka/groups/ResetOffset/TopicPartitionSelector.tsx b/api/src/main/webui/src/components/kafka/groups/ResetOffset/TopicPartitionSelector.tsx index bf6ae9cdf..424e493d8 100644 --- a/api/src/main/webui/src/components/kafka/groups/ResetOffset/TopicPartitionSelector.tsx +++ b/api/src/main/webui/src/components/kafka/groups/ResetOffset/TopicPartitionSelector.tsx @@ -6,7 +6,6 @@ import { useTranslation } from 'react-i18next'; import { FormGroup, - Radio, Menu, MenuList, MenuContent, @@ -18,30 +17,21 @@ import { MenuContainer, } from '@patternfly/react-core'; import { useState, useRef } from 'react'; -import { TopicSelection, PartitionSelection } from '@/api/types'; interface TopicPartitionSelectorProps { topics: Array<{ id: string; name: string }>; partitions: number[]; - topicSelection: TopicSelection; - partitionSelection: PartitionSelection; selectedTopicId?: string; selectedPartition?: number; - onTopicSelectionChange: (selection: TopicSelection) => void; - onPartitionSelectionChange: (selection: PartitionSelection) => void; - onTopicChange: (topicId: string) => void; - onPartitionChange: (partition: number) => void; + onTopicChange: (topicId?: string) => void; + onPartitionChange: (partition?: number) => void; } export function TopicPartitionSelector({ topics, partitions, - topicSelection, - partitionSelection, selectedTopicId, selectedPartition, - onTopicSelectionChange, - onPartitionSelectionChange, onTopicChange, onPartitionChange, }: TopicPartitionSelectorProps) { @@ -97,8 +87,9 @@ export function TopicPartitionSelector({ onClick={() => setIsTopicSelectOpen(!isTopicSelectOpen)} isExpanded={isTopicSelectOpen} isFullWidth + isDisabled={topics.length === 0} > - {selectedTopic?.name || t('groups.resetOffset.selectTopicPlaceholder')} + {selectedTopic?.name || t('groups.resetOffset.allTopics')} ); @@ -106,11 +97,11 @@ export function TopicPartitionSelector({ { - onTopicChange(itemId as string); + onTopicChange(itemId as string | undefined); setIsTopicSelectOpen(false); setTopicFilter(''); }} - activeItemId={selectedTopicId} + activeItemId={selectedTopicId ?? ''} isScrollable > @@ -127,7 +118,12 @@ export function TopicPartitionSelector({ - {topicMenuItems} + + + {t('groups.resetOffset.allTopics')} + + {topicMenuItems} + ); @@ -138,10 +134,11 @@ export function TopicPartitionSelector({ onClick={() => setIsPartitionSelectOpen(!isPartitionSelectOpen)} isExpanded={isPartitionSelectOpen} isFullWidth + isDisabled={!selectedTopicId} > {selectedPartition !== undefined ? selectedPartition.toString() - : t('groups.resetOffset.selectPartitionPlaceholder')} + : t('groups.resetOffset.allPartitions')} ); @@ -149,14 +146,21 @@ export function TopicPartitionSelector({ { - onPartitionChange(Number(itemId)); + onPartitionChange( + itemId === '' ? undefined : Number(itemId) + ); setIsPartitionSelectOpen(false); }} - activeItemId={selectedPartition} + activeItemId={selectedPartition ?? ''} isScrollable > - {partitionMenuItems} + + + {t('groups.resetOffset.allPartitions')} + + {partitionMenuItems} + ); @@ -164,84 +168,36 @@ export function TopicPartitionSelector({ return ( <> - onTopicSelectionChange('allTopics')} - /> - onTopicSelectionChange('selectedTopic')} + - {topicSelection === 'selectedTopic' && ( - <> - - - - - - onPartitionSelectionChange('allPartitions')} - /> - onPartitionSelectionChange('selectedPartition')} - /> - - - {partitionSelection === 'selectedPartition' && ( - - - - )} - - )} + + + ); } \ No newline at end of file diff --git a/api/src/main/webui/src/components/kafka/groups/ResetOffset/types.ts b/api/src/main/webui/src/components/kafka/groups/ResetOffset/types.ts new file mode 100644 index 000000000..8d8ed7fcc --- /dev/null +++ b/api/src/main/webui/src/components/kafka/groups/ResetOffset/types.ts @@ -0,0 +1,30 @@ +export type OffsetValue = + | 'custom' + | 'latest' + | 'earliest' + | 'dateTimeIso' + | 'dateTimeEpoch' + | 'delete'; +export type TopicSelection = 'allTopics' | 'selectedTopic'; +export type PartitionSelection = 'allPartitions' | 'selectedPartition'; + +export interface ResetOffsetFormState { + selectedTopicId?: string; + selectedPartition?: number; + offsetValue?: OffsetValue; + customOffset?: number; + dateTime?: string; + dateTimeDisplayMode?: 'utc' | 'local'; +} + +export type ResetOffsetErrorType = + | 'CustomOffsetError' + | 'PartitionError' + | 'KafkaError' + | 'SpecificDateTimeNotValidError' + | 'GeneralError'; + +export interface ResetOffsetError { + type: ResetOffsetErrorType; + message: string; +} diff --git a/api/src/main/webui/src/i18n/messages/en.json b/api/src/main/webui/src/i18n/messages/en.json index 49476a909..06c98591c 100644 --- a/api/src/main/webui/src/i18n/messages/en.json +++ b/api/src/main/webui/src/i18n/messages/en.json @@ -325,20 +325,20 @@ "resetOffsetDisabledDescription": "Group must be empty to reset offsets.", "resetOffsetDisabledDescriptionNonConsumer": "Only consumer group offsets may be reset. This group uses the {{protocol}} protocol.", "resetOffset": { - "title": "Reset consumer group offsets", + "title": "Reset group offsets: {{groupId}}", "description": "Reset the consumer offsets for this group. The group must be in EMPTY state.", "descriptionNonConsumer": "Offset reset is only supported for consumer groups. This is a {{protocol}} group.", "target": "Target", - "targetWithGroupId": "Reset {{groupId}} offsets", + "targetSelectionTitle": "Reset target", "applyActionOn": "Apply action on", - "allConsumerTopics": "All consumer topics", + "allTopics": "All topics", "selectedTopic": "Selected topic", - "selectTopic": "Select topic", + "selectTopic": "Topic", "selectTopicPlaceholder": "Select a topic", "partitions": "Partitions", "allPartitions": "All partitions", "selectedPartition": "Selected partition", - "selectPartition": "Select partition", + "selectPartition": "Partition", "selectPartitionPlaceholder": "Select a partition", "offsetDetails": "Offset details", "newOffset": "New offset", @@ -346,31 +346,33 @@ "customOffset": "Custom offset", "customOffsetPlaceholder": "Enter offset value", "customOffsetHelper": "Specify the exact offset position", - "selectDateTime": "Select date/time format", - "isoDateFormat": "ISO date format", - "unixDateFormat": "Unix timestamp (epoch)", + "dateTime": "Date/time", "dateTimePlaceholder": "Enter date and time", - "dateTimeHelper": "Format: yyyy-MM-ddTHH:mm:ss.SSSZ", - "epochPlaceholder": "Enter Unix timestamp", + "dateTimeHelperUtc": "Format: yyyy-MM-ddTHH:mm:ss.SSSZ", + "dateTimeHelperLocal": "Format: yyyy-MM-ddTHH:mm:ss.SSS±HH:mm", + "timeZoneMode": "Time zone", + "timeZoneUtc": "UTC", + "timeZoneLocal": "Local time", + "epochPlaceholder": "Enter Unix epoch timestamp", "epochHelper": "Unix timestamp in milliseconds", + "useCurrentDateTime": "Use current", "offset": { "custom": "Custom offset", - "earliest": "Earliest", - "latest": "Latest", - "specificDateTime": "Specific date/time", + "earliest": "Earliest offset", + "latest": "Latest offset", + "dateTimeIso": "Specific date/time (ISO 8601)", + "dateTimeEpoch": "Specific date/time (Unix epoch timestamp)", "delete": "Delete committed offsets" }, "reset": "Reset offsets", "dryRun": "Dry run", - "dryRunDescription": "Preview the offset changes before applying them", - "viewDryRun": "View dry run results", + "success": "Offsets for group {{groupId}} successfully reset.", "cliCommand": "CLI command", - "cliCommandDescription": "Equivalent kafka-consumer-groups command", "copyCommand": "Copy command", "commandCopied": "Command copied to clipboard", "dryRunResults": { - "title": "Dry run results", - "description": "Preview of offset changes that will be applied", + "title": "Dry Run Results", + "description": "Dry Run results are accurate as of the execution time, but they may differ when performing the live reset of offset changes", "topic": "Topic", "partition": "Partition", "currentOffset": "Current offset", @@ -378,26 +380,9 @@ "offsetDeleted": "Offset will be deleted", "noResults": "No offset changes to preview", "apply": "Apply changes", - "cancel": "Cancel", - "downloadResults": "Download results" - }, - "confirmation": { - "title": "Confirm offset reset", - "message": "Are you sure you want to reset offsets for group '{{groupId}}'?", - "warningMessage": "This action cannot be undone. The consumer group must be stopped (EMPTY state) before resetting offsets.", - "confirm": "Reset offsets", "cancel": "Cancel" }, - "success": { - "title": "Offsets reset successfully", - "message": "Consumer group offsets have been reset for group '{{groupId}}'" - }, "errors": { - "groupNotEmpty": "Consumer group must be in EMPTY state to reset offsets", - "customOffsetOutOfRange": "Custom offset must be between the earliest and latest offset for the partition", - "partitionNotValid": "The specified partition is not valid for this topic", - "topicNotFound": "The specified topic does not exist", - "invalidDateTime": "Please provide a valid UTC ISO timestamp", "invalidEpoch": "Please provide a valid Unix timestamp", "generalError": "An error occurred while resetting offsets", "selectTopic": "Please select a topic", diff --git a/api/src/main/webui/src/pages/kafka/KafkaLayout.tsx b/api/src/main/webui/src/pages/kafka/KafkaLayout.tsx index 0611639cb..882a62664 100644 --- a/api/src/main/webui/src/pages/kafka/KafkaLayout.tsx +++ b/api/src/main/webui/src/pages/kafka/KafkaLayout.tsx @@ -22,6 +22,7 @@ import { useUser } from '@/api/hooks/useUsers'; import { KafkaClusterSidebar } from '@/components/kafka/KafkaClusterSidebar'; import { AppMasthead } from '@/components/app/AppMasthead'; import { ReconciliationControls } from '@/components/kafka/overview/ReconciliationControls'; +import { useGroup } from '@/api/hooks/useGroups'; export function KafkaLayout() { const { t } = useTranslation(); @@ -57,7 +58,14 @@ export function KafkaLayout() { topicId, { fields: ['name'] } ); - + + // Fetch group data if we're on a group detail page + const { data: groupData } = useGroup( + kafkaId, + groupId, + { fields: 'groupId' } + ); + const { data: connectorData } = useConnector( connectorId, ); @@ -282,6 +290,11 @@ export function KafkaLayout() { )} + {isGroupDetailPage && ( + + {groupData?.attributes?.groupId} + + )} {isGroupDetailPage && groupTab && groupTab !== groupId && ( {getGroupTabTitle(groupTab)} diff --git a/api/src/main/webui/src/pages/kafka/groups/GroupsPage.tsx b/api/src/main/webui/src/pages/kafka/groups/GroupsPage.tsx index e8b9956ab..76dbe2d54 100644 --- a/api/src/main/webui/src/pages/kafka/groups/GroupsPage.tsx +++ b/api/src/main/webui/src/pages/kafka/groups/GroupsPage.tsx @@ -78,6 +78,7 @@ export function GroupsPage() { // Reset offset modal state const [isResetOffsetModalOpen, setIsResetOffsetModalOpen] = useState(false); const [selectedGroup, setSelectedGroup] = useState(null); + const [resetOffsetSuccessMessage, setResetOffsetSuccessMessage] = useState(); // Fetch groups const { data, isLoading, error } = useGroups(kafkaId, { @@ -145,6 +146,15 @@ export function GroupsPage() { + {resetOffsetSuccessMessage && ( + setResetOffsetSuccessMessage(undefined)} />} + style={{ marginBottom: '1rem' }} + /> + )} {showLearning && isAlertVisible && ( setResetOffsetSuccessMessage(message)} kafkaId={kafkaId!} group={selectedGroup} /> diff --git a/api/src/main/webui/src/pages/kafka/groups/detail/GroupDetailPage.tsx b/api/src/main/webui/src/pages/kafka/groups/detail/GroupDetailPage.tsx index 3293afd34..a1cf11412 100644 --- a/api/src/main/webui/src/pages/kafka/groups/detail/GroupDetailPage.tsx +++ b/api/src/main/webui/src/pages/kafka/groups/detail/GroupDetailPage.tsx @@ -17,6 +17,8 @@ import { Button, Flex, FlexItem, + Alert, + AlertActionCloseButton, } from '@patternfly/react-core'; import { useGroup } from '@/api/hooks/useGroups'; import { ResetOffsetModal } from '@/components/kafka/groups/ResetOffset'; @@ -31,6 +33,7 @@ export function GroupDetailPage() { // Reset offset modal state const [isResetOffsetModalOpen, setIsResetOffsetModalOpen] = useState(false); + const [resetOffsetSuccessMessage, setResetOffsetSuccessMessage] = useState(); // Determine active tab from URL const pathSegments = location.pathname.split('/').filter(Boolean); @@ -95,7 +98,7 @@ export function GroupDetailPage() { {group && (