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

329 lines
12 KiB
TypeScript

import {Component} from 'react';
import type {InjectedRouter} from 'react-router';
import styled from '@emotion/styled';
import type {LineSeriesOption} from 'echarts';
import type {Location} from 'history';
import compact from 'lodash/compact';
import pick from 'lodash/pick';
import moment from 'moment';
import type {Client} from 'sentry/api';
import ChartZoom from 'sentry/components/charts/chartZoom';
import {LineChart} from 'sentry/components/charts/lineChart';
import SessionsRequest from 'sentry/components/charts/sessionsRequest';
import {
HeaderTitleLegend,
InlineContainer,
SectionHeading,
SectionValue,
} from 'sentry/components/charts/styles';
import TransitionChart from 'sentry/components/charts/transitionChart';
import TransparentLoadingMask from 'sentry/components/charts/transparentLoadingMask';
import {
getDiffInMinutes,
ONE_WEEK,
truncationFormatter,
} from 'sentry/components/charts/utils';
import Count from 'sentry/components/count';
import type {StatsPeriodType} from 'sentry/components/organizations/pageFilters/parse';
import {
normalizeDateTimeParams,
parseStatsPeriod,
} from 'sentry/components/organizations/pageFilters/parse';
import Panel from 'sentry/components/panels/panel';
import PanelBody from 'sentry/components/panels/panelBody';
import PanelFooter from 'sentry/components/panels/panelFooter';
import Placeholder from 'sentry/components/placeholder';
import {URL_PARAM} from 'sentry/constants/pageFilters';
import {t, tct, tn} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {Organization, PageFilters, SessionApiResponse} from 'sentry/types';
import type {EChartClickHandler} from 'sentry/types/echarts';
import {formatVersion} from 'sentry/utils/formatters';
import {decodeScalar} from 'sentry/utils/queryString';
import {getAdoptionSeries, getCount} from 'sentry/utils/sessions';
import withApi from 'sentry/utils/withApi';
import {normalizeUrl} from 'sentry/utils/withDomainRequired';
import {sessionDisplayToField} from 'sentry/views/releases/list/releasesRequest';
import {ReleasesDisplayOption} from './releasesDisplayOptions';
type Props = {
activeDisplay: ReleasesDisplayOption;
api: Client;
location: Location;
organization: Organization;
router: InjectedRouter;
selection: PageFilters;
};
class ReleasesAdoptionChart extends Component<Props> {
// needs to have different granularity, that's why we use custom getInterval instead of getSessionsInterval
getInterval() {
const {organization, location} = this.props;
const datetimeObj = {
start: decodeScalar(location.query.start),
end: decodeScalar(location.query.end),
period: decodeScalar(location.query.statsPeriod),
};
const diffInMinutes = getDiffInMinutes(datetimeObj);
// use high fidelity intervals when available
// limit on backend is set to six hour
if (
organization.features.includes('minute-resolution-sessions') &&
diffInMinutes < 360
) {
return '10m';
}
if (diffInMinutes >= ONE_WEEK) {
return '1d';
}
return '1h';
}
getReleasesSeries(response: SessionApiResponse | null) {
const {activeDisplay} = this.props;
// If there are many releases, display releases with the highest number of sessions
// Often this due to many releases with low session counts or not filtering by environment
let releases: string[] | undefined =
response?.groups.map(group => group.by.release as string) ?? [];
if (response?.groups && response.groups.length > 50) {
releases = response!.groups
.sort((a, b) => b.totals['sum(session)'] - a.totals['sum(session)'])
.slice(0, 50)
.map(group => group.by.release as string);
}
if (!releases) {
return null;
}
return releases.map(release => ({
id: release,
seriesName: formatVersion(release),
data: getAdoptionSeries(
[response?.groups.find(({by}) => by.release === release)!],
response?.groups,
response?.intervals,
sessionDisplayToField(activeDisplay)
),
emphasis: {
focus: 'series',
} as LineSeriesOption['emphasis'],
}));
}
handleClick: EChartClickHandler = params => {
const {organization, router, selection, location} = this.props;
const project = selection.projects[0];
router.push(
normalizeUrl({
pathname: `/organizations/${organization?.slug}/releases/${encodeURIComponent(
params.seriesId
)}/`,
query: {project, environment: location.query.environment},
})
);
};
renderEmpty() {
return (
<Panel>
<PanelBody withPadding>
<ChartHeader>
<Placeholder height="24px" />
</ChartHeader>
<Placeholder height="200px" />
</PanelBody>
<ChartFooter>
<Placeholder height="34px" />
</ChartFooter>
</Panel>
);
}
render() {
const {activeDisplay, router, selection, api, organization, location} = this.props;
const {start, end, period, utc} = selection.datetime;
const interval = this.getInterval();
const field = sessionDisplayToField(activeDisplay);
return (
<SessionsRequest
api={api}
organization={organization}
interval={interval}
groupBy={['release']}
field={[field]}
{...normalizeDateTimeParams(pick(location.query, Object.values(URL_PARAM)))}
>
{({response, loading, reloading}) => {
const totalCount = getCount(response?.groups, field);
const releasesSeries = this.getReleasesSeries(response);
if (loading) {
return this.renderEmpty();
}
if (!releasesSeries?.length) {
return null;
}
const numDataPoints = releasesSeries[0].data.length;
const xAxisData = releasesSeries[0].data.map(point => point.name);
const hideLastPoint =
releasesSeries.findIndex(
series => series.data[numDataPoints - 1].value > 0
) === -1;
return (
<Panel>
<PanelBody withPadding>
<ChartHeader>
<ChartTitle>{t('Release Adoption')}</ChartTitle>
</ChartHeader>
<TransitionChart loading={loading} reloading={reloading}>
<TransparentLoadingMask visible={reloading} />
<ChartZoom
router={router}
period={period}
utc={utc}
start={start}
end={end}
>
{zoomRenderProps => (
<LineChart
{...zoomRenderProps}
grid={{left: '10px', right: '10px', top: '40px', bottom: '0px'}}
series={releasesSeries.map(series => ({
...series,
data: hideLastPoint ? series.data.slice(0, -1) : series.data,
}))}
yAxis={{
min: 0,
max: 100,
type: 'value',
interval: 10,
splitNumber: 10,
axisLabel: {
formatter: '{value}%',
},
}}
xAxis={{
show: true,
min: xAxisData[0],
max: xAxisData[numDataPoints - 1],
type: 'time',
}}
tooltip={{
formatter: seriesParams => {
const series = Array.isArray(seriesParams)
? seriesParams
: [seriesParams];
const timestamp = series[0].data[0];
const [first, second, third, ...rest] = series
.filter(s => s.data[1] > 0)
.sort((a, b) => b.data[1] - a.data[1]);
const restSum = rest.reduce((acc, s) => acc + s.data[1], 0);
const seriesToRender = compact([first, second, third]);
if (rest.length) {
seriesToRender.push({
seriesName: tn('%s Other', '%s Others', rest.length),
data: [timestamp, restSum],
marker:
'<span style="display:inline-block;margin-right:5px;border-radius:10px;width:10px;height:10px;"></span>',
});
}
if (!seriesToRender.length) {
return '<div/>';
}
const periodObj = parseStatsPeriod(interval) || {
periodLength: 'd',
period: '1',
};
const intervalStart = moment(timestamp).format('MMM D LT');
const intervalEnd = (
series[0].dataIndex === numDataPoints - 1
? moment(response?.end)
: moment(timestamp).add(
parseInt(periodObj.period, 10),
periodObj.periodLength as StatsPeriodType
)
).format('MMM D LT');
return [
'<div class="tooltip-series">',
seriesToRender
.map(
s =>
`<div><span class="tooltip-label">${
s.marker
}<strong>${
s.seriesName &&
truncationFormatter(s.seriesName, 32)
}</strong></span>${s.data[1].toFixed(2)}%</div>`
)
.join(''),
'</div>',
`<div class="tooltip-footer">${intervalStart} &mdash; ${intervalEnd}</div>`,
'<div class="tooltip-arrow"></div>',
].join('');
},
}}
onClick={this.handleClick}
/>
)}
</ChartZoom>
</TransitionChart>
</PanelBody>
<ChartFooter>
<InlineContainer>
<SectionHeading>
{tct('Total [display]', {
display:
activeDisplay === ReleasesDisplayOption.USERS
? 'Users'
: 'Sessions',
})}
</SectionHeading>
<SectionValue>
<Count value={totalCount || 0} />
</SectionValue>
</InlineContainer>
</ChartFooter>
</Panel>
);
}}
</SessionsRequest>
);
}
}
export default withApi(ReleasesAdoptionChart);
const ChartHeader = styled(HeaderTitleLegend)`
margin-bottom: ${space(1)};
`;
const ChartTitle = styled('header')`
display: flex;
flex-direction: row;
`;
const ChartFooter = styled(PanelFooter)`
display: flex;
align-items: center;
padding: ${space(1)} 20px;
`;