mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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
This commit is contained in:
parent
c36f7aa92b
commit
536edee7bf
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
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}
|
||||
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} />
|
||||
)}
|
||||
<>
|
||||
<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}>
|
||||
<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">
|
||||
{/* 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>
|
||||
)}
|
||||
{!isPreviewLoading && (
|
||||
<Button
|
||||
data-testid={selectors.components.AlertRules.previewButton}
|
||||
icon="sync"
|
||||
type="button"
|
||||
onClick={() => runQueriesPreview()}
|
||||
disabled={emptyQueries}
|
||||
>
|
||||
Preview
|
||||
</Button>
|
||||
{/* We only show Switch for Grafana managed alerts */}
|
||||
{isGrafanaAlertingType && isAdvancedMode && (
|
||||
<SmartAlertTypeDetector
|
||||
editingExistingRule={editingExistingRule}
|
||||
rulesSourcesWithRuler={rulesSourcesWithRuler}
|
||||
queries={queries}
|
||||
onClickSwitch={onClickSwitch}
|
||||
/>
|
||||
)}
|
||||
{/* 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>
|
||||
)}
|
||||
</RuleEditorSection>
|
||||
|
||||
{/* 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>
|
||||
<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…
Reference in New Issue
Block a user