import {Fragment} from 'react';
import {Link} from 'react-router';
import {css} from '@emotion/react';
import styled from '@emotion/styled';
import {Location} from 'history';
import Alert from 'sentry/components/alert';
import {AsyncComponentProps} from 'sentry/components/asyncComponent';
import LoadingIndicator from 'sentry/components/loadingIndicator';
import NotAvailable from 'sentry/components/notAvailable';
import {PanelItem} from 'sentry/components/panels';
import PanelTable from 'sentry/components/panels/panelTable';
import {IconArrow} from 'sentry/icons';
import {t, tct} from 'sentry/locale';
import space from 'sentry/styles/space';
import {Organization, ReleaseProject} from 'sentry/types';
import DiscoverQuery, {TableData} from 'sentry/utils/discover/discoverQuery';
import EventView from 'sentry/utils/discover/eventView';
import {getFieldRenderer} from 'sentry/utils/discover/fieldRenderers';
import {MobileVital, WebVital} from 'sentry/utils/fields';
import {
MOBILE_VITAL_DETAILS,
WEB_VITAL_DETAILS,
} from 'sentry/utils/performance/vitals/constants';
import {PROJECT_PERFORMANCE_TYPE} from 'sentry/views/performance/utils';
type PerformanceCardTableProps = {
allReleasesEventView: EventView;
allReleasesTableData: TableData | null;
isLoading: boolean;
location: Location;
organization: Organization;
performanceType: string;
project: ReleaseProject;
releaseEventView: EventView;
thisReleaseTableData: TableData | null;
};
function PerformanceCardTable({
organization,
location,
project,
releaseEventView,
allReleasesTableData,
thisReleaseTableData,
performanceType,
isLoading,
}: PerformanceCardTableProps) {
const miseryRenderer =
allReleasesTableData?.meta &&
getFieldRenderer('user_misery()', allReleasesTableData.meta, false);
function renderChange(
allReleasesScore: number,
thisReleaseScore: number,
meta: string
) {
if (allReleasesScore === undefined || thisReleaseScore === undefined) {
return ;
}
const trend = allReleasesScore - thisReleaseScore;
const trendSeconds = trend >= 1000 ? trend / 1000 : trend;
const trendPercentage = (allReleasesScore - thisReleaseScore) * 100;
const valPercentage = Math.round(Math.abs(trendPercentage));
const val = Math.abs(trendSeconds).toFixed(2);
if (trend === 0) {
return {`0${meta === 'duration' ? 'ms' : '%'}`} ;
}
return (
= 0 ? 'success' : 'error'}>
{`${meta === 'duration' ? val : valPercentage}${
meta === 'duration' ? (trend >= 1000 ? 's' : 'ms') : '%'
}`}
= 0 ? 'success' : 'error'}
direction={trend >= 0 ? 'down' : 'up'}
size="xs"
/>
);
}
function userMiseryTrend() {
const allReleasesUserMisery = allReleasesTableData?.data?.[0]?.['user_misery()'];
const thisReleaseUserMisery = thisReleaseTableData?.data?.[0]?.['user_misery()'];
return (
{renderChange(
allReleasesUserMisery as number,
thisReleaseUserMisery as number,
'number' as string
)}
);
}
function renderFrontendPerformance() {
const webVitals = [
{title: WebVital.FCP, field: 'p75(measurements.fcp)'},
{title: WebVital.FID, field: 'p75(measurements.fid)'},
{title: WebVital.LCP, field: 'p75(measurements.lcp)'},
{title: WebVital.CLS, field: 'p75(measurements.cls)'},
];
const spans = [
{title: 'HTTP', column: 'p75(spans.http)', field: 'p75(spans.http)'},
{title: 'Browser', column: 'p75(spans.browser)', field: 'p75(spans.browser)'},
{title: 'Resource', column: 'p75(spans.resource)', field: 'p75(spans.resource)'},
];
const webVitalTitles = webVitals.map((vital, idx) => {
const newView = releaseEventView.withColumns([
{kind: 'field', field: `p75(${vital.title})`},
]);
return (
{WEB_VITAL_DETAILS[vital.title].name} (
{WEB_VITAL_DETAILS[vital.title].acronym})
);
});
const spanTitles = spans.map((span, idx) => {
const newView = releaseEventView.withColumns([
{kind: 'field', field: `${span.column}`},
]);
return (
{span.title}
);
});
const webVitalsRenderer = webVitals.map(
vital =>
allReleasesTableData?.meta &&
getFieldRenderer(vital.field, allReleasesTableData?.meta, false)
);
const spansRenderer = spans.map(
span =>
allReleasesTableData?.meta &&
getFieldRenderer(span.field, allReleasesTableData?.meta, false)
);
const webReleaseTrend = webVitals.map(vital => {
return {
allReleasesRow: {
data: allReleasesTableData?.data?.[0]?.[vital.field],
meta: allReleasesTableData?.meta?.[vital.field],
},
thisReleaseRow: {
data: thisReleaseTableData?.data?.[0]?.[vital.field],
meta: thisReleaseTableData?.meta?.[vital.field],
},
};
});
const spansReleaseTrend = spans.map(span => {
return {
allReleasesRow: {
data: allReleasesTableData?.data?.[0]?.[span.field],
meta: allReleasesTableData?.meta?.[span.field],
},
thisReleaseRow: {
data: thisReleaseTableData?.data?.[0]?.[span.field],
meta: thisReleaseTableData?.meta?.[span.field],
},
};
});
const emptyColumn = (
{webVitals.map((vital, index) => (
{ }
))}
{spans.map((span, index) => (
{ }
))}
);
return (
{t('User Misery')}
{t('Web Vitals')}
{webVitalTitles}
{t('Span Operations')}
{spanTitles}
{allReleasesTableData?.data.length === 0
? emptyColumn
: allReleasesTableData?.data.map((dataRow, idx) => {
const allReleasesMisery = miseryRenderer?.(dataRow, {
organization,
location,
});
const allReleasesWebVitals = webVitalsRenderer?.map(renderer =>
renderer?.(dataRow, {organization, location})
);
const allReleasesSpans = spansRenderer?.map(renderer =>
renderer?.(dataRow, {organization, location})
);
return (
{allReleasesMisery}
{allReleasesWebVitals.map(webVital => webVital)}
{allReleasesSpans.map(span => span)}
);
})}
{thisReleaseTableData?.data.length === 0
? emptyColumn
: thisReleaseTableData?.data.map((dataRow, idx) => {
const thisReleasesMisery = miseryRenderer?.(dataRow, {
organization,
location,
});
const thisReleasesWebVitals = webVitalsRenderer?.map(renderer =>
renderer?.(dataRow, {organization, location})
);
const thisReleasesSpans = spansRenderer?.map(renderer =>
renderer?.(dataRow, {organization, location})
);
return (
{thisReleasesMisery}
{thisReleasesWebVitals.map(webVital => webVital)}
{thisReleasesSpans.map(span => span)}
);
})}
{userMiseryTrend()}
{webReleaseTrend?.map(row =>
renderChange(
row.allReleasesRow?.data as number,
row.thisReleaseRow?.data as number,
row.allReleasesRow?.meta as string
)
)}
{spansReleaseTrend?.map(row =>
renderChange(
row.allReleasesRow?.data as number,
row.thisReleaseRow?.data as number,
row.allReleasesRow?.meta as string
)
)}
);
}
function renderBackendPerformance() {
const spans = [
{title: 'HTTP', column: 'p75(spans.http)', field: 'p75_spans_http'},
{title: 'DB', column: 'p75(spans.db)', field: 'p75_spans_db'},
];
const spanTitles = spans.map((span, idx) => {
const newView = releaseEventView.withColumns([
{kind: 'field', field: `${span.column}`},
]);
return (
{span.title}
);
});
const apdexRenderer =
allReleasesTableData?.meta &&
getFieldRenderer('apdex', allReleasesTableData.meta, false);
const spansRenderer = spans.map(
span =>
allReleasesTableData?.meta &&
getFieldRenderer(span.field, allReleasesTableData?.meta, false)
);
const spansReleaseTrend = spans.map(span => {
return {
allReleasesRow: {
data: allReleasesTableData?.data?.[0]?.[span.field],
meta: allReleasesTableData?.meta?.[span.field],
},
thisReleaseRow: {
data: thisReleaseTableData?.data?.[0]?.[span.field],
meta: thisReleaseTableData?.meta?.[span.field],
},
};
});
function apdexTrend() {
const allReleasesApdex = allReleasesTableData?.data?.[0]?.apdex;
const thisReleaseApdex = thisReleaseTableData?.data?.[0]?.apdex;
return (
{renderChange(
allReleasesApdex as number,
thisReleaseApdex as number,
'string' as string
)}
);
}
const emptyColumn = (
{spans.map((span, index) => (
{ }
))}
);
return (
{t('User Misery')}
{t('Apdex')}
{t('Span Operations')}
{spanTitles}
{allReleasesTableData?.data.length === 0
? emptyColumn
: allReleasesTableData?.data.map((dataRow, idx) => {
const allReleasesMisery = miseryRenderer?.(dataRow, {
organization,
location,
});
const allReleasesApdex = apdexRenderer?.(dataRow, {organization, location});
const allReleasesSpans = spansRenderer?.map(renderer =>
renderer?.(dataRow, {organization, location})
);
return (
{allReleasesMisery}
{allReleasesApdex}
{allReleasesSpans.map(span => span)}
);
})}
{thisReleaseTableData?.data.length === 0
? emptyColumn
: thisReleaseTableData?.data.map((dataRow, idx) => {
const thisReleasesMisery = miseryRenderer?.(dataRow, {
organization,
location,
});
const thisReleasesApdex = apdexRenderer?.(dataRow, {
organization,
location,
});
const thisReleasesSpans = spansRenderer?.map(renderer =>
renderer?.(dataRow, {organization, location})
);
return (
{thisReleasesMisery}
{thisReleasesApdex}
{thisReleasesSpans.map(span => span)}
);
})}
{userMiseryTrend()}
{apdexTrend()}
{spansReleaseTrend?.map(row =>
renderChange(
row.allReleasesRow?.data as number,
row.thisReleaseRow?.data as number,
row.allReleasesRow?.meta as string
)
)}
);
}
function renderMobilePerformance() {
const mobileVitals = [
MobileVital.AppStartCold,
MobileVital.AppStartWarm,
MobileVital.FramesSlow,
MobileVital.FramesFrozen,
];
const mobileVitalTitles = mobileVitals.map(mobileVital => {
return (
{MOBILE_VITAL_DETAILS[mobileVital].name}
);
});
const mobileVitalFields = [
'p75(measurements.app_start_cold)',
'p75(measurements.app_start_warm)',
'p75(measurements.frames_slow)',
'p75(measurements.frames_frozen)',
];
const mobileVitalsRenderer = mobileVitalFields.map(
field =>
allReleasesTableData?.meta &&
getFieldRenderer(field, allReleasesTableData?.meta, false)
);
const mobileReleaseTrend = mobileVitalFields.map(field => {
return {
allReleasesRow: {
data: allReleasesTableData?.data?.[0]?.[field],
meta: allReleasesTableData?.meta?.[field],
},
thisReleaseRow: {
data: thisReleaseTableData?.data?.[0]?.[field],
meta: thisReleaseTableData?.meta?.[field],
},
};
});
const emptyColumn = (
{mobileVitalFields.map((vital, index) => (
))}
);
return (
{t('User Misery')}
{mobileVitalTitles}
{allReleasesTableData?.data.length === 0
? emptyColumn
: allReleasesTableData?.data.map((dataRow, idx) => {
const allReleasesMisery = miseryRenderer?.(dataRow, {
organization,
location,
});
const allReleasesMobile = mobileVitalsRenderer?.map(renderer =>
renderer?.(dataRow, {organization, location})
);
return (
{allReleasesMisery}
{allReleasesMobile.map((mobileVital, i) => (
{mobileVital}
))}
);
})}
{thisReleaseTableData?.data.length === 0
? emptyColumn
: thisReleaseTableData?.data.map((dataRow, idx) => {
const thisReleasesMisery = miseryRenderer?.(dataRow, {
organization,
location,
});
const thisReleasesMobile = mobileVitalsRenderer?.map(renderer =>
renderer?.(dataRow, {organization, location})
);
return (
{thisReleasesMisery}
{thisReleasesMobile.map((mobileVital, i) => (
{mobileVital}
))}
);
})}
{userMiseryTrend()}
{mobileReleaseTrend?.map((row, idx) => (
{renderChange(
row.allReleasesRow?.data as number,
row.thisReleaseRow?.data as number,
row.allReleasesRow?.meta as string
)}
))}
);
}
function renderUnknownPerformance() {
const emptyColumn = (
);
return (
{allReleasesTableData?.data.length === 0
? emptyColumn
: allReleasesTableData?.data.map((dataRow, idx) => {
const allReleasesMisery = miseryRenderer?.(dataRow, {
organization,
location,
});
return (
{allReleasesMisery}
);
})}
{thisReleaseTableData?.data.length === 0
? emptyColumn
: thisReleaseTableData?.data.map((dataRow, idx) => {
const thisReleasesMisery = miseryRenderer?.(dataRow, {
organization,
location,
});
return (
{thisReleasesMisery}
);
})}
{userMiseryTrend()}
);
}
const loader = ;
const platformPerformanceRender = {
[PROJECT_PERFORMANCE_TYPE.FRONTEND]: {
title: t('Frontend Performance'),
section: renderFrontendPerformance(),
},
[PROJECT_PERFORMANCE_TYPE.BACKEND]: {
title: t('Backend Performance'),
section: renderBackendPerformance(),
},
[PROJECT_PERFORMANCE_TYPE.MOBILE]: {
title: t('Mobile Performance'),
section: renderMobilePerformance(),
},
[PROJECT_PERFORMANCE_TYPE.ANY]: {
title: t('[Unknown] Performance'),
section: renderUnknownPerformance(),
},
};
const isUnknownPlatform = performanceType === PROJECT_PERFORMANCE_TYPE.ANY;
return (
{platformPerformanceRender[performanceType].title}
{isUnknownPlatform && (
{tct(
'For more performance metrics, specify which platform this project is using in [link]',
{
link: (
{t('project settings.')}
),
}
)}
)}
{t('Description')}
,
{t('All Releases')}
| ,
{t('This Release')}
| ,
{t('Change')}
| ,
]}
disablePadding
loader={loader}
disableTopBorder={isUnknownPlatform}
>
{platformPerformanceRender[performanceType].section}
);
}
interface Props extends AsyncComponentProps {
allReleasesEventView: EventView;
location: Location;
organization: Organization;
performanceType: string;
project: ReleaseProject;
releaseEventView: EventView;
}
function PerformanceCardTableWrapper({
organization,
project,
allReleasesEventView,
releaseEventView,
performanceType,
location,
}: Props) {
return (
{({isLoading, tableData: allReleasesTableData}) => (
{({isLoading: isReleaseLoading, tableData: thisReleaseTableData}) => (
)}
)}
);
}
export default PerformanceCardTableWrapper;
const emptyFieldCss = p => css`
color: ${p.theme.chartOther};
text-align: right;
`;
const StyledLoadingIndicator = styled(LoadingIndicator)`
margin: 70px auto;
`;
const HeadCellContainer = styled('div')`
font-size: ${p => p.theme.fontSizeExtraLarge};
padding: ${space(2)};
border-top: 1px solid ${p => p.theme.border};
border-left: 1px solid ${p => p.theme.border};
border-right: 1px solid ${p => p.theme.border};
border-top-left-radius: ${p => p.theme.borderRadius};
border-top-right-radius: ${p => p.theme.borderRadius};
`;
const StyledPanelTable = styled(PanelTable)<{disableTopBorder: boolean}>`
border-top-left-radius: 0;
border-top-right-radius: 0;
border-top: ${p => (p.disableTopBorder ? 'none' : `1px solid ${p.theme.border}`)};
@media (max-width: ${p => p.theme.breakpoints.large}) {
grid-template-columns: min-content 1fr 1fr 1fr;
}
`;
const StyledPanelItem = styled(PanelItem)`
display: block;
white-space: nowrap;
width: 100%;
`;
const SubTitle = styled('div')`
margin-left: ${space(3)};
`;
const TitleSpace = styled('div')`
height: 24px;
`;
const UserMiseryPanelItem = styled(PanelItem)`
justify-content: flex-end;
`;
const ApdexPanelItem = styled(PanelItem)`
text-align: right;
`;
const SingleEmptySubText = styled(PanelItem)`
display: block;
${emptyFieldCss}
`;
const MultipleEmptySubText = styled('div')`
${emptyFieldCss}
`;
const Cell = styled('div')<{align: 'left' | 'right'}>`
text-align: ${p => p.align};
margin-left: ${p => p.align === 'left' && space(2)};
padding-right: ${p => p.align === 'right' && space(2)};
${p => p.theme.overflowEllipsis}
`;
const StyledAlert = styled(Alert)`
border-top: 1px solid ${p => p.theme.border};
border-right: 1px solid ${p => p.theme.border};
border-left: 1px solid ${p => p.theme.border};
margin-bottom: 0;
`;
const StyledNotAvailable = styled(NotAvailable)`
text-align: right;
`;
const SubText = styled('div')`
color: ${p => p.theme.subText};
text-align: right;
`;
const TrendText = styled('div')<{color: string}>`
color: ${p => p.theme[p.color]};
text-align: right;
`;
const StyledIconArrow = styled(IconArrow)<{color: string}>`
color: ${p => p.theme[p.color]};
margin-left: ${space(0.5)};
`;