uptimeAlertForm.tsx 11 KB

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