mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
388 lines
12 KiB
TypeScript
388 lines
12 KiB
TypeScript
import {useMemo} from 'react';
|
|
import styled from '@emotion/styled';
|
|
import type {LocationDescriptor} from 'history';
|
|
import omit from 'lodash/omit';
|
|
|
|
import Badge from 'sentry/components/badge';
|
|
import {Breadcrumbs} from 'sentry/components/breadcrumbs';
|
|
import Count from 'sentry/components/count';
|
|
import EventOrGroupTitle from 'sentry/components/eventOrGroupTitle';
|
|
import ErrorLevel from 'sentry/components/events/errorLevel';
|
|
import EventMessage from 'sentry/components/events/eventMessage';
|
|
import {GroupStatusBadge} from 'sentry/components/group/inboxBadges/statusBadge';
|
|
import UnhandledInboxTag from 'sentry/components/group/inboxBadges/unhandledTag';
|
|
import * as Layout from 'sentry/components/layouts/thirds';
|
|
import Link from 'sentry/components/links/link';
|
|
import {EnvironmentPageFilter} from 'sentry/components/organizations/environmentPageFilter';
|
|
import ReplayCountBadge from 'sentry/components/replays/replayCountBadge';
|
|
import {TabList} from 'sentry/components/tabs';
|
|
import {IconChat} from 'sentry/icons';
|
|
import {t} from 'sentry/locale';
|
|
import {space} from 'sentry/styles/space';
|
|
import type {Event, Group, Organization, Project} from 'sentry/types';
|
|
import {IssueCategory} from 'sentry/types';
|
|
import {getMessage} from 'sentry/utils/events';
|
|
import {getConfigForIssueType} from 'sentry/utils/issueTypeConfig';
|
|
import useReplayCountForIssues from 'sentry/utils/replayCount/useReplayCountForIssues';
|
|
import {projectCanLinkToReplay} from 'sentry/utils/replays/projectSupportsReplay';
|
|
import useRouteAnalyticsParams from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams';
|
|
import {useLocation} from 'sentry/utils/useLocation';
|
|
import useOrganization from 'sentry/utils/useOrganization';
|
|
import GroupPriority from 'sentry/views/issueDetails/groupPriority';
|
|
|
|
import GroupActions from './actions';
|
|
import {ShortIdBreadrcumb} from './shortIdBreadcrumb';
|
|
import {Tab} from './types';
|
|
import {TagAndMessageWrapper} from './unhandledTag';
|
|
import {ReprocessingStatus} from './utils';
|
|
|
|
type Props = {
|
|
baseUrl: string;
|
|
group: Group;
|
|
groupReprocessingStatus: ReprocessingStatus;
|
|
organization: Organization;
|
|
project: Project;
|
|
event?: Event;
|
|
};
|
|
|
|
interface GroupHeaderTabsProps extends Pick<Props, 'baseUrl' | 'group' | 'project'> {
|
|
disabledTabs: Tab[];
|
|
eventRoute: LocationDescriptor;
|
|
}
|
|
|
|
function GroupHeaderTabs({
|
|
baseUrl,
|
|
disabledTabs,
|
|
eventRoute,
|
|
group,
|
|
project,
|
|
}: GroupHeaderTabsProps) {
|
|
const organization = useOrganization();
|
|
|
|
const {getReplayCountForIssue} = useReplayCountForIssues();
|
|
const replaysCount = getReplayCountForIssue(group.id, group.issueCategory);
|
|
|
|
const projectFeatures = new Set(project ? project.features : []);
|
|
const organizationFeatures = new Set(organization ? organization.features : []);
|
|
|
|
const hasSimilarView = projectFeatures.has('similarity-view');
|
|
const hasEventAttachments = organizationFeatures.has('event-attachments');
|
|
const hasReplaySupport =
|
|
organizationFeatures.has('session-replay') && projectCanLinkToReplay(project);
|
|
|
|
const issueTypeConfig = getConfigForIssueType(group, project);
|
|
|
|
useRouteAnalyticsParams({
|
|
group_has_replay: (replaysCount ?? 0) > 0,
|
|
});
|
|
|
|
return (
|
|
<StyledTabList hideBorder>
|
|
<TabList.Item
|
|
key={Tab.DETAILS}
|
|
disabled={disabledTabs.includes(Tab.DETAILS)}
|
|
to={`${baseUrl}${location.search}`}
|
|
>
|
|
{t('Details')}
|
|
</TabList.Item>
|
|
<TabList.Item
|
|
key={Tab.ACTIVITY}
|
|
textValue={t('Activity')}
|
|
disabled={disabledTabs.includes(Tab.ACTIVITY)}
|
|
to={`${baseUrl}activity/${location.search}`}
|
|
>
|
|
{t('Activity')}
|
|
<IconBadge>
|
|
{group.numComments}
|
|
<IconChat size="xs" />
|
|
</IconBadge>
|
|
</TabList.Item>
|
|
<TabList.Item
|
|
key={Tab.USER_FEEDBACK}
|
|
textValue={t('User Feedback')}
|
|
hidden={!issueTypeConfig.userFeedback.enabled}
|
|
disabled={disabledTabs.includes(Tab.USER_FEEDBACK)}
|
|
to={`${baseUrl}feedback/${location.search}`}
|
|
>
|
|
{t('User Feedback')} <Badge text={group.userReportCount} />
|
|
</TabList.Item>
|
|
<TabList.Item
|
|
key={Tab.ATTACHMENTS}
|
|
hidden={!hasEventAttachments || !issueTypeConfig.attachments.enabled}
|
|
disabled={disabledTabs.includes(Tab.ATTACHMENTS)}
|
|
to={`${baseUrl}attachments/${location.search}`}
|
|
>
|
|
{t('Attachments')}
|
|
</TabList.Item>
|
|
<TabList.Item
|
|
key={Tab.TAGS}
|
|
hidden={!issueTypeConfig.tags.enabled}
|
|
disabled={disabledTabs.includes(Tab.TAGS)}
|
|
to={`${baseUrl}tags/${location.search}`}
|
|
>
|
|
{t('Tags')}
|
|
</TabList.Item>
|
|
<TabList.Item
|
|
key={Tab.EVENTS}
|
|
hidden={!issueTypeConfig.events.enabled}
|
|
disabled={disabledTabs.includes(Tab.EVENTS)}
|
|
to={eventRoute}
|
|
>
|
|
{group.issueCategory === IssueCategory.ERROR
|
|
? t('All Events')
|
|
: t('Sampled Events')}
|
|
</TabList.Item>
|
|
<TabList.Item
|
|
key={Tab.MERGED}
|
|
hidden={!issueTypeConfig.mergedIssues.enabled}
|
|
disabled={disabledTabs.includes(Tab.MERGED)}
|
|
to={`${baseUrl}merged/${location.search}`}
|
|
>
|
|
{t('Merged Issues')}
|
|
</TabList.Item>
|
|
<TabList.Item
|
|
key={Tab.SIMILAR_ISSUES}
|
|
hidden={!hasSimilarView || !issueTypeConfig.similarIssues.enabled}
|
|
disabled={disabledTabs.includes(Tab.SIMILAR_ISSUES)}
|
|
to={`${baseUrl}similar/${location.search}`}
|
|
>
|
|
{t('Similar Issues')}
|
|
</TabList.Item>
|
|
<TabList.Item
|
|
key={Tab.REPLAYS}
|
|
textValue={t('Replays')}
|
|
hidden={!hasReplaySupport || !issueTypeConfig.replays.enabled}
|
|
to={`${baseUrl}replays/${location.search}`}
|
|
>
|
|
{t('Replays')}
|
|
<ReplayCountBadge count={replaysCount} />
|
|
</TabList.Item>
|
|
</StyledTabList>
|
|
);
|
|
}
|
|
|
|
function GroupHeader({
|
|
baseUrl,
|
|
group,
|
|
groupReprocessingStatus,
|
|
organization,
|
|
event,
|
|
project,
|
|
}: Props) {
|
|
const location = useLocation();
|
|
|
|
const disabledTabs = useMemo(() => {
|
|
const hasReprocessingV2Feature = organization.features.includes('reprocessing-v2');
|
|
|
|
if (!hasReprocessingV2Feature) {
|
|
return [];
|
|
}
|
|
|
|
if (groupReprocessingStatus === ReprocessingStatus.REPROCESSING) {
|
|
return [
|
|
Tab.ACTIVITY,
|
|
Tab.USER_FEEDBACK,
|
|
Tab.ATTACHMENTS,
|
|
Tab.EVENTS,
|
|
Tab.MERGED,
|
|
Tab.SIMILAR_ISSUES,
|
|
Tab.TAGS,
|
|
];
|
|
}
|
|
|
|
if (groupReprocessingStatus === ReprocessingStatus.REPROCESSED_AND_HASNT_EVENT) {
|
|
return [
|
|
Tab.DETAILS,
|
|
Tab.ATTACHMENTS,
|
|
Tab.EVENTS,
|
|
Tab.MERGED,
|
|
Tab.SIMILAR_ISSUES,
|
|
Tab.TAGS,
|
|
Tab.USER_FEEDBACK,
|
|
];
|
|
}
|
|
|
|
return [];
|
|
}, [organization, groupReprocessingStatus]);
|
|
|
|
const eventRoute = useMemo(() => {
|
|
const searchTermWithoutQuery = omit(location.query, 'query');
|
|
return {
|
|
pathname: `${baseUrl}events/`,
|
|
query: searchTermWithoutQuery,
|
|
};
|
|
}, [location, baseUrl]);
|
|
|
|
const {userCount} = group;
|
|
|
|
let className = 'group-detail';
|
|
|
|
if (group.hasSeen) {
|
|
className += ' hasSeen';
|
|
}
|
|
|
|
if (group.status === 'resolved') {
|
|
className += ' isResolved';
|
|
}
|
|
|
|
const message = getMessage(group);
|
|
|
|
const disableActions = !!disabledTabs.length;
|
|
|
|
const shortIdBreadcrumb = (
|
|
<ShortIdBreadrcumb organization={organization} project={project} group={group} />
|
|
);
|
|
|
|
const issueTypeConfig = getConfigForIssueType(group, project);
|
|
|
|
return (
|
|
<Layout.Header>
|
|
<div className={className}>
|
|
<BreadcrumbActionWrapper>
|
|
<Breadcrumbs
|
|
crumbs={[
|
|
{
|
|
label: 'Issues',
|
|
to: {
|
|
pathname: `/organizations/${organization.slug}/issues/`,
|
|
// Sanitize sort queries from query
|
|
query: omit(location.query, 'sort'),
|
|
},
|
|
},
|
|
{label: shortIdBreadcrumb},
|
|
]}
|
|
/>
|
|
<GroupActions
|
|
group={group}
|
|
project={project}
|
|
disabled={disableActions}
|
|
event={event}
|
|
query={location.query}
|
|
/>
|
|
</BreadcrumbActionWrapper>
|
|
<HeaderRow>
|
|
<TitleWrapper>
|
|
<TitleHeading>
|
|
<h3>
|
|
<StyledEventOrGroupTitle data={group} />
|
|
</h3>
|
|
<GroupStatusBadge
|
|
status={group.status}
|
|
substatus={group.substatus}
|
|
fontSize="md"
|
|
/>
|
|
</TitleHeading>
|
|
<StyledTagAndMessageWrapper>
|
|
{group.level && <ErrorLevel level={group.level} size="11px" />}
|
|
{group.isUnhandled && <UnhandledInboxTag />}
|
|
<EventMessage message={message} />
|
|
</StyledTagAndMessageWrapper>
|
|
</TitleWrapper>
|
|
{issueTypeConfig.stats.enabled && (
|
|
<StatsWrapper>
|
|
<div className="count">
|
|
<h6 className="nav-header">{t('Events')}</h6>
|
|
<Link disabled={disableActions} to={eventRoute}>
|
|
<Count className="count" value={group.count} />
|
|
</Link>
|
|
</div>
|
|
<div className="count">
|
|
<h6 className="nav-header">{t('Users')}</h6>
|
|
{userCount !== 0 ? (
|
|
<Link
|
|
disabled={disableActions}
|
|
to={`${baseUrl}tags/user/${location.search}`}
|
|
>
|
|
<Count className="count" value={userCount} />
|
|
</Link>
|
|
) : (
|
|
<span>0</span>
|
|
)}
|
|
</div>
|
|
{organization.features.includes('issue-priority-ui') && group.priority ? (
|
|
<PriorityContainer>
|
|
<h6 className="nav-header">{t('Priority')}</h6>
|
|
<GroupPriority group={group} />
|
|
</PriorityContainer>
|
|
) : null}
|
|
</StatsWrapper>
|
|
)}
|
|
</HeaderRow>
|
|
{/* Environment picker for mobile */}
|
|
<HeaderRow className="hidden-sm hidden-md hidden-lg">
|
|
<EnvironmentPageFilter position="bottom-end" />
|
|
</HeaderRow>
|
|
<GroupHeaderTabs {...{baseUrl, disabledTabs, eventRoute, group, project}} />
|
|
</div>
|
|
</Layout.Header>
|
|
);
|
|
}
|
|
|
|
export default GroupHeader;
|
|
|
|
const BreadcrumbActionWrapper = styled('div')`
|
|
display: flex;
|
|
flex-direction: row;
|
|
justify-content: space-between;
|
|
gap: ${space(1)};
|
|
align-items: center;
|
|
`;
|
|
|
|
const HeaderRow = styled('div')`
|
|
display: flex;
|
|
gap: ${space(2)};
|
|
justify-content: space-between;
|
|
margin-top: ${space(2)};
|
|
|
|
@media (max-width: ${p => p.theme.breakpoints.small}) {
|
|
flex-direction: column;
|
|
}
|
|
`;
|
|
|
|
const TitleWrapper = styled('div')`
|
|
@media (min-width: ${p => p.theme.breakpoints.small}) {
|
|
max-width: 65%;
|
|
}
|
|
`;
|
|
|
|
const TitleHeading = styled('div')`
|
|
display: flex;
|
|
line-height: 2;
|
|
gap: ${space(1)};
|
|
`;
|
|
|
|
const StyledEventOrGroupTitle = styled(EventOrGroupTitle)`
|
|
font-size: inherit;
|
|
`;
|
|
|
|
const StatsWrapper = styled('div')`
|
|
display: flex;
|
|
gap: calc(${space(3)} + ${space(3)});
|
|
|
|
@media (min-width: ${p => p.theme.breakpoints.small}) {
|
|
justify-content: flex-end;
|
|
}
|
|
`;
|
|
|
|
const StyledTagAndMessageWrapper = styled(TagAndMessageWrapper)`
|
|
display: flex;
|
|
gap: ${space(1)};
|
|
justify-content: flex-start;
|
|
line-height: 1.2;
|
|
`;
|
|
|
|
const IconBadge = styled(Badge)`
|
|
display: flex;
|
|
align-items: center;
|
|
gap: ${space(0.5)};
|
|
`;
|
|
|
|
const StyledTabList = styled(TabList)`
|
|
margin-top: ${space(2)};
|
|
`;
|
|
|
|
const PriorityContainer = styled('div')`
|
|
/* Ensures that the layout doesn't shift when changing priority */
|
|
min-width: 80px;
|
|
`;
|