uptimeAlertForm.tsx 12 KB

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