Skip to content

Commit 38f0963

Browse files
feat(preprod): Add snapshot diff comparison UI (#109403)
## Summary Adds the frontend diff comparison UI for the preprod snapshot viewer, enabling side-by-side visual comparison of snapshot images between a base artifact and the current branch. **New components:** - `DiffImageDisplay` — side-by-side base vs current branch image view with diff mask overlay support - `SnapshotDevTools` — dev tools panel showing comparison state, polling status, duration, and a "Re-run Comparison" button **Sidebar improvements:** - Collapsible sections grouped by diff status (Modified, Added, Removed, Renamed, Unchanged) using `Disclosure` - Diff percentage shown per changed image - Resizable sidebar panel via drag handle **Comparison toolbar:** - Summary counts (changed, added, removed, renamed, unchanged) - Toggle diff overlay on/off with configurable color picker **Type additions:** - `SnapshotComparisonRunInfo`, `ComparisonState`, `DiffStatus`, and `SidebarItem` discriminated union ### Legal Boilerplate Look, I get it. The entity doing business as "Sentry" was incorporated in the State of Delaware in 2015 as Functional Software, Inc. and is gonna need some rights from me in order to utilize my contributions in this here PR. So here's the deal: I retain all rights, title and interest in and to my contributions, and by keeping this boilerplate intact I confirm that Sentry can use, modify, copy, and redistribute my contributions, under Sentry's choice of terms.
1 parent ac82352 commit 38f0963

File tree

6 files changed

+930
-148
lines changed

6 files changed

+930
-148
lines changed
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
2+
import {keyframes} from '@emotion/react';
3+
import styled from '@emotion/styled';
4+
5+
import {Button} from '@sentry/scraps/button';
6+
import {Flex} from '@sentry/scraps/layout';
7+
import {Text} from '@sentry/scraps/text';
8+
9+
import {Client} from 'sentry/api';
10+
import {IconAdd, IconSubtract} from 'sentry/icons';
11+
import {t} from 'sentry/locale';
12+
import {space} from 'sentry/styles/space';
13+
import formatDuration from 'sentry/utils/duration/formatDuration';
14+
import {
15+
ComparisonState,
16+
type SnapshotComparisonRunInfo,
17+
} from 'sentry/views/preprod/types/snapshotTypes';
18+
19+
interface SnapshotDevToolsProps {
20+
hasBaseArtifact: boolean;
21+
organizationSlug: string;
22+
projectSlug: string;
23+
refetch: () => void;
24+
snapshotId: string;
25+
comparisonRunInfo?: SnapshotComparisonRunInfo | null;
26+
}
27+
28+
export function SnapshotDevTools({
29+
organizationSlug,
30+
projectSlug,
31+
snapshotId,
32+
comparisonRunInfo,
33+
hasBaseArtifact,
34+
refetch,
35+
}: SnapshotDevToolsProps) {
36+
const comparisonState = comparisonRunInfo?.state;
37+
const comparisonCompletedAt = comparisonRunInfo?.completed_at;
38+
const comparisonDurationMs = comparisonRunInfo?.duration_ms;
39+
const [devToolsCollapsed, setDevToolsCollapsed] = useState(
40+
() => localStorage.getItem('snapshot-dev-tools-collapsed') === 'true'
41+
);
42+
const [recompareLoading, setRecompareLoading] = useState(false);
43+
const [recompareError, setRecompareError] = useState<string | null>(null);
44+
const clientRef = useRef(new Client());
45+
46+
const polling = useMemo(
47+
() =>
48+
comparisonState === ComparisonState.PENDING ||
49+
comparisonState === ComparisonState.PROCESSING,
50+
[comparisonState]
51+
);
52+
53+
useEffect(() => {
54+
if (!polling) {
55+
return undefined;
56+
}
57+
const interval = setInterval(() => refetch(), 1000);
58+
return () => clearInterval(interval);
59+
}, [polling, refetch]);
60+
61+
const setCollapsed = (collapsed: boolean) => {
62+
setDevToolsCollapsed(collapsed);
63+
localStorage.setItem('snapshot-dev-tools-collapsed', String(collapsed));
64+
};
65+
66+
const handleRecompare = useCallback(() => {
67+
setRecompareLoading(true);
68+
setRecompareError(null);
69+
clientRef.current.request(
70+
`/projects/${organizationSlug}/${projectSlug}/preprodartifacts/snapshots/${snapshotId}/recompare/`,
71+
{
72+
method: 'POST',
73+
success: () => {
74+
setRecompareLoading(false);
75+
refetch();
76+
},
77+
error: (err: any) => {
78+
setRecompareLoading(false);
79+
setRecompareError(err?.responseJSON?.detail ?? 'Failed to recompare');
80+
},
81+
}
82+
);
83+
}, [organizationSlug, projectSlug, snapshotId, refetch]);
84+
85+
let stateLabel: string;
86+
if (comparisonState === ComparisonState.PROCESSING) {
87+
stateLabel = t('Processing...');
88+
} else if (comparisonState === ComparisonState.PENDING) {
89+
stateLabel = t('Queued...');
90+
} else if (comparisonState === ComparisonState.FAILED) {
91+
stateLabel = t('Failed');
92+
} else if (comparisonCompletedAt) {
93+
stateLabel = t('Done');
94+
} else {
95+
stateLabel = t('No comparison');
96+
}
97+
98+
return (
99+
<DevToolsBox data-collapsed={devToolsCollapsed}>
100+
{devToolsCollapsed ? (
101+
<Flex align="center" justify="center" gap="xs">
102+
<Text size="xs" variant="muted">
103+
{t('temp dev tools')}
104+
</Text>
105+
<Button
106+
size="zero"
107+
priority="transparent"
108+
icon={<IconAdd size="xs" />}
109+
aria-label={t('Expand')}
110+
onClick={() => setCollapsed(false)}
111+
/>
112+
</Flex>
113+
) : (
114+
<Flex direction="column" gap="sm">
115+
<CollapseButton
116+
size="zero"
117+
priority="transparent"
118+
icon={<IconSubtract size="xs" />}
119+
aria-label={t('Collapse')}
120+
onClick={() => setCollapsed(true)}
121+
/>
122+
<Flex align="center" justify="center">
123+
<Text size="xs" variant="muted">
124+
{t('temp dev tools')}
125+
</Text>
126+
</Flex>
127+
</Flex>
128+
)}
129+
{!devToolsCollapsed && (
130+
<Flex align="center" gap="sm">
131+
<StatusPill>
132+
<Text size="xs" variant="muted">
133+
{t('Mode:')}
134+
</Text>
135+
<Text size="xs" bold>
136+
{hasBaseArtifact ? t('Diff') : t('Solo')}
137+
</Text>
138+
</StatusPill>
139+
<Text size="xs" variant="muted">
140+
{'|'}
141+
</Text>
142+
<StatusPill>
143+
<PulsingDot active={polling} />
144+
<Text size="xs" variant="muted">
145+
{t('State:')}
146+
</Text>
147+
<Text size="xs" bold>
148+
{stateLabel}
149+
</Text>
150+
</StatusPill>
151+
{comparisonCompletedAt && (
152+
<Text size="xs" variant="muted">
153+
{'|'}
154+
</Text>
155+
)}
156+
{comparisonCompletedAt && (
157+
<Flex align="center" gap="xs">
158+
<Text size="xs" variant="muted">
159+
{t('Last run:')}
160+
</Text>
161+
<Text size="xs" bold>
162+
{new Date(comparisonCompletedAt).toLocaleTimeString()}
163+
</Text>
164+
</Flex>
165+
)}
166+
{comparisonDurationMs !== undefined && (
167+
<Text size="xs" variant="muted">
168+
{'|'}
169+
</Text>
170+
)}
171+
{comparisonDurationMs !== undefined && (
172+
<Flex align="center" gap="xs">
173+
<Text size="xs" variant="muted">
174+
{t('comparison e2e:')}
175+
</Text>
176+
<Text size="xs" bold>
177+
{formatDuration({
178+
duration: [comparisonDurationMs, 'ms'],
179+
precision: 'sec',
180+
style: 'h:mm:ss',
181+
})}
182+
</Text>
183+
</Flex>
184+
)}
185+
{hasBaseArtifact && (
186+
<Text size="xs" variant="muted">
187+
{'|'}
188+
</Text>
189+
)}
190+
{hasBaseArtifact && (
191+
<Button size="xs" onClick={handleRecompare} disabled={recompareLoading}>
192+
{recompareLoading ? t('Queuing...') : t('Re-run Comparison')}
193+
</Button>
194+
)}
195+
</Flex>
196+
)}
197+
{!devToolsCollapsed && recompareError && (
198+
<Text size="xs" variant="danger">
199+
{recompareError}
200+
</Text>
201+
)}
202+
</DevToolsBox>
203+
);
204+
}
205+
206+
const pulse = keyframes`
207+
0%, 100% { opacity: 1; }
208+
50% { opacity: 0.3; }
209+
`;
210+
211+
const DevToolsBox = styled('div')`
212+
position: relative;
213+
display: flex;
214+
flex-direction: column;
215+
gap: ${space(0.5)};
216+
padding: ${space(0.75)} ${space(1)};
217+
border: 1px dashed ${p => p.theme.tokens.border.primary};
218+
border-radius: ${p => p.theme.radius.md};
219+
220+
&:not([data-collapsed='true']) {
221+
padding-right: 48px;
222+
}
223+
`;
224+
225+
const CollapseButton = styled(Button)`
226+
position: absolute;
227+
top: ${space(0.5)};
228+
right: ${space(0.5)};
229+
`;
230+
231+
const StatusPill = styled('div')`
232+
display: flex;
233+
align-items: center;
234+
gap: ${space(0.5)};
235+
padding: 2px ${space(0.75)};
236+
border: 1px solid ${p => p.theme.tokens.border.accent};
237+
border-radius: 12px;
238+
`;
239+
240+
const PulsingDot = styled('div')<{active: boolean}>`
241+
width: 8px;
242+
height: 8px;
243+
border-radius: 50%;
244+
background: ${p => (p.active ? p.theme.colors.yellow300 : p.theme.colors.gray200)};
245+
animation: ${p => (p.active ? pulse : 'none')} 1.2s ease-in-out infinite;
246+
`;
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import {useEffect, useRef, useState} from 'react';
2+
import styled from '@emotion/styled';
3+
4+
import {Image} from '@sentry/scraps/image';
5+
import {Flex, Grid} from '@sentry/scraps/layout';
6+
import {Heading, Text} from '@sentry/scraps/text';
7+
8+
import {t} from 'sentry/locale';
9+
import type {SnapshotDiffPair} from 'sentry/views/preprod/types/snapshotTypes';
10+
11+
interface DiffImageDisplayProps {
12+
diffImageBaseUrl: string;
13+
imageBaseUrl: string;
14+
overlayColor: string;
15+
pair: SnapshotDiffPair;
16+
showOverlay: boolean;
17+
}
18+
19+
export function DiffImageDisplay({
20+
pair,
21+
imageBaseUrl,
22+
diffImageBaseUrl,
23+
showOverlay,
24+
overlayColor,
25+
}: DiffImageDisplayProps) {
26+
const [diffMaskUrl, setDiffMaskUrl] = useState<string | null>(null);
27+
const blobUrlRef = useRef<string | null>(null);
28+
29+
const baseImageUrl = `${imageBaseUrl}${pair.base_image.key}/`;
30+
const headImageUrl = `${imageBaseUrl}${pair.head_image.key}/`;
31+
const diffImageUrl = pair.diff_image_key
32+
? `${diffImageBaseUrl}${pair.diff_image_key}`
33+
: null;
34+
35+
useEffect(() => {
36+
if (blobUrlRef.current) {
37+
URL.revokeObjectURL(blobUrlRef.current);
38+
blobUrlRef.current = null;
39+
}
40+
setDiffMaskUrl(null);
41+
42+
if (!diffImageUrl) {
43+
return undefined;
44+
}
45+
let cancelled = false;
46+
fetch(diffImageUrl)
47+
.then(r => {
48+
if (!r.ok) {
49+
throw new Error(`Failed to fetch diff image: ${r.status}`);
50+
}
51+
return r.blob();
52+
})
53+
.then(blob => {
54+
if (!cancelled) {
55+
const url = URL.createObjectURL(blob);
56+
blobUrlRef.current = url;
57+
setDiffMaskUrl(url);
58+
}
59+
})
60+
.catch(() => {});
61+
return () => {
62+
cancelled = true;
63+
if (blobUrlRef.current) {
64+
URL.revokeObjectURL(blobUrlRef.current);
65+
blobUrlRef.current = null;
66+
}
67+
};
68+
}, [diffImageUrl]);
69+
70+
const diffPercent = pair.diff === null ? null : `${(pair.diff * 100).toFixed(1)}%`;
71+
72+
return (
73+
<Flex direction="column" gap="lg" padding="xl" height="100%">
74+
{diffPercent && (
75+
<Text variant="muted" size="sm">
76+
{t('Diff: %s', diffPercent)}
77+
</Text>
78+
)}
79+
<Grid columns="repeat(2, 1fr)" gap="xl" flex="1" minHeight="0">
80+
<Flex direction="column" gap="sm">
81+
<Heading as="h4">{t('Base')}</Heading>
82+
<Flex
83+
justify="center"
84+
border="primary"
85+
radius="md"
86+
overflow="hidden"
87+
background="secondary"
88+
>
89+
<ConstrainedImage src={baseImageUrl} alt={t('Base')} />
90+
</Flex>
91+
</Flex>
92+
93+
<Flex direction="column" gap="sm">
94+
<Heading as="h4">{t('Current Branch')}</Heading>
95+
<Flex
96+
justify="center"
97+
border="primary"
98+
radius="md"
99+
overflow="hidden"
100+
background="secondary"
101+
>
102+
<ImageWrapper>
103+
<ConstrainedImage src={headImageUrl} alt={t('Current Branch')} />
104+
{showOverlay && diffMaskUrl && (
105+
<DiffOverlay $overlayColor={overlayColor} $maskUrl={diffMaskUrl} />
106+
)}
107+
</ImageWrapper>
108+
</Flex>
109+
</Flex>
110+
</Grid>
111+
</Flex>
112+
);
113+
}
114+
115+
const ConstrainedImage = styled(Image)`
116+
max-height: 65vh;
117+
width: auto;
118+
`;
119+
120+
const ImageWrapper = styled('div')`
121+
position: relative;
122+
`;
123+
124+
const DiffOverlay = styled('span')<{$maskUrl: string; $overlayColor: string}>`
125+
position: absolute;
126+
top: 0;
127+
left: 0;
128+
width: 100%;
129+
height: 100%;
130+
pointer-events: none;
131+
background-color: ${p => p.$overlayColor};
132+
mask-image: url(${p => p.$maskUrl});
133+
mask-size: 100% 100%;
134+
mask-mode: luminance;
135+
-webkit-mask-image: url(${p => p.$maskUrl});
136+
-webkit-mask-size: 100% 100%;
137+
`;

0 commit comments

Comments
 (0)