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