groupEventCarousel.tsx 18 KB

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