alertRulesList.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. import {Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import type {Location} from 'history';
  4. import {
  5. addErrorMessage,
  6. addMessage,
  7. addSuccessMessage,
  8. } from 'sentry/actionCreators/indicator';
  9. import HookOrDefault from 'sentry/components/hookOrDefault';
  10. import * as Layout from 'sentry/components/layouts/thirds';
  11. import Link from 'sentry/components/links/link';
  12. import LoadingError from 'sentry/components/loadingError';
  13. import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container';
  14. import Pagination from 'sentry/components/pagination';
  15. import {PanelTable} from 'sentry/components/panels/panelTable';
  16. import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
  17. import {IconArrow} from 'sentry/icons';
  18. import {t} from 'sentry/locale';
  19. import {space} from 'sentry/styles/space';
  20. import type {Project} from 'sentry/types/project';
  21. import {defined} from 'sentry/utils';
  22. import {uniq} from 'sentry/utils/array/uniq';
  23. import {VisuallyCompleteWithData} from 'sentry/utils/performanceForSentry';
  24. import Projects from 'sentry/utils/projects';
  25. import type {ApiQueryKey} from 'sentry/utils/queryClient';
  26. import {setApiQueryData, useApiQuery, useQueryClient} from 'sentry/utils/queryClient';
  27. import useRouteAnalyticsEventNames from 'sentry/utils/routeAnalytics/useRouteAnalyticsEventNames';
  28. import useRouteAnalyticsParams from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams';
  29. import useApi from 'sentry/utils/useApi';
  30. import {useLocation} from 'sentry/utils/useLocation';
  31. import useOrganization from 'sentry/utils/useOrganization';
  32. import useRouter from 'sentry/utils/useRouter';
  33. import {MetricsRemovedAlertsWidgetsAlert} from '../../../metrics/metricsRemovedAlertsWidgetsAlert';
  34. import FilterBar from '../../filterBar';
  35. import type {CombinedAlerts} from '../../types';
  36. import {AlertRuleType, CombinedAlertType} from '../../types';
  37. import {getTeamParams, isIssueAlert} from '../../utils';
  38. import AlertHeader from '../header';
  39. import RuleListRow from './row';
  40. type SortField = 'date_added' | 'name' | ['incident_status', 'date_triggered'];
  41. const defaultSort: SortField = ['incident_status', 'date_triggered'];
  42. function getAlertListQueryKey(orgSlug: string, query: Location['query']): ApiQueryKey {
  43. const queryParams = {...query};
  44. queryParams.expand = ['latestIncident', 'lastTriggered'];
  45. queryParams.team = getTeamParams(queryParams.team!);
  46. if (!queryParams.sort) {
  47. queryParams.sort = defaultSort;
  48. }
  49. return [`/organizations/${orgSlug}/combined-rules/`, {query: queryParams}];
  50. }
  51. const DataConsentBanner = HookOrDefault({
  52. hookName: 'component:data-consent-banner',
  53. defaultComponent: null,
  54. });
  55. function AlertRulesList() {
  56. const location = useLocation();
  57. const router = useRouter();
  58. const api = useApi();
  59. const queryClient = useQueryClient();
  60. const organization = useOrganization();
  61. useRouteAnalyticsEventNames('alert_rules.viewed', 'Alert Rules: Viewed');
  62. useRouteAnalyticsParams({
  63. sort: Array.isArray(location.query.sort)
  64. ? location.query.sort.join(',')
  65. : location.query.sort,
  66. });
  67. // Fetch alert rules
  68. const {
  69. data: ruleListResponse = [],
  70. refetch,
  71. getResponseHeader,
  72. isPending,
  73. isError,
  74. } = useApiQuery<Array<CombinedAlerts | null>>(
  75. getAlertListQueryKey(organization.slug, location.query),
  76. {
  77. staleTime: 0,
  78. }
  79. );
  80. const handleChangeFilter = (activeFilters: string[]) => {
  81. const {cursor: _cursor, page: _page, ...currentQuery} = location.query;
  82. router.push({
  83. pathname: location.pathname,
  84. query: {
  85. ...currentQuery,
  86. team: activeFilters.length > 0 ? activeFilters : '',
  87. },
  88. });
  89. };
  90. const handleChangeSearch = (name: string) => {
  91. const {cursor: _cursor, page: _page, ...currentQuery} = location.query;
  92. router.push({
  93. pathname: location.pathname,
  94. query: {
  95. ...currentQuery,
  96. name,
  97. },
  98. });
  99. };
  100. const handleChangeType = (alertType: CombinedAlertType[]) => {
  101. const {cursor: _cursor, page: _page, ...currentQuery} = location.query;
  102. router.push({
  103. pathname: location.pathname,
  104. query: {
  105. ...currentQuery,
  106. alertType,
  107. },
  108. });
  109. };
  110. const handleOwnerChange = (
  111. projectId: string,
  112. rule: CombinedAlerts,
  113. ownerValue: string
  114. ) => {
  115. // TODO(davidenwang): Once we have edit apis for uptime alerts, fill this in
  116. if (rule.type === CombinedAlertType.UPTIME) {
  117. return;
  118. }
  119. const endpoint =
  120. rule.type === 'alert_rule'
  121. ? `/organizations/${organization.slug}/alert-rules/${rule.id}`
  122. : `/projects/${organization.slug}/${projectId}/rules/${rule.id}/`;
  123. const updatedRule = {...rule, owner: ownerValue};
  124. api.request(endpoint, {
  125. method: 'PUT',
  126. data: updatedRule,
  127. success: () => {
  128. addMessage(t('Updated alert rule'), 'success');
  129. },
  130. error: () => {
  131. addMessage(t('Unable to save change'), 'error');
  132. },
  133. });
  134. };
  135. const handleDeleteRule = async (projectId: string, rule: CombinedAlerts) => {
  136. const deleteEndpoints: Record<CombinedAlertType, string> = {
  137. [CombinedAlertType.ISSUE]: `/projects/${organization.slug}/${projectId}/rules/${rule.id}/`,
  138. [CombinedAlertType.METRIC]: `/organizations/${organization.slug}/alert-rules/${rule.id}/`,
  139. [CombinedAlertType.UPTIME]: `/projects/${organization.slug}/${projectId}/uptime/${rule.id}/`,
  140. [CombinedAlertType.CRONS]: `/projects/${organization.slug}/${projectId}/monitors/${rule.id}/`,
  141. };
  142. try {
  143. await api.requestPromise(deleteEndpoints[rule.type], {method: 'DELETE'});
  144. setApiQueryData<Array<CombinedAlerts | null>>(
  145. queryClient,
  146. getAlertListQueryKey(organization.slug, location.query),
  147. data => data?.filter(r => r?.id !== rule.id && r?.type !== rule.type)
  148. );
  149. refetch();
  150. addSuccessMessage(t('Deleted rule'));
  151. } catch (_err) {
  152. addErrorMessage(t('Error deleting rule'));
  153. }
  154. };
  155. const hasEditAccess = organization.access.includes('alerts:write');
  156. const ruleList = ruleListResponse.filter(defined);
  157. const projectsFromResults = uniq(
  158. ruleList.flatMap(rule =>
  159. rule.type === CombinedAlertType.UPTIME
  160. ? [rule.projectSlug]
  161. : rule.type === CombinedAlertType.CRONS
  162. ? [rule.project.slug]
  163. : rule.projects
  164. )
  165. );
  166. const ruleListPageLinks = getResponseHeader?.('Link');
  167. const sort: {asc: boolean; field: SortField} = {
  168. asc: location.query.asc === '1',
  169. field: (location.query.sort as SortField) || defaultSort,
  170. };
  171. const {cursor: _cursor, page: _page, ...currentQuery} = location.query;
  172. const isAlertRuleSort =
  173. sort.field.includes('incident_status') || sort.field.includes('date_triggered');
  174. const sortArrow = (
  175. <IconArrow color="gray300" size="xs" direction={sort.asc ? 'up' : 'down'} />
  176. );
  177. return (
  178. <Fragment>
  179. <SentryDocumentTitle title={t('Alerts')} orgSlug={organization.slug} />
  180. <PageFiltersContainer>
  181. <AlertHeader activeTab="rules" />
  182. <Layout.Body>
  183. <Layout.Main fullWidth>
  184. <MetricsRemovedAlertsWidgetsAlert organization={organization} />
  185. <DataConsentBanner source="alerts" />
  186. <FilterBar
  187. location={location}
  188. onChangeFilter={handleChangeFilter}
  189. onChangeSearch={handleChangeSearch}
  190. onChangeAlertType={handleChangeType}
  191. hasTypeFilter
  192. />
  193. <StyledPanelTable
  194. isLoading={isPending}
  195. isEmpty={ruleList.length === 0 && !isError}
  196. emptyMessage={t('No alert rules found for the current query.')}
  197. headers={[
  198. <StyledSortLink
  199. key="name"
  200. role="columnheader"
  201. aria-sort={
  202. sort.field !== 'name' ? 'none' : sort.asc ? 'ascending' : 'descending'
  203. }
  204. to={{
  205. pathname: location.pathname,
  206. query: {
  207. ...currentQuery,
  208. // sort by name should start by ascending on first click
  209. asc: sort.field === 'name' && sort.asc ? undefined : '1',
  210. sort: 'name',
  211. },
  212. }}
  213. >
  214. {t('Alert Rule')} {sort.field === 'name' ? sortArrow : null}
  215. </StyledSortLink>,
  216. <StyledSortLink
  217. key="status"
  218. role="columnheader"
  219. aria-sort={
  220. !isAlertRuleSort ? 'none' : sort.asc ? 'ascending' : 'descending'
  221. }
  222. to={{
  223. pathname: location.pathname,
  224. query: {
  225. ...currentQuery,
  226. asc: isAlertRuleSort && !sort.asc ? '1' : undefined,
  227. sort: ['incident_status', 'date_triggered'],
  228. },
  229. }}
  230. >
  231. {t('Status')} {isAlertRuleSort ? sortArrow : null}
  232. </StyledSortLink>,
  233. t('Project'),
  234. t('Team'),
  235. t('Actions'),
  236. ]}
  237. >
  238. {isError ? (
  239. <StyledLoadingError
  240. message={t('There was an error loading alerts.')}
  241. onRetry={refetch}
  242. />
  243. ) : null}
  244. <VisuallyCompleteWithData
  245. id="AlertRules-Body"
  246. hasData={ruleList.length > 0}
  247. >
  248. <Projects orgId={organization.slug} slugs={projectsFromResults}>
  249. {({initiallyLoaded, projects}) =>
  250. ruleList.map(rule => {
  251. const isIssueAlertInstance = isIssueAlert(rule);
  252. const keyPrefix = isIssueAlertInstance
  253. ? AlertRuleType.ISSUE
  254. : rule.type === CombinedAlertType.UPTIME
  255. ? AlertRuleType.UPTIME
  256. : AlertRuleType.METRIC;
  257. return (
  258. <RuleListRow
  259. // Metric and issue alerts can have the same id
  260. key={`${keyPrefix}-${rule.id}`}
  261. projectsLoaded={initiallyLoaded}
  262. projects={projects as Project[]}
  263. rule={rule}
  264. orgId={organization.slug}
  265. onOwnerChange={handleOwnerChange}
  266. onDelete={handleDeleteRule}
  267. hasEditAccess={hasEditAccess}
  268. />
  269. );
  270. })
  271. }
  272. </Projects>
  273. </VisuallyCompleteWithData>
  274. </StyledPanelTable>
  275. <Pagination
  276. pageLinks={ruleListPageLinks}
  277. onCursor={(cursor, path, _direction) => {
  278. let team = currentQuery.team;
  279. // Keep team parameter, but empty to remove parameters
  280. if (!team || team.length === 0) {
  281. team = '';
  282. }
  283. router.push({
  284. pathname: path,
  285. query: {...currentQuery, team, cursor},
  286. });
  287. }}
  288. />
  289. </Layout.Main>
  290. </Layout.Body>
  291. </PageFiltersContainer>
  292. </Fragment>
  293. );
  294. }
  295. export default AlertRulesList;
  296. const StyledLoadingError = styled(LoadingError)`
  297. grid-column: 1 / -1;
  298. margin-bottom: ${space(4)};
  299. border-radius: 0;
  300. border-width: 1px 0;
  301. `;
  302. const StyledSortLink = styled(Link)`
  303. color: inherit;
  304. display: flex;
  305. align-items: center;
  306. gap: ${space(0.5)};
  307. :hover {
  308. color: inherit;
  309. }
  310. `;
  311. const StyledPanelTable = styled(PanelTable)`
  312. @media (min-width: ${p => p.theme.breakpoints.small}) {
  313. overflow: initial;
  314. }
  315. grid-template-columns: minmax(250px, 4fr) auto auto 60px auto;
  316. white-space: nowrap;
  317. font-size: ${p => p.theme.fontSizeMedium};
  318. `;