mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
b3d0c39f08
commit
43bbe567e7
@ -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>
|
||||
);
|
||||
};
|
@ -19,6 +19,14 @@ jest.mock('@grafana/runtime', () => {
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('./MultipleDataSourcePicker', () => {
|
||||
const original = jest.requireActual('./MultipleDataSourcePicker');
|
||||
return {
|
||||
...original,
|
||||
MultipleDataSourcePicker: () => <></>,
|
||||
};
|
||||
});
|
||||
|
||||
setDataSourceSrv(new MockDataSourceSrv({}));
|
||||
|
||||
const ui = {
|
||||
|
@ -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%"' />
|
||||
|
@ -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');
|
||||
|
@ -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);
|
||||
});
|
||||
};
|
||||
|
@ -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',
|
||||
|
@ -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 });
|
||||
|
@ -4,6 +4,7 @@ export function getFilter(filter: Partial<RulesFilter>): RulesFilter {
|
||||
return {
|
||||
freeFormWords: [],
|
||||
labels: [],
|
||||
dataSourceNames: [],
|
||||
...filter,
|
||||
};
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user