index.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  1. import {Component} from 'react';
  2. import {RouteComponentProps} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import flatten from 'lodash/flatten';
  5. import {addErrorMessage} from 'app/actionCreators/indicator';
  6. import AsyncComponent from 'app/components/asyncComponent';
  7. import * as Layout from 'app/components/layouts/thirds';
  8. import ExternalLink from 'app/components/links/externalLink';
  9. import Link from 'app/components/links/link';
  10. import GlobalSelectionHeader from 'app/components/organizations/globalSelectionHeader';
  11. import Pagination from 'app/components/pagination';
  12. import {PanelTable} from 'app/components/panels';
  13. import SearchBar from 'app/components/searchBar';
  14. import SentryDocumentTitle from 'app/components/sentryDocumentTitle';
  15. import {IconArrow} from 'app/icons';
  16. import {t, tct} from 'app/locale';
  17. import space from 'app/styles/space';
  18. import {GlobalSelection, Organization, Project, Team} from 'app/types';
  19. import {trackAnalyticsEvent} from 'app/utils/analytics';
  20. import Projects from 'app/utils/projects';
  21. import withGlobalSelection from 'app/utils/withGlobalSelection';
  22. import withTeams from 'app/utils/withTeams';
  23. import AlertHeader from '../list/header';
  24. import {CombinedMetricIssueAlerts} from '../types';
  25. import {isIssueAlert} from '../utils';
  26. import RuleListRow from './row';
  27. import TeamFilter, {getTeamParams} from './teamFilter';
  28. const DOCS_URL = 'https://docs.sentry.io/product/alerts-notifications/metric-alerts/';
  29. type Props = RouteComponentProps<{orgId: string}, {}> & {
  30. organization: Organization;
  31. selection: GlobalSelection;
  32. teams: Team[];
  33. };
  34. type State = {
  35. ruleList?: CombinedMetricIssueAlerts[];
  36. teamFilterSearch?: string;
  37. };
  38. class AlertRulesList extends AsyncComponent<Props, State & AsyncComponent['state']> {
  39. getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
  40. const {params, location, organization} = this.props;
  41. const {query} = location;
  42. if (organization.features.includes('alert-details-redesign')) {
  43. query.expand = ['latestIncident'];
  44. }
  45. query.team = getTeamParams(query.team);
  46. if (organization.features.includes('alert-details-redesign') && !query.sort) {
  47. query.sort = ['incident_status', 'date_triggered'];
  48. }
  49. return [
  50. [
  51. 'ruleList',
  52. `/organizations/${params && params.orgId}/combined-rules/`,
  53. {
  54. query,
  55. },
  56. ],
  57. ];
  58. }
  59. handleChangeFilter = (_sectionId: string, activeFilters: Set<string>) => {
  60. const {router, location} = this.props;
  61. const {cursor: _cursor, page: _page, ...currentQuery} = location.query;
  62. const teams = [...activeFilters];
  63. router.push({
  64. pathname: location.pathname,
  65. query: {
  66. ...currentQuery,
  67. team: teams.length ? teams : '',
  68. },
  69. });
  70. };
  71. handleChangeSearch = (name: string) => {
  72. const {router, location} = this.props;
  73. const {cursor: _cursor, page: _page, ...currentQuery} = location.query;
  74. router.push({
  75. pathname: location.pathname,
  76. query: {
  77. ...currentQuery,
  78. name,
  79. },
  80. });
  81. };
  82. handleDeleteRule = async (projectId: string, rule: CombinedMetricIssueAlerts) => {
  83. const {params} = this.props;
  84. const {orgId} = params;
  85. const alertPath = isIssueAlert(rule) ? 'rules' : 'alert-rules';
  86. try {
  87. await this.api.requestPromise(
  88. `/projects/${orgId}/${projectId}/${alertPath}/${rule.id}/`,
  89. {
  90. method: 'DELETE',
  91. }
  92. );
  93. this.reloadData();
  94. } catch (_err) {
  95. addErrorMessage(t('Error deleting rule'));
  96. }
  97. };
  98. renderLoading() {
  99. return this.renderBody();
  100. }
  101. renderFilterBar() {
  102. const {teams, location} = this.props;
  103. const selectedTeams = new Set(getTeamParams(location.query.team));
  104. return (
  105. <FilterWrapper>
  106. <TeamFilter
  107. teams={teams}
  108. selectedTeams={selectedTeams}
  109. handleChangeFilter={this.handleChangeFilter}
  110. />
  111. <StyledSearchBar
  112. placeholder={t('Search by name')}
  113. query={location.query?.name}
  114. onSearch={this.handleChangeSearch}
  115. />
  116. </FilterWrapper>
  117. );
  118. }
  119. renderList() {
  120. const {
  121. params: {orgId},
  122. location: {query},
  123. organization,
  124. teams,
  125. } = this.props;
  126. const {loading, ruleList = [], ruleListPageLinks} = this.state;
  127. const allProjectsFromIncidents = new Set(
  128. flatten(ruleList?.map(({projects}) => projects))
  129. );
  130. const sort: {
  131. asc: boolean;
  132. field: 'date_added' | 'name' | ['incident_status', 'date_triggered'];
  133. } = {
  134. asc: query.asc === '1',
  135. field: query.sort || 'date_added',
  136. };
  137. const {cursor: _cursor, page: _page, ...currentQuery} = query;
  138. const hasAlertList = organization.features.includes('alert-details-redesign');
  139. const isAlertRuleSort =
  140. sort.field.includes('incident_status') || sort.field.includes('date_triggered');
  141. const sortArrow = (
  142. <IconArrow color="gray300" size="xs" direction={sort.asc ? 'up' : 'down'} />
  143. );
  144. const userTeams = new Set(teams.filter(({isMember}) => isMember).map(({id}) => id));
  145. return (
  146. <StyledLayoutBody>
  147. <Layout.Main fullWidth>
  148. {this.renderFilterBar()}
  149. <StyledPanelTable
  150. headers={[
  151. ...(hasAlertList
  152. ? [
  153. // eslint-disable-next-line react/jsx-key
  154. <StyledSortLink
  155. to={{
  156. pathname: location.pathname,
  157. query: {
  158. ...currentQuery,
  159. // sort by name should start by ascending on first click
  160. asc: sort.field === 'name' && sort.asc ? undefined : '1',
  161. sort: 'name',
  162. },
  163. }}
  164. >
  165. {t('Alert Rule')} {sort.field === 'name' && sortArrow}
  166. </StyledSortLink>,
  167. // eslint-disable-next-line react/jsx-key
  168. <StyledSortLink
  169. to={{
  170. pathname: location.pathname,
  171. query: {
  172. ...currentQuery,
  173. asc: isAlertRuleSort && !sort.asc ? '1' : undefined,
  174. sort: ['incident_status', 'date_triggered'],
  175. },
  176. }}
  177. >
  178. {t('Status')} {isAlertRuleSort && sortArrow}
  179. </StyledSortLink>,
  180. ]
  181. : [
  182. t('Type'),
  183. // eslint-disable-next-line react/jsx-key
  184. <StyledSortLink
  185. to={{
  186. pathname: location.pathname,
  187. query: {
  188. ...currentQuery,
  189. asc: sort.field === 'name' && !sort.asc ? '1' : undefined,
  190. sort: 'name',
  191. },
  192. }}
  193. >
  194. {t('Alert Name')} {sort.field === 'name' && sortArrow}
  195. </StyledSortLink>,
  196. ]),
  197. t('Project'),
  198. t('Team'),
  199. ...(hasAlertList ? [] : [t('Created By')]),
  200. // eslint-disable-next-line react/jsx-key
  201. <StyledSortLink
  202. to={{
  203. pathname: location.pathname,
  204. query: {
  205. ...currentQuery,
  206. asc: sort.field === 'date_added' && !sort.asc ? '1' : undefined,
  207. sort: 'date_added',
  208. },
  209. }}
  210. >
  211. {t('Created')} {sort.field === 'date_added' && sortArrow}
  212. </StyledSortLink>,
  213. t('Actions'),
  214. ]}
  215. isLoading={loading}
  216. isEmpty={ruleList?.length === 0}
  217. emptyMessage={t('No alert rules found for the current query.')}
  218. emptyAction={
  219. <EmptyStateAction>
  220. {tct('Learn more about [link:Alerts]', {
  221. link: <ExternalLink href={DOCS_URL} />,
  222. })}
  223. </EmptyStateAction>
  224. }
  225. hasAlertList={hasAlertList}
  226. >
  227. <Projects orgId={orgId} slugs={Array.from(allProjectsFromIncidents)}>
  228. {({initiallyLoaded, projects}) =>
  229. ruleList.map(rule => (
  230. <RuleListRow
  231. // Metric and issue alerts can have the same id
  232. key={`${isIssueAlert(rule) ? 'metric' : 'issue'}-${rule.id}`}
  233. projectsLoaded={initiallyLoaded}
  234. projects={projects as Project[]}
  235. rule={rule}
  236. orgId={orgId}
  237. onDelete={this.handleDeleteRule}
  238. organization={organization}
  239. userTeams={userTeams}
  240. />
  241. ))
  242. }
  243. </Projects>
  244. </StyledPanelTable>
  245. <Pagination pageLinks={ruleListPageLinks} />
  246. </Layout.Main>
  247. </StyledLayoutBody>
  248. );
  249. }
  250. renderBody() {
  251. const {params, organization, router} = this.props;
  252. const {orgId} = params;
  253. return (
  254. <SentryDocumentTitle title={t('Alerts')} orgSlug={orgId}>
  255. <GlobalSelectionHeader
  256. organization={organization}
  257. showDateSelector={false}
  258. showEnvironmentSelector={false}
  259. >
  260. <AlertHeader organization={organization} router={router} activeTab="rules" />
  261. {this.renderList()}
  262. </GlobalSelectionHeader>
  263. </SentryDocumentTitle>
  264. );
  265. }
  266. }
  267. class AlertRulesListContainer extends Component<Props> {
  268. componentDidMount() {
  269. this.trackView();
  270. }
  271. componentDidUpdate(prevProps: Props) {
  272. const {location} = this.props;
  273. if (prevProps.location.query?.sort !== location.query?.sort) {
  274. this.trackView();
  275. }
  276. }
  277. trackView() {
  278. const {organization, location} = this.props;
  279. trackAnalyticsEvent({
  280. eventKey: 'alert_rules.viewed',
  281. eventName: 'Alert Rules: Viewed',
  282. organization_id: organization.id,
  283. sort: Array.isArray(location.query.sort)
  284. ? location.query.sort.join(',')
  285. : location.query.sort,
  286. });
  287. }
  288. render() {
  289. return <AlertRulesList {...this.props} />;
  290. }
  291. }
  292. export default withGlobalSelection(withTeams(AlertRulesListContainer));
  293. const StyledLayoutBody = styled(Layout.Body)`
  294. margin-bottom: -20px;
  295. `;
  296. const StyledSortLink = styled(Link)`
  297. color: inherit;
  298. :hover {
  299. color: inherit;
  300. }
  301. `;
  302. const FilterWrapper = styled('div')`
  303. display: flex;
  304. margin-bottom: ${space(1.5)};
  305. `;
  306. const StyledSearchBar = styled(SearchBar)`
  307. flex-grow: 1;
  308. margin-left: ${space(1.5)};
  309. `;
  310. const StyledPanelTable = styled(PanelTable)<{hasAlertList: boolean}>`
  311. overflow: auto;
  312. @media (min-width: ${p => p.theme.breakpoints[0]}) {
  313. overflow: initial;
  314. }
  315. grid-template-columns: auto 1.5fr 1fr 1fr ${p => (!p.hasAlertList ? '1fr' : '')} 1fr auto;
  316. white-space: nowrap;
  317. font-size: ${p => p.theme.fontSizeMedium};
  318. `;
  319. const EmptyStateAction = styled('p')`
  320. font-size: ${p => p.theme.fontSizeLarge};
  321. `;