mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-10-30 14:49:14 +00:00
524 lines
17 KiB
TypeScript
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);
|