groupEventCarousel.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
  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. 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. trackAdvancedAnalyticsEvent('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. trackAdvancedAnalyticsEvent('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 showUnderline title={<EventCreatedTooltip event={event} />}>
  153. <DateTime date={event.dateCreated ?? event.dateReceived} />
  154. </Tooltip>
  155. ),
  156. })}
  157. {isOverLatencyThreshold && (
  158. <Tooltip title="High latency">
  159. <StyledIconWarning size="xs" color="warningText" />
  160. </Tooltip>
  161. )}
  162. </EventTimeLabel>
  163. )}
  164. <QuickTrace
  165. event={event}
  166. group={group}
  167. organization={organization}
  168. location={location}
  169. />
  170. </EventHeading>
  171. <ActionsWrapper>
  172. <DropdownMenu
  173. position="bottom-end"
  174. triggerProps={{
  175. 'aria-label': t('Event Actions Menu'),
  176. icon: <IconEllipsis size="xs" />,
  177. showChevron: false,
  178. size: BUTTON_SIZE,
  179. }}
  180. items={[
  181. {
  182. key: 'copy-event-id',
  183. label: t('Copy Event ID'),
  184. onAction: () => copyToClipboard(event.id),
  185. },
  186. {
  187. key: 'copy-event-url',
  188. label: t('Copy Event Link'),
  189. hidden: xlargeViewport,
  190. onAction: copyLink,
  191. },
  192. {
  193. key: 'json',
  194. label: `JSON (${formatBytesBase2(event.size)})`,
  195. onAction: downloadJson,
  196. hidden: largeViewport,
  197. },
  198. {
  199. key: 'full-event-discover',
  200. label: t('Full Event Details'),
  201. hidden: !organization.features.includes('discover-basic'),
  202. to: eventDetailsRoute({
  203. eventSlug: generateEventSlug({project: projectSlug, id: event.id}),
  204. orgSlug: organization.slug,
  205. }),
  206. onAction: () => {
  207. trackAdvancedAnalyticsEvent('issue_details.event_details_clicked', {
  208. organization,
  209. ...getAnalyticsDataForGroup(group),
  210. ...getAnalyticsDataForEvent(event),
  211. });
  212. },
  213. },
  214. {
  215. key: 'replay',
  216. label: t('View Replay'),
  217. hidden: !hasReplay || !isReplayEnabled,
  218. onAction: () => {
  219. const breadcrumbsHeader = document.getElementById('breadcrumbs');
  220. if (breadcrumbsHeader) {
  221. breadcrumbsHeader.scrollIntoView({behavior: 'smooth'});
  222. }
  223. trackAdvancedAnalyticsEvent('issue_details.header_view_replay_clicked', {
  224. organization,
  225. ...getAnalyticsDataForGroup(group),
  226. ...getAnalyticsDataForEvent(event),
  227. });
  228. },
  229. },
  230. ]}
  231. />
  232. {xlargeViewport && (
  233. <Button size={BUTTON_SIZE} onClick={copyLink}>
  234. Copy Link
  235. </Button>
  236. )}
  237. {largeViewport && (
  238. <Button
  239. size={BUTTON_SIZE}
  240. icon={<IconOpen size={BUTTON_ICON_SIZE} />}
  241. onClick={downloadJson}
  242. >
  243. JSON
  244. </Button>
  245. )}
  246. <NavButtons>
  247. <EventNavigationButton
  248. group={group}
  249. icon={<IconPrevious size={BUTTON_ICON_SIZE} />}
  250. disabled={!hasPreviousEvent}
  251. title={t('First Event')}
  252. eventId="oldest"
  253. referrer="oldest-event"
  254. />
  255. <EventNavigationButton
  256. group={group}
  257. icon={<IconChevron direction="left" size={BUTTON_ICON_SIZE} />}
  258. disabled={!hasPreviousEvent}
  259. title={t('Previous Event')}
  260. eventId={event.previousEventID}
  261. referrer="previous-event"
  262. />
  263. <EventNavigationButton
  264. group={group}
  265. icon={<IconChevron direction="right" size={BUTTON_ICON_SIZE} />}
  266. disabled={!hasNextEvent}
  267. title={t('Next Event')}
  268. eventId={event.nextEventID}
  269. referrer="next-event"
  270. />
  271. <EventNavigationButton
  272. group={group}
  273. icon={<IconNext size={BUTTON_ICON_SIZE} />}
  274. disabled={!hasNextEvent}
  275. title={t('Latest Event')}
  276. eventId="latest"
  277. referrer="latest-event"
  278. />
  279. </NavButtons>
  280. </ActionsWrapper>
  281. </CarouselAndButtonsWrapper>
  282. );
  283. }
  284. const CarouselAndButtonsWrapper = styled('div')`
  285. display: flex;
  286. justify-content: space-between;
  287. align-items: flex-start;
  288. gap: ${space(1)};
  289. margin-bottom: ${space(0.5)};
  290. `;
  291. const EventHeading = styled('div')`
  292. font-size: ${p => p.theme.fontSizeLarge};
  293. @media (max-width: 600px) {
  294. font-size: ${p => p.theme.fontSizeMedium};
  295. }
  296. `;
  297. const ActionsWrapper = styled('div')`
  298. display: flex;
  299. align-items: center;
  300. gap: ${space(0.5)};
  301. `;
  302. const StyledNavButton = styled(Button)`
  303. border-radius: 0;
  304. `;
  305. const NavButtons = styled('div')`
  306. display: flex;
  307. > * {
  308. &:not(:last-child) {
  309. ${StyledNavButton} {
  310. border-right: none;
  311. }
  312. }
  313. &:first-child {
  314. ${StyledNavButton} {
  315. border-radius: ${p => p.theme.borderRadius} 0 0 ${p => p.theme.borderRadius};
  316. }
  317. }
  318. &:last-child {
  319. ${StyledNavButton} {
  320. border-radius: 0 ${p => p.theme.borderRadius} ${p => p.theme.borderRadius} 0;
  321. }
  322. }
  323. }
  324. `;
  325. const EventIdLabel = styled('span')`
  326. font-weight: bold;
  327. `;
  328. const EventTimeLabel = styled('span')`
  329. color: ${p => p.theme.subText};
  330. `;
  331. const StyledIconWarning = styled(IconWarning)`
  332. margin-left: ${space(0.25)};
  333. position: relative;
  334. top: 1px;
  335. `;
  336. const EventId = styled('span')`
  337. position: relative;
  338. cursor: pointer;
  339. &:hover {
  340. > span {
  341. display: flex;
  342. }
  343. }
  344. `;
  345. const CopyIconContainer = styled('span')`
  346. display: none;
  347. align-items: center;
  348. padding: ${space(0.25)};
  349. background: ${p => p.theme.background};
  350. position: absolute;
  351. right: 0;
  352. top: 50%;
  353. transform: translateY(-50%);
  354. `;