diff --git a/static/app/views/preprod/buildComparison/main/BuildComparisonMetricCards.tsx b/static/app/views/preprod/buildComparison/main/buildComparisonMetricCards.tsx similarity index 94% rename from static/app/views/preprod/buildComparison/main/BuildComparisonMetricCards.tsx rename to static/app/views/preprod/buildComparison/main/buildComparisonMetricCards.tsx index f473a49bf43987..b40f3d589b9fb8 100644 --- a/static/app/views/preprod/buildComparison/main/BuildComparisonMetricCards.tsx +++ b/static/app/views/preprod/buildComparison/main/buildComparisonMetricCards.tsx @@ -25,6 +25,7 @@ interface ComparisonMetric { head: number; icon: ReactNode; key: string; + labelTooltip: string; percentageChange: number; title: string; } @@ -47,6 +48,7 @@ export function BuildComparisonMetricCards(props: BuildComparisonMetricCardsProp key: 'install', title: labels.installSizeLabel, icon: , + labelTooltip: labels.installSizeDescription, head: size_metric_diff_item.head_install_size, base: size_metric_diff_item.base_install_size, diff: @@ -63,6 +65,7 @@ export function BuildComparisonMetricCards(props: BuildComparisonMetricCardsProp key: 'download', title: labels.downloadSizeLabel, icon: , + labelTooltip: labels.downloadSizeDescription, head: size_metric_diff_item.head_download_size, base: size_metric_diff_item.base_download_size, diff: @@ -88,7 +91,12 @@ export function BuildComparisonMetricCards(props: BuildComparisonMetricCardsProp const {variant, icon} = getTrend(metric.diff); return ( - + {formatBytesBase10(metric.head)} diff --git a/static/app/views/preprod/buildComparison/main/sizeCompareMainContent.tsx b/static/app/views/preprod/buildComparison/main/sizeCompareMainContent.tsx index 0382e6ca58edbb..9f16aa5c45dc57 100644 --- a/static/app/views/preprod/buildComparison/main/sizeCompareMainContent.tsx +++ b/static/app/views/preprod/buildComparison/main/sizeCompareMainContent.tsx @@ -17,7 +17,7 @@ import type RequestError from 'sentry/utils/requestError/requestError'; import {useNavigate} from 'sentry/utils/useNavigate'; import useOrganization from 'sentry/utils/useOrganization'; import {useParams} from 'sentry/utils/useParams'; -import {BuildComparisonMetricCards} from 'sentry/views/preprod/buildComparison/main/BuildComparisonMetricCards'; +import {BuildComparisonMetricCards} from 'sentry/views/preprod/buildComparison/main/buildComparisonMetricCards'; import {SizeCompareItemDiffTable} from 'sentry/views/preprod/buildComparison/main/sizeCompareItemDiffTable'; import {SizeCompareSelectedBuilds} from 'sentry/views/preprod/buildComparison/main/sizeCompareSelectedBuilds'; import {BuildError} from 'sentry/views/preprod/components/buildError'; diff --git a/static/app/views/preprod/buildDetails/main/buildDetailsMainContent.tsx b/static/app/views/preprod/buildDetails/main/buildDetailsMainContent.tsx index e8c8146fbcb171..cc710b727689e4 100644 --- a/static/app/views/preprod/buildDetails/main/buildDetailsMainContent.tsx +++ b/static/app/views/preprod/buildDetails/main/buildDetailsMainContent.tsx @@ -1,3 +1,5 @@ +import {useCallback} from 'react'; +import {useSearchParams} from 'react-router-dom'; import styled from '@emotion/styled'; import {Alert} from '@sentry/scraps/alert'; @@ -13,6 +15,7 @@ import {t} from 'sentry/locale'; import type {UseApiQueryResult} from 'sentry/utils/queryClient'; import type RequestError from 'sentry/utils/requestError/requestError'; import {useQueryParamState} from 'sentry/utils/url/useQueryParamState'; +import {BuildDetailsMetricCards} from 'sentry/views/preprod/buildDetails/main/buildDetailsMetricCards'; import {AppSizeInsights} from 'sentry/views/preprod/buildDetails/main/insights/appSizeInsights'; import {BuildError} from 'sentry/views/preprod/components/buildError'; import {BuildProcessing} from 'sentry/views/preprod/components/buildProcessing'; @@ -52,6 +55,12 @@ export function BuildDetailsMainContent(props: BuildDetailsMainContentProps) { isError: isAppSizeError, error: appSizeError, } = appSizeQuery; + const [searchParams, setSearchParams] = useSearchParams(); + const openInsightsSidebar = useCallback(() => { + const next = new URLSearchParams(searchParams); + next.set('insights', 'open'); + setSearchParams(next); + }, [searchParams, setSearchParams]); // If the main data fetch fails, this component will not be rendered // so we don't handle 'isBuildDetailsError'. @@ -107,13 +116,21 @@ export function BuildDetailsMainContent(props: BuildDetailsMainContentProps) { if (isLoadingRequests) { return ( - - - - + + + + + - - + + + + + + + + + ); } @@ -204,7 +221,6 @@ export function BuildDetailsMainContent(props: BuildDetailsMainContentProps) { appSizeData.insights && totalSize > 0 ? processInsights(appSizeData.insights, totalSize) : []; - const categoriesEnabled = appSizeData.treemap.category_breakdown && Object.keys(appSizeData.treemap.category_breakdown).length > 0; @@ -283,42 +299,49 @@ export function BuildDetailsMainContent(props: BuildDetailsMainContentProps) { } return ( - - - {categoriesEnabled && ( - - } /> - } /> - - )} - {selectedContent === 'treemap' && ( - - - - - setSearchQuery(e.target.value || undefined)} - /> - {searchQuery && ( - - - - )} - - )} - - {visualizationContent} + + - + + + {categoriesEnabled && ( + + } /> + } /> + + )} + {selectedContent === 'treemap' && ( + + + + + setSearchQuery(e.target.value || undefined)} + /> + {searchQuery && ( + + + + )} + + )} + + {visualizationContent} {selectedContent === 'treemap' && appSizeData && ( )} - - + + + ); } diff --git a/static/app/views/preprod/buildDetails/main/buildDetailsMetricCards.tsx b/static/app/views/preprod/buildDetails/main/buildDetailsMetricCards.tsx new file mode 100644 index 00000000000000..13140929212351 --- /dev/null +++ b/static/app/views/preprod/buildDetails/main/buildDetailsMetricCards.tsx @@ -0,0 +1,218 @@ +import type {ReactNode} from 'react'; +import styled from '@emotion/styled'; + +import {Flex, Stack} from '@sentry/scraps/layout'; +import {Heading, Text} from '@sentry/scraps/text'; +import {Tooltip} from '@sentry/scraps/tooltip'; + +import {IconCode, IconDownload, IconLightning, IconSettings} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import {formatBytesBase10} from 'sentry/utils/bytes/formatBytesBase10'; +import {formatPercentage} from 'sentry/utils/number/formatPercentage'; +import {MetricCard} from 'sentry/views/preprod/components/metricCard'; +import {MetricsArtifactType} from 'sentry/views/preprod/types/appSizeTypes'; +import { + getMainArtifactSizeMetric, + isSizeInfoCompleted, +} from 'sentry/views/preprod/types/buildDetailsTypes'; +import type { + BuildDetailsSizeInfo, + BuildDetailsSizeInfoSizeMetric, +} from 'sentry/views/preprod/types/buildDetailsTypes'; +import type {Platform} from 'sentry/views/preprod/types/sharedTypes'; +import type {ProcessedInsight} from 'sentry/views/preprod/utils/insightProcessing'; +import { + formattedPrimaryMetricDownloadSize, + formattedPrimaryMetricInstallSize, + getLabels, +} from 'sentry/views/preprod/utils/labelUtils'; + +interface BuildDetailsMetricCardsProps { + onOpenInsightsSidebar: () => void; + processedInsights: ProcessedInsight[]; + sizeInfo: BuildDetailsSizeInfo | undefined; + totalSize: number; + platform?: Platform | null; +} + +interface MetricCardConfig { + icon: ReactNode; + key: string; + title: string; + value: string; + labelTooltip?: string; + percentageText?: string; + showInsightsButton?: boolean; + watchBreakdown?: WatchBreakdown; +} + +interface WatchBreakdown { + appValue: string; + watchValue: string; +} + +export function BuildDetailsMetricCards(props: BuildDetailsMetricCardsProps) { + const { + sizeInfo, + processedInsights, + totalSize, + platform: platformProp, + onOpenInsightsSidebar, + } = props; + + if (!isSizeInfoCompleted(sizeInfo)) { + return null; + } + + const labels = getLabels(platformProp ?? undefined); + const primarySizeMetric = getMainArtifactSizeMetric(sizeInfo); + const watchArtifactMetric = sizeInfo.size_metrics.find( + metric => metric.metrics_artifact_type === MetricsArtifactType.WATCH_ARTIFACT + ); + const installMetricValue = formattedPrimaryMetricInstallSize(sizeInfo); + const downloadMetricValue = formattedPrimaryMetricDownloadSize(sizeInfo); + const totalPotentialSavings = processedInsights.reduce( + (sum, insight) => sum + (insight.totalSavings ?? 0), + 0 + ); + const potentialSavingsPercentage = + totalSize > 0 ? totalPotentialSavings / totalSize : null; + const potentialSavingsPercentageText = + potentialSavingsPercentage !== null && potentialSavingsPercentage !== undefined + ? ` (${formatPercentage(potentialSavingsPercentage, 1, { + minimumValue: 0.001, + })})` + : undefined; + + const metricsCards: MetricCardConfig[] = [ + { + key: 'install', + title: labels.installSizeLabel, + icon: , + labelTooltip: labels.installSizeDescription, + value: installMetricValue, + watchBreakdown: getWatchBreakdown( + primarySizeMetric, + watchArtifactMetric, + 'install_size_bytes' + ), + }, + { + key: 'download', + title: labels.downloadSizeLabel, + icon: , + labelTooltip: labels.downloadSizeDescription, + value: downloadMetricValue, + watchBreakdown: getWatchBreakdown( + primarySizeMetric, + watchArtifactMetric, + 'download_size_bytes' + ), + }, + { + key: 'savings', + title: t('Potential savings'), + icon: , + labelTooltip: t('Total savings from insights'), + value: formatBytesBase10(totalPotentialSavings), + percentageText: potentialSavingsPercentageText, + showInsightsButton: totalPotentialSavings > 0, + }, + ]; + + return ( + + {metricsCards.map(card => { + const valueContent = ( + + + {card.value} + + {card.percentageText ?? ''} + + ); + + return ( + , + tooltip: t('View insight details'), + ariaLabel: t('View insight details'), + onClick: onOpenInsightsSidebar, + } + : undefined + } + > + {card.watchBreakdown ? ( + + } + position="left" + > + {valueContent} + + ) : ( + valueContent + )} + + ); + })} + + ); +} + +function WatchBreakdownTooltip(props: {appValue: string; watchValue: string}) { + const {appValue, watchValue} = props; + + return ( + + + + {t('App')}: + + {appValue} + + + + {t('Watch')}: + + {watchValue} + + + ); +} + +function getWatchBreakdown( + primaryMetric: BuildDetailsSizeInfoSizeMetric | undefined, + watchMetric: BuildDetailsSizeInfoSizeMetric | undefined, + field: 'install_size_bytes' | 'download_size_bytes' +): WatchBreakdown | undefined { + if (!primaryMetric || !watchMetric) { + return undefined; + } + + return { + appValue: formatBytesBase10(primaryMetric[field]), + watchValue: formatBytesBase10(watchMetric[field]), + }; +} + +const MetricValue = styled('span')<{$interactive?: boolean}>` + ${p => + p.$interactive + ? ` + text-decoration: underline dotted; + cursor: help; + ` + : ''} +`; diff --git a/static/app/views/preprod/buildDetails/sidebar/buildDetailsSidebarAppInfo.tsx b/static/app/views/preprod/buildDetails/sidebar/buildDetailsSidebarAppInfo.tsx index aa3ff6e65e9c8d..1a89fbf5bc551d 100644 --- a/static/app/views/preprod/buildDetails/sidebar/buildDetailsSidebarAppInfo.tsx +++ b/static/app/views/preprod/buildDetails/sidebar/buildDetailsSidebarAppInfo.tsx @@ -2,26 +2,17 @@ import styled from '@emotion/styled'; import {PlatformIcon} from 'platformicons'; import {CodeBlock} from '@sentry/scraps/code'; -import {Flex, Stack} from '@sentry/scraps/layout'; +import {Flex} from '@sentry/scraps/layout'; import {Heading, Text} from '@sentry/scraps/text'; import {Tooltip} from '@sentry/scraps/tooltip'; import Feature from 'sentry/components/acl/feature'; import {IconClock, IconFile, IconJson, IconLink, IconMobile} from 'sentry/icons'; import {t} from 'sentry/locale'; -import {formatBytesBase10} from 'sentry/utils/bytes/formatBytesBase10'; import {getFormat, getFormattedDate, getUtcToSystem} from 'sentry/utils/dates'; import {openInstallModal} from 'sentry/views/preprod/components/installModal'; -import {MetricsArtifactType} from 'sentry/views/preprod/types/appSizeTypes'; +import {type BuildDetailsAppInfo} from 'sentry/views/preprod/types/buildDetailsTypes'; import { - BuildDetailsSizeAnalysisState, - getMainArtifactSizeMetric, - type BuildDetailsAppInfo, - type BuildDetailsSizeInfo, -} from 'sentry/views/preprod/types/buildDetailsTypes'; -import { - formattedPrimaryMetricDownloadSize, - formattedPrimaryMetricInstallSize, getLabels, getPlatformIconFromPlatform, getReadableArtifactTypeLabel, @@ -33,7 +24,6 @@ interface BuildDetailsSidebarAppInfoProps { appInfo: BuildDetailsAppInfo; artifactId: string; projectId: string | null; - sizeInfo?: BuildDetailsSizeInfo; } export function BuildDetailsSidebarAppInfo(props: BuildDetailsSidebarAppInfoProps) { @@ -44,101 +34,6 @@ export function BuildDetailsSidebarAppInfo(props: BuildDetailsSidebarAppInfoProp timeZone: true, }); - let sizeInfoGroup = null; - if ( - props.sizeInfo && - props.sizeInfo.state === BuildDetailsSizeAnalysisState.COMPLETED - ) { - const primarySizeMetric = getMainArtifactSizeMetric(props.sizeInfo); - const watchAppMetrics = props.sizeInfo.size_metrics.find( - metric => metric.metrics_artifact_type === MetricsArtifactType.WATCH_ARTIFACT - ); - - let installSizeContent = ( - {formattedPrimaryMetricInstallSize(props.sizeInfo)} - ); - let downloadSizeContent = ( - {formattedPrimaryMetricDownloadSize(props.sizeInfo)} - ); - if (watchAppMetrics) { - installSizeContent = ( - - - - {t('App')}: - - - {formatBytesBase10(primarySizeMetric?.install_size_bytes ?? 0)} - - - - - {t('Watch')}: - - - {formatBytesBase10(watchAppMetrics.install_size_bytes)} - - - - } - position="left" - > - - {formattedPrimaryMetricInstallSize(props.sizeInfo)} - - - ); - downloadSizeContent = ( - - - - {t('App')}: - - - {formatBytesBase10(watchAppMetrics.download_size_bytes)} - - - - - {t('Watch')}: - - - {formatBytesBase10(watchAppMetrics.download_size_bytes)} - - - - } - position="left" - > - - {formattedPrimaryMetricDownloadSize(props.sizeInfo)} - - - ); - } - - sizeInfoGroup = ( - - - - {labels.installSizeLabel} - - {installSizeContent} - - - - {labels.downloadSizeLabel} - - {downloadSizeContent} - - - ); - } - return ( @@ -148,8 +43,6 @@ export function BuildDetailsSidebarAppInfo(props: BuildDetailsSidebarAppInfoProp {props.appInfo.name && {props.appInfo.name}} - {sizeInfoGroup} - diff --git a/static/app/views/preprod/buildDetails/sidebar/buildDetailsSidebarContent.tsx b/static/app/views/preprod/buildDetails/sidebar/buildDetailsSidebarContent.tsx index bc6fd9a452362c..567b85efb3f91e 100644 --- a/static/app/views/preprod/buildDetails/sidebar/buildDetailsSidebarContent.tsx +++ b/static/app/views/preprod/buildDetails/sidebar/buildDetailsSidebarContent.tsx @@ -126,7 +126,6 @@ export function BuildDetailsSidebarContent(props: BuildDetailsSidebarContentProp {buildDetailsData.state === BuildDetailsState.PROCESSED && ( @@ -149,18 +148,6 @@ function SidebarLoadingSkeleton(props: {['data-testid']: string}) { - {/* Size info section */} - - - - - - - - - - - {/* Additional info */} diff --git a/static/app/views/preprod/components/metricCard.tsx b/static/app/views/preprod/components/metricCard.tsx index 535ac9f24b0b27..15e7d8cad7037d 100644 --- a/static/app/views/preprod/components/metricCard.tsx +++ b/static/app/views/preprod/components/metricCard.tsx @@ -16,8 +16,8 @@ interface MetricCardProps { children: ReactNode; icon: ReactNode; label: string; + labelTooltip: ReactNode; action?: MetricCardAction; - labelTooltip?: ReactNode; style?: CSSProperties; } diff --git a/static/app/views/preprod/types/buildDetailsTypes.ts b/static/app/views/preprod/types/buildDetailsTypes.ts index 9ded2c20700f2e..d2fe800b91e7b7 100644 --- a/static/app/views/preprod/types/buildDetailsTypes.ts +++ b/static/app/views/preprod/types/buildDetailsTypes.ts @@ -45,7 +45,7 @@ export interface BuildDetailsVcsInfo { provider?: string | null; } -interface BuildDetailsSizeInfoSizeMetric { +export interface BuildDetailsSizeInfoSizeMetric { metrics_artifact_type: MetricsArtifactType; install_size_bytes: number; download_size_bytes: number;