issues.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400
  1. import {useMemo} from 'react';
  2. import type {Theme} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import ActorAvatar from 'sentry/components/avatar/actorAvatar';
  5. import Count from 'sentry/components/count';
  6. import EventOrGroupExtraDetails from 'sentry/components/eventOrGroupExtraDetails';
  7. import LoadingError from 'sentry/components/loadingError';
  8. import LoadingIndicator from 'sentry/components/loadingIndicator';
  9. import Panel from 'sentry/components/panels/panel';
  10. import PanelHeader from 'sentry/components/panels/panelHeader';
  11. import PanelItem from 'sentry/components/panels/panelItem';
  12. import {IconWrapper} from 'sentry/components/sidebarSection';
  13. import GroupChart from 'sentry/components/stream/groupChart';
  14. import {IconUser} from 'sentry/icons';
  15. import {t, tct, tn} from 'sentry/locale';
  16. import {space} from 'sentry/styles/space';
  17. import type {Group, Organization} from 'sentry/types';
  18. import type {
  19. TraceError,
  20. TraceErrorOrIssue,
  21. TracePerformanceIssue,
  22. } from 'sentry/utils/performance/quickTrace/types';
  23. import {useApiQuery} from 'sentry/utils/queryClient';
  24. import type {
  25. TraceTree,
  26. TraceTreeNode,
  27. } from 'sentry/views/performance/newTraceDetails/traceModels/traceTree';
  28. import {TraceDrawerComponents} from '../styles';
  29. import {IssueSummary} from './issueSummary';
  30. type IssueProps = {
  31. issue: TraceErrorOrIssue;
  32. organization: Organization;
  33. };
  34. const MAX_DISPLAYED_ISSUES_COUNT = 3;
  35. const TABLE_WIDTH_BREAKPOINTS = {
  36. FIRST: 800,
  37. SECOND: 600,
  38. THIRD: 500,
  39. FOURTH: 400,
  40. };
  41. const issueOrderPriority: Record<keyof Theme['level'], number> = {
  42. fatal: 0,
  43. error: 1,
  44. warning: 2,
  45. sample: 3,
  46. info: 4,
  47. default: 5,
  48. unknown: 6,
  49. };
  50. function sortIssuesByLevel(a: TraceError, b: TraceError): number {
  51. // If the level is not defined in the priority map, default to unknown
  52. const aPriority = issueOrderPriority[a.level] ?? issueOrderPriority.unknown;
  53. const bPriority = issueOrderPriority[b.level] ?? issueOrderPriority.unknown;
  54. return aPriority - bPriority;
  55. }
  56. function Issue(props: IssueProps) {
  57. const {
  58. isLoading,
  59. data: fetchedIssue,
  60. isError,
  61. } = useApiQuery<Group>(
  62. [
  63. `/issues/${props.issue.issue_id}/`,
  64. {
  65. query: {
  66. collapse: 'release',
  67. expand: 'inbox',
  68. },
  69. },
  70. ],
  71. {
  72. staleTime: 2 * 60 * 1000,
  73. }
  74. );
  75. return isLoading ? (
  76. <StyledLoadingIndicatorWrapper>
  77. <LoadingIndicator size={24} mini />
  78. </StyledLoadingIndicatorWrapper>
  79. ) : fetchedIssue ? (
  80. <StyledPanelItem>
  81. <IssueSummaryWrapper>
  82. <IssueSummary
  83. data={fetchedIssue}
  84. organization={props.organization}
  85. event_id={props.issue.event_id}
  86. />
  87. <EventOrGroupExtraDetails data={fetchedIssue} />
  88. </IssueSummaryWrapper>
  89. <ChartWrapper>
  90. <GroupChart
  91. stats={
  92. fetchedIssue.filtered
  93. ? fetchedIssue.filtered.stats?.['24h']
  94. : fetchedIssue.stats?.['24h']
  95. }
  96. secondaryStats={fetchedIssue.filtered ? fetchedIssue.stats?.['24h'] : []}
  97. showSecondaryPoints
  98. showMarkLine
  99. />
  100. </ChartWrapper>
  101. <EventsWrapper>
  102. <PrimaryCount
  103. value={fetchedIssue.filtered ? fetchedIssue.filtered.count : fetchedIssue.count}
  104. />
  105. </EventsWrapper>
  106. <UserCountWrapper>
  107. <PrimaryCount
  108. value={
  109. fetchedIssue.filtered
  110. ? fetchedIssue.filtered.userCount
  111. : fetchedIssue.userCount
  112. }
  113. />
  114. </UserCountWrapper>
  115. <AssineeWrapper>
  116. {fetchedIssue.assignedTo ? (
  117. <ActorAvatar actor={fetchedIssue.assignedTo} hasTooltip size={24} />
  118. ) : (
  119. <StyledIconWrapper>
  120. <IconUser size="md" />
  121. </StyledIconWrapper>
  122. )}
  123. </AssineeWrapper>
  124. </StyledPanelItem>
  125. ) : isError ? (
  126. <LoadingError message={t('Failed to fetch issue')} />
  127. ) : null;
  128. }
  129. type IssueListProps = {
  130. issues: TraceErrorOrIssue[];
  131. node: TraceTreeNode<TraceTree.NodeValue>;
  132. organization: Organization;
  133. };
  134. export function IssueList({issues, node, organization}: IssueListProps) {
  135. const uniqueErrorIssues = useMemo(() => {
  136. const unique: TraceError[] = [];
  137. const seenIssues: Set<number> = new Set();
  138. for (const issue of node.errors) {
  139. if (seenIssues.has(issue.issue_id)) {
  140. continue;
  141. }
  142. seenIssues.add(issue.issue_id);
  143. unique.push(issue);
  144. }
  145. return unique;
  146. // eslint-disable-next-line react-hooks/exhaustive-deps
  147. }, [node, node.errors.size]);
  148. const uniquePerformanceIssues = useMemo(() => {
  149. const unique: TracePerformanceIssue[] = [];
  150. const seenIssues: Set<number> = new Set();
  151. for (const issue of node.performance_issues) {
  152. if (seenIssues.has(issue.issue_id)) {
  153. continue;
  154. }
  155. seenIssues.add(issue.issue_id);
  156. unique.push(issue);
  157. }
  158. return unique;
  159. // eslint-disable-next-line react-hooks/exhaustive-deps
  160. }, [node, node.performance_issues.size]);
  161. const uniqueIssues = useMemo(() => {
  162. return [...uniquePerformanceIssues, ...uniqueErrorIssues.sort(sortIssuesByLevel)];
  163. }, [uniqueErrorIssues, uniquePerformanceIssues]);
  164. if (!issues.length) {
  165. return null;
  166. }
  167. return (
  168. <StyledPanel>
  169. <IssueListHeader
  170. node={node}
  171. errorIssues={uniqueErrorIssues}
  172. performanceIssues={uniquePerformanceIssues}
  173. />
  174. {uniqueIssues.slice(0, MAX_DISPLAYED_ISSUES_COUNT).map((issue, index) => (
  175. <Issue key={index} issue={issue} organization={organization} />
  176. ))}
  177. </StyledPanel>
  178. );
  179. }
  180. function IssueListHeader({
  181. node,
  182. errorIssues,
  183. performanceIssues,
  184. }: {
  185. errorIssues: TraceError[];
  186. node: TraceTreeNode<TraceTree.NodeValue>;
  187. performanceIssues: TracePerformanceIssue[];
  188. }) {
  189. const [singular, plural] = useMemo((): [string, string] => {
  190. const label = [t('Issue'), t('Issues')] as [string, string];
  191. for (const event of errorIssues) {
  192. if (event.level === 'error' || event.level === 'fatal') {
  193. return [t('Error'), t('Errors')];
  194. }
  195. }
  196. return label;
  197. }, [errorIssues]);
  198. return (
  199. <StyledPanelHeader disablePadding>
  200. <IssueHeading>
  201. {errorIssues.length + performanceIssues.length > MAX_DISPLAYED_ISSUES_COUNT
  202. ? tct(`[count]+ issues, [link]`, {
  203. count: MAX_DISPLAYED_ISSUES_COUNT,
  204. link: <StyledIssuesLink node={node}>{t('View All')}</StyledIssuesLink>,
  205. })
  206. : errorIssues.length > 0 && performanceIssues.length === 0
  207. ? tct('[count] [text]', {
  208. count: errorIssues.length,
  209. text: errorIssues.length > 1 ? plural : singular,
  210. })
  211. : performanceIssues.length > 0 && errorIssues.length === 0
  212. ? tct('[count] [text]', {
  213. count: performanceIssues.length,
  214. text: tn(
  215. 'Performance issue',
  216. 'Performance Issues',
  217. performanceIssues.length
  218. ),
  219. })
  220. : tct(
  221. '[errors] [errorsText] and [performance_issues] [performanceIssuesText]',
  222. {
  223. errors: errorIssues.length,
  224. performance_issues: performanceIssues.length,
  225. errorsText: errorIssues.length > 1 ? plural : singular,
  226. performanceIssuesText: tn(
  227. 'performance issue',
  228. 'performance issues',
  229. performanceIssues.length
  230. ),
  231. }
  232. )}
  233. </IssueHeading>
  234. <GraphHeading>{t('Graph')}</GraphHeading>
  235. <EventsHeading>{t('Events')}</EventsHeading>
  236. <UsersHeading>{t('Users')}</UsersHeading>
  237. <AssigneeHeading>{t('Assignee')}</AssigneeHeading>
  238. </StyledPanelHeader>
  239. );
  240. }
  241. const StyledIssuesLink = styled(TraceDrawerComponents.IssuesLink)`
  242. margin-left: ${space(0.5)};
  243. `;
  244. const Heading = styled('div')`
  245. display: flex;
  246. align-self: center;
  247. margin: 0 ${space(2)};
  248. width: 60px;
  249. color: ${p => p.theme.subText};
  250. `;
  251. const IssueHeading = styled(Heading)`
  252. flex: 1;
  253. width: 66.66%;
  254. @media (min-width: ${p => p.theme.breakpoints.medium}) {
  255. width: 50%;
  256. }
  257. `;
  258. const GraphHeading = styled(Heading)`
  259. width: 160px;
  260. display: flex;
  261. justify-content: center;
  262. @container (width < ${TABLE_WIDTH_BREAKPOINTS.FIRST}px) {
  263. display: none;
  264. }
  265. `;
  266. const EventsHeading = styled(Heading)`
  267. @container (width < ${TABLE_WIDTH_BREAKPOINTS.SECOND}px) {
  268. display: none;
  269. }
  270. `;
  271. const UsersHeading = styled(Heading)`
  272. display: flex;
  273. justify-content: center;
  274. @container (width < ${TABLE_WIDTH_BREAKPOINTS.THIRD}px) {
  275. display: none;
  276. }
  277. `;
  278. const AssigneeHeading = styled(Heading)`
  279. @container (width < ${TABLE_WIDTH_BREAKPOINTS.FOURTH}px) {
  280. display: none;
  281. }
  282. `;
  283. const StyledPanel = styled(Panel)`
  284. container-type: inline-size;
  285. `;
  286. const StyledPanelHeader = styled(PanelHeader)`
  287. padding-top: ${space(1)};
  288. padding-bottom: ${space(1)};
  289. `;
  290. const StyledLoadingIndicatorWrapper = styled('div')`
  291. display: flex;
  292. justify-content: center;
  293. width: 100%;
  294. padding: ${space(2)} 0;
  295. height: 84px;
  296. /* Add a border between two rows of loading issue states */
  297. & + & {
  298. border-top: 1px solid ${p => p.theme.border};
  299. }
  300. `;
  301. const StyledIconWrapper = styled(IconWrapper)`
  302. margin: 0;
  303. `;
  304. const IssueSummaryWrapper = styled('div')`
  305. overflow: hidden;
  306. flex: 1;
  307. width: 66.66%;
  308. @media (min-width: ${p => p.theme.breakpoints.medium}) {
  309. width: 50%;
  310. }
  311. `;
  312. const ColumnWrapper = styled('div')`
  313. display: flex;
  314. justify-content: flex-end;
  315. align-self: center;
  316. width: 60px;
  317. margin: 0 ${space(2)};
  318. `;
  319. const EventsWrapper = styled(ColumnWrapper)`
  320. @container (width < ${TABLE_WIDTH_BREAKPOINTS.SECOND}px) {
  321. display: none;
  322. }
  323. `;
  324. const UserCountWrapper = styled(ColumnWrapper)`
  325. @container (width < ${TABLE_WIDTH_BREAKPOINTS.THIRD}px) {
  326. display: none;
  327. }
  328. `;
  329. const AssineeWrapper = styled(ColumnWrapper)`
  330. @container (width < ${TABLE_WIDTH_BREAKPOINTS.FOURTH}px) {
  331. display: none;
  332. }
  333. `;
  334. const ChartWrapper = styled('div')`
  335. width: 200px;
  336. align-self: center;
  337. @container (width < ${TABLE_WIDTH_BREAKPOINTS.FIRST}px) {
  338. display: none;
  339. }
  340. `;
  341. const PrimaryCount = styled(Count)`
  342. font-size: ${p => p.theme.fontSizeLarge};
  343. font-variant-numeric: tabular-nums;
  344. `;
  345. const StyledPanelItem = styled(PanelItem)`
  346. padding-top: ${space(1)};
  347. padding-bottom: ${space(1)};
  348. height: 84px;
  349. `;