ruleDetails.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
  1. import type {RouteComponentProps} from 'react-router';
  2. import styled from '@emotion/styled';
  3. import pick from 'lodash/pick';
  4. import moment from 'moment';
  5. import Access from 'sentry/components/acl/access';
  6. import {Alert} from 'sentry/components/alert';
  7. import SnoozeAlert from 'sentry/components/alerts/snoozeAlert';
  8. import Breadcrumbs from 'sentry/components/breadcrumbs';
  9. import {Button} from 'sentry/components/button';
  10. import ButtonBar from 'sentry/components/buttonBar';
  11. import type {DateTimeObject} from 'sentry/components/charts/utils';
  12. import ErrorBoundary from 'sentry/components/errorBoundary';
  13. import IdBadge from 'sentry/components/idBadge';
  14. import * as Layout from 'sentry/components/layouts/thirds';
  15. import Link from 'sentry/components/links/link';
  16. import LoadingError from 'sentry/components/loadingError';
  17. import LoadingIndicator from 'sentry/components/loadingIndicator';
  18. import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container';
  19. import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
  20. import {ChangeData} from 'sentry/components/organizations/timeRangeSelector';
  21. import PageTimeRangeSelector from 'sentry/components/pageTimeRangeSelector';
  22. import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
  23. import {IconCopy, IconEdit} from 'sentry/icons';
  24. import {t, tct} from 'sentry/locale';
  25. import {space} from 'sentry/styles/space';
  26. import type {DateString} from 'sentry/types';
  27. import type {IssueAlertRule} from 'sentry/types/alerts';
  28. import {trackAnalytics} from 'sentry/utils/analytics';
  29. import {
  30. ApiQueryKey,
  31. setApiQueryData,
  32. useApiQuery,
  33. useQueryClient,
  34. } from 'sentry/utils/queryClient';
  35. import useRouteAnalyticsEventNames from 'sentry/utils/routeAnalytics/useRouteAnalyticsEventNames';
  36. import useRouteAnalyticsParams from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams';
  37. import useOrganization from 'sentry/utils/useOrganization';
  38. import useProjects from 'sentry/utils/useProjects';
  39. import {findIncompatibleRules} from 'sentry/views/alerts/rules/issue';
  40. import {ALERT_DEFAULT_CHART_PERIOD} from 'sentry/views/alerts/rules/metric/details/constants';
  41. import {IssueAlertDetailsChart} from './alertChart';
  42. import AlertRuleIssuesList from './issuesList';
  43. import Sidebar from './sidebar';
  44. interface AlertRuleDetailsProps
  45. extends RouteComponentProps<{projectId: string; ruleId: string}, {}> {}
  46. const PAGE_QUERY_PARAMS = [
  47. 'pageStatsPeriod',
  48. 'pageStart',
  49. 'pageEnd',
  50. 'pageUtc',
  51. 'cursor',
  52. ];
  53. const getIssueAlertDetailsQueryKey = ({
  54. orgSlug,
  55. projectSlug,
  56. ruleId,
  57. }: {
  58. orgSlug: string;
  59. projectSlug: string;
  60. ruleId: string;
  61. }): ApiQueryKey => [
  62. `/projects/${orgSlug}/${projectSlug}/rules/${ruleId}/`,
  63. {query: {expand: 'lastTriggered'}},
  64. ];
  65. function AlertRuleDetails({params, location, router}: AlertRuleDetailsProps) {
  66. const queryClient = useQueryClient();
  67. const organization = useOrganization();
  68. const {projects, fetching: projectIsLoading} = useProjects();
  69. const project = projects.find(({slug}) => slug === params.projectId);
  70. const {projectId: projectSlug, ruleId} = params;
  71. const {
  72. data: rule,
  73. isLoading,
  74. isError,
  75. } = useApiQuery<IssueAlertRule>(
  76. getIssueAlertDetailsQueryKey({orgSlug: organization.slug, projectSlug, ruleId}),
  77. {staleTime: 0}
  78. );
  79. useRouteAnalyticsEventNames(
  80. 'issue_alert_rule_details.viewed',
  81. 'Issue Alert Rule Details: Viewed'
  82. );
  83. useRouteAnalyticsParams({rule_id: parseInt(params.ruleId, 10)});
  84. function getDataDatetime(): DateTimeObject {
  85. const query = location?.query ?? {};
  86. const {
  87. start,
  88. end,
  89. statsPeriod,
  90. utc: utcString,
  91. } = normalizeDateTimeParams(query, {
  92. allowEmptyPeriod: true,
  93. allowAbsoluteDatetime: true,
  94. allowAbsolutePageDatetime: true,
  95. });
  96. if (!statsPeriod && !start && !end) {
  97. return {period: ALERT_DEFAULT_CHART_PERIOD};
  98. }
  99. // Following getParams, statsPeriod will take priority over start/end
  100. if (statsPeriod) {
  101. return {period: statsPeriod};
  102. }
  103. const utc = utcString === 'true';
  104. if (start && end) {
  105. return utc
  106. ? {
  107. start: moment.utc(start).format(),
  108. end: moment.utc(end).format(),
  109. utc,
  110. }
  111. : {
  112. start: moment(start).utc().format(),
  113. end: moment(end).utc().format(),
  114. utc,
  115. };
  116. }
  117. return {period: ALERT_DEFAULT_CHART_PERIOD};
  118. }
  119. function setStateOnUrl(nextState: {
  120. cursor?: string;
  121. pageEnd?: DateString;
  122. pageStart?: DateString;
  123. pageStatsPeriod?: string | null;
  124. pageUtc?: boolean | null;
  125. team?: string;
  126. }) {
  127. return router.push({
  128. ...location,
  129. query: {
  130. ...location.query,
  131. ...pick(nextState, PAGE_QUERY_PARAMS),
  132. },
  133. });
  134. }
  135. function onSnooze({
  136. snooze,
  137. snoozeCreatedBy,
  138. snoozeForEveryone,
  139. }: {
  140. snooze: boolean;
  141. snoozeCreatedBy?: string;
  142. snoozeForEveryone?: boolean;
  143. }) {
  144. setApiQueryData<IssueAlertRule>(
  145. queryClient,
  146. getIssueAlertDetailsQueryKey({orgSlug: organization.slug, projectSlug, ruleId}),
  147. alertRule => ({...alertRule, snooze, snoozeCreatedBy, snoozeForEveryone})
  148. );
  149. }
  150. function handleUpdateDatetime(datetime: ChangeData) {
  151. const {start, end, relative, utc} = datetime;
  152. if (start && end) {
  153. const parser = utc ? moment.utc : moment;
  154. return setStateOnUrl({
  155. pageStatsPeriod: undefined,
  156. pageStart: parser(start).format(),
  157. pageEnd: parser(end).format(),
  158. pageUtc: utc ?? undefined,
  159. cursor: undefined,
  160. });
  161. }
  162. return setStateOnUrl({
  163. pageStatsPeriod: relative || undefined,
  164. pageStart: undefined,
  165. pageEnd: undefined,
  166. pageUtc: undefined,
  167. cursor: undefined,
  168. });
  169. }
  170. if (isLoading || projectIsLoading) {
  171. return (
  172. <Layout.Body>
  173. <Layout.Main fullWidth>
  174. <LoadingIndicator />
  175. </Layout.Main>
  176. </Layout.Body>
  177. );
  178. }
  179. if (!rule || isError) {
  180. return (
  181. <StyledLoadingError
  182. message={t('The alert rule you were looking for was not found.')}
  183. />
  184. );
  185. }
  186. if (!project) {
  187. return (
  188. <StyledLoadingError
  189. message={t('The project you were looking for was not found.')}
  190. />
  191. );
  192. }
  193. const hasSnoozeFeature = organization.features.includes('mute-alerts');
  194. const isSnoozed = rule.snooze;
  195. const duplicateLink = {
  196. pathname: `/organizations/${organization.slug}/alerts/new/issue/`,
  197. query: {
  198. project: project.slug,
  199. duplicateRuleId: rule.id,
  200. createFromDuplicate: true,
  201. referrer: 'issue_rule_details',
  202. },
  203. };
  204. function renderIncompatibleAlert() {
  205. const incompatibleRule = findIncompatibleRules(rule);
  206. if (
  207. (incompatibleRule.conditionIndices || incompatibleRule.filterIndices) &&
  208. organization.features.includes('issue-alert-incompatible-rules')
  209. ) {
  210. return (
  211. <Alert type="error" showIcon>
  212. {tct(
  213. 'The conditions in this alert rule conflict and might not be working properly. [link:Edit alert rule]',
  214. {
  215. link: (
  216. <Link
  217. to={`/organizations/${organization.slug}/alerts/rules/${projectSlug}/${ruleId}/`}
  218. />
  219. ),
  220. }
  221. )}
  222. </Alert>
  223. );
  224. }
  225. return null;
  226. }
  227. const {period, start, end, utc} = getDataDatetime();
  228. const {cursor} = location.query;
  229. return (
  230. <PageFiltersContainer
  231. skipInitializeUrlParams
  232. skipLoadLastUsed
  233. shouldForceProject
  234. forceProject={project}
  235. >
  236. <SentryDocumentTitle
  237. title={rule.name}
  238. orgSlug={organization.slug}
  239. projectSlug={projectSlug}
  240. />
  241. <Layout.Header>
  242. <Layout.HeaderContent>
  243. <Breadcrumbs
  244. crumbs={[
  245. {
  246. label: t('Alerts'),
  247. to: `/organizations/${organization.slug}/alerts/rules/`,
  248. },
  249. {
  250. label: rule.name,
  251. to: null,
  252. },
  253. ]}
  254. />
  255. <Layout.Title>
  256. <IdBadge
  257. project={project}
  258. avatarSize={28}
  259. hideName
  260. avatarProps={{hasTooltip: true, tooltip: project.slug}}
  261. />
  262. {rule.name}
  263. </Layout.Title>
  264. </Layout.HeaderContent>
  265. <Layout.HeaderActions>
  266. <ButtonBar gap={1}>
  267. {hasSnoozeFeature && (
  268. <Access access={['alerts:write']}>
  269. {({hasAccess}) => (
  270. <SnoozeAlert
  271. isSnoozed={isSnoozed}
  272. onSnooze={onSnooze}
  273. ruleId={rule.id}
  274. projectSlug={projectSlug}
  275. hasAccess={hasAccess}
  276. />
  277. )}
  278. </Access>
  279. )}
  280. <Button size="sm" icon={<IconCopy />} to={duplicateLink}>
  281. {t('Duplicate')}
  282. </Button>
  283. <Button
  284. size="sm"
  285. icon={<IconEdit />}
  286. to={`/organizations/${organization.slug}/alerts/rules/${projectSlug}/${ruleId}/`}
  287. onClick={() =>
  288. trackAnalytics('issue_alert_rule_details.edit_clicked', {
  289. organization,
  290. rule_id: parseInt(ruleId, 10),
  291. })
  292. }
  293. >
  294. {t('Edit Rule')}
  295. </Button>
  296. </ButtonBar>
  297. </Layout.HeaderActions>
  298. </Layout.Header>
  299. <Layout.Body>
  300. <Layout.Main>
  301. {renderIncompatibleAlert()}
  302. {hasSnoozeFeature && isSnoozed && (
  303. <Alert showIcon>
  304. {tct(
  305. "[creator] muted this alert[forEveryone]so you won't get these notifications in the future.",
  306. {
  307. creator: rule.snoozeCreatedBy,
  308. forEveryone: rule.snoozeForEveryone ? ' for everyone ' : ' ',
  309. }
  310. )}
  311. </Alert>
  312. )}
  313. <StyledPageTimeRangeSelector
  314. organization={organization}
  315. relative={period ?? ''}
  316. start={start ?? null}
  317. end={end ?? null}
  318. utc={utc ?? null}
  319. onUpdate={handleUpdateDatetime}
  320. />
  321. <ErrorBoundary>
  322. <IssueAlertDetailsChart
  323. project={project}
  324. rule={rule}
  325. period={period ?? ''}
  326. start={start ?? null}
  327. end={end ?? null}
  328. utc={utc ?? null}
  329. />
  330. </ErrorBoundary>
  331. <AlertRuleIssuesList
  332. organization={organization}
  333. project={project}
  334. rule={rule}
  335. period={period ?? ''}
  336. start={start ?? null}
  337. end={end ?? null}
  338. utc={utc ?? null}
  339. cursor={cursor}
  340. />
  341. </Layout.Main>
  342. <Layout.Side>
  343. <Sidebar rule={rule} projectSlug={project.slug} teams={project.teams} />
  344. </Layout.Side>
  345. </Layout.Body>
  346. </PageFiltersContainer>
  347. );
  348. }
  349. export default AlertRuleDetails;
  350. const StyledPageTimeRangeSelector = styled(PageTimeRangeSelector)`
  351. margin-bottom: ${space(2)};
  352. `;
  353. const StyledLoadingError = styled(LoadingError)`
  354. margin: ${space(2)};
  355. `;