import {useEffect, useState} from 'react'; import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import * as Sentry from '@sentry/react'; import {Button} from 'sentry/components/button'; import EmptyStateWarning from 'sentry/components/emptyStateWarning'; import Link from 'sentry/components/links/link'; import LoadingIndicator from 'sentry/components/loadingIndicator'; import {IconImage} from 'sentry/icons'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import {safeURL} from 'sentry/utils/url/safeURL'; import {useLocalStorageState} from 'sentry/utils/useLocalStorageState'; import usePageFilters from 'sentry/utils/usePageFilters'; import useProjects from 'sentry/utils/useProjects'; import {normalizeUrl} from 'sentry/utils/withDomainRequired'; import ResourceSize from 'sentry/views/performance/browser/resources/shared/resourceSize'; import {useIndexedResourcesQuery} from 'sentry/views/performance/browser/resources/utils/useIndexedResourceQuery'; import {useResourceModuleFilters} from 'sentry/views/performance/browser/resources/utils/useResourceFilters'; import {usePerformanceGeneralProjectSettings} from 'sentry/views/performance/utils'; import ChartPanel from 'sentry/views/starfish/components/chartPanel'; import {SpanIndexedField} from 'sentry/views/starfish/types'; type Props = {groupId: string; projectId?: number}; export const LOCAL_STORAGE_SHOW_LINKS = 'performance-resources-images-showLinks'; const {SPAN_GROUP, RAW_DOMAIN, SPAN_DESCRIPTION, HTTP_RESPONSE_CONTENT_LENGTH, SPAN_OP} = SpanIndexedField; const imageWidth = '200px'; const imageHeight = '180px'; function SampleImages({groupId, projectId}: Props) { const [showLinks, setShowLinks] = useLocalStorageState(LOCAL_STORAGE_SHOW_LINKS, false); const filters = useResourceModuleFilters(); const [showImages, setShowImages] = useState(showLinks); const {data: settings, isLoading: isSettingsLoading} = usePerformanceGeneralProjectSettings(projectId); const isImagesEnabled = settings?.enable_images ?? false; const {data: imageResources, isLoading: isLoadingImages} = useIndexedResourcesQuery({ queryConditions: [ `${SPAN_GROUP}:${groupId}`, ...(filters[SPAN_OP] ? [`${SPAN_OP}:${filters[SPAN_OP]}`] : []), ], sorts: [{field: `measurements.${HTTP_RESPONSE_CONTENT_LENGTH}`, kind: 'desc'}], limit: 100, referrer: 'api.performance.resources.sample-images', }); const uniqueResources = new Set(); const filteredResources = imageResources .filter(resource => { const fileName = getFileNameFromDescription(resource[SPAN_DESCRIPTION]); if (uniqueResources.has(fileName)) { return false; } uniqueResources.add(fileName); return true; }) .splice(0, 5); const handleClickOnlyShowLinks = () => { setShowLinks(true); setShowImages(true); }; return ( ); } function SampleImagesChartPanelBody(props: { images: ReturnType['data']; isImagesEnabled: boolean; isLoadingImages: boolean; isSettingsLoading: boolean; showImages: boolean; onClickShowLinks?: () => void; }) { const { onClickShowLinks, images, isLoadingImages, showImages, isImagesEnabled, isSettingsLoading, } = props; const hasImages = images.length > 0; useEffect(() => { if (showImages && !hasImages && !isLoadingImages) { Sentry.captureException(new Error('No sample images found')); } }, [showImages, hasImages, isLoadingImages]); if (isSettingsLoading || (showImages && isLoadingImages)) { return ; } if (!showImages) { return ; } if (showImages && !hasImages) { return (

{t('No images detected')}

); } return ( {images.map(resource => { const hasRawDomain = Boolean(resource[RAW_DOMAIN]); const isRelativeUrl = resource[SPAN_DESCRIPTION].startsWith('/'); let src = resource[SPAN_DESCRIPTION]; if (isRelativeUrl && hasRawDomain) { try { const url = new URL(resource[SPAN_DESCRIPTION], resource[RAW_DOMAIN]); src = url.href; } catch { Sentry.captureException(new Error('Invalid URL'), { data: { [SPAN_DESCRIPTION]: resource[SPAN_DESCRIPTION], [RAW_DOMAIN]: resource[RAW_DOMAIN], }, }); } } return ( ); })} ); } function DisabledImages(props: {onClickShowLinks?: () => void}) { const {onClickShowLinks} = props; const { selection: {projects: selectedProjects}, } = usePageFilters(); const {projects} = useProjects(); const firstProjectSelected = projects.find( project => project.id === selectedProjects[0].toString() ); return (
{t('Images not shown')}
{t( 'You know, you can see the actual images that are on your site if you opt into this feature.' )}
); } function ImageContainer(props: { fileName: string; showImage: boolean; size: number; src: string; }) { const [hasError, setHasError] = useState(false); const {fileName, size, src, showImage = true} = props; const isRelativeUrl = src.startsWith('/'); const handleError = () => { setHasError(true); Sentry.metrics.increment('performance.resource.image_load', 1, { tags: {status: 'error'}, }); }; const handleLoad = () => { Sentry.metrics.increment('performance.resource.image_load', 1, { tags: {status: 'success'}, }); }; return (
{showImage && !isRelativeUrl && !hasError ? (
) : ( )} {fileName} ()
); } function MissingImage() { const theme = useTheme(); return (
); } const getFileNameFromDescription = (description: string) => { const url = safeURL(description); if (!url) { return description; } return url.pathname.split('/').pop() ?? ''; }; const ImageWrapper = styled('div')` display: grid; grid-template-columns: repeat(auto-fill, ${imageWidth}); padding-top: ${space(2)}; gap: 30px; `; const ButtonContainer = styled('div')` display: grid; grid-template-columns: repeat(2, auto); gap: ${space(1)}; justify-content: center; align-items: center; padding-top: ${space(2)}; `; const ChartPanelTextContainer = styled('div')` text-align: center; width: 300px; margin: auto; `; export default SampleImages;