grouping.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451
  1. import {Fragment, useEffect, useState} from 'react';
  2. import {browserHistory, InjectedRouter} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import {Location} from 'history';
  5. import debounce from 'lodash/debounce';
  6. import {Client} from 'sentry/api';
  7. import Button from 'sentry/components/button';
  8. import ButtonBar from 'sentry/components/buttonBar';
  9. import {FeatureFeedback} from 'sentry/components/featureFeedback';
  10. import RangeSlider from 'sentry/components/forms/controls/rangeSlider';
  11. import Slider from 'sentry/components/forms/controls/rangeSlider/slider';
  12. import * as Layout from 'sentry/components/layouts/thirds';
  13. import LoadingIndicator from 'sentry/components/loadingIndicator';
  14. import Pagination from 'sentry/components/pagination';
  15. import {PanelTable} from 'sentry/components/panels';
  16. import {DEFAULT_DEBOUNCE_DURATION} from 'sentry/constants';
  17. import {t, tct, tn} from 'sentry/locale';
  18. import space from 'sentry/styles/space';
  19. import {BaseGroup, Group, Organization, Project} from 'sentry/types';
  20. import {defined} from 'sentry/utils';
  21. import parseLinkHeader from 'sentry/utils/parseLinkHeader';
  22. import withApi from 'sentry/utils/withApi';
  23. import ErrorMessage from './errorMessage';
  24. import NewIssue from './newIssue';
  25. type Error = React.ComponentProps<typeof ErrorMessage>['error'];
  26. type Props = {
  27. api: Client;
  28. groupId: Group['id'];
  29. location: Location<{cursor?: string; level?: number}>;
  30. organization: Organization;
  31. projSlug: Project['slug'];
  32. router: InjectedRouter;
  33. };
  34. type GroupingLevelDetails = Partial<Pick<BaseGroup, 'title' | 'metadata'>> & {
  35. eventCount: number;
  36. hash: string;
  37. latestEvent: BaseGroup['latestEvent'];
  38. };
  39. type GroupingLevel = {
  40. id: number;
  41. isCurrent: boolean;
  42. };
  43. export const groupingFeedbackTypes = [
  44. t('Too eager grouping'),
  45. t('Too specific grouping'),
  46. t('Other grouping issue'),
  47. ];
  48. const GROUPING_BREAKDOWN__DOC_LINK =
  49. 'https://docs.sentry.io/product/data-management-settings/event-grouping/grouping-breakdown/';
  50. function Grouping({api, groupId, location, organization, router, projSlug}: Props) {
  51. const {cursor, level} = location.query;
  52. const [isLoading, setIsLoading] = useState(false);
  53. const [isGroupingLevelDetailsLoading, setIsGroupingLevelDetailsLoading] =
  54. useState(false);
  55. const [error, setError] = useState<undefined | Error | string>(undefined);
  56. const [groupingLevels, setGroupingLevels] = useState<GroupingLevel[]>([]);
  57. const [activeGroupingLevel, setActiveGroupingLevel] = useState<number | undefined>(
  58. undefined
  59. );
  60. const [activeGroupingLevelDetails, setActiveGroupingLevelDetails] = useState<
  61. GroupingLevelDetails[]
  62. >([]);
  63. const [pagination, setPagination] = useState('');
  64. useEffect(() => {
  65. fetchGroupingLevels();
  66. return browserHistory.listen(handleRouteLeave);
  67. }, []);
  68. useEffect(() => {
  69. setSecondGrouping();
  70. }, [groupingLevels]);
  71. useEffect(() => {
  72. updateUrlWithNewLevel();
  73. }, [activeGroupingLevel]);
  74. useEffect(() => {
  75. fetchGroupingLevelDetails();
  76. }, [activeGroupingLevel, cursor]);
  77. function handleRouteLeave(newLocation: Location<{cursor?: string; level?: number}>) {
  78. if (
  79. newLocation.pathname === location.pathname ||
  80. (newLocation.pathname !== location.pathname &&
  81. newLocation.query.cursor === undefined &&
  82. newLocation.query.level === undefined)
  83. ) {
  84. return true;
  85. }
  86. // Removes cursor and level from the URL on route leave
  87. // so that the parameters will not interfere with other pages
  88. browserHistory.replace({
  89. pathname: newLocation.pathname,
  90. query: {
  91. ...newLocation.query,
  92. cursor: undefined,
  93. level: undefined,
  94. },
  95. });
  96. return false;
  97. }
  98. const handleSetActiveGroupingLevel = debounce((groupingLevelId: number | '') => {
  99. setActiveGroupingLevel(Number(groupingLevelId));
  100. }, DEFAULT_DEBOUNCE_DURATION);
  101. async function fetchGroupingLevels() {
  102. setIsLoading(true);
  103. setError(undefined);
  104. try {
  105. const response = await api.requestPromise(`/issues/${groupId}/grouping/levels/`);
  106. setIsLoading(false);
  107. setGroupingLevels(response.levels);
  108. } catch (err) {
  109. setIsLoading(false);
  110. setError(err);
  111. }
  112. }
  113. async function fetchGroupingLevelDetails() {
  114. if (!groupingLevels.length || !defined(activeGroupingLevel)) {
  115. return;
  116. }
  117. setIsGroupingLevelDetailsLoading(true);
  118. setError(undefined);
  119. try {
  120. const [data, , resp] = await api.requestPromise(
  121. `/issues/${groupId}/grouping/levels/${activeGroupingLevel}/new-issues/`,
  122. {
  123. method: 'GET',
  124. includeAllArgs: true,
  125. query: {
  126. ...location.query,
  127. per_page: 10,
  128. },
  129. }
  130. );
  131. const pageLinks = resp?.getResponseHeader?.('Link');
  132. setPagination(pageLinks ?? '');
  133. setActiveGroupingLevelDetails(Array.isArray(data) ? data : [data]);
  134. setIsGroupingLevelDetailsLoading(false);
  135. } catch (err) {
  136. setIsGroupingLevelDetailsLoading(false);
  137. setError(err);
  138. }
  139. }
  140. function updateUrlWithNewLevel() {
  141. if (!defined(activeGroupingLevel) || level === activeGroupingLevel) {
  142. return;
  143. }
  144. router.replace({
  145. pathname: location.pathname,
  146. query: {...location.query, cursor: undefined, level: activeGroupingLevel},
  147. });
  148. }
  149. function setSecondGrouping() {
  150. if (!groupingLevels.length) {
  151. return;
  152. }
  153. if (defined(level)) {
  154. if (!defined(groupingLevels[level])) {
  155. setError(t('The level you were looking for was not found.'));
  156. return;
  157. }
  158. if (level === activeGroupingLevel) {
  159. return;
  160. }
  161. setActiveGroupingLevel(level);
  162. return;
  163. }
  164. if (groupingLevels.length > 1) {
  165. setActiveGroupingLevel(groupingLevels[1].id);
  166. return;
  167. }
  168. setActiveGroupingLevel(groupingLevels[0].id);
  169. }
  170. if (isLoading) {
  171. return <LoadingIndicator />;
  172. }
  173. if (error) {
  174. return (
  175. <Fragment>
  176. <Layout.Body>
  177. <Layout.Main fullWidth>
  178. <ErrorWrapper>
  179. <ButtonBar gap={1}>
  180. <Button href={GROUPING_BREAKDOWN__DOC_LINK} external>
  181. {t('Read Docs')}
  182. </Button>
  183. <FeatureFeedback
  184. featureName="grouping"
  185. feedbackTypes={groupingFeedbackTypes}
  186. />
  187. </ButtonBar>
  188. <StyledErrorMessage
  189. onRetry={fetchGroupingLevels}
  190. groupId={groupId}
  191. error={error}
  192. projSlug={projSlug}
  193. orgSlug={organization.slug}
  194. hasProjectWriteAccess={organization.access.includes('project:write')}
  195. />
  196. </ErrorWrapper>
  197. </Layout.Main>
  198. </Layout.Body>
  199. </Fragment>
  200. );
  201. }
  202. if (!activeGroupingLevelDetails.length) {
  203. return <LoadingIndicator />;
  204. }
  205. const links = parseLinkHeader(pagination);
  206. const hasMore = links.previous?.results || links.next?.results;
  207. const paginationCurrentQuantity = activeGroupingLevelDetails.length;
  208. return (
  209. <Layout.Body>
  210. <Layout.Main fullWidth>
  211. <Wrapper>
  212. <Header>
  213. {t(
  214. 'This issue is an aggregate of multiple events that sentry determined originate from the same root-cause. Use this page to explore more detailed groupings that exist within this issue.'
  215. )}
  216. </Header>
  217. <Body>
  218. <Actions>
  219. <SliderWrapper>
  220. {t('Fewer issues')}
  221. <StyledRangeSlider
  222. name="grouping-level"
  223. allowedValues={groupingLevels.map(groupingLevel =>
  224. Number(groupingLevel.id)
  225. )}
  226. value={activeGroupingLevel ?? 0}
  227. onChange={handleSetActiveGroupingLevel}
  228. showLabel={false}
  229. />
  230. {t('More issues')}
  231. </SliderWrapper>
  232. <StyledButtonBar gap={1}>
  233. <Button href={GROUPING_BREAKDOWN__DOC_LINK} external>
  234. {t('Read Docs')}
  235. </Button>
  236. <FeatureFeedback
  237. featureName="grouping"
  238. feedbackTypes={groupingFeedbackTypes}
  239. />
  240. </StyledButtonBar>
  241. </Actions>
  242. <Content isReloading={isGroupingLevelDetailsLoading}>
  243. <StyledPanelTable headers={['', t('Events')]}>
  244. {activeGroupingLevelDetails.map(
  245. ({hash, title, metadata, latestEvent, eventCount}) => {
  246. // XXX(markus): Ugly hack to make NewIssue show the right things.
  247. return (
  248. <NewIssue
  249. key={hash}
  250. sampleEvent={{
  251. ...latestEvent,
  252. metadata: {
  253. ...(metadata || latestEvent.metadata),
  254. current_level: activeGroupingLevel,
  255. },
  256. title: title || latestEvent.title,
  257. }}
  258. eventCount={eventCount}
  259. organization={organization}
  260. />
  261. );
  262. }
  263. )}
  264. </StyledPanelTable>
  265. <StyledPagination
  266. pageLinks={pagination}
  267. disabled={isGroupingLevelDetailsLoading}
  268. caption={tct('Showing [current] of [total] [result]', {
  269. result: hasMore
  270. ? t('results')
  271. : tn('result', 'results', paginationCurrentQuantity),
  272. current: paginationCurrentQuantity,
  273. total: hasMore
  274. ? `${paginationCurrentQuantity}+`
  275. : paginationCurrentQuantity,
  276. })}
  277. />
  278. </Content>
  279. </Body>
  280. </Wrapper>
  281. </Layout.Main>
  282. </Layout.Body>
  283. );
  284. }
  285. export default withApi(Grouping);
  286. const Wrapper = styled('div')`
  287. flex: 1;
  288. display: grid;
  289. align-content: flex-start;
  290. margin: -${space(3)} -${space(4)};
  291. padding: ${space(3)} ${space(4)};
  292. `;
  293. const Header = styled('p')`
  294. && {
  295. margin-bottom: ${space(2)};
  296. }
  297. `;
  298. const Body = styled('div')`
  299. display: grid;
  300. gap: ${space(3)};
  301. `;
  302. const Actions = styled('div')`
  303. display: grid;
  304. align-items: center;
  305. gap: ${space(3)};
  306. @media (min-width: ${p => p.theme.breakpoints.small}) {
  307. grid-template-columns: 1fr max-content;
  308. gap: ${space(2)};
  309. }
  310. `;
  311. const StyledButtonBar = styled(ButtonBar)`
  312. justify-content: flex-start;
  313. `;
  314. const StyledErrorMessage = styled(ErrorMessage)`
  315. width: 100%;
  316. `;
  317. const ErrorWrapper = styled('div')`
  318. display: flex;
  319. flex-direction: column;
  320. align-items: flex-end;
  321. gap: ${space(1)};
  322. `;
  323. const StyledPanelTable = styled(PanelTable)`
  324. grid-template-columns: 1fr minmax(60px, auto);
  325. > * {
  326. padding: ${space(1.5)} ${space(2)};
  327. :nth-child(-n + 2) {
  328. padding: ${space(2)};
  329. }
  330. :nth-child(2n) {
  331. display: flex;
  332. text-align: right;
  333. justify-content: flex-end;
  334. }
  335. }
  336. @media (min-width: ${p => p.theme.breakpoints.xlarge}) {
  337. grid-template-columns: 1fr minmax(80px, auto);
  338. }
  339. `;
  340. const StyledPagination = styled(Pagination)`
  341. margin-top: 0;
  342. `;
  343. const Content = styled('div')<{isReloading: boolean}>`
  344. ${p =>
  345. p.isReloading &&
  346. `
  347. ${StyledPanelTable}, ${StyledPagination} {
  348. opacity: 0.5;
  349. pointer-events: none;
  350. }
  351. `}
  352. `;
  353. const SliderWrapper = styled('div')`
  354. display: grid;
  355. gap: ${space(1.5)};
  356. grid-template-columns: max-content max-content;
  357. justify-content: space-between;
  358. align-items: flex-start;
  359. position: relative;
  360. font-size: ${p => p.theme.fontSizeMedium};
  361. color: ${p => p.theme.subText};
  362. padding-bottom: ${space(2)};
  363. @media (min-width: 700px) {
  364. grid-template-columns: max-content minmax(270px, auto) max-content;
  365. align-items: center;
  366. justify-content: flex-start;
  367. padding-bottom: 0;
  368. }
  369. `;
  370. const StyledRangeSlider = styled(RangeSlider)`
  371. ${Slider} {
  372. background: transparent;
  373. margin-top: 0;
  374. margin-bottom: 0;
  375. ::-ms-thumb {
  376. box-shadow: 0 0 0 3px ${p => p.theme.backgroundSecondary};
  377. }
  378. ::-moz-range-thumb {
  379. box-shadow: 0 0 0 3px ${p => p.theme.backgroundSecondary};
  380. }
  381. ::-webkit-slider-thumb {
  382. box-shadow: 0 0 0 3px ${p => p.theme.backgroundSecondary};
  383. }
  384. }
  385. position: absolute;
  386. bottom: 0;
  387. left: ${space(1.5)};
  388. right: ${space(1.5)};
  389. @media (min-width: 700px) {
  390. position: static;
  391. left: auto;
  392. right: auto;
  393. }
  394. `;