Files
2024-02-15 17:01:50 +00:00

568 lines
16 KiB
TypeScript

import {Fragment} from 'react';
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 type {ButtonProps} from 'sentry/components/button';
import {Button} from 'sentry/components/button';
import {CompactSelect} from 'sentry/components/compactSelect';
import DateTime from 'sentry/components/dateTime';
import {DropdownMenu} from 'sentry/components/dropdownMenu';
import TimeSince from 'sentry/components/timeSince';
import {Tooltip} from 'sentry/components/tooltip';
import {
IconChevron,
IconCopy,
IconEllipsis,
IconJson,
IconLink,
IconWarning,
} from 'sentry/icons';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {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 {TraceLink} from 'sentry/views/issueDetails/traceTimeline/traceLink';
import {hasTraceTimelineFeature} from 'sentry/views/issueDetails/traceTimeline/utils';
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 (
<Tooltip title={title} disabled={disabled} skipWrapper>
<div>
<StyledNavButton
size={BUTTON_SIZE}
icon={icon}
aria-label={title}
to={{
pathname: `${baseEventsPath}${eventId}/`,
query: {...location.query, referrer},
}}
disabled={disabled}
/>
</div>
</Tooltip>
);
}
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();
if (!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 (
<GuideAnchor target="issue_details_default_event" position="bottom">
<CompactSelect
size="sm"
disabled={isDisabled}
options={eventNavDropdownOptions}
value={!selectedValue ? EventNavDropdownOption.CUSTOM : selectedValue}
triggerLabel={
!selectedValue ? (
<TimeSince
date={event.dateCreated ?? event.dateReceived}
disabledAbsoluteTooltip
/>
) : 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;
}
}}
/>
</GuideAnchor>
);
}
type GroupEventActionsProps = {
event: Event;
group: Group;
projectSlug: string;
};
export function GroupEventActions({event, group, projectSlug}: GroupEventActionsProps) {
const theme = useTheme();
const xlargeViewport = useMedia(`(min-width: ${theme.breakpoints.xlarge})`);
const organization = useOrganization();
const hasReplay = Boolean(event?.tags?.find(({key}) => key === 'replayId')?.value);
const isReplayEnabled =
organization.features.includes('session-replay') &&
projectCanLinkToReplay(group.project);
const downloadJson = () => {
const host = organization.links.regionUrl;
const jsonUrl = `${host}/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,
});
return (
<Fragment>
<DropdownMenu
position="bottom-end"
triggerProps={{
'aria-label': t('Event Actions Menu'),
icon: <IconEllipsis />,
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('replay');
if (breadcrumbsHeader) {
breadcrumbsHeader.scrollIntoView({behavior: 'smooth'});
}
trackAnalytics('issue_details.header_view_replay_clicked', {
organization,
...getAnalyticsDataForGroup(group),
...getAnalyticsDataForEvent(event),
});
},
},
]}
/>
{xlargeViewport && (
<Button
title={t('Copy link to this issue event')}
size={BUTTON_SIZE}
onClick={copyLink}
aria-label={t('Copy Link')}
icon={<IconLink />}
/>
)}
{xlargeViewport && (
<Button
title={t('View JSON')}
size={BUTTON_SIZE}
onClick={downloadJson}
aria-label={t('View JSON')}
icon={<IconJson />}
/>
)}
</Fragment>
);
}
export function GroupEventCarousel({event, group, projectSlug}: GroupEventCarouselProps) {
const organization = useOrganization();
const location = useLocation();
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 {onClick: copyEventId} = useCopyToClipboard({
successMessage: t('Event ID copied to clipboard'),
text: event.id,
});
const hasTraceTimeline = hasTraceTimelineFeature(organization);
return (
<CarouselAndButtonsWrapper>
<div>
<EventHeading>
<EventIdAndTimeContainer>
<EventIdContainer>
<strong>Event ID:</strong>
<Button
aria-label={t('Copy')}
borderless
onClick={copyEventId}
size="zero"
title={event.id}
tooltipProps={{overlayStyle: {maxWidth: 'max-content'}}}
translucentBorder
>
<EventId>
{getShortEventId(event.id)}
<CopyIconContainer>
<IconCopy size="xs" />
</CopyIconContainer>
</EventId>
</Button>
</EventIdContainer>
{(event.dateCreated ?? event.dateReceived) && (
<EventTimeLabel>
{getDynamicText({
fixed: 'Jan 1, 12:00 AM',
value: (
<Tooltip
isHoverable
showUnderline
title={<EventCreatedTooltip event={event} />}
overlayStyle={{maxWidth: 300}}
>
<DateTime date={event.dateCreated ?? event.dateReceived} />
</Tooltip>
),
})}
{isOverLatencyThreshold && (
<Tooltip title="High latency">
<StyledIconWarning size="xs" color="warningText" />
</Tooltip>
)}
</EventTimeLabel>
)}
</EventIdAndTimeContainer>
</EventHeading>
{hasTraceTimeline ? (
<TraceLink event={event} />
) : (
<QuickTrace event={event} organization={organization} location={location} />
)}
</div>
<ActionsWrapper>
<GroupEventActions event={event} group={group} projectSlug={projectSlug} />
<EventNavigationDropdown
isDisabled={!hasPreviousEvent && !hasNextEvent}
group={group}
event={event}
/>
<NavButtons>
<EventNavigationButton
group={group}
icon={<IconChevron direction="left" size={BUTTON_ICON_SIZE} />}
disabled={!hasPreviousEvent}
title={t('Previous Event')}
eventId={event.previousEventID}
referrer="previous-event"
/>
<EventNavigationButton
group={group}
icon={<IconChevron direction="right" size={BUTTON_ICON_SIZE} />}
disabled={!hasNextEvent}
title={t('Next Event')}
eventId={event.nextEventID}
referrer="next-event"
/>
</NavButtons>
</ActionsWrapper>
</CarouselAndButtonsWrapper>
);
}
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%);
`;