groupEventCarousel.tsx 11 KB

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