mirror of https://github.com/grafana/grafana.git
				
				
				
			Alerting: Improved filters part 2 (#109738)
* refactor: split out form component into separate functions * add storybook variation for infoOption in dropdown * add tracking to search input for v2 view, tidy up tracking functions * add tests for filter tracking * refactor: boy-scouting V1 filter * move FiltersV2 to rule-list directory * fix lint issue * resolve PR comments round 1 * resolve PR comments round 2- update file locations * generate apis * fix tests * fix lint issue * fix imports
This commit is contained in:
		
							parent
							
								
									82d36a259e
								
							
						
					
					
						commit
						56c8e53a99
					
				|  | @ -66,6 +66,31 @@ export const Basic: Story = { | ||||||
|   }, |   }, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | export const WithInfoOption: Story = { | ||||||
|  |   name: 'With infoOption', | ||||||
|  |   args: { | ||||||
|  |     ...commonArgs, | ||||||
|  |     options: [ | ||||||
|  |       ...commonArgs.options, | ||||||
|  |       { label: 'Can’t find your country? Select “Other” or contact an admin', value: '__INFO__', infoOption: true }, | ||||||
|  |     ], | ||||||
|  |   }, | ||||||
|  |   render: (args) => { | ||||||
|  |     const [{ value }, setArgs] = useArgs(); | ||||||
|  | 
 | ||||||
|  |     return ( | ||||||
|  |       <MultiCombobox | ||||||
|  |         {...args} | ||||||
|  |         value={value} | ||||||
|  |         onChange={(val) => { | ||||||
|  |           onChangeAction(val); | ||||||
|  |           setArgs({ value: val }); | ||||||
|  |         }} | ||||||
|  |       /> | ||||||
|  |     ); | ||||||
|  |   }, | ||||||
|  | }; | ||||||
|  | 
 | ||||||
| export const AutoSize: Story = { | export const AutoSize: Story = { | ||||||
|   args: { ...commonArgs, width: 'auto', minWidth: 20 }, |   args: { ...commonArgs, width: 'auto', minWidth: 20 }, | ||||||
|   render: (args) => { |   render: (args) => { | ||||||
|  |  | ||||||
|  | @ -1,4 +1,4 @@ | ||||||
| import { isEmpty, pickBy } from 'lodash'; | import { pickBy } from 'lodash'; | ||||||
| 
 | 
 | ||||||
| import { config, createMonitoringLogger, reportInteraction } from '@grafana/runtime'; | import { config, createMonitoringLogger, reportInteraction } from '@grafana/runtime'; | ||||||
| import { contextSrv } from 'app/core/core'; | import { contextSrv } from 'app/core/core'; | ||||||
|  | @ -7,9 +7,9 @@ import { RuleNamespace } from '../../../types/unified-alerting'; | ||||||
| import { RulerRulesConfigDTO } from '../../../types/unified-alerting-dto'; | import { RulerRulesConfigDTO } from '../../../types/unified-alerting-dto'; | ||||||
| 
 | 
 | ||||||
| import { Origin } from './components/rule-viewer/tabs/version-history/ConfirmVersionRestoreModal'; | import { Origin } from './components/rule-viewer/tabs/version-history/ConfirmVersionRestoreModal'; | ||||||
| import { AdvancedFilters } from './components/rules/Filter/RulesFilter.v2'; |  | ||||||
| import { FilterType } from './components/rules/central-state-history/EventListSceneObject'; | import { FilterType } from './components/rules/central-state-history/EventListSceneObject'; | ||||||
| import { RulesFilter, getSearchFilterFromQuery } from './search/rulesSearchParser'; | import { AdvancedFilters } from './rule-list/filter/types'; | ||||||
|  | import { RulesFilter } from './search/rulesSearchParser'; | ||||||
| import { RuleFormType } from './types/rule-form'; | import { RuleFormType } from './types/rule-form'; | ||||||
| 
 | 
 | ||||||
| export const LogMessages = { | export const LogMessages = { | ||||||
|  | @ -245,44 +245,6 @@ export const trackImportToGMAError = async (payload: { importSource: 'yaml' | 'd | ||||||
|   reportInteraction('grafana_alerting_import_to_gma_error', { ...payload }); |   reportInteraction('grafana_alerting_import_to_gma_error', { ...payload }); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| interface RulesSearchInteractionPayload { |  | ||||||
|   filter: string; |  | ||||||
|   triggeredBy: 'typing' | 'component'; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| function trackRulesSearchInteraction(payload: RulesSearchInteractionPayload) { |  | ||||||
|   reportInteraction('grafana_alerting_rules_search', { ...payload }); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| export function trackRulesSearchInputInteraction({ oldQuery, newQuery }: { oldQuery: string; newQuery: string }) { |  | ||||||
|   try { |  | ||||||
|     const oldFilter = getSearchFilterFromQuery(oldQuery); |  | ||||||
|     const newFilter = getSearchFilterFromQuery(newQuery); |  | ||||||
| 
 |  | ||||||
|     const oldFilterTerms = extractFilterKeys(oldFilter); |  | ||||||
|     const newFilterTerms = extractFilterKeys(newFilter); |  | ||||||
| 
 |  | ||||||
|     const newTerms = newFilterTerms.filter((term) => !oldFilterTerms.includes(term)); |  | ||||||
|     newTerms.forEach((term) => { |  | ||||||
|       trackRulesSearchInteraction({ filter: term, triggeredBy: 'typing' }); |  | ||||||
|     }); |  | ||||||
|   } catch (e: unknown) { |  | ||||||
|     if (e instanceof Error) { |  | ||||||
|       logError(e); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| function extractFilterKeys(filter: RulesFilter) { |  | ||||||
|   return Object.entries(filter) |  | ||||||
|     .filter(([_, value]) => !isEmpty(value)) |  | ||||||
|     .map(([key]) => key); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| export function trackRulesSearchComponentInteraction(filter: keyof RulesFilter) { |  | ||||||
|   trackRulesSearchInteraction({ filter, triggeredBy: 'component' }); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| export function trackRulesListViewChange(payload: { view: string }) { | export function trackRulesListViewChange(payload: { view: string }) { | ||||||
|   reportInteraction('grafana_alerting_rules_list_mode', { ...payload }); |   reportInteraction('grafana_alerting_rules_list_mode', { ...payload }); | ||||||
| } | } | ||||||
|  | @ -335,26 +297,64 @@ export function trackFilterButtonClick() { | ||||||
|   reportInteraction('grafana_alerting_filter_button_click'); |   reportInteraction('grafana_alerting_filter_button_click'); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | export function trackAlertRuleFilterEvent( | ||||||
|  |   payload: | ||||||
|  |     | { filterMethod: 'search-input'; filter: RulesFilter } | ||||||
|  |     | { filterMethod: 'filter-component'; filter: keyof RulesFilter } | ||||||
|  | ) { | ||||||
|  |   if (payload.filterMethod === 'search-input') { | ||||||
|  |     const meaningfulValues = filterMeaningfulValues(payload.filter); | ||||||
|  |     reportInteraction('grafana_alerting_rules_filter', { ...meaningfulValues, filterMethod: 'search-input' }); | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  |   reportInteraction('grafana_alerting_rules_filter', { filter: payload.filter, filterMethod: 'filter-component' }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function trackRulesSearchInputCleared(prev: string, next: string) { | ||||||
|  |   // Only report an explicit clear action when transitioning from non-empty to empty
 | ||||||
|  |   if (prev !== '' && next === '') { | ||||||
|  |     reportInteraction('grafana_alerting_rules_filter_cleared', { filterMethod: 'search-input' }); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
| export function trackFilterButtonApplyClick(payload: AdvancedFilters, pluginsFilterEnabled: boolean) { | export function trackFilterButtonApplyClick(payload: AdvancedFilters, pluginsFilterEnabled: boolean) { | ||||||
|   // Filter out empty/default values before tracking
 |   // Filter out empty/default values before tracking
 | ||||||
|   const meaningfulValues = pickBy(payload, (value, key) => { |   const meaningfulValues = filterMeaningfulValues(payload, { pluginsFilterEnabled }); | ||||||
|  | 
 | ||||||
|  |   reportInteraction('grafana_alerting_rules_filter', { | ||||||
|  |     ...meaningfulValues, | ||||||
|  |     filterMethod: 'filter-component', | ||||||
|  |   }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function filterMeaningfulValues( | ||||||
|  |   // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | ||||||
|  |   obj: Record<string, any>, | ||||||
|  |   opts?: { pluginsFilterEnabled?: boolean } | ||||||
|  | ) { | ||||||
|  |   const { pluginsFilterEnabled = true } = opts ?? {}; | ||||||
|  |   return pickBy(obj, (value, key) => { | ||||||
|     if (value === null || value === undefined || value === '') { |     if (value === null || value === undefined || value === '') { | ||||||
|       return false; |       return false; | ||||||
|     } |     } | ||||||
|     if (Array.isArray(value) && value.length === 0) { |     if (Array.isArray(value) && value.length === 0) { | ||||||
|       return false; |       return false; | ||||||
|     } |     } | ||||||
|  |     if (value === '*') { | ||||||
|  |       return false; | ||||||
|  |     } | ||||||
|     if (key === 'plugins' && !pluginsFilterEnabled) { |     if (key === 'plugins' && !pluginsFilterEnabled) { | ||||||
|       return false; |       return false; | ||||||
|     } |     } | ||||||
|  |     if (key === 'plugins' && value === 'show') { | ||||||
|  |       return false; | ||||||
|  |     } | ||||||
|     return true; |     return true; | ||||||
|   }); |   }); | ||||||
| 
 |  | ||||||
|   reportInteraction('grafana_alerting_filter_button_apply_click', meaningfulValues); |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export function trackFilterButtonClearClick() { | export function trackFilterButtonClearClick() { | ||||||
|   reportInteraction('grafana_alerting_filter_button_clear_click'); |   reportInteraction('grafana_alerting_rules_filter_cleared', { filterMethod: 'filter-component' }); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export type AlertRuleTrackingProps = { | export type AlertRuleTrackingProps = { | ||||||
|  |  | ||||||
|  | @ -11,20 +11,15 @@ import { contextSrv } from 'app/core/core'; | ||||||
| import { AccessControlAction } from 'app/types/accessControl'; | import { AccessControlAction } from 'app/types/accessControl'; | ||||||
| import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto'; | import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto'; | ||||||
| 
 | 
 | ||||||
| import { | import { LogMessages, logInfo, trackAlertRuleFilterEvent } from '../../../Analytics'; | ||||||
|   LogMessages, |  | ||||||
|   logInfo, |  | ||||||
|   trackRulesSearchComponentInteraction, |  | ||||||
|   trackRulesSearchInputInteraction, |  | ||||||
| } from '../../../Analytics'; |  | ||||||
| import { useRulesFilter } from '../../../hooks/useFilteredRules'; | import { useRulesFilter } from '../../../hooks/useFilteredRules'; | ||||||
| import { useAlertingHomePageExtensions } from '../../../plugins/useAlertingHomePageExtensions'; | import { useAlertingHomePageExtensions } from '../../../plugins/useAlertingHomePageExtensions'; | ||||||
| import { RuleHealth } from '../../../search/rulesSearchParser'; | import { RulesFilterProps } from '../../../rule-list/filter/RulesFilter'; | ||||||
|  | import { RuleHealth, getSearchFilterFromQuery } from '../../../search/rulesSearchParser'; | ||||||
| import { alertStateToReadable } from '../../../utils/rules'; | import { alertStateToReadable } from '../../../utils/rules'; | ||||||
| import { PopupCard } from '../../HoverCard'; | import { PopupCard } from '../../HoverCard'; | ||||||
| import { MultipleDataSourcePicker } from '../MultipleDataSourcePicker'; | import { MultipleDataSourcePicker } from '../MultipleDataSourcePicker'; | ||||||
| 
 | 
 | ||||||
| import { RulesFilterProps } from './RulesFilter'; |  | ||||||
| import { RulesViewModeSelector } from './RulesViewModeSelector'; | import { RulesViewModeSelector } from './RulesViewModeSelector'; | ||||||
| 
 | 
 | ||||||
| const RuleTypeOptions: SelectableValue[] = [ | const RuleTypeOptions: SelectableValue[] = [ | ||||||
|  | @ -79,33 +74,27 @@ const RulesFilter = ({ onClear = () => undefined, viewMode, onViewModeChange }: | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     setFilterKey((key) => key + 1); |     setFilterKey((key) => key + 1); | ||||||
|     trackRulesSearchComponentInteraction('dataSourceNames'); |     trackAlertRuleFilterEvent({ filterMethod: 'filter-component', filter: 'dataSourceNames' }); | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   const handleDashboardChange = (dashboardUid: string | undefined) => { |   type Filters = typeof filterState; | ||||||
|     updateFilters({ ...filterState, dashboardUid }); | 
 | ||||||
|     trackRulesSearchComponentInteraction('dashboardUid'); |   const updateAndTrack = | ||||||
|   }; |     <K extends keyof Filters>(key: K) => | ||||||
|  |     (value: Filters[K]) => { | ||||||
|  |       updateFilters({ ...filterState, [key]: value }); | ||||||
|  |       trackAlertRuleFilterEvent({ filterMethod: 'filter-component', filter: key }); | ||||||
|  |     }; | ||||||
| 
 | 
 | ||||||
|   const clearDataSource = () => { |   const clearDataSource = () => { | ||||||
|     updateFilters({ ...filterState, dataSourceNames: [] }); |     updateFilters({ ...filterState, dataSourceNames: [] }); | ||||||
|     setFilterKey((key) => key + 1); |     setFilterKey((key) => key + 1); | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|  |   // Note: keep explicit logging for alert state filter clicks
 | ||||||
|   const handleAlertStateChange = (value: PromAlertingRuleState) => { |   const handleAlertStateChange = (value: PromAlertingRuleState) => { | ||||||
|     logInfo(LogMessages.clickingAlertStateFilters); |     logInfo(LogMessages.clickingAlertStateFilters); | ||||||
|     updateFilters({ ...filterState, ruleState: value }); |     updateAndTrack('ruleState')(value); | ||||||
|     trackRulesSearchComponentInteraction('ruleState'); |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
|   const handleRuleTypeChange = (ruleType: PromRuleType) => { |  | ||||||
|     updateFilters({ ...filterState, ruleType }); |  | ||||||
|     trackRulesSearchComponentInteraction('ruleType'); |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
|   const handleRuleHealthChange = (ruleHealth: RuleHealth) => { |  | ||||||
|     updateFilters({ ...filterState, ruleHealth }); |  | ||||||
|     trackRulesSearchComponentInteraction('ruleHealth'); |  | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   const handleClearFiltersClick = () => { |   const handleClearFiltersClick = () => { | ||||||
|  | @ -116,8 +105,7 @@ const RulesFilter = ({ onClear = () => undefined, viewMode, onViewModeChange }: | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   const handleContactPointChange = (contactPoint: string) => { |   const handleContactPointChange = (contactPoint: string) => { | ||||||
|     updateFilters({ ...filterState, contactPoint }); |     updateAndTrack('contactPoint')(contactPoint); | ||||||
|     trackRulesSearchComponentInteraction('contactPoint'); |  | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   const searchIcon = <Icon name={'search'} />; |   const searchIcon = <Icon name={'search'} />; | ||||||
|  | @ -190,7 +178,7 @@ const RulesFilter = ({ onClear = () => undefined, viewMode, onViewModeChange }: | ||||||
|             inputId="filters-dashboard-picker" |             inputId="filters-dashboard-picker" | ||||||
|             key={filterState.dashboardUid ? 'dashboard-defined' : 'dashboard-not-defined'} |             key={filterState.dashboardUid ? 'dashboard-defined' : 'dashboard-not-defined'} | ||||||
|             value={filterState.dashboardUid} |             value={filterState.dashboardUid} | ||||||
|             onChange={(value) => handleDashboardChange(value?.uid)} |             onChange={(value) => updateAndTrack('dashboardUid')(value?.uid)} | ||||||
|             isClearable |             isClearable | ||||||
|             cacheOptions |             cacheOptions | ||||||
|           /> |           /> | ||||||
|  | @ -210,7 +198,11 @@ const RulesFilter = ({ onClear = () => undefined, viewMode, onViewModeChange }: | ||||||
|           <Label> |           <Label> | ||||||
|             <Trans i18nKey="alerting.rules-filter.rule-type">Rule type</Trans> |             <Trans i18nKey="alerting.rules-filter.rule-type">Rule type</Trans> | ||||||
|           </Label> |           </Label> | ||||||
|           <RadioButtonGroup options={RuleTypeOptions} value={filterState.ruleType} onChange={handleRuleTypeChange} /> |           <RadioButtonGroup | ||||||
|  |             options={RuleTypeOptions} | ||||||
|  |             value={filterState.ruleType} | ||||||
|  |             onChange={updateAndTrack('ruleType')} | ||||||
|  |           /> | ||||||
|         </div> |         </div> | ||||||
|         <div> |         <div> | ||||||
|           <Label> |           <Label> | ||||||
|  | @ -219,7 +211,7 @@ const RulesFilter = ({ onClear = () => undefined, viewMode, onViewModeChange }: | ||||||
|           <RadioButtonGroup |           <RadioButtonGroup | ||||||
|             options={RuleHealthOptions} |             options={RuleHealthOptions} | ||||||
|             value={filterState.ruleHealth} |             value={filterState.ruleHealth} | ||||||
|             onChange={handleRuleHealthChange} |             onChange={updateAndTrack('ruleHealth')} | ||||||
|           /> |           /> | ||||||
|         </div> |         </div> | ||||||
|         {canRenderContactPointSelector && ( |         {canRenderContactPointSelector && ( | ||||||
|  | @ -271,7 +263,10 @@ const RulesFilter = ({ onClear = () => undefined, viewMode, onViewModeChange }: | ||||||
|             onSubmit={handleSubmit((data) => { |             onSubmit={handleSubmit((data) => { | ||||||
|               setSearchQuery(data.searchQuery); |               setSearchQuery(data.searchQuery); | ||||||
|               searchQueryRef.current?.blur(); |               searchQueryRef.current?.blur(); | ||||||
|               trackRulesSearchInputInteraction({ oldQuery: searchQuery, newQuery: data.searchQuery }); |               trackAlertRuleFilterEvent({ | ||||||
|  |                 filterMethod: 'search-input', | ||||||
|  |                 filter: getSearchFilterFromQuery(data.searchQuery), | ||||||
|  |               }); | ||||||
|             })} |             })} | ||||||
|           > |           > | ||||||
|             <Field |             <Field | ||||||
|  |  | ||||||
|  | @ -1,623 +0,0 @@ | ||||||
| import { css } from '@emotion/css'; |  | ||||||
| import { useEffect, useRef, useState } from 'react'; |  | ||||||
| import { Controller, SubmitHandler, useForm } from 'react-hook-form'; |  | ||||||
| 
 |  | ||||||
| import { ContactPointSelector } from '@grafana/alerting/unstable'; |  | ||||||
| import { GrafanaTheme2 } from '@grafana/data'; |  | ||||||
| import { Trans, t } from '@grafana/i18n'; |  | ||||||
| import { |  | ||||||
|   Box, |  | ||||||
|   Button, |  | ||||||
|   Combobox, |  | ||||||
|   FilterInput, |  | ||||||
|   Icon, |  | ||||||
|   Input, |  | ||||||
|   Label, |  | ||||||
|   MultiCombobox, |  | ||||||
|   RadioButtonGroup, |  | ||||||
|   Stack, |  | ||||||
|   Tooltip, |  | ||||||
|   useStyles2, |  | ||||||
| } from '@grafana/ui'; |  | ||||||
| import { contextSrv } from 'app/core/core'; |  | ||||||
| import { AccessControlAction } from 'app/types/accessControl'; |  | ||||||
| import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto'; |  | ||||||
| 
 |  | ||||||
| import { trackFilterButtonApplyClick, trackFilterButtonClearClick, trackFilterButtonClick } from '../../../Analytics'; |  | ||||||
| import { useRulesFilter } from '../../../hooks/useFilteredRules'; |  | ||||||
| import { RuleHealth, applySearchFilterToQuery, getSearchFilterFromQuery } from '../../../search/rulesSearchParser'; |  | ||||||
| import { PopupCard } from '../../HoverCard'; |  | ||||||
| 
 |  | ||||||
| import { RulesFilterProps } from './RulesFilter'; |  | ||||||
| import { RulesViewModeSelector } from './RulesViewModeSelector'; |  | ||||||
| import { |  | ||||||
|   useAlertingDataSourceOptions, |  | ||||||
|   useLabelOptions, |  | ||||||
|   useNamespaceAndGroupOptions, |  | ||||||
| } from './useRuleFilterAutocomplete'; |  | ||||||
| import { |  | ||||||
|   emptyAdvancedFilters, |  | ||||||
|   formAdvancedFiltersToRuleFilter, |  | ||||||
|   searchQueryToDefaultValues, |  | ||||||
|   usePluginsFilterStatus, |  | ||||||
|   usePortalContainer, |  | ||||||
| } from './utils'; |  | ||||||
| 
 |  | ||||||
| const canRenderContactPointSelector = contextSrv.hasPermission(AccessControlAction.AlertingReceiversRead); |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * Custom hook that creates a DOM container for rendering dropdowns outside of popup stacking contexts. |  | ||||||
|  * This prevents dropdowns from appearing behind modals/popups due to CSS stacking context limitations. |  | ||||||
|  * |  | ||||||
|  * @param zIndex - The z-index value for the portal container |  | ||||||
|  * @returns HTMLDivElement container appended to document.body, or undefined during initial render |  | ||||||
|  */ |  | ||||||
| 
 |  | ||||||
| export type AdvancedFilters = { |  | ||||||
|   namespace?: string | null; |  | ||||||
|   groupName?: string | null; |  | ||||||
|   ruleName?: string; |  | ||||||
|   ruleType?: PromRuleType | '*'; |  | ||||||
|   ruleState: PromAlertingRuleState | '*'; |  | ||||||
|   dataSourceNames: string[]; |  | ||||||
|   labels: string[]; |  | ||||||
|   ruleHealth?: RuleHealth | '*'; |  | ||||||
|   dashboardUid?: string; |  | ||||||
|   plugins?: 'show' | 'hide'; |  | ||||||
|   contactPoint?: string | null; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| type SearchQueryForm = { |  | ||||||
|   query: string; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export default function RulesFilter({ viewMode, onViewModeChange }: RulesFilterProps) { |  | ||||||
|   const styles = useStyles2(getStyles); |  | ||||||
| 
 |  | ||||||
|   const [isPopupOpen, setIsPopupOpen] = useState(false); |  | ||||||
|   const { searchQuery, updateFilters, setSearchQuery } = useRulesFilter(); |  | ||||||
|   const popupRef = useRef<HTMLDivElement>(null); |  | ||||||
|   const { pluginsFilterEnabled } = usePluginsFilterStatus(); |  | ||||||
| 
 |  | ||||||
|   // this form will managed the search query string, which is updated either by the user typing in the input or by the advanced filters
 |  | ||||||
|   const { setValue, watch, getValues, handleSubmit } = useForm<SearchQueryForm>({ |  | ||||||
|     defaultValues: { |  | ||||||
|       query: searchQuery, |  | ||||||
|     }, |  | ||||||
|   }); |  | ||||||
| 
 |  | ||||||
|   useEffect(() => { |  | ||||||
|     setValue('query', searchQuery); |  | ||||||
|   }, [searchQuery, setValue]); |  | ||||||
| 
 |  | ||||||
|   const submitHandler: SubmitHandler<SearchQueryForm> = (values: SearchQueryForm) => { |  | ||||||
|     const parsedFilter = getSearchFilterFromQuery(values.query); |  | ||||||
|     updateFilters(parsedFilter); |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
|   const handleAdvancedFilters: SubmitHandler<AdvancedFilters> = (values) => { |  | ||||||
|     const newFilter = formAdvancedFiltersToRuleFilter(values); |  | ||||||
|     updateFilters(newFilter); |  | ||||||
| 
 |  | ||||||
|     const newSearchQuery = applySearchFilterToQuery('', newFilter); |  | ||||||
|     setSearchQuery(newSearchQuery); |  | ||||||
| 
 |  | ||||||
|     trackFilterButtonApplyClick(values, pluginsFilterEnabled); |  | ||||||
|     setIsPopupOpen(false); // Should close popup after applying filters?
 |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
|   const handleClearFilters = () => { |  | ||||||
|     updateFilters(formAdvancedFiltersToRuleFilter(emptyAdvancedFilters)); |  | ||||||
|     setSearchQuery(undefined); |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
|   const handleOnToggle = () => { |  | ||||||
|     trackFilterButtonClick(); |  | ||||||
|     setIsPopupOpen(!isPopupOpen); |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
|   // Handle outside clicks to close the popup
 |  | ||||||
|   useEffect(() => { |  | ||||||
|     const handleClickOutside = (event: MouseEvent) => { |  | ||||||
|       if (isPopupOpen && popupRef.current && event.target instanceof Node && !popupRef.current.contains(event.target)) { |  | ||||||
|         // Check if click is on a portal element (combobox dropdown)
 |  | ||||||
|         if (event.target instanceof Element) { |  | ||||||
|           const isPortalClick = |  | ||||||
|             event.target.closest('[data-popper-placement]') || event.target.closest('[role="listbox"]'); |  | ||||||
| 
 |  | ||||||
|           if (!isPortalClick) { |  | ||||||
|             setIsPopupOpen(false); |  | ||||||
|           } |  | ||||||
|         } else { |  | ||||||
|           setIsPopupOpen(false); |  | ||||||
|         } |  | ||||||
|       } |  | ||||||
|     }; |  | ||||||
| 
 |  | ||||||
|     if (isPopupOpen) { |  | ||||||
|       document.addEventListener('mousedown', handleClickOutside); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     return () => { |  | ||||||
|       document.removeEventListener('mousedown', handleClickOutside); |  | ||||||
|     }; |  | ||||||
|   }, [isPopupOpen]); |  | ||||||
| 
 |  | ||||||
|   const filterButtonLabel = t('alerting.rules-filter.filter-options.aria-label-show-filters', 'Filter'); |  | ||||||
|   return ( |  | ||||||
|     <form onSubmit={handleSubmit(submitHandler)} onReset={() => {}}> |  | ||||||
|       <Stack direction="column" gap={1}> |  | ||||||
|         <Label htmlFor="rulesSearchInput"> |  | ||||||
|           <Stack gap={0.5} alignItems="center"> |  | ||||||
|             <span> |  | ||||||
|               <Trans i18nKey="alerting.rules-filter.search">Search</Trans> |  | ||||||
|             </span> |  | ||||||
|             <PopupCard content={<SearchQueryHelp />}> |  | ||||||
|               <Icon |  | ||||||
|                 name="info-circle" |  | ||||||
|                 size="sm" |  | ||||||
|                 tabIndex={0} |  | ||||||
|                 title={t('alerting.rules-filter.title-search-help', 'Search help')} |  | ||||||
|               /> |  | ||||||
|             </PopupCard> |  | ||||||
|           </Stack> |  | ||||||
|         </Label> |  | ||||||
|         <Stack direction="row" alignItems="center" gap={1}> |  | ||||||
|           <Box flex={1}> |  | ||||||
|             <FilterInput |  | ||||||
|               id="rulesSearchInput" |  | ||||||
|               data-testid="search-query-input" |  | ||||||
|               placeholder={t( |  | ||||||
|                 'alerting.rules-filter.filter-options.placeholder-search-input', |  | ||||||
|                 'Search by name or enter filter query...' |  | ||||||
|               )} |  | ||||||
|               name="searchQuery" |  | ||||||
|               onChange={(string) => setValue('query', string)} |  | ||||||
|               onBlur={() => { |  | ||||||
|                 const currentQuery = getValues('query'); |  | ||||||
|                 const parsedFilter = getSearchFilterFromQuery(currentQuery); |  | ||||||
|                 updateFilters(parsedFilter); |  | ||||||
|               }} |  | ||||||
|               value={watch('query')} |  | ||||||
|             /> |  | ||||||
|           </Box> |  | ||||||
|           {/* the popup card is mounted inside of a portal, so we can't rely on the usual form handling mechanisms of button[type=submit] */} |  | ||||||
|           <PopupCard |  | ||||||
|             showOn="click" |  | ||||||
|             placement="auto" |  | ||||||
|             disableBlur={true} |  | ||||||
|             isOpen={isPopupOpen} |  | ||||||
|             onClose={() => setIsPopupOpen(false)} |  | ||||||
|             onToggle={handleOnToggle} |  | ||||||
|             content={ |  | ||||||
|               // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
 |  | ||||||
|               <div |  | ||||||
|                 ref={popupRef} |  | ||||||
|                 className={styles.content} |  | ||||||
|                 onClick={(e) => e.stopPropagation()} |  | ||||||
|                 onKeyDown={(e) => { |  | ||||||
|                   if (e.key === 'Enter' || e.key === ' ') { |  | ||||||
|                     e.stopPropagation(); |  | ||||||
|                   } |  | ||||||
|                 }} |  | ||||||
|                 role="dialog" |  | ||||||
|                 aria-label={t('alerting.rules-filter.filter-options.aria-label', 'Filter options')} |  | ||||||
|                 tabIndex={-1} |  | ||||||
|               > |  | ||||||
|                 <FilterOptions |  | ||||||
|                   onSubmit={handleAdvancedFilters} |  | ||||||
|                   onClear={handleClearFilters} |  | ||||||
|                   pluginsFilterEnabled={pluginsFilterEnabled} |  | ||||||
|                 /> |  | ||||||
|               </div> |  | ||||||
|             } |  | ||||||
|           > |  | ||||||
|             <Button name="filter" icon="filter" variant="secondary" aria-label={filterButtonLabel}> |  | ||||||
|               {filterButtonLabel} |  | ||||||
|             </Button> |  | ||||||
|           </PopupCard> |  | ||||||
|           <RulesViewModeSelector viewMode={viewMode} onViewModeChange={onViewModeChange} /> |  | ||||||
|         </Stack> |  | ||||||
|       </Stack> |  | ||||||
|     </form> |  | ||||||
|   ); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| interface FilterOptionsProps { |  | ||||||
|   onSubmit: SubmitHandler<AdvancedFilters>; |  | ||||||
|   onClear: () => void; |  | ||||||
|   pluginsFilterEnabled: boolean; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| const FilterOptions = ({ onSubmit, onClear, pluginsFilterEnabled }: FilterOptionsProps) => { |  | ||||||
|   const styles = useStyles2(getStyles); |  | ||||||
|   const theme = useStyles2((theme) => theme); |  | ||||||
|   const { filterState } = useRulesFilter(); |  | ||||||
|   const isManualResetRef = useRef(false); |  | ||||||
| 
 |  | ||||||
|   // Create portal container to render dropdowns above the popup modal
 |  | ||||||
|   const portalContainer = usePortalContainer(theme.zIndex.portal + 100); |  | ||||||
| 
 |  | ||||||
|   const defaultValues = searchQueryToDefaultValues(filterState); |  | ||||||
| 
 |  | ||||||
|   // Fetch namespace and group data from all sources (optimized for filter UI)
 |  | ||||||
|   const { namespaceOptions, allGroupNames, isLoadingNamespaces, namespacePlaceholder, groupPlaceholder } = |  | ||||||
|     useNamespaceAndGroupOptions(); |  | ||||||
| 
 |  | ||||||
|   const { labelOptions, isLoadingGrafanaLabels } = useLabelOptions(); |  | ||||||
| 
 |  | ||||||
|   // Create label options for the multi-select dropdown
 |  | ||||||
|   const dataSourceOptions = useAlertingDataSourceOptions(); |  | ||||||
| 
 |  | ||||||
|   // turn the filterState into form default values
 |  | ||||||
|   const { handleSubmit, reset, register, control } = useForm<AdvancedFilters>({ |  | ||||||
|     defaultValues, |  | ||||||
|   }); |  | ||||||
| 
 |  | ||||||
|   // Update form values when filterState changes (e.g., when popup reopens)
 |  | ||||||
|   useEffect(() => { |  | ||||||
|     // Skip if we're in the middle of a manual reset
 |  | ||||||
|     if (isManualResetRef.current) { |  | ||||||
|       isManualResetRef.current = false; |  | ||||||
|       return; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     const newDefaultValues = searchQueryToDefaultValues(filterState); |  | ||||||
|     reset(newDefaultValues); |  | ||||||
|   }, [filterState, reset]); |  | ||||||
| 
 |  | ||||||
|   const submitAdvancedFilters = handleSubmit(onSubmit); |  | ||||||
| 
 |  | ||||||
|   return ( |  | ||||||
|     <form |  | ||||||
|       onSubmit={submitAdvancedFilters} |  | ||||||
|       onReset={() => { |  | ||||||
|         isManualResetRef.current = true; |  | ||||||
|         reset(emptyAdvancedFilters); |  | ||||||
|         trackFilterButtonClearClick(); |  | ||||||
|         onClear(); |  | ||||||
|       }} |  | ||||||
|     > |  | ||||||
|       <Stack direction="column" alignItems="end" gap={2}> |  | ||||||
|         <div className={styles.grid}> |  | ||||||
|           <Label> |  | ||||||
|             <Trans i18nKey="alerting.search.property.rule-name">Rule name</Trans> |  | ||||||
|           </Label> |  | ||||||
|           <Input {...register('ruleName')} data-testid="rule-name-input" /> |  | ||||||
|           <Label> |  | ||||||
|             <Trans i18nKey="alerting.search.property.labels">Labels</Trans> |  | ||||||
|           </Label> |  | ||||||
|           <Controller |  | ||||||
|             name="labels" |  | ||||||
|             control={control} |  | ||||||
|             render={({ field }) => ( |  | ||||||
|               <MultiCombobox |  | ||||||
|                 options={labelOptions} |  | ||||||
|                 value={field.value} |  | ||||||
|                 onChange={(selections) => field.onChange(selections.map((s) => s.value))} |  | ||||||
|                 placeholder={ |  | ||||||
|                   isLoadingGrafanaLabels |  | ||||||
|                     ? t('common.loading', 'Loading...') |  | ||||||
|                     : t('alerting.rules-filter.placeholder-labels', 'Select labels') |  | ||||||
|                 } |  | ||||||
|                 loading={isLoadingGrafanaLabels} |  | ||||||
|                 disabled={isLoadingGrafanaLabels || labelOptions.filter((option) => !option.infoOption).length === 0} |  | ||||||
|                 portalContainer={portalContainer} |  | ||||||
|                 width="auto" |  | ||||||
|                 minWidth={40} |  | ||||||
|                 maxWidth={80} |  | ||||||
|               /> |  | ||||||
|             )} |  | ||||||
|           /> |  | ||||||
|           <Label> |  | ||||||
|             <Trans i18nKey="alerting.search.property.namespace">Folder / Namespace</Trans> |  | ||||||
|           </Label> |  | ||||||
|           <Controller |  | ||||||
|             name="namespace" |  | ||||||
|             control={control} |  | ||||||
|             render={({ field }) => { |  | ||||||
|               return ( |  | ||||||
|                 <Combobox<string> |  | ||||||
|                   placeholder={namespacePlaceholder} |  | ||||||
|                   options={namespaceOptions} |  | ||||||
|                   onChange={(option) => field.onChange(option?.value || null)} |  | ||||||
|                   value={field.value} |  | ||||||
|                   loading={isLoadingNamespaces} |  | ||||||
|                   disabled={isLoadingNamespaces || namespaceOptions.length === 0} |  | ||||||
|                   isClearable |  | ||||||
|                   portalContainer={portalContainer} |  | ||||||
|                 /> |  | ||||||
|               ); |  | ||||||
|             }} |  | ||||||
|           /> |  | ||||||
|           <Label> |  | ||||||
|             <Trans i18nKey="alerting.search.property.evaluation-group">Evaluation group</Trans> |  | ||||||
|           </Label> |  | ||||||
|           <Controller |  | ||||||
|             name="groupName" |  | ||||||
|             control={control} |  | ||||||
|             render={({ field }) => { |  | ||||||
|               return ( |  | ||||||
|                 <Combobox<string> |  | ||||||
|                   placeholder={groupPlaceholder} |  | ||||||
|                   options={allGroupNames.map((name) => ({ label: name, value: name }))} |  | ||||||
|                   onChange={(option) => field.onChange(option?.value || null)} |  | ||||||
|                   value={field.value} |  | ||||||
|                   loading={isLoadingNamespaces} |  | ||||||
|                   disabled={isLoadingNamespaces || allGroupNames.length === 0} |  | ||||||
|                   isClearable |  | ||||||
|                   portalContainer={portalContainer} |  | ||||||
|                 /> |  | ||||||
|               ); |  | ||||||
|             }} |  | ||||||
|           /> |  | ||||||
|           <Label> |  | ||||||
|             <Stack gap={0.5} alignItems="center"> |  | ||||||
|               <span> |  | ||||||
|                 <Trans i18nKey="alerting.search.property.data-source">Data source</Trans> |  | ||||||
|               </span> |  | ||||||
|               <Tooltip |  | ||||||
|                 content={ |  | ||||||
|                   <div> |  | ||||||
|                     <p> |  | ||||||
|                       <Trans i18nKey="alerting.rules-filter.configured-alert-rules"> |  | ||||||
|                         Data sources containing configured alert rules are Mimir or Loki data sources where alert rules |  | ||||||
|                         are stored and evaluated in the data source itself. |  | ||||||
|                       </Trans> |  | ||||||
|                     </p> |  | ||||||
|                     <p> |  | ||||||
|                       <Trans i18nKey="alerting.rules-filter.manage-alerts"> |  | ||||||
|                         In these data sources, you can select Manage alerts via Alerting UI to be able to manage these |  | ||||||
|                         alert rules in the Grafana UI as well as in the data source where they were configured. |  | ||||||
|                       </Trans> |  | ||||||
|                     </p> |  | ||||||
|                   </div> |  | ||||||
|                 } |  | ||||||
|               > |  | ||||||
|                 <Icon |  | ||||||
|                   name="info-circle" |  | ||||||
|                   size="sm" |  | ||||||
|                   title={t( |  | ||||||
|                     'alerting.rules-filter.data-source-picker-inline-help-title-search-by-data-sources-help', |  | ||||||
|                     'Search by data sources help' |  | ||||||
|                   )} |  | ||||||
|                 /> |  | ||||||
|               </Tooltip> |  | ||||||
|             </Stack> |  | ||||||
|           </Label> |  | ||||||
|           <Controller |  | ||||||
|             name="dataSourceNames" |  | ||||||
|             control={control} |  | ||||||
|             render={({ field }) => ( |  | ||||||
|               <MultiCombobox |  | ||||||
|                 options={dataSourceOptions} |  | ||||||
|                 value={field.value} |  | ||||||
|                 onChange={(selections) => field.onChange(selections.map((s) => s.value))} |  | ||||||
|                 placeholder={t('alerting.rules-filter.placeholder-data-sources', 'Select data sources')} |  | ||||||
|                 portalContainer={portalContainer} |  | ||||||
|                 width="auto" |  | ||||||
|                 minWidth={40} |  | ||||||
|                 maxWidth={80} |  | ||||||
|               /> |  | ||||||
|             )} |  | ||||||
|           /> |  | ||||||
|           {canRenderContactPointSelector && ( |  | ||||||
|             <> |  | ||||||
|               <Label> |  | ||||||
|                 <Stack gap={0.5} alignItems="center"> |  | ||||||
|                   <span> |  | ||||||
|                     <Trans i18nKey="alerting.contactPointFilter.label">Contact point</Trans> |  | ||||||
|                   </span> |  | ||||||
|                   <Tooltip |  | ||||||
|                     content={ |  | ||||||
|                       <Trans i18nKey="alerting.rules-filter.contact-point-tooltip"> |  | ||||||
|                         Filters alert rules which route directly to the selected contact point. Alert rules routed to |  | ||||||
|                         notification policies will not be displayed. |  | ||||||
|                       </Trans> |  | ||||||
|                     } |  | ||||||
|                   > |  | ||||||
|                     <Icon |  | ||||||
|                       name="info-circle" |  | ||||||
|                       size="sm" |  | ||||||
|                       title={t('alerting.rules-filter.contact-point-tooltip-title', 'Contact point filter help')} |  | ||||||
|                     /> |  | ||||||
|                   </Tooltip> |  | ||||||
|                 </Stack> |  | ||||||
|               </Label> |  | ||||||
|               <Controller |  | ||||||
|                 name="contactPoint" |  | ||||||
|                 control={control} |  | ||||||
|                 render={({ field }) => { |  | ||||||
|                   return ( |  | ||||||
|                     <ContactPointSelector |  | ||||||
|                       placeholder={t('alerting.rules-filter.placeholder-contact-point', 'Select contact point')} |  | ||||||
|                       value={field.value} |  | ||||||
|                       isClearable |  | ||||||
|                       onChange={(contactPoint) => { |  | ||||||
|                         field.onChange(contactPoint?.spec.title || null); |  | ||||||
|                       }} |  | ||||||
|                       portalContainer={portalContainer} |  | ||||||
|                     /> |  | ||||||
|                   ); |  | ||||||
|                 }} |  | ||||||
|               /> |  | ||||||
|             </> |  | ||||||
|           )} |  | ||||||
|           <Label> |  | ||||||
|             <Trans i18nKey="alerting.search.property.state">State</Trans> |  | ||||||
|           </Label> |  | ||||||
|           <Controller |  | ||||||
|             name="ruleState" |  | ||||||
|             control={control} |  | ||||||
|             render={({ field }) => ( |  | ||||||
|               <RadioButtonGroup<AdvancedFilters['ruleState']> |  | ||||||
|                 options={[ |  | ||||||
|                   { label: t('common.all', 'All'), value: '*' }, |  | ||||||
|                   { label: t('alerting.rules.state.firing', 'Firing'), value: PromAlertingRuleState.Firing }, |  | ||||||
|                   { label: t('alerting.rules.state.normal', 'Normal'), value: PromAlertingRuleState.Inactive }, |  | ||||||
|                   { label: t('alerting.rules.state.pending', 'Pending'), value: PromAlertingRuleState.Pending }, |  | ||||||
|                   { |  | ||||||
|                     label: t('alerting.rules.state.recovering', 'Recovering'), |  | ||||||
|                     value: PromAlertingRuleState.Recovering, |  | ||||||
|                   }, |  | ||||||
|                   { label: t('alerting.rules.state.unknown', 'Unknown'), value: PromAlertingRuleState.Unknown }, |  | ||||||
|                 ]} |  | ||||||
|                 value={field.value} |  | ||||||
|                 onChange={field.onChange} |  | ||||||
|               /> |  | ||||||
|             )} |  | ||||||
|           /> |  | ||||||
|           <Label> |  | ||||||
|             <Trans i18nKey="alerting.search.property.rule-type">Type</Trans> |  | ||||||
|           </Label> |  | ||||||
|           <Controller |  | ||||||
|             name="ruleType" |  | ||||||
|             control={control} |  | ||||||
|             render={({ field }) => ( |  | ||||||
|               <RadioButtonGroup<AdvancedFilters['ruleType']> |  | ||||||
|                 options={[ |  | ||||||
|                   { label: t('common.all', 'All'), value: '*' }, |  | ||||||
|                   { label: t('alerting.rules.type.alert', 'Alert rule'), value: PromRuleType.Alerting }, |  | ||||||
|                   { label: t('alerting.rules.type.recording', 'Recording rule'), value: PromRuleType.Recording }, |  | ||||||
|                 ]} |  | ||||||
|                 value={field.value} |  | ||||||
|                 onChange={field.onChange} |  | ||||||
|               /> |  | ||||||
|             )} |  | ||||||
|           /> |  | ||||||
|           <Label> |  | ||||||
|             <Trans i18nKey="alerting.search.property.rule-health">Health</Trans> |  | ||||||
|           </Label> |  | ||||||
|           <Controller |  | ||||||
|             name="ruleHealth" |  | ||||||
|             control={control} |  | ||||||
|             render={({ field }) => ( |  | ||||||
|               <RadioButtonGroup<AdvancedFilters['ruleHealth']> |  | ||||||
|                 options={[ |  | ||||||
|                   { label: t('common.all', 'All'), value: '*' }, |  | ||||||
|                   { label: t('alerting.rules.health.ok', 'OK'), value: RuleHealth.Ok }, |  | ||||||
|                   { label: t('alerting.rules.health.no-data', 'No data'), value: RuleHealth.NoData }, |  | ||||||
|                   { label: t('alerting.rules.health.error', 'Error'), value: RuleHealth.Error }, |  | ||||||
|                 ]} |  | ||||||
|                 value={field.value} |  | ||||||
|                 onChange={field.onChange} |  | ||||||
|               /> |  | ||||||
|             )} |  | ||||||
|           /> |  | ||||||
|           {pluginsFilterEnabled && ( |  | ||||||
|             <> |  | ||||||
|               <Label> |  | ||||||
|                 <Trans i18nKey="alerting.rules-filter.plugin-rules">Plugin rules</Trans> |  | ||||||
|               </Label> |  | ||||||
|               <Controller |  | ||||||
|                 name="plugins" |  | ||||||
|                 control={control} |  | ||||||
|                 render={({ field }) => ( |  | ||||||
|                   <RadioButtonGroup<AdvancedFilters['plugins']> |  | ||||||
|                     options={[ |  | ||||||
|                       { label: t('alerting.rules-filter.label.show', 'Show'), value: 'show' }, |  | ||||||
|                       { label: t('alerting.rules-filter.label.hide', 'Hide'), value: 'hide' }, |  | ||||||
|                     ]} |  | ||||||
|                     value={field.value} |  | ||||||
|                     onChange={field.onChange} |  | ||||||
|                   /> |  | ||||||
|                 )} |  | ||||||
|               /> |  | ||||||
|             </> |  | ||||||
|           )} |  | ||||||
|         </div> |  | ||||||
|         <Stack direction="row" alignItems="center"> |  | ||||||
|           <Button type="reset" variant="secondary" data-testid="filter-clear-button"> |  | ||||||
|             <Trans i18nKey="common.clear">Clear</Trans> |  | ||||||
|           </Button> |  | ||||||
|           <Button type="submit" data-testid="filter-apply-button"> |  | ||||||
|             <Trans i18nKey="common.apply">Apply</Trans> |  | ||||||
|           </Button> |  | ||||||
|         </Stack> |  | ||||||
|       </Stack> |  | ||||||
|     </form> |  | ||||||
|   ); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| function SearchQueryHelp() { |  | ||||||
|   const styles = useStyles2(helpStyles); |  | ||||||
| 
 |  | ||||||
|   return ( |  | ||||||
|     <div> |  | ||||||
|       <div> |  | ||||||
|         <Trans i18nKey="alerting.search-query-help.search-syntax"> |  | ||||||
|           Search syntax allows to query alert rules by the parameters defined below. |  | ||||||
|         </Trans> |  | ||||||
|       </div> |  | ||||||
|       <hr /> |  | ||||||
|       <div className={styles.grid}> |  | ||||||
|         <div> |  | ||||||
|           <Trans i18nKey="alerting.search-query-help.filter-type">Filter type</Trans> |  | ||||||
|         </div> |  | ||||||
|         <div> |  | ||||||
|           <Trans i18nKey="alerting.search-query-help.expression">Expression</Trans> |  | ||||||
|         </div> |  | ||||||
|         <HelpRow |  | ||||||
|           title={t('alerting.search-query-help.title-datasources', 'Datasources')} |  | ||||||
|           expr="datasource:mimir datasource:prometheus" |  | ||||||
|         /> |  | ||||||
|         <HelpRow |  | ||||||
|           title={t('alerting.search-query-help.title-folder-namespace', 'Folder/Namespace')} |  | ||||||
|           expr="namespace:global" |  | ||||||
|         /> |  | ||||||
|         <HelpRow title={t('alerting.search-query-help.title-group', 'Group')} expr="group:cpu-usage" /> |  | ||||||
|         <HelpRow title={t('alerting.search-query-help.title-rule', 'Rule')} expr='rule:"cpu 80%"' /> |  | ||||||
|         <HelpRow title={t('alerting.search-query-help.title-labels', 'Labels')} expr="label:team=A label:cluster=a1" /> |  | ||||||
|         <HelpRow title={t('alerting.search-query-help.title-state', 'State')} expr="state:firing|normal|pending" /> |  | ||||||
|         <HelpRow title={t('alerting.search-query-help.title-type', 'Type')} expr="type:alerting|recording" /> |  | ||||||
|         <HelpRow title={t('alerting.search-query-help.title-health', 'Health')} expr="health:ok|nodata|error" /> |  | ||||||
|         <HelpRow |  | ||||||
|           title={t('alerting.search-query-help.title-dashboard-uid', 'Dashboard UID')} |  | ||||||
|           expr="dashboard:eadde4c7-54e6-4964-85c0-484ab852fd04" |  | ||||||
|         /> |  | ||||||
|         <HelpRow |  | ||||||
|           title={t('alerting.search-query-help.title-contact-point', 'Contact point')} |  | ||||||
|           expr="contactPoint:slack" |  | ||||||
|         /> |  | ||||||
|       </div> |  | ||||||
|     </div> |  | ||||||
|   ); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| function HelpRow({ title, expr }: { title: string; expr: string }) { |  | ||||||
|   const styles = useStyles2(helpStyles); |  | ||||||
| 
 |  | ||||||
|   return ( |  | ||||||
|     <> |  | ||||||
|       <div>{title}</div> |  | ||||||
|       <code className={styles.code}>{expr}</code> |  | ||||||
|     </> |  | ||||||
|   ); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| const helpStyles = (theme: GrafanaTheme2) => ({ |  | ||||||
|   grid: css({ |  | ||||||
|     display: 'grid', |  | ||||||
|     gridTemplateColumns: 'max-content auto', |  | ||||||
|     gap: theme.spacing(1), |  | ||||||
|     alignItems: 'center', |  | ||||||
|   }), |  | ||||||
|   code: css({ |  | ||||||
|     display: 'block', |  | ||||||
|     textAlign: 'center', |  | ||||||
|   }), |  | ||||||
| }); |  | ||||||
| 
 |  | ||||||
| function getStyles(theme: GrafanaTheme2) { |  | ||||||
|   return { |  | ||||||
|     content: css({ |  | ||||||
|       padding: theme.spacing(1), |  | ||||||
|     }), |  | ||||||
|     grid: css({ |  | ||||||
|       display: 'grid', |  | ||||||
|       gridTemplateColumns: 'auto 1fr', |  | ||||||
|       alignItems: 'center', |  | ||||||
|       gap: theme.spacing(2), |  | ||||||
|     }), |  | ||||||
|   }; |  | ||||||
| } |  | ||||||
|  | @ -7,7 +7,6 @@ import { Button, Dropdown, Icon, LinkButton, Menu, Stack } from '@grafana/ui'; | ||||||
| 
 | 
 | ||||||
| import { AlertingPageWrapper } from '../components/AlertingPageWrapper'; | import { AlertingPageWrapper } from '../components/AlertingPageWrapper'; | ||||||
| import { GrafanaRulesExporter } from '../components/export/GrafanaRulesExporter'; | import { GrafanaRulesExporter } from '../components/export/GrafanaRulesExporter'; | ||||||
| import RulesFilter from '../components/rules/Filter/RulesFilter'; |  | ||||||
| import { useListViewMode } from '../components/rules/Filter/RulesViewModeSelector'; | import { useListViewMode } from '../components/rules/Filter/RulesViewModeSelector'; | ||||||
| import { AIAlertRuleButtonComponent } from '../enterprise-components/AI/AIGenAlertRuleButton/addAIAlertRuleButton'; | import { AIAlertRuleButtonComponent } from '../enterprise-components/AI/AIGenAlertRuleButton/addAIAlertRuleButton'; | ||||||
| import { AlertingAction, useAlertingAbility } from '../hooks/useAbilities'; | import { AlertingAction, useAlertingAbility } from '../hooks/useAbilities'; | ||||||
|  | @ -17,6 +16,7 @@ import { isAdmin } from '../utils/misc'; | ||||||
| import { FilterView } from './FilterView'; | import { FilterView } from './FilterView'; | ||||||
| import { GroupedView } from './GroupedView'; | import { GroupedView } from './GroupedView'; | ||||||
| import { RuleListPageTitle } from './RuleListPageTitle'; | import { RuleListPageTitle } from './RuleListPageTitle'; | ||||||
|  | import RulesFilter from './filter/RulesFilter'; | ||||||
| 
 | 
 | ||||||
| function RuleList() { | function RuleList() { | ||||||
|   const { filterState } = useRulesFilter(); |   const { filterState } = useRulesFilter(); | ||||||
|  |  | ||||||
|  | @ -7,13 +7,13 @@ import { setupMswServer } from 'app/features/alerting/unified/mockApi'; | ||||||
| import * as analytics from '../../Analytics'; | import * as analytics from '../../Analytics'; | ||||||
| import { setupPluginsExtensionsHook } from '../../testSetup/plugins'; | import { setupPluginsExtensionsHook } from '../../testSetup/plugins'; | ||||||
| 
 | 
 | ||||||
| import RulesFilter from './Filter/RulesFilter'; | import RulesFilter from './RulesFilter'; | ||||||
| 
 | 
 | ||||||
| setupMswServer(); | setupMswServer(); | ||||||
| jest.spyOn(analytics, 'logInfo'); | jest.spyOn(analytics, 'logInfo'); | ||||||
| 
 | 
 | ||||||
| jest.mock('./MultipleDataSourcePicker', () => { | jest.mock('../../components/rules/MultipleDataSourcePicker', () => { | ||||||
|   const original = jest.requireActual('./MultipleDataSourcePicker'); |   const original = jest.requireActual('../../components/rules/MultipleDataSourcePicker'); | ||||||
|   return { |   return { | ||||||
|     ...original, |     ...original, | ||||||
|     MultipleDataSourcePicker: () => null, |     MultipleDataSourcePicker: () => null, | ||||||
|  | @ -2,8 +2,8 @@ import { Suspense, lazy } from 'react'; | ||||||
| 
 | 
 | ||||||
| import { config } from '@grafana/runtime'; | import { config } from '@grafana/runtime'; | ||||||
| 
 | 
 | ||||||
| import RulesFilterV1 from './RulesFilter.v1'; | import RulesFilterV1 from '../../components/rules/Filter/RulesFilter.v1'; | ||||||
| import { SupportedView } from './RulesViewModeSelector'; | import { SupportedView } from '../../components/rules/Filter/RulesViewModeSelector'; | ||||||
| 
 | 
 | ||||||
| const RulesFilterV2 = lazy(() => import('./RulesFilter.v2')); | const RulesFilterV2 = lazy(() => import('./RulesFilter.v2')); | ||||||
| 
 | 
 | ||||||
|  | @ -13,8 +13,12 @@ import { useRulesFilter } from '../../hooks/useFilteredRules'; | ||||||
| import { RulesFilter as RulesFilterType } from '../../search/rulesSearchParser'; | import { RulesFilter as RulesFilterType } from '../../search/rulesSearchParser'; | ||||||
| import { setupPluginsExtensionsHook } from '../../testSetup/plugins'; | import { setupPluginsExtensionsHook } from '../../testSetup/plugins'; | ||||||
| 
 | 
 | ||||||
|  | import RulesFilter from './RulesFilter'; | ||||||
|  | 
 | ||||||
| // Grant permission before importing the component since permission check happens at module level
 | // Grant permission before importing the component since permission check happens at module level
 | ||||||
| grantUserPermissions([AccessControlAction.AlertingReceiversRead]); | grantUserPermissions([AccessControlAction.AlertingReceiversRead]); | ||||||
|  | // eslint-disable-next-line @typescript-eslint/no-var-requires
 | ||||||
|  | const RulesFilterV2 = require('./RulesFilter.v2').default; | ||||||
| 
 | 
 | ||||||
| let mockFilterState: RulesFilterType = { | let mockFilterState: RulesFilterType = { | ||||||
|   ruleName: '', |   ruleName: '', | ||||||
|  | @ -40,9 +44,6 @@ jest.mock('../../hooks/useFilteredRules', () => ({ | ||||||
|   })), |   })), | ||||||
| })); | })); | ||||||
| 
 | 
 | ||||||
| import RulesFilter from './Filter/RulesFilter'; |  | ||||||
| import RulesFilterV2 from './Filter/RulesFilter.v2'; |  | ||||||
| 
 |  | ||||||
| const useRulesFilterMock = useRulesFilter as jest.MockedFunction<typeof useRulesFilter>; | const useRulesFilterMock = useRulesFilter as jest.MockedFunction<typeof useRulesFilter>; | ||||||
| 
 | 
 | ||||||
| setupMswServer(); | setupMswServer(); | ||||||
|  | @ -50,6 +51,8 @@ setupMswServer(); | ||||||
| jest.spyOn(analytics, 'trackFilterButtonClick'); | jest.spyOn(analytics, 'trackFilterButtonClick'); | ||||||
| jest.spyOn(analytics, 'trackFilterButtonApplyClick'); | jest.spyOn(analytics, 'trackFilterButtonApplyClick'); | ||||||
| jest.spyOn(analytics, 'trackFilterButtonClearClick'); | jest.spyOn(analytics, 'trackFilterButtonClearClick'); | ||||||
|  | jest.spyOn(analytics, 'trackAlertRuleFilterEvent'); | ||||||
|  | jest.spyOn(analytics, 'trackRulesSearchInputCleared'); | ||||||
| 
 | 
 | ||||||
| jest.mock('@grafana/runtime', () => ({ | jest.mock('@grafana/runtime', () => ({ | ||||||
|   ...jest.requireActual('@grafana/runtime'), |   ...jest.requireActual('@grafana/runtime'), | ||||||
|  | @ -61,8 +64,8 @@ jest.mock('@grafana/runtime', () => ({ | ||||||
|   }), |   }), | ||||||
| })); | })); | ||||||
| 
 | 
 | ||||||
| jest.mock('./MultipleDataSourcePicker', () => { | jest.mock('../../components/rules/MultipleDataSourcePicker', () => { | ||||||
|   const original = jest.requireActual('./MultipleDataSourcePicker'); |   const original = jest.requireActual('../../components/rules/MultipleDataSourcePicker'); | ||||||
|   return { |   return { | ||||||
|     ...original, |     ...original, | ||||||
|     MultipleDataSourcePicker: () => null, |     MultipleDataSourcePicker: () => null, | ||||||
|  | @ -119,10 +122,25 @@ beforeEach(() => { | ||||||
|     labels: [], |     labels: [], | ||||||
|   }; |   }; | ||||||
|   mockSearchQuery = ''; |   mockSearchQuery = ''; | ||||||
|   mockUpdateFilters.mockClear(); |   // Fully reset mock implementations between tests to avoid leakage across cases
 | ||||||
|   mockSetSearchQuery.mockClear(); |   mockUpdateFilters.mockReset(); | ||||||
|   mockClearAll.mockClear(); |   mockSetSearchQuery.mockReset(); | ||||||
|  |   mockClearAll.mockReset(); | ||||||
|  |   mockUpdateFilters.mockImplementation(() => {}); | ||||||
|   mockSetSearchQuery.mockImplementation(() => {}); |   mockSetSearchQuery.mockImplementation(() => {}); | ||||||
|  |   mockClearAll.mockImplementation(() => {}); | ||||||
|  | 
 | ||||||
|  |   // Restore the default implementation of the hook to use current mock variables
 | ||||||
|  |   useRulesFilterMock.mockReset(); | ||||||
|  |   useRulesFilterMock.mockImplementation(() => ({ | ||||||
|  |     searchQuery: mockSearchQuery, | ||||||
|  |     filterState: mockFilterState, | ||||||
|  |     updateFilters: mockUpdateFilters, | ||||||
|  |     setSearchQuery: mockSetSearchQuery, | ||||||
|  |     clearAll: mockClearAll, | ||||||
|  |     hasActiveFilters: false, | ||||||
|  |     activeFilters: [], | ||||||
|  |   })); | ||||||
| 
 | 
 | ||||||
|   // Reset plugin components hook to default (no plugins)
 |   // Reset plugin components hook to default (no plugins)
 | ||||||
|   setPluginComponentsHook(() => ({ |   setPluginComponentsHook(() => ({ | ||||||
|  | @ -209,21 +227,33 @@ describe('RulesFilterV2', () => { | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   it('Should populate search field with query string when filters are applied via rule name', async () => { |   it('Should populate search field with query string when filters are applied via rule name', async () => { | ||||||
|     const { user } = render(<RulesFilterV2 />); |     const { user, rerender } = render(<RulesFilterV2 />); | ||||||
| 
 | 
 | ||||||
|     await user.click(ui.filterButton.get()); |     await user.click(ui.filterButton.get()); | ||||||
| 
 | 
 | ||||||
|     await user.type(ui.ruleNameInput.get(), 'test'); |     await user.type(ui.ruleNameInput.get(), 'test'); | ||||||
| 
 | 
 | ||||||
|     // Mock the setSearchQuery to update mockSearchQuery
 |     // Mock updateFilters to update the search query as the implementation does
 | ||||||
|     mockSetSearchQuery.mockImplementation((newQuery: string | undefined) => { |     mockUpdateFilters.mockImplementation(() => { | ||||||
|       mockSearchQuery = newQuery ?? ''; |       mockSearchQuery = 'rule:test'; | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     await user.click(ui.applyButton.get()); |     await user.click(ui.applyButton.get()); | ||||||
| 
 | 
 | ||||||
|     // Check that setSearchQuery was called with the expected query
 |     // Update the mock to return the new search query and re-render
 | ||||||
|     expect(mockSetSearchQuery).toHaveBeenCalledWith('rule:test'); |     useRulesFilterMock.mockReturnValue({ | ||||||
|  |       searchQuery: mockSearchQuery, | ||||||
|  |       filterState: mockFilterState, | ||||||
|  |       updateFilters: mockUpdateFilters, | ||||||
|  |       setSearchQuery: mockSetSearchQuery, | ||||||
|  |       clearAll: mockClearAll, | ||||||
|  |       hasActiveFilters: false, | ||||||
|  |       activeFilters: [], | ||||||
|  |     }); | ||||||
|  |     rerender(<RulesFilterV2 />); | ||||||
|  | 
 | ||||||
|  |     // The search input should reflect the updated query string
 | ||||||
|  |     expect(ui.searchInput.get()).toHaveValue('rule:test'); | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   it('Should parse search query and call updateFilters when user types directly in search field', async () => { |   it('Should parse search query and call updateFilters when user types directly in search field', async () => { | ||||||
|  | @ -294,7 +324,7 @@ describe('RulesFilterV2', () => { | ||||||
|       // Permission is already mocked to true at module level
 |       // Permission is already mocked to true at module level
 | ||||||
|       const { user } = render(<RulesFilterV2 />); |       const { user } = render(<RulesFilterV2 />); | ||||||
|       await user.click(ui.filterButton.get()); |       await user.click(ui.filterButton.get()); | ||||||
|       expect(screen.getByText('Contact point')).toBeInTheDocument(); |       expect(await screen.findByText('Contact point')).toBeInTheDocument(); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     it('Should show plugin filter when plugins are enabled', async () => { |     it('Should show plugin filter when plugins are enabled', async () => { | ||||||
|  | @ -306,7 +336,7 @@ describe('RulesFilterV2', () => { | ||||||
| 
 | 
 | ||||||
|       const { user } = render(<RulesFilterV2 />); |       const { user } = render(<RulesFilterV2 />); | ||||||
|       await user.click(ui.filterButton.get()); |       await user.click(ui.filterButton.get()); | ||||||
|       expect(screen.getByText('Plugin rules')).toBeInTheDocument(); |       expect(await screen.findByText('Plugin rules')).toBeInTheDocument(); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     it('Should hide plugin filter when no plugins are available', async () => { |     it('Should hide plugin filter when no plugins are available', async () => { | ||||||
|  | @ -349,6 +379,40 @@ describe('RulesFilterV2', () => { | ||||||
|       expect(analytics.trackFilterButtonApplyClick).toHaveBeenCalledTimes(1); |       expect(analytics.trackFilterButtonApplyClick).toHaveBeenCalledTimes(1); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|  |     it('Should track search input submit with parsed filter payload', async () => { | ||||||
|  |       const { user } = render(<RulesFilterV2 />); | ||||||
|  | 
 | ||||||
|  |       await user.type(ui.searchInput.get(), 'rule:test state:firing'); | ||||||
|  |       await user.keyboard('{Enter}'); | ||||||
|  | 
 | ||||||
|  |       expect(analytics.trackAlertRuleFilterEvent).toHaveBeenCalled(); | ||||||
|  |       const callArg = (analytics.trackAlertRuleFilterEvent as jest.Mock).mock.calls.at(-1)?.[0]; | ||||||
|  |       expect(callArg.filterMethod).toBe('search-input'); | ||||||
|  |       expect(callArg.filter).toMatchObject({ ruleName: 'test', ruleState: 'firing' }); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('Should track search input blur with parsed filter payload', async () => { | ||||||
|  |       const { user } = render(<RulesFilterV2 />); | ||||||
|  | 
 | ||||||
|  |       await user.type(ui.searchInput.get(), 'state:firing'); | ||||||
|  |       await user.click(document.body); | ||||||
|  | 
 | ||||||
|  |       expect(analytics.trackAlertRuleFilterEvent).toHaveBeenCalled(); | ||||||
|  |       const callArg = (analytics.trackAlertRuleFilterEvent as jest.Mock).mock.calls.at(-1)?.[0]; | ||||||
|  |       expect(callArg.filterMethod).toBe('search-input'); | ||||||
|  |       expect(callArg.filter).toMatchObject({ ruleState: 'firing' }); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('Should track search input clear when input transitions to empty', async () => { | ||||||
|  |       const { user } = render(<RulesFilterV2 />); | ||||||
|  | 
 | ||||||
|  |       await user.type(ui.searchInput.get(), 'abc'); | ||||||
|  |       expect(ui.searchInput.get()).toHaveValue('abc'); | ||||||
|  |       await user.clear(ui.searchInput.get()); | ||||||
|  | 
 | ||||||
|  |       expect(analytics.trackRulesSearchInputCleared).toHaveBeenCalled(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|     it('Should not track filter button click when filter button is clicked to close popup', async () => { |     it('Should not track filter button click when filter button is clicked to close popup', async () => { | ||||||
|       const { user } = render(<RulesFilterV2 />); |       const { user } = render(<RulesFilterV2 />); | ||||||
| 
 | 
 | ||||||
|  | @ -0,0 +1,753 @@ | ||||||
|  | import { css } from '@emotion/css'; | ||||||
|  | import { useEffect, useRef, useState } from 'react'; | ||||||
|  | import { Controller, FormProvider, SubmitHandler, useForm, useFormContext } from 'react-hook-form'; | ||||||
|  | 
 | ||||||
|  | import { ContactPointSelector } from '@grafana/alerting/unstable'; | ||||||
|  | import { GrafanaTheme2 } from '@grafana/data'; | ||||||
|  | import { Trans, t } from '@grafana/i18n'; | ||||||
|  | import { | ||||||
|  |   Box, | ||||||
|  |   Button, | ||||||
|  |   Combobox, | ||||||
|  |   FilterInput, | ||||||
|  |   Icon, | ||||||
|  |   Input, | ||||||
|  |   Label, | ||||||
|  |   MultiCombobox, | ||||||
|  |   RadioButtonGroup, | ||||||
|  |   Stack, | ||||||
|  |   Tooltip, | ||||||
|  |   useStyles2, | ||||||
|  |   useTheme2, | ||||||
|  | } from '@grafana/ui'; | ||||||
|  | import { contextSrv } from 'app/core/core'; | ||||||
|  | import type { AdvancedFilters } from 'app/features/alerting/unified/rule-list/filter/types'; | ||||||
|  | import { AccessControlAction } from 'app/types/accessControl'; | ||||||
|  | import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto'; | ||||||
|  | 
 | ||||||
|  | import { | ||||||
|  |   trackAlertRuleFilterEvent, | ||||||
|  |   trackFilterButtonApplyClick, | ||||||
|  |   trackFilterButtonClearClick, | ||||||
|  |   trackFilterButtonClick, | ||||||
|  |   trackRulesSearchInputCleared, | ||||||
|  | } from '../../Analytics'; | ||||||
|  | import { PopupCard } from '../../components/HoverCard'; | ||||||
|  | import { RulesViewModeSelector } from '../../components/rules/Filter/RulesViewModeSelector'; | ||||||
|  | import { | ||||||
|  |   useAlertingDataSourceOptions, | ||||||
|  |   useLabelOptions, | ||||||
|  |   useNamespaceAndGroupOptions, | ||||||
|  | } from '../../components/rules/Filter/useRuleFilterAutocomplete'; | ||||||
|  | import { useRulesFilter } from '../../hooks/useFilteredRules'; | ||||||
|  | import { RuleHealth, getSearchFilterFromQuery } from '../../search/rulesSearchParser'; | ||||||
|  | 
 | ||||||
|  | import { RulesFilterProps } from './RulesFilter'; | ||||||
|  | import { | ||||||
|  |   emptyAdvancedFilters, | ||||||
|  |   formAdvancedFiltersToRuleFilter, | ||||||
|  |   searchQueryToDefaultValues, | ||||||
|  |   usePluginsFilterStatus, | ||||||
|  |   usePortalContainer, | ||||||
|  | } from './utils'; | ||||||
|  | 
 | ||||||
|  | const canRenderContactPointSelector = contextSrv.hasPermission(AccessControlAction.AlertingReceiversRead); | ||||||
|  | 
 | ||||||
|  | type SearchQueryForm = { | ||||||
|  |   query: string; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default function RulesFilter({ viewMode, onViewModeChange }: RulesFilterProps) { | ||||||
|  |   const styles = useStyles2(getStyles); | ||||||
|  | 
 | ||||||
|  |   const [isPopupOpen, setIsPopupOpen] = useState(false); | ||||||
|  |   const { searchQuery, updateFilters, setSearchQuery } = useRulesFilter(); | ||||||
|  |   const popupRef = useRef<HTMLDivElement>(null); | ||||||
|  |   const { pluginsFilterEnabled } = usePluginsFilterStatus(); | ||||||
|  | 
 | ||||||
|  |   // this form will managed the search query string, which is updated either by the user typing in the input or by the advanced filters
 | ||||||
|  |   const { control, setValue, handleSubmit } = useForm<SearchQueryForm>({ | ||||||
|  |     defaultValues: { | ||||||
|  |       query: searchQuery, | ||||||
|  |     }, | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   useEffect(() => { | ||||||
|  |     setValue('query', searchQuery); | ||||||
|  |   }, [searchQuery, setValue]); | ||||||
|  | 
 | ||||||
|  |   const submitHandler: SubmitHandler<SearchQueryForm> = (values: SearchQueryForm) => { | ||||||
|  |     const parsedFilter = getSearchFilterFromQuery(values.query); | ||||||
|  |     trackAlertRuleFilterEvent({ filterMethod: 'search-input', filter: parsedFilter }); | ||||||
|  |     updateFilters(parsedFilter); | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   const handleAdvancedFilters: SubmitHandler<AdvancedFilters> = (values) => { | ||||||
|  |     const newFilter = formAdvancedFiltersToRuleFilter(values); | ||||||
|  |     updateFilters(newFilter); | ||||||
|  | 
 | ||||||
|  |     trackFilterButtonApplyClick(values, pluginsFilterEnabled); | ||||||
|  |     setIsPopupOpen(false); // Should close popup after applying filters?
 | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   const handleClearFilters = () => { | ||||||
|  |     updateFilters(formAdvancedFiltersToRuleFilter(emptyAdvancedFilters)); | ||||||
|  |     setSearchQuery(undefined); | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   const handleOnToggle = () => { | ||||||
|  |     trackFilterButtonClick(); | ||||||
|  |     setIsPopupOpen(!isPopupOpen); | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   // Handle outside clicks to close the popup
 | ||||||
|  |   useEffect(() => { | ||||||
|  |     const handleClickOutside = (event: MouseEvent) => { | ||||||
|  |       if (isPopupOpen && popupRef.current && event.target instanceof Node && !popupRef.current.contains(event.target)) { | ||||||
|  |         // Check if click is on a portal element (combobox dropdown)
 | ||||||
|  |         if (event.target instanceof Element) { | ||||||
|  |           const isPortalClick = | ||||||
|  |             event.target.closest('[data-popper-placement]') || event.target.closest('[role="listbox"]'); | ||||||
|  | 
 | ||||||
|  |           if (!isPortalClick) { | ||||||
|  |             setIsPopupOpen(false); | ||||||
|  |           } | ||||||
|  |         } else { | ||||||
|  |           setIsPopupOpen(false); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     if (isPopupOpen) { | ||||||
|  |       document.addEventListener('mousedown', handleClickOutside); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return () => { | ||||||
|  |       document.removeEventListener('mousedown', handleClickOutside); | ||||||
|  |     }; | ||||||
|  |   }, [isPopupOpen]); | ||||||
|  | 
 | ||||||
|  |   const filterButtonLabel = t('alerting.rules-filter.filter-options.aria-label-show-filters', 'Filter'); | ||||||
|  |   return ( | ||||||
|  |     <form onSubmit={handleSubmit(submitHandler)} onReset={() => {}}> | ||||||
|  |       <Stack direction="column" gap={1}> | ||||||
|  |         <Label htmlFor="rulesSearchInput"> | ||||||
|  |           <Stack gap={0.5} alignItems="center"> | ||||||
|  |             <span> | ||||||
|  |               <Trans i18nKey="alerting.rules-filter.search">Search</Trans> | ||||||
|  |             </span> | ||||||
|  |             <PopupCard content={<SearchQueryHelp />}> | ||||||
|  |               <Icon | ||||||
|  |                 name="info-circle" | ||||||
|  |                 size="sm" | ||||||
|  |                 tabIndex={0} | ||||||
|  |                 title={t('alerting.rules-filter.title-search-help', 'Search help')} | ||||||
|  |               /> | ||||||
|  |             </PopupCard> | ||||||
|  |           </Stack> | ||||||
|  |         </Label> | ||||||
|  |         <Stack direction="row" alignItems="center" gap={1}> | ||||||
|  |           <Box flex={1}> | ||||||
|  |             <Controller | ||||||
|  |               name="query" | ||||||
|  |               control={control} | ||||||
|  |               render={({ field }) => ( | ||||||
|  |                 <FilterInput | ||||||
|  |                   id="rulesSearchInput" | ||||||
|  |                   data-testid="search-query-input" | ||||||
|  |                   placeholder={t( | ||||||
|  |                     'alerting.rules-filter.filter-options.placeholder-search-input', | ||||||
|  |                     'Search by name or enter filter query...' | ||||||
|  |                   )} | ||||||
|  |                   name="searchQuery" | ||||||
|  |                   onChange={(next) => { | ||||||
|  |                     trackRulesSearchInputCleared(field.value, next); | ||||||
|  |                     field.onChange(next); | ||||||
|  |                   }} | ||||||
|  |                   onKeyDown={(event) => { | ||||||
|  |                     if (event.key === 'Enter' || event.key === 'NumpadEnter') { | ||||||
|  |                       event.preventDefault(); | ||||||
|  |                       handleSubmit(submitHandler)(); | ||||||
|  |                     } | ||||||
|  |                   }} | ||||||
|  |                   onBlur={() => { | ||||||
|  |                     const currentQuery = field.value; | ||||||
|  |                     const parsedFilter = getSearchFilterFromQuery(currentQuery); | ||||||
|  |                     trackAlertRuleFilterEvent({ filterMethod: 'search-input', filter: parsedFilter }); | ||||||
|  |                     updateFilters(parsedFilter); | ||||||
|  |                   }} | ||||||
|  |                   value={field.value} | ||||||
|  |                 /> | ||||||
|  |               )} | ||||||
|  |             /> | ||||||
|  |           </Box> | ||||||
|  |           {/* the popup card is mounted inside of a portal, so we can't rely on the usual form handling mechanisms of button[type=submit] */} | ||||||
|  |           <PopupCard | ||||||
|  |             showOn="click" | ||||||
|  |             placement="auto" | ||||||
|  |             disableBlur={true} | ||||||
|  |             isOpen={isPopupOpen} | ||||||
|  |             onClose={() => setIsPopupOpen(false)} | ||||||
|  |             onToggle={handleOnToggle} | ||||||
|  |             content={ | ||||||
|  |               // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
 | ||||||
|  |               <div | ||||||
|  |                 ref={popupRef} | ||||||
|  |                 className={styles.content} | ||||||
|  |                 onClick={(e) => e.stopPropagation()} | ||||||
|  |                 onKeyDown={(e) => { | ||||||
|  |                   if (e.key === 'Enter' || e.key === ' ') { | ||||||
|  |                     e.stopPropagation(); | ||||||
|  |                   } | ||||||
|  |                 }} | ||||||
|  |                 role="dialog" | ||||||
|  |                 aria-label={t('alerting.rules-filter.filter-options.aria-label', 'Filter options')} | ||||||
|  |                 tabIndex={-1} | ||||||
|  |               > | ||||||
|  |                 <FilterOptions | ||||||
|  |                   onSubmit={handleAdvancedFilters} | ||||||
|  |                   onClear={handleClearFilters} | ||||||
|  |                   pluginsFilterEnabled={pluginsFilterEnabled} | ||||||
|  |                 /> | ||||||
|  |               </div> | ||||||
|  |             } | ||||||
|  |           > | ||||||
|  |             <Button name="filter" icon="filter" variant="secondary" aria-label={filterButtonLabel}> | ||||||
|  |               {filterButtonLabel} | ||||||
|  |             </Button> | ||||||
|  |           </PopupCard> | ||||||
|  |           <RulesViewModeSelector viewMode={viewMode} onViewModeChange={onViewModeChange} /> | ||||||
|  |         </Stack> | ||||||
|  |       </Stack> | ||||||
|  |     </form> | ||||||
|  |   ); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | interface FilterOptionsProps { | ||||||
|  |   onSubmit: SubmitHandler<AdvancedFilters>; | ||||||
|  |   onClear: () => void; | ||||||
|  |   pluginsFilterEnabled: boolean; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const FilterOptions = ({ onSubmit, onClear, pluginsFilterEnabled }: FilterOptionsProps) => { | ||||||
|  |   const styles = useStyles2(getStyles); | ||||||
|  |   const theme = useTheme2(); | ||||||
|  |   const { filterState } = useRulesFilter(); | ||||||
|  |   const isManualResetRef = useRef(false); | ||||||
|  | 
 | ||||||
|  |   // Create portal container to render dropdowns above the popup modal
 | ||||||
|  |   const portalContainer = usePortalContainer(theme.zIndex.portal + 100); | ||||||
|  | 
 | ||||||
|  |   const defaultValues = searchQueryToDefaultValues(filterState); | ||||||
|  | 
 | ||||||
|  |   // Fetch namespace and group data from all sources (optimized for filter UI)
 | ||||||
|  |   const { namespaceOptions, allGroupNames, isLoadingNamespaces, namespacePlaceholder, groupPlaceholder } = | ||||||
|  |     useNamespaceAndGroupOptions(); | ||||||
|  | 
 | ||||||
|  |   const { labelOptions, isLoadingGrafanaLabels } = useLabelOptions(); | ||||||
|  | 
 | ||||||
|  |   // Create label options for the multi-select dropdown
 | ||||||
|  |   const dataSourceOptions = useAlertingDataSourceOptions(); | ||||||
|  | 
 | ||||||
|  |   // turn the filterState into form default values
 | ||||||
|  |   const methods = useForm<AdvancedFilters>({ | ||||||
|  |     defaultValues, | ||||||
|  |   }); | ||||||
|  |   const { handleSubmit, reset } = methods; | ||||||
|  | 
 | ||||||
|  |   // Update form values when filterState changes (e.g., when popup reopens)
 | ||||||
|  |   useEffect(() => { | ||||||
|  |     // Skip if we're in the middle of a manual reset
 | ||||||
|  |     if (isManualResetRef.current) { | ||||||
|  |       isManualResetRef.current = false; | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     const newDefaultValues = searchQueryToDefaultValues(filterState); | ||||||
|  |     reset(newDefaultValues); | ||||||
|  |   }, [filterState, reset]); | ||||||
|  | 
 | ||||||
|  |   const submitAdvancedFilters = handleSubmit(onSubmit); | ||||||
|  | 
 | ||||||
|  |   return ( | ||||||
|  |     <FormProvider {...methods}> | ||||||
|  |       <form | ||||||
|  |         onSubmit={submitAdvancedFilters} | ||||||
|  |         onReset={() => { | ||||||
|  |           isManualResetRef.current = true; | ||||||
|  |           reset(emptyAdvancedFilters); | ||||||
|  |           trackFilterButtonClearClick(); | ||||||
|  |           onClear(); | ||||||
|  |         }} | ||||||
|  |       > | ||||||
|  |         <Stack direction="column" alignItems="end" gap={2}> | ||||||
|  |           <div className={styles.grid}> | ||||||
|  |             <RuleNameField /> | ||||||
|  |             <LabelsField | ||||||
|  |               labelOptions={labelOptions} | ||||||
|  |               isLoadingGrafanaLabels={isLoadingGrafanaLabels} | ||||||
|  |               portalContainer={portalContainer} | ||||||
|  |             /> | ||||||
|  |             <NamespaceField | ||||||
|  |               namespaceOptions={namespaceOptions} | ||||||
|  |               namespacePlaceholder={namespacePlaceholder} | ||||||
|  |               isLoadingNamespaces={isLoadingNamespaces} | ||||||
|  |               portalContainer={portalContainer} | ||||||
|  |             /> | ||||||
|  |             <GroupField | ||||||
|  |               allGroupNames={allGroupNames} | ||||||
|  |               groupPlaceholder={groupPlaceholder} | ||||||
|  |               isLoadingNamespaces={isLoadingNamespaces} | ||||||
|  |               portalContainer={portalContainer} | ||||||
|  |             /> | ||||||
|  |             <DataSourceNamesField dataSourceOptions={dataSourceOptions} portalContainer={portalContainer} /> | ||||||
|  |             {canRenderContactPointSelector && <ContactPointField portalContainer={portalContainer} />} | ||||||
|  |             <RuleStateField /> | ||||||
|  |             <RuleTypeField /> | ||||||
|  |             <RuleHealthField /> | ||||||
|  |             {pluginsFilterEnabled && <PluginsField />} | ||||||
|  |           </div> | ||||||
|  |           <Stack direction="row" alignItems="center"> | ||||||
|  |             <Button type="reset" variant="secondary" data-testid="filter-clear-button"> | ||||||
|  |               <Trans i18nKey="common.clear">Clear</Trans> | ||||||
|  |             </Button> | ||||||
|  |             <Button type="submit" data-testid="filter-apply-button"> | ||||||
|  |               <Trans i18nKey="common.apply">Apply</Trans> | ||||||
|  |             </Button> | ||||||
|  |           </Stack> | ||||||
|  |         </Stack> | ||||||
|  |       </form> | ||||||
|  |     </FormProvider> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | function RuleNameField() { | ||||||
|  |   const { register } = useFormContext<AdvancedFilters>(); | ||||||
|  |   return ( | ||||||
|  |     <> | ||||||
|  |       <Label> | ||||||
|  |         <Trans i18nKey="alerting.search.property.rule-name">Rule name</Trans> | ||||||
|  |       </Label> | ||||||
|  |       <Input {...register('ruleName')} data-testid="rule-name-input" /> | ||||||
|  |     </> | ||||||
|  |   ); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function LabelsField({ | ||||||
|  |   labelOptions, | ||||||
|  |   isLoadingGrafanaLabels, | ||||||
|  |   portalContainer, | ||||||
|  | }: { | ||||||
|  |   labelOptions: Array<{ label?: string; value: string; infoOption?: boolean }>; | ||||||
|  |   isLoadingGrafanaLabels: boolean; | ||||||
|  |   portalContainer?: HTMLElement; | ||||||
|  | }) { | ||||||
|  |   const { control } = useFormContext<AdvancedFilters>(); | ||||||
|  |   return ( | ||||||
|  |     <> | ||||||
|  |       <Label> | ||||||
|  |         <Trans i18nKey="alerting.search.property.labels">Labels</Trans> | ||||||
|  |       </Label> | ||||||
|  |       <Controller | ||||||
|  |         name="labels" | ||||||
|  |         control={control} | ||||||
|  |         render={({ field }) => ( | ||||||
|  |           <MultiCombobox | ||||||
|  |             options={labelOptions} | ||||||
|  |             value={field.value} | ||||||
|  |             onChange={(selections) => field.onChange(selections.map((s) => s.value))} | ||||||
|  |             placeholder={ | ||||||
|  |               isLoadingGrafanaLabels | ||||||
|  |                 ? t('common.loading', 'Loading...') | ||||||
|  |                 : t('alerting.rules-filter.placeholder-labels', 'Select labels') | ||||||
|  |             } | ||||||
|  |             loading={isLoadingGrafanaLabels} | ||||||
|  |             disabled={isLoadingGrafanaLabels || labelOptions.filter((option) => !option.infoOption).length === 0} | ||||||
|  |             portalContainer={portalContainer} | ||||||
|  |             width="auto" | ||||||
|  |             minWidth={40} | ||||||
|  |             maxWidth={80} | ||||||
|  |           /> | ||||||
|  |         )} | ||||||
|  |       /> | ||||||
|  |     </> | ||||||
|  |   ); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function NamespaceField({ | ||||||
|  |   namespaceOptions, | ||||||
|  |   namespacePlaceholder, | ||||||
|  |   isLoadingNamespaces, | ||||||
|  |   portalContainer, | ||||||
|  | }: { | ||||||
|  |   namespaceOptions: Array<{ label?: string; value: string; description?: string }>; | ||||||
|  |   namespacePlaceholder: string; | ||||||
|  |   isLoadingNamespaces: boolean; | ||||||
|  |   portalContainer?: HTMLElement; | ||||||
|  | }) { | ||||||
|  |   const { control } = useFormContext<AdvancedFilters>(); | ||||||
|  |   return ( | ||||||
|  |     <> | ||||||
|  |       <Label> | ||||||
|  |         <Trans i18nKey="alerting.search.property.namespace">Folder / Namespace</Trans> | ||||||
|  |       </Label> | ||||||
|  |       <Controller | ||||||
|  |         name="namespace" | ||||||
|  |         control={control} | ||||||
|  |         render={({ field }) => { | ||||||
|  |           return ( | ||||||
|  |             <Combobox<string> | ||||||
|  |               placeholder={namespacePlaceholder} | ||||||
|  |               options={namespaceOptions} | ||||||
|  |               onChange={(option) => field.onChange(option?.value || null)} | ||||||
|  |               value={field.value} | ||||||
|  |               loading={isLoadingNamespaces} | ||||||
|  |               disabled={isLoadingNamespaces || namespaceOptions.length === 0} | ||||||
|  |               isClearable | ||||||
|  |               portalContainer={portalContainer} | ||||||
|  |             /> | ||||||
|  |           ); | ||||||
|  |         }} | ||||||
|  |       /> | ||||||
|  |     </> | ||||||
|  |   ); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function GroupField({ | ||||||
|  |   allGroupNames, | ||||||
|  |   groupPlaceholder, | ||||||
|  |   isLoadingNamespaces, | ||||||
|  |   portalContainer, | ||||||
|  | }: { | ||||||
|  |   allGroupNames: string[]; | ||||||
|  |   groupPlaceholder: string; | ||||||
|  |   isLoadingNamespaces: boolean; | ||||||
|  |   portalContainer?: HTMLElement; | ||||||
|  | }) { | ||||||
|  |   const { control } = useFormContext<AdvancedFilters>(); | ||||||
|  |   return ( | ||||||
|  |     <> | ||||||
|  |       <Label> | ||||||
|  |         <Trans i18nKey="alerting.search.property.evaluation-group">Evaluation group</Trans> | ||||||
|  |       </Label> | ||||||
|  |       <Controller | ||||||
|  |         name="groupName" | ||||||
|  |         control={control} | ||||||
|  |         render={({ field }) => { | ||||||
|  |           return ( | ||||||
|  |             <Combobox<string> | ||||||
|  |               placeholder={groupPlaceholder} | ||||||
|  |               options={allGroupNames.map((name) => ({ label: name, value: name }))} | ||||||
|  |               onChange={(option) => field.onChange(option?.value || null)} | ||||||
|  |               value={field.value} | ||||||
|  |               loading={isLoadingNamespaces} | ||||||
|  |               disabled={isLoadingNamespaces || allGroupNames.length === 0} | ||||||
|  |               isClearable | ||||||
|  |               portalContainer={portalContainer} | ||||||
|  |             /> | ||||||
|  |           ); | ||||||
|  |         }} | ||||||
|  |       /> | ||||||
|  |     </> | ||||||
|  |   ); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function DataSourceNamesField({ | ||||||
|  |   dataSourceOptions, | ||||||
|  |   portalContainer, | ||||||
|  | }: { | ||||||
|  |   dataSourceOptions: Array<{ label?: string; value: string }>; | ||||||
|  |   portalContainer?: HTMLElement; | ||||||
|  | }) { | ||||||
|  |   const { control } = useFormContext<AdvancedFilters>(); | ||||||
|  |   return ( | ||||||
|  |     <> | ||||||
|  |       <Label> | ||||||
|  |         <Stack gap={0.5} alignItems="center"> | ||||||
|  |           <span> | ||||||
|  |             <Trans i18nKey="alerting.search.property.data-source">Data source</Trans> | ||||||
|  |           </span> | ||||||
|  |           <Tooltip | ||||||
|  |             content={ | ||||||
|  |               <div> | ||||||
|  |                 <p> | ||||||
|  |                   <Trans i18nKey="alerting.rules-filter.configured-alert-rules"> | ||||||
|  |                     Data sources containing configured alert rules are Mimir or Loki data sources where alert rules are | ||||||
|  |                     stored and evaluated in the data source itself. | ||||||
|  |                   </Trans> | ||||||
|  |                 </p> | ||||||
|  |                 <p> | ||||||
|  |                   <Trans i18nKey="alerting.rules-filter.manage-alerts"> | ||||||
|  |                     In these data sources, you can select Manage alerts via Alerting UI to be able to manage these alert | ||||||
|  |                     rules in the Grafana UI as well as in the data source where they were configured. | ||||||
|  |                   </Trans> | ||||||
|  |                 </p> | ||||||
|  |               </div> | ||||||
|  |             } | ||||||
|  |           > | ||||||
|  |             <Icon | ||||||
|  |               name="info-circle" | ||||||
|  |               size="sm" | ||||||
|  |               title={t( | ||||||
|  |                 'alerting.rules-filter.data-source-picker-inline-help-title-search-by-data-sources-help', | ||||||
|  |                 'Search by data sources help' | ||||||
|  |               )} | ||||||
|  |             /> | ||||||
|  |           </Tooltip> | ||||||
|  |         </Stack> | ||||||
|  |       </Label> | ||||||
|  |       <Controller | ||||||
|  |         name="dataSourceNames" | ||||||
|  |         control={control} | ||||||
|  |         render={({ field }) => ( | ||||||
|  |           <MultiCombobox | ||||||
|  |             options={dataSourceOptions} | ||||||
|  |             value={field.value} | ||||||
|  |             onChange={(selections) => field.onChange(selections.map((s) => s.value))} | ||||||
|  |             placeholder={t('alerting.rules-filter.placeholder-data-sources', 'Select data sources')} | ||||||
|  |             portalContainer={portalContainer} | ||||||
|  |             width="auto" | ||||||
|  |             minWidth={40} | ||||||
|  |             maxWidth={80} | ||||||
|  |           /> | ||||||
|  |         )} | ||||||
|  |       /> | ||||||
|  |     </> | ||||||
|  |   ); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function ContactPointField({ portalContainer }: { portalContainer?: HTMLElement }) { | ||||||
|  |   const { control } = useFormContext<AdvancedFilters>(); | ||||||
|  |   return ( | ||||||
|  |     <> | ||||||
|  |       <Label> | ||||||
|  |         <Stack gap={0.5} alignItems="center"> | ||||||
|  |           <span> | ||||||
|  |             <Trans i18nKey="alerting.contactPointFilter.label">Contact point</Trans> | ||||||
|  |           </span> | ||||||
|  |           <Tooltip | ||||||
|  |             content={ | ||||||
|  |               <Trans i18nKey="alerting.rules-filter.contact-point-tooltip"> | ||||||
|  |                 Filters alert rules which route directly to the selected contact point. Alert rules routed to | ||||||
|  |                 notification policies will not be displayed. | ||||||
|  |               </Trans> | ||||||
|  |             } | ||||||
|  |           > | ||||||
|  |             <Icon | ||||||
|  |               name="info-circle" | ||||||
|  |               size="sm" | ||||||
|  |               title={t('alerting.rules-filter.contact-point-tooltip-title', 'Contact point filter help')} | ||||||
|  |             /> | ||||||
|  |           </Tooltip> | ||||||
|  |         </Stack> | ||||||
|  |       </Label> | ||||||
|  |       <Controller | ||||||
|  |         name="contactPoint" | ||||||
|  |         control={control} | ||||||
|  |         render={({ field }) => { | ||||||
|  |           return ( | ||||||
|  |             <ContactPointSelector | ||||||
|  |               placeholder={t('alerting.rules-filter.placeholder-contact-point', 'Select contact point')} | ||||||
|  |               value={field.value} | ||||||
|  |               isClearable | ||||||
|  |               onChange={(contactPoint) => { | ||||||
|  |                 field.onChange(contactPoint?.spec.title || null); | ||||||
|  |               }} | ||||||
|  |               portalContainer={portalContainer} | ||||||
|  |             /> | ||||||
|  |           ); | ||||||
|  |         }} | ||||||
|  |       /> | ||||||
|  |     </> | ||||||
|  |   ); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function RuleStateField() { | ||||||
|  |   const { control } = useFormContext<AdvancedFilters>(); | ||||||
|  |   return ( | ||||||
|  |     <> | ||||||
|  |       <Label> | ||||||
|  |         <Trans i18nKey="alerting.search.property.state">State</Trans> | ||||||
|  |       </Label> | ||||||
|  |       <Controller | ||||||
|  |         name="ruleState" | ||||||
|  |         control={control} | ||||||
|  |         render={({ field }) => ( | ||||||
|  |           <RadioButtonGroup<AdvancedFilters['ruleState']> | ||||||
|  |             options={[ | ||||||
|  |               { label: t('common.all', 'All'), value: '*' }, | ||||||
|  |               { label: t('alerting.rules.state.firing', 'Firing'), value: PromAlertingRuleState.Firing }, | ||||||
|  |               { label: t('alerting.rules.state.normal', 'Normal'), value: PromAlertingRuleState.Inactive }, | ||||||
|  |               { label: t('alerting.rules.state.pending', 'Pending'), value: PromAlertingRuleState.Pending }, | ||||||
|  |               { label: t('alerting.rules.state.recovering', 'Recovering'), value: PromAlertingRuleState.Recovering }, | ||||||
|  |               { label: t('alerting.rules.state.unknown', 'Unknown'), value: PromAlertingRuleState.Unknown }, | ||||||
|  |             ]} | ||||||
|  |             value={field.value} | ||||||
|  |             onChange={field.onChange} | ||||||
|  |           /> | ||||||
|  |         )} | ||||||
|  |       /> | ||||||
|  |     </> | ||||||
|  |   ); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function RuleTypeField() { | ||||||
|  |   const { control } = useFormContext<AdvancedFilters>(); | ||||||
|  |   return ( | ||||||
|  |     <> | ||||||
|  |       <Label> | ||||||
|  |         <Trans i18nKey="alerting.search.property.rule-type">Type</Trans> | ||||||
|  |       </Label> | ||||||
|  |       <Controller | ||||||
|  |         name="ruleType" | ||||||
|  |         control={control} | ||||||
|  |         render={({ field }) => ( | ||||||
|  |           <RadioButtonGroup<AdvancedFilters['ruleType']> | ||||||
|  |             options={[ | ||||||
|  |               { label: t('common.all', 'All'), value: '*' }, | ||||||
|  |               { label: t('alerting.rules.type.alert', 'Alert rule'), value: PromRuleType.Alerting }, | ||||||
|  |               { label: t('alerting.rules.type.recording', 'Recording rule'), value: PromRuleType.Recording }, | ||||||
|  |             ]} | ||||||
|  |             value={field.value} | ||||||
|  |             onChange={field.onChange} | ||||||
|  |           /> | ||||||
|  |         )} | ||||||
|  |       /> | ||||||
|  |     </> | ||||||
|  |   ); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function RuleHealthField() { | ||||||
|  |   const { control } = useFormContext<AdvancedFilters>(); | ||||||
|  |   return ( | ||||||
|  |     <> | ||||||
|  |       <Label> | ||||||
|  |         <Trans i18nKey="alerting.search.property.rule-health">Health</Trans> | ||||||
|  |       </Label> | ||||||
|  |       <Controller | ||||||
|  |         name="ruleHealth" | ||||||
|  |         control={control} | ||||||
|  |         render={({ field }) => ( | ||||||
|  |           <RadioButtonGroup<AdvancedFilters['ruleHealth']> | ||||||
|  |             options={[ | ||||||
|  |               { label: t('common.all', 'All'), value: '*' }, | ||||||
|  |               { label: t('alerting.rules.health.ok', 'OK'), value: RuleHealth.Ok }, | ||||||
|  |               { label: t('alerting.rules.health.no-data', 'No data'), value: RuleHealth.NoData }, | ||||||
|  |               { label: t('alerting.rules.health.error', 'Error'), value: RuleHealth.Error }, | ||||||
|  |             ]} | ||||||
|  |             value={field.value} | ||||||
|  |             onChange={field.onChange} | ||||||
|  |           /> | ||||||
|  |         )} | ||||||
|  |       /> | ||||||
|  |     </> | ||||||
|  |   ); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function PluginsField() { | ||||||
|  |   const { control } = useFormContext<AdvancedFilters>(); | ||||||
|  |   return ( | ||||||
|  |     <> | ||||||
|  |       <Label> | ||||||
|  |         <Trans i18nKey="alerting.rules-filter.plugin-rules">Plugin rules</Trans> | ||||||
|  |       </Label> | ||||||
|  |       <Controller | ||||||
|  |         name="plugins" | ||||||
|  |         control={control} | ||||||
|  |         render={({ field }) => ( | ||||||
|  |           <RadioButtonGroup<AdvancedFilters['plugins']> | ||||||
|  |             options={[ | ||||||
|  |               { label: t('alerting.rules-filter.label.show', 'Show'), value: 'show' }, | ||||||
|  |               { label: t('alerting.rules-filter.label.hide', 'Hide'), value: 'hide' }, | ||||||
|  |             ]} | ||||||
|  |             value={field.value} | ||||||
|  |             onChange={field.onChange} | ||||||
|  |           /> | ||||||
|  |         )} | ||||||
|  |       /> | ||||||
|  |     </> | ||||||
|  |   ); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function SearchQueryHelp() { | ||||||
|  |   const styles = useStyles2(helpStyles); | ||||||
|  | 
 | ||||||
|  |   return ( | ||||||
|  |     <div> | ||||||
|  |       <div> | ||||||
|  |         <Trans i18nKey="alerting.search-query-help.search-syntax"> | ||||||
|  |           Search syntax allows to query alert rules by the parameters defined below. | ||||||
|  |         </Trans> | ||||||
|  |       </div> | ||||||
|  |       <hr /> | ||||||
|  |       <div className={styles.grid}> | ||||||
|  |         <div> | ||||||
|  |           <Trans i18nKey="alerting.search-query-help.filter-type">Filter type</Trans> | ||||||
|  |         </div> | ||||||
|  |         <div> | ||||||
|  |           <Trans i18nKey="alerting.search-query-help.expression">Expression</Trans> | ||||||
|  |         </div> | ||||||
|  |         <HelpRow | ||||||
|  |           title={t('alerting.search-query-help.title-datasources', 'Datasources')} | ||||||
|  |           expr="datasource:mimir datasource:prometheus" | ||||||
|  |         /> | ||||||
|  |         <HelpRow | ||||||
|  |           title={t('alerting.search-query-help.title-folder-namespace', 'Folder/Namespace')} | ||||||
|  |           expr="namespace:global" | ||||||
|  |         /> | ||||||
|  |         <HelpRow title={t('alerting.search-query-help.title-group', 'Group')} expr="group:cpu-usage" /> | ||||||
|  |         <HelpRow title={t('alerting.search-query-help.title-rule', 'Rule')} expr='rule:"cpu 80%"' /> | ||||||
|  |         <HelpRow title={t('alerting.search-query-help.title-labels', 'Labels')} expr="label:team=A label:cluster=a1" /> | ||||||
|  |         <HelpRow title={t('alerting.search-query-help.title-state', 'State')} expr="state:firing|normal|pending" /> | ||||||
|  |         <HelpRow title={t('alerting.search-query-help.title-type', 'Type')} expr="type:alerting|recording" /> | ||||||
|  |         <HelpRow title={t('alerting.search-query-help.title-health', 'Health')} expr="health:ok|nodata|error" /> | ||||||
|  |         <HelpRow | ||||||
|  |           title={t('alerting.search-query-help.title-dashboard-uid', 'Dashboard UID')} | ||||||
|  |           expr="dashboard:eadde4c7-54e6-4964-85c0-484ab852fd04" | ||||||
|  |         /> | ||||||
|  |         <HelpRow | ||||||
|  |           title={t('alerting.search-query-help.title-contact-point', 'Contact point')} | ||||||
|  |           expr="contactPoint:slack" | ||||||
|  |         /> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function HelpRow({ title, expr }: { title: string; expr: string }) { | ||||||
|  |   const styles = useStyles2(helpStyles); | ||||||
|  | 
 | ||||||
|  |   return ( | ||||||
|  |     <> | ||||||
|  |       <div>{title}</div> | ||||||
|  |       <code className={styles.code}>{expr}</code> | ||||||
|  |     </> | ||||||
|  |   ); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const helpStyles = (theme: GrafanaTheme2) => ({ | ||||||
|  |   grid: css({ | ||||||
|  |     display: 'grid', | ||||||
|  |     gridTemplateColumns: 'max-content auto', | ||||||
|  |     gap: theme.spacing(1), | ||||||
|  |     alignItems: 'center', | ||||||
|  |   }), | ||||||
|  |   code: css({ | ||||||
|  |     display: 'block', | ||||||
|  |     textAlign: 'center', | ||||||
|  |   }), | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | function getStyles(theme: GrafanaTheme2) { | ||||||
|  |   return { | ||||||
|  |     content: css({ | ||||||
|  |       padding: theme.spacing(1), | ||||||
|  |     }), | ||||||
|  |     grid: css({ | ||||||
|  |       display: 'grid', | ||||||
|  |       gridTemplateColumns: 'auto 1fr', | ||||||
|  |       alignItems: 'center', | ||||||
|  |       gap: theme.spacing(2), | ||||||
|  |     }), | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  | @ -0,0 +1,17 @@ | ||||||
|  | import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto'; | ||||||
|  | 
 | ||||||
|  | import type { RuleHealth } from '../../search/rulesSearchParser'; | ||||||
|  | 
 | ||||||
|  | export type AdvancedFilters = { | ||||||
|  |   namespace?: string | null; | ||||||
|  |   groupName?: string | null; | ||||||
|  |   ruleName?: string; | ||||||
|  |   ruleType?: PromRuleType | '*'; | ||||||
|  |   ruleState: PromAlertingRuleState | '*'; | ||||||
|  |   dataSourceNames: string[]; | ||||||
|  |   labels: string[]; | ||||||
|  |   ruleHealth?: RuleHealth | '*'; | ||||||
|  |   dashboardUid?: string; | ||||||
|  |   plugins?: 'show' | 'hide'; | ||||||
|  |   contactPoint?: string | null; | ||||||
|  | }; | ||||||
|  | @ -1,9 +1,9 @@ | ||||||
| import { useEffect, useRef } from 'react'; | import { useEffect, useRef } from 'react'; | ||||||
| 
 | 
 | ||||||
| import { useAlertingHomePageExtensions } from '../../../plugins/useAlertingHomePageExtensions'; | import { useAlertingHomePageExtensions } from '../../plugins/useAlertingHomePageExtensions'; | ||||||
| import { RulesFilter } from '../../../search/rulesSearchParser'; | import { RulesFilter } from '../../search/rulesSearchParser'; | ||||||
| 
 | 
 | ||||||
| import { AdvancedFilters } from './RulesFilter.v2'; | import { AdvancedFilters } from './types'; | ||||||
| 
 | 
 | ||||||
| export function formAdvancedFiltersToRuleFilter(values: AdvancedFilters): RulesFilter { | export function formAdvancedFiltersToRuleFilter(values: AdvancedFilters): RulesFilter { | ||||||
|   return { |   return { | ||||||
		Loading…
	
		Reference in New Issue