groupEventCarousel.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388
  1. import {useTheme} from '@emotion/react';
  2. import styled from '@emotion/styled';
  3. import moment from 'moment-timezone';
  4. import {addSuccessMessage} from 'sentry/actionCreators/indicator';
  5. import {Button, ButtonProps} from 'sentry/components/button';
  6. import Clipboard from 'sentry/components/clipboard';
  7. import DateTime from 'sentry/components/dateTime';
  8. import {DropdownMenu} from 'sentry/components/dropdownMenu';
  9. import {Tooltip} from 'sentry/components/tooltip';
  10. import {
  11. IconChevron,
  12. IconCopy,
  13. IconEllipsis,
  14. IconNext,
  15. IconOpen,
  16. IconPrevious,
  17. IconWarning,
  18. } from 'sentry/icons';
  19. import {t} from 'sentry/locale';
  20. import {space} from 'sentry/styles/space';
  21. import {Event, Group, Organization} from 'sentry/types';
  22. import {defined, formatBytesBase2} from 'sentry/utils';
  23. import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
  24. import {eventDetailsRoute, generateEventSlug} from 'sentry/utils/discover/urls';
  25. import {
  26. getAnalyticsDataForEvent,
  27. getAnalyticsDataForGroup,
  28. getShortEventId,
  29. } from 'sentry/utils/events';
  30. import getDynamicText from 'sentry/utils/getDynamicText';
  31. import {useLocation} from 'sentry/utils/useLocation';
  32. import useMedia from 'sentry/utils/useMedia';
  33. import useOrganization from 'sentry/utils/useOrganization';
  34. import {normalizeUrl} from 'sentry/utils/withDomainRequired';
  35. import EventCreatedTooltip from 'sentry/views/issueDetails/eventCreatedTooltip';
  36. import QuickTrace from './quickTrace';
  37. type GroupEventCarouselProps = {
  38. event: Event;
  39. group: Group;
  40. projectSlug: string;
  41. };
  42. type EventNavigationButtonProps = {
  43. disabled: boolean;
  44. group: Group;
  45. icon: ButtonProps['icon'];
  46. referrer: string;
  47. title: string;
  48. eventId?: string | null;
  49. };
  50. const BUTTON_SIZE = 'sm';
  51. const BUTTON_ICON_SIZE = 'sm';
  52. const copyToClipboard = (value: string) => {
  53. navigator.clipboard
  54. .writeText(value)
  55. .then(() => {
  56. addSuccessMessage(t('Copied to clipboard'));
  57. })
  58. .catch(() => {
  59. t('Error copying to clipboard');
  60. });
  61. };
  62. const makeBaseEventsPath = ({
  63. organization,
  64. group,
  65. }: {
  66. group: Group;
  67. organization: Organization;
  68. }) => `/organizations/${organization.slug}/issues/${group.id}/events/`;
  69. const EventNavigationButton = ({
  70. disabled,
  71. eventId,
  72. group,
  73. icon,
  74. title,
  75. referrer,
  76. }: EventNavigationButtonProps) => {
  77. const organization = useOrganization();
  78. const location = useLocation();
  79. const baseEventsPath = makeBaseEventsPath({organization, group});
  80. // Need to wrap with Tooltip because our version of React Router doesn't allow access
  81. // to the anchor ref which is needed by Tooltip to position correctly.
  82. return (
  83. <Tooltip title={title} disabled={disabled} skipWrapper>
  84. <div>
  85. <StyledNavButton
  86. size={BUTTON_SIZE}
  87. icon={icon}
  88. aria-label={title}
  89. to={{
  90. pathname: `${baseEventsPath}${eventId}/`,
  91. query: {...location.query, referrer},
  92. }}
  93. disabled={disabled}
  94. />
  95. </div>
  96. </Tooltip>
  97. );
  98. };
  99. export const GroupEventCarousel = ({
  100. event,
  101. group,
  102. projectSlug,
  103. }: GroupEventCarouselProps) => {
  104. const theme = useTheme();
  105. const organization = useOrganization();
  106. const location = useLocation();
  107. const largeViewport = useMedia(`(min-width: ${theme.breakpoints.large})`);
  108. const xlargeViewport = useMedia(`(min-width: ${theme.breakpoints.xlarge})`);
  109. const hasReplay = Boolean(event?.tags?.find(({key}) => key === 'replayId')?.value);
  110. const isReplayEnabled = organization.features.includes('session-replay');
  111. const latencyThreshold = 30 * 60 * 1000; // 30 minutes
  112. const isOverLatencyThreshold =
  113. event.dateReceived &&
  114. event.dateCreated &&
  115. Math.abs(+moment(event.dateReceived) - +moment(event.dateCreated)) > latencyThreshold;
  116. const hasPreviousEvent = defined(event.previousEventID);
  117. const hasNextEvent = defined(event.nextEventID);
  118. const downloadJson = () => {
  119. const jsonUrl = `/api/0/projects/${organization.slug}/${projectSlug}/events/${event.id}/json/`;
  120. window.open(jsonUrl);
  121. trackAdvancedAnalyticsEvent('issue_details.event_json_clicked', {
  122. organization,
  123. group_id: parseInt(`${event.groupID}`, 10),
  124. });
  125. };
  126. const copyLink = () => {
  127. copyToClipboard(
  128. window.location.origin +
  129. normalizeUrl(`${makeBaseEventsPath({organization, group})}${event.id}/`)
  130. );
  131. trackAdvancedAnalyticsEvent('issue_details.copy_event_link_clicked', {
  132. organization,
  133. ...getAnalyticsDataForGroup(group),
  134. ...getAnalyticsDataForEvent(event),
  135. });
  136. };
  137. return (
  138. <CarouselAndButtonsWrapper>
  139. <EventHeading>
  140. <EventIdLabel>Event ID:</EventIdLabel>{' '}
  141. <Tooltip overlayStyle={{maxWidth: 'max-content'}} title={event.id}>
  142. <Clipboard value={event.id}>
  143. <EventId>
  144. {getShortEventId(event.id)}
  145. <CopyIconContainer>
  146. <IconCopy size="xs" />
  147. </CopyIconContainer>
  148. </EventId>
  149. </Clipboard>
  150. </Tooltip>{' '}
  151. {(event.dateCreated ?? event.dateReceived) && (
  152. <EventTimeLabel>
  153. {getDynamicText({
  154. fixed: 'Jan 1, 12:00 AM',
  155. value: (
  156. <Tooltip showUnderline title={<EventCreatedTooltip event={event} />}>
  157. <DateTime date={event.dateCreated ?? event.dateReceived} />
  158. </Tooltip>
  159. ),
  160. })}
  161. {isOverLatencyThreshold && (
  162. <Tooltip title="High latency">
  163. <StyledIconWarning size="xs" color="warningText" />
  164. </Tooltip>
  165. )}
  166. </EventTimeLabel>
  167. )}
  168. <QuickTrace
  169. event={event}
  170. group={group}
  171. organization={organization}
  172. location={location}
  173. />
  174. </EventHeading>
  175. <ActionsWrapper>
  176. <DropdownMenu
  177. position="bottom-end"
  178. triggerProps={{
  179. 'aria-label': t('Event Actions Menu'),
  180. icon: <IconEllipsis size="xs" />,
  181. showChevron: false,
  182. size: BUTTON_SIZE,
  183. }}
  184. items={[
  185. {
  186. key: 'copy-event-id',
  187. label: t('Copy Event ID'),
  188. onAction: () => copyToClipboard(event.id),
  189. },
  190. {
  191. key: 'copy-event-url',
  192. label: t('Copy Event Link'),
  193. hidden: xlargeViewport,
  194. onAction: copyLink,
  195. },
  196. {
  197. key: 'json',
  198. label: `JSON (${formatBytesBase2(event.size)})`,
  199. onAction: downloadJson,
  200. hidden: largeViewport,
  201. },
  202. {
  203. key: 'full-event-discover',
  204. label: t('Full Event Details'),
  205. hidden: !organization.features.includes('discover-basic'),
  206. to: eventDetailsRoute({
  207. eventSlug: generateEventSlug({project: projectSlug, id: event.id}),
  208. orgSlug: organization.slug,
  209. }),
  210. onAction: () => {
  211. trackAdvancedAnalyticsEvent('issue_details.event_details_clicked', {
  212. organization,
  213. ...getAnalyticsDataForGroup(group),
  214. ...getAnalyticsDataForEvent(event),
  215. });
  216. },
  217. },
  218. {
  219. key: 'replay',
  220. label: t('View Replay'),
  221. hidden: !hasReplay || !isReplayEnabled,
  222. onAction: () => {
  223. const breadcrumbsHeader = document.getElementById('breadcrumbs');
  224. if (breadcrumbsHeader) {
  225. breadcrumbsHeader.scrollIntoView({behavior: 'smooth'});
  226. }
  227. trackAdvancedAnalyticsEvent('issue_details.header_view_replay_clicked', {
  228. organization,
  229. ...getAnalyticsDataForGroup(group),
  230. ...getAnalyticsDataForEvent(event),
  231. });
  232. },
  233. },
  234. ]}
  235. />
  236. {xlargeViewport && (
  237. <Button size={BUTTON_SIZE} onClick={copyLink}>
  238. Copy Link
  239. </Button>
  240. )}
  241. {largeViewport && (
  242. <Button
  243. size={BUTTON_SIZE}
  244. icon={<IconOpen size={BUTTON_ICON_SIZE} />}
  245. onClick={downloadJson}
  246. >
  247. JSON
  248. </Button>
  249. )}
  250. <NavButtons>
  251. <EventNavigationButton
  252. group={group}
  253. icon={<IconPrevious size={BUTTON_ICON_SIZE} />}
  254. disabled={!hasPreviousEvent}
  255. title={t('First Event')}
  256. eventId="oldest"
  257. referrer="oldest-event"
  258. />
  259. <EventNavigationButton
  260. group={group}
  261. icon={<IconChevron direction="left" size={BUTTON_ICON_SIZE} />}
  262. disabled={!hasPreviousEvent}
  263. title={t('Previous Event')}
  264. eventId={event.previousEventID}
  265. referrer="previous-event"
  266. />
  267. <EventNavigationButton
  268. group={group}
  269. icon={<IconChevron direction="right" size={BUTTON_ICON_SIZE} />}
  270. disabled={!hasNextEvent}
  271. title={t('Next Event')}
  272. eventId={event.nextEventID}
  273. referrer="next-event"
  274. />
  275. <EventNavigationButton
  276. group={group}
  277. icon={<IconNext size={BUTTON_ICON_SIZE} />}
  278. disabled={!hasNextEvent}
  279. title={t('Latest Event')}
  280. eventId="latest"
  281. referrer="latest-event"
  282. />
  283. </NavButtons>
  284. </ActionsWrapper>
  285. </CarouselAndButtonsWrapper>
  286. );
  287. };
  288. const CarouselAndButtonsWrapper = styled('div')`
  289. display: flex;
  290. justify-content: space-between;
  291. align-items: flex-start;
  292. gap: ${space(1)};
  293. margin-bottom: ${space(0.5)};
  294. `;
  295. const EventHeading = styled('div')`
  296. font-size: ${p => p.theme.fontSizeLarge};
  297. @media (max-width: 600px) {
  298. font-size: ${p => p.theme.fontSizeMedium};
  299. }
  300. `;
  301. const ActionsWrapper = styled('div')`
  302. display: flex;
  303. align-items: center;
  304. gap: ${space(0.5)};
  305. `;
  306. const StyledNavButton = styled(Button)`
  307. border-radius: 0;
  308. `;
  309. const NavButtons = styled('div')`
  310. display: flex;
  311. > * {
  312. &:not(:last-child) {
  313. ${StyledNavButton} {
  314. border-right: none;
  315. }
  316. }
  317. &:first-child {
  318. ${StyledNavButton} {
  319. border-radius: ${p => p.theme.borderRadius} 0 0 ${p => p.theme.borderRadius};
  320. }
  321. }
  322. &:last-child {
  323. ${StyledNavButton} {
  324. border-radius: 0 ${p => p.theme.borderRadius} ${p => p.theme.borderRadius} 0;
  325. }
  326. }
  327. }
  328. `;
  329. const EventIdLabel = styled('span')`
  330. font-weight: bold;
  331. `;
  332. const EventTimeLabel = styled('span')`
  333. color: ${p => p.theme.subText};
  334. `;
  335. const StyledIconWarning = styled(IconWarning)`
  336. margin-left: ${space(0.25)};
  337. position: relative;
  338. top: 1px;
  339. `;
  340. const EventId = styled('span')`
  341. position: relative;
  342. cursor: pointer;
  343. &:hover {
  344. > span {
  345. display: flex;
  346. }
  347. }
  348. `;
  349. const CopyIconContainer = styled('span')`
  350. display: none;
  351. align-items: center;
  352. padding: ${space(0.25)};
  353. background: ${p => p.theme.background};
  354. position: absolute;
  355. right: 0;
  356. top: 50%;
  357. transform: translateY(-50%);
  358. `;