issues.tsx 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338
  1. import {useMemo} from 'react';
  2. import styled from '@emotion/styled';
  3. import * as qs from 'query-string';
  4. import ActorAvatar from 'sentry/components/avatar/actorAvatar';
  5. import Count from 'sentry/components/count';
  6. import EventOrGroupExtraDetails from 'sentry/components/eventOrGroupExtraDetails';
  7. import Link from 'sentry/components/links/link';
  8. import LoadingError from 'sentry/components/loadingError';
  9. import LoadingIndicator from 'sentry/components/loadingIndicator';
  10. import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
  11. import Panel from 'sentry/components/panels/panel';
  12. import PanelHeader from 'sentry/components/panels/panelHeader';
  13. import PanelItem from 'sentry/components/panels/panelItem';
  14. import {IconWrapper} from 'sentry/components/sidebarSection';
  15. import GroupChart from 'sentry/components/stream/groupChart';
  16. import {IconUser} from 'sentry/icons';
  17. import {t, tct, tn} from 'sentry/locale';
  18. import {space} from 'sentry/styles/space';
  19. import type {Group, Organization} from 'sentry/types';
  20. import type {TraceErrorOrIssue} from 'sentry/utils/performance/quickTrace/types';
  21. import {useApiQuery} from 'sentry/utils/queryClient';
  22. import {decodeScalar} from 'sentry/utils/queryString';
  23. import useOrganization from 'sentry/utils/useOrganization';
  24. import {useParams} from 'sentry/utils/useParams';
  25. import type {
  26. TraceTree,
  27. TraceTreeNode,
  28. } from 'sentry/views/performance/newTraceDetails/traceTree';
  29. import {
  30. isAutogroupedNode,
  31. isMissingInstrumentationNode,
  32. isSpanNode,
  33. isTraceErrorNode,
  34. isTransactionNode,
  35. } from '../../../guards';
  36. import {IssueSummary} from './issueSummary';
  37. type IssueProps = {
  38. issue: TraceErrorOrIssue;
  39. organization: Organization;
  40. };
  41. const MAX_DISPLAYED_ISSUES_COUNT = 10;
  42. const MIN_ISSUES_TABLE_WIDTH = 600;
  43. function Issue(props: IssueProps) {
  44. const {
  45. isLoading,
  46. data: fetchedIssue,
  47. isError,
  48. } = useApiQuery<Group>(
  49. [
  50. `/issues/${props.issue.issue_id}/`,
  51. {
  52. query: {
  53. collapse: 'release',
  54. expand: 'inbox',
  55. },
  56. },
  57. ],
  58. {
  59. staleTime: 2 * 60 * 1000,
  60. }
  61. );
  62. return isLoading ? (
  63. <StyledLoadingIndicatorWrapper>
  64. <LoadingIndicator size={24} mini />
  65. </StyledLoadingIndicatorWrapper>
  66. ) : fetchedIssue ? (
  67. <StyledPanelItem>
  68. <IssueSummaryWrapper>
  69. <IssueSummary
  70. data={fetchedIssue}
  71. organization={props.organization}
  72. event_id={props.issue.event_id}
  73. />
  74. <EventOrGroupExtraDetails data={fetchedIssue} />
  75. </IssueSummaryWrapper>
  76. <ChartWrapper>
  77. <GroupChart
  78. statsPeriod={'24h'}
  79. data={fetchedIssue}
  80. showSecondaryPoints
  81. showMarkLine
  82. />
  83. </ChartWrapper>
  84. <ColumnWrapper>
  85. <PrimaryCount
  86. value={fetchedIssue.filtered ? fetchedIssue.filtered.count : fetchedIssue.count}
  87. />
  88. </ColumnWrapper>
  89. <ColumnWrapper>
  90. <PrimaryCount
  91. value={
  92. fetchedIssue.filtered
  93. ? fetchedIssue.filtered.userCount
  94. : fetchedIssue.userCount
  95. }
  96. />
  97. </ColumnWrapper>
  98. <ColumnWrapper>
  99. {fetchedIssue.assignedTo ? (
  100. <ActorAvatar actor={fetchedIssue.assignedTo} hasTooltip size={24} />
  101. ) : (
  102. <StyledIconWrapper>
  103. <IconUser size="md" />
  104. </StyledIconWrapper>
  105. )}
  106. </ColumnWrapper>
  107. </StyledPanelItem>
  108. ) : isError ? (
  109. <LoadingError message={t('Failed to fetch issue')} />
  110. ) : null;
  111. }
  112. type IssueListProps = {
  113. issues: TraceErrorOrIssue[];
  114. node: TraceTreeNode<TraceTree.NodeValue>;
  115. organization: Organization;
  116. };
  117. export function IssueList({issues, node, organization}: IssueListProps) {
  118. if (!issues.length) {
  119. return null;
  120. }
  121. return (
  122. <StyledPanel>
  123. <IssueListHeader node={node} />
  124. {issues.slice(0, MAX_DISPLAYED_ISSUES_COUNT).map((issue, index) => (
  125. <Issue key={index} issue={issue} organization={organization} />
  126. ))}
  127. </StyledPanel>
  128. );
  129. }
  130. function getSearchParamFromNode(node: TraceTreeNode<TraceTree.NodeValue>) {
  131. if (isTransactionNode(node) || isTraceErrorNode(node)) {
  132. return `id:${node.value.event_id}`;
  133. }
  134. // Issues associated to a span or autogrouped node are not queryable, so we query by
  135. // the parent transaction's id
  136. const parentTransaction = node.parent_transaction;
  137. if ((isSpanNode(node) || isAutogroupedNode(node)) && parentTransaction) {
  138. return `id:${parentTransaction.value.event_id}`;
  139. }
  140. if (isMissingInstrumentationNode(node)) {
  141. throw new Error('Missing instrumentation nodes do not have associated issues');
  142. }
  143. return '';
  144. }
  145. function IssueListHeader({node}: {node: TraceTreeNode<TraceTree.NodeValue>}) {
  146. const {errors, performance_issues} = node;
  147. const organization = useOrganization();
  148. const params = useParams<{traceSlug?: string}>();
  149. const traceSlug = params.traceSlug?.trim() ?? '';
  150. const dateSelection = useMemo(() => {
  151. const normalizedParams = normalizeDateTimeParams(qs.parse(window.location.search), {
  152. allowAbsolutePageDatetime: true,
  153. });
  154. const start = decodeScalar(normalizedParams.start);
  155. const end = decodeScalar(normalizedParams.end);
  156. const statsPeriod = decodeScalar(normalizedParams.statsPeriod);
  157. return {start, end, statsPeriod};
  158. }, []);
  159. return (
  160. <StyledPanelHeader disablePadding>
  161. <IssueHeading>
  162. {errors.size + performance_issues.size > MAX_DISPLAYED_ISSUES_COUNT
  163. ? tct(`[count]+ issues, [link]`, {
  164. count: MAX_DISPLAYED_ISSUES_COUNT,
  165. link: (
  166. <StyledLink
  167. to={{
  168. pathname: `/organizations/${organization.slug}/issues/`,
  169. query: {
  170. query: `trace:${traceSlug} ${getSearchParamFromNode(node)}`,
  171. start: dateSelection.start,
  172. end: dateSelection.end,
  173. statsPeriod: dateSelection.statsPeriod,
  174. },
  175. }}
  176. >
  177. {t('View All')}
  178. </StyledLink>
  179. ),
  180. })
  181. : errors.size > 0 && performance_issues.size === 0
  182. ? tct('[count] [text]', {
  183. count: errors.size,
  184. text: tn('Error', 'Errors', errors.size),
  185. })
  186. : performance_issues.size > 0 && errors.size === 0
  187. ? tct('[count] [text]', {
  188. count: performance_issues.size,
  189. text: tn(
  190. 'Performance issue',
  191. 'Performance Issues',
  192. performance_issues.size
  193. ),
  194. })
  195. : tct(
  196. '[errors] [errorsText] and [performance_issues] [performanceIssuesText]',
  197. {
  198. errors: errors.size,
  199. performance_issues: performance_issues.size,
  200. errorsText: tn('Error', 'Errors', errors.size),
  201. performanceIssuesText: tn(
  202. 'performance issue',
  203. 'performance issues',
  204. performance_issues.size
  205. ),
  206. }
  207. )}
  208. </IssueHeading>
  209. <GraphHeading>{t('Graph')}</GraphHeading>
  210. <Heading>{t('Events')}</Heading>
  211. <UsersHeading>{t('Users')}</UsersHeading>
  212. <Heading>{t('Assignee')}</Heading>
  213. </StyledPanelHeader>
  214. );
  215. }
  216. const StyledLink = styled(Link)`
  217. margin-left: ${space(0.5)};
  218. `;
  219. const Heading = styled('div')`
  220. display: flex;
  221. align-self: center;
  222. margin: 0 ${space(2)};
  223. width: 60px;
  224. color: ${p => p.theme.subText};
  225. `;
  226. const IssueHeading = styled(Heading)`
  227. flex: 1;
  228. width: 66.66%;
  229. @media (min-width: ${p => p.theme.breakpoints.medium}) {
  230. width: 50%;
  231. }
  232. `;
  233. const GraphHeading = styled(Heading)`
  234. width: 160px;
  235. display: flex;
  236. justify-content: center;
  237. @container (width < ${MIN_ISSUES_TABLE_WIDTH}px) {
  238. display: none;
  239. }
  240. `;
  241. const UsersHeading = styled(Heading)`
  242. display: flex;
  243. justify-content: center;
  244. `;
  245. const StyledPanel = styled(Panel)`
  246. margin-bottom: 0;
  247. border: 1px solid ${p => p.theme.red200};
  248. container-type: inline-size;
  249. `;
  250. const StyledPanelHeader = styled(PanelHeader)`
  251. padding-top: ${space(1)};
  252. padding-bottom: ${space(1)};
  253. border-bottom: 1px solid ${p => p.theme.red200};
  254. `;
  255. const StyledLoadingIndicatorWrapper = styled('div')`
  256. display: flex;
  257. justify-content: center;
  258. width: 100%;
  259. padding: ${space(2)} 0;
  260. height: 84px;
  261. /* Add a border between two rows of loading issue states */
  262. & + & {
  263. border-top: 1px solid ${p => p.theme.border};
  264. }
  265. `;
  266. const StyledIconWrapper = styled(IconWrapper)`
  267. margin: 0;
  268. `;
  269. const IssueSummaryWrapper = styled('div')`
  270. overflow: hidden;
  271. flex: 1;
  272. width: 66.66%;
  273. @media (min-width: ${p => p.theme.breakpoints.medium}) {
  274. width: 50%;
  275. }
  276. `;
  277. const ChartWrapper = styled('div')`
  278. width: 200px;
  279. align-self: center;
  280. @container (width < ${MIN_ISSUES_TABLE_WIDTH}px) {
  281. display: none;
  282. }
  283. `;
  284. const ColumnWrapper = styled('div')`
  285. display: flex;
  286. justify-content: flex-end;
  287. align-self: center;
  288. width: 60px;
  289. margin: 0 ${space(2)};
  290. `;
  291. const PrimaryCount = styled(Count)`
  292. font-size: ${p => p.theme.fontSizeLarge};
  293. font-variant-numeric: tabular-nums;
  294. `;
  295. const StyledPanelItem = styled(PanelItem)`
  296. padding-top: ${space(1)};
  297. padding-bottom: ${space(1)};
  298. height: 84px;
  299. `;