index.tsx 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
  1. import {Component, Fragment} from 'react';
  2. import type {Location} from 'history';
  3. import isEqual from 'lodash/isEqual';
  4. import pick from 'lodash/pick';
  5. import moment from 'moment-timezone';
  6. import {fetchOrgMembers} from 'sentry/actionCreators/members';
  7. import type {Client, ResponseMeta} from 'sentry/api';
  8. import {Alert} from 'sentry/components/alert';
  9. import {DateTime} from 'sentry/components/dateTime';
  10. import * as Layout from 'sentry/components/layouts/thirds';
  11. import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container';
  12. import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
  13. import {t} from 'sentry/locale';
  14. import type {RouteComponentProps} from 'sentry/types/legacyReactRouter';
  15. import type {Organization} from 'sentry/types/organization';
  16. import type {Project} from 'sentry/types/project';
  17. import {trackAnalytics} from 'sentry/utils/analytics';
  18. import {getUtcDateString} from 'sentry/utils/dates';
  19. import withApi from 'sentry/utils/withApi';
  20. import withProjects from 'sentry/utils/withProjects';
  21. import type {MetricRule} from 'sentry/views/alerts/rules/metric/types';
  22. import {TimePeriod} from 'sentry/views/alerts/rules/metric/types';
  23. import type {Anomaly, Incident} from 'sentry/views/alerts/types';
  24. import {
  25. fetchAlertRule,
  26. fetchAnomaliesForRule,
  27. fetchIncident,
  28. fetchIncidentsForRule,
  29. } from 'sentry/views/alerts/utils/apiCalls';
  30. import MetricDetailsBody from './body';
  31. import type {TimePeriodType} from './constants';
  32. import {ALERT_RULE_STATUS, TIME_OPTIONS, TIME_WINDOWS} from './constants';
  33. import DetailsHeader from './header';
  34. import {buildMetricGraphDateRange} from './utils';
  35. interface Props extends RouteComponentProps<{ruleId: string}, {}> {
  36. api: Client;
  37. location: Location;
  38. organization: Organization;
  39. projects: Project[];
  40. loadingProjects?: boolean;
  41. }
  42. interface State {
  43. error: ResponseMeta | null;
  44. hasError: boolean;
  45. isLoading: boolean;
  46. selectedIncident: Incident | null;
  47. anomalies?: Anomaly[];
  48. incidents?: Incident[];
  49. rule?: MetricRule;
  50. warning?: string;
  51. }
  52. class MetricAlertDetails extends Component<Props, State> {
  53. state: State = {isLoading: false, hasError: false, error: null, selectedIncident: null};
  54. componentDidMount() {
  55. const {api, organization} = this.props;
  56. fetchOrgMembers(api, organization.slug);
  57. this.fetchData();
  58. this.trackView();
  59. }
  60. componentDidUpdate(prevProps: Props) {
  61. const prevQuery = pick(prevProps.location.query, ['start', 'end', 'period', 'alert']);
  62. const nextQuery = pick(this.props.location.query, [
  63. 'start',
  64. 'end',
  65. 'period',
  66. 'alert',
  67. ]);
  68. if (
  69. !isEqual(prevQuery, nextQuery) ||
  70. prevProps.organization.slug !== this.props.organization.slug ||
  71. prevProps.params.ruleId !== this.props.params.ruleId
  72. ) {
  73. this.fetchData();
  74. this.trackView();
  75. }
  76. }
  77. trackView() {
  78. const {params, organization, location} = this.props;
  79. trackAnalytics('alert_rule_details.viewed', {
  80. organization,
  81. rule_id: parseInt(params.ruleId, 10),
  82. alert: (location.query.alert as string) ?? '',
  83. has_chartcuterie: organization.features
  84. .includes('metric-alert-chartcuterie')
  85. .toString(),
  86. });
  87. }
  88. getTimePeriod(selectedIncident: Incident | null): TimePeriodType {
  89. const {location} = this.props;
  90. const period = (location.query.period as string) ?? TimePeriod.SEVEN_DAYS;
  91. if (location.query.start && location.query.end) {
  92. return {
  93. start: location.query.start as string,
  94. end: location.query.end as string,
  95. period,
  96. usingPeriod: false,
  97. label: t('Custom time'),
  98. display: (
  99. <Fragment>
  100. <DateTime date={moment.utc(location.query.start)} />
  101. {' — '}
  102. <DateTime date={moment.utc(location.query.end)} />
  103. </Fragment>
  104. ),
  105. custom: true,
  106. };
  107. }
  108. if (location.query.alert && selectedIncident) {
  109. const {start, end} = buildMetricGraphDateRange(selectedIncident);
  110. return {
  111. start,
  112. end,
  113. period,
  114. usingPeriod: false,
  115. label: t('Custom time'),
  116. display: (
  117. <Fragment>
  118. <DateTime date={moment.utc(start)} />
  119. {' — '}
  120. <DateTime date={moment.utc(end)} />
  121. </Fragment>
  122. ),
  123. custom: true,
  124. };
  125. }
  126. const timeOption =
  127. TIME_OPTIONS.find(item => item.value === period) ?? TIME_OPTIONS[1];
  128. const start = getUtcDateString(
  129. moment(moment.utc().diff(TIME_WINDOWS[timeOption.value]))
  130. );
  131. const end = getUtcDateString(moment.utc());
  132. return {
  133. start,
  134. end,
  135. period,
  136. usingPeriod: true,
  137. label: timeOption.label as string,
  138. display: timeOption.label as string,
  139. };
  140. }
  141. onSnooze = ({
  142. snooze,
  143. snoozeCreatedBy,
  144. snoozeForEveryone,
  145. }: {
  146. snooze: boolean;
  147. snoozeCreatedBy?: string;
  148. snoozeForEveryone?: boolean;
  149. }) => {
  150. if (this.state.rule) {
  151. const rule = {...this.state.rule, snooze, snoozeCreatedBy, snoozeForEveryone};
  152. this.setState({rule});
  153. }
  154. };
  155. fetchData = async () => {
  156. const {
  157. api,
  158. organization,
  159. params: {ruleId},
  160. location,
  161. } = this.props;
  162. this.setState({isLoading: true, hasError: false});
  163. // Skip loading existing rule
  164. const rulePromise =
  165. ruleId === this.state.rule?.id
  166. ? Promise.resolve(this.state.rule)
  167. : fetchAlertRule(organization.slug, ruleId, {expand: 'latestIncident'});
  168. // Fetch selected incident, if it exists. We need this to set the selected date range
  169. let selectedIncident: Incident | null = null;
  170. if (location.query.alert) {
  171. try {
  172. selectedIncident = await fetchIncident(
  173. api,
  174. organization.slug,
  175. location.query.alert as string
  176. );
  177. } catch {
  178. // TODO: selectedIncident specific error
  179. }
  180. }
  181. const timePeriod = this.getTimePeriod(selectedIncident);
  182. const {start, end} = timePeriod;
  183. try {
  184. const [incidents, rule, anomalies] = await Promise.all([
  185. fetchIncidentsForRule(organization.slug, ruleId, start, end),
  186. rulePromise,
  187. organization.features.includes('anomaly-detection-alerts-charts')
  188. ? fetchAnomaliesForRule(organization.slug, ruleId, start, end)
  189. : undefined, // NOTE: there's no way for us to determine the alert rule detection type here.
  190. // proxy API will need to determine whether to fetch anomalies or not
  191. ]);
  192. // NOTE: 'anomaly-detection-alerts-charts' flag does not exist
  193. // Flag can be enabled IF we want to enable marked lines/areas for anomalies in the future
  194. // For now, we defer to incident lines as indicators for anomalies
  195. let warning;
  196. if (rule.status === ALERT_RULE_STATUS.NOT_ENOUGH_DATA) {
  197. warning =
  198. 'Insufficient data for anomaly detection. This feature will enable automatically when more data is available.';
  199. }
  200. this.setState({
  201. anomalies,
  202. incidents,
  203. rule,
  204. warning,
  205. selectedIncident,
  206. isLoading: false,
  207. hasError: false,
  208. });
  209. } catch (error) {
  210. this.setState({selectedIncident, isLoading: false, hasError: true, error});
  211. }
  212. };
  213. renderError() {
  214. const {error} = this.state;
  215. return (
  216. <Layout.Page withPadding>
  217. <Alert type="error" showIcon>
  218. {error?.status === 404
  219. ? t('This alert rule could not be found.')
  220. : t('An error occurred while fetching the alert rule.')}
  221. </Alert>
  222. </Layout.Page>
  223. );
  224. }
  225. render() {
  226. const {rule, incidents, hasError, selectedIncident, anomalies, warning} = this.state;
  227. const {organization, projects, loadingProjects} = this.props;
  228. const timePeriod = this.getTimePeriod(selectedIncident);
  229. if (hasError) {
  230. return this.renderError();
  231. }
  232. const project = projects.find(({slug}) => slug === rule?.projects[0]) as
  233. | Project
  234. | undefined;
  235. const isGlobalSelectionReady = project !== undefined && !loadingProjects;
  236. return (
  237. <PageFiltersContainer
  238. skipLoadLastUsed
  239. skipInitializeUrlParams
  240. shouldForceProject={isGlobalSelectionReady}
  241. forceProject={project}
  242. >
  243. {warning && (
  244. <Alert type="warning" showIcon>
  245. {warning}
  246. </Alert>
  247. )}
  248. <SentryDocumentTitle title={rule?.name ?? ''} />
  249. <DetailsHeader
  250. hasMetricRuleDetailsError={hasError}
  251. organization={organization}
  252. rule={rule}
  253. project={project}
  254. onSnooze={this.onSnooze}
  255. />
  256. <MetricDetailsBody
  257. {...this.props}
  258. rule={rule}
  259. project={project}
  260. incidents={incidents}
  261. anomalies={anomalies}
  262. timePeriod={timePeriod}
  263. selectedIncident={selectedIncident}
  264. />
  265. </PageFiltersContainer>
  266. );
  267. }
  268. }
  269. export default withApi(withProjects(MetricAlertDetails));