mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
568 lines
16 KiB
TypeScript
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%);
|
|
`;
|