mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
237 lines
7.1 KiB
TypeScript
237 lines
7.1 KiB
TypeScript
import {Fragment} from 'react';
|
|
import styled from '@emotion/styled';
|
|
|
|
import {BarChart} from 'sentry/components/charts/barChart';
|
|
import type {DateTimeObject} from 'sentry/components/charts/utils';
|
|
import CollapsePanel, {COLLAPSE_COUNT} from 'sentry/components/collapsePanel';
|
|
import LoadingError from 'sentry/components/loadingError';
|
|
import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
|
|
import PanelTable from 'sentry/components/panels/panelTable';
|
|
import Placeholder from 'sentry/components/placeholder';
|
|
import {IconArrow} from 'sentry/icons';
|
|
import {t, tct} from 'sentry/locale';
|
|
import {space} from 'sentry/styles/space';
|
|
import type {Organization, Project} from 'sentry/types';
|
|
import {formatPercentage} from 'sentry/utils/formatters';
|
|
import {useApiQuery} from 'sentry/utils/queryClient';
|
|
import type {ColorOrAlias} from 'sentry/utils/theme';
|
|
|
|
import {ProjectBadge, ProjectBadgeContainer} from './styles';
|
|
import {
|
|
barAxisLabel,
|
|
convertDayValueObjectToSeries,
|
|
groupByTrend,
|
|
sortSeriesByDay,
|
|
} from './utils';
|
|
|
|
interface TeamUnresolvedIssuesProps extends DateTimeObject {
|
|
organization: Organization;
|
|
projects: Project[];
|
|
teamSlug: string;
|
|
environment?: string;
|
|
}
|
|
|
|
type UnresolvedCount = {unresolved: number};
|
|
type ProjectReleaseCount = Record<string, Record<string, UnresolvedCount>>;
|
|
|
|
export function TeamUnresolvedIssues({
|
|
organization,
|
|
teamSlug,
|
|
projects,
|
|
start,
|
|
end,
|
|
period,
|
|
utc,
|
|
environment,
|
|
}: TeamUnresolvedIssuesProps) {
|
|
const {
|
|
data: periodIssues = {},
|
|
isLoading,
|
|
isError,
|
|
refetch,
|
|
} = useApiQuery<ProjectReleaseCount>(
|
|
[
|
|
`/teams/${organization.slug}/${teamSlug}/all-unresolved-issues/`,
|
|
{
|
|
query: {
|
|
...normalizeDateTimeParams({start, end, period, utc}),
|
|
environment,
|
|
},
|
|
},
|
|
],
|
|
{staleTime: 0}
|
|
);
|
|
|
|
function getTotalUnresolved(projectId: number): number {
|
|
const entries = Object.values(periodIssues?.[projectId] ?? {});
|
|
const total = entries.reduce((acc, current) => acc + current.unresolved, 0);
|
|
|
|
return Math.round(total / entries.length);
|
|
}
|
|
|
|
const projectTotals: Record<
|
|
string,
|
|
{percentChange: number; periodAvg: number; projectId: string; today: number}
|
|
> = {};
|
|
for (const projectId of Object.keys(periodIssues)) {
|
|
const periodAvg = getTotalUnresolved(Number(projectId));
|
|
const projectPeriodEntries = Object.values(periodIssues?.[projectId] ?? {});
|
|
const today = projectPeriodEntries[projectPeriodEntries.length - 1]?.unresolved ?? 0;
|
|
const percentChange = (today - periodAvg) / periodAvg;
|
|
projectTotals[projectId] = {
|
|
projectId,
|
|
periodAvg,
|
|
today,
|
|
percentChange: Number.isNaN(percentChange) ? 0 : percentChange,
|
|
};
|
|
}
|
|
|
|
const sortedProjects = projects
|
|
.map(project => ({project, trend: projectTotals[project.id]?.percentChange ?? 0}))
|
|
.sort((a, b) => Math.abs(b.trend) - Math.abs(a.trend));
|
|
|
|
const groupedProjects = groupByTrend(sortedProjects);
|
|
|
|
// All data will contain all pairs of [day, unresolved_count].
|
|
const allData = Object.values(periodIssues).flatMap(data =>
|
|
Object.entries(data).map(
|
|
([bucket, {unresolved}]) => [bucket, unresolved] as [string, number]
|
|
)
|
|
);
|
|
// Total by day for all projects
|
|
const totalByDay = allData.reduce((acc, [bucket, unresolved]) => {
|
|
if (acc[bucket] === undefined) {
|
|
acc[bucket] = 0;
|
|
}
|
|
acc[bucket] += unresolved;
|
|
return acc;
|
|
}, {});
|
|
|
|
const seriesData = sortSeriesByDay(convertDayValueObjectToSeries(totalByDay));
|
|
|
|
if (isError) {
|
|
return <LoadingError onRetry={refetch} />;
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<ChartWrapper>
|
|
{isLoading && <Placeholder height="200px" />}
|
|
{!isLoading && (
|
|
<BarChart
|
|
style={{height: 190}}
|
|
isGroupedByDate
|
|
useShortDate
|
|
legend={{right: 3, top: 0}}
|
|
yAxis={{minInterval: 1}}
|
|
xAxis={barAxisLabel()}
|
|
series={[
|
|
{
|
|
seriesName: t('Unresolved Issues'),
|
|
silent: true,
|
|
data: seriesData,
|
|
barCategoryGap: '6%',
|
|
},
|
|
]}
|
|
/>
|
|
)}
|
|
</ChartWrapper>
|
|
<CollapsePanel items={groupedProjects.length}>
|
|
{({isExpanded, showMoreButton}) => (
|
|
<Fragment>
|
|
<StyledPanelTable
|
|
isEmpty={projects.length === 0}
|
|
isLoading={isLoading}
|
|
headers={[
|
|
t('Project'),
|
|
<RightAligned key="last">
|
|
{tct('Last [period] Average', {period})}
|
|
</RightAligned>,
|
|
<RightAligned key="curr">{t('Today')}</RightAligned>,
|
|
<RightAligned key="diff">{t('Change')}</RightAligned>,
|
|
]}
|
|
>
|
|
{groupedProjects.map(({project}, idx) => {
|
|
const totals = projectTotals[project.id] ?? {};
|
|
|
|
if (idx >= COLLAPSE_COUNT && !isExpanded) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<Fragment key={project.id}>
|
|
<ProjectBadgeContainer>
|
|
<ProjectBadge avatarSize={18} project={project} />
|
|
</ProjectBadgeContainer>
|
|
|
|
<ScoreWrapper>{totals.periodAvg}</ScoreWrapper>
|
|
<ScoreWrapper>{totals.today}</ScoreWrapper>
|
|
<ScoreWrapper>
|
|
<SubText
|
|
color={
|
|
totals.percentChange === 0
|
|
? 'subText'
|
|
: totals.percentChange > 0
|
|
? 'errorText'
|
|
: 'successText'
|
|
}
|
|
>
|
|
{formatPercentage(
|
|
Number.isNaN(totals.percentChange) ? 0 : totals.percentChange,
|
|
0
|
|
)}
|
|
<PaddedIconArrow
|
|
direction={totals.percentChange > 0 ? 'up' : 'down'}
|
|
size="xs"
|
|
/>
|
|
</SubText>
|
|
</ScoreWrapper>
|
|
</Fragment>
|
|
);
|
|
})}
|
|
</StyledPanelTable>
|
|
{!isLoading && showMoreButton}
|
|
</Fragment>
|
|
)}
|
|
</CollapsePanel>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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.2fr 0.2fr 0.2fr;
|
|
white-space: nowrap;
|
|
margin-bottom: 0;
|
|
border: 0;
|
|
font-size: ${p => p.theme.fontSizeMedium};
|
|
box-shadow: unset;
|
|
|
|
& > div {
|
|
padding: ${space(1)} ${space(2)};
|
|
}
|
|
`;
|
|
|
|
const RightAligned = styled('span')`
|
|
text-align: right;
|
|
`;
|
|
|
|
const ScoreWrapper = styled('div')`
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
text-align: right;
|
|
`;
|
|
|
|
const PaddedIconArrow = styled(IconArrow)`
|
|
margin: 0 ${space(0.5)};
|
|
`;
|
|
|
|
const SubText = styled('div')<{color: ColorOrAlias}>`
|
|
color: ${p => p.theme[p.color]};
|
|
`;
|