uptimeAlertForm.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  1. import {useEffect, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {autorun} from 'mobx';
  4. import {Observer} from 'mobx-react';
  5. import Confirm from 'sentry/components/confirm';
  6. import {Alert} from 'sentry/components/core/alert';
  7. import {Button} from 'sentry/components/core/button';
  8. import {FieldWrapper} from 'sentry/components/forms/fieldGroup/fieldWrapper';
  9. import BooleanField from 'sentry/components/forms/fields/booleanField';
  10. import HiddenField from 'sentry/components/forms/fields/hiddenField';
  11. import RangeField from 'sentry/components/forms/fields/rangeField';
  12. import SelectField from 'sentry/components/forms/fields/selectField';
  13. import SentryMemberTeamSelectorField from 'sentry/components/forms/fields/sentryMemberTeamSelectorField';
  14. import SentryProjectSelectorField from 'sentry/components/forms/fields/sentryProjectSelectorField';
  15. import TextareaField from 'sentry/components/forms/fields/textareaField';
  16. import TextField from 'sentry/components/forms/fields/textField';
  17. import Form from 'sentry/components/forms/form';
  18. import FormModel from 'sentry/components/forms/model';
  19. import ExternalLink from 'sentry/components/links/externalLink';
  20. import List from 'sentry/components/list';
  21. import ListItem from 'sentry/components/list/listItem';
  22. import Panel from 'sentry/components/panels/panel';
  23. import Text from 'sentry/components/text';
  24. import {t, tct} from 'sentry/locale';
  25. import {space} from 'sentry/styles/space';
  26. import type {Organization} from 'sentry/types/organization';
  27. import type {Project} from 'sentry/types/project';
  28. import getDuration from 'sentry/utils/duration/getDuration';
  29. import {useNavigate} from 'sentry/utils/useNavigate';
  30. import useOrganization from 'sentry/utils/useOrganization';
  31. import useProjects from 'sentry/utils/useProjects';
  32. import {makeAlertsPathname} from 'sentry/views/alerts/pathnames';
  33. import type {UptimeRule} from 'sentry/views/alerts/rules/uptime/types';
  34. import {HTTPSnippet} from './httpSnippet';
  35. import {UptimeHeadersField} from './uptimeHeadersField';
  36. interface Props {
  37. organization: Organization;
  38. project: Project;
  39. handleDelete?: () => void;
  40. rule?: UptimeRule;
  41. }
  42. const HTTP_METHOD_OPTIONS = ['GET', 'POST', 'HEAD', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'];
  43. const MINUTE = 60;
  44. const VALID_INTERVALS_SEC = [
  45. MINUTE * 1,
  46. MINUTE * 5,
  47. MINUTE * 10,
  48. MINUTE * 20,
  49. MINUTE * 30,
  50. MINUTE * 60,
  51. ];
  52. function getFormDataFromRule(rule: UptimeRule) {
  53. return {
  54. name: rule.name,
  55. environment: rule.environment,
  56. url: rule.url,
  57. projectSlug: rule.projectSlug,
  58. method: rule.method,
  59. body: rule.body,
  60. headers: rule.headers,
  61. intervalSeconds: rule.intervalSeconds,
  62. timeoutMs: rule.timeoutMs,
  63. traceSampling: rule.traceSampling,
  64. owner: rule.owner ? `${rule.owner.type}:${rule.owner.id}` : null,
  65. };
  66. }
  67. export function UptimeAlertForm({project, handleDelete, rule}: Props) {
  68. const navigate = useNavigate();
  69. const organization = useOrganization();
  70. const {projects} = useProjects();
  71. const initialData = rule
  72. ? getFormDataFromRule(rule)
  73. : {projectSlug: project.slug, method: 'GET', headers: []};
  74. const [formModel] = useState(() => new FormModel());
  75. const [knownEnvironments, setEnvironments] = useState<string[]>([]);
  76. const [newEnvironment, setNewEnvironment] = useState<string | undefined>(undefined);
  77. const environments = [newEnvironment, ...knownEnvironments].filter(Boolean);
  78. // XXX(epurkhiser): The forms API endpoint is derived from the selcted
  79. // project. We don't have an easy way to interpolate this into the <Form />
  80. // components `apiEndpoint` prop, so instead we setup a mobx observer on
  81. // value of the project slug and use that to update the endpoint of the form
  82. // model
  83. useEffect(
  84. () =>
  85. autorun(() => {
  86. const projectSlug = formModel.getValue('projectSlug');
  87. const selectedProject = projects.find(p => p.slug === projectSlug);
  88. const apiEndpoint = rule
  89. ? `/projects/${organization.slug}/${projectSlug}/uptime/${rule.id}/`
  90. : `/projects/${organization.slug}/${projectSlug}/uptime/`;
  91. function onSubmitSuccess(response: any) {
  92. navigate(
  93. makeAlertsPathname({
  94. path: `/rules/uptime/${projectSlug}/${response.id}/details/`,
  95. organization,
  96. })
  97. );
  98. }
  99. formModel.setFormOptions({apiEndpoint, onSubmitSuccess});
  100. if (selectedProject) {
  101. setEnvironments(selectedProject.environments);
  102. }
  103. }),
  104. [formModel, navigate, organization, projects, rule]
  105. );
  106. return (
  107. <Form
  108. model={formModel}
  109. apiMethod={rule ? 'PUT' : 'POST'}
  110. saveOnBlur={false}
  111. initialData={initialData}
  112. submitLabel={rule ? t('Save Rule') : t('Create Rule')}
  113. extraButton={
  114. rule && handleDelete ? (
  115. <Confirm
  116. message={t(
  117. 'Are you sure you want to delete "%s"? Once deleted, this alert cannot be recreated automatically.',
  118. rule.name
  119. )}
  120. header={<h5>{t('Delete Uptime Rule?')}</h5>}
  121. priority="danger"
  122. confirmText={t('Delete Rule')}
  123. onConfirm={handleDelete}
  124. >
  125. <Button priority="danger">{t('Delete Rule')}</Button>
  126. </Confirm>
  127. ) : undefined
  128. }
  129. >
  130. <List symbol="colored-numeric">
  131. <AlertListItem>{t('Select a project and environment')}</AlertListItem>
  132. <ListItemSubText>
  133. {t(
  134. 'The selected project and environment is where Uptime Issues will be created.'
  135. )}
  136. </ListItemSubText>
  137. <FormRow>
  138. <SentryProjectSelectorField
  139. disabled={rule !== undefined}
  140. disabledReason={t('Existing uptime rules cannot be moved between projects')}
  141. name="projectSlug"
  142. label={t('Project')}
  143. placeholder={t('Choose Project')}
  144. hideLabel
  145. projects={projects}
  146. valueIsSlug
  147. inline={false}
  148. flexibleControlStateSize
  149. stacked
  150. required
  151. />
  152. <SelectField
  153. name="environment"
  154. label={t('Environment')}
  155. placeholder={t('Select an environment')}
  156. noOptionsMessage={() => t('Start typing to create an environment')}
  157. hideLabel
  158. onCreateOption={(env: any) => {
  159. setNewEnvironment(env);
  160. formModel.setValue('environment', env);
  161. }}
  162. creatable
  163. options={environments.map(e => ({value: e, label: e}))}
  164. inline={false}
  165. flexibleControlStateSize
  166. stacked
  167. required
  168. />
  169. </FormRow>
  170. <AlertListItem>{t('Configure Request')}</AlertListItem>
  171. <ListItemSubText>
  172. {t('Configure the HTTP request made for uptime checks.')}
  173. </ListItemSubText>
  174. <Configuration>
  175. <ConfigurationPanel>
  176. <SelectField
  177. options={VALID_INTERVALS_SEC.map(value => ({
  178. value,
  179. label: t('Every %s', getDuration(value)),
  180. }))}
  181. name="intervalSeconds"
  182. label={t('Interval')}
  183. defaultValue={60}
  184. flexibleControlStateSize
  185. showHelpInTooltip={{isHoverable: true}}
  186. help={({model}) =>
  187. tct(
  188. 'The amount of time between each uptime check request. Selecting a period of [interval] means it will take at least [expectedFailureInterval] until you are notified of a failure. [link:Learn more].',
  189. {
  190. link: (
  191. <ExternalLink href="https://docs.sentry.io/product/alerts/uptime-monitoring/#uptime-check-failures" />
  192. ),
  193. interval: (
  194. <strong>{getDuration(model.getValue('intervalSeconds'))}</strong>
  195. ),
  196. expectedFailureInterval: (
  197. <strong>
  198. {getDuration(Number(model.getValue('intervalSeconds')) * 3)}
  199. </strong>
  200. ),
  201. }
  202. )
  203. }
  204. required
  205. />
  206. <RangeField
  207. name="timeoutMs"
  208. label={t('Timeout')}
  209. min={1000}
  210. max={30_000}
  211. step={250}
  212. tickValues={[1_000, 5_000, 10_000, 15_000, 20_000, 25_000, 30_000]}
  213. defaultValue={5_000}
  214. showTickLabels
  215. formatLabel={value => getDuration((value || 0) / 1000, 2, true)}
  216. flexibleControlStateSize
  217. required
  218. />
  219. <TextField
  220. name="url"
  221. label={t('URL')}
  222. placeholder={t('The URL to monitor')}
  223. flexibleControlStateSize
  224. monospace
  225. required
  226. />
  227. <SelectField
  228. name="method"
  229. label={t('Method')}
  230. defaultValue="GET"
  231. options={HTTP_METHOD_OPTIONS.map(option => ({
  232. value: option,
  233. label: option,
  234. }))}
  235. flexibleControlStateSize
  236. required
  237. />
  238. <UptimeHeadersField
  239. name="headers"
  240. label={t('Headers')}
  241. flexibleControlStateSize
  242. />
  243. <TextareaField
  244. name="body"
  245. label={t('Body')}
  246. visible={({model}: any) =>
  247. !['GET', 'HEAD'].includes(model.getValue('method'))
  248. }
  249. rows={4}
  250. maxRows={15}
  251. autosize
  252. monospace
  253. placeholder='{"key": "value"}'
  254. flexibleControlStateSize
  255. />
  256. <BooleanField
  257. name="traceSampling"
  258. label={t('Allow Sampling')}
  259. showHelpInTooltip={{isHoverable: true}}
  260. help={tct(
  261. 'Defer the sampling decision to a Sentry SDK configured in your application. Disable to prevent all span sampling. [link:Learn more].',
  262. {
  263. link: (
  264. <ExternalLink href="https://docs.sentry.io/product/alerts/uptime-monitoring/uptime-tracing/" />
  265. ),
  266. }
  267. )}
  268. flexibleControlStateSize
  269. />
  270. </ConfigurationPanel>
  271. <Alert.Container>
  272. <Alert type="muted" showIcon>
  273. {tct(
  274. 'By enabling uptime monitoring, you acknowledge that uptime check data may be stored outside your selected data region. [link:Learn more].',
  275. {
  276. link: (
  277. <ExternalLink href="https://docs.sentry.io/organization/data-storage-location/#data-stored-in-us" />
  278. ),
  279. }
  280. )}
  281. </Alert>
  282. </Alert.Container>
  283. <Observer>
  284. {() => (
  285. <HTTPSnippet
  286. url={formModel.getValue('url')}
  287. method={formModel.getValue('method')}
  288. headers={formModel.getValue('headers')}
  289. body={formModel.getValue('body')}
  290. traceSampling={formModel.getValue('traceSampling')}
  291. />
  292. )}
  293. </Observer>
  294. </Configuration>
  295. <AlertListItem>{t('Establish ownership')}</AlertListItem>
  296. <ListItemSubText>
  297. {t(
  298. 'Choose a team or member as the rule owner. Issues created will be automatically assigned to the owner.'
  299. )}
  300. </ListItemSubText>
  301. <FormRow>
  302. <TextField
  303. name="name"
  304. label={t('Uptime rule name')}
  305. hideLabel
  306. placeholder={t('Uptime rule name')}
  307. inline={false}
  308. flexibleControlStateSize
  309. stacked
  310. required
  311. />
  312. <SentryMemberTeamSelectorField
  313. name="owner"
  314. label={t('Owner')}
  315. hideLabel
  316. menuPlacement="auto"
  317. inline={false}
  318. flexibleControlStateSize
  319. stacked
  320. style={{
  321. padding: 0,
  322. border: 'none',
  323. }}
  324. />
  325. <HiddenField name="timeoutMs" defaultValue={10000} />
  326. </FormRow>
  327. </List>
  328. </Form>
  329. );
  330. }
  331. const AlertListItem = styled(ListItem)`
  332. font-size: ${p => p.theme.fontSizeExtraLarge};
  333. font-weight: ${p => p.theme.fontWeightBold};
  334. line-height: 1.3;
  335. `;
  336. const ListItemSubText = styled(Text)`
  337. padding-left: ${space(4)};
  338. color: ${p => p.theme.subText};
  339. `;
  340. const FormRow = styled('div')`
  341. display: grid;
  342. grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  343. align-items: center;
  344. gap: ${space(2)};
  345. margin-top: ${space(1)};
  346. margin-bottom: ${space(4)};
  347. margin-left: ${space(4)};
  348. ${FieldWrapper} {
  349. padding: 0;
  350. }
  351. `;
  352. const Configuration = styled('div')`
  353. margin-top: ${space(1)};
  354. margin-bottom: ${space(4)};
  355. margin-left: ${space(4)};
  356. `;
  357. const ConfigurationPanel = styled(Panel)`
  358. display: grid;
  359. gap: 0 ${space(2)};
  360. grid-template-columns: max-content 1fr;
  361. align-items: center;
  362. ${FieldWrapper} {
  363. display: grid;
  364. grid-template-columns: subgrid;
  365. grid-column: 1 / -1;
  366. label {
  367. width: auto;
  368. }
  369. }
  370. `;