123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580 |
- 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, LinkButton} 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 {SelectValue} from 'sentry/types/core';
- import type {Organization} from 'sentry/types/organization';
- import type {Project} from 'sentry/types/project';
- import removeAtArrayIndex from 'sentry/utils/array/removeAtArrayIndex';
- import replaceAtArrayIndex from 'sentry/utils/array/replaceAtArrayIndex';
- import {uniqueId} from 'sentry/utils/guid';
- 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,
- type ActionType,
- AlertRuleComparisonType,
- type MetricActionTemplate,
- type Trigger,
- } from 'sentry/views/alerts/rules/metric/types';
- import {
- ActionLabel,
- DefaultPriorities,
- PriorityOptions,
- TargetLabel,
- } from 'sentry/views/alerts/rules/metric/types';
- type Props = {
- availableActions: MetricActionTemplate[] | null;
- comparisonType: AlertRuleComparisonType;
- 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 its 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={
- <LinkButton
- href="https://docs.sentry.io/product/integrations/notification-incidents/slack/#rate-limiting-error"
- external
- size="xs"
- >
- {t('Learn More')}
- </LinkButton>
- }
- >
- {t('Having rate limiting problems? Enter a channel or user ID.')}
- </MarginlessAlert>
- );
- }
- if (newAction.type === 'discord') {
- return (
- <MarginlessAlert
- type="info"
- showIcon
- trailingItems={
- <LinkButton
- href="https://docs.sentry.io/product/accounts/early-adopter-features/discord/#issue-alerts"
- external
- size="xs"
- >
- {t('Learn More')}
- </LinkButton>
- }
- >
- {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));
- };
- handleChangePriority = (
- triggerIndex: number,
- index: number,
- value: SelectValue<keyof typeof PriorityOptions>
- ) => {
- const {triggers, onChange} = this.props;
- const {actions} = triggers[triggerIndex];
- const newAction = {
- ...actions[index],
- priority: value.value,
- };
- 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,
- comparisonType,
- } = this.props;
- const project = projects.find(({slug}) => slug === currentProject);
- const items = availableActions?.map(availableAction => ({
- value: getActionUniqueKey(availableAction),
- label: getFullActionTitle(availableAction),
- }));
- const hasPriorityFlag = organization.features.includes(
- 'integrations-custom-alert-priorities'
- );
- const levels = [
- {value: 0, label: 'Critical Status'},
- {value: 1, label: 'Warning Status'},
- ];
- // NOTE: we don't support warning triggers for anomaly detection alerts yet
- // once we do, this can be deleted
- const anomalyDetectionLevels = [{value: 0, label: 'Critical 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={
- comparisonType === AlertRuleComparisonType.DYNAMIC
- ? anomalyDetectionLevels
- : 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'
- )}
- />
- {hasPriorityFlag &&
- availableAction &&
- (availableAction.type === 'opsgenie' ||
- availableAction.type === 'pagerduty') ? (
- <SelectControl
- isDisabled={disabled || loading}
- value={action.priority}
- placeholder={
- DefaultPriorities[availableAction.type][triggerIndex]
- }
- options={PriorityOptions[availableAction.type].map(priority => ({
- value: priority,
- label: priority,
- }))}
- onChange={this.handleChangePriority.bind(
- this,
- triggerIndex,
- actionIdx
- )}
- />
- ) : null}
- </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);
|