Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Alerting: Enable preview for recording rules #63260

Merged
merged 19 commits into from
Mar 22, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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