mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-10-30 14:49:14 +00:00
488 lines
13 KiB
TypeScript
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));
|