groupEventCarousel.tsx 16 KB

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