mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Improve default form values handling (#97564)
This PR refactors how rule form state is managed and relies less on prop drilling and removes dependency on redux store.
This commit is contained in:
parent
8b9d4d1358
commit
0bf31c14a7
@ -1555,19 +1555,6 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "3"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"]
|
||||
],
|
||||
"public/app/features/alerting/unified/CloneRuleEditor.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "2"]
|
||||
],
|
||||
"public/app/features/alerting/unified/ExistingRuleEditor.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "2"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "3"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"]
|
||||
],
|
||||
"public/app/features/alerting/unified/GrafanaRuleQueryViewer.tsx:5381": [
|
||||
[0, 0, 0, "\'@grafana/data/src/datetime/rangeutil\' import is restricted from being used by a pattern. Import from the public export instead.", "0"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
|
||||
@ -1606,12 +1593,6 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "6"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "7"]
|
||||
],
|
||||
"public/app/features/alerting/unified/RuleEditor.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"]
|
||||
],
|
||||
"public/app/features/alerting/unified/RuleViewer.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"]
|
||||
],
|
||||
@ -2563,7 +2544,8 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "7"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "8"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "9"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "10"]
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "10"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "11"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
|
||||
@ -2925,6 +2907,25 @@ exports[`better eslint`] = {
|
||||
"public/app/features/alerting/unified/plugins/PluginOriginBadge.tsx:5381": [
|
||||
[0, 0, 0, "\'@grafana/ui/src/components/Icon/utils\' import is restricted from being used by a pattern. Import from the public export instead.", "0"]
|
||||
],
|
||||
"public/app/features/alerting/unified/rule-editor/CloneRuleEditor.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "2"]
|
||||
],
|
||||
"public/app/features/alerting/unified/rule-editor/ExistingRuleEditor.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "2"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "3"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"]
|
||||
],
|
||||
"public/app/features/alerting/unified/rule-editor/RuleEditor.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"]
|
||||
],
|
||||
"public/app/features/alerting/unified/rule-list/FilterView.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"]
|
||||
],
|
||||
|
@ -214,7 +214,7 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] {
|
||||
pageClass: 'page-alerting',
|
||||
roles: evaluateAccess([AccessControlAction.AlertingRuleCreate, AccessControlAction.AlertingRuleExternalWrite]),
|
||||
component: importAlertingComponent(
|
||||
() => import(/* webpackChunkName: "AlertingRuleForm"*/ 'app/features/alerting/unified/RuleEditor')
|
||||
() => import(/* webpackChunkName: "AlertingRuleForm"*/ 'app/features/alerting/unified/rule-editor/RuleEditor')
|
||||
),
|
||||
},
|
||||
{
|
||||
@ -222,7 +222,7 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] {
|
||||
pageClass: 'page-alerting',
|
||||
roles: evaluateAccess([AccessControlAction.AlertingRuleUpdate, AccessControlAction.AlertingRuleExternalWrite]),
|
||||
component: importAlertingComponent(
|
||||
() => import(/* webpackChunkName: "AlertingRuleForm"*/ 'app/features/alerting/unified/RuleEditor')
|
||||
() => import(/* webpackChunkName: "AlertingRuleForm"*/ 'app/features/alerting/unified/rule-editor/RuleEditor')
|
||||
),
|
||||
},
|
||||
{
|
||||
|
@ -248,6 +248,7 @@ export const alertRuleApi = alertingApi.injectEndpoints({
|
||||
const { path, params } = rulerUrlBuilder(rulerConfig).namespace(namespace);
|
||||
return { url: path, params };
|
||||
},
|
||||
providesTags: (_result, _error, { namespace }) => [{ type: 'RuleNamespace', id: namespace }],
|
||||
}),
|
||||
|
||||
// TODO This should be probably a separate ruler API file
|
||||
|
@ -7,8 +7,8 @@ import { Alert, LoadingPlaceholder } from '@grafana/ui';
|
||||
|
||||
import { RuleIdentifier } from '../../../../../types/unified-alerting';
|
||||
import { useRuleWithLocation } from '../../hooks/useCombinedRule';
|
||||
import { formValuesFromExistingRule } from '../../rule-editor/formDefaults';
|
||||
import { stringifyErrorLike } from '../../utils/misc';
|
||||
import { formValuesFromExistingRule } from '../../utils/rule-form';
|
||||
import * as ruleId from '../../utils/rule-id';
|
||||
import { isGrafanaRulerRule } from '../../utils/rules';
|
||||
import { createRelativeUrl } from '../../utils/url';
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
import { getDefaultFormValues } from '../../rule-editor/formDefaults';
|
||||
import { RuleFormValues } from '../../types/rule-form';
|
||||
import { getDefaultFormValues } from '../../utils/rule-form';
|
||||
|
||||
import { ExpressionStatusIndicator } from './ExpressionStatusIndicator';
|
||||
|
||||
|
@ -6,9 +6,9 @@ import { byRole, byTestId } from 'testing-library-selector';
|
||||
import { DashboardSearchItemType } from '../../../../search/types';
|
||||
import { mockDashboardApi, setupMswServer } from '../../mockApi';
|
||||
import { mockDashboardDto, mockDashboardSearchItem } from '../../mocks';
|
||||
import { getDefaultFormValues } from '../../rule-editor/formDefaults';
|
||||
import { RuleFormValues } from '../../types/rule-form';
|
||||
import { Annotation } from '../../utils/constants';
|
||||
import { getDefaultFormValues } from '../../utils/rule-form';
|
||||
|
||||
import AnnotationsStep from './AnnotationsStep';
|
||||
|
||||
|
@ -1,12 +1,11 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { debounce, take, uniqueId } from 'lodash';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { uniqueId } from 'lodash';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Controller, FormProvider, RegisterOptions, useForm, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import {
|
||||
AsyncSelect,
|
||||
Box,
|
||||
Button,
|
||||
Field,
|
||||
@ -15,6 +14,7 @@ import {
|
||||
Input,
|
||||
Label,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
@ -22,17 +22,13 @@ import {
|
||||
useStyles2,
|
||||
} from '@grafana/ui';
|
||||
import { Trans, t } from 'app/core/internationalization';
|
||||
import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting';
|
||||
import { RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { LogMessages, logInfo } from '../../Analytics';
|
||||
import { alertRuleApi } from '../../api/alertRuleApi';
|
||||
import { GRAFANA_RULER_CONFIG } from '../../api/featureDiscoveryApi';
|
||||
import { useCombinedRuleNamespaces } from '../../hooks/useCombinedRuleNamespaces';
|
||||
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
|
||||
import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../../rule-editor/formDefaults';
|
||||
import { RuleFormValues } from '../../types/rule-form';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
|
||||
import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../../utils/rule-form';
|
||||
import {
|
||||
isGrafanaAlertingRuleByType,
|
||||
isGrafanaManagedRuleByType,
|
||||
@ -53,48 +49,38 @@ import { RuleEditorSection } from './RuleEditorSection';
|
||||
export const MIN_TIME_RANGE_STEP_S = 10; // 10 seconds
|
||||
export const MAX_GROUP_RESULTS = 1000;
|
||||
|
||||
export const useFolderGroupOptions = (folderUid: string, enableProvisionedGroups: boolean) => {
|
||||
const useFetchGroupsForFolder = (folderUid: string) => {
|
||||
// fetch the ruler rules from the database so we can figure out what other "groups" are already defined
|
||||
// for our folders
|
||||
const { isLoading: isLoadingRulerNamespace, currentData: rulerNamespace } =
|
||||
alertRuleApi.endpoints.rulerNamespace.useQuery(
|
||||
{
|
||||
namespace: folderUid,
|
||||
rulerConfig: GRAFANA_RULER_CONFIG,
|
||||
},
|
||||
{
|
||||
skip: !folderUid,
|
||||
refetchOnMountOrArgChange: true,
|
||||
}
|
||||
);
|
||||
|
||||
// There should be only one entry in the rulerNamespace object
|
||||
// However it uses folder name as key, so to avoid fetching folder name, we use Object.values
|
||||
const groupOptions = useMemo(() => {
|
||||
if (!rulerNamespace) {
|
||||
// still waiting for namespace information to be fetched
|
||||
return [];
|
||||
return alertRuleApi.endpoints.rulerNamespace.useQuery(
|
||||
{
|
||||
namespace: folderUid,
|
||||
rulerConfig: GRAFANA_RULER_CONFIG,
|
||||
},
|
||||
{
|
||||
refetchOnMountOrArgChange: true,
|
||||
skip: !folderUid,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const folderGroups = Object.values(rulerNamespace).flat() ?? [];
|
||||
const namespaceToGroupOptions = (rulerNamespace: RulerRulesConfigDTO, enableProvisionedGroups: boolean) => {
|
||||
const folderGroups = Object.values(rulerNamespace).flat();
|
||||
|
||||
return folderGroups
|
||||
.map<SelectableValue<string>>((group) => {
|
||||
const isProvisioned = isProvisionedGroup(group);
|
||||
return {
|
||||
label: group.name,
|
||||
value: group.name,
|
||||
description: group.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL,
|
||||
// we include provisioned folders, but disable the option to select them
|
||||
isDisabled: !enableProvisionedGroups ? isProvisioned : false,
|
||||
isProvisioned: isProvisioned,
|
||||
};
|
||||
})
|
||||
return folderGroups
|
||||
.map<SelectableValue<string>>((group) => {
|
||||
const isProvisioned = isProvisionedGroup(group);
|
||||
return {
|
||||
label: group.name,
|
||||
value: group.name,
|
||||
description: group.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL,
|
||||
// we include provisioned folders, but disable the option to select them
|
||||
isDisabled: !enableProvisionedGroups ? isProvisioned : false,
|
||||
isProvisioned: isProvisioned,
|
||||
};
|
||||
})
|
||||
|
||||
.sort(sortByLabel);
|
||||
}, [rulerNamespace, enableProvisionedGroups]);
|
||||
|
||||
return { groupOptions, loading: isLoadingRulerNamespace };
|
||||
.sort(sortByLabel);
|
||||
};
|
||||
|
||||
const isProvisionedGroup = (group: RulerRuleGroupDTO) => {
|
||||
@ -105,10 +91,6 @@ const sortByLabel = (a: SelectableValue<string>, b: SelectableValue<string>) =>
|
||||
return a.label?.localeCompare(b.label ?? '') || 0;
|
||||
};
|
||||
|
||||
const findGroupMatchingLabel = (group: SelectableValue<string>, query: string) => {
|
||||
return group.label?.toLowerCase().includes(query.toLowerCase());
|
||||
};
|
||||
|
||||
const forValidationOptions = (evaluateEvery: string): RegisterOptions<{ evaluateFor: string }> => ({
|
||||
required: {
|
||||
value: true,
|
||||
@ -149,24 +131,10 @@ const forValidationOptions = (evaluateEvery: string): RegisterOptions<{ evaluate
|
||||
},
|
||||
});
|
||||
|
||||
const useIsNewGroup = (folder: string, group: string) => {
|
||||
const { groupOptions } = useFolderGroupOptions(folder, false);
|
||||
|
||||
const groupIsInGroupOptions = useCallback(
|
||||
(group_: string) => groupOptions.some((groupInList: SelectableValue<string>) => groupInList.label === group_),
|
||||
[groupOptions]
|
||||
);
|
||||
return !groupIsInGroupOptions(group);
|
||||
};
|
||||
|
||||
export function GrafanaEvaluationBehaviorStep({
|
||||
evaluateEvery,
|
||||
setEvaluateEvery,
|
||||
existing,
|
||||
enableProvisionedGroups,
|
||||
}: {
|
||||
evaluateEvery: string;
|
||||
setEvaluateEvery: (value: string) => void;
|
||||
existing: boolean;
|
||||
enableProvisionedGroups: boolean;
|
||||
}) {
|
||||
@ -181,51 +149,39 @@ export function GrafanaEvaluationBehaviorStep({
|
||||
control,
|
||||
} = useFormContext<RuleFormValues>();
|
||||
|
||||
const [folder, group, type, isPaused, folderUid, folderName] = watch([
|
||||
'folder',
|
||||
const [group, type, isPaused, folder, evaluateEvery] = watch([
|
||||
'group',
|
||||
'type',
|
||||
'isPaused',
|
||||
'folder.uid',
|
||||
'folder.title',
|
||||
'folder',
|
||||
'evaluateEvery',
|
||||
]);
|
||||
|
||||
const isGrafanaAlertingRule = isGrafanaAlertingRuleByType(type);
|
||||
const isGrafanaRecordingRule = isGrafanaRecordingRuleByType(type);
|
||||
const { groupOptions, loading } = useFolderGroupOptions(folder?.uid ?? '', enableProvisionedGroups);
|
||||
const { currentData: rulerNamespace, isLoading: loadingGroups } = useFetchGroupsForFolder(folder?.uid ?? '');
|
||||
const [isEditingGroup, setIsEditingGroup] = useState(false);
|
||||
|
||||
const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules);
|
||||
const groupfoldersForGrafana = rulerRuleRequests[GRAFANA_RULES_SOURCE_NAME];
|
||||
const groupOptions = useMemo(() => {
|
||||
return rulerNamespace ? namespaceToGroupOptions(rulerNamespace, enableProvisionedGroups) : [];
|
||||
}, [enableProvisionedGroups, rulerNamespace]);
|
||||
|
||||
const grafanaNamespaces = useCombinedRuleNamespaces(GRAFANA_RULES_SOURCE_NAME);
|
||||
const existingNamespace = grafanaNamespaces.find((ns) => ns.uid === folderUid);
|
||||
const existingGroup = existingNamespace?.groups.find((g) => g.name === group);
|
||||
|
||||
const isNewGroup = useIsNewGroup(folderUid ?? '', group);
|
||||
const existingGroup = Object.values(rulerNamespace ?? {})
|
||||
.flat()
|
||||
.find((ruleGroup) => ruleGroup.name === group);
|
||||
const isNewGroup = !existingGroup && !loadingGroups;
|
||||
|
||||
// synchronize the evaluation interval with the group name when it's an existing group
|
||||
useEffect(() => {
|
||||
if (!isNewGroup && existingGroup?.interval) {
|
||||
setEvaluateEvery(existingGroup.interval);
|
||||
if (existingGroup) {
|
||||
setValue('evaluateEvery', existingGroup.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL);
|
||||
}
|
||||
}, [setEvaluateEvery, isNewGroup, setValue, existingGroup]);
|
||||
|
||||
const closeEditGroupModal = (saved = false) => {
|
||||
if (!saved) {
|
||||
logInfo(LogMessages.leavingRuleGroupEdit);
|
||||
}
|
||||
setIsEditingGroup(false);
|
||||
};
|
||||
}, [existingGroup, setValue]);
|
||||
|
||||
const closeEditGroupModal = () => setIsEditingGroup(false);
|
||||
const onOpenEditGroupModal = () => setIsEditingGroup(true);
|
||||
|
||||
const editGroupDisabled = groupfoldersForGrafana?.loading || isNewGroup || !folderUid || !group;
|
||||
const emptyNamespace: CombinedRuleNamespace = {
|
||||
name: folderName,
|
||||
rulesSource: GRAFANA_RULES_SOURCE_NAME,
|
||||
groups: [],
|
||||
};
|
||||
const emptyGroup: CombinedRuleGroup = { name: group, interval: evaluateEvery, rules: [], totals: {} };
|
||||
const editGroupDisabled = loadingGroups || isNewGroup || !folder?.uid || !group;
|
||||
|
||||
const [isCreatingEvaluationGroup, setIsCreatingEvaluationGroup] = useState(false);
|
||||
|
||||
@ -235,18 +191,6 @@ export function GrafanaEvaluationBehaviorStep({
|
||||
setIsCreatingEvaluationGroup(false);
|
||||
};
|
||||
|
||||
const getOptions = useCallback(
|
||||
async (query: string) => {
|
||||
const results = query ? groupOptions.filter((group) => findGroupMatchingLabel(group, query)) : groupOptions;
|
||||
return take(results, MAX_GROUP_RESULTS);
|
||||
},
|
||||
[groupOptions]
|
||||
);
|
||||
|
||||
const debouncedSearch = useMemo(() => {
|
||||
return debounce(getOptions, 300, { leading: true });
|
||||
}, [getOptions]);
|
||||
|
||||
const defaultGroupValue = group ? { value: group, label: group } : undefined;
|
||||
|
||||
const pauseContentText = isGrafanaRecordingRule
|
||||
@ -257,7 +201,7 @@ export function GrafanaEvaluationBehaviorStep({
|
||||
|
||||
const step = isGrafanaManagedRuleByType(type) ? 4 : 3;
|
||||
const label =
|
||||
isGrafanaManagedRuleByType(type) && !folder
|
||||
isGrafanaManagedRuleByType(type) && !folder?.uid
|
||||
? t(
|
||||
'alerting.rule-form.evaluation.select-folder-before',
|
||||
'Select a folder before setting evaluation group and interval'
|
||||
@ -284,21 +228,20 @@ export function GrafanaEvaluationBehaviorStep({
|
||||
>
|
||||
<Controller
|
||||
render={({ field: { ref, ...field }, fieldState }) => (
|
||||
<AsyncSelect
|
||||
disabled={!folder || loading}
|
||||
<Select
|
||||
disabled={!folder?.uid || loadingGroups}
|
||||
inputId="group"
|
||||
key={uniqueId()}
|
||||
{...field}
|
||||
onChange={(group) => {
|
||||
field.onChange(group.label ?? '');
|
||||
}}
|
||||
isLoading={loading}
|
||||
invalid={Boolean(folder) && !group && Boolean(fieldState.error)}
|
||||
loadOptions={debouncedSearch}
|
||||
isLoading={loadingGroups}
|
||||
invalid={Boolean(folder?.uid) && !group && Boolean(fieldState.error)}
|
||||
cacheOptions
|
||||
loadingMessage={'Loading groups...'}
|
||||
defaultValue={defaultGroupValue}
|
||||
defaultOptions={groupOptions}
|
||||
options={groupOptions}
|
||||
getOptionLabel={(option: SelectableValue<string>) => (
|
||||
<div>
|
||||
<span>{option.label}</span>
|
||||
@ -329,7 +272,7 @@ export function GrafanaEvaluationBehaviorStep({
|
||||
icon="plus"
|
||||
fill="outline"
|
||||
variant="secondary"
|
||||
disabled={!folder}
|
||||
disabled={!folder?.uid}
|
||||
data-testid={selectors.components.AlertRules.newEvaluationGroupButton}
|
||||
>
|
||||
<Trans i18nKey="alerting.rule-form.evaluation.new-group">New evaluation group</Trans>
|
||||
@ -339,22 +282,25 @@ export function GrafanaEvaluationBehaviorStep({
|
||||
<EvaluationGroupCreationModal
|
||||
onCreate={handleEvalGroupCreation}
|
||||
onClose={() => setIsCreatingEvaluationGroup(false)}
|
||||
groupfoldersForGrafana={groupfoldersForGrafana?.result}
|
||||
groupfoldersForGrafana={rulerNamespace}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{folderName && isEditingGroup && (
|
||||
{folder?.uid && isEditingGroup && (
|
||||
<EditRuleGroupModal
|
||||
namespace={existingNamespace ?? emptyNamespace}
|
||||
group={existingGroup ?? emptyGroup}
|
||||
folderUid={folderUid}
|
||||
ruleGroupIdentifier={{
|
||||
dataSourceName: GRAFANA_RULES_SOURCE_NAME,
|
||||
groupName: existingGroup?.name ?? '',
|
||||
namespaceName: folder?.uid ?? '',
|
||||
}}
|
||||
rulerConfig={GRAFANA_RULER_CONFIG}
|
||||
onClose={() => closeEditGroupModal()}
|
||||
intervalEditOnly
|
||||
hideFolder={true}
|
||||
/>
|
||||
)}
|
||||
{folderName && group && (
|
||||
{folder?.title && group && (
|
||||
<div className={styles.evaluationContainer}>
|
||||
<Stack direction="column" gap={0}>
|
||||
<div className={styles.marginTop}>
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { FormProvider, SubmitErrorHandler, UseFormWatch, useForm } from 'react-hook-form';
|
||||
import { useParams } from 'react-router-dom-v5-compat';
|
||||
|
||||
@ -22,7 +22,6 @@ import {
|
||||
isGrafanaRulerRulePaused,
|
||||
isRecordingRuleByType,
|
||||
} from 'app/features/alerting/unified/utils/rules';
|
||||
import { isExpressionQuery } from 'app/features/expressions/guards';
|
||||
import { RuleGroupIdentifier, RuleIdentifier, RuleWithLocation } from 'app/types/unified-alerting';
|
||||
import { PostableRuleGrafanaRuleDTO, RulerRuleDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
@ -40,24 +39,21 @@ import { shouldUsePrometheusRulesPrimary } from '../../../featureToggles';
|
||||
import { useDeleteRuleFromGroup } from '../../../hooks/ruleGroup/useDeleteRuleFromGroup';
|
||||
import { useAddRuleToRuleGroup, useUpdateRuleInRuleGroup } from '../../../hooks/ruleGroup/useUpsertRuleFromRuleGroup';
|
||||
import { useReturnTo } from '../../../hooks/useReturnTo';
|
||||
import { useURLSearchParams } from '../../../hooks/useURLSearchParams';
|
||||
import { RuleFormType, RuleFormValues } from '../../../types/rule-form';
|
||||
import { DataSourceType } from '../../../utils/datasource';
|
||||
import {
|
||||
DEFAULT_GROUP_EVALUATION_INTERVAL,
|
||||
defaultFormValuesForRuleType,
|
||||
formValuesFromExistingRule,
|
||||
formValuesFromPrefill,
|
||||
translateRouteParamToRuleType,
|
||||
} from '../../../rule-editor/formDefaults';
|
||||
import { RuleFormType, RuleFormValues } from '../../../types/rule-form';
|
||||
import {
|
||||
MANUAL_ROUTING_KEY,
|
||||
SIMPLIFIED_QUERY_EDITOR_KEY,
|
||||
formValuesFromExistingRule,
|
||||
formValuesToRulerGrafanaRuleDTO,
|
||||
formValuesToRulerRuleDTO,
|
||||
getDefaultFormValues,
|
||||
getDefaultQueries,
|
||||
ignoreHiddenQueries,
|
||||
normalizeDefaultAnnotations,
|
||||
} from '../../../utils/rule-form';
|
||||
import * as ruleId from '../../../utils/rule-id';
|
||||
import { fromRulerRule, fromRulerRuleAndRuleGroupIdentifier, stringifyIdentifier } from '../../../utils/rule-id';
|
||||
import { isGrafanaRecordingRuleByType } from '../../../utils/rules';
|
||||
import { createRelativeUrl } from '../../../utils/url';
|
||||
import { GrafanaRuleExporter } from '../../export/GrafanaRuleExporter';
|
||||
import { AlertRuleNameAndMetric } from '../AlertRuleNameInput';
|
||||
@ -68,12 +64,7 @@ import { GrafanaFolderAndLabelsStep } from '../GrafanaFolderAndLabelsStep';
|
||||
import { NotificationsStep } from '../NotificationsStep';
|
||||
import { RecordingRulesNameSpaceAndGroupStep } from '../RecordingRulesNameSpaceAndGroupStep';
|
||||
import { RuleInspector } from '../RuleInspector';
|
||||
import {
|
||||
QueryAndExpressionsStep,
|
||||
areQueriesTransformableToSimpleCondition,
|
||||
isExpressionQueryInAlert,
|
||||
} from '../query-and-alert-condition/QueryAndExpressionsStep';
|
||||
import { translateRouteParamToRuleType } from '../util';
|
||||
import { QueryAndExpressionsStep } from '../query-and-alert-condition/QueryAndExpressionsStep';
|
||||
|
||||
type Props = {
|
||||
existing?: RuleWithLocation;
|
||||
@ -85,9 +76,7 @@ const prometheusRulesPrimary = shouldUsePrometheusRulesPrimary();
|
||||
export const AlertRuleForm = ({ existing, prefill }: Props) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const notifyApp = useAppNotification();
|
||||
const [queryParams] = useURLSearchParams();
|
||||
const [showEditYaml, setShowEditYaml] = useState(false);
|
||||
const [evaluateEvery, setEvaluateEvery] = useState(existing?.group.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL);
|
||||
|
||||
const [deleteRuleFromGroup] = useDeleteRuleFromGroup();
|
||||
const [addRuleToRuleGroup] = useAddRuleToRuleGroup();
|
||||
@ -110,19 +99,10 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
|
||||
return formValuesFromPrefill(prefill);
|
||||
}
|
||||
|
||||
if (queryParams.has('defaults')) {
|
||||
return formValuesFromQueryParams(queryParams.get('defaults') ?? '', ruleType);
|
||||
}
|
||||
const defaultRuleType = ruleType || RuleFormType.grafana;
|
||||
|
||||
return {
|
||||
...getDefaultFormValues(),
|
||||
condition: 'C',
|
||||
queries: getDefaultQueries(isGrafanaRecordingRuleByType(defaultRuleType)),
|
||||
type: defaultRuleType,
|
||||
evaluateEvery: evaluateEvery,
|
||||
};
|
||||
}, [existing, prefill, queryParams, evaluateEvery, ruleType]);
|
||||
return defaultFormValuesForRuleType(defaultRuleType);
|
||||
}, [existing, prefill, ruleType]);
|
||||
|
||||
const formAPI = useForm<RuleFormValues>({
|
||||
mode: 'onSubmit',
|
||||
@ -151,6 +131,8 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
|
||||
|
||||
// @todo why is error not propagated to form?
|
||||
const submit = async (values: RuleFormValues, exitOnSave: boolean) => {
|
||||
const { type, evaluateEvery } = values;
|
||||
|
||||
if (conditionErrorMsg !== '') {
|
||||
notifyApp.error(conditionErrorMsg);
|
||||
if (!existing && grafanaTypeRule) {
|
||||
@ -160,7 +142,7 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
|
||||
return;
|
||||
}
|
||||
|
||||
trackAlertRuleFormSaved({ formAction: existing ? 'update' : 'create', ruleType: values.type });
|
||||
trackAlertRuleFormSaved({ formAction: existing ? 'update' : 'create', ruleType: type });
|
||||
|
||||
const ruleDefinition = grafanaTypeRule ? formValuesToRulerGrafanaRuleDTO(values) : formValuesToRulerRuleDTO(values);
|
||||
|
||||
@ -206,12 +188,11 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
|
||||
|
||||
const deleteRule = async () => {
|
||||
if (existing) {
|
||||
const returnTo = queryParams.get('returnTo') || '/alerting/list';
|
||||
const ruleGroupIdentifier = getRuleGroupLocationFromRuleWithLocation(existing);
|
||||
const ruleIdentifier = fromRulerRuleAndRuleGroupIdentifier(ruleGroupIdentifier, existing.rule);
|
||||
|
||||
await deleteRuleFromGroup.execute(ruleGroupIdentifier, ruleIdentifier);
|
||||
locationService.replace(returnTo);
|
||||
locationService.replace(returnTo ?? '/alerting/list');
|
||||
}
|
||||
};
|
||||
|
||||
@ -236,9 +217,6 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
|
||||
locationService.getHistory().goBack();
|
||||
};
|
||||
|
||||
const evaluateEveryInForm = watch('evaluateEvery');
|
||||
useEffect(() => setEvaluateEvery(evaluateEveryInForm), [evaluateEveryInForm]);
|
||||
|
||||
const actionButtons = (
|
||||
<Stack justifyContent="flex-end" alignItems="center">
|
||||
{existing && (
|
||||
@ -314,12 +292,7 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
|
||||
|
||||
{/* Step 4 & 5 & 6*/}
|
||||
{isGrafanaManagedRuleByType(type) && (
|
||||
<GrafanaEvaluationBehaviorStep
|
||||
evaluateEvery={evaluateEvery}
|
||||
setEvaluateEvery={setEvaluateEvery}
|
||||
existing={Boolean(existing)}
|
||||
enableProvisionedGroups={false}
|
||||
/>
|
||||
<GrafanaEvaluationBehaviorStep existing={Boolean(existing)} enableProvisionedGroups={false} />
|
||||
)}
|
||||
{/* Notifications step*/}
|
||||
<NotificationsStep alertUid={uidFromParams} />
|
||||
@ -379,101 +352,17 @@ const isCortexLokiOrRecordingRule = (watch: UseFormWatch<RuleFormValues>) => {
|
||||
return (ruleType === RuleFormType.cloudAlerting || ruleType === RuleFormType.cloudRecording) && dataSourceName !== '';
|
||||
};
|
||||
|
||||
function formValuesFromQueryParams(ruleDefinition: string, type: RuleFormType): RuleFormValues {
|
||||
let ruleFromQueryParams: Partial<RuleFormValues>;
|
||||
|
||||
try {
|
||||
ruleFromQueryParams = JSON.parse(ruleDefinition);
|
||||
} catch (err) {
|
||||
return {
|
||||
...getDefaultFormValues(),
|
||||
queries: getDefaultQueries(),
|
||||
};
|
||||
}
|
||||
|
||||
return setQueryEditorSettings(
|
||||
setInstantOrRange(
|
||||
ignoreHiddenQueries({
|
||||
...getDefaultFormValues(),
|
||||
...ruleFromQueryParams,
|
||||
annotations: normalizeDefaultAnnotations(ruleFromQueryParams.annotations ?? []),
|
||||
queries: ruleFromQueryParams.queries ?? getDefaultQueries(),
|
||||
type: type || RuleFormType.grafana,
|
||||
evaluateEvery: DEFAULT_GROUP_EVALUATION_INTERVAL,
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function formValuesFromPrefill(rule: Partial<RuleFormValues>): RuleFormValues {
|
||||
return ignoreHiddenQueries({
|
||||
...getDefaultFormValues(),
|
||||
...rule,
|
||||
});
|
||||
}
|
||||
|
||||
function setQueryEditorSettings(values: RuleFormValues): RuleFormValues {
|
||||
const isQuerySwitchModeEnabled = config.featureToggles.alertingQueryAndExpressionsStepMode ?? false;
|
||||
|
||||
if (!isQuerySwitchModeEnabled) {
|
||||
return {
|
||||
...values,
|
||||
editorSettings: {
|
||||
simplifiedQueryEditor: false,
|
||||
simplifiedNotificationEditor: true, // actually it doesn't matter in this case
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// data queries only
|
||||
const dataQueries = values.queries.filter((query) => !isExpressionQuery(query.model));
|
||||
|
||||
// expression queries only
|
||||
const expressionQueries = values.queries.filter((query) => isExpressionQueryInAlert(query));
|
||||
|
||||
const queryParamsAreTransformable = areQueriesTransformableToSimpleCondition(dataQueries, expressionQueries);
|
||||
return {
|
||||
...values,
|
||||
editorSettings: {
|
||||
simplifiedQueryEditor: queryParamsAreTransformable,
|
||||
simplifiedNotificationEditor: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setInstantOrRange(values: RuleFormValues): RuleFormValues {
|
||||
return {
|
||||
...values,
|
||||
queries: values.queries?.map((query) => {
|
||||
if (isExpressionQuery(query.model)) {
|
||||
return query;
|
||||
}
|
||||
// data query
|
||||
const defaultToInstant =
|
||||
query.model.datasource?.type === DataSourceType.Loki ||
|
||||
query.model.datasource?.type === DataSourceType.Prometheus;
|
||||
const isInstant =
|
||||
'instant' in query.model && query.model.instant !== undefined ? query.model.instant : defaultToInstant;
|
||||
return {
|
||||
...query,
|
||||
model: {
|
||||
...query.model,
|
||||
instant: isInstant,
|
||||
range: !isInstant, // we cannot have both instant and range queries in alerting
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function storeInLocalStorageValues(values: RuleFormValues) {
|
||||
if (values.manualRouting) {
|
||||
const { manualRouting, editorSettings } = values;
|
||||
|
||||
if (manualRouting) {
|
||||
localStorage.setItem(MANUAL_ROUTING_KEY, 'true');
|
||||
} else {
|
||||
localStorage.setItem(MANUAL_ROUTING_KEY, 'false');
|
||||
}
|
||||
if (values.editorSettings) {
|
||||
if (values.editorSettings.simplifiedQueryEditor) {
|
||||
|
||||
if (editorSettings) {
|
||||
if (editorSettings.simplifiedQueryEditor) {
|
||||
localStorage.setItem(SIMPLIFIED_QUERY_EDITOR_KEY, 'true');
|
||||
} else {
|
||||
localStorage.setItem(SIMPLIFIED_QUERY_EDITOR_KEY, 'false');
|
||||
|
@ -16,14 +16,10 @@ import { alertRuleApi } from '../../../api/alertRuleApi';
|
||||
import { fetchRulerRulesGroup } from '../../../api/ruler';
|
||||
import { useDataSourceFeatures } from '../../../hooks/useCombinedRule';
|
||||
import { useReturnTo } from '../../../hooks/useReturnTo';
|
||||
import { DEFAULT_GROUP_EVALUATION_INTERVAL, getDefaultFormValues } from '../../../rule-editor/formDefaults';
|
||||
import { RuleFormType, RuleFormValues } from '../../../types/rule-form';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource';
|
||||
import {
|
||||
DEFAULT_GROUP_EVALUATION_INTERVAL,
|
||||
formValuesToRulerGrafanaRuleDTO,
|
||||
getDefaultFormValues,
|
||||
getDefaultQueries,
|
||||
} from '../../../utils/rule-form';
|
||||
import { formValuesToRulerGrafanaRuleDTO, getDefaultQueries } from '../../../utils/rule-form';
|
||||
import { isGrafanaRulerRule } from '../../../utils/rules';
|
||||
import { FileExportPreview } from '../../export/FileExportPreview';
|
||||
import { GrafanaExportDrawer } from '../../export/GrafanaExportDrawer';
|
||||
@ -64,9 +60,7 @@ export function ModifyExportRuleForm({ ruleForm, alertUid }: ModifyExportRuleFor
|
||||
const { returnTo } = useReturnTo('/alerting/list');
|
||||
|
||||
const [exportData, setExportData] = useState<RuleFormValues | undefined>(undefined);
|
||||
|
||||
const [conditionErrorMsg, setConditionErrorMsg] = useState('');
|
||||
const [evaluateEvery, setEvaluateEvery] = useState(ruleForm?.evaluateEvery ?? DEFAULT_GROUP_EVALUATION_INTERVAL);
|
||||
|
||||
const onInvalid = (): void => {
|
||||
notifyApp.error('There are errors in the form. Please correct them and try again!');
|
||||
@ -112,12 +106,7 @@ export function ModifyExportRuleForm({ ruleForm, alertUid }: ModifyExportRuleFor
|
||||
<GrafanaFolderAndLabelsStep />
|
||||
|
||||
{/* Step 4 & 5 */}
|
||||
<GrafanaEvaluationBehaviorStep
|
||||
evaluateEvery={evaluateEvery}
|
||||
setEvaluateEvery={setEvaluateEvery}
|
||||
existing={Boolean(existing)}
|
||||
enableProvisionedGroups={true}
|
||||
/>
|
||||
<GrafanaEvaluationBehaviorStep existing={Boolean(existing)} enableProvisionedGroups={true} />
|
||||
{/* Notifications step*/}
|
||||
<NotificationsStep alertUid={alertUid} />
|
||||
{/* Annotations only for cloud and Grafana */}
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { RulerRuleDTO, RulerRuleGroupDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { mockRulerGrafanaRecordingRule, mockRulerGrafanaRule } from '../../../mocks';
|
||||
import { getDefaultFormValues } from '../../../rule-editor/formDefaults';
|
||||
import { RuleFormType, RuleFormValues } from '../../../types/rule-form';
|
||||
import { Annotation } from '../../../utils/constants';
|
||||
import { getDefaultFormValues } from '../../../utils/rule-form';
|
||||
|
||||
import { getPayloadToExport } from './ModifyExportRuleForm';
|
||||
|
||||
|
@ -106,8 +106,10 @@ describe('Can create a new grafana managed alert using simplified routing', () =
|
||||
|
||||
it('simplified routing is not available when Grafana AM is not enabled', async () => {
|
||||
setAlertmanagerChoices(AlertmanagerChoice.External, 1);
|
||||
renderRuleEditor();
|
||||
const { user } = renderRuleEditor();
|
||||
|
||||
// Just to make sure all dropdowns have been loaded
|
||||
await selectFolderAndGroup(user);
|
||||
await waitFor(() => expect(ui.inputs.simplifiedRouting.contactPointRouting.query()).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
@ -147,6 +149,7 @@ describe('Can create a new grafana managed alert using simplified routing', () =
|
||||
expect(await screen.findByText('Email')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('switch modes enabled', () => {
|
||||
testWithFeatureToggles(['alertingQueryAndExpressionsStepMode', 'alertingNotificationsStepMode']);
|
||||
|
||||
@ -168,6 +171,7 @@ describe('Can create a new grafana managed alert using simplified routing', () =
|
||||
const serializedRequests = await serializeRequests(requests);
|
||||
expect(serializedRequests).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('can create the new grafana-managed rule with advanced modes', async () => {
|
||||
const capture = captureRequests((r) => r.method === 'POST' && r.url.includes('/api/ruler/'));
|
||||
|
||||
@ -185,6 +189,7 @@ describe('Can create a new grafana managed alert using simplified routing', () =
|
||||
const serializedRequests = await serializeRequests(requests);
|
||||
expect(serializedRequests).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('can create the new grafana-managed rule with only notifications step advanced mode', async () => {
|
||||
const capture = captureRequests((r) => r.method === 'POST' && r.url.includes('/api/ruler/'));
|
||||
|
||||
@ -202,6 +207,7 @@ describe('Can create a new grafana managed alert using simplified routing', () =
|
||||
const serializedRequests = await serializeRequests(requests);
|
||||
expect(serializedRequests).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('can create the new grafana-managed rule with only query step advanced mode', async () => {
|
||||
const contactPointName = 'lotsa-emails';
|
||||
const capture = captureRequests((r) => r.method === 'POST' && r.url.includes('/api/ruler/'));
|
||||
@ -221,6 +227,7 @@ describe('Can create a new grafana managed alert using simplified routing', () =
|
||||
const serializedRequests = await serializeRequests(requests);
|
||||
expect(serializedRequests).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('switch modes are intiallized depending on the local storage - 1', async () => {
|
||||
localStorage.setItem(SIMPLIFIED_QUERY_EDITOR_KEY, 'false');
|
||||
localStorage.setItem(MANUAL_ROUTING_KEY, 'true');
|
||||
@ -231,6 +238,7 @@ describe('Can create a new grafana managed alert using simplified routing', () =
|
||||
expect(ui.inputs.switchModeAdvanced(GrafanaRuleFormStep.Query).get()).toBeInTheDocument();
|
||||
expect(ui.inputs.switchModeBasic(GrafanaRuleFormStep.Notification).get()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switch modes are intiallized depending on the local storage - 2', async () => {
|
||||
localStorage.setItem(SIMPLIFIED_QUERY_EDITOR_KEY, 'true');
|
||||
localStorage.setItem(MANUAL_ROUTING_KEY, 'false');
|
||||
|
@ -136,6 +136,7 @@ describe('LabelsField with suggestions', () => {
|
||||
expect(screen.getByTestId('labelsInSubform-key-2')).toHaveTextContent('key3');
|
||||
expect(screen.getByTestId('labelsInSubform-value-2')).toHaveTextContent('value3');
|
||||
});
|
||||
|
||||
it('Should be able to write new keys and values using the dropdowns, case sensitive', async () => {
|
||||
const { user } = await renderLabelsWithSuggestions();
|
||||
|
||||
|
@ -23,16 +23,14 @@ import {
|
||||
import { Text } from '@grafana/ui/src/components/Text/Text';
|
||||
import { Trans, t } from 'app/core/internationalization';
|
||||
import { isExpressionQuery } from 'app/features/expressions/guards';
|
||||
import {
|
||||
ExpressionDatasourceUID,
|
||||
ExpressionQuery,
|
||||
ExpressionQueryType,
|
||||
ReducerMode,
|
||||
expressionTypes,
|
||||
} from 'app/features/expressions/types';
|
||||
import { AlertDataQuery, AlertQuery } from 'app/types/unified-alerting-dto';
|
||||
import { ExpressionDatasourceUID, ExpressionQueryType, expressionTypes } from 'app/features/expressions/types';
|
||||
import { AlertQuery } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { useRulesSourcesWithRuler } from '../../../hooks/useRuleSourcesWithRuler';
|
||||
import {
|
||||
areQueriesTransformableToSimpleCondition,
|
||||
isExpressionQueryInAlert,
|
||||
} from '../../../rule-editor/formProcessing';
|
||||
import { RuleFormType, RuleFormValues } from '../../../types/rule-form';
|
||||
import { getDefaultOrFirstCompatibleDataSource } from '../../../utils/datasource';
|
||||
import { PromOrLokiQuery, isPromOrLokiQuery } from '../../../utils/rule-form';
|
||||
@ -76,49 +74,6 @@ import {
|
||||
import { useAdvancedMode } from './useAdvancedMode';
|
||||
import { useAlertQueryRunner } from './useAlertQueryRunner';
|
||||
|
||||
export function areQueriesTransformableToSimpleCondition(
|
||||
dataQueries: Array<AlertQuery<AlertDataQuery | ExpressionQuery>>,
|
||||
expressionQueries: Array<AlertQuery<ExpressionQuery>>
|
||||
) {
|
||||
if (dataQueries.length !== 1) {
|
||||
return false;
|
||||
}
|
||||
const singleReduceExpressionInInstantQuery =
|
||||
'instant' in dataQueries[0].model && dataQueries[0].model.instant && expressionQueries.length === 1;
|
||||
|
||||
if (expressionQueries.length !== 2 && !singleReduceExpressionInInstantQuery) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const query = dataQueries[0];
|
||||
|
||||
if (query.refId !== SimpleConditionIdentifier.queryId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const reduceExpressionIndex = expressionQueries.findIndex(
|
||||
(query) => query.model.type === ExpressionQueryType.reduce && query.refId === SimpleConditionIdentifier.reducerId
|
||||
);
|
||||
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 === SimpleConditionIdentifier.thresholdId
|
||||
);
|
||||
const thresholdExpression = expressionQueries.at(thresholdExpressionIndex);
|
||||
const conditions = thresholdExpression?.model.conditions ?? [];
|
||||
const thresholdIndexOk = singleReduceExpressionInInstantQuery
|
||||
? thresholdExpressionIndex === 0
|
||||
: thresholdExpressionIndex === 1;
|
||||
const thresholdOk = thresholdExpression && thresholdIndexOk && conditions[0]?.unloadEvaluator === undefined;
|
||||
return (Boolean(reduceOk) || Boolean(singleReduceExpressionInInstantQuery)) && Boolean(thresholdOk);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
editingExistingRule: boolean;
|
||||
onDataChange: (error: string) => void;
|
||||
@ -777,9 +732,3 @@ const useSetExpressionAndDataSource = () => {
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export function isExpressionQueryInAlert(
|
||||
query: AlertQuery<AlertDataQuery | ExpressionQuery>
|
||||
): query is AlertQuery<ExpressionQuery> {
|
||||
return isExpressionQuery(query.model);
|
||||
}
|
||||
|
@ -2,11 +2,10 @@ import { produce } from 'immer';
|
||||
|
||||
import { EvalFunction } from 'app/features/alerting/state/alertDef';
|
||||
import { dataQuery, reduceExpression, thresholdExpression } from 'app/features/alerting/unified/mocks';
|
||||
import { areQueriesTransformableToSimpleCondition } from 'app/features/alerting/unified/rule-editor/formProcessing';
|
||||
import { ExpressionQuery, ReducerMode } from 'app/features/expressions/types';
|
||||
import { AlertDataQuery, AlertQuery } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { areQueriesTransformableToSimpleCondition } from '../QueryAndExpressionsStep';
|
||||
|
||||
const expressionQueries: Array<AlertQuery<ExpressionQuery>> = [reduceExpression, thresholdExpression];
|
||||
|
||||
describe('areQueriesTransformableToSimpleCondition', () => {
|
||||
|
@ -5,7 +5,8 @@ import { EvalFunction } from 'app/features/alerting/state/alertDef';
|
||||
import { ExpressionQuery } from 'app/features/expressions/types';
|
||||
import { AlertDataQuery, AlertQuery } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { areQueriesTransformableToSimpleCondition } from './QueryAndExpressionsStep';
|
||||
import { areQueriesTransformableToSimpleCondition } from '../../../rule-editor/formProcessing';
|
||||
|
||||
import { SimpleCondition, getSimpleConditionFromExpressions } from './SimpleCondition';
|
||||
|
||||
function initializeSimpleCondition(
|
||||
@ -30,7 +31,7 @@ export function determineAdvancedMode(simplifiedQueryEditor: boolean | undefined
|
||||
}
|
||||
|
||||
/*
|
||||
This hook is used mantain the state of the advanced mode, and the simple condition,
|
||||
This hook is used mantain the state of the advanced mode, and the simple condition,
|
||||
depending on the editor settings, the alert type, and the queries.
|
||||
*/
|
||||
export const useAdvancedMode = (
|
||||
|
@ -15,8 +15,6 @@ import { isExpressionQuery } from 'app/features/expressions/guards';
|
||||
import { ClassicCondition, ExpressionQueryType } from 'app/features/expressions/types';
|
||||
import { AlertQuery } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { RuleFormType } from '../../types/rule-form';
|
||||
|
||||
import { createDagFromQueries, getOriginOfRefId } from './dag';
|
||||
|
||||
export function queriesWithUpdatedReferences(
|
||||
@ -312,18 +310,6 @@ export function getStatusMessage(data: PanelData): string | undefined {
|
||||
return data.error?.message ?? genericErrorMessage;
|
||||
}
|
||||
|
||||
export function translateRouteParamToRuleType(param = ''): RuleFormType {
|
||||
if (param === 'recording') {
|
||||
return RuleFormType.cloudRecording;
|
||||
}
|
||||
|
||||
if (param === 'grafana-recording') {
|
||||
return RuleFormType.grafanaRecording;
|
||||
}
|
||||
|
||||
return RuleFormType.grafana;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function finds what refIds have been updated given the previous Array of queries and an Array of updated data queries.
|
||||
* All expression queries are discarded from the arrays, since we have separate handlers for those (see "onUpdateRefId") of the ExpressionEditor
|
||||
|
@ -4,7 +4,6 @@ import { byRole, byText } from 'testing-library-selector';
|
||||
|
||||
import { setPluginLinksHook } from '@grafana/runtime';
|
||||
import { setupMswServer } from 'app/features/alerting/unified/mockApi';
|
||||
import { setFolderAccessControl } from 'app/features/alerting/unified/mocks/server/configure';
|
||||
import { AlertManagerDataSourceJsonData } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting';
|
||||
@ -20,6 +19,7 @@ import {
|
||||
mockPromAlertingRule,
|
||||
} from '../../mocks';
|
||||
import { grafanaRulerRule } from '../../mocks/grafanaRulerApi';
|
||||
import { grantPermissionsHelper } from '../../test/test-utils';
|
||||
import { setupDataSources } from '../../testSetup/datasources';
|
||||
import { Annotation } from '../../utils/constants';
|
||||
import { DataSourceType } from '../../utils/datasource';
|
||||
@ -76,16 +76,6 @@ setPluginLinksHook(() => ({
|
||||
isLoading: false,
|
||||
}));
|
||||
|
||||
/**
|
||||
* "Grants" permissions via contextSrv mock, and additionally sets folder access control
|
||||
* API response to match
|
||||
*/
|
||||
const grantPermissionsHelper = (permissions: AccessControlAction[]) => {
|
||||
const permissionsHash = permissions.reduce((hash, permission) => ({ ...hash, [permission]: true }), {});
|
||||
grantUserPermissions(permissions);
|
||||
setFolderAccessControl(permissionsHash);
|
||||
};
|
||||
|
||||
const openSilenceDrawer = async () => {
|
||||
const user = userEvent.setup();
|
||||
await user.click(ELEMENTS.actions.more.button.get());
|
||||
|
@ -1,17 +1,17 @@
|
||||
import { HttpResponse } from 'msw';
|
||||
import { render } from 'test/test-utils';
|
||||
import { byLabelText, byTestId, byText, byTitle } from 'testing-library-selector';
|
||||
|
||||
import { CombinedRuleNamespace } from 'app/types/unified-alerting';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
import { RuleGroupIdentifier } from 'app/types/unified-alerting';
|
||||
|
||||
import {
|
||||
mockCombinedRule,
|
||||
mockCombinedRuleNamespace,
|
||||
mockDataSource,
|
||||
mockPromAlertingRule,
|
||||
mockPromRecordingRule,
|
||||
mockRulerAlertingRule,
|
||||
mockRulerRecordingRule,
|
||||
} from '../../mocks';
|
||||
import { GRAFANA_RULER_CONFIG } from '../../api/featureDiscoveryApi';
|
||||
import server, { setupMswServer } from '../../mockApi';
|
||||
import { mimirDataSource } from '../../mocks/server/configure';
|
||||
import { alertingFactory } from '../../mocks/server/db';
|
||||
import { rulerRuleGroupHandler as grafanaRulerRuleGroupHandler } from '../../mocks/server/handlers/grafanaRuler';
|
||||
import { rulerRuleGroupHandler } from '../../mocks/server/handlers/mimirRuler';
|
||||
import { grantPermissionsHelper } from '../../test/test-utils';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
|
||||
|
||||
import { EditRuleGroupModal } from './EditRuleGroupModal';
|
||||
@ -29,133 +29,165 @@ const ui = {
|
||||
};
|
||||
|
||||
const noop = () => jest.fn();
|
||||
setupMswServer();
|
||||
|
||||
describe('EditGroupModal', () => {
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
useReturnToPrevious: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('EditGroupModal component on cloud alert rules', () => {
|
||||
it('Should disable all inputs but interval when intervalEditOnly is set', async () => {
|
||||
const namespace = mockCombinedRuleNamespace({
|
||||
name: 'my-alerts',
|
||||
rulesSource: mockDataSource(),
|
||||
groups: [{ name: 'default-group', interval: '90s', rules: [], totals: {} }],
|
||||
const { rulerConfig } = mimirDataSource();
|
||||
|
||||
const group = alertingFactory.ruler.group.build({
|
||||
rules: [alertingFactory.ruler.alertingRule.build(), alertingFactory.ruler.recordingRule.build()],
|
||||
});
|
||||
|
||||
const group = namespace.groups[0];
|
||||
// @TODO need to simplify this a bit I think, ideally there would be a higher-level function that simply sets up a few rules
|
||||
// and attaches the ruler and prometheus endpoint(s) – including the namespaces and group endpoints.
|
||||
server.use(
|
||||
rulerRuleGroupHandler({
|
||||
response: HttpResponse.json(group),
|
||||
})
|
||||
);
|
||||
|
||||
render(<EditRuleGroupModal namespace={namespace} group={group} intervalEditOnly onClose={noop} />);
|
||||
const rulerGroupIdentifier: RuleGroupIdentifier = {
|
||||
dataSourceName: rulerConfig.dataSourceName,
|
||||
groupName: 'default-group',
|
||||
namespaceName: 'my-namespace',
|
||||
};
|
||||
|
||||
render(
|
||||
<EditRuleGroupModal
|
||||
ruleGroupIdentifier={rulerGroupIdentifier}
|
||||
intervalEditOnly
|
||||
onClose={noop}
|
||||
rulerConfig={rulerConfig}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(await ui.input.namespace.find()).toHaveAttribute('readonly');
|
||||
expect(ui.input.group.get()).toHaveAttribute('readonly');
|
||||
expect(ui.input.interval.get()).not.toHaveAttribute('readonly');
|
||||
});
|
||||
});
|
||||
|
||||
describe('EditGroupModal component on cloud alert rules', () => {
|
||||
const promDsSettings = mockDataSource({ name: 'Prometheus-1', uid: 'Prometheus-1' });
|
||||
|
||||
const alertingRule = mockCombinedRule({
|
||||
namespace: undefined,
|
||||
promRule: mockPromAlertingRule({ name: 'alerting-rule-cpu' }),
|
||||
rulerRule: mockRulerAlertingRule({ alert: 'alerting-rule-cpu' }),
|
||||
});
|
||||
|
||||
const recordingRule1 = mockCombinedRule({
|
||||
namespace: undefined,
|
||||
promRule: mockPromRecordingRule({ name: 'recording-rule-memory' }),
|
||||
rulerRule: mockRulerRecordingRule({ record: 'recording-rule-memory' }),
|
||||
});
|
||||
|
||||
const recordingRule2 = mockCombinedRule({
|
||||
namespace: undefined,
|
||||
promRule: mockPromRecordingRule({ name: 'recording-rule-cpu' }),
|
||||
rulerRule: mockRulerRecordingRule({ record: 'recording-rule-cpu' }),
|
||||
});
|
||||
|
||||
it('Should show alert table in case of having some non-recording rules in the group', async () => {
|
||||
const promNs = mockCombinedRuleNamespace({
|
||||
name: 'prometheus-ns',
|
||||
rulesSource: promDsSettings,
|
||||
groups: [
|
||||
{ name: 'default-group', interval: '90s', rules: [alertingRule, recordingRule1, recordingRule2], totals: {} },
|
||||
],
|
||||
const { dataSource, rulerConfig } = mimirDataSource();
|
||||
|
||||
const group = alertingFactory.ruler.group.build({
|
||||
rules: [alertingFactory.ruler.alertingRule.build(), alertingFactory.ruler.recordingRule.build()],
|
||||
});
|
||||
|
||||
const group = promNs.groups[0];
|
||||
// @TODO need to simplify this a bit I think, ideally there would be a higher-level function that simply sets up a few rules
|
||||
// and attaches the ruler and prometheus endpoint(s) – including the namespaces and group endpoints.
|
||||
server.use(
|
||||
rulerRuleGroupHandler({
|
||||
response: HttpResponse.json(group),
|
||||
})
|
||||
);
|
||||
|
||||
render(<EditRuleGroupModal namespace={promNs} group={group} onClose={noop} />);
|
||||
const ruleGroupIdentifier: RuleGroupIdentifier = {
|
||||
dataSourceName: dataSource.name,
|
||||
groupName: group.name,
|
||||
namespaceName: 'ns1',
|
||||
};
|
||||
|
||||
expect(await ui.input.namespace.find()).toHaveValue('prometheus-ns');
|
||||
render(<EditRuleGroupModal ruleGroupIdentifier={ruleGroupIdentifier} rulerConfig={rulerConfig} onClose={noop} />);
|
||||
|
||||
expect(await ui.input.namespace.find()).toHaveValue('ns1');
|
||||
expect(ui.input.namespace.get()).not.toHaveAttribute('readonly');
|
||||
expect(ui.input.group.get()).toHaveValue('default-group');
|
||||
expect(ui.input.group.get()).toHaveValue(group.name);
|
||||
|
||||
// @ts-ignore
|
||||
const ruleName = group.rules.at(0).alert;
|
||||
|
||||
expect(ui.tableRows.getAll()).toHaveLength(1); // Only one rule is non-recording
|
||||
expect(ui.tableRows.getAll()[0]).toHaveTextContent('alerting-rule-cpu');
|
||||
expect(ui.tableRows.getAll().at(0)).toHaveTextContent(ruleName);
|
||||
});
|
||||
|
||||
it('Should not show alert table in case of having exclusively recording rules in the group', async () => {
|
||||
const promNs = mockCombinedRuleNamespace({
|
||||
name: 'prometheus-ns',
|
||||
rulesSource: promDsSettings,
|
||||
groups: [{ name: 'default-group', interval: '90s', rules: [recordingRule1, recordingRule2], totals: {} }],
|
||||
const { dataSource, rulerConfig } = mimirDataSource();
|
||||
|
||||
const group = alertingFactory.ruler.group.build({
|
||||
rules: [alertingFactory.ruler.recordingRule.build(), alertingFactory.ruler.recordingRule.build()],
|
||||
});
|
||||
|
||||
const group = promNs.groups[0];
|
||||
// @TODO need to simplify this a bit I think
|
||||
server.use(
|
||||
rulerRuleGroupHandler({
|
||||
response: HttpResponse.json(group),
|
||||
})
|
||||
);
|
||||
|
||||
render(<EditRuleGroupModal namespace={promNs} group={group} onClose={noop} />);
|
||||
const ruleGroupIdentifier: RuleGroupIdentifier = {
|
||||
dataSourceName: dataSource.name,
|
||||
groupName: group.name,
|
||||
namespaceName: 'ns1',
|
||||
};
|
||||
|
||||
render(<EditRuleGroupModal rulerConfig={rulerConfig} ruleGroupIdentifier={ruleGroupIdentifier} onClose={noop} />);
|
||||
expect(ui.table.query()).not.toBeInTheDocument();
|
||||
expect(await ui.noRulesText.find()).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('EditGroupModal component on grafana-managed alert rules', () => {
|
||||
const grafanaNamespace: CombinedRuleNamespace = {
|
||||
name: 'namespace1',
|
||||
rulesSource: GRAFANA_RULES_SOURCE_NAME,
|
||||
groups: [
|
||||
{
|
||||
name: 'grafanaGroup1',
|
||||
interval: '30s',
|
||||
rules: [
|
||||
mockCombinedRule({
|
||||
namespace: undefined,
|
||||
promRule: mockPromAlertingRule({ name: 'high-cpu-1' }),
|
||||
rulerRule: mockRulerAlertingRule({ alert: 'high-cpu-1' }),
|
||||
}),
|
||||
mockCombinedRule({
|
||||
namespace: undefined,
|
||||
promRule: mockPromAlertingRule({ name: 'high-memory' }),
|
||||
rulerRule: mockRulerAlertingRule({ alert: 'high-memory' }),
|
||||
}),
|
||||
],
|
||||
totals: {},
|
||||
},
|
||||
],
|
||||
// @TODO simplify folder stuff, should also have a higher-level function to set these up
|
||||
const folder = alertingFactory.folder.build();
|
||||
const NAMESPACE_UID = folder.uid;
|
||||
|
||||
const group = alertingFactory.ruler.group.build({
|
||||
rules: [alertingFactory.ruler.alertingRule.build(), alertingFactory.ruler.alertingRule.build()],
|
||||
});
|
||||
|
||||
const ruleGroupIdentifier: RuleGroupIdentifier = {
|
||||
dataSourceName: GRAFANA_RULES_SOURCE_NAME,
|
||||
groupName: group.name,
|
||||
namespaceName: NAMESPACE_UID,
|
||||
};
|
||||
|
||||
const grafanaGroup1 = grafanaNamespace.groups[0];
|
||||
beforeEach(() => {
|
||||
grantPermissionsHelper([
|
||||
AccessControlAction.AlertingRuleCreate,
|
||||
AccessControlAction.AlertingRuleRead,
|
||||
AccessControlAction.AlertingRuleUpdate,
|
||||
]);
|
||||
|
||||
server.use(
|
||||
grafanaRulerRuleGroupHandler({
|
||||
response: HttpResponse.json(group),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const renderWithGrafanaGroup = () =>
|
||||
render(<EditRuleGroupModal namespace={grafanaNamespace} group={grafanaGroup1} onClose={noop} />);
|
||||
render(
|
||||
<EditRuleGroupModal ruleGroupIdentifier={ruleGroupIdentifier} rulerConfig={GRAFANA_RULER_CONFIG} onClose={noop} />
|
||||
);
|
||||
|
||||
it('Should show alert table', async () => {
|
||||
renderWithGrafanaGroup();
|
||||
|
||||
expect(await ui.input.namespace.find()).toHaveValue('namespace1');
|
||||
expect(ui.input.group.get()).toHaveValue('grafanaGroup1');
|
||||
expect(ui.input.interval.get()).toHaveValue('30s');
|
||||
expect(await ui.input.namespace.find()).toHaveValue(NAMESPACE_UID);
|
||||
expect(ui.input.group.get()).toHaveValue(group.name);
|
||||
expect(ui.input.interval.get()).toHaveValue(group.interval);
|
||||
|
||||
expect(ui.tableRows.getAll()).toHaveLength(2);
|
||||
expect(ui.tableRows.getAll()[0]).toHaveTextContent('high-cpu-1');
|
||||
expect(ui.tableRows.getAll()[1]).toHaveTextContent('high-memory');
|
||||
// @ts-ignore
|
||||
expect(ui.tableRows.getAll().at(0)).toHaveTextContent(group.rules.at(0).alert);
|
||||
// @ts-ignore
|
||||
expect(ui.tableRows.getAll().at(1)).toHaveTextContent(group.rules.at(1).alert);
|
||||
});
|
||||
|
||||
it('Should have folder input in readonly mode', async () => {
|
||||
renderWithGrafanaGroup();
|
||||
|
||||
expect(await ui.input.namespace.find()).toHaveAttribute('readonly');
|
||||
});
|
||||
|
||||
it('Should not display folder link if no folderUrl provided', async () => {
|
||||
renderWithGrafanaGroup();
|
||||
expect(await ui.input.namespace.find()).toHaveValue('namespace1');
|
||||
expect(await ui.input.namespace.find()).toHaveValue(NAMESPACE_UID);
|
||||
expect(ui.folderLink.query()).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
@ -4,32 +4,46 @@ import { useMemo } from 'react';
|
||||
import { FieldValues, FormProvider, RegisterOptions, useForm, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Alert, Badge, Button, Field, Input, Label, LinkButton, Modal, Stack, useStyles2 } from '@grafana/ui';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Field,
|
||||
Input,
|
||||
Label,
|
||||
LinkButton,
|
||||
LoadingPlaceholder,
|
||||
Modal,
|
||||
Stack,
|
||||
useStyles2,
|
||||
} from '@grafana/ui';
|
||||
import { useAppNotification } from 'app/core/copy/appNotification';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
import { Trans, t } from 'app/core/internationalization';
|
||||
import { dispatch } from 'app/store/store';
|
||||
import { CombinedRuleGroup, CombinedRuleNamespace, RuleGroupIdentifier } from 'app/types/unified-alerting';
|
||||
import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
|
||||
import { RuleGroupIdentifier, RulerDataSourceConfig } from 'app/types/unified-alerting';
|
||||
import { RulerRuleDTO, RulerRuleGroupDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { alertRuleApi } from '../../api/alertRuleApi';
|
||||
import {
|
||||
useMoveRuleGroup,
|
||||
useRenameRuleGroup,
|
||||
useUpdateRuleGroupConfiguration,
|
||||
} from '../../hooks/ruleGroup/useUpdateRuleGroup';
|
||||
import { anyOfRequestState } from '../../hooks/useAsync';
|
||||
import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../../rule-editor/formDefaults';
|
||||
import { fetchRulerRulesAction, rulesInSameGroupHaveInvalidFor } from '../../state/actions';
|
||||
import { checkEvaluationIntervalGlobalLimit } from '../../utils/config';
|
||||
import { GRAFANA_RULES_SOURCE_NAME, getRulesSourceName } from '../../utils/datasource';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
|
||||
import { stringifyErrorLike } from '../../utils/misc';
|
||||
import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../../utils/rule-form';
|
||||
import { AlertInfo, getAlertInfo, isGrafanaOrDataSourceRecordingRule } from '../../utils/rules';
|
||||
import { formatPrometheusDuration, parsePrometheusDuration, safeParsePrometheusDuration } from '../../utils/time';
|
||||
import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
|
||||
import { EvaluationIntervalLimitExceeded } from '../InvalidIntervalWarning';
|
||||
import { decodeGrafanaNamespace, encodeGrafanaNamespace } from '../expressions/util';
|
||||
import { EvaluationGroupQuickPick } from '../rule-editor/EvaluationGroupQuickPick';
|
||||
import { MIN_TIME_RANGE_STEP_S } from '../rule-editor/GrafanaEvaluationBehavior';
|
||||
|
||||
const useRuleGroupDefinition = alertRuleApi.endpoints.getRuleGroupForNamespace.useQuery;
|
||||
|
||||
const ITEMS_PER_PAGE = 10;
|
||||
|
||||
function ForBadge({ message, error }: { message: string; error?: boolean }) {
|
||||
@ -170,17 +184,59 @@ export const evaluateEveryValidationOptions = <T extends FieldValues>(rules: Rul
|
||||
});
|
||||
|
||||
export interface ModalProps {
|
||||
namespace: CombinedRuleNamespace;
|
||||
group: CombinedRuleGroup;
|
||||
ruleGroupIdentifier: RuleGroupIdentifier;
|
||||
folderTitle?: string;
|
||||
rulerConfig: RulerDataSourceConfig;
|
||||
onClose: (saved?: boolean) => void;
|
||||
intervalEditOnly?: boolean;
|
||||
folderUrl?: string;
|
||||
folderUid?: string;
|
||||
hideFolder?: boolean;
|
||||
}
|
||||
|
||||
export function EditRuleGroupModal(props: ModalProps): React.ReactElement {
|
||||
const { namespace, group, onClose, intervalEditOnly, folderUid } = props;
|
||||
export interface ModalFormProps {
|
||||
ruleGroupIdentifier: RuleGroupIdentifier;
|
||||
folderTitle?: string; // used to display the GMA folder title
|
||||
ruleGroup: RulerRuleGroupDTO;
|
||||
onClose: (saved?: boolean) => void;
|
||||
intervalEditOnly?: boolean;
|
||||
folderUrl?: string;
|
||||
hideFolder?: boolean;
|
||||
}
|
||||
|
||||
// this component just wraps the modal with some loading state for grabbing rules and such
|
||||
export function EditRuleGroupModal(props: ModalProps) {
|
||||
const { ruleGroupIdentifier, rulerConfig, intervalEditOnly, onClose } = props;
|
||||
const rulesSourceName = ruleGroupIdentifier.dataSourceName;
|
||||
const isGrafanaManagedGroup = rulesSourceName === GRAFANA_RULES_SOURCE_NAME;
|
||||
|
||||
const modalTitle =
|
||||
intervalEditOnly || isGrafanaManagedGroup ? 'Edit evaluation group' : 'Edit namespace or evaluation group';
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const {
|
||||
data: ruleGroup,
|
||||
error,
|
||||
isLoading,
|
||||
} = useRuleGroupDefinition({
|
||||
group: ruleGroupIdentifier.groupName,
|
||||
namespace: ruleGroupIdentifier.namespaceName,
|
||||
rulerConfig,
|
||||
});
|
||||
|
||||
const loadingText = t('alerting.common.loading', 'Loading...');
|
||||
|
||||
return (
|
||||
<Modal className={styles.modal} isOpen={true} title={modalTitle} onDismiss={onClose} onClickBackdrop={onClose}>
|
||||
{isLoading && <LoadingPlaceholder text={loadingText} />}
|
||||
{error ? stringifyErrorLike(error) : null}
|
||||
{ruleGroup && <EditRuleGroupModalForm {...props} ruleGroup={ruleGroup} />}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditRuleGroupModalForm(props: ModalFormProps): React.ReactElement {
|
||||
const { ruleGroup, ruleGroupIdentifier, folderTitle, onClose, intervalEditOnly } = props;
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
const notifyApp = useAppNotification();
|
||||
@ -200,32 +256,21 @@ export function EditRuleGroupModal(props: ModalProps): React.ReactElement {
|
||||
|
||||
const defaultValues = useMemo(
|
||||
(): FormValues => ({
|
||||
namespaceName: decodeGrafanaNamespace(namespace).name,
|
||||
groupName: group.name,
|
||||
groupInterval: group.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL,
|
||||
namespaceName: ruleGroupIdentifier.namespaceName,
|
||||
groupName: ruleGroupIdentifier.groupName,
|
||||
groupInterval: ruleGroup?.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL,
|
||||
}),
|
||||
[namespace, group.name, group.interval]
|
||||
[ruleGroup?.interval, ruleGroupIdentifier.groupName, ruleGroupIdentifier.namespaceName]
|
||||
);
|
||||
|
||||
const rulesSourceName = getRulesSourceName(namespace.rulesSource);
|
||||
const rulesSourceName = ruleGroupIdentifier.dataSourceName;
|
||||
const isGrafanaManagedGroup = rulesSourceName === GRAFANA_RULES_SOURCE_NAME;
|
||||
|
||||
// parse any parent folders the alert rule might be stored in
|
||||
const nestedFolderParents = decodeGrafanaNamespace(namespace).parents;
|
||||
|
||||
const nameSpaceLabel = isGrafanaManagedGroup ? 'Folder' : 'Namespace';
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
const ruleGroupIdentifier: RuleGroupIdentifier = {
|
||||
dataSourceName: rulesSourceName,
|
||||
groupName: group.name,
|
||||
namespaceName: isGrafanaManagedGroup ? folderUid! : namespace.name,
|
||||
};
|
||||
|
||||
// make sure that when dealing with a nested folder for Grafana managed rules we encode the folder properly
|
||||
const updatedNamespaceName = isGrafanaManagedGroup
|
||||
? encodeGrafanaNamespace(values.namespaceName, nestedFolderParents)
|
||||
: values.namespaceName;
|
||||
const updatedNamespaceName = values.namespaceName;
|
||||
const updatedGroupName = values.groupName;
|
||||
const updatedInterval = values.groupInterval;
|
||||
|
||||
@ -266,136 +311,133 @@ export function EditRuleGroupModal(props: ModalProps): React.ReactElement {
|
||||
};
|
||||
|
||||
const rulesWithoutRecordingRules = compact(
|
||||
group.rules.map((r) => r.rulerRule).filter((rule) => !isGrafanaOrDataSourceRecordingRule(rule))
|
||||
ruleGroup?.rules.filter((rule) => !isGrafanaOrDataSourceRecordingRule(rule))
|
||||
);
|
||||
const hasSomeNoRecordingRules = rulesWithoutRecordingRules.length > 0;
|
||||
const modalTitle =
|
||||
intervalEditOnly || isGrafanaManagedGroup ? 'Edit evaluation group' : 'Edit namespace or evaluation group';
|
||||
|
||||
return (
|
||||
<Modal className={styles.modal} isOpen={true} title={modalTitle} onDismiss={onClose} onClickBackdrop={onClose}>
|
||||
<FormProvider {...formAPI}>
|
||||
<form onSubmit={handleSubmit(onSubmit, onInvalid)} key={JSON.stringify(defaultValues)}>
|
||||
<>
|
||||
{!props.hideFolder && (
|
||||
<Stack gap={1} alignItems={'center'}>
|
||||
<Field
|
||||
className={styles.formInput}
|
||||
label={
|
||||
<Label
|
||||
htmlFor="namespaceName"
|
||||
description={
|
||||
!isGrafanaManagedGroup &&
|
||||
'Change the current namespace name. Moving groups between namespaces is not supported'
|
||||
}
|
||||
>
|
||||
{nameSpaceLabel}
|
||||
</Label>
|
||||
}
|
||||
invalid={Boolean(errors.namespaceName) ? true : undefined}
|
||||
error={errors.namespaceName?.message}
|
||||
>
|
||||
<Input
|
||||
id="namespaceName"
|
||||
readOnly={intervalEditOnly || isGrafanaManagedGroup}
|
||||
{...register('namespaceName', {
|
||||
required: 'Namespace name is required.',
|
||||
})}
|
||||
/>
|
||||
</Field>
|
||||
{isGrafanaManagedGroup && props.folderUrl && (
|
||||
<LinkButton
|
||||
href={props.folderUrl}
|
||||
title="Go to folder"
|
||||
variant="secondary"
|
||||
icon="folder-open"
|
||||
target="_blank"
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
<Field
|
||||
label={
|
||||
<Label
|
||||
htmlFor="groupName"
|
||||
description="A group evaluates all its rules over the same evaluation interval."
|
||||
>
|
||||
Evaluation group
|
||||
</Label>
|
||||
}
|
||||
invalid={!!errors.groupName}
|
||||
error={errors.groupName?.message}
|
||||
>
|
||||
<Input
|
||||
autoFocus={true}
|
||||
id="groupName"
|
||||
readOnly={intervalEditOnly}
|
||||
{...register('groupName', {
|
||||
required: 'Evaluation group name is required.',
|
||||
})}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={
|
||||
<Label
|
||||
htmlFor="groupInterval"
|
||||
description="How often is the rule evaluated. Applies to every rule within the group."
|
||||
>
|
||||
<Stack gap={0.5}>Evaluation interval</Stack>
|
||||
</Label>
|
||||
}
|
||||
invalid={Boolean(errors.groupInterval) ? true : undefined}
|
||||
error={errors.groupInterval?.message}
|
||||
>
|
||||
<Stack direction="column">
|
||||
<FormProvider {...formAPI}>
|
||||
<form onSubmit={handleSubmit(onSubmit, onInvalid)} key={JSON.stringify(defaultValues)}>
|
||||
<>
|
||||
{!props.hideFolder && (
|
||||
<Stack gap={1} alignItems={'center'}>
|
||||
<Field
|
||||
className={styles.formInput}
|
||||
label={
|
||||
<Label
|
||||
htmlFor="namespaceName"
|
||||
description={
|
||||
!isGrafanaManagedGroup &&
|
||||
'Change the current namespace name. Moving groups between namespaces is not supported'
|
||||
}
|
||||
>
|
||||
{nameSpaceLabel}
|
||||
</Label>
|
||||
}
|
||||
invalid={Boolean(errors.namespaceName) ? true : undefined}
|
||||
error={errors.namespaceName?.message}
|
||||
>
|
||||
<Input
|
||||
id="groupInterval"
|
||||
placeholder={DEFAULT_GROUP_EVALUATION_INTERVAL}
|
||||
{...register('groupInterval', evaluateEveryValidationOptions(rulesWithoutRecordingRules))}
|
||||
id="namespaceName"
|
||||
readOnly={intervalEditOnly || isGrafanaManagedGroup}
|
||||
value={folderTitle}
|
||||
{...register('namespaceName', {
|
||||
required: 'Namespace name is required.',
|
||||
})}
|
||||
/>
|
||||
<EvaluationGroupQuickPick
|
||||
currentInterval={getValues('groupInterval')}
|
||||
onSelect={(value) => setValue('groupInterval', value, { shouldValidate: true, shouldDirty: true })}
|
||||
/>
|
||||
</Stack>
|
||||
</Field>
|
||||
|
||||
{/* if we're dealing with a Grafana-managed group, check if the evaluation interval is valid / permitted */}
|
||||
{isGrafanaManagedGroup && checkEvaluationIntervalGlobalLimit(watch('groupInterval')).exceedsLimit && (
|
||||
<EvaluationIntervalLimitExceeded />
|
||||
)}
|
||||
|
||||
{!hasSomeNoRecordingRules && <div>This group does not contain alert rules.</div>}
|
||||
{hasSomeNoRecordingRules && (
|
||||
<>
|
||||
<div>List of rules that belong to this group</div>
|
||||
<div className={styles.evalRequiredLabel}>
|
||||
#Eval column represents the number of evaluations needed before alert starts firing.
|
||||
</div>
|
||||
<RulesForGroupTable rulesWithoutRecordingRules={rulesWithoutRecordingRules} />
|
||||
</>
|
||||
)}
|
||||
{error && <Alert title={'Failed to update rule group'}>{stringifyErrorLike(error)}</Alert>}
|
||||
<div className={styles.modalButtons}>
|
||||
<Modal.ButtonRow>
|
||||
<Button
|
||||
</Field>
|
||||
{isGrafanaManagedGroup && props.folderUrl && (
|
||||
<LinkButton
|
||||
href={props.folderUrl}
|
||||
title="Go to folder"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => onClose(false)}
|
||||
fill="outline"
|
||||
>
|
||||
<Trans i18nKey="alerting.common.cancel">Cancel</Trans>
|
||||
</Button>
|
||||
<Button type="submit" disabled={!isDirty || !isValid || loading}>
|
||||
{loading ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</Modal.ButtonRow>
|
||||
</div>
|
||||
</>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</Modal>
|
||||
icon="folder-open"
|
||||
target="_blank"
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
<Field
|
||||
label={
|
||||
<Label
|
||||
htmlFor="groupName"
|
||||
description="A group evaluates all its rules over the same evaluation interval."
|
||||
>
|
||||
Evaluation group
|
||||
</Label>
|
||||
}
|
||||
invalid={!!errors.groupName}
|
||||
error={errors.groupName?.message}
|
||||
>
|
||||
<Input
|
||||
autoFocus={true}
|
||||
id="groupName"
|
||||
readOnly={intervalEditOnly}
|
||||
{...register('groupName', {
|
||||
required: 'Evaluation group name is required.',
|
||||
})}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={
|
||||
<Label
|
||||
htmlFor="groupInterval"
|
||||
description="How often is the rule evaluated. Applies to every rule within the group."
|
||||
>
|
||||
<Stack gap={0.5}>Evaluation interval</Stack>
|
||||
</Label>
|
||||
}
|
||||
invalid={Boolean(errors.groupInterval) ? true : undefined}
|
||||
error={errors.groupInterval?.message}
|
||||
>
|
||||
<Stack direction="column">
|
||||
<Input
|
||||
id="groupInterval"
|
||||
placeholder={DEFAULT_GROUP_EVALUATION_INTERVAL}
|
||||
{...register('groupInterval', evaluateEveryValidationOptions(rulesWithoutRecordingRules))}
|
||||
/>
|
||||
<EvaluationGroupQuickPick
|
||||
currentInterval={getValues('groupInterval')}
|
||||
onSelect={(value) => setValue('groupInterval', value, { shouldValidate: true, shouldDirty: true })}
|
||||
/>
|
||||
</Stack>
|
||||
</Field>
|
||||
|
||||
{/* if we're dealing with a Grafana-managed group, check if the evaluation interval is valid / permitted */}
|
||||
{isGrafanaManagedGroup && checkEvaluationIntervalGlobalLimit(watch('groupInterval')).exceedsLimit && (
|
||||
<EvaluationIntervalLimitExceeded />
|
||||
)}
|
||||
|
||||
{!hasSomeNoRecordingRules && <div>This group does not contain alert rules.</div>}
|
||||
{hasSomeNoRecordingRules && (
|
||||
<>
|
||||
<div>List of rules that belong to this group</div>
|
||||
<div className={styles.evalRequiredLabel}>
|
||||
#Eval column represents the number of evaluations needed before alert starts firing.
|
||||
</div>
|
||||
<RulesForGroupTable rulesWithoutRecordingRules={rulesWithoutRecordingRules} />
|
||||
</>
|
||||
)}
|
||||
{error && <Alert title={'Failed to update rule group'}>{stringifyErrorLike(error)}</Alert>}
|
||||
<div className={styles.modalButtons}>
|
||||
<Modal.ButtonRow>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => onClose(false)}
|
||||
fill="outline"
|
||||
>
|
||||
<Trans i18nKey="alerting.common.cancel">Cancel</Trans>
|
||||
</Button>
|
||||
<Button type="submit" disabled={!isDirty || !isValid || loading}>
|
||||
{loading ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</Modal.ButtonRow>
|
||||
</div>
|
||||
</>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -7,12 +7,14 @@ import { byRole, byTestId, byText } from 'testing-library-selector';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { configureStore } from 'app/store/configureStore';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting';
|
||||
import { CombinedRuleGroup, CombinedRuleNamespace, RulerDataSourceConfig } from 'app/types/unified-alerting';
|
||||
|
||||
import * as analytics from '../../Analytics';
|
||||
import { GRAFANA_RULER_CONFIG } from '../../api/featureDiscoveryApi';
|
||||
import { useHasRuler } from '../../hooks/useHasRuler';
|
||||
import { mockExportApi, mockFolderApi, setupMswServer } from '../../mockApi';
|
||||
import { grantUserPermissions, mockCombinedRule, mockDataSource, mockFolder, mockGrafanaRulerRule } from '../../mocks';
|
||||
import { mimirDataSource } from '../../mocks/server/configure';
|
||||
|
||||
import { RulesGroup } from './RulesGroup';
|
||||
|
||||
@ -38,10 +40,10 @@ const mocks = {
|
||||
useHasRuler: jest.mocked(useHasRuler),
|
||||
};
|
||||
|
||||
function mockUseHasRuler(hasRuler: boolean, rulerRulesLoaded: boolean) {
|
||||
function mockUseHasRuler(hasRuler: boolean, rulerConfig: RulerDataSourceConfig) {
|
||||
mocks.useHasRuler.mockReturnValue({
|
||||
hasRuler,
|
||||
rulerRulesLoaded,
|
||||
rulerConfig,
|
||||
});
|
||||
}
|
||||
|
||||
@ -107,7 +109,7 @@ describe('Rules group tests', () => {
|
||||
|
||||
it('Should hide delete and edit group buttons', async () => {
|
||||
// Act
|
||||
mockUseHasRuler(true, true);
|
||||
mockUseHasRuler(true, GRAFANA_RULER_CONFIG);
|
||||
mockFolderApi(server).folder('cpu-usage', mockFolder({ uid: 'cpu-usage', canSave: false }));
|
||||
renderRulesGroup(namespace, group);
|
||||
expect(await screen.findByTestId('rule-group')).toBeInTheDocument();
|
||||
@ -119,7 +121,7 @@ describe('Rules group tests', () => {
|
||||
|
||||
it('Should allow exporting rules group', async () => {
|
||||
// Arrange
|
||||
mockUseHasRuler(true, true);
|
||||
mockUseHasRuler(true, GRAFANA_RULER_CONFIG);
|
||||
mockFolderApi(server).folder('cpu-usage', mockFolder({ uid: 'cpu-usage' }));
|
||||
mockExportApi(server).exportRulesGroup('cpu-usage', 'TestGroup', {
|
||||
yaml: 'Yaml Export Content',
|
||||
@ -151,6 +153,8 @@ describe('Rules group tests', () => {
|
||||
});
|
||||
|
||||
describe('Cloud rules', () => {
|
||||
const { rulerConfig } = mimirDataSource();
|
||||
|
||||
beforeEach(() => {
|
||||
contextSrv.isEditor = true;
|
||||
});
|
||||
@ -169,7 +173,7 @@ describe('Rules group tests', () => {
|
||||
|
||||
it('When ruler enabled should display delete and edit group buttons', () => {
|
||||
// Arrange
|
||||
mockUseHasRuler(true, true);
|
||||
mockUseHasRuler(true, rulerConfig);
|
||||
|
||||
// Act
|
||||
renderRulesGroup(namespace, group);
|
||||
@ -182,7 +186,7 @@ describe('Rules group tests', () => {
|
||||
|
||||
it('When ruler disabled should hide delete and edit group buttons', () => {
|
||||
// Arrange
|
||||
mockUseHasRuler(false, false);
|
||||
mockUseHasRuler(false, rulerConfig);
|
||||
|
||||
// Act
|
||||
renderRulesGroup(namespace, group);
|
||||
@ -195,7 +199,7 @@ describe('Rules group tests', () => {
|
||||
|
||||
it('Delete button click should display confirmation modal', async () => {
|
||||
// Arrange
|
||||
mockUseHasRuler(true, true);
|
||||
mockUseHasRuler(true, rulerConfig);
|
||||
|
||||
// Act
|
||||
renderRulesGroup(namespace, group);
|
||||
@ -206,36 +210,4 @@ describe('Rules group tests', () => {
|
||||
expect(ui.confirmDeleteModal.confirmButton.get()).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Analytics', () => {
|
||||
beforeEach(() => {
|
||||
contextSrv.isEditor = true;
|
||||
});
|
||||
|
||||
const group: CombinedRuleGroup = {
|
||||
name: 'TestGroup',
|
||||
rules: [mockCombinedRule()],
|
||||
totals: {},
|
||||
};
|
||||
|
||||
const namespace: CombinedRuleNamespace = {
|
||||
name: 'TestNamespace',
|
||||
rulesSource: mockDataSource(),
|
||||
groups: [group],
|
||||
};
|
||||
|
||||
it('Should log info when closing the edit group rule modal without saving', async () => {
|
||||
mockUseHasRuler(true, true);
|
||||
renderRulesGroup(namespace, group);
|
||||
|
||||
await userEvent.click(ui.editGroupButton.get());
|
||||
|
||||
expect(screen.getByText('Cancel')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText('Cancel'));
|
||||
|
||||
expect(screen.queryByText('Cancel')).not.toBeInTheDocument();
|
||||
expect(analytics.logInfo).toHaveBeenCalledWith(analytics.LogMessages.leavingRuleGroupEdit);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { css } from '@emotion/css';
|
||||
import pluralize from 'pluralize';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
@ -26,7 +26,7 @@ import { ActionIcon } from './ActionIcon';
|
||||
import { EditRuleGroupModal } from './EditRuleGroupModal';
|
||||
import { ReorderCloudGroupModal } from './ReorderRuleGroupModal';
|
||||
import { RuleGroupStats } from './RuleStats';
|
||||
import { RulesTable } from './RulesTable';
|
||||
import { RulesTable, useIsRulesLoading } from './RulesTable';
|
||||
|
||||
type ViewMode = 'grouped' | 'list';
|
||||
|
||||
@ -42,6 +42,7 @@ const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi;
|
||||
export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }: Props) => {
|
||||
const { rulesSource } = namespace;
|
||||
const rulesSourceName = getRulesSourceName(rulesSource);
|
||||
const rulerRulesLoaded = useIsRulesLoading(rulesSource);
|
||||
|
||||
const [deleteRuleGroup] = useDeleteRuleGroup();
|
||||
const styles = useStyles2(getStyles);
|
||||
@ -58,7 +59,7 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }:
|
||||
setIsCollapsed(!expandAll);
|
||||
}, [expandAll]);
|
||||
|
||||
const { hasRuler, rulerRulesLoaded } = useHasRuler(namespace.rulesSource);
|
||||
const { hasRuler, rulerConfig } = useHasRuler(namespace.rulesSource);
|
||||
const { currentData: dsFeatures } = useDiscoverDsFeaturesQuery({ rulesSourceName });
|
||||
|
||||
const rulerRule = group.rules[0]?.rulerRule;
|
||||
@ -78,12 +79,15 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }:
|
||||
const isListView = viewMode === 'list';
|
||||
const isGroupView = viewMode === 'grouped';
|
||||
|
||||
const deleteGroup = async () => {
|
||||
const namespaceName = decodeGrafanaNamespace(namespace).name;
|
||||
const ruleGroupIdentifier = useMemo<RuleGroupIdentifier>(() => {
|
||||
const namespaceName = namespace.uid ?? namespace.name;
|
||||
const groupName = group.name;
|
||||
const dataSourceName = getRulesSourceName(namespace.rulesSource);
|
||||
|
||||
const ruleGroupIdentifier: RuleGroupIdentifier = { namespaceName, groupName, dataSourceName };
|
||||
return { namespaceName, groupName, dataSourceName };
|
||||
}, [namespace, group.name]);
|
||||
|
||||
const deleteGroup = async () => {
|
||||
await deleteRuleGroup.execute(ruleGroupIdentifier);
|
||||
setIsDeletingGroup(false);
|
||||
};
|
||||
@ -274,13 +278,13 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }:
|
||||
rules={group.rules}
|
||||
/>
|
||||
)}
|
||||
{isEditingGroup && (
|
||||
{isEditingGroup && rulerConfig && (
|
||||
<EditRuleGroupModal
|
||||
namespace={namespace}
|
||||
group={group}
|
||||
ruleGroupIdentifier={ruleGroupIdentifier}
|
||||
rulerConfig={rulerConfig}
|
||||
folderTitle={decodeGrafanaNamespace(namespace).name}
|
||||
onClose={() => closeEditModal()}
|
||||
folderUrl={folder?.canEdit ? makeFolderSettingsLink(folder.uid) : undefined}
|
||||
folderUid={folderUID}
|
||||
/>
|
||||
)}
|
||||
{isReorderingGroup && dsFeatures?.rulerConfig && (
|
||||
|
@ -4,7 +4,7 @@ import Skeleton from 'react-loading-skeleton';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Pagination, Tooltip, useStyles2 } from '@grafana/ui';
|
||||
import { CombinedRule } from 'app/types/unified-alerting';
|
||||
import { CombinedRule, RulesSource } from 'app/types/unified-alerting';
|
||||
|
||||
import { DEFAULT_PER_PAGE_PAGINATION } from '../../../../../core/constants';
|
||||
import { alertRuleApi } from '../../api/alertRuleApi';
|
||||
@ -14,6 +14,7 @@ import { useAsync } from '../../hooks/useAsync';
|
||||
import { attachRulerRuleToCombinedRule } from '../../hooks/useCombinedRuleNamespaces';
|
||||
import { useHasRuler } from '../../hooks/useHasRuler';
|
||||
import { usePagination } from '../../hooks/usePagination';
|
||||
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
|
||||
import { PluginOriginBadge } from '../../plugins/PluginOriginBadge';
|
||||
import { calculateNextEvaluationEstimate } from '../../rule-list/components/util';
|
||||
import { Annotation } from '../../utils/constants';
|
||||
@ -328,8 +329,20 @@ function RuleActionsCell({ rule, isLoadingRuler }: { rule: CombinedRule; isLoadi
|
||||
);
|
||||
}
|
||||
|
||||
export function useIsRulesLoading(rulesSource: RulesSource) {
|
||||
const rulerRules = useUnifiedAlertingSelector((state) => state.rulerRules);
|
||||
const rulesSourceName = getRulesSourceName(rulesSource);
|
||||
|
||||
const rulerRulesLoaded = Boolean(rulerRules[rulesSourceName]?.result);
|
||||
return rulerRulesLoaded;
|
||||
}
|
||||
|
||||
function useRuleStatus(rule: CombinedRule) {
|
||||
const { hasRuler, rulerRulesLoaded } = useHasRuler(rule.namespace.rulesSource);
|
||||
const rulesSource = rule.namespace.rulesSource;
|
||||
|
||||
const rulerRulesLoaded = useIsRulesLoading(rulesSource);
|
||||
const { hasRuler } = useHasRuler(rulesSource);
|
||||
|
||||
const { promRule, rulerRule } = rule;
|
||||
|
||||
// If prometheusRulesPrimary is enabled, we don't fetch rules from the Ruler API (except for Grafana managed rules)
|
||||
|
@ -7,7 +7,7 @@ import { alertRuleApi } from '../../api/alertRuleApi';
|
||||
import { featureDiscoveryApi } from '../../api/featureDiscoveryApi';
|
||||
import { notFoundToNullOrThrow } from '../../api/util';
|
||||
import { ruleGroupReducer } from '../../reducers/ruler/ruleGroups';
|
||||
import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../../utils/rule-form';
|
||||
import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../../rule-editor/formDefaults';
|
||||
|
||||
const { useLazyGetRuleGroupForNamespaceQuery } = alertRuleApi;
|
||||
const { useLazyDiscoverDsFeaturesQuery } = featureDiscoveryApi;
|
||||
|
@ -3,19 +3,14 @@ import { RulesSource } from 'app/types/unified-alerting';
|
||||
import { featureDiscoveryApi } from '../api/featureDiscoveryApi';
|
||||
import { getRulesSourceName } from '../utils/datasource';
|
||||
|
||||
import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector';
|
||||
|
||||
const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi;
|
||||
|
||||
// datasource has ruler if the discovery api returns a rulerConfig
|
||||
export function useHasRuler(rulesSource: RulesSource) {
|
||||
const rulerRules = useUnifiedAlertingSelector((state) => state.rulerRules);
|
||||
const rulesSourceName = getRulesSourceName(rulesSource);
|
||||
|
||||
const { currentData: dsFeatures } = useDiscoverDsFeaturesQuery({ rulesSourceName });
|
||||
|
||||
const hasRuler = Boolean(dsFeatures?.rulerConfig);
|
||||
const rulerRulesLoaded = Boolean(rulerRules[rulesSourceName]?.result);
|
||||
|
||||
return { hasRuler, rulerRulesLoaded };
|
||||
return { hasRuler, rulerConfig: dsFeatures?.rulerConfig };
|
||||
}
|
||||
|
@ -4,6 +4,9 @@ import { StoreState, useSelector } from 'app/types';
|
||||
|
||||
import { UnifiedAlertingState } from '../state/reducers';
|
||||
|
||||
/**
|
||||
* @deprecated: DO NOT USE THIS; when using this you are INCORRECTLY assuming that we already have dispatched an action to populate the redux store values
|
||||
*/
|
||||
export function useUnifiedAlertingSelector<TSelected = unknown>(
|
||||
selector: (state: UnifiedAlertingState) => TSelected,
|
||||
equalityFn?: (left: TSelected, right: TSelected) => boolean
|
||||
|
@ -196,15 +196,12 @@ export const mockRulerAlertingRule = (partial: Partial<RulerAlertingRuleDTO> = {
|
||||
...partial,
|
||||
});
|
||||
|
||||
export const mockRulerRecordingRule = (partial: Partial<RulerRecordingRuleDTO> = {}): RulerAlertingRuleDTO => ({
|
||||
alert: 'alert1',
|
||||
export const mockRulerRecordingRule = (partial: Partial<RulerRecordingRuleDTO> = {}): RulerRecordingRuleDTO => ({
|
||||
record: 'alert1',
|
||||
expr: 'up = 1',
|
||||
labels: {
|
||||
severity: 'warning',
|
||||
},
|
||||
annotations: {
|
||||
summary: 'test alert',
|
||||
},
|
||||
...partial,
|
||||
});
|
||||
|
||||
@ -735,7 +732,7 @@ export function mockStore(recipe: (state: StoreState) => void) {
|
||||
return configureStore(produce(defaultState, recipe));
|
||||
}
|
||||
|
||||
export function mockAlertQuery(query: Partial<AlertQuery>): AlertQuery {
|
||||
export function mockAlertQuery(query: Partial<AlertQuery> = {}): AlertQuery {
|
||||
return {
|
||||
datasourceUid: '--uid--',
|
||||
refId: 'A',
|
||||
|
@ -20,6 +20,7 @@ import { SupportedPlugin } from 'app/features/alerting/unified/types/pluginBridg
|
||||
import { clearPluginSettingsCache } from 'app/features/plugins/pluginSettings';
|
||||
import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { FolderDTO } from 'app/types';
|
||||
import { RulerDataSourceConfig } from 'app/types/unified-alerting';
|
||||
import { PromRuleGroupDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { setupDataSources } from '../../testSetup/datasources';
|
||||
@ -108,9 +109,15 @@ export function mimirDataSource() {
|
||||
{ alerting: true, module: 'core:plugin/prometheus' }
|
||||
);
|
||||
|
||||
const rulerConfig: RulerDataSourceConfig = {
|
||||
apiVersion: 'config',
|
||||
dataSourceUid: dataSource.uid,
|
||||
dataSourceName: dataSource.name,
|
||||
};
|
||||
|
||||
setupDataSources(dataSource);
|
||||
|
||||
return { dataSource };
|
||||
return { dataSource, rulerConfig };
|
||||
}
|
||||
|
||||
export function setPrometheusRules(ds: DataSourceInstanceSettings, groups: PromRuleGroupDTO[]) {
|
||||
|
@ -1,18 +1,23 @@
|
||||
import { Factory } from 'fishery';
|
||||
import { uniqueId } from 'lodash';
|
||||
|
||||
import { DataSourceInstanceSettings, PluginType } from '@grafana/data';
|
||||
import { config, setDataSourceSrv } from '@grafana/runtime';
|
||||
import { FolderDTO } from 'app/types';
|
||||
import {
|
||||
PromAlertingRuleDTO,
|
||||
PromAlertingRuleState,
|
||||
PromRuleGroupDTO,
|
||||
PromRuleType,
|
||||
RulerAlertingRuleDTO,
|
||||
RulerRecordingRuleDTO,
|
||||
RulerRuleGroupDTO,
|
||||
} from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { MockDataSourceSrv } from '../../mocks';
|
||||
import { DataSourceType } from '../../utils/datasource';
|
||||
|
||||
const ruleFactory = Factory.define<PromAlertingRuleDTO>(({ sequence }) => ({
|
||||
const prometheusRuleFactory = Factory.define<PromAlertingRuleDTO>(({ sequence }) => ({
|
||||
name: `test-rule-${sequence}`,
|
||||
query: 'test-query',
|
||||
state: PromAlertingRuleState.Inactive,
|
||||
@ -21,15 +26,35 @@ const ruleFactory = Factory.define<PromAlertingRuleDTO>(({ sequence }) => ({
|
||||
labels: { team: 'infra' },
|
||||
}));
|
||||
|
||||
const groupFactory = Factory.define<PromRuleGroupDTO>(({ sequence }) => {
|
||||
const rulerAlertingRuleFactory = Factory.define<RulerAlertingRuleDTO>(({ sequence }) => ({
|
||||
alert: `ruler-alerting-rule-${sequence}`,
|
||||
expr: 'vector(0)',
|
||||
annotations: { 'annotation-key-1': 'annotation-value-1' },
|
||||
labels: { 'label-key-1': 'label-value-1' },
|
||||
for: '5m',
|
||||
}));
|
||||
|
||||
const rulerRecordingRuleFactory = Factory.define<RulerRecordingRuleDTO>(({ sequence }) => ({
|
||||
record: `ruler-recording-rule-${sequence}`,
|
||||
expr: 'vector(0)',
|
||||
labels: { 'label-key-1': 'label-value-1' },
|
||||
}));
|
||||
|
||||
const rulerRuleGroupFactory = Factory.define<RulerRuleGroupDTO>(({ sequence }) => ({
|
||||
name: `ruler-rule-group-${sequence}`,
|
||||
rules: [],
|
||||
interval: '1m',
|
||||
}));
|
||||
|
||||
const prometheusRuleGroupFactory = Factory.define<PromRuleGroupDTO>(({ sequence }) => {
|
||||
const group = {
|
||||
name: `test-group-${sequence}`,
|
||||
file: `test-namespace`,
|
||||
interval: 10,
|
||||
rules: ruleFactory.buildList(10),
|
||||
rules: prometheusRuleFactory.buildList(10),
|
||||
};
|
||||
|
||||
ruleFactory.rewindSequence();
|
||||
prometheusRuleFactory.rewindSequence();
|
||||
|
||||
return group;
|
||||
});
|
||||
@ -72,8 +97,33 @@ const dataSourceFactory = Factory.define<DataSourceInstanceSettings>(({ sequence
|
||||
};
|
||||
});
|
||||
|
||||
const grafanaFolderFactory = Factory.define<FolderDTO>(({ sequence }) => ({
|
||||
id: sequence,
|
||||
uid: uniqueId(),
|
||||
title: `Mock Folder ${sequence}`,
|
||||
version: 1,
|
||||
url: '',
|
||||
canAdmin: true,
|
||||
canDelete: true,
|
||||
canEdit: true,
|
||||
canSave: true,
|
||||
created: '',
|
||||
createdBy: '',
|
||||
hasAcl: false,
|
||||
updated: '',
|
||||
updatedBy: '',
|
||||
}));
|
||||
|
||||
export const alertingFactory = {
|
||||
group: groupFactory,
|
||||
rule: ruleFactory,
|
||||
folder: grafanaFolderFactory,
|
||||
prometheus: {
|
||||
group: prometheusRuleGroupFactory,
|
||||
rule: prometheusRuleFactory,
|
||||
},
|
||||
ruler: {
|
||||
group: rulerRuleGroupFactory,
|
||||
alertingRule: rulerAlertingRuleFactory,
|
||||
recordingRule: rulerRecordingRuleFactory,
|
||||
},
|
||||
dataSource: dataSourceFactory,
|
||||
};
|
||||
|
@ -104,10 +104,6 @@ exports[`removing a rule should remove a Data source managed ruler rule without
|
||||
},
|
||||
},
|
||||
{
|
||||
"alert": "alert1",
|
||||
"annotations": {
|
||||
"summary": "test alert",
|
||||
},
|
||||
"expr": "up = 1",
|
||||
"labels": {
|
||||
"severity": "warning",
|
||||
|
@ -4,36 +4,36 @@ import { getWrapper, render, waitFor, waitForElementToBeRemoved, within } from '
|
||||
import { byRole, byTestId, byText } from 'testing-library-selector';
|
||||
|
||||
import { MIMIR_DATASOURCE_UID } from 'app/features/alerting/unified/mocks/server/constants';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
import { RuleWithLocation } from 'app/types/unified-alerting';
|
||||
|
||||
import { AccessControlAction } from '../../../types';
|
||||
import {
|
||||
RulerAlertingRuleDTO,
|
||||
RulerGrafanaRuleDTO,
|
||||
RulerRecordingRuleDTO,
|
||||
RulerRuleDTO,
|
||||
} from '../../../types/unified-alerting-dto';
|
||||
} from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { CloneRuleEditor, cloneRuleDefinition } from './CloneRuleEditor';
|
||||
import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
|
||||
import { setupMswServer } from './mockApi';
|
||||
import { ExpressionEditorProps } from '../components/rule-editor/ExpressionEditor';
|
||||
import { setupMswServer } from '../mockApi';
|
||||
import {
|
||||
grantUserPermissions,
|
||||
mockDataSource,
|
||||
mockRulerAlertingRule,
|
||||
mockRulerGrafanaRule,
|
||||
mockRulerRuleGroup,
|
||||
} from './mocks';
|
||||
import { grafanaRulerRule } from './mocks/grafanaRulerApi';
|
||||
import { mockRulerRulesApiResponse, mockRulerRulesGroupApiResponse } from './mocks/rulerApi';
|
||||
import { AlertingQueryRunner } from './state/AlertingQueryRunner';
|
||||
import { setupDataSources } from './testSetup/datasources';
|
||||
import { RuleFormValues } from './types/rule-form';
|
||||
import { Annotation } from './utils/constants';
|
||||
import { getDefaultFormValues } from './utils/rule-form';
|
||||
import { hashRulerRule } from './utils/rule-id';
|
||||
} from '../mocks';
|
||||
import { grafanaRulerRule } from '../mocks/grafanaRulerApi';
|
||||
import { mockRulerRulesApiResponse, mockRulerRulesGroupApiResponse } from '../mocks/rulerApi';
|
||||
import { AlertingQueryRunner } from '../state/AlertingQueryRunner';
|
||||
import { setupDataSources } from '../testSetup/datasources';
|
||||
import { RuleFormValues } from '../types/rule-form';
|
||||
import { Annotation } from '../utils/constants';
|
||||
import { hashRulerRule } from '../utils/rule-id';
|
||||
|
||||
jest.mock('./components/rule-editor/ExpressionEditor', () => ({
|
||||
import { CloneRuleEditor, cloneRuleDefinition } from './CloneRuleEditor';
|
||||
import { getDefaultFormValues } from './formDefaults';
|
||||
|
||||
jest.mock('../components/rule-editor/ExpressionEditor', () => ({
|
||||
// eslint-disable-next-line react/display-name
|
||||
ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => (
|
||||
<input value={value} data-testid="expr" onChange={(e) => onChange(e.target.value)} />
|
@ -1,18 +1,17 @@
|
||||
import { cloneDeep } from 'lodash';
|
||||
|
||||
import { locationService } from '@grafana/runtime/src';
|
||||
import { Alert, LoadingPlaceholder } from '@grafana/ui/src';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import { Alert, LoadingPlaceholder } from '@grafana/ui';
|
||||
import { RuleIdentifier, RuleWithLocation } from 'app/types/unified-alerting';
|
||||
import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { RuleIdentifier, RuleWithLocation } from '../../../types/unified-alerting';
|
||||
import { RulerRuleDTO } from '../../../types/unified-alerting-dto';
|
||||
|
||||
import { AlertRuleForm } from './components/rule-editor/alert-rule-form/AlertRuleForm';
|
||||
import { useRuleWithLocation } from './hooks/useCombinedRule';
|
||||
import { generateCopiedName } from './utils/duplicate';
|
||||
import { stringifyErrorLike } from './utils/misc';
|
||||
import { rulerRuleToFormValues } from './utils/rule-form';
|
||||
import { getRuleName, isAlertingRulerRule, isGrafanaRulerRule, isRecordingRulerRule } from './utils/rules';
|
||||
import { createRelativeUrl } from './utils/url';
|
||||
import { AlertRuleForm } from '../components/rule-editor/alert-rule-form/AlertRuleForm';
|
||||
import { useRuleWithLocation } from '../hooks/useCombinedRule';
|
||||
import { generateCopiedName } from '../utils/duplicate';
|
||||
import { stringifyErrorLike } from '../utils/misc';
|
||||
import { rulerRuleToFormValues } from '../utils/rule-form';
|
||||
import { getRuleName, isAlertingRulerRule, isGrafanaRulerRule, isRecordingRulerRule } from '../utils/rules';
|
||||
import { createRelativeUrl } from '../utils/url';
|
||||
|
||||
export function CloneRuleEditor({ sourceRuleId }: { sourceRuleId: RuleIdentifier }) {
|
||||
const { loading, result: rule, error } = useRuleWithLocation({ ruleIdentifier: sourceRuleId });
|
@ -1,19 +1,18 @@
|
||||
import { Alert, LoadingPlaceholder } from '@grafana/ui';
|
||||
import { RuleIdentifier } from 'app/types/unified-alerting';
|
||||
|
||||
import { AlertWarning } from './AlertWarning';
|
||||
import { AlertRuleForm } from './components/rule-editor/alert-rule-form/AlertRuleForm';
|
||||
import { useRuleWithLocation } from './hooks/useCombinedRule';
|
||||
import { useIsRuleEditable } from './hooks/useIsRuleEditable';
|
||||
import { stringifyErrorLike } from './utils/misc';
|
||||
import * as ruleId from './utils/rule-id';
|
||||
import { AlertWarning } from '../AlertWarning';
|
||||
import { AlertRuleForm } from '../components/rule-editor/alert-rule-form/AlertRuleForm';
|
||||
import { useRuleWithLocation } from '../hooks/useCombinedRule';
|
||||
import { useIsRuleEditable } from '../hooks/useIsRuleEditable';
|
||||
import { stringifyErrorLike } from '../utils/misc';
|
||||
import * as ruleId from '../utils/rule-id';
|
||||
|
||||
interface ExistingRuleEditorProps {
|
||||
identifier: RuleIdentifier;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export function ExistingRuleEditor({ identifier, id }: ExistingRuleEditorProps) {
|
||||
export function ExistingRuleEditor({ identifier }: ExistingRuleEditorProps) {
|
||||
const {
|
||||
loading: loadingAlertRule,
|
||||
result: ruleWithLocation,
|
@ -5,14 +5,16 @@ import { NavModelItem } from '@grafana/data';
|
||||
import { withErrorBoundary } from '@grafana/ui';
|
||||
import { RuleIdentifier } from 'app/types/unified-alerting';
|
||||
|
||||
import { AlertWarning } from './AlertWarning';
|
||||
import { AlertWarning } from '../AlertWarning';
|
||||
import { AlertingPageWrapper } from '../components/AlertingPageWrapper';
|
||||
import { AlertRuleForm } from '../components/rule-editor/alert-rule-form/AlertRuleForm';
|
||||
import { useURLSearchParams } from '../hooks/useURLSearchParams';
|
||||
import { useRulesAccess } from '../utils/accessControlHooks';
|
||||
import * as ruleId from '../utils/rule-id';
|
||||
|
||||
import { CloneRuleEditor } from './CloneRuleEditor';
|
||||
import { ExistingRuleEditor } from './ExistingRuleEditor';
|
||||
import { AlertingPageWrapper } from './components/AlertingPageWrapper';
|
||||
import { AlertRuleForm } from './components/rule-editor/alert-rule-form/AlertRuleForm';
|
||||
import { useURLSearchParams } from './hooks/useURLSearchParams';
|
||||
import { useRulesAccess } from './utils/accessControlHooks';
|
||||
import * as ruleId from './utils/rule-id';
|
||||
import { formValuesFromQueryParams, translateRouteParamToRuleType } from './formDefaults';
|
||||
|
||||
type RuleEditorPathParams = {
|
||||
id?: string;
|
||||
@ -44,14 +46,8 @@ const getPageNav = (identifier?: RuleIdentifier, type?: RuleEditorPathParams['ty
|
||||
};
|
||||
|
||||
const RuleEditor = () => {
|
||||
const [searchParams] = useURLSearchParams();
|
||||
const params = useParams<RuleEditorPathParams>();
|
||||
const { type } = params;
|
||||
const id = ruleId.getRuleIdFromPathname(params);
|
||||
const identifier = ruleId.tryParse(id, true);
|
||||
|
||||
const copyFromId = searchParams.get('copyFrom') ?? undefined;
|
||||
const copyFromIdentifier = ruleId.tryParse(copyFromId);
|
||||
const { identifier, type } = useRuleEditorPathParams();
|
||||
const { copyFromIdentifier, queryDefaults } = useRuleEditorQueryParams();
|
||||
|
||||
const { canCreateGrafanaRules, canCreateCloudRules, canEditRules } = useRulesAccess();
|
||||
|
||||
@ -65,15 +61,15 @@ const RuleEditor = () => {
|
||||
}
|
||||
|
||||
if (identifier) {
|
||||
return <ExistingRuleEditor key={id} identifier={identifier} id={id} />;
|
||||
return <ExistingRuleEditor key={JSON.stringify(identifier)} identifier={identifier} />;
|
||||
}
|
||||
|
||||
if (copyFromIdentifier) {
|
||||
return <CloneRuleEditor sourceRuleId={copyFromIdentifier} />;
|
||||
}
|
||||
// new alert rule
|
||||
return <AlertRuleForm />;
|
||||
}, [canCreateCloudRules, canCreateGrafanaRules, canEditRules, copyFromIdentifier, id, identifier]);
|
||||
return <AlertRuleForm prefill={queryDefaults} />;
|
||||
}, [canCreateCloudRules, canCreateGrafanaRules, canEditRules, copyFromIdentifier, identifier, queryDefaults]);
|
||||
|
||||
return (
|
||||
<AlertingPageWrapper navId="alert-list" pageNav={getPageNav(identifier, type)}>
|
||||
@ -83,3 +79,28 @@ const RuleEditor = () => {
|
||||
};
|
||||
|
||||
export default withErrorBoundary(RuleEditor, { style: 'page' });
|
||||
|
||||
function useRuleEditorPathParams() {
|
||||
const params = useParams<RuleEditorPathParams>();
|
||||
const { type } = params;
|
||||
const id = ruleId.getRuleIdFromPathname(params);
|
||||
const identifier = ruleId.tryParse(id, true);
|
||||
|
||||
return { identifier, type };
|
||||
}
|
||||
|
||||
function useRuleEditorQueryParams() {
|
||||
const { type } = useParams<RuleEditorPathParams>();
|
||||
|
||||
const [searchParams] = useURLSearchParams();
|
||||
const copyFromId = searchParams.get('copyFrom') ?? undefined;
|
||||
const copyFromIdentifier = ruleId.tryParse(copyFromId);
|
||||
|
||||
const ruleType = translateRouteParamToRuleType(type);
|
||||
|
||||
const queryDefaults = searchParams.has('defaults')
|
||||
? formValuesFromQueryParams(searchParams.get('defaults') ?? '', ruleType)
|
||||
: undefined;
|
||||
|
||||
return { copyFromIdentifier, queryDefaults };
|
||||
}
|
@ -6,24 +6,24 @@ import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
import { PromApiFeatures, PromApplication } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { discoverFeaturesByUid } from './api/buildInfo';
|
||||
import { fetchRulerRulesGroup } from './api/ruler';
|
||||
import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
|
||||
import { setupMswServer } from './mockApi';
|
||||
import { grantUserPermissions, mockDataSource } from './mocks';
|
||||
import { setupDataSources } from './testSetup/datasources';
|
||||
import { DataSourceType, GRAFANA_DATASOURCE_NAME, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
|
||||
import { discoverFeaturesByUid } from '../api/buildInfo';
|
||||
import { fetchRulerRulesGroup } from '../api/ruler';
|
||||
import { ExpressionEditorProps } from '../components/rule-editor/ExpressionEditor';
|
||||
import { setupMswServer } from '../mockApi';
|
||||
import { grantUserPermissions, mockDataSource } from '../mocks';
|
||||
import { setupDataSources } from '../testSetup/datasources';
|
||||
import { DataSourceType, GRAFANA_DATASOURCE_NAME, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
|
||||
|
||||
jest.mock('./components/rule-editor/ExpressionEditor', () => ({
|
||||
jest.mock('../components/rule-editor/ExpressionEditor', () => ({
|
||||
// eslint-disable-next-line react/display-name
|
||||
ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => (
|
||||
<input value={value} data-testid="expr" onChange={(e) => onChange(e.target.value)} />
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('./api/buildInfo');
|
||||
jest.mock('./api/ruler', () => ({
|
||||
rulerUrlBuilder: jest.requireActual('./api/ruler').rulerUrlBuilder,
|
||||
jest.mock('../api/buildInfo');
|
||||
jest.mock('../api/ruler', () => ({
|
||||
rulerUrlBuilder: jest.requireActual('../api/ruler').rulerUrlBuilder,
|
||||
fetchRulerRules: jest.fn(),
|
||||
fetchRulerRulesGroup: jest.fn(),
|
||||
fetchRulerRulesNamespace: jest.fn(),
|
||||
@ -36,8 +36,8 @@ jest.mock('app/features/query/components/QueryEditorRow', () => ({
|
||||
QueryEditorRow: () => <p>hi</p>,
|
||||
}));
|
||||
|
||||
jest.mock('./components/rule-editor/util', () => {
|
||||
const originalModule = jest.requireActual('./components/rule-editor/util');
|
||||
jest.mock('../components/rule-editor/util', () => {
|
||||
const originalModule = jest.requireActual('../components/rule-editor/util');
|
||||
return {
|
||||
...originalModule,
|
||||
getThresholdsForQueries: jest.fn(() => ({})),
|
@ -5,15 +5,15 @@ import { screen } from 'test/test-utils';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
|
||||
import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
|
||||
import { setupMswServer } from './mockApi';
|
||||
import { grantUserPermissions } from './mocks';
|
||||
import { GROUP_3, NAMESPACE_2 } from './mocks/mimirRulerApi';
|
||||
import { mimirDataSource } from './mocks/server/configure';
|
||||
import { MIMIR_DATASOURCE_UID } from './mocks/server/constants';
|
||||
import { captureRequests, serializeRequests } from './mocks/server/events';
|
||||
import { ExpressionEditorProps } from '../components/rule-editor/ExpressionEditor';
|
||||
import { setupMswServer } from '../mockApi';
|
||||
import { grantUserPermissions } from '../mocks';
|
||||
import { GROUP_3, NAMESPACE_2 } from '../mocks/mimirRulerApi';
|
||||
import { mimirDataSource } from '../mocks/server/configure';
|
||||
import { MIMIR_DATASOURCE_UID } from '../mocks/server/constants';
|
||||
import { captureRequests, serializeRequests } from '../mocks/server/events';
|
||||
|
||||
jest.mock('./components/rule-editor/ExpressionEditor', () => ({
|
||||
jest.mock('../components/rule-editor/ExpressionEditor', () => ({
|
||||
// eslint-disable-next-line react/display-name
|
||||
ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => (
|
||||
<input value={value} data-testid="expr" onChange={(e) => onChange(e.target.value)} />
|
@ -7,15 +7,15 @@ import { setFolderResponse } from 'app/features/alerting/unified/mocks/server/co
|
||||
import { MIMIR_DATASOURCE_UID } from 'app/features/alerting/unified/mocks/server/constants';
|
||||
import { captureRequests } from 'app/features/alerting/unified/mocks/server/events';
|
||||
import { DashboardSearchItemType } from 'app/features/search/types';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
|
||||
import { AccessControlAction } from '../../../types';
|
||||
import { setupMswServer } from '../mockApi';
|
||||
import { grantUserPermissions, mockDataSource, mockFolder } from '../mocks';
|
||||
import { grafanaRulerRule } from '../mocks/grafanaRulerApi';
|
||||
import { setupDataSources } from '../testSetup/datasources';
|
||||
import { Annotation } from '../utils/constants';
|
||||
|
||||
import RuleEditor from './RuleEditor';
|
||||
import { setupMswServer } from './mockApi';
|
||||
import { grantUserPermissions, mockDataSource, mockFolder } from './mocks';
|
||||
import { grafanaRulerRule } from './mocks/grafanaRulerApi';
|
||||
import { setupDataSources } from './testSetup/datasources';
|
||||
import { Annotation } from './utils/constants';
|
||||
|
||||
jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({
|
||||
AppChromeUpdate: ({ actions }: { actions: React.ReactNode }) => <div>{actions}</div>,
|
@ -9,10 +9,10 @@ import { setupMswServer } from 'app/features/alerting/unified/mockApi';
|
||||
import { PROMETHEUS_DATASOURCE_UID } from 'app/features/alerting/unified/mocks/server/constants';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
|
||||
import { grantUserPermissions, mockDataSource } from './mocks';
|
||||
import { grafanaRulerGroup } from './mocks/grafanaRulerApi';
|
||||
import { captureRequests, serializeRequests } from './mocks/server/events';
|
||||
import { setupDataSources } from './testSetup/datasources';
|
||||
import { grantUserPermissions, mockDataSource } from '../mocks';
|
||||
import { grafanaRulerGroup } from '../mocks/grafanaRulerApi';
|
||||
import { captureRequests, serializeRequests } from '../mocks/server/events';
|
||||
import { setupDataSources } from '../testSetup/datasources';
|
||||
|
||||
jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({
|
||||
AppChromeUpdate: ({ actions }: { actions: React.ReactNode }) => <div>{actions}</div>,
|
@ -7,14 +7,14 @@ import { byText } from 'testing-library-selector';
|
||||
import { setupMswServer } from 'app/features/alerting/unified/mockApi';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
|
||||
import { RecordingRuleEditorProps } from './components/rule-editor/RecordingRuleEditor';
|
||||
import { grantUserPermissions } from './mocks';
|
||||
import { GROUP_3, NAMESPACE_2 } from './mocks/mimirRulerApi';
|
||||
import { mimirDataSource } from './mocks/server/configure';
|
||||
import { MIMIR_DATASOURCE_UID } from './mocks/server/constants';
|
||||
import { captureRequests, serializeRequests } from './mocks/server/events';
|
||||
import { RecordingRuleEditorProps } from '../components/rule-editor/RecordingRuleEditor';
|
||||
import { grantUserPermissions } from '../mocks';
|
||||
import { GROUP_3, NAMESPACE_2 } from '../mocks/mimirRulerApi';
|
||||
import { mimirDataSource } from '../mocks/server/configure';
|
||||
import { MIMIR_DATASOURCE_UID } from '../mocks/server/constants';
|
||||
import { captureRequests, serializeRequests } from '../mocks/server/events';
|
||||
|
||||
jest.mock('./components/rule-editor/RecordingRuleEditor', () => ({
|
||||
jest.mock('../components/rule-editor/RecordingRuleEditor', () => ({
|
||||
RecordingRuleEditor: ({ queries, onChangeQuery }: Pick<RecordingRuleEditorProps, 'queries' | 'onChangeQuery'>) => {
|
||||
const onChange = (expr: string) => {
|
||||
const query = queries[0];
|
@ -0,0 +1,196 @@
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { mockAlertQuery, mockDataSource, reduceExpression, thresholdExpression } from '../mocks';
|
||||
import { testWithFeatureToggles } from '../test/test-utils';
|
||||
import { RuleFormType } from '../types/rule-form';
|
||||
import { Annotation } from '../utils/constants';
|
||||
import { DataSourceType, getDefaultOrFirstCompatibleDataSource } from '../utils/datasource';
|
||||
import { MANUAL_ROUTING_KEY, getDefaultQueries } from '../utils/rule-form';
|
||||
|
||||
import { formValuesFromQueryParams, getDefaultFormValues, getDefautManualRouting } from './formDefaults';
|
||||
import { isAlertQueryOfAlertData } from './formProcessing';
|
||||
|
||||
jest.mock('../utils/datasource');
|
||||
|
||||
const mocks = {
|
||||
getDefaultOrFirstCompatibleDataSource: jest.mocked(getDefaultOrFirstCompatibleDataSource),
|
||||
};
|
||||
|
||||
// Setup mock implementation
|
||||
mocks.getDefaultOrFirstCompatibleDataSource.mockReturnValue(
|
||||
mockDataSource({
|
||||
type: DataSourceType.Prometheus,
|
||||
})
|
||||
);
|
||||
|
||||
// TODO Not sure why queries are an empty array in the default form values
|
||||
const defaultFormValues = {
|
||||
...getDefaultFormValues(),
|
||||
queries: getDefaultQueries(),
|
||||
};
|
||||
|
||||
describe('formValuesFromQueryParams', () => {
|
||||
it('should return default values when given invalid JSON', () => {
|
||||
const result = formValuesFromQueryParams('invalid json', RuleFormType.grafana);
|
||||
|
||||
expect(result).toEqual(defaultFormValues);
|
||||
});
|
||||
|
||||
it('should normalize annotations', () => {
|
||||
const ruleDefinition = JSON.stringify({
|
||||
annotations: [
|
||||
{ key: 'custom', value: 'my custom annotation' },
|
||||
{ key: Annotation.runbookURL, value: 'runbook annotation' },
|
||||
{ key: 'custom-2', value: 'custom annotation v2' },
|
||||
{ key: Annotation.summary, value: 'summary annotation' },
|
||||
{ key: 'custom-3', value: 'custom annotation v3' },
|
||||
{ key: Annotation.description, value: 'description annotation' },
|
||||
],
|
||||
});
|
||||
|
||||
const result = formValuesFromQueryParams(ruleDefinition, RuleFormType.grafana);
|
||||
|
||||
const [summary, description, runbookURL, ...rest] = result.annotations;
|
||||
|
||||
expect(summary).toEqual({ key: Annotation.summary, value: 'summary annotation' });
|
||||
expect(description).toEqual({ key: Annotation.description, value: 'description annotation' });
|
||||
expect(runbookURL).toEqual({ key: Annotation.runbookURL, value: 'runbook annotation' });
|
||||
expect(rest).toContainEqual({ key: 'custom', value: 'my custom annotation' });
|
||||
expect(rest).toContainEqual({ key: 'custom-2', value: 'custom annotation v2' });
|
||||
expect(rest).toContainEqual({ key: 'custom-3', value: 'custom annotation v3' });
|
||||
});
|
||||
|
||||
it('should disable simplified query editor when query switch mode is disabled', () => {
|
||||
const result = formValuesFromQueryParams(JSON.stringify({}), RuleFormType.grafana);
|
||||
|
||||
expect(result.editorSettings).toBeDefined();
|
||||
expect(result.editorSettings!.simplifiedQueryEditor).toBe(false);
|
||||
});
|
||||
|
||||
describe('when simplified query editor is enabled', () => {
|
||||
testWithFeatureToggles(['alertingQueryAndExpressionsStepMode']);
|
||||
|
||||
it('should enable simplified query editor if queries are transformable to simple condition', () => {
|
||||
const result = formValuesFromQueryParams(
|
||||
JSON.stringify({
|
||||
queries: [mockAlertQuery(), reduceExpression, thresholdExpression],
|
||||
}),
|
||||
RuleFormType.grafana
|
||||
);
|
||||
|
||||
expect(result.editorSettings).toBeDefined();
|
||||
expect(result.editorSettings!.simplifiedQueryEditor).toBe(true);
|
||||
});
|
||||
|
||||
it('should disable simplified query editor if queries are not transformable to simple condition', () => {
|
||||
const result = formValuesFromQueryParams(
|
||||
JSON.stringify({
|
||||
queries: [mockAlertQuery(), mockAlertQuery(), thresholdExpression],
|
||||
}),
|
||||
RuleFormType.grafana
|
||||
);
|
||||
|
||||
expect(result.editorSettings).toBeDefined();
|
||||
expect(result.editorSettings!.simplifiedQueryEditor).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should default to instant queries for loki and prometheus if not specified', () => {
|
||||
const result = formValuesFromQueryParams(
|
||||
JSON.stringify({
|
||||
queries: [
|
||||
mockAlertQuery({ datasourceUid: 'loki', model: { refId: 'A', datasource: { type: DataSourceType.Loki } } }),
|
||||
mockAlertQuery({
|
||||
datasourceUid: 'prometheus',
|
||||
model: { refId: 'B', datasource: { type: DataSourceType.Prometheus } },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
RuleFormType.grafana
|
||||
);
|
||||
|
||||
const [lokiQuery, prometheusQuery] = result.queries.filter(isAlertQueryOfAlertData);
|
||||
|
||||
expect(lokiQuery.model.instant).toBe(true);
|
||||
expect(lokiQuery.model.range).toBe(false);
|
||||
expect(prometheusQuery.model.instant).toBe(true);
|
||||
expect(prometheusQuery.model.range).toBe(false);
|
||||
});
|
||||
|
||||
it('should preserver instant and range values if specified', () => {
|
||||
const result = formValuesFromQueryParams(
|
||||
JSON.stringify({
|
||||
queries: [
|
||||
mockAlertQuery({
|
||||
datasourceUid: 'loki',
|
||||
model: { refId: 'A', datasource: { type: DataSourceType.Loki }, instant: true, range: false },
|
||||
}),
|
||||
mockAlertQuery({
|
||||
datasourceUid: 'prometheus',
|
||||
model: { refId: 'B', datasource: { type: DataSourceType.Prometheus }, instant: false, range: true },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
RuleFormType.grafana
|
||||
);
|
||||
|
||||
const [lokiQuery, prometheusQuery] = result.queries.filter(isAlertQueryOfAlertData);
|
||||
|
||||
expect(lokiQuery.model.instant).toBe(true);
|
||||
expect(lokiQuery.model.range).toBe(false);
|
||||
expect(prometheusQuery.model.range).toBe(true);
|
||||
expect(prometheusQuery.model.instant).toBe(false);
|
||||
});
|
||||
|
||||
it('should reveal hidden queries', () => {
|
||||
const ruleDefinition = JSON.stringify({
|
||||
queries: [
|
||||
{ refId: 'A', model: { refId: 'A', hide: true } },
|
||||
{ refId: 'B', model: { refId: 'B', hide: false } },
|
||||
{ refId: 'C', model: { refId: 'C' } },
|
||||
],
|
||||
});
|
||||
|
||||
const result = formValuesFromQueryParams(ruleDefinition, RuleFormType.grafana);
|
||||
|
||||
expect(result.queries.length).toBe(3);
|
||||
|
||||
const [q1, q2, q3] = result.queries;
|
||||
expect(q1.refId).toBe('A');
|
||||
expect(q2.refId).toBe('B');
|
||||
expect(q3.refId).toBe('C');
|
||||
expect(q1.model).not.toHaveProperty('hide');
|
||||
expect(q2.model).not.toHaveProperty('hide');
|
||||
expect(q3.model).not.toHaveProperty('hide');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDefaultManualRouting', () => {
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it('returns false if the feature toggle is not enabled', () => {
|
||||
config.featureToggles.alertingSimplifiedRouting = false;
|
||||
expect(getDefautManualRouting()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true if the feature toggle is enabled and localStorage is not set', () => {
|
||||
config.featureToggles.alertingSimplifiedRouting = true;
|
||||
expect(getDefautManualRouting()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false if the feature toggle is enabled and localStorage is set to "false"', () => {
|
||||
config.featureToggles.alertingSimplifiedRouting = true;
|
||||
localStorage.setItem(MANUAL_ROUTING_KEY, 'false');
|
||||
expect(getDefautManualRouting()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true if the feature toggle is enabled and localStorage is set to any value other than "false"', () => {
|
||||
config.featureToggles.alertingSimplifiedRouting = true;
|
||||
localStorage.setItem(MANUAL_ROUTING_KEY, 'true');
|
||||
expect(getDefautManualRouting()).toBe(true);
|
||||
localStorage.removeItem(MANUAL_ROUTING_KEY);
|
||||
expect(getDefautManualRouting()).toBe(true);
|
||||
});
|
||||
});
|
157
public/app/features/alerting/unified/rule-editor/formDefaults.ts
Normal file
157
public/app/features/alerting/unified/rule-editor/formDefaults.ts
Normal file
@ -0,0 +1,157 @@
|
||||
import { clamp } from 'lodash';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
import { RuleWithLocation } from 'app/types/unified-alerting';
|
||||
import { GrafanaAlertStateDecision, RulerRuleDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { RuleFormType, RuleFormValues } from '../types/rule-form';
|
||||
// TODO Ideally all of these should be moved here
|
||||
import { getRulesAccess } from '../utils/access-control';
|
||||
import { defaultAnnotations } from '../utils/constants';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
|
||||
import {
|
||||
MANUAL_ROUTING_KEY,
|
||||
SIMPLIFIED_QUERY_EDITOR_KEY,
|
||||
getDefaultQueries,
|
||||
rulerRuleToFormValues,
|
||||
} from '../utils/rule-form';
|
||||
import { isGrafanaRecordingRuleByType } from '../utils/rules';
|
||||
import { formatPrometheusDuration, safeParsePrometheusDuration } from '../utils/time';
|
||||
|
||||
import {
|
||||
normalizeDefaultAnnotations,
|
||||
revealHiddenQueries,
|
||||
setInstantOrRange,
|
||||
setQueryEditorSettings,
|
||||
} from './formProcessing';
|
||||
|
||||
// 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');
|
||||
const GROUP_EVALUATION_INTERVAL_LOWER_BOUND = safeParsePrometheusDuration('1m');
|
||||
const GROUP_EVALUATION_INTERVAL_UPPER_BOUND = Infinity;
|
||||
|
||||
export const DEFAULT_GROUP_EVALUATION_INTERVAL = formatPrometheusDuration(
|
||||
clamp(GROUP_EVALUATION_MIN_INTERVAL_MS, GROUP_EVALUATION_INTERVAL_LOWER_BOUND, GROUP_EVALUATION_INTERVAL_UPPER_BOUND)
|
||||
);
|
||||
export const getDefaultFormValues = (): RuleFormValues => {
|
||||
const { canCreateGrafanaRules, canCreateCloudRules } = getRulesAccess();
|
||||
|
||||
return Object.freeze({
|
||||
name: '',
|
||||
uid: '',
|
||||
labels: [{ key: '', value: '' }],
|
||||
annotations: defaultAnnotations,
|
||||
dataSourceName: GRAFANA_RULES_SOURCE_NAME, // let's use Grafana-managed alert rule by default
|
||||
type: canCreateGrafanaRules ? RuleFormType.grafana : canCreateCloudRules ? RuleFormType.cloudAlerting : undefined, // viewers can't create prom alerts
|
||||
group: '',
|
||||
|
||||
// grafana
|
||||
folder: undefined,
|
||||
queries: [],
|
||||
recordingRulesQueries: [],
|
||||
condition: '',
|
||||
noDataState: GrafanaAlertStateDecision.NoData,
|
||||
execErrState: GrafanaAlertStateDecision.Error,
|
||||
evaluateFor: DEFAULT_GROUP_EVALUATION_INTERVAL,
|
||||
evaluateEvery: DEFAULT_GROUP_EVALUATION_INTERVAL,
|
||||
manualRouting: getDefautManualRouting(), // we default to true if the feature toggle is enabled and the user hasn't set local storage to false
|
||||
contactPoints: {},
|
||||
overrideGrouping: false,
|
||||
overrideTimings: false,
|
||||
muteTimeIntervals: [],
|
||||
editorSettings: getDefaultEditorSettings(),
|
||||
|
||||
// cortex / loki
|
||||
namespace: '',
|
||||
expression: '',
|
||||
forTime: 1,
|
||||
forTimeUnit: 'm',
|
||||
});
|
||||
};
|
||||
|
||||
export const getDefautManualRouting = () => {
|
||||
// first check if feature toggle for simplified routing is enabled
|
||||
const simplifiedRoutingToggleEnabled = config.featureToggles.alertingSimplifiedRouting ?? false;
|
||||
if (!simplifiedRoutingToggleEnabled) {
|
||||
return false;
|
||||
}
|
||||
//then, check in local storage if the user has enabled simplified routing
|
||||
// if it's not set, we'll default to true
|
||||
const manualRouting = localStorage.getItem(MANUAL_ROUTING_KEY);
|
||||
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 sections simplified
|
||||
const queryEditorSettings = localStorage.getItem(SIMPLIFIED_QUERY_EDITOR_KEY);
|
||||
const notificationStepSettings = localStorage.getItem(MANUAL_ROUTING_KEY);
|
||||
return {
|
||||
simplifiedQueryEditor: queryEditorSettings !== 'false',
|
||||
simplifiedNotificationEditor: notificationStepSettings !== 'false',
|
||||
};
|
||||
}
|
||||
|
||||
export function formValuesFromQueryParams(ruleDefinition: string, type: RuleFormType): RuleFormValues {
|
||||
let ruleFromQueryParams: Partial<RuleFormValues>;
|
||||
|
||||
try {
|
||||
ruleFromQueryParams = JSON.parse(ruleDefinition);
|
||||
} catch (err) {
|
||||
return {
|
||||
...getDefaultFormValues(),
|
||||
queries: getDefaultQueries(),
|
||||
};
|
||||
}
|
||||
|
||||
return setQueryEditorSettings(
|
||||
setInstantOrRange(
|
||||
revealHiddenQueries({
|
||||
...getDefaultFormValues(),
|
||||
...ruleFromQueryParams,
|
||||
annotations: normalizeDefaultAnnotations(ruleFromQueryParams.annotations ?? []),
|
||||
queries: ruleFromQueryParams.queries ?? getDefaultQueries(),
|
||||
type: type || RuleFormType.grafana,
|
||||
evaluateEvery: DEFAULT_GROUP_EVALUATION_INTERVAL,
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function formValuesFromPrefill(rule: Partial<RuleFormValues>): RuleFormValues {
|
||||
return revealHiddenQueries({
|
||||
...getDefaultFormValues(),
|
||||
...rule,
|
||||
});
|
||||
}
|
||||
|
||||
export function formValuesFromExistingRule(rule: RuleWithLocation<RulerRuleDTO>) {
|
||||
return revealHiddenQueries(rulerRuleToFormValues(rule));
|
||||
}
|
||||
|
||||
export function defaultFormValuesForRuleType(ruleType: RuleFormType): RuleFormValues {
|
||||
return {
|
||||
...getDefaultFormValues(),
|
||||
condition: 'C',
|
||||
queries: getDefaultQueries(isGrafanaRecordingRuleByType(ruleType)),
|
||||
type: ruleType,
|
||||
evaluateEvery: DEFAULT_GROUP_EVALUATION_INTERVAL,
|
||||
};
|
||||
}
|
||||
|
||||
// TODO This function is not 100% valid. There is no support for cloud form type because
|
||||
// it's not valid from the path param point of view.
|
||||
export function translateRouteParamToRuleType(param = ''): RuleFormType {
|
||||
if (param === 'recording') {
|
||||
return RuleFormType.cloudRecording;
|
||||
}
|
||||
|
||||
if (param === 'grafana-recording') {
|
||||
return RuleFormType.grafanaRecording;
|
||||
}
|
||||
|
||||
return RuleFormType.grafana;
|
||||
}
|
@ -0,0 +1,150 @@
|
||||
import { omit } from 'lodash';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
import { isExpressionQuery } from 'app/features/expressions/guards';
|
||||
import { ExpressionQuery, ExpressionQueryType, ReducerMode } from 'app/features/expressions/types';
|
||||
import { AlertDataQuery, AlertQuery } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { SimpleConditionIdentifier } from '../components/rule-editor/query-and-alert-condition/SimpleCondition';
|
||||
import { KVObject, RuleFormValues } from '../types/rule-form';
|
||||
import { defaultAnnotations } from '../utils/constants';
|
||||
import { DataSourceType } from '../utils/datasource';
|
||||
|
||||
export function setQueryEditorSettings(values: RuleFormValues): RuleFormValues {
|
||||
const isQuerySwitchModeEnabled = config.featureToggles.alertingQueryAndExpressionsStepMode ?? false;
|
||||
|
||||
if (!isQuerySwitchModeEnabled) {
|
||||
return {
|
||||
...values,
|
||||
editorSettings: {
|
||||
simplifiedQueryEditor: false,
|
||||
simplifiedNotificationEditor: true, // actually it doesn't matter in this case
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// data queries only
|
||||
const dataQueries = values.queries.filter((query) => !isExpressionQuery(query.model));
|
||||
|
||||
// expression queries only
|
||||
const expressionQueries = values.queries.filter((query) => isExpressionQueryInAlert(query));
|
||||
|
||||
const queryParamsAreTransformable = areQueriesTransformableToSimpleCondition(dataQueries, expressionQueries);
|
||||
return {
|
||||
...values,
|
||||
editorSettings: {
|
||||
simplifiedQueryEditor: queryParamsAreTransformable,
|
||||
simplifiedNotificationEditor: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function setInstantOrRange(values: RuleFormValues): RuleFormValues {
|
||||
return {
|
||||
...values,
|
||||
queries: values.queries?.map((query) => {
|
||||
if (isExpressionQuery(query.model)) {
|
||||
return query;
|
||||
}
|
||||
// data query
|
||||
const defaultToInstant =
|
||||
query.model.datasource?.type === DataSourceType.Loki ||
|
||||
query.model.datasource?.type === DataSourceType.Prometheus;
|
||||
const isInstant =
|
||||
'instant' in query.model && query.model.instant !== undefined ? query.model.instant : defaultToInstant;
|
||||
return {
|
||||
...query,
|
||||
model: {
|
||||
...query.model,
|
||||
instant: isInstant,
|
||||
range: !isInstant, // we cannot have both instant and range queries in alerting
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function areQueriesTransformableToSimpleCondition(
|
||||
dataQueries: Array<AlertQuery<AlertDataQuery | ExpressionQuery>>,
|
||||
expressionQueries: Array<AlertQuery<ExpressionQuery>>
|
||||
) {
|
||||
if (dataQueries.length !== 1) {
|
||||
return false;
|
||||
}
|
||||
const singleReduceExpressionInInstantQuery =
|
||||
'instant' in dataQueries[0].model && dataQueries[0].model.instant && expressionQueries.length === 1;
|
||||
|
||||
if (expressionQueries.length !== 2 && !singleReduceExpressionInInstantQuery) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const query = dataQueries[0];
|
||||
|
||||
if (query.refId !== SimpleConditionIdentifier.queryId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const reduceExpressionIndex = expressionQueries.findIndex(
|
||||
(query) => query.model.type === ExpressionQueryType.reduce && query.refId === SimpleConditionIdentifier.reducerId
|
||||
);
|
||||
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 === SimpleConditionIdentifier.thresholdId
|
||||
);
|
||||
const thresholdExpression = expressionQueries.at(thresholdExpressionIndex);
|
||||
const conditions = thresholdExpression?.model.conditions ?? [];
|
||||
const thresholdIndexOk = singleReduceExpressionInInstantQuery
|
||||
? thresholdExpressionIndex === 0
|
||||
: thresholdExpressionIndex === 1;
|
||||
const thresholdOk = thresholdExpression && thresholdIndexOk && conditions[0]?.unloadEvaluator === undefined;
|
||||
return (Boolean(reduceOk) || Boolean(singleReduceExpressionInInstantQuery)) && Boolean(thresholdOk);
|
||||
}
|
||||
|
||||
export function isExpressionQueryInAlert(
|
||||
query: AlertQuery<AlertDataQuery | ExpressionQuery>
|
||||
): query is AlertQuery<ExpressionQuery> {
|
||||
return isExpressionQuery(query.model);
|
||||
}
|
||||
|
||||
export function isAlertQueryOfAlertData(
|
||||
query: AlertQuery<AlertDataQuery | ExpressionQuery>
|
||||
): query is AlertQuery<AlertDataQuery> {
|
||||
return !isExpressionQuery(query.model);
|
||||
}
|
||||
|
||||
// the backend will always execute "hidden" queries, so we have no choice but to remove the property in the front-end
|
||||
// to avoid confusion. The query editor shows them as "disabled" and that's a different semantic meaning.
|
||||
// furthermore the "AlertingQueryRunner" calls `filterQuery` on each data source and those will skip running queries that are "hidden"."
|
||||
// It seems like we have no choice but to act like "hidden" queries don't exist in alerting.
|
||||
export const revealHiddenQueries = (ruleDefinition: RuleFormValues): RuleFormValues => {
|
||||
return {
|
||||
...ruleDefinition,
|
||||
queries: ruleDefinition.queries?.map((query) => omit(query, 'model.hide')),
|
||||
};
|
||||
};
|
||||
|
||||
export function normalizeDefaultAnnotations(annotations: KVObject[]) {
|
||||
const orderedAnnotations = [...annotations];
|
||||
const defaultAnnotationKeys = defaultAnnotations.map((annotation) => annotation.key);
|
||||
|
||||
defaultAnnotationKeys.forEach((defaultAnnotationKey, index) => {
|
||||
const fieldIndex = orderedAnnotations.findIndex((field) => field.key === defaultAnnotationKey);
|
||||
|
||||
if (fieldIndex === -1) {
|
||||
//add the default annotation if abstent
|
||||
const emptyValue = { key: defaultAnnotationKey, value: '' };
|
||||
orderedAnnotations.splice(index, 0, emptyValue);
|
||||
} else if (fieldIndex !== index) {
|
||||
//move it to the correct position if present
|
||||
orderedAnnotations.splice(index, 0, orderedAnnotations.splice(fieldIndex, 1)[0]);
|
||||
}
|
||||
});
|
||||
return orderedAnnotations;
|
||||
}
|
@ -19,9 +19,9 @@ grantUserPermissions([AccessControlAction.AlertingRuleExternalRead]);
|
||||
|
||||
setupMswServer();
|
||||
|
||||
const mimirGroups = alertingFactory.group.buildList(5000, { file: 'test-mimir-namespace' });
|
||||
alertingFactory.group.rewindSequence();
|
||||
const prometheusGroups = alertingFactory.group.buildList(200, { file: 'test-prometheus-namespace' });
|
||||
const mimirGroups = alertingFactory.prometheus.group.buildList(5000, { file: 'test-mimir-namespace' });
|
||||
alertingFactory.prometheus.group.rewindSequence();
|
||||
const prometheusGroups = alertingFactory.prometheus.group.buildList(200, { file: 'test-prometheus-namespace' });
|
||||
|
||||
const mimirDs = alertingFactory.dataSource.build({ name: 'Mimir', uid: 'mimir' });
|
||||
const prometheusDs = alertingFactory.dataSource.build({ name: 'Prometheus', uid: 'prometheus' });
|
||||
|
@ -19,9 +19,9 @@ grantUserPermissions([AccessControlAction.AlertingRuleExternalRead]);
|
||||
|
||||
setupMswServer();
|
||||
|
||||
const mimirGroups = alertingFactory.group.buildList(500, { file: 'test-mimir-namespace' });
|
||||
alertingFactory.group.rewindSequence();
|
||||
const prometheusGroups = alertingFactory.group.buildList(130, { file: 'test-prometheus-namespace' });
|
||||
const mimirGroups = alertingFactory.prometheus.group.buildList(500, { file: 'test-mimir-namespace' });
|
||||
alertingFactory.prometheus.group.rewindSequence();
|
||||
const prometheusGroups = alertingFactory.prometheus.group.buildList(130, { file: 'test-prometheus-namespace' });
|
||||
|
||||
const mimirDs = alertingFactory.dataSource.build({ name: 'Mimir', uid: 'mimir' });
|
||||
const prometheusDs = alertingFactory.dataSource.build({ name: 'Prometheus', uid: 'prometheus' });
|
||||
|
@ -2,6 +2,10 @@ import { act } from '@testing-library/react';
|
||||
|
||||
import { FeatureToggles } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
|
||||
import { grantUserPermissions } from '../mocks';
|
||||
import { setFolderAccessControl } from '../mocks/server/configure';
|
||||
|
||||
/**
|
||||
* Flushes out microtasks so we don't get warnings from `@floating-ui/react`
|
||||
@ -47,3 +51,13 @@ export const testWithLicenseFeatures = (features: string[]) => {
|
||||
config.licenseInfo.enabledFeatures = originalFeatures;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* "Grants" permissions via contextSrv mock, and additionally sets folder access control
|
||||
* API response to match
|
||||
*/
|
||||
export const grantPermissionsHelper = (permissions: AccessControlAction[]) => {
|
||||
const permissionsHash = permissions.reduce((hash, permission) => ({ ...hash, [permission]: true }), {});
|
||||
grantUserPermissions(permissions);
|
||||
setFolderAccessControl(permissionsHash);
|
||||
};
|
||||
|
@ -1,20 +1,17 @@
|
||||
import { PromQuery } from '@grafana/prometheus';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { GrafanaAlertStateDecision, GrafanaRuleDefinition, RulerAlertingRuleDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { getDefaultFormValues } from '../rule-editor/formDefaults';
|
||||
import { AlertManagerManualRouting, RuleFormType, RuleFormValues } from '../types/rule-form';
|
||||
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from './datasource';
|
||||
import {
|
||||
MANUAL_ROUTING_KEY,
|
||||
alertingRulerRuleToRuleForm,
|
||||
cleanAnnotations,
|
||||
cleanLabels,
|
||||
formValuesToRulerGrafanaRuleDTO,
|
||||
formValuesToRulerRuleDTO,
|
||||
getContactPointsFromDTO,
|
||||
getDefaultFormValues,
|
||||
getDefautManualRouting,
|
||||
getNotificationSettingsForDTO,
|
||||
} from './rule-form';
|
||||
|
||||
@ -227,36 +224,6 @@ describe('getNotificationSettingsForDTO', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDefautManualRouting', () => {
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it('returns false if the feature toggle is not enabled', () => {
|
||||
config.featureToggles.alertingSimplifiedRouting = false;
|
||||
expect(getDefautManualRouting()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true if the feature toggle is enabled and localStorage is not set', () => {
|
||||
config.featureToggles.alertingSimplifiedRouting = true;
|
||||
expect(getDefautManualRouting()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false if the feature toggle is enabled and localStorage is set to "false"', () => {
|
||||
config.featureToggles.alertingSimplifiedRouting = true;
|
||||
localStorage.setItem(MANUAL_ROUTING_KEY, 'false');
|
||||
expect(getDefautManualRouting()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true if the feature toggle is enabled and localStorage is set to any value other than "false"', () => {
|
||||
config.featureToggles.alertingSimplifiedRouting = true;
|
||||
localStorage.setItem(MANUAL_ROUTING_KEY, 'true');
|
||||
expect(getDefautManualRouting()).toBe(true);
|
||||
localStorage.removeItem(MANUAL_ROUTING_KEY);
|
||||
expect(getDefautManualRouting()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanAnnotations', () => {
|
||||
it('should remove falsy KVs', () => {
|
||||
const output = cleanAnnotations([{ key: '', value: '' }]);
|
||||
|
@ -1,5 +1,3 @@
|
||||
import { clamp, omit } from 'lodash';
|
||||
|
||||
import {
|
||||
DataQuery,
|
||||
DataSourceInstanceSettings,
|
||||
@ -31,7 +29,6 @@ import {
|
||||
AlertDataQuery,
|
||||
AlertQuery,
|
||||
Annotations,
|
||||
GrafanaAlertStateDecision,
|
||||
GrafanaNotificationSettings,
|
||||
GrafanaRuleDefinition,
|
||||
Labels,
|
||||
@ -42,6 +39,8 @@ import {
|
||||
} from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { EvalFunction } from '../../state/alertDef';
|
||||
import { getDefaultFormValues } from '../rule-editor/formDefaults';
|
||||
import { normalizeDefaultAnnotations } from '../rule-editor/formProcessing';
|
||||
import {
|
||||
AlertManagerManualRouting,
|
||||
ContactPoint,
|
||||
@ -51,8 +50,7 @@ import {
|
||||
SimplifiedEditor,
|
||||
} from '../types/rule-form';
|
||||
|
||||
import { getRulesAccess } from './access-control';
|
||||
import { Annotation, defaultAnnotations } from './constants';
|
||||
import { Annotation } from './constants';
|
||||
import {
|
||||
DataSourceType,
|
||||
GRAFANA_RULES_SOURCE_NAME,
|
||||
@ -68,84 +66,13 @@ import {
|
||||
isGrafanaRulerRule,
|
||||
isRecordingRulerRule,
|
||||
} from './rules';
|
||||
import { formatPrometheusDuration, parseInterval, safeParsePrometheusDuration } from './time';
|
||||
import { parseInterval } from './time';
|
||||
|
||||
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');
|
||||
const GROUP_EVALUATION_INTERVAL_LOWER_BOUND = safeParsePrometheusDuration('1m');
|
||||
const GROUP_EVALUATION_INTERVAL_UPPER_BOUND = Infinity;
|
||||
|
||||
export const DEFAULT_GROUP_EVALUATION_INTERVAL = formatPrometheusDuration(
|
||||
clamp(GROUP_EVALUATION_MIN_INTERVAL_MS, GROUP_EVALUATION_INTERVAL_LOWER_BOUND, GROUP_EVALUATION_INTERVAL_UPPER_BOUND)
|
||||
);
|
||||
|
||||
export const getDefaultFormValues = (): RuleFormValues => {
|
||||
const { canCreateGrafanaRules, canCreateCloudRules } = getRulesAccess();
|
||||
|
||||
return Object.freeze({
|
||||
name: '',
|
||||
uid: '',
|
||||
labels: [{ key: '', value: '' }],
|
||||
annotations: defaultAnnotations,
|
||||
dataSourceName: GRAFANA_RULES_SOURCE_NAME, // let's use Grafana-managed alert rule by default
|
||||
type: canCreateGrafanaRules ? RuleFormType.grafana : canCreateCloudRules ? RuleFormType.cloudAlerting : undefined, // viewers can't create prom alerts
|
||||
group: '',
|
||||
|
||||
// grafana
|
||||
folder: undefined,
|
||||
queries: [],
|
||||
recordingRulesQueries: [],
|
||||
condition: '',
|
||||
noDataState: GrafanaAlertStateDecision.NoData,
|
||||
execErrState: GrafanaAlertStateDecision.Error,
|
||||
evaluateFor: DEFAULT_GROUP_EVALUATION_INTERVAL,
|
||||
evaluateEvery: DEFAULT_GROUP_EVALUATION_INTERVAL,
|
||||
manualRouting: getDefautManualRouting(), // we default to true if the feature toggle is enabled and the user hasn't set local storage to false
|
||||
contactPoints: {},
|
||||
overrideGrouping: false,
|
||||
overrideTimings: false,
|
||||
muteTimeIntervals: [],
|
||||
editorSettings: getDefaultEditorSettings(),
|
||||
|
||||
// cortex / loki
|
||||
namespace: '',
|
||||
expression: '',
|
||||
forTime: 1,
|
||||
forTimeUnit: 'm',
|
||||
});
|
||||
};
|
||||
|
||||
export const getDefautManualRouting = () => {
|
||||
// first check if feature toggle for simplified routing is enabled
|
||||
const simplifiedRoutingToggleEnabled = config.featureToggles.alertingSimplifiedRouting ?? false;
|
||||
if (!simplifiedRoutingToggleEnabled) {
|
||||
return false;
|
||||
}
|
||||
//then, check in local storage if the user has enabled simplified routing
|
||||
// if it's not set, we'll default to true
|
||||
const manualRouting = localStorage.getItem(MANUAL_ROUTING_KEY);
|
||||
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 sections simplified
|
||||
const queryEditorSettings = localStorage.getItem(SIMPLIFIED_QUERY_EDITOR_KEY);
|
||||
const notificationStepSettings = localStorage.getItem(MANUAL_ROUTING_KEY);
|
||||
return {
|
||||
simplifiedQueryEditor: queryEditorSettings !== 'false',
|
||||
simplifiedNotificationEditor: notificationStepSettings !== 'false',
|
||||
};
|
||||
}
|
||||
|
||||
export function formValuesToRulerRuleDTO(values: RuleFormValues): RulerRuleDTO {
|
||||
const { name, expression, forTime, forTimeUnit, keepFiringForTime, keepFiringForTimeUnit, type } = values;
|
||||
|
||||
@ -184,26 +111,6 @@ export function listifyLabelsOrAnnotations(item: Labels | Annotations | undefine
|
||||
return list;
|
||||
}
|
||||
|
||||
//make sure default annotations are always shown in order even if empty
|
||||
export function normalizeDefaultAnnotations(annotations: KVObject[]) {
|
||||
const orderedAnnotations = [...annotations];
|
||||
const defaultAnnotationKeys = defaultAnnotations.map((annotation) => annotation.key);
|
||||
|
||||
defaultAnnotationKeys.forEach((defaultAnnotationKey, index) => {
|
||||
const fieldIndex = orderedAnnotations.findIndex((field) => field.key === defaultAnnotationKey);
|
||||
|
||||
if (fieldIndex === -1) {
|
||||
//add the default annotation if abstent
|
||||
const emptyValue = { key: defaultAnnotationKey, value: '' };
|
||||
orderedAnnotations.splice(index, 0, emptyValue);
|
||||
} else if (fieldIndex !== index) {
|
||||
//move it to the correct position if present
|
||||
orderedAnnotations.splice(index, 0, orderedAnnotations.splice(fieldIndex, 1)[0]);
|
||||
}
|
||||
});
|
||||
return orderedAnnotations;
|
||||
}
|
||||
|
||||
export function getNotificationSettingsForDTO(
|
||||
manualRouting: boolean,
|
||||
contactPoints?: AlertManagerManualRouting
|
||||
@ -902,21 +809,6 @@ export function isPromOrLokiQuery(model: AlertDataQuery): model is PromOrLokiQue
|
||||
return 'expr' in model;
|
||||
}
|
||||
|
||||
// the backend will always execute "hidden" queries, so we have no choice but to remove the property in the front-end
|
||||
// to avoid confusion. The query editor shows them as "disabled" and that's a different semantic meaning.
|
||||
// furthermore the "AlertingQueryRunner" calls `filterQuery` on each data source and those will skip running queries that are "hidden"."
|
||||
// It seems like we have no choice but to act like "hidden" queries don't exist in alerting.
|
||||
export const ignoreHiddenQueries = (ruleDefinition: RuleFormValues): RuleFormValues => {
|
||||
return {
|
||||
...ruleDefinition,
|
||||
queries: ruleDefinition.queries?.map((query) => omit(query, 'model.hide')),
|
||||
};
|
||||
};
|
||||
|
||||
export function formValuesFromExistingRule(rule: RuleWithLocation<RulerRuleDTO>) {
|
||||
return ignoreHiddenQueries(rulerRuleToFormValues(rule));
|
||||
}
|
||||
|
||||
export function getInstantFromDataQuery(model: AlertDataQuery, type: string): boolean | undefined {
|
||||
// if the datasource is not prometheus or loki, instant is defined in the model or defaults to undefined
|
||||
if (type !== DataSourceType.Prometheus && type !== DataSourceType.Loki) {
|
||||
|
@ -4,7 +4,8 @@ import { byRole, byTestId, byText } from 'testing-library-selector';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { AppNotificationList } from 'app/core/components/AppNotifications/AppNotificationList';
|
||||
import RuleEditor from 'app/features/alerting/unified/RuleEditor';
|
||||
import RuleEditor from 'app/features/alerting/unified/rule-editor/RuleEditor';
|
||||
|
||||
export enum GrafanaRuleFormStep {
|
||||
Query = 2,
|
||||
Notification = 5,
|
||||
|
Loading…
Reference in New Issue
Block a user