import {Fragment} from 'react'; import styled from '@emotion/styled'; import * as Sentry from '@sentry/react'; import type {Location} from 'history'; import Card from 'sentry/components/card'; import EventsRequest from 'sentry/components/charts/eventsRequest'; import {HeaderTitle} from 'sentry/components/charts/styles'; import {getInterval} from 'sentry/components/charts/utils'; import EmptyStateWarning from 'sentry/components/emptyStateWarning'; import Link from 'sentry/components/links/link'; import Placeholder from 'sentry/components/placeholder'; import QuestionTooltip from 'sentry/components/questionTooltip'; import {Sparklines} from 'sentry/components/sparklines'; import SparklinesLine from 'sentry/components/sparklines/line'; import {Tooltip} from 'sentry/components/tooltip'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type {Organization} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; import {defined} from 'sentry/utils'; import toArray from 'sentry/utils/array/toArray'; import {getUtcToLocalDateObject} from 'sentry/utils/dates'; import DiscoverQuery from 'sentry/utils/discover/discoverQuery'; import type EventView from 'sentry/utils/discover/eventView'; import type {Column} from 'sentry/utils/discover/fields'; import {generateFieldAsString, getAggregateAlias} from 'sentry/utils/discover/fields'; import {WebVital} from 'sentry/utils/fields'; import {WEB_VITAL_DETAILS} from 'sentry/utils/performance/vitals/constants'; import type { VitalData, VitalsData, } from 'sentry/utils/performance/vitals/vitalsCardsDiscoverQuery'; import VitalsCardsDiscoverQuery from 'sentry/utils/performance/vitals/vitalsCardsDiscoverQuery'; import {decodeList} from 'sentry/utils/queryString'; import theme from 'sentry/utils/theme'; import useApi from 'sentry/utils/useApi'; import ColorBar from '../vitalDetail/colorBar'; import { vitalAbbreviations, vitalDetailRouteWithQuery, vitalMap, VitalState, vitalStateColors, } from '../vitalDetail/utils'; import VitalPercents from '../vitalDetail/vitalPercents'; import { getDefaultDisplayFieldForPlatform, LandingDisplayField, vitalCardDetails, } from './utils'; type FrontendCardsProps = { eventView: EventView; location: Location; organization: Organization; projects: Project[]; frontendOnly?: boolean; }; export function FrontendCards(props: FrontendCardsProps) { const {eventView, location, organization, projects, frontendOnly = false} = props; if (frontendOnly) { const defaultDisplay = getDefaultDisplayFieldForPlatform(projects, eventView); const isFrontend = defaultDisplay === LandingDisplayField.FRONTEND_PAGELOAD; if (!isFrontend) { return null; } } const vitals = [WebVital.FCP, WebVital.LCP, WebVital.FID, WebVital.CLS]; return ( <VitalsCardsDiscoverQuery eventView={eventView} location={location} orgSlug={organization.slug} vitals={vitals} > {({isLoading, vitalsData}) => { return ( <VitalsContainer> {vitals.map(vital => { const target = vitalDetailRouteWithQuery({ orgSlug: organization.slug, query: eventView.generateQueryStringObject(), vitalName: vital, projectID: decodeList(location.query.project), }); const value = isLoading ? '\u2014' : getP75(vitalsData?.[vital] ?? null, vital); const chart = ( <VitalBarContainer> <VitalBar isLoading={isLoading} vital={vital} data={vitalsData} /> </VitalBarContainer> ); return ( <Link key={vital} to={target} data-test-id={`vitals-linked-card-${vitalAbbreviations[vital]}`} > <VitalCard title={vitalMap[vital] ?? ''} tooltip={WEB_VITAL_DETAILS[vital].description ?? ''} value={isLoading ? '\u2014' : value} chart={chart} minHeight={150} /> </Link> ); })} </VitalsContainer> ); }} </VitalsCardsDiscoverQuery> ); } const VitalBarContainer = styled('div')` margin-top: ${space(1.5)}; `; type BaseCardsProps = { eventView: EventView; location: Location; organization: Organization; }; type GenericCardsProps = BaseCardsProps & { functions: Column[]; }; function GenericCards(props: GenericCardsProps) { const api = useApi(); const {eventView: baseEventView, location, organization, functions} = props; const {query} = location; const eventView = baseEventView.withColumns(functions); // construct request parameters for fetching chart data const globalSelection = eventView.getPageFilters(); const start = globalSelection.datetime.start ? getUtcToLocalDateObject(globalSelection.datetime.start) : undefined; const end = globalSelection.datetime.end ? getUtcToLocalDateObject(globalSelection.datetime.end) : undefined; const interval = typeof query.sparkInterval === 'string' ? query.sparkInterval : getInterval( { start: start || null, end: end || null, period: globalSelection.datetime.period, }, 'low' ); const apiPayload = eventView.getEventsAPIPayload(location); return ( <DiscoverQuery location={location} eventView={eventView} orgSlug={organization.slug} limit={1} referrer="api.performance.vitals-cards" > {({isLoading: isSummaryLoading, tableData}) => ( <EventsRequest api={api} organization={organization} period={globalSelection.datetime.period} project={globalSelection.projects} environment={globalSelection.environments} team={apiPayload.team} start={start} end={end} interval={interval} query={apiPayload.query} includePrevious={false} yAxis={eventView.getFields()} partial > {({results}) => { const series = results?.reduce((allSeries, oneSeries) => { allSeries[oneSeries.seriesName] = oneSeries.data.map(item => item.value); return allSeries; }, {}); const details = vitalCardDetails(organization); return ( <VitalsContainer> {functions.map(func => { let fieldName = generateFieldAsString(func); if (fieldName.includes('apdex')) { // Replace apdex with explicit thresholds with a generic one for lookup fieldName = 'apdex()'; } const cardDetail = details[fieldName]; if (!cardDetail) { Sentry.captureMessage(`Missing field '${fieldName}' in vital cards.`); return null; } const {title, tooltip, formatter} = cardDetail; const alias = getAggregateAlias(fieldName); const rawValue = tableData?.data?.[0]?.[alias] as number; const data = series?.[fieldName]; const value = isSummaryLoading || !defined(rawValue) ? '\u2014' : formatter(rawValue); const chart = <SparklineChart data={data} />; return ( <VitalCard key={fieldName} title={title} tooltip={tooltip} value={value} chart={chart} horizontal minHeight={96} isNotInteractive /> ); })} </VitalsContainer> ); }} </EventsRequest> )} </DiscoverQuery> ); } function _BackendCards(props: BaseCardsProps) { const functions: Column[] = [ { kind: 'function', function: ['p75', 'transaction.duration', undefined, undefined], }, {kind: 'function', function: ['tpm', '', undefined, undefined]}, {kind: 'function', function: ['failure_rate', '', undefined, undefined]}, { kind: 'function', function: ['apdex', '', undefined, undefined], }, ]; return <GenericCards {...props} functions={functions} />; } export const BackendCards = _BackendCards; type MobileCardsProps = BaseCardsProps & { showStallPercentage: boolean; }; function _MobileCards(props: MobileCardsProps) { const functions: Column[] = [ { kind: 'function', function: ['p75', 'measurements.app_start_cold', undefined, undefined], }, { kind: 'function', function: ['p75', 'measurements.app_start_warm', undefined, undefined], }, { kind: 'function', function: ['p75', 'measurements.frames_slow_rate', undefined, undefined], }, { kind: 'function', function: ['p75', 'measurements.frames_frozen_rate', undefined, undefined], }, ]; if (props.showStallPercentage) { functions.push({ kind: 'function', function: ['p75', 'measurements.stall_percentage', undefined, undefined], }); } return <GenericCards {...props} functions={functions} />; } export const MobileCards = _MobileCards; type SparklineChartProps = { data: number[]; }; function SparklineChart(props: SparklineChartProps) { const {data} = props; const width = 150; const height = 24; const lineColor = theme.charts.getColorPalette(1)[0]; return ( <SparklineContainer data-test-id="sparkline" width={width} height={height}> <Sparklines data={data} width={width} height={height}> <SparklinesLine style={{stroke: lineColor, fill: 'none', strokeWidth: 3}} /> </Sparklines> </SparklineContainer> ); } type SparklineContainerProps = { height: number; width: number; }; const SparklineContainer = styled('div')<SparklineContainerProps>` flex-grow: 4; max-height: ${p => p.height}px; max-width: ${p => p.width}px; margin: ${space(1)} ${space(0)} ${space(0.5)} ${space(3)}; `; const VitalsContainer = styled('div')` display: grid; grid-template-columns: 1fr; grid-column-gap: ${space(2)}; @media (min-width: ${p => p.theme.breakpoints.small}) { grid-template-columns: repeat(2, 1fr); } @media (min-width: ${p => p.theme.breakpoints.large}) { grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); } `; type VitalBarProps = { data: VitalsData | null; isLoading: boolean; vital: WebVital | WebVital[]; barHeight?: number; showBar?: boolean; showDetail?: boolean; showDurationDetail?: boolean; showStates?: boolean; showTooltip?: boolean; showVitalPercentNames?: boolean; showVitalThresholds?: boolean; value?: string; }; export function VitalBar(props: VitalBarProps) { const { isLoading, data, vital, value, showBar = true, showStates = false, showDurationDetail = false, showVitalPercentNames = true, showVitalThresholds = false, showDetail = true, showTooltip = false, barHeight, } = props; if (isLoading) { return showStates ? <Placeholder height="48px" /> : null; } const emptyState = showStates ? ( <EmptyVitalBar small>{t('No vitals found')}</EmptyVitalBar> ) : null; if (!data) { return emptyState; } const counts: Pick<VitalData, 'poor' | 'meh' | 'good' | 'total'> = { poor: 0, meh: 0, good: 0, total: 0, }; const vitals = toArray(vital); vitals.forEach(vitalName => { const c = data?.[vitalName] ?? {}; Object.keys(counts).forEach(countKey => (counts[countKey] += c[countKey])); }); if (!counts.total) { return emptyState; } const p75: React.ReactNode = Array.isArray(vital) ? null : value ?? getP75(data?.[vital] ?? null, vital); const percents = getPercentsFromCounts(counts); const colorStops = getColorStopsFromPercents(percents); return ( <Fragment> {showBar && ( <StyledTooltip title={ <VitalPercents vital={vital} percents={percents} showVitalPercentNames={false} showVitalThresholds={false} hideTooltips={showTooltip} /> } disabled={!showTooltip} position="bottom" > <ColorBar barHeight={barHeight} colorStops={colorStops} /> </StyledTooltip> )} {showDetail && ( <BarDetail> {showDurationDetail && p75 && ( <div> {t('The p75 for all transactions is ')} <strong>{p75}</strong> </div> )} <VitalPercents vital={vital} percents={percents} showVitalPercentNames={showVitalPercentNames} showVitalThresholds={showVitalThresholds} /> </BarDetail> )} </Fragment> ); } const EmptyVitalBar = styled(EmptyStateWarning)` height: 48px; padding: ${space(1.5)} 15%; `; type VitalCardProps = { chart: React.ReactNode; title: string; tooltip: string; value: string | number; horizontal?: boolean; isNotInteractive?: boolean; minHeight?: number; }; function VitalCard(props: VitalCardProps) { const {chart, minHeight, horizontal, title, tooltip, value, isNotInteractive} = props; return ( <StyledCard interactive={!isNotInteractive} minHeight={minHeight}> <HeaderTitle> <OverflowEllipsis>{title}</OverflowEllipsis> <QuestionTooltip size="sm" position="top" title={tooltip} /> </HeaderTitle> <CardContent horizontal={horizontal}> <CardValue>{value}</CardValue> {chart} </CardContent> </StyledCard> ); } const CardContent = styled('div')<{horizontal?: boolean}>` width: 100%; display: flex; flex-direction: ${p => (p.horizontal ? 'row' : 'column')}; justify-content: space-between; `; const StyledCard = styled(Card)<{minHeight?: number}>` color: ${p => p.theme.textColor}; padding: ${space(2)} ${space(3)}; align-items: flex-start; margin-bottom: ${space(2)}; ${p => p.minHeight && `min-height: ${p.minHeight}px`}; `; const StyledTooltip = styled(Tooltip)` width: 100%; `; function getP75(data: VitalData | null, vitalName: WebVital): string { const p75 = data?.p75 ?? null; if (p75 === null) { return '\u2014'; } return vitalName === WebVital.CLS ? p75.toFixed(2) : `${p75.toFixed(0)}ms`; } type Percent = { percent: number; vitalState: VitalState; }; function getPercentsFromCounts({poor, meh, good, total}) { const poorPercent = poor / total; const mehPercent = meh / total; const goodPercent = good / total; const percents: Percent[] = [ { vitalState: VitalState.GOOD, percent: goodPercent, }, { vitalState: VitalState.MEH, percent: mehPercent, }, { vitalState: VitalState.POOR, percent: poorPercent, }, ]; return percents; } function getColorStopsFromPercents(percents: Percent[]) { return percents.map(({percent, vitalState}) => ({ percent, color: vitalStateColors[vitalState], })); } const BarDetail = styled('div')` font-size: ${p => p.theme.fontSizeMedium}; @media (min-width: ${p => p.theme.breakpoints.small}) { display: flex; justify-content: space-between; } `; const CardValue = styled('div')` font-size: 32px; margin-top: ${space(1)}; `; const OverflowEllipsis = styled('div')` ${p => p.theme.overflowEllipsis}; `;