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

488 lines
13 KiB
TypeScript

import {Component, Fragment} from 'react';
import styled from '@emotion/styled';
import round from 'lodash/round';
import {loadStatsForProject} from 'sentry/actionCreators/projects';
import type {Client} from 'sentry/api';
import {Button} from 'sentry/components/button';
import IdBadge from 'sentry/components/idBadge';
import Link from 'sentry/components/links/link';
import Panel from 'sentry/components/panels/panel';
import Placeholder from 'sentry/components/placeholder';
import BookmarkStar from 'sentry/components/projects/bookmarkStar';
import QuestionTooltip from 'sentry/components/questionTooltip';
import ScoreCard, {
Score,
ScorePanel,
ScoreWrapper,
Title,
Trend,
} from 'sentry/components/scoreCard';
import {releaseHealth} from 'sentry/data/platformCategories';
import {IconArrow, IconSettings} from 'sentry/icons';
import {t} from 'sentry/locale';
import ProjectsStatsStore from 'sentry/stores/projectsStatsStore';
import {space} from 'sentry/styles/space';
import type {Organization, Project} from 'sentry/types';
import {defined} from 'sentry/utils';
import {DiscoverDatasets} from 'sentry/utils/discover/types';
import {formatAbbreviatedNumber} from 'sentry/utils/formatters';
import withApi from 'sentry/utils/withApi';
import withOrganization from 'sentry/utils/withOrganization';
import MissingReleasesButtons from 'sentry/views/projectDetail/missingFeatureButtons/missingReleasesButtons';
import {
CRASH_FREE_DECIMAL_THRESHOLD,
displayCrashFreePercent,
} from 'sentry/views/releases/utils';
import Chart from './chart';
import Deploys, {DeployRows, GetStarted, TextOverflow} from './deploys';
type Props = {
api: Client;
hasProjectAccess: boolean;
organization: Organization;
project: Project;
};
class ProjectCard extends Component<Props> {
componentDidMount() {
const {organization, project, api} = this.props;
// fetch project stats
loadStatsForProject(api, project.id, {
orgId: organization.slug,
projectId: project.id,
query: {
transactionStats: this.hasPerformance ? '1' : undefined,
dataset: DiscoverDatasets.METRICS_ENHANCED,
sessionStats: '1',
},
});
}
get hasPerformance() {
return this.props.organization.features.includes('performance-view');
}
get crashFreeTrend() {
const {currentCrashFreeRate, previousCrashFreeRate} =
this.props.project.sessionStats || {};
if (!defined(currentCrashFreeRate) || !defined(previousCrashFreeRate)) {
return undefined;
}
return round(
currentCrashFreeRate - previousCrashFreeRate,
currentCrashFreeRate > CRASH_FREE_DECIMAL_THRESHOLD ? 3 : 0
);
}
renderMissingFeatureCard() {
const {organization, project} = this.props;
if (project.platform && releaseHealth.includes(project.platform)) {
return (
<ScoreCard
title={t('Crash Free Sessions')}
score={<MissingReleasesButtons organization={organization} health />}
/>
);
}
return (
<ScoreCard
title={t('Crash Free Sessions')}
score={
<NotAvailable>
{t('Not Available')}
<QuestionTooltip
title={t('Release Health is not yet supported on this platform.')}
size="xs"
/>
</NotAvailable>
}
/>
);
}
renderTrend() {
const {currentCrashFreeRate} = this.props.project.sessionStats || {};
if (!defined(currentCrashFreeRate) || !defined(this.crashFreeTrend)) {
return null;
}
return (
<div>
{this.crashFreeTrend >= 0 ? (
<IconArrow direction="up" size="xs" />
) : (
<IconArrow direction="down" size="xs" />
)}
{`${formatAbbreviatedNumber(Math.abs(this.crashFreeTrend))}\u0025`}
</div>
);
}
render() {
const {organization, project, hasProjectAccess} = this.props;
const {stats, slug, transactionStats, sessionStats} = project;
const {hasHealthData, currentCrashFreeRate} = sessionStats || {};
const totalErrors = stats?.reduce((sum, [_, value]) => sum + value, 0) ?? 0;
const totalTransactions =
transactionStats?.reduce((sum, [_, value]) => sum + value, 0) ?? 0;
const zeroTransactions = totalTransactions === 0;
const hasFirstEvent = Boolean(project.firstEvent || project.firstTransactionEvent);
return (
<div data-test-id={slug}>
<StyledProjectCard>
<CardHeader>
<HeaderRow>
<StyledIdBadge
project={project}
avatarSize={32}
hideOverflow
disableLink={!hasProjectAccess}
/>
<SettingsButton
borderless
size="zero"
icon={<IconSettings color="subText" />}
aria-label={t('Settings')}
to={`/settings/${organization.slug}/projects/${slug}/`}
/>
<StyledBookmarkStar organization={organization} project={project} />
</HeaderRow>
<SummaryLinks data-test-id="summary-links">
{stats ? (
<Fragment>
<Link
data-test-id="project-errors"
to={`/organizations/${organization.slug}/issues/?project=${project.id}`}
>
{t('Errors: %s', formatAbbreviatedNumber(totalErrors))}
</Link>
{this.hasPerformance && (
<Fragment>
<em>|</em>
<TransactionsLink
data-test-id="project-transactions"
to={`/organizations/${organization.slug}/performance/?project=${project.id}`}
>
{t(
'Transactions: %s',
formatAbbreviatedNumber(totalTransactions)
)}
{zeroTransactions && (
<QuestionTooltip
title={t(
'Click here to learn more about performance monitoring'
)}
position="top"
size="xs"
/>
)}
</TransactionsLink>
</Fragment>
)}
</Fragment>
) : (
<SummaryLinkPlaceholder />
)}
</SummaryLinks>
</CardHeader>
<ChartContainer data-test-id="chart-container">
{stats ? (
<Chart
firstEvent={hasFirstEvent}
stats={stats}
transactionStats={transactionStats}
/>
) : (
<Placeholder height="150px" />
)}
</ChartContainer>
<FooterWrapper>
<ScoreCardWrapper>
{!stats ? (
<Fragment>
<ReleaseTitle>{t('Crash Free Sessions')}</ReleaseTitle>
<FooterPlaceholder />
</Fragment>
) : hasHealthData ? (
<ScoreCard
title={t('Crash Free Sessions')}
score={
defined(currentCrashFreeRate)
? displayCrashFreePercent(currentCrashFreeRate)
: '\u2014'
}
trend={this.renderTrend()}
trendStatus={
this.crashFreeTrend
? this.crashFreeTrend > 0
? 'good'
: 'bad'
: undefined
}
/>
) : (
this.renderMissingFeatureCard()
)}
</ScoreCardWrapper>
<DeploysWrapper>
<ReleaseTitle>{t('Latest Deploys')}</ReleaseTitle>
{stats ? <Deploys project={project} shorten /> : <FooterPlaceholder />}
</DeploysWrapper>
</FooterWrapper>
</StyledProjectCard>
</div>
);
}
}
type ContainerProps = {
api: Client;
hasProjectAccess: boolean;
organization: Organization;
project: Project;
};
type ContainerState = {
projectDetails: Project | null;
};
class ProjectCardContainer extends Component<ContainerProps, ContainerState> {
state = this.getInitialState();
getInitialState(): ContainerState {
const {project} = this.props;
const initialState = ProjectsStatsStore.getInitialState() || {};
return {
projectDetails: initialState[project.slug] || null,
};
}
componentWillUnmount() {
this.listeners.forEach(listener => {
if (typeof listener === 'function') {
listener();
}
});
}
listeners = [
ProjectsStatsStore.listen(itemsBySlug => {
this.onProjectStatsStoreUpdate(itemsBySlug);
}, undefined),
];
onProjectStatsStoreUpdate(itemsBySlug: (typeof ProjectsStatsStore)['itemsBySlug']) {
const {project} = this.props;
// Don't update state if we already have stats
if (!itemsBySlug[project.slug]) {
return;
}
if (itemsBySlug[project.slug] === this.state.projectDetails) {
return;
}
this.setState({
projectDetails: itemsBySlug[project.slug],
});
}
render() {
const {project, ...props} = this.props;
const {projectDetails} = this.state;
return (
<ProjectCard
{...props}
project={{
...project,
...(projectDetails || {}),
}}
/>
);
}
}
const ChartContainer = styled('div')`
position: relative;
background: ${p => p.theme.backgroundSecondary};
`;
const CardHeader = styled('div')`
margin: ${space(2)} 13px;
height: 32px;
`;
const SettingsButton = styled(Button)`
margin-left: auto;
margin-top: -${space(0.5)};
padding: 3px;
border-radius: 50%;
`;
const StyledBookmarkStar = styled(BookmarkStar)`
padding: 0;
`;
const HeaderRow = styled('div')`
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0 ${space(0.5)};
${p => p.theme.text.cardTitle};
color: ${p => p.theme.headingColor};
`;
const StyledProjectCard = styled(Panel)`
min-height: 330px;
margin: 0;
`;
const FooterWrapper = styled('div')`
display: grid;
grid-template-columns: 1fr 1fr;
div {
border: none;
box-shadow: none;
font-size: ${p => p.theme.fontSizeMedium};
padding: 0;
}
`;
const ScoreCardWrapper = styled('div')`
margin: ${space(2)} 0 0 ${space(2)};
${ScorePanel} {
min-height: auto;
}
${Title} {
color: ${p => p.theme.gray300};
}
${ScoreWrapper} {
flex-direction: column;
align-items: flex-start;
}
${Score} {
font-size: 28px;
}
${Trend} {
margin-left: 0;
margin-top: ${space(0.5)};
}
`;
const DeploysWrapper = styled('div')`
margin-top: ${space(2)};
${GetStarted} {
display: block;
height: 100%;
}
${TextOverflow} {
display: grid;
grid-template-columns: 1fr 1fr;
grid-column-gap: ${space(1)};
div {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
a {
display: grid;
}
}
${DeployRows} {
grid-template-columns: 2fr auto;
margin-right: ${space(2)};
height: auto;
svg {
display: none;
}
}
`;
const ReleaseTitle = styled('span')`
color: ${p => p.theme.gray300};
font-weight: 600;
`;
const StyledIdBadge = styled(IdBadge)`
overflow: hidden;
white-space: nowrap;
flex-shrink: 1;
& div {
align-items: flex-start;
}
& span {
padding: 0;
position: relative;
top: -1px;
}
`;
const SummaryLinks = styled('div')`
display: flex;
position: relative;
top: -${space(2)};
align-items: center;
font-weight: 400;
color: ${p => p.theme.subText};
font-size: ${p => p.theme.fontSizeSmall};
/* Need to offset for the project icon and margin */
margin-left: 40px;
a {
color: ${p => p.theme.subText};
:hover {
color: ${p => p.theme.linkHoverColor};
}
}
em {
font-style: normal;
margin: 0 ${space(0.5)};
}
`;
const TransactionsLink = styled(Link)`
display: flex;
align-items: center;
justify-content: space-between;
> span {
margin-left: ${space(0.5)};
}
`;
const NotAvailable = styled('div')`
font-size: ${p => p.theme.fontSizeMedium};
font-weight: normal;
display: grid;
grid-template-columns: auto auto;
gap: ${space(0.5)};
align-items: center;
`;
const SummaryLinkPlaceholder = styled(Placeholder)`
height: 15px;
width: 180px;
margin-top: ${space(0.75)};
margin-bottom: ${space(0.5)};
`;
const FooterPlaceholder = styled(Placeholder)`
height: 40px;
width: auto;
margin-right: ${space(2)};
`;
export {ProjectCard};
export default withOrganization(withApi(ProjectCardContainer));