Alerting: Query and expressions section simplification (#93022)

* Add mode switch in Query section

* Implement simple query mode : WIP

* fix logic switching mode

* move guard and get methodd to another folder

* Add more requiremts for being transformable from advanced to not advanced mode

* fix usig mode when it's not a grafana managed alert

* Show warning when switching to not advanced and its not possible to convert

* Add feature toggle alertingQueryAndExpressionsStepMode

* fix test

* add translations

* address PR feedback

* Use form context for sharing simplfied mode used, save in local storage and use the new fields in the api

* add check to valid reducer and threshold when switching to simplified mode

* Use only one expression list

* fix test

* move existing rule check outside storeInLocalStorageValues

* add id in InlineSwitch to handle onClick on label

* fix

* Fix default values when editing existing rule

* Update dto fields for the api request

* fix snapshot

* Fix recording rules to not show switch mode

* remove unnecessary Boolean conversion

* fix areQueriesTransformableToSimpleCondition

* update text

* pr review nit

* pr review part2
sanitize-queries
Sonia Aguilar 8 months ago committed by GitHub
parent c36f7aa92b
commit 536edee7bf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
  2. 1
      packages/grafana-data/src/types/featureToggles.gen.ts
  3. 7
      pkg/services/featuremgmt/registry.go
  4. 1
      pkg/services/featuremgmt/toggles_gen.csv
  5. 4
      pkg/services/featuremgmt/toggles_gen.go
  6. 13
      pkg/services/featuremgmt/toggles_gen.json
  7. 2
      public/app/features/alerting/unified/components/expressions/Expression.tsx
  8. 33
      public/app/features/alerting/unified/components/rule-editor/QueryWrapper.tsx
  9. 32
      public/app/features/alerting/unified/components/rule-editor/RuleEditorSection.tsx
  10. 31
      public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx
  11. 489
      public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx
  12. 241
      public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/SimpleCondition.tsx
  13. 121
      public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/__snapshots__/areQueriesTransformableToSimpleCondition.test.ts
  14. 7
      public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.ts
  15. 5
      public/app/features/alerting/unified/types/rule-form.ts
  16. 2
      public/app/features/alerting/unified/utils/__snapshots__/rule-form.test.ts.snap
  17. 51
      public/app/features/alerting/unified/utils/rule-form.ts
  18. 19
      public/app/features/expressions/guards.ts
  19. 14
      public/app/features/expressions/utils/expressionTypes.ts
  20. 11
      public/app/features/query/components/QueryEditorRow.tsx
  21. 7
      public/app/types/unified-alerting-dto.ts
  22. 6
      public/locales/en-US/grafana.json
  23. 6
      public/locales/pseudo-LOCALE/grafana.json

@ -202,6 +202,7 @@ Experimental features might be changed or removed without prior notice.
| `exploreLogsLimitedTimeRange` | Used in Explore Logs to limit the time range |
| `homeSetupGuide` | Used in Home for users who want to return to the onboarding flow or quickly find popular config pages |
| `appSidecar` | Enable the app sidecar feature that allows rendering 2 apps at the same time |
| `alertingQueryAndExpressionsStepMode` | Enables step mode for alerting queries and expressions |
## Development feature toggles

@ -212,6 +212,7 @@ export interface FeatureToggles {
appPlatformAccessTokens?: boolean;
appSidecar?: boolean;
groupAttributeSync?: boolean;
alertingQueryAndExpressionsStepMode?: boolean;
improvedExternalSessionHandling?: boolean;
useSessionStorageForRedirection?: boolean;
}

@ -1459,6 +1459,13 @@ var (
Owner: identityAccessTeam,
HideFromDocs: true,
},
{
Name: "alertingQueryAndExpressionsStepMode",
Description: "Enables step mode for alerting queries and expressions",
Stage: FeatureStageExperimental,
Owner: grafanaAlertingSquad,
FrontendOnly: true,
},
{
Name: "improvedExternalSessionHandling",
Description: "Enable improved support for external sessions in Grafana",

@ -193,5 +193,6 @@ homeSetupGuide,experimental,@grafana/growth-and-onboarding,false,false,true
appPlatformAccessTokens,experimental,@grafana/identity-access-team,false,false,false
appSidecar,experimental,@grafana/explore-squad,false,false,false
groupAttributeSync,experimental,@grafana/identity-access-team,false,false,false
alertingQueryAndExpressionsStepMode,experimental,@grafana/alerting-squad,false,false,true
improvedExternalSessionHandling,experimental,@grafana/identity-access-team,false,false,false
useSessionStorageForRedirection,preview,@grafana/identity-access-team,false,false,false

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
193 appPlatformAccessTokens experimental @grafana/identity-access-team false false false
194 appSidecar experimental @grafana/explore-squad false false false
195 groupAttributeSync experimental @grafana/identity-access-team false false false
196 alertingQueryAndExpressionsStepMode experimental @grafana/alerting-squad false false true
197 improvedExternalSessionHandling experimental @grafana/identity-access-team false false false
198 useSessionStorageForRedirection preview @grafana/identity-access-team false false false

@ -783,6 +783,10 @@ const (
// Enable the groupsync extension for managing Group Attribute Sync feature
FlagGroupAttributeSync = "groupAttributeSync"
// FlagAlertingQueryAndExpressionsStepMode
// Enables step mode for alerting queries and expressions
FlagAlertingQueryAndExpressionsStepMode = "alertingQueryAndExpressionsStepMode"
// FlagImprovedExternalSessionHandling
// Enable improved support for external sessions in Grafana
FlagImprovedExternalSessionHandling = "improvedExternalSessionHandling"

@ -240,6 +240,19 @@
"hideFromAdminPage": true
}
},
{
"metadata": {
"name": "alertingQueryAndExpressionsStepMode",
"resourceVersion": "1725978395461",
"creationTimestamp": "2024-09-10T14:26:35Z"
},
"spec": {
"description": "Enables step mode for alerting queries and expressions",
"stage": "experimental",
"codeowner": "@grafana/alerting-squad",
"frontend": true
}
},
{
"metadata": {
"name": "alertingQueryOptimization",

@ -51,7 +51,7 @@ export const Expression: FC<ExpressionProps> = ({
onSetCondition,
onUpdateRefId,
onRemoveExpression,
onUpdateExpressionType,
onUpdateExpressionType, // this method is not used? maybe we should remove it
onChangeQuery,
}) => {
const styles = useStyles2(getStyles);

@ -1,7 +1,8 @@
import { css } from '@emotion/css';
import { cloneDeep } from 'lodash';
import { ChangeEvent, useState } from 'react';
import * as React from 'react';
import { ChangeEvent, useState } from 'react';
import { useFormContext } from 'react-hook-form';
import {
CoreApp,
@ -14,11 +15,12 @@ import {
ThresholdsConfig,
} from '@grafana/data';
import { DataQuery } from '@grafana/schema';
import { GraphThresholdsStyleMode, Icon, InlineField, Input, Tooltip, useStyles2, Stack } from '@grafana/ui';
import { GraphThresholdsStyleMode, Icon, InlineField, Input, Stack, Tooltip, useStyles2 } from '@grafana/ui';
import { logInfo } from 'app/features/alerting/unified/Analytics';
import { QueryEditorRow } from 'app/features/query/components/QueryEditorRow';
import { AlertDataQuery, AlertQuery } from 'app/types/unified-alerting-dto';
import { RuleFormValues } from '../../types/rule-form';
import { msToSingleUnitDuration } from '../../utils/time';
import { ExpressionStatusIndicator } from '../expressions/ExpressionStatusIndicator';
@ -78,6 +80,9 @@ export const QueryWrapper = ({
const [dsInstance, setDsInstance] = useState<DataSourceApi>();
const defaults = dsInstance?.getDefaultQuery ? dsInstance.getDefaultQuery(CoreApp.UnifiedAlerting) : {};
const { getValues } = useFormContext<RuleFormValues>();
const isAdvancedMode = getValues('editorSettings.simplifiedQueryEditor') !== true;
const queryWithDefaults = {
...defaults,
...cloneDeep(query.model),
@ -123,7 +128,17 @@ export const QueryWrapper = ({
}
// TODO add a warning label here too when the data looks like time series data and is used as an alert condition
function HeaderExtras({ query, error, index }: { query: AlertQuery<AlertDataQuery>; error?: Error; index: number }) {
function HeaderExtras({
query,
error,
index,
isAdvancedMode = true,
}: {
query: AlertQuery<AlertDataQuery>;
error?: Error;
index: number;
isAdvancedMode?: boolean;
}) {
const queryOptions: AlertQueryOptions = {
maxDataPoints: query.model.maxDataPoints,
minInterval: query.model.intervalMs ? msToSingleUnitDuration(query.model.intervalMs) : undefined,
@ -145,7 +160,12 @@ export const QueryWrapper = ({
onChangeQueryOptions={onChangeQueryOptions}
index={index}
/>
<ExpressionStatusIndicator onSetCondition={() => onSetCondition(query.refId)} isCondition={isAlertCondition} />
{isAdvancedMode && (
<ExpressionStatusIndicator
onSetCondition={() => onSetCondition(query.refId)}
isCondition={isAlertCondition}
/>
)}
</Stack>
);
}
@ -160,6 +180,7 @@ export const QueryWrapper = ({
<div className={styles.wrapper}>
<QueryEditorRow<AlertDataQuery>
alerting
hideActionButtons={!isAdvancedMode}
collapsable={false}
dataSource={dsSettings}
onDataSourceLoaded={setDsInstance}
@ -174,7 +195,9 @@ export const QueryWrapper = ({
onAddQuery={() => onDuplicateQuery(cloneDeep(query))}
onRunQuery={onRunQueries}
queries={editorQueries}
renderHeaderExtras={() => <HeaderExtras query={query} index={index} error={error} />}
renderHeaderExtras={() => (
<HeaderExtras query={query} index={index} error={error} isAdvancedMode={isAdvancedMode} />
)}
app={CoreApp.UnifiedAlerting}
hideHideQueryButton={true}
/>

@ -1,15 +1,19 @@
import { css, cx } from '@emotion/css';
import { ReactElement } from 'react';
import * as React from 'react';
import { ReactElement } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { FieldSet, Text, useStyles2, Stack } from '@grafana/ui';
import { FieldSet, InlineSwitch, Stack, Text, useStyles2 } from '@grafana/ui';
export interface RuleEditorSectionProps {
title: string;
stepNo: number;
description?: string | ReactElement;
fullWidth?: boolean;
switchMode?: {
isAdvancedMode: boolean;
setAdvancedMode: (isAdvanced: boolean) => void;
};
}
export const RuleEditorSection = ({
@ -18,17 +22,33 @@ export const RuleEditorSection = ({
children,
fullWidth = false,
description,
switchMode,
}: React.PropsWithChildren<RuleEditorSectionProps>) => {
const styles = useStyles2(getStyles);
return (
<div className={styles.parent}>
<FieldSet
className={cx(fullWidth && styles.fullWidth)}
label={
<Text variant="h3">
{stepNo}. {title}
</Text>
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Text variant="h3">
{stepNo}. {title}
</Text>
{switchMode && (
<Text variant="bodySmall">
<InlineSwitch
id="query-and-expressions-advanced-options"
value={switchMode.isAdvancedMode}
onChange={(event) => {
switchMode.setAdvancedMode(event.currentTarget.checked);
}}
label="Advanced options"
showLabel
transparent
/>
</Text>
)}
</Stack>
}
>
<Stack direction="column">

@ -35,13 +35,14 @@ import { RuleFormType, RuleFormValues } from '../../../types/rule-form';
import {
DEFAULT_GROUP_EVALUATION_INTERVAL,
MANUAL_ROUTING_KEY,
SIMPLIFIED_QUERY_EDITOR_KEY,
formValuesFromExistingRule,
formValuesToRulerGrafanaRuleDTO,
formValuesToRulerRuleDTO,
getDefaultFormValues,
getDefaultQueries,
ignoreHiddenQueries,
normalizeDefaultAnnotations,
formValuesToRulerGrafanaRuleDTO,
formValuesToRulerRuleDTO,
} from '../../../utils/rule-form';
import { fromRulerRule, fromRulerRuleAndRuleGroupIdentifier, stringifyIdentifier } from '../../../utils/rule-id';
import { GrafanaRuleExporter } from '../../export/GrafanaRuleExporter';
@ -135,15 +136,6 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
trackAlertRuleFormSaved({ formAction: existing ? 'update' : 'create', ruleType: values.type });
// when creating a new rule, we save the manual routing setting in local storage
if (!existing) {
if (values.manualRouting) {
localStorage.setItem(MANUAL_ROUTING_KEY, 'true');
} else {
localStorage.setItem(MANUAL_ROUTING_KEY, 'false');
}
}
const ruleDefinition = grafanaTypeRule ? formValuesToRulerGrafanaRuleDTO(values) : formValuesToRulerRuleDTO(values);
const ruleGroupIdentifier = existing
@ -153,6 +145,8 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
// @TODO what is "evaluateEvery" being used for?
// @TODO move this to a hook too to make sure the logic here is tested for regressions?
if (!existing) {
// when creating a new rule, we save the manual routing setting , and editorSettings.simplifiedQueryEditor to the local storage
storeInLocalStorageValues(values);
await addRuleToRuleGroup.execute(ruleGroupIdentifier, ruleDefinition, values.evaluateEvery);
} else {
const ruleIdentifier = fromRulerRuleAndRuleGroupIdentifier(ruleGroupIdentifier, existing.rule);
@ -353,6 +347,21 @@ function formValuesFromPrefill(rule: Partial<RuleFormValues>): RuleFormValues {
});
}
function storeInLocalStorageValues(values: RuleFormValues) {
if (values.manualRouting) {
localStorage.setItem(MANUAL_ROUTING_KEY, 'true');
} else {
localStorage.setItem(MANUAL_ROUTING_KEY, 'false');
}
if (values.editorSettings) {
if (values.editorSettings.simplifiedQueryEditor) {
localStorage.setItem(SIMPLIFIED_QUERY_EDITOR_KEY, 'true');
} else {
localStorage.setItem(SIMPLIFIED_QUERY_EDITOR_KEY, 'false');
}
}
}
const getStyles = (theme: GrafanaTheme2) => ({
buttonSpinner: css({
marginRight: theme.spacing(1),

@ -3,15 +3,34 @@ import { cloneDeep } from 'lodash';
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { getDefaultRelativeTimeRange, GrafanaTheme2 } from '@grafana/data';
import { getDefaultRelativeTimeRange, GrafanaTheme2, ReducerID } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { config, getDataSourceSrv } from '@grafana/runtime';
import { Alert, Button, Dropdown, Field, Icon, Menu, MenuItem, Stack, Tooltip, useStyles2 } from '@grafana/ui';
import {
Alert,
Button,
ConfirmModal,
Dropdown,
Field,
Icon,
Menu,
MenuItem,
Stack,
Tooltip,
useStyles2,
} from '@grafana/ui';
import { Text } from '@grafana/ui/src/components/Text/Text';
import { EvalFunction } from 'app/features/alerting/state/alertDef';
import { isExpressionQuery } from 'app/features/expressions/guards';
import { ExpressionDatasourceUID, ExpressionQueryType, expressionTypes } from 'app/features/expressions/types';
import {
ExpressionDatasourceUID,
ExpressionQuery,
ExpressionQueryType,
expressionTypes,
ReducerMode,
} from 'app/features/expressions/types';
import { useDispatch } from 'app/types';
import { AlertQuery } from 'app/types/unified-alerting-dto';
import { AlertDataQuery, AlertQuery } from 'app/types/unified-alerting-dto';
import { useRulesSourcesWithRuler } from '../../../hooks/useRuleSourcesWithRuler';
import { fetchAllPromBuildInfoAction } from '../../../state/actions';
@ -34,6 +53,14 @@ import { RuleEditorSection } from '../RuleEditorSection';
import { errorFromCurrentCondition, errorFromPreviewData, findRenamedDataQueryReferences, refIdExists } from '../util';
import { CloudDataSourceSelector } from './CloudDataSourceSelector';
import {
getSimpleConditionFromExpressions,
SIMPLE_CONDITION_QUERY_ID,
SIMPLE_CONDITION_REDUCER_ID,
SIMPLE_CONDITION_THRESHOLD_ID,
SimpleCondition,
SimpleConditionEditor,
} from './SimpleCondition';
import { SmartAlertTypeDetector } from './SmartAlertTypeDetector';
import { DESCRIPTIONS } from './descriptions';
import {
@ -44,6 +71,7 @@ import {
queriesAndExpressionsReducer,
removeExpression,
removeExpressions,
resetToSimpleCondition,
rewireExpressions,
setDataQueries,
setRecordingRulesQueries,
@ -54,6 +82,44 @@ import {
} from './reducer';
import { useAlertQueryRunner } from './useAlertQueryRunner';
export function areQueriesTransformableToSimpleCondition(
dataQueries: Array<AlertQuery<AlertDataQuery | ExpressionQuery>>,
expressionQueries: Array<AlertQuery<ExpressionQuery>>
) {
if (dataQueries.length !== 1) {
return false;
}
if (expressionQueries.length !== 2) {
return false;
}
const query = dataQueries[0];
if (query.refId !== SIMPLE_CONDITION_QUERY_ID) {
return false;
}
const reduceExpressionIndex = expressionQueries.findIndex(
(query) => query.model.type === ExpressionQueryType.reduce && query.refId === SIMPLE_CONDITION_REDUCER_ID
);
const reduceExpression = expressionQueries.at(reduceExpressionIndex);
const reduceOk =
reduceExpression &&
reduceExpressionIndex === 0 &&
(reduceExpression.model.settings?.mode === ReducerMode.Strict ||
reduceExpression.model.settings?.mode === undefined);
const thresholdExpressionIndex = expressionQueries.findIndex(
(query) => query.model.type === ExpressionQueryType.threshold && query.refId === SIMPLE_CONDITION_THRESHOLD_ID
);
const thresholdExpression = expressionQueries.at(thresholdExpressionIndex);
const conditions = thresholdExpression?.model.conditions ?? [];
const thresholdOk =
thresholdExpression && thresholdExpressionIndex === 1 && conditions[0]?.unloadEvaluator === undefined;
return Boolean(reduceOk) && Boolean(thresholdOk);
}
interface Props {
editingExistingRule: boolean;
onDataChange: (error: string) => void;
@ -69,18 +135,59 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
} = useFormContext<RuleFormValues>();
const { queryPreviewData, runQueries, cancelQueries, isPreviewLoading, clearPreviewData } = useAlertQueryRunner();
const isSwitchModeEnabled = config.featureToggles.alertingQueryAndExpressionsStepMode ?? false;
const initialState = {
queries: getValues('queries'),
};
const [{ queries }, dispatch] = useReducer(queriesAndExpressionsReducer, initialState);
const [type, condition, dataSourceName] = watch(['type', 'condition', 'dataSourceName']);
// data queries only
const dataQueries = useMemo(() => {
return queries.filter((query) => !isExpressionQuery(query.model));
}, [queries]);
// expression queries only
const expressionQueries = useMemo(() => {
return queries.filter((query) => isExpressionQueryInAlert(query));
}, [queries]);
const [type, condition, dataSourceName, editorSettings] = watch([
'type',
'condition',
'dataSourceName',
'editorSettings',
]);
//if its a new rule, look at the local storage
const isGrafanaAlertingType = isGrafanaAlertingRuleByType(type);
const isRecordingRuleType = isCloudRecordingRuleByType(type);
const isCloudAlertRuleType = isCloudAlertingRuleByType(type);
const isAdvancedMode = editorSettings?.simplifiedQueryEditor !== true || !isGrafanaAlertingType;
const [showResetModeModal, setShowResetModal] = useState(false);
const [simpleCondition, setSimpleCondition] = useState<SimpleCondition>(
isGrafanaAlertingType && areQueriesTransformableToSimpleCondition(dataQueries, expressionQueries)
? getSimpleConditionFromExpressions(expressionQueries)
: {
whenField: ReducerID.last,
evaluator: {
params: [0],
type: EvalFunction.IsAbove,
},
}
);
// If we switch to simple mode we need to update the simple condition with the data in the queries reducer
useEffect(() => {
if (!isAdvancedMode && isGrafanaAlertingType) {
setSimpleCondition(getSimpleConditionFromExpressions(expressionQueries));
}
}, [isAdvancedMode, expressionQueries, isGrafanaAlertingType]);
const dispatchReduxAction = useDispatch();
useEffect(() => {
dispatchReduxAction(fetchAllPromBuildInfoAction());
@ -95,10 +202,15 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
// Grafana Managed rules and recording rules do
return;
}
runQueries(getValues('queries'), condition || (getValues('condition') ?? ''));
// we need to be sure the condition is set once we switch to simple mode
if (!isAdvancedMode) {
setValue('condition', SIMPLE_CONDITION_THRESHOLD_ID);
runQueries(getValues('queries'), SIMPLE_CONDITION_THRESHOLD_ID);
} else {
runQueries(getValues('queries'), condition || (getValues('condition') ?? ''));
}
},
[isCloudAlertRuleType, runQueries, getValues]
[isCloudAlertRuleType, runQueries, getValues, isAdvancedMode, setValue]
);
// whenever we update the queries we have to update the form too
@ -108,16 +220,6 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
const noCompatibleDataSources = getDefaultOrFirstCompatibleDataSource() === undefined;
// data queries only
const dataQueries = useMemo(() => {
return queries.filter((query) => !isExpressionQuery(query.model));
}, [queries]);
// expression queries only
const expressionQueries = useMemo(() => {
return queries.filter((query) => isExpressionQuery(query.model));
}, [queries]);
const emptyQueries = queries.length === 0;
// apply some validations and asserts to the results of the evaluation when creating or editing
@ -368,168 +470,221 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
]);
const { sectionTitle, helpLabel, helpContent, helpLink } = DESCRIPTIONS[type ?? RuleFormType.grafana];
if (!type) {
return null;
}
return (
<RuleEditorSection
stepNo={2}
title={sectionTitle}
description={
<Stack direction="row" gap={0.5} alignItems="center">
<Text variant="bodySmall" color="secondary">
{helpLabel}
</Text>
<NeedHelpInfo
contentText={helpContent}
externalLink={helpLink}
linkText={'Read more on our documentation website'}
title={helpLabel}
/>
</Stack>
}
>
{/* This is the cloud data source selector */}
{isDataSourceManagedRuleByType(type) && (
<CloudDataSourceSelector onChangeCloudDatasource={onChangeCloudDatasource} disabled={editingExistingRule} />
)}
{/* 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={() => runQueriesPreview()}
onChangeQuery={onChangeRecordingRulesQueries}
panelData={queryPreviewData}
/>
</Field>
)}
{/* This is the PromQL Editor for Cloud rules */}
{isCloudAlertRuleType && dataSourceName && (
<Stack direction="column">
const switchMode =
isGrafanaAlertingType && isSwitchModeEnabled
? {
isAdvancedMode,
setAdvancedMode: (isAdvanced: boolean) => {
if (!isAdvanced) {
if (!areQueriesTransformableToSimpleCondition(dataQueries, expressionQueries)) {
setShowResetModal(true);
return;
}
}
setValue('editorSettings', { simplifiedQueryEditor: !isAdvanced });
},
}
: undefined;
return (
<>
<RuleEditorSection
stepNo={2}
title={sectionTitle}
fullWidth={true}
description={
<Stack direction="row" gap={0.5} alignItems="center">
<Text variant="bodySmall" color="secondary">
{helpLabel}
</Text>
<NeedHelpInfo
contentText={helpContent}
externalLink={helpLink}
linkText={'Read more on our documentation website'}
title={helpLabel}
/>
</Stack>
}
switchMode={switchMode}
>
{/* This is the cloud data source selector */}
{isDataSourceManagedRuleByType(type) && (
<CloudDataSourceSelector onChangeCloudDatasource={onChangeCloudDatasource} disabled={editingExistingRule} />
)}
{/* This is the PromQL Editor for recording rules */}
{isRecordingRuleType && dataSourceName && (
<Field error={errors.expression?.message} invalid={!!errors.expression?.message}>
<Controller
name="expression"
render={({ field: { ref, ...field } }) => {
return (
<ExpressionEditor
{...field}
dataSourceName={dataSourceName}
showPreviewAlertsButton={!isRecordingRuleType}
onChange={onChangeExpression}
/>
);
}}
control={control}
rules={{
required: { value: true, message: 'A valid expression is required' },
}}
<RecordingRuleEditor
dataSourceName={dataSourceName}
queries={queries}
runQueries={() => runQueriesPreview()}
onChangeQuery={onChangeRecordingRulesQueries}
panelData={queryPreviewData}
/>
</Field>
<SmartAlertTypeDetector
editingExistingRule={editingExistingRule}
queries={queries}
rulesSourcesWithRuler={rulesSourcesWithRuler}
onClickSwitch={onClickSwitch}
/>
</Stack>
)}
{/* This is the editor for Grafana managed rules and Grafana managed recording rules */}
{isGrafanaManagedRuleByType(type) && (
<Stack direction="column">
{/* Data Queries */}
<QueryEditor
queries={dataQueries}
expressions={expressionQueries}
onRunQueries={() => runQueriesPreview()}
onChangeQueries={onChangeQueries}
onDuplicateQuery={onDuplicateQuery}
panelData={queryPreviewData}
condition={condition}
onSetCondition={handleSetCondition}
/>
<Tooltip content={'You appear to have no compatible data sources'} show={noCompatibleDataSources}>
<Button
type="button"
onClick={() => {
dispatch(addNewDataQuery());
}}
variant="secondary"
data-testid={selectors.components.QueryTab.addQuery}
disabled={noCompatibleDataSources}
className={styles.addQueryButton}
>
Add query
</Button>
</Tooltip>
{/* We only show Switch for Grafana managed alerts */}
{isGrafanaAlertingType && (
)}
{/* This is the PromQL Editor for Cloud rules */}
{isCloudAlertRuleType && dataSourceName && (
<Stack direction="column">
<Field error={errors.expression?.message} invalid={!!errors.expression?.message}>
<Controller
name="expression"
render={({ field: { ref, ...field } }) => {
return (
<ExpressionEditor
{...field}
dataSourceName={dataSourceName}
showPreviewAlertsButton={!isRecordingRuleType}
onChange={onChangeExpression}
/>
);
}}
control={control}
rules={{
required: { value: true, message: 'A valid expression is required' },
}}
/>
</Field>
<SmartAlertTypeDetector
editingExistingRule={editingExistingRule}
rulesSourcesWithRuler={rulesSourcesWithRuler}
queries={queries}
rulesSourcesWithRuler={rulesSourcesWithRuler}
onClickSwitch={onClickSwitch}
/>
)}
{/* Expression Queries */}
<Stack direction="column" gap={0}>
<Text element="h5">Expressions</Text>
<Text variant="bodySmall" color="secondary">
Manipulate data returned from queries with math and other operations.
</Text>
</Stack>
<ExpressionsEditor
queries={queries}
panelData={queryPreviewData}
condition={condition}
onSetCondition={handleSetCondition}
onRemoveExpression={(refId) => {
dispatch(removeExpression(refId));
}}
onUpdateRefId={onUpdateRefId}
onUpdateExpressionType={(refId, type) => {
dispatch(updateExpressionType({ refId, type }));
}}
onUpdateQueryExpression={(model) => {
dispatch(updateExpression(model));
}}
/>
{/* action buttons */}
<Stack direction="row">
{config.expressionsEnabled && <TypeSelectorButton onClickType={onClickType} />}
{isPreviewLoading && (
<Button icon="spinner" type="button" variant="destructive" onClick={cancelQueries}>
Cancel
</Button>
)}
{/* This is the editor for Grafana managed rules and Grafana managed recording rules */}
{isGrafanaManagedRuleByType(type) && (
<Stack direction="column">
{/* Data Queries */}
<QueryEditor
queries={dataQueries}
expressions={expressionQueries}
onRunQueries={() => runQueriesPreview()}
onChangeQueries={onChangeQueries}
onDuplicateQuery={onDuplicateQuery}
panelData={queryPreviewData}
condition={condition}
onSetCondition={handleSetCondition}
/>
{isAdvancedMode && (
<Tooltip content={'You appear to have no compatible data sources'} show={noCompatibleDataSources}>
<Button
type="button"
onClick={() => {
dispatch(addNewDataQuery());
}}
variant="secondary"
data-testid={selectors.components.QueryTab.addQuery}
disabled={noCompatibleDataSources}
className={styles.addQueryButton}
>
Add query
</Button>
</Tooltip>
)}
{/* We only show Switch for Grafana managed alerts */}
{isGrafanaAlertingType && isAdvancedMode && (
<SmartAlertTypeDetector
editingExistingRule={editingExistingRule}
rulesSourcesWithRuler={rulesSourcesWithRuler}
queries={queries}
onClickSwitch={onClickSwitch}
/>
)}
{!isPreviewLoading && (
<Button
data-testid={selectors.components.AlertRules.previewButton}
icon="sync"
type="button"
onClick={() => runQueriesPreview()}
disabled={emptyQueries}
>
Preview
</Button>
{/* Expression Queries */}
{isAdvancedMode && isGrafanaAlertingType && (
<>
<Stack direction="column" gap={0}>
<Text element="h5">Expressions</Text>
<Text variant="bodySmall" color="secondary">
Manipulate data returned from queries with math and other operations.
</Text>
</Stack>
<ExpressionsEditor
queries={queries}
panelData={queryPreviewData}
condition={condition}
onSetCondition={handleSetCondition}
onRemoveExpression={(refId) => {
dispatch(removeExpression(refId));
}}
onUpdateRefId={onUpdateRefId}
onUpdateExpressionType={(refId, type) => {
dispatch(updateExpressionType({ refId, type }));
}}
onUpdateQueryExpression={(model) => {
dispatch(updateExpression(model));
}}
/>
</>
)}
{/* action buttons */}
<Stack direction="column">
{!isAdvancedMode && (
<SimpleConditionEditor
simpleCondition={simpleCondition}
onChange={setSimpleCondition}
expressionQueriesList={expressionQueries}
dispatch={dispatch}
previewData={queryPreviewData[condition ?? '']}
/>
)}
<Stack direction="row">
{isAdvancedMode && config.expressionsEnabled && <TypeSelectorButton onClickType={onClickType} />}
{isPreviewLoading && (
<Button icon="spinner" type="button" variant="destructive" onClick={cancelQueries}>
Cancel
</Button>
)}
{!isPreviewLoading && (
<Button
data-testid={selectors.components.AlertRules.previewButton}
icon="sync"
type="button"
onClick={() => runQueriesPreview()}
disabled={emptyQueries}
>
Preview
</Button>
)}
</Stack>
</Stack>
{/* No Queries */}
{emptyQueries && (
<Alert title="No queries or expressions have been configured" severity="warning">
Create at least one query or expression to be alerted on
</Alert>
)}
</Stack>
{/* No Queries */}
{emptyQueries && (
<Alert title="No queries or expressions have been configured" severity="warning">
Create at least one query or expression to be alerted on
</Alert>
)}
</Stack>
)}
</RuleEditorSection>
)}
</RuleEditorSection>
<ConfirmModal
isOpen={showResetModeModal}
title="Switching to simple mode"
body="The selected queries and expressions cannot be converted to simple mode. Switching will remove them. Do you want to proceed?"
confirmText="Yes"
icon="exclamation-triangle"
onConfirm={() => {
setValue('editorSettings', { simplifiedQueryEditor: true });
setShowResetModal(false);
dispatch(resetToSimpleCondition());
}}
onDismiss={() => setShowResetModal(false)}
/>
</>
);
};
@ -602,3 +757,9 @@ const useSetExpressionAndDataSource = () => {
}
};
};
function isExpressionQueryInAlert(
query: AlertQuery<AlertDataQuery | ExpressionQuery>
): query is AlertQuery<ExpressionQuery> {
return isExpressionQuery(query.model);
}

@ -0,0 +1,241 @@
import { css } from '@emotion/css';
import { produce } from 'immer';
import { Dispatch, FormEvent } from 'react';
import { UnknownAction } from 'redux';
import { GrafanaTheme2, PanelData, ReducerID, SelectableValue } from '@grafana/data';
import { ButtonSelect, InlineField, InlineFieldRow, Input, Select, Stack, Text, useStyles2 } from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
import { EvalFunction } from 'app/features/alerting/state/alertDef';
import { ExpressionQuery, ExpressionQueryType, reducerTypes, thresholdFunctions } from 'app/features/expressions/types';
import { getReducerType } from 'app/features/expressions/utils/expressionTypes';
import { AlertQuery } from 'app/types/unified-alerting-dto';
import { ExpressionResult } from '../../expressions/Expression';
import { updateExpression } from './reducer';
export const SIMPLE_CONDITION_QUERY_ID = 'A';
export const SIMPLE_CONDITION_REDUCER_ID = 'B';
export const SIMPLE_CONDITION_THRESHOLD_ID = 'C';
export interface SimpleCondition {
whenField: string;
evaluator: {
params: number[];
type: EvalFunction;
};
}
/**
* This is the simple condition editor if the user is in the simple mode in the query section
*/
export interface SimpleConditionEditorProps {
simpleCondition: SimpleCondition;
onChange: (condition: SimpleCondition) => void;
expressionQueriesList: Array<AlertQuery<ExpressionQuery>>;
dispatch: Dispatch<UnknownAction>;
previewData?: PanelData;
}
/**
*
* This represents the simple condition editor for the alerting query section
* The state for this simple condition is kept in the parent component
* But we have also to keep the reducer state in sync with this condition state (both kept in the parent)
*/
export const SimpleConditionEditor = ({
simpleCondition,
onChange,
expressionQueriesList,
dispatch,
previewData,
}: SimpleConditionEditorProps) => {
const onReducerTypeChange = (value: SelectableValue<string>) => {
onChange({ ...simpleCondition, whenField: value.value ?? ReducerID.last });
updateReduceExpression(value.value ?? ReducerID.last, expressionQueriesList, dispatch);
};
const isRange =
simpleCondition.evaluator.type === EvalFunction.IsWithinRange ||
simpleCondition.evaluator.type === EvalFunction.IsOutsideRange;
const thresholdFunction = thresholdFunctions.find((fn) => fn.value === simpleCondition.evaluator?.type);
const onEvalFunctionChange = (value: SelectableValue<EvalFunction>) => {
// change the condition kept in the parent
onChange({
...simpleCondition,
evaluator: { ...simpleCondition.evaluator, type: value.value ?? EvalFunction.IsAbove },
});
// update the reducer state where we store the queries
updateThresholdFunction(value.value ?? EvalFunction.IsAbove, expressionQueriesList, dispatch);
};
const onEvaluateValueChange = (event: FormEvent<HTMLInputElement>, index?: number) => {
if (isRange) {
const newParams = produce(simpleCondition.evaluator.params, (draft) => {
draft[index ?? 0] = parseFloat(event.currentTarget.value);
});
// update the condition kept in the parent
onChange({ ...simpleCondition, evaluator: { ...simpleCondition.evaluator, params: newParams } });
// update the reducer state where we store the queries
updateThresholdValue(parseFloat(event.currentTarget.value), index ?? 0, expressionQueriesList, dispatch);
} else {
// update the condition kept in the parent
onChange({
...simpleCondition,
evaluator: { ...simpleCondition.evaluator, params: [parseFloat(event.currentTarget.value)] },
});
// update the reducer state where we store the queries
updateThresholdValue(parseFloat(event.currentTarget.value), 0, expressionQueriesList, dispatch);
}
};
const styles = useStyles2(getStyles);
return (
<div className={styles.condition.wrapper}>
<Stack direction="column" gap={0} width="100%">
<header className={styles.condition.header}>
<Text variant="body">
<Trans i18nKey="alerting.simpleCondition.alertCondition">Alert condition</Trans>
</Text>
</header>
<InlineFieldRow>
<InlineField label="WHEN">
<Select
options={reducerTypes}
value={reducerTypes.find((o) => o.value === simpleCondition.whenField)}
onChange={onReducerTypeChange}
width={20}
/>
</InlineField>
<InlineField label="OF QUERY">
<Stack direction="row" gap={1} alignItems="center">
<ButtonSelect options={thresholdFunctions} onChange={onEvalFunctionChange} value={thresholdFunction} />
{isRange ? (
<>
<Input
type="number"
width={10}
value={simpleCondition.evaluator.params[0]}
onChange={(event) => onEvaluateValueChange(event, 0)}
/>
<div>
<Trans i18nKey="alerting.simpleCondition.ofQuery.To">TO</Trans>
</div>
<Input
type="number"
width={10}
value={simpleCondition.evaluator.params[1]}
onChange={(event) => onEvaluateValueChange(event, 1)}
/>
</>
) : (
<Input
type="number"
width={10}
onChange={onEvaluateValueChange}
value={simpleCondition.evaluator.params[0] || 0}
/>
)}
</Stack>
</InlineField>
</InlineFieldRow>
{previewData?.series && <ExpressionResult series={previewData?.series} isAlertCondition={true} />}
</Stack>
</div>
);
};
function updateReduceExpression(
reducer: string,
expressionQueriesList: Array<AlertQuery<ExpressionQuery>>,
dispatch: Dispatch<UnknownAction>
) {
const reduceExpression = expressionQueriesList.find(
(query) => query.model.type === ExpressionQueryType.reduce && query.model.refId === SIMPLE_CONDITION_REDUCER_ID
);
const newReduceExpression = reduceExpression
? produce(reduceExpression?.model, (draft) => {
if (draft && draft.conditions) {
draft.reducer = reducer;
draft.conditions[0].reducer.type = getReducerType(reducer) ?? ReducerID.last;
}
})
: undefined;
newReduceExpression && dispatch(updateExpression(newReduceExpression));
}
function updateThresholdFunction(
evaluator: EvalFunction,
expressionQueriesList: Array<AlertQuery<ExpressionQuery>>,
dispatch: Dispatch<UnknownAction>
) {
const thresholdExpression = expressionQueriesList.find(
(query) => query.model.type === ExpressionQueryType.threshold && query.model.refId === SIMPLE_CONDITION_THRESHOLD_ID
);
const newThresholdExpression = produce(thresholdExpression, (draft) => {
if (draft && draft.model.conditions) {
draft.model.conditions[0].evaluator.type = evaluator;
}
});
newThresholdExpression && dispatch(updateExpression(newThresholdExpression.model));
}
function updateThresholdValue(
value: number,
index: number,
expressionQueriesList: Array<AlertQuery<ExpressionQuery>>,
dispatch: Dispatch<UnknownAction>
) {
const thresholdExpression = expressionQueriesList.find(
(query) => query.model.type === ExpressionQueryType.threshold && query.model.refId === SIMPLE_CONDITION_THRESHOLD_ID
);
const newThresholdExpression = produce(thresholdExpression, (draft) => {
if (draft && draft.model.conditions) {
draft.model.conditions[0].evaluator.params[index] = value;
}
});
newThresholdExpression && dispatch(updateExpression(newThresholdExpression.model));
}
export function getSimpleConditionFromExpressions(expressions: Array<AlertQuery<ExpressionQuery>>): SimpleCondition {
const reduceExpression = expressions.find(
(query) => query.model.type === ExpressionQueryType.reduce && query.refId === SIMPLE_CONDITION_REDUCER_ID
);
const thresholdExpression = expressions.find(
(query) => query.model.type === ExpressionQueryType.threshold && query.refId === SIMPLE_CONDITION_THRESHOLD_ID
);
const conditionsFromThreshold = thresholdExpression?.model.conditions ?? [];
return {
whenField: reduceExpression?.model.reducer ?? ReducerID.last,
evaluator: {
params: [...conditionsFromThreshold[0]?.evaluator?.params] ?? [0],
type: conditionsFromThreshold[0]?.evaluator?.type ?? EvalFunction.IsAbove,
},
};
}
const getStyles = (theme: GrafanaTheme2) => ({
condition: {
wrapper: css({
display: 'flex',
border: `solid 1px ${theme.colors.border.medium}`,
flex: 1,
height: 'fit-content',
borderRadius: theme.shape.radius.default,
}),
header: css({
background: theme.colors.background.secondary,
padding: `${theme.spacing(0.5)} ${theme.spacing(1)}`,
borderBottom: `solid 1px ${theme.colors.border.weak}`,
flex: 1,
}),
},
});

@ -0,0 +1,121 @@
// QueryAndExpressionsStep.test.tsx
import { produce } from 'immer';
import { EvalFunction } from 'app/features/alerting/state/alertDef';
import { ExpressionQuery, ExpressionQueryType, ReducerMode } from 'app/features/expressions/types';
import { AlertDataQuery, AlertQuery } from 'app/types/unified-alerting-dto';
import { areQueriesTransformableToSimpleCondition } from '../QueryAndExpressionsStep';
import {
SIMPLE_CONDITION_QUERY_ID,
SIMPLE_CONDITION_REDUCER_ID,
SIMPLE_CONDITION_THRESHOLD_ID,
} from '../SimpleCondition';
const dataQuery: AlertQuery<AlertDataQuery | ExpressionQuery> = {
refId: SIMPLE_CONDITION_QUERY_ID,
datasourceUid: 'abc123',
queryType: '',
model: { refId: SIMPLE_CONDITION_QUERY_ID },
};
const reduceExpression: AlertQuery<ExpressionQuery> = {
refId: SIMPLE_CONDITION_REDUCER_ID,
queryType: 'expression',
datasourceUid: '__expr__',
model: {
type: ExpressionQueryType.reduce,
refId: SIMPLE_CONDITION_REDUCER_ID,
settings: { mode: ReducerMode.Strict },
},
};
const thresholdExpression: AlertQuery<ExpressionQuery> = {
refId: SIMPLE_CONDITION_THRESHOLD_ID,
queryType: 'expression',
datasourceUid: '__expr__',
model: {
type: ExpressionQueryType.threshold,
refId: SIMPLE_CONDITION_THRESHOLD_ID,
},
};
const expressionQueries: Array<AlertQuery<ExpressionQuery>> = [reduceExpression, thresholdExpression];
describe('areQueriesTransformableToSimpleCondition', () => {
it('should return false if dataQueries length is not 1', () => {
// zero dataQueries
expect(areQueriesTransformableToSimpleCondition([], expressionQueries)).toBe(false);
// more than one dataQueries
expect(areQueriesTransformableToSimpleCondition([dataQuery, dataQuery], expressionQueries)).toBe(false);
});
it('should return false if expressionQueries length is not 2', () => {
const dataQueries: Array<AlertQuery<AlertDataQuery | ExpressionQuery>> = [dataQuery];
const result = areQueriesTransformableToSimpleCondition(dataQueries, []);
expect(result).toBe(false);
});
it('should return false if the dataQuery refId does not match SIMPLE_CONDITION_QUERY_ID', () => {
const dataQueries: Array<AlertQuery<AlertDataQuery | ExpressionQuery>> = [
{ refId: 'notSimpleCondition', datasourceUid: 'abc123', queryType: '', model: { refId: 'notSimpleCondition' } },
];
const result = areQueriesTransformableToSimpleCondition(dataQueries, expressionQueries);
expect(result).toBe(false);
});
it('should return false if no reduce expression is found with correct type and refId', () => {
const dataQueries: Array<AlertQuery<AlertDataQuery | ExpressionQuery>> = [dataQuery];
const result = areQueriesTransformableToSimpleCondition(dataQueries, [
{ ...reduceExpression, refId: 'hello' },
thresholdExpression,
]);
expect(result).toBe(false);
});
it('should return false if no threshold expression is found with correct type and refId', () => {
const dataQueries: Array<AlertQuery<AlertDataQuery | ExpressionQuery>> = [dataQuery];
const result = areQueriesTransformableToSimpleCondition(dataQueries, [
reduceExpression,
{ ...thresholdExpression, refId: 'hello' },
]);
expect(result).toBe(false);
});
it('should return false if reduceExpression settings mode is not ReducerMode.Strict', () => {
const dataQueries: Array<AlertQuery<AlertDataQuery | ExpressionQuery>> = [dataQuery];
const transformedReduceExpression = produce(reduceExpression, (draft) => {
draft.model.settings = { mode: ReducerMode.DropNonNumbers };
});
const result = areQueriesTransformableToSimpleCondition(dataQueries, [
transformedReduceExpression,
thresholdExpression,
]);
expect(result).toBe(false);
});
it('should return false if thresholdExpression unloadEvaluator has a value', () => {
const dataQueries: Array<AlertQuery<AlertDataQuery | ExpressionQuery>> = [dataQuery];
const transformedThresholdExpression = produce(thresholdExpression, (draft) => {
draft.model.conditions = [
{
evaluator: { params: [1], type: EvalFunction.IsAbove },
unloadEvaluator: { params: [1], type: EvalFunction.IsAbove },
query: { params: ['A'] },
reducer: { params: [], type: 'avg' },
type: 'query',
},
];
});
const result = areQueriesTransformableToSimpleCondition(dataQueries, [
reduceExpression,
transformedThresholdExpression,
]);
expect(result).toBe(false);
});
it('should return true when all conditions are met', () => {
const dataQueries: Array<AlertQuery<AlertDataQuery | ExpressionQuery>> = [dataQuery];
const result = areQueriesTransformableToSimpleCondition(dataQueries, expressionQueries);
expect(result).toBe(true);
});
});

@ -16,6 +16,7 @@ import { AlertQuery } from 'app/types/unified-alerting-dto';
import { logError } from '../../../Analytics';
import { getDefaultOrFirstCompatibleDataSource } from '../../../utils/datasource';
import { getDefaultQueries } from '../../../utils/rule-form';
import { createDagFromQueries, getOriginOfRefId } from '../dag';
import { queriesWithUpdatedReferences, refIdExists } from '../util';
@ -58,6 +59,8 @@ export const updateExpressionTimeRange = createAction('updateExpressionTimeRange
export const updateMaxDataPoints = createAction<{ refId: string; maxDataPoints: number }>('updateMaxDataPoints');
export const updateMinInterval = createAction<{ refId: string; minInterval: string }>('updateMinInterval');
export const resetToSimpleCondition = createAction('resetToSimpleCondition');
export const setRecordingRulesQueries = createAction<{ recordingRuleQueries: AlertQuery[]; expression: string }>(
'setRecordingRulesQueries'
);
@ -65,6 +68,10 @@ export const setRecordingRulesQueries = createAction<{ recordingRuleQueries: Ale
export const queriesAndExpressionsReducer = createReducer(initialState, (builder) => {
// data queries actions
builder
// simple condition actions
.addCase(resetToSimpleCondition, (state) => {
state.queries = getDefaultQueries();
})
.addCase(duplicateQuery, (state, { payload }) => {
state.queries = addQuery(state.queries, payload);
})

@ -25,6 +25,10 @@ export interface AlertManagerManualRouting {
[key: string]: ContactPoint;
}
export interface SimplifiedEditor {
simplifiedQueryEditor: boolean;
}
export interface RuleFormValues {
// common
name: string;
@ -46,6 +50,7 @@ export interface RuleFormValues {
isPaused?: boolean;
manualRouting: boolean; // if true contactPoints are used. This field will not be used for saving the rule
contactPoints?: AlertManagerManualRouting;
editorSettings?: SimplifiedEditor;
metric?: string;
// cortex / loki rules

@ -9,6 +9,7 @@ exports[`formValuesToRulerGrafanaRuleDTO should correctly convert rule form valu
"data": [],
"exec_err_state": "Error",
"is_paused": false,
"metadata": undefined,
"no_data_state": "NoData",
"notification_settings": undefined,
"title": "",
@ -59,6 +60,7 @@ exports[`formValuesToRulerGrafanaRuleDTO should not save both instant and range
],
"exec_err_state": "Error",
"is_paused": false,
"metadata": undefined,
"no_data_state": "NoData",
"notification_settings": undefined,
"title": "",

@ -43,7 +43,13 @@ import {
type KVObject = { key: string; value: string };
import { EvalFunction } from '../../state/alertDef';
import { AlertManagerManualRouting, ContactPoint, RuleFormType, RuleFormValues } from '../types/rule-form';
import {
AlertManagerManualRouting,
ContactPoint,
RuleFormType,
RuleFormValues,
SimplifiedEditor,
} from '../types/rule-form';
import { getRulesAccess } from './access-control';
import { Annotation, defaultAnnotations } from './constants';
@ -62,6 +68,7 @@ import { formatPrometheusDuration, parseInterval, safeParsePrometheusDuration }
export type PromOrLokiQuery = PromQuery | LokiQuery;
export const MANUAL_ROUTING_KEY = 'grafana.alerting.manualRouting';
export const SIMPLIFIED_QUERY_EDITOR_KEY = 'grafana.alerting.simplifiedQueryEditor';
// even if the min interval is < 1m we should default to 1m, but allow arbitrary values for minInterval > 1m
const GROUP_EVALUATION_MIN_INTERVAL_MS = safeParsePrometheusDuration(config.unifiedAlerting?.minInterval ?? '10s');
@ -98,6 +105,7 @@ export const getDefaultFormValues = (): RuleFormValues => {
overrideGrouping: false,
overrideTimings: false,
muteTimeIntervals: [],
editorSettings: getDefaultEditorSettings(),
// cortex / loki
namespace: '',
@ -119,6 +127,18 @@ export const getDefautManualRouting = () => {
return manualRouting !== 'false';
};
function getDefaultEditorSettings() {
const editorSettingsEnabled = config.featureToggles.alertingQueryAndExpressionsStepMode ?? false;
if (!editorSettingsEnabled) {
return undefined;
}
//then, check in local storage if the user has saved last rule with simplified query editor
const queryEditorSettings = localStorage.getItem(SIMPLIFIED_QUERY_EDITOR_KEY);
return {
simplifiedQueryEditor: queryEditorSettings !== 'false',
};
}
export function formValuesToRulerRuleDTO(values: RuleFormValues): RulerRuleDTO {
const { name, expression, forTime, forTimeUnit, keepFiringForTime, keepFiringForTimeUnit, type } = values;
@ -202,7 +222,11 @@ export function getNotificationSettingsForDTO(
}
return undefined;
}
function getEditorSettingsForDTO(simplifiedEditor: SimplifiedEditor) {
return {
simplified_query_and_expressions_section: simplifiedEditor.simplifiedQueryEditor,
};
}
export function formValuesToRulerGrafanaRuleDTO(values: RuleFormValues): PostableRuleGrafanaRuleDTO {
const {
name,
@ -222,6 +246,9 @@ export function formValuesToRulerGrafanaRuleDTO(values: RuleFormValues): Postabl
}
const notificationSettings = getNotificationSettingsForDTO(manualRouting, contactPoints);
const metadata = values.editorSettings
? { editor_settings: getEditorSettingsForDTO(values.editorSettings) }
: undefined;
const annotations = arrayToRecord(cleanAnnotations(values.annotations));
const labels = arrayToRecord(cleanLabels(values.labels));
@ -241,6 +268,7 @@ export function formValuesToRulerGrafanaRuleDTO(values: RuleFormValues): Postabl
no_data_state: noDataState,
exec_err_state: execErrState,
notification_settings: notificationSettings,
metadata,
},
annotations,
labels,
@ -307,6 +335,23 @@ export function getContactPointsFromDTO(ga: GrafanaRuleDefinition): AlertManager
return routingSettings;
}
function getEditorSettingsFromDTO(ga: GrafanaRuleDefinition) {
// we need to check if the feature toggle is enabled as it might be disabled after the rule was created with the feature enabled
if (!config.featureToggles.alertingQueryAndExpressionsStepMode) {
return undefined;
}
if (ga.metadata?.editor_settings) {
return {
simplifiedQueryEditor: ga.metadata.editor_settings.simplified_query_and_expressions_section,
};
}
return {
simplifiedQueryEditor: false,
};
}
export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleFormValues {
const { ruleSourceName, namespace, group, rule } = ruleWithLocation;
@ -353,6 +398,8 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF
contactPoints: routingSettings,
manualRouting: Boolean(routingSettings),
editorSettings: getEditorSettingsFromDTO(ga),
};
} else {
throw new Error('Unexpected type of rule for grafana rules source');

@ -1,7 +1,7 @@
import { isExpressionReference } from '@grafana/runtime/src/utils/DataSourceWithBackend';
import { DataQuery } from '@grafana/schema';
import { ExpressionQuery, ExpressionQueryType } from './types';
import { ExpressionQuery, ExpressionQueryType, ReducerType } from './types';
export const isExpressionQuery = (dataQuery?: DataQuery): dataQuery is ExpressionQuery => {
if (!dataQuery) {
@ -19,3 +19,20 @@ export const isExpressionQuery = (dataQuery?: DataQuery): dataQuery is Expressio
}
return Object.values(ExpressionQueryType).includes(expression.type);
};
export function isReducerType(value: string): value is ReducerType {
return [
'avg',
'min',
'max',
'sum',
'count',
'last',
'median',
'diff',
'diff_abs',
'percent_diff',
'percent_diff_abs',
'count_non_null',
].includes(value);
}

@ -1,7 +1,8 @@
import { ReducerID } from '@grafana/data';
import { EvalFunction } from '../../alerting/state/alertDef';
import { ClassicCondition, ExpressionQuery, ExpressionQueryType } from '../types';
import { isReducerType } from '../guards';
import { ClassicCondition, ExpressionQuery, ExpressionQueryType, ReducerType } from '../types';
export const getDefaults = (query: ExpressionQuery) => {
switch (query.type) {
@ -57,3 +58,14 @@ export const defaultCondition: ClassicCondition = {
type: EvalFunction.IsAbove,
},
};
/**
* Returns the ReducerType if the value is a valid ReducerType, otherwise undefined
* @param value string
*/
export function getReducerType(value: string): ReducerType | undefined {
if (isReducerType(value)) {
return value;
}
return undefined;
}

@ -2,8 +2,8 @@
import classNames from 'classnames';
import { cloneDeep, filter, has, uniqBy, uniqueId } from 'lodash';
import pluralize from 'pluralize';
import { PureComponent, ReactNode } from 'react';
import * as React from 'react';
import { PureComponent, ReactNode } from 'react';
// Utils & Services
import {
@ -35,7 +35,7 @@ import {
QueryOperationRow,
QueryOperationRowRenderProps,
} from 'app/core/components/QueryOperationRow/QueryOperationRow';
import { t, Trans } from 'app/core/internationalization';
import { Trans, t } from 'app/core/internationalization';
import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
@ -64,6 +64,7 @@ export interface Props<TQuery extends DataQuery> {
history?: Array<HistoryItem<TQuery>>;
eventBus?: EventBusExtended;
alerting?: boolean;
hideActionButtons?: boolean;
onQueryCopied?: () => void;
onQueryRemoved?: () => void;
onQueryToggled?: (queryStatus?: boolean | undefined) => void;
@ -527,7 +528,7 @@ export class QueryEditorRow<TQuery extends DataQuery> extends PureComponent<Prop
};
render() {
const { query, index, visualization, collapsable } = this.props;
const { query, index, visualization, collapsable, hideActionButtons } = this.props;
const { datasource, showingHelp, data } = this.state;
const isHidden = query.hide;
const error =
@ -548,11 +549,11 @@ export class QueryEditorRow<TQuery extends DataQuery> extends PureComponent<Prop
<div data-testid="query-editor-row" aria-label={selectors.components.QueryEditorRows.rows}>
<QueryOperationRow
id={this.id}
draggable={true}
draggable={!hideActionButtons}
collapsable={collapsable}
index={index}
headerElement={this.renderHeader}
actions={this.renderActions}
actions={hideActionButtons ? undefined : this.renderActions}
onOpen={this.onOpen}
>
<div className={rowClasses} id={this.id}>

@ -219,6 +219,10 @@ export interface GrafanaNotificationSettings {
repeat_interval?: string;
mute_time_intervals?: string[];
}
export interface GrafanaEditorSettings {
simplified_query_and_expressions_section: boolean;
}
export interface PostableGrafanaRuleDefinition {
uid?: string;
title: string;
@ -228,6 +232,9 @@ export interface PostableGrafanaRuleDefinition {
data: AlertQuery[];
is_paused?: boolean;
notification_settings?: GrafanaNotificationSettings;
metadata?: {
editor_settings?: GrafanaEditorSettings;
};
record?: {
metric: string;
from: string;

@ -244,6 +244,12 @@
"state": "State"
},
"save-query": "Save current search"
},
"simpleCondition": {
"alertCondition": "Alert condition",
"ofQuery": {
"To": "TO"
}
}
},
"annotations": {

@ -244,6 +244,12 @@
"state": "Ŝŧäŧę"
},
"save-query": "Ŝävę čūřřęʼnŧ şęäřčĥ"
},
"simpleCondition": {
"alertCondition": "Åľęřŧ čőʼnđįŧįőʼn",
"ofQuery": {
"To": "ŦØ"
}
}
},
"annotations": {

Loading…
Cancel
Save