mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-10-30 14:49:14 +00:00
330 lines
9.1 KiB
TypeScript
330 lines
9.1 KiB
TypeScript
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]};
|
||
`;
|