From cdb04e742476a33edda90a91d5a23a1a1829d8c8 Mon Sep 17 00:00:00 2001 From: hemahg Date: Tue, 13 Oct 2026 23:19:56 +0530 Subject: [PATCH 1/6] replace advanced search with new filter on messages tab Signed-off-by: hemahg --- .../kafka/topics/AdvancedSearch.tsx | 591 ++++-------------- .../topics/TopicsFilter/FetchFromSelector.tsx | 111 ++++ .../topics/TopicsFilter/LimitSelector.tsx | 58 ++ .../MessageTableFilter.stories.tsx | 69 ++ .../TopicsFilter/MessageTableFilter.tsx | 129 ++++ .../PartitionSelector.stories.tsx | 48 ++ .../topics/TopicsFilter/PartitionSelector.tsx | 91 +++ .../topics/TopicsFilter/whereSelector.tsx | 66 ++ api/src/main/webui/src/i18n/messages/en.json | 10 +- .../kafka/topics/detail/TopicMessagesTab.tsx | 320 +++++----- .../kafka/topics/detail/useDebounceValue.ts | 13 + 11 files changed, 886 insertions(+), 620 deletions(-) create mode 100644 api/src/main/webui/src/components/kafka/topics/TopicsFilter/FetchFromSelector.tsx create mode 100644 api/src/main/webui/src/components/kafka/topics/TopicsFilter/LimitSelector.tsx create mode 100644 api/src/main/webui/src/components/kafka/topics/TopicsFilter/MessageTableFilter.stories.tsx create mode 100644 api/src/main/webui/src/components/kafka/topics/TopicsFilter/MessageTableFilter.tsx create mode 100644 api/src/main/webui/src/components/kafka/topics/TopicsFilter/PartitionSelector.stories.tsx create mode 100644 api/src/main/webui/src/components/kafka/topics/TopicsFilter/PartitionSelector.tsx create mode 100644 api/src/main/webui/src/components/kafka/topics/TopicsFilter/whereSelector.tsx create mode 100644 api/src/main/webui/src/pages/kafka/topics/detail/useDebounceValue.ts diff --git a/api/src/main/webui/src/components/kafka/topics/AdvancedSearch.tsx b/api/src/main/webui/src/components/kafka/topics/AdvancedSearch.tsx index 49314c797..ecc3840b6 100644 --- a/api/src/main/webui/src/components/kafka/topics/AdvancedSearch.tsx +++ b/api/src/main/webui/src/components/kafka/topics/AdvancedSearch.tsx @@ -1,38 +1,8 @@ -/** - * Advanced Search Component for Messages - * Provides filtering options for Kafka messages - */ - -import { useState, useCallback, useTransition, useRef, useLayoutEffect } from 'react'; -import { createPortal } from 'react-dom'; -import { useTranslation } from 'react-i18next'; -import { toDatetimeLocal, toISOWithLocalOffset } from '@/utils/dateTime'; -import { - SearchInput, - Button, - Form, - FormGroup, - TextInput, - Panel, - PanelMain, - PanelMainBody, - ActionGroup, - Grid, - GridItem, - FormSection, - MenuToggle, - Select, - SelectOption, - SelectList, - Flex, - FlexItem, - FormHelperText, - HelperText, - HelperTextItem, - Divider, -} from '@patternfly/react-core'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { SearchParams } from '@/api/types'; -import { parseSearchInput } from './parseSearchInput'; +import { WhereOption } from './TopicsFilter/whereSelector'; +import { FromOptions } from './TopicsFilter/FetchFromSelector'; +import { NewFilter } from './TopicsFilter/MessageTableFilter'; interface AdvancedSearchProps { partitions: number; @@ -40,14 +10,61 @@ interface AdvancedSearchProps { filterWhere?: 'key' | 'headers' | 'value'; filterPartition?: number; filterOffset?: number; - filterTimestamp?: string; - filterEpoch?: number; filterLimit?: number | 'continuously'; onSearch: (params: SearchParams) => void; } -type FromCategory = 'offset' | 'timestamp' | 'epoch' | 'latest'; -type RetrieveCategory = 'limit' | 'continuously'; +const DEFAULT_LIMIT = 50; +const SEARCH_DEBOUNCE_MS = 400; + +const RELATIVE_MINUTES: Partial> = { + last5: 5, + last10: 10, + last15: 15, +}; + +interface FilterState { + partition?: number; + from: FromOptions; + offset?: number; + limit: number; + query: string; + where: WhereOption; +} + +function buildSearchParams({ + partition, + from, + offset, + limit, + query, + where, +}: FilterState): SearchParams { + const minutes = RELATIVE_MINUTES[from]; + + const fromParam: SearchParams['from'] = + minutes !== undefined + ? { + // "Last N minutes": start from now minus N minutes + type: 'timestamp', + value: new Date(Date.now() - minutes * 60_000).toISOString(), + } + : from === 'offset' && offset !== undefined + ? { type: 'offset', value: offset } + : { type: 'latest' }; + + // The text search is only part of the filter when not fetching "latest" + const hasQuery = from !== 'latest' && query.trim() !== ''; + + return { + query: hasQuery + ? { value: query, where: where ?? 'everywhere' } + : undefined, + partition, + from: fromParam, + limit, + }; +} export function AdvancedSearch({ partitions, @@ -55,450 +72,88 @@ export function AdvancedSearch({ filterWhere, filterPartition, filterOffset, - filterTimestamp, - filterEpoch, filterLimit, onSearch, }: AdvancedSearchProps) { - const { t } = useTranslation(); - const DEFAULT_LIMIT = 50; - - const [isExpanded, setIsExpanded] = useState(false); - const [query, setQuery] = useState(filterQuery || ''); - const [where, setWhere] = useState<'key' | 'headers' | 'value' | undefined>(filterWhere); - const [partition, setPartition] = useState(filterPartition); - - // From group state - const [fromCategory, setFromCategory] = useState( - filterOffset !== undefined ? 'offset' : - filterTimestamp !== undefined ? 'timestamp' : - filterEpoch !== undefined ? 'epoch' : 'latest' - ); - const [fromOffset, setFromOffset] = useState(filterOffset); - const [fromTimestamp, setFromTimestamp] = useState( - filterTimestamp ? toDatetimeLocal(filterTimestamp) : undefined + const [partition, setPartition] = useState( + filterPartition ); - const [fromEpoch, setFromEpoch] = useState(filterEpoch); - const [isFromOpen, setIsFromOpen] = useState(false); - const [, startTransition] = useTransition(); - - // Retrieve group state - const [retrieveCategory, setRetrieveCategory] = useState( - filterLimit === 'continuously' ? 'continuously' : 'limit' + const [from, setFrom] = useState( + filterOffset !== undefined ? 'offset' : 'latest' ); + const [offset, setOffset] = useState(filterOffset); const [limit, setLimit] = useState( typeof filterLimit === 'number' ? filterLimit : DEFAULT_LIMIT ); - const [isRetrieveOpen, setIsRetrieveOpen] = useState(false); - const [isLimitOpen, setIsLimitOpen] = useState(false); - - // Where selector state - const [isWhereOpen, setIsWhereOpen] = useState(false); - - // Partition selector state - const [isPartitionOpen, setIsPartitionOpen] = useState(false); + const [query, setQuery] = useState(filterQuery ?? ''); + const [where, setWhere] = useState(filterWhere); - const getParameters = useCallback((): SearchParams => { - const from: SearchParams['from'] = (() => { - if (fromCategory === 'offset' && fromOffset !== undefined) { - return { type: 'offset', value: fromOffset }; - } else if (fromCategory === 'epoch' && fromEpoch !== undefined) { - return { type: 'epoch', value: fromEpoch }; - } else if (fromCategory === 'timestamp' && fromTimestamp) { - return { type: 'timestamp', value: toISOWithLocalOffset(fromTimestamp) }; - } - return { type: 'latest' }; - })(); - - return { - query: query ? { value: query, where: where ?? 'everywhere' } : undefined, - partition, - from, - limit: retrieveCategory === 'continuously' ? 'continuously' : limit, - }; - }, [query, where, partition, fromCategory, fromOffset, fromEpoch, fromTimestamp, retrieveCategory, limit]); - - const buildSearchInputValue = useCallback(() => { - const parameters = getParameters(); - const { query, from, limit, partition } = parameters; - return (() => { - const composed: string[] = []; - if (from !== undefined) { - if ("value" in from) { - composed.push(`messages=${from.type}:${from.value}`); - } else { - composed.push(`messages=latest`); - } - } - if (limit !== undefined) { - composed.push(`retrieve=${limit}`); - } - if (partition !== undefined) { - composed.push(`partition=${partition}`); - } - if (query !== undefined) { - composed.push(query.value); - if (query.where !== "everywhere") { - composed.push(`where=${query.where}`); - } - } - return composed.join(" "); - })(); - }, [getParameters]); - - const handleSubmit = (e?: React.FormEvent) => { - e?.preventDefault(); - onSearch(getParameters()); - setIsExpanded(false); - }; + // NewFilter wants the list of partitions; callers still pass a count + const partitionList = useMemo( + () => Array.from({ length: partitions }, (_, i) => i), + [partitions] + ); - const handleReset = () => { - setQuery(''); - setWhere(undefined); - setPartition(undefined); - setFromCategory('latest'); - setFromOffset(undefined); - setFromTimestamp(undefined); - setFromEpoch(undefined); - setRetrieveCategory('limit'); - setLimit(DEFAULT_LIMIT); - - onSearch({ - query: undefined, - partition: undefined, - from: { type: 'latest' }, - limit: DEFAULT_LIMIT, - }); - - setIsExpanded(false); - }; + // Keep the latest callback without re-running the search effect + const onSearchRef = useRef(onSearch); + useEffect(() => { + onSearchRef.current = onSearch; + }, [onSearch]); + + // Run the search whenever the selection changes (debounced for typing). + // The initial selection is not searched: the page already loaded it. + const lastSearchedKey = useRef(null); + const key = JSON.stringify({ partition, from, offset, limit, query, where }); + if (lastSearchedKey.current === null) { + lastSearchedKey.current = key; + } + + useEffect(() => { + if (key === lastSearchedKey.current) { + return; + } + // Offset selected but not typed yet: nothing to search for + if (from === 'offset' && offset === undefined) { + return; + } - const searchString = buildSearchInputValue(); - const [searchInputValue, setSearchInputValue] = useState(searchString); + const timer = setTimeout(() => { + lastSearchedKey.current = key; + onSearchRef.current( + buildSearchParams({ partition, from, offset, limit, query, where }) + ); + }, SEARCH_DEBOUNCE_MS); - const anchorRef = useRef(null); - const [panelStyle, setPanelStyle] = useState({}); + return () => clearTimeout(timer); + }, [key, partition, from, offset, limit, query, where]); - const updatePanelPosition = useCallback(() => { - if (anchorRef.current) { - const rect = anchorRef.current.getBoundingClientRect(); - const top = rect.bottom + 8; - setPanelStyle({ - position: 'fixed', - top, - left: rect.left, - width: rect.width, - maxHeight: `calc(100vh - ${top}px - 16px)`, - overflowY: 'auto', - zIndex: 9999, - }); + const handleFromChange = (next: FromOptions) => { + setFrom(next); + if (next !== 'offset') { + setOffset(undefined); } - }, []); - - useLayoutEffect(() => { - if (!isExpanded) return; - updatePanelPosition(); - window.addEventListener('scroll', updatePanelPosition, true); - window.addEventListener('resize', updatePanelPosition); - return () => { - window.removeEventListener('scroll', updatePanelPosition, true); - window.removeEventListener('resize', updatePanelPosition); - }; - }, [isExpanded, updatePanelPosition]); + if (next === 'latest') { + // The search box is hidden for "latest", so don't keep a hidden filter + setQuery(''); + setWhere(undefined); + } + }; return ( -
- setSearchInputValue(v)} - onSearch={(e) => { - e.preventDefault(); - const sp = parseSearchInput({ - value: searchInputValue, - }); - startTransition(() => { - onSearch(sp); - }); - }} - onClear={() => handleReset()} - onToggleAdvancedSearch={() => setIsExpanded(!isExpanded)} - isAdvancedSearchOpen={isExpanded} - /> - {isExpanded && createPortal( - - -
- - - - - setQuery(value)} - placeholder={t('topics.messages.advancedSearch.queryHelper')} - /> - - - - {t('topics.messages.advancedSearch.queryHelper')} - - - - - - - - - - - - - - - - - - - - - - - {fromCategory === 'offset' && ( - - setFromOffset(value ? parseInt(value) : undefined)} - placeholder={t('topics.messages.filter.offsetPlaceholder')} - aria-label={t('topics.messages.filter.offsetAriaLabel')} - /> - - )} - {fromCategory === 'timestamp' && ( - - setFromTimestamp(value || undefined)} - aria-label={t('topics.messages.filter.timestampAriaLabel')} - /> - - )} - {fromCategory === 'epoch' && ( - - setFromEpoch(value ? parseInt(value) : undefined)} - placeholder={t('topics.messages.filter.epochPlaceholder')} - aria-label={t('topics.messages.filter.epochAriaLabel')} - /> - - )} - - - - - - - - - - - {retrieveCategory === 'limit' && ( - - - - )} - {retrieveCategory === 'continuously' && ( - - - - - The screen displays only the most recent 100 messages, with older messages rotating out. - - - - - )} - - - - - - - - - - - - - - - - -
-
-
-
, document.body)} -
+ ); -} \ No newline at end of file +} diff --git a/api/src/main/webui/src/components/kafka/topics/TopicsFilter/FetchFromSelector.tsx b/api/src/main/webui/src/components/kafka/topics/TopicsFilter/FetchFromSelector.tsx new file mode 100644 index 000000000..836d65e2c --- /dev/null +++ b/api/src/main/webui/src/components/kafka/topics/TopicsFilter/FetchFromSelector.tsx @@ -0,0 +1,111 @@ +/** + * Fetch-from selector. + * + * Controlled component: the selected fetch option comes in as a prop, + * and changes go out through onFromChange. + */ + +import { useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Menu, + MenuContainer, + MenuContent, + MenuList, + MenuToggle, + SelectOption, +} from '@patternfly/react-core'; + +export type FromOptions = + | 'latest' + | 'last5' + | 'last10' + | 'last15' + | 'offset' + | 'timestamp'; + +interface FetchFromSelectorProps { + selectedFrom?: FromOptions; + onFromChange: (from: FromOptions) => void; + isDisabled?: boolean; + ouiaId?: string; +} + +export function FetchFromSelector({ + selectedFrom = 'latest', + onFromChange, + isDisabled = false, + ouiaId = 'fetch-from-select', +}: FetchFromSelectorProps) { + const { t } = useTranslation(); + const [isOpen, setIsOpen] = useState(false); + + const FROM_OPTIONS: { value: FromOptions; label: string }[] = [ + { value: 'latest', label: t('topics.messages.fetchFrom.latest') }, + { value: 'last5', label: t('topics.messages.fetchFrom.last5') }, + { value: 'last10', label: t('topics.messages.fetchFrom.last10') }, + { value: 'last15', label: t('topics.messages.fetchFrom.last15') }, + { value: 'offset', label: t('topics.messages.fetchFrom.offset') }, + { + value: 'timestamp', + label: t('topics.messages.fetchFrom.timestamp'), + }, + ]; + + const toggleRef = useRef(null); + const menuRef = useRef(null); + + const selectedOption = FROM_OPTIONS.find( + (option) => option.value === selectedFrom + ); + + const toggle = ( + setIsOpen(!isOpen)} + isExpanded={isOpen} + isDisabled={isDisabled} + aria-label={t('topics.messages.fetchFrom.label')} + > + {selectedOption?.label ?? FROM_OPTIONS[0].label} + + ); + + const menu = ( + { + onFromChange(itemId as FromOptions); + setIsOpen(false); + }} + activeItemId={selectedFrom} + > + + + {FROM_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + + ); + + return ( + + ); +} diff --git a/api/src/main/webui/src/components/kafka/topics/TopicsFilter/LimitSelector.tsx b/api/src/main/webui/src/components/kafka/topics/TopicsFilter/LimitSelector.tsx new file mode 100644 index 000000000..10df585d8 --- /dev/null +++ b/api/src/main/webui/src/components/kafka/topics/TopicsFilter/LimitSelector.tsx @@ -0,0 +1,58 @@ +import { useState } from 'react'; +import { + MenuToggle, + MenuToggleElement, + Select, + SelectList, + SelectOption, +} from '@patternfly/react-core'; + +// 50, 100, 150 ... 1000 +const LIMIT_OPTIONS = Array.from({ length: 20 }, (_, i) => (i + 1) * 50); + +interface LimitSelectProps { + value: number; + onChange: (limit: number) => void; + ouiaId?: string; +} + +export function LimitSelect({ + value, + onChange, + ouiaId = 'limit-select', +}: LimitSelectProps) { + const [isOpen, setIsOpen] = useState(false); + + return ( + + ); +} diff --git a/api/src/main/webui/src/components/kafka/topics/TopicsFilter/MessageTableFilter.stories.tsx b/api/src/main/webui/src/components/kafka/topics/TopicsFilter/MessageTableFilter.stories.tsx new file mode 100644 index 000000000..5dbb582ef --- /dev/null +++ b/api/src/main/webui/src/components/kafka/topics/TopicsFilter/MessageTableFilter.stories.tsx @@ -0,0 +1,69 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { NewFilter } from './MessageTableFilter'; + +const meta: Meta = { + component: NewFilter, +}; + +export default meta; +type Story = StoryObj; + +const defaultArgs = { + partitions: [0, 1, 2, 3, 4], + selectedFrom: 'latest' as const, + limit: 50, + onPartitionChange: (partition?: number) => { + console.log('Selected partition:', partition); + }, + onFromChange: ( + from: 'latest' | 'last5' | 'last10' | 'last15' | 'offset' | 'timestamp' + ) => { + console.log('Selected fetch from:', from); + }, + onLimitChange: (limit: number) => { + console.log('Selected limit:', limit); + }, +}; + +export const Default: Story = { + args: { + ...defaultArgs, + }, +}; + +export const SelectedPartition: Story = { + args: { + ...defaultArgs, + selectedFrom: 'offset', + selectedPartition: 2, + }, +}; + +export const SinglePartition: Story = { + args: { + ...defaultArgs, + partitions: [0], + selectedPartition: 0, + }, +}; + +export const NoPartitions: Story = { + args: { + ...defaultArgs, + partitions: [], + }, +}; + +export const Last10Minutes: Story = { + args: { + ...defaultArgs, + selectedFrom: 'last10', + }, +}; + +export const LargeLimit: Story = { + args: { + ...defaultArgs, + limit: 500, + }, +}; diff --git a/api/src/main/webui/src/components/kafka/topics/TopicsFilter/MessageTableFilter.tsx b/api/src/main/webui/src/components/kafka/topics/TopicsFilter/MessageTableFilter.tsx new file mode 100644 index 000000000..7a2e2a3ca --- /dev/null +++ b/api/src/main/webui/src/components/kafka/topics/TopicsFilter/MessageTableFilter.tsx @@ -0,0 +1,129 @@ +import { + Flex, + FlexItem, + TextInput, + Toolbar, + ToolbarContent, + ToolbarItem, +} from '@patternfly/react-core'; +import { PartitionSelector } from './PartitionSelector'; +import { FetchFromSelector, FromOptions } from './FetchFromSelector'; +import { LimitSelect } from './LimitSelector'; +import { WhereOption, WhereSelector } from './whereSelector'; +import { useTranslation } from 'react-i18next'; + +interface NewFilterProps { + partitions: number[]; + selectedPartition?: number; + onPartitionChange: (partition?: number) => void; + selectedFrom: FromOptions; + onFromChange: (from: FromOptions) => void; + limit: number; + onLimitChange: (limit: number) => void; + query: string; + onQueryChange: (query: string) => void; + where: WhereOption; + onWhereChange: (where: WhereOption) => void; + offset?: number; + onOffsetChange: (offset?: number) => void; +} + +function FilterSection({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( + + + {title} + {children} + + + ); +} + +export function NewFilter({ + partitions, + selectedPartition, + onPartitionChange, + selectedFrom, + onFromChange, + limit, + onLimitChange, + query, + onQueryChange, + where, + onWhereChange, + offset, + onOffsetChange, +}: NewFilterProps) { + const showSearch = selectedFrom !== 'latest'; + const { t } = useTranslation(); + + return ( + + + + + + + + + + + + {selectedFrom === 'offset' && ( + + + onOffsetChange(value ? parseInt(value, 10) : undefined) + } + placeholder={t('topics.messages.filter.offsetPlaceholder')} + aria-label={t('topics.messages.filter.offsetAriaLabel')} + /> + + )} + + + + + + {showSearch && ( + <> + + + + + + + onQueryChange(value)} + placeholder={t( + 'topics.messages.advancedSearch.queryHelper' + )} + style={{ width: '260px' }} + /> + + + + + )} + + + ); +} diff --git a/api/src/main/webui/src/components/kafka/topics/TopicsFilter/PartitionSelector.stories.tsx b/api/src/main/webui/src/components/kafka/topics/TopicsFilter/PartitionSelector.stories.tsx new file mode 100644 index 000000000..86dc96cc8 --- /dev/null +++ b/api/src/main/webui/src/components/kafka/topics/TopicsFilter/PartitionSelector.stories.tsx @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { NewFilter } from './MessageTableFilter'; + +const meta: Meta = { + component: NewFilter, + title: 'Kafka/Messages/Filters/NewFilter', +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + partitions: [0, 1, 2, 3, 4], + onPartitionChange: (partition) => { + console.log('Selected partition:', partition); + }, + }, +}; + +export const SelectedPartition: Story = { + args: { + partitions: [0, 1, 2, 3, 4], + selectedPartition: 2, + onPartitionChange: (partition) => { + console.log('Selected partition:', partition); + }, + }, +}; + +export const SinglePartition: Story = { + args: { + partitions: [0], + selectedPartition: 0, + onPartitionChange: (partition) => { + console.log('Selected partition:', partition); + }, + }, +}; + +export const NoPartitions: Story = { + args: { + partitions: [], + onPartitionChange: (partition) => { + console.log('Selected partition:', partition); + }, + }, +}; diff --git a/api/src/main/webui/src/components/kafka/topics/TopicsFilter/PartitionSelector.tsx b/api/src/main/webui/src/components/kafka/topics/TopicsFilter/PartitionSelector.tsx new file mode 100644 index 000000000..52addbcfe --- /dev/null +++ b/api/src/main/webui/src/components/kafka/topics/TopicsFilter/PartitionSelector.tsx @@ -0,0 +1,91 @@ +import { useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Divider, + Menu, + MenuContainer, + MenuContent, + MenuList, + MenuToggle, + SelectOption, +} from '@patternfly/react-core'; + +interface PartitionSelectorProps { + partitions: number[]; + selectedPartition?: number; + onPartitionChange: (partition?: number) => void; + isDisabled?: boolean; + ouiaId?: string; +} + +export function PartitionSelector({ + partitions, + selectedPartition, + onPartitionChange, + isDisabled = false, + ouiaId = 'partition-select', +}: PartitionSelectorProps) { + const { t } = useTranslation(); + const [isOpen, setIsOpen] = useState(false); + + const toggleRef = useRef(null); + const menuRef = useRef(null); + + const toggle = ( + setIsOpen(!isOpen)} + isExpanded={isOpen} + isDisabled={isDisabled} + isFullWidth + aria-label={t('topics.messages.advancedSearch.inPartition')} + > + {selectedPartition !== undefined + ? t('topics.messages.partitionOption', { value: selectedPartition }) + : t('topics.messages.partitionPlaceholder')} + + ); + + const menu = ( + { + onPartitionChange(itemId === '' ? undefined : Number(itemId)); + setIsOpen(false); + }} + activeItemId={selectedPartition ?? ''} + isScrollable + > + + + + {t('topics.messages.partitionPlaceholder')} + + + {partitions.map((partition) => ( + + {partition} + + ))} + + + + ); + + return ( + + ); +} diff --git a/api/src/main/webui/src/components/kafka/topics/TopicsFilter/whereSelector.tsx b/api/src/main/webui/src/components/kafka/topics/TopicsFilter/whereSelector.tsx new file mode 100644 index 000000000..e296a3414 --- /dev/null +++ b/api/src/main/webui/src/components/kafka/topics/TopicsFilter/whereSelector.tsx @@ -0,0 +1,66 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + MenuToggle, + MenuToggleElement, + Select, + SelectList, + SelectOption, +} from '@patternfly/react-core'; + +export type WhereOption = 'key' | 'headers' | 'value' | undefined; + +interface WhereSelectorProps { + where: WhereOption; + onWhereChange: (where: WhereOption) => void; + ouiaId?: string; +} + +export function WhereSelector({ + where, + onWhereChange, + ouiaId = 'where-selector', +}: WhereSelectorProps) { + const { t } = useTranslation(); + const [isOpen, setIsOpen] = useState(false); + + const labels: Record<'everywhere' | 'key' | 'headers' | 'value', string> = { + everywhere: 'Anywhere', + key: t('topics.messages.advancedSearch.whereKey'), + headers: t('topics.messages.advancedSearch.whereHeaders'), + value: t('topics.messages.advancedSearch.whereValue'), + }; + + return ( + + ); +} diff --git a/api/src/main/webui/src/i18n/messages/en.json b/api/src/main/webui/src/i18n/messages/en.json index 802fe4230..fed30ff7e 100644 --- a/api/src/main/webui/src/i18n/messages/en.json +++ b/api/src/main/webui/src/i18n/messages/en.json @@ -289,10 +289,18 @@ "partitionPlaceholder": "All partitions", "partitionOption": "Partition {{value}}", "selectPartitionAriaLabel": "Specify partition", + "fetchFrom": { + "latest": "Latest", + "last5": "Last 5 minutes", + "last10": "Last 10 minutes", + "last15": "Last 15 minutes", + "offset": "Offset", + "timestamp": "Timestamp" + }, "advancedSearch": { "dialogLabel": "Search messages", "hasTheWords": "Has the words", - "queryHelper": "Enter your search terms to find matches within the selected field. Wildcards and regular expressions are not required or supported.", + "queryHelper": "Enter search term...", "inPartition": "In partition", "search": "Search", "reset": "Reset", diff --git a/api/src/main/webui/src/pages/kafka/topics/detail/TopicMessagesTab.tsx b/api/src/main/webui/src/pages/kafka/topics/detail/TopicMessagesTab.tsx index eb2e1edb7..a00f19c09 100644 --- a/api/src/main/webui/src/pages/kafka/topics/detail/TopicMessagesTab.tsx +++ b/api/src/main/webui/src/pages/kafka/topics/detail/TopicMessagesTab.tsx @@ -46,18 +46,23 @@ const defaultColumns: Column[] = [ export function TopicMessagesTab() { const { t } = useTranslation(); - const { kafkaId, topicId } = useParams<{ kafkaId: string; topicId: string }>(); + const { kafkaId, topicId } = useParams<{ + kafkaId: string; + topicId: string; + }>(); const [searchParams, setSearchParams] = useSearchParams(); - + // Parse search params const retrieveParam = searchParams.get('retrieve'); - const limit = retrieveParam === 'continuously' - ? 'continuously' as const - : parseInt(retrieveParam || '50'); + const limit = + retrieveParam === 'continuously' + ? ('continuously' as const) + : parseInt(retrieveParam || '50'); const partitionParam = searchParams.get('partition'); const partition = partitionParam ? parseInt(partitionParam) : undefined; const query = searchParams.get('query') || undefined; - const where = (searchParams.get('where') as 'key' | 'headers' | 'value') || undefined; + const where = + (searchParams.get('where') as 'key' | 'headers' | 'value') || undefined; const offsetParam = searchParams.get('offset'); const offset = offsetParam ? parseInt(offsetParam) : undefined; const timestamp = searchParams.get('timestamp') || undefined; @@ -98,7 +103,10 @@ export function TopicMessagesTab() { } ); - const selectedMessageId = useMemo(() => searchParams.get('selected'), [searchParams]); + const selectedMessageId = useMemo( + () => searchParams.get('selected'), + [searchParams] + ); // Derive selected message directly from URL + messages — no separate state needed. const selectedMessage = useMemo(() => { if (!selectedMessageId) return undefined; @@ -106,49 +114,58 @@ export function TopicMessagesTab() { const part = parseInt(partStr); const off = parseInt(offsetStr); return messages.find( - m => m.attributes.partition === part && m.attributes.offset === off + (m) => m.attributes.partition === part && m.attributes.offset === off ); }, [selectedMessageId, messages]); - const handleSearch = useCallback((params: SearchParams) => { - const newParams = new URLSearchParams(); - - if (params.query?.value) { - newParams.set('query', params.query.value); - if (params.query.where !== 'everywhere') { - newParams.set('where', params.query.where); + const handleSearch = useCallback( + (params: SearchParams) => { + const newParams = new URLSearchParams(); + + if (params.query?.value) { + newParams.set('query', params.query.value); + if (params.query.where !== 'everywhere') { + newParams.set('where', params.query.where); + } } - } - - if (params.partition !== undefined) { - newParams.set('partition', String(params.partition)); - } - - if (params.from.type !== 'latest') { - if (params.from.type === 'offset') { - newParams.set('offset', String(params.from.value)); - } else if (params.from.type === 'timestamp') { - newParams.set('timestamp', params.from.value); - } else if (params.from.type === 'epoch') { - newParams.set('epoch', String(params.from.value)); + + if (params.partition !== undefined) { + newParams.set('partition', String(params.partition)); } - } - - if (params.limit !== 50) { - newParams.set('retrieve', String(params.limit)); - } - - setSearchParams(newParams, { replace: true }); - // Always refetch explicitly — if the params haven't changed the query key - // is identical and TanStack Query won't issue a new request otherwise. - refetch(); - }, [setSearchParams, refetch]); - const handleSelectMessage = useCallback((message: KafkaRecord) => { - const newParams = new URLSearchParams(searchParams); - newParams.set('selected', `${message.attributes.partition}:${message.attributes.offset}`); - setSearchParams(newParams, { replace: true }); - }, [searchParams, setSearchParams]); + if (params.from.type !== 'latest') { + if (params.from.type === 'offset') { + newParams.set('offset', String(params.from.value)); + } else if (params.from.type === 'timestamp') { + newParams.set('timestamp', params.from.value); + } else if (params.from.type === 'epoch') { + newParams.set('epoch', String(params.from.value)); + } + } + + if (params.limit !== 50) { + newParams.set('retrieve', String(params.limit)); + } + + setSearchParams(newParams, { replace: true }); + // Always refetch explicitly — if the params haven't changed the query key + // is identical and TanStack Query won't issue a new request otherwise. + refetch(); + }, + [setSearchParams, refetch] + ); + + const handleSelectMessage = useCallback( + (message: KafkaRecord) => { + const newParams = new URLSearchParams(searchParams); + newParams.set( + 'selected', + `${message.attributes.partition}:${message.attributes.offset}` + ); + setSearchParams(newParams, { replace: true }); + }, + [searchParams, setSearchParams] + ); const handleDeselectMessage = useCallback(() => { const newParams = new URLSearchParams(searchParams); @@ -161,22 +178,19 @@ export function TopicMessagesTab() { }, [setSearchParams]); // Load saved column preferences from localStorage - const persistedColumns = useMemo( - () => { - const columnsJson = localStorage.getItem('message-browser-columns'); - if (columnsJson) { - try { - const parsed = JSON.parse(columnsJson); - if (Array.isArray(parsed)) { - return parsed; - } - } catch { - // Ignore parse errors + const persistedColumns = useMemo(() => { + const columnsJson = localStorage.getItem('message-browser-columns'); + if (columnsJson) { + try { + const parsed = JSON.parse(columnsJson); + if (Array.isArray(parsed)) { + return parsed; } + } catch { + // Ignore parse errors } - }, - [] - ); + } + }, []); const [chosenColumns, setChosenColumns] = useState( persistedColumns ?? defaultColumns @@ -202,105 +216,109 @@ export function TopicMessagesTab() { ); } - const hasFilters = partition !== undefined || offset !== undefined || - timestamp !== undefined || epoch !== undefined || query !== undefined; + const hasFilters = + partition !== undefined || + offset !== undefined || + timestamp !== undefined || + epoch !== undefined || + query !== undefined; return ( <> - - - - {t('topics.messages.message')} - - - - - - - ) - } - > - - - {limit === 'continuously' && ( - - )} + + + + {t('topics.messages.message')} + + + + + + + ) + } + > + + + {limit === 'continuously' && ( + + )} - - - - - - - - - - ) : ( - - )} - - - - + {messagesError ? ( + + + + {messagesError instanceof ApiError && + messagesError.errors?.[0]?.title + ? messagesError.errors[0].title + : t('topics.messages.noDataTitle')} + + + {messagesError instanceof ApiError && + messagesError.errors?.[0]?.detail + ? messagesError.errors[0].detail + : messagesError.message} + + + + ) : ( + + )} + + + + - {showColumnsModal && ( - setShowColumnsModal(false)} - /> - )} + {showColumnsModal && ( + setShowColumnsModal(false)} + /> + )} ); -} \ No newline at end of file +} diff --git a/api/src/main/webui/src/pages/kafka/topics/detail/useDebounceValue.ts b/api/src/main/webui/src/pages/kafka/topics/detail/useDebounceValue.ts new file mode 100644 index 000000000..56bbc6a5d --- /dev/null +++ b/api/src/main/webui/src/pages/kafka/topics/detail/useDebounceValue.ts @@ -0,0 +1,13 @@ +import { useEffect, useState } from 'react'; + +/** Returns `value`, but only after it has stopped changing for `delay` ms. */ +export function useDebouncedValue(value: T, delay = 400): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const id = setTimeout(() => setDebounced(value), delay); + return () => clearTimeout(id); + }, [value, delay]); + + return debounced; +} From d6e62c6aa12f81a02f59649f2c67e88dfff1c580 Mon Sep 17 00:00:00 2001 From: hemahg Date: Thu, 15 Oct 2026 01:25:27 +0530 Subject: [PATCH 2/6] Topic message table filter Signed-off-by: hemahg --- .../kafka/topics/AdvancedSearch.tsx | 62 ++++++++++++++-- .../MessageTableFilter.stories.tsx | 74 +++++++++++++------ .../TopicsFilter/MessageTableFilter.tsx | 34 ++++++--- .../PartitionSelector.stories.tsx | 48 ------------ .../TopicsFilter/whereSelector.stories.tsx | 16 ++++ .../topics/TopicsFilter/whereSelector.tsx | 50 +++++++++---- .../kafka/topics/detail/TopicMessagesTab.tsx | 24 +++++- 7 files changed, 203 insertions(+), 105 deletions(-) delete mode 100644 api/src/main/webui/src/components/kafka/topics/TopicsFilter/PartitionSelector.stories.tsx create mode 100644 api/src/main/webui/src/components/kafka/topics/TopicsFilter/whereSelector.stories.tsx diff --git a/api/src/main/webui/src/components/kafka/topics/AdvancedSearch.tsx b/api/src/main/webui/src/components/kafka/topics/AdvancedSearch.tsx index ecc3840b6..f2114fec2 100644 --- a/api/src/main/webui/src/components/kafka/topics/AdvancedSearch.tsx +++ b/api/src/main/webui/src/components/kafka/topics/AdvancedSearch.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { SearchParams } from '@/api/types'; import { WhereOption } from './TopicsFilter/whereSelector'; import { FromOptions } from './TopicsFilter/FetchFromSelector'; -import { NewFilter } from './TopicsFilter/MessageTableFilter'; +import { MessageTableFilter } from './TopicsFilter/MessageTableFilter'; interface AdvancedSearchProps { partitions: number; @@ -11,7 +11,8 @@ interface AdvancedSearchProps { filterPartition?: number; filterOffset?: number; filterLimit?: number | 'continuously'; - onSearch: (params: SearchParams) => void; + filterFrom?: FromOptions; + onSearch: (params: SearchParams, selectedFrom: FromOptions) => void; } const DEFAULT_LIMIT = 50; @@ -73,21 +74,27 @@ export function AdvancedSearch({ filterPartition, filterOffset, filterLimit, + filterFrom, onSearch, }: AdvancedSearchProps) { const [partition, setPartition] = useState( filterPartition ); const [from, setFrom] = useState( - filterOffset !== undefined ? 'offset' : 'latest' + filterFrom ?? (filterOffset !== undefined ? 'offset' : 'latest') ); const [offset, setOffset] = useState(filterOffset); const [limit, setLimit] = useState( typeof filterLimit === 'number' ? filterLimit : DEFAULT_LIMIT ); const [query, setQuery] = useState(filterQuery ?? ''); + const [appliedQuery, setAppliedQuery] = useState(filterQuery ?? ''); const [where, setWhere] = useState(filterWhere); + const [offsetInput, setOffsetInput] = useState( + filterOffset?.toString() ?? '' + ); + // NewFilter wants the list of partitions; callers still pass a count const partitionList = useMemo( () => Array.from({ length: partitions }, (_, i) => i), @@ -103,11 +110,37 @@ export function AdvancedSearch({ // Run the search whenever the selection changes (debounced for typing). // The initial selection is not searched: the page already loaded it. const lastSearchedKey = useRef(null); - const key = JSON.stringify({ partition, from, offset, limit, query, where }); + const key = JSON.stringify({ + partition, + from, + offset, + limit, + query: appliedQuery, + where, + }); if (lastSearchedKey.current === null) { lastSearchedKey.current = key; } + const commitOffset = () => { + const trimmed = offsetInput.trim(); + + if (trimmed === '') { + setOffset(undefined); + return; + } + + const parsed = Number(trimmed); + + if (Number.isInteger(parsed) && parsed >= 0) { + setOffset(parsed); + } + }; + + const commitQuery = () => { + setAppliedQuery(query.trim()); + }; + useEffect(() => { if (key === lastSearchedKey.current) { return; @@ -120,27 +153,36 @@ export function AdvancedSearch({ const timer = setTimeout(() => { lastSearchedKey.current = key; onSearchRef.current( - buildSearchParams({ partition, from, offset, limit, query, where }) + buildSearchParams({ + partition, + from, + offset, + limit, + query: appliedQuery, + where, + }), + from ); }, SEARCH_DEBOUNCE_MS); return () => clearTimeout(timer); - }, [key, partition, from, offset, limit, query, where]); + }, [key, partition, from, offset, limit, appliedQuery, where]); const handleFromChange = (next: FromOptions) => { setFrom(next); if (next !== 'offset') { setOffset(undefined); + setOffsetInput(''); } if (next === 'latest') { - // The search box is hidden for "latest", so don't keep a hidden filter setQuery(''); + setAppliedQuery(''); setWhere(undefined); } }; return ( - ); } diff --git a/api/src/main/webui/src/components/kafka/topics/TopicsFilter/MessageTableFilter.stories.tsx b/api/src/main/webui/src/components/kafka/topics/TopicsFilter/MessageTableFilter.stories.tsx index 5dbb582ef..8fb18af7d 100644 --- a/api/src/main/webui/src/components/kafka/topics/TopicsFilter/MessageTableFilter.stories.tsx +++ b/api/src/main/webui/src/components/kafka/topics/TopicsFilter/MessageTableFilter.stories.tsx @@ -1,69 +1,101 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { NewFilter } from './MessageTableFilter'; +import { MessageTableFilter } from './MessageTableFilter'; -const meta: Meta = { - component: NewFilter, +const meta: Meta = { + component: MessageTableFilter, }; export default meta; -type Story = StoryObj; +type Story = StoryObj; const defaultArgs = { partitions: [0, 1, 2, 3, 4], + selectedPartition: undefined, selectedFrom: 'latest' as const, limit: 50, - onPartitionChange: (partition?: number) => { - console.log('Selected partition:', partition); + query: '', + where: undefined, +}; + +export const Latest: Story = { + args: { + ...defaultArgs, + selectedFrom: 'latest', }, - onFromChange: ( - from: 'latest' | 'last5' | 'last10' | 'last15' | 'offset' | 'timestamp' - ) => { - console.log('Selected fetch from:', from); +}; + +export const Last5Minutes: Story = { + args: { + ...defaultArgs, + selectedFrom: 'last5', }, - onLimitChange: (limit: number) => { - console.log('Selected limit:', limit); +}; + +export const Last10Minutes: Story = { + args: { + ...defaultArgs, + selectedFrom: 'last10', }, }; -export const Default: Story = { +export const Last15Minutes: Story = { args: { ...defaultArgs, + selectedFrom: 'last15', }, }; -export const SelectedPartition: Story = { +export const FetchFromOffset: Story = { args: { ...defaultArgs, selectedFrom: 'offset', - selectedPartition: 2, + offset: 123, }, }; -export const SinglePartition: Story = { +export const FetchFromOffsetWithoutValue: Story = { args: { ...defaultArgs, - partitions: [0], - selectedPartition: 0, + selectedFrom: 'offset', + offset: undefined, }, }; -export const NoPartitions: Story = { +export const FetchFromTimestamp: Story = { args: { ...defaultArgs, - partitions: [], + selectedFrom: 'timestamp', + query: 'payment', }, }; -export const Last10Minutes: Story = { +export const SelectedPartition: Story = { args: { ...defaultArgs, + selectedPartition: 2, selectedFrom: 'last10', }, }; +export const SinglePartition: Story = { + args: { + ...defaultArgs, + partitions: [0], + selectedPartition: 0, + }, +}; + +export const NoPartitions: Story = { + args: { + ...defaultArgs, + partitions: [], + }, +}; + export const LargeLimit: Story = { args: { ...defaultArgs, limit: 500, + selectedFrom: 'last10', }, }; diff --git a/api/src/main/webui/src/components/kafka/topics/TopicsFilter/MessageTableFilter.tsx b/api/src/main/webui/src/components/kafka/topics/TopicsFilter/MessageTableFilter.tsx index 7a2e2a3ca..f18ea7ed3 100644 --- a/api/src/main/webui/src/components/kafka/topics/TopicsFilter/MessageTableFilter.tsx +++ b/api/src/main/webui/src/components/kafka/topics/TopicsFilter/MessageTableFilter.tsx @@ -12,7 +12,7 @@ import { LimitSelect } from './LimitSelector'; import { WhereOption, WhereSelector } from './whereSelector'; import { useTranslation } from 'react-i18next'; -interface NewFilterProps { +interface MessageTableFilterProps { partitions: number[]; selectedPartition?: number; onPartitionChange: (partition?: number) => void; @@ -26,6 +26,10 @@ interface NewFilterProps { onWhereChange: (where: WhereOption) => void; offset?: number; onOffsetChange: (offset?: number) => void; + offsetInput: string; + onOffsetInputChange: (value: string) => void; + onOffsetCommit: () => void; + onQueryCommit: () => void; } function FilterSection({ @@ -45,7 +49,7 @@ function FilterSection({ ); } -export function NewFilter({ +export function MessageTableFilter({ partitions, selectedPartition, onPartitionChange, @@ -57,9 +61,11 @@ export function NewFilter({ onQueryChange, where, onWhereChange, - offset, - onOffsetChange, -}: NewFilterProps) { + offsetInput, + onOffsetInputChange, + onOffsetCommit, + onQueryCommit, +}: MessageTableFilterProps) { const showSearch = selectedFrom !== 'latest'; const { t } = useTranslation(); @@ -86,10 +92,14 @@ export function NewFilter({ - onOffsetChange(value ? parseInt(value, 10) : undefined) - } + value={offsetInput} + onChange={(_, value) => onOffsetInputChange(value)} + onBlur={onOffsetCommit} + onKeyDown={(event) => { + if (event.key === 'Enter') { + onOffsetCommit(); + } + }} placeholder={t('topics.messages.filter.offsetPlaceholder')} aria-label={t('topics.messages.filter.offsetAriaLabel')} /> @@ -116,6 +126,12 @@ export function NewFilter({ placeholder={t( 'topics.messages.advancedSearch.queryHelper' )} + onBlur={onQueryCommit} + onKeyDown={(event) => { + if (event.key === 'Enter') { + onQueryCommit(); + } + }} style={{ width: '260px' }} /> diff --git a/api/src/main/webui/src/components/kafka/topics/TopicsFilter/PartitionSelector.stories.tsx b/api/src/main/webui/src/components/kafka/topics/TopicsFilter/PartitionSelector.stories.tsx deleted file mode 100644 index 86dc96cc8..000000000 --- a/api/src/main/webui/src/components/kafka/topics/TopicsFilter/PartitionSelector.stories.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react'; -import { NewFilter } from './MessageTableFilter'; - -const meta: Meta = { - component: NewFilter, - title: 'Kafka/Messages/Filters/NewFilter', -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - partitions: [0, 1, 2, 3, 4], - onPartitionChange: (partition) => { - console.log('Selected partition:', partition); - }, - }, -}; - -export const SelectedPartition: Story = { - args: { - partitions: [0, 1, 2, 3, 4], - selectedPartition: 2, - onPartitionChange: (partition) => { - console.log('Selected partition:', partition); - }, - }, -}; - -export const SinglePartition: Story = { - args: { - partitions: [0], - selectedPartition: 0, - onPartitionChange: (partition) => { - console.log('Selected partition:', partition); - }, - }, -}; - -export const NoPartitions: Story = { - args: { - partitions: [], - onPartitionChange: (partition) => { - console.log('Selected partition:', partition); - }, - }, -}; diff --git a/api/src/main/webui/src/components/kafka/topics/TopicsFilter/whereSelector.stories.tsx b/api/src/main/webui/src/components/kafka/topics/TopicsFilter/whereSelector.stories.tsx new file mode 100644 index 000000000..30775b6a4 --- /dev/null +++ b/api/src/main/webui/src/components/kafka/topics/TopicsFilter/whereSelector.stories.tsx @@ -0,0 +1,16 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { WhereSelector } from './whereSelector'; + +const meta: Meta = { + component: WhereSelector, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + where: undefined, + onWhereChange: () => {}, + }, +}; diff --git a/api/src/main/webui/src/components/kafka/topics/TopicsFilter/whereSelector.tsx b/api/src/main/webui/src/components/kafka/topics/TopicsFilter/whereSelector.tsx index e296a3414..bc8a57ac3 100644 --- a/api/src/main/webui/src/components/kafka/topics/TopicsFilter/whereSelector.tsx +++ b/api/src/main/webui/src/components/kafka/topics/TopicsFilter/whereSelector.tsx @@ -16,6 +16,8 @@ interface WhereSelectorProps { ouiaId?: string; } +type WhereValue = 'everywhere' | 'key' | 'headers' | 'value'; + export function WhereSelector({ where, onWhereChange, @@ -24,40 +26,56 @@ export function WhereSelector({ const { t } = useTranslation(); const [isOpen, setIsOpen] = useState(false); - const labels: Record<'everywhere' | 'key' | 'headers' | 'value', string> = { - everywhere: 'Anywhere', - key: t('topics.messages.advancedSearch.whereKey'), - headers: t('topics.messages.advancedSearch.whereHeaders'), - value: t('topics.messages.advancedSearch.whereValue'), - }; + const options: { value: WhereValue; label: string }[] = [ + { + value: 'everywhere', + label: t('topics.messages.advancedSearch.whereEverywhere'), + }, + { + value: 'key', + label: t('topics.messages.advancedSearch.whereKey'), + }, + { + value: 'headers', + label: t('topics.messages.advancedSearch.whereHeaders'), + }, + { + value: 'value', + label: t('topics.messages.advancedSearch.whereValue'), + }, + ]; + + const selected = where ?? 'everywhere'; + const selectedLabel = + options.find((option) => option.value === selected)?.label ?? + options[0].label; return ( { + setZone(selection as Zone); + setIsZoneOpen(false); + }} + toggle={(toggleRef: React.Ref) => ( + setIsZoneOpen((o) => !o)} + > + {zone === 'UTC' ? 'UTC' : 'Local time'} + + )} + > + + UTC + Local time + + + + setPickedDate(date)} + /> + + + { + setTime(next); + setTimeTouched(false); + }} + onBlur={() => setTimeTouched(true)} + /> +
+ {' '} + +
+ + + + + ); + + return ( + document.body} + placement="bottom-start" + /> + ); +} diff --git a/api/src/main/webui/src/components/kafka/topics/MessageFilter/messageFilter.ts b/api/src/main/webui/src/components/kafka/topics/MessageFilter/messageFilter.ts new file mode 100644 index 000000000..0abe22509 --- /dev/null +++ b/api/src/main/webui/src/components/kafka/topics/MessageFilter/messageFilter.ts @@ -0,0 +1,86 @@ +import { SearchParams } from '@/api/types'; +import { FromOptions } from './FetchFromSelector'; +import { WhereOption } from './whereSelector'; + +export const DEFAULT_LIMIT = 50; + +export interface FilterState { + partition?: number; + from: FromOptions; + offset?: number; + limit: number; + query: string; + where: WhereOption; + timestamp?: string; +} + +const RELATIVE_MINUTES: Partial> = { + last5: 5, + last10: 10, + last15: 15, +}; + +/** Applies a change and enforces the rules between fields in one place. */ +export function applyPatch( + current: FilterState, + patch: Partial +): FilterState { + const next = { ...current, ...patch }; + if (next.from !== 'offset') { + next.offset = undefined; + } + if (next.from !== 'timestamp') next.timestamp = undefined; + if (next.from === 'latest') { + // The search box is hidden for "latest", so don't keep a hidden filter + next.query = ''; + next.where = undefined; + } + return next; +} + +export function isSameFilter(a: FilterState, b: FilterState) { + return (Object.keys(b) as (keyof FilterState)[]).every((k) => a[k] === b[k]); +} + +export function isValidOffset(raw: string) { + const s = raw.trim(); + return s === '' || (/^\d+$/.test(s) && Number.isSafeInteger(Number(s))); +} + +export function parseOffset(raw: string) { + const s = raw.trim(); + return s === '' ? undefined : Number(s); +} + +function buildFrom({ + from, + offset, + timestamp, +}: FilterState): SearchParams['from'] { + const minutes = RELATIVE_MINUTES[from]; + if (minutes !== undefined) { + return { + type: 'timestamp', + value: new Date(Date.now() - minutes * 60_000).toISOString(), + }; + } + if (from === 'timestamp' && timestamp !== undefined) { + return { type: 'timestamp', value: timestamp }; + } + if (from === 'offset' && offset !== undefined) { + return { type: 'offset', value: offset }; + } + return { type: 'latest' }; +} + +export function buildSearchParams(state: FilterState): SearchParams { + const text = state.query.trim(); + return { + query: text + ? { value: text, where: state.where ?? 'everywhere' } + : undefined, + partition: state.partition, + from: buildFrom(state), + limit: state.limit, + }; +} diff --git a/api/src/main/webui/src/components/kafka/topics/TopicsFilter/whereSelector.stories.tsx b/api/src/main/webui/src/components/kafka/topics/MessageFilter/whereSelector.stories.tsx similarity index 100% rename from api/src/main/webui/src/components/kafka/topics/TopicsFilter/whereSelector.stories.tsx rename to api/src/main/webui/src/components/kafka/topics/MessageFilter/whereSelector.stories.tsx diff --git a/api/src/main/webui/src/components/kafka/topics/TopicsFilter/whereSelector.tsx b/api/src/main/webui/src/components/kafka/topics/MessageFilter/whereSelector.tsx similarity index 100% rename from api/src/main/webui/src/components/kafka/topics/TopicsFilter/whereSelector.tsx rename to api/src/main/webui/src/components/kafka/topics/MessageFilter/whereSelector.tsx diff --git a/api/src/main/webui/src/components/kafka/topics/TopicsFilter/MessageTableFilter.stories.tsx b/api/src/main/webui/src/components/kafka/topics/TopicsFilter/MessageTableFilter.stories.tsx deleted file mode 100644 index 8fb18af7d..000000000 --- a/api/src/main/webui/src/components/kafka/topics/TopicsFilter/MessageTableFilter.stories.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react'; -import { MessageTableFilter } from './MessageTableFilter'; - -const meta: Meta = { - component: MessageTableFilter, -}; - -export default meta; -type Story = StoryObj; - -const defaultArgs = { - partitions: [0, 1, 2, 3, 4], - selectedPartition: undefined, - selectedFrom: 'latest' as const, - limit: 50, - query: '', - where: undefined, -}; - -export const Latest: Story = { - args: { - ...defaultArgs, - selectedFrom: 'latest', - }, -}; - -export const Last5Minutes: Story = { - args: { - ...defaultArgs, - selectedFrom: 'last5', - }, -}; - -export const Last10Minutes: Story = { - args: { - ...defaultArgs, - selectedFrom: 'last10', - }, -}; - -export const Last15Minutes: Story = { - args: { - ...defaultArgs, - selectedFrom: 'last15', - }, -}; - -export const FetchFromOffset: Story = { - args: { - ...defaultArgs, - selectedFrom: 'offset', - offset: 123, - }, -}; - -export const FetchFromOffsetWithoutValue: Story = { - args: { - ...defaultArgs, - selectedFrom: 'offset', - offset: undefined, - }, -}; - -export const FetchFromTimestamp: Story = { - args: { - ...defaultArgs, - selectedFrom: 'timestamp', - query: 'payment', - }, -}; - -export const SelectedPartition: Story = { - args: { - ...defaultArgs, - selectedPartition: 2, - selectedFrom: 'last10', - }, -}; - -export const SinglePartition: Story = { - args: { - ...defaultArgs, - partitions: [0], - selectedPartition: 0, - }, -}; - -export const NoPartitions: Story = { - args: { - ...defaultArgs, - partitions: [], - }, -}; - -export const LargeLimit: Story = { - args: { - ...defaultArgs, - limit: 500, - selectedFrom: 'last10', - }, -}; diff --git a/api/src/main/webui/src/components/kafka/topics/TopicsFilter/MessageTableFilter.tsx b/api/src/main/webui/src/components/kafka/topics/TopicsFilter/MessageTableFilter.tsx deleted file mode 100644 index f18ea7ed3..000000000 --- a/api/src/main/webui/src/components/kafka/topics/TopicsFilter/MessageTableFilter.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import { - Flex, - FlexItem, - TextInput, - Toolbar, - ToolbarContent, - ToolbarItem, -} from '@patternfly/react-core'; -import { PartitionSelector } from './PartitionSelector'; -import { FetchFromSelector, FromOptions } from './FetchFromSelector'; -import { LimitSelect } from './LimitSelector'; -import { WhereOption, WhereSelector } from './whereSelector'; -import { useTranslation } from 'react-i18next'; - -interface MessageTableFilterProps { - partitions: number[]; - selectedPartition?: number; - onPartitionChange: (partition?: number) => void; - selectedFrom: FromOptions; - onFromChange: (from: FromOptions) => void; - limit: number; - onLimitChange: (limit: number) => void; - query: string; - onQueryChange: (query: string) => void; - where: WhereOption; - onWhereChange: (where: WhereOption) => void; - offset?: number; - onOffsetChange: (offset?: number) => void; - offsetInput: string; - onOffsetInputChange: (value: string) => void; - onOffsetCommit: () => void; - onQueryCommit: () => void; -} - -function FilterSection({ - title, - children, -}: { - title: string; - children: React.ReactNode; -}) { - return ( - - - {title} - {children} - - - ); -} - -export function MessageTableFilter({ - partitions, - selectedPartition, - onPartitionChange, - selectedFrom, - onFromChange, - limit, - onLimitChange, - query, - onQueryChange, - where, - onWhereChange, - offsetInput, - onOffsetInputChange, - onOffsetCommit, - onQueryCommit, -}: MessageTableFilterProps) { - const showSearch = selectedFrom !== 'latest'; - const { t } = useTranslation(); - - return ( - - - - - - - - - - - - {selectedFrom === 'offset' && ( - - onOffsetInputChange(value)} - onBlur={onOffsetCommit} - onKeyDown={(event) => { - if (event.key === 'Enter') { - onOffsetCommit(); - } - }} - placeholder={t('topics.messages.filter.offsetPlaceholder')} - aria-label={t('topics.messages.filter.offsetAriaLabel')} - /> - - )} - - - - - - {showSearch && ( - <> - - - - - - - onQueryChange(value)} - placeholder={t( - 'topics.messages.advancedSearch.queryHelper' - )} - onBlur={onQueryCommit} - onKeyDown={(event) => { - if (event.key === 'Enter') { - onQueryCommit(); - } - }} - style={{ width: '260px' }} - /> - - - - - )} - - - ); -} diff --git a/api/src/main/webui/src/i18n/messages/en.json b/api/src/main/webui/src/i18n/messages/en.json index fed30ff7e..ad3c4d763 100644 --- a/api/src/main/webui/src/i18n/messages/en.json +++ b/api/src/main/webui/src/i18n/messages/en.json @@ -272,6 +272,12 @@ "valueFormat": "Value format" }, "filter": { + "hasTheWords": "Has the words", + "partition": "Partition", + "fetchFrom": "Fetch from", + "limit": "limit", + "search": "Search", + "timestamp": "Timestamp", "epoch": "From Unix timestamp", "epochAriaLabel": "Specify Unix timestamp", "epochPlaceholder": "Specify Unix timestamp", @@ -279,7 +285,6 @@ "offset": "From offset", "offsetAriaLabel": "Specify offset", "offsetPlaceholder": "Specify offset", - "timestamp": "From timestamp", "timestampAriaLabel": "Specify timestamp" }, "tooltip": { @@ -299,7 +304,6 @@ }, "advancedSearch": { "dialogLabel": "Search messages", - "hasTheWords": "Has the words", "queryHelper": "Enter search term...", "inPartition": "In partition", "search": "Search", diff --git a/api/src/main/webui/src/pages/kafka/topics/detail/TopicMessagesTab.tsx b/api/src/main/webui/src/pages/kafka/topics/detail/TopicMessagesTab.tsx index 849d19fc7..8857acbc6 100644 --- a/api/src/main/webui/src/pages/kafka/topics/detail/TopicMessagesTab.tsx +++ b/api/src/main/webui/src/pages/kafka/topics/detail/TopicMessagesTab.tsx @@ -24,6 +24,7 @@ import { ToolbarContent, ToolbarItem, Tooltip, + ToolbarGroup, } from '@patternfly/react-core'; import { ExclamationCircleIcon, ColumnsIcon } from '@patternfly/react-icons'; import { useMessages } from '@/api/hooks/useMessages'; @@ -34,7 +35,7 @@ import { MessagesTable } from '@/components/kafka/topics/MessagesTable'; import { MessageDetails } from '@/components/kafka/topics/MessageDetails'; import { AdvancedSearch } from '@/components/kafka/topics/AdvancedSearch'; import { Column, ColumnsModal } from '@/components/kafka/topics/ColumnsModal'; -import { FromOptions } from '@/components/kafka/topics/TopicsFilter/FetchFromSelector'; +import { FromOptions } from '@/components/kafka/topics/MessageFilter/FetchFromSelector'; const defaultColumns: Column[] = [ 'offset-partition', @@ -84,6 +85,7 @@ export function TopicMessagesTab() { const epochParam = searchParams.get('epoch'); const epoch = epochParam ? parseInt(epochParam) : undefined; const [showColumnsModal, setShowColumnsModal] = useState(false); + const [resetCount, setResetCount] = useState(0); // Fetch topic info to get partition count const { data: topic, isLoading: isLoadingTopic } = useTopic( @@ -192,6 +194,7 @@ export function TopicMessagesTab() { const handleReset = useCallback(() => { setSearchParams(new URLSearchParams()); + setResetCount((c) => c + 1); }, [setSearchParams]); // Load saved column preferences from localStorage @@ -270,19 +273,25 @@ export function TopicMessagesTab() { - - - + + + + +