Files
2024-02-15 17:01:50 +00:00

558 lines
16 KiB
TypeScript

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, Project} from 'sentry/types';
import {defined} from 'sentry/utils';
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 toArray from 'sentry/utils/toArray';
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};
`;