diff --git a/public/app/features/alerting/unified/components/rules/MultipleDataSourcePicker.tsx b/public/app/features/alerting/unified/components/rules/MultipleDataSourcePicker.tsx new file mode 100644 index 00000000000..4e6942011b0 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/MultipleDataSourcePicker.tsx @@ -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 { + onChange: (ds: DataSourceInstanceSettings, action: 'add' | 'remove') => void; + current: string[] | undefined; +} + +export const MultipleDataSourcePicker = (props: MultipleDataSourcePickerProps) => { + const dataSourceSrv = getDataSourceSrv(); + + const [state, setState] = useState(); + + const onChange = (items: Array>, 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> + | PopValueActionMeta> = 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> | 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 ( +
+ { + if (o.meta && isUnsignedPluginSignature(o.meta.signature) && o !== value) { + return ( + + {o.label} + + ); + } + return o.label || ''; + }} + /> +
+ ); +}; diff --git a/public/app/features/alerting/unified/components/rules/RulesFilter.test.tsx b/public/app/features/alerting/unified/components/rules/RulesFilter.test.tsx index 536a496b198..a8818b2158d 100644 --- a/public/app/features/alerting/unified/components/rules/RulesFilter.test.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesFilter.test.tsx @@ -19,6 +19,14 @@ jest.mock('@grafana/runtime', () => { }; }); +jest.mock('./MultipleDataSourcePicker', () => { + const original = jest.requireActual('./MultipleDataSourcePicker'); + return { + ...original, + MultipleDataSourcePicker: () => <>, + }; +}); + setDataSourceSrv(new MockDataSourceSrv({})); const ui = { diff --git a/public/app/features/alerting/unified/components/rules/RulesFilter.tsx b/public/app/features/alerting/unified/components/rules/RulesFilter.tsx index 829dfe6f5fa..b38e4ac6218 100644 --- a/public/app/features/alerting/unified/components/rules/RulesFilter.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesFilter.tsx @@ -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) =>
- - + + Search by data sources + +

+ Data sources containing configured alert rules are Mimir or Loki data sources where alert + rules are stored and evaluated in the data source itself. +

+

+ 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. +

+
+ } + > + + + + + } + > + +
{ margin-bottom: ${theme.spacing(1)}; `, dsPickerContainer: css` - width: 250px; + width: 550px; flex-grow: 0; margin: 0; `, @@ -236,7 +273,7 @@ function SearchQueryHelp() {
Filter type
Expression
- + diff --git a/public/app/features/alerting/unified/hooks/useFilteredRules.test.ts b/public/app/features/alerting/unified/hooks/useFilteredRules.test.ts index 2bda8046d5b..d0e7a36f9df 100644 --- a/public/app/features/alerting/unified/hooks/useFilteredRules.test.ts +++ b/public/app/features/alerting/unified/hooks/useFilteredRules.test.ts @@ -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'); diff --git a/public/app/features/alerting/unified/hooks/useFilteredRules.ts b/public/app/features/alerting/unified/hooks/useFilteredRules.ts index 75cc2226bee..2979c6a78cc 100644 --- a/public/app/features/alerting/unified/hooks/useFilteredRules.ts +++ b/public/app/features/alerting/unified/hooks/useFilteredRules.ts @@ -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); }); }; diff --git a/public/app/features/alerting/unified/search/rulesSearchParser.test.ts b/public/app/features/alerting/unified/search/rulesSearchParser.test.ts index e32aa102e23..54c5eb02b10 100644 --- a/public/app/features/alerting/unified/search/rulesSearchParser.test.ts +++ b/public/app/features/alerting/unified/search/rulesSearchParser.test.ts @@ -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', diff --git a/public/app/features/alerting/unified/search/rulesSearchParser.ts b/public/app/features/alerting/unified/search/rulesSearchParser.ts index e70e00ecc8d..8e9b6157180 100644 --- a/public/app/features/alerting/unified/search/rulesSearchParser.ts +++ b/public/app/features/alerting/unified/search/rulesSearchParser.ts @@ -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 }); diff --git a/public/app/features/alerting/unified/utils/search.ts b/public/app/features/alerting/unified/utils/search.ts index 846da556b01..77dc99c2f7c 100644 --- a/public/app/features/alerting/unified/utils/search.ts +++ b/public/app/features/alerting/unified/utils/search.ts @@ -4,6 +4,7 @@ export function getFilter(filter: Partial): RulesFilter { return { freeFormWords: [], labels: [], + dataSourceNames: [], ...filter, }; }