groupEventCarousel.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389
  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 {trackAnalytics} from 'sentry/utils/analytics';
  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. function 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 function GroupEventCarousel({event, group, projectSlug}: GroupEventCarouselProps) {
  100. const theme = useTheme();
  101. const organization = useOrganization();
  102. const location = useLocation();
  103. const largeViewport = useMedia(`(min-width: ${theme.breakpoints.large})`);
  104. const xlargeViewport = useMedia(`(min-width: ${theme.breakpoints.xlarge})`);
  105. const hasReplay = Boolean(event?.tags?.find(({key}) => key === 'replayId')?.value);
  106. const isReplayEnabled = organization.features.includes('session-replay');
  107. const latencyThreshold = 30 * 60 * 1000; // 30 minutes
  108. const isOverLatencyThreshold =
  109. event.dateReceived &&
  110. event.dateCreated &&
  111. Math.abs(+moment(event.dateReceived) - +moment(event.dateCreated)) > latencyThreshold;
  112. const hasPreviousEvent = defined(event.previousEventID);
  113. const hasNextEvent = defined(event.nextEventID);
  114. const downloadJson = () => {
  115. const jsonUrl = `/api/0/projects/${organization.slug}/${projectSlug}/events/${event.id}/json/`;
  116. window.open(jsonUrl);
  117. trackAnalytics('issue_details.event_json_clicked', {
  118. organization,
  119. group_id: parseInt(`${event.groupID}`, 10),
  120. });
  121. };
  122. const copyLink = () => {
  123. copyToClipboard(
  124. window.location.origin +
  125. normalizeUrl(`${makeBaseEventsPath({organization, group})}${event.id}/`)
  126. );
  127. trackAnalytics('issue_details.copy_event_link_clicked', {
  128. organization,
  129. ...getAnalyticsDataForGroup(group),
  130. ...getAnalyticsDataForEvent(event),
  131. });
  132. };
  133. return (
  134. <CarouselAndButtonsWrapper>
  135. <EventHeading>
  136. <EventIdLabel>Event ID:</EventIdLabel>{' '}
  137. <Tooltip overlayStyle={{maxWidth: 'max-content'}} title={event.id}>
  138. <Clipboard value={event.id}>
  139. <EventId>
  140. {getShortEventId(event.id)}
  141. <CopyIconContainer>
  142. <IconCopy size="xs" />
  143. </CopyIconContainer>
  144. </EventId>
  145. </Clipboard>
  146. </Tooltip>{' '}
  147. {(event.dateCreated ?? event.dateReceived) && (
  148. <EventTimeLabel>
  149. {getDynamicText({
  150. fixed: 'Jan 1, 12:00 AM',
  151. value: (
  152. <Tooltip
  153. isHoverable
  154. showUnderline
  155. title={<EventCreatedTooltip event={event} />}
  156. overlayStyle={{maxWidth: 300}}
  157. >
  158. <DateTime date={event.dateCreated ?? event.dateReceived} />
  159. </Tooltip>
  160. ),
  161. })}
  162. {isOverLatencyThreshold && (
  163. <Tooltip title="High latency">
  164. <StyledIconWarning size="xs" color="warningText" />
  165. </Tooltip>
  166. )}
  167. </EventTimeLabel>
  168. )}
  169. <QuickTrace
  170. event={event}
  171. group={group}
  172. organization={organization}
  173. location={location}
  174. />
  175. </EventHeading>
  176. <ActionsWrapper>
  177. <DropdownMenu
  178. position="bottom-end"
  179. triggerProps={{
  180. 'aria-label': t('Event Actions Menu'),
  181. icon: <IconEllipsis size="xs" />,
  182. showChevron: false,
  183. size: BUTTON_SIZE,
  184. }}
  185. items={[
  186. {
  187. key: 'copy-event-id',
  188. label: t('Copy Event ID'),
  189. onAction: () => copyToClipboard(event.id),
  190. },
  191. {
  192. key: 'copy-event-url',
  193. label: t('Copy Event Link'),
  194. hidden: xlargeViewport,
  195. onAction: copyLink,
  196. },
  197. {
  198. key: 'json',
  199. label: `JSON (${formatBytesBase2(event.size)})`,
  200. onAction: downloadJson,
  201. hidden: largeViewport,
  202. },
  203. {
  204. key: 'full-event-discover',
  205. label: t('Full Event Details'),
  206. hidden: !organization.features.includes('discover-basic'),
  207. to: eventDetailsRoute({
  208. eventSlug: generateEventSlug({project: projectSlug, id: event.id}),
  209. orgSlug: organization.slug,
  210. }),
  211. onAction: () => {
  212. trackAnalytics('issue_details.event_details_clicked', {
  213. organization,
  214. ...getAnalyticsDataForGroup(group),
  215. ...getAnalyticsDataForEvent(event),
  216. });
  217. },
  218. },
  219. {
  220. key: 'replay',
  221. label: t('View Replay'),
  222. hidden: !hasReplay || !isReplayEnabled,
  223. onAction: () => {
  224. const breadcrumbsHeader = document.getElementById('breadcrumbs');
  225. if (breadcrumbsHeader) {
  226. breadcrumbsHeader.scrollIntoView({behavior: 'smooth'});
  227. }
  228. trackAnalytics('issue_details.header_view_replay_clicked', {
  229. organization,
  230. ...getAnalyticsDataForGroup(group),
  231. ...getAnalyticsDataForEvent(event),
  232. });
  233. },
  234. },
  235. ]}
  236. />
  237. {xlargeViewport && (
  238. <Button size={BUTTON_SIZE} onClick={copyLink}>
  239. Copy Link
  240. </Button>
  241. )}
  242. {largeViewport && (
  243. <Button
  244. size={BUTTON_SIZE}
  245. icon={<IconOpen size={BUTTON_ICON_SIZE} />}
  246. onClick={downloadJson}
  247. >
  248. JSON
  249. </Button>
  250. )}
  251. <NavButtons>
  252. <EventNavigationButton
  253. group={group}
  254. icon={<IconPrevious size={BUTTON_ICON_SIZE} />}
  255. disabled={!hasPreviousEvent}
  256. title={t('First Event')}
  257. eventId="oldest"
  258. referrer="oldest-event"
  259. />
  260. <EventNavigationButton
  261. group={group}
  262. icon={<IconChevron direction="left" size={BUTTON_ICON_SIZE} />}
  263. disabled={!hasPreviousEvent}
  264. title={t('Previous Event')}
  265. eventId={event.previousEventID}
  266. referrer="previous-event"
  267. />
  268. <EventNavigationButton
  269. group={group}
  270. icon={<IconChevron direction="right" size={BUTTON_ICON_SIZE} />}
  271. disabled={!hasNextEvent}
  272. title={t('Next Event')}
  273. eventId={event.nextEventID}
  274. referrer="next-event"
  275. />
  276. <EventNavigationButton
  277. group={group}
  278. icon={<IconNext size={BUTTON_ICON_SIZE} />}
  279. disabled={!hasNextEvent}
  280. title={t('Latest Event')}
  281. eventId="latest"
  282. referrer="latest-event"
  283. />
  284. </NavButtons>
  285. </ActionsWrapper>
  286. </CarouselAndButtonsWrapper>
  287. );
  288. }
  289. const CarouselAndButtonsWrapper = styled('div')`
  290. display: flex;
  291. justify-content: space-between;
  292. align-items: flex-start;
  293. gap: ${space(1)};
  294. margin-bottom: ${space(0.5)};
  295. `;
  296. const EventHeading = styled('div')`
  297. font-size: ${p => p.theme.fontSizeLarge};
  298. @media (max-width: 600px) {
  299. font-size: ${p => p.theme.fontSizeMedium};
  300. }
  301. `;
  302. const ActionsWrapper = styled('div')`
  303. display: flex;
  304. align-items: center;
  305. gap: ${space(0.5)};
  306. `;
  307. const StyledNavButton = styled(Button)`
  308. border-radius: 0;
  309. `;
  310. const NavButtons = styled('div')`
  311. display: flex;
  312. > * {
  313. &:not(:last-child) {
  314. ${StyledNavButton} {
  315. border-right: none;
  316. }
  317. }
  318. &:first-child {
  319. ${StyledNavButton} {
  320. border-radius: ${p => p.theme.borderRadius} 0 0 ${p => p.theme.borderRadius};
  321. }
  322. }
  323. &:last-child {
  324. ${StyledNavButton} {
  325. border-radius: 0 ${p => p.theme.borderRadius} ${p => p.theme.borderRadius} 0;
  326. }
  327. }
  328. }
  329. `;
  330. const EventIdLabel = styled('span')`
  331. font-weight: bold;
  332. `;
  333. const EventTimeLabel = styled('span')`
  334. color: ${p => p.theme.subText};
  335. `;
  336. const StyledIconWarning = styled(IconWarning)`
  337. margin-left: ${space(0.25)};
  338. position: relative;
  339. top: 1px;
  340. `;
  341. const EventId = styled('span')`
  342. position: relative;
  343. cursor: pointer;
  344. &:hover {
  345. > span {
  346. display: flex;
  347. }
  348. }
  349. `;
  350. const CopyIconContainer = styled('span')`
  351. display: none;
  352. align-items: center;
  353. padding: ${space(0.25)};
  354. background: ${p => p.theme.background};
  355. position: absolute;
  356. right: 0;
  357. top: 50%;
  358. transform: translateY(-50%);
  359. `;