monitorCreateForm.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
  1. import {Fragment, useContext, useEffect, useRef} from 'react';
  2. import {browserHistory} from 'react-router';
  3. import {css} from '@emotion/react';
  4. import styled from '@emotion/styled';
  5. import {Observer} from 'mobx-react';
  6. import NumberField from 'sentry/components/forms/fields/numberField';
  7. import SelectField from 'sentry/components/forms/fields/selectField';
  8. import SentryProjectSelectorField from 'sentry/components/forms/fields/sentryProjectSelectorField';
  9. import TextField from 'sentry/components/forms/fields/textField';
  10. import Form from 'sentry/components/forms/form';
  11. import FormContext from 'sentry/components/forms/formContext';
  12. import FormModel, {FieldValue} from 'sentry/components/forms/model';
  13. import Panel from 'sentry/components/panels/panel';
  14. import PanelBody from 'sentry/components/panels/panelBody';
  15. import Placeholder from 'sentry/components/placeholder';
  16. import {timezoneOptions} from 'sentry/data/timezones';
  17. import {t} from 'sentry/locale';
  18. import {space} from 'sentry/styles/space';
  19. import {isActiveSuperuser} from 'sentry/utils/isActiveSuperuser';
  20. import {useApiQuery} from 'sentry/utils/queryClient';
  21. import commonTheme from 'sentry/utils/theme';
  22. import {useDimensions} from 'sentry/utils/useDimensions';
  23. import useOrganization from 'sentry/utils/useOrganization';
  24. import usePageFilters from 'sentry/utils/usePageFilters';
  25. import useProjects from 'sentry/utils/useProjects';
  26. import {normalizeUrl} from 'sentry/utils/withDomainRequired';
  27. import {
  28. DEFAULT_CRONTAB,
  29. DEFAULT_MONITOR_TYPE,
  30. mapMonitorFormErrors,
  31. transformMonitorFormData,
  32. } from 'sentry/views/monitors/components/monitorForm';
  33. import {MockCheckInTimeline} from 'sentry/views/monitors/components/overviewTimeline/checkInTimeline';
  34. import {
  35. GridLineOverlay,
  36. GridLineTimeLabels,
  37. } from 'sentry/views/monitors/components/overviewTimeline/gridLines';
  38. import {TimelinePlaceholder} from 'sentry/views/monitors/components/overviewTimeline/timelinePlaceholder';
  39. import {getConfigFromTimeRange} from 'sentry/views/monitors/components/overviewTimeline/utils';
  40. import {Monitor, ScheduleType} from 'sentry/views/monitors/types';
  41. import {crontabAsText, getScheduleIntervals} from 'sentry/views/monitors/utils';
  42. const NUM_SAMPLE_TICKS = 9;
  43. interface ScheduleConfig {
  44. cronSchedule?: FieldValue;
  45. intervalFrequency?: FieldValue;
  46. intervalUnit?: FieldValue;
  47. scheduleType?: FieldValue;
  48. }
  49. function isValidConfig(schedule: ScheduleConfig) {
  50. const {scheduleType, cronSchedule, intervalFrequency, intervalUnit} = schedule;
  51. return !!(
  52. (scheduleType === ScheduleType.CRONTAB && cronSchedule) ||
  53. (scheduleType === ScheduleType.INTERVAL && intervalFrequency && intervalUnit)
  54. );
  55. }
  56. const DEFAULT_SCHEDULE_CONFIG = {
  57. scheduleType: 'crontab',
  58. cronSchedule: DEFAULT_CRONTAB,
  59. intervalFrequency: '1',
  60. intervalUnit: 'day',
  61. };
  62. interface Props {
  63. schedule: ScheduleConfig;
  64. }
  65. function MockTimelineVisualization({schedule}: Props) {
  66. const {scheduleType, cronSchedule, intervalFrequency, intervalUnit} = schedule;
  67. const organization = useOrganization();
  68. const {form} = useContext(FormContext);
  69. const query = {
  70. num_ticks: NUM_SAMPLE_TICKS,
  71. schedule_type: scheduleType,
  72. schedule:
  73. scheduleType === 'interval' ? [intervalFrequency, intervalUnit] : cronSchedule,
  74. };
  75. const elementRef = useRef<HTMLDivElement>(null);
  76. const {width: timelineWidth} = useDimensions<HTMLDivElement>({elementRef});
  77. const sampleDataQueryKey = [
  78. `/organizations/${organization.slug}/monitors-schedule-data/`,
  79. {query},
  80. ] as const;
  81. const {data, isLoading, isError, error} = useApiQuery<number[]>(sampleDataQueryKey, {
  82. staleTime: 0,
  83. enabled: isValidConfig(schedule),
  84. retry: false,
  85. });
  86. const errorMessage =
  87. isError || !isValidConfig(schedule)
  88. ? error?.responseJSON?.schedule?.[0] ?? t('Invalid Schedule')
  89. : null;
  90. useEffect(() => {
  91. if (!form) {
  92. return;
  93. }
  94. if (scheduleType === ScheduleType.INTERVAL) {
  95. form.setError('config.schedule.frequency', errorMessage);
  96. } else if (scheduleType === ScheduleType.CRONTAB) {
  97. form.setError('config.schedule', errorMessage);
  98. }
  99. }, [errorMessage, form, scheduleType]);
  100. const mockTimestamps = data?.map(ts => new Date(ts * 1000));
  101. const start = mockTimestamps?.[0];
  102. const end = mockTimestamps?.[mockTimestamps.length - 1];
  103. const timeWindowConfig =
  104. start && end ? getConfigFromTimeRange(start, end, timelineWidth) : undefined;
  105. return (
  106. <TimelineContainer>
  107. <TimelineWidthTracker ref={elementRef} />
  108. {isLoading || !start || !end || !timeWindowConfig || !mockTimestamps ? (
  109. <Fragment>
  110. <Placeholder height="40px" />
  111. {errorMessage ? <Placeholder height="100px" /> : <TimelinePlaceholder />}
  112. </Fragment>
  113. ) : (
  114. <Fragment>
  115. <StyledGridLineTimeLabels
  116. timeWindowConfig={timeWindowConfig}
  117. start={start}
  118. end={end}
  119. width={timelineWidth}
  120. />
  121. <StyledGridLineOverlay
  122. showCursor={!isLoading}
  123. timeWindowConfig={timeWindowConfig}
  124. start={start}
  125. end={end}
  126. width={timelineWidth}
  127. />
  128. <MockCheckInTimeline
  129. width={timelineWidth}
  130. mockTimestamps={mockTimestamps.slice(1, mockTimestamps.length - 1)}
  131. start={start}
  132. end={end}
  133. timeWindowConfig={timeWindowConfig}
  134. />
  135. </Fragment>
  136. )}
  137. </TimelineContainer>
  138. );
  139. }
  140. const TimelineContainer = styled(Panel)`
  141. display: grid;
  142. grid-template-columns: 1fr;
  143. grid-template-rows: 40px 100px;
  144. align-items: center;
  145. `;
  146. const StyledGridLineTimeLabels = styled(GridLineTimeLabels)`
  147. grid-column: 0;
  148. `;
  149. const StyledGridLineOverlay = styled(GridLineOverlay)`
  150. grid-column: 0;
  151. `;
  152. const TimelineWidthTracker = styled('div')`
  153. position: absolute;
  154. width: 100%;
  155. grid-row: 1;
  156. grid-column: 0;
  157. `;
  158. export default function MonitorCreateForm() {
  159. const organization = useOrganization();
  160. const {projects} = useProjects();
  161. const {selection} = usePageFilters();
  162. const form = useRef(
  163. new FormModel({
  164. transformData: transformMonitorFormData,
  165. mapFormErrors: mapMonitorFormErrors,
  166. })
  167. );
  168. const selectedProjectId = selection.projects[0];
  169. const selectedProject = selectedProjectId
  170. ? projects.find(p => p.id === selectedProjectId + '')
  171. : null;
  172. const isSuperuser = isActiveSuperuser();
  173. const filteredProjects = projects.filter(project => isSuperuser || project.isMember);
  174. function onCreateMonitor(data: Monitor) {
  175. const endpointOptions = {
  176. query: {
  177. project: selection.projects,
  178. environment: selection.environments,
  179. },
  180. };
  181. browserHistory.push(
  182. normalizeUrl({
  183. pathname: `/organizations/${organization.slug}/crons/${data.slug}/`,
  184. query: endpointOptions.query,
  185. })
  186. );
  187. }
  188. function changeScheduleType(type: ScheduleType) {
  189. form.current.setValue('config.schedule_type', type);
  190. }
  191. return (
  192. <Form
  193. allowUndo
  194. requireChanges
  195. apiEndpoint={`/organizations/${organization.slug}/monitors/`}
  196. apiMethod="POST"
  197. model={form.current}
  198. initialData={{
  199. project: selectedProject ? selectedProject.slug : null,
  200. type: DEFAULT_MONITOR_TYPE,
  201. 'config.schedule_type': DEFAULT_SCHEDULE_CONFIG.scheduleType,
  202. }}
  203. onSubmitSuccess={onCreateMonitor}
  204. submitLabel={t('Next')}
  205. >
  206. <FieldContainer>
  207. <MultiColumnInput columns="250px 1fr">
  208. <StyledSentryProjectSelectorField
  209. name="project"
  210. projects={filteredProjects}
  211. placeholder={t('Choose Project')}
  212. disabledReason={t('Existing monitors cannot be moved between projects')}
  213. valueIsSlug
  214. required
  215. stacked
  216. inline={false}
  217. />
  218. <StyledTextField
  219. name="name"
  220. placeholder={t('My Cron Job')}
  221. required
  222. stacked
  223. inline={false}
  224. />
  225. </MultiColumnInput>
  226. <LabelText>{t('SCHEDULE')}</LabelText>
  227. <ScheduleOptions>
  228. <Observer>
  229. {() => {
  230. const currScheduleType = form.current.getValue('config.schedule_type');
  231. const selectedCrontab = currScheduleType === ScheduleType.CRONTAB;
  232. const parsedSchedule = form.current.getError('config.schedule')
  233. ? ''
  234. : crontabAsText(
  235. form.current.getValue('config.schedule')?.toString() ?? ''
  236. );
  237. return (
  238. <Fragment>
  239. <SchedulePanel
  240. highlighted={selectedCrontab}
  241. onClick={() => changeScheduleType(ScheduleType.CRONTAB)}
  242. >
  243. <PanelBody withPadding>
  244. <ScheduleLabel>{t('Crontab Schedule')}</ScheduleLabel>
  245. <MultiColumnInput columns="1fr 1fr">
  246. <StyledTextField
  247. name="config.schedule"
  248. placeholder="* * * * *"
  249. defaultValue={DEFAULT_SCHEDULE_CONFIG.cronSchedule}
  250. css={{input: {fontFamily: commonTheme.text.familyMono}}}
  251. required={selectedCrontab}
  252. stacked
  253. inline={false}
  254. hideControlState={!selectedCrontab}
  255. />
  256. <StyledSelectField
  257. name="config.timezone"
  258. defaultValue="UTC"
  259. options={timezoneOptions}
  260. required={selectedCrontab}
  261. stacked
  262. inline={false}
  263. />
  264. <CronstrueText>{parsedSchedule}</CronstrueText>
  265. </MultiColumnInput>
  266. </PanelBody>
  267. </SchedulePanel>
  268. <SchedulePanel
  269. highlighted={!selectedCrontab}
  270. onClick={() => changeScheduleType(ScheduleType.INTERVAL)}
  271. >
  272. <PanelBody withPadding>
  273. <ScheduleLabel>{t('Interval Schedule')}</ScheduleLabel>
  274. <MultiColumnInput columns="auto 1fr 2fr">
  275. <Label>{t('Every')}</Label>
  276. <StyledNumberField
  277. name="config.schedule.frequency"
  278. placeholder="e.g. 1"
  279. defaultValue={DEFAULT_SCHEDULE_CONFIG.intervalFrequency}
  280. required={!selectedCrontab}
  281. stacked
  282. inline={false}
  283. hideControlState={selectedCrontab}
  284. />
  285. <StyledSelectField
  286. name="config.schedule.interval"
  287. options={getScheduleIntervals(
  288. Number(
  289. form.current.getValue('config.schedule.frequency') ?? 1
  290. )
  291. )}
  292. defaultValue={DEFAULT_SCHEDULE_CONFIG.intervalUnit}
  293. required={!selectedCrontab}
  294. stacked
  295. inline={false}
  296. />
  297. </MultiColumnInput>
  298. </PanelBody>
  299. </SchedulePanel>
  300. </Fragment>
  301. );
  302. }}
  303. </Observer>
  304. </ScheduleOptions>
  305. <Observer>
  306. {() => {
  307. const scheduleType = form.current.getValue('config.schedule_type');
  308. const cronSchedule = form.current.getValue('config.schedule');
  309. const intervalFrequency = form.current.getValue('config.schedule.frequency');
  310. const intervalUnit = form.current.getValue('config.schedule.interval');
  311. const schedule = {
  312. scheduleType,
  313. cronSchedule,
  314. intervalFrequency,
  315. intervalUnit,
  316. };
  317. return <MockTimelineVisualization schedule={schedule} />;
  318. }}
  319. </Observer>
  320. </FieldContainer>
  321. </Form>
  322. );
  323. }
  324. const FieldContainer = styled('div')`
  325. width: 800px;
  326. `;
  327. const SchedulePanel = styled(Panel)<{highlighted: boolean}>`
  328. border-radius: 0 ${space(0.75)} ${space(0.75)} 0;
  329. ${p =>
  330. p.highlighted &&
  331. css`
  332. border: 2px solid ${p.theme.purple300};
  333. `};
  334. &:first-child {
  335. border-radius: ${space(0.75)} 0 0 ${space(0.75)};
  336. }
  337. `;
  338. const ScheduleLabel = styled('div')`
  339. font-weight: bold;
  340. margin-bottom: ${space(2)};
  341. `;
  342. const Label = styled('div')`
  343. font-weight: bold;
  344. color: ${p => p.theme.subText};
  345. `;
  346. const LabelText = styled(Label)`
  347. margin-top: ${space(2)};
  348. margin-bottom: ${space(1)};
  349. `;
  350. const ScheduleOptions = styled('div')`
  351. display: grid;
  352. grid-template-columns: 1fr 1fr;
  353. `;
  354. const MultiColumnInput = styled('div')<{columns?: string}>`
  355. display: grid;
  356. align-items: center;
  357. gap: ${space(1)};
  358. grid-template-columns: ${p => p.columns};
  359. `;
  360. const CronstrueText = styled(LabelText)`
  361. font-weight: normal;
  362. font-size: ${p => p.theme.fontSizeExtraSmall};
  363. font-family: ${p => p.theme.text.familyMono};
  364. grid-column: auto / span 2;
  365. `;
  366. const StyledNumberField = styled(NumberField)`
  367. padding: 0;
  368. `;
  369. const StyledSelectField = styled(SelectField)`
  370. padding: 0;
  371. `;
  372. const StyledTextField = styled(TextField)`
  373. padding: 0;
  374. `;
  375. const StyledSentryProjectSelectorField = styled(SentryProjectSelectorField)`
  376. padding: 0;
  377. `;