Alerting: Alert creation UI changes (#73835)

This commit is contained in:
Gilles De Mey 2023-09-06 13:24:48 +02:00 committed by GitHub
parent d50ccd6741
commit a2c93bb8bc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 452 additions and 546 deletions

1
.github/CODEOWNERS vendored
View File

@ -453,6 +453,7 @@ lerna.json @grafana/frontend-ops
/public/robots.txt @grafana/frontend-ops /public/robots.txt @grafana/frontend-ops
/public/sass/ @grafana/grafana-frontend-platform /public/sass/ @grafana/grafana-frontend-platform
/public/test/ @grafana/grafana-frontend-platform /public/test/ @grafana/grafana-frontend-platform
/public/test/helpers/alertingRuleEditor.tsx @grafana/alerting-frontend
/public/views/ @grafana/grafana-frontend-platform /public/views/ @grafana/grafana-frontend-platform
/public/app/features/explore/Logs/ @grafana/observability-logs /public/app/features/explore/Logs/ @grafana/observability-logs

View File

@ -71,7 +71,7 @@ afterAll(() => {
const ui = { const ui = {
inputs: { inputs: {
name: byRole('textbox', { name: /rule name name for the alert rule\./i }), name: byRole('textbox', { name: 'name' }),
expr: byTestId('expr'), expr: byTestId('expr'),
folderContainer: byTestId(selectors.components.FolderPicker.containerV2), folderContainer: byTestId(selectors.components.FolderPicker.containerV2),
namespace: byTestId('namespace-picker'), namespace: byTestId('namespace-picker'),

View File

@ -5,6 +5,7 @@ import { NavModelItem } from '@grafana/data';
import { withErrorBoundary } from '@grafana/ui'; import { withErrorBoundary } from '@grafana/ui';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { useDispatch } from 'app/types'; import { useDispatch } from 'app/types';
import { RuleIdentifier } from 'app/types/unified-alerting';
import { AlertWarning } from './AlertWarning'; import { AlertWarning } from './AlertWarning';
import { CloneRuleEditor } from './CloneRuleEditor'; import { CloneRuleEditor } from './CloneRuleEditor';
@ -16,27 +17,37 @@ import { fetchRulesSourceBuildInfoAction } from './state/actions';
import { useRulesAccess } from './utils/accessControlHooks'; import { useRulesAccess } from './utils/accessControlHooks';
import * as ruleId from './utils/rule-id'; import * as ruleId from './utils/rule-id';
type RuleEditorProps = GrafanaRouteComponentProps<{ id?: string }>; type RuleEditorProps = GrafanaRouteComponentProps<{ id?: string; type?: 'recording' | 'alerting' }>;
const defaultPageNav: Partial<NavModelItem> = { const defaultPageNav: Partial<NavModelItem> = {
icon: 'bell', icon: 'bell',
id: 'alert-rule-view', id: 'alert-rule-view',
}; };
const getPageNav = (state: 'edit' | 'add') => { // sadly we only get the "type" when a new rule is being created, when editing an existing recording rule we can't actually know it from the URL
if (state === 'edit') { const getPageNav = (identifier?: RuleIdentifier, type?: 'recording' | 'alerting') => {
return { ...defaultPageNav, id: 'alert-rule-edit', text: 'Edit rule' }; if (type === 'recording') {
} else if (state === 'add') { if (identifier) {
return { ...defaultPageNav, id: 'alert-rule-add', text: 'Add rule' }; // this branch should never trigger actually, the type param isn't used when editing rules
return { ...defaultPageNav, id: 'alert-rule-edit', text: 'Edit recording rule' };
} else {
return { ...defaultPageNav, id: 'alert-rule-add', text: 'New recording rule' };
}
}
if (identifier) {
// keep this one ambiguous, don't mentiond a specific alert type here
return { ...defaultPageNav, id: 'alert-rule-edit', text: 'Edit rule' };
} else {
return { ...defaultPageNav, id: 'alert-rule-add', text: 'New alert rule' };
} }
return undefined;
}; };
const RuleEditor = ({ match }: RuleEditorProps) => { const RuleEditor = ({ match }: RuleEditorProps) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const [searchParams] = useURLSearchParams(); const [searchParams] = useURLSearchParams();
const { id } = match.params; const { id, type } = match.params;
const identifier = ruleId.tryParse(id, true); const identifier = ruleId.tryParse(id, true);
const copyFromId = searchParams.get('copyFrom') ?? undefined; const copyFromId = searchParams.get('copyFrom') ?? undefined;
@ -75,7 +86,7 @@ const RuleEditor = ({ match }: RuleEditorProps) => {
}, [canCreateCloudRules, canCreateGrafanaRules, canEditRules, copyFromIdentifier, id, identifier, loading]); }, [canCreateCloudRules, canCreateGrafanaRules, canEditRules, copyFromIdentifier, id, identifier, loading]);
return ( return (
<AlertingPageWrapper isLoading={loading} pageId="alert-list" pageNav={getPageNav(identifier ? 'edit' : 'add')}> <AlertingPageWrapper isLoading={loading} pageId="alert-list" pageNav={getPageNav(identifier, type)}>
{getContent()} {getContent()}
</AlertingPageWrapper> </AlertingPageWrapper>
); );

View File

@ -186,7 +186,7 @@ describe('RuleEditor cloud: checking editable data sources', () => {
await ui.inputs.name.find(); await ui.inputs.name.find();
const switchToCloudButton = screen.getByText('Switch to data source-managed alert rule'); const switchToCloudButton = screen.getByText('Data source-managed');
expect(switchToCloudButton).toBeInTheDocument(); expect(switchToCloudButton).toBeInTheDocument();
await userEvent.click(switchToCloudButton); await userEvent.click(switchToCloudButton);

View File

@ -13,6 +13,12 @@ import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace, setRul
import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
import { mockApi, mockFeatureDiscoveryApi, setupMswServer } from './mockApi'; import { mockApi, mockFeatureDiscoveryApi, setupMswServer } from './mockApi';
import { disableRBAC, mockDataSource } from './mocks'; import { disableRBAC, mockDataSource } from './mocks';
import {
defaultAlertmanagerChoiceResponse,
emptyExternalAlertmanagersResponse,
mockAlertmanagerChoiceResponse,
mockAlertmanagersResponse,
} from './mocks/alertmanagerApi';
import { fetchRulerRulesIfNotFetchedYet } from './state/actions'; import { fetchRulerRulesIfNotFetchedYet } from './state/actions';
import { setupDataSources } from './testSetup/datasources'; import { setupDataSources } from './testSetup/datasources';
import { buildInfoResponse } from './testSetup/featureDiscovery'; import { buildInfoResponse } from './testSetup/featureDiscovery';
@ -48,6 +54,8 @@ setupDataSources(dataSources.default);
const server = setupMswServer(); const server = setupMswServer();
mockFeatureDiscoveryApi(server).discoverDsFeatures(dataSources.default, buildInfoResponse.mimir); mockFeatureDiscoveryApi(server).discoverDsFeatures(dataSources.default, buildInfoResponse.mimir);
mockAlertmanagerChoiceResponse(server, defaultAlertmanagerChoiceResponse);
mockAlertmanagersResponse(server, emptyExternalAlertmanagersResponse);
mockApi(server).eval({ results: {} }); mockApi(server).eval({ results: {} });
// these tests are rather slow because we have to wait for various API calls and mocks to be called // these tests are rather slow because we have to wait for various API calls and mocks to be called
@ -110,10 +118,11 @@ describe('RuleEditor cloud', () => {
expect(removeExpressionsButtons).toHaveLength(2); expect(removeExpressionsButtons).toHaveLength(2);
// Needs to wait for featrue discovery API call to finish - Check if ruler enabled // Needs to wait for featrue discovery API call to finish - Check if ruler enabled
await waitFor(() => expect(screen.getByText('Switch to data source-managed alert rule')).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('Data source-managed')).toBeInTheDocument());
const switchToCloudButton = screen.getByText('Switch to data source-managed alert rule'); const switchToCloudButton = screen.getByText('Data source-managed');
expect(switchToCloudButton).toBeInTheDocument(); expect(switchToCloudButton).toBeInTheDocument();
expect(switchToCloudButton).not.toBeDisabled();
await user.click(switchToCloudButton); await user.click(switchToCloudButton);

View File

@ -1,8 +1,6 @@
import { css, cx } from '@emotion/css';
import React, { HTMLAttributes } from 'react'; import React, { HTMLAttributes } from 'react';
import { GrafanaTheme2 } from '@grafana/data'; import { IconSize, Button } from '@grafana/ui';
import { IconSize, useStyles2, Button } from '@grafana/ui';
interface Props extends HTMLAttributes<HTMLButtonElement> { interface Props extends HTMLAttributes<HTMLButtonElement> {
isCollapsed: boolean; isCollapsed: boolean;
@ -23,8 +21,6 @@ export const CollapseToggle = ({
size = 'xl', size = 'xl',
...restOfProps ...restOfProps
}: Props) => { }: Props) => {
const styles = useStyles2(getStyles);
return ( return (
<Button <Button
type="button" type="button"
@ -32,7 +28,7 @@ export const CollapseToggle = ({
variant="secondary" variant="secondary"
aria-expanded={!isCollapsed} aria-expanded={!isCollapsed}
aria-controls={idControlled} aria-controls={idControlled}
className={cx(styles.expandButton, className)} className={className}
icon={isCollapsed ? 'angle-right' : 'angle-down'} icon={isCollapsed ? 'angle-right' : 'angle-down'}
onClick={() => onToggle(!isCollapsed)} onClick={() => onToggle(!isCollapsed)}
{...restOfProps} {...restOfProps}
@ -41,9 +37,3 @@ export const CollapseToggle = ({
</Button> </Button>
); );
}; };
export const getStyles = (theme: GrafanaTheme2) => ({
expandButton: css`
margin-right: ${theme.spacing(1)};
`,
});

View File

@ -5,8 +5,19 @@ import { DeepMap, FieldError, FormProvider, useForm, useFormContext, UseFormWatc
import { Link, useParams } from 'react-router-dom'; import { Link, useParams } from 'react-router-dom';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { config, logInfo } from '@grafana/runtime'; import { config, logInfo } from '@grafana/runtime';
import { Button, ConfirmModal, CustomScrollbar, Field, HorizontalGroup, Input, Spinner, useStyles2 } from '@grafana/ui'; import {
Button,
ConfirmModal,
CustomScrollbar,
Field,
HorizontalGroup,
Input,
Spinner,
Text,
useStyles2,
} from '@grafana/ui';
import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate'; import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
import { useAppNotification } from 'app/core/copy/appNotification'; import { useAppNotification } from 'app/core/copy/appNotification';
import { contextSrv } from 'app/core/core'; import { contextSrv } from 'app/core/core';
@ -48,7 +59,6 @@ const recordingRuleNameValidationPattern = {
}; };
const AlertRuleNameInput = () => { const AlertRuleNameInput = () => {
const styles = useStyles2(getStyles);
const { const {
register, register,
watch, watch,
@ -56,22 +66,29 @@ const AlertRuleNameInput = () => {
} = useFormContext<RuleFormValues & { location?: string }>(); } = useFormContext<RuleFormValues & { location?: string }>();
const ruleFormType = watch('type'); const ruleFormType = watch('type');
const entityName = ruleFormType === RuleFormType.cloudRecording ? 'recording rule' : 'alert rule';
return ( return (
<RuleEditorSection stepNo={1} title="Set alert rule name."> <RuleEditorSection
<Field stepNo={1}
className={styles.formInput} title={`Enter ${entityName} name`}
label="Rule name" description={
description="Name for the alert rule." <Text variant="bodySmall" color="secondary">
error={errors?.name?.message} {/* sigh language rules we should use translations ideally but for now we deal with "a" and "an" */}
invalid={!!errors.name?.message} Enter {entityName === 'alert rule' ? 'an' : 'a'} {entityName} name to identify your alert.
> </Text>
}
>
<Field label="Name" error={errors?.name?.message} invalid={!!errors.name?.message}>
<Input <Input
id="name" id="name"
width={35}
{...register('name', { {...register('name', {
required: { value: true, message: 'Must enter an alert name' }, required: { value: true, message: 'Must enter a name' },
pattern: ruleFormType === RuleFormType.cloudRecording ? recordingRuleNameValidationPattern : undefined, pattern: ruleFormType === RuleFormType.cloudRecording ? recordingRuleNameValidationPattern : undefined,
})} })}
placeholder="Give your alert rule a name." aria-label="name"
placeholder={`Give your ${entityName} a name`}
/> />
</Field> </Field>
</RuleEditorSection> </RuleEditorSection>
@ -254,7 +271,7 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
<form onSubmit={(e) => e.preventDefault()} className={styles.form}> <form onSubmit={(e) => e.preventDefault()} className={styles.form}>
<div className={styles.contentOuter}> <div className={styles.contentOuter}>
<CustomScrollbar autoHeightMin="100%" hideHorizontalTrack={true}> <CustomScrollbar autoHeightMin="100%" hideHorizontalTrack={true}>
<div className={styles.contentInner}> <Stack direction="column" gap={3}>
{/* Step 1 */} {/* Step 1 */}
<AlertRuleNameInput /> <AlertRuleNameInput />
{/* Step 2 */} {/* Step 2 */}
@ -282,7 +299,7 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
<NotificationsStep alertUid={uidFromParams} /> <NotificationsStep alertUid={uidFromParams} />
</> </>
)} )}
</div> </Stack>
</CustomScrollbar> </CustomScrollbar>
</div> </div>
</form> </form>
@ -369,29 +386,15 @@ const getStyles = (theme: GrafanaTheme2) => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
`, `,
contentInner: css`
flex: 1;
padding: ${theme.spacing(2)};
`,
contentOuter: css` contentOuter: css`
background: ${theme.colors.background.primary}; background: ${theme.colors.background.primary};
border: 1px solid ${theme.colors.border.weak};
border-radius: ${theme.shape.radius.default};
overflow: hidden; overflow: hidden;
flex: 1; flex: 1;
margin-top: ${theme.spacing(1)};
`, `,
flexRow: css` flexRow: css`
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: flex-start; justify-content: flex-start;
`, `,
formInput: css`
width: 275px;
& + & {
margin-left: ${theme.spacing(3)};
}
`,
}; };
}; };

View File

@ -1,9 +1,8 @@
import { css } from '@emotion/css';
import React from 'react'; import React from 'react';
import { FieldArrayWithId, useFormContext } from 'react-hook-form'; import { FieldArrayWithId, useFormContext } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data'; import { Stack } from '@grafana/experimental';
import { InputControl, useStyles2 } from '@grafana/ui'; import { InputControl, Text } from '@grafana/ui';
import { RuleFormValues } from '../../types/rule-form'; import { RuleFormValues } from '../../types/rule-form';
import { Annotation, annotationDescriptions, annotationLabels } from '../../utils/constants'; import { Annotation, annotationDescriptions, annotationLabels } from '../../utils/constants';
@ -21,58 +20,49 @@ const AnnotationHeaderField = ({
annotation: Annotation; annotation: Annotation;
index: number; index: number;
}) => { }) => {
const styles = useStyles2(getStyles);
const { control } = useFormContext<RuleFormValues>(); const { control } = useFormContext<RuleFormValues>();
return ( return (
<div> <Stack direction="column" gap={0}>
<label className={styles.annotationContainer}> <label>
{ {
<InputControl <InputControl
name={`annotations.${index}.key`} name={`annotations.${index}.key`}
defaultValue={annotationField.key} defaultValue={annotationField.key}
render={({ field: { ref, ...field } }) => { render={({ field: { ref, ...field } }) => {
if (!annotationLabels[annotation]) {
return <CustomAnnotationHeaderField field={field} />;
}
let label;
switch (annotationField.key) { switch (annotationField.key) {
case Annotation.dashboardUID: case Annotation.dashboardUID:
return <div>Dashboard and panel</div>; label = 'Dashboard and panel';
case Annotation.panelID: case Annotation.panelID:
return <span></span>; label = '';
default: default:
return ( label = annotationLabels[annotation] && annotationLabels[annotation] + ' (optional)';
<div>
{annotationLabels[annotation] && (
<span className={styles.annotationTitle} data-testid={`annotation-key-${index}`}>
{annotationLabels[annotation]}
{' (optional)'}
</span>
)}
{!annotationLabels[annotation] && <CustomAnnotationHeaderField field={field} />}
</div>
);
} }
return (
<span data-testid={`annotation-key-${index}`}>
<Text color="primary" variant="bodySmall">
{label}
</Text>
</span>
);
}} }}
control={control} control={control}
rules={{ required: { value: !!annotations[index]?.value, message: 'Required.' } }} rules={{ required: { value: !!annotations[index]?.value, message: 'Required.' } }}
/> />
} }
</label> </label>
<div className={styles.annotationDescription}>{annotationDescriptions[annotation]}</div> <Text variant="bodySmall" color="secondary">
</div> {annotationDescriptions[annotation]}
</Text>
</Stack>
); );
}; };
const getStyles = (theme: GrafanaTheme2) => ({
annotationTitle: css`
color: ${theme.colors.text.primary};
margin-bottom: 3px;
`,
annotationContainer: css`
margin-top: 5px;
`,
annotationDescription: css`
color: ${theme.colors.text.secondary};
`,
});
export default AnnotationHeaderField; export default AnnotationHeaderField;

View File

@ -6,7 +6,7 @@ import { useToggle } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { Stack } from '@grafana/experimental'; import { Stack } from '@grafana/experimental';
import { Button, Field, Input, TextArea, useStyles2 } from '@grafana/ui'; import { Button, Field, Input, Text, TextArea, useStyles2 } from '@grafana/ui';
import { DashboardDataDTO } from 'app/types'; import { DashboardDataDTO } from 'app/types';
import { dashboardApi } from '../../api/dashboardApi'; import { dashboardApi } from '../../api/dashboardApi';
@ -97,10 +97,12 @@ const AnnotationsStep = () => {
'https://grafana.com/docs/grafana/latest/alerting/fundamentals/annotation-label/variables-label-annotation'; 'https://grafana.com/docs/grafana/latest/alerting/fundamentals/annotation-label/variables-label-annotation';
return ( return (
<Stack gap={0.5}> <Stack direction="row" gap={0.5} alignItems="baseline">
Add annotations to provide more context in your alert notifications. <Text variant="bodySmall" color="secondary">
Add annotations to provide more context in your alert notifications.
</Text>
<NeedHelpInfo <NeedHelpInfo
contentText={`Annotations add metadata to provide more information on the alert in your alert notifications. contentText={`Annotations add metadata to provide more information on the alert in your alert notifications.
For example, add a Summary annotation to tell you which value caused the alert to fire or which server it happened on. For example, add a Summary annotation to tell you which value caused the alert to fire or which server it happened on.
Annotations can contain a combination of text and template code.`} Annotations can contain a combination of text and template code.`}
externalLink={docsLink} externalLink={docsLink}
@ -112,8 +114,8 @@ const AnnotationsStep = () => {
} }
return ( return (
<RuleEditorSection stepNo={4} title="Add annotations" description={getAnnotationsSectionDescription()}> <RuleEditorSection stepNo={4} title="Add annotations" description={getAnnotationsSectionDescription()} fullWidth>
<div className={styles.flexColumn}> <Stack direction="column" gap={1}>
{fields.map((annotationField, index: number) => { {fields.map((annotationField, index: number) => {
const isUrl = annotations[index]?.key?.toLocaleLowerCase().endsWith('url'); const isUrl = annotations[index]?.key?.toLocaleLowerCase().endsWith('url');
const ValueInputComponent = isUrl ? Input : TextArea; const ValueInputComponent = isUrl ? Input : TextArea;
@ -206,7 +208,7 @@ const AnnotationsStep = () => {
onDismiss={() => setShowPanelSelector(false)} onDismiss={() => setShowPanelSelector(false)}
/> />
)} )}
</div> </Stack>
</RuleEditorSection> </RuleEditorSection>
); );
}; };
@ -223,11 +225,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
gap: ${theme.spacing(1)}; gap: ${theme.spacing(1)};
display: flex; display: flex;
`, `,
flexColumn: css`
display: flex;
flex-direction: column;
margin-top: ${theme.spacing(2)};
`,
field: css` field: css`
margin-bottom: ${theme.spacing(0.5)}; margin-bottom: ${theme.spacing(0.5)};
`, `,

View File

@ -25,7 +25,7 @@ export const CloudEvaluationBehavior = () => {
const dataSourceName = watch('dataSourceName'); const dataSourceName = watch('dataSourceName');
return ( return (
<RuleEditorSection stepNo={3} title="Set alert evaluation behavior"> <RuleEditorSection stepNo={3} title="Set evaluation behavior">
<Field <Field
label="Pending period" label="Pending period"
description="Period in which an alert rule can be in breach of the condition until the alert rule fires." description="Period in which an alert rule can be in breach of the condition until the alert rule fires."

View File

@ -4,7 +4,8 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { FormProvider, useForm, useFormContext } from 'react-hook-form'; import { FormProvider, useForm, useFormContext } from 'react-hook-form';
import { AppEvents, GrafanaTheme2, SelectableValue } from '@grafana/data'; import { AppEvents, GrafanaTheme2, SelectableValue } from '@grafana/data';
import { AsyncSelect, Button, Field, Input, InputControl, Label, Modal, useStyles2 } from '@grafana/ui'; import { Stack } from '@grafana/experimental';
import { AsyncSelect, Button, Field, Input, InputControl, Label, Modal, Text, useStyles2 } from '@grafana/ui';
import appEvents from 'app/core/app_events'; import appEvents from 'app/core/app_events';
import { contextSrv } from 'app/core/services/context_srv'; import { contextSrv } from 'app/core/services/context_srv';
import { createFolder } from 'app/features/manage-dashboards/state/actions'; import { createFolder } from 'app/features/manage-dashboards/state/actions';
@ -122,7 +123,7 @@ export function FolderAndGroup({ groupfoldersForGrafana }: { groupfoldersForGraf
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.evaluationGroupsContainer}> <div>
{ {
<Field <Field
label={ label={
@ -135,50 +136,53 @@ export function FolderAndGroup({ groupfoldersForGrafana }: { groupfoldersForGraf
invalid={!!errors.folder?.message} invalid={!!errors.folder?.message}
data-testid="folder-picker" data-testid="folder-picker"
> >
{(!isCreatingFolder && ( <Stack direction="row" alignItems="center">
<InputControl {(!isCreatingFolder && (
render={({ field: { ref, ...field } }) => ( <>
<RuleFolderPicker <InputControl
inputId="folder" render={({ field: { ref, ...field } }) => (
{...field} <div style={{ width: 420 }}>
enableReset={true} <RuleFolderPicker
onChange={({ title, uid }) => { inputId="folder"
field.onChange({ title, uid }); {...field}
resetGroup(); enableReset={true}
onChange={({ title, uid }) => {
field.onChange({ title, uid });
resetGroup();
}}
/>
</div>
)}
name="folder"
rules={{
required: { value: true, message: 'Select a folder' },
validate: {
pathSeparator: (folder: Folder) => checkForPathSeparator(folder.title),
},
}} }}
/> />
)} <Text color="secondary">or</Text>
name="folder" <Button
rules={{ onClick={onOpenFolderCreationModal}
required: { value: true, message: 'Select a folder' }, type="button"
validate: { icon="plus"
pathSeparator: (folder: Folder) => checkForPathSeparator(folder.title), fill="outline"
}, variant="secondary"
}} disabled={!contextSrv.hasPermission(AccessControlAction.FoldersCreate)}
/> >
)) || <div>Creating new folder...</div>} New folder
</Button>
</>
)) || <div>Creating new folder...</div>}
</Stack>
</Field> </Field>
} }
<div className={styles.addButton}>
<span>or</span>
<Button
onClick={onOpenFolderCreationModal}
type="button"
icon="plus"
fill="outline"
variant="secondary"
disabled={!contextSrv.hasPermission(AccessControlAction.FoldersCreate)}
>
New folder
</Button>
</div>
{isCreatingFolder && ( {isCreatingFolder && (
<FolderCreationModal onCreate={handleFolderCreation} onClose={() => setIsCreatingFolder(false)} /> <FolderCreationModal onCreate={handleFolderCreation} onClose={() => setIsCreatingFolder(false)} />
)} )}
</div> </div>
<div className={styles.evaluationGroupsContainer}> <div>
<Field <Field
label="Evaluation group" label="Evaluation group"
data-testid="group-picker" data-testid="group-picker"
@ -187,62 +191,63 @@ export function FolderAndGroup({ groupfoldersForGrafana }: { groupfoldersForGraf
error={errors.group?.message} error={errors.group?.message}
invalid={!!errors.group?.message} invalid={!!errors.group?.message}
> >
<InputControl <Stack direction="row" alignItems="center">
render={({ field: { ref, ...field }, fieldState }) => ( <InputControl
<AsyncSelect render={({ field: { ref, ...field }, fieldState }) => (
disabled={!folder || loading} <div style={{ width: 420 }}>
inputId="group" <AsyncSelect
key={uniqueId()} disabled={!folder || loading}
{...field} inputId="group"
onChange={(group) => { key={uniqueId()}
field.onChange(group.label ?? ''); {...field}
}} onChange={(group) => {
isLoading={loading} field.onChange(group.label ?? '');
invalid={Boolean(folder) && !group && Boolean(fieldState.error)} }}
loadOptions={debouncedSearch} isLoading={loading}
cacheOptions invalid={Boolean(folder) && !group && Boolean(fieldState.error)}
loadingMessage={'Loading groups...'} loadOptions={debouncedSearch}
defaultValue={defaultGroupValue} cacheOptions
defaultOptions={groupOptions} loadingMessage={'Loading groups...'}
getOptionLabel={(option: SelectableValue<string>) => ( defaultValue={defaultGroupValue}
<div> defaultOptions={groupOptions}
<span>{option.label}</span> getOptionLabel={(option: SelectableValue<string>) => (
{/* making the assumption here that it's provisioned when it's disabled, should probably change this */} <div>
{option.isDisabled && ( <span>{option.label}</span>
<> {/* making the assumption here that it's provisioned when it's disabled, should probably change this */}
{' '} {option.isDisabled && (
<ProvisioningBadge /> <>
</> {' '}
<ProvisioningBadge />
</>
)}
</div>
)} )}
</div> placeholder={'Select an evaluation group...'}
)} />
placeholder={'Select an evaluation group...'} </div>
/> )}
)} name="group"
name="group" control={control}
control={control} rules={{
rules={{ required: { value: true, message: 'Must enter a group name' },
required: { value: true, message: 'Must enter a group name' }, validate: {
validate: { pathSeparator: (group_: string) => checkForPathSeparator(group_),
pathSeparator: (group_: string) => checkForPathSeparator(group_), },
}, }}
}} />
/> <Text color="secondary">or</Text>
<Button
onClick={onOpenEvaluationGroupCreationModal}
type="button"
icon="plus"
fill="outline"
variant="secondary"
disabled={!folder}
>
New evaluation group
</Button>
</Stack>
</Field> </Field>
<div className={styles.addButton}>
<span>or</span>
<Button
onClick={onOpenEvaluationGroupCreationModal}
type="button"
icon="plus"
fill="outline"
variant="secondary"
disabled={!folder}
>
New evaluation group
</Button>
</div>
{isCreatingEvaluationGroup && ( {isCreatingEvaluationGroup && (
<EvaluationGroupCreationModal <EvaluationGroupCreationModal
onCreate={handleEvalGroupCreation} onCreate={handleEvalGroupCreation}
@ -407,40 +412,18 @@ function EvaluationGroupCreationModal({
const getStyles = (theme: GrafanaTheme2) => ({ const getStyles = (theme: GrafanaTheme2) => ({
container: css` container: css`
margin-top: ${theme.spacing(1)};
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: baseline; align-items: baseline;
max-width: ${theme.breakpoints.values.lg}px; max-width: ${theme.breakpoints.values.lg}px;
justify-content: space-between; justify-content: space-between;
`, `,
evaluationGroupsContainer: css`
width: 100%;
display: flex;
flex-direction: row;
gap: ${theme.spacing(2)};
`,
addButton: css`
display: flex;
direction: row;
gap: ${theme.spacing(2)};
line-height: 2;
margin-top: 35px;
`,
formInput: css` formInput: css`
max-width: ${theme.breakpoints.values.sm}px;
flex-grow: 1; flex-grow: 1;
label {
width: ${theme.breakpoints.values.sm}px;
}
`, `,
modal: css` modal: css`
width: ${theme.breakpoints.values.sm}px; width: ${theme.breakpoints.values.sm}px;
`, `,
modalTitle: css` modalTitle: css`
color: ${theme.colors.text.secondary}; color: ${theme.colors.text.secondary};
margin-bottom: ${theme.spacing(2)}; margin-bottom: ${theme.spacing(2)};

View File

@ -4,7 +4,7 @@ import { RegisterOptions, useFormContext } from 'react-hook-form';
import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Stack } from '@grafana/experimental'; import { Stack } from '@grafana/experimental';
import { Field, Icon, IconButton, Input, InputControl, Label, Switch, Tooltip, useStyles2 } from '@grafana/ui'; import { Field, Icon, IconButton, Input, InputControl, Label, Switch, Text, Tooltip, useStyles2 } from '@grafana/ui';
import { CombinedRuleGroup, CombinedRuleNamespace } from '../../../../../types/unified-alerting'; import { CombinedRuleGroup, CombinedRuleNamespace } from '../../../../../types/unified-alerting';
import { logInfo, LogMessages } from '../../Analytics'; import { logInfo, LogMessages } from '../../Analytics';
@ -185,11 +185,13 @@ function ForInput({ evaluateEvery }: { evaluateEvery: string }) {
} }
function getDescription() { function getDescription() {
const textToRender = 'Define how the alert rule is evaluated.';
const docsLink = 'https://grafana.com/docs/grafana/latest/alerting/fundamentals/alert-rules/rule-evaluation/'; const docsLink = 'https://grafana.com/docs/grafana/latest/alerting/fundamentals/alert-rules/rule-evaluation/';
return ( return (
<Stack gap={0.5}> <Stack direction="row" gap={0.5} alignItems="baseline">
{`${textToRender}`} <Text variant="bodySmall" color="secondary">
Define how the alert rule is evaluated.
</Text>
<NeedHelpInfo <NeedHelpInfo
contentText="Evaluation groups are containers for evaluating alert and recording rules. An evaluation group defines an evaluation interval - how often a rule is checked. Alert rules within the same evaluation group are evaluated sequentially" contentText="Evaluation groups are containers for evaluating alert and recording rules. An evaluation group defines an evaluation interval - how often a rule is checked. Alert rules within the same evaluation group are evaluated sequentially"
externalLink={docsLink} externalLink={docsLink}
@ -218,7 +220,7 @@ export function GrafanaEvaluationBehavior({
return ( return (
// TODO remove "and alert condition" for recording rules // TODO remove "and alert condition" for recording rules
<RuleEditorSection stepNo={3} title="Set alert evaluation behavior" description={getDescription()}> <RuleEditorSection stepNo={3} title="Set evaluation behavior" description={getDescription()}>
<Stack direction="column" justify-content="flex-start" align-items="flex-start"> <Stack direction="column" justify-content="flex-start" align-items="flex-start">
<FolderGroupAndEvaluationInterval setEvaluateEvery={setEvaluateEvery} evaluateEvery={evaluateEvery} /> <FolderGroupAndEvaluationInterval setEvaluateEvery={setEvaluateEvery} evaluateEvery={evaluateEvery} />
<ForInput evaluateEvery={evaluateEvery} /> <ForInput evaluateEvery={evaluateEvery} />
@ -252,7 +254,6 @@ export function GrafanaEvaluationBehavior({
isCollapsed={!showErrorHandling} isCollapsed={!showErrorHandling}
onToggle={(collapsed) => setShowErrorHandling(!collapsed)} onToggle={(collapsed) => setShowErrorHandling(!collapsed)}
text="Configure no data and error handling" text="Configure no data and error handling"
className={styles.collapseToggle}
/> />
{showErrorHandling && ( {showErrorHandling && (
<> <>
@ -296,9 +297,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
inlineField: css` inlineField: css`
margin-bottom: 0; margin-bottom: 0;
`, `,
collapseToggle: css`
margin: ${theme.spacing(2, 0, 2, -1)};
`,
evaluateLabel: css` evaluateLabel: css`
margin-right: ${theme.spacing(1)}; margin-right: ${theme.spacing(1)};
`, `,

View File

@ -5,7 +5,18 @@ import { FieldArrayMethodProps, useFieldArray, useFormContext } from 'react-hook
import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Stack } from '@grafana/experimental'; import { Stack } from '@grafana/experimental';
import { Button, Field, InlineLabel, Label, useStyles2, Tooltip, Icon, Input, LoadingPlaceholder } from '@grafana/ui'; import {
Button,
Field,
InlineLabel,
Label,
useStyles2,
Text,
Tooltip,
Icon,
Input,
LoadingPlaceholder,
} from '@grafana/ui';
import { useDispatch } from 'app/types'; import { useDispatch } from 'app/types';
import { RulerRuleGroupDTO } from 'app/types/unified-alerting-dto'; import { RulerRuleGroupDTO } from 'app/types/unified-alerting-dto';
@ -129,7 +140,7 @@ const LabelsWithSuggestions: FC<{ dataSourceName: string }> = ({ dataSourceName
<> <>
{loading && <LoadingPlaceholder text="Loading" />} {loading && <LoadingPlaceholder text="Loading" />}
{!loading && ( {!loading && (
<> <Stack direction="column" gap={0.5}>
{fields.map((field, index) => { {fields.map((field, index) => {
return ( return (
<div key={field.id}> <div key={field.id}>
@ -182,7 +193,7 @@ const LabelsWithSuggestions: FC<{ dataSourceName: string }> = ({ dataSourceName
); );
})} })}
<AddButton className={styles.addLabelButton} append={append} /> <AddButton className={styles.addLabelButton} append={append} />
</> </Stack>
)} )}
</> </>
); );
@ -245,14 +256,16 @@ const LabelsWithoutSuggestions: FC = () => {
); );
}; };
const LabelsField: FC<Props> = ({ className, dataSourceName }) => { const LabelsField: FC<Props> = ({ dataSourceName }) => {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
return ( return (
<div className={cx(className, styles.wrapper)}> <div>
<Label> <Label description="A set of default labels is automatically added. Add additional labels as required.">
<Stack gap={0.5}> <Stack gap={0.5} alignItems="center">
<span>Custom Labels</span> <Text variant="bodySmall" color="primary">
Labels
</Text>
<Tooltip <Tooltip
content={ content={
<div> <div>
@ -265,15 +278,7 @@ const LabelsField: FC<Props> = ({ className, dataSourceName }) => {
</Tooltip> </Tooltip>
</Stack> </Stack>
</Label> </Label>
<> {dataSourceName ? <LabelsWithSuggestions dataSourceName={dataSourceName} /> : <LabelsWithoutSuggestions />}
<div className={styles.flexRow}>
<InlineLabel width={18}>Labels</InlineLabel>
<div className={styles.flexColumn}>
{dataSourceName && <LabelsWithSuggestions dataSourceName={dataSourceName} />}
{!dataSourceName && <LabelsWithoutSuggestions />}
</div>
</div>
</>
</div> </div>
); );
}; };
@ -283,9 +288,6 @@ const getStyles = (theme: GrafanaTheme2) => {
icon: css` icon: css`
margin-right: ${theme.spacing(0.5)}; margin-right: ${theme.spacing(0.5)};
`, `,
wrapper: css`
margin-bottom: ${theme.spacing(4)};
`,
flexColumn: css` flexColumn: css`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -318,7 +320,8 @@ const getStyles = (theme: GrafanaTheme2) => {
`, `,
labelInput: css` labelInput: css`
width: 175px; width: 175px;
margin-bottom: ${theme.spacing(1)}; margin-bottom: -${theme.spacing(1)};
& + & { & + & {
margin-left: ${theme.spacing(1)}; margin-left: ${theme.spacing(1)};
} }

View File

@ -3,7 +3,7 @@ import React from 'react';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { Stack } from '@grafana/experimental'; import { Stack } from '@grafana/experimental';
import { Icon, Toggletip, useStyles2 } from '@grafana/ui'; import { Icon, Text, Toggletip, useStyles2 } from '@grafana/ui';
interface NeedHelpInfoProps { interface NeedHelpInfoProps {
contentText: string | JSX.Element; contentText: string | JSX.Element;
@ -25,9 +25,11 @@ export function NeedHelpInfo({ contentText, externalLink, linkText, title }: Nee
footer={ footer={
externalLink ? ( externalLink ? (
<a href={externalLink} target="_blank" rel="noreferrer"> <a href={externalLink} target="_blank" rel="noreferrer">
<div className={styles.infoLink}> <Stack direction="row" gap={0.5} alignItems="center">
{linkText} <Icon name="external-link-alt" /> <Text color="link">
</div> {linkText} <Icon size="sm" name="external-link-alt" />
</Text>
</Stack>
</a> </a>
) : undefined ) : undefined
} }
@ -35,8 +37,12 @@ export function NeedHelpInfo({ contentText, externalLink, linkText, title }: Nee
placement="bottom-start" placement="bottom-start"
> >
<div className={styles.helpInfo}> <div className={styles.helpInfo}>
<Icon name="question-circle" /> <Stack direction="row" alignItems="center" gap={0.5}>
<div className={styles.helpInfoText}>Need help?</div> <Icon name="question-circle" size="sm" />
<Text variant="bodySmall" color="primary">
Need help?
</Text>
</Stack>
</div> </div>
</Toggletip> </Toggletip>
); );
@ -48,21 +54,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
font-size: ${theme.typography.size.sm}; font-size: ${theme.typography.size.sm};
`, `,
helpInfo: css` helpInfo: css`
display: flex;
flex-direction: row;
align-items: center;
width: fit-content;
font-weight: ${theme.typography.fontWeightMedium};
margin-left: ${theme.spacing(1)};
font-size: ${theme.typography.size.sm};
cursor: pointer; cursor: pointer;
color: ${theme.colors.text.primary};
`,
helpInfoText: css`
margin-left: ${theme.spacing(0.5)};
text-decoration: underline; text-decoration: underline;
`, `,
infoLink: css`
color: ${theme.colors.text.link};
`,
}); });

View File

@ -1,10 +1,8 @@
import { css } from '@emotion/css';
import React from 'react'; import React from 'react';
import { useFormContext } from 'react-hook-form'; import { useFormContext } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data';
import { Stack } from '@grafana/experimental'; import { Stack } from '@grafana/experimental';
import { Card, Icon, Link, useStyles2 } from '@grafana/ui'; import { Icon, Text } from '@grafana/ui';
import { RuleFormType, RuleFormValues } from '../../types/rule-form'; import { RuleFormType, RuleFormValues } from '../../types/rule-form';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
@ -18,8 +16,7 @@ type NotificationsStepProps = {
alertUid?: string; alertUid?: string;
}; };
export const NotificationsStep = ({ alertUid }: NotificationsStepProps) => { export const NotificationsStep = ({ alertUid }: NotificationsStepProps) => {
const styles = useStyles2(getStyles); const { watch } = useFormContext<RuleFormValues & { location?: string }>();
const { watch, getValues } = useFormContext<RuleFormValues & { location?: string }>();
const [type, labels, queries, condition, folder, alertName] = watch([ const [type, labels, queries, condition, folder, alertName] = watch([
'type', 'type',
@ -31,15 +28,15 @@ export const NotificationsStep = ({ alertUid }: NotificationsStepProps) => {
]); ]);
const dataSourceName = watch('dataSourceName') ?? GRAFANA_RULES_SOURCE_NAME; const dataSourceName = watch('dataSourceName') ?? GRAFANA_RULES_SOURCE_NAME;
const hasLabelsDefined = getNonEmptyLabels(getValues('labels')).length > 0;
const shouldRenderPreview = Boolean(condition) && Boolean(folder) && type === RuleFormType.grafana; const shouldRenderPreview = type === RuleFormType.grafana;
const NotificationsStepDescription = () => { const NotificationsStepDescription = () => {
return ( return (
<div className={styles.stepDescription}> <Stack direction="row" gap={0.5} alignItems="baseline">
<div>Add custom labels to change the way your notifications are routed.</div> <Text variant="bodySmall" color="secondary">
Add custom labels to change the way your notifications are routed.
</Text>
<NeedHelpInfo <NeedHelpInfo
contentText={ contentText={
<Stack gap={1}> <Stack gap={1}>
@ -55,9 +52,9 @@ export const NotificationsStep = ({ alertUid }: NotificationsStepProps) => {
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
<div className={styles.infoLink}> <Text color="link">
Read about notification routing. <Icon name="external-link-alt" /> Read about notification routing. <Icon name="external-link-alt" />
</div> </Text>
</a> </a>
</Stack> </Stack>
<Stack direction="row" gap={0}> <Stack direction="row" gap={0}>
@ -70,16 +67,16 @@ export const NotificationsStep = ({ alertUid }: NotificationsStepProps) => {
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
<div className={styles.infoLink}> <Text color="link">
Read about Labels and annotations. <Icon name="external-link-alt" /> Read about Labels and annotations. <Icon name="external-link-alt" />
</div> </Text>
</a> </a>
</Stack> </Stack>
</Stack> </Stack>
} }
title="Notification routing" title="Notification routing"
/> />
</div> </Stack>
); );
}; };
@ -88,80 +85,29 @@ export const NotificationsStep = ({ alertUid }: NotificationsStepProps) => {
stepNo={type === RuleFormType.cloudRecording ? 4 : 5} stepNo={type === RuleFormType.cloudRecording ? 4 : 5}
title={type === RuleFormType.cloudRecording ? 'Add labels' : 'Configure notifications'} title={type === RuleFormType.cloudRecording ? 'Add labels' : 'Configure notifications'}
description={ description={
type === RuleFormType.cloudRecording ? ( <Stack direction="row" gap={0.5} alignItems="baseline">
'Add labels to help you better manage your recording rules' {type === RuleFormType.cloudRecording ? (
) : ( <Text variant="bodySmall" color="secondary">
<NotificationsStepDescription /> Add labels to help you better manage your recording rules
) </Text>
} ) : (
> <NotificationsStepDescription />
<div className={styles.contentWrapper}>
<div style={{ display: 'flex', flexDirection: 'column' }}>
{!hasLabelsDefined && type !== RuleFormType.cloudRecording && (
<Card className={styles.card}>
<Card.Heading>Default policy</Card.Heading>
<Card.Description>
All alert instances are handled by the default policy if no other matching policies are found. To view
and edit the default policy, go to <Link href="/alerting/routes">Notification Policies</Link>
&nbsp;or contact your Admin if you are using provisioning.
</Card.Description>
</Card>
)} )}
<LabelsField dataSourceName={dataSourceName} /> </Stack>
</div> }
</div> fullWidth
{shouldRenderPreview && >
condition && <LabelsField dataSourceName={dataSourceName} />
folder && ( // need to check for condition and folder again because of typescript {shouldRenderPreview && (
<NotificationPreview <NotificationPreview
alertQueries={queries} alertQueries={queries}
customLabels={labels} customLabels={labels}
condition={condition} condition={condition}
folder={folder} folder={folder}
alertName={alertName} alertName={alertName}
alertUid={alertUid} alertUid={alertUid}
/> />
)} )}
</RuleEditorSection> </RuleEditorSection>
); );
}; };
interface Label {
key: string;
value: string;
}
function getNonEmptyLabels(labels: Label[]) {
return labels.filter((label) => label.key && label.value);
}
const getStyles = (theme: GrafanaTheme2) => ({
contentWrapper: css`
display: flex;
align-items: center;
margin-top: ${theme.spacing(2)};
`,
hideButton: css`
color: ${theme.colors.text.secondary};
cursor: pointer;
margin-bottom: ${theme.spacing(1)};
`,
card: css`
max-width: 500px;
`,
flowChart: css`
margin-right: ${theme.spacing(3)};
`,
title: css`
margin-bottom: ${theme.spacing(2)};
`,
stepDescription: css`
margin-bottom: ${theme.spacing(2)};
display: flex;
gap: ${theme.spacing(1)};
)};
`,
infoLink: css`
color: ${theme.colors.text.link};
`,
});

View File

@ -1,70 +1,58 @@
import { css } from '@emotion/css'; import { css, cx } from '@emotion/css';
import React, { ReactElement } from 'react'; import React, { ReactElement } from 'react';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { FieldSet, useStyles2 } from '@grafana/ui'; import { Stack } from '@grafana/experimental';
import { FieldSet, Text, useStyles2 } from '@grafana/ui';
export interface RuleEditorSectionProps { export interface RuleEditorSectionProps {
title: string; title: string;
stepNo: number; stepNo: number;
description?: string | ReactElement; description?: string | ReactElement;
fullWidth?: boolean;
} }
export const RuleEditorSection = ({ export const RuleEditorSection = ({
title, title,
stepNo, stepNo,
children, children,
fullWidth = false,
description, description,
}: React.PropsWithChildren<RuleEditorSectionProps>) => { }: React.PropsWithChildren<RuleEditorSectionProps>) => {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
return ( return (
<div className={styles.parent}> <div className={styles.parent}>
<div> <FieldSet
<span className={styles.stepNo}>{stepNo}</span> className={cx(fullWidth && styles.fullWidth)}
</div> label={
<div className={styles.content}> <Text variant="h3">
<FieldSet label={title} className={styles.fieldset}> {stepNo}. {title}
</Text>
}
>
<Stack direction="column">
{description && <div className={styles.description}>{description}</div>} {description && <div className={styles.description}>{description}</div>}
{children} {children}
</FieldSet> </Stack>
</div> </FieldSet>
</div> </div>
); );
}; };
const getStyles = (theme: GrafanaTheme2) => ({ const getStyles = (theme: GrafanaTheme2) => ({
fieldset: css`
legend {
font-size: 16px;
padding-top: ${theme.spacing(0.5)};
}
`,
parent: css` parent: css`
display: flex; display: flex;
flex-direction: row; flex-direction: row;
max-width: ${theme.breakpoints.values.xl}; max-width: ${theme.breakpoints.values.xl}px;
& + & { border: solid 1px ${theme.colors.border.weak};
margin-top: ${theme.spacing(4)}; border-radius: ${theme.shape.radius.default};
} padding: ${theme.spacing(2)} ${theme.spacing(3)};
`, `,
description: css` description: css`
margin-top: -${theme.spacing(2)}; margin-top: -${theme.spacing(2)};
color: ${theme.colors.text.secondary};
`, `,
stepNo: css` fullWidth: css`
display: inline-block; width: 100%;
width: ${theme.spacing(4)};
height: ${theme.spacing(4)};
line-height: ${theme.spacing(4)};
border-radius: ${theme.shape.radius.circle};
text-align: center;
color: ${theme.colors.text.maxContrast};
background-color: ${theme.colors.background.canvas};
font-size: ${theme.typography.size.lg};
margin-right: ${theme.spacing(2)};
`,
content: css`
flex: 1;
`, `,
}); });

View File

@ -139,7 +139,7 @@ describe('NotificationPreview', () => {
mockOneAlertManager(); mockOneAlertManager();
mockPreviewApiResponse(server, [{ labels: [{ tomato: 'red', avocate: 'green' }] }]); mockPreviewApiResponse(server, [{ labels: [{ tomato: 'red', avocate: 'green' }] }]);
render(<NotificationPreview alertQueries={[alertQuery]} customLabels={[]} condition="" folder={folder} />, { render(<NotificationPreview alertQueries={[alertQuery]} customLabels={[]} condition="A" folder={folder} />, {
wrapper: TestProvider, wrapper: TestProvider,
}); });
@ -147,20 +147,25 @@ describe('NotificationPreview', () => {
await waitFor(() => { await waitFor(() => {
expect(ui.loadingIndicator.query()).not.toBeInTheDocument(); expect(ui.loadingIndicator.query()).not.toBeInTheDocument();
}); });
// we expect the alert manager label to be missing as there is only one alert manager configured to receive alerts
expect(ui.grafanaAlertManagerLabel.query()).not.toBeInTheDocument();
expect(ui.otherAlertManagerLabel.query()).not.toBeInTheDocument();
const matchingPoliciesElements = ui.route.queryAll(); // we expect the alert manager label to be missing as there is only one alert manager configured to receive alerts
expect(matchingPoliciesElements).toHaveLength(1); await waitFor(() => {
expect(matchingPoliciesElements[0]).toHaveTextContent(/tomato = red/); expect(ui.grafanaAlertManagerLabel.query()).not.toBeInTheDocument();
expect(ui.otherAlertManagerLabel.query()).not.toBeInTheDocument();
});
await waitFor(() => {
const matchingPoliciesElements = ui.route.queryAll();
expect(matchingPoliciesElements).toHaveLength(1);
expect(matchingPoliciesElements[0]).toHaveTextContent(/tomato = red/);
});
}); });
it('should render notification preview with alert manager sections, when having more than one alert manager configured to receive alerts', async () => { it('should render notification preview with alert manager sections, when having more than one alert manager configured to receive alerts', async () => {
// two alert managers configured to receive alerts // two alert managers configured to receive alerts
mockTwoAlertManagers(); mockTwoAlertManagers();
mockPreviewApiResponse(server, [{ labels: [{ tomato: 'red', avocate: 'green' }] }]); mockPreviewApiResponse(server, [{ labels: [{ tomato: 'red', avocate: 'green' }] }]);
render(<NotificationPreview alertQueries={[alertQuery]} customLabels={[]} condition="" folder={folder} />, { render(<NotificationPreview alertQueries={[alertQuery]} customLabels={[]} condition="A" folder={folder} />, {
wrapper: TestProvider, wrapper: TestProvider,
}); });
await waitFor(() => { await waitFor(() => {
@ -171,14 +176,12 @@ describe('NotificationPreview', () => {
await waitFor(() => { await waitFor(() => {
expect(ui.loadingIndicator.query()).not.toBeInTheDocument(); expect(ui.loadingIndicator.query()).not.toBeInTheDocument();
}); });
await waitFor(() => {
expect(ui.loadingIndicator.query()).not.toBeInTheDocument();
});
// we expect the alert manager label to be present as there is more than one alert manager configured to receive alerts // we expect the alert manager label to be present as there is more than one alert manager configured to receive alerts
expect(ui.grafanaAlertManagerLabel.query()).toBeInTheDocument(); await waitFor(() => {
expect(ui.grafanaAlertManagerLabel.query()).toBeInTheDocument();
expect(ui.otherAlertManagerLabel.query()).toBeInTheDocument(); expect(ui.otherAlertManagerLabel.query()).toBeInTheDocument();
});
const matchingPoliciesElements = ui.route.queryAll(); const matchingPoliciesElements = ui.route.queryAll();
expect(matchingPoliciesElements).toHaveLength(2); expect(matchingPoliciesElements).toHaveLength(2);
@ -191,7 +194,7 @@ describe('NotificationPreview', () => {
mockPreviewApiResponse(server, [{ labels: [{ tomato: 'red', avocate: 'green' }] }]); mockPreviewApiResponse(server, [{ labels: [{ tomato: 'red', avocate: 'green' }] }]);
mockHasEditPermission(true); mockHasEditPermission(true);
render(<NotificationPreview alertQueries={[alertQuery]} customLabels={[]} condition="" folder={folder} />, { render(<NotificationPreview alertQueries={[alertQuery]} customLabels={[]} condition="A" folder={folder} />, {
wrapper: TestProvider, wrapper: TestProvider,
}); });
await waitFor(() => { await waitFor(() => {
@ -222,7 +225,7 @@ describe('NotificationPreview', () => {
mockPreviewApiResponse(server, [{ labels: [{ tomato: 'red', avocate: 'green' }] }]); mockPreviewApiResponse(server, [{ labels: [{ tomato: 'red', avocate: 'green' }] }]);
mockHasEditPermission(false); mockHasEditPermission(false);
render(<NotificationPreview alertQueries={[alertQuery]} customLabels={[]} condition="" folder={folder} />, { render(<NotificationPreview alertQueries={[alertQuery]} customLabels={[]} condition="A" folder={folder} />, {
wrapper: TestProvider, wrapper: TestProvider,
}); });
await waitFor(() => { await waitFor(() => {

View File

@ -20,12 +20,14 @@ interface NotificationPreviewProps {
value: string; value: string;
}>; }>;
alertQueries: AlertQuery[]; alertQueries: AlertQuery[];
condition: string; condition: string | null;
folder: Folder; folder: Folder | null;
alertName?: string; alertName?: string;
alertUid?: string; alertUid?: string;
} }
// TODO the scroll position keeps resetting when we preview
// this is to be expected because the list of routes dissapears as we start the request but is very annoying
export const NotificationPreview = ({ export const NotificationPreview = ({
alertQueries, alertQueries,
customLabels, customLabels,
@ -35,16 +37,21 @@ export const NotificationPreview = ({
alertUid, alertUid,
}: NotificationPreviewProps) => { }: NotificationPreviewProps) => {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const disabled = !condition || !folder;
const { usePreviewMutation } = alertRuleApi; const previewEndpoint = alertRuleApi.endpoints.preview;
const [trigger, { data = [], isLoading, isUninitialized: previewUninitialized }] = usePreviewMutation(); const [trigger, { data = [], isLoading, isUninitialized: previewUninitialized }] = previewEndpoint.useMutation();
// potential instances are the instances that are going to be routed to the notification policies // potential instances are the instances that are going to be routed to the notification policies
// convert data to list of labels: are the representation of the potential instances // convert data to list of labels: are the representation of the potential instances
const potentialInstances = compact(data.flatMap((label) => label?.labels)); const potentialInstances = compact(data.flatMap((label) => label?.labels));
const onPreview = () => { const onPreview = () => {
if (!folder || !condition) {
return;
}
// Get the potential labels given the alert queries, the condition and the custom labels (autogenerated labels are calculated on the BE side) // Get the potential labels given the alert queries, the condition and the custom labels (autogenerated labels are calculated on the BE side)
trigger({ trigger({
alertQueries: alertQueries, alertQueries: alertQueries,
@ -60,32 +67,35 @@ export const NotificationPreview = ({
const alertManagerSourceNamesAndImage = useGetAlertManagersSourceNamesAndImage(); const alertManagerSourceNamesAndImage = useGetAlertManagersSourceNamesAndImage();
const onlyOneAM = alertManagerSourceNamesAndImage.length === 1; const onlyOneAM = alertManagerSourceNamesAndImage.length === 1;
const renderHowToPreview = !Boolean(data?.length) && !isLoading;
return ( return (
<Stack direction="column" gap={2}> <Stack direction="column">
<div className={styles.routePreviewHeaderRow}> <div className={styles.routePreviewHeaderRow}>
<div className={styles.previewHeader}> <div className={styles.previewHeader}>
<Text element="h4">Alert instance routing preview</Text> <Text element="h4">Alert instance routing preview</Text>
{isLoading && previewUninitialized && (
<Text color="secondary" variant="bodySmall">
Loading...
</Text>
)}
{previewUninitialized ? (
<Text color="secondary" variant="bodySmall">
When you have your folder selected and your query and labels are configured, click &quot;Preview
routing&quot; to see the results here.
</Text>
) : (
<Text color="secondary" variant="bodySmall">
Based on the labels added, alert instances are routed to the following notification policies. Expand each
notification policy below to view more details.
</Text>
)}
</div> </div>
<div className={styles.button}> <div className={styles.button}>
<Button icon="sync" variant="secondary" type="button" onClick={onPreview}> <Button icon="sync" variant="secondary" type="button" onClick={onPreview} disabled={disabled}>
Preview routing Preview routing
</Button> </Button>
</div> </div>
</div> </div>
{!renderHowToPreview && (
<div className={styles.textMuted}>
Based on the labels added, alert instances are routed to the following notification policies. Expand each
notification policy below to view more details.
</div>
)}
{isLoading && <div className={styles.textMuted}>Loading...</div>}
{renderHowToPreview && (
<div className={styles.previewHowToText}>
{`When your query and labels are configured, click "Preview routing" to see the results here.`}
</div>
)}
{!isLoading && !previewUninitialized && potentialInstances.length > 0 && ( {!isLoading && !previewUninitialized && potentialInstances.length > 0 && (
<Suspense fallback={<LoadingPlaceholder text="Loading preview..." />}> <Suspense fallback={<LoadingPlaceholder text="Loading preview..." />}>
{alertManagerSourceNamesAndImage.map((alertManagerSource) => ( {alertManagerSourceNamesAndImage.map((alertManagerSource) => (
@ -107,15 +117,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
width: auto; width: auto;
border: 0; border: 0;
`, `,
textMuted: css`
color: ${theme.colors.text.secondary};
`,
previewHowToText: css`
display: flex;
color: ${theme.colors.text.secondary};
justify-content: center;
font-size: ${theme.typography.size.sm};
`,
previewHeader: css` previewHeader: css`
margin: 0; margin: 0;
`, `,
@ -123,14 +124,13 @@ const getStyles = (theme: GrafanaTheme2) => ({
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: flex-start;
`, `,
collapseLabel: css` collapseLabel: css`
flex: 1; flex: 1;
`, `,
button: css` button: css`
justify-content: flex-end; justify-content: flex-end;
display: flex;
`, `,
tagsInDetails: css` tagsInDetails: css`
display: flex; display: flex;

View File

@ -88,7 +88,6 @@ export default withErrorBoundary(NotificationPreviewByAlertManager);
const getStyles = (theme: GrafanaTheme2) => ({ const getStyles = (theme: GrafanaTheme2) => ({
alertManagerRow: css` alertManagerRow: css`
margin-top: ${theme.spacing(2)};
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: ${theme.spacing(1)}; gap: ${theme.spacing(1)};

View File

@ -342,6 +342,22 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
<RuleEditorSection <RuleEditorSection
stepNo={2} stepNo={2}
title={type !== RuleFormType.cloudRecording ? 'Define query and alert condition' : 'Define query'} title={type !== RuleFormType.cloudRecording ? 'Define query and alert condition' : 'Define query'}
description={
<Stack direction="row" gap={0.5} alignItems="baseline">
<Text variant="bodySmall" color="secondary">
Define queries and/or expressions and then choose one of them as the alert rule condition. This is the
threshold that an alert rule must meet or exceed in order to fire.
</Text>
<NeedHelpInfo
contentText={`An alert rule consists of one or more queries and expressions that select the data you want to measure.
Define queries and/or expressions and then choose one of them as the alert rule condition. This is the threshold that an alert rule must meet or exceed in order to fire.
For more information on queries and expressions, see Query and transform data.`}
externalLink={`https://grafana.com/docs/grafana/latest/panels-visualizations/query-transform-data/`}
linkText={`Read about query and condition`}
title="Define query and alert condition"
/>
</Stack>
}
> >
{/* This is the cloud data source selector */} {/* This is the cloud data source selector */}
{(type === RuleFormType.cloudRecording || type === RuleFormType.cloudAlerting) && ( {(type === RuleFormType.cloudRecording || type === RuleFormType.cloudAlerting) && (
@ -396,22 +412,6 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
{isGrafanaManagedType && ( {isGrafanaManagedType && (
<Stack direction="column"> <Stack direction="column">
{/* Data Queries */} {/* Data Queries */}
<Stack direction="row" gap={1} alignItems="baseline">
<div className={styles.mutedText}>
Define queries and/or expressions and then choose one of them as the alert rule condition. This is the
threshold that an alert rule must meet or exceed in order to fire.
</div>
<NeedHelpInfo
contentText={`An alert rule consists of one or more queries and expressions that select the data you want to measure.
Define queries and/or expressions and then choose one of them as the alert rule condition. This is the threshold that an alert rule must meet or exceed in order to fire.
For more information on queries and expressions, see Query and transform data.`}
externalLink={`https://grafana.com/docs/grafana/latest/panels-visualizations/query-transform-data/`}
linkText={`Read about query and condition`}
title="Define query and alert condition"
/>
</Stack>
<QueryEditor <QueryEditor
queries={dataQueries} queries={dataQueries}
expressions={expressionQueries} expressions={expressionQueries}
@ -443,8 +443,13 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
onClickSwitch={onClickSwitch} onClickSwitch={onClickSwitch}
/> />
{/* Expression Queries */} {/* Expression Queries */}
<Text element="h5">Expressions</Text> <Stack direction="column" gap={0}>
<div className={styles.mutedText}>Manipulate data returned from queries with math and other operations.</div> <Text element="h5">Expressions</Text>
<Text variant="bodySmall" color="secondary">
Manipulate data returned from queries with math and other operations.
</Text>
</Stack>
<ExpressionsEditor <ExpressionsEditor
queries={queries} queries={queries}
panelData={queryPreviewData} panelData={queryPreviewData}
@ -515,11 +520,6 @@ function TypeSelectorButton({ onClickType }: { onClickType: (type: ExpressionQue
} }
const getStyles = (theme: GrafanaTheme2) => ({ const getStyles = (theme: GrafanaTheme2) => ({
mutedText: css`
color: ${theme.colors.text.secondary};
font-size: ${theme.typography.size.sm};
margin-top: ${theme.spacing(-1)};
`,
addQueryButton: css` addQueryButton: css`
width: fit-content; width: fit-content;
`, `,

View File

@ -1,11 +1,10 @@
import { css } from '@emotion/css';
import React from 'react'; import React from 'react';
import { useFormContext } from 'react-hook-form'; import { useFormContext } from 'react-hook-form';
import { DataSourceInstanceSettings, GrafanaTheme2 } from '@grafana/data'; import { DataSourceInstanceSettings } from '@grafana/data';
import { Stack } from '@grafana/experimental'; import { Stack } from '@grafana/experimental';
import { DataSourceJsonData } from '@grafana/schema'; import { DataSourceJsonData } from '@grafana/schema';
import { Alert, useStyles2 } from '@grafana/ui'; import { RadioButtonGroup, Text } from '@grafana/ui';
import { contextSrv } from 'app/core/core'; import { contextSrv } from 'app/core/core';
import { ExpressionDatasourceUID } from 'app/features/expressions/types'; import { ExpressionDatasourceUID } from 'app/features/expressions/types';
import { AccessControlAction } from 'app/types'; import { AccessControlAction } from 'app/types';
@ -39,13 +38,11 @@ const onlyOneDSInQueries = (queries: AlertQuery[]) => {
const getCanSwitch = ({ const getCanSwitch = ({
queries, queries,
ruleFormType, ruleFormType,
editingExistingRule,
rulesSourcesWithRuler, rulesSourcesWithRuler,
}: { }: {
rulesSourcesWithRuler: Array<DataSourceInstanceSettings<DataSourceJsonData>>; rulesSourcesWithRuler: Array<DataSourceInstanceSettings<DataSourceJsonData>>;
queries: AlertQuery[]; queries: AlertQuery[];
ruleFormType: RuleFormType | undefined; ruleFormType: RuleFormType | undefined;
editingExistingRule: boolean;
}) => { }) => {
// get available rule types // get available rule types
const availableRuleTypes = getAvailableRuleTypes(); const availableRuleTypes = getAvailableRuleTypes();
@ -57,12 +54,11 @@ const getCanSwitch = ({
//let's check if we switch to cloud type //let's check if we switch to cloud type
const canSwitchToCloudRule = const canSwitchToCloudRule =
!editingExistingRule &&
!isRecordingRuleType && !isRecordingRuleType &&
onlyOneDS && onlyOneDS &&
rulesSourcesWithRuler.some((dsJsonData) => dsJsonData.uid === dataSourceIdFromQueries); rulesSourcesWithRuler.some((dsJsonData) => dsJsonData.uid === dataSourceIdFromQueries);
const canSwitchToGrafanaRule = !editingExistingRule && !isRecordingRuleType; const canSwitchToGrafanaRule = !isRecordingRuleType;
// check for enabled types // check for enabled types
const grafanaTypeEnabled = availableRuleTypes.enabledRuleTypes.includes(RuleFormType.grafana); const grafanaTypeEnabled = availableRuleTypes.enabledRuleTypes.includes(RuleFormType.grafana);
const cloudTypeEnabled = availableRuleTypes.enabledRuleTypes.includes(RuleFormType.cloudAlerting); const cloudTypeEnabled = availableRuleTypes.enabledRuleTypes.includes(RuleFormType.cloudAlerting);
@ -83,43 +79,6 @@ export interface SmartAlertTypeDetectorProps {
onClickSwitch: () => void; onClickSwitch: () => void;
} }
const getContentText = (ruleFormType: RuleFormType, isEditing: boolean, dataSourceName: string, canSwitch: boolean) => {
if (isEditing) {
if (ruleFormType === RuleFormType.grafana) {
return {
contentText: `Grafana-managed alert rules allow you to create alerts that can act on data from any of our supported data sources, including having multiple data sources in the same rule. You can also add expressions to transform your data and set alert conditions. Using images in alert notifications is also supported. `,
title: `This alert rule is managed by Grafana.`,
};
} else {
return {
contentText: `Data source-managed alert rules can be used for Grafana Mimir or Grafana Loki data sources which have been configured to support rule creation. The use of expressions or multiple queries is not supported.`,
title: `This alert rule is managed by the data source ${dataSourceName}.`,
};
}
}
if (canSwitch) {
if (ruleFormType === RuleFormType.cloudAlerting) {
return {
contentText:
'Data source-managed alert rules can be used for Grafana Mimir or Grafana Loki data sources which have been configured to support rule creation. The use of expressions or multiple queries is not supported.',
title: `This alert rule is managed by the data source ${dataSourceName}. If you want to use expressions or have multiple queries, switch to a Grafana-managed alert rule.`,
};
} else {
return {
contentText:
'Grafana-managed alert rules allow you to create alerts that can act on data from any of our supported data sources, including having multiple data sources in the same rule. You can also add expressions to transform your data and set alert conditions. Using images in alert notifications is also supported.',
title: `This alert rule will be managed by Grafana. The selected data source is configured to support rule creation.`,
};
}
} else {
// it can be only grafana rule
return {
contentText: `Grafana-managed alert rules allow you to create alerts that can act on data from any of our supported data sources, including having multiple data sources in the same rule. You can also add expressions to transform your data and set alert conditions. Using images in alert notifications is also supported.`,
title: `Based on the data sources selected, this alert rule is managed by Grafana.`,
};
}
};
export function SmartAlertTypeDetector({ export function SmartAlertTypeDetector({
editingExistingRule, editingExistingRule,
rulesSourcesWithRuler, rulesSourcesWithRuler,
@ -127,54 +86,77 @@ export function SmartAlertTypeDetector({
onClickSwitch, onClickSwitch,
}: SmartAlertTypeDetectorProps) { }: SmartAlertTypeDetectorProps) {
const { getValues } = useFormContext<RuleFormValues>(); const { getValues } = useFormContext<RuleFormValues>();
const [ruleFormType] = getValues(['type']);
const canSwitch = getCanSwitch({ queries, ruleFormType, rulesSourcesWithRuler });
const [ruleFormType, dataSourceName] = getValues(['type', 'dataSourceName']); const options = [
const styles = useStyles2(getStyles); { label: 'Grafana-managed', value: RuleFormType.grafana },
{ label: 'Data source-managed', value: RuleFormType.cloudAlerting },
];
const canSwitch = getCanSwitch({ queries, ruleFormType, editingExistingRule, rulesSourcesWithRuler }); // if we can't switch to data-source managed, disable it
// TODO figure out how to show a popover to the user to indicate _why_ it's disabled
const typeTitle = const disabledOptions = canSwitch ? [] : [RuleFormType.cloudAlerting];
ruleFormType === RuleFormType.cloudAlerting ? 'Data source-managed alert rule' : 'Grafana-managed alert rule';
const switchToLabel = ruleFormType !== RuleFormType.cloudAlerting ? 'data source-managed' : 'Grafana-managed';
const content = ruleFormType
? getContentText(ruleFormType, editingExistingRule, dataSourceName ?? '', canSwitch)
: undefined;
return ( return (
<div className={styles.alert}> <Stack direction="column" gap={1} alignItems="flex-start">
<Alert <Stack direction="column" gap={0}>
severity="info" <Text variant="h5">Rule type</Text>
title={typeTitle} <Stack direction="row" gap={0.5} alignItems="baseline">
onRemove={canSwitch ? onClickSwitch : undefined} <Text variant="bodySmall" color="secondary">
buttonContent={`Switch to ${switchToLabel} alert rule`} Select where the alert rule will be managed.
> </Text>
<Stack gap={0.5} direction="row" alignItems={'baseline'}> <NeedHelpInfo
<div className={styles.alertText}>{content?.title}</div> contentText={
<div className={styles.needInfo}> <>
<NeedHelpInfo <Text color="primary" variant="h6">
contentText={content?.contentText ?? ''} Grafana-managed alert rules
externalLink={`https://grafana.com/docs/grafana/latest/alerting/fundamentals/alert-rules/alert-rule-types/`} </Text>
linkText={`Read about alert rule types`} <p>
title=" Alert rule types" Grafana-managed alert rules allow you to create alerts that can act on data from any of our supported
/> data sources, including having multiple data sources in the same rule. You can also add expressions to
</div> transform your data and set alert conditions. Using images in alert notifications is also supported.
</p>
<Text color="primary" variant="h6">
Data source-managed alert rules
</Text>
<p>
Data source-managed alert rules can be used for Grafana Mimir or Grafana Loki data sources which have
been configured to support rule creation. The use of expressions or multiple queries is not supported.
</p>
</>
}
externalLink="https://grafana.com/docs/grafana/latest/alerting/fundamentals/alert-rules/alert-rule-types/"
linkText="Read about alert rule types"
title="Alert rule types"
/>
</Stack> </Stack>
</Alert> </Stack>
</div> <RadioButtonGroup
options={options}
disabled={editingExistingRule}
disabledOptions={disabledOptions}
value={ruleFormType}
onChange={onClickSwitch}
/>
{/* editing an existing rule, we just show "cannot be changed" */}
{editingExistingRule && (
<Text color="secondary">The alert rule type cannot be changed for an existing rule.</Text>
)}
{/* in regular alert creation we tell the user what options they have when using a cloud data source */}
{!editingExistingRule && (
<>
{canSwitch ? (
<Text color="secondary">
{ruleFormType === RuleFormType.grafana
? 'The data source selected in your query supports alert rule management. Switch to data source-managed if you want the alert rule to be managed by the data source instead of Grafana.'
: 'Switch to Grafana-managed to use expressions, multiple queries, images in notifications and various other features.'}
</Text>
) : (
<Text color="secondary">Based on the selected data sources this alert rule will be Grafana-managed.</Text>
)}
</>
)}
</Stack>
); );
} }
const getStyles = (theme: GrafanaTheme2) => ({
alertText: css`
max-width: fit-content;
flex: 1;
`,
alert: css`
margin-top: ${theme.spacing(2)};
`,
needInfo: css`
flex: 1;
max-width: fit-content;
`,
});

View File

@ -2,16 +2,27 @@ import { rest } from 'msw';
import { SetupServer } from 'msw/node'; import { SetupServer } from 'msw/node';
import { import {
AlertmanagerChoice,
AlertManagerCortexConfig, AlertManagerCortexConfig,
ExternalAlertmanagersResponse, ExternalAlertmanagersResponse,
} from '../../../../plugins/datasource/alertmanager/types'; } from '../../../../plugins/datasource/alertmanager/types';
import { AlertmanagersChoiceResponse } from '../api/alertmanagerApi'; import { AlertmanagersChoiceResponse } from '../api/alertmanagerApi';
import { getDatasourceAPIUid } from '../utils/datasource'; import { getDatasourceAPIUid } from '../utils/datasource';
export const defaultAlertmanagerChoiceResponse: AlertmanagersChoiceResponse = {
alertmanagersChoice: AlertmanagerChoice.Internal,
numExternalAlertmanagers: 0,
};
export function mockAlertmanagerChoiceResponse(server: SetupServer, response: AlertmanagersChoiceResponse) { export function mockAlertmanagerChoiceResponse(server: SetupServer, response: AlertmanagersChoiceResponse) {
server.use(rest.get('/api/v1/ngalert', (req, res, ctx) => res(ctx.status(200), ctx.json(response)))); server.use(rest.get('/api/v1/ngalert', (req, res, ctx) => res(ctx.status(200), ctx.json(response))));
} }
export const emptyExternalAlertmanagersResponse: ExternalAlertmanagersResponse = {
data: {
droppedAlertManagers: [],
activeAlertManagers: [],
},
};
export function mockAlertmanagersResponse(server: SetupServer, response: ExternalAlertmanagersResponse) { export function mockAlertmanagersResponse(server: SetupServer, response: ExternalAlertmanagersResponse) {
server.use(rest.get('/api/v1/ngalert/alertmanagers', (req, res, ctx) => res(ctx.status(200), ctx.json(response)))); server.use(rest.get('/api/v1/ngalert/alertmanagers', (req, res, ctx) => res(ctx.status(200), ctx.json(response))));
} }

View File

@ -11,7 +11,7 @@ import { TestProvider } from './TestProvider';
export const ui = { export const ui = {
inputs: { inputs: {
name: byRole('textbox', { name: /rule name name for the alert rule\./i }), name: byRole('textbox', { name: 'name' }),
alertType: byTestId('alert-type-picker'), alertType: byTestId('alert-type-picker'),
dataSource: byTestId('datasource-picker'), dataSource: byTestId('datasource-picker'),
folder: byTestId('folder-picker'), folder: byTestId('folder-picker'),