From 3cec48c1063513f78e722a87eb11daea568d6e15 Mon Sep 17 00:00:00 2001 From: Maksym Yadlovskyi Date: Thu, 9 Apr 2026 09:49:56 +0200 Subject: [PATCH 1/6] Add new error types to validate time range --- .../engine/validation/TimeRangeValidator.java | 15 +++++++---- .../errors/QueryTimeRangeLimitError.java | 27 +++++++++++++++++++ .../views/search/errors/SearchError.java | 2 ++ .../errors/SearchTypeTimeRangeLimitError.java | 27 +++++++++++++++++++ graylog2-web-interface/src/views/Constants.ts | 2 ++ .../src/views/components/SearchBar.tsx | 10 ++++++- 6 files changed, 77 insertions(+), 6 deletions(-) create mode 100644 graylog2-server/src/main/java/org/graylog/plugins/views/search/errors/QueryTimeRangeLimitError.java create mode 100644 graylog2-server/src/main/java/org/graylog/plugins/views/search/errors/SearchTypeTimeRangeLimitError.java diff --git a/graylog2-server/src/main/java/org/graylog/plugins/views/search/engine/validation/TimeRangeValidator.java b/graylog2-server/src/main/java/org/graylog/plugins/views/search/engine/validation/TimeRangeValidator.java index f187f4093604..0cf3c3604cb7 100644 --- a/graylog2-server/src/main/java/org/graylog/plugins/views/search/engine/validation/TimeRangeValidator.java +++ b/graylog2-server/src/main/java/org/graylog/plugins/views/search/engine/validation/TimeRangeValidator.java @@ -22,9 +22,9 @@ import org.graylog.plugins.views.search.Search; import org.graylog.plugins.views.search.SearchType; import org.graylog.plugins.views.search.engine.SearchConfig; -import org.graylog.plugins.views.search.errors.QueryError; +import org.graylog.plugins.views.search.errors.QueryTimeRangeLimitError; import org.graylog.plugins.views.search.errors.SearchError; -import org.graylog.plugins.views.search.errors.SearchTypeError; +import org.graylog.plugins.views.search.errors.SearchTypeTimeRangeLimitError; import org.graylog.plugins.views.search.permissions.SearchUser; import org.graylog.plugins.views.search.searchtypes.DataLakeSearchType; import org.graylog2.plugin.indexer.searches.timeranges.TimeRange; @@ -49,7 +49,7 @@ private Stream validateQueryTimeRange(Query query, SearchConfig con .flatMap(timeRangeLimit -> Optional.ofNullable(query.timerange()) .filter(tr -> tr.getFrom() != null && tr.getTo() != null) // TODO: is this check necessary? .filter(tr -> isOutOfLimit(tr, timeRangeLimit))) - .map(tr -> new QueryError(query, "Search out of allowed time range limit", true)); + .map(tr -> new QueryTimeRangeLimitError(query, "Search out of allowed time range limit", true)); final Stream searchTypeErrors = query.searchTypes() .stream() @@ -58,12 +58,17 @@ private Stream validateQueryTimeRange(Query query, SearchConfig con return Stream.concat(queryError.map(Stream::of).orElseGet(Stream::empty), searchTypeErrors); } - private Optional validateSearchType(Query query, SearchType searchType, SearchConfig searchConfig) { + private Optional validateSearchType(Query query, SearchType searchType, SearchConfig searchConfig) { return searchConfig.getQueryTimeRangeLimit() .flatMap(configuredTimeLimit -> searchType.timerange() // TODO: what if there is no timerange for the type but there is a global limit? .map(tr -> tr.effectiveTimeRange(query, searchType)) .filter(tr -> isOutOfLimit(tr, configuredTimeLimit)) - .map(tr -> new SearchTypeError(query, searchType.id(), "Search type '" + searchType.type() + "' out of allowed time range limit", true))); + .map(tr -> new SearchTypeTimeRangeLimitError( + query, + searchType.id(), + "Search type '" + searchType.type() + "' out of allowed time range limit", + true + ))); } boolean isOutOfLimit(TimeRange timeRange, Period limit) { diff --git a/graylog2-server/src/main/java/org/graylog/plugins/views/search/errors/QueryTimeRangeLimitError.java b/graylog2-server/src/main/java/org/graylog/plugins/views/search/errors/QueryTimeRangeLimitError.java new file mode 100644 index 000000000000..05c6bde1dfab --- /dev/null +++ b/graylog2-server/src/main/java/org/graylog/plugins/views/search/errors/QueryTimeRangeLimitError.java @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +package org.graylog.plugins.views.search.errors; + +import org.graylog.plugins.views.search.Query; + +import javax.annotation.Nonnull; + +public class QueryTimeRangeLimitError extends QueryError { + public QueryTimeRangeLimitError(@Nonnull Query query, @Nonnull String description, boolean fatal) { + super(query, description, fatal); + } +} diff --git a/graylog2-server/src/main/java/org/graylog/plugins/views/search/errors/SearchError.java b/graylog2-server/src/main/java/org/graylog/plugins/views/search/errors/SearchError.java index 3fade51f028b..d3d477f659b2 100644 --- a/graylog2-server/src/main/java/org/graylog/plugins/views/search/errors/SearchError.java +++ b/graylog2-server/src/main/java/org/graylog/plugins/views/search/errors/SearchError.java @@ -24,7 +24,9 @@ @JsonSubTypes({ @JsonSubTypes.Type(name = "query", value = QueryError.class), + @JsonSubTypes.Type(name = "query_time_range_limit", value = QueryTimeRangeLimitError.class), @JsonSubTypes.Type(name = "search_type", value = SearchTypeError.class), + @JsonSubTypes.Type(name = "search_type_time_range_limit", value = SearchTypeTimeRangeLimitError.class), @JsonSubTypes.Type(name = "unbound_parameter", value = UnboundParameterError.class), @JsonSubTypes.Type(name = "result_window_limit", value = ResultWindowLimitError.class), @JsonSubTypes.Type(name = "search_type_aborted", value = SearchTypeAbortedError.class), diff --git a/graylog2-server/src/main/java/org/graylog/plugins/views/search/errors/SearchTypeTimeRangeLimitError.java b/graylog2-server/src/main/java/org/graylog/plugins/views/search/errors/SearchTypeTimeRangeLimitError.java new file mode 100644 index 000000000000..4e4f5e95b7e5 --- /dev/null +++ b/graylog2-server/src/main/java/org/graylog/plugins/views/search/errors/SearchTypeTimeRangeLimitError.java @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +package org.graylog.plugins.views.search.errors; + +import org.graylog.plugins.views.search.Query; + +import javax.annotation.Nonnull; + +public class SearchTypeTimeRangeLimitError extends SearchTypeError { + public SearchTypeTimeRangeLimitError(@Nonnull Query query, @Nonnull String searchTypeId, @Nonnull String description, boolean fatal) { + super(query, searchTypeId, description, fatal); + } +} diff --git a/graylog2-web-interface/src/views/Constants.ts b/graylog2-web-interface/src/views/Constants.ts index 4672de047e29..c87c9386bf34 100644 --- a/graylog2-web-interface/src/views/Constants.ts +++ b/graylog2-web-interface/src/views/Constants.ts @@ -169,3 +169,5 @@ export const keySeparator = '\u2E31'; export const humanSeparator = '-'; export const thresholdsSupportedVisualizations = ['bar', 'area', 'line', 'scatter']; export const multipleValuesActionsSupportedVisualizations = ['bar', 'area', 'line', 'scatter']; +export const QUERY_TIME_RANGE_LIMIT_ERROR_TYPE = 'query_time_range_limit'; +export const SEARCH_TYPE_RANGE_LIMIT_ERROR_TYPE = 'search_type_time_range_limit'; diff --git a/graylog2-web-interface/src/views/components/SearchBar.tsx b/graylog2-web-interface/src/views/components/SearchBar.tsx index b8ccac65b827..126f61bded84 100644 --- a/graylog2-web-interface/src/views/components/SearchBar.tsx +++ b/graylog2-web-interface/src/views/components/SearchBar.tsx @@ -42,6 +42,7 @@ import { newFiltersForQuery, } from 'views/logic/queries/Query'; import type { SearchBarFormValues } from 'views/Constants'; +import { QUERY_TIME_RANGE_LIMIT_ERROR_TYPE, SEARCH_TYPE_RANGE_LIMIT_ERROR_TYPE } from 'views/Constants'; import WidgetFocusContext from 'views/components/contexts/WidgetFocusContext'; import FormWarningsContext from 'contexts/FormWarningsContext'; import FormWarningsProvider from 'contexts/FormWarningsProvider'; @@ -77,6 +78,7 @@ import StreamCategoryFilter from 'views/components/searchbar/StreamCategoryFilte import useAutoRefresh from 'views/hooks/useAutoRefresh'; import useViewsSelector from 'views/stores/useViewsSelector'; import { selectCurrentQueryResults } from 'views/logic/slices/viewSelectors'; +import useSearchResult from 'views/hooks/useSearchResult'; import SearchBarForm from './searchbar/SearchBarForm'; @@ -199,6 +201,7 @@ const SearchBar = ({ onSubmit = defaultProps.onSubmit, scrollContainer }: Props) const currentQuery = useCurrentQuery(); const queryFilters = useQueryFilters(); const results = useViewsSelector(selectCurrentQueryResults); + const searchResult = useSearchResult(); const pluggableSearchBarControls = usePluginEntities('views.components.searchBar'); const initialValues = useInitialFormValues({ queryFilters, currentQuery }); const dispatch = useViewsDispatch(); @@ -218,6 +221,11 @@ const SearchBar = ({ onSubmit = defaultProps.onSubmit, scrollContainer }: Props) const { query } = currentQuery; const limitDuration = moment.duration(config.query_time_range_limit).asSeconds() ?? 0; + const searchResulErrors = searchResult?.result?.errors?.filter((error) => error.queryId === currentQuery?.id) ?? []; + const timeRangeHasErrorInResults = searchResulErrors.some( + ({ type }) => type === QUERY_TIME_RANGE_LIMIT_ERROR_TYPE || type === SEARCH_TYPE_RANGE_LIMIT_ERROR_TYPE, + ); + return ( {({ focusedWidget: { editing } = { editing: false } }) => ( @@ -256,7 +264,7 @@ const SearchBar = ({ onSubmit = defaultProps.onSubmit, scrollContainer }: Props) limitDuration={limitDuration} onChange={(nextTimeRange) => setFieldValue('timerange', nextTimeRange)} value={values?.timerange} - hasErrorOnMount={!!errors.timerange} + hasErrorOnMount={!!errors.timerange || timeRangeHasErrorInResults} moveRangeProps={{ effectiveTimerange: results?.effectiveTimerange, initialTimerange: currentQuery.timerange, From 81d9bb4c2e057f5468432687a5c9a88cc5d3f35b Mon Sep 17 00:00:00 2001 From: Maksym Yadlovskyi Date: Thu, 9 Apr 2026 10:02:41 +0200 Subject: [PATCH 2/6] changelog --- changelog/unreleased/issue-15957.toml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog/unreleased/issue-15957.toml diff --git a/changelog/unreleased/issue-15957.toml b/changelog/unreleased/issue-15957.toml new file mode 100644 index 000000000000..df346a5b9875 --- /dev/null +++ b/changelog/unreleased/issue-15957.toml @@ -0,0 +1,5 @@ +type = "f" +message = "Fix issue when timerage validation didn't run after loading a saved search." + +issues = ["15957"] +pulls = ["25585"] From 7d9598eb473cc2a6d9fc7a4dfa5ae4e5f758f6ca Mon Sep 17 00:00:00 2001 From: Maksym Yadlovskyi Date: Thu, 9 Apr 2026 11:52:15 +0200 Subject: [PATCH 3/6] Add error type check for dashboard widget editing --- .../src/views/components/SearchBar.tsx | 13 ++++----- .../views/components/WidgetQueryControls.tsx | 8 ++++-- .../hooks/useCurrentQuerySearchResulErrors.ts | 27 +++++++++++++++++++ 3 files changed, 38 insertions(+), 10 deletions(-) create mode 100644 graylog2-web-interface/src/views/hooks/useCurrentQuerySearchResulErrors.ts diff --git a/graylog2-web-interface/src/views/components/SearchBar.tsx b/graylog2-web-interface/src/views/components/SearchBar.tsx index 126f61bded84..37c2d96c8c0b 100644 --- a/graylog2-web-interface/src/views/components/SearchBar.tsx +++ b/graylog2-web-interface/src/views/components/SearchBar.tsx @@ -42,7 +42,7 @@ import { newFiltersForQuery, } from 'views/logic/queries/Query'; import type { SearchBarFormValues } from 'views/Constants'; -import { QUERY_TIME_RANGE_LIMIT_ERROR_TYPE, SEARCH_TYPE_RANGE_LIMIT_ERROR_TYPE } from 'views/Constants'; +import { QUERY_TIME_RANGE_LIMIT_ERROR_TYPE } from 'views/Constants'; import WidgetFocusContext from 'views/components/contexts/WidgetFocusContext'; import FormWarningsContext from 'contexts/FormWarningsContext'; import FormWarningsProvider from 'contexts/FormWarningsProvider'; @@ -78,7 +78,7 @@ import StreamCategoryFilter from 'views/components/searchbar/StreamCategoryFilte import useAutoRefresh from 'views/hooks/useAutoRefresh'; import useViewsSelector from 'views/stores/useViewsSelector'; import { selectCurrentQueryResults } from 'views/logic/slices/viewSelectors'; -import useSearchResult from 'views/hooks/useSearchResult'; +import useCurrentQuerySearchResulErrors from 'views/hooks/useCurrentQuerySearchResulErrors'; import SearchBarForm from './searchbar/SearchBarForm'; @@ -201,7 +201,6 @@ const SearchBar = ({ onSubmit = defaultProps.onSubmit, scrollContainer }: Props) const currentQuery = useCurrentQuery(); const queryFilters = useQueryFilters(); const results = useViewsSelector(selectCurrentQueryResults); - const searchResult = useSearchResult(); const pluggableSearchBarControls = usePluginEntities('views.components.searchBar'); const initialValues = useInitialFormValues({ queryFilters, currentQuery }); const dispatch = useViewsDispatch(); @@ -214,6 +213,9 @@ const SearchBar = ({ onSubmit = defaultProps.onSubmit, scrollContainer }: Props) const handlerContext = useHandlerContext(); const isLoadingExecution = useIsLoading(); + const searchResulErrors = useCurrentQuerySearchResulErrors(); + const timeRangeHasErrorInResults = searchResulErrors.some(({ type }) => type === QUERY_TIME_RANGE_LIMIT_ERROR_TYPE); + if (!currentQuery || !config) { return ; } @@ -221,11 +223,6 @@ const SearchBar = ({ onSubmit = defaultProps.onSubmit, scrollContainer }: Props) const { query } = currentQuery; const limitDuration = moment.duration(config.query_time_range_limit).asSeconds() ?? 0; - const searchResulErrors = searchResult?.result?.errors?.filter((error) => error.queryId === currentQuery?.id) ?? []; - const timeRangeHasErrorInResults = searchResulErrors.some( - ({ type }) => type === QUERY_TIME_RANGE_LIMIT_ERROR_TYPE || type === SEARCH_TYPE_RANGE_LIMIT_ERROR_TYPE, - ); - return ( {({ focusedWidget: { editing } = { editing: false } }) => ( diff --git a/graylog2-web-interface/src/views/components/WidgetQueryControls.tsx b/graylog2-web-interface/src/views/components/WidgetQueryControls.tsx index 6363101b6237..fe19960453ab 100644 --- a/graylog2-web-interface/src/views/components/WidgetQueryControls.tsx +++ b/graylog2-web-interface/src/views/components/WidgetQueryControls.tsx @@ -29,7 +29,7 @@ import connect from 'stores/connect'; import { createElasticsearchQueryString } from 'views/logic/queries/Query'; import type Widget from 'views/logic/widgets/Widget'; import type { SearchBarFormValues } from 'views/Constants'; -import { DEFAULT_TIMERANGE } from 'views/Constants'; +import { SEARCH_TYPE_RANGE_LIMIT_ERROR_TYPE, DEFAULT_TIMERANGE } from 'views/Constants'; import type GlobalOverride from 'views/logic/search/GlobalOverride'; import WidgetContext from 'views/components/contexts/WidgetContext'; import { PropagateDisableSubmissionState } from 'views/components/aggregationwizard'; @@ -64,6 +64,7 @@ import useSearchConfiguration from 'hooks/useSearchConfiguration'; import { defaultCompare } from 'logic/DefaultCompare'; import StreamCategoryFilter from 'views/components/searchbar/StreamCategoryFilter'; import { executeActiveQuery } from 'views/logic/slices/viewSlice'; +import useCurrentQuerySearchResulErrors from 'views/hooks/useCurrentQuerySearchResulErrors'; import TimeRangeOverrideInfo from './searchbar/WidgetTimeRangeOverride'; import TimeRangeFilter from './searchbar/time-range-filter'; @@ -238,6 +239,9 @@ const WidgetQueryControls = ({ availableStreams }: Props) => { useBindApplySearchControlsChanges(formRef); + const searchResulErrors = useCurrentQuerySearchResulErrors(); + const timeRangeHasErrorInResults = searchResulErrors.some(({ type }) => type === SEARCH_TYPE_RANGE_LIMIT_ERROR_TYPE); + return ( { limitDuration={limitDuration} onChange={(nextTimeRange) => setFieldValue('timerange', nextTimeRange)} value={values?.timerange} - hasErrorOnMount={!!errors.timerange} + hasErrorOnMount={!!errors.timerange || timeRangeHasErrorInResults} /> )} {hasTimeRangeOverride && ( diff --git a/graylog2-web-interface/src/views/hooks/useCurrentQuerySearchResulErrors.ts b/graylog2-web-interface/src/views/hooks/useCurrentQuerySearchResulErrors.ts new file mode 100644 index 000000000000..b02434ec6164 --- /dev/null +++ b/graylog2-web-interface/src/views/hooks/useCurrentQuerySearchResulErrors.ts @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import useSearchResult from 'views/hooks/useSearchResult'; +import useCurrentQuery from 'views/logic/queries/useCurrentQuery'; + +const useCurrentQuerySearchResulErrors = () => { + const searchResult = useSearchResult(); + const currentQuery = useCurrentQuery(); + + return searchResult?.result?.errors?.filter((error) => error.queryId === currentQuery?.id) ?? []; +}; + +export default useCurrentQuerySearchResulErrors; From 451504ae56e5cc21cb6a070ea227058a8145c5c0 Mon Sep 17 00:00:00 2001 From: Maksym Yadlovskyi Date: Thu, 9 Apr 2026 17:51:59 +0200 Subject: [PATCH 4/6] Fix issue with blinking the submit button and timerange error button --- .../src/views/components/SearchBar.tsx | 11 ++-- .../views/components/WidgetQueryControls.tsx | 10 ++-- .../hooks/useCurrentQuerySearchResulErrors.ts | 27 ---------- .../useSearchResultTimeRangeErrorCheck.ts | 50 +++++++++++++++++++ .../logic/slices/searchExecutionSlice.ts | 8 +-- 5 files changed, 66 insertions(+), 40 deletions(-) delete mode 100644 graylog2-web-interface/src/views/hooks/useCurrentQuerySearchResulErrors.ts create mode 100644 graylog2-web-interface/src/views/hooks/useSearchResultTimeRangeErrorCheck.ts diff --git a/graylog2-web-interface/src/views/components/SearchBar.tsx b/graylog2-web-interface/src/views/components/SearchBar.tsx index 37c2d96c8c0b..fb27342342df 100644 --- a/graylog2-web-interface/src/views/components/SearchBar.tsx +++ b/graylog2-web-interface/src/views/components/SearchBar.tsx @@ -78,7 +78,7 @@ import StreamCategoryFilter from 'views/components/searchbar/StreamCategoryFilte import useAutoRefresh from 'views/hooks/useAutoRefresh'; import useViewsSelector from 'views/stores/useViewsSelector'; import { selectCurrentQueryResults } from 'views/logic/slices/viewSelectors'; -import useCurrentQuerySearchResulErrors from 'views/hooks/useCurrentQuerySearchResulErrors'; +import useSearchResultTimeRangeErrorCheck from 'views/hooks/useSearchResultTimeRangeErrorCheck'; import SearchBarForm from './searchbar/SearchBarForm'; @@ -213,8 +213,7 @@ const SearchBar = ({ onSubmit = defaultProps.onSubmit, scrollContainer }: Props) const handlerContext = useHandlerContext(); const isLoadingExecution = useIsLoading(); - const searchResulErrors = useCurrentQuerySearchResulErrors(); - const timeRangeHasErrorInResults = searchResulErrors.some(({ type }) => type === QUERY_TIME_RANGE_LIMIT_ERROR_TYPE); + const searchResultTimeRangeErrorCheck = useSearchResultTimeRangeErrorCheck(QUERY_TIME_RANGE_LIMIT_ERROR_TYPE); if (!currentQuery || !config) { return ; @@ -245,7 +244,9 @@ const SearchBar = ({ onSubmit = defaultProps.onSubmit, scrollContainer }: Props) setFieldValue, validateForm, }) => { - const disableSearchSubmit = isSubmitting || isValidating || !isValid || isLoadingExecution; + const showTimeRangeErrorFromResults = searchResultTimeRangeErrorCheck(values?.timerange); + const disableSearchSubmit = + isSubmitting || isValidating || !isValid || isLoadingExecution || showTimeRangeErrorFromResults; return ( <> @@ -261,7 +262,7 @@ const SearchBar = ({ onSubmit = defaultProps.onSubmit, scrollContainer }: Props) limitDuration={limitDuration} onChange={(nextTimeRange) => setFieldValue('timerange', nextTimeRange)} value={values?.timerange} - hasErrorOnMount={!!errors.timerange || timeRangeHasErrorInResults} + hasErrorOnMount={!!errors.timerange || showTimeRangeErrorFromResults} moveRangeProps={{ effectiveTimerange: results?.effectiveTimerange, initialTimerange: currentQuery.timerange, diff --git a/graylog2-web-interface/src/views/components/WidgetQueryControls.tsx b/graylog2-web-interface/src/views/components/WidgetQueryControls.tsx index fe19960453ab..ef061bfbba4c 100644 --- a/graylog2-web-interface/src/views/components/WidgetQueryControls.tsx +++ b/graylog2-web-interface/src/views/components/WidgetQueryControls.tsx @@ -64,7 +64,7 @@ import useSearchConfiguration from 'hooks/useSearchConfiguration'; import { defaultCompare } from 'logic/DefaultCompare'; import StreamCategoryFilter from 'views/components/searchbar/StreamCategoryFilter'; import { executeActiveQuery } from 'views/logic/slices/viewSlice'; -import useCurrentQuerySearchResulErrors from 'views/hooks/useCurrentQuerySearchResulErrors'; +import useSearchResultTimeRangeErrorCheck from 'views/hooks/useSearchResultTimeRangeErrorCheck'; import TimeRangeOverrideInfo from './searchbar/WidgetTimeRangeOverride'; import TimeRangeFilter from './searchbar/time-range-filter'; @@ -239,8 +239,7 @@ const WidgetQueryControls = ({ availableStreams }: Props) => { useBindApplySearchControlsChanges(formRef); - const searchResulErrors = useCurrentQuerySearchResulErrors(); - const timeRangeHasErrorInResults = searchResulErrors.some(({ type }) => type === SEARCH_TYPE_RANGE_LIMIT_ERROR_TYPE); + const searchResultTimeRangeErrorCheck = useSearchResultTimeRangeErrorCheck(SEARCH_TYPE_RANGE_LIMIT_ERROR_TYPE); return ( @@ -251,7 +250,8 @@ const WidgetQueryControls = ({ availableStreams }: Props) => { onSubmit={_onSubmit} validateQueryString={validate}> {({ dirty, errors, isValid, isSubmitting, handleSubmit, values, setFieldValue, validateForm }) => { - const disableSearchSubmit = isSubmitting || isValidatingQuery || !isValid; + const showTimeRangeErrorFromResults = searchResultTimeRangeErrorCheck(values?.timerange); + const disableSearchSubmit = isSubmitting || isValidatingQuery || !isValid || showTimeRangeErrorFromResults; return ( @@ -267,7 +267,7 @@ const WidgetQueryControls = ({ availableStreams }: Props) => { limitDuration={limitDuration} onChange={(nextTimeRange) => setFieldValue('timerange', nextTimeRange)} value={values?.timerange} - hasErrorOnMount={!!errors.timerange || timeRangeHasErrorInResults} + hasErrorOnMount={!!errors.timerange || showTimeRangeErrorFromResults} /> )} {hasTimeRangeOverride && ( diff --git a/graylog2-web-interface/src/views/hooks/useCurrentQuerySearchResulErrors.ts b/graylog2-web-interface/src/views/hooks/useCurrentQuerySearchResulErrors.ts deleted file mode 100644 index b02434ec6164..000000000000 --- a/graylog2-web-interface/src/views/hooks/useCurrentQuerySearchResulErrors.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright (C) 2020 Graylog, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the Server Side Public License, version 1, - * as published by MongoDB, Inc. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * Server Side Public License for more details. - * - * You should have received a copy of the Server Side Public License - * along with this program. If not, see - * . - */ -import useSearchResult from 'views/hooks/useSearchResult'; -import useCurrentQuery from 'views/logic/queries/useCurrentQuery'; - -const useCurrentQuerySearchResulErrors = () => { - const searchResult = useSearchResult(); - const currentQuery = useCurrentQuery(); - - return searchResult?.result?.errors?.filter((error) => error.queryId === currentQuery?.id) ?? []; -}; - -export default useCurrentQuerySearchResulErrors; diff --git a/graylog2-web-interface/src/views/hooks/useSearchResultTimeRangeErrorCheck.ts b/graylog2-web-interface/src/views/hooks/useSearchResultTimeRangeErrorCheck.ts new file mode 100644 index 000000000000..a240bd1bc22f --- /dev/null +++ b/graylog2-web-interface/src/views/hooks/useSearchResultTimeRangeErrorCheck.ts @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import { useCallback, useMemo } from 'react'; +import isEqual from 'lodash/isEqual'; + +import useSearchResult from 'views/hooks/useSearchResult'; +import useCurrentQuery from 'views/logic/queries/useCurrentQuery'; +import type { TimeRange } from 'views/logic/queries/Query'; +import useIsLoading from 'views/hooks/useIsLoading'; + +const useSearchResultTimeRangeErrorCheck = (errorType: string) => { + const searchResult = useSearchResult(); + const currentQuery = useCurrentQuery(); + const isLoadingExecution = useIsLoading(); + + const searchResultErrors = useMemo( + () => searchResult?.result?.errors?.filter((error) => error.queryId === currentQuery?.id) ?? [], + [currentQuery?.id, searchResult?.result?.errors], + ); + + const timeRangeHasErrorInResults = useMemo( + () => searchResultErrors.some(({ type }) => type === errorType), + [errorType, searchResultErrors], + ); + + return useCallback( + (currentTimeRange: TimeRange | {}) => { + const executedTimerange = currentQuery?.timerange; + + return !isLoadingExecution && isEqual(currentTimeRange, executedTimerange) && timeRangeHasErrorInResults; + }, + [currentQuery?.timerange, isLoadingExecution, timeRangeHasErrorInResults], + ); +}; + +export default useSearchResultTimeRangeErrorCheck; diff --git a/graylog2-web-interface/src/views/logic/slices/searchExecutionSlice.ts b/graylog2-web-interface/src/views/logic/slices/searchExecutionSlice.ts index ade8aa3a12ef..00889c0c1fd7 100644 --- a/graylog2-web-interface/src/views/logic/slices/searchExecutionSlice.ts +++ b/graylog2-web-interface/src/views/logic/slices/searchExecutionSlice.ts @@ -225,10 +225,11 @@ export const executeWithExecutionState = perPage?: number; stopPolling?: (progress: number) => boolean; }) => - (dispatch: ViewsDispatch) => - dispatch(parseSearch(search, searchExecutors.parse)) + async (dispatch: ViewsDispatch) => { + dispatch(loading()); + + return dispatch(parseSearch(search, searchExecutors.parse)) .then(() => { - dispatch(loading()); dispatch(cancelExecutedJob()); return searchExecutors.startJob(search, searchTypesToSearch, executionState, [activeQuery]); @@ -242,6 +243,7 @@ export const executeWithExecutionState = throw error; }); + }; export const execute = ({ From a1e0e936ea37da2f0dccfd90eced10bc8ef4ec5b Mon Sep 17 00:00:00 2001 From: Maksym Yadlovskyi Date: Fri, 10 Apr 2026 09:10:43 +0200 Subject: [PATCH 5/6] Add test --- .../src/views/components/SearchBar.test.tsx | 23 ++++ .../components/WidgetQueryControls.test.tsx | 25 +++- ...useSearchResultTimeRangeErrorCheck.test.ts | 128 ++++++++++++++++++ 3 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 graylog2-web-interface/src/views/hooks/useSearchResultTimeRangeErrorCheck.test.ts diff --git a/graylog2-web-interface/src/views/components/SearchBar.test.tsx b/graylog2-web-interface/src/views/components/SearchBar.test.tsx index f0792f889d7d..8a167471110e 100644 --- a/graylog2-web-interface/src/views/components/SearchBar.test.tsx +++ b/graylog2-web-interface/src/views/components/SearchBar.test.tsx @@ -29,6 +29,7 @@ import useViewsPlugin from 'views/test/testViewsPlugin'; import TestStoreProvider from 'views/test/TestStoreProvider'; import useViewsDispatch from 'views/stores/useViewsDispatch'; import useSearchConfiguration from 'hooks/useSearchConfiguration'; +import useSearchResultTimeRangeErrorCheck from 'views/hooks/useSearchResultTimeRangeErrorCheck'; import OriginalSearchBar from './SearchBar'; @@ -58,6 +59,7 @@ jest.mock('views/logic/debounceWithPromise', () => (fn: any) => fn); jest.mock('views/logic/queries/useCurrentQuery'); jest.mock('views/stores/useViewsDispatch'); jest.mock('views/hooks/useAutoRefresh'); +jest.mock('views/hooks/useSearchResultTimeRangeErrorCheck'); const query = MockQuery.builder() .timerange({ type: 'relative', from: 300 }) @@ -81,6 +83,7 @@ describe('SearchBar', () => { isInitialLoading: false, }); asMock(useCurrentQuery).mockReturnValue(query); + asMock(useSearchResultTimeRangeErrorCheck).mockReturnValue(() => false); }); it('should render the SearchBar', async () => { @@ -174,4 +177,24 @@ describe('SearchBar', () => { await waitFor(() => expect(validateQuery).toHaveBeenCalled()); }); + + it('shows warning icon on timerange button when search result timerange check returns true', async () => { + asMock(useSearchResultTimeRangeErrorCheck).mockReturnValue(() => true); + + render(); + + const timeRangePickerButton = await screen.findByLabelText('Open Time Range Selector'); + + await waitFor(() => expect(within(timeRangePickerButton).getByText('warning')).toBeInTheDocument()); + }); + + it('does not show warning icon on timerange button when search result timerange check returns false', async () => { + asMock(useSearchResultTimeRangeErrorCheck).mockReturnValue(() => false); + + render(); + + const timeRangePickerButton = await screen.findByLabelText('Open Time Range Selector'); + + expect(within(timeRangePickerButton).queryByText('warning')).not.toBeInTheDocument(); + }); }); diff --git a/graylog2-web-interface/src/views/components/WidgetQueryControls.test.tsx b/graylog2-web-interface/src/views/components/WidgetQueryControls.test.tsx index 3680c219a08d..94830c30842a 100644 --- a/graylog2-web-interface/src/views/components/WidgetQueryControls.test.tsx +++ b/graylog2-web-interface/src/views/components/WidgetQueryControls.test.tsx @@ -15,7 +15,7 @@ * . */ import * as React from 'react'; -import { render, waitFor, screen } from 'wrappedTestingLibrary'; +import { render, waitFor, screen, within } from 'wrappedTestingLibrary'; import userEvent from '@testing-library/user-event'; import GlobalOverride from 'views/logic/search/GlobalOverride'; @@ -27,6 +27,7 @@ import { asMock } from 'helpers/mocking'; import useGlobalOverride from 'views/hooks/useGlobalOverride'; import { setGlobalOverrideTimerange, setGlobalOverrideQuery } from 'views/logic/slices/searchExecutionSlice'; import { executeActiveQuery } from 'views/logic/slices/viewSlice'; +import useSearchResultTimeRangeErrorCheck from 'views/hooks/useSearchResultTimeRangeErrorCheck'; import WidgetQueryControls from './WidgetQueryControls'; import WidgetContext from './contexts/WidgetContext'; @@ -38,6 +39,7 @@ jest.mock('views/components/searchbar/queryinput/QueryInput'); jest.mock('views/components/searchbar/queryinput/BasicQueryInput'); jest.mock('views/logic/fieldtypes/useFieldTypes'); jest.mock('views/hooks/useGlobalOverride'); +jest.mock('views/hooks/useSearchResultTimeRangeErrorCheck'); jest.mock('views/logic/slices/searchExecutionSlice', () => ({ ...jest.requireActual('views/logic/slices/searchExecutionSlice'), @@ -54,6 +56,7 @@ describe('WidgetQueryControls', () => { beforeEach(() => { jest.clearAllMocks(); asMock(useGlobalOverride).mockReturnValue(GlobalOverride.empty()); + asMock(useSearchResultTimeRangeErrorCheck).mockReturnValue(() => false); }); useViewsPlugin(); @@ -199,4 +202,24 @@ describe('WidgetQueryControls', () => { expect(screen.queryByText(timeRangeOverrideInfo)).toBeNull(); }); }); + + it('shows warning icon on timerange button when search result timerange check returns true', async () => { + asMock(useSearchResultTimeRangeErrorCheck).mockReturnValue(() => true); + + renderSUT(); + + const timeRangePickerButton = await screen.findByLabelText('Open Time Range Selector'); + + await waitFor(() => expect(within(timeRangePickerButton).getByText('warning')).toBeInTheDocument()); + }); + + it('does not show warning icon on timerange button when search result timerange check returns false', async () => { + asMock(useSearchResultTimeRangeErrorCheck).mockReturnValue(() => false); + + renderSUT(); + + const timeRangePickerButton = await screen.findByLabelText('Open Time Range Selector'); + + expect(within(timeRangePickerButton).queryByText('warning')).not.toBeInTheDocument(); + }); }); diff --git a/graylog2-web-interface/src/views/hooks/useSearchResultTimeRangeErrorCheck.test.ts b/graylog2-web-interface/src/views/hooks/useSearchResultTimeRangeErrorCheck.test.ts new file mode 100644 index 000000000000..f1d7916f40e2 --- /dev/null +++ b/graylog2-web-interface/src/views/hooks/useSearchResultTimeRangeErrorCheck.test.ts @@ -0,0 +1,128 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import { renderHook } from 'wrappedTestingLibrary/hooks'; + +import { asMock } from 'helpers/mocking'; +import useCurrentQuery from 'views/logic/queries/useCurrentQuery'; +import useSearchResult from 'views/hooks/useSearchResult'; +import useIsLoading from 'views/hooks/useIsLoading'; +import type Query from 'views/logic/queries/Query'; +import type { TimeRange } from 'views/logic/queries/Query'; +import type { SearchExecutionResult } from 'views/types'; + +import useSearchResultTimeRangeErrorCheck from './useSearchResultTimeRangeErrorCheck'; + +jest.mock('views/logic/queries/useCurrentQuery'); +jest.mock('views/hooks/useSearchResult'); +jest.mock('views/hooks/useIsLoading'); + +const errorType = 'query_time_range_limit'; +const timerange: TimeRange = { type: 'relative', from: 300 }; +const currentQuery = { id: 'query-1', timerange } as Query; + +const mockSearchResult = (errors: Array<{ queryId: string; type: string }> = []) => + ({ + result: { + errors, + }, + }) as SearchExecutionResult; + +const renderSubject = () => renderHook(() => useSearchResultTimeRangeErrorCheck(errorType)); + +describe('useSearchResultTimeRangeErrorCheck', () => { + beforeEach(() => { + asMock(useCurrentQuery).mockReturnValue(currentQuery); + asMock(useIsLoading).mockReturnValue(false); + asMock(useSearchResult).mockReturnValue(mockSearchResult()); + }); + + it('returns true when matching query has the requested time range error and timerange matches', () => { + asMock(useSearchResult).mockReturnValue( + mockSearchResult([ + { + queryId: 'query-1', + type: errorType, + }, + ]), + ); + + const { result } = renderSubject(); + + expect(result.current(timerange)).toBe(true); + }); + + it('returns false when current timerange does not match executed timerange', () => { + asMock(useSearchResult).mockReturnValue( + mockSearchResult([ + { + queryId: 'query-1', + type: errorType, + }, + ]), + ); + + const { result } = renderSubject(); + + expect(result.current({ type: 'relative', from: 60 })).toBe(false); + }); + + it('returns false when execution is loading', () => { + asMock(useSearchResult).mockReturnValue( + mockSearchResult([ + { + queryId: 'query-1', + type: errorType, + }, + ]), + ); + asMock(useIsLoading).mockReturnValue(true); + + const { result } = renderSubject(); + + expect(result.current(timerange)).toBe(false); + }); + + it('returns false when only different error types are present', () => { + asMock(useSearchResult).mockReturnValue( + mockSearchResult([ + { + queryId: 'query-1', + type: 'search_type_time_range_limit', + }, + ]), + ); + + const { result } = renderSubject(); + + expect(result.current(timerange)).toBe(false); + }); + + it('returns false when the error belongs to another query', () => { + asMock(useSearchResult).mockReturnValue( + mockSearchResult([ + { + queryId: 'query-2', + type: errorType, + }, + ]), + ); + + const { result } = renderSubject(); + + expect(result.current(timerange)).toBe(false); + }); +}); From b33d2018421b2a7bced2d2af743e207380f1a70a Mon Sep 17 00:00:00 2001 From: Maksym Yadlovskyi Date: Fri, 10 Apr 2026 09:28:31 +0200 Subject: [PATCH 6/6] Add more tests. update change log --- changelog/unreleased/issue-15957.toml | 4 ++-- .../src/views/components/SearchBar.test.tsx | 20 +++++++++++++++++++ .../components/WidgetQueryControls.test.tsx | 20 +++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/changelog/unreleased/issue-15957.toml b/changelog/unreleased/issue-15957.toml index df346a5b9875..b5383490adf1 100644 --- a/changelog/unreleased/issue-15957.toml +++ b/changelog/unreleased/issue-15957.toml @@ -1,5 +1,5 @@ -type = "f" -message = "Fix issue when timerage validation didn't run after loading a saved search." +type = "fixed" +message = "Fix timerange validation not running when loading a saved search." issues = ["15957"] pulls = ["25585"] diff --git a/graylog2-web-interface/src/views/components/SearchBar.test.tsx b/graylog2-web-interface/src/views/components/SearchBar.test.tsx index 8a167471110e..4e02643233c9 100644 --- a/graylog2-web-interface/src/views/components/SearchBar.test.tsx +++ b/graylog2-web-interface/src/views/components/SearchBar.test.tsx @@ -188,6 +188,16 @@ describe('SearchBar', () => { await waitFor(() => expect(within(timeRangePickerButton).getByText('warning')).toBeInTheDocument()); }); + it('disables the search button when search result timerange check returns true', async () => { + asMock(useSearchResultTimeRangeErrorCheck).mockReturnValue(() => true); + + render(); + + const searchButton = await screen.findByRole('button', { name: /perform search/i }); + + await waitFor(() => expect(searchButton.classList).toContain('disabled')); + }); + it('does not show warning icon on timerange button when search result timerange check returns false', async () => { asMock(useSearchResultTimeRangeErrorCheck).mockReturnValue(() => false); @@ -197,4 +207,14 @@ describe('SearchBar', () => { expect(within(timeRangePickerButton).queryByText('warning')).not.toBeInTheDocument(); }); + + it('does not disable the search button when search result timerange check returns false', async () => { + asMock(useSearchResultTimeRangeErrorCheck).mockReturnValue(() => false); + + render(); + + const searchButton = await screen.findByRole('button', { name: /perform search/i }); + + await waitFor(() => expect(searchButton.classList).not.toContain('disabled')); + }); }); diff --git a/graylog2-web-interface/src/views/components/WidgetQueryControls.test.tsx b/graylog2-web-interface/src/views/components/WidgetQueryControls.test.tsx index 94830c30842a..f7911573a575 100644 --- a/graylog2-web-interface/src/views/components/WidgetQueryControls.test.tsx +++ b/graylog2-web-interface/src/views/components/WidgetQueryControls.test.tsx @@ -213,6 +213,16 @@ describe('WidgetQueryControls', () => { await waitFor(() => expect(within(timeRangePickerButton).getByText('warning')).toBeInTheDocument()); }); + it('disables the search button when search result timerange check returns true', async () => { + asMock(useSearchResultTimeRangeErrorCheck).mockReturnValue(() => true); + + renderSUT(); + + const searchButton = await screen.findByRole('button', { name: /perform search/i }); + + await waitFor(() => expect(searchButton.classList).toContain('disabled')); + }); + it('does not show warning icon on timerange button when search result timerange check returns false', async () => { asMock(useSearchResultTimeRangeErrorCheck).mockReturnValue(() => false); @@ -222,4 +232,14 @@ describe('WidgetQueryControls', () => { expect(within(timeRangePickerButton).queryByText('warning')).not.toBeInTheDocument(); }); + + it('does not disable the search button when search result timerange check returns false', async () => { + asMock(useSearchResultTimeRangeErrorCheck).mockReturnValue(() => false); + + renderSUT(); + + const searchButton = await screen.findByRole('button', { name: /perform search/i }); + + await waitFor(() => expect(searchButton.classList).not.toContain('disabled')); + }); });