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

289 lines
9.0 KiB
TypeScript

import {Component, Fragment} from 'react';
import styled from '@emotion/styled';
import debounce from 'lodash/debounce';
import * as qs from 'query-string';
import {addErrorMessage, addLoadingMessage} from 'sentry/actionCreators/indicator';
import Button from 'sentry/components/actions/button';
import SelectField from 'sentry/components/forms/fields/selectField';
import TextField from 'sentry/components/forms/fields/textField';
import List from 'sentry/components/list';
import ListItem from 'sentry/components/list/listItem';
import {t} from 'sentry/locale';
import type {Organization} from 'sentry/types';
import {uniqueId} from 'sentry/utils/guid';
import {trackIntegrationAnalytics} from 'sentry/utils/integrationUtil';
import FooterWithButtons from './components/footerWithButtons';
import HeaderWithHelp from './components/headerWithHelp';
// let the browser generate and store the external ID
// this way the same user always has the same external ID if they restart the pipeline
const ID_NAME = 'AWS_EXTERNAL_ID';
const getAwsExternalId = () => {
let awsExternalId = window.localStorage.getItem(ID_NAME);
if (!awsExternalId) {
awsExternalId = uniqueId();
window.localStorage.setItem(ID_NAME, awsExternalId);
}
return awsExternalId;
};
const accountNumberRegex = /^\d{12}$/;
const testAccountNumber = (arn: string) => accountNumberRegex.test(arn);
type Props = {
baseCloudformationUrl: string;
initialStepNumber: number;
organization: Organization;
regionList: string[];
stackName: string;
templateUrl: string;
accountNumber?: string;
awsExternalId?: string;
error?: string;
region?: string;
};
type State = {
accountNumber?: string;
accountNumberError?: string;
awsExternalId?: string;
region?: string;
showInputs?: boolean;
submitting?: boolean;
};
export default class AwsLambdaCloudformation extends Component<Props, State> {
state: State = {
accountNumber: this.props.accountNumber,
region: this.props.region,
awsExternalId: this.props.awsExternalId ?? getAwsExternalId(),
showInputs: !!this.props.awsExternalId,
};
componentDidMount() {
// show the error if we have it
const {error} = this.props;
if (error) {
addErrorMessage(error, {duration: 10000});
}
}
get initialData() {
const {region, accountNumber} = this.props;
const {awsExternalId} = this.state;
return {
awsExternalId,
region,
accountNumber,
};
}
get cloudformationUrl() {
// generate the cloudformation URL using the params we get from the server
// and the external id we generate
const {baseCloudformationUrl, templateUrl, stackName} = this.props;
// always us the generated AWS External ID in local storage
const awsExternalId = getAwsExternalId();
const query = qs.stringify({
templateURL: templateUrl,
stackName,
param_ExternalId: awsExternalId,
});
return `${baseCloudformationUrl}?${query}`;
}
get regionOptions() {
return this.props.regionList.map(region => ({value: region, label: region}));
}
handleSubmit = (e: React.MouseEvent) => {
this.setState({submitting: true});
e.preventDefault();
// use the external ID from the form on on the submission
const {accountNumber, region, awsExternalId} = this.state;
const data = {
accountNumber,
region,
awsExternalId,
};
addLoadingMessage(t('Submitting\u2026'));
const {
location: {origin},
} = window;
// redirect to the extensions endpoint with the form fields as query params
// this is needed so we don't restart the pipeline loading from the original
// OrganizationIntegrationSetupView route
const newUrl = `${origin}/extensions/aws_lambda/setup/?${qs.stringify(data)}`;
window.location.assign(newUrl);
};
validateAccountNumber = (value: string) => {
// validate the account number
let accountNumberError = '';
if (!value) {
accountNumberError = t('Account ID required');
} else if (!testAccountNumber(value)) {
accountNumberError = t('Invalid Account ID');
}
this.setState({accountNumberError});
};
handleChangeArn = (accountNumber: string) => {
this.debouncedTrackValueChanged('accountNumber');
// reset the error if we ever get a valid account number
if (testAccountNumber(accountNumber)) {
this.setState({accountNumberError: ''});
}
this.setState({accountNumber});
};
handleChangeRegion = (region: string) => {
this.debouncedTrackValueChanged('region');
this.setState({region});
};
handleChangeExternalId = (awsExternalId: string) => {
this.debouncedTrackValueChanged('awsExternalId');
awsExternalId = awsExternalId.trim();
this.setState({awsExternalId});
};
handleChangeShowInputs = () => {
this.setState({showInputs: true});
trackIntegrationAnalytics('integrations.installation_input_value_changed', {
integration: 'aws_lambda',
integration_type: 'first_party',
field_name: 'showInputs',
organization: this.props.organization,
});
};
get formValid() {
const {accountNumber, region, awsExternalId} = this.state;
return !!region && testAccountNumber(accountNumber || '') && !!awsExternalId;
}
// debounce so we don't send a request on every input change
debouncedTrackValueChanged = debounce((fieldName: string) => {
trackIntegrationAnalytics('integrations.installation_input_value_changed', {
integration: 'aws_lambda',
integration_type: 'first_party',
field_name: fieldName,
organization: this.props.organization,
});
}, 200);
trackOpenCloudFormation = () => {
trackIntegrationAnalytics('integrations.cloudformation_link_clicked', {
integration: 'aws_lambda',
integration_type: 'first_party',
organization: this.props.organization,
});
};
render() {
const {initialStepNumber} = this.props;
const {
accountNumber,
region,
accountNumberError,
submitting,
awsExternalId,
showInputs,
} = this.state;
return (
<Fragment>
<HeaderWithHelp docsUrl="https://docs.sentry.io/product/integrations/cloud-monitoring/aws-lambda/" />
<StyledList symbol="colored-numeric" initialCounterValue={initialStepNumber}>
<ListItem>
<h3>{t("Add Sentry's CloudFormation")}</h3>
<StyledButton
priority="primary"
onClick={this.trackOpenCloudFormation}
external
href={this.cloudformationUrl}
>
{t('Go to AWS')}
</StyledButton>
{!showInputs && (
<Fragment>
<p>
{t(
"Once you've created Sentry's CloudFormation stack (or if you already have one) press the button below to continue."
)}
</p>
<Button name="showInputs" onClick={this.handleChangeShowInputs}>
{t("I've created the stack")}
</Button>
</Fragment>
)}
</ListItem>
{showInputs ? (
<ListItem>
<h3>{t('Add AWS Account Information')}</h3>
<TextField
name="accountNumber"
value={accountNumber}
onChange={this.handleChangeArn}
onBlur={this.validateAccountNumber}
error={accountNumberError}
inline={false}
stacked
label={t('AWS Account ID')}
showHelpInTooltip
help={t(
'Your Account ID can be found on the right side of the header in AWS'
)}
/>
<SelectField
name="region"
value={region}
onChange={this.handleChangeRegion}
options={this.regionOptions}
allowClear={false}
inline={false}
stacked
label={t('AWS Region')}
showHelpInTooltip
help={t(
'Your current region can be found on the right side of the header in AWS'
)}
/>
<TextField
name="awsExternalId"
value={awsExternalId}
onChange={this.handleChangeExternalId}
inline={false}
stacked
error={awsExternalId ? '' : t('External ID Required')}
label={t('External ID')}
showHelpInTooltip
help={t(
'Do not edit unless you are copying from a previously created CloudFormation stack'
)}
/>
</ListItem>
) : (
<Fragment />
)}
</StyledList>
<FooterWithButtons
buttonText={t('Next')}
onClick={this.handleSubmit}
disabled={submitting || !this.formValid}
/>
</Fragment>
);
}
}
const StyledList = styled(List)`
padding: 100px 50px 50px 50px;
`;
const StyledButton = styled(Button)`
margin-bottom: 20px;
`;