mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Improve UI for making more clear that evaluation interval belongs to the group (#56397)
* In GrafanaEvaluationBehaviour component : Split evaluation interval from for duration and add button to edit to allow editing it and warning * Move folder and group fields to the evaluation section in the alert form * Include 'Group behaviour' info in a card and fix 'Edit group behaviour' button onClick. * Create hook for getting groups for a particular folder * Use dropdown in group instead of input and fill it with groups that belong to the selected folder * Add evaluation interval for each group in dropdown , and show warning in case user wants to update it * Avoid saving evaluation interval when some rules in the same group would have invalid For with this change * Clear group value when reseting the drop down * Remove evaluationEvery from form values, show this as a label and add a button to edit the group * Open EditRuleGroupModal for editing evaluation interval form the alert rule form * Fix aligment in group behaviour card * compact space in evaluation behaviour card and change group drop down label * In EditgroupModal, in case of grafana managed group, show folder instead of namespcace label and disable the folder name input * Add edge case in rulesInSameGroupHaveInvalidFor method when For value is zero * Vertically align annotations input to the evaluation section in alert rule form * Fix width when editing new group * Add placeholder for group input * Make folder and group in modal readonly from alert form and disable edit group button when new group * Update texts * Don't show evaluation behaviour section until folder and group are selected * Update texts * Fix merge conflits * Fix wrong margin in evaluation label * Remove non-used isRulerGrafanaRuleDTO method * Remove negative margin to avoid overlaping on Firefox
This commit is contained in:
@@ -24,6 +24,7 @@ import { discoverFeatures } from './api/buildInfo';
|
||||
import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace, setRulerRuleGroup } from './api/ruler';
|
||||
import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
|
||||
import { disableRBAC, mockDataSource, MockDataSourceSrv, mockFolder } from './mocks';
|
||||
import { fetchRulerRulesIfNotFetchedYet } from './state/actions';
|
||||
import * as config from './utils/config';
|
||||
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
|
||||
import { getDefaultQueries } from './utils/rule-form';
|
||||
@@ -57,6 +58,7 @@ const mocks = {
|
||||
setRulerRuleGroup: jest.mocked(setRulerRuleGroup),
|
||||
fetchRulerRulesNamespace: jest.mocked(fetchRulerRulesNamespace),
|
||||
fetchRulerRules: jest.mocked(fetchRulerRules),
|
||||
fetchRulerRulesIfNotFetchedYet: jest.mocked(fetchRulerRulesIfNotFetchedYet),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -226,7 +228,7 @@ describe.skip('RuleEditor', () => {
|
||||
rules: [],
|
||||
});
|
||||
mocks.api.fetchRulerRules.mockResolvedValue({
|
||||
namespace1: [
|
||||
'Folder A': [
|
||||
{
|
||||
name: 'group1',
|
||||
rules: [],
|
||||
@@ -270,9 +272,9 @@ describe.skip('RuleEditor', () => {
|
||||
|
||||
const folderInput = await ui.inputs.folder.find();
|
||||
await clickSelectOption(folderInput, 'Folder A');
|
||||
|
||||
const groupInput = screen.getByRole('textbox', { name: /^Group/ });
|
||||
await userEvent.type(groupInput, 'my group');
|
||||
const groupInput = await ui.inputs.group.find();
|
||||
await userEvent.click(byRole('combobox').get(groupInput));
|
||||
await clickSelectOption(groupInput, 'group1 (1m)');
|
||||
|
||||
await userEvent.type(ui.inputs.annotationValue(0).get(), 'some summary');
|
||||
await userEvent.type(ui.inputs.annotationValue(1).get(), 'some description');
|
||||
@@ -293,7 +295,7 @@ describe.skip('RuleEditor', () => {
|
||||
'Folder A',
|
||||
{
|
||||
interval: '1m',
|
||||
name: 'my group',
|
||||
name: 'group1',
|
||||
rules: [
|
||||
{
|
||||
annotations: { description: 'some description', summary: 'some summary' },
|
||||
|
||||
@@ -65,6 +65,8 @@ const AlertRuleNameInput = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const MINUTE = '1m';
|
||||
|
||||
type Props = {
|
||||
existing?: RuleWithLocation;
|
||||
};
|
||||
@@ -75,6 +77,7 @@ export const AlertRuleForm: FC<Props> = ({ existing }) => {
|
||||
const notifyApp = useAppNotification();
|
||||
const [queryParams] = useQueryParams();
|
||||
const [showEditYaml, setShowEditYaml] = useState(false);
|
||||
const [evaluateEvery, setEvaluateEvery] = useState(existing?.group.interval ?? MINUTE);
|
||||
|
||||
const returnTo: string = (queryParams['returnTo'] as string | undefined) ?? '/alerting/list';
|
||||
const [showDeleteModal, setShowDeleteModal] = useState<boolean>(false);
|
||||
@@ -89,8 +92,9 @@ export const AlertRuleForm: FC<Props> = ({ existing }) => {
|
||||
condition: 'C',
|
||||
...(queryParams['defaults'] ? JSON.parse(queryParams['defaults'] as string) : {}),
|
||||
type: RuleFormType.grafana,
|
||||
evaluateEvery: evaluateEvery,
|
||||
};
|
||||
}, [existing, queryParams]);
|
||||
}, [existing, queryParams, evaluateEvery]);
|
||||
|
||||
const formAPI = useForm<RuleFormValues>({
|
||||
mode: 'onSubmit',
|
||||
@@ -125,6 +129,8 @@ export const AlertRuleForm: FC<Props> = ({ existing }) => {
|
||||
},
|
||||
existing,
|
||||
redirectOnSave: exitOnSave ? returnTo : undefined,
|
||||
initialAlertRuleName: defaultValues.name,
|
||||
evaluateEvery: evaluateEvery,
|
||||
})
|
||||
);
|
||||
};
|
||||
@@ -202,8 +208,16 @@ export const AlertRuleForm: FC<Props> = ({ existing }) => {
|
||||
<QueryAndExpressionsStep editingExistingRule={!!existing} />
|
||||
{showStep2 && (
|
||||
<>
|
||||
{type === RuleFormType.grafana ? <GrafanaEvaluationBehavior /> : <CloudEvaluationBehavior />}
|
||||
<DetailsStep initialFolder={defaultValues.folder} />
|
||||
{type === RuleFormType.grafana ? (
|
||||
<GrafanaEvaluationBehavior
|
||||
initialFolder={defaultValues.folder}
|
||||
evaluateEvery={evaluateEvery}
|
||||
setEvaluateEvery={setEvaluateEvery}
|
||||
/>
|
||||
) : (
|
||||
<CloudEvaluationBehavior />
|
||||
)}
|
||||
<DetailsStep />
|
||||
<NotificationsStep />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -99,7 +99,7 @@ const AnnotationsField = () => {
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
annotationValueInput: css`
|
||||
width: 426px;
|
||||
width: 394px;
|
||||
`,
|
||||
textarea: css`
|
||||
height: 76px;
|
||||
|
||||
@@ -1,43 +1,19 @@
|
||||
import { css } from '@emotion/css';
|
||||
import classNames from 'classnames';
|
||||
import React, { useCallback } from 'react';
|
||||
import React from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Stack } from '@grafana/experimental';
|
||||
import { useStyles2, Field, Input, InputControl, Label, Tooltip, Icon } from '@grafana/ui';
|
||||
import { FolderPickerFilter } from 'app/core/components/Select/FolderPicker';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { DashboardSearchHit } from 'app/features/search/types';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
|
||||
import { RuleForm, RuleFormType, RuleFormValues } from '../../types/rule-form';
|
||||
import { RuleFormType, RuleFormValues } from '../../types/rule-form';
|
||||
|
||||
import AnnotationsField from './AnnotationsField';
|
||||
import { GroupAndNamespaceFields } from './GroupAndNamespaceFields';
|
||||
import { RuleEditorSection } from './RuleEditorSection';
|
||||
import { RuleFolderPicker, Folder, containsSlashes } from './RuleFolderPicker';
|
||||
import { checkForPathSeparator } from './util';
|
||||
|
||||
interface DetailsStepProps {
|
||||
initialFolder: RuleForm | null;
|
||||
}
|
||||
|
||||
export const DetailsStep = ({ initialFolder }: DetailsStepProps) => {
|
||||
const {
|
||||
register,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useFormContext<RuleFormValues & { location?: string }>();
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
export function DetailsStep() {
|
||||
const { watch } = useFormContext<RuleFormValues & { location?: string }>();
|
||||
|
||||
const ruleFormType = watch('type');
|
||||
const dataSourceName = watch('dataSourceName');
|
||||
const type = watch('type');
|
||||
|
||||
const folderFilter = useRuleFolderFilter(initialFolder);
|
||||
|
||||
return (
|
||||
<RuleEditorSection
|
||||
stepNo={type === RuleFormType.cloudRecording ? 3 : 4}
|
||||
@@ -53,119 +29,7 @@ export const DetailsStep = ({ initialFolder }: DetailsStepProps) => {
|
||||
{(ruleFormType === RuleFormType.cloudRecording || ruleFormType === RuleFormType.cloudAlerting) &&
|
||||
dataSourceName && <GroupAndNamespaceFields rulesSourceName={dataSourceName} />}
|
||||
|
||||
{ruleFormType === RuleFormType.grafana && (
|
||||
<div className={classNames([styles.flexRow, styles.alignBaseline])}>
|
||||
<Field
|
||||
label={
|
||||
<Label htmlFor="folder" description={'Select a folder to store your rule.'}>
|
||||
<Stack gap={0.5}>
|
||||
Folder
|
||||
<Tooltip
|
||||
placement="top"
|
||||
content={
|
||||
<div>
|
||||
Each folder has unique folder permission. When you store multiple rules in a folder, the folder
|
||||
access permissions get assigned to the rules.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Icon name="info-circle" size="xs" />
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Label>
|
||||
}
|
||||
className={styles.formInput}
|
||||
error={errors.folder?.message}
|
||||
invalid={!!errors.folder?.message}
|
||||
data-testid="folder-picker"
|
||||
>
|
||||
<InputControl
|
||||
render={({ field: { ref, ...field } }) => (
|
||||
<RuleFolderPicker
|
||||
inputId="folder"
|
||||
{...field}
|
||||
enableCreateNew={contextSrv.hasPermission(AccessControlAction.FoldersCreate)}
|
||||
enableReset={true}
|
||||
filter={folderFilter}
|
||||
dissalowSlashes={true}
|
||||
/>
|
||||
)}
|
||||
name="folder"
|
||||
rules={{
|
||||
required: { value: true, message: 'Please select a folder' },
|
||||
validate: {
|
||||
pathSeparator: (folder: Folder) => checkForPathSeparator(folder.title),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Group"
|
||||
data-testid="group-picker"
|
||||
description="Rules within the same group are evaluated after the same time interval."
|
||||
className={styles.formInput}
|
||||
error={errors.group?.message}
|
||||
invalid={!!errors.group?.message}
|
||||
>
|
||||
<Input
|
||||
id="group"
|
||||
{...register('group', {
|
||||
required: { value: true, message: 'Must enter a group name' },
|
||||
})}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
{type !== RuleFormType.cloudRecording && <AnnotationsField />}
|
||||
</RuleEditorSection>
|
||||
);
|
||||
};
|
||||
|
||||
const useRuleFolderFilter = (existingRuleForm: RuleForm | null) => {
|
||||
const isSearchHitAvailable = useCallback(
|
||||
(hit: DashboardSearchHit) => {
|
||||
const rbacDisabledFallback = contextSrv.hasEditPermissionInFolders;
|
||||
|
||||
const canCreateRuleInFolder = contextSrv.hasAccessInMetadata(
|
||||
AccessControlAction.AlertingRuleCreate,
|
||||
hit,
|
||||
rbacDisabledFallback
|
||||
);
|
||||
|
||||
const canUpdateInCurrentFolder =
|
||||
existingRuleForm &&
|
||||
hit.folderId === existingRuleForm.id &&
|
||||
contextSrv.hasAccessInMetadata(AccessControlAction.AlertingRuleUpdate, hit, rbacDisabledFallback);
|
||||
return canCreateRuleInFolder || canUpdateInCurrentFolder;
|
||||
},
|
||||
[existingRuleForm]
|
||||
);
|
||||
|
||||
return useCallback<FolderPickerFilter>(
|
||||
(folderHits) =>
|
||||
folderHits
|
||||
.filter(isSearchHitAvailable)
|
||||
.filter((value: DashboardSearchHit) => !containsSlashes(value.title ?? '')),
|
||||
[isSearchHitAvailable]
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
alignBaseline: css`
|
||||
align-items: baseline;
|
||||
margin-bottom: ${theme.spacing(3)};
|
||||
`,
|
||||
formInput: css`
|
||||
width: 275px;
|
||||
|
||||
& + & {
|
||||
margin-left: ${theme.spacing(3)};
|
||||
}
|
||||
`,
|
||||
flexRow: css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: end;
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
import { Stack } from '@grafana/experimental';
|
||||
import { Field, InputControl, Label, LoadingPlaceholder, useStyles2 } from '@grafana/ui';
|
||||
import { FolderPickerFilter } from 'app/core/components/Select/FolderPicker';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { DashboardSearchHit } from 'app/features/search/types';
|
||||
import { AccessControlAction, useDispatch } from 'app/types';
|
||||
import { RulerRuleDTO, RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
|
||||
import { fetchRulerRulesIfNotFetchedYet } from '../../state/actions';
|
||||
import { RuleForm, RuleFormValues } from '../../types/rule-form';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
|
||||
import { InfoIcon } from '../InfoIcon';
|
||||
|
||||
import { getIntervalForGroup } from './GrafanaEvaluationBehavior';
|
||||
import { containsSlashes, Folder, RuleFolderPicker } from './RuleFolderPicker';
|
||||
import { SelectWithAdd } from './SelectWIthAdd';
|
||||
import { checkForPathSeparator } from './util';
|
||||
|
||||
const useGetGroups = (groupfoldersForGrafana: RulerRulesConfigDTO | null | undefined, folderName: string) => {
|
||||
const groupOptions = useMemo(() => {
|
||||
const groupsForFolderResult: Array<RulerRuleGroupDTO<RulerRuleDTO>> = groupfoldersForGrafana
|
||||
? groupfoldersForGrafana[folderName] ?? []
|
||||
: [];
|
||||
return groupsForFolderResult.map((group) => group.name);
|
||||
}, [groupfoldersForGrafana, folderName]);
|
||||
|
||||
return groupOptions;
|
||||
};
|
||||
|
||||
function mapGroupsToOptions(groups: string[]): Array<SelectableValue<string>> {
|
||||
return groups.map((group) => ({ label: group, value: group }));
|
||||
}
|
||||
interface FolderAndGroupProps {
|
||||
initialFolder: RuleForm | null;
|
||||
}
|
||||
|
||||
export const useGetGroupOptionsFromFolder = (folderTilte: string) => {
|
||||
const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules);
|
||||
|
||||
const groupfoldersForGrafana = rulerRuleRequests[GRAFANA_RULES_SOURCE_NAME];
|
||||
|
||||
const groupOptions: Array<SelectableValue<string>> = mapGroupsToOptions(
|
||||
useGetGroups(groupfoldersForGrafana?.result, folderTilte)
|
||||
);
|
||||
const groupsForFolder = groupfoldersForGrafana?.result;
|
||||
return { groupOptions, groupsForFolder, loading: groupfoldersForGrafana?.loading };
|
||||
};
|
||||
|
||||
const useRuleFolderFilter = (existingRuleForm: RuleForm | null) => {
|
||||
const isSearchHitAvailable = useCallback(
|
||||
(hit: DashboardSearchHit) => {
|
||||
const rbacDisabledFallback = contextSrv.hasEditPermissionInFolders;
|
||||
|
||||
const canCreateRuleInFolder = contextSrv.hasAccessInMetadata(
|
||||
AccessControlAction.AlertingRuleCreate,
|
||||
hit,
|
||||
rbacDisabledFallback
|
||||
);
|
||||
|
||||
const canUpdateInCurrentFolder =
|
||||
existingRuleForm &&
|
||||
hit.folderId === existingRuleForm.id &&
|
||||
contextSrv.hasAccessInMetadata(AccessControlAction.AlertingRuleUpdate, hit, rbacDisabledFallback);
|
||||
return canCreateRuleInFolder || canUpdateInCurrentFolder;
|
||||
},
|
||||
[existingRuleForm]
|
||||
);
|
||||
|
||||
return useCallback<FolderPickerFilter>(
|
||||
(folderHits) =>
|
||||
folderHits
|
||||
.filter(isSearchHitAvailable)
|
||||
.filter((value: DashboardSearchHit) => !containsSlashes(value.title ?? '')),
|
||||
[isSearchHitAvailable]
|
||||
);
|
||||
};
|
||||
|
||||
export function FolderAndGroup({ initialFolder }: FolderAndGroupProps) {
|
||||
const {
|
||||
formState: { errors },
|
||||
watch,
|
||||
control,
|
||||
} = useFormContext<RuleFormValues>();
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
const dispatch = useDispatch();
|
||||
const folderFilter = useRuleFolderFilter(initialFolder);
|
||||
const [isAddingGroup, setIsAddingGroup] = useState(false);
|
||||
|
||||
const folder = watch('folder');
|
||||
const group = watch('group');
|
||||
const [selectedGroup, setSelectedGroup] = useState(group);
|
||||
const initialRender = useRef(true);
|
||||
|
||||
const { groupOptions, groupsForFolder, loading } = useGetGroupOptionsFromFolder(folder?.title ?? '');
|
||||
|
||||
useEffect(() => setSelectedGroup(group), [group, setSelectedGroup]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchRulerRulesIfNotFetchedYet(GRAFANA_RULES_SOURCE_NAME));
|
||||
}, [dispatch]);
|
||||
|
||||
const resetGroup = useCallback(() => {
|
||||
if (group && !initialRender.current && folder?.title) {
|
||||
setSelectedGroup('');
|
||||
}
|
||||
initialRender.current = false;
|
||||
}, [group, folder?.title]);
|
||||
|
||||
const groupIsInGroupOptions = useCallback(
|
||||
(group_: string) => {
|
||||
return groupOptions.includes((groupInList: SelectableValue<string>) => groupInList.label === group_);
|
||||
},
|
||||
[groupOptions]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Field
|
||||
label={
|
||||
<Label htmlFor="folder" description={'Select a folder for your rule.'}>
|
||||
<Stack gap={0.5}>
|
||||
Folder
|
||||
<InfoIcon
|
||||
text={
|
||||
'Each folder has unique folder permission. When you store multiple rules in a folder, the folder access permissions are assigned to the rules.'
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Label>
|
||||
}
|
||||
className={styles.formInput}
|
||||
error={errors.folder?.message}
|
||||
invalid={!!errors.folder?.message}
|
||||
data-testid="folder-picker"
|
||||
>
|
||||
<InputControl
|
||||
render={({ field: { ref, ...field } }) => (
|
||||
<RuleFolderPicker
|
||||
inputId="folder"
|
||||
{...field}
|
||||
enableCreateNew={contextSrv.hasPermission(AccessControlAction.FoldersCreate)}
|
||||
enableReset={true}
|
||||
filter={folderFilter}
|
||||
dissalowSlashes={true}
|
||||
onChange={({ title, uid }) => {
|
||||
field.onChange({ title, uid });
|
||||
if (!groupIsInGroupOptions(selectedGroup)) {
|
||||
setIsAddingGroup(false);
|
||||
resetGroup();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
name="folder"
|
||||
rules={{
|
||||
required: { value: true, message: 'Select a folder' },
|
||||
validate: {
|
||||
pathSeparator: (folder: Folder) => checkForPathSeparator(folder.title),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Evaluation group (interval)"
|
||||
data-testid="group-picker"
|
||||
description="Select a group to evaluate all rules in the same group over the same time interval."
|
||||
className={styles.formInput}
|
||||
error={errors.group?.message}
|
||||
invalid={!!errors.group?.message}
|
||||
>
|
||||
<InputControl
|
||||
render={({ field: { ref, ...field } }) =>
|
||||
loading ? (
|
||||
<LoadingPlaceholder text="Loading..." />
|
||||
) : (
|
||||
<SelectWithAdd
|
||||
key={`my_unique_select_key__${folder?.title ?? ''}`}
|
||||
{...field}
|
||||
options={groupOptions}
|
||||
getOptionLabel={(option: SelectableValue<string>) =>
|
||||
`${option.label} (${getIntervalForGroup(groupsForFolder, option.label ?? '', folder?.title ?? '')})`
|
||||
}
|
||||
value={selectedGroup}
|
||||
custom={isAddingGroup}
|
||||
onCustomChange={(custom: boolean) => setIsAddingGroup(custom)}
|
||||
placeholder="Evaluation group name"
|
||||
onChange={(value: string) => {
|
||||
field.onChange(value);
|
||||
setSelectedGroup(value);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
name="group"
|
||||
control={control}
|
||||
rules={{
|
||||
required: { value: true, message: 'Must enter a group name' },
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
container: css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
max-width: ${theme.breakpoints.values.sm}px;
|
||||
justify-content: space-between;
|
||||
`,
|
||||
formInput: css`
|
||||
width: 275px;
|
||||
& + & {
|
||||
margin-left: ${theme.spacing(3)};
|
||||
}
|
||||
`,
|
||||
});
|
||||
@@ -1,22 +1,39 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { RegisterOptions, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Field, InlineLabel, Input, InputControl, useStyles2 } from '@grafana/ui';
|
||||
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
import { Button, Card, Field, InlineLabel, Input, InputControl, useStyles2 } from '@grafana/ui';
|
||||
import { RulerRuleDTO, RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { RuleFormValues } from '../../types/rule-form';
|
||||
import { checkEvaluationIntervalGlobalLimit } from '../../utils/config';
|
||||
import { logInfo, LogMessages } from '../../Analytics';
|
||||
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
|
||||
import { RuleForm, RuleFormValues } from '../../types/rule-form';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
|
||||
import { parsePrometheusDuration } from '../../utils/time';
|
||||
import { CollapseToggle } from '../CollapseToggle';
|
||||
import { EvaluationIntervalLimitExceeded } from '../InvalidIntervalWarning';
|
||||
import { EditCloudGroupModal } from '../rules/EditRuleGroupModal';
|
||||
|
||||
import { MINUTE } from './AlertRuleForm';
|
||||
import { FolderAndGroup, useGetGroupOptionsFromFolder } from './FolderAndGroup';
|
||||
import { GrafanaAlertStatePicker } from './GrafanaAlertStatePicker';
|
||||
import { RuleEditorSection } from './RuleEditorSection';
|
||||
|
||||
export const MIN_TIME_RANGE_STEP_S = 10; // 10 seconds
|
||||
|
||||
export const forValidationOptions = (evaluateEvery: string): RegisterOptions => ({
|
||||
export const getIntervalForGroup = (
|
||||
rulerRules: RulerRulesConfigDTO | null | undefined,
|
||||
group: string,
|
||||
folder: string
|
||||
) => {
|
||||
const folderObj: Array<RulerRuleGroupDTO<RulerRuleDTO>> = rulerRules ? rulerRules[folder] : [];
|
||||
const groupObj = folderObj?.find((rule) => rule.name === group);
|
||||
|
||||
const interval = groupObj?.interval ?? MINUTE;
|
||||
return interval;
|
||||
};
|
||||
|
||||
const forValidationOptions = (evaluateEvery: string): RegisterOptions => ({
|
||||
required: {
|
||||
value: true,
|
||||
message: 'Required.',
|
||||
@@ -51,90 +68,162 @@ export const forValidationOptions = (evaluateEvery: string): RegisterOptions =>
|
||||
},
|
||||
});
|
||||
|
||||
export const evaluateEveryValidationOptions: RegisterOptions = {
|
||||
required: {
|
||||
value: true,
|
||||
message: 'Required.',
|
||||
},
|
||||
validate: (value: string) => {
|
||||
try {
|
||||
const duration = parsePrometheusDuration(value);
|
||||
const useIsNewGroup = (folder: string, group: string) => {
|
||||
const { groupOptions } = useGetGroupOptionsFromFolder(folder);
|
||||
|
||||
if (duration < MIN_TIME_RANGE_STEP_S * 1000) {
|
||||
return `Cannot be less than ${MIN_TIME_RANGE_STEP_S} seconds.`;
|
||||
}
|
||||
|
||||
if (duration % (MIN_TIME_RANGE_STEP_S * 1000) !== 0) {
|
||||
return `Must be a multiple of ${MIN_TIME_RANGE_STEP_S} seconds.`;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : 'Failed to parse duration';
|
||||
}
|
||||
},
|
||||
const groupIsInGroupOptions = useCallback(
|
||||
(group_: string) => groupOptions.some((groupInList: SelectableValue<string>) => groupInList.label === group_),
|
||||
[groupOptions]
|
||||
);
|
||||
return !groupIsInGroupOptions(group);
|
||||
};
|
||||
|
||||
export const GrafanaEvaluationBehavior = () => {
|
||||
function FolderGroupAndEvaluationInterval({
|
||||
initialFolder,
|
||||
evaluateEvery,
|
||||
setEvaluateEvery,
|
||||
}: {
|
||||
initialFolder: RuleForm | null;
|
||||
evaluateEvery: string;
|
||||
setEvaluateEvery: (value: string) => void;
|
||||
}) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const { watch } = useFormContext<RuleFormValues>();
|
||||
const [isEditingGroup, setIsEditingGroup] = useState(false);
|
||||
|
||||
const group = watch('group');
|
||||
const folder = watch('folder');
|
||||
|
||||
const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules);
|
||||
const groupfoldersForGrafana = rulerRuleRequests[GRAFANA_RULES_SOURCE_NAME];
|
||||
|
||||
const isNewGroup = useIsNewGroup(folder?.title ?? '', group);
|
||||
|
||||
useEffect(() => {
|
||||
group &&
|
||||
folder &&
|
||||
setEvaluateEvery(getIntervalForGroup(groupfoldersForGrafana?.result, group, folder?.title ?? ''));
|
||||
}, [group, folder, groupfoldersForGrafana?.result, setEvaluateEvery]);
|
||||
|
||||
const closeEditGroupModal = (saved = false) => {
|
||||
if (!saved) {
|
||||
logInfo(LogMessages.leavingRuleGroupEdit);
|
||||
}
|
||||
setIsEditingGroup(false);
|
||||
};
|
||||
|
||||
const onOpenEditGroupModal = () => setIsEditingGroup(true);
|
||||
|
||||
const editGroupDisabled = groupfoldersForGrafana?.loading || isNewGroup || !folder || !group;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FolderAndGroup initialFolder={initialFolder} />
|
||||
{isEditingGroup && (
|
||||
<EditCloudGroupModal
|
||||
groupInterval={evaluateEvery}
|
||||
nameSpaceAndGroup={{ namespace: folder?.title ?? '', group: group }}
|
||||
sourceName={GRAFANA_RULES_SOURCE_NAME}
|
||||
onClose={() => closeEditGroupModal()}
|
||||
folderAndGroupReadOnly
|
||||
/>
|
||||
)}
|
||||
{folder && group && (
|
||||
<Card className={styles.cardContainer}>
|
||||
<Card.Heading>Evaluation behavior</Card.Heading>
|
||||
<Card.Meta>
|
||||
<div className={styles.evaluationDescription}>
|
||||
<div className={styles.evaluateLabel}>
|
||||
{`Alert rules in the `} <span className={styles.bold}>{group}</span> group are evaluated every{' '}
|
||||
<span className={styles.bold}>{evaluateEvery}</span>.
|
||||
</div>
|
||||
|
||||
<br />
|
||||
{!isNewGroup && (
|
||||
<div>
|
||||
{`Evaluation group interval applies to every rule within a group. It overwrites intervals defined for existing alert rules.`}
|
||||
</div>
|
||||
)}
|
||||
<br />
|
||||
</div>
|
||||
</Card.Meta>
|
||||
<Card.Actions>
|
||||
<div className={styles.editGroup}>
|
||||
{isNewGroup && (
|
||||
<div className={styles.warningMessage}>
|
||||
{`To edit the evaluation group interval, save the alert rule.`}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
icon={'edit'}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={editGroupDisabled}
|
||||
onClick={onOpenEditGroupModal}
|
||||
>
|
||||
<span>{'Edit evaluation group'}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</Card.Actions>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ForInput({ evaluateEvery }: { evaluateEvery: string }) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const [showErrorHandling, setShowErrorHandling] = useState(false);
|
||||
const {
|
||||
register,
|
||||
formState: { errors },
|
||||
watch,
|
||||
} = useFormContext<RuleFormValues>();
|
||||
|
||||
const { exceedsLimit: exceedsGlobalEvaluationLimit } = checkEvaluationIntervalGlobalLimit(watch('evaluateEvery'));
|
||||
|
||||
const evaluateEveryId = 'eval-every-input';
|
||||
const evaluateForId = 'eval-for-input';
|
||||
|
||||
return (
|
||||
<div className={styles.flexRow}>
|
||||
<InlineLabel
|
||||
htmlFor={evaluateForId}
|
||||
width={7}
|
||||
tooltip='Once the condition is breached, the alert goes into pending state. If the alert is pending longer than the "for" value, it becomes a firing alert.'
|
||||
>
|
||||
for
|
||||
</InlineLabel>
|
||||
<Field
|
||||
className={styles.inlineField}
|
||||
error={errors.evaluateFor?.message}
|
||||
invalid={!!errors.evaluateFor?.message}
|
||||
validationMessageHorizontalOverflow={true}
|
||||
>
|
||||
<Input id={evaluateForId} width={8} {...register('evaluateFor', forValidationOptions(evaluateEvery))} />
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GrafanaEvaluationBehavior({
|
||||
initialFolder,
|
||||
evaluateEvery,
|
||||
setEvaluateEvery,
|
||||
}: {
|
||||
initialFolder: RuleForm | null;
|
||||
evaluateEvery: string;
|
||||
setEvaluateEvery: (value: string) => void;
|
||||
}) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const [showErrorHandling, setShowErrorHandling] = useState(false);
|
||||
|
||||
return (
|
||||
// TODO remove "and alert condition" for recording rules
|
||||
<RuleEditorSection stepNo={3} title="Alert evaluation behavior">
|
||||
<Field
|
||||
label="Evaluate"
|
||||
description="Evaluation interval applies to every rule within a group. It can overwrite the interval of an existing alert rule."
|
||||
>
|
||||
<div className={styles.flexRow}>
|
||||
<InlineLabel
|
||||
htmlFor={evaluateEveryId}
|
||||
width={16}
|
||||
tooltip="How often the alert will be evaluated to see if it fires"
|
||||
>
|
||||
Evaluate every
|
||||
</InlineLabel>
|
||||
<Field
|
||||
className={styles.inlineField}
|
||||
error={errors.evaluateEvery?.message}
|
||||
invalid={!!errors.evaluateEvery}
|
||||
validationMessageHorizontalOverflow={true}
|
||||
>
|
||||
<Input id={evaluateEveryId} width={8} {...register('evaluateEvery', evaluateEveryValidationOptions)} />
|
||||
</Field>
|
||||
|
||||
<InlineLabel
|
||||
htmlFor={evaluateForId}
|
||||
width={7}
|
||||
tooltip='Once condition is breached, alert will go into pending state. If it is pending for longer than the "for" value, it will become a firing alert.'
|
||||
>
|
||||
for
|
||||
</InlineLabel>
|
||||
<Field
|
||||
className={styles.inlineField}
|
||||
error={errors.evaluateFor?.message}
|
||||
invalid={!!errors.evaluateFor?.message}
|
||||
validationMessageHorizontalOverflow={true}
|
||||
>
|
||||
<Input
|
||||
id={evaluateForId}
|
||||
width={8}
|
||||
{...register('evaluateFor', forValidationOptions(watch('evaluateEvery')))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</Field>
|
||||
{exceedsGlobalEvaluationLimit && <EvaluationIntervalLimitExceeded />}
|
||||
<div className={styles.flexColumn}>
|
||||
<FolderGroupAndEvaluationInterval
|
||||
initialFolder={initialFolder}
|
||||
setEvaluateEvery={setEvaluateEvery}
|
||||
evaluateEvery={evaluateEvery}
|
||||
/>
|
||||
<ForInput evaluateEvery={evaluateEvery} />
|
||||
</div>
|
||||
<CollapseToggle
|
||||
isCollapsed={!showErrorHandling}
|
||||
onToggle={(collapsed) => setShowErrorHandling(!collapsed)}
|
||||
@@ -177,22 +266,55 @@ export const GrafanaEvaluationBehavior = () => {
|
||||
)}
|
||||
</RuleEditorSection>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
inlineField: css`
|
||||
margin-bottom: 0;
|
||||
`,
|
||||
flexRow: css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
`,
|
||||
inlineField: css`
|
||||
margin-bottom: 0;
|
||||
`,
|
||||
flexColumn: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
`,
|
||||
collapseToggle: css`
|
||||
margin: ${theme.spacing(2, 0, 2, -1)};
|
||||
`,
|
||||
globalLimitValue: css`
|
||||
font-weight: ${theme.typography.fontWeightBold};
|
||||
evaluateLabel: css`
|
||||
align-self: left;
|
||||
margin-right: ${theme.spacing(1)};
|
||||
`,
|
||||
cardContainer: css`
|
||||
max-width: ${theme.breakpoints.values.sm}px;
|
||||
`,
|
||||
intervalChangedLabel: css`
|
||||
margin-bottom: ${theme.spacing(1)};
|
||||
`,
|
||||
warningIcon: css`
|
||||
justify-self: center;
|
||||
margin-right: ${theme.spacing(1)};
|
||||
color: ${theme.colors.warning.text};
|
||||
`,
|
||||
warningMessage: css`
|
||||
color: ${theme.colors.warning.text};
|
||||
`,
|
||||
editGroup: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: right;
|
||||
`,
|
||||
bold: css`
|
||||
font-weight: bold;
|
||||
`,
|
||||
evaluationDescription: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`,
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ interface Props {
|
||||
width?: number;
|
||||
disabled?: boolean;
|
||||
'aria-label'?: string;
|
||||
getOptionLabel?: ((item: SelectableValue<string>) => React.ReactNode) | undefined;
|
||||
}
|
||||
|
||||
export const SelectWithAdd: FC<Props> = ({
|
||||
@@ -29,13 +30,12 @@ export const SelectWithAdd: FC<Props> = ({
|
||||
disabled = false,
|
||||
addLabel = '+ Add new',
|
||||
'aria-label': ariaLabel,
|
||||
getOptionLabel,
|
||||
}) => {
|
||||
const [isCustom, setIsCustom] = useState(custom);
|
||||
|
||||
useEffect(() => {
|
||||
if (custom) {
|
||||
setIsCustom(custom);
|
||||
}
|
||||
setIsCustom(custom);
|
||||
}, [custom]);
|
||||
|
||||
const _options = useMemo(
|
||||
@@ -65,6 +65,7 @@ export const SelectWithAdd: FC<Props> = ({
|
||||
value={value}
|
||||
className={className}
|
||||
placeholder={placeholder}
|
||||
getOptionLabel={getOptionLabel}
|
||||
disabled={disabled}
|
||||
onChange={(val: SelectableValue) => {
|
||||
const value = val?.value;
|
||||
|
||||
@@ -14,7 +14,7 @@ import { RulerRulesConfigDTO, RulerRuleGroupDTO, RulerRuleDTO } from 'app/types/
|
||||
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
|
||||
import { rulesInSameGroupHaveInvalidFor, updateLotexNamespaceAndGroupAction } from '../../state/actions';
|
||||
import { checkEvaluationIntervalGlobalLimit } from '../../utils/config';
|
||||
import { getRulesSourceName } from '../../utils/datasource';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
|
||||
import { initialAsyncRequestState } from '../../utils/redux';
|
||||
import { isAlertingRulerRule, isGrafanaRulerRule } from '../../utils/rules';
|
||||
import { parsePrometheusDuration } from '../../utils/time';
|
||||
@@ -186,10 +186,20 @@ export const RulesForGroupTable = ({
|
||||
);
|
||||
};
|
||||
|
||||
interface ModalProps {
|
||||
interface CombinedGroupAndNameSpace {
|
||||
namespace: CombinedRuleNamespace;
|
||||
group: CombinedRuleGroup;
|
||||
}
|
||||
interface GroupAndNameSpaceNames {
|
||||
namespace: string;
|
||||
group: string;
|
||||
}
|
||||
interface ModalProps {
|
||||
nameSpaceAndGroup: CombinedGroupAndNameSpace | GroupAndNameSpaceNames;
|
||||
sourceName: string;
|
||||
groupInterval: string;
|
||||
onClose: (saved?: boolean) => void;
|
||||
folderAndGroupReadOnly?: boolean;
|
||||
}
|
||||
|
||||
interface FormValues {
|
||||
@@ -199,22 +209,42 @@ interface FormValues {
|
||||
}
|
||||
|
||||
export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
|
||||
const { namespace, group, onClose } = props;
|
||||
const {
|
||||
nameSpaceAndGroup: { namespace, group },
|
||||
onClose,
|
||||
groupInterval,
|
||||
sourceName,
|
||||
folderAndGroupReadOnly,
|
||||
} = props;
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
const dispatch = useDispatch();
|
||||
const { loading, error, dispatched } =
|
||||
useUnifiedAlertingSelector((state) => state.updateLotexNamespaceAndGroup) ?? initialAsyncRequestState;
|
||||
const notifyApp = useAppNotification();
|
||||
|
||||
const nameSpaceName = typeof namespace === 'string' ? namespace : namespace.name;
|
||||
const groupName = typeof group === 'string' ? group : group.name;
|
||||
const defaultValues = useMemo(
|
||||
(): FormValues => ({
|
||||
namespaceName: namespace.name,
|
||||
groupName: group.name,
|
||||
groupInterval: group.interval ?? '',
|
||||
namespaceName: nameSpaceName,
|
||||
groupName: groupName,
|
||||
groupInterval: groupInterval ?? '',
|
||||
}),
|
||||
[namespace, group]
|
||||
[nameSpaceName, groupName, groupInterval]
|
||||
);
|
||||
|
||||
const isGrafanaManagedGroup = sourceName === GRAFANA_RULES_SOURCE_NAME;
|
||||
const nameSpaceLabel = isGrafanaManagedGroup ? 'Folder' : 'Namespace';
|
||||
const nameSpaceInfoIconLabelEditable = isGrafanaManagedGroup
|
||||
? 'Folder name can be updated to a non-existing folder name'
|
||||
: 'Name space can be updated to a non-existing name space';
|
||||
const nameSpaceInfoIconLabelNonEditable = isGrafanaManagedGroup
|
||||
? 'Folder name can be updated in folder view'
|
||||
: 'Name space can be updated folder view';
|
||||
|
||||
const spaceNameInfoIconLabel = folderAndGroupReadOnly
|
||||
? nameSpaceInfoIconLabelNonEditable
|
||||
: nameSpaceInfoIconLabelEditable;
|
||||
// close modal if successfully saved
|
||||
useEffect(() => {
|
||||
if (dispatched && !loading && !error) {
|
||||
@@ -223,14 +253,13 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
|
||||
}, [dispatched, loading, onClose, error]);
|
||||
|
||||
useCleanup((state) => (state.unifiedAlerting.updateLotexNamespaceAndGroup = initialAsyncRequestState));
|
||||
|
||||
const onSubmit = (values: FormValues) => {
|
||||
dispatch(
|
||||
updateLotexNamespaceAndGroupAction({
|
||||
rulesSourceName: getRulesSourceName(namespace.rulesSource),
|
||||
groupName: group.name,
|
||||
rulesSourceName: sourceName,
|
||||
groupName: groupName,
|
||||
newGroupName: values.groupName,
|
||||
namespaceName: namespace.name,
|
||||
namespaceName: nameSpaceName,
|
||||
newNamespaceName: values.namespaceName,
|
||||
groupInterval: values.groupInterval || undefined,
|
||||
})
|
||||
@@ -254,7 +283,7 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
|
||||
};
|
||||
|
||||
const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules);
|
||||
const groupfoldersForSource = rulerRuleRequests[getRulesSourceName(namespace.rulesSource)];
|
||||
const groupfoldersForSource = rulerRuleRequests[sourceName];
|
||||
|
||||
const evaluateEveryValidationOptions: RegisterOptions = {
|
||||
required: {
|
||||
@@ -273,7 +302,7 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
|
||||
return `Must be a multiple of ${MIN_TIME_RANGE_STEP_S} seconds.`;
|
||||
}
|
||||
if (
|
||||
rulesInSameGroupHaveInvalidFor(groupfoldersForSource.result, group.name, namespace.name, value).length === 0
|
||||
rulesInSameGroupHaveInvalidFor(groupfoldersForSource.result, groupName, nameSpaceName, value).length === 0
|
||||
) {
|
||||
return true;
|
||||
} else {
|
||||
@@ -289,7 +318,7 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
|
||||
<Modal
|
||||
className={styles.modal}
|
||||
isOpen={true}
|
||||
title="Edit namespace or evaluation group"
|
||||
title={folderAndGroupReadOnly ? 'Edit evaluation group' : `Edit ${nameSpaceLabel} or evaluation group`}
|
||||
onDismiss={onClose}
|
||||
onClickBackdrop={onClose}
|
||||
>
|
||||
@@ -300,8 +329,8 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
|
||||
label={
|
||||
<Label htmlFor="namespaceName">
|
||||
<Stack gap={0.5}>
|
||||
NameSpace
|
||||
<InfoIcon text={'Name space can be updated'} />
|
||||
{nameSpaceLabel}
|
||||
<InfoIcon text={spaceNameInfoIconLabel} />
|
||||
</Stack>
|
||||
</Label>
|
||||
}
|
||||
@@ -310,6 +339,7 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
|
||||
>
|
||||
<Input
|
||||
id="namespaceName"
|
||||
readOnly={folderAndGroupReadOnly}
|
||||
{...register('namespaceName', {
|
||||
required: 'Namespace name is required.',
|
||||
})}
|
||||
@@ -320,7 +350,11 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
|
||||
<Label htmlFor="groupName">
|
||||
<Stack gap={0.5}>
|
||||
Evaluation group
|
||||
<InfoIcon text={'Group name can be updated'} />
|
||||
{isGrafanaManagedGroup ? (
|
||||
<InfoIcon text={'Group name can be updated on Group view.'} />
|
||||
) : (
|
||||
<InfoIcon text={'Group name can be updated to a non existing group name.'} />
|
||||
)}
|
||||
</Stack>
|
||||
</Label>
|
||||
}
|
||||
@@ -329,6 +363,7 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
|
||||
>
|
||||
<Input
|
||||
id="groupName"
|
||||
readOnly={folderAndGroupReadOnly}
|
||||
{...register('groupName', {
|
||||
required: 'Evaluation group name is required.',
|
||||
})}
|
||||
@@ -367,8 +402,8 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
|
||||
</div>
|
||||
<RulesForGroupTable
|
||||
rulerRules={groupfoldersForSource?.result}
|
||||
groupName={group.name}
|
||||
folderName={namespace.name}
|
||||
groupName={groupName}
|
||||
folderName={nameSpaceName}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useFolder } from '../../hooks/useFolder';
|
||||
import { useHasRuler } from '../../hooks/useHasRuler';
|
||||
import { deleteRulesGroupAction } from '../../state/actions';
|
||||
import { useRulesAccess } from '../../utils/accessControlHooks';
|
||||
import { GRAFANA_RULES_SOURCE_NAME, isCloudRulesSource } from '../../utils/datasource';
|
||||
import { getRulesSourceName, GRAFANA_RULES_SOURCE_NAME, isCloudRulesSource } from '../../utils/datasource';
|
||||
import { makeFolderLink } from '../../utils/misc';
|
||||
import { isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules';
|
||||
import { CollapseToggle } from '../CollapseToggle';
|
||||
@@ -224,7 +224,14 @@ export const RulesGroup: FC<Props> = React.memo(({ group, namespace, expandAll,
|
||||
{!isCollapsed && (
|
||||
<RulesTable showSummaryColumn={true} className={styles.rulesTable} showGuidelines={true} rules={group.rules} />
|
||||
)}
|
||||
{isEditingGroup && <EditCloudGroupModal group={group} namespace={namespace} onClose={() => closeEditModal()} />}
|
||||
{isEditingGroup && (
|
||||
<EditCloudGroupModal
|
||||
groupInterval={group.interval ?? ''}
|
||||
nameSpaceAndGroup={{ group: group, namespace: namespace }}
|
||||
sourceName={getRulesSourceName(namespace.rulesSource)}
|
||||
onClose={() => closeEditModal()}
|
||||
/>
|
||||
)}
|
||||
{isReorderingGroup && (
|
||||
<ReorderCloudGroupModal group={group} namespace={namespace} onClose={() => setIsReorderingGroup(false)} />
|
||||
)}
|
||||
|
||||
@@ -443,10 +443,13 @@ export const saveRuleFormAction = createAsyncThunk(
|
||||
values,
|
||||
existing,
|
||||
redirectOnSave,
|
||||
evaluateEvery,
|
||||
}: {
|
||||
values: RuleFormValues;
|
||||
existing?: RuleWithLocation;
|
||||
redirectOnSave?: string;
|
||||
initialAlertRuleName?: string;
|
||||
evaluateEvery: string;
|
||||
},
|
||||
thunkAPI
|
||||
): Promise<void> =>
|
||||
@@ -463,15 +466,18 @@ export const saveRuleFormAction = createAsyncThunk(
|
||||
if (!values.dataSourceName) {
|
||||
throw new Error('The Data source has not been defined.');
|
||||
}
|
||||
|
||||
const rulerConfig = getDataSourceRulerConfig(thunkAPI.getState, values.dataSourceName);
|
||||
const rulerClient = getRulerClient(rulerConfig);
|
||||
identifier = await rulerClient.saveLotexRule(values, existing);
|
||||
identifier = await rulerClient.saveLotexRule(values, evaluateEvery, existing);
|
||||
await thunkAPI.dispatch(fetchRulerRulesAction({ rulesSourceName: values.dataSourceName }));
|
||||
|
||||
// in case of grafana managed
|
||||
} else if (type === RuleFormType.grafana) {
|
||||
const rulerConfig = getDataSourceRulerConfig(thunkAPI.getState, GRAFANA_RULES_SOURCE_NAME);
|
||||
const rulerClient = getRulerClient(rulerConfig);
|
||||
identifier = await rulerClient.saveGrafanaRule(values, existing);
|
||||
identifier = await rulerClient.saveGrafanaRule(values, evaluateEvery, existing);
|
||||
await thunkAPI.dispatch(fetchRulerRulesAction({ rulesSourceName: GRAFANA_RULES_SOURCE_NAME }));
|
||||
} else {
|
||||
throw new Error('Unexpected rule form type');
|
||||
}
|
||||
@@ -765,7 +771,9 @@ export const rulesInSameGroupHaveInvalidFor = (
|
||||
|
||||
return rulesSameGroup.filter((rule: RulerRuleDTO) => {
|
||||
const { forDuration } = getAlertInfo(rule, everyDuration);
|
||||
return safeParseDurationstr(forDuration) < safeParseDurationstr(everyDuration);
|
||||
const forNumber = safeParseDurationstr(forDuration);
|
||||
const everyNumber = safeParseDurationstr(everyDuration);
|
||||
return forNumber !== 0 && forNumber < everyNumber;
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ export interface RuleFormValues {
|
||||
noDataState: GrafanaAlertStateDecision;
|
||||
execErrState: GrafanaAlertStateDecision;
|
||||
folder: RuleForm | null;
|
||||
evaluateEvery: string;
|
||||
evaluateFor: string;
|
||||
|
||||
// cortex / loki rules
|
||||
|
||||
@@ -59,7 +59,6 @@ export const getDefaultFormValues = (): RuleFormValues => {
|
||||
condition: '',
|
||||
noDataState: GrafanaAlertStateDecision.NoData,
|
||||
execErrState: GrafanaAlertStateDecision.Error,
|
||||
evaluateEvery: '1m',
|
||||
evaluateFor: '5m',
|
||||
|
||||
// cortex / loki
|
||||
@@ -126,7 +125,6 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF
|
||||
type: RuleFormType.grafana,
|
||||
group: group.name,
|
||||
evaluateFor: rule.for || '0',
|
||||
evaluateEvery: group.interval || defaultFormValues.evaluateEvery,
|
||||
noDataState: ga.no_data_state,
|
||||
execErrState: ga.exec_err_state,
|
||||
queries: ga.data,
|
||||
|
||||
@@ -22,8 +22,8 @@ import {
|
||||
export interface RulerClient {
|
||||
findEditableRule(ruleIdentifier: RuleIdentifier): Promise<RuleWithLocation | null>;
|
||||
deleteRule(ruleWithLocation: RuleWithLocation): Promise<void>;
|
||||
saveLotexRule(values: RuleFormValues, existing?: RuleWithLocation): Promise<RuleIdentifier>;
|
||||
saveGrafanaRule(values: RuleFormValues, existing?: RuleWithLocation): Promise<RuleIdentifier>;
|
||||
saveLotexRule(values: RuleFormValues, evaluateEvery: string, existing?: RuleWithLocation): Promise<RuleIdentifier>;
|
||||
saveGrafanaRule(values: RuleFormValues, evaluateEvery: string, existing?: RuleWithLocation): Promise<RuleIdentifier>;
|
||||
}
|
||||
|
||||
export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient {
|
||||
@@ -95,7 +95,11 @@ export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient
|
||||
});
|
||||
};
|
||||
|
||||
const saveLotexRule = async (values: RuleFormValues, existing?: RuleWithLocation): Promise<RuleIdentifier> => {
|
||||
const saveLotexRule = async (
|
||||
values: RuleFormValues,
|
||||
evaluateEvery: string,
|
||||
existing?: RuleWithLocation
|
||||
): Promise<RuleIdentifier> => {
|
||||
const { dataSourceName, group, namespace } = values;
|
||||
const formRule = formValuesToRulerRuleDTO(values);
|
||||
if (dataSourceName && group && namespace) {
|
||||
@@ -116,6 +120,7 @@ export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient
|
||||
rules: freshExisting.group.rules.map((existingRule) =>
|
||||
existingRule === freshExisting.rule ? formRule : existingRule
|
||||
),
|
||||
evaluateEvery: evaluateEvery,
|
||||
};
|
||||
await setRulerRuleGroup(rulerConfig, namespace, payload);
|
||||
return ruleId.fromRulerRule(dataSourceName, namespace, group, formRule);
|
||||
@@ -143,8 +148,12 @@ export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient
|
||||
}
|
||||
};
|
||||
|
||||
const saveGrafanaRule = async (values: RuleFormValues, existingRule?: RuleWithLocation): Promise<RuleIdentifier> => {
|
||||
const { folder, group, evaluateEvery } = values;
|
||||
const saveGrafanaRule = async (
|
||||
values: RuleFormValues,
|
||||
evaluateEvery: string,
|
||||
existingRule?: RuleWithLocation
|
||||
): Promise<RuleIdentifier> => {
|
||||
const { folder, group } = values;
|
||||
if (!folder) {
|
||||
throw new Error('Folder must be specified');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user