groupEventCarousel.tsx 19 KB

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