Files
2024-02-15 17:01:50 +00:00

330 lines
9.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {Fragment} from 'react';
import type {Theme} from '@emotion/react';
import {css, withTheme} from '@emotion/react';
import styled from '@emotion/styled';
import round from 'lodash/round';
import moment from 'moment';
import {Button} from 'sentry/components/button';
import {BarChart} from 'sentry/components/charts/barChart';
import MarkLine from 'sentry/components/charts/components/markLine';
import type {DateTimeObject} from 'sentry/components/charts/utils';
import Link from 'sentry/components/links/link';
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 {useApiQuery} from 'sentry/utils/queryClient';
import type {ColorOrAlias} from 'sentry/utils/theme';
import toArray from 'sentry/utils/toArray';
import {ProjectBadge, ProjectBadgeContainer} from './styles';
import {barAxisLabel, groupByTrend, sortSeriesByDay} from './utils';
interface TeamReleasesProps extends DateTimeObject {
organization: Organization;
projects: Project[];
teamSlug: string;
theme: Theme;
}
export type ProjectReleaseCount = {
last_week_totals: Record<string, number>;
project_avgs: Record<string, number>;
release_counts: Record<string, number>;
};
function TeamReleases({
organization,
projects,
teamSlug,
theme,
start,
end,
period,
utc,
}: TeamReleasesProps) {
const datetime = {start, end, period, utc};
const {
data: periodReleases,
isLoading: isPeriodReleasesLoading,
isError: isPeriodReleasesError,
refetch: refetchPeriodReleases,
} = useApiQuery<ProjectReleaseCount>(
[
`/teams/${organization.slug}/${teamSlug}/release-count/`,
{
query: {
...normalizeDateTimeParams(datetime),
},
},
],
{staleTime: 5000}
);
const {
data: weekReleases,
isLoading: isWeekReleasesLoading,
isError: isWeekReleasesError,
refetch: refetchWeekReleases,
} = useApiQuery<ProjectReleaseCount>(
[
`/teams/${organization.slug}/${teamSlug}/release-count/`,
{
query: {
statsPeriod: '7d',
},
},
],
{staleTime: 5000}
);
const isLoading = isPeriodReleasesLoading || isWeekReleasesLoading;
if (isPeriodReleasesError || isWeekReleasesError) {
return (
<LoadingError
onRetry={() => {
refetchPeriodReleases();
refetchWeekReleases();
}}
/>
);
}
function getReleaseCount(projectId: number, dataset: 'week' | 'period'): number | null {
const releasesPeriod =
dataset === 'week' ? weekReleases?.last_week_totals : periodReleases?.project_avgs;
const count = releasesPeriod?.[projectId]
? Math.ceil(releasesPeriod?.[projectId])
: 0;
return count;
}
function getTrend(projectId: number): number | null {
const periodCount = getReleaseCount(projectId, 'period');
const weekCount = getReleaseCount(projectId, 'week');
if (periodCount === null || weekCount === null) {
return null;
}
return weekCount - periodCount;
}
function renderReleaseCount(projectId: string, dataset: 'week' | 'period') {
if (isLoading) {
return (
<div>
<Placeholder width="80px" height="25px" />
</div>
);
}
const count = getReleaseCount(Number(projectId), dataset);
if (count === null) {
return '\u2014';
}
return count;
}
function renderTrend(projectId: string) {
if (isLoading) {
return (
<div>
<Placeholder width="80px" height="25px" />
</div>
);
}
const trend = getTrend(Number(projectId));
if (trend === null) {
return '\u2014';
}
return (
<SubText color={trend >= 0 ? 'successText' : 'errorText'}>
{`${round(Math.abs(trend), 3)}`}
<PaddedIconArrow direction={trend >= 0 ? 'up' : 'down'} size="xs" />
</SubText>
);
}
const sortedProjects = projects
.map(project => ({project, trend: getTrend(Number(project.id)) ?? 0}))
.sort((a, b) => Math.abs(b.trend) - Math.abs(a.trend));
const groupedProjects = groupByTrend(sortedProjects);
const data = Object.entries(periodReleases?.release_counts ?? {}).map(
([bucket, count]) => ({
value: Math.ceil(count),
name: new Date(bucket).getTime(),
})
);
const seriesData = sortSeriesByDay(data);
const averageValues = Object.values(periodReleases?.project_avgs ?? {});
const projectAvgSum = averageValues.reduce(
(total, currentData) => total + currentData,
0
);
const totalPeriodAverage = Math.ceil(projectAvgSum / averageValues.length);
return (
<div>
<ChartWrapper>
<BarChart
style={{height: 190}}
isGroupedByDate
useShortDate
period="7d"
legend={{right: 3, top: 0}}
yAxis={{minInterval: 1}}
xAxis={barAxisLabel()}
series={[
{
seriesName: t('This Period'),
silent: true,
data: seriesData,
markLine: MarkLine({
silent: true,
lineStyle: {color: theme.gray200, type: 'dashed', width: 1},
data: [{yAxis: totalPeriodAverage}],
label: {
show: false,
},
}),
barCategoryGap: '5%',
},
]}
tooltip={{
formatter: seriesParams => {
// `seriesParams` can be an array or an object :/
const [series] = toArray(seriesParams);
const dateFormat = 'MMM D';
const startDate = moment(series.data[0]).format(dateFormat);
const endDate = moment(series.data[0]).add(7, 'days').format(dateFormat);
return [
'<div class="tooltip-series">',
`<div><span class="tooltip-label">${series.marker} <strong>${series.seriesName}</strong></span> ${series.data[1]}</div>`,
`<div><span class="tooltip-label"><strong>Last ${period} Average</strong></span> ${totalPeriodAverage}</div>`,
'</div>',
`<div class="tooltip-footer">${startDate} - ${endDate}</div>`,
'<div class="tooltip-arrow"></div>',
].join('');
},
}}
/>
</ChartWrapper>
<StyledPanelTable
isEmpty={projects.length === 0}
emptyMessage={t('No releases were setup for this team’s projects')}
emptyAction={
<Button
size="sm"
external
href="https://docs.sentry.io/product/releases/setup/"
>
{t('Learn More')}
</Button>
}
headers={[
t('Releases Per Project'),
<RightAligned key="last">
{tct('Last [period] Average', {period})}
</RightAligned>,
<RightAligned key="curr">{t('Last 7 Days')}</RightAligned>,
<RightAligned key="diff">{t('Difference')}</RightAligned>,
]}
>
{groupedProjects.map(({project}) => (
<Fragment key={project.id}>
<ProjectBadgeContainer>
<ProjectBadge
avatarSize={18}
project={project}
to={{
pathname: `/organizations/${organization.slug}/releases/`,
query: {project: project.id},
}}
/>
</ProjectBadgeContainer>
<ScoreWrapper>{renderReleaseCount(project.id, 'period')}</ScoreWrapper>
<ScoreWrapper>
<Link
to={{
pathname: `/organizations/${organization.slug}/releases/`,
query: {project: project.id, statsPeriod: '7d'},
}}
>
{renderReleaseCount(project.id, 'week')}
</Link>
</ScoreWrapper>
<ScoreWrapper>{renderTrend(project.id)}</ScoreWrapper>
</Fragment>
))}
</StyledPanelTable>
</div>
);
}
export default withTheme(TeamReleases);
const ChartWrapper = styled('div')`
padding: ${space(2)} ${space(2)} 0 ${space(2)};
border-bottom: 1px solid ${p => p.theme.border};
`;
const StyledPanelTable = styled(PanelTable)<{isEmpty: boolean}>`
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)};
}
${p =>
p.isEmpty &&
css`
& > div:last-child {
padding: 48px ${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]};
`;