Alerting: Allow filter by rule source in Filter V2 (#110336)

* add UI for rule source section of filter

* add logic to filter grafana vs external datasources

* run yarn i18n-extract

* resolve PR comments

* resolve design comments

* add rule source to search parser

* rename external to datasource

* import empty from ix

* fix tests

* fix typing

* resolve comments- treat undefined as *

* resolve PR comments
This commit is contained in:
Lauren
2025-09-02 15:27:54 +01:00
committed by GitHub
parent 146caddf1f
commit c6ff3b5be2
12 changed files with 171 additions and 56 deletions
@@ -8,7 +8,12 @@ import { CombinedRuleGroup, CombinedRuleNamespace, Rule } from 'app/types/unifie
import { PromRuleType, RulerGrafanaRuleDTO, isPromAlertingRuleState } from 'app/types/unified-alerting-dto';
import { logError } from '../Analytics';
import { RulesFilter, applySearchFilterToQuery, getSearchFilterFromQuery } from '../search/rulesSearchParser';
import {
RuleSource,
RulesFilter,
applySearchFilterToQuery,
getSearchFilterFromQuery,
} from '../search/rulesSearchParser';
import { labelsMatchMatchers, matcherToMatcherField } from '../utils/alertmanager';
import { Annotation } from '../utils/constants';
import { isCloudRulesSource } from '../utils/datasource';
@@ -183,6 +188,15 @@ const reduceGroups = (filterState: RulesFilter) => {
filteredRules = fuzzyFilter(filteredRules, (r) => r.name, ruleNameQuery);
}
// Filter by rule source at rule-level (Grafana-managed vs datasource-managed)
if (filterState.ruleSource) {
const grafanaSelected = filterState.ruleSource === RuleSource.Grafana;
filteredRules = filteredRules.filter((rule) => {
const isGrafana = !!(rule.rulerRule && rulerRuleType.grafana.rule(rule.rulerRule));
return grafanaSelected && isGrafana;
});
}
filteredRules = filteredRules.filter((rule) => {
const promRuleDefition = rule.promRule;
@@ -201,6 +215,7 @@ const reduceGroups = (filterState: RulesFilter) => {
'dashboardUid',
'plugins',
'contactPoint',
'ruleSource',
])
.omitBy(isEmpty)
.mapValues(() => false)
@@ -332,6 +347,7 @@ const RULES_FILTER_KEYS: Set<keyof RulesFilter> = new Set([
'dashboardUid',
'plugins',
'contactPoint',
'ruleSource',
]);
const isRuleFilterKey = (key: string): key is keyof RulesFilter => RULES_FILTER_KEYS.has(key as keyof RulesFilter);
@@ -40,7 +40,7 @@ import {
useNamespaceAndGroupOptions,
} from '../../components/rules/Filter/useRuleFilterAutocomplete';
import { useRulesFilter } from '../../hooks/useFilteredRules';
import { RuleHealth, getSearchFilterFromQuery } from '../../search/rulesSearchParser';
import { RuleHealth, RuleSource, getSearchFilterFromQuery } from '../../search/rulesSearchParser';
import { RulesFilterProps } from './RulesFilter';
import {
@@ -53,6 +53,8 @@ import {
const canRenderContactPointSelector = contextSrv.hasPermission(AccessControlAction.AlertingReceiversRead);
const radioGroupCompactClass = css({ width: 'max-content' });
type SearchQueryForm = {
query: string;
};
@@ -298,6 +300,7 @@ const FilterOptions = ({ onSubmit, onClear, pluginsFilterEnabled }: FilterOption
/>
<DataSourceNamesField dataSourceOptions={dataSourceOptions} portalContainer={portalContainer} />
{canRenderContactPointSelector && <ContactPointField portalContainer={portalContainer} />}
<RuleSourceField />
<RuleStateField />
<RuleTypeField />
<RuleHealthField />
@@ -570,6 +573,8 @@ function RuleStateField() {
]}
value={field.value}
onChange={field.onChange}
fullWidth={false}
className={radioGroupCompactClass}
/>
)}
/>
@@ -596,6 +601,39 @@ function RuleTypeField() {
]}
value={field.value}
onChange={field.onChange}
fullWidth={false}
className={radioGroupCompactClass}
/>
)}
/>
</>
);
}
function RuleSourceField() {
const { control } = useFormContext<AdvancedFilters>();
return (
<>
<Label>
<Trans i18nKey="alerting.search.property.rule-source">Rule source</Trans>
</Label>
<Controller
name="ruleSource"
control={control}
render={({ field }) => (
<RadioButtonGroup<AdvancedFilters['ruleSource']>
options={[
{ label: t('common.all', 'All'), value: null },
{ label: t('alerting.rules-filter.rule-source.grafana', 'Grafana managed'), value: RuleSource.Grafana },
{
label: t('alerting.rules-filter.rule-source.datasource', 'Data source managed'),
value: RuleSource.DataSource,
},
]}
value={field.value}
onChange={field.onChange}
fullWidth={false}
className={radioGroupCompactClass}
/>
)}
/>
@@ -623,6 +661,8 @@ function RuleHealthField() {
]}
value={field.value}
onChange={field.onChange}
fullWidth={false}
className={radioGroupCompactClass}
/>
)}
/>
@@ -648,6 +688,8 @@ function PluginsField() {
]}
value={field.value}
onChange={field.onChange}
fullWidth={false}
className={radioGroupCompactClass}
/>
)}
/>
@@ -1,6 +1,6 @@
import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto';
import type { RuleHealth } from '../../search/rulesSearchParser';
import type { RuleHealth, RuleSource } from '../../search/rulesSearchParser';
export type AdvancedFilters = {
namespace?: string | null;
@@ -14,4 +14,5 @@ export type AdvancedFilters = {
dashboardUid?: string;
plugins?: 'show' | 'hide';
contactPoint?: string | null;
ruleSource?: RuleSource | null;
};
@@ -16,6 +16,7 @@ export function formAdvancedFiltersToRuleFilter(values: AdvancedFilters): RulesF
ruleState: values.ruleState === '*' ? undefined : values.ruleState,
ruleType: values.ruleType === '*' ? undefined : values.ruleType,
plugins: values.plugins === 'show' ? undefined : 'hide',
ruleSource: values.ruleSource ?? undefined,
};
}
@@ -31,6 +32,7 @@ export const emptyAdvancedFilters: AdvancedFilters = {
dashboardUid: undefined,
plugins: 'show',
contactPoint: null,
ruleSource: null,
};
export function searchQueryToDefaultValues(filterState: RulesFilter): AdvancedFilters {
@@ -46,6 +48,7 @@ export function searchQueryToDefaultValues(filterState: RulesFilter): AdvancedFi
dashboardUid: filterState.dashboardUid,
plugins: filterState.plugins ?? 'show',
contactPoint: filterState.contactPoint ?? null,
ruleSource: filterState.ruleSource ?? null,
};
}
@@ -1,7 +1,7 @@
import { AsyncIterableX, empty, from } from 'ix/asynciterable';
import { AsyncIterableX, from } from 'ix/asynciterable';
import { empty } from 'ix/asynciterable/empty';
import { merge } from 'ix/asynciterable/merge';
import { catchError, concatMap, withAbort } from 'ix/asynciterable/operators';
import { isEmpty } from 'lodash';
import {
DataSourceRuleGroupIdentifier,
@@ -15,7 +15,7 @@ import {
PromRuleGroupDTO,
} from 'app/types/unified-alerting-dto';
import { RulesFilter } from '../../search/rulesSearchParser';
import { RuleSource, RulesFilter } from '../../search/rulesSearchParser';
import {
getDataSourceByUid,
getDatasourceAPIUid,
@@ -63,7 +63,7 @@ export function useFilteredRulesIteratorProvider() {
const normalizedFilterState = normalizeFilterState(filterState);
const hasDataSourceFilterActive = Boolean(filterState.dataSourceNames.length);
const grafanaRulesGenerator = from(
const grafanaRulesGenerator: AsyncIterableX<RuleWithOrigin> = from(
grafanaGroupsGenerator(groupLimit, {
contactPoint: filterState.contactPoint ?? undefined,
health: filterState.ruleHealth ? [filterState.ruleHealth] : [],
@@ -74,9 +74,9 @@ export function useFilteredRulesIteratorProvider() {
concatMap((groups) =>
groups
.filter((group) => groupFilter(group, normalizedFilterState))
.flatMap((group) => group.rules.map((rule) => [group, rule] as const))
.filter(([, rule]) => ruleFilter(rule, normalizedFilterState))
.map(([group, rule]) => mapGrafanaRuleToRuleWithOrigin(group, rule))
.flatMap((group) => group.rules.map((rule) => ({ group, rule })))
.filter(({ rule }) => ruleFilter(rule, normalizedFilterState))
.map(({ group, rule }) => mapGrafanaRuleToRuleWithOrigin(group, rule))
),
catchError(() => empty())
);
@@ -86,38 +86,57 @@ export function useFilteredRulesIteratorProvider() {
? getRulesSourcesFromFilter(filterState)
: allExternalRulesSources;
// If no data sources, just return Grafana rules
if (isEmpty(externalRulesSourcesToFetchFrom)) {
if (filterState.ruleSource === RuleSource.Grafana) {
return { iterable: grafanaRulesGenerator, abortController };
}
// Create a generator for each data source
const dataSourceGenerators = externalRulesSourcesToFetchFrom.map((dataSourceIdentifier) => {
const promGroupsGenerator = from(prometheusGroupsGenerator(dataSourceIdentifier, groupLimit)).pipe(
withAbort(abortController.signal),
concatMap((groups) =>
groups
.filter((group) => groupFilter(group, normalizedFilterState))
.flatMap((group) => group.rules.map((rule) => [group, rule] as const))
.filter(([, rule]) => ruleFilter(rule, normalizedFilterState))
.map(([group, rule]) => mapRuleToRuleWithOrigin(dataSourceIdentifier, group, rule))
),
catchError(() => empty())
);
const dataSourceGenerators: Array<AsyncIterableX<RuleWithOrigin>> = externalRulesSourcesToFetchFrom.map(
(dataSourceIdentifier) => {
const promGroupsGenerator: AsyncIterableX<RuleWithOrigin> = from(
prometheusGroupsGenerator(dataSourceIdentifier, groupLimit)
).pipe(
withAbort(abortController.signal),
concatMap((groups) =>
groups
.filter((group) => groupFilter(group, normalizedFilterState))
.flatMap((group) => group.rules.map((rule) => ({ group, rule })))
.filter(({ rule }) => ruleFilter(rule, normalizedFilterState))
.map(({ group, rule }) => mapRuleToRuleWithOrigin(dataSourceIdentifier, group, rule))
),
catchError(() => empty())
);
return promGroupsGenerator;
});
return promGroupsGenerator;
}
);
// Merge all generators
return {
iterable: merge<RuleWithOrigin>(grafanaRulesGenerator, ...dataSourceGenerators),
abortController,
};
const iterablesToMerge: Array<AsyncIterableX<RuleWithOrigin>> = [];
const includeGrafana = filterState.ruleSource !== 'datasource';
const includeExternal = true;
if (includeGrafana) {
iterablesToMerge.push(grafanaRulesGenerator);
}
if (includeExternal) {
iterablesToMerge.push(...dataSourceGenerators);
}
const iterable = mergeIterables(iterablesToMerge);
return { iterable, abortController };
};
return getFilteredRulesIterable;
}
function mergeIterables(iterables: Array<AsyncIterableX<RuleWithOrigin>>): AsyncIterableX<RuleWithOrigin> {
if (iterables.length === 0) {
return empty();
}
const [firstIterable, ...rest] = iterables;
return merge(firstIterable, ...rest);
}
/**
* Finds all data sources that the user might want to filter by.
* Only allows Prometheus and Loki data source types.
@@ -1,5 +1,5 @@
import { PromAlertingRuleState, PromRuleType, isPromAlertingRuleState } from '../../../../types/unified-alerting-dto';
import { getRuleHealth, isPromRuleType } from '../utils/rules';
import { getRuleHealth, getRuleSource, isPromRuleType } from '../utils/rules';
import * as terms from './search.terms';
import {
@@ -23,6 +23,7 @@ export interface RulesFilter {
dashboardUid?: string;
plugins?: 'hide';
contactPoint?: string | null;
ruleSource?: RuleSource;
}
const filterSupportedTerms: FilterSupportedTerm[] = [
@@ -37,6 +38,7 @@ const filterSupportedTerms: FilterSupportedTerm[] = [
FilterSupportedTerm.dashboard,
FilterSupportedTerm.plugins,
FilterSupportedTerm.contactPoint,
FilterSupportedTerm.source,
];
export enum RuleHealth {
@@ -46,6 +48,11 @@ export enum RuleHealth {
Unknown = 'unknown',
}
export enum RuleSource {
Grafana = 'grafana',
DataSource = 'datasource',
}
// Define how to map parsed tokens into the filter object
export function getSearchFilterFromQuery(query: string): RulesFilter {
const filter: RulesFilter = { labels: [], freeFormWords: [], dataSourceNames: [] };
@@ -62,6 +69,7 @@ export function getSearchFilterFromQuery(query: string): RulesFilter {
[terms.DashboardToken]: (value) => (filter.dashboardUid = value),
[terms.PluginsToken]: (value) => (filter.plugins = value === 'hide' ? value : undefined),
[terms.ContactPointToken]: (value) => (filter.contactPoint = value),
[terms.RuleSourceToken]: (value) => (filter.ruleSource = getRuleSource(value)),
[terms.FreeFormExpression]: (value) => filter.freeFormWords.push(value),
};
@@ -107,6 +115,9 @@ export function applySearchFilterToQuery(query: string, filter: RulesFilter): st
if (filter.plugins) {
filterStateArray.push({ type: terms.PluginsToken, value: filter.plugins });
}
if (filter.ruleSource) {
filterStateArray.push({ type: terms.RuleSourceToken, value: filter.ruleSource });
}
if (filter.freeFormWords) {
filterStateArray.push(...filter.freeFormWords.map((word) => ({ type: terms.FreeFormExpression, value: word })));
}
@@ -1,6 +1,6 @@
@top AlertRuleSearch { expression+ }
@dialects { dataSourceFilter, nameSpaceFilter, labelFilter, groupFilter, ruleFilter, stateFilter, typeFilter, healthFilter, dashboardFilter, pluginsFilter, contactPointFilter }
@dialects { dataSourceFilter, nameSpaceFilter, labelFilter, groupFilter, ruleFilter, stateFilter, typeFilter, healthFilter, dashboardFilter, pluginsFilter, contactPointFilter, sourceFilter }
expression { (FilterExpression | FreeFormExpression) expression }
@@ -17,7 +17,8 @@ FilterExpression {
filter<HealthToken> |
filter<DashboardToken> |
filter<PluginsToken> |
filter<ContactPointToken>
filter<ContactPointToken> |
filter<RuleSourceToken>
}
filter<token> { token FilterValue }
@@ -47,6 +48,7 @@ filter<token> { token FilterValue }
DashboardToken[@dialect=dashboardFilter] { filterToken<"dashboard"> }
PluginsToken[@dialect=pluginsFilter] { filterToken<"plugins"> }
ContactPointToken[@dialect=contactPointFilter] { filterToken<"contactPoint"> }
RuleSourceToken[@dialect=sourceFilter] { filterToken<"source"> }
@precedence { DataSourceToken, word }
@precedence { NameSpaceToken, word }
@@ -59,5 +61,6 @@ filter<token> { token FilterValue }
@precedence { DashboardToken, word }
@precedence { PluginsToken, word }
@precedence { ContactPointToken, word }
@precedence { RuleSourceToken, word }
}
File diff suppressed because one or more lines are too long
@@ -13,7 +13,8 @@ export const AlertRuleSearch = 1,
DashboardToken = 12,
PluginsToken = 13,
ContactPointToken = 14,
FreeFormExpression = 15,
RuleSourceToken = 15,
FreeFormExpression = 16,
Dialect_dataSourceFilter = 0,
Dialect_nameSpaceFilter = 1,
Dialect_labelFilter = 2,
@@ -24,4 +25,5 @@ export const AlertRuleSearch = 1,
Dialect_healthFilter = 7,
Dialect_dashboardFilter = 8,
Dialect_pluginsFilter = 9,
Dialect_contactPointFilter = 10;
Dialect_contactPointFilter = 10,
Dialect_sourceFilter = 11;
@@ -16,6 +16,7 @@ const filterTokenToTypeMap: Record<number, string> = {
[terms.DashboardToken]: 'dashboard',
[terms.PluginsToken]: 'plugins',
[terms.ContactPointToken]: 'contactPoint',
[terms.RuleSourceToken]: 'source',
};
// This enum allows to configure parser behavior
@@ -33,6 +34,7 @@ export enum FilterSupportedTerm {
dashboard = 'dashboardFilter',
plugins = 'pluginsFilter',
contactPoint = 'contactPointFilter',
source = 'sourceFilter',
}
export type QueryFilterMapper = Record<number, (filter: string) => void>;
@@ -44,7 +44,7 @@ import {
import { CombinedRuleNamespace } from '../../../../types/unified-alerting';
import { State } from '../components/StateTag';
import { RuleHealth } from '../search/rulesSearchParser';
import { RuleHealth, RuleSource } from '../search/rulesSearchParser';
import { RuleFormType, RuleFormValues } from '../types/rule-form';
import { RULER_NOT_SUPPORTED_MSG } from './constants';
@@ -192,6 +192,16 @@ export function getRuleHealth(health: string): RuleHealth | undefined {
}
}
export function getRuleSource(source: string): RuleSource | undefined {
if (source === 'grafana') {
return RuleSource.Grafana;
}
if (source === 'datasource') {
return RuleSource.DataSource;
}
return undefined;
}
export function getPendingPeriod(rule: CombinedRule): string | undefined {
if (rulerRuleType.any.recordingRule(rule.rulerRule)) {
return undefined;
+5
View File
@@ -2643,6 +2643,10 @@
"placeholder-data-sources": "Select data sources",
"placeholder-labels": "Select labels",
"plugin-rules": "Plugin rules",
"rule-source": {
"datasource": "Data source managed",
"grafana": "Grafana managed"
},
"rule-type": "Rule type",
"rulesSearchInput-placeholder-search": "Search",
"search": "Search",
@@ -2664,6 +2668,7 @@
"namespace": "Folder / Namespace",
"rule-health": "Health",
"rule-name": "Rule name",
"rule-source": "Rule source",
"rule-type": "Type",
"state": "State"
}