Skip to content

Commit

Permalink
Alerting: Enable preview for recording rules (#63260)
Browse files Browse the repository at this point in the history
* Create RecordingRuleEditor component

It reuses QueryEditor and propagates a few properties to allow to filter the visible datasources and customize what's shown in the editor header

* Set recording rules queries as a new state prop

Otherwise it would get mixed up with the alert rules queries when switching back and forth from this option. This also allows me to initialize these queries with the right datasource

* Show CloudRulesSourcePicker only for Loki/Mimir rules

As now we use the query editor for recording rules which already includes a datasource picker within

* Fix lint and tests

* Fix saving a recording rule

* Show expression when editing the recording rule

* Show query editor back for cloud rules

* Fix duplicated import

* Tweak after rebase

* Remove ts-ignore

* Refactor to use queries state instead of recordingRuleQueries

* Refacrtor RecordingRuleEditor to use ds QueryEditor

* Revert extra properties previously added to QueryEditor components

* Remove console.log

* Fix saving/editing a recording rule

* Fix tests

* Add margin to vizwrapper component
  • Loading branch information
VikaCep committed Mar 22, 2023
1 parent e243db6 commit a1fc515
Show file tree
Hide file tree
Showing 6 changed files with 253 additions and 28 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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) => (
<input value={value} data-testid="expr" onChange={(e) => onChange(e.target.value)} />
),
jest.mock('./components/rule-editor/RecordingRuleEditor', () => ({
RecordingRuleEditor: ({ queries, onChangeQuery }: Pick<RecordingRuleEditorProps, 'queries' | 'onChangeQuery'>) => {
const onChange = (expr: string) => {
const query = queries[0];

const merged = {
...query,
expr,
model: {
...query.model,
expr,
},
};

onChangeQuery([merged]);
};

return <input data-testid="expr" onChange={(e) => 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', () => ({
Expand All @@ -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 = {
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, PanelData>;
dataSourceName: string;
}

export const RecordingRuleEditor: FC<RecordingRuleEditorProps> = ({
queries,
onChangeQuery,
runQueries,
panelData,
dataSourceName,
}) => {
const [data, setData] = useState<PanelData>({
series: [],
state: LoadingState.NotStarted,
timeRange: getTimeSrv().timeRange(),
});

const styles = useStyles2(getStyles);

const isExpression = isExpressionQuery(queries[0]?.model);

const [pluginId, changePluginId] = useState<SupportedPanelPlugins>(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 <div>Could not load query editor due to: {errorMessage}</div>;
}

const QueryEditor = dataSource.components.QueryEditor;

return (
<>
{queries.length && (
<QueryEditor
query={queries[0]}
queries={queries}
app={CoreApp.UnifiedAlerting}
onChange={handleChangedQuery}
onRunQuery={() => runQueries(queries)}
datasource={dataSource}
/>
)}

{data && (
<div className={styles.vizWrapper}>
<VizWrapper data={data} currentPanel={pluginId} changePanel={changePluginId} />
</div>
)}
</>
);
};

const getStyles = (theme: GrafanaTheme2) => ({
vizWrapper: css`
margin: ${theme.spacing(1, 0)};
`,
});
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export const AlertType = ({ editingExistingRule }: Props) => {
)}

<div className={styles.flexRow}>
{(ruleFormType === RuleFormType.cloudRecording || ruleFormType === RuleFormType.cloudAlerting) && (
{(ruleFormType === RuleFormType.cloudAlerting || ruleFormType === RuleFormType.cloudRecording) && (
<Field
className={styles.formInput}
label="Select data source"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
import { useFormContext } from 'react-hook-form';

import { LoadingState, PanelData } from '@grafana/data';
import { LoadingState, PanelData, getDefaultRelativeTimeRange } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Stack } from '@grafana/experimental';
import { config } from '@grafana/runtime';
import { Alert, Button, Field, InputControl, Tooltip } from '@grafana/ui';
import { config, getDataSourceSrv } from '@grafana/runtime';
import { Alert, Button, Field, Tooltip, InputControl } from '@grafana/ui';
import { isExpressionQuery } from 'app/features/expressions/guards';
import { AlertQuery } from 'app/types/unified-alerting-dto';

import { useRulesSourcesWithRuler } from '../../../hooks/useRuleSourcesWithRuler';
import { AlertingQueryRunner } from '../../../state/AlertingQueryRunner';
import { RuleFormType, RuleFormValues } from '../../../types/rule-form';
import { getDefaultOrFirstCompatibleDataSource } from '../../../utils/datasource';
import { ExpressionEditor } from '../ExpressionEditor';
import { ExpressionsEditor } from '../ExpressionsEditor';
import { QueryEditor } from '../QueryEditor';
import { RecordingRuleEditor } from '../RecordingRuleEditor';
import { RuleEditorSection } from '../RuleEditorSection';
import { errorFromSeries, refIdExists } from '../util';

Expand All @@ -27,6 +29,7 @@ import {
removeExpression,
rewireExpressions,
setDataQueries,
setRecordingRulesQueries,
updateExpression,
updateExpressionRefId,
updateExpressionTimeRange,
Expand All @@ -40,6 +43,7 @@ interface Props {

export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: Props) => {
const runner = useRef(new AlertingQueryRunner());

const {
setValue,
getValues,
Expand All @@ -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();
Expand Down Expand Up @@ -108,7 +112,7 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
useEffect(() => {
const currentCondition = getValues('condition');

if (!currentCondition) {
if (!currentCondition || RuleFormType.cloudRecording) {
return;
}

Expand Down Expand Up @@ -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));
}, []);
Expand All @@ -180,8 +221,21 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
<RuleEditorSection stepNo={2} title="Set a query and alert condition">
<AlertType editingExistingRule={editingExistingRule} />

{/* This is the PromQL Editor for Cloud rules and recording rules */}
{showCloudExpressionEditor && (
{/* This is the PromQL Editor for recording rules */}
{isRecordingRuleType && dataSourceName && (
<Field error={errors.expression?.message} invalid={!!errors.expression?.message}>
<RecordingRuleEditor
dataSourceName={dataSourceName}
queries={queries}
runQueries={runQueries}
onChangeQuery={onChangeRecordingRulesQueries}
panelData={panelData}
/>
</Field>
)}

{/* This is the PromQL Editor for Cloud rules */}
{isCloudAlertRuleType && dataSourceName && (
<Field error={errors.expression?.message} invalid={!!errors.expression?.message}>
<InputControl
name="expression"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ export const updateExpressionType = createAction<{ refId: string; type: Expressi
export const updateExpressionTimeRange = createAction('updateExpressionTimeRange');
export const updateMaxDataPoints = createAction<{ refId: string; maxDataPoints: number }>('updateMaxDataPoints');

export const setRecordingRulesQueries = createAction<{ recordingRuleQueries: AlertQuery[]; expression: string }>(
'setRecordingRulesQueries'
);

export const queriesAndExpressionsReducer = createReducer(initialState, (builder) => {
// data queries actions
builder
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit a1fc515

Please sign in to comment.