groupEventCarousel.tsx 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  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} from 'sentry/components/button';
  6. import ButtonBar from 'sentry/components/buttonBar';
  7. import Clipboard from 'sentry/components/clipboard';
  8. import {DropdownMenu} from 'sentry/components/dropdownMenu';
  9. import TimeSince from 'sentry/components/timeSince';
  10. import {Tooltip} from 'sentry/components/tooltip';
  11. import {
  12. IconChevron,
  13. IconCopy,
  14. IconEllipsis,
  15. IconNext,
  16. IconOpen,
  17. IconPrevious,
  18. IconWarning,
  19. } from 'sentry/icons';
  20. import {t} from 'sentry/locale';
  21. import {space} from 'sentry/styles/space';
  22. import {Event, Group} from 'sentry/types';
  23. import {defined, formatBytesBase2} from 'sentry/utils';
  24. import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
  25. import {eventDetailsRoute, generateEventSlug} from 'sentry/utils/discover/urls';
  26. import {
  27. getAnalyticsDataForEvent,
  28. getAnalyticsDataForGroup,
  29. getShortEventId,
  30. } from 'sentry/utils/events';
  31. import getDynamicText from 'sentry/utils/getDynamicText';
  32. import {useLocation} from 'sentry/utils/useLocation';
  33. import useMedia from 'sentry/utils/useMedia';
  34. import useOrganization from 'sentry/utils/useOrganization';
  35. import {normalizeUrl} from 'sentry/utils/withDomainRequired';
  36. import EventCreatedTooltip from 'sentry/views/issueDetails/eventCreatedTooltip';
  37. type GroupEventCarouselProps = {
  38. event: Event;
  39. group: Group;
  40. projectSlug: string;
  41. };
  42. const BUTTON_SIZE = 'md';
  43. const BUTTON_ICON_SIZE = 'sm';
  44. const copyToClipboard = (value: string) => {
  45. navigator.clipboard
  46. .writeText(value)
  47. .then(() => {
  48. addSuccessMessage(t('Copied to clipboard'));
  49. })
  50. .catch(() => {
  51. t('Error copying to clipboard');
  52. });
  53. };
  54. export const GroupEventCarousel = ({
  55. event,
  56. group,
  57. projectSlug,
  58. }: GroupEventCarouselProps) => {
  59. const theme = useTheme();
  60. const organization = useOrganization();
  61. const location = useLocation();
  62. const xlargeViewport = useMedia(`(min-width: ${theme.breakpoints.xlarge})`);
  63. const groupId = group.id;
  64. const hasReplay = Boolean(event?.tags?.find(({key}) => key === 'replayId')?.value);
  65. const isReplayEnabled = organization.features.includes('session-replay');
  66. const baseEventsPath = `/organizations/${organization.slug}/issues/${groupId}/events/`;
  67. const latencyThreshold = 30 * 60 * 1000; // 30 minutes
  68. const isOverLatencyThreshold =
  69. event.dateReceived &&
  70. event.dateCreated &&
  71. Math.abs(+moment(event.dateReceived) - +moment(event.dateCreated)) > latencyThreshold;
  72. const hasPreviousEvent = defined(event.previousEventID);
  73. const hasNextEvent = defined(event.nextEventID);
  74. const downloadJson = () => {
  75. const jsonUrl = `/api/0/projects/${organization.slug}/${projectSlug}/events/${event.id}/json/`;
  76. window.open(jsonUrl);
  77. trackAdvancedAnalyticsEvent('issue_details.event_json_clicked', {
  78. organization,
  79. group_id: parseInt(`${event.groupID}`, 10),
  80. });
  81. };
  82. const copyLink = () => {
  83. copyToClipboard(
  84. window.location.origin + normalizeUrl(`${baseEventsPath}${event.id}/`)
  85. );
  86. trackAdvancedAnalyticsEvent('issue_details.copy_event_link_clicked', {
  87. organization,
  88. ...getAnalyticsDataForGroup(group),
  89. ...getAnalyticsDataForEvent(event),
  90. });
  91. };
  92. return (
  93. <CarouselAndButtonsWrapper>
  94. <StyledButtonBar merged>
  95. <EventNavigationButton
  96. size={BUTTON_SIZE}
  97. icon={<IconPrevious size={BUTTON_ICON_SIZE} />}
  98. aria-label="Oldest"
  99. to={{
  100. pathname: `${baseEventsPath}oldest/`,
  101. query: {...location.query, referrer: 'oldest-event'},
  102. }}
  103. disabled={!hasPreviousEvent}
  104. />
  105. <EventNavigationButton
  106. size={BUTTON_SIZE}
  107. icon={<IconChevron direction="left" size={BUTTON_ICON_SIZE} />}
  108. aria-label="Older"
  109. to={{
  110. pathname: `${baseEventsPath}${event.previousEventID}/`,
  111. query: {...location.query, referrer: 'previous-event'},
  112. }}
  113. disabled={!hasPreviousEvent}
  114. />
  115. <EventLabelContainer>
  116. <div>
  117. <EventIdLabel>Event ID:</EventIdLabel>{' '}
  118. <Tooltip overlayStyle={{maxWidth: 'max-content'}} title={event.id}>
  119. <Clipboard value={event.id}>
  120. <EventId>
  121. {getShortEventId(event.id)}
  122. <CopyIconContainer>
  123. <IconCopy size="xs" />
  124. </CopyIconContainer>
  125. </EventId>
  126. </Clipboard>
  127. </Tooltip>{' '}
  128. {(event.dateCreated ?? event.dateReceived) && (
  129. <EventTimeLabel>
  130. {getDynamicText({
  131. fixed: '1d ago',
  132. value: (
  133. <TimeSince
  134. date={event.dateCreated ?? event.dateReceived}
  135. tooltipBody={<EventCreatedTooltip event={event} />}
  136. unitStyle="short"
  137. />
  138. ),
  139. })}
  140. {isOverLatencyThreshold && (
  141. <Tooltip title="High latency">
  142. <StyledIconWarning size="xs" color="warningText" />
  143. </Tooltip>
  144. )}
  145. </EventTimeLabel>
  146. )}
  147. </div>
  148. </EventLabelContainer>
  149. <EventNavigationButton
  150. size={BUTTON_SIZE}
  151. icon={<IconChevron direction="right" size={BUTTON_ICON_SIZE} />}
  152. aria-label="Newer"
  153. to={{
  154. pathname: `${baseEventsPath}${event.nextEventID}/`,
  155. query: {...location.query, referrer: 'next-event'},
  156. }}
  157. disabled={!hasNextEvent}
  158. />
  159. <EventNavigationButton
  160. size={BUTTON_SIZE}
  161. icon={<IconNext size={BUTTON_ICON_SIZE} />}
  162. aria-label="Newest"
  163. to={{
  164. pathname: `${baseEventsPath}latest/`,
  165. query: {...location.query, referrer: 'latest-event'},
  166. }}
  167. disabled={!hasNextEvent}
  168. />
  169. </StyledButtonBar>
  170. {xlargeViewport && (
  171. <Button
  172. size={BUTTON_SIZE}
  173. icon={<IconOpen size={BUTTON_ICON_SIZE} />}
  174. onClick={downloadJson}
  175. >
  176. JSON
  177. </Button>
  178. )}
  179. {xlargeViewport && (
  180. <Button size={BUTTON_SIZE} onClick={copyLink}>
  181. Copy Link
  182. </Button>
  183. )}
  184. <DropdownMenu
  185. position="bottom-end"
  186. triggerProps={{
  187. 'aria-label': t('Event Actions Menu'),
  188. icon: <IconEllipsis size={BUTTON_ICON_SIZE} />,
  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: xlargeViewport,
  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. trackAdvancedAnalyticsEvent('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. trackAdvancedAnalyticsEvent('issue_details.header_view_replay_clicked', {
  236. organization,
  237. ...getAnalyticsDataForGroup(group),
  238. ...getAnalyticsDataForEvent(event),
  239. });
  240. },
  241. },
  242. ]}
  243. />
  244. </CarouselAndButtonsWrapper>
  245. );
  246. };
  247. const CarouselAndButtonsWrapper = styled('div')`
  248. display: flex;
  249. gap: ${space(1)};
  250. margin-bottom: ${space(0.5)};
  251. max-width: 900px;
  252. `;
  253. const StyledButtonBar = styled(ButtonBar)`
  254. grid-template-columns: auto auto 1fr auto auto;
  255. flex: 1;
  256. `;
  257. const EventNavigationButton = styled(Button)`
  258. width: 42px;
  259. `;
  260. const EventLabelContainer = styled('div')`
  261. background: ${p => p.theme.background};
  262. display: flex;
  263. border-top: 1px solid ${p => p.theme.button.default.border};
  264. border-bottom: 1px solid ${p => p.theme.button.default.border};
  265. height: ${p => p.theme.form[BUTTON_SIZE].height}px;
  266. justify-content: center;
  267. align-items: center;
  268. padding: 0 ${space(1)};
  269. font-size: ${p => p.theme.fontSizeMedium};
  270. `;
  271. const EventIdLabel = styled('span')`
  272. font-weight: bold;
  273. @media (max-width: 600px) {
  274. display: none;
  275. }
  276. `;
  277. const EventTimeLabel = styled('span')`
  278. color: ${p => p.theme.subText};
  279. @media (max-width: 500px) {
  280. display: none;
  281. }
  282. `;
  283. const StyledIconWarning = styled(IconWarning)`
  284. margin-left: ${space(0.25)};
  285. position: relative;
  286. top: 1px;
  287. `;
  288. const EventId = styled('span')`
  289. position: relative;
  290. cursor: pointer;
  291. &:hover {
  292. > span {
  293. display: flex;
  294. }
  295. }
  296. `;
  297. const CopyIconContainer = styled('span')`
  298. display: none;
  299. align-items: center;
  300. padding: ${space(0.25)};
  301. background: ${p => p.theme.background};
  302. position: absolute;
  303. right: 0;
  304. top: 50%;
  305. transform: translateY(-50%);
  306. `;