From 345d9f93fe5280ddfa079022f22840406bce23b4 Mon Sep 17 00:00:00 2001 From: Nathan Rodman Date: Thu, 15 Apr 2021 04:53:40 -0700 Subject: [PATCH] Alerting: Filter rules list (#32818) --- .../src/components/DataSourcePicker.tsx | 6 +- .../src/services/dataSourceSrv.ts | 3 + .../alerting/unified/RuleList.test.tsx | 10 +- .../features/alerting/unified/RuleList.tsx | 29 +++-- .../unified/components/rules/RulesFilter.tsx | 122 ++++++++++++++++++ .../rules/SystemOrApplicationRules.tsx | 4 +- .../hooks/useCombinedRuleNamespaces.ts | 2 +- .../unified/hooks/useFilteredRules.ts | 79 ++++++++++++ public/app/features/alerting/unified/mocks.ts | 48 ++++++- .../features/alerting/unified/utils/misc.ts | 11 +- public/app/features/plugins/datasource_srv.ts | 9 +- public/app/types/unified-alerting.ts | 6 + 12 files changed, 312 insertions(+), 17 deletions(-) create mode 100644 public/app/features/alerting/unified/components/rules/RulesFilter.tsx create mode 100644 public/app/features/alerting/unified/hooks/useFilteredRules.ts diff --git a/packages/grafana-runtime/src/components/DataSourcePicker.tsx b/packages/grafana-runtime/src/components/DataSourcePicker.tsx index 82733b654f5..1146c35fe02 100644 --- a/packages/grafana-runtime/src/components/DataSourcePicker.tsx +++ b/packages/grafana-runtime/src/components/DataSourcePicker.tsx @@ -24,6 +24,7 @@ export interface DataSourcePickerProps { mixed?: boolean; dashboard?: boolean; metrics?: boolean; + type?: string | string[]; annotations?: boolean; variables?: boolean; alerting?: boolean; @@ -108,9 +109,10 @@ export class DataSourcePicker extends PureComponent ({ value: ds.name, diff --git a/packages/grafana-runtime/src/services/dataSourceSrv.ts b/packages/grafana-runtime/src/services/dataSourceSrv.ts index 9dde93d8566..d167770f1f2 100644 --- a/packages/grafana-runtime/src/services/dataSourceSrv.ts +++ b/packages/grafana-runtime/src/services/dataSourceSrv.ts @@ -60,6 +60,9 @@ export interface GetDataSourceListFilters { /** apply a function to filter */ filter?: (dataSource: DataSourceInstanceSettings) => boolean; + + /** Only returns datasources matching the specified types (ie. Loki, Prometheus) */ + type?: string | string[]; } let singletonInstance: DataSourceSrv; diff --git a/public/app/features/alerting/unified/RuleList.test.tsx b/public/app/features/alerting/unified/RuleList.test.tsx index f70e5170f9b..654e9bd3c43 100644 --- a/public/app/features/alerting/unified/RuleList.test.tsx +++ b/public/app/features/alerting/unified/RuleList.test.tsx @@ -14,11 +14,13 @@ import { mockPromRecordingRule, mockPromRuleGroup, mockPromRuleNamespace, + MockDataSourceSrv, } from './mocks'; import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; import { SerializedError } from '@reduxjs/toolkit'; import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; import userEvent from '@testing-library/user-event'; +import { setDataSourceSrv } from '@grafana/runtime'; jest.mock('./api/prometheus'); jest.mock('./utils/config'); @@ -66,11 +68,16 @@ const ui = { }; describe('RuleList', () => { - afterEach(() => jest.resetAllMocks()); + afterEach(() => { + jest.resetAllMocks(); + setDataSourceSrv(undefined as any); + }); it('load & show rule groups from multiple cloud data sources', async () => { mocks.getAllDataSourcesMock.mockReturnValue(Object.values(dataSources)); + setDataSourceSrv(new MockDataSourceSrv(dataSources)); + mocks.api.fetchRules.mockImplementation((dataSourceName: string) => { if (dataSourceName === dataSources.prom.name) { return Promise.resolve([ @@ -145,6 +152,7 @@ describe('RuleList', () => { it('expand rule group, rule and alert details', async () => { mocks.getAllDataSourcesMock.mockReturnValue([dataSources.prom]); + setDataSourceSrv(new MockDataSourceSrv({ prom: dataSources.prom })); mocks.api.fetchRules.mockImplementation((dataSourceName: string) => { if (dataSourceName === GRAFANA_RULES_SOURCE_NAME) { return Promise.resolve([]); diff --git a/public/app/features/alerting/unified/RuleList.tsx b/public/app/features/alerting/unified/RuleList.tsx index a9af51b5ad9..e7c9798f7e4 100644 --- a/public/app/features/alerting/unified/RuleList.tsx +++ b/public/app/features/alerting/unified/RuleList.tsx @@ -7,6 +7,7 @@ import { AlertingPageWrapper } from './components/AlertingPageWrapper'; import { NoRulesSplash } from './components/rules/NoRulesCTA'; import { SystemOrApplicationRules } from './components/rules/SystemOrApplicationRules'; import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector'; +import { useFilteredRules } from './hooks/useFilteredRules'; import { fetchAllPromAndRulerRulesAction } from './state/actions'; import { getAllRulesSourceNames, @@ -19,6 +20,7 @@ import { ThresholdRules } from './components/rules/ThresholdRules'; import { useCombinedRuleNamespaces } from './hooks/useCombinedRuleNamespaces'; import { RULE_LIST_POLL_INTERVAL_MS } from './utils/constants'; import { isRulerNotSupportedResponse } from './utils/rules'; +import RulesFilter from './components/rules/RulesFilter'; export const RuleList: FC = () => { const dispatch = useDispatch(); @@ -72,8 +74,9 @@ export const RuleList: FC = () => { const showNewAlertSplash = dispatched && !loading && !haveResults; const combinedNamespaces = useCombinedRuleNamespaces(); + const filteredNamespaces = useFilteredRules(combinedNamespaces); const [thresholdNamespaces, systemNamespaces] = useMemo(() => { - const sorted = combinedNamespaces + const sorted = filteredNamespaces .map((namespace) => ({ ...namespace, groups: namespace.groups.sort((a, b) => a.name.localeCompare(b.name)), @@ -83,7 +86,7 @@ export const RuleList: FC = () => { sorted.filter((ns) => ns.rulesSource === GRAFANA_RULES_SOURCE_NAME), sorted.filter((ns) => isCloudRulesSource(ns.rulesSource)), ]; - }, [combinedNamespaces]); + }, [filteredNamespaces]); return ( @@ -119,12 +122,16 @@ export const RuleList: FC = () => { )} {!showNewAlertSplash && ( -
-
- - - -
+ <> + +
+
+ + )} {showNewAlertSplash && } {haveResults && } @@ -134,6 +141,12 @@ export const RuleList: FC = () => { }; const getStyles = (theme: GrafanaTheme) => ({ + break: css` + width: 100%; + height: 0; + margin-bottom: ${theme.spacing.md}; + border-bottom: solid 1px ${theme.colors.border2}; + `, iconError: css` color: ${theme.palette.red}; margin-right: ${theme.spacing.md}; diff --git a/public/app/features/alerting/unified/components/rules/RulesFilter.tsx b/public/app/features/alerting/unified/components/rules/RulesFilter.tsx new file mode 100644 index 00000000000..fac44ccc10f --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/RulesFilter.tsx @@ -0,0 +1,122 @@ +import React, { FormEvent, useState } from 'react'; +import { Button, Icon, Input, Label, RadioButtonGroup, useStyles } from '@grafana/ui'; +import { DataSourceInstanceSettings, GrafanaTheme } from '@grafana/data'; +import { css, cx } from '@emotion/css'; +import { debounce } from 'lodash'; + +import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; +import { useQueryParams } from 'app/core/hooks/useQueryParams'; +import { getFiltersFromUrlParams } from '../../utils/misc'; +import { DataSourcePicker } from '@grafana/runtime'; + +const RulesFilter = () => { + const [queryParams, setQueryParams] = useQueryParams(); + // This key is used to force a rerender on the inputs when the filters are cleared + const [filterKey, setFilterKey] = useState(Math.floor(Math.random() * 100)); + const dataSourceKey = `dataSource-${filterKey}`; + const queryStringKey = `queryString-${filterKey}`; + + const { dataSource, alertState, queryString } = getFiltersFromUrlParams(queryParams); + + const styles = useStyles(getStyles); + const stateOptions = Object.entries(PromAlertingRuleState).map(([key, value]) => ({ label: key, value })); + + const handleDataSourceChange = (dataSourceValue: DataSourceInstanceSettings) => { + setQueryParams({ dataSource: dataSourceValue.name }); + }; + + const handleQueryStringChange = debounce((e: FormEvent) => { + const target = e.target as HTMLInputElement; + setQueryParams({ queryString: target.value || null }); + }, 600); + + const handleAlertStateChange = (value: string) => { + setQueryParams({ alertState: value }); + }; + + const handleClearFiltersClick = () => { + setQueryParams({ + alertState: null, + queryString: null, + dataSource: null, + }); + setFilterKey(filterKey + 1); + }; + + const searchIcon = ; + return ( +
+
+ + +
+
+
+
+ + +
+
+ +
+
+ {(dataSource || alertState || queryString) && ( +
+ +
+ )} +
+
+ ); +}; + +const getStyles = (theme: GrafanaTheme) => { + return { + container: css` + display: flex; + flex-direction: column; + border-bottom: 1px solid ${theme.colors.border1}; + padding-bottom: ${theme.spacing.sm}; + + & > div { + margin-bottom: ${theme.spacing.sm}; + } + `, + inputWidth: css` + width: 340px; + flex-grow: 0; + `, + flexRow: css` + display: flex; + flex-direction: row; + align-items: flex-end; + `, + spaceBetween: css` + justify-content: space-between; + `, + rowChild: css` + & + & { + margin-left: ${theme.spacing.sm}; + } + `, + clearButton: css` + align-self: flex-end; + `, + }; +}; + +export default RulesFilter; diff --git a/public/app/features/alerting/unified/components/rules/SystemOrApplicationRules.tsx b/public/app/features/alerting/unified/components/rules/SystemOrApplicationRules.tsx index 2c1d5ee8f69..0a04c1d1863 100644 --- a/public/app/features/alerting/unified/components/rules/SystemOrApplicationRules.tsx +++ b/public/app/features/alerting/unified/components/rules/SystemOrApplicationRules.tsx @@ -36,7 +36,7 @@ export const SystemOrApplicationRules: FC = ({ namespaces }) => { )}
- {namespaces.map(({ rulesSource, name, groups }) => + {namespaces?.map(({ rulesSource, name, groups }) => groups.map((group) => ( = ({ namespaces }) => { /> )) )} - {namespaces?.length === 0 && !!rulesDataSources.length &&

No rules found.

} + {namespaces?.length === 0 && !dataSourcesLoading.length && !!rulesDataSources.length &&

No rules found.

} {!rulesDataSources.length &&

There are no Prometheus or Loki datas sources configured.

} ); diff --git a/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts b/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts index 722c9702619..6e72a873cb8 100644 --- a/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts +++ b/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts @@ -87,7 +87,7 @@ export function useCombinedRuleNamespaces(): CombinedRuleNamespace[] { ns.groups.push(combinedGroup); } - group.rules.forEach((rule) => { + (group.rules ?? []).forEach((rule) => { const existingRule = combinedGroup!.rules.find((existingRule) => { return !existingRule.promRule && isCombinedRuleEqualToPromRule(existingRule, rule); }); diff --git a/public/app/features/alerting/unified/hooks/useFilteredRules.ts b/public/app/features/alerting/unified/hooks/useFilteredRules.ts new file mode 100644 index 00000000000..d9538c2a15c --- /dev/null +++ b/public/app/features/alerting/unified/hooks/useFilteredRules.ts @@ -0,0 +1,79 @@ +import { useMemo } from 'react'; + +import { CombinedRuleGroup, CombinedRuleNamespace, RuleFilterState } from 'app/types/unified-alerting'; +import { isCloudRulesSource } from '../utils/datasource'; +import { isAlertingRule } from '../utils/rules'; +import { getFiltersFromUrlParams } from '../utils/misc'; +import { useQueryParams } from 'app/core/hooks/useQueryParams'; + +export const useFilteredRules = (namespaces: CombinedRuleNamespace[]) => { + const [queryParams] = useQueryParams(); + const filters = getFiltersFromUrlParams(queryParams); + + return useMemo(() => { + if (!filters.queryString && !filters.dataSource && !filters.alertState) { + return namespaces; + } + const filteredNamespaces = namespaces + // Filter by data source + // TODO: filter by multiple data sources for grafana-managed alerts + .filter(({ rulesSource }) => + filters.dataSource && isCloudRulesSource(rulesSource) ? rulesSource.name === filters.dataSource : true + ) + // If a namespace and group have rules that match the rules filters then keep them. + .reduce(reduceNamespaces(filters), [] as CombinedRuleNamespace[]); + return filteredNamespaces; + }, [namespaces, filters]); +}; + +const reduceNamespaces = (filters: RuleFilterState) => { + return (namespaceAcc: CombinedRuleNamespace[], namespace: CombinedRuleNamespace) => { + const groups = namespace.groups.reduce(reduceGroups(filters), [] as CombinedRuleGroup[]); + + if (groups.length) { + namespaceAcc.push({ + ...namespace, + groups, + }); + } + + return namespaceAcc; + }; +}; + +// Reduces groups to only groups that have rules matching the filters +const reduceGroups = (filters: RuleFilterState) => { + return (groupAcc: CombinedRuleGroup[], group: CombinedRuleGroup) => { + const rules = group.rules.filter((rule) => { + let shouldKeep = true; + // Query strings can match alert name, label keys, and label values + if (filters.queryString) { + const normalizedQueryString = filters.queryString.toLocaleLowerCase(); + const doesNameContainsQueryString = rule.name?.toLocaleLowerCase().includes(normalizedQueryString); + + const doLabelsContainQueryString = Object.entries(rule.labels || {}).some( + ([key, value]) => + key.toLocaleLowerCase().includes(normalizedQueryString) || + value.toLocaleLowerCase().includes(normalizedQueryString) + ); + shouldKeep = doesNameContainsQueryString || doLabelsContainQueryString; + } + if (filters.alertState) { + const matchesAlertState = Boolean( + rule.promRule && isAlertingRule(rule.promRule) && rule.promRule.state === filters.alertState + ); + + shouldKeep = shouldKeep && matchesAlertState; + } + return shouldKeep; + }); + // Add rules to the group that match the rule list filters + if (rules.length) { + groupAcc.push({ + ...group, + rules, + }); + } + return groupAcc; + }; +}; diff --git a/public/app/features/alerting/unified/mocks.ts b/public/app/features/alerting/unified/mocks.ts index a80c6d526d3..4271faef2c6 100644 --- a/public/app/features/alerting/unified/mocks.ts +++ b/public/app/features/alerting/unified/mocks.ts @@ -1,6 +1,8 @@ -import { DataSourceInstanceSettings, DataSourcePluginMeta } from '@grafana/data'; +import { DataSourceApi, DataSourceInstanceSettings, DataSourcePluginMeta, ScopedVars } from '@grafana/data'; import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto'; import { AlertingRule, Alert, RecordingRule, RuleGroup, RuleNamespace } from 'app/types/unified-alerting'; +import DatasourceSrv from 'app/features/plugins/datasource_srv'; +import { DataSourceSrv, GetDataSourceListFilters } from '@grafana/runtime'; let nextDataSourceId = 1; @@ -91,3 +93,47 @@ export const mockPromRuleNamespace = (partial: Partial = {}): Rul ...partial, }; }; + +export class MockDataSourceSrv implements DataSourceSrv { + // @ts-ignore + private settingsMapByName: Record = {}; + private settingsMapByUid: Record = {}; + private settingsMapById: Record = {}; + // @ts-ignore + private templateSrv = { + getVariables: () => [], + replace: (name: any) => name, + }; + + constructor(datasources: Record) { + this.settingsMapByName = Object.values(datasources).reduce>( + (acc, ds) => { + acc[ds.name] = ds; + return acc; + }, + {} + ); + for (const dsSettings of Object.values(this.settingsMapByName)) { + this.settingsMapByUid[dsSettings.uid] = dsSettings; + this.settingsMapById[dsSettings.id] = dsSettings; + } + } + + get(name?: string | null, scopedVars?: ScopedVars): Promise { + return Promise.reject(new Error('not implemented')); + } + + /** + * Get a list of data sources + */ + getList(filters?: GetDataSourceListFilters): DataSourceInstanceSettings[] { + return DatasourceSrv.prototype.getList.call(this, filters); + } + + /** + * Get settings and plugin metadata by name or uid + */ + getInstanceSettings(nameOrUid: string | null | undefined): DataSourceInstanceSettings | undefined { + return DatasourceSrv.prototype.getInstanceSettings.call(this, nameOrUid) || { meta: { info: { logos: {} } } }; + } +} diff --git a/public/app/features/alerting/unified/utils/misc.ts b/public/app/features/alerting/unified/utils/misc.ts index 6fd30967ff2..25910a249a9 100644 --- a/public/app/features/alerting/unified/utils/misc.ts +++ b/public/app/features/alerting/unified/utils/misc.ts @@ -1,5 +1,6 @@ import { config } from '@grafana/runtime'; -import { urlUtil } from '@grafana/data'; +import { urlUtil, UrlQueryMap } from '@grafana/data'; +import { RuleFilterState } from 'app/types/unified-alerting'; export function createExploreLink(dataSourceName: string, query: string) { return urlUtil.renderUrl(config.appSubUrl + '/explore', { @@ -33,3 +34,11 @@ export function arrayToRecord(items: Array<{ key: string; value: string }>): Rec return rec; }, {}); } + +export const getFiltersFromUrlParams = (queryParams: UrlQueryMap): RuleFilterState => { + const queryString = queryParams['queryString'] === undefined ? undefined : String(queryParams['queryString']); + const alertState = queryParams['alertState'] === undefined ? undefined : String(queryParams['alertState']); + const dataSource = queryParams['dataSource'] === undefined ? undefined : String(queryParams['dataSource']); + + return { queryString, alertState, dataSource }; +}; diff --git a/public/app/features/plugins/datasource_srv.ts b/public/app/features/plugins/datasource_srv.ts index 4c9a37df68c..f2ef1c25b7e 100644 --- a/public/app/features/plugins/datasource_srv.ts +++ b/public/app/features/plugins/datasource_srv.ts @@ -173,18 +173,25 @@ export class DatasourceSrv implements DataSourceService { if (filters.annotations && !x.meta.annotations) { return false; } + if (filters.alerting && !x.meta.alerting) { + return false; + } if (filters.pluginId && x.meta.id !== filters.pluginId) { return false; } if (filters.filter && !filters.filter(x)) { return false; } + if (filters.type && (Array.isArray(filters.type) ? !filters.type.includes(x.type) : filters.type !== x.type)) { + return false; + } if ( !filters.all && x.meta.metrics !== true && x.meta.annotations !== true && x.meta.tracing !== true && - x.meta.logs !== true + x.meta.logs !== true && + x.meta.alerting !== true ) { return false; } diff --git a/public/app/types/unified-alerting.ts b/public/app/types/unified-alerting.ts index bb0c2a9062e..4f4b46fa42f 100644 --- a/public/app/types/unified-alerting.ts +++ b/public/app/types/unified-alerting.ts @@ -91,3 +91,9 @@ export interface RuleLocation { groupName: string; ruleHash: number; } + +export interface RuleFilterState { + queryString?: string; + dataSource?: string; + alertState?: string; +}