Files
2024-02-15 17:01:50 +00:00

524 lines
17 KiB
TypeScript

import {Fragment, PureComponent} from 'react';
import styled from '@emotion/styled';
import * as Sentry from '@sentry/react';
import {addErrorMessage} from 'sentry/actionCreators/indicator';
import {openModal} from 'sentry/actionCreators/modal';
import {Alert} from 'sentry/components/alert';
import {Button} from 'sentry/components/button';
import SelectControl from 'sentry/components/forms/controls/selectControl';
import ListItem from 'sentry/components/list/listItem';
import LoadingIndicator from 'sentry/components/loadingIndicator';
import PanelItem from 'sentry/components/panels/panelItem';
import {IconAdd, IconSettings} from 'sentry/icons';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {Organization, Project, SelectValue} from 'sentry/types';
import {uniqueId} from 'sentry/utils/guid';
import {removeAtArrayIndex} from 'sentry/utils/removeAtArrayIndex';
import {replaceAtArrayIndex} from 'sentry/utils/replaceAtArrayIndex';
import withOrganization from 'sentry/utils/withOrganization';
import SentryAppRuleModal from 'sentry/views/alerts/rules/issue/sentryAppRuleModal';
import ActionSpecificTargetSelector from 'sentry/views/alerts/rules/metric/triggers/actionsPanel/actionSpecificTargetSelector';
import ActionTargetSelector from 'sentry/views/alerts/rules/metric/triggers/actionsPanel/actionTargetSelector';
import DeleteActionButton from 'sentry/views/alerts/rules/metric/triggers/actionsPanel/deleteActionButton';
import type {
Action,
ActionType,
MetricActionTemplate,
Trigger,
} from 'sentry/views/alerts/rules/metric/types';
import {ActionLabel, TargetLabel} from 'sentry/views/alerts/rules/metric/types';
type Props = {
availableActions: MetricActionTemplate[] | null;
currentProject: string;
disabled: boolean;
error: boolean;
loading: boolean;
onAdd: (triggerIndex: number, action: Action) => void;
onChange: (triggerIndex: number, triggers: Trigger[], actions: Action[]) => void;
organization: Organization;
projects: Project[];
triggers: Trigger[];
className?: string;
};
/**
* When a new action is added, all of it's settings should be set to their default values.
* @param actionConfig
* @param dateCreated kept to maintain order of unsaved actions
*/
const getCleanAction = (actionConfig, dateCreated?: string): Action => {
return {
unsavedId: uniqueId(),
unsavedDateCreated: dateCreated ?? new Date().toISOString(),
type: actionConfig.type,
targetType:
actionConfig?.allowedTargetTypes && actionConfig.allowedTargetTypes.length > 0
? actionConfig.allowedTargetTypes[0]
: null,
targetIdentifier: actionConfig.sentryAppId || '',
inputChannelId: null,
integrationId: actionConfig.integrationId,
sentryAppId: actionConfig.sentryAppId,
options: actionConfig.options || null,
};
};
/**
* Actions have a type (e.g. email, slack, etc), but only some have
* an integrationId (e.g. email is null). This helper creates a unique
* id based on the type and integrationId so that we know what action
* a user's saved action corresponds to.
*/
const getActionUniqueKey = ({
type,
integrationId,
sentryAppId,
}: Pick<Action, 'type' | 'integrationId' | 'sentryAppId'>) => {
if (integrationId) {
return `${type}-${integrationId}`;
}
if (sentryAppId) {
return `${type}-${sentryAppId}`;
}
return type;
};
/**
* Creates a human-friendly display name for the integration based on type and
* server provided `integrationName`
*
* e.g. for slack we show that it is slack and the `integrationName` is the workspace name
*/
const getFullActionTitle = ({
type,
integrationName,
sentryAppName,
status,
}: Pick<
MetricActionTemplate,
'type' | 'integrationName' | 'sentryAppName' | 'status'
>) => {
if (sentryAppName) {
if (status && status !== 'published') {
return `${sentryAppName} (${status})`;
}
return `${sentryAppName}`;
}
const label = ActionLabel[type];
if (integrationName) {
return `${label} - ${integrationName}`;
}
return label;
};
/**
* Lists saved actions as well as control to add a new action
*/
class ActionsPanel extends PureComponent<Props> {
handleChangeKey(
triggerIndex: number,
index: number,
key: 'targetIdentifier' | 'inputChannelId',
value: string
) {
const {triggers, onChange} = this.props;
const {actions} = triggers[triggerIndex];
const newAction = {
...actions[index],
[key]: value,
};
onChange(triggerIndex, triggers, replaceAtArrayIndex(actions, index, newAction));
}
conditionallyRenderHelpfulBanner(triggerIndex: number, index: number) {
const {triggers} = this.props;
const {actions} = triggers[triggerIndex];
const newAction = {...actions[index]};
if (newAction.type === 'slack') {
return (
<MarginlessAlert
type="info"
showIcon
trailingItems={
<Button
href="https://docs.sentry.io/product/integrations/notification-incidents/slack/#rate-limiting-error"
external
size="xs"
>
{t('Learn More')}
</Button>
}
>
{t('Having rate limiting problems? Enter a channel or user ID.')}
</MarginlessAlert>
);
}
if (newAction.type === 'discord') {
return (
<MarginlessAlert
type="info"
showIcon
trailingItems={
<Button
href="https://docs.sentry.io/product/accounts/early-adopter-features/discord/#issue-alerts"
external
size="xs"
>
{t('Learn More')}
</Button>
}
>
{t('Note that you must enter a Discord channel ID, not a channel name.')}
</MarginlessAlert>
);
}
return null;
}
handleAddAction = () => {
const {availableActions, onAdd} = this.props;
const actionConfig = availableActions?.[0];
if (!actionConfig) {
addErrorMessage(t('There was a problem adding an action'));
Sentry.captureException(new Error('Unable to add an action'));
return;
}
const action: Action = getCleanAction(actionConfig);
// Add new actions to critical by default
const triggerIndex = 0;
onAdd(triggerIndex, action);
};
handleDeleteAction = (triggerIndex: number, index: number) => {
const {triggers, onChange} = this.props;
const {actions} = triggers[triggerIndex];
onChange(triggerIndex, triggers, removeAtArrayIndex(actions, index));
};
handleChangeActionLevel = (
triggerIndex: number,
index: number,
value: SelectValue<number>
) => {
const {triggers, onChange} = this.props;
// Convert saved action to unsaved by removing id
const {id: _, ...action} = triggers[triggerIndex].actions[index];
action.unsavedId = uniqueId();
triggers[value.value].actions.push(action);
onChange(value.value, triggers, triggers[value.value].actions);
this.handleDeleteAction(triggerIndex, index);
};
handleChangeActionType = (
triggerIndex: number,
index: number,
value: SelectValue<ActionType>
) => {
const {triggers, onChange, availableActions} = this.props;
const {actions} = triggers[triggerIndex];
const actionConfig = availableActions?.find(
availableAction => getActionUniqueKey(availableAction) === value.value
);
if (!actionConfig) {
addErrorMessage(t('There was a problem changing an action'));
Sentry.captureException(new Error('Unable to change an action type'));
return;
}
const existingDateCreated =
actions[index].dateCreated ?? actions[index].unsavedDateCreated;
const newAction: Action = getCleanAction(actionConfig, existingDateCreated);
onChange(triggerIndex, triggers, replaceAtArrayIndex(actions, index, newAction));
};
handleChangeTarget = (
triggerIndex: number,
index: number,
value: SelectValue<keyof typeof TargetLabel>
) => {
const {triggers, onChange} = this.props;
const {actions} = triggers[triggerIndex];
const newAction = {
...actions[index],
targetType: value.value,
targetIdentifier: '',
};
onChange(triggerIndex, triggers, replaceAtArrayIndex(actions, index, newAction));
};
/**
* Update the Trigger's Action fields from the SentryAppRuleModal together
* only after the user clicks "Save Changes".
* @param formData Form data
*/
updateParentFromSentryAppRule = (
triggerIndex: number,
actionIndex: number,
formData: {[key: string]: string}
): void => {
const {triggers, onChange} = this.props;
const {actions} = triggers[triggerIndex];
const newAction = {
...actions[actionIndex],
...formData,
};
onChange(
triggerIndex,
triggers,
replaceAtArrayIndex(actions, actionIndex, newAction)
);
};
render() {
const {
availableActions,
currentProject,
disabled,
loading,
organization,
projects,
triggers,
} = this.props;
const project = projects.find(({slug}) => slug === currentProject);
const items = availableActions?.map(availableAction => ({
value: getActionUniqueKey(availableAction),
label: getFullActionTitle(availableAction),
}));
const levels = [
{value: 0, label: 'Critical Status'},
{value: 1, label: 'Warning Status'},
];
// Create single array of unsaved and saved trigger actions
// Sorted by date created ascending
const actions = triggers
.flatMap((trigger, triggerIndex) => {
return trigger.actions.map((action, actionIdx) => {
const availableAction = availableActions?.find(
a => getActionUniqueKey(a) === getActionUniqueKey(action)
);
return {
dateCreated: new Date(
action.dateCreated ?? action.unsavedDateCreated
).getTime(),
triggerIndex,
action,
actionIdx,
availableAction,
};
});
})
.sort((a, b) => a.dateCreated - b.dateCreated);
return (
<Fragment>
<PerformActionsListItem>{t('Set actions')}</PerformActionsListItem>
{loading && <LoadingIndicator />}
{actions.map(({action, actionIdx, triggerIndex, availableAction}) => {
const actionDisabled =
triggers[triggerIndex].actions[actionIdx]?.disabled || disabled;
return (
<div key={action.id ?? action.unsavedId}>
<RuleRowContainer>
<PanelItemGrid>
<PanelItemSelects>
<SelectControl
name="select-level"
aria-label={t('Select a status level')}
isDisabled={disabled || loading}
placeholder={t('Select Level')}
onChange={this.handleChangeActionLevel.bind(
this,
triggerIndex,
actionIdx
)}
value={triggerIndex}
options={levels}
/>
<SelectControl
name="select-action"
aria-label={t('Select an Action')}
isDisabled={disabled || loading}
placeholder={t('Select Action')}
onChange={this.handleChangeActionType.bind(
this,
triggerIndex,
actionIdx
)}
value={getActionUniqueKey(action)}
options={items ?? []}
/>
{availableAction && availableAction.allowedTargetTypes.length > 1 ? (
<SelectControl
isDisabled={disabled || loading}
value={action.targetType}
options={availableAction?.allowedTargetTypes?.map(
allowedType => ({
value: allowedType,
label: TargetLabel[allowedType],
})
)}
onChange={this.handleChangeTarget.bind(
this,
triggerIndex,
actionIdx
)}
/>
) : availableAction &&
availableAction.type === 'sentry_app' &&
availableAction.settings ? (
<Button
icon={<IconSettings />}
disabled={actionDisabled}
onClick={() => {
openModal(
deps => (
<SentryAppRuleModal
{...deps}
// Using ! for keys that will exist for sentryapps
sentryAppInstallationUuid={
availableAction.sentryAppInstallationUuid!
}
config={availableAction.settings!}
appName={availableAction.sentryAppName!}
onSubmitSuccess={this.updateParentFromSentryAppRule.bind(
this,
triggerIndex,
actionIdx
)}
resetValues={
triggers[triggerIndex].actions[actionIdx] || {}
}
/>
),
{closeEvents: 'escape-key'}
);
}}
>
{t('Settings')}
</Button>
) : null}
<ActionTargetSelector
action={action}
availableAction={availableAction}
disabled={disabled}
loading={loading}
onChange={this.handleChangeKey.bind(
this,
triggerIndex,
actionIdx,
'targetIdentifier'
)}
organization={organization}
project={project}
/>
<ActionSpecificTargetSelector
action={action}
disabled={disabled}
onChange={this.handleChangeKey.bind(
this,
triggerIndex,
actionIdx,
'inputChannelId'
)}
/>
</PanelItemSelects>
<DeleteActionButton
triggerIndex={triggerIndex}
index={actionIdx}
onClick={this.handleDeleteAction}
disabled={disabled}
/>
</PanelItemGrid>
</RuleRowContainer>
{this.conditionallyRenderHelpfulBanner(triggerIndex, actionIdx)}
</div>
);
})}
<ActionSection>
<Button
disabled={disabled || loading}
icon={<IconAdd isCircled color="gray300" />}
onClick={this.handleAddAction}
>
{t('Add Action')}
</Button>
</ActionSection>
</Fragment>
);
}
}
const ActionsPanelWithSpace = styled(ActionsPanel)`
margin-top: ${space(4)};
`;
const ActionSection = styled('div')`
margin-top: ${space(1)};
margin-bottom: ${space(3)};
`;
const PanelItemGrid = styled(PanelItem)`
display: flex;
align-items: center;
border-bottom: 0;
padding: ${space(1)};
`;
const PanelItemSelects = styled('div')`
display: flex;
width: 100%;
margin-right: ${space(1)};
> * {
flex: 0 1 200px;
&:not(:last-child) {
margin-right: ${space(1)};
}
}
`;
const RuleRowContainer = styled('div')`
background-color: ${p => p.theme.backgroundSecondary};
border: 1px ${p => p.theme.border} solid;
border-radius: ${p => p.theme.borderRadius} ${p => p.theme.borderRadius} 0 0;
&:last-child {
border-radius: ${p => p.theme.borderRadius};
}
`;
const StyledListItem = styled(ListItem)`
margin: ${space(2)} 0 ${space(3)} 0;
font-size: ${p => p.theme.fontSizeExtraLarge};
`;
const PerformActionsListItem = styled(StyledListItem)`
margin-bottom: 0;
line-height: 1.3;
`;
const MarginlessAlert = styled(Alert)`
border-radius: 0 0 ${p => p.theme.borderRadius} ${p => p.theme.borderRadius};
border: 1px ${p => p.theme.border} solid;
border-top-width: 0;
margin: 0;
padding: ${space(1)} ${space(1)};
font-size: ${p => p.theme.fontSizeSmall};
`;
export default withOrganization(ActionsPanelWithSpace);