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 (
{t('User Misery')}
{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)}; `;