Files
zeekayandHanzo Dev f888991a7a drop the Sentry wordmark from the react app
FSL-1.0-Apache-2.0 grants us the code, not the name. "Sentry" is
Functional Software's trademark, so every place the running app says it
to a user -- page titles, onboarding, empty states, toasts, settings
copy, the self-hosted footer -- now says Sentinel.

Attribution is the other half of the deal and stays untouched: upstream
copyright headers, LICENSE, NOTICE. So does anything where the mark is a
fact rather than a brand -- @sentry/* packages, sentry-cli, the wizard
and bundler plugins, docs.sentry.io, the whole gettingStartedDocs tree.
Renaming those would leave instructions that no longer work.

Nothing structural moved. No import, identifier, route path, feature
flag, API field or test id changed; the diff is 312 lines in and 312
out. Colocated specs were updated in lockstep with the copy they assert.
Marketing and community links point at hanzo.ai, only where a real page
exists to point at.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-10 18:01:22 -07:00

1695 lines
55 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {ChangeEvent, ReactNode} from 'react';
import {Fragment} from 'react';
import type {RouteComponentProps} from 'react-router';
import {browserHistory} from 'react-router';
import {components} from 'react-select';
import styled from '@emotion/styled';
import classNames from 'classnames';
import type {Location} from 'history';
import cloneDeep from 'lodash/cloneDeep';
import debounce from 'lodash/debounce';
import omit from 'lodash/omit';
import set from 'lodash/set';
import {
addErrorMessage,
addLoadingMessage,
addSuccessMessage,
} from 'sentry/actionCreators/indicator';
import {updateOnboardingTask} from 'sentry/actionCreators/onboardingTasks';
import {hasEveryAccess} from 'sentry/components/acl/access';
import {Alert} from 'sentry/components/alert';
import AlertLink from 'sentry/components/alertLink';
import {Button} from 'sentry/components/button';
import Checkbox from 'sentry/components/checkbox';
import Confirm from 'sentry/components/confirm';
import ErrorBoundary from 'sentry/components/errorBoundary';
import SelectControl from 'sentry/components/forms/controls/selectControl';
import FieldGroup from 'sentry/components/forms/fieldGroup';
import FieldHelp from 'sentry/components/forms/fieldGroup/fieldHelp';
import SelectField from 'sentry/components/forms/fields/selectField';
import type {FormProps} from 'sentry/components/forms/form';
import Form from 'sentry/components/forms/form';
import FormField from 'sentry/components/forms/formField';
import IdBadge from 'sentry/components/idBadge';
import Input from 'sentry/components/input';
import * as Layout from 'sentry/components/layouts/thirds';
import ExternalLink from 'sentry/components/links/externalLink';
import List from 'sentry/components/list';
import ListItem from 'sentry/components/list/listItem';
import LoadingMask from 'sentry/components/loadingMask';
import Panel from 'sentry/components/panels/panel';
import PanelBody from 'sentry/components/panels/panelBody';
import TeamSelector from 'sentry/components/teamSelector';
import {ALL_ENVIRONMENTS_KEY} from 'sentry/constants';
import {IconChevron, IconNot} from 'sentry/icons';
import {t, tct, tn} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {Environment, Member, Organization, Project, Team} from 'sentry/types';
import {OnboardingTaskKey} from 'sentry/types';
import type {
IssueAlertConfiguration,
IssueAlertRule,
IssueAlertRuleAction,
IssueAlertRuleActionTemplate,
UnsavedIssueAlertRule,
} from 'sentry/types/alerts';
import {
IssueAlertActionType,
IssueAlertConditionType,
IssueAlertFilterType,
} from 'sentry/types/alerts';
import {metric, trackAnalytics} from 'sentry/utils/analytics';
import {getDisplayName} from 'sentry/utils/environment';
import {isActiveSuperuser} from 'sentry/utils/isActiveSuperuser';
import recreateRoute from 'sentry/utils/recreateRoute';
import routeTitleGen from 'sentry/utils/routeTitle';
import {normalizeUrl} from 'sentry/utils/withDomainRequired';
import withOrganization from 'sentry/utils/withOrganization';
import withProjects from 'sentry/utils/withProjects';
import {PreviewIssues} from 'sentry/views/alerts/rules/issue/previewIssues';
import {
CHANGE_ALERT_CONDITION_IDS,
CHANGE_ALERT_PLACEHOLDERS_LABELS,
} from 'sentry/views/alerts/utils/constants';
import DeprecatedAsyncView from 'sentry/views/deprecatedAsyncView';
import PermissionAlert from 'sentry/views/settings/project/permissionAlert';
import {getProjectOptions} from '../utils';
import RuleNodeList from './ruleNodeList';
import SetupAlertIntegrationButton from './setupAlertIntegrationButton';
const FREQUENCY_OPTIONS = [
{value: '5', label: t('5 minutes')},
{value: '10', label: t('10 minutes')},
{value: '30', label: t('30 minutes')},
{value: '60', label: t('60 minutes')},
{value: '180', label: t('3 hours')},
{value: '720', label: t('12 hours')},
{value: '1440', label: t('24 hours')},
{value: '10080', label: t('1 week')},
{value: '43200', label: t('30 days')},
];
const ACTION_MATCH_OPTIONS = [
{value: 'all', label: t('all')},
{value: 'any', label: t('any')},
{value: 'none', label: t('none')},
];
const ACTION_MATCH_OPTIONS_MIGRATED = [
{value: 'all', label: t('all')},
{value: 'any', label: t('any')},
];
const defaultRule: UnsavedIssueAlertRule = {
actionMatch: 'any',
filterMatch: 'all',
actions: [],
// note we update the default conditions in onLoadAllEndpointsSuccess
conditions: [],
filters: [],
name: '',
frequency: 60 * 24,
environment: ALL_ENVIRONMENTS_KEY,
};
const POLLING_MAX_TIME_LIMIT = 3 * 60000;
type ConfigurationKey = keyof IssueAlertConfiguration;
type RuleTaskResponse = {
status: 'pending' | 'failed' | 'success';
error?: string;
rule?: IssueAlertRule;
};
type RouteParams = {projectId?: string; ruleId?: string};
export type IncompatibleRule = {
conditionIndices: number[] | null;
filterIndices: number[] | null;
};
type Props = {
location: Location;
members: Member[] | undefined;
organization: Organization;
project: Project;
projects: Project[];
userTeamIds: string[];
loadingProjects?: boolean;
onChangeTitle?: (data: string) => void;
} & RouteComponentProps<RouteParams, {}>;
type State = DeprecatedAsyncView['state'] & {
configs: IssueAlertConfiguration | null;
detailedError: null | {
[key: string]: string[];
};
environments: Environment[] | null;
incompatibleConditions: number[] | null;
incompatibleFilters: number[] | null;
project: Project;
sendingNotification: boolean;
uuid: null | string;
acceptedNoisyAlert?: boolean;
duplicateTargetRule?: UnsavedIssueAlertRule | IssueAlertRule | null;
rule?: UnsavedIssueAlertRule | IssueAlertRule | null;
};
function isSavedAlertRule(rule: State['rule']): rule is IssueAlertRule {
return rule?.hasOwnProperty('id') ?? false;
}
/**
* Expecting "This rule is an exact duplicate of '{duplicate_rule.label}' in this project and may not be created."
*/
const isExactDuplicateExp = /duplicate of '(.*)'/;
class IssueRuleEditor extends DeprecatedAsyncView<Props, State> {
pollingTimeout: number | undefined = undefined;
trackIncompatibleAnalytics: boolean = false;
trackNoisyWarningViewed: boolean = false;
isUnmounted = false;
get isDuplicateRule(): boolean {
const {location} = this.props;
const createFromDuplicate = location?.query.createFromDuplicate === 'true';
return createFromDuplicate && location?.query.duplicateRuleId;
}
componentDidMount() {
super.componentDidMount();
}
componentWillUnmount() {
super.componentWillUnmount();
this.isUnmounted = true;
window.clearTimeout(this.pollingTimeout);
this.checkIncompatibleRuleDebounced.cancel();
}
componentDidUpdate(_prevProps: Props, prevState: State) {
if (this.isRuleStateChange(prevState)) {
this.setState({
incompatibleConditions: null,
incompatibleFilters: null,
});
this.checkIncompatibleRuleDebounced();
}
if (prevState.project.id === this.state.project.id) {
return;
}
this.fetchEnvironments();
}
isRuleStateChange(prevState: State): boolean {
const prevRule = prevState.rule;
const curRule = this.state.rule;
return (
JSON.stringify(prevRule?.conditions) !== JSON.stringify(curRule?.conditions) ||
JSON.stringify(prevRule?.filters) !== JSON.stringify(curRule?.filters) ||
prevRule?.actionMatch !== curRule?.actionMatch ||
prevRule?.filterMatch !== curRule?.filterMatch ||
prevRule?.frequency !== curRule?.frequency ||
JSON.stringify(prevState.project) !== JSON.stringify(this.state.project)
);
}
getTitle() {
const {organization} = this.props;
const {rule, project} = this.state;
const ruleName = rule?.name;
return routeTitleGen(
ruleName ? t('Alert - %s', ruleName) : t('New Alert Rule'),
organization.slug,
false,
project?.slug
);
}
getDefaultState() {
const {userTeamIds, project} = this.props;
const defaultState = {
...super.getDefaultState(),
configs: null,
detailedError: null,
rule: {...defaultRule},
environments: [],
uuid: null,
project,
sendingNotification: false,
incompatibleConditions: null,
incompatibleFilters: null,
};
const projectTeamIds = new Set(project.teams.map(({id}) => id));
const userTeamId = userTeamIds.find(id => projectTeamIds.has(id)) ?? null;
defaultState.rule.owner = userTeamId && `team:${userTeamId}`;
return defaultState;
}
getEndpoints(): ReturnType<DeprecatedAsyncView['getEndpoints']> {
const {
location: {query},
params: {ruleId},
} = this.props;
const {organization} = this.props;
// project in state isn't initialized when getEndpoints is first called
const project = this.state?.project ?? this.props.project;
const endpoints = [
[
'environments',
`/projects/${organization.slug}/${project.slug}/environments/`,
{
query: {
visibility: 'visible',
},
},
],
['configs', `/projects/${organization.slug}/${project.slug}/rules/configuration/`],
];
if (ruleId) {
endpoints.push([
'rule',
`/projects/${organization.slug}/${project.slug}/rules/${ruleId}/`,
]);
}
if (!ruleId && query.createFromDuplicate && query.duplicateRuleId) {
endpoints.push([
'duplicateTargetRule',
`/projects/${organization.slug}/${project.slug}/rules/${query.duplicateRuleId}/`,
]);
}
return endpoints as [string, string][];
}
onRequestSuccess({stateKey, data}) {
if (stateKey === 'rule' && data.name) {
this.props.onChangeTitle?.(data.name);
}
if (stateKey === 'duplicateTargetRule') {
this.setState({
rule: {
...omit(data, ['id']),
name: data.name + ' copy',
} as UnsavedIssueAlertRule,
});
}
}
onLoadAllEndpointsSuccess() {
const {rule} = this.state;
const {
params: {ruleId},
} = this.props;
if (rule) {
((rule as IssueAlertRule)?.errors || []).map(({detail}) =>
addErrorMessage(detail, {append: true})
);
}
if (!ruleId && !this.isDuplicateRule) {
// now that we've loaded all the possible conditions, we can populate the
// value of conditions for a new alert
this.handleChange('conditions', [{id: IssueAlertConditionType.FIRST_SEEN_EVENT}]);
}
}
pollHandler = async (quitTime: number) => {
if (Date.now() > quitTime) {
addErrorMessage(t('Looking for that channel took too long :('));
this.setState({loading: false});
return;
}
const {organization} = this.props;
const {uuid, project} = this.state;
const origRule = this.state.rule;
try {
const response: RuleTaskResponse = await this.api.requestPromise(
`/projects/${organization.slug}/${project.slug}/rule-task/${uuid}/`
);
const {status, rule, error} = response;
if (status === 'pending') {
window.clearTimeout(this.pollingTimeout);
this.pollingTimeout = window.setTimeout(() => {
this.pollHandler(quitTime);
}, 1000);
return;
}
if (status === 'failed') {
this.setState({
detailedError: {actions: [error ? error : t('An error occurred')]},
loading: false,
});
this.handleRuleSaveFailure(t('An error occurred'));
}
if (rule) {
const ruleId = isSavedAlertRule(origRule) ? `${origRule.id}/` : '';
const isNew = !ruleId;
this.handleRuleSuccess(isNew, rule);
}
} catch {
this.handleRuleSaveFailure(t('An error occurred'));
this.setState({loading: false});
}
};
// As more incompatible combinations are added, we will need a more generic way to check for incompatibility.
checkIncompatibleRuleDebounced = debounce(() => {
const {conditionIndices, filterIndices} = findIncompatibleRules(this.state.rule);
if (
!this.trackIncompatibleAnalytics &&
(conditionIndices !== null || filterIndices !== null)
) {
this.trackIncompatibleAnalytics = true;
trackAnalytics('edit_alert_rule.incompatible_rule', {
organization: this.props.organization,
});
}
this.setState({
incompatibleConditions: conditionIndices,
incompatibleFilters: filterIndices,
});
}, 500);
fetchEnvironments() {
const {organization} = this.props;
const {project} = this.state;
this.api
.requestPromise(`/projects/${organization.slug}/${project.slug}/environments/`, {
query: {
visibility: 'visible',
},
})
.then(response => this.setState({environments: response}))
.catch(_err => addErrorMessage(t('Unable to fetch environments')));
}
fetchStatus() {
// pollHandler calls itself until it gets either a success
// or failed status but we don't want to poll forever so we pass
// in a hard stop time of 3 minutes before we bail.
const quitTime = Date.now() + POLLING_MAX_TIME_LIMIT;
window.clearTimeout(this.pollingTimeout);
this.pollingTimeout = window.setTimeout(() => {
this.pollHandler(quitTime);
}, 1000);
}
testNotifications = () => {
const {organization} = this.props;
const {project, rule} = this.state;
this.setState({detailedError: null, sendingNotification: true});
const actions = rule?.actions ? rule?.actions.length : 0;
addLoadingMessage(
tn('Sending a test notification...', 'Sending test notifications...', actions)
);
this.api
.requestPromise(`/projects/${organization.slug}/${project.slug}/rule-actions/`, {
method: 'POST',
data: {
actions: rule?.actions ?? [],
},
})
.then(() => {
addSuccessMessage(tn('Notification sent!', 'Notifications sent!', actions));
trackAnalytics('edit_alert_rule.notification_test', {
organization,
success: true,
});
})
.catch(error => {
addErrorMessage(tn('Notification failed', 'Notifications failed', actions));
this.setState({detailedError: error.responseJSON || null});
trackAnalytics('edit_alert_rule.notification_test', {
organization,
success: false,
});
})
.finally(() => {
this.setState({sendingNotification: false});
});
};
handleRuleSuccess = (isNew: boolean, rule: IssueAlertRule) => {
const {organization, router} = this.props;
const {project} = this.state;
// The onboarding task will be completed on the server side when the alert
// is created
updateOnboardingTask(null, organization, {
task: OnboardingTaskKey.ALERT_RULE,
status: 'complete',
});
metric.endTransaction({name: 'saveAlertRule'});
router.push(
normalizeUrl({
pathname: `/organizations/${organization.slug}/alerts/rules/${project.slug}/${rule.id}/details/`,
})
);
addSuccessMessage(isNew ? t('Created alert rule') : t('Updated alert rule'));
};
handleRuleSaveFailure(msg: ReactNode) {
addErrorMessage(msg);
metric.endTransaction({name: 'saveAlertRule'});
}
handleSubmit = async () => {
const {project, rule} = this.state;
const ruleId = isSavedAlertRule(rule) ? `${rule.id}/` : '';
const isNew = !ruleId;
const {organization} = this.props;
const endpoint = `/projects/${organization.slug}/${project.slug}/rules/${ruleId}`;
if (rule && rule.environment === ALL_ENVIRONMENTS_KEY) {
delete rule.environment;
}
// Check conditions exist or they've accepted a noisy alert
if (this.displayNoConditionsWarning() && !this.state.acceptedNoisyAlert) {
this.setState({detailedError: {acceptedNoisyAlert: [t('Required')]}});
return;
}
addLoadingMessage();
try {
const transaction = metric.startTransaction({name: 'saveAlertRule'});
transaction.setTag('type', 'issue');
transaction.setTag('operation', isNew ? 'create' : 'edit');
if (rule) {
for (const action of rule.actions) {
if (action.id === IssueAlertActionType.SLACK) {
transaction.setTag('SlackNotifyServiceAction', true);
}
// to avoid storing inconsistent data in the db, don't pass the name fields
delete action.name;
}
for (const condition of rule.conditions) {
delete condition.name;
}
for (const filter of rule.filters) {
delete filter.name;
}
transaction.setData('actions', rule.actions);
// Check if rule is currently disabled or going to be disabled
if ('status' in rule && (rule.status === 'disabled' || !!rule.disableDate)) {
rule.optOutEdit = true;
}
}
const [data, , resp] = await this.api.requestPromise(endpoint, {
includeAllArgs: true,
method: isNew ? 'POST' : 'PUT',
data: rule,
query: {
duplicateRule: this.isDuplicateRule ? 'true' : 'false',
wizardV3: 'true',
},
});
// if we get a 202 back it means that we have an async task
// running to lookup and verify the channel id for Slack.
if (resp?.status === 202) {
this.setState({detailedError: null, loading: true, uuid: data.uuid});
this.fetchStatus();
addLoadingMessage(t('Looking through all your channels...'));
} else {
this.handleRuleSuccess(isNew, data);
}
} catch (err) {
this.setState({
detailedError: err.responseJSON || {__all__: 'Unknown error'},
loading: false,
});
this.handleRuleSaveFailure(t('An error occurred'));
}
};
handleDeleteRule = async () => {
const {project, rule} = this.state;
const ruleId = isSavedAlertRule(rule) ? `${rule.id}/` : '';
const isNew = !ruleId;
const {organization} = this.props;
if (isNew) {
return;
}
const endpoint = `/projects/${organization.slug}/${project.slug}/rules/${ruleId}`;
addLoadingMessage(t('Deleting...'));
try {
await this.api.requestPromise(endpoint, {
method: 'DELETE',
});
addSuccessMessage(t('Deleted alert rule'));
browserHistory.replace(
recreateRoute('', {
...this.props,
params: {...this.props.params, orgId: organization.slug},
stepBack: -2,
})
);
} catch (err) {
this.setState({
detailedError: err.responseJSON || {__all__: 'Unknown error'},
});
addErrorMessage(t('There was a problem deleting the alert'));
}
};
handleCancel = () => {
const {organization, router} = this.props;
router.push(normalizeUrl(`/organizations/${organization.slug}/alerts/rules/`));
};
hasError = (field: string) => {
const {detailedError} = this.state;
if (!detailedError) {
return false;
}
return detailedError.hasOwnProperty(field);
};
handleEnvironmentChange = (val: string) => {
// If 'All Environments' is selected the value should be null
if (val === ALL_ENVIRONMENTS_KEY) {
this.handleChange('environment', null);
} else {
this.handleChange('environment', val);
}
};
handleChange = <T extends keyof IssueAlertRule>(prop: T, val: IssueAlertRule[T]) => {
this.setState(prevState => {
const clonedState = cloneDeep(prevState);
set(clonedState, `rule[${prop}]`, val);
return {...clonedState, detailedError: omit(prevState.detailedError, prop)};
});
};
handlePropertyChange = <T extends keyof IssueAlertRuleAction>(
type: ConfigurationKey,
idx: number,
prop: T,
val: IssueAlertRuleAction[T]
) => {
this.setState(prevState => {
const clonedState = cloneDeep(prevState);
set(clonedState, `rule[${type}][${idx}][${prop}]`, val);
return clonedState;
});
};
getInitialValue = (
type: ConfigurationKey,
id: string
): IssueAlertConfiguration[ConfigurationKey] => {
const configuration = this.state.configs?.[type]?.find(c => c.id === id);
const hasChangeAlerts =
configuration?.id &&
this.props.organization.features.includes('change-alerts') &&
CHANGE_ALERT_CONDITION_IDS.includes(configuration.id);
return configuration?.formFields
? Object.fromEntries(
Object.entries(configuration.formFields)
// TODO(ts): Doesn't work if I cast formField as IssueAlertRuleFormField
.map(([key, formField]: [string, any]) => [
key,
hasChangeAlerts && key === 'interval'
? '1h'
: formField?.initial ?? formField?.choices?.[0]?.[0],
])
.filter(([, initial]) => !!initial)
)
: {};
};
handleResetRow = <T extends keyof IssueAlertRuleAction>(
type: ConfigurationKey,
idx: number,
prop: T,
val: IssueAlertRuleAction[T]
) => {
this.setState(prevState => {
const clonedState = cloneDeep(prevState);
// Set initial configuration, but also set
const id = (clonedState.rule as IssueAlertRule)[type][idx].id;
const newRule = {
...this.getInitialValue(type, id),
id,
[prop]: val,
};
set(clonedState, `rule[${type}][${idx}]`, newRule);
return clonedState;
});
};
handleAddRow = (type: ConfigurationKey, item: IssueAlertRuleActionTemplate) => {
this.setState(prevState => {
const clonedState = cloneDeep(prevState);
// Set initial configuration
const newRule = {
...this.getInitialValue(type, item.id),
id: item.id,
sentryAppInstallationUuid: item.sentryAppInstallationUuid,
};
const newTypeList = prevState.rule ? prevState.rule[type] : [];
set(clonedState, `rule[${type}]`, [...newTypeList, newRule]);
return clonedState;
});
const {organization} = this.props;
const {project} = this.state;
trackAnalytics('edit_alert_rule.add_row', {
organization,
project_id: project.id,
type,
name: item.id,
});
};
handleDeleteRow = (type: ConfigurationKey, idx: number) => {
this.setState(prevState => {
const clonedState = cloneDeep(prevState);
const newTypeList = prevState.rule ? [...prevState.rule[type]] : [];
newTypeList.splice(idx, 1);
set(clonedState, `rule[${type}]`, newTypeList);
return clonedState;
});
};
handleAddCondition = (template: IssueAlertRuleActionTemplate) =>
this.handleAddRow('conditions', template);
handleAddAction = (template: IssueAlertRuleActionTemplate) =>
this.handleAddRow('actions', template);
handleAddFilter = (template: IssueAlertRuleActionTemplate) =>
this.handleAddRow('filters', template);
handleDeleteCondition = (ruleIndex: number) =>
this.handleDeleteRow('conditions', ruleIndex);
handleDeleteAction = (ruleIndex: number) => this.handleDeleteRow('actions', ruleIndex);
handleDeleteFilter = (ruleIndex: number) => this.handleDeleteRow('filters', ruleIndex);
handleChangeConditionProperty = (ruleIndex: number, prop: string, val: string) =>
this.handlePropertyChange('conditions', ruleIndex, prop, val);
handleChangeActionProperty = (ruleIndex: number, prop: string, val: string) =>
this.handlePropertyChange('actions', ruleIndex, prop, val);
handleChangeFilterProperty = (ruleIndex: number, prop: string, val: string) =>
this.handlePropertyChange('filters', ruleIndex, prop, val);
handleResetCondition = (ruleIndex: number, prop: string, value: string) =>
this.handleResetRow('conditions', ruleIndex, prop, value);
handleResetAction = (ruleIndex: number, prop: string, value: string) =>
this.handleResetRow('actions', ruleIndex, prop, value);
handleResetFilter = (ruleIndex: number, prop: string, value: string) =>
this.handleResetRow('filters', ruleIndex, prop, value);
handleValidateRuleName = () => {
const isRuleNameEmpty = !this.state.rule?.name.trim();
if (!isRuleNameEmpty) {
return;
}
this.setState(prevState => ({
detailedError: {
...prevState.detailedError,
name: [t('Field Required')],
},
}));
};
getConditions(): IssueAlertConfiguration['conditions'] | null {
const {organization} = this.props;
if (!organization.features.includes('change-alerts')) {
return this.state.configs?.conditions ?? null;
}
return (
this.state.configs?.conditions?.map(condition =>
CHANGE_ALERT_CONDITION_IDS.includes(condition.id)
? {
...condition,
label: `${CHANGE_ALERT_PLACEHOLDERS_LABELS[condition.id]}...`,
}
: condition
) ?? null
);
}
getTeamId = () => {
const {rule} = this.state;
const owner = rule?.owner;
// ownership follows the format team:<id>, just grab the id
return owner?.split(':')[1];
};
handleOwnerChange = ({value}: {value: string}) => {
const ownerValue = value && `team:${value}`;
this.handleChange('owner', ownerValue);
};
renderLoading() {
return this.renderBody();
}
renderError() {
return (
<Alert type="error" showIcon>
{t(
'Unable to access this alert rule -- check to make sure you have the correct permissions'
)}
</Alert>
);
}
renderRuleName(disabled: boolean) {
const {rule, detailedError} = this.state;
const {name} = rule || {};
// Duplicate errors display on the "name" field but we're showing them in a banner
// Remove them from the name detailed error
const filteredDetailedError =
detailedError?.name?.filter(str => !isExactDuplicateExp.test(str)) ?? [];
return (
<StyledField
label={null}
help={null}
error={filteredDetailedError[0]}
disabled={disabled}
required
stacked
flexibleControlStateSize
>
<Input
type="text"
name="name"
value={name}
data-test-id="alert-name"
placeholder={t('Enter Alert Name')}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
this.handleChange('name', event.target.value)
}
onBlur={this.handleValidateRuleName}
disabled={disabled}
/>
</StyledField>
);
}
renderTeamSelect(disabled: boolean) {
const {rule, project} = this.state;
const ownerId = rule?.owner?.split(':')[1];
return (
<StyledField label={null} help={null} disabled={disabled} flexibleControlStateSize>
<TeamSelector
value={this.getTeamId()}
project={project}
onChange={this.handleOwnerChange}
teamFilter={(team: Team) =>
team.isMember || team.id === ownerId || team.access.includes('team:admin')
}
useId
includeUnassigned
disabled={disabled}
/>
</StyledField>
);
}
renderDuplicateErrorAlert() {
const {organization} = this.props;
const {detailedError, project} = this.state;
const duplicateName = isExactDuplicateExp.exec(detailedError?.name?.[0] ?? '')?.[1];
const duplicateRuleId = detailedError?.ruleId?.[0] ?? '';
// We want this to open in a new tab to not lose the current state of the rule editor
return (
<AlertLink
openInNewTab
priority="error"
icon={<IconNot color="red300" />}
href={normalizeUrl(
`/organizations/${organization.slug}/alerts/rules/${project.slug}/${duplicateRuleId}/details/`
)}
>
{tct(
'This rule fully duplicates "[alertName]" in the project [projectName] and cannot be saved.',
{
alertName: duplicateName,
projectName: project.name,
}
)}
</AlertLink>
);
}
displayNoConditionsWarning(): boolean {
const {rule} = this.state;
const acceptedNoisyActionIds: string[] = [
// Webhooks
IssueAlertActionType.NOTIFY_EVENT_SERVICE_ACTION,
// Legacy integrations
IssueAlertActionType.NOTIFY_EVENT_ACTION,
];
return (
this.props.organization.features.includes('noisy-alert-warning') &&
!!rule &&
!isSavedAlertRule(rule) &&
rule.conditions.length === 0 &&
rule.filters.length === 0 &&
!rule.actions.every(action => acceptedNoisyActionIds.includes(action.id))
);
}
renderAcknowledgeNoConditions(disabled: boolean) {
const {detailedError, acceptedNoisyAlert} = this.state;
// Bit goofy to do in render but should only track onceish
if (!this.trackNoisyWarningViewed) {
this.trackNoisyWarningViewed = true;
trackAnalytics('alert_builder.noisy_warning_viewed', {
organization: this.props.organization,
});
}
return (
<Alert type="warning" showIcon>
<div>
{t(
'Alerts without conditions can fire too frequently. Are you sure you want to save this alert rule?'
)}
</div>
<AcknowledgeField
label={null}
help={null}
error={detailedError?.acceptedNoisyAlert?.[0]}
disabled={disabled}
required
stacked
flexibleControlStateSize
inline
>
<AcknowledgeLabel>
<Checkbox
size="sm"
name="acceptedNoisyAlert"
checked={acceptedNoisyAlert}
onChange={() => {
this.setState({acceptedNoisyAlert: !acceptedNoisyAlert});
if (!acceptedNoisyAlert) {
trackAnalytics('alert_builder.noisy_warning_agreed', {
organization: this.props.organization,
});
}
}}
disabled={disabled}
/>
{t('Yes, I don’t mind if this alert gets noisy')}
</AcknowledgeLabel>
</AcknowledgeField>
</Alert>
);
}
renderIdBadge(project: Project) {
return (
<IdBadge
project={project}
avatarProps={{consistentWidth: true}}
avatarSize={18}
disableLink
hideName
/>
);
}
renderEnvironmentSelect(disabled: boolean) {
const {environments, rule} = this.state;
const environmentOptions = [
{
value: ALL_ENVIRONMENTS_KEY,
label: t('All Environments'),
},
...(environments?.map(env => ({value: env.name, label: getDisplayName(env)})) ??
[]),
];
const environment =
!rule || !rule.environment ? ALL_ENVIRONMENTS_KEY : rule.environment;
return (
<FormField
name="environment"
inline={false}
style={{padding: 0, border: 'none'}}
flexibleControlStateSize
className={this.hasError('environment') ? ' error' : ''}
required
disabled={disabled}
>
{({onChange, onBlur}) => (
<SelectControl
clearable={false}
disabled={disabled}
value={environment}
options={environmentOptions}
onChange={({value}) => {
this.handleEnvironmentChange(value);
onChange(value, {});
onBlur(value, {});
}}
/>
)}
</FormField>
);
}
renderProjectSelect(disabled: boolean) {
const {project: _selectedProject, projects, organization} = this.props;
const {rule} = this.state;
const projectOptions = getProjectOptions({
organization,
projects,
isFormDisabled: disabled,
});
return (
<FormField
name="projectId"
inline={false}
style={{padding: 0}}
flexibleControlStateSize
>
{({onChange, onBlur, model}) => {
const selectedProject =
projects.find(({id}) => id === model.getValue('projectId')) ||
_selectedProject;
return (
<SelectControl
disabled={disabled || isSavedAlertRule(rule)}
value={selectedProject.id}
styles={{
container: (provided: {[x: string]: string | number | boolean}) => ({
...provided,
marginBottom: `${space(1)}`,
}),
}}
options={projectOptions}
onChange={({value}: {value: Project['id']}) => {
// if the current owner/team isn't part of project selected, update to the first available team
const nextSelectedProject =
projects.find(({id}) => id === value) ?? selectedProject;
const ownerId: string | undefined = model
.getValue('owner')
?.split(':')[1];
if (
ownerId &&
nextSelectedProject.teams.find(({id}) => id === ownerId) ===
undefined &&
nextSelectedProject.teams.length
) {
this.handleOwnerChange({value: nextSelectedProject.teams[0].id});
}
this.setState({project: nextSelectedProject});
onChange(value, {});
onBlur(value, {});
}}
components={{
SingleValue: containerProps => (
<components.ValueContainer {...containerProps}>
<IdBadge
project={selectedProject}
avatarProps={{consistentWidth: true}}
avatarSize={18}
disableLink
/>
</components.ValueContainer>
),
}}
/>
);
}}
</FormField>
);
}
renderActionInterval(disabled: boolean) {
const {rule} = this.state;
const {frequency} = rule || {};
return (
<FormField
name="frequency"
inline={false}
style={{padding: 0, border: 'none'}}
label={null}
help={null}
className={this.hasError('frequency') ? ' error' : ''}
required
disabled={disabled}
flexibleControlStateSize
>
{({onChange, onBlur}) => (
<SelectControl
clearable={false}
disabled={disabled}
value={`${frequency}`}
options={FREQUENCY_OPTIONS}
onChange={({value}) => {
this.handleChange('frequency', value);
onChange(value, {});
onBlur(value, {});
}}
/>
)}
</FormField>
);
}
renderBody() {
const {organization, members} = this.props;
const {
project,
rule,
detailedError,
loading,
sendingNotification,
incompatibleConditions,
incompatibleFilters,
} = this.state;
const {actions, filters, conditions, frequency} = rule || {};
const environment =
!rule || !rule.environment ? ALL_ENVIRONMENTS_KEY : rule.environment;
const canCreateAlert = hasEveryAccess(['alerts:write'], {organization, project});
const disabled = loading || !(canCreateAlert || isActiveSuperuser());
const displayDuplicateError =
detailedError?.name?.some(str => isExactDuplicateExp.test(str)) ?? false;
// Note `key` on `<Form>` below is so that on initial load, we show
// the form with a loading mask on top of it, but force a re-render by using
// a different key when we have fetched the rule so that form inputs are filled in
return (
<Main fullWidth>
<PermissionAlert access={['alerts:write']} project={project} />
<StyledForm
key={isSavedAlertRule(rule) ? rule.id : undefined}
onCancel={this.handleCancel}
onSubmit={this.handleSubmit}
initialData={{
...rule,
environment,
frequency: `${frequency}`,
projectId: project.id,
}}
submitDisabled={
disabled || incompatibleConditions !== null || incompatibleFilters !== null
}
submitLabel={t('Save Rule')}
extraButton={
isSavedAlertRule(rule) ? (
<Confirm
disabled={disabled}
priority="danger"
confirmText={t('Delete Rule')}
onConfirm={this.handleDeleteRule}
header={<h5>{t('Delete Alert Rule?')}</h5>}
message={t(
'Are you sure you want to delete "%s"? You won\'t be able to view the history of this alert once it\'s deleted.',
rule.name
)}
>
<Button priority="danger">{t('Delete Rule')}</Button>
</Confirm>
) : null
}
>
<List symbol="colored-numeric">
{loading && <SemiTransparentLoadingMask data-test-id="loading-mask" />}
<StyledListItem>
<StepHeader>{t('Select an environment and project')}</StepHeader>
</StyledListItem>
<ContentIndent>
<SettingsContainer>
{this.renderEnvironmentSelect(disabled)}
{this.renderProjectSelect(disabled)}
</SettingsContainer>
</ContentIndent>
<SetConditionsListItem>
<StepHeader>{t('Set conditions')}</StepHeader>
<SetupAlertIntegrationButton
projectSlug={project.slug}
organization={organization}
/>
</SetConditionsListItem>
<ContentIndent>
<ConditionsPanel>
<PanelBody>
<Step>
<StepConnector />
<StepContainer>
<ChevronContainer>
<IconChevron
color="gray200"
isCircled
direction="right"
size="sm"
/>
</ChevronContainer>
<StepContent>
<StepLead>
{tct(
'[when:When] an event is captured by Sentinel and [selector] of the following happens',
{
when: <Badge />,
selector: (
<EmbeddedWrapper>
<EmbeddedSelectField
className={classNames({
error: this.hasError('actionMatch'),
})}
styles={{
control: provided => ({
...provided,
minHeight: '21px',
height: '21px',
}),
}}
inline={false}
isSearchable={false}
isClearable={false}
name="actionMatch"
required
flexibleControlStateSize
options={ACTION_MATCH_OPTIONS_MIGRATED}
onChange={val =>
this.handleChange('actionMatch', val)
}
size="xs"
disabled={disabled}
/>
</EmbeddedWrapper>
),
}
)}
</StepLead>
<RuleNodeList
nodes={this.getConditions()}
items={conditions ?? []}
selectType="grouped"
placeholder={t('Add optional trigger...')}
onPropertyChange={this.handleChangeConditionProperty}
onAddRow={this.handleAddCondition}
onResetRow={this.handleResetCondition}
onDeleteRow={this.handleDeleteCondition}
organization={organization}
project={project}
disabled={disabled}
error={
this.hasError('conditions') && (
<StyledAlert type="error">
{detailedError?.conditions[0]}
{(detailedError?.conditions[0] || '').startsWith(
'You may not exceed'
) && (
<Fragment>
{' '}
<ExternalLink href="https://docs.sentry.io/product/alerts/create-alerts/#alert-limits">
{t('View Docs')}
</ExternalLink>
</Fragment>
)}
</StyledAlert>
)
}
incompatibleRules={incompatibleConditions}
incompatibleBanner={
incompatibleFilters === null &&
incompatibleConditions !== null
? incompatibleConditions.at(-1)
: null
}
/>
</StepContent>
</StepContainer>
</Step>
<Step>
<StepConnector />
<StepContainer>
<ChevronContainer>
<IconChevron
color="gray200"
isCircled
direction="right"
size="sm"
/>
</ChevronContainer>
<StepContent data-test-id="rule-filters">
<StepLead>
{tct('[if:If][selector] of these filters match', {
if: <Badge />,
selector: (
<EmbeddedWrapper>
<EmbeddedSelectField
className={classNames({
error: this.hasError('filterMatch'),
})}
styles={{
control: provided => ({
...provided,
minHeight: '21px',
height: '21px',
}),
}}
inline={false}
isSearchable={false}
isClearable={false}
name="filterMatch"
required
flexibleControlStateSize
options={ACTION_MATCH_OPTIONS}
onChange={val => this.handleChange('filterMatch', val)}
size="xs"
disabled={disabled}
/>
</EmbeddedWrapper>
),
})}
</StepLead>
<RuleNodeList
nodes={this.state.configs?.filters ?? null}
items={filters ?? []}
placeholder={t('Add optional filter...')}
onPropertyChange={this.handleChangeFilterProperty}
onAddRow={this.handleAddFilter}
onResetRow={this.handleResetFilter}
onDeleteRow={this.handleDeleteFilter}
organization={organization}
project={project}
disabled={disabled}
error={
this.hasError('filters') && (
<StyledAlert type="error">
{detailedError?.filters[0]}
</StyledAlert>
)
}
incompatibleRules={incompatibleFilters}
incompatibleBanner={
incompatibleFilters ? incompatibleFilters.at(-1) : null
}
/>
</StepContent>
</StepContainer>
</Step>
<Step>
<StepContainer>
<ChevronContainer>
<IconChevron
isCircled
color="gray200"
direction="right"
size="sm"
/>
</ChevronContainer>
<StepContent>
<StepLead>
{tct('[then:Then] perform these actions', {
then: <Badge />,
})}
</StepLead>
<RuleNodeList
nodes={this.state.configs?.actions ?? null}
selectType="grouped"
items={actions ?? []}
placeholder={t('Add action...')}
onPropertyChange={this.handleChangeActionProperty}
onAddRow={this.handleAddAction}
onResetRow={this.handleResetAction}
onDeleteRow={this.handleDeleteAction}
organization={organization}
project={project}
disabled={disabled}
error={
this.hasError('actions') && (
<StyledAlert type="error">
{detailedError?.actions[0]}
</StyledAlert>
)
}
/>
<TestButtonWrapper>
<Button
onClick={this.testNotifications}
disabled={sendingNotification || rule?.actions?.length === 0}
>
{t('Send Test Notification')}
</Button>
</TestButtonWrapper>
</StepContent>
</StepContainer>
</Step>
</PanelBody>
</ConditionsPanel>
</ContentIndent>
<StyledListItem>
<StepHeader>{t('Set action interval')}</StepHeader>
<StyledFieldHelp>
{t('Perform the actions above once this often for an issue')}
</StyledFieldHelp>
</StyledListItem>
<ContentIndent>{this.renderActionInterval(disabled)}</ContentIndent>
<ErrorBoundary mini>
<PreviewIssues members={members} rule={rule} project={project} />
</ErrorBoundary>
<StyledListItem>
<StepHeader>{t('Add a name and owner')}</StepHeader>
<StyledFieldHelp>
{t(
'This name will show up in notifications and the owner will give permissions to your whole team to edit and view this alert.'
)}
</StyledFieldHelp>
</StyledListItem>
<ContentIndent>
<StyledFieldWrapper>
{this.renderRuleName(disabled)}
{this.renderTeamSelect(disabled)}
</StyledFieldWrapper>
{displayDuplicateError && this.renderDuplicateErrorAlert()}
{this.displayNoConditionsWarning() &&
this.renderAcknowledgeNoConditions(disabled)}
</ContentIndent>
</List>
</StyledForm>
</Main>
);
}
}
export default withOrganization(withProjects(IssueRuleEditor));
export const findIncompatibleRules = (
rule: IssueAlertRule | UnsavedIssueAlertRule | null | undefined
): IncompatibleRule => {
if (!rule) {
return {conditionIndices: null, filterIndices: null};
}
const {conditions, filters} = rule;
// Check for more than one 'issue state change' condition
// or 'FirstSeenEventCondition' + 'EventFrequencyCondition'
if (rule.actionMatch === 'all') {
let firstSeen = -1;
let regression = -1;
let reappeared = -1;
let eventFrequency = -1;
let userFrequency = -1;
for (let i = 0; i < conditions.length; i++) {
const id = conditions[i].id;
if (id === IssueAlertConditionType.FIRST_SEEN_EVENT) {
firstSeen = i;
} else if (id === IssueAlertConditionType.REGRESSION_EVENT) {
regression = i;
} else if (id === IssueAlertConditionType.REAPPEARED_EVENT) {
reappeared = i;
} else if (
id === IssueAlertConditionType.EVENT_FREQUENCY &&
(conditions[i].value as number) >= 1
) {
eventFrequency = i;
} else if (
id === IssueAlertConditionType.EVENT_UNIQUE_USER_FREQUENCY &&
(conditions[i].value as number) >= 1
) {
userFrequency = i;
}
// FirstSeenEventCondition is incompatible with all the following types
const firstSeenError =
firstSeen !== -1 &&
[regression, reappeared, eventFrequency, userFrequency].some(idx => idx !== -1);
const regressionReappearedError = regression !== -1 && reappeared !== -1;
if (firstSeenError || regressionReappearedError) {
const indices = [firstSeen, regression, reappeared, eventFrequency, userFrequency]
.filter(idx => idx !== -1)
.sort((a, b) => a - b);
return {conditionIndices: indices, filterIndices: null};
}
}
}
// Check for 'FirstSeenEventCondition' and ('IssueOccurrencesFilter' or 'AgeComparisonFilter')
// Considers the case where filterMatch is 'any' and all filters are incompatible
const firstSeen = conditions.findIndex(condition =>
condition.id.endsWith('FirstSeenEventCondition')
);
if (firstSeen !== -1 && (rule.actionMatch === 'all' || conditions.length === 1)) {
let incompatibleFilters = 0;
for (let i = 0; i < filters.length; i++) {
const filter = filters[i];
const id = filter.id;
if (id === IssueAlertFilterType.ISSUE_OCCURRENCES && filter) {
if (
(rule.filterMatch === 'all' && (filter.value as number) > 1) ||
(rule.filterMatch === 'none' && (filter.value as number) <= 1)
) {
return {conditionIndices: [firstSeen], filterIndices: [i]};
}
if (rule.filterMatch === 'any' && (filter.value as number) > 1) {
incompatibleFilters += 1;
}
} else if (id === IssueAlertFilterType.AGE_COMPARISON) {
if (rule.filterMatch !== 'none') {
if (filter.comparison_type === 'older') {
if (rule.filterMatch === 'all') {
return {conditionIndices: [firstSeen], filterIndices: [i]};
}
incompatibleFilters += 1;
}
} else if (filter.comparison_type === 'newer' && (filter.value as number) > 0) {
return {conditionIndices: [firstSeen], filterIndices: [i]};
}
}
}
if (incompatibleFilters === filters.length && incompatibleFilters > 0) {
return {
conditionIndices: [firstSeen],
filterIndices: [...Array(filters.length).keys()],
};
}
}
return {conditionIndices: null, filterIndices: null};
};
const Main = styled(Layout.Main)`
max-width: 1000px;
`;
// TODO(ts): Understand why styled is not correctly inheriting props here
const StyledForm = styled(Form)<FormProps>`
position: relative;
`;
const ConditionsPanel = styled(Panel)`
padding-top: ${space(0.5)};
padding-bottom: ${space(2)};
`;
const StyledAlert = styled(Alert)`
margin-bottom: 0;
`;
const StyledListItem = styled(ListItem)`
margin: ${space(2)} 0 ${space(1)} 0;
font-size: ${p => p.theme.fontSizeExtraLarge};
`;
const StyledFieldHelp = styled(FieldHelp)`
margin-top: 0;
@media (max-width: ${p => p.theme.breakpoints.small}) {
margin-left: -${space(4)};
}
`;
const SetConditionsListItem = styled(StyledListItem)`
display: flex;
justify-content: space-between;
`;
const Step = styled('div')`
position: relative;
display: flex;
align-items: flex-start;
margin: ${space(4)} ${space(4)} ${space(3)} ${space(1)};
`;
const StepHeader = styled('h5')`
margin-bottom: ${space(1)};
`;
const StepContainer = styled('div')`
position: relative;
display: flex;
align-items: flex-start;
flex-grow: 1;
`;
const StepContent = styled('div')`
flex-grow: 1;
`;
const StepConnector = styled('div')`
position: absolute;
height: 100%;
top: 28px;
left: 19px;
border-right: 1px ${p => p.theme.gray200} dashed;
`;
const StepLead = styled('div')`
margin-bottom: ${space(0.5)};
display: flex;
align-items: center;
gap: ${space(0.5)};
`;
const TestButtonWrapper = styled('div')`
margin-top: ${space(1.5)};
`;
const ChevronContainer = styled('div')`
display: flex;
align-items: center;
padding: ${space(0.5)} ${space(1.5)};
`;
const Badge = styled('span')`
min-width: 56px;
background-color: ${p => p.theme.purple300};
padding: 0 ${space(0.75)};
border-radius: ${p => p.theme.borderRadius};
color: ${p => p.theme.white};
text-transform: uppercase;
text-align: center;
font-size: ${p => p.theme.fontSizeMedium};
font-weight: 600;
line-height: 1.5;
`;
const EmbeddedWrapper = styled('div')`
width: 80px;
`;
const EmbeddedSelectField = styled(SelectField)`
padding: 0;
font-weight: normal;
text-transform: none;
`;
const SemiTransparentLoadingMask = styled(LoadingMask)`
opacity: 0.6;
z-index: 1; /* Needed so that it sits above form elements */
`;
const SettingsContainer = styled('div')`
display: grid;
grid-template-columns: 1fr 1fr;
gap: ${space(1)};
`;
const StyledField = styled(FieldGroup)`
border-bottom: none;
padding: 0;
& > div {
padding: 0;
width: 100%;
}
margin-bottom: ${space(1)};
`;
const StyledFieldWrapper = styled('div')`
@media (min-width: ${p => p.theme.breakpoints.small}) {
display: grid;
grid-template-columns: 2fr 1fr;
gap: ${space(1)};
}
`;
const ContentIndent = styled('div')`
@media (min-width: ${p => p.theme.breakpoints.small}) {
margin-left: ${space(4)};
}
`;
const AcknowledgeLabel = styled('label')`
display: flex;
align-items: center;
gap: ${space(1)};
line-height: 2;
font-weight: normal;
`;
const AcknowledgeField = styled(FieldGroup)`
padding: 0;
display: flex;
align-items: center;
margin-top: ${space(1)};
& > div {
padding-left: 0;
display: flex;
align-items: baseline;
flex: unset;
gap: ${space(1)};
}
`;