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;