Alerting: Improve Alert Rules Filter (#109176)

* big WIP

* more WIP

* leftover changes after merge

* bug fix: prevent modal close onclick inside modal

* add logic to filter by data sources

* use MultiComboBox component instead of MultipleDataSourcePicker

* add namespace and evaluation group filtering logic

* remove header section of PopupCard

* set max width on popup

* add contact point field

* add contact point logic and tooltip

* add tracking to filter- track open, apply, clear and values submitted

* add plugins show/hide field

* add plugins show/hide field

* add tests for new filter

* test tracking works as expected

* check v2 filter only shows if feature toggle is on

* tidying up

* fix lint errors

* fix close-on-type bug

* use ContactPointSelector component

* fix close-on-click-outside issue

* Add label dropdown logic

* Add query string to search field

* add test to check filtering by search field updates the filter popup values

* clear search input onClear of filters

* fix lint issues

* add tooltips to search input and datasource fields

* Sort typing in ContactPointSelector

* update translation file

* Add logic to group and list view buttons & refactoring

* update failing test

* update test mocks

* fix failing tests

* fix typecheck errors

* resolve PR comments part 1

* update label dropdown to include info text

* Translation extraction

* resolving PR comments

* move autocomplete logic to reusable hook

* resolve PR comments

* fix typecheck

---------

Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
Co-authored-by: Lauren Armstrong <laurenarmstrong@laurenskmacbook.home>
Co-authored-by: Lauren Armstrong <laurenarmstrong@mac.home>
Co-authored-by: Lauren Armstrong <laurenarmstrong@Laurens-Work-MacBook.local>
This commit is contained in:
Lauren
2025-08-14 15:21:03 +01:00
committed by GitHub
co-authored by Gilles De Mey Lauren Armstrong Lauren Armstrong Lauren Armstrong
parent 43cda1f1a4
commit af68304af0
16 changed files with 1370 additions and 248 deletions
@@ -32,6 +32,10 @@ interface ComboboxStaticProps<T extends string | number>
* Allows the user to set a value which is not in the list of options.
*/
createCustomValue?: boolean;
/**
* Custom container for rendering the dropdown menu via Portal
*/
portalContainer?: HTMLElement;
/**
* An array of options, or a function that returns a promise resolving to an array of options.
@@ -131,6 +135,7 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
autoFocus,
onBlur,
disabled,
portalContainer,
invalid,
} = props;
@@ -383,10 +388,13 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
'data-testid': dataTestId,
})}
/>
<Portal>
<Portal root={portalContainer}>
<div
className={cx(styles.menu, !isOpen && styles.menuClosed)}
style={floatStyles}
style={{
...floatStyles,
pointerEvents: 'auto', // Override container's pointer-events: none
}}
{...getMenuProps({
ref: floatingRef,
'aria-labelledby': ariaLabelledBy,
@@ -120,26 +120,30 @@ export const ComboboxList = <T extends string | number>({
className={cx(
styles.option,
!isMultiSelect && isOptionSelected(item) && styles.optionSelected,
highlightedIndex === virtualRow.index && styles.optionFocused
highlightedIndex === virtualRow.index && !item.infoOption && styles.optionFocused,
item.infoOption && styles.optionInfo
)}
{...getItemProps({
item: item,
index: virtualRow.index,
id: itemId,
'aria-describedby': groupHeaderId,
disabled: item.infoOption,
})}
>
{isMultiSelect && (
<div className={styles.optionAccessory}>
<Checkbox
key={itemId}
value={allItemsSelected || isOptionSelected(item)}
indeterminate={item.value === ALL_OPTION_VALUE && selectedItems.length > 0 && !allItemsSelected}
aria-labelledby={itemId}
onClick={(e) => {
e.stopPropagation();
}}
/>
{!item.infoOption && (
<Checkbox
key={itemId}
value={allItemsSelected || isOptionSelected(item)}
indeterminate={item.value === ALL_OPTION_VALUE && selectedItems.length > 0 && !allItemsSelected}
aria-labelledby={itemId}
onClick={(e) => {
e.stopPropagation();
}}
/>
)}
</div>
)}
@@ -30,6 +30,7 @@ interface MultiComboboxBaseProps<T extends string | number>
onChange: (option: Array<ComboboxOption<T>>) => void;
isClearable?: boolean;
enableAllOption?: boolean;
portalContainer?: HTMLElement;
}
export type MultiComboboxProps<T extends string | number> = MultiComboboxBaseProps<T> & AutoSizeConditionals;
@@ -49,6 +50,7 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
createCustomValue = false,
'aria-labelledby': ariaLabelledBy,
'data-testid': dataTestId,
portalContainer,
} = props;
const styles = useStyles2(getComboboxStyles);
@@ -197,6 +199,11 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
switch (type) {
case useCombobox.stateChangeTypes.InputKeyDownEnter:
case useCombobox.stateChangeTypes.ItemClick:
// Don't allow selection of info options
if (newSelectedItem?.infoOption) {
break;
}
// Handle All functionality
if (newSelectedItem?.value === ALL_OPTION_VALUE) {
// TODO: fix bug where if the search filtered items list is the
@@ -204,12 +211,11 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
const isAllFilteredSelected = selectedItems.length === options.length - 1;
// if every option is already selected, clear the selection.
// otherwise, select all the options (excluding the first ALL_OTION)
const realOptions = options.slice(1);
// otherwise, select all the options (excluding the first ALL_OPTION and info options)
const realOptions = options.slice(1).filter((option) => !option.infoOption);
let newSelectedItems = isAllFilteredSelected && inputValue === '' ? [] : realOptions;
if (!isAllFilteredSelected && inputValue !== '') {
// Select all currently filtered items and deduplicate
newSelectedItems = [...new Set([...selectedItems, ...realOptions])];
}
@@ -329,12 +335,13 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
</div>
</span>
</div>
<Portal>
<Portal root={portalContainer}>
<div
className={cx(styles.menu, !isOpen && styles.menuClosed)}
style={{
...floatStyles,
width: floatStyles.width + 24, // account for checkbox
pointerEvents: 'auto', // Override container's pointer-events: none
}}
{...getMenuProps({ ref: floatingRef })}
>
@@ -141,6 +141,15 @@ export const getComboboxStyles = (theme: GrafanaTheme2) => {
top: 0,
},
}),
optionInfo: css({
label: 'combobox-option-info',
color: theme.colors.text.disabled,
cursor: 'not-allowed',
pointerEvents: 'none',
'&:hover': {
background: 'transparent',
},
}),
clear: css({
label: 'combobox-clear',
cursor: 'pointer',
@@ -5,4 +5,5 @@ export type ComboboxOption<T extends string | number = string> = {
value: T;
description?: string;
group?: string;
infoOption?: boolean;
};
@@ -1,4 +1,4 @@
import { isEmpty } from 'lodash';
import { isEmpty, pickBy } from 'lodash';
import { config, createMonitoringLogger, reportInteraction } from '@grafana/runtime';
import { contextSrv } from 'app/core/core';
@@ -7,6 +7,7 @@ import { RuleNamespace } from '../../../types/unified-alerting';
import { RulerRulesConfigDTO } from '../../../types/unified-alerting-dto';
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 { RulesFilter, getSearchFilterFromQuery } from './search/rulesSearchParser';
import { RuleFormType } from './types/rule-form';
@@ -330,6 +331,32 @@ export function trackFolderBulkActionsUnpauseFail() {
reportInteraction('grafana_alerting_folder_bulk_actions_unpause_fail');
}
export function trackFilterButtonClick() {
reportInteraction('grafana_alerting_filter_button_click');
}
export function trackFilterButtonApplyClick(payload: AdvancedFilters, pluginsFilterEnabled: boolean) {
// Filter out empty/default values before tracking
const meaningfulValues = pickBy(payload, (value, key) => {
if (value === null || value === undefined || value === '') {
return false;
}
if (Array.isArray(value) && value.length === 0) {
return false;
}
if (key === 'plugins' && !pluginsFilterEnabled) {
return false;
}
return true;
});
reportInteraction('grafana_alerting_filter_button_apply_click', meaningfulValues);
}
export function trackFilterButtonClearClick() {
reportInteraction('grafana_alerting_filter_button_clear_click');
}
export type AlertRuleTrackingProps = {
user_id: number;
grafana_version?: string;
@@ -17,6 +17,10 @@ export interface PopupCardProps {
showAfter?: number;
arrow?: boolean;
showOn?: 'click' | 'hover';
disableBlur?: boolean;
isOpen?: boolean;
onClose?: () => void;
onToggle?: () => void;
}
export const PopupCard = ({
@@ -29,6 +33,10 @@ export const PopupCard = ({
wrapperClassName,
disabled = false,
showOn = 'hover',
disableBlur = false,
isOpen,
onClose,
onToggle,
...rest
}: PopupCardProps) => {
const popoverRef = useRef<HTMLElement>(null);
@@ -52,19 +60,37 @@ export const PopupCard = ({
return (
<PopoverController content={body} hideAfter={100}>
{(showPopper, hidePopper, popperProps) => {
// Use manual control if provided, otherwise use internal state
const isManuallyControlled = isOpen !== undefined;
const shouldShow = isManuallyControlled ? isOpen : popperProps.show;
const handleClose = () => {
if (onClose) {
onClose();
} else {
hidePopper();
}
};
const handleShow = () => {
if (!isManuallyControlled) {
showPopper();
}
};
// support hover and click interaction
const onClickProps = {
onClick: showPopper,
onClick: onToggle || (isManuallyControlled ? handleClose : showPopper),
};
const onHoverProps = {
onMouseLeave: hidePopper,
onMouseEnter: showPopper,
onMouseLeave: handleClose,
onMouseEnter: handleShow,
};
const blurFocusProps = {
onBlur: hidePopper,
onFocus: showPopper,
onBlur: handleClose,
onFocus: handleShow,
};
return (
@@ -72,26 +98,29 @@ export const PopupCard = ({
{popoverRef.current && (
<GrafanaPopover
{...popperProps}
show={shouldShow}
{...rest}
wrapperClassName={classnames(styles.popover, wrapperClassName)}
referenceElement={popoverRef.current}
renderArrow={arrow}
// @TODO
// if we want interaction with the content we should not pass blur / focus handlers but then clicking outside doesn't close the popper
{...blurFocusProps}
{...(disableBlur ? {} : blurFocusProps)}
// if we want hover interaction we have to make sure we add the leave / enter handlers
{...(showOnHover ? onHoverProps : {})}
hidePopper={handleClose}
/>
)}
{cloneElement(children, {
ref: popoverRef,
onFocus: showPopper,
onBlur: hidePopper,
onFocus: handleShow,
onBlur: disableBlur ? undefined : handleClose,
tabIndex: 0,
// make sure we pass the correct interaction handlers here to the element we want to interact with
...(showOnHover ? onHoverProps : {}),
...(showOnClick ? onClickProps : {}),
// Only add click handling if we have onToggle or not manually controlled
...(showOnClick && (onToggle || !isManuallyControlled) ? onClickProps : {}),
})}
</>
);
@@ -7,13 +7,13 @@ import { SupportedView } from './RulesViewModeSelector';
const RulesFilterV2 = lazy(() => import('./RulesFilter.v2'));
interface RulesFilerProps {
export interface RulesFilterProps {
onClear?: () => void;
viewMode?: SupportedView;
onViewModeChange?: (viewMode: SupportedView) => void;
}
const RulesFilter = (props: RulesFilerProps) => {
const RulesFilter = (props: RulesFilterProps) => {
const newView = config.featureToggles.alertingFilterV2;
return <Suspense>{newView ? <RulesFilterV2 {...props} /> : <RulesFilterV1 {...props} />}</Suspense>;
};
@@ -24,7 +24,8 @@ import { alertStateToReadable } from '../../../utils/rules';
import { PopupCard } from '../../HoverCard';
import { MultipleDataSourcePicker } from '../MultipleDataSourcePicker';
import { RulesViewModeSelector, SupportedView } from './RulesViewModeSelector';
import { RulesFilterProps } from './RulesFilter';
import { RulesViewModeSelector } from './RulesViewModeSelector';
const RuleTypeOptions: SelectableValue[] = [
{ label: 'Alert ', value: PromRuleType.Alerting },
@@ -37,15 +38,8 @@ const RuleHealthOptions: SelectableValue[] = [
{ label: 'Error', value: RuleHealth.Error },
];
// Contact point selector is not supported in Alerting ListView V2 yet
const canRenderContactPointSelector = contextSrv.hasPermission(AccessControlAction.AlertingReceiversRead);
interface RulesFilerProps {
onClear?: () => void;
viewMode?: SupportedView;
onViewModeChange?: (viewMode: SupportedView) => void;
}
const RuleStateOptions = Object.entries(PromAlertingRuleState)
.filter(([key, value]) => value !== PromAlertingRuleState.Unknown) // Exclude Unknown state from filter options
.map(([key, value]) => ({
@@ -53,7 +47,7 @@ const RuleStateOptions = Object.entries(PromAlertingRuleState)
value,
}));
const RulesFilter = ({ onClear = () => undefined, viewMode, onViewModeChange }: RulesFilerProps) => {
const RulesFilter = ({ onClear = () => undefined, viewMode, onViewModeChange }: RulesFilterProps) => {
const styles = useStyles2(getStyles);
const { pluginsFilterEnabled } = usePluginsFilterStatus();
const { filterState, hasActiveFilters, searchQuery, setSearchQuery, updateFilters } = useRulesFilter();
@@ -1,226 +1,623 @@
import { css } from '@emotion/css';
import { useCallback, useMemo, useState } from 'react';
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 {
Badge,
Box,
Button,
Grid,
IconButton,
Combobox,
FilterInput,
Icon,
Input,
InteractiveTable,
Label,
MultiCombobox,
RadioButtonGroup,
Select,
Stack,
Tab,
TabsBar,
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 MoreButton from '../../MoreButton';
type RulesFilterProps = {
onClear?: () => void;
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 ActiveTab = 'custom' | 'saved';
type SearchQueryForm = {
query: string;
};
export default function RulesFilter({ onClear = () => {} }: RulesFilterProps) {
export default function RulesFilter({ viewMode, onViewModeChange }: RulesFilterProps) {
const styles = useStyles2(getStyles);
const [activeTab, setActiveTab] = useState<ActiveTab>('custom');
const filterOptions = useMemo(() => {
return (
<PopupCard
showOn="click"
placement="bottom-start"
content={
<div className={styles.content}>
{activeTab === 'custom' && <FilterOptions />}
{activeTab === 'saved' && <SavedSearches />}
</div>
}
header={
<TabsBar hideBorder className={styles.fixTabsMargin}>
<Tab
active={activeTab === 'custom'}
icon="filter"
label={t('alerting.rules-filter.filter-options.label-custom-filter', 'Custom filter')}
onChangeTab={() => setActiveTab('custom')}
/>
<Tab
active={activeTab === 'saved'}
icon="bookmark"
label={t('alerting.rules-filter.filter-options.label-saved-searches', 'Saved searches')}
onChangeTab={() => setActiveTab('saved')}
/>
</TabsBar>
}
>
<IconButton
name="filter"
aria-label={t('alerting.rules-filter.filter-options.aria-label-show-filters', 'Show filters')}
/>
</PopupCard>
);
}, [activeTab, styles.content, styles.fixTabsMargin]);
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 (
<Stack direction="column" gap={0}>
<Label>
<Trans i18nKey="common.search">Search</Trans>
</Label>
<Stack direction="row">
<Input prefix={filterOptions} />
<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>
</Stack>
</form>
);
}
const FilterOptions = () => {
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 (
<Stack direction="column" alignItems="end" gap={2}>
<Grid columns={2} gap={2} alignItems="center">
<Label>
<Trans i18nKey="alerting.search.property.namespace">Folder / Namespace</Trans>
</Label>
<Select options={[]} onChange={() => {}} />
<Label>
<Trans i18nKey="alerting.search.property.rule-name">Alerting rule name</Trans>
</Label>
<Input />
<Label>
<Trans i18nKey="alerting.search.property.evaluation-group">Evaluation group</Trans>
</Label>
<Input />
<Label>
<Trans i18nKey="alerting.search.property.labels">Labels</Trans>
</Label>
<Input />
<Label>
<Trans i18nKey="alerting.search.property.data-source">Data source</Trans>
</Label>
<Select options={[]} onChange={() => {}} />
<Label>
<Trans i18nKey="alerting.search.property.state">State</Trans>
</Label>
<RadioButtonGroup
value={'*'}
options={[
{ label: t('alerting.filter-options.label.all', 'All'), value: '*' },
{ label: t('alerting.filter-options.label.normal', 'Normal'), value: 'normal' },
{ label: t('alerting.filter-options.label.pending', 'Pending'), value: 'pending' },
{ label: t('alerting.filter-options.label.recovering', 'Recovering'), value: 'recovering' },
{ label: t('alerting.filter-options.label.firing', 'Firing'), value: 'firing' },
]}
/>
<Label>
<Trans i18nKey="alerting.search.property.rule-type">Type</Trans>
</Label>
<RadioButtonGroup
value={'*'}
options={[
{ label: t('alerting.filter-options.label.all', 'All'), value: '*' },
{ label: t('alerting.filter-options.label.alert-rule', 'Alert rule'), value: 'alerting' },
{ label: t('alerting.filter-options.label.recording-rule', 'Recording rule'), value: 'recording' },
]}
/>
<Label>
<Trans i18nKey="alerting.search.property.rule-health">Health</Trans>
</Label>
<RadioButtonGroup
value={'*'}
options={[
{ label: t('alerting.filter-options.label.all', 'All'), value: '*' },
{ label: t('alerting.filter-options.label.ok', 'OK'), value: 'ok' },
{ label: t('alerting.filter-options.label.no-data', 'No data'), value: 'no_data' },
{ label: t('alerting.filter-options.label.error', 'Error'), value: 'error' },
]}
/>
</Grid>
<Stack direction="row" alignItems="center">
<Button variant="secondary">
<Trans i18nKey="common.clear">Clear</Trans>
</Button>
<Button>
<Trans i18nKey="common.apply">Apply</Trans>
</Button>
<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>
</Stack>
</form>
);
};
type TableColumns = {
name: string;
default?: boolean;
};
const SavedSearches = () => {
const applySearch = useCallback((name: string) => {}, []);
function SearchQueryHelp() {
const styles = useStyles2(helpStyles);
return (
<Stack direction="column" gap={2} alignItems="flex-end">
<Button variant="secondary" size="sm">
<Trans i18nKey="alerting.search.save-query">Save current search</Trans>
</Button>
<InteractiveTable<TableColumns>
columns={[
{
id: 'name',
header: 'Saved search name',
cell: ({ row }) => (
<Stack alignItems="center">
{row.original.name}
{row.original.default ? (
<Badge text={t('alerting.saved-searches.text-default', 'Default')} color="blue" />
) : null}
</Stack>
),
},
{
id: 'actions',
cell: ({ row }) => (
<Stack direction="row" alignItems="center">
<Button variant="secondary" fill="outline" size="sm" onClick={() => applySearch(row.original.name)}>
<Trans i18nKey="common.apply">Apply</Trans>
</Button>
<MoreButton size="sm" fill="outline" />
</Stack>
),
},
]}
data={[
{
name: 'My saved search',
default: true,
},
{
name: 'Another saved search',
},
{
name: 'This one has a really long name and some emojis too 🥒',
},
]}
getRowId={(row) => row.name}
/>
<Button variant="secondary">
<Trans i18nKey="common.close">Close</Trans>
</Button>
</Stack>
<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),
maxWidth: 500,
}),
fixTabsMargin: css({
marginTop: theme.spacing(-1),
grid: css({
display: 'grid',
gridTemplateColumns: 'auto 1fr',
alignItems: 'center',
gap: theme.spacing(2),
}),
};
}
@@ -13,8 +13,8 @@ export type SupportedView = 'list' | 'grouped';
type LegacySupportedView = 'list' | 'grouped' | 'state';
const ViewOptions: Array<SelectableValue<SupportedView>> = [
{ icon: 'folder', label: 'Grouped', value: 'grouped' },
{ icon: 'list-ul', label: 'List', value: 'list' },
{ icon: 'folder', value: 'grouped', label: 'Grouped' },
{ icon: 'list-ul', value: 'list', label: 'List' },
];
interface RulesViewModeSelectorV2Props {
@@ -0,0 +1,186 @@
import { useMemo } from 'react';
import { DataSourceInstanceSettings } from '@grafana/data';
import { t } from '@grafana/i18n';
import { getDataSourceSrv } from '@grafana/runtime';
import { ComboboxOption } from '@grafana/ui';
import { GrafanaPromRuleGroupDTO } from 'app/types/unified-alerting-dto';
import { alertRuleApi } from '../../../api/alertRuleApi';
import { GRAFANA_RULER_CONFIG } from '../../../api/featureDiscoveryApi';
import { prometheusApi } from '../../../api/prometheusApi';
import { useGetLabelsFromDataSourceName } from '../../../components/rule-editor/useAlertRuleSuggestions';
import { GRAFANA_RULES_SOURCE_NAME, getRulesDataSources } from '../../../utils/datasource';
export function useNamespaceAndGroupOptions(): {
namespaceOptions: Array<ComboboxOption<string>>;
allGroupNames: string[];
isLoadingNamespaces: boolean;
namespacePlaceholder: string;
groupPlaceholder: string;
} {
const { currentData: grafanaPromRulesResponse, isLoading: isLoadingGrafanaPromRules } =
prometheusApi.endpoints.getGrafanaGroups.useQuery({
limitAlerts: 0,
groupLimit: 1000,
});
// Transform Grafana groups to namespace structure
const grafanaPromRules = useMemo(() => {
const groups = grafanaPromRulesResponse?.data?.groups ?? [];
const namespaceMap = new Map<string, { name: string; groups: GrafanaPromRuleGroupDTO[] }>();
groups.forEach((group) => {
const namespaceName = group.file || 'default';
const existing = namespaceMap.get(namespaceName);
if (existing) {
existing.groups.push(group);
} else {
namespaceMap.set(namespaceName, { name: namespaceName, groups: [group] });
}
});
return Array.from(namespaceMap.values());
}, [grafanaPromRulesResponse]);
const { isLoading: isLoadingGrafanaRulerRules } = alertRuleApi.endpoints.rulerRules.useQuery({
rulerConfig: GRAFANA_RULER_CONFIG,
});
const externalDataSources = useMemo(getRulesDataSources, []);
const externalPromRulesQueries = externalDataSources.map((ds) =>
prometheusApi.endpoints.getGroups.useQuery({
ruleSource: { uid: ds.uid },
excludeAlerts: true,
groupLimit: 500,
})
);
const isLoadingNamespaces = useMemo(() => {
return (
isLoadingGrafanaPromRules ||
isLoadingGrafanaRulerRules ||
externalPromRulesQueries.some((query) => query.isLoading)
);
}, [isLoadingGrafanaPromRules, isLoadingGrafanaRulerRules, externalPromRulesQueries]);
const namespaceOptions = useMemo((): Array<ComboboxOption<string>> => {
const grafanaFolders: Array<ComboboxOption<string>> = [];
const externalNamespaces: Array<ComboboxOption<string>> = [];
// Grafana folders
grafanaPromRules.forEach((namespace) => {
grafanaFolders.push({
label: namespace.name,
value: namespace.name,
description: t('alerting.rules-filter.grafana-folder', 'Grafana folder'),
});
});
// External namespaces (dedupe by file)
externalPromRulesQueries.forEach((query) => {
const namespaces = new Set<string>();
query.currentData?.data?.groups?.forEach((group) => {
namespaces.add(group.file || 'default');
});
namespaces.forEach((namespaceName) => {
if (namespaceName.includes('/') && (namespaceName.endsWith('.yml') || namespaceName.endsWith('.yaml'))) {
const filename = namespaceName.split('/').pop() || namespaceName;
const maxDescriptionLength = 100;
const truncatedDescription =
namespaceName.length > maxDescriptionLength
? `${namespaceName.substring(0, maxDescriptionLength)}...`
: namespaceName;
externalNamespaces.push({ label: filename, value: namespaceName, description: truncatedDescription });
} else {
const maxLength = 50;
const maxDescriptionLength = 100;
const truncatedName =
namespaceName.length > maxLength ? `${namespaceName.substring(0, maxLength)}...` : namespaceName;
const truncatedDescription =
namespaceName.length > maxDescriptionLength
? `${namespaceName.substring(0, maxDescriptionLength)}...`
: namespaceName;
externalNamespaces.push({ label: truncatedName, value: namespaceName, description: truncatedDescription });
}
});
});
const collator = new Intl.Collator();
grafanaFolders.sort((a, b) => collator.compare(a.label ?? '', b.label ?? ''));
externalNamespaces.sort((a, b) => collator.compare(a.label ?? '', b.label ?? ''));
return [...grafanaFolders, ...externalNamespaces];
}, [grafanaPromRules, externalPromRulesQueries]);
const allGroupNames = useMemo(() => {
const groupSet = new Set<string>();
grafanaPromRules.forEach((namespace) => {
namespace.groups.forEach((group) => groupSet.add(group.name));
});
externalPromRulesQueries.forEach((query) => {
query.currentData?.data?.groups?.forEach((group) => {
groupSet.add(group.name);
});
});
return Array.from(groupSet).sort();
}, [grafanaPromRules, externalPromRulesQueries]);
const namespacePlaceholder = useMemo(() => {
if (isLoadingNamespaces) {
return t('common.loading', 'Loading...');
}
if (namespaceOptions.length === 0) {
return t('alerting.rules-filter.no-namespaces', 'No folders available');
}
return t('alerting.rules-filter.filter-options.placeholder-namespace', 'Select namespace');
}, [isLoadingNamespaces, namespaceOptions.length]);
const groupPlaceholder = useMemo(() => {
if (isLoadingNamespaces) {
return t('common.loading', 'Loading...');
}
if (allGroupNames.length === 0) {
return t('alerting.rules-filter.no-groups', 'No groups available');
}
return t('grafana.select-group', 'Select group');
}, [isLoadingNamespaces, allGroupNames.length]);
return { namespaceOptions, allGroupNames, isLoadingNamespaces, namespacePlaceholder, groupPlaceholder };
}
export function useLabelOptions(): {
labelOptions: Array<ComboboxOption<string>>;
isLoadingGrafanaLabels: boolean;
} {
const { labels: grafanaLabels, isLoading: isLoadingGrafanaLabels } =
useGetLabelsFromDataSourceName(GRAFANA_RULES_SOURCE_NAME);
const labelOptions = useMemo((): Array<ComboboxOption<string>> => {
const infoOption: ComboboxOption<string> = {
label: t('label-dropdown-info', "Can't find your label? Enter it manually"),
value: '__GRAFANA_LABEL_DROPDOWN_INFO__',
infoOption: true,
};
const selectableOptions = Array.from(grafanaLabels.entries())
.flatMap(([key, values]) =>
Array.from(values).map((value: string) => ({ label: `${key}=${value}`, value: `${key}=${value}` }))
)
.sort((a, b) => new Intl.Collator().compare(a.label, b.label));
return [...selectableOptions, infoOption];
}, [grafanaLabels]);
return { labelOptions, isLoadingGrafanaLabels };
}
export function useAlertingDataSourceOptions(): Array<ComboboxOption<string>> {
return useMemo(() => {
return getDataSourceSrv()
.getList({ alerting: true })
.map((ds: DataSourceInstanceSettings) => ({ label: ds.name, value: ds.name }));
}, []);
}
@@ -0,0 +1,81 @@
import { useEffect, useRef } from 'react';
import { useAlertingHomePageExtensions } from '../../../plugins/useAlertingHomePageExtensions';
import { RulesFilter } from '../../../search/rulesSearchParser';
import { AdvancedFilters } from './RulesFilter.v2';
export function formAdvancedFiltersToRuleFilter(values: AdvancedFilters): RulesFilter {
return {
freeFormWords: [],
...values,
namespace: values.namespace || undefined,
groupName: values.groupName || undefined,
contactPoint: values.contactPoint || undefined,
ruleHealth: values.ruleHealth === '*' ? undefined : values.ruleHealth,
ruleState: values.ruleState === '*' ? undefined : values.ruleState,
ruleType: values.ruleType === '*' ? undefined : values.ruleType,
plugins: values.plugins === 'show' ? undefined : 'hide',
};
}
export const emptyAdvancedFilters: AdvancedFilters = {
namespace: null,
groupName: null,
ruleName: undefined,
ruleType: '*',
ruleState: '*',
dataSourceNames: [],
labels: [],
ruleHealth: '*',
dashboardUid: undefined,
plugins: 'show',
contactPoint: null,
};
export function searchQueryToDefaultValues(filterState: RulesFilter): AdvancedFilters {
return {
namespace: filterState.namespace ?? null,
groupName: filterState.groupName ?? null,
ruleName: filterState.ruleName,
ruleType: filterState.ruleType ?? '*',
ruleState: filterState.ruleState ?? '*',
dataSourceNames: filterState.dataSourceNames,
labels: filterState.labels,
ruleHealth: filterState.ruleHealth ?? '*',
dashboardUid: filterState.dashboardUid,
plugins: filterState.plugins ?? 'show',
contactPoint: filterState.contactPoint ?? null,
};
}
export function usePortalContainer(zIndex: number): HTMLElement | undefined {
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const container = document.createElement('div');
Object.assign(container.style, {
position: 'fixed',
top: '0',
left: '0',
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: String(zIndex),
});
document.body.appendChild(container);
containerRef.current = container;
return () => {
container.remove();
};
}, [zIndex]);
return containerRef.current || undefined;
}
export function usePluginsFilterStatus() {
const { components } = useAlertingHomePageExtensions();
return { pluginsFilterEnabled: components.length > 0 };
}
@@ -0,0 +1,366 @@
import { render, screen } from 'test/test-utils';
import { byRole, byTestId } from 'testing-library-selector';
import { ComponentTypeWithExtensionMeta, PluginExtensionComponentMeta, PluginExtensionTypes } from '@grafana/data';
import { config, locationService, setPluginComponentsHook } from '@grafana/runtime';
import { setupMswServer } from 'app/features/alerting/unified/mockApi';
import { grantUserPermissions } from 'app/features/alerting/unified/mocks';
import { AccessControlAction } from 'app/types/accessControl';
import { PromAlertingRuleState } from 'app/types/unified-alerting-dto';
import * as analytics from '../../Analytics';
import { useRulesFilter } from '../../hooks/useFilteredRules';
import { RulesFilter as RulesFilterType } from '../../search/rulesSearchParser';
import { setupPluginsExtensionsHook } from '../../testSetup/plugins';
// Grant permission before importing the component since permission check happens at module level
grantUserPermissions([AccessControlAction.AlertingReceiversRead]);
let mockFilterState: RulesFilterType = {
ruleName: '',
ruleState: undefined,
dataSourceNames: [],
freeFormWords: [],
labels: [],
};
let mockSearchQuery = '';
const mockUpdateFilters = jest.fn();
const mockSetSearchQuery = jest.fn();
const mockClearAll = jest.fn();
jest.mock('../../hooks/useFilteredRules', () => ({
useRulesFilter: jest.fn(() => ({
searchQuery: mockSearchQuery,
filterState: mockFilterState,
updateFilters: mockUpdateFilters,
setSearchQuery: mockSetSearchQuery,
clearAll: mockClearAll,
hasActiveFilters: false,
activeFilters: [],
})),
}));
import RulesFilter from './Filter/RulesFilter';
import RulesFilterV2 from './Filter/RulesFilter.v2';
const useRulesFilterMock = useRulesFilter as jest.MockedFunction<typeof useRulesFilter>;
setupMswServer();
jest.spyOn(analytics, 'trackFilterButtonClick');
jest.spyOn(analytics, 'trackFilterButtonApplyClick');
jest.spyOn(analytics, 'trackFilterButtonClearClick');
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getDataSourceSrv: () => ({
getList: jest.fn().mockReturnValue([
{ name: 'Prometheus', uid: 'prometheus-uid' },
{ name: 'Loki', uid: 'loki-uid' },
]),
}),
}));
jest.mock('./MultipleDataSourcePicker', () => {
const original = jest.requireActual('./MultipleDataSourcePicker');
return {
...original,
MultipleDataSourcePicker: () => null,
};
});
jest.mock('../../plugins/useAlertingHomePageExtensions', () => ({
useAlertingHomePageExtensions: jest.fn(() => {
const { usePluginComponents } = jest.requireActual('@grafana/runtime');
const { PluginExtensionPoints } = jest.requireActual('@grafana/data');
return usePluginComponents({
extensionPointId: PluginExtensionPoints.AlertingHomePage,
limitPerPlugin: 1,
});
}),
}));
setupPluginsExtensionsHook();
// Helper function to create mock plugin components
function createMockComponent(pluginId: string): ComponentTypeWithExtensionMeta<{}> {
function MockComponent() {
return <div>Test Plugin Component</div>;
}
MockComponent.meta = {
id: `test-component-${pluginId}`,
pluginId,
title: 'Test Component',
description: 'Test plugin component',
type: PluginExtensionTypes.component,
} satisfies PluginExtensionComponentMeta;
return MockComponent as ComponentTypeWithExtensionMeta<{}>;
}
const ui = {
searchInput: byTestId('search-query-input'),
filterButton: byRole('button', { name: 'Filter' }),
applyButton: byTestId('filter-apply-button'),
clearButton: byTestId('filter-clear-button'),
ruleNameInput: byTestId('rule-name-input'),
};
beforeEach(() => {
locationService.replace({ search: '' });
jest.clearAllMocks();
mockFilterState = {
ruleName: '',
ruleState: undefined,
dataSourceNames: [],
freeFormWords: [],
labels: [],
};
mockSearchQuery = '';
mockUpdateFilters.mockClear();
mockSetSearchQuery.mockClear();
mockClearAll.mockClear();
mockSetSearchQuery.mockImplementation(() => {});
// Reset plugin components hook to default (no plugins)
setPluginComponentsHook(() => ({
components: [],
isLoading: false,
}));
});
describe('RulesFilter Feature Flag', () => {
const originalFeatureToggle = config.featureToggles.alertingFilterV2;
afterEach(() => {
config.featureToggles.alertingFilterV2 = originalFeatureToggle;
});
it('Should render RulesFilterV2 when alertingFilterV2 feature flag is enabled', async () => {
config.featureToggles.alertingFilterV2 = true;
render(<RulesFilter />);
// Wait for suspense to resolve and check that the V2 filter button is present
await screen.findByRole('button', { name: 'Filter' });
expect(ui.filterButton.get()).toBeInTheDocument();
expect(ui.searchInput.get()).toBeInTheDocument();
});
it('Should render RulesFilterV1 when alertingFilterV2 feature flag is disabled', async () => {
config.featureToggles.alertingFilterV2 = false;
render(<RulesFilter />);
// Wait for suspense to resolve and check V1 structure
await screen.findByText('Search');
// V1 has search input but no V2-style filter button
expect(ui.searchInput.get()).toBeInTheDocument();
expect(ui.filterButton.query()).not.toBeInTheDocument();
// V1 has a help icon next to the search input
expect(screen.getByText('Search')).toBeInTheDocument();
});
});
describe('RulesFilterV2', () => {
it('Should render component without crashing', () => {
render(<RulesFilterV2 />);
expect(ui.searchInput.get()).toBeInTheDocument();
expect(ui.filterButton.get()).toBeInTheDocument();
});
it('Should allow typing in search input', async () => {
const { user } = render(<RulesFilterV2 />);
await user.type(ui.searchInput.get(), 'test search');
expect(ui.searchInput.get()).toHaveValue('test search');
});
it('Should open filter popup when filter button is clicked', async () => {
const { user } = render(<RulesFilterV2 />);
await user.click(ui.filterButton.get());
expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument();
});
it('Should close popup when clicking outside', async () => {
const { user } = render(<RulesFilterV2 />);
await user.click(ui.filterButton.get());
expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument();
await user.click(document.body);
expect(screen.queryByRole('button', { name: 'Apply' })).not.toBeInTheDocument();
});
it('Should clear filters and search field when clicking clear button', async () => {
const { user } = render(<RulesFilterV2 />);
await user.type(ui.searchInput.get(), 'test');
expect(ui.searchInput.get()).toHaveValue('test');
await user.click(ui.filterButton.get());
await user.click(ui.clearButton.get());
expect(ui.ruleNameInput.get()).toHaveValue('');
// Check that setSearchQuery was called with undefined to clear the search
expect(mockSetSearchQuery).toHaveBeenCalledWith(undefined);
});
it('Should populate search field with query string when filters are applied via rule name', async () => {
const { user } = render(<RulesFilterV2 />);
await user.click(ui.filterButton.get());
await user.type(ui.ruleNameInput.get(), 'test');
// Mock the setSearchQuery to update mockSearchQuery
mockSetSearchQuery.mockImplementation((newQuery: string | undefined) => {
mockSearchQuery = newQuery ?? '';
});
await user.click(ui.applyButton.get());
// Check that setSearchQuery was called with the expected query
expect(mockSetSearchQuery).toHaveBeenCalledWith('rule:test');
});
it('Should parse search query and call updateFilters when user types directly in search field', async () => {
const { user, rerender } = render(<RulesFilterV2 />);
// Type a search query directly into the search input
await user.type(ui.searchInput.get(), 'rule:test state:firing');
// Trigger the onBlur handler by clicking elsewhere
await user.click(document.body);
// Verify updateFilters was called with the parsed filter
expect(mockUpdateFilters).toHaveBeenCalledWith({
dataSourceNames: [],
freeFormWords: [],
labels: [],
ruleName: 'test',
ruleState: 'firing',
});
// Simulate the filter state update by updating our mock
mockFilterState = {
dataSourceNames: [],
freeFormWords: [],
labels: [],
ruleName: 'test',
ruleState: PromAlertingRuleState.Firing,
};
// Update the mock to return the new state
useRulesFilterMock.mockReturnValue({
searchQuery: mockSearchQuery,
filterState: mockFilterState,
updateFilters: mockUpdateFilters,
setSearchQuery: mockSetSearchQuery,
clearAll: mockClearAll,
hasActiveFilters: false,
activeFilters: [],
});
// Force a re-render to pick up the new filter state
rerender(<RulesFilterV2 />);
// Open the filter popup
await user.click(ui.filterButton.get());
expect(ui.ruleNameInput.get()).toHaveValue('test');
const firingRadio = screen.getByRole('radio', { name: 'Firing' });
expect(firingRadio).toBeChecked();
});
it('Should handle free-form rule name search in query string', async () => {
const { user } = render(<RulesFilterV2 />);
// Type a free-form search (no filter prefix)
await user.type(ui.searchInput.get(), 'test');
// Trigger the search by pressing Enter
await user.keyboard('{Enter}');
// The search input should retain the value
expect(ui.searchInput.get()).toHaveValue('test');
});
describe('Conditional Fields', () => {
it('Should show contact point field when user has proper permissions', async () => {
// Permission is already mocked to true at module level
const { user } = render(<RulesFilterV2 />);
await user.click(ui.filterButton.get());
expect(screen.getByText('Contact point')).toBeInTheDocument();
});
it('Should show plugin filter when plugins are enabled', async () => {
// Mock plugin components to simulate plugins available
setPluginComponentsHook(() => ({
components: [createMockComponent('test-plugin')],
isLoading: false,
}));
const { user } = render(<RulesFilterV2 />);
await user.click(ui.filterButton.get());
expect(screen.getByText('Plugin rules')).toBeInTheDocument();
});
it('Should hide plugin filter when no plugins are available', async () => {
// Mock plugin components to return no components
setPluginComponentsHook(() => ({
components: [],
isLoading: false,
}));
const { user } = render(<RulesFilterV2 />);
await user.click(ui.filterButton.get());
expect(screen.queryByText('Plugin rules')).not.toBeInTheDocument();
});
});
describe('Analytics Tracking', () => {
it('Should track filter button clicks when opening popup', async () => {
const { user } = render(<RulesFilterV2 />);
await user.click(ui.filterButton.get());
expect(analytics.trackFilterButtonClick).toHaveBeenCalledTimes(1);
});
it('Should track clear button clicks', async () => {
const { user } = render(<RulesFilterV2 />);
await user.click(ui.filterButton.get());
await user.click(ui.clearButton.get());
expect(analytics.trackFilterButtonClearClick).toHaveBeenCalledTimes(1);
});
it('Should track apply button clicks with filter values', async () => {
const { user } = render(<RulesFilterV2 />);
await user.click(ui.filterButton.get());
await user.click(ui.applyButton.get());
expect(analytics.trackFilterButtonApplyClick).toHaveBeenCalledTimes(1);
});
it('Should not track filter button click when filter button is clicked to close popup', async () => {
const { user } = render(<RulesFilterV2 />);
await user.click(ui.filterButton.get());
expect(analytics.trackFilterButtonClick).toHaveBeenCalledTimes(1);
await user.click(document.body);
jest.clearAllMocks();
await user.click(ui.filterButton.get());
expect(analytics.trackFilterButtonClick).toHaveBeenCalledTimes(1);
});
});
});
@@ -80,7 +80,11 @@ export function useRulesFilter() {
}
}, [queryParams, updateFilters, filterState, updateQueryParams]);
return { filterState, hasActiveFilters, activeFilters, searchQuery, setSearchQuery, updateFilters };
const clearAll = useCallback(() => {
updateQueryParams({ search: undefined });
}, [updateQueryParams]);
return { filterState, hasActiveFilters, searchQuery, setSearchQuery, updateFilters, clearAll, activeFilters };
}
export const useFilteredRules = (namespaces: CombinedRuleNamespace[], filterState: RulesFilter) => {
+34 -25
View File
@@ -1244,20 +1244,6 @@
"copy-code": "Copy code",
"download": "Download"
},
"filter-options": {
"label": {
"alert-rule": "Alert rule",
"all": "All",
"error": "Error",
"firing": "Firing",
"no-data": "No data",
"normal": "Normal",
"ok": "OK",
"pending": "Pending",
"recording-rule": "Recording rule",
"recovering": "Recovering"
}
},
"filter-view-results": {
"aria-label-filteredrulelist": "filtered-rule-list"
},
@@ -2588,12 +2574,28 @@
"delete-rule": {
"success": "Rule successfully deleted"
},
"health": {
"error": "Error",
"no-data": "No data",
"ok": "OK"
},
"pause-rule": {
"success": "Rule evaluation paused"
},
"resume-rule": {
"success": "Rule evaluation resumed"
},
"state": {
"firing": "Firing",
"normal": "Normal",
"pending": "Pending",
"recovering": "Recovering",
"unknown": "Unknown"
},
"type": {
"alert": "Alert rule",
"recording": "Recording rule"
},
"update-rule": {
"success": "Rule updated successfully"
}
@@ -2601,20 +2603,29 @@
"rules-filter": {
"clear-filters": "Clear filters",
"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.",
"contact-point-tooltip": "Filters alert rules which route directly to the selected contact point. Alert rules routed to notification policies will not be displayed.",
"contact-point-tooltip-title": "Contact point filter help",
"dashboard": "Dashboard",
"data-source-picker-inline-help-title-search-by-data-sources-help": "Search by data sources help",
"filter-options": {
"aria-label-show-filters": "Show filters",
"label-custom-filter": "Custom filter",
"label-saved-searches": "Saved searches"
"aria-label": "Filter options",
"aria-label-show-filters": "Filter",
"placeholder-namespace": "Select namespace",
"placeholder-search-input": "Search by name or enter filter query..."
},
"grafana-folder": "Grafana folder",
"health": "Health",
"label": {
"hide": "Hide",
"show": "Show"
},
"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.",
"no-groups": "No groups available",
"no-namespaces": "No folders available",
"placeholder-all-data-sources": "All data sources",
"placeholder-contact-point": "Select contact point",
"placeholder-data-sources": "Select data sources",
"placeholder-labels": "Select labels",
"plugin-rules": "Plugin rules",
"rule-type": "Rule type",
"rulesSearchInput-placeholder-search": "Search",
@@ -2629,9 +2640,6 @@
"text-federated": "Federated",
"text-provisioned": "Provisioned"
},
"saved-searches": {
"text-default": "Default"
},
"search": {
"property": {
"data-source": "Data source",
@@ -2639,11 +2647,10 @@
"labels": "Labels",
"namespace": "Folder / Namespace",
"rule-health": "Health",
"rule-name": "Alerting rule name",
"rule-name": "Rule name",
"rule-type": "Type",
"state": "State"
},
"save-query": "Save current search"
}
},
"search-field-input": {
"clear": "Clear",
@@ -4096,10 +4103,10 @@
}
},
"common": {
"all": "All",
"apply": "Apply",
"cancel": "Cancel",
"clear": "Clear",
"close": "Close",
"collapse": "Collapse",
"edit": "Edit",
"help": "Help",
@@ -7874,7 +7881,8 @@
"edit-pane": {
"go-back": "Go back"
}
}
},
"select-group": "Select group"
},
"grafana-data": {
"valueFormats": {
@@ -9063,6 +9071,7 @@
"sign-up": "Sign up"
}
},
"label-dropdown-info": "Can't find your label? Enter it manually",
"layers": {
"layer-drag-drop-list": {
"draggable-aria-label": "Drag and drop to reorder",