mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
323 lines
8.6 KiB
TypeScript
323 lines
8.6 KiB
TypeScript
import {Fragment} from 'react';
|
|
import {css} from '@emotion/react';
|
|
import styled from '@emotion/styled';
|
|
import type {Location} from 'history';
|
|
|
|
import AssigneeSelector from 'sentry/components/assigneeSelector';
|
|
import Count from 'sentry/components/count';
|
|
import Link from 'sentry/components/links/link';
|
|
import {getRelativeSummary} from 'sentry/components/timeRangeSelector/utils';
|
|
import {Tooltip} from 'sentry/components/tooltip';
|
|
import {DEFAULT_STATS_PERIOD} from 'sentry/constants';
|
|
import {t} from 'sentry/locale';
|
|
import MemberListStore from 'sentry/stores/memberListStore';
|
|
import {space} from 'sentry/styles/space';
|
|
import type {Organization} from 'sentry/types';
|
|
import type {EventData} from 'sentry/utils/discover/eventView';
|
|
import EventView from 'sentry/utils/discover/eventView';
|
|
import {FieldKey} from 'sentry/views/dashboards/widgetBuilder/issueWidget/fields';
|
|
|
|
import {Container, FieldShortId, OverflowLink} from '../discover/styles';
|
|
|
|
/**
|
|
* Types, functions and definitions for rendering fields in discover results.
|
|
*/
|
|
type RenderFunctionBaggage = {
|
|
location: Location;
|
|
organization: Organization;
|
|
eventView?: EventView;
|
|
};
|
|
|
|
type FieldFormatterRenderFunctionPartial = (
|
|
data: EventData,
|
|
baggage: RenderFunctionBaggage
|
|
) => React.ReactNode;
|
|
|
|
type SpecialFieldRenderFunc = (
|
|
data: EventData,
|
|
baggage: RenderFunctionBaggage
|
|
) => React.ReactNode;
|
|
|
|
type SpecialField = {
|
|
renderFunc: SpecialFieldRenderFunc;
|
|
sortField: string | null;
|
|
};
|
|
|
|
type SpecialFields = {
|
|
assignee: SpecialField;
|
|
count: SpecialField;
|
|
events: SpecialField;
|
|
issue: SpecialField;
|
|
lifetimeCount: SpecialField;
|
|
lifetimeEvents: SpecialField;
|
|
lifetimeUserCount: SpecialField;
|
|
lifetimeUsers: SpecialField;
|
|
links: SpecialField;
|
|
userCount: SpecialField;
|
|
users: SpecialField;
|
|
};
|
|
|
|
/**
|
|
* "Special fields" either do not map 1:1 to an single column in the event database,
|
|
* or they require custom UI formatting that can't be handled by the datatype formatters.
|
|
*/
|
|
const SPECIAL_FIELDS: SpecialFields = {
|
|
issue: {
|
|
sortField: null,
|
|
renderFunc: (data, {organization}) => {
|
|
const issueID = data['issue.id'];
|
|
|
|
if (!issueID) {
|
|
return (
|
|
<Container>
|
|
<FieldShortId shortId={`${data.issue}`} />
|
|
</Container>
|
|
);
|
|
}
|
|
|
|
const target = {
|
|
pathname: `/organizations/${organization.slug}/issues/${issueID}/`,
|
|
};
|
|
|
|
return (
|
|
<Container>
|
|
<OverflowLink to={target} aria-label={issueID}>
|
|
<FieldShortId shortId={`${data.issue}`} />
|
|
</OverflowLink>
|
|
</Container>
|
|
);
|
|
},
|
|
},
|
|
assignee: {
|
|
sortField: null,
|
|
renderFunc: data => {
|
|
const memberList = MemberListStore.getAll();
|
|
return (
|
|
<ActorContainer>
|
|
<AssigneeSelector id={data.id} memberList={memberList} noDropdown />
|
|
</ActorContainer>
|
|
);
|
|
},
|
|
},
|
|
lifetimeEvents: {
|
|
sortField: null,
|
|
renderFunc: (data, {organization}) =>
|
|
issuesCountRenderer(data, organization, 'lifetimeEvents'),
|
|
},
|
|
lifetimeUsers: {
|
|
sortField: null,
|
|
renderFunc: (data, {organization}) =>
|
|
issuesCountRenderer(data, organization, 'lifetimeUsers'),
|
|
},
|
|
events: {
|
|
sortField: 'freq',
|
|
renderFunc: (data, {organization}) =>
|
|
issuesCountRenderer(data, organization, 'events'),
|
|
},
|
|
users: {
|
|
sortField: 'user',
|
|
renderFunc: (data, {organization}) =>
|
|
issuesCountRenderer(data, organization, 'users'),
|
|
},
|
|
lifetimeCount: {
|
|
sortField: null,
|
|
renderFunc: (data, {organization}) =>
|
|
issuesCountRenderer(data, organization, 'lifetimeEvents'),
|
|
},
|
|
lifetimeUserCount: {
|
|
sortField: null,
|
|
renderFunc: (data, {organization}) =>
|
|
issuesCountRenderer(data, organization, 'lifetimeUsers'),
|
|
},
|
|
count: {
|
|
sortField: null,
|
|
renderFunc: (data, {organization}) =>
|
|
issuesCountRenderer(data, organization, 'events'),
|
|
},
|
|
userCount: {
|
|
sortField: null,
|
|
renderFunc: (data, {organization}) =>
|
|
issuesCountRenderer(data, organization, 'users'),
|
|
},
|
|
links: {
|
|
sortField: null,
|
|
renderFunc: ({links}) => <LinksContainer dangerouslySetInnerHTML={{__html: links}} />,
|
|
},
|
|
};
|
|
|
|
const issuesCountRenderer = (
|
|
data: EventData,
|
|
organization: Organization,
|
|
field: 'events' | 'users' | 'lifetimeEvents' | 'lifetimeUsers'
|
|
) => {
|
|
const {start, end, period} = data;
|
|
const isUserField = !!/user/i.exec(field.toLowerCase());
|
|
const primaryCount = data[field];
|
|
const count = data[isUserField ? 'users' : 'events'];
|
|
const lifetimeCount = data[isUserField ? 'lifetimeUsers' : 'lifetimeEvents'];
|
|
const filteredCount = data[isUserField ? 'filteredUsers' : 'filteredEvents'];
|
|
const discoverLink = getDiscoverUrl(data, organization);
|
|
const filteredDiscoverLink = getDiscoverUrl(data, organization, true);
|
|
const selectionDateString =
|
|
!!start && !!end
|
|
? 'time range'
|
|
: getRelativeSummary(period || DEFAULT_STATS_PERIOD).toLowerCase();
|
|
return (
|
|
<Container>
|
|
<Tooltip
|
|
isHoverable
|
|
skipWrapper
|
|
overlayStyle={{padding: 0}}
|
|
title={
|
|
<div>
|
|
{filteredCount ? (
|
|
<Fragment>
|
|
<StyledLink to={filteredDiscoverLink}>
|
|
{t('Matching search filters')}
|
|
<WrappedCount value={filteredCount} />
|
|
</StyledLink>
|
|
<Divider />
|
|
</Fragment>
|
|
) : null}
|
|
<StyledLink to={discoverLink}>
|
|
{t('Total in %s', selectionDateString)}
|
|
<WrappedCount value={count} />
|
|
</StyledLink>
|
|
<Divider />
|
|
<StyledContent>
|
|
{t('Since issue began')}
|
|
<WrappedCount value={lifetimeCount} />
|
|
</StyledContent>
|
|
</div>
|
|
}
|
|
>
|
|
<span>
|
|
{['events', 'users'].includes(field) && filteredCount ? (
|
|
<Fragment>
|
|
<Count value={filteredCount} />
|
|
<SecondaryCount value={primaryCount} />
|
|
</Fragment>
|
|
) : (
|
|
<Count value={primaryCount} />
|
|
)}
|
|
</span>
|
|
</Tooltip>
|
|
</Container>
|
|
);
|
|
};
|
|
|
|
const getDiscoverUrl = (
|
|
data: EventData,
|
|
organization: Organization,
|
|
filtered?: boolean
|
|
) => {
|
|
const commonQuery = {projects: [Number(data.projectId)]};
|
|
const discoverView = EventView.fromSavedQuery({
|
|
...commonQuery,
|
|
id: undefined,
|
|
start: data.start,
|
|
end: data.end,
|
|
range: data.period,
|
|
name: data.title,
|
|
fields: ['title', 'release', 'environment', 'user', 'timestamp'],
|
|
orderby: '-timestamp',
|
|
query: `issue.id:${data.id}${filtered ? data.discoverSearchQuery : ''}`,
|
|
version: 2,
|
|
});
|
|
return discoverView.getResultsViewUrlTarget(organization.slug);
|
|
};
|
|
|
|
export function getSortField(field: string): string | null {
|
|
if (SPECIAL_FIELDS.hasOwnProperty(field)) {
|
|
return SPECIAL_FIELDS[field as keyof typeof SPECIAL_FIELDS].sortField;
|
|
}
|
|
switch (field) {
|
|
case FieldKey.LAST_SEEN:
|
|
return 'date';
|
|
case FieldKey.FIRST_SEEN:
|
|
return 'new';
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const contentStyle = css`
|
|
width: 100%;
|
|
justify-content: space-between;
|
|
display: flex;
|
|
padding: 6px 10px;
|
|
`;
|
|
|
|
const StyledContent = styled('div')`
|
|
${contentStyle};
|
|
`;
|
|
|
|
const StyledLink = styled(Link)`
|
|
${contentStyle};
|
|
color: ${p => p.theme.gray400};
|
|
&:hover {
|
|
color: ${p => p.theme.gray400};
|
|
background: ${p => p.theme.hover};
|
|
}
|
|
`;
|
|
|
|
const SecondaryCount = styled(Count)`
|
|
:before {
|
|
content: '/';
|
|
padding-left: ${space(0.25)};
|
|
padding-right: 2px;
|
|
}
|
|
`;
|
|
|
|
const WrappedCount = styled(({value, ...p}) => (
|
|
<div {...p}>
|
|
<Count value={value} />
|
|
</div>
|
|
))`
|
|
text-align: right;
|
|
font-weight: bold;
|
|
font-variant-numeric: tabular-nums;
|
|
padding-left: ${space(2)};
|
|
color: ${p => p.theme.subText};
|
|
`;
|
|
|
|
const Divider = styled('div')`
|
|
height: 1px;
|
|
overflow: hidden;
|
|
background-color: ${p => p.theme.innerBorder};
|
|
`;
|
|
|
|
const ActorContainer = styled('div')`
|
|
display: flex;
|
|
justify-content: left;
|
|
margin-left: 18px;
|
|
/* IconUser is the only one with 20px. We are setting 24px here to make the height consistent */
|
|
height: 24px;
|
|
:hover {
|
|
cursor: default;
|
|
}
|
|
`;
|
|
|
|
const LinksContainer = styled('span')`
|
|
white-space: nowrap;
|
|
`;
|
|
|
|
/**
|
|
* Get the field renderer for the named field and metadata
|
|
*
|
|
* @param {String} field name
|
|
* @param {object} metadata mapping.
|
|
* @returns {Function}
|
|
*/
|
|
export function getIssueFieldRenderer(
|
|
field: string
|
|
): FieldFormatterRenderFunctionPartial | null {
|
|
if (SPECIAL_FIELDS.hasOwnProperty(field)) {
|
|
return SPECIAL_FIELDS[field].renderFunc;
|
|
}
|
|
|
|
// Return null if there is no field renderer for this field
|
|
// Should check the discover field renderer for this field
|
|
return null;
|
|
}
|