uptimeAlertForm.tsx 10 KB

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