Alerting: Support filtering rules by multiple datasources (#64355)

* Support having a datasources array in the rules filter

* Fix tests

* Display a MultiplePicker for filtering datasources

* Fix tests

* Refactor as MultipleDataSourcePicker as FC

* Make select box wider

* Remove FC from component definition

* Display ds options in groups based on whether they manage/don't manage rules

* Change dropdown texts and add help info
This commit is contained in:
Virginia Cepeda 2023-03-22 12:02:56 -03:00 committed by GitHub
parent b3d0c39f08
commit 43bbe567e7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 258 additions and 31 deletions

View File

@ -0,0 +1,181 @@
import React, { useState } from 'react';
import { PopValueActionMeta, RemoveValueActionMeta } from 'react-select';
import {
DataSourceInstanceSettings,
getDataSourceUID,
isUnsignedPluginSignature,
SelectableValue,
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { getDataSourceSrv, DataSourcePickerState, DataSourcePickerProps } from '@grafana/runtime';
import { ExpressionDatasourceRef } from '@grafana/runtime/src/utils/DataSourceWithBackend';
import { ActionMeta, HorizontalGroup, PluginSignatureBadge, MultiSelect } from '@grafana/ui';
export interface MultipleDataSourcePickerProps extends Omit<DataSourcePickerProps, 'onChange' | 'current'> {
onChange: (ds: DataSourceInstanceSettings, action: 'add' | 'remove') => void;
current: string[] | undefined;
}
export const MultipleDataSourcePicker = (props: MultipleDataSourcePickerProps) => {
const dataSourceSrv = getDataSourceSrv();
const [state, setState] = useState<DataSourcePickerState>();
const onChange = (items: Array<SelectableValue<string>>, actionMeta: ActionMeta) => {
if (actionMeta.action === 'clear' && props.onClear) {
props.onClear();
return;
}
const selectedItem = items[items.length - 1];
let dataSourceName, action: 'add' | 'remove';
if (actionMeta.action === 'pop-value' || actionMeta.action === 'remove-value') {
const castedActionMeta:
| RemoveValueActionMeta<SelectableValue<string>>
| PopValueActionMeta<SelectableValue<string>> = actionMeta;
dataSourceName = castedActionMeta.removedValue?.value;
action = 'remove';
} else {
dataSourceName = selectedItem.value;
action = 'add';
}
const dsSettings = dataSourceSrv.getInstanceSettings(dataSourceName);
if (dsSettings) {
props.onChange(dsSettings, action);
setState({ error: undefined });
}
};
const getCurrentValue = (): Array<SelectableValue<string>> | undefined => {
const { current, hideTextValue, noDefault } = props;
if (!current && noDefault) {
return;
}
return current?.map((dataSourceName: string) => {
const ds = dataSourceSrv.getInstanceSettings(dataSourceName);
if (ds) {
return {
label: ds.name.slice(0, 37),
value: ds.name,
imgUrl: ds.meta.info.logos.small,
hideText: hideTextValue,
meta: ds.meta,
};
}
const uid = getDataSourceUID(dataSourceName);
if (uid === ExpressionDatasourceRef.uid || uid === ExpressionDatasourceRef.name) {
return { label: uid, value: uid, hideText: hideTextValue };
}
return {
label: (uid ?? 'no name') + ' - not found',
value: uid ?? undefined,
imgUrl: '',
hideText: hideTextValue,
};
});
};
const getDataSourceOptions = () => {
const { alerting, tracing, metrics, mixed, dashboard, variables, annotations, pluginId, type, filter, logs } =
props;
const dataSources = dataSourceSrv.getList({
alerting,
tracing,
metrics,
logs,
dashboard,
mixed,
variables,
annotations,
pluginId,
filter,
type,
});
const alertManagingDs = dataSources
.filter((ds) => ds.jsonData.manageAlerts)
.map((ds) => ({
value: ds.name,
label: `${ds.name}${ds.isDefault ? ' (default)' : ''}`,
imgUrl: ds.meta.info.logos.small,
meta: ds.meta,
}));
const nonAlertManagingDs = dataSources
.filter((ds) => !ds.jsonData.manageAlerts)
.map((ds) => ({
value: ds.name,
label: `${ds.name}${ds.isDefault ? ' (default)' : ''}`,
imgUrl: ds.meta.info.logos.small,
meta: ds.meta,
}));
const groupedOptions = [
{ label: 'Data sources with configured alert rules', options: alertManagingDs, expanded: true },
{ label: 'Other data sources', options: nonAlertManagingDs, expanded: true },
];
return groupedOptions;
};
const {
autoFocus,
onBlur,
onClear,
openMenuOnFocus,
placeholder,
width,
inputId,
disabled = false,
isLoading = false,
} = props;
const options = getDataSourceOptions();
const value = getCurrentValue();
const isClearable = typeof onClear === 'function';
return (
<div data-testid={selectors.components.DataSourcePicker.container}>
<MultiSelect
isLoading={isLoading}
disabled={disabled}
data-testid={selectors.components.DataSourcePicker.inputV2}
inputId={inputId || 'data-source-picker'}
className="ds-picker select-container"
isClearable={isClearable}
backspaceRemovesValue={true}
onChange={onChange}
options={options}
autoFocus={autoFocus}
onBlur={onBlur}
width={width}
openMenuOnFocus={openMenuOnFocus}
maxMenuHeight={500}
placeholder={placeholder}
noOptionsMessage="No datasources found"
value={value ?? []}
invalid={Boolean(state?.error) || Boolean(props.invalid)}
getOptionLabel={(o) => {
if (o.meta && isUnsignedPluginSignature(o.meta.signature) && o !== value) {
return (
<HorizontalGroup align="center" justify="space-between" height="auto">
<span>{o.label}</span> <PluginSignatureBadge status={o.meta.signature} />
</HorizontalGroup>
);
}
return o.label || '';
}}
/>
</div>
);
};

View File

@ -19,6 +19,14 @@ jest.mock('@grafana/runtime', () => {
};
});
jest.mock('./MultipleDataSourcePicker', () => {
const original = jest.requireActual('./MultipleDataSourcePicker');
return {
...original,
MultipleDataSourcePicker: () => <></>,
};
});
setDataSourceSrv(new MockDataSourceSrv({}));
const ui = {

View File

@ -4,8 +4,8 @@ import { useForm } from 'react-hook-form';
import { DataSourceInstanceSettings, GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { DataSourcePicker, logInfo } from '@grafana/runtime';
import { Button, Field, Icon, Input, Label, RadioButtonGroup, useStyles2 } from '@grafana/ui';
import { logInfo } from '@grafana/runtime';
import { Button, Field, Icon, Input, Label, RadioButtonGroup, Tooltip, useStyles2 } from '@grafana/ui';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto';
@ -15,6 +15,8 @@ import { RuleHealth } from '../../search/rulesSearchParser';
import { alertStateToReadable } from '../../utils/rules';
import { HoverCard } from '../HoverCard';
import { MultipleDataSourcePicker } from './MultipleDataSourcePicker';
const ViewOptions: SelectableValue[] = [
{
icon: 'folder',
@ -77,13 +79,22 @@ const RulesFilter = ({ onFilterCleared = () => undefined }: RulesFilerProps) =>
setValue('searchQuery', searchQuery);
}, [searchQuery, setValue]);
const handleDataSourceChange = (dataSourceValue: DataSourceInstanceSettings) => {
updateFilters({ ...filterState, dataSourceName: dataSourceValue.name });
const handleDataSourceChange = (dataSourceValue: DataSourceInstanceSettings, action: 'add' | 'remove') => {
const dataSourceNames =
action === 'add'
? [...filterState.dataSourceNames].concat([dataSourceValue.name])
: filterState.dataSourceNames.filter((name) => name !== dataSourceValue.name);
updateFilters({
...filterState,
dataSourceNames,
});
setFilterKey((key) => key + 1);
};
const clearDataSource = () => {
updateFilters({ ...filterState, dataSourceName: undefined });
updateFilters({ ...filterState, dataSourceNames: [] });
setFilterKey((key) => key + 1);
};
@ -119,17 +130,43 @@ const RulesFilter = ({ onFilterCleared = () => undefined }: RulesFilerProps) =>
<div className={styles.container}>
<Stack direction="column" gap={1}>
<Stack direction="row" gap={1}>
<Field className={styles.dsPickerContainer} label="Search by data source">
<DataSourcePicker
<Field
className={styles.dsPickerContainer}
label={
<Label htmlFor="data-source-picker">
<Stack gap={0.5}>
<span>Search by data sources</span>
<Tooltip
content={
<div>
<p>
Data sources containing configured alert rules are Mimir or Loki data sources where alert
rules are stored and evaluated in the data source itself.
</p>
<p>
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.
</p>
</div>
}
>
<Icon name="info-circle" size="sm" />
</Tooltip>
</Stack>
</Label>
}
>
<MultipleDataSourcePicker
key={dataSourceKey}
alerting
noDefault
placeholder="All data sources"
current={filterState.dataSourceName}
current={filterState.dataSourceNames}
onChange={handleDataSourceChange}
onClear={clearDataSource}
/>
</Field>
<div>
<Label>State</Label>
<RadioButtonGroup
@ -215,7 +252,7 @@ const getStyles = (theme: GrafanaTheme2) => {
margin-bottom: ${theme.spacing(1)};
`,
dsPickerContainer: css`
width: 250px;
width: 550px;
flex-grow: 0;
margin: 0;
`,
@ -236,7 +273,7 @@ function SearchQueryHelp() {
<div className={styles.grid}>
<div>Filter type</div>
<div>Expression</div>
<HelpRow title="Datasource" expr="datasource:mimir" />
<HelpRow title="Datasources" expr="datasource:mimir datasource:prometheus" />
<HelpRow title="Folder/Namespace" expr="namespace:global" />
<HelpRow title="Group" expr="group:cpu-usage" />
<HelpRow title="Rule" expr='rule:"cpu 80%"' />

View File

@ -160,7 +160,7 @@ describe('filterRules', function () {
groups: [mockCombinedRuleGroup('Resources usage group', rules)],
});
const filtered = filterRules([ns], getFilter({ dataSourceName: 'loki' }));
const filtered = filterRules([ns], getFilter({ dataSourceNames: ['loki'] }));
expect(filtered[0].groups[0].rules).toHaveLength(1);
expect(filtered[0].groups[0].rules[0].name).toBe('Memory too low');

View File

@ -52,7 +52,7 @@ export function useRulesFilter() {
// Existing query filters takes precedence over legacy ones
updateFilters(
produce(filterState, (draft) => {
draft.dataSourceName ??= legacyFilters.dataSource;
draft.dataSourceNames ??= legacyFilters.dataSource ? [legacyFilters.dataSource] : [];
if (legacyFilters.alertState && isPromAlertingRuleState(legacyFilters.alertState)) {
draft.ruleState ??= legacyFilters.alertState;
}
@ -90,14 +90,14 @@ const ufuzzy = new uFuzzy({
export const filterRules = (
namespaces: CombinedRuleNamespace[],
filterState: RulesFilter = { labels: [], freeFormWords: [] }
filterState: RulesFilter = { dataSourceNames: [], labels: [], freeFormWords: [] }
): CombinedRuleNamespace[] => {
let filteredNamespaces = namespaces;
const dataSourceFilter = filterState.dataSourceName;
if (dataSourceFilter) {
const dataSourceFilter = filterState.dataSourceNames;
if (dataSourceFilter.length) {
filteredNamespaces = filteredNamespaces.filter(({ rulesSource }) =>
isCloudRulesSource(rulesSource) ? rulesSource.name === dataSourceFilter : true
isCloudRulesSource(rulesSource) ? dataSourceFilter.includes(rulesSource.name) : true
);
}
@ -168,7 +168,7 @@ const reduceGroups = (filterState: RulesFilter) => {
}
const doesNotQueryDs = isGrafanaRulerRule(rule.rulerRule) && !isQueryingDataSource(rule.rulerRule, filterState);
if (filterState.dataSourceName && doesNotQueryDs) {
if (filterState.dataSourceNames?.length && doesNotQueryDs) {
return false;
}
@ -223,7 +223,7 @@ function looseParseMatcher(matcherQuery: string): Matcher | undefined {
}
const isQueryingDataSource = (rulerRule: RulerGrafanaRuleDTO, filterState: RulesFilter): boolean => {
if (!filterState.dataSourceName) {
if (!filterState.dataSourceNames?.length) {
return true;
}
@ -232,6 +232,6 @@ const isQueryingDataSource = (rulerRule: RulerGrafanaRuleDTO, filterState: Rules
return false;
}
const ds = getDataSourceSrv().getInstanceSettings(query.datasourceUid);
return ds?.name === filterState.dataSourceName;
return ds?.name && filterState?.dataSourceNames?.includes(ds.name);
});
};

View File

@ -7,7 +7,7 @@ describe('Alert rules searchParser', () => {
describe('getSearchFilterFromQuery', () => {
it.each(['datasource:prometheus'])('should parse data source filter from "%s" query', (query) => {
const filter = getSearchFilterFromQuery(query);
expect(filter.dataSourceName).toBe('prometheus');
expect(filter.dataSourceNames).toEqual(['prometheus']);
});
it.each(['namespace:integrations-node'])('should parse namespace filter from "%s" query', (query) => {
@ -79,7 +79,7 @@ describe('Alert rules searchParser', () => {
'datasource:"prom dev" namespace:"node one" label:"team=frontend us" group:"cpu alerts" rule:"cpu failure"';
const filter = getSearchFilterFromQuery(query);
expect(filter.dataSourceName).toBe('prom dev');
expect(filter.dataSourceNames).toEqual(['prom dev']);
expect(filter.namespace).toBe('node one');
expect(filter.labels).toContain('team=frontend us');
expect(filter.groupName).toContain('cpu alerts');
@ -91,7 +91,7 @@ describe('Alert rules searchParser', () => {
'datasource:prom::dev/linux>>; namespace:"[{node}] (#20+)" label:_region=apac|emea\\nasa group:$20.00%$ rule:"cpu!! & memory.,?"';
const filter = getSearchFilterFromQuery(query);
expect(filter.dataSourceName).toBe('prom::dev/linux>>;');
expect(filter.dataSourceNames).toEqual(['prom::dev/linux>>;']);
expect(filter.namespace).toBe('[{node}] (#20+)');
expect(filter.labels).toContain('_region=apac|emea\\nasa');
expect(filter.groupName).toContain('$20.00%$');
@ -110,7 +110,7 @@ describe('Alert rules searchParser', () => {
const query = 'datasource:prometheus utilization label:team cpu';
const filter = getSearchFilterFromQuery(query);
expect(filter.dataSourceName).toBe('prometheus');
expect(filter.dataSourceNames).toEqual(['prometheus']);
expect(filter.labels).toContain('team');
expect(filter.freeFormWords).toContain('utilization');
expect(filter.freeFormWords).toContain('cpu');
@ -130,7 +130,7 @@ describe('Alert rules searchParser', () => {
it('should apply filters to an empty query', () => {
const filter = getFilter({
freeFormWords: ['cpu', 'eighty'],
dataSourceName: 'Mimir Dev',
dataSourceNames: ['Mimir Dev'],
namespace: '/etc/prometheus',
labels: ['team', 'region=apac'],
groupName: 'cpu-usage',
@ -149,7 +149,7 @@ describe('Alert rules searchParser', () => {
it('should update filters in existing query', () => {
const filter = getFilter({
dataSourceName: 'Mimir Dev',
dataSourceNames: ['Mimir Dev'],
namespace: '/etc/prometheus',
labels: ['team', 'region=apac'],
groupName: 'cpu-usage',
@ -166,7 +166,7 @@ describe('Alert rules searchParser', () => {
it('should preserve the order of parameters when updating', () => {
const filter = getFilter({
dataSourceName: 'Mimir Dev',
dataSourceNames: ['Mimir Dev'],
namespace: '/etc/prometheus',
labels: ['region=emea'],
groupName: 'cpu-usage',

View File

@ -17,7 +17,7 @@ export interface RulesFilter {
ruleName?: string;
ruleState?: PromAlertingRuleState;
ruleType?: PromRuleType;
dataSourceName?: string;
dataSourceNames: string[];
labels: string[];
ruleHealth?: RuleHealth;
}
@ -42,10 +42,10 @@ export enum RuleHealth {
// Define how to map parsed tokens into the filter object
export function getSearchFilterFromQuery(query: string): RulesFilter {
const filter: RulesFilter = { labels: [], freeFormWords: [] };
const filter: RulesFilter = { labels: [], freeFormWords: [], dataSourceNames: [] };
const tokenToFilterMap: QueryFilterMapper = {
[terms.DataSourceToken]: (value) => (filter.dataSourceName = value),
[terms.DataSourceToken]: (value) => filter.dataSourceNames.push(value),
[terms.NameSpaceToken]: (value) => (filter.namespace = value),
[terms.GroupToken]: (value) => (filter.groupName = value),
[terms.RuleToken]: (value) => (filter.ruleName = value),
@ -68,8 +68,8 @@ export function applySearchFilterToQuery(query: string, filter: RulesFilter): st
// Convert filter object into an array
// It allows to pick filters from the array in the same order as they were applied in the original query
if (filter.dataSourceName) {
filterStateArray.push({ type: terms.DataSourceToken, value: filter.dataSourceName });
if (filter.dataSourceNames) {
filterStateArray.push(...filter.dataSourceNames.map((t) => ({ type: terms.DataSourceToken, value: t })));
}
if (filter.namespace) {
filterStateArray.push({ type: terms.NameSpaceToken, value: filter.namespace });

View File

@ -4,6 +4,7 @@ export function getFilter(filter: Partial<RulesFilter>): RulesFilter {
return {
freeFormWords: [],
labels: [],
dataSourceNames: [],
...filter,
};
}