index.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. import {Component, Fragment} from 'react';
  2. import {RouteComponentProps} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import flatten from 'lodash/flatten';
  5. import {promptsCheck, promptsUpdate} from 'sentry/actionCreators/prompts';
  6. import Feature from 'sentry/components/acl/feature';
  7. import Alert from 'sentry/components/alert';
  8. import AsyncComponent from 'sentry/components/asyncComponent';
  9. import Button from 'sentry/components/button';
  10. import CreateAlertButton from 'sentry/components/createAlertButton';
  11. import * as Layout from 'sentry/components/layouts/thirds';
  12. import ExternalLink from 'sentry/components/links/externalLink';
  13. import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container';
  14. import Pagination from 'sentry/components/pagination';
  15. import {PanelTable} from 'sentry/components/panels';
  16. import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
  17. import {IconInfo} from 'sentry/icons';
  18. import {t, tct} from 'sentry/locale';
  19. import space from 'sentry/styles/space';
  20. import {Organization, Project} from 'sentry/types';
  21. import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
  22. import Projects from 'sentry/utils/projects';
  23. import withOrganization from 'sentry/utils/withOrganization';
  24. import FilterBar from '../filterBar';
  25. import {Incident} from '../types';
  26. import {getQueryStatus, getTeamParams} from '../utils';
  27. import AlertHeader from './header';
  28. import Onboarding from './onboarding';
  29. import AlertListRow from './row';
  30. const DOCS_URL =
  31. 'https://docs.sentry.io/workflow/alerts-notifications/alerts/?_ga=2.21848383.580096147.1592364314-1444595810.1582160976';
  32. type Props = RouteComponentProps<{orgId: string}, {}> & {
  33. organization: Organization;
  34. };
  35. type State = {
  36. incidentList: Incident[];
  37. /**
  38. * User has not yet seen the 'alert_stream' welcome prompt for this
  39. * organization.
  40. */
  41. firstVisitShown?: boolean;
  42. /**
  43. * Is there at least one alert rule configured for the currently selected
  44. * projects?
  45. */
  46. hasAlertRule?: boolean;
  47. };
  48. class IncidentsList extends AsyncComponent<Props, State & AsyncComponent['state']> {
  49. getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
  50. const {params, location} = this.props;
  51. const {query} = location;
  52. const status = getQueryStatus(query.status);
  53. // Filtering by one status, both does nothing
  54. if (status.length === 1) {
  55. query.status = status;
  56. }
  57. query.team = getTeamParams(query.team);
  58. query.expand = ['original_alert_rule'];
  59. return [['incidentList', `/organizations/${params?.orgId}/incidents/`, {query}]];
  60. }
  61. /**
  62. * If our incidentList is empty, determine if we've configured alert rules or
  63. * if the user has seen the welcome prompt.
  64. */
  65. async onLoadAllEndpointsSuccess() {
  66. const {incidentList} = this.state;
  67. if (!incidentList || incidentList.length !== 0) {
  68. this.setState({hasAlertRule: true, firstVisitShown: false});
  69. return;
  70. }
  71. this.setState({loading: true});
  72. // Check if they have rules or not, to know which empty state message to
  73. // display
  74. const {params, location, organization} = this.props;
  75. const alertRules = await this.api.requestPromise(
  76. `/organizations/${params?.orgId}/alert-rules/`,
  77. {
  78. method: 'GET',
  79. query: location.query,
  80. }
  81. );
  82. const hasAlertRule = alertRules.length > 0;
  83. // We've already configured alert rules, no need to check if we should show
  84. // the "first time welcome" prompt
  85. if (hasAlertRule) {
  86. this.setState({hasAlertRule, firstVisitShown: false, loading: false});
  87. return;
  88. }
  89. // Check if they have already seen the prompt for the alert stream
  90. const prompt = await promptsCheck(this.api, {
  91. organizationId: organization.id,
  92. feature: 'alert_stream',
  93. });
  94. const firstVisitShown = !prompt?.dismissedTime;
  95. if (firstVisitShown) {
  96. // Prompt has not been seen, mark the prompt as seen immediately so they
  97. // don't see it again
  98. promptsUpdate(this.api, {
  99. feature: 'alert_stream',
  100. organizationId: organization.id,
  101. status: 'dismissed',
  102. });
  103. }
  104. this.setState({hasAlertRule, firstVisitShown, loading: false});
  105. }
  106. handleChangeSearch = (title: string) => {
  107. const {router, location} = this.props;
  108. const {cursor: _cursor, page: _page, ...currentQuery} = location.query;
  109. router.push({
  110. pathname: location.pathname,
  111. query: {
  112. ...currentQuery,
  113. title,
  114. },
  115. });
  116. };
  117. handleChangeFilter = (sectionId: string, activeFilters: Set<string>) => {
  118. const {router, location} = this.props;
  119. const {cursor: _cursor, page: _page, ...currentQuery} = location.query;
  120. let team = currentQuery.team;
  121. if (sectionId === 'teams') {
  122. team = activeFilters.size ? [...activeFilters] : '';
  123. }
  124. let status = currentQuery.status;
  125. if (sectionId === 'status') {
  126. status = activeFilters.size ? [...activeFilters] : '';
  127. }
  128. router.push({
  129. pathname: location.pathname,
  130. query: {
  131. ...currentQuery,
  132. status,
  133. // Preserve empty team query parameter
  134. team: team.length === 0 ? '' : team,
  135. },
  136. });
  137. };
  138. tryRenderOnboarding() {
  139. const {firstVisitShown} = this.state;
  140. const {organization} = this.props;
  141. if (!firstVisitShown) {
  142. return null;
  143. }
  144. const actions = (
  145. <Fragment>
  146. <Button size="small" external href={DOCS_URL}>
  147. {t('View Features')}
  148. </Button>
  149. <CreateAlertButton
  150. organization={organization}
  151. iconProps={{size: 'xs'}}
  152. size="small"
  153. priority="primary"
  154. referrer="alert_stream"
  155. >
  156. {t('Create Alert')}
  157. </CreateAlertButton>
  158. </Fragment>
  159. );
  160. return <Onboarding actions={actions} />;
  161. }
  162. renderLoading() {
  163. return this.renderBody();
  164. }
  165. renderList() {
  166. const {loading, incidentList, incidentListPageLinks, hasAlertRule} = this.state;
  167. const {
  168. params: {orgId},
  169. organization,
  170. } = this.props;
  171. const allProjectsFromIncidents = new Set(
  172. flatten(incidentList?.map(({projects}) => projects))
  173. );
  174. const checkingForAlertRules =
  175. incidentList && incidentList.length === 0 && hasAlertRule === undefined
  176. ? true
  177. : false;
  178. const showLoadingIndicator = loading || checkingForAlertRules;
  179. return (
  180. <Fragment>
  181. {this.tryRenderOnboarding() ?? (
  182. <PanelTable
  183. isLoading={showLoadingIndicator}
  184. isEmpty={incidentList?.length === 0}
  185. emptyMessage={t('No incidents exist for the current query.')}
  186. emptyAction={
  187. <EmptyStateAction>
  188. {tct('Learn more about [link:Metric Alerts]', {
  189. link: <ExternalLink href={DOCS_URL} />,
  190. })}
  191. </EmptyStateAction>
  192. }
  193. headers={[
  194. t('Alert Rule'),
  195. t('Triggered'),
  196. t('Duration'),
  197. t('Project'),
  198. t('Alert ID'),
  199. t('Team'),
  200. ]}
  201. >
  202. <Projects orgId={orgId} slugs={Array.from(allProjectsFromIncidents)}>
  203. {({initiallyLoaded, projects}) =>
  204. incidentList.map(incident => (
  205. <AlertListRow
  206. key={incident.id}
  207. projectsLoaded={initiallyLoaded}
  208. projects={projects as Project[]}
  209. incident={incident}
  210. orgId={orgId}
  211. organization={organization}
  212. />
  213. ))
  214. }
  215. </Projects>
  216. </PanelTable>
  217. )}
  218. <Pagination pageLinks={incidentListPageLinks} />
  219. </Fragment>
  220. );
  221. }
  222. renderBody() {
  223. const {params, organization, router, location} = this.props;
  224. const {orgId} = params;
  225. return (
  226. <SentryDocumentTitle title={t('Alerts')} orgSlug={orgId}>
  227. <PageFiltersContainer
  228. organization={organization}
  229. showDateSelector={false}
  230. hideGlobalHeader
  231. >
  232. <AlertHeader organization={organization} router={router} activeTab="stream" />
  233. <StyledLayoutBody>
  234. <Layout.Main fullWidth>
  235. {!this.tryRenderOnboarding() && (
  236. <Fragment>
  237. <StyledAlert icon={<IconInfo />}>
  238. {t('This page only shows metric alerts.')}
  239. </StyledAlert>
  240. <FilterBar
  241. location={location}
  242. onChangeFilter={this.handleChangeFilter}
  243. onChangeSearch={this.handleChangeSearch}
  244. hasStatusFilters
  245. />
  246. </Fragment>
  247. )}
  248. {this.renderList()}
  249. </Layout.Main>
  250. </StyledLayoutBody>
  251. </PageFiltersContainer>
  252. </SentryDocumentTitle>
  253. );
  254. }
  255. }
  256. class IncidentsListContainer extends Component<Props> {
  257. componentDidMount() {
  258. this.trackView();
  259. }
  260. componentDidUpdate(nextProps: Props) {
  261. if (nextProps.location.query?.status !== this.props.location.query?.status) {
  262. this.trackView();
  263. }
  264. }
  265. trackView() {
  266. const {organization} = this.props;
  267. trackAdvancedAnalyticsEvent('alert_stream.viewed', {
  268. organization,
  269. });
  270. }
  271. renderNoAccess() {
  272. return (
  273. <Layout.Body>
  274. <Layout.Main fullWidth>
  275. <Alert type="warning">{t("You don't have access to this feature")}</Alert>
  276. </Layout.Main>
  277. </Layout.Body>
  278. );
  279. }
  280. render() {
  281. const {organization} = this.props;
  282. return (
  283. <Feature
  284. features={['organizations:incidents']}
  285. organization={organization}
  286. hookName="feature-disabled:alerts-page"
  287. renderDisabled={this.renderNoAccess}
  288. >
  289. <IncidentsList {...this.props} />
  290. </Feature>
  291. );
  292. }
  293. }
  294. const StyledAlert = styled(Alert)`
  295. margin-bottom: ${space(1.5)};
  296. `;
  297. const StyledLayoutBody = styled(Layout.Body)`
  298. margin-bottom: -20px;
  299. `;
  300. const EmptyStateAction = styled('p')`
  301. font-size: ${p => p.theme.fontSizeLarge};
  302. `;
  303. export default withOrganization(IncidentsListContainer);