sentryFunctionDetails.tsx 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. import React, {useEffect, useRef, useState} from 'react';
  2. import {browserHistory} from 'react-router';
  3. import Editor from '@monaco-editor/react';
  4. import {
  5. addErrorMessage,
  6. addLoadingMessage,
  7. addSuccessMessage,
  8. } from 'sentry/actionCreators/indicator';
  9. import Feature from 'sentry/components/acl/feature';
  10. import AsyncComponent from 'sentry/components/asyncComponent';
  11. import Button from 'sentry/components/button';
  12. import Form from 'sentry/components/forms/form';
  13. import JsonForm from 'sentry/components/forms/jsonForm';
  14. import FormModel from 'sentry/components/forms/model';
  15. import {Field} from 'sentry/components/forms/type';
  16. import {Panel, PanelBody, PanelHeader} from 'sentry/components/panels';
  17. import {t, tct} from 'sentry/locale';
  18. import {SentryFunction} from 'sentry/types';
  19. import useApi from 'sentry/utils/useApi';
  20. import SentryFunctionEnvironmentVariables from './sentryFunctionsEnvironmentVariables';
  21. import SentryFunctionSubscriptions from './sentryFunctionSubscriptions';
  22. class SentryFunctionFormModel extends FormModel {
  23. getTransformedData() {
  24. const data = super.getTransformedData() as Record<string, any>;
  25. const events: string[] = [];
  26. if (data.onIssue) {
  27. events.push('issue');
  28. }
  29. if (data.onError) {
  30. events.push('error');
  31. }
  32. if (data.onComment) {
  33. events.push('comment');
  34. }
  35. delete data.onIssue;
  36. delete data.onError;
  37. delete data.onComment;
  38. data.events = events;
  39. const envVariables: EnvVariable[] = [];
  40. let i = 0;
  41. while (data[`env-variable-name-${i}`]) {
  42. if (data[`env-variable-value-${i}`]) {
  43. envVariables.push({
  44. name: data[`env-variable-name-${i}`],
  45. value: data[`env-variable-value-${i}`],
  46. });
  47. }
  48. delete data[`env-variable-name-${i}`];
  49. delete data[`env-variable-value-${i}`];
  50. i++;
  51. }
  52. data.envVariables = envVariables;
  53. const {...output} = data;
  54. return output;
  55. }
  56. }
  57. type Props = {
  58. sentryFunction?: SentryFunction;
  59. } & WrapperProps;
  60. type EnvVariable = {
  61. name: string;
  62. value: string;
  63. };
  64. const formFields: Field[] = [
  65. {
  66. name: 'name',
  67. type: 'string',
  68. required: true,
  69. placeholder: 'e.g. My Sentry Function',
  70. label: 'Name',
  71. help: 'Human readable name of your Sentry Function',
  72. },
  73. {
  74. name: 'author',
  75. type: 'string',
  76. placeholder: 'e.g. Acme Software',
  77. label: 'Author',
  78. help: 'The company or person who built and maintains this Sentry Function.',
  79. },
  80. {
  81. name: 'overview',
  82. type: 'string',
  83. placeholder: 'e.g. This Sentry Function does something useful',
  84. label: 'Overview',
  85. help: 'A short description of your Sentry Function.',
  86. },
  87. ];
  88. function SentryFunctionDetails(props: Props) {
  89. const api = useApi();
  90. const form = useRef(new SentryFunctionFormModel());
  91. const {orgId, functionSlug} = props.params;
  92. const {sentryFunction} = props;
  93. const method = functionSlug ? 'PUT' : 'POST';
  94. let endpoint = `/organizations/${orgId}/functions/`;
  95. if (functionSlug) {
  96. endpoint += `${functionSlug}/`;
  97. }
  98. const defaultCode = sentryFunction
  99. ? sentryFunction.code
  100. : `exports.yourFunction = (req, res) => {
  101. let message = req.query.message || req.body.message || 'Hello World!';
  102. console.log('Query: ' + req.query);
  103. console.log('Body: ' + req.body);
  104. res.status(200).send(message);
  105. };`;
  106. const [events, setEvents] = useState(sentryFunction?.events || []);
  107. useEffect(() => {
  108. form.current.setValue('onIssue', events.includes('issue'));
  109. form.current.setValue('onError', events.includes('error'));
  110. form.current.setValue('onComment', events.includes('comment'));
  111. }, [events]);
  112. const [envVariables, setEnvVariables] = useState(
  113. sentryFunction?.env_variables || [{name: '', value: ''}]
  114. );
  115. const handleSubmitError = err => {
  116. let errorMessage = t('Unknown Error');
  117. if (err.status >= 400 && err.status < 500) {
  118. errorMessage = err?.responseJSON.detail ?? errorMessage;
  119. }
  120. addErrorMessage(errorMessage);
  121. };
  122. const handleSubmitSuccess = data => {
  123. addSuccessMessage(t('Sentry Function successfully saved.', data.name));
  124. const baseUrl = `/settings/${orgId}/developer-settings/sentry-functions/`;
  125. // TODO: should figure out where to redirect this
  126. const url = `${baseUrl}${data.slug}/`;
  127. if (sentryFunction) {
  128. addSuccessMessage(t('%s successfully saved.', data.name));
  129. } else {
  130. addSuccessMessage(t('%s successfully created.', data.name));
  131. }
  132. browserHistory.push(url);
  133. };
  134. function handleEditorChange(value, _event) {
  135. form.current.setValue('code', value);
  136. }
  137. async function handleDelete() {
  138. try {
  139. await api.requestPromise(endpoint, {
  140. method: 'DELETE',
  141. });
  142. addSuccessMessage(t('Sentry Function successfully deleted.'));
  143. // TODO: Not sure where to redirect to, so just redirect to the unbuilt Sentry Functions page
  144. browserHistory.push(`/settings/${orgId}/developer-settings/sentry-functions/`);
  145. } catch (err) {
  146. addErrorMessage(err?.responseJSON?.detail || t('Unknown Error'));
  147. }
  148. }
  149. return (
  150. <div>
  151. <Feature features={['organizations:sentry-functions']}>
  152. <h1>{t('Sentry Function Details')}</h1>
  153. <h2>
  154. {sentryFunction
  155. ? tct('Editing [name]', {name: sentryFunction.name})
  156. : t('New Function')}
  157. </h2>
  158. <Form
  159. apiMethod={method}
  160. apiEndpoint={endpoint}
  161. model={form.current}
  162. onPreSubmit={() => {
  163. addLoadingMessage(t('Saving changes..'));
  164. }}
  165. initialData={{
  166. code: defaultCode,
  167. events,
  168. envVariables,
  169. ...props.sentryFunction,
  170. }}
  171. onSubmitError={handleSubmitError}
  172. onSubmitSuccess={handleSubmitSuccess}
  173. >
  174. <JsonForm forms={[{title: t('Sentry Function Details'), fields: formFields}]} />
  175. <Panel>
  176. <PanelHeader>{t('Webhooks')}</PanelHeader>
  177. <PanelBody>
  178. <SentryFunctionSubscriptions events={events} setEvents={setEvents} />
  179. </PanelBody>
  180. </Panel>
  181. <Panel>
  182. <SentryFunctionEnvironmentVariables
  183. envVariables={envVariables}
  184. setEnvVariables={setEnvVariables}
  185. />
  186. </Panel>
  187. <Panel>
  188. <PanelHeader>{t('Write your Code Below')}</PanelHeader>
  189. <PanelBody>
  190. <Editor
  191. height="40vh"
  192. theme="light"
  193. defaultLanguage="javascript"
  194. defaultValue={defaultCode}
  195. onChange={handleEditorChange}
  196. options={{
  197. minimap: {
  198. enabled: false,
  199. },
  200. scrollBeyondLastLine: false,
  201. }}
  202. />
  203. </PanelBody>
  204. </Panel>
  205. </Form>
  206. {sentryFunction && (
  207. <Button
  208. onClick={handleDelete}
  209. title={t('Delete Sentry Function')}
  210. aria-label={t('Delete Sentry Function')}
  211. type="button"
  212. priority="danger"
  213. >
  214. {t('Delete Sentry Function')}
  215. </Button>
  216. )}
  217. </Feature>
  218. </div>
  219. );
  220. }
  221. type WrapperState = {
  222. sentryFunction?: SentryFunction;
  223. } & AsyncComponent['state'];
  224. type WrapperProps = {
  225. params: {orgId: string; functionSlug?: string};
  226. } & AsyncComponent['props'];
  227. class SentryFunctionsWrapper extends AsyncComponent<WrapperProps, WrapperState> {
  228. getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
  229. const {functionSlug, orgId} = this.props.params;
  230. if (functionSlug) {
  231. return [['sentryFunction', `/organizations/${orgId}/functions/${functionSlug}/`]];
  232. }
  233. return [];
  234. }
  235. renderBody() {
  236. return (
  237. <SentryFunctionDetails sentryFunction={this.state.sentryFunction} {...this.props} />
  238. );
  239. }
  240. }
  241. export default SentryFunctionsWrapper;