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

391 lines
11 KiB
TypeScript

import {createContext} from 'react';
import type {RouteComponentProps} from 'react-router';
import styled from '@emotion/styled';
import type {Location} from 'history';
import isEqual from 'lodash/isEqual';
import pick from 'lodash/pick';
import {Alert} from 'sentry/components/alert';
import DeprecatedAsyncComponent from 'sentry/components/deprecatedAsyncComponent';
import * as Layout from 'sentry/components/layouts/thirds';
import LoadingIndicator from 'sentry/components/loadingIndicator';
import NoProjectMessage from 'sentry/components/noProjectMessage';
import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container';
import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
import PickProjectToContinue from 'sentry/components/pickProjectToContinue';
import {PAGE_URL_PARAM, URL_PARAM} from 'sentry/constants/pageFilters';
import {IconInfo} from 'sentry/icons';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {
Deploy,
Organization,
PageFilters,
ReleaseMeta,
ReleaseProject,
ReleaseWithHealth,
SessionApiResponse,
} from 'sentry/types';
import {SessionFieldWithOperation} from 'sentry/types';
import {formatVersion} from 'sentry/utils/formatters';
import type {WithRouteAnalyticsProps} from 'sentry/utils/routeAnalytics/withRouteAnalytics';
import withRouteAnalytics from 'sentry/utils/routeAnalytics/withRouteAnalytics';
import routeTitleGen from 'sentry/utils/routeTitle';
import {getCount} from 'sentry/utils/sessions';
import withOrganization from 'sentry/utils/withOrganization';
import withPageFilters from 'sentry/utils/withPageFilters';
import DeprecatedAsyncView from 'sentry/views/deprecatedAsyncView';
import type {ReleaseBounds} from '../utils';
import {getReleaseBounds, searchReleaseVersion} from '../utils';
import ReleaseHeader from './header/releaseHeader';
type ReleaseContextType = {
deploys: Deploy[];
hasHealthData: boolean;
project: Required<ReleaseProject>;
refetchData: () => void;
release: ReleaseWithHealth;
releaseBounds: ReleaseBounds;
releaseMeta: ReleaseMeta;
};
const ReleaseContext = createContext<ReleaseContextType>({} as ReleaseContextType);
type RouteParams = {
orgId: string;
release: string;
};
type Props = RouteComponentProps<RouteParams, {}> &
WithRouteAnalyticsProps & {
organization: Organization;
releaseMeta: ReleaseMeta;
selection: PageFilters;
};
type State = {
deploys: Deploy[];
release: ReleaseWithHealth;
sessions: SessionApiResponse | null;
} & DeprecatedAsyncView['state'];
class ReleasesDetail extends DeprecatedAsyncView<Props, State> {
shouldReload = true;
getTitle() {
const {params, organization, selection} = this.props;
const {release} = this.state;
// The release details page will always have only one project selected
const project = release?.projects.find(p => p.id === selection.projects[0]);
return routeTitleGen(
t('Release %s', formatVersion(params.release)),
organization.slug,
false,
project?.slug
);
}
getDefaultState() {
return {
...super.getDefaultState(),
deploys: [],
sessions: null,
};
}
componentDidUpdate(prevProps: Props, prevState: State) {
const {organization, params, location} = this.props;
if (
prevProps.params.release !== params.release ||
prevProps.organization.slug !== organization.slug ||
!isEqual(
this.pickLocationQuery(prevProps.location),
this.pickLocationQuery(location)
)
) {
super.componentDidUpdate(prevProps, prevState);
}
}
getEndpoints(): ReturnType<DeprecatedAsyncComponent['getEndpoints']> {
const {organization, location, params, releaseMeta} = this.props;
const basePath = `/organizations/${organization.slug}/releases/${encodeURIComponent(
params.release
)}/`;
const endpoints: ReturnType<DeprecatedAsyncView['getEndpoints']> = [
[
'release',
basePath,
{
query: {
adoptionStages: 1,
...normalizeDateTimeParams(this.pickLocationQuery(location)),
},
},
],
];
if (releaseMeta.deployCount > 0) {
endpoints.push(['deploys', `${basePath}deploys/`]);
}
// Used to figure out if the release has any health data
endpoints.push([
'sessions',
`/organizations/${organization.slug}/sessions/`,
{
query: {
project: location.query.project,
environment: location.query.environment ?? [],
query: searchReleaseVersion(params.release),
field: 'sum(session)',
statsPeriod: '90d',
interval: '1d',
},
},
{
allowError: error => error.status === 400,
},
]);
return endpoints;
}
pickLocationQuery(location: Location) {
return pick(location.query, [
...Object.values(URL_PARAM),
...Object.values(PAGE_URL_PARAM),
]);
}
renderError(...args) {
const possiblyWrongProject = Object.values(this.state.errors).find(
e => e?.status === 404 || e?.status === 403
);
if (possiblyWrongProject) {
return (
<Layout.Page>
<Alert type="error" showIcon>
{t('This release may not be in your selected project.')}
</Alert>
</Layout.Page>
);
}
return super.renderError(...args);
}
renderLoading() {
return (
<Layout.Page>
<LoadingIndicator />
</Layout.Page>
);
}
renderBody() {
const {organization, location, selection, releaseMeta} = this.props;
const {release, deploys, sessions, reloading} = this.state;
const project = release?.projects.find(p => p.id === selection.projects[0]);
const releaseBounds = getReleaseBounds(release);
if (!project || !release) {
if (reloading) {
return <LoadingIndicator />;
}
return null;
}
return (
<Layout.Page>
<NoProjectMessage organization={organization}>
<ReleaseHeader
location={location}
organization={organization}
release={release}
project={project}
releaseMeta={releaseMeta}
refetchData={this.fetchData}
/>
<ReleaseContext.Provider
value={{
release,
project,
deploys,
releaseMeta,
refetchData: this.fetchData,
hasHealthData:
getCount(sessions?.groups, SessionFieldWithOperation.SESSIONS) > 0,
releaseBounds,
}}
>
{this.props.children}
</ReleaseContext.Provider>
</NoProjectMessage>
</Layout.Page>
);
}
}
// ========================================================================
// RELEASE DETAIL CONTAINER
// ========================================================================
type ReleasesDetailContainerProps = Omit<Props, 'releaseMeta'>;
type ReleasesDetailContainerState = {
releaseMeta: ReleaseMeta | null;
} & DeprecatedAsyncComponent['state'];
class ReleasesDetailContainer extends DeprecatedAsyncComponent<
ReleasesDetailContainerProps,
ReleasesDetailContainerState
> {
shouldReload = true;
getEndpoints(): ReturnType<DeprecatedAsyncComponent['getEndpoints']> {
const {organization, params} = this.props;
// fetch projects this release belongs to
return [
[
'releaseMeta',
`/organizations/${organization.slug}/releases/${encodeURIComponent(
params.release
)}/meta/`,
],
];
}
componentDidMount() {
super.componentDidMount();
this.removeGlobalDateTimeFromUrl();
this.props.setRouteAnalyticsParams({release: this.props.params.release});
}
componentDidUpdate(
prevProps: ReleasesDetailContainerProps,
prevState: ReleasesDetailContainerState
) {
const {organization, params} = this.props;
this.removeGlobalDateTimeFromUrl();
if (
prevProps.params.release !== params.release ||
prevProps.organization.slug !== organization.slug
) {
this.props.setRouteAnalyticsParams({release: this.props.params.release});
super.componentDidUpdate(prevProps, prevState);
}
}
removeGlobalDateTimeFromUrl() {
const {router, location} = this.props;
const {start, end, statsPeriod, utc, ...restQuery} = location.query;
if (start || end || statsPeriod || utc) {
router.replace({
...location,
query: restQuery,
});
}
}
renderError(...args) {
const has404Errors = Object.values(this.state.errors).find(e => e?.status === 404);
if (has404Errors) {
// This catches a 404 coming from the release endpoint and displays a custom error message.
return (
<Layout.Page withPadding>
<Alert type="error" showIcon>
{t('This release could not be found.')}
</Alert>
</Layout.Page>
);
}
return super.renderError(...args);
}
isProjectMissingInUrl() {
const projectId = this.props.location.query.project;
return !projectId || typeof projectId !== 'string';
}
renderLoading() {
return (
<Layout.Page>
<LoadingIndicator />
</Layout.Page>
);
}
renderProjectsFooterMessage() {
return (
<ProjectsFooterMessage>
<IconInfo size="xs" /> {t('Only projects with this release are visible.')}
</ProjectsFooterMessage>
);
}
renderBody() {
const {organization, params, router} = this.props;
const {releaseMeta} = this.state;
if (!releaseMeta) {
return null;
}
const {projects} = releaseMeta;
if (this.isProjectMissingInUrl()) {
return (
<PickProjectToContinue
projects={projects.map(({id, slug}) => ({
id: String(id),
slug,
}))}
router={router}
nextPath={{
pathname: `/organizations/${organization.slug}/releases/${encodeURIComponent(
params.release
)}/`,
}}
noProjectRedirectPath={`/organizations/${organization.slug}/releases/`}
/>
);
}
return (
<PageFiltersContainer
shouldForceProject={projects.length === 1}
forceProject={
projects.length === 1 ? {...projects[0], id: String(projects[0].id)} : undefined
}
specificProjectSlugs={projects.map(p => p.slug)}
>
<ReleasesDetail {...this.props} releaseMeta={releaseMeta} />
</PageFiltersContainer>
);
}
}
const ProjectsFooterMessage = styled('div')`
display: grid;
align-items: center;
grid-template-columns: min-content 1fr;
gap: ${space(1)};
`;
export {ReleaseContext, ReleasesDetailContainer};
export default withRouteAnalytics(
withPageFilters(withOrganization(ReleasesDetailContainer))
);