From 3e124c854e109214a12b241624bde324cf856a15 Mon Sep 17 00:00:00 2001 From: Domas Date: Tue, 10 Aug 2021 10:59:48 +0300 Subject: [PATCH] Alerting: interpolate variables when creating alert rule from dashboard panel (#37201) --- pkg/expr/nodes.go | 4 +- .../unified/PanelAlertTabContent.test.tsx | 273 ++++++++++++++++++ .../NewRuleFromPanelButton.tsx | 10 +- public/app/features/alerting/unified/mocks.ts | 44 ++- .../alerting/unified/utils/rule-form.ts | 98 +++++-- .../plugins/datasource/influxdb/datasource.ts | 2 +- public/app/types/unified-alerting-dto.ts | 7 +- 7 files changed, 401 insertions(+), 37 deletions(-) create mode 100644 public/app/features/alerting/unified/PanelAlertTabContent.test.tsx diff --git a/pkg/expr/nodes.go b/pkg/expr/nodes.go index f88d054e0a9..e8914c0e7ff 100644 --- a/pkg/expr/nodes.go +++ b/pkg/expr/nodes.go @@ -186,7 +186,7 @@ func (s *Service) buildDSNode(dp *simple.DirectedGraph, rn *rawNode, req *Reques } var floatIntervalMS float64 - if rawIntervalMS := rn.Query["intervalMs"]; ok { + if rawIntervalMS, ok := rn.Query["intervalMs"]; ok { if floatIntervalMS, ok = rawIntervalMS.(float64); !ok { return nil, fmt.Errorf("expected intervalMs to be an float64, got type %T for refId %v", rawIntervalMS, rn.RefID) } @@ -194,7 +194,7 @@ func (s *Service) buildDSNode(dp *simple.DirectedGraph, rn *rawNode, req *Reques } var floatMaxDP float64 - if rawMaxDP := rn.Query["maxDataPoints"]; ok { + if rawMaxDP, ok := rn.Query["maxDataPoints"]; ok { if floatMaxDP, ok = rawMaxDP.(float64); !ok { return nil, fmt.Errorf("expected maxDataPoints to be an float64, got type %T for refId %v", rawMaxDP, rn.RefID) } diff --git a/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx b/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx new file mode 100644 index 00000000000..f584e1d80c0 --- /dev/null +++ b/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx @@ -0,0 +1,273 @@ +import React from 'react'; +import { locationService, setDataSourceSrv } from '@grafana/runtime'; +import { configureStore } from 'app/store/configureStore'; +import { Provider } from 'react-redux'; +import { Router } from 'react-router-dom'; +import { render } from '@testing-library/react'; +import { PanelAlertTabContent } from './PanelAlertTabContent'; +import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; +import { + mockDataSource, + MockDataSourceSrv, + mockPromAlertingRule, + mockPromRuleGroup, + mockPromRuleNamespace, + mockRulerGrafanaRule, +} from './mocks'; +import { DataSourceType } from './utils/datasource'; +import { typeAsJestMock } from 'test/helpers/typeAsJestMock'; +import { getAllDataSources } from './utils/config'; +import { fetchRules } from './api/prometheus'; +import { fetchRulerRules } from './api/ruler'; +import { Annotation } from './utils/constants'; +import { byTestId } from 'testing-library-selector'; +import { PrometheusDatasource } from 'app/plugins/datasource/prometheus/datasource'; +import { DataSourceApi } from '@grafana/data'; +import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; + +jest.mock('./api/prometheus'); +jest.mock('./api/ruler'); +jest.mock('./utils/config'); + +const dataSources = { + prometheus: mockDataSource({ + name: 'Prometheus', + type: DataSourceType.Prometheus, + }), +}; +dataSources.prometheus.meta.alerting = true; + +const mocks = { + getAllDataSources: typeAsJestMock(getAllDataSources), + api: { + fetchRules: typeAsJestMock(fetchRules), + fetchRulerRules: typeAsJestMock(fetchRulerRules), + }, +}; + +const renderAlertTabContent = (dashboard: DashboardModel, panel: PanelModel) => { + const store = configureStore(); + + return render( + + + + + + ); +}; + +const rules = [ + mockPromRuleNamespace({ + name: 'default', + groups: [ + mockPromRuleGroup({ + name: 'mygroup', + rules: [ + mockPromAlertingRule({ + name: 'dashboardrule1', + annotations: { + [Annotation.dashboardUID]: '12', + [Annotation.panelID]: '34', + }, + }), + ], + }), + mockPromRuleGroup({ + name: 'othergroup', + rules: [ + mockPromAlertingRule({ + name: 'dashboardrule2', + annotations: { + [Annotation.dashboardUID]: '121', + [Annotation.panelID]: '341', + }, + }), + ], + }), + ], + }), +]; + +const rulerRules = { + default: [ + { + name: 'mygroup', + rules: [ + mockRulerGrafanaRule( + { + annotations: { + [Annotation.dashboardUID]: '12', + [Annotation.panelID]: '34', + }, + }, + { + title: 'dashboardrule1', + } + ), + ], + }, + { + name: 'othergroup', + rules: [ + mockRulerGrafanaRule( + { + annotations: { + [Annotation.dashboardUID]: '121', + [Annotation.panelID]: '341', + }, + }, + { + title: 'dashboardrule2', + } + ), + ], + }, + ], +}; + +const dashboard = { + uid: '12', + time: { + from: 'now-6h', + to: 'now', + }, + meta: { + canSave: true, + folderId: 1, + folderTitle: 'super folder', + }, +} as DashboardModel; +const panel = ({ + datasource: dataSources.prometheus.uid, + title: 'mypanel', + editSourceId: 34, + targets: [ + { + expr: 'sum(some_metric [$__interval])) by (app)', + refId: 'A', + }, + ], +} as any) as PanelModel; + +const ui = { + row: byTestId('row'), + createButton: byTestId('create-alert-rule-button'), +}; + +describe('PanelAlertTabContent', () => { + beforeEach(() => { + jest.resetAllMocks(); + mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); + const dsService = new MockDataSourceSrv(dataSources); + dsService.datasources[dataSources.prometheus.name] = new PrometheusDatasource( + dataSources.prometheus + ) as DataSourceApi; + setDataSourceSrv(dsService); + }); + + it('Will take into account panel maxDataPoints', async () => { + await renderAlertTabContent(dashboard, ({ + ...panel, + maxDataPoints: 100, + interval: '10s', + } as any) as PanelModel); + const button = await ui.createButton.find(); + const href = button.href; + const match = href.match(/alerting\/new\?defaults=(.*)&returnTo=/); + expect(match).toHaveLength(2); + const defaults = JSON.parse(decodeURIComponent(match![1])); + expect(defaults.queries[0].model).toEqual({ + expr: 'sum(some_metric [5m])) by (app)', + refId: 'A', + datasource: 'Prometheus', + interval: '', + intervalMs: 300000, + maxDataPoints: 100, + }); + }); + + it('Will take into account datasource minInterval', async () => { + ((getDatasourceSrv() as any) as MockDataSourceSrv).datasources[dataSources.prometheus.name].interval = '7m'; + + await renderAlertTabContent(dashboard, ({ + ...panel, + maxDataPoints: 100, + } as any) as PanelModel); + const button = await ui.createButton.find(); + const href = button.href; + const match = href.match(/alerting\/new\?defaults=(.*)&returnTo=/); + expect(match).toHaveLength(2); + const defaults = JSON.parse(decodeURIComponent(match![1])); + expect(defaults.queries[0].model).toEqual({ + expr: 'sum(some_metric [7m])) by (app)', + refId: 'A', + datasource: 'Prometheus', + interval: '', + intervalMs: 420000, + maxDataPoints: 100, + }); + }); + + it('Will render alerts belonging to panel and a button to create alert from panel queries', async () => { + mocks.api.fetchRules.mockResolvedValue(rules); + mocks.api.fetchRulerRules.mockResolvedValue(rulerRules); + + await renderAlertTabContent(dashboard, panel); + + const rows = await ui.row.findAll(); + expect(rows).toHaveLength(1); + expect(rows[0]).toHaveTextContent(/dashboardrule1/); + expect(rows[0]).not.toHaveTextContent(/dashboardrule2/); + const button = await ui.createButton.find(); + const href = button.href; + const match = href.match(/alerting\/new\?defaults=(.*)&returnTo=/); + expect(match).toHaveLength(2); + const defaults = JSON.parse(decodeURIComponent(match![1])); + expect(defaults).toEqual({ + type: 'grafana', + folder: { id: 1, title: 'super folder' }, + queries: [ + { + refId: 'A', + queryType: '', + relativeTimeRange: { from: 21600, to: 0 }, + datasourceUid: 'mock-ds-2', + model: { + expr: 'sum(some_metric [15s])) by (app)', + refId: 'A', + datasource: 'Prometheus', + interval: '', + intervalMs: 15000, + }, + }, + { + refId: 'B', + datasourceUid: '-100', + queryType: '', + model: { + refId: 'B', + hide: false, + type: 'classic_conditions', + datasource: '__expr__', + conditions: [ + { + type: 'query', + evaluator: { params: [3], type: 'gt' }, + operator: { type: 'and' }, + query: { params: ['A'] }, + reducer: { params: [], type: 'last' }, + }, + ], + }, + }, + ], + name: 'mypanel', + condition: 'B', + annotations: [ + { key: '__dashboardUid__', value: '12' }, + { key: '__panelId__', value: '34' }, + ], + }); + }); +}); diff --git a/public/app/features/alerting/unified/components/panel-alerts-tab/NewRuleFromPanelButton.tsx b/public/app/features/alerting/unified/components/panel-alerts-tab/NewRuleFromPanelButton.tsx index fb11e355d08..234dc3c3175 100644 --- a/public/app/features/alerting/unified/components/panel-alerts-tab/NewRuleFromPanelButton.tsx +++ b/public/app/features/alerting/unified/components/panel-alerts-tab/NewRuleFromPanelButton.tsx @@ -1,9 +1,10 @@ import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; import React, { FC } from 'react'; -import { Alert, LinkButton } from '@grafana/ui'; +import { Alert, LinkButton, Button } from '@grafana/ui'; import { panelToRuleFormValues } from '../../utils/rule-form'; import { useLocation } from 'react-router-dom'; import { urlUtil } from '@grafana/data'; +import { useAsync } from 'react-use'; interface Props { panel: PanelModel; @@ -12,8 +13,11 @@ interface Props { } export const NewRuleFromPanelButton: FC = ({ dashboard, panel, className }) => { - const formValues = panelToRuleFormValues(panel, dashboard); + const { loading, value: formValues } = useAsync(() => panelToRuleFormValues(panel, dashboard), [panel, dashboard]); const location = useLocation(); + if (loading) { + return ; + } if (!formValues) { return ( @@ -29,7 +33,7 @@ export const NewRuleFromPanelButton: FC = ({ dashboard, panel, className }); return ( - + Create alert rule from this panel ); diff --git a/public/app/features/alerting/unified/mocks.ts b/public/app/features/alerting/unified/mocks.ts index 70dc72ad90a..bd8c0f0bc20 100644 --- a/public/app/features/alerting/unified/mocks.ts +++ b/public/app/features/alerting/unified/mocks.ts @@ -1,5 +1,11 @@ import { DataSourceApi, DataSourceInstanceSettings, DataSourcePluginMeta, ScopedVars } from '@grafana/data'; -import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto'; +import { + GrafanaAlertStateDecision, + GrafanaRuleDefinition, + PromAlertingRuleState, + PromRuleType, + RulerGrafanaRuleDTO, +} 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'; @@ -17,7 +23,7 @@ let nextDataSourceId = 1; export const mockDataSource = ( partial: Partial = {}, meta: Partial = {} -): DataSourceInstanceSettings => { +): DataSourceInstanceSettings => { const id = partial.id ?? nextDataSourceId++; return { @@ -54,6 +60,40 @@ export const mockPromAlert = (partial: Partial = {}): Alert => ({ ...partial, }); +export const mockRulerGrafanaRule = ( + partial: Partial = {}, + partialDef: Partial = {} +): RulerGrafanaRuleDTO => { + return { + for: '1m', + grafana_alert: { + uid: '123', + title: 'myalert', + namespace_uid: '123', + namespace_id: 1, + condition: 'A', + no_data_state: GrafanaAlertStateDecision.Alerting, + exec_err_state: GrafanaAlertStateDecision.Alerting, + data: [ + { + datasourceUid: '123', + refId: 'A', + queryType: 'huh', + model: {} as any, + }, + ], + ...partialDef, + }, + annotations: { + message: 'alert with severity "{{.warning}}}"', + }, + labels: { + severity: 'warning', + }, + ...partial, + }; +}; + export const mockPromAlertingRule = (partial: Partial = {}): AlertingRule => { return { type: PromRuleType.Alerting, diff --git a/public/app/features/alerting/unified/utils/rule-form.ts b/public/app/features/alerting/unified/utils/rule-form.ts index e5006b801ea..5ce60e559b7 100644 --- a/public/app/features/alerting/unified/utils/rule-form.ts +++ b/public/app/features/alerting/unified/utils/rule-form.ts @@ -1,11 +1,18 @@ -import { DataQuery, rangeUtil, RelativeTimeRange } from '@grafana/data'; +import { + DataQuery, + rangeUtil, + RelativeTimeRange, + ScopedVars, + getDefaultRelativeTimeRange, + TimeRange, + IntervalValues, +} from '@grafana/data'; import { getDataSourceSrv } from '@grafana/runtime'; import { contextSrv } from 'app/core/services/context_srv'; import { getNextRefIdChar } from 'app/core/utils/query'; import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; import { ExpressionDatasourceID, ExpressionDatasourceUID } from 'app/features/expressions/ExpressionDatasource'; import { ExpressionQuery, ExpressionQueryType } from 'app/features/expressions/types'; -import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { RuleWithLocation } from 'app/types/unified-alerting'; import { Annotations, @@ -22,7 +29,6 @@ import { isGrafanaRulesSource } from './datasource'; import { arrayToRecord, recordToArray } from './misc'; import { isAlertingRulerRule, isGrafanaRulerRule } from './rules'; import { parseInterval } from './time'; -import { getDefaultRelativeTimeRange } from '../../../../../../packages/grafana-data'; export const getDefaultFormValues = (): RuleFormValues => Object.freeze({ @@ -193,60 +199,81 @@ const getDefaultExpression = (refId: string): AlertQuery => { }; }; -const dataQueriesToGrafanaQueries = ( +const dataQueriesToGrafanaQueries = async ( queries: DataQuery[], relativeTimeRange: RelativeTimeRange, - datasourceName?: string -): AlertQuery[] => { - return queries.reduce((queries, target) => { + scopedVars: ScopedVars | {}, + datasourceName?: string, + maxDataPoints?: number, + minInterval?: string +): Promise => { + const result: AlertQuery[] = []; + for (const target of queries) { const dsName = target.datasource || datasourceName; + const datasource = await getDataSourceSrv().get(dsName); + + const range = rangeUtil.relativeToTimeRange(relativeTimeRange); + const { interval, intervalMs } = getIntervals(range, minInterval ?? datasource.interval, maxDataPoints); + const queryVariables = { + __interval: { text: interval, value: interval }, + __interval_ms: { text: intervalMs, value: intervalMs }, + ...scopedVars, + }; + const interpolatedTarget = datasource.interpolateVariablesInQueries + ? await datasource.interpolateVariablesInQueries([target], queryVariables)[0] + : target; if (dsName) { // expressions if (dsName === ExpressionDatasourceID) { const newQuery: AlertQuery = { - refId: target.refId, + refId: interpolatedTarget.refId, queryType: '', relativeTimeRange, datasourceUid: ExpressionDatasourceUID, - model: target, + model: interpolatedTarget, }; - return [...queries, newQuery]; + result.push(newQuery); // queries } else { - const datasource = getDataSourceSrv().getInstanceSettings(target.datasource || datasourceName); - if (datasource && datasource.meta.alerting) { + const datasourceSettings = getDataSourceSrv().getInstanceSettings(dsName); + if (datasourceSettings && datasourceSettings.meta.alerting) { const newQuery: AlertQuery = { - refId: target.refId, - queryType: target.queryType ?? '', + refId: interpolatedTarget.refId, + queryType: interpolatedTarget.queryType ?? '', relativeTimeRange, - datasourceUid: datasource.uid, - model: target, + datasourceUid: datasourceSettings.uid, + model: { + ...interpolatedTarget, + maxDataPoints, + intervalMs, + }, }; - return [...queries, newQuery]; + result.push(newQuery); } } } - return queries; - }, []); + } + return result; }; -export const panelToRuleFormValues = ( +export const panelToRuleFormValues = async ( panel: PanelModel, dashboard: DashboardModel -): Partial | undefined => { +): Promise | undefined> => { const { targets } = panel; - - // it seems if default datasource is selected, datasource=null, hah - const datasourceName = - panel.datasource === null ? getDatasourceSrv().getInstanceSettings('default')?.name : panel.datasource; - if (!panel.editSourceId || !dashboard.uid) { return undefined; } const relativeTimeRange = rangeUtil.timeRangeToRelative(rangeUtil.convertRawToRange(dashboard.time)); - const queries = dataQueriesToGrafanaQueries(targets, relativeTimeRange, datasourceName); - + const queries = await dataQueriesToGrafanaQueries( + targets, + relativeTimeRange, + panel.scopedVars || {}, + panel.datasource ?? undefined, + panel.maxDataPoints ?? undefined, + panel.interval ?? undefined + ); // if no alerting capable queries are found, can't create a rule if (!queries.length || !queries.find((query) => query.datasourceUid !== ExpressionDatasourceUID)) { return undefined; @@ -269,6 +296,7 @@ export const panelToRuleFormValues = ( : undefined, queries, name: panel.title, + condition: queries[queries.length - 1].refId, annotations: [ { key: Annotation.dashboardUID, @@ -282,3 +310,17 @@ export const panelToRuleFormValues = ( }; return formValues; }; + +export function getIntervals(range: TimeRange, lowLimit?: string, resolution?: number): IntervalValues { + if (!resolution) { + if (lowLimit && rangeUtil.intervalToMs(lowLimit) > 1000) { + return { + interval: lowLimit, + intervalMs: rangeUtil.intervalToMs(lowLimit), + }; + } + return { interval: '1s', intervalMs: 1000 }; + } + + return rangeUtil.calculateInterval(range, resolution, lowLimit); +} diff --git a/public/app/plugins/datasource/influxdb/datasource.ts b/public/app/plugins/datasource/influxdb/datasource.ts index caa8bd0de92..abb555b050e 100644 --- a/public/app/plugins/datasource/influxdb/datasource.ts +++ b/public/app/plugins/datasource/influxdb/datasource.ts @@ -344,7 +344,7 @@ export default class InfluxDatasource extends DataSourceWithBackend