import {Fragment} from 'react'; import * as React 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 AsyncComponent 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, IconWarning} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import overflowEllipsis from 'sentry/styles/overflowEllipsis'; 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/discover/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); 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 ( {t(span.title)} ); }); const webVitalsRenderer = webVitals.map( vital => allReleasesTableData?.meta && getFieldRenderer(vital.field, allReleasesTableData?.meta) ); const spansRenderer = spans.map( span => allReleasesTableData?.meta && getFieldRenderer(span.field, allReleasesTableData?.meta) ); 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 ( {t(span.title)} ); }); const apdexRenderer = allReleasesTableData?.meta && getFieldRenderer('apdex', allReleasesTableData.meta); const spansRenderer = spans.map( span => allReleasesTableData?.meta && getFieldRenderer(span.field, allReleasesTableData?.meta) ); 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) ); 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 && ( } system> {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} ); } type Props = AsyncComponent['props'] & { 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[2]}) { 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)}; ${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)}; `;