groupEventCarousel.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583
  1. import {browserHistory} from 'react-router';
  2. import {useTheme} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import omit from 'lodash/omit';
  5. import moment from 'moment-timezone';
  6. import GuideAnchor from 'sentry/components/assistant/guideAnchor';
  7. import {Button, ButtonProps} from 'sentry/components/button';
  8. import {CompactSelect} from 'sentry/components/compactSelect';
  9. import DateTime from 'sentry/components/dateTime';
  10. import {DropdownMenu} from 'sentry/components/dropdownMenu';
  11. import FeatureBadge from 'sentry/components/featureBadge';
  12. import TimeSince from 'sentry/components/timeSince';
  13. import {Tooltip} from 'sentry/components/tooltip';
  14. import {
  15. IconChevron,
  16. IconCopy,
  17. IconEllipsis,
  18. IconJson,
  19. IconLink,
  20. IconNext,
  21. IconOpen,
  22. IconPrevious,
  23. IconWarning,
  24. } from 'sentry/icons';
  25. import {t} from 'sentry/locale';
  26. import {space} from 'sentry/styles/space';
  27. import {Event, Group, Organization} from 'sentry/types';
  28. import {defined, formatBytesBase2} from 'sentry/utils';
  29. import {trackAnalytics} from 'sentry/utils/analytics';
  30. import {eventDetailsRoute, generateEventSlug} from 'sentry/utils/discover/urls';
  31. import {
  32. getAnalyticsDataForEvent,
  33. getAnalyticsDataForGroup,
  34. getShortEventId,
  35. } from 'sentry/utils/events';
  36. import getDynamicText from 'sentry/utils/getDynamicText';
  37. import {projectCanLinkToReplay} from 'sentry/utils/replays/projectSupportsReplay';
  38. import useCopyToClipboard from 'sentry/utils/useCopyToClipboard';
  39. import {useLocation} from 'sentry/utils/useLocation';
  40. import useMedia from 'sentry/utils/useMedia';
  41. import useOrganization from 'sentry/utils/useOrganization';
  42. import {useParams} from 'sentry/utils/useParams';
  43. import {normalizeUrl} from 'sentry/utils/withDomainRequired';
  44. import EventCreatedTooltip from 'sentry/views/issueDetails/eventCreatedTooltip';
  45. import {useDefaultIssueEvent} from 'sentry/views/issueDetails/utils';
  46. import QuickTrace from './quickTrace';
  47. type GroupEventCarouselProps = {
  48. event: Event;
  49. group: Group;
  50. projectSlug: string;
  51. };
  52. type GroupEventNavigationProps = {
  53. event: Event;
  54. group: Group;
  55. isDisabled: boolean;
  56. };
  57. type EventNavigationButtonProps = {
  58. disabled: boolean;
  59. group: Group;
  60. icon: ButtonProps['icon'];
  61. referrer: string;
  62. title: string;
  63. eventId?: string | null;
  64. };
  65. enum EventNavDropdownOption {
  66. RECOMMENDED = 'recommended',
  67. LATEST = 'latest',
  68. OLDEST = 'oldest',
  69. CUSTOM = 'custom',
  70. ALL = 'all',
  71. }
  72. const BUTTON_SIZE = 'sm';
  73. const BUTTON_ICON_SIZE = 'sm';
  74. const makeBaseEventsPath = ({
  75. organization,
  76. group,
  77. }: {
  78. group: Group;
  79. organization: Organization;
  80. }) => `/organizations/${organization.slug}/issues/${group.id}/events/`;
  81. function EventNavigationButton({
  82. disabled,
  83. eventId,
  84. group,
  85. icon,
  86. title,
  87. referrer,
  88. }: EventNavigationButtonProps) {
  89. const organization = useOrganization();
  90. const location = useLocation();
  91. const baseEventsPath = makeBaseEventsPath({organization, group});
  92. // Need to wrap with Tooltip because our version of React Router doesn't allow access
  93. // to the anchor ref which is needed by Tooltip to position correctly.
  94. return (
  95. <Tooltip title={title} disabled={disabled} skipWrapper>
  96. <div>
  97. <StyledNavButton
  98. size={BUTTON_SIZE}
  99. icon={icon}
  100. aria-label={title}
  101. to={{
  102. pathname: `${baseEventsPath}${eventId}/`,
  103. query: {...location.query, referrer},
  104. }}
  105. disabled={disabled}
  106. />
  107. </div>
  108. </Tooltip>
  109. );
  110. }
  111. function EventNavigationDropdown({group, event, isDisabled}: GroupEventNavigationProps) {
  112. const location = useLocation();
  113. const params = useParams<{eventId?: string}>();
  114. const theme = useTheme();
  115. const organization = useOrganization();
  116. const largeViewport = useMedia(`(min-width: ${theme.breakpoints.large})`);
  117. const defaultIssueEvent = useDefaultIssueEvent();
  118. const isHelpfulEventUiEnabled =
  119. organization.features.includes('issue-details-most-helpful-event') &&
  120. organization.features.includes('issue-details-most-helpful-event-ui');
  121. if (!isHelpfulEventUiEnabled || !largeViewport) {
  122. return null;
  123. }
  124. const getSelectedOption = () => {
  125. switch (params.eventId) {
  126. case EventNavDropdownOption.RECOMMENDED:
  127. case EventNavDropdownOption.LATEST:
  128. case EventNavDropdownOption.OLDEST:
  129. return params.eventId;
  130. case undefined:
  131. return defaultIssueEvent;
  132. default:
  133. return undefined;
  134. }
  135. };
  136. const selectedValue = getSelectedOption();
  137. const eventNavDropdownOptions = [
  138. {
  139. value: EventNavDropdownOption.RECOMMENDED,
  140. label: (
  141. <div>
  142. {t('Recommended')}
  143. <FeatureBadge type="new" />
  144. </div>
  145. ),
  146. textValue: t('Recommended'),
  147. details: t('Event with the most context'),
  148. },
  149. {
  150. value: EventNavDropdownOption.LATEST,
  151. label: t('Latest'),
  152. details: t('Last seen event in this issue'),
  153. },
  154. {
  155. value: EventNavDropdownOption.OLDEST,
  156. label: t('Oldest'),
  157. details: t('First seen event in this issue'),
  158. },
  159. ...(!selectedValue
  160. ? [
  161. {
  162. value: EventNavDropdownOption.CUSTOM,
  163. label: t('Custom Selection'),
  164. },
  165. ]
  166. : []),
  167. {
  168. options: [{value: EventNavDropdownOption.ALL, label: 'View All Events'}],
  169. },
  170. ];
  171. return (
  172. <GuideAnchor target="issue_details_default_event" position="bottom">
  173. <CompactSelect
  174. size="sm"
  175. disabled={isDisabled}
  176. options={eventNavDropdownOptions}
  177. value={!selectedValue ? EventNavDropdownOption.CUSTOM : selectedValue}
  178. triggerLabel={
  179. !selectedValue ? (
  180. <TimeSince
  181. date={event.dateCreated ?? event.dateReceived}
  182. disabledAbsoluteTooltip
  183. />
  184. ) : selectedValue === EventNavDropdownOption.RECOMMENDED ? (
  185. t('Recommended')
  186. ) : undefined
  187. }
  188. menuWidth={232}
  189. onChange={selectedOption => {
  190. trackAnalytics('issue_details.event_dropdown_option_selected', {
  191. organization,
  192. selected_event_type: selectedOption.value,
  193. from_event_type: selectedValue ?? EventNavDropdownOption.CUSTOM,
  194. event_id: event.id,
  195. group_id: group.id,
  196. });
  197. switch (selectedOption.value) {
  198. case EventNavDropdownOption.RECOMMENDED:
  199. case EventNavDropdownOption.LATEST:
  200. case EventNavDropdownOption.OLDEST:
  201. browserHistory.push({
  202. pathname: normalizeUrl(
  203. makeBaseEventsPath({organization, group}) + selectedOption.value + '/'
  204. ),
  205. query: {...location.query, referrer: `${selectedOption.value}-event`},
  206. });
  207. break;
  208. case EventNavDropdownOption.ALL:
  209. const searchTermWithoutQuery = omit(location.query, 'query');
  210. browserHistory.push({
  211. pathname: normalizeUrl(
  212. `/organizations/${organization.slug}/issues/${group.id}/events/`
  213. ),
  214. query: searchTermWithoutQuery,
  215. });
  216. break;
  217. default:
  218. break;
  219. }
  220. }}
  221. />
  222. </GuideAnchor>
  223. );
  224. }
  225. export function GroupEventCarousel({event, group, projectSlug}: GroupEventCarouselProps) {
  226. const theme = useTheme();
  227. const organization = useOrganization();
  228. const location = useLocation();
  229. const xlargeViewport = useMedia(`(min-width: ${theme.breakpoints.xlarge})`);
  230. const hasReplay = Boolean(event?.tags?.find(({key}) => key === 'replayId')?.value);
  231. const isReplayEnabled =
  232. organization.features.includes('session-replay') &&
  233. projectCanLinkToReplay(group.project);
  234. const latencyThreshold = 30 * 60 * 1000; // 30 minutes
  235. const isOverLatencyThreshold =
  236. event.dateReceived &&
  237. event.dateCreated &&
  238. Math.abs(+moment(event.dateReceived) - +moment(event.dateCreated)) > latencyThreshold;
  239. const hasPreviousEvent = defined(event.previousEventID);
  240. const hasNextEvent = defined(event.nextEventID);
  241. const downloadJson = () => {
  242. const jsonUrl = `/api/0/projects/${organization.slug}/${projectSlug}/events/${event.id}/json/`;
  243. window.open(jsonUrl);
  244. trackAnalytics('issue_details.event_json_clicked', {
  245. organization,
  246. group_id: parseInt(`${event.groupID}`, 10),
  247. });
  248. };
  249. const {onClick: copyLink} = useCopyToClipboard({
  250. successMessage: t('Event URL copied to clipboard'),
  251. text:
  252. window.location.origin +
  253. normalizeUrl(`${makeBaseEventsPath({organization, group})}${event.id}/`),
  254. onCopy: () =>
  255. trackAnalytics('issue_details.copy_event_link_clicked', {
  256. organization,
  257. ...getAnalyticsDataForGroup(group),
  258. ...getAnalyticsDataForEvent(event),
  259. }),
  260. });
  261. const {onClick: copyEventId} = useCopyToClipboard({
  262. successMessage: t('Event ID copied to clipboard'),
  263. text: event.id,
  264. });
  265. const isHelpfulEventUiEnabled =
  266. organization.features.includes('issue-details-most-helpful-event') &&
  267. organization.features.includes('issue-details-most-helpful-event-ui');
  268. return (
  269. <CarouselAndButtonsWrapper>
  270. <div>
  271. <EventHeading>
  272. <EventIdAndTimeContainer>
  273. <EventIdContainer>
  274. <strong>Event ID:</strong>
  275. <Button
  276. aria-label={t('Copy')}
  277. borderless
  278. onClick={copyEventId}
  279. size="zero"
  280. title={event.id}
  281. tooltipProps={{overlayStyle: {maxWidth: 'max-content'}}}
  282. translucentBorder
  283. >
  284. <EventId>
  285. {getShortEventId(event.id)}
  286. <CopyIconContainer>
  287. <IconCopy size="xs" />
  288. </CopyIconContainer>
  289. </EventId>
  290. </Button>
  291. </EventIdContainer>
  292. {(event.dateCreated ?? event.dateReceived) && (
  293. <EventTimeLabel>
  294. {getDynamicText({
  295. fixed: 'Jan 1, 12:00 AM',
  296. value: (
  297. <Tooltip
  298. isHoverable
  299. showUnderline
  300. title={<EventCreatedTooltip event={event} />}
  301. overlayStyle={{maxWidth: 300}}
  302. >
  303. <DateTime date={event.dateCreated ?? event.dateReceived} />
  304. </Tooltip>
  305. ),
  306. })}
  307. {isOverLatencyThreshold && (
  308. <Tooltip title="High latency">
  309. <StyledIconWarning size="xs" color="warningText" />
  310. </Tooltip>
  311. )}
  312. </EventTimeLabel>
  313. )}
  314. </EventIdAndTimeContainer>
  315. </EventHeading>
  316. <QuickTrace event={event} organization={organization} location={location} />
  317. </div>
  318. <ActionsWrapper>
  319. <DropdownMenu
  320. position="bottom-end"
  321. triggerProps={{
  322. 'aria-label': t('Event Actions Menu'),
  323. icon: <IconEllipsis size="xs" />,
  324. showChevron: false,
  325. size: BUTTON_SIZE,
  326. }}
  327. items={[
  328. {
  329. key: 'copy-event-id',
  330. label: t('Copy Event ID'),
  331. onAction: copyEventId,
  332. },
  333. {
  334. key: 'copy-event-url',
  335. label: t('Copy Event Link'),
  336. hidden: xlargeViewport,
  337. onAction: copyLink,
  338. },
  339. {
  340. key: 'json',
  341. label: `JSON (${formatBytesBase2(event.size)})`,
  342. onAction: downloadJson,
  343. hidden: xlargeViewport,
  344. },
  345. {
  346. key: 'full-event-discover',
  347. label: t('Full Event Details'),
  348. hidden: !organization.features.includes('discover-basic'),
  349. to: eventDetailsRoute({
  350. eventSlug: generateEventSlug({project: projectSlug, id: event.id}),
  351. orgSlug: organization.slug,
  352. }),
  353. onAction: () => {
  354. trackAnalytics('issue_details.event_details_clicked', {
  355. organization,
  356. ...getAnalyticsDataForGroup(group),
  357. ...getAnalyticsDataForEvent(event),
  358. });
  359. },
  360. },
  361. {
  362. key: 'replay',
  363. label: t('View Replay'),
  364. hidden: !hasReplay || !isReplayEnabled,
  365. onAction: () => {
  366. const breadcrumbsHeader = document.getElementById('breadcrumbs');
  367. if (breadcrumbsHeader) {
  368. breadcrumbsHeader.scrollIntoView({behavior: 'smooth'});
  369. }
  370. trackAnalytics('issue_details.header_view_replay_clicked', {
  371. organization,
  372. ...getAnalyticsDataForGroup(group),
  373. ...getAnalyticsDataForEvent(event),
  374. });
  375. },
  376. },
  377. ]}
  378. />
  379. {xlargeViewport && (
  380. <Button
  381. title={
  382. isHelpfulEventUiEnabled ? t('Copy link to this issue event') : undefined
  383. }
  384. size={BUTTON_SIZE}
  385. onClick={copyLink}
  386. aria-label={t('Copy Link')}
  387. icon={isHelpfulEventUiEnabled ? <IconLink /> : undefined}
  388. >
  389. {!isHelpfulEventUiEnabled && 'Copy Link'}
  390. </Button>
  391. )}
  392. {xlargeViewport && (
  393. <Button
  394. title={isHelpfulEventUiEnabled ? t('View JSON') : undefined}
  395. size={BUTTON_SIZE}
  396. onClick={downloadJson}
  397. aria-label={t('View JSON')}
  398. icon={
  399. isHelpfulEventUiEnabled ? (
  400. <IconJson />
  401. ) : (
  402. <IconOpen size={BUTTON_ICON_SIZE} />
  403. )
  404. }
  405. >
  406. {!isHelpfulEventUiEnabled && 'JSON'}
  407. </Button>
  408. )}
  409. <EventNavigationDropdown
  410. isDisabled={!hasPreviousEvent && !hasNextEvent}
  411. group={group}
  412. event={event}
  413. />
  414. <NavButtons>
  415. {!isHelpfulEventUiEnabled && (
  416. <EventNavigationButton
  417. group={group}
  418. icon={<IconPrevious size={BUTTON_ICON_SIZE} />}
  419. disabled={!hasPreviousEvent}
  420. title={t('First Event')}
  421. eventId="oldest"
  422. referrer="oldest-event"
  423. />
  424. )}
  425. <EventNavigationButton
  426. group={group}
  427. icon={<IconChevron direction="left" size={BUTTON_ICON_SIZE} />}
  428. disabled={!hasPreviousEvent}
  429. title={t('Previous Event')}
  430. eventId={event.previousEventID}
  431. referrer="previous-event"
  432. />
  433. <EventNavigationButton
  434. group={group}
  435. icon={<IconChevron direction="right" size={BUTTON_ICON_SIZE} />}
  436. disabled={!hasNextEvent}
  437. title={t('Next Event')}
  438. eventId={event.nextEventID}
  439. referrer="next-event"
  440. />
  441. {!isHelpfulEventUiEnabled && (
  442. <EventNavigationButton
  443. group={group}
  444. icon={<IconNext size={BUTTON_ICON_SIZE} />}
  445. disabled={!hasNextEvent}
  446. title={t('Latest Event')}
  447. eventId="latest"
  448. referrer="latest-event"
  449. />
  450. )}
  451. </NavButtons>
  452. </ActionsWrapper>
  453. </CarouselAndButtonsWrapper>
  454. );
  455. }
  456. const CarouselAndButtonsWrapper = styled('div')`
  457. display: flex;
  458. justify-content: space-between;
  459. align-items: flex-start;
  460. gap: ${space(1)};
  461. margin-bottom: ${space(0.5)};
  462. `;
  463. const EventHeading = styled('div')`
  464. display: flex;
  465. align-items: center;
  466. flex-wrap: wrap;
  467. gap: ${space(1)};
  468. font-size: ${p => p.theme.fontSizeLarge};
  469. @media (max-width: 600px) {
  470. font-size: ${p => p.theme.fontSizeMedium};
  471. }
  472. `;
  473. const ActionsWrapper = styled('div')`
  474. display: flex;
  475. align-items: center;
  476. gap: ${space(0.5)};
  477. `;
  478. const StyledNavButton = styled(Button)`
  479. border-radius: 0;
  480. `;
  481. const NavButtons = styled('div')`
  482. display: flex;
  483. > * {
  484. &:not(:last-child) {
  485. ${StyledNavButton} {
  486. border-right: none;
  487. }
  488. }
  489. &:first-child {
  490. ${StyledNavButton} {
  491. border-radius: ${p => p.theme.borderRadius} 0 0 ${p => p.theme.borderRadius};
  492. }
  493. }
  494. &:last-child {
  495. ${StyledNavButton} {
  496. border-radius: 0 ${p => p.theme.borderRadius} ${p => p.theme.borderRadius} 0;
  497. }
  498. }
  499. }
  500. `;
  501. const EventIdAndTimeContainer = styled('div')`
  502. display: flex;
  503. align-items: center;
  504. column-gap: ${space(0.75)};
  505. row-gap: 0;
  506. flex-wrap: wrap;
  507. `;
  508. const EventIdContainer = styled('div')`
  509. display: flex;
  510. align-items: center;
  511. column-gap: ${space(0.25)};
  512. `;
  513. const EventTimeLabel = styled('span')`
  514. color: ${p => p.theme.subText};
  515. `;
  516. const StyledIconWarning = styled(IconWarning)`
  517. margin-left: ${space(0.25)};
  518. position: relative;
  519. top: 1px;
  520. `;
  521. const EventId = styled('span')`
  522. position: relative;
  523. font-weight: normal;
  524. font-size: ${p => p.theme.fontSizeLarge};
  525. &:hover {
  526. > span {
  527. display: flex;
  528. }
  529. }
  530. @media (max-width: 600px) {
  531. font-size: ${p => p.theme.fontSizeMedium};
  532. }
  533. `;
  534. const CopyIconContainer = styled('span')`
  535. display: none;
  536. align-items: center;
  537. padding: ${space(0.25)};
  538. background: ${p => p.theme.background};
  539. position: absolute;
  540. right: 0;
  541. top: 50%;
  542. transform: translateY(-50%);
  543. `;