ruleDetails.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511
  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 {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
  6. import Access from 'sentry/components/acl/access';
  7. import {Alert} from 'sentry/components/alert';
  8. import SnoozeAlert from 'sentry/components/alerts/snoozeAlert';
  9. import Breadcrumbs from 'sentry/components/breadcrumbs';
  10. import {Button, LinkButton} from 'sentry/components/button';
  11. import ButtonBar from 'sentry/components/buttonBar';
  12. import type {DateTimeObject} from 'sentry/components/charts/utils';
  13. import ErrorBoundary from 'sentry/components/errorBoundary';
  14. import IdBadge from 'sentry/components/idBadge';
  15. import * as Layout from 'sentry/components/layouts/thirds';
  16. import ExternalLink from 'sentry/components/links/externalLink';
  17. import Link from 'sentry/components/links/link';
  18. import LoadingError from 'sentry/components/loadingError';
  19. import LoadingIndicator from 'sentry/components/loadingIndicator';
  20. import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container';
  21. import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
  22. import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
  23. import {ChangeData, TimeRangeSelector} from 'sentry/components/timeRangeSelector';
  24. import TimeSince from 'sentry/components/timeSince';
  25. import {IconCopy, IconEdit} from 'sentry/icons';
  26. import {t, tct} from 'sentry/locale';
  27. import {space} from 'sentry/styles/space';
  28. import type {DateString} from 'sentry/types';
  29. import type {IssueAlertRule} from 'sentry/types/alerts';
  30. import {RuleActionsCategories} from 'sentry/types/alerts';
  31. import {trackAnalytics} from 'sentry/utils/analytics';
  32. import {
  33. ApiQueryKey,
  34. setApiQueryData,
  35. useApiQuery,
  36. useQueryClient,
  37. } from 'sentry/utils/queryClient';
  38. import useRouteAnalyticsEventNames from 'sentry/utils/routeAnalytics/useRouteAnalyticsEventNames';
  39. import useRouteAnalyticsParams from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams';
  40. import useApi from 'sentry/utils/useApi';
  41. import useOrganization from 'sentry/utils/useOrganization';
  42. import useProjects from 'sentry/utils/useProjects';
  43. import {findIncompatibleRules} from 'sentry/views/alerts/rules/issue';
  44. import {ALERT_DEFAULT_CHART_PERIOD} from 'sentry/views/alerts/rules/metric/details/constants';
  45. import {getRuleActionCategory} from 'sentry/views/alerts/rules/utils';
  46. import {IssueAlertDetailsChart} from './alertChart';
  47. import AlertRuleIssuesList from './issuesList';
  48. import Sidebar from './sidebar';
  49. interface AlertRuleDetailsProps
  50. extends RouteComponentProps<{projectId: string; ruleId: string}, {}> {}
  51. const PAGE_QUERY_PARAMS = [
  52. 'pageStatsPeriod',
  53. 'pageStart',
  54. 'pageEnd',
  55. 'pageUtc',
  56. 'cursor',
  57. ];
  58. const getIssueAlertDetailsQueryKey = ({
  59. orgSlug,
  60. projectSlug,
  61. ruleId,
  62. }: {
  63. orgSlug: string;
  64. projectSlug: string;
  65. ruleId: string;
  66. }): ApiQueryKey => [
  67. `/projects/${orgSlug}/${projectSlug}/rules/${ruleId}/`,
  68. {query: {expand: 'lastTriggered'}},
  69. ];
  70. function AlertRuleDetails({params, location, router}: AlertRuleDetailsProps) {
  71. const queryClient = useQueryClient();
  72. const organization = useOrganization();
  73. const api = useApi();
  74. const {projects, fetching: projectIsLoading} = useProjects();
  75. const project = projects.find(({slug}) => slug === params.projectId);
  76. const {projectId: projectSlug, ruleId} = params;
  77. const {
  78. data: rule,
  79. isLoading,
  80. isError,
  81. } = useApiQuery<IssueAlertRule>(
  82. getIssueAlertDetailsQueryKey({orgSlug: organization.slug, projectSlug, ruleId}),
  83. {staleTime: 0}
  84. );
  85. useRouteAnalyticsEventNames(
  86. 'issue_alert_rule_details.viewed',
  87. 'Issue Alert Rule Details: Viewed'
  88. );
  89. useRouteAnalyticsParams({rule_id: parseInt(params.ruleId, 10)});
  90. function getDataDatetime(): DateTimeObject {
  91. const query = location?.query ?? {};
  92. const {
  93. start,
  94. end,
  95. statsPeriod,
  96. utc: utcString,
  97. } = normalizeDateTimeParams(query, {
  98. allowEmptyPeriod: true,
  99. allowAbsoluteDatetime: true,
  100. allowAbsolutePageDatetime: true,
  101. });
  102. if (!statsPeriod && !start && !end) {
  103. return {period: ALERT_DEFAULT_CHART_PERIOD};
  104. }
  105. // Following getParams, statsPeriod will take priority over start/end
  106. if (statsPeriod) {
  107. return {period: statsPeriod};
  108. }
  109. const utc = utcString === 'true';
  110. if (start && end) {
  111. return utc
  112. ? {
  113. start: moment.utc(start).format(),
  114. end: moment.utc(end).format(),
  115. utc,
  116. }
  117. : {
  118. start: moment(start).utc().format(),
  119. end: moment(end).utc().format(),
  120. utc,
  121. };
  122. }
  123. return {period: ALERT_DEFAULT_CHART_PERIOD};
  124. }
  125. function setStateOnUrl(nextState: {
  126. cursor?: string;
  127. pageEnd?: DateString;
  128. pageStart?: DateString;
  129. pageStatsPeriod?: string | null;
  130. pageUtc?: boolean | null;
  131. team?: string;
  132. }) {
  133. return router.push({
  134. ...location,
  135. query: {
  136. ...location.query,
  137. ...pick(nextState, PAGE_QUERY_PARAMS),
  138. },
  139. });
  140. }
  141. function onSnooze({
  142. snooze,
  143. snoozeCreatedBy,
  144. snoozeForEveryone,
  145. }: {
  146. snooze: boolean;
  147. snoozeCreatedBy?: string;
  148. snoozeForEveryone?: boolean;
  149. }) {
  150. setApiQueryData<IssueAlertRule>(
  151. queryClient,
  152. getIssueAlertDetailsQueryKey({orgSlug: organization.slug, projectSlug, ruleId}),
  153. alertRule => ({...alertRule, snooze, snoozeCreatedBy, snoozeForEveryone})
  154. );
  155. }
  156. async function handleKeepAlertAlive() {
  157. try {
  158. await api.requestPromise(
  159. `/projects/${organization.slug}/${projectSlug}/rules/${ruleId}/`,
  160. {
  161. method: 'PUT',
  162. data: {
  163. ...rule,
  164. optOutExplicit: true,
  165. },
  166. }
  167. );
  168. // Update alert rule to remove disableDate
  169. setApiQueryData<IssueAlertRule>(
  170. queryClient,
  171. getIssueAlertDetailsQueryKey({orgSlug: organization.slug, projectSlug, ruleId}),
  172. alertRule => ({...alertRule, disableDate: undefined})
  173. );
  174. addSuccessMessage(t('Successfully updated'));
  175. } catch (err) {
  176. addErrorMessage(t('Unable to update alert rule'));
  177. }
  178. }
  179. async function handleReEnable() {
  180. try {
  181. await api.requestPromise(
  182. `/projects/${organization.slug}/${projectSlug}/rules/${ruleId}/enable/`,
  183. {method: 'PUT'}
  184. );
  185. // Update alert rule to remove disableDate
  186. setApiQueryData<IssueAlertRule>(
  187. queryClient,
  188. getIssueAlertDetailsQueryKey({orgSlug: organization.slug, projectSlug, ruleId}),
  189. alertRule => ({...alertRule, disableDate: undefined, status: 'active'})
  190. );
  191. addSuccessMessage(t('Successfully re-enabled'));
  192. } catch (err) {
  193. addErrorMessage(
  194. typeof err.responseJSON?.detail === 'string'
  195. ? err.responseJSON.detail
  196. : t('Unable to update alert rule')
  197. );
  198. }
  199. }
  200. function handleUpdateDatetime(datetime: ChangeData) {
  201. const {start, end, relative, utc} = datetime;
  202. if (start && end) {
  203. const parser = utc ? moment.utc : moment;
  204. return setStateOnUrl({
  205. pageStatsPeriod: undefined,
  206. pageStart: parser(start).format(),
  207. pageEnd: parser(end).format(),
  208. pageUtc: utc ?? undefined,
  209. cursor: undefined,
  210. });
  211. }
  212. return setStateOnUrl({
  213. pageStatsPeriod: relative || undefined,
  214. pageStart: undefined,
  215. pageEnd: undefined,
  216. pageUtc: undefined,
  217. cursor: undefined,
  218. });
  219. }
  220. if (isLoading || projectIsLoading) {
  221. return (
  222. <Layout.Body>
  223. <Layout.Main fullWidth>
  224. <LoadingIndicator />
  225. </Layout.Main>
  226. </Layout.Body>
  227. );
  228. }
  229. if (!rule || isError) {
  230. return (
  231. <StyledLoadingError
  232. message={t('The alert rule you were looking for was not found.')}
  233. />
  234. );
  235. }
  236. if (!project) {
  237. return (
  238. <StyledLoadingError
  239. message={t('The project you were looking for was not found.')}
  240. />
  241. );
  242. }
  243. const isSnoozed = rule.snooze;
  244. const ruleActionCategory = getRuleActionCategory(rule);
  245. const duplicateLink = {
  246. pathname: `/organizations/${organization.slug}/alerts/new/issue/`,
  247. query: {
  248. project: project.slug,
  249. duplicateRuleId: rule.id,
  250. createFromDuplicate: true,
  251. referrer: 'issue_rule_details',
  252. },
  253. };
  254. function renderIncompatibleAlert() {
  255. const incompatibleRule = findIncompatibleRules(rule);
  256. if (incompatibleRule.conditionIndices || incompatibleRule.filterIndices) {
  257. return (
  258. <Alert type="error" showIcon>
  259. {tct(
  260. 'The conditions in this alert rule conflict and might not be working properly. [link:Edit alert rule]',
  261. {
  262. link: (
  263. <Link
  264. to={`/organizations/${organization.slug}/alerts/rules/${projectSlug}/${ruleId}/`}
  265. />
  266. ),
  267. }
  268. )}
  269. </Alert>
  270. );
  271. }
  272. return null;
  273. }
  274. function renderDisabledAlertBanner() {
  275. // Rule has been disabled and has a disabled date indicating it was disabled due to lack of activity
  276. if (rule?.status === 'disabled' && moment(new Date()).isAfter(rule.disableDate)) {
  277. return (
  278. <Alert type="warning" showIcon>
  279. {tct(
  280. 'This alert was disabled due to lack of activity. Please [keepAlive] to enable this alert.',
  281. {
  282. keepAlive: (
  283. <BoldButton priority="link" size="sm" onClick={handleReEnable}>
  284. {t('click here')}
  285. </BoldButton>
  286. ),
  287. }
  288. )}
  289. </Alert>
  290. );
  291. }
  292. // Generic rule disabled banner
  293. if (rule?.status === 'disabled') {
  294. return (
  295. <Alert type="warning" showIcon>
  296. {rule.actions?.length === 0
  297. ? t(
  298. 'This alert is disabled due to missing actions. Please edit the alert rule to enable this alert.'
  299. )
  300. : t(
  301. 'This alert is disabled due to its configuration and needs to be edited to be enabled.'
  302. )}
  303. </Alert>
  304. );
  305. }
  306. // Rule to be disabled soon
  307. if (rule?.disableDate && moment(rule.disableDate).isAfter(new Date())) {
  308. return (
  309. <Alert type="warning" showIcon>
  310. {tct(
  311. 'This alert is scheduled to be disabled [date] due to lack of activity. Please [keepAlive] to keep this alert active. [docs:Learn more]',
  312. {
  313. date: <TimeSince date={rule.disableDate} />,
  314. keepAlive: (
  315. <BoldButton priority="link" size="sm" onClick={handleKeepAlertAlive}>
  316. {t('click here')}
  317. </BoldButton>
  318. ),
  319. docs: (
  320. <ExternalLink href="https://docs.sentry.io/product/alerts/#disabled-alerts" />
  321. ),
  322. }
  323. )}
  324. </Alert>
  325. );
  326. }
  327. return null;
  328. }
  329. const {period, start, end, utc} = getDataDatetime();
  330. const {cursor} = location.query;
  331. return (
  332. <PageFiltersContainer
  333. skipInitializeUrlParams
  334. skipLoadLastUsed
  335. shouldForceProject
  336. forceProject={project}
  337. >
  338. <SentryDocumentTitle
  339. title={rule.name}
  340. orgSlug={organization.slug}
  341. projectSlug={projectSlug}
  342. />
  343. <Layout.Header>
  344. <Layout.HeaderContent>
  345. <Breadcrumbs
  346. crumbs={[
  347. {
  348. label: t('Alerts'),
  349. to: `/organizations/${organization.slug}/alerts/rules/`,
  350. },
  351. {
  352. label: rule.name,
  353. to: null,
  354. },
  355. ]}
  356. />
  357. <Layout.Title>
  358. <IdBadge
  359. project={project}
  360. avatarSize={28}
  361. hideName
  362. avatarProps={{hasTooltip: true, tooltip: project.slug}}
  363. />
  364. {rule.name}
  365. </Layout.Title>
  366. </Layout.HeaderContent>
  367. <Layout.HeaderActions>
  368. <ButtonBar gap={1}>
  369. <Access access={['alerts:write']}>
  370. {({hasAccess}) => (
  371. <SnoozeAlert
  372. isSnoozed={isSnoozed}
  373. onSnooze={onSnooze}
  374. ruleId={rule.id}
  375. projectSlug={projectSlug}
  376. ruleActionCategory={ruleActionCategory}
  377. hasAccess={hasAccess}
  378. type="issue"
  379. disabled={rule.status === 'disabled'}
  380. />
  381. )}
  382. </Access>
  383. <LinkButton
  384. size="sm"
  385. icon={<IconCopy />}
  386. to={duplicateLink}
  387. disabled={rule.status === 'disabled'}
  388. >
  389. {t('Duplicate')}
  390. </LinkButton>
  391. <Button
  392. size="sm"
  393. icon={<IconEdit />}
  394. to={`/organizations/${organization.slug}/alerts/rules/${projectSlug}/${ruleId}/`}
  395. onClick={() =>
  396. trackAnalytics('issue_alert_rule_details.edit_clicked', {
  397. organization,
  398. rule_id: parseInt(ruleId, 10),
  399. })
  400. }
  401. >
  402. {rule.status === 'disabled' ? t('Edit to enable') : t('Edit Rule')}
  403. </Button>
  404. </ButtonBar>
  405. </Layout.HeaderActions>
  406. </Layout.Header>
  407. <Layout.Body>
  408. <Layout.Main>
  409. {renderIncompatibleAlert()}
  410. {renderDisabledAlertBanner()}
  411. {isSnoozed && (
  412. <Alert showIcon>
  413. {ruleActionCategory === RuleActionsCategories.NO_DEFAULT
  414. ? tct(
  415. "[creator] muted this alert so these notifications won't be sent in the future.",
  416. {creator: rule.snoozeCreatedBy}
  417. )
  418. : tct(
  419. "[creator] muted this alert[forEveryone]so you won't get these notifications in the future.",
  420. {
  421. creator: rule.snoozeCreatedBy,
  422. forEveryone: rule.snoozeForEveryone ? ' for everyone ' : ' ',
  423. }
  424. )}
  425. </Alert>
  426. )}
  427. <StyledTimeRangeSelector
  428. relative={period ?? ''}
  429. start={start ?? null}
  430. end={end ?? null}
  431. utc={utc ?? null}
  432. onChange={handleUpdateDatetime}
  433. />
  434. <ErrorBoundary>
  435. <IssueAlertDetailsChart
  436. project={project}
  437. rule={rule}
  438. period={period ?? ''}
  439. start={start ?? null}
  440. end={end ?? null}
  441. utc={utc ?? null}
  442. />
  443. </ErrorBoundary>
  444. <AlertRuleIssuesList
  445. organization={organization}
  446. project={project}
  447. rule={rule}
  448. period={period ?? ''}
  449. start={start ?? null}
  450. end={end ?? null}
  451. utc={utc ?? null}
  452. cursor={cursor}
  453. />
  454. </Layout.Main>
  455. <Layout.Side>
  456. <Sidebar rule={rule} projectSlug={project.slug} teams={project.teams} />
  457. </Layout.Side>
  458. </Layout.Body>
  459. </PageFiltersContainer>
  460. );
  461. }
  462. export default AlertRuleDetails;
  463. const StyledTimeRangeSelector = styled(TimeRangeSelector)`
  464. margin-bottom: ${space(2)};
  465. `;
  466. const StyledLoadingError = styled(LoadingError)`
  467. margin: ${space(2)};
  468. `;
  469. const BoldButton = styled(Button)`
  470. font-weight: 600;
  471. `;