import {browserHistory} from 'react-router'; import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import omit from 'lodash/omit'; import moment from 'moment-timezone'; import GuideAnchor from 'sentry/components/assistant/guideAnchor'; import {Button, ButtonProps} from 'sentry/components/button'; import {CompactSelect} from 'sentry/components/compactSelect'; import DateTime from 'sentry/components/dateTime'; import {DropdownMenu} from 'sentry/components/dropdownMenu'; import FeatureBadge from 'sentry/components/featureBadge'; import {useOmniActions} from 'sentry/components/omniSearch/useOmniActions'; import TimeSince from 'sentry/components/timeSince'; import {Tooltip} from 'sentry/components/tooltip'; import { IconChevron, IconCopy, IconEllipsis, IconJson, IconLink, IconNext, IconOpen, IconPrevious, IconStar, IconWarning, } from 'sentry/icons'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import {Event, Group, Organization} from 'sentry/types'; import {defined, formatBytesBase2} from 'sentry/utils'; import {trackAnalytics} from 'sentry/utils/analytics'; import {eventDetailsRoute, generateEventSlug} from 'sentry/utils/discover/urls'; import { getAnalyticsDataForEvent, getAnalyticsDataForGroup, getShortEventId, } from 'sentry/utils/events'; import getDynamicText from 'sentry/utils/getDynamicText'; import {projectCanLinkToReplay} from 'sentry/utils/replays/projectSupportsReplay'; import useCopyToClipboard from 'sentry/utils/useCopyToClipboard'; import {useLocation} from 'sentry/utils/useLocation'; import useMedia from 'sentry/utils/useMedia'; import useOrganization from 'sentry/utils/useOrganization'; import {useParams} from 'sentry/utils/useParams'; import {normalizeUrl} from 'sentry/utils/withDomainRequired'; import EventCreatedTooltip from 'sentry/views/issueDetails/eventCreatedTooltip'; import {useDefaultIssueEvent} from 'sentry/views/issueDetails/utils'; import QuickTrace from './quickTrace'; type GroupEventCarouselProps = { event: Event; group: Group; projectSlug: string; }; type GroupEventNavigationProps = { event: Event; group: Group; isDisabled: boolean; }; type EventNavigationButtonProps = { disabled: boolean; group: Group; icon: ButtonProps['icon']; referrer: string; title: string; eventId?: string | null; }; enum EventNavDropdownOption { RECOMMENDED = 'recommended', LATEST = 'latest', OLDEST = 'oldest', CUSTOM = 'custom', ALL = 'all', } const BUTTON_SIZE = 'sm'; const BUTTON_ICON_SIZE = 'sm'; const makeBaseEventsPath = ({ organization, group, }: { group: Group; organization: Organization; }) => `/organizations/${organization.slug}/issues/${group.id}/events/`; function EventNavigationButton({ disabled, eventId, group, icon, title, referrer, }: EventNavigationButtonProps) { const organization = useOrganization(); const location = useLocation(); const baseEventsPath = makeBaseEventsPath({organization, group}); // Need to wrap with Tooltip because our version of React Router doesn't allow access // to the anchor ref which is needed by Tooltip to position correctly. return (
); } function EventNavigationDropdown({group, event, isDisabled}: GroupEventNavigationProps) { const location = useLocation(); const params = useParams<{eventId?: string}>(); const theme = useTheme(); const organization = useOrganization(); const largeViewport = useMedia(`(min-width: ${theme.breakpoints.large})`); const defaultIssueEvent = useDefaultIssueEvent(); const isHelpfulEventUiEnabled = organization.features.includes('issue-details-most-helpful-event') && organization.features.includes('issue-details-most-helpful-event-ui'); if (!isHelpfulEventUiEnabled || !largeViewport) { return null; } const getSelectedOption = () => { switch (params.eventId) { case EventNavDropdownOption.RECOMMENDED: case EventNavDropdownOption.LATEST: case EventNavDropdownOption.OLDEST: return params.eventId; case undefined: return defaultIssueEvent; default: return undefined; } }; const selectedValue = getSelectedOption(); const eventNavDropdownOptions = [ { value: EventNavDropdownOption.RECOMMENDED, label: (
{t('Recommended')}
), textValue: t('Recommended'), details: t('Event with the most context'), }, { value: EventNavDropdownOption.LATEST, label: t('Latest'), details: t('Last seen event in this issue'), }, { value: EventNavDropdownOption.OLDEST, label: t('Oldest'), details: t('First seen event in this issue'), }, ...(!selectedValue ? [ { value: EventNavDropdownOption.CUSTOM, label: t('Custom Selection'), }, ] : []), { options: [{value: EventNavDropdownOption.ALL, label: 'View All Events'}], }, ]; return ( ) : selectedValue === EventNavDropdownOption.RECOMMENDED ? ( t('Recommended') ) : undefined } menuWidth={232} onChange={selectedOption => { trackAnalytics('issue_details.event_dropdown_option_selected', { organization, selected_event_type: selectedOption.value, from_event_type: selectedValue ?? EventNavDropdownOption.CUSTOM, event_id: event.id, group_id: group.id, }); switch (selectedOption.value) { case EventNavDropdownOption.RECOMMENDED: case EventNavDropdownOption.LATEST: case EventNavDropdownOption.OLDEST: browserHistory.push({ pathname: normalizeUrl( makeBaseEventsPath({organization, group}) + selectedOption.value + '/' ), query: {...location.query, referrer: `${selectedOption.value}-event`}, }); break; case EventNavDropdownOption.ALL: const searchTermWithoutQuery = omit(location.query, 'query'); browserHistory.push({ pathname: normalizeUrl( `/organizations/${organization.slug}/issues/${group.id}/events/` ), query: searchTermWithoutQuery, }); break; default: break; } }} /> ); } export function GroupEventCarousel({event, group, projectSlug}: GroupEventCarouselProps) { const theme = useTheme(); const organization = useOrganization(); const location = useLocation(); const xlargeViewport = useMedia(`(min-width: ${theme.breakpoints.xlarge})`); const hasReplay = Boolean(event?.tags?.find(({key}) => key === 'replayId')?.value); const isReplayEnabled = organization.features.includes('session-replay') && projectCanLinkToReplay(group.project); const latencyThreshold = 30 * 60 * 1000; // 30 minutes const isOverLatencyThreshold = event.dateReceived && event.dateCreated && Math.abs(+moment(event.dateReceived) - +moment(event.dateCreated)) > latencyThreshold; const hasPreviousEvent = defined(event.previousEventID); const hasNextEvent = defined(event.nextEventID); const downloadJson = () => { const jsonUrl = `/api/0/projects/${organization.slug}/${projectSlug}/events/${event.id}/json/`; window.open(jsonUrl); trackAnalytics('issue_details.event_json_clicked', { organization, group_id: parseInt(`${event.groupID}`, 10), }); }; const {onClick: copyLink} = useCopyToClipboard({ successMessage: t('Event URL copied to clipboard'), text: window.location.origin + normalizeUrl(`${makeBaseEventsPath({organization, group})}${event.id}/`), onCopy: () => trackAnalytics('issue_details.copy_event_link_clicked', { organization, ...getAnalyticsDataForGroup(group), ...getAnalyticsDataForEvent(event), }), }); const {onClick: copyEventId} = useCopyToClipboard({ successMessage: t('Event ID copied to clipboard'), text: event.id, }); const isHelpfulEventUiEnabled = organization.features.includes('issue-details-most-helpful-event') && organization.features.includes('issue-details-most-helpful-event-ui'); useOmniActions([ { key: 'issue-next-event', label: t('Go to Recommended Event'), areaKey: 'issue', actionType: 'navigate', actionIcon: IconStar, to: normalizeUrl( makeBaseEventsPath({organization, group}) + EventNavDropdownOption.RECOMMENDED + '/' ), }, { key: 'issue-oldest-event', label: t('Go to Oldest Event'), areaKey: 'issue', actionType: 'navigate', actionIcon: IconPrevious, actionHotkey: 'shift+[', to: normalizeUrl( makeBaseEventsPath({organization, group}) + EventNavDropdownOption.OLDEST + '/' ), }, { key: 'issue-latest-event', label: t('Go to Latest Event'), areaKey: 'issue', actionType: 'navigate', actionIcon: IconNext, actionHotkey: 'shift+]', to: normalizeUrl( makeBaseEventsPath({organization, group}) + EventNavDropdownOption.LATEST + '/' ), }, ]); return (
Event ID: {(event.dateCreated ?? event.dateReceived) && ( {getDynamicText({ fixed: 'Jan 1, 12:00 AM', value: ( } overlayStyle={{maxWidth: 300}} > ), })} {isOverLatencyThreshold && ( )} )}
, showChevron: false, size: BUTTON_SIZE, }} items={[ { key: 'copy-event-id', label: t('Copy Event ID'), onAction: copyEventId, }, { key: 'copy-event-url', label: t('Copy Event Link'), hidden: xlargeViewport, onAction: copyLink, }, { key: 'json', label: `JSON (${formatBytesBase2(event.size)})`, onAction: downloadJson, hidden: xlargeViewport, }, { key: 'full-event-discover', label: t('Full Event Details'), hidden: !organization.features.includes('discover-basic'), to: eventDetailsRoute({ eventSlug: generateEventSlug({project: projectSlug, id: event.id}), orgSlug: organization.slug, }), onAction: () => { trackAnalytics('issue_details.event_details_clicked', { organization, ...getAnalyticsDataForGroup(group), ...getAnalyticsDataForEvent(event), }); }, }, { key: 'replay', label: t('View Replay'), hidden: !hasReplay || !isReplayEnabled, onAction: () => { const breadcrumbsHeader = document.getElementById('breadcrumbs'); if (breadcrumbsHeader) { breadcrumbsHeader.scrollIntoView({behavior: 'smooth'}); } trackAnalytics('issue_details.header_view_replay_clicked', { organization, ...getAnalyticsDataForGroup(group), ...getAnalyticsDataForEvent(event), }); }, }, ]} /> {xlargeViewport && ( )} {xlargeViewport && ( )} {!isHelpfulEventUiEnabled && ( } disabled={!hasPreviousEvent} title={t('First Event')} eventId="oldest" referrer="oldest-event" /> )} } disabled={!hasPreviousEvent} title={t('Previous Event')} eventId={event.previousEventID} referrer="previous-event" /> } disabled={!hasNextEvent} title={t('Next Event')} eventId={event.nextEventID} referrer="next-event" /> {!isHelpfulEventUiEnabled && ( } disabled={!hasNextEvent} title={t('Latest Event')} eventId="latest" referrer="latest-event" /> )}
); } const CarouselAndButtonsWrapper = styled('div')` display: flex; justify-content: space-between; align-items: flex-start; gap: ${space(1)}; margin-bottom: ${space(0.5)}; `; const EventHeading = styled('div')` display: flex; align-items: center; flex-wrap: wrap; gap: ${space(1)}; font-size: ${p => p.theme.fontSizeLarge}; @media (max-width: 600px) { font-size: ${p => p.theme.fontSizeMedium}; } `; const ActionsWrapper = styled('div')` display: flex; align-items: center; gap: ${space(0.5)}; `; const StyledNavButton = styled(Button)` border-radius: 0; `; const NavButtons = styled('div')` display: flex; > * { &:not(:last-child) { ${StyledNavButton} { border-right: none; } } &:first-child { ${StyledNavButton} { border-radius: ${p => p.theme.borderRadius} 0 0 ${p => p.theme.borderRadius}; } } &:last-child { ${StyledNavButton} { border-radius: 0 ${p => p.theme.borderRadius} ${p => p.theme.borderRadius} 0; } } } `; const EventIdAndTimeContainer = styled('div')` display: flex; align-items: center; column-gap: ${space(0.75)}; row-gap: 0; flex-wrap: wrap; `; const EventIdContainer = styled('div')` display: flex; align-items: center; column-gap: ${space(0.25)}; `; const EventTimeLabel = styled('span')` color: ${p => p.theme.subText}; `; const StyledIconWarning = styled(IconWarning)` margin-left: ${space(0.25)}; position: relative; top: 1px; `; const EventId = styled('span')` position: relative; font-weight: normal; font-size: ${p => p.theme.fontSizeLarge}; &:hover { > span { display: flex; } } @media (max-width: 600px) { font-size: ${p => p.theme.fontSizeMedium}; } `; const CopyIconContainer = styled('span')` display: none; align-items: center; padding: ${space(0.25)}; background: ${p => p.theme.background}; position: absolute; right: 0; top: 50%; transform: translateY(-50%); `;