123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568 |
- 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<Record<string, number[]>>(
- (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)} 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) as Array<keyof typeof 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,
- }: Pick<VitalData, '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};
- `;
|