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:
Lauren 2025-08-21 17:04:00 +01:00 committed by GitHub
parent 82d36a259e
commit 56c8e53a99
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 955 additions and 724 deletions

View File

@ -66,6 +66,31 @@ export const Basic: Story = {
},
};
export const WithInfoOption: Story = {
name: 'With infoOption',
args: {
...commonArgs,
options: [
...commonArgs.options,
{ label: 'Cant 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) => {

View File

@ -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 = {

View File

@ -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

View File

@ -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),
}),
};
}

View File

@ -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();

View File

@ -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,

View File

@ -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'));

View File

@ -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 />);

View File

@ -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),
}),
};
}

View File

@ -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;
};

View File

@ -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 {