groupEventCarousel.tsx 11 KB

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