mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-10-30 14:49:14 +00:00
236 lines
6.2 KiB
TypeScript
236 lines
6.2 KiB
TypeScript
import {Fragment} from 'react';
|
||
import {css} from '@emotion/react';
|
||
import styled from '@emotion/styled';
|
||
import moment from 'moment';
|
||
|
||
import {BarChart} from 'sentry/components/charts/barChart';
|
||
import Count from 'sentry/components/count';
|
||
import ProjectBadge from 'sentry/components/idBadge/projectBadge';
|
||
import Link from 'sentry/components/links/link';
|
||
import LoadingError from 'sentry/components/loadingError';
|
||
import PanelTable from 'sentry/components/panels/panelTable';
|
||
import Placeholder from 'sentry/components/placeholder';
|
||
import TimeSince from 'sentry/components/timeSince';
|
||
import {IconArrow} from 'sentry/icons';
|
||
import {t} from 'sentry/locale';
|
||
import {space} from 'sentry/styles/space';
|
||
import type {Group, Organization} from 'sentry/types';
|
||
import {getTitle} from 'sentry/utils/events';
|
||
import {useApiQuery} from 'sentry/utils/queryClient';
|
||
|
||
interface TeamIssuesAgeProps {
|
||
organization: Organization;
|
||
teamSlug: string;
|
||
}
|
||
|
||
/**
|
||
* takes "< 1 hour" and returns a datetime of 1 hour ago
|
||
*/
|
||
function parseBucket(bucket: string): number {
|
||
if (bucket === '> 1 year') {
|
||
return moment().subtract(1, 'y').subtract(1, 'd').valueOf();
|
||
}
|
||
|
||
const [_, num, unit] = bucket.split(' ');
|
||
return moment()
|
||
.subtract(num, unit as any)
|
||
.valueOf();
|
||
}
|
||
|
||
const bucketLabels = {
|
||
'< 1 hour': t('1 hour'),
|
||
'< 4 hour': t('4 hours'),
|
||
'< 12 hour': t('12 hours'),
|
||
'< 1 day': t('1 day'),
|
||
'< 1 week': t('1 week'),
|
||
'< 4 week': t('1 month'),
|
||
'< 24 week': t('6 months'),
|
||
'< 1 year': t('1 year'),
|
||
'> 1 year': t('> 1 year'),
|
||
};
|
||
|
||
function TeamIssuesAge({organization, teamSlug}: TeamIssuesAgeProps) {
|
||
const {
|
||
data: oldestIssues,
|
||
isLoading: isOldestIssuesLoading,
|
||
isError: isOldestIssuesError,
|
||
refetch: refetchOldestIssues,
|
||
} = useApiQuery<Group[]>(
|
||
[
|
||
`/teams/${organization.slug}/${teamSlug}/issues/old/`,
|
||
{
|
||
query: {
|
||
limit: 7,
|
||
},
|
||
},
|
||
],
|
||
{staleTime: 5000}
|
||
);
|
||
|
||
const {
|
||
data: unresolvedIssueAge,
|
||
isLoading: isUnresolvedIssueAgeLoading,
|
||
isError: isUnresolvedIssueAgeError,
|
||
refetch: refetchUnresolvedIssueAge,
|
||
} = useApiQuery<Record<string, number>>(
|
||
[`/teams/${organization.slug}/${teamSlug}/unresolved-issue-age/`],
|
||
{staleTime: 5000}
|
||
);
|
||
|
||
const isLoading = isOldestIssuesLoading || isUnresolvedIssueAgeLoading;
|
||
|
||
if (isOldestIssuesError || isUnresolvedIssueAgeError) {
|
||
return (
|
||
<LoadingError
|
||
onRetry={() => {
|
||
refetchOldestIssues();
|
||
refetchUnresolvedIssueAge();
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
|
||
const seriesData = Object.entries(unresolvedIssueAge ?? {})
|
||
.map(([bucket, value]) => ({
|
||
name: bucket,
|
||
value,
|
||
}))
|
||
.sort((a, b) => parseBucket(b.name) - parseBucket(a.name));
|
||
|
||
return (
|
||
<div>
|
||
<ChartWrapper>
|
||
{isLoading && <Placeholder height="200px" />}
|
||
{!isLoading && (
|
||
<BarChart
|
||
style={{height: 190}}
|
||
legend={{right: 3, top: 0}}
|
||
yAxis={{minInterval: 1}}
|
||
xAxis={{
|
||
type: 'category',
|
||
min: 0,
|
||
axisLabel: {
|
||
showMaxLabel: true,
|
||
showMinLabel: true,
|
||
formatter: (bucket: string) => {
|
||
return bucketLabels[bucket] ?? bucket;
|
||
},
|
||
},
|
||
}}
|
||
series={[
|
||
{
|
||
seriesName: t('Unresolved Issues'),
|
||
silent: true,
|
||
data: seriesData,
|
||
barCategoryGap: '5%',
|
||
},
|
||
]}
|
||
/>
|
||
)}
|
||
</ChartWrapper>
|
||
<StyledPanelTable
|
||
isEmpty={!oldestIssues || oldestIssues?.length === 0}
|
||
emptyMessage={t('No unresolved issues for this team’s projects')}
|
||
headers={[
|
||
t('Oldest Issues'),
|
||
<RightAligned key="events">{t('Events')}</RightAligned>,
|
||
<RightAligned key="users">{t('Users')}</RightAligned>,
|
||
<RightAligned key="age">
|
||
{t('Age')} <IconArrow direction="down" size="xs" color="gray300" />
|
||
</RightAligned>,
|
||
]}
|
||
isLoading={isLoading}
|
||
>
|
||
{oldestIssues?.map(issue => {
|
||
const {title} = getTitle(issue, organization?.features, false);
|
||
|
||
return (
|
||
<Fragment key={issue.id}>
|
||
<ProjectTitleContainer>
|
||
<ShadowlessProjectBadge
|
||
disableLink
|
||
hideName
|
||
avatarSize={18}
|
||
project={issue.project}
|
||
/>
|
||
<TitleOverflow>
|
||
<Link
|
||
to={{
|
||
pathname: `/organizations/${organization.slug}/issues/${issue.id}/`,
|
||
}}
|
||
>
|
||
{title}
|
||
</Link>
|
||
</TitleOverflow>
|
||
</ProjectTitleContainer>
|
||
<RightAligned>
|
||
<Count value={issue.count} />
|
||
</RightAligned>
|
||
<RightAligned>
|
||
<Count value={issue.userCount} />
|
||
</RightAligned>
|
||
<RightAligned>
|
||
<TimeSince date={issue.firstSeen} />
|
||
</RightAligned>
|
||
</Fragment>
|
||
);
|
||
})}
|
||
</StyledPanelTable>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default TeamIssuesAge;
|
||
|
||
const ChartWrapper = styled('div')`
|
||
padding: ${space(2)} ${space(2)} 0 ${space(2)};
|
||
border-bottom: 1px solid ${p => p.theme.border};
|
||
`;
|
||
|
||
const StyledPanelTable = styled(PanelTable)`
|
||
grid-template-columns: 1fr 0.15fr 0.15fr 0.25fr;
|
||
white-space: nowrap;
|
||
margin-bottom: 0;
|
||
border: 0;
|
||
font-size: ${p => p.theme.fontSizeMedium};
|
||
box-shadow: unset;
|
||
|
||
> * {
|
||
padding: ${space(1)} ${space(2)};
|
||
}
|
||
|
||
${p =>
|
||
p.isEmpty &&
|
||
css`
|
||
& > div:last-child {
|
||
padding: 48px ${space(2)};
|
||
}
|
||
`}
|
||
`;
|
||
|
||
const RightAligned = styled('span')`
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
`;
|
||
|
||
const ProjectTitleContainer = styled('div')`
|
||
${p => p.theme.overflowEllipsis};
|
||
display: flex;
|
||
align-items: center;
|
||
`;
|
||
|
||
const TitleOverflow = styled('div')`
|
||
${p => p.theme.overflowEllipsis};
|
||
`;
|
||
|
||
const ShadowlessProjectBadge = styled(ProjectBadge)`
|
||
display: inline-flex;
|
||
align-items: center;
|
||
margin-right: ${space(1)};
|
||
|
||
* > img {
|
||
box-shadow: none;
|
||
}
|
||
`;
|