diff --git a/public/app/features/alerting/unified/utils/rule-form.ts b/public/app/features/alerting/unified/utils/rule-form.ts index 26e286c0898..cd4d6abdaad 100644 --- a/public/app/features/alerting/unified/utils/rule-form.ts +++ b/public/app/features/alerting/unified/utils/rule-form.ts @@ -13,9 +13,15 @@ import { } from '@grafana/data'; import { getDataSourceSrv } from '@grafana/runtime'; import { ExpressionDatasourceRef } from '@grafana/runtime/src/utils/DataSourceWithBackend'; +import { sceneGraph, VizPanel } from '@grafana/scenes'; import { DataSourceJsonData } from '@grafana/schema'; import { getNextRefIdChar } from 'app/core/utils/query'; import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; +import { + getDashboardSceneFor, + getPanelIdForVizPanel, + getQueryRunnerFor, +} from 'app/features/dashboard-scene/utils/utils'; import { ExpressionDatasourceUID, ExpressionQuery, ExpressionQueryType } from 'app/features/expressions/types'; import { LokiQuery } from 'app/plugins/datasource/loki/types'; import { PromQuery } from 'app/plugins/datasource/prometheus/types'; @@ -482,7 +488,7 @@ const dataQueriesToGrafanaQueries = async ( }; const interpolatedTarget = datasource.interpolateVariablesInQueries - ? await datasource.interpolateVariablesInQueries([target], queryVariables)[0] + ? datasource.interpolateVariablesInQueries([target], queryVariables)[0] : target; // expressions @@ -579,6 +585,77 @@ export const panelToRuleFormValues = async ( return formValues; }; +export const scenesPanelToRuleFormValues = async (vizPanel: VizPanel): Promise | undefined> => { + if (!vizPanel.state.key) { + return undefined; + } + + const timeRange = sceneGraph.getTimeRange(vizPanel); + const queryRunner = getQueryRunnerFor(vizPanel); + if (!queryRunner) { + return undefined; + } + const { queries, datasource, maxDataPoints, minInterval } = queryRunner.state; + + const dashboard = getDashboardSceneFor(vizPanel); + if (!dashboard || !dashboard.state.uid) { + return undefined; + } + + const grafanaQueries = await dataQueriesToGrafanaQueries( + queries, + rangeUtil.timeRangeToRelative(rangeUtil.convertRawToRange(timeRange.state.value.raw)), + { __sceneObject: { value: vizPanel } }, + datasource, + maxDataPoints, + minInterval + ); + + // if no alerting capable queries are found, can't create a rule + if (!grafanaQueries.length || !grafanaQueries.find((query) => query.datasourceUid !== ExpressionDatasourceUID)) { + return undefined; + } + + if (!grafanaQueries.find((query) => query.datasourceUid === ExpressionDatasourceUID)) { + const [reduceExpression, _thresholdExpression] = getDefaultExpressions(getNextRefIdChar(grafanaQueries), '-'); + grafanaQueries.push(reduceExpression); + + const [_reduceExpression, thresholdExpression] = getDefaultExpressions( + reduceExpression.refId, + getNextRefIdChar(grafanaQueries) + ); + grafanaQueries.push(thresholdExpression); + } + + const { folderTitle, folderUid } = dashboard.state.meta; + + const formValues = { + type: RuleFormType.grafana, + folder: + folderUid && folderTitle + ? { + uid: folderUid, + title: folderTitle, + } + : undefined, + queries: grafanaQueries, + name: vizPanel.state.title, + condition: grafanaQueries[grafanaQueries.length - 1].refId, + annotations: [ + { + key: Annotation.dashboardUID, + value: dashboard.state.uid, + }, + { + key: Annotation.panelID, + + value: String(getPanelIdForVizPanel(vizPanel)), + }, + ], + }; + return formValues; +}; + export function getIntervals(range: TimeRange, lowLimit?: string, resolution?: number): IntervalValues { if (!resolution) { if (lowLimit && rangeUtil.intervalToMs(lowLimit) > 1000) { diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/NewAlertRuleButton.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/NewAlertRuleButton.tsx new file mode 100644 index 00000000000..1c52f7f262f --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/NewAlertRuleButton.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { useLocation } from 'react-router-dom'; +import { useAsync } from 'react-use'; + +import { urlUtil } from '@grafana/data'; +import { locationService, logInfo } from '@grafana/runtime'; +import { VizPanel } from '@grafana/scenes'; +import { Alert, Button } from '@grafana/ui'; +import { LogMessages } from 'app/features/alerting/unified/Analytics'; +import { scenesPanelToRuleFormValues } from 'app/features/alerting/unified/utils/rule-form'; + +interface ScenesNewRuleFromPanelButtonProps { + panel: VizPanel; + className?: string; +} +export const ScenesNewRuleFromPanelButton = ({ panel, className }: ScenesNewRuleFromPanelButtonProps) => { + const location = useLocation(); + + const { loading, value: formValues } = useAsync(() => scenesPanelToRuleFormValues(panel), [panel]); + + if (loading) { + return ; + } + + if (!formValues) { + return ( + + Cannot create alerts from this panel because no query to an alerting capable datasource is found. + + ); + } + + const onClick = async () => { + logInfo(LogMessages.alertRuleFromPanel); + + const updateToDateFormValues = await scenesPanelToRuleFormValues(panel); + + const ruleFormUrl = urlUtil.renderUrl('/alerting/new', { + defaults: JSON.stringify(updateToDateFormValues), + returnTo: location.pathname + location.search, + }); + + locationService.push(ruleFormUrl); + }; + + return ( + + ); +}; diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx new file mode 100644 index 00000000000..d79fdaee329 --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx @@ -0,0 +1,356 @@ +import { act, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { TestProvider } from 'test/helpers/TestProvider'; +import { byTestId } from 'testing-library-selector'; + +import { DataSourceApi } from '@grafana/data'; +import { locationService, setDataSourceSrv } from '@grafana/runtime'; +import { fetchRules } from 'app/features/alerting/unified/api/prometheus'; +import { fetchRulerRules } from 'app/features/alerting/unified/api/ruler'; +import * as ruleActionButtons from 'app/features/alerting/unified/components/rules/RuleActionsButtons'; +import { + MockDataSourceSrv, + grantUserPermissions, + mockDataSource, + mockPromAlertingRule, + mockPromRuleGroup, + mockPromRuleNamespace, + mockRulerGrafanaRule, +} from 'app/features/alerting/unified/mocks'; +import { RuleFormValues } from 'app/features/alerting/unified/types/rule-form'; +import * as config from 'app/features/alerting/unified/utils/config'; +import { Annotation } from 'app/features/alerting/unified/utils/constants'; +import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; +import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; +import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; +import { PrometheusDatasource } from 'app/plugins/datasource/prometheus/datasource'; +import { PromOptions } from 'app/plugins/datasource/prometheus/types'; +import { configureStore } from 'app/store/configureStore'; +import { AccessControlAction } from 'app/types'; +import { AlertQuery } from 'app/types/unified-alerting-dto'; + +import { createDashboardSceneFromDashboardModel } from '../../serialization/transformSaveModelToScene'; +import { findVizPanelByKey, getVizPanelKeyForPanelId } from '../../utils/utils'; +import * as utils from '../../utils/utils'; +import { VizPanelManager } from '../VizPanelManager'; + +import { PanelDataAlertingTab, PanelDataAlertingTabRendered } from './PanelDataAlertingTab'; + +/** + * These tests has been copied from public/app/features/alerting/unified/PanelAlertTabContent.test.tsx and been slightly modified to make sure the scenes alert edit tab is as close to the old alert edit tab as possible + */ + +jest.mock('app/features/alerting/unified/api/prometheus'); +jest.mock('app/features/alerting/unified/api/ruler'); + +jest.spyOn(config, 'getAllDataSources'); +jest.spyOn(ruleActionButtons, 'matchesWidth').mockReturnValue(false); + +const dataSources = { + prometheus: mockDataSource({ + name: 'Prometheus', + type: DataSourceType.Prometheus, + isDefault: false, + }), + default: mockDataSource({ + name: 'Default', + type: DataSourceType.Prometheus, + isDefault: true, + }), +}; +dataSources.prometheus.meta.alerting = true; +dataSources.default.meta.alerting = true; + +const mocks = { + getAllDataSources: jest.mocked(config.getAllDataSources), + api: { + fetchRules: jest.mocked(fetchRules), + fetchRulerRules: jest.mocked(fetchRulerRules), + }, +}; + +const renderAlertTabContent = (model: PanelDataAlertingTab, initialStore?: ReturnType) => { + 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', + }, + timepicker: { refresh_intervals: 5 }, + meta: { + canSave: true, + folderId: 1, + folderTitle: 'super folder', + }, +} as unknown as DashboardModel; + +const panel = new PanelModel({ + datasource: { + type: 'prometheus', + uid: dataSources.prometheus.uid, + }, + title: 'mypanel', + id: 34, + targets: [ + { + expr: 'sum(some_metric [$__interval])) by (app)', + refId: 'A', + }, + ], +}); + +const ui = { + row: byTestId('row'), + createButton: byTestId('create-alert-rule-button'), +}; + +describe('PanelAlertTabContent', () => { + beforeEach(() => { + jest.resetAllMocks(); + grantUserPermissions([ + AccessControlAction.AlertingRuleRead, + AccessControlAction.AlertingRuleUpdate, + AccessControlAction.AlertingRuleDelete, + AccessControlAction.AlertingRuleCreate, + AccessControlAction.AlertingRuleExternalRead, + AccessControlAction.AlertingRuleExternalWrite, + ]); + mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); + const dsService = new MockDataSourceSrv(dataSources); + dsService.datasources[dataSources.prometheus.uid] = new PrometheusDatasource( + dataSources.prometheus + ) as DataSourceApi; + dsService.datasources[dataSources.default.uid] = new PrometheusDatasource(dataSources.default) as DataSourceApi; + setDataSourceSrv(dsService); + }); + + it('Will take into account panel maxDataPoints', async () => { + dashboard.panels = [ + new PanelModel({ + ...panel, + maxDataPoints: 100, + interval: '10s', + }), + ]; + + renderAlertTab(dashboard); + + const defaults = await clickNewButton(); + + expect(defaults.queries[0].model).toEqual({ + expr: 'sum(some_metric [5m])) by (app)', + refId: 'A', + datasource: { + type: 'prometheus', + uid: 'mock-ds-2', + }, + interval: '', + intervalMs: 300000, + maxDataPoints: 100, + }); + }); + + it('Will work with default datasource', async () => { + dashboard.panels = [ + new PanelModel({ + ...panel, + datasource: undefined, + maxDataPoints: 100, + interval: '10s', + }), + ]; + + renderAlertTab(dashboard); + const defaults = await clickNewButton(); + + expect(defaults.queries[0].model).toEqual({ + expr: 'sum(some_metric [5m])) by (app)', + refId: 'A', + datasource: { + type: 'prometheus', + uid: 'mock-ds-3', + }, + interval: '', + intervalMs: 300000, + maxDataPoints: 100, + }); + }); + + it('Will take into account datasource minInterval', async () => { + (getDatasourceSrv() as unknown as MockDataSourceSrv).datasources[dataSources.prometheus.uid].interval = '7m'; + + dashboard.panels = [ + new PanelModel({ + ...panel, + maxDataPoints: 100, + }), + ]; + + renderAlertTab(dashboard); + const defaults = await clickNewButton(); + + expect(defaults.queries[0].model).toEqual({ + expr: 'sum(some_metric [7m])) by (app)', + refId: 'A', + datasource: { + type: 'prometheus', + uid: 'mock-ds-2', + }, + 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); + + dashboard.panels = [panel]; + + renderAlertTab(dashboard); + + const rows = await ui.row.findAll(); + expect(rows).toHaveLength(1); + expect(rows[0]).toHaveTextContent(/dashboardrule1/); + expect(rows[0]).not.toHaveTextContent(/dashboardrule2/); + + const defaults = await clickNewButton(); + + const defaultsWithDeterministicTime: Partial = { + ...defaults, + queries: defaults.queries.map((q: AlertQuery) => { + return { + ...q, + // Fix computed time stamp to avoid assertion flakiness + ...(q.relativeTimeRange ? { relativeTimeRange: { from: 21600, to: 0 } } : {}), + }; + }), + }; + + expect(defaultsWithDeterministicTime).toMatchSnapshot(); + + expect(mocks.api.fetchRulerRules).toHaveBeenCalledWith( + { dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' }, + { + dashboardUID: dashboard.uid, + panelId: panel.id, + } + ); + expect(mocks.api.fetchRules).toHaveBeenCalledWith( + GRAFANA_RULES_SOURCE_NAME, + { + dashboardUID: dashboard.uid, + panelId: panel.id, + }, + undefined, + undefined, + undefined, + undefined + ); + }); +}); + +function renderAlertTab(dashboard: DashboardModel) { + const model = createModel(dashboard); + renderAlertTabContent(model); +} + +async function clickNewButton() { + const pushMock = jest.fn(); + const oldPush = locationService.push; + locationService.push = pushMock; + const button = await ui.createButton.find(); + await act(async () => { + await userEvent.click(button); + }); + const match = pushMock.mock.lastCall[0].match(/alerting\/new\?defaults=(.*)&returnTo=/); + const defaults = JSON.parse(decodeURIComponent(match![1])); + locationService.push = oldPush; + return defaults; +} + +function createModel(dashboard: DashboardModel) { + const scene = createDashboardSceneFromDashboardModel(dashboard); + const vizPanel = findVizPanelByKey(scene, getVizPanelKeyForPanelId(34)); + const model = new PanelDataAlertingTab(new VizPanelManager(vizPanel!.clone())); + jest.spyOn(utils, 'getDashboardSceneFor').mockReturnValue(scene); + return model; +} diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.tsx index d726d5d4b27..754929a73f8 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.tsx @@ -1,13 +1,18 @@ +import { css } from '@emotion/css'; import React from 'react'; +import { GrafanaTheme2 } from '@grafana/data'; import { SceneObjectBase, SceneComponentProps } from '@grafana/scenes'; -import { Alert, LoadingPlaceholder, Tab } from '@grafana/ui'; +import { Alert, LoadingPlaceholder, Tab, useStyles2 } from '@grafana/ui'; +import { contextSrv } from 'app/core/core'; import { RulesTable } from 'app/features/alerting/unified/components/rules/RulesTable'; import { usePanelCombinedRules } from 'app/features/alerting/unified/hooks/usePanelCombinedRules'; +import { getRulesPermissions } from 'app/features/alerting/unified/utils/access-control'; import { getDashboardSceneFor, getPanelIdForVizPanel } from '../../utils/utils'; import { VizPanelManager } from '../VizPanelManager'; +import { ScenesNewRuleFromPanelButton } from './NewAlertRuleButton'; import { PanelDataPaneTabState, PanelDataPaneTab, TabId, PanelDataTabHeaderProps } from './types'; export class PanelDataAlertingTab extends SceneObjectBase implements PanelDataPaneTab { @@ -22,31 +27,46 @@ export class PanelDataAlertingTab extends SceneObjectBase this.TabComponent = (props: PanelDataTabHeaderProps) => AlertingTab({ ...props, model: this }); this._panelManager = panelManager; } + getTabLabel() { return 'Alert'; } getDashboardUID() { - const dashboard = getDashboardSceneFor(this._panelManager); + const dashboard = this.getDashboard(); return dashboard.state.uid!; } - getPanelId() { + getDashboard() { + return getDashboardSceneFor(this._panelManager); + } + + getLegacyPanelId() { return getPanelIdForVizPanel(this._panelManager.state.panel); } + getCanCreateRules() { + const rulesPermissions = getRulesPermissions('grafana'); + return this.getDashboard().state.meta.canSave && contextSrv.hasPermission(rulesPermissions.create); + } + get panelManager() { return this._panelManager; } + + get panel() { + return this._panelManager.state.panel; + } } -function PanelDataAlertingTabRendered(props: SceneComponentProps) { +export function PanelDataAlertingTabRendered(props: SceneComponentProps) { const { model } = props; + const styles = useStyles2(getStyles); + const { errors, loading, rules } = usePanelCombinedRules({ dashboardUID: model.getDashboardUID(), - panelId: model.getPanelId(), - poll: true, + panelId: model.getLegacyPanelId(), }); const alert = errors.length ? ( @@ -66,19 +86,36 @@ function PanelDataAlertingTabRendered(props: SceneComponentProps; + return ( + <> + + {canCreateRules && } + + ); } - // TODO: this is the tricky part, converting queries and such to pre populate the new alert form when clicking the button return ( -
+

There are no alert rules linked to this panel.

- + {canCreateRules && }
); } +const getStyles = (theme: GrafanaTheme2) => ({ + newButton: css({ + marginTop: theme.spacing(3), + }), + noRulesWrapper: css({ + margin: theme.spacing(2), + backgroundColor: theme.colors.background.secondary, + padding: theme.spacing(3), + }), +}); interface PanelDataAlertingTabHeaderProps extends PanelDataTabHeaderProps { model: PanelDataAlertingTab; } @@ -88,7 +125,7 @@ function AlertingTab(props: PanelDataAlertingTabHeaderProps) { const { rules } = usePanelCombinedRules({ dashboardUID: model.getDashboardUID(), - panelId: model.getPanelId(), + panelId: model.getLegacyPanelId(), poll: false, }); diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/__snapshots__/PanelDataAlertingTab.test.tsx.snap b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/__snapshots__/PanelDataAlertingTab.test.tsx.snap new file mode 100644 index 00000000000..c6d28162c50 --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/__snapshots__/PanelDataAlertingTab.test.tsx.snap @@ -0,0 +1,113 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PanelAlertTabContent Will render alerts belonging to panel and a button to create alert from panel queries 1`] = ` +{ + "annotations": [ + { + "key": "__dashboardUid__", + "value": "12", + }, + { + "key": "__panelId__", + "value": "34", + }, + ], + "condition": "C", + "name": "mypanel", + "queries": [ + { + "datasourceUid": "mock-ds-2", + "model": { + "datasource": { + "type": "prometheus", + "uid": "mock-ds-2", + }, + "expr": "sum(some_metric [15s])) by (app)", + "interval": "", + "intervalMs": 15000, + "refId": "A", + }, + "queryType": "", + "refId": "A", + "relativeTimeRange": { + "from": 21600, + "to": 0, + }, + }, + { + "datasourceUid": "__expr__", + "model": { + "conditions": [ + { + "evaluator": { + "params": [], + "type": "gt", + }, + "operator": { + "type": "and", + }, + "query": { + "params": [ + "B", + ], + }, + "reducer": { + "params": [], + "type": "last", + }, + "type": "query", + }, + ], + "datasource": { + "type": "__expr__", + "uid": "__expr__", + }, + "expression": "A", + "reducer": "last", + "refId": "B", + "type": "reduce", + }, + "queryType": "", + "refId": "B", + }, + { + "datasourceUid": "__expr__", + "model": { + "conditions": [ + { + "evaluator": { + "params": [ + 0, + ], + "type": "gt", + }, + "operator": { + "type": "and", + }, + "query": { + "params": [ + "C", + ], + }, + "reducer": { + "params": [], + "type": "last", + }, + "type": "query", + }, + ], + "datasource": { + "type": "__expr__", + "uid": "__expr__", + }, + "expression": "B", + "refId": "C", + "type": "threshold", + }, + "queryType": "", + "refId": "C", + }, + ], + "type": "grafana", +} +`;