import * as React from 'react'; import {withRouter, WithRouterProps} from 'react-router'; import styled from '@emotion/styled'; import { addErrorMessage, addLoadingMessage, addSuccessMessage, } from 'sentry/actionCreators/indicator'; import {navigateTo} from 'sentry/actionCreators/navigation'; import Access from 'sentry/components/acl/access'; import Alert from 'sentry/components/alert'; import GuideAnchor from 'sentry/components/assistant/guideAnchor'; import Button from 'sentry/components/button'; import Link from 'sentry/components/links/link'; import {IconClose, IconInfo, IconSiren} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import {Organization, Project} from 'sentry/types'; import EventView from 'sentry/utils/discover/eventView'; import { Aggregation, AGGREGATIONS, explodeFieldString, } from 'sentry/utils/discover/fields'; import useApi from 'sentry/utils/useApi'; import { errorFieldConfig, transactionFieldConfig, } from 'sentry/views/alerts/incidentRules/constants'; import {getQueryDatasource} from 'sentry/views/alerts/utils'; /** * Discover query supports more features than alert rules * To create an alert rule from a discover query, some parameters need to be adjusted */ type IncompatibleQueryProperties = { /** * Must have exactly one project selected and not -1 (all projects) */ hasProjectError: boolean; /** * Must have zero or one environments */ hasEnvironmentError: boolean; /** * event.type must be error or transaction */ hasEventTypeError: boolean; hasYAxisError: boolean; }; type AlertProps = { incompatibleQuery: IncompatibleQueryProperties; eventView: EventView; orgId: string; /** * Dismiss alert */ onClose: () => void; }; /** * Displays messages to the user on what needs to change in their query */ function IncompatibleQueryAlert({ incompatibleQuery, eventView, orgId, onClose, }: AlertProps) { const {hasProjectError, hasEnvironmentError, hasEventTypeError, hasYAxisError} = incompatibleQuery; const totalErrors = Object.values(incompatibleQuery).filter(val => val === true).length; const eventTypeError = eventView.clone(); eventTypeError.query += ' event.type:error'; const eventTypeTransaction = eventView.clone(); eventTypeTransaction.query += ' event.type:transaction'; const eventTypeDefault = eventView.clone(); eventTypeDefault.query += ' event.type:default'; const eventTypeErrorDefault = eventView.clone(); eventTypeErrorDefault.query += ' event.type:error or event.type:default'; const pathname = `/organizations/${orgId}/discover/results/`; const eventTypeLinks = { error: ( ), default: ( ), transaction: ( ), errorDefault: ( ), }; return ( }> {totalErrors === 1 && ( {hasProjectError && t('An alert can use data from only one Project. Select one and try again.')} {hasEnvironmentError && t( 'An alert supports data from a single Environment or All Environments. Pick one try again.' )} {hasEventTypeError && tct( 'An alert needs a filter of [error:event.type:error], [default:event.type:default], [transaction:event.type:transaction], [errorDefault:(event.type:error OR event.type:default)]. Use one of these and try again.', eventTypeLinks )} {hasYAxisError && tct( 'An alert can’t use the metric [yAxis] just yet. Select another metric and try again.', { yAxis: {eventView.getYAxis()}, } )} )} {totalErrors > 1 && ( {t('Yikes! That button didn’t work. Please fix the following problems:')} {hasProjectError &&
  • {t('Select one Project.')}
  • } {hasEnvironmentError && (
  • {t('Select a single Environment or All Environments.')}
  • )} {hasEventTypeError && (
  • {tct( 'Use the filter [error:event.type:error], [default:event.type:default], [transaction:event.type:transaction], [errorDefault:(event.type:error OR event.type:default)].', eventTypeLinks )}
  • )} {hasYAxisError && (
  • {tct( 'An alert can’t use the metric [yAxis] just yet. Select another metric and try again.', { yAxis: {eventView.getYAxis()}, } )}
  • )}
    )} } aria-label={t('Close')} size="zero" onClick={onClose} borderless />
    ); } type CreateAlertFromViewButtonProps = React.ComponentProps & { className?: string; projects: Project[]; /** * Discover query used to create the alert */ eventView: EventView; organization: Organization; referrer?: string; /** * Called when the current eventView does not meet the requirements of alert rules * @returns a function that takes an alert close function argument */ onIncompatibleQuery: ( incompatibleAlertNoticeFn: (onAlertClose: () => void) => React.ReactNode, errors: IncompatibleQueryProperties ) => void; /** * Called when the user is redirected to the alert builder */ onSuccess: () => void; }; function incompatibleYAxis(eventView: EventView): boolean { const column = explodeFieldString(eventView.getYAxis()); if (column.kind === 'field' || column.kind === 'equation') { return true; } const eventTypeMatch = eventView.query.match(/event\.type:(transaction|error)/); if (!eventTypeMatch) { return false; } const dataset = eventTypeMatch[1]; const yAxisConfig = dataset === 'error' ? errorFieldConfig : transactionFieldConfig; const invalidFunction = !yAxisConfig.aggregations.includes(column.function[0]); // Allow empty parameters, allow all numeric parameters - eg. apdex(300) const aggregation: Aggregation | undefined = AGGREGATIONS[column.function[0]]; if (!aggregation) { return false; } const isNumericParameter = aggregation.parameters.some( param => param.kind === 'value' && param.dataType === 'number' ); // There are other measurements possible, but for the time being, only allow alerting // on the predefined set of measurements for alerts. const allowedParameters = [ '', ...yAxisConfig.fields, ...(yAxisConfig.measurementKeys ?? []), ]; const invalidParameter = !isNumericParameter && !allowedParameters.includes(column.function[1]); return invalidFunction || invalidParameter; } /** * Provide a button that can create an alert from an event view. * Emits incompatible query issues on click */ function CreateAlertFromViewButton({ projects, eventView, organization, referrer, onIncompatibleQuery, onSuccess, ...buttonProps }: CreateAlertFromViewButtonProps) { // Must have exactly one project selected and not -1 (all projects) const hasProjectError = eventView.project.length !== 1 || eventView.project[0] === -1; // Must have one or zero environments const hasEnvironmentError = eventView.environment.length > 1; // Must have event.type of error or transaction const hasEventTypeError = getQueryDatasource(eventView.query) === null; // yAxis must be a function and enabled on alerts const hasYAxisError = incompatibleYAxis(eventView); const errors: IncompatibleQueryProperties = { hasProjectError, hasEnvironmentError, hasEventTypeError, hasYAxisError, }; const project = projects.find(p => p.id === `${eventView.project[0]}`); const hasErrors = Object.values(errors).some(x => x); const to = hasErrors ? undefined : { pathname: `/organizations/${organization.slug}/alerts/${project?.slug}/new/`, query: { ...eventView.generateQueryStringObject(), createFromDiscover: true, referrer, }, }; const handleClick = (event: React.MouseEvent) => { if (hasErrors) { event.preventDefault(); onIncompatibleQuery( (onAlertClose: () => void) => ( ), errors ); return; } onSuccess(); }; return ( ); } type Props = { organization: Organization; projectSlug?: string; iconProps?: React.ComponentProps; referrer?: string; hideIcon?: boolean; showPermissionGuide?: boolean; } & WithRouterProps & React.ComponentProps; const CreateAlertButton = withRouter( ({ organization, projectSlug, iconProps, referrer, router, hideIcon, showPermissionGuide, ...buttonProps }: Props) => { const api = useApi(); const createAlertUrl = (providedProj: string) => { const alertsBaseUrl = `/organizations/${organization.slug}/alerts/${providedProj}`; return `${alertsBaseUrl}/wizard/${referrer ? `?referrer=${referrer}` : ''}`; }; function handleClickWithoutProject(event: React.MouseEvent) { event.preventDefault(); navigateTo(createAlertUrl(':projectId'), router); } async function enableAlertsMemberWrite() { const settingsEndpoint = `/organizations/${organization.slug}/`; addLoadingMessage(); try { await api.requestPromise(settingsEndpoint, { method: 'PUT', data: { alertsMemberWrite: true, }, }); addSuccessMessage(t('Successfully updated organization settings')); } catch (err) { addErrorMessage(t('Unable to update organization settings')); } } const permissionTooltipText = tct( 'Ask your organization owner or manager to [settingsLink:enable alerts access] for you.', {settingsLink: } ); const renderButton = (hasAccess: boolean) => ( ); const showGuide = !organization.alertsMemberWrite && !!showPermissionGuide; return ( {({hasAccess}) => showGuide ? ( {({hasAccess: isOrgAdmin}) => ( {renderButton(hasAccess)} )} ) : ( renderButton(hasAccess) ) } ); } ); export {CreateAlertFromViewButton}; export default CreateAlertButton; const StyledAlert = styled(Alert)` color: ${p => p.theme.textColor}; margin-bottom: 0; `; const StyledUnorderedList = styled('ul')` margin-bottom: 0; `; const StyledCode = styled('code')` background-color: transparent; padding: 0; `; const StyledCloseButton = styled(Button)` transition: opacity 0.1s linear; position: absolute; top: 3px; right: 0; &:hover, &:focus { background-color: transparent; } `;