eventNavigation.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  1. import {type CSSProperties, forwardRef} from 'react';
  2. import {Fragment} from 'react';
  3. import {css, useTheme} from '@emotion/react';
  4. import styled from '@emotion/styled';
  5. import color from 'color';
  6. import omit from 'lodash/omit';
  7. import {Button, LinkButton} from 'sentry/components/button';
  8. import {Chevron} from 'sentry/components/chevron';
  9. import {DropdownMenu} from 'sentry/components/dropdownMenu';
  10. import {useActionableItems} from 'sentry/components/events/interfaces/crashContent/exception/useActionableItems';
  11. import {ScrollCarousel} from 'sentry/components/scrollCarousel';
  12. import {TabList, Tabs} from 'sentry/components/tabs';
  13. import TimeSince from 'sentry/components/timeSince';
  14. import {Tooltip} from 'sentry/components/tooltip';
  15. import {IconChevron, IconCopy, IconWarning} from 'sentry/icons';
  16. import {t} from 'sentry/locale';
  17. import {space} from 'sentry/styles/space';
  18. import type {Event} from 'sentry/types/event';
  19. import type {Group} from 'sentry/types/group';
  20. import {defined} from 'sentry/utils';
  21. import {trackAnalytics} from 'sentry/utils/analytics';
  22. import {
  23. getAnalyticsDataForEvent,
  24. getAnalyticsDataForGroup,
  25. getShortEventId,
  26. } from 'sentry/utils/events';
  27. import {getReplayIdFromEvent} from 'sentry/utils/replays/getReplayIdFromEvent';
  28. import normalizeUrl from 'sentry/utils/url/normalizeUrl';
  29. import useCopyToClipboard from 'sentry/utils/useCopyToClipboard';
  30. import {useLocation} from 'sentry/utils/useLocation';
  31. import useOrganization from 'sentry/utils/useOrganization';
  32. import {useParams} from 'sentry/utils/useParams';
  33. import {Divider} from 'sentry/views/issueDetails/divider';
  34. import {FoldSectionKey} from 'sentry/views/issueDetails/streamline/foldSection';
  35. import {useDefaultIssueEvent} from 'sentry/views/issueDetails/utils';
  36. export const MIN_NAV_HEIGHT = 44;
  37. type EventNavigationProps = {
  38. event: Event;
  39. group: Group;
  40. className?: string;
  41. style?: CSSProperties;
  42. };
  43. type SectionDefinition = {
  44. condition: (event: Event) => boolean;
  45. label: string;
  46. section: FoldSectionKey;
  47. };
  48. enum EventNavOptions {
  49. RECOMMENDED = 'recommended',
  50. LATEST = 'latest',
  51. OLDEST = 'oldest',
  52. CUSTOM = 'custom',
  53. }
  54. const EventNavLabels = {
  55. [EventNavOptions.RECOMMENDED]: t('Recommended Event'),
  56. [EventNavOptions.OLDEST]: t('First Event'),
  57. [EventNavOptions.LATEST]: t('Last Event'),
  58. [EventNavOptions.CUSTOM]: t('Custom Event'),
  59. };
  60. const EventNavOrder = [
  61. EventNavOptions.RECOMMENDED,
  62. EventNavOptions.OLDEST,
  63. EventNavOptions.LATEST,
  64. EventNavOptions.CUSTOM,
  65. ];
  66. const eventDataSections: SectionDefinition[] = [
  67. {
  68. section: FoldSectionKey.HIGHLIGHTS,
  69. label: t('Event Highlights'),
  70. condition: () => true,
  71. },
  72. {
  73. section: FoldSectionKey.STACKTRACE,
  74. label: t('Stack Trace'),
  75. condition: (event: Event) => event.entries.some(entry => entry.type === 'stacktrace'),
  76. },
  77. {
  78. section: FoldSectionKey.EXCEPTION,
  79. label: t('Stack Trace'),
  80. condition: (event: Event) => event.entries.some(entry => entry.type === 'exception'),
  81. },
  82. {
  83. section: FoldSectionKey.BREADCRUMBS,
  84. label: t('Breadcrumbs'),
  85. condition: (event: Event) =>
  86. event.entries.some(entry => entry.type === 'breadcrumbs'),
  87. },
  88. {
  89. section: FoldSectionKey.TAGS,
  90. label: t('Tags'),
  91. condition: (event: Event) => event.tags.length > 0,
  92. },
  93. {
  94. section: FoldSectionKey.CONTEXTS,
  95. label: t('Context'),
  96. condition: (event: Event) => !!event.context,
  97. },
  98. {
  99. section: FoldSectionKey.USER_FEEDBACK,
  100. label: t('User Feedback'),
  101. condition: (event: Event) => !!event.userReport,
  102. },
  103. {
  104. section: FoldSectionKey.REPLAY,
  105. label: t('Replay'),
  106. condition: (event: Event) => !!getReplayIdFromEvent(event),
  107. },
  108. ];
  109. export const EventNavigation = forwardRef<HTMLDivElement, EventNavigationProps>(
  110. function EventNavigation({event, group, ...props}, ref) {
  111. const location = useLocation();
  112. const organization = useOrganization();
  113. const theme = useTheme();
  114. const params = useParams<{eventId?: string}>();
  115. const defaultIssueEvent = useDefaultIssueEvent();
  116. const {data: actionableItems} = useActionableItems({
  117. eventId: event.id,
  118. orgSlug: organization.slug,
  119. projectSlug: group.project.slug,
  120. });
  121. const hasEventError = actionableItems?.errors && actionableItems.errors.length > 0;
  122. const getSelectedOption = () => {
  123. switch (params.eventId) {
  124. case EventNavOptions.RECOMMENDED:
  125. case EventNavOptions.LATEST:
  126. case EventNavOptions.OLDEST:
  127. return params.eventId;
  128. case undefined:
  129. return defaultIssueEvent;
  130. default:
  131. return EventNavOptions.CUSTOM;
  132. }
  133. };
  134. const selectedOption = getSelectedOption();
  135. const hasPreviousEvent = defined(event.previousEventID);
  136. const hasNextEvent = defined(event.nextEventID);
  137. const baseEventsPath = `/organizations/${organization.slug}/issues/${group.id}/events/`;
  138. const jumpToSections = eventDataSections.filter(eventSection =>
  139. eventSection.condition(event)
  140. );
  141. const grayText = css`
  142. color: ${theme.subText};
  143. font-weight: ${theme.fontWeightNormal};
  144. `;
  145. const downloadJson = () => {
  146. const host = organization.links.regionUrl;
  147. const jsonUrl = `${host}/api/0/projects/${organization.slug}/${group.project.slug}/events/${event.id}/json/`;
  148. window.open(jsonUrl);
  149. trackAnalytics('issue_details.event_json_clicked', {
  150. organization,
  151. group_id: parseInt(`${event.groupID}`, 10),
  152. });
  153. };
  154. const {onClick: copyLink} = useCopyToClipboard({
  155. successMessage: t('Event URL copied to clipboard'),
  156. text: window.location.origin + normalizeUrl(`${baseEventsPath}${event.id}/`),
  157. onCopy: () =>
  158. trackAnalytics('issue_details.copy_event_link_clicked', {
  159. organization,
  160. ...getAnalyticsDataForGroup(group),
  161. ...getAnalyticsDataForEvent(event),
  162. }),
  163. });
  164. const {onClick: copyEventId} = useCopyToClipboard({
  165. successMessage: t('Event ID copied to clipboard'),
  166. text: event.id,
  167. });
  168. return (
  169. <div {...props} ref={ref}>
  170. <EventNavigationWrapper>
  171. <Tabs value={selectedOption}>
  172. <TabList hideBorder variant="floating">
  173. {EventNavOrder.map(label => {
  174. const eventPath =
  175. label === selectedOption
  176. ? undefined
  177. : {
  178. pathname: normalizeUrl(baseEventsPath + label + '/'),
  179. query: {...location.query, referrer: `${label}-event`},
  180. };
  181. return (
  182. <TabList.Item
  183. to={eventPath}
  184. key={label}
  185. hidden={
  186. label === EventNavOptions.CUSTOM &&
  187. selectedOption !== EventNavOptions.CUSTOM
  188. }
  189. >
  190. {EventNavLabels[label]}
  191. </TabList.Item>
  192. );
  193. })}
  194. </TabList>
  195. </Tabs>
  196. <NavigationWrapper>
  197. <Navigation>
  198. <Tooltip title={t('Previous Event')}>
  199. <LinkButton
  200. aria-label={t('Previous Event')}
  201. borderless
  202. size="xs"
  203. icon={<IconChevron direction="left" />}
  204. disabled={!hasPreviousEvent}
  205. to={{
  206. pathname: `${baseEventsPath}${event.previousEventID}/`,
  207. query: {...location.query, referrer: 'previous-event'},
  208. }}
  209. css={grayText}
  210. />
  211. </Tooltip>
  212. <Tooltip title={t('Next Event')}>
  213. <LinkButton
  214. aria-label={t('Next Event')}
  215. borderless
  216. size="xs"
  217. icon={<IconChevron direction="right" />}
  218. disabled={!hasNextEvent}
  219. to={{
  220. pathname: `${baseEventsPath}${event.nextEventID}/`,
  221. query: {...location.query, referrer: 'next-event'},
  222. }}
  223. css={grayText}
  224. />
  225. </Tooltip>
  226. </Navigation>
  227. <LinkButton
  228. to={{
  229. pathname: normalizeUrl(
  230. `/organizations/${organization.slug}/issues/${group.id}/events/`
  231. ),
  232. query: omit(location.query, 'query'),
  233. }}
  234. borderless
  235. size="xs"
  236. css={grayText}
  237. >
  238. {t('View All Events')}
  239. </LinkButton>
  240. </NavigationWrapper>
  241. </EventNavigationWrapper>
  242. <EventInfoJumpToWrapper>
  243. <EventInfo>
  244. <EventIdInfo>
  245. <EventTitle>{t('Event')}</EventTitle>
  246. <Button
  247. aria-label={t('Copy')}
  248. borderless
  249. onClick={copyEventId}
  250. size="zero"
  251. title={event.id}
  252. tooltipProps={{overlayStyle: {maxWidth: 'max-content'}}}
  253. translucentBorder
  254. >
  255. <EventId>
  256. {getShortEventId(event.id)}
  257. <CopyIconContainer>
  258. <IconCopy size="xs" />
  259. </CopyIconContainer>
  260. </EventId>
  261. </Button>
  262. <DropdownMenu
  263. triggerProps={{
  264. 'aria-label': t('Event actions'),
  265. icon: <Chevron direction="down" color={theme.subText} />,
  266. size: 'zero',
  267. borderless: true,
  268. showChevron: false,
  269. }}
  270. position="bottom"
  271. size="xs"
  272. items={[
  273. {
  274. key: 'copy-event-id',
  275. label: t('Copy Event ID'),
  276. onAction: copyEventId,
  277. },
  278. {
  279. key: 'copy-event-link',
  280. label: t('Copy Event Link'),
  281. onAction: copyLink,
  282. },
  283. {
  284. key: 'view-json',
  285. label: t('View JSON'),
  286. onAction: downloadJson,
  287. },
  288. ]}
  289. />
  290. </EventIdInfo>
  291. <StyledTimeSince
  292. date={event.dateCreated ?? event.dateReceived}
  293. css={grayText}
  294. />
  295. {hasEventError && (
  296. <Fragment>
  297. <Divider />
  298. <ProcessingErrorButton
  299. title={t(
  300. 'Sentry has detected configuration issues with this event. Click for more info.'
  301. )}
  302. borderless
  303. size="zero"
  304. icon={<IconWarning color="red300" />}
  305. onClick={() => {
  306. document
  307. .getElementById(FoldSectionKey.PROCESSING_ERROR)
  308. ?.scrollIntoView({block: 'start', behavior: 'smooth'});
  309. }}
  310. >
  311. {t('Processing Error')}
  312. </ProcessingErrorButton>
  313. </Fragment>
  314. )}
  315. </EventInfo>
  316. <JumpTo>
  317. <div>{t('Jump to:')}</div>
  318. <ScrollCarousel gap={0.25}>
  319. {jumpToSections.map(jump => (
  320. <Button
  321. key={jump.section}
  322. onClick={() => {
  323. document
  324. .getElementById(jump.section)
  325. ?.scrollIntoView({block: 'start', behavior: 'smooth'});
  326. }}
  327. borderless
  328. size="xs"
  329. css={grayText}
  330. >
  331. {jump.label}
  332. </Button>
  333. ))}
  334. </ScrollCarousel>
  335. </JumpTo>
  336. </EventInfoJumpToWrapper>
  337. </div>
  338. );
  339. }
  340. );
  341. const EventNavigationWrapper = styled('div')`
  342. display: flex;
  343. justify-content: space-between;
  344. font-size: ${p => p.theme.fontSizeSmall};
  345. padding: ${space(1)} ${space(1.5)};
  346. min-height: ${MIN_NAV_HEIGHT}px;
  347. border-bottom: 1px solid ${p => p.theme.border};
  348. `;
  349. const NavigationWrapper = styled('div')`
  350. display: flex;
  351. `;
  352. const Navigation = styled('div')`
  353. display: flex;
  354. border-right: 1px solid ${p => p.theme.gray100};
  355. `;
  356. const StyledTimeSince = styled(TimeSince)`
  357. color: ${p => p.theme.subText};
  358. font-weight: ${p => p.theme.fontWeightNormal};
  359. white-space: nowrap;
  360. `;
  361. const EventInfoJumpToWrapper = styled('div')`
  362. display: flex;
  363. gap: ${space(1)};
  364. flex-direction: row;
  365. justify-content: space-between;
  366. align-items: center;
  367. padding: ${space(1)} ${space(2)};
  368. flex-wrap: wrap;
  369. min-height: ${MIN_NAV_HEIGHT}px;
  370. @media (min-width: ${p => p.theme.breakpoints.small}) {
  371. flex-wrap: nowrap;
  372. }
  373. box-shadow: ${p => p.theme.translucentBorder} 0 1px;
  374. `;
  375. const EventInfo = styled('div')`
  376. display: flex;
  377. gap: ${space(1)};
  378. flex-direction: row;
  379. align-items: center;
  380. `;
  381. const JumpTo = styled('div')`
  382. display: flex;
  383. gap: ${space(1)};
  384. flex-direction: row;
  385. align-items: center;
  386. color: ${p => p.theme.subText};
  387. font-size: ${p => p.theme.fontSizeSmall};
  388. white-space: nowrap;
  389. max-width: 100%;
  390. @media (min-width: ${p => p.theme.breakpoints.small}) {
  391. max-width: 50%;
  392. }
  393. `;
  394. const EventIdInfo = styled('span')`
  395. display: flex;
  396. align-items: center;
  397. gap: ${space(0.25)};
  398. `;
  399. const EventId = styled('span')`
  400. position: relative;
  401. font-weight: ${p => p.theme.fontWeightBold};
  402. text-decoration: underline;
  403. text-decoration-color: ${p => color(p.theme.gray200).alpha(0.5).string()};
  404. &:hover {
  405. > span {
  406. display: flex;
  407. }
  408. }
  409. `;
  410. const CopyIconContainer = styled('span')`
  411. display: none;
  412. align-items: center;
  413. padding: ${space(0.25)};
  414. background: ${p => p.theme.background};
  415. position: absolute;
  416. right: 0;
  417. top: 50%;
  418. transform: translateY(-50%);
  419. `;
  420. const EventTitle = styled('div')`
  421. font-weight: ${p => p.theme.fontWeightBold};
  422. `;
  423. const ProcessingErrorButton = styled(Button)`
  424. color: ${p => p.theme.red300};
  425. font-weight: ${p => p.theme.fontWeightNormal};
  426. :hover {
  427. color: ${p => p.theme.red300};
  428. }
  429. `;