groupEventCarousel.tsx 15 KB

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