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:
co-authored by
Gilles De Mey
Lauren Armstrong
Lauren Armstrong
Lauren Armstrong
parent
43cda1f1a4
commit
af68304af0
@@ -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),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
+2
-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 {
|
||||
|
||||
+186
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user