grouping.tsx 12 KB

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