11import type { ReactNode } from 'react' ;
22import styled from '@emotion/styled' ;
33
4+ import { LinkButton } from '@sentry/scraps/button/linkButton' ;
45import { Flex , Stack } from '@sentry/scraps/layout' ;
56import { Heading , Text } from '@sentry/scraps/text' ;
67import { Tooltip } from '@sentry/scraps/tooltip' ;
78
9+ import { PercentChange } from 'sentry/components/percentChange' ;
810import { IconCode , IconDownload , IconLightning , IconSettings } from 'sentry/icons' ;
911import { t } from 'sentry/locale' ;
1012import { trackAnalytics } from 'sentry/utils/analytics' ;
@@ -27,14 +29,18 @@ import {
2729 formattedPrimaryMetricDownloadSize ,
2830 formattedPrimaryMetricInstallSize ,
2931 getLabels ,
32+ getTrend ,
3033} from 'sentry/views/preprod/utils/labelUtils' ;
3134
3235interface BuildDetailsMetricCardsProps {
3336 onOpenInsightsSidebar : ( ) => void ;
3437 processedInsights : ProcessedInsight [ ] ;
3538 sizeInfo : BuildDetailsSizeInfo | undefined ;
3639 totalSize : number ;
40+ artifactId ?: string ;
41+ baseArtifactId ?: string | null ;
3742 platform ?: Platform | null ;
43+ projectId ?: string ;
3844 projectType ?: string | null ;
3945}
4046
@@ -43,11 +49,18 @@ interface MetricCardConfig {
4349 key : string ;
4450 title : string ;
4551 value : string ;
52+ comparisonUrl ?: string ;
53+ delta ?: MetricDelta ;
4654 labelTooltip ?: string ;
4755 percentageText ?: string ;
4856 showInsightsButton ?: boolean ;
4957 watchBreakdown ?: WatchBreakdown ;
5058}
59+ interface MetricDelta {
60+ baseValue : number ;
61+ diff : number ;
62+ percentageChange : number ;
63+ }
5164
5265interface WatchBreakdown {
5366 appValue : string ;
@@ -59,8 +72,11 @@ export function BuildDetailsMetricCards(props: BuildDetailsMetricCardsProps) {
5972 sizeInfo,
6073 processedInsights,
6174 totalSize,
75+ artifactId,
76+ baseArtifactId,
6277 platform : platformProp ,
6378 projectType,
79+ projectId,
6480 onOpenInsightsSidebar,
6581 } = props ;
6682
@@ -77,6 +93,28 @@ export function BuildDetailsMetricCards(props: BuildDetailsMetricCardsProps) {
7793 ) ;
7894 const installMetricValue = formattedPrimaryMetricInstallSize ( sizeInfo ) ;
7995 const downloadMetricValue = formattedPrimaryMetricDownloadSize ( sizeInfo ) ;
96+
97+ // Find matching base metrics for comparison
98+ const basePrimarySizeMetric = sizeInfo . base_size_metrics . find (
99+ metric => metric . metrics_artifact_type === MetricsArtifactType . MAIN_ARTIFACT
100+ ) ;
101+
102+ // Calculate deltas for install and download sizes
103+ const installDelta = calculateDelta (
104+ primarySizeMetric ?. install_size_bytes ,
105+ basePrimarySizeMetric ?. install_size_bytes
106+ ) ;
107+ const downloadDelta = calculateDelta (
108+ primarySizeMetric ?. download_size_bytes ,
109+ basePrimarySizeMetric ?. download_size_bytes
110+ ) ;
111+
112+ // Build comparison URL using route params
113+ const comparisonUrl =
114+ baseArtifactId && projectId && artifactId
115+ ? `/organizations/${ organization . slug } /preprod/${ projectId } /compare/${ artifactId } /${ baseArtifactId } /`
116+ : undefined ;
117+
80118 const totalPotentialSavings = processedInsights . reduce (
81119 ( sum , insight ) => sum + ( insight . totalSavings ?? 0 ) ,
82120 0
@@ -97,30 +135,35 @@ export function BuildDetailsMetricCards(props: BuildDetailsMetricCardsProps) {
97135 icon : < IconCode size = "sm" /> ,
98136 labelTooltip : labels . installSizeDescription ,
99137 value : installMetricValue ,
138+ comparisonUrl,
100139 watchBreakdown : getWatchBreakdown (
101140 primarySizeMetric ,
102141 watchArtifactMetric ,
103142 'install_size_bytes'
104143 ) ,
144+ delta : installDelta ,
105145 } ,
106146 {
107147 key : 'download' ,
108148 title : labels . downloadSizeLabel ,
109149 icon : < IconDownload size = "sm" /> ,
110150 labelTooltip : labels . downloadSizeDescription ,
111151 value : downloadMetricValue ,
152+ comparisonUrl,
112153 watchBreakdown : getWatchBreakdown (
113154 primarySizeMetric ,
114155 watchArtifactMetric ,
115156 'download_size_bytes'
116157 ) ,
158+ delta : downloadDelta ,
117159 } ,
118160 {
119161 key : 'savings' ,
120162 title : t ( 'Potential savings' ) ,
121163 icon : < IconLightning size = "sm" /> ,
122164 labelTooltip : t ( 'Total savings from insights' ) ,
123165 value : formatBytesBase10 ( totalPotentialSavings ) ,
166+ comparisonUrl : undefined ,
124167 percentageText : potentialSavingsPercentageText ,
125168 showInsightsButton : totalPotentialSavings > 0 ,
126169 } ,
@@ -153,24 +196,95 @@ export function BuildDetailsMetricCards(props: BuildDetailsMetricCardsProps) {
153196 : undefined
154197 }
155198 >
156- < Heading as = "h3" >
157- { card . watchBreakdown ? (
158- < Tooltip
159- title = {
160- < WatchBreakdownTooltip
161- appValue = { card . watchBreakdown . appValue }
162- watchValue = { card . watchBreakdown . watchValue }
163- />
164- }
165- position = "bottom"
166- >
167- < MetricValue $interactive > { card . value } </ MetricValue >
168- </ Tooltip >
169- ) : (
170- < MetricValue > { card . value } </ MetricValue >
199+ < Stack gap = "xs" >
200+ < Flex align = "end" gap = "sm" wrap = "wrap" >
201+ < Heading as = "h3" >
202+ { card . watchBreakdown ? (
203+ < Tooltip
204+ title = {
205+ < WatchBreakdownTooltip
206+ appValue = { card . watchBreakdown . appValue }
207+ watchValue = { card . watchBreakdown . watchValue }
208+ />
209+ }
210+ position = "bottom"
211+ >
212+ < MetricValue $interactive > { card . value } </ MetricValue >
213+ </ Tooltip >
214+ ) : (
215+ < MetricValue > { card . value } </ MetricValue >
216+ ) }
217+ { card . percentageText ?? '' }
218+ </ Heading >
219+
220+ { card . delta &&
221+ card . comparisonUrl &&
222+ ( ( ) => {
223+ const { variant, icon} = getTrend ( card . delta . diff ) ;
224+
225+ return (
226+ < LinkButton
227+ to = { card . comparisonUrl }
228+ size = "xs"
229+ priority = "link"
230+ aria-label = { t ( 'Compare builds' ) }
231+ >
232+ < Flex align = "center" gap = "xs" >
233+ { icon }
234+ < Text
235+ as = "span"
236+ variant = { variant }
237+ size = "sm"
238+ style = { {
239+ display : 'inline-flex' ,
240+ alignItems : 'center' ,
241+ flexWrap : 'wrap' ,
242+ gap : '0.25em' ,
243+ fontWeight : 'normal' ,
244+ } }
245+ >
246+ { card . delta . diff > 0 ? '+' : card . delta . diff < 0 ? '-' : '' }
247+ { formatBytesBase10 ( Math . abs ( card . delta . diff ) ) }
248+ { card . delta . percentageChange !== 0 && (
249+ < Text
250+ as = "span"
251+ variant = { variant }
252+ style = { {
253+ display : 'inline-flex' ,
254+ alignItems : 'center' ,
255+ whiteSpace : 'nowrap' ,
256+ } }
257+ >
258+ { ' (' }
259+ < PercentChange
260+ value = { card . delta . percentageChange }
261+ minimumValue = { 0.001 }
262+ preferredPolarity = "-"
263+ colorize
264+ />
265+ { ')' }
266+ </ Text >
267+ ) }
268+ </ Text >
269+ </ Flex >
270+ </ LinkButton >
271+ ) ;
272+ } ) ( ) }
273+ </ Flex >
274+
275+ { card . delta && (
276+ < Flex gap = "xs" wrap = "wrap" >
277+ < Text variant = "muted" size = "sm" >
278+ { t ( 'Base Build Size:' ) }
279+ </ Text >
280+ < Text variant = "muted" size = "sm" bold >
281+ { card . delta . baseValue === 0
282+ ? t ( 'Not present' )
283+ : formatBytesBase10 ( card . delta . baseValue ) }
284+ </ Text >
285+ </ Flex >
171286 ) }
172- { card . percentageText ?? '' }
173- </ Heading >
287+ </ Stack >
174288 </ MetricCard >
175289 ) ) }
176290 </ Flex >
@@ -213,6 +327,24 @@ function getWatchBreakdown(
213327 } ;
214328}
215329
330+ function calculateDelta (
331+ headValue : number | undefined ,
332+ baseValue : number | undefined
333+ ) : MetricDelta | undefined {
334+ if ( headValue === undefined || baseValue === undefined ) {
335+ return undefined ;
336+ }
337+
338+ const diff = headValue - baseValue ;
339+ const percentageChange = baseValue === 0 ? 0 : diff / baseValue ;
340+
341+ return {
342+ baseValue,
343+ diff,
344+ percentageChange,
345+ } ;
346+ }
347+
216348const MetricValue = styled ( 'span' ) < { $interactive ?: boolean } > `
217349 ${ p =>
218350 p . $interactive
0 commit comments