diff --git a/public/app/features/alerting/unified/RuleEditorRecordingRule.test.tsx b/public/app/features/alerting/unified/RuleEditorRecordingRule.test.tsx index 9573d851405..7648fd4967e 100644 --- a/public/app/features/alerting/unified/RuleEditorRecordingRule.test.tsx +++ b/public/app/features/alerting/unified/RuleEditorRecordingRule.test.tsx @@ -13,22 +13,35 @@ import { searchFolders } from '../../manage-dashboards/state/actions'; import { discoverFeatures } from './api/buildInfo'; import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace, setRulerRuleGroup } from './api/ruler'; -import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; +import { RecordingRuleEditorProps } from './components/rule-editor/RecordingRuleEditor'; import { disableRBAC, mockDataSource, MockDataSourceSrv } from './mocks'; import { fetchRulerRulesIfNotFetchedYet } from './state/actions'; import * as config from './utils/config'; -jest.mock('./components/rule-editor/ExpressionEditor', () => ({ - // eslint-disable-next-line react/display-name - ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => ( - onChange(e.target.value)} /> - ), +jest.mock('./components/rule-editor/RecordingRuleEditor', () => ({ + RecordingRuleEditor: ({ queries, onChangeQuery }: Pick) => { + const onChange = (expr: string) => { + const query = queries[0]; + + const merged = { + ...query, + expr, + model: { + ...query.model, + expr, + }, + }; + + onChangeQuery([merged]); + }; + + return onChange(e.target.value)} />; + }, })); jest.mock('./api/buildInfo'); jest.mock('./api/ruler'); jest.mock('../../../../app/features/manage-dashboards/state/actions'); - // there's no angular scope in test and things go terribly wrong when trying to render the query editor row. // lets just skip it jest.mock('app/features/query/components/QueryEditorRow', () => ({ @@ -38,6 +51,25 @@ jest.mock('app/features/query/components/QueryEditorRow', () => ({ jest.spyOn(config, 'getAllDataSources'); +const dataSources = { + default: mockDataSource( + { + type: 'prometheus', + name: 'Prom', + isDefault: true, + }, + { alerting: true } + ), +}; + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getDataSourceSrv: jest.fn(() => ({ + getInstanceSettings: () => dataSources.default, + get: () => dataSources.default, + })), +})); + jest.setTimeout(60 * 1000); const mocks = { @@ -64,17 +96,6 @@ describe('RuleEditor recording rules', () => { disableRBAC(); it('can create a new cloud recording rule', async () => { - const dataSources = { - default: mockDataSource( - { - type: 'prometheus', - name: 'Prom', - isDefault: true, - }, - { alerting: true } - ), - }; - setDataSourceSrv(new MockDataSourceSrv(dataSources)); mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); mocks.api.setRulerRuleGroup.mockResolvedValue(); diff --git a/public/app/features/alerting/unified/components/rule-editor/RecordingRuleEditor.tsx b/public/app/features/alerting/unified/components/rule-editor/RecordingRuleEditor.tsx new file mode 100644 index 00000000000..2278a7105fb --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/RecordingRuleEditor.tsx @@ -0,0 +1,115 @@ +import { css } from '@emotion/css'; +import React, { FC, useEffect, useState } from 'react'; +import { useAsync } from 'react-use'; + +import { PanelData, CoreApp, GrafanaTheme2 } from '@grafana/data'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { DataQuery, LoadingState } from '@grafana/schema'; +import { useStyles2 } from '@grafana/ui'; +import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; +import { isExpressionQuery } from 'app/features/expressions/guards'; +import { AlertQuery } from 'app/types/unified-alerting-dto'; + +import { TABLE, TIMESERIES } from '../../utils/constants'; +import { SupportedPanelPlugins } from '../PanelPluginsButtonGroup'; + +import { VizWrapper } from './VizWrapper'; + +export interface RecordingRuleEditorProps { + queries: AlertQuery[]; + onChangeQuery: (updatedQueries: AlertQuery[]) => void; + runQueries: (queries: AlertQuery[]) => void; + panelData: Record; + dataSourceName: string; +} + +export const RecordingRuleEditor: FC = ({ + queries, + onChangeQuery, + runQueries, + panelData, + dataSourceName, +}) => { + const [data, setData] = useState({ + series: [], + state: LoadingState.NotStarted, + timeRange: getTimeSrv().timeRange(), + }); + + const styles = useStyles2(getStyles); + + const isExpression = isExpressionQuery(queries[0]?.model); + + const [pluginId, changePluginId] = useState(isExpression ? TABLE : TIMESERIES); + + useEffect(() => { + setData(panelData?.[queries[0]?.refId]); + }, [panelData, queries]); + + const { + error, + loading, + value: dataSource, + } = useAsync(() => { + return getDataSourceSrv().get(dataSourceName); + }, [dataSourceName]); + + const handleChangedQuery = (changedQuery: DataQuery) => { + const query = queries[0]; + + const merged = { + ...query, + refId: changedQuery.refId, + queryType: query.model.queryType ?? '', + //@ts-ignore + expr: changedQuery?.expr, + model: { + refId: changedQuery.refId, + //@ts-ignore + expr: changedQuery?.expr, + editorMode: 'code', + }, + }; + onChangeQuery([merged]); + }; + + if (loading || dataSource?.name !== dataSourceName) { + return null; + } + + const dsi = getDataSourceSrv().getInstanceSettings(dataSourceName); + + if (error || !dataSource || !dataSource?.components?.QueryEditor || !dsi) { + const errorMessage = error?.message || 'Data source plugin does not export any Query Editor component'; + return
Could not load query editor due to: {errorMessage}
; + } + + const QueryEditor = dataSource.components.QueryEditor; + + return ( + <> + {queries.length && ( + runQueries(queries)} + datasource={dataSource} + /> + )} + + {data && ( +
+ +
+ )} + + ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + vizWrapper: css` + margin: ${theme.spacing(1, 0)}; + `, +}); diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/AlertType.tsx b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/AlertType.tsx index 870dfaafc9a..73448a0ad42 100644 --- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/AlertType.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/AlertType.tsx @@ -52,7 +52,7 @@ export const AlertType = ({ editingExistingRule }: Props) => { )}
- {(ruleFormType === RuleFormType.cloudRecording || ruleFormType === RuleFormType.cloudAlerting) && ( + {(ruleFormType === RuleFormType.cloudAlerting || ruleFormType === RuleFormType.cloudRecording) && ( { const runner = useRef(new AlertingQueryRunner()); + const { setValue, getValues, @@ -53,15 +57,15 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P queries: getValues('queries'), panelData: {}, }; - const [{ queries }, dispatch] = useReducer(queriesAndExpressionsReducer, initialState); + const [{ queries }, dispatch] = useReducer(queriesAndExpressionsReducer, initialState); const [type, condition, dataSourceName] = watch(['type', 'condition', 'dataSourceName']); const isGrafanaManagedType = type === RuleFormType.grafana; - const isCloudAlertRuleType = type === RuleFormType.cloudAlerting; const isRecordingRuleType = type === RuleFormType.cloudRecording; + const isCloudAlertRuleType = type === RuleFormType.cloudAlerting; - const showCloudExpressionEditor = (isRecordingRuleType || isCloudAlertRuleType) && dataSourceName; + const rulesSourcesWithRuler = useRulesSourcesWithRuler(); const cancelQueries = useCallback(() => { runner.current.cancel(); @@ -108,7 +112,7 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P useEffect(() => { const currentCondition = getValues('condition'); - if (!currentCondition) { + if (!currentCondition || RuleFormType.cloudRecording) { return; } @@ -164,6 +168,43 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P [queries] ); + const onChangeRecordingRulesQueries = useCallback( + (updatedQueries) => { + const dataSourceSettings = getDataSourceSrv().getInstanceSettings(updatedQueries[0].datasourceUid); + if (!dataSourceSettings) { + throw new Error('The Data source has not been defined.'); + } + + const expression = updatedQueries[0].model?.expr || ''; + + setValue('dataSourceName', dataSourceSettings.name); + setValue('expression', expression); + + dispatch(setRecordingRulesQueries({ recordingRuleQueries: updatedQueries, expression })); + runQueries(); + }, + [runQueries, setValue] + ); + + const recordingRuleDefaultDatasource = rulesSourcesWithRuler[0]; + + useEffect(() => { + setPanelData({}); + if (type === RuleFormType.cloudRecording) { + const defaultQuery = { + refId: 'A', + datasourceUid: recordingRuleDefaultDatasource.uid, + queryType: '', + relativeTimeRange: getDefaultRelativeTimeRange(), + model: { + refId: 'A', + hide: false, + }, + }; + dispatch(setRecordingRulesQueries({ recordingRuleQueries: [defaultQuery], expression: getValues('expression') })); + } + }, [type, recordingRuleDefaultDatasource, editingExistingRule, getValues]); + const onDuplicateQuery = useCallback((query: AlertQuery) => { dispatch(duplicateQuery(query)); }, []); @@ -180,8 +221,21 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P - {/* This is the PromQL Editor for Cloud rules and recording rules */} - {showCloudExpressionEditor && ( + {/* This is the PromQL Editor for recording rules */} + {isRecordingRuleType && dataSourceName && ( + + + + )} + + {/* This is the PromQL Editor for Cloud rules */} + {isCloudAlertRuleType && dataSourceName && ( ('updateMaxDataPoints'); +export const setRecordingRulesQueries = createAction<{ recordingRuleQueries: AlertQuery[]; expression: string }>( + 'setRecordingRulesQueries' +); + export const queriesAndExpressionsReducer = createReducer(initialState, (builder) => { // data queries actions builder @@ -69,6 +73,15 @@ export const queriesAndExpressionsReducer = createReducer(initialState, (builder const expressionQueries = state.queries.filter((query) => isExpressionQuery(query.model)); state.queries = [...payload, ...expressionQueries]; }) + .addCase(setRecordingRulesQueries, (state, { payload }) => { + const query = payload.recordingRuleQueries[0]; + const recordingRuleQuery = { + ...query, + ...{ expr: payload.expression, model: { expr: payload.expression, refId: query.model.refId } }, + }; + + state.queries = [recordingRuleQuery]; + }) .addCase(updateMaxDataPoints, (state, action) => { state.queries = state.queries.map((query) => { return query.refId === action.payload.refId diff --git a/public/app/features/alerting/unified/utils/rule-form.ts b/public/app/features/alerting/unified/utils/rule-form.ts index 339dd0ca46f..f369ee5bbb2 100644 --- a/public/app/features/alerting/unified/utils/rule-form.ts +++ b/public/app/features/alerting/unified/utils/rule-form.ts @@ -7,9 +7,11 @@ import { RelativeTimeRange, ScopedVars, TimeRange, + DataSourceInstanceSettings, } from '@grafana/data'; import { getDataSourceSrv } from '@grafana/runtime'; import { ExpressionDatasourceRef } from '@grafana/runtime/src/utils/DataSourceWithBackend'; +import { DataSourceJsonData } from '@grafana/schema'; import { getNextRefIdChar } from 'app/core/utils/query'; import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; import { ExpressionQuery, ExpressionQueryType, ExpressionDatasourceUID } from 'app/features/expressions/types'; @@ -56,6 +58,7 @@ export const getDefaultFormValues = (): RuleFormValues => { // grafana folder: null, queries: [], + recordingRulesQueries: [], condition: '', noDataState: GrafanaAlertStateDecision.NoData, execErrState: GrafanaAlertStateDecision.Error, @@ -221,6 +224,25 @@ export const getDefaultQueries = (): AlertQuery[] => { ]; }; +export const getDefaultRecordingRulesQueries = ( + rulesSourcesWithRuler: Array> +): AlertQuery[] => { + const relativeTimeRange = getDefaultRelativeTimeRange(); + + return [ + { + refId: 'A', + datasourceUid: rulesSourcesWithRuler[0]?.uid || '', + queryType: '', + relativeTimeRange, + model: { + refId: 'A', + hide: false, + }, + }, + ]; +}; + const getDefaultExpressions = (...refIds: [string, string]): AlertQuery[] => { const refOne = refIds[0]; const refTwo = refIds[1];