ruleConditionsForm.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513
  1. import * as React from 'react';
  2. import {Fragment} from 'react';
  3. import styled from '@emotion/styled';
  4. import pick from 'lodash/pick';
  5. import {addErrorMessage} from 'sentry/actionCreators/indicator';
  6. import {Client} from 'sentry/api';
  7. import Feature from 'sentry/components/acl/feature';
  8. import SearchBar from 'sentry/components/events/searchBar';
  9. import FormField from 'sentry/components/forms/formField';
  10. import SelectControl from 'sentry/components/forms/selectControl';
  11. import SelectField from 'sentry/components/forms/selectField';
  12. import ListItem from 'sentry/components/list/listItem';
  13. import {Panel, PanelBody} from 'sentry/components/panels';
  14. import Tooltip from 'sentry/components/tooltip';
  15. import {IconQuestion} from 'sentry/icons';
  16. import {t} from 'sentry/locale';
  17. import space from 'sentry/styles/space';
  18. import {Environment, Organization, SelectValue} from 'sentry/types';
  19. import {MobileVital, WebVital} from 'sentry/utils/discover/fields';
  20. import {getDisplayName} from 'sentry/utils/environment';
  21. import {
  22. convertDatasetEventTypesToSource,
  23. DATA_SOURCE_LABELS,
  24. DATA_SOURCE_TO_SET_AND_EVENT_TYPES,
  25. } from 'sentry/views/alerts/utils';
  26. import {AlertType, getFunctionHelpText} from 'sentry/views/alerts/wizard/options';
  27. import {isCrashFreeAlert} from './utils/isCrashFreeAlert';
  28. import {
  29. COMPARISON_DELTA_OPTIONS,
  30. DEFAULT_AGGREGATE,
  31. DEFAULT_TRANSACTION_AGGREGATE,
  32. } from './constants';
  33. import MetricField from './metricField';
  34. import {AlertRuleComparisonType, Dataset, Datasource, TimeWindow} from './types';
  35. const TIME_WINDOW_MAP: Record<TimeWindow, string> = {
  36. [TimeWindow.ONE_MINUTE]: t('1 minute'),
  37. [TimeWindow.FIVE_MINUTES]: t('5 minutes'),
  38. [TimeWindow.TEN_MINUTES]: t('10 minutes'),
  39. [TimeWindow.FIFTEEN_MINUTES]: t('15 minutes'),
  40. [TimeWindow.THIRTY_MINUTES]: t('30 minutes'),
  41. [TimeWindow.ONE_HOUR]: t('1 hour'),
  42. [TimeWindow.TWO_HOURS]: t('2 hours'),
  43. [TimeWindow.FOUR_HOURS]: t('4 hours'),
  44. [TimeWindow.ONE_DAY]: t('24 hours'),
  45. };
  46. type Props = {
  47. alertType: AlertType;
  48. api: Client;
  49. comparisonType: AlertRuleComparisonType;
  50. dataset: Dataset;
  51. disabled: boolean;
  52. hasAlertWizardV3: boolean;
  53. onComparisonDeltaChange: (value: number) => void;
  54. onFilterSearch: (query: string) => void;
  55. onTimeWindowChange: (value: number) => void;
  56. organization: Organization;
  57. projectSlug: string;
  58. thresholdChart: React.ReactNode;
  59. timeWindow: number;
  60. allowChangeEventTypes?: boolean;
  61. comparisonDelta?: number;
  62. };
  63. type State = {
  64. environments: Environment[] | null;
  65. };
  66. class RuleConditionsForm extends React.PureComponent<Props, State> {
  67. state: State = {
  68. environments: null,
  69. };
  70. componentDidMount() {
  71. this.fetchData();
  72. }
  73. async fetchData() {
  74. const {api, organization, projectSlug} = this.props;
  75. try {
  76. const environments = await api.requestPromise(
  77. `/projects/${organization.slug}/${projectSlug}/environments/`,
  78. {
  79. query: {
  80. visibility: 'visible',
  81. },
  82. }
  83. );
  84. this.setState({environments});
  85. } catch (_err) {
  86. addErrorMessage(t('Unable to fetch environments'));
  87. }
  88. }
  89. get timeWindowOptions() {
  90. let options: Record<string, string> = TIME_WINDOW_MAP;
  91. if (isCrashFreeAlert(this.props.dataset)) {
  92. options = pick(TIME_WINDOW_MAP, [
  93. // TimeWindow.THIRTY_MINUTES, leaving this option out until we figure out the sub-hour session resolution chart limitations
  94. TimeWindow.ONE_HOUR,
  95. TimeWindow.TWO_HOURS,
  96. TimeWindow.FOUR_HOURS,
  97. TimeWindow.ONE_DAY,
  98. ]);
  99. }
  100. return Object.entries(options).map(([value, label]) => ({
  101. value: parseInt(value, 10),
  102. label,
  103. }));
  104. }
  105. get searchPlaceholder() {
  106. switch (this.props.dataset) {
  107. case Dataset.ERRORS:
  108. return t('Filter events by level, message, and other properties\u2026');
  109. case Dataset.METRICS:
  110. case Dataset.SESSIONS:
  111. return t('Filter sessions by release version\u2026');
  112. case Dataset.TRANSACTIONS:
  113. default:
  114. return t('Filter transactions by URL, tags, and other properties\u2026');
  115. }
  116. }
  117. get searchSupportedTags() {
  118. if (isCrashFreeAlert(this.props.dataset)) {
  119. return {
  120. release: {
  121. key: 'release',
  122. name: 'release',
  123. },
  124. };
  125. }
  126. return undefined;
  127. }
  128. renderInterval() {
  129. const {
  130. organization,
  131. disabled,
  132. alertType,
  133. hasAlertWizardV3,
  134. timeWindow,
  135. comparisonDelta,
  136. comparisonType,
  137. onTimeWindowChange,
  138. onComparisonDeltaChange,
  139. } = this.props;
  140. const formElemBaseStyle = {
  141. padding: `${space(0.5)}`,
  142. border: 'none',
  143. };
  144. const {labelText, timeWindowText} = getFunctionHelpText(alertType);
  145. const intervalLabelText = hasAlertWizardV3 ? t('Define your metric') : labelText;
  146. return (
  147. <Fragment>
  148. <StyledListItem>
  149. <StyledListTitle>
  150. <div>{intervalLabelText}</div>
  151. <Tooltip
  152. title={t(
  153. 'Time window over which the metric is evaluated. Alerts are evaluated every minute regardless of this value.'
  154. )}
  155. >
  156. <IconQuestion size="sm" color="gray200" />
  157. </Tooltip>
  158. </StyledListTitle>
  159. </StyledListItem>
  160. <FormRow>
  161. {timeWindowText && (
  162. <MetricField
  163. name="aggregate"
  164. help={null}
  165. organization={organization}
  166. disabled={disabled}
  167. style={{
  168. ...formElemBaseStyle,
  169. }}
  170. inline={false}
  171. flexibleControlStateSize
  172. columnWidth={200}
  173. alertType={alertType}
  174. required
  175. />
  176. )}
  177. {timeWindowText && <FormRowText>{timeWindowText}</FormRowText>}
  178. <SelectControl
  179. name="timeWindow"
  180. styles={{
  181. control: (provided: {[x: string]: string | number | boolean}) => ({
  182. ...provided,
  183. minWidth: 130,
  184. maxWidth: 300,
  185. }),
  186. }}
  187. options={this.timeWindowOptions}
  188. required
  189. isDisabled={disabled}
  190. value={timeWindow}
  191. onChange={({value}) => onTimeWindowChange(value)}
  192. inline={false}
  193. flexibleControlStateSize
  194. />
  195. {!hasAlertWizardV3 && (
  196. <Feature
  197. features={['organizations:change-alerts']}
  198. organization={organization}
  199. >
  200. {comparisonType === AlertRuleComparisonType.CHANGE && (
  201. <ComparisonContainer>
  202. {t(' compared to ')}
  203. <SelectControl
  204. name="comparisonDelta"
  205. styles={{
  206. container: (provided: {
  207. [x: string]: string | number | boolean;
  208. }) => ({
  209. ...provided,
  210. marginLeft: space(1),
  211. }),
  212. control: (provided: {[x: string]: string | number | boolean}) => ({
  213. ...provided,
  214. minWidth: 500,
  215. maxWidth: 1000,
  216. }),
  217. }}
  218. value={comparisonDelta}
  219. onChange={({value}) => onComparisonDeltaChange(value)}
  220. options={COMPARISON_DELTA_OPTIONS}
  221. required={comparisonType === AlertRuleComparisonType.CHANGE}
  222. />
  223. </ComparisonContainer>
  224. )}
  225. </Feature>
  226. )}
  227. </FormRow>
  228. </Fragment>
  229. );
  230. }
  231. render() {
  232. const {
  233. organization,
  234. disabled,
  235. onFilterSearch,
  236. allowChangeEventTypes,
  237. alertType,
  238. hasAlertWizardV3,
  239. dataset,
  240. } = this.props;
  241. const {environments} = this.state;
  242. const environmentOptions: SelectValue<string | null>[] =
  243. environments?.map((env: Environment) => ({
  244. value: env.name,
  245. label: getDisplayName(env),
  246. })) ?? [];
  247. environmentOptions.unshift({
  248. value: null,
  249. label: t('All'),
  250. });
  251. const dataSourceOptions = [
  252. {
  253. label: t('Errors'),
  254. options: [
  255. {
  256. value: Datasource.ERROR_DEFAULT,
  257. label: DATA_SOURCE_LABELS[Datasource.ERROR_DEFAULT],
  258. },
  259. {
  260. value: Datasource.DEFAULT,
  261. label: DATA_SOURCE_LABELS[Datasource.DEFAULT],
  262. },
  263. {
  264. value: Datasource.ERROR,
  265. label: DATA_SOURCE_LABELS[Datasource.ERROR],
  266. },
  267. ],
  268. },
  269. ];
  270. if (organization.features.includes('performance-view') && alertType === 'custom') {
  271. dataSourceOptions.push({
  272. label: t('Transactions'),
  273. options: [
  274. {
  275. value: Datasource.TRANSACTION,
  276. label: DATA_SOURCE_LABELS[Datasource.TRANSACTION],
  277. },
  278. ],
  279. });
  280. }
  281. const transactionTags = [
  282. 'transaction',
  283. 'transaction.duration',
  284. 'transaction.op',
  285. 'transaction.status',
  286. ];
  287. const measurementTags = Object.values({...WebVital, ...MobileVital});
  288. const eventOmitTags =
  289. dataset === 'events' ? [...measurementTags, ...transactionTags] : [];
  290. const formElemBaseStyle = {
  291. padding: `${space(0.5)}`,
  292. border: 'none',
  293. };
  294. return (
  295. <React.Fragment>
  296. <ChartPanel>
  297. <StyledPanelBody>{this.props.thresholdChart}</StyledPanelBody>
  298. </ChartPanel>
  299. {hasAlertWizardV3 && this.renderInterval()}
  300. <StyledListItem>{t('Filter events')}</StyledListItem>
  301. <FormRow>
  302. <SelectField
  303. name="environment"
  304. placeholder={t('All')}
  305. style={{
  306. ...formElemBaseStyle,
  307. minWidth: 230,
  308. flex: 1,
  309. }}
  310. styles={{
  311. singleValue: (base: any) => ({
  312. ...base,
  313. }),
  314. option: (base: any) => ({
  315. ...base,
  316. }),
  317. }}
  318. options={environmentOptions}
  319. isDisabled={disabled || this.state.environments === null}
  320. isClearable
  321. inline={false}
  322. flexibleControlStateSize
  323. inFieldLabel={t('Environment: ')}
  324. />
  325. {allowChangeEventTypes && (
  326. <FormField
  327. name="datasource"
  328. inline={false}
  329. style={{
  330. ...formElemBaseStyle,
  331. minWidth: 300,
  332. flex: 2,
  333. }}
  334. flexibleControlStateSize
  335. >
  336. {({onChange, onBlur, model}) => {
  337. const formDataset = model.getValue('dataset');
  338. const formEventTypes = model.getValue('eventTypes');
  339. const mappedValue = convertDatasetEventTypesToSource(
  340. formDataset,
  341. formEventTypes
  342. );
  343. return (
  344. <SelectControl
  345. value={mappedValue}
  346. inFieldLabel={t('Events: ')}
  347. onChange={optionObj => {
  348. const optionValue = optionObj.value;
  349. onChange(optionValue, {});
  350. onBlur(optionValue, {});
  351. // Reset the aggregate to the default (which works across
  352. // datatypes), otherwise we may send snuba an invalid query
  353. // (transaction aggregate on events datasource = bad).
  354. optionValue === 'transaction'
  355. ? model.setValue('aggregate', DEFAULT_TRANSACTION_AGGREGATE)
  356. : model.setValue('aggregate', DEFAULT_AGGREGATE);
  357. // set the value of the dataset and event type from data source
  358. const {dataset: datasetFromDataSource, eventTypes} =
  359. DATA_SOURCE_TO_SET_AND_EVENT_TYPES[optionValue] ?? {};
  360. model.setValue('dataset', datasetFromDataSource);
  361. model.setValue('eventTypes', eventTypes);
  362. }}
  363. options={dataSourceOptions}
  364. isDisabled={disabled}
  365. />
  366. );
  367. }}
  368. </FormField>
  369. )}
  370. <FormField
  371. name="query"
  372. inline={false}
  373. style={{
  374. ...formElemBaseStyle,
  375. flex: '6 0 500px',
  376. }}
  377. flexibleControlStateSize
  378. >
  379. {({onChange, onBlur, onKeyDown, initialData}) => (
  380. <SearchContainer>
  381. <StyledSearchBar
  382. searchSource="alert_builder"
  383. defaultQuery={initialData?.query ?? ''}
  384. omitTags={[
  385. 'event.type',
  386. 'release.version',
  387. 'release.stage',
  388. 'release.package',
  389. 'release.build',
  390. 'project',
  391. ...eventOmitTags,
  392. ]}
  393. includeSessionTagsValues={dataset === Dataset.SESSIONS}
  394. disabled={disabled}
  395. useFormWrapper={false}
  396. organization={organization}
  397. placeholder={this.searchPlaceholder}
  398. onChange={onChange}
  399. onKeyDown={e => {
  400. /**
  401. * Do not allow enter key to submit the alerts form since it is unlikely
  402. * users will be ready to create the rule as this sits above required fields.
  403. */
  404. if (e.key === 'Enter') {
  405. e.preventDefault();
  406. e.stopPropagation();
  407. }
  408. onKeyDown?.(e);
  409. }}
  410. onBlur={query => {
  411. onFilterSearch(query);
  412. onBlur(query);
  413. }}
  414. onSearch={query => {
  415. onFilterSearch(query);
  416. onChange(query, {});
  417. }}
  418. {...(this.searchSupportedTags
  419. ? {supportedTags: this.searchSupportedTags}
  420. : {})}
  421. hasRecentSearches={dataset !== Dataset.SESSIONS}
  422. />
  423. </SearchContainer>
  424. )}
  425. </FormField>
  426. </FormRow>
  427. {!hasAlertWizardV3 && this.renderInterval()}
  428. </React.Fragment>
  429. );
  430. }
  431. }
  432. const StyledListTitle = styled('div')`
  433. display: flex;
  434. span {
  435. margin-left: ${space(1)};
  436. }
  437. `;
  438. const ChartPanel = styled(Panel)`
  439. margin-bottom: ${space(4)};
  440. `;
  441. const StyledPanelBody = styled(PanelBody)`
  442. ol,
  443. h4 {
  444. margin-bottom: ${space(1)};
  445. }
  446. `;
  447. const SearchContainer = styled('div')`
  448. display: flex;
  449. `;
  450. const StyledSearchBar = styled(SearchBar)`
  451. flex-grow: 1;
  452. `;
  453. const StyledListItem = styled(ListItem)`
  454. margin-bottom: ${space(1)};
  455. font-size: ${p => p.theme.fontSizeExtraLarge};
  456. line-height: 1.3;
  457. `;
  458. const FormRow = styled('div')`
  459. display: flex;
  460. flex-direction: row;
  461. align-items: center;
  462. flex-wrap: wrap;
  463. margin-bottom: ${space(4)};
  464. `;
  465. const FormRowText = styled('div')`
  466. margin: ${space(1)};
  467. `;
  468. const ComparisonContainer = styled('div')`
  469. margin-left: ${space(1)};
  470. display: flex;
  471. flex-direction: row;
  472. align-items: center;
  473. `;
  474. export default RuleConditionsForm;