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..cde0cffc0 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,14 @@ -/** - * 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 { useMemo, useState } from 'react'; import { SearchParams } from '@/api/types'; -import { parseSearchInput } from './parseSearchInput'; +import { FromOptions } from './MessageFilter/FetchFromSelector'; +import { + applyPatch, + buildSearchParams, + DEFAULT_LIMIT, + FilterState, + isSameFilter, +} from './MessageFilter/messageFilter'; +import { MessageTableFilter } from './MessageFilter/MessageTableFilter'; interface AdvancedSearchProps { partitions: number; @@ -40,465 +16,70 @@ interface AdvancedSearchProps { filterWhere?: 'key' | 'headers' | 'value'; filterPartition?: number; filterOffset?: number; - filterTimestamp?: string; - filterEpoch?: number; filterLimit?: number | 'continuously'; - onSearch: (params: SearchParams) => void; + filterFrom?: FromOptions; + onSearch: (params: SearchParams, selectedFrom: FromOptions) => void; + filterTimestamp?: string; } -type FromCategory = 'offset' | 'timestamp' | 'epoch' | 'latest'; -type RetrieveCategory = 'limit' | 'continuously'; - export function AdvancedSearch({ partitions, filterQuery, filterWhere, filterPartition, filterOffset, - filterTimestamp, - filterEpoch, filterLimit, + filterFrom, onSearch, + filterTimestamp, }: 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 [fromEpoch, setFromEpoch] = useState(filterEpoch); - const [isFromOpen, setIsFromOpen] = useState(false); - const [, startTransition] = useTransition(); - - // Retrieve group state - const [retrieveCategory, setRetrieveCategory] = useState( - filterLimit === 'continuously' ? 'continuously' : 'limit' + // Initial state comes from the URL; the page already loaded it, so no search here. + const [filter, setFilter] = useState(() => + applyPatch( + { + partition: filterPartition, + from: + filterFrom ?? + (filterOffset !== undefined + ? 'offset' + : filterTimestamp !== undefined + ? 'timestamp' + : 'latest'), + offset: filterOffset, + timestamp: filterTimestamp, + limit: typeof filterLimit === 'number' ? filterLimit : DEFAULT_LIMIT, + query: filterQuery ?? '', + where: filterWhere, + }, + {} + ) ); - 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 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); - }; - - 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); - }; - - const searchString = buildSearchInputValue(); - const [searchInputValue, setSearchInputValue] = useState(searchString); + const partitionList = useMemo( + () => Array.from({ length: partitions }, (_, i) => i), + [partitions] + ); - const anchorRef = useRef(null); - const [panelStyle, setPanelStyle] = useState({}); + const handleChange = (patch: Partial) => { + const next = applyPatch(filter, patch); + if (isSameFilter(filter, next)) return; + setFilter(next); - 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, - }); + if ( + (next.from === 'offset' && next.offset === undefined) || + (next.from === 'timestamp' && next.timestamp === undefined) + ) { + return; } - }, []); - 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]); + onSearch(buildSearchParams(next), next.from); + }; 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/MessageFilter/CommitInput.tsx b/api/src/main/webui/src/components/kafka/topics/MessageFilter/CommitInput.tsx new file mode 100644 index 000000000..ff18c30b6 --- /dev/null +++ b/api/src/main/webui/src/components/kafka/topics/MessageFilter/CommitInput.tsx @@ -0,0 +1,41 @@ +import { useState } from 'react'; +import { TextInput, TextInputProps } from '@patternfly/react-core'; + +interface CommitInputProps + extends Omit { + value: string; + onCommit: (value: string) => void; + isValid?: (value: string) => boolean; +} + +export function CommitInput({ + value, + onCommit, + isValid = () => true, + ...props +}: CommitInputProps) { + const [draft, setDraft] = useState(value); + const [prevValue, setPrevValue] = useState(value); + if (value !== prevValue) { + setPrevValue(value); + setDraft(value); + } + + const valid = isValid(draft); + const commit = () => { + if (valid) onCommit(draft); + }; + + return ( + setDraft(next)} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === 'Enter') commit(); + }} + /> + ); +} diff --git a/api/src/main/webui/src/components/kafka/topics/MessageFilter/FetchFromSelector.tsx b/api/src/main/webui/src/components/kafka/topics/MessageFilter/FetchFromSelector.tsx new file mode 100644 index 000000000..836d65e2c --- /dev/null +++ b/api/src/main/webui/src/components/kafka/topics/MessageFilter/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/MessageFilter/LimitSelector.tsx b/api/src/main/webui/src/components/kafka/topics/MessageFilter/LimitSelector.tsx new file mode 100644 index 000000000..10df585d8 --- /dev/null +++ b/api/src/main/webui/src/components/kafka/topics/MessageFilter/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/MessageFilter/MessageTableFilter.stories.tsx b/api/src/main/webui/src/components/kafka/topics/MessageFilter/MessageTableFilter.stories.tsx new file mode 100644 index 000000000..189c3117b --- /dev/null +++ b/api/src/main/webui/src/components/kafka/topics/MessageFilter/MessageTableFilter.stories.tsx @@ -0,0 +1,127 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import { MessageTableFilter } from './MessageTableFilter'; +import { FilterState } from './messageFilter'; + +const meta: Meta = { + component: MessageTableFilter, +}; + +export default meta; +type Story = StoryObj; + +const defaultValue: FilterState = { + partition: undefined, + from: 'latest', + offset: undefined, + limit: 50, + query: '', + where: undefined, +}; + +interface FilterStoryProps { + partitions?: number[]; + initialValue?: FilterState; +} + +function FilterStory({ + partitions = [0, 1, 2, 3, 4], + initialValue = defaultValue, +}: FilterStoryProps) { + const [value, setValue] = useState(initialValue); + + return ( + setValue((current) => ({ ...current, ...patch }))} + /> + ); +} + +export const Latest: Story = { + render: () => , +}; + +export const Last5Minutes: Story = { + render: () => ( + + ), +}; + +export const Last10Minutes: Story = { + render: () => ( + + ), +}; + +export const Last15Minutes: Story = { + render: () => ( + + ), +}; + +export const FetchFromOffset: Story = { + render: () => ( + + ), +}; + +export const FetchFromOffsetWithoutValue: Story = { + render: () => ( + + ), +}; + +export const FetchFromTimestamp: Story = { + render: () => ( + + ), +}; + +export const SelectedPartition: Story = { + render: () => ( + + ), +}; + +export const SinglePartition: Story = { + render: () => ( + + ), +}; + +export const NoPartitions: Story = { + render: () => , +}; + +export const LargeLimit: Story = { + render: () => ( + + ), +}; diff --git a/api/src/main/webui/src/components/kafka/topics/MessageFilter/MessageTableFilter.tsx b/api/src/main/webui/src/components/kafka/topics/MessageFilter/MessageTableFilter.tsx new file mode 100644 index 000000000..dcb324e0a --- /dev/null +++ b/api/src/main/webui/src/components/kafka/topics/MessageFilter/MessageTableFilter.tsx @@ -0,0 +1,115 @@ +import { Flex, FlexItem, ToolbarItem } from '@patternfly/react-core'; +import { useTranslation } from 'react-i18next'; +import { PartitionSelector } from './PartitionSelector'; +import { FetchFromSelector } from './FetchFromSelector'; +import { LimitSelect } from './LimitSelector'; +import { WhereSelector } from './whereSelector'; +import { CommitInput } from './CommitInput'; +import { FilterState, isValidOffset, parseOffset } from './messageFilter'; +import { TimestampPicker } from './TimeStampPicker'; + +interface MessageTableFilterProps { + partitions: number[]; + value: FilterState; + onChange: (patch: Partial) => void; +} + +function FilterSection({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( + + + {title} + {children} + + + ); +} + +export function MessageTableFilter({ + partitions, + value, + onChange, +}: MessageTableFilterProps) { + const { t } = useTranslation(); + const { partition, from, offset, limit, query, where, timestamp } = value; + + return ( + <> + + onChange({ partition: next })} + /> + + + + + + onChange({ from: next })} + /> + + {from === 'offset' && ( + + onChange({ offset: parseOffset(raw) })} + placeholder={t('topics.messages.filter.offsetPlaceholder')} + aria-label={t('topics.messages.filter.offsetAriaLabel')} + /> + + )} + {from === 'timestamp' && ( + + onChange({ timestamp: ts })} + /> + + )} + + + + onChange({ limit: next })} + /> + + {from !== 'latest' && ( + + + + onChange({ where: next })} + /> + + + onChange({ query: text.trim() })} + style={{ width: '260px' }} + /> + + + + )} + + ); +} diff --git a/api/src/main/webui/src/components/kafka/topics/MessageFilter/PartitionSelector.tsx b/api/src/main/webui/src/components/kafka/topics/MessageFilter/PartitionSelector.tsx new file mode 100644 index 000000000..52addbcfe --- /dev/null +++ b/api/src/main/webui/src/components/kafka/topics/MessageFilter/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/MessageFilter/TimeStampPicker.stories.tsx b/api/src/main/webui/src/components/kafka/topics/MessageFilter/TimeStampPicker.stories.tsx new file mode 100644 index 000000000..8c474449e --- /dev/null +++ b/api/src/main/webui/src/components/kafka/topics/MessageFilter/TimeStampPicker.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { TimestampPicker } from './TimeStampPicker'; + +const meta: Meta = { + component: TimestampPicker, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + value: undefined, + onCommit: (value) => console.log('Timestamp committed:', value), + }, +}; + +export const WithTimestamp: Story = { + args: { + value: '2026-10-15T10:30:45.123+05:30', + onCommit: (value) => console.log('Timestamp committed:', value), + }, +}; + +export const WithUTCTimestamp: Story = { + args: { + value: '2026-10-15T05:00:00.000Z', + onCommit: (value) => console.log('Timestamp committed:', value), + }, +}; diff --git a/api/src/main/webui/src/components/kafka/topics/MessageFilter/TimeStampPicker.tsx b/api/src/main/webui/src/components/kafka/topics/MessageFilter/TimeStampPicker.tsx new file mode 100644 index 000000000..bdfafcdbc --- /dev/null +++ b/api/src/main/webui/src/components/kafka/topics/MessageFilter/TimeStampPicker.tsx @@ -0,0 +1,249 @@ +import { useEffect, useRef, useState } from 'react'; +import { + Button, + CalendarMonth, + Flex, + FlexItem, + InputGroup, + InputGroupItem, + MenuToggle, + MenuToggleElement, + Panel, + PanelMain, + PanelMainBody, + Popper, + Select, + SelectList, + SelectOption, + TextInput, +} from '@patternfly/react-core'; +import OutlinedClockIcon from '@patternfly/react-icons/dist/esm/icons/outlined-clock-icon'; +import { + buildTimestamp, + defaultDraft, + Draft, + normalizeTimestamp, + parseTime, + stripOffset, + toDraft, + Zone, +} from '@/utils/timestamp'; + +interface TimestampPickerProps { + value?: string; + onCommit: (value: string | undefined) => void; +} + +export function TimestampPicker({ value, onCommit }: TimestampPickerProps) { + const [text, setText] = useState(value ?? ''); + const [prevValue, setPrevValue] = useState(value); + + const [draft, setDraft] = useState(defaultDraft); + + const [isOpen, setIsOpen] = useState(false); + const [isZoneOpen, setIsZoneOpen] = useState(false); + + const [textError, setTextError] = useState(false); + const [timeError, setTimeError] = useState(false); + + const triggerRef = useRef(null); + const popperRef = useRef(null); + + if (value !== prevValue) { + setPrevValue(value); + setText(value ?? ''); + } + + useEffect(() => { + if (!isOpen) return; + const onClick = (e: MouseEvent) => { + const target = e.target as Node; + if ( + !triggerRef.current?.contains(target) && + !popperRef.current?.contains(target) + ) { + setIsOpen(false); + } + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setIsOpen(false); + }; + document.addEventListener('mousedown', onClick); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onClick); + document.removeEventListener('keydown', onKey); + }; + }, [isOpen]); + + const patchDraft = (patch: Partial) => + setDraft((d) => ({ ...d, ...patch })); + + const commit = (next: string | undefined) => { + setText(next ?? ''); + onCommit(next); + }; + + const openPicker = () => { + setDraft(toDraft(text) ?? defaultDraft()); + setTimeError(false); + setIsOpen(true); + }; + + const changeZone = (zone: Zone) => { + patchDraft({ + zone, + time: zone === 'UTC' ? stripOffset(draft.time) : draft.time, + }); + setTimeError(false); + }; + + const commitText = () => { + const trimmed = text.trim(); + if (trimmed === '') return commit(undefined); + + const normalized = normalizeTimestamp(trimmed); + if (normalized) commit(normalized); + else setTextError(true); + }; + + const result = buildTimestamp(draft); + + const confirm = () => { + if (!result) return; + commit(result); + setIsOpen(false); + }; + + const normalizeTimeField = () => { + const parsed = parseTime(draft.time, draft.zone); + setTimeError(!parsed); + if (parsed) patchDraft({ time: `${parsed.time}${parsed.offset ?? ''}` }); + }; + + const trigger = ( +
+ + + + +
+ ); + + const popper = ( +
+ + + + + + patchDraft({ date })} + /> + + + + + + + { + patchDraft({ time }); + setTimeError(false); + }} + onBlur={normalizeTimeField} + /> + + + +
+ {' '} + +
+
+
+
+
+ ); + + 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/MessageFilter/whereSelector.stories.tsx b/api/src/main/webui/src/components/kafka/topics/MessageFilter/whereSelector.stories.tsx new file mode 100644 index 000000000..30775b6a4 --- /dev/null +++ b/api/src/main/webui/src/components/kafka/topics/MessageFilter/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/MessageFilter/whereSelector.tsx b/api/src/main/webui/src/components/kafka/topics/MessageFilter/whereSelector.tsx new file mode 100644 index 000000000..bc8a57ac3 --- /dev/null +++ b/api/src/main/webui/src/components/kafka/topics/MessageFilter/whereSelector.tsx @@ -0,0 +1,84 @@ +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; +} + +type WhereValue = 'everywhere' | 'key' | 'headers' | 'value'; + +export function WhereSelector({ + where, + onWhereChange, + ouiaId = 'where-selector', +}: WhereSelectorProps) { + const { t } = useTranslation(); + const [isOpen, setIsOpen] = useState(false); + + 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 ( + + ); +} diff --git a/api/src/main/webui/src/i18n/messages/en.json b/api/src/main/webui/src/i18n/messages/en.json index 802fe4230..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": { @@ -289,10 +294,17 @@ "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..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,6 +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/MessageFilter/FetchFromSelector'; const defaultColumns: Column[] = [ 'offset-partition', @@ -46,24 +48,44 @@ 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 fromParam = searchParams.get('from'); + const validFromOptions: FromOptions[] = [ + 'latest', + 'last5', + 'last10', + 'last15', + 'offset', + 'timestamp', + ]; + + const filterFrom: FromOptions | undefined = + fromParam && validFromOptions.includes(fromParam as FromOptions) + ? (fromParam as FromOptions) + : undefined; 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; 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( @@ -98,7 +120,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 +131,60 @@ 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, selectedFrom: FromOptions) => { + const newParams = new URLSearchParams(); + + // Preserve the selected Fetch From option. + newParams.set('from', selectedFrom); + + 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 }); + 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); @@ -158,25 +194,23 @@ export function TopicMessagesTab() { const handleReset = useCallback(() => { setSearchParams(new URLSearchParams()); + setResetCount((c) => c + 1); }, [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 +236,116 @@ 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; +} diff --git a/api/src/main/webui/src/utils/timestamp.ts b/api/src/main/webui/src/utils/timestamp.ts new file mode 100644 index 000000000..34da48c0a --- /dev/null +++ b/api/src/main/webui/src/utils/timestamp.ts @@ -0,0 +1,113 @@ +export type Zone = 'UTC' | 'local'; + +export interface Draft { + date: Date; + time: string; + zone: Zone; +} + +export const DEFAULT_TIME = '00:00:00.000'; + +const OFFSET = '[+-](?:[01]\\d|2[0-3]):[0-5]\\d'; +const HMS = '([01]\\d|2[0-3]):([0-5]\\d):([0-5]\\d)'; + +const TIMESTAMP_RE = new RegExp( + `^(\\d{4})-(\\d{2})-(\\d{2})T${HMS}(?:\\.(\\d{1,3}))?(Z|${OFFSET})$` +); + +const TIME_RE = new RegExp( + `^([01]?\\d|2[0-3]):([0-5]\\d)(?::([0-5]\\d)(?:\\.(\\d{1,3}))?)?\\s*(${OFFSET})?$` +); + +const pad = (n: number) => String(n).padStart(2, '0'); + +function isRealDate(y: number, m: number, d: number) { + const dt = new Date(0); + dt.setUTCFullYear(y, m - 1, d); + return ( + dt.getUTCFullYear() === y && + dt.getUTCMonth() === m - 1 && + dt.getUTCDate() === d + ); +} + +export function normalizeTimestamp(text: string): string | undefined { + const m = TIMESTAMP_RE.exec(text.trim()); + if (!m) return undefined; + + const [, y, mo, d, hh, mm, ss, ms = '', offset] = m; + if (!isRealDate(+y, +mo, +d)) return undefined; + + return `${y}-${mo}-${d}T${hh}:${mm}:${ss}.${ms.padEnd(3, '0')}${offset}`; +} + +export function parseTime(raw: string, zone: Zone) { + const trimmed = raw.trim(); + if (!trimmed) return { time: DEFAULT_TIME, offset: undefined }; + + const m = TIME_RE.exec(trimmed); + if (!m) return undefined; + + const [, h, min, s = '00', ms = '', offset] = m; + if (offset && zone === 'UTC') return undefined; + + return { time: `${pad(+h)}:${min}:${s}.${ms.padEnd(3, '0')}`, offset }; +} + +export function stripOffset(time: string) { + return time.replace(/\s*[+-]\d{2}:\d{2}$/, ''); +} + +export function machineOffset(date: Date, time: string) { + const [hh = 0, mm = 0, ss = 0] = time.split(/[:.]/).map(Number); + const minutes = -new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + hh, + mm, + ss + ).getTimezoneOffset(); + const abs = Math.abs(minutes); + return `${minutes >= 0 ? '+' : '-'}${pad(Math.floor(abs / 60))}:${pad( + abs % 60 + )}`; +} + +export function buildTimestamp({ date, time, zone }: Draft) { + const parsed = parseTime(time, zone); + if (!parsed) return undefined; + + const offset = + zone === 'UTC' ? 'Z' : parsed.offset ?? machineOffset(date, parsed.time); + const day = `${String(date.getFullYear()).padStart(4, '0')}-${pad( + date.getMonth() + 1 + )}-${pad(date.getDate())}`; + + return normalizeTimestamp(`${day}T${parsed.time}${offset}`); +} + +export function toDraft(text: string | undefined): Draft | undefined { + const ts = text ? normalizeTimestamp(text) : undefined; + if (!ts) return undefined; + + const [y, mo, d] = ts.slice(0, 10).split('-').map(Number); + const time = ts.slice(11, 23); + const offset = ts.slice(23); + const isUtc = offset === 'Z' || offset === '+00:00'; + + return { + date: new Date(y, mo - 1, d), + time: isUtc ? time : `${time}${offset}`, + zone: isUtc ? 'UTC' : 'local', + }; +} + +export function defaultDraft(): Draft { + const now = new Date(); + return { + date: new Date(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()), + time: DEFAULT_TIME, + zone: 'UTC', + }; +}