eventNavigation.tsx 14 KB

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