groupEventCarousel.tsx 11 KB

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