index.tsx 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783
  1. import * as React from 'react';
  2. import {RouteComponentProps} from 'react-router';
  3. import {PlainRoute} from 'react-router/lib/Route';
  4. import styled from '@emotion/styled';
  5. import {
  6. addErrorMessage,
  7. addSuccessMessage,
  8. clearIndicators,
  9. Indicator,
  10. } from 'app/actionCreators/indicator';
  11. import {fetchOrganizationTags} from 'app/actionCreators/tags';
  12. import Access from 'app/components/acl/access';
  13. import Feature from 'app/components/acl/feature';
  14. import AsyncComponent from 'app/components/asyncComponent';
  15. import Button from 'app/components/button';
  16. import Confirm from 'app/components/confirm';
  17. import List from 'app/components/list';
  18. import ListItem from 'app/components/list/listItem';
  19. import {t} from 'app/locale';
  20. import IndicatorStore from 'app/stores/indicatorStore';
  21. import space from 'app/styles/space';
  22. import {Organization, Project} from 'app/types';
  23. import {defined} from 'app/utils';
  24. import {metric, trackAnalyticsEvent} from 'app/utils/analytics';
  25. import {AlertWizardAlertNames} from 'app/views/alerts/wizard/options';
  26. import {getAlertTypeFromAggregateDataset} from 'app/views/alerts/wizard/utils';
  27. import Form from 'app/views/settings/components/forms/form';
  28. import FormModel from 'app/views/settings/components/forms/model';
  29. import RuleNameOwnerForm from 'app/views/settings/incidentRules/ruleNameOwnerForm';
  30. import Triggers from 'app/views/settings/incidentRules/triggers';
  31. import TriggersChart from 'app/views/settings/incidentRules/triggers/chart';
  32. import {getEventTypeFilter} from 'app/views/settings/incidentRules/utils/getEventTypeFilter';
  33. import hasThresholdValue from 'app/views/settings/incidentRules/utils/hasThresholdValue';
  34. import {addOrUpdateRule} from '../actions';
  35. import {createDefaultTrigger} from '../constants';
  36. import RuleConditionsForm from '../ruleConditionsForm';
  37. import RuleConditionsFormForWizard from '../ruleConditionsFormForWizard';
  38. import {
  39. AlertRuleThresholdType,
  40. Dataset,
  41. EventTypes,
  42. IncidentRule,
  43. MetricActionTemplate,
  44. Trigger,
  45. UnsavedIncidentRule,
  46. } from '../types';
  47. const POLLING_MAX_TIME_LIMIT = 3 * 60000;
  48. type RuleTaskResponse = {
  49. status: 'pending' | 'failed' | 'success';
  50. alertRule?: IncidentRule;
  51. error?: string;
  52. };
  53. type Props = {
  54. organization: Organization;
  55. project: Project;
  56. routes: PlainRoute[];
  57. rule: IncidentRule;
  58. userTeamIds: Set<string>;
  59. ruleId?: string;
  60. sessionId?: string;
  61. isCustomMetric?: boolean;
  62. } & RouteComponentProps<{orgId: string; projectId: string; ruleId?: string}, {}> & {
  63. onSubmitSuccess?: Form['props']['onSubmitSuccess'];
  64. } & AsyncComponent['props'];
  65. type State = {
  66. triggers: Trigger[];
  67. resolveThreshold: UnsavedIncidentRule['resolveThreshold'];
  68. thresholdType: UnsavedIncidentRule['thresholdType'];
  69. projects: Project[];
  70. triggerErrors: Map<number, {[fieldName: string]: string}>;
  71. // `null` means loading
  72. availableActions: MetricActionTemplate[] | null;
  73. // Rule conditions form inputs
  74. // Needed for TriggersChart
  75. dataset: Dataset;
  76. query: string;
  77. aggregate: string;
  78. timeWindow: number;
  79. environment: string | null;
  80. uuid?: string;
  81. eventTypes?: EventTypes[];
  82. } & AsyncComponent['state'];
  83. const isEmpty = (str: unknown): boolean => str === '' || !defined(str);
  84. class RuleFormContainer extends AsyncComponent<Props, State> {
  85. componentDidMount() {
  86. const {organization, project} = this.props;
  87. // SearchBar gets its tags from Reflux.
  88. fetchOrganizationTags(this.api, organization.slug, [project.id]);
  89. }
  90. getDefaultState(): State {
  91. const {rule} = this.props;
  92. const triggersClone = [...rule.triggers];
  93. // Warning trigger is removed if it is blank when saving
  94. if (triggersClone.length !== 2) {
  95. triggersClone.push(createDefaultTrigger('warning'));
  96. }
  97. return {
  98. ...super.getDefaultState(),
  99. dataset: rule.dataset,
  100. eventTypes: rule.eventTypes,
  101. aggregate: rule.aggregate,
  102. query: rule.query || '',
  103. timeWindow: rule.timeWindow,
  104. environment: rule.environment || null,
  105. triggerErrors: new Map(),
  106. availableActions: null,
  107. triggers: triggersClone,
  108. resolveThreshold: rule.resolveThreshold,
  109. thresholdType: rule.thresholdType,
  110. projects: [this.props.project],
  111. owner: rule.owner,
  112. };
  113. }
  114. getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
  115. const {orgId} = this.props.params;
  116. // TODO(incidents): This is temporary until new API endpoints
  117. // We should be able to just fetch the rule if rule.id exists
  118. return [
  119. ['availableActions', `/organizations/${orgId}/alert-rules/available-actions/`],
  120. ];
  121. }
  122. goBack() {
  123. const {router} = this.props;
  124. const {orgId} = this.props.params;
  125. router.push(`/organizations/${orgId}/alerts/rules/`);
  126. }
  127. resetPollingState = (loadingSlackIndicator: Indicator) => {
  128. IndicatorStore.remove(loadingSlackIndicator);
  129. this.setState({loading: false, uuid: undefined});
  130. };
  131. fetchStatus(model: FormModel) {
  132. const loadingSlackIndicator = IndicatorStore.addMessage(
  133. t('Looking for your slack channel (this can take a while)'),
  134. 'loading'
  135. );
  136. // pollHandler calls itself until it gets either a success
  137. // or failed status but we don't want to poll forever so we pass
  138. // in a hard stop time of 3 minutes before we bail.
  139. const quitTime = Date.now() + POLLING_MAX_TIME_LIMIT;
  140. setTimeout(() => {
  141. this.pollHandler(model, quitTime, loadingSlackIndicator);
  142. }, 1000);
  143. }
  144. pollHandler = async (
  145. model: FormModel,
  146. quitTime: number,
  147. loadingSlackIndicator: Indicator
  148. ) => {
  149. if (Date.now() > quitTime) {
  150. addErrorMessage(t('Looking for that channel took too long :('));
  151. this.resetPollingState(loadingSlackIndicator);
  152. return;
  153. }
  154. const {
  155. organization,
  156. project,
  157. onSubmitSuccess,
  158. params: {ruleId},
  159. } = this.props;
  160. const {uuid} = this.state;
  161. try {
  162. const response: RuleTaskResponse = await this.api.requestPromise(
  163. `/projects/${organization.slug}/${project.slug}/alert-rule-task/${uuid}/`
  164. );
  165. const {status, alertRule, error} = response;
  166. if (status === 'pending') {
  167. setTimeout(() => {
  168. this.pollHandler(model, quitTime, loadingSlackIndicator);
  169. }, 1000);
  170. return;
  171. }
  172. this.resetPollingState(loadingSlackIndicator);
  173. if (status === 'failed') {
  174. this.handleRuleSaveFailure(error);
  175. }
  176. if (alertRule) {
  177. addSuccessMessage(ruleId ? t('Updated alert rule') : t('Created alert rule'));
  178. if (onSubmitSuccess) {
  179. onSubmitSuccess(alertRule, model);
  180. }
  181. }
  182. } catch {
  183. this.handleRuleSaveFailure(t('An error occurred'));
  184. this.resetPollingState(loadingSlackIndicator);
  185. }
  186. };
  187. /**
  188. * Checks to see if threshold is valid given target value, and state of
  189. * inverted threshold as well as the *other* threshold
  190. *
  191. * @param type The threshold type to be updated
  192. * @param value The new threshold value
  193. */
  194. isValidTrigger = (
  195. triggerIndex: number,
  196. trigger: Trigger,
  197. errors,
  198. resolveThreshold: number | '' | null
  199. ): boolean => {
  200. const {alertThreshold} = trigger;
  201. const {thresholdType} = this.state;
  202. // If value and/or other value is empty
  203. // then there are no checks to perform against
  204. if (!hasThresholdValue(alertThreshold) || !hasThresholdValue(resolveThreshold)) {
  205. return true;
  206. }
  207. // If this is alert threshold and not inverted, it can't be below resolve
  208. // If this is alert threshold and inverted, it can't be above resolve
  209. // If this is resolve threshold and not inverted, it can't be above resolve
  210. // If this is resolve threshold and inverted, it can't be below resolve
  211. // Since we're comparing non-inclusive thresholds here (>, <), we need
  212. // to modify the values when we compare. An example of why:
  213. // Alert > 0, resolve < 1. This means that we want to alert on values
  214. // of 1 or more, and resolve on values of 0 or less. This is valid, but
  215. // without modifying the values, this boundary case will fail.
  216. const isValid =
  217. thresholdType === AlertRuleThresholdType.BELOW
  218. ? alertThreshold - 1 <= resolveThreshold + 1
  219. : alertThreshold + 1 >= resolveThreshold - 1;
  220. const otherErrors = errors.get(triggerIndex) || {};
  221. if (isValid) {
  222. return true;
  223. }
  224. // Not valid... let's figure out an error message
  225. const isBelow = thresholdType === AlertRuleThresholdType.BELOW;
  226. let errorMessage = '';
  227. if (typeof resolveThreshold !== 'number') {
  228. errorMessage = isBelow
  229. ? t('Resolution threshold must be greater than alert')
  230. : t('Resolution threshold must be less than alert');
  231. } else {
  232. errorMessage = isBelow
  233. ? t('Alert threshold must be less than resolution')
  234. : t('Alert threshold must be greater than resolution');
  235. }
  236. errors.set(triggerIndex, {
  237. ...otherErrors,
  238. alertThreshold: errorMessage,
  239. });
  240. return false;
  241. };
  242. validateFieldInTrigger({errors, triggerIndex, field, message, isValid}) {
  243. // If valid, reset error for fieldName
  244. if (isValid()) {
  245. const {[field]: _validatedField, ...otherErrors} = errors.get(triggerIndex) || {};
  246. if (Object.keys(otherErrors).length > 0) {
  247. errors.set(triggerIndex, otherErrors);
  248. } else {
  249. errors.delete(triggerIndex);
  250. }
  251. return errors;
  252. }
  253. if (!errors.has(triggerIndex)) {
  254. errors.set(triggerIndex, {});
  255. }
  256. const currentErrors = errors.get(triggerIndex);
  257. errors.set(triggerIndex, {
  258. ...currentErrors,
  259. [field]: message,
  260. });
  261. return errors;
  262. }
  263. /**
  264. * Validate triggers
  265. *
  266. * @return Returns true if triggers are valid
  267. */
  268. validateTriggers(
  269. triggers = this.state.triggers,
  270. thresholdType = this.state.thresholdType,
  271. resolveThreshold = this.state.resolveThreshold,
  272. changedTriggerIndex?: number
  273. ) {
  274. const triggerErrors = new Map();
  275. const requiredFields = ['label', 'alertThreshold'];
  276. triggers.forEach((trigger, triggerIndex) => {
  277. requiredFields.forEach(field => {
  278. // check required fields
  279. this.validateFieldInTrigger({
  280. errors: triggerErrors,
  281. triggerIndex,
  282. isValid: (): boolean => {
  283. if (trigger.label === 'critical') {
  284. return !isEmpty(trigger[field]);
  285. }
  286. // If warning trigger has actions, it must have a value
  287. return trigger.actions.length === 0 || !isEmpty(trigger[field]);
  288. },
  289. field,
  290. message: t('Field is required'),
  291. });
  292. });
  293. // Check thresholds
  294. this.isValidTrigger(
  295. changedTriggerIndex ?? triggerIndex,
  296. trigger,
  297. triggerErrors,
  298. resolveThreshold
  299. );
  300. });
  301. // If we have 2 triggers, we need to make sure that the critical and warning
  302. // alert thresholds are valid (e.g. if critical is above x, warning must be less than x)
  303. const criticalTriggerIndex = triggers.findIndex(({label}) => label === 'critical');
  304. const warningTriggerIndex = criticalTriggerIndex ^ 1;
  305. const criticalTrigger = triggers[criticalTriggerIndex];
  306. const warningTrigger = triggers[warningTriggerIndex];
  307. const isEmptyWarningThreshold = isEmpty(warningTrigger.alertThreshold);
  308. const warningThreshold = warningTrigger.alertThreshold ?? 0;
  309. const criticalThreshold = criticalTrigger.alertThreshold ?? 0;
  310. const hasError =
  311. thresholdType === AlertRuleThresholdType.ABOVE
  312. ? warningThreshold > criticalThreshold
  313. : warningThreshold < criticalThreshold;
  314. if (hasError && !isEmptyWarningThreshold) {
  315. [criticalTriggerIndex, warningTriggerIndex].forEach(index => {
  316. const otherErrors = triggerErrors.get(index) ?? {};
  317. triggerErrors.set(index, {
  318. ...otherErrors,
  319. alertThreshold:
  320. thresholdType === AlertRuleThresholdType.BELOW
  321. ? t('Warning threshold must be greater than critical alert')
  322. : t('Warning threshold must be less than critical alert'),
  323. });
  324. });
  325. }
  326. return triggerErrors;
  327. }
  328. handleFieldChange = (name: string, value: unknown) => {
  329. if (
  330. ['dataset', 'eventTypes', 'timeWindow', 'environment', 'aggregate'].includes(name)
  331. ) {
  332. this.setState({[name]: value});
  333. }
  334. };
  335. // We handle the filter update outside of the fieldChange handler since we
  336. // don't want to update the filter on every input change, just on blurs and
  337. // searches.
  338. handleFilterUpdate = (query: string) => {
  339. const {organization, sessionId} = this.props;
  340. trackAnalyticsEvent({
  341. eventKey: 'alert_builder.filter',
  342. eventName: 'Alert Builder: Filter',
  343. query,
  344. organization_id: organization.id,
  345. session_id: sessionId,
  346. });
  347. this.setState({query});
  348. };
  349. handleSubmit = async (
  350. _data: Partial<IncidentRule>,
  351. _onSubmitSuccess,
  352. _onSubmitError,
  353. _e,
  354. model: FormModel
  355. ) => {
  356. // This validates all fields *except* for Triggers
  357. const validRule = model.validateForm();
  358. // Validate Triggers
  359. const triggerErrors = this.validateTriggers();
  360. const validTriggers = Array.from(triggerErrors).length === 0;
  361. if (!validTriggers) {
  362. this.setState(state => ({
  363. triggerErrors: new Map([...triggerErrors, ...state.triggerErrors]),
  364. }));
  365. }
  366. if (!validRule || !validTriggers) {
  367. addErrorMessage(t('Alert not valid'));
  368. return;
  369. }
  370. const {organization, params, rule, onSubmitSuccess, location, sessionId} = this.props;
  371. const {ruleId} = this.props.params;
  372. const {resolveThreshold, triggers, thresholdType, uuid} = this.state;
  373. // Remove empty warning trigger
  374. const sanitizedTriggers = triggers.filter(
  375. trigger => trigger.label !== 'warning' || !isEmpty(trigger.alertThreshold)
  376. );
  377. // form model has all form state data, however we use local state to keep
  378. // track of the list of triggers (and actions within triggers)
  379. const loadingIndicator = IndicatorStore.addMessage(
  380. t('Saving your alert rule, hold on...'),
  381. 'loading'
  382. );
  383. try {
  384. const transaction = metric.startTransaction({name: 'saveAlertRule'});
  385. transaction.setTag('type', 'metric');
  386. transaction.setTag('operation', !rule.id ? 'create' : 'edit');
  387. for (const trigger of sanitizedTriggers) {
  388. for (const action of trigger.actions) {
  389. if (action.type === 'slack') {
  390. transaction.setTag(action.type, true);
  391. if (action.integrationId) {
  392. transaction.setTag(`integrationId:${action.integrationId}`, true);
  393. }
  394. }
  395. }
  396. }
  397. transaction.setData('actions', sanitizedTriggers);
  398. this.setState({loading: true});
  399. const [resp, , xhr] = await addOrUpdateRule(
  400. this.api,
  401. organization.slug,
  402. params.projectId,
  403. {
  404. ...rule,
  405. ...model.getTransformedData(),
  406. triggers: sanitizedTriggers,
  407. resolveThreshold: isEmpty(resolveThreshold) ? null : resolveThreshold,
  408. thresholdType,
  409. },
  410. {
  411. referrer: location?.query?.referrer,
  412. sessionId,
  413. }
  414. );
  415. // if we get a 202 back it means that we have an async task
  416. // running to lookup and verify the channel id for Slack.
  417. if (xhr && xhr.status === 202) {
  418. // if we have a uuid in state, no need to start a new polling cycle
  419. if (!uuid) {
  420. this.setState({loading: true, uuid: resp.uuid});
  421. this.fetchStatus(model);
  422. }
  423. } else {
  424. IndicatorStore.remove(loadingIndicator);
  425. this.setState({loading: false});
  426. addSuccessMessage(ruleId ? t('Updated alert rule') : t('Created alert rule'));
  427. if (onSubmitSuccess) {
  428. onSubmitSuccess(resp, model);
  429. }
  430. }
  431. } catch (err) {
  432. IndicatorStore.remove(loadingIndicator);
  433. this.setState({loading: false});
  434. const errors = err?.responseJSON
  435. ? Array.isArray(err?.responseJSON)
  436. ? err?.responseJSON
  437. : Object.values(err?.responseJSON)
  438. : [];
  439. const apiErrors = errors.length > 0 ? `: ${errors.join(', ')}` : '';
  440. this.handleRuleSaveFailure(t('Unable to save alert%s', apiErrors));
  441. }
  442. };
  443. /**
  444. * Callback for when triggers change
  445. *
  446. * Re-validate triggers on every change and reset indicators when no errors
  447. */
  448. handleChangeTriggers = (triggers: Trigger[], triggerIndex?: number) => {
  449. this.setState(state => {
  450. let triggerErrors = state.triggerErrors;
  451. const newTriggerErrors = this.validateTriggers(
  452. triggers,
  453. state.thresholdType,
  454. state.resolveThreshold,
  455. triggerIndex
  456. );
  457. triggerErrors = newTriggerErrors;
  458. if (Array.from(newTriggerErrors).length === 0) {
  459. clearIndicators();
  460. }
  461. return {triggers, triggerErrors};
  462. });
  463. };
  464. handleThresholdTypeChange = (thresholdType: AlertRuleThresholdType) => {
  465. const {triggers} = this.state;
  466. const triggerErrors = this.validateTriggers(triggers, thresholdType);
  467. this.setState(state => ({
  468. thresholdType,
  469. triggerErrors: new Map([...triggerErrors, ...state.triggerErrors]),
  470. }));
  471. };
  472. handleResolveThresholdChange = (
  473. resolveThreshold: UnsavedIncidentRule['resolveThreshold']
  474. ) => {
  475. const {triggers} = this.state;
  476. const triggerErrors = this.validateTriggers(triggers, undefined, resolveThreshold);
  477. this.setState(state => ({
  478. resolveThreshold,
  479. triggerErrors: new Map([...triggerErrors, ...state.triggerErrors]),
  480. }));
  481. };
  482. handleDeleteRule = async () => {
  483. const {params} = this.props;
  484. const {orgId, projectId, ruleId} = params;
  485. try {
  486. await this.api.requestPromise(
  487. `/projects/${orgId}/${projectId}/alert-rules/${ruleId}/`,
  488. {
  489. method: 'DELETE',
  490. }
  491. );
  492. this.goBack();
  493. } catch (_err) {
  494. addErrorMessage(t('Error deleting rule'));
  495. }
  496. };
  497. handleRuleSaveFailure = (msg: React.ReactNode) => {
  498. addErrorMessage(msg);
  499. metric.endTransaction({name: 'saveAlertRule'});
  500. };
  501. handleCancel = () => {
  502. this.goBack();
  503. };
  504. renderLoading() {
  505. return this.renderBody();
  506. }
  507. renderBody() {
  508. const {
  509. organization,
  510. ruleId,
  511. rule,
  512. params,
  513. onSubmitSuccess,
  514. project,
  515. userTeamIds,
  516. isCustomMetric,
  517. } = this.props;
  518. const {
  519. query,
  520. timeWindow,
  521. triggers,
  522. aggregate,
  523. environment,
  524. thresholdType,
  525. resolveThreshold,
  526. loading,
  527. eventTypes,
  528. dataset,
  529. } = this.state;
  530. const eventTypeFilter = getEventTypeFilter(this.state.dataset, eventTypes);
  531. const queryWithTypeFilter = `${query} ${eventTypeFilter}`.trim();
  532. const chartProps = {
  533. organization,
  534. projects: this.state.projects,
  535. triggers,
  536. query: queryWithTypeFilter,
  537. aggregate,
  538. timeWindow,
  539. environment,
  540. resolveThreshold,
  541. thresholdType,
  542. };
  543. const alertType = getAlertTypeFromAggregateDataset({aggregate, dataset});
  544. const wizardBuilderChart = (
  545. <TriggersChart
  546. {...chartProps}
  547. header={
  548. <ChartHeader>
  549. <AlertName>{AlertWizardAlertNames[alertType]}</AlertName>
  550. <AlertInfo>
  551. {aggregate} | event.type:{eventTypes?.join(',')}
  552. </AlertInfo>
  553. </ChartHeader>
  554. }
  555. />
  556. );
  557. const chart = <TriggersChart {...chartProps} />;
  558. const ownerId = rule.owner?.split(':')[1];
  559. const canEdit = ownerId ? userTeamIds.has(ownerId) : true;
  560. const triggerForm = (hasAccess: boolean) => (
  561. <Triggers
  562. disabled={!hasAccess || !canEdit}
  563. projects={this.state.projects}
  564. errors={this.state.triggerErrors}
  565. triggers={triggers}
  566. aggregate={aggregate}
  567. resolveThreshold={resolveThreshold}
  568. thresholdType={thresholdType}
  569. currentProject={params.projectId}
  570. organization={organization}
  571. ruleId={ruleId}
  572. availableActions={this.state.availableActions}
  573. onChange={this.handleChangeTriggers}
  574. onThresholdTypeChange={this.handleThresholdTypeChange}
  575. onResolveThresholdChange={this.handleResolveThresholdChange}
  576. />
  577. );
  578. const ruleNameOwnerForm = (hasAccess: boolean) => (
  579. <RuleNameOwnerForm
  580. disabled={!hasAccess || !canEdit}
  581. organization={organization}
  582. project={project}
  583. userTeamIds={userTeamIds}
  584. />
  585. );
  586. return (
  587. <Access access={['alerts:write']}>
  588. {({hasAccess}) => (
  589. <Form
  590. apiMethod={ruleId ? 'PUT' : 'POST'}
  591. apiEndpoint={`/organizations/${organization.slug}/alert-rules/${
  592. ruleId ? `${ruleId}/` : ''
  593. }`}
  594. submitDisabled={!hasAccess || loading || !canEdit}
  595. initialData={{
  596. name: rule.name || '',
  597. dataset: rule.dataset,
  598. eventTypes: rule.eventTypes,
  599. aggregate: rule.aggregate,
  600. query: rule.query || '',
  601. timeWindow: rule.timeWindow,
  602. environment: rule.environment || null,
  603. owner: rule.owner,
  604. }}
  605. saveOnBlur={false}
  606. onSubmit={this.handleSubmit}
  607. onSubmitSuccess={onSubmitSuccess}
  608. onCancel={this.handleCancel}
  609. onFieldChange={this.handleFieldChange}
  610. extraButton={
  611. !!rule.id ? (
  612. <Confirm
  613. disabled={!hasAccess || !canEdit}
  614. message={t('Are you sure you want to delete this alert rule?')}
  615. header={t('Delete Alert Rule?')}
  616. priority="danger"
  617. confirmText={t('Delete Rule')}
  618. onConfirm={this.handleDeleteRule}
  619. >
  620. <Button type="button" priority="danger">
  621. {t('Delete Rule')}
  622. </Button>
  623. </Confirm>
  624. ) : null
  625. }
  626. submitLabel={t('Save Rule')}
  627. >
  628. <Feature organization={organization} features={['alert-wizard']}>
  629. {({hasFeature}) =>
  630. hasFeature ? (
  631. <List symbol="colored-numeric">
  632. <RuleConditionsFormForWizard
  633. api={this.api}
  634. projectSlug={params.projectId}
  635. organization={organization}
  636. disabled={!hasAccess || !canEdit}
  637. thresholdChart={wizardBuilderChart}
  638. onFilterSearch={this.handleFilterUpdate}
  639. allowChangeEventTypes={dataset === Dataset.ERRORS}
  640. alertType={isCustomMetric ? 'custom' : alertType}
  641. />
  642. <AlertListItem>{t('Set thresholds to trigger alert')}</AlertListItem>
  643. {triggerForm(hasAccess)}
  644. <StyledListItem>{t('Add a rule name and team')}</StyledListItem>
  645. {ruleNameOwnerForm(hasAccess)}
  646. </List>
  647. ) : (
  648. <React.Fragment>
  649. <RuleConditionsForm
  650. api={this.api}
  651. projectSlug={params.projectId}
  652. organization={organization}
  653. disabled={!hasAccess || !canEdit}
  654. thresholdChart={chart}
  655. onFilterSearch={this.handleFilterUpdate}
  656. />
  657. <List symbol="colored-numeric" initialCounterValue={2}>
  658. {triggerForm(hasAccess)}
  659. {ruleNameOwnerForm(hasAccess)}
  660. </List>
  661. </React.Fragment>
  662. )
  663. }
  664. </Feature>
  665. </Form>
  666. )}
  667. </Access>
  668. );
  669. }
  670. }
  671. const StyledListItem = styled(ListItem)`
  672. margin: ${space(2)} 0 ${space(1)} 0;
  673. font-size: ${p => p.theme.fontSizeExtraLarge};
  674. `;
  675. const AlertListItem = styled(StyledListItem)`
  676. margin-top: 0;
  677. `;
  678. const ChartHeader = styled('div')`
  679. padding: ${space(3)} ${space(3)} 0 ${space(3)};
  680. `;
  681. const AlertName = styled('div')`
  682. font-size: ${p => p.theme.fontSizeExtraLarge};
  683. font-weight: normal;
  684. color: ${p => p.theme.textColor};
  685. `;
  686. const AlertInfo = styled('div')`
  687. font-size: ${p => p.theme.fontSizeMedium};
  688. font-family: ${p => p.theme.text.familyMono};
  689. font-weight: normal;
  690. color: ${p => p.theme.subText};
  691. `;
  692. export {RuleFormContainer};
  693. export default RuleFormContainer;