import {Fragment, useCallback} from 'react'; import type {RouteComponentProps} from 'react-router'; import styled from '@emotion/styled'; import {Role} from 'sentry/components/acl/role'; import {Button} from 'sentry/components/button'; import FileSize from 'sentry/components/fileSize'; import Link from 'sentry/components/links/link'; import Pagination from 'sentry/components/pagination'; import Panel from 'sentry/components/panels/panel'; import {PanelTable} from 'sentry/components/panels/panelTable'; import SearchBar from 'sentry/components/searchBar'; import {Tag} from 'sentry/components/tag'; import TimeSince from 'sentry/components/timeSince'; import {Tooltip} from 'sentry/components/tooltip'; import {IconClock, IconDownload} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type {Artifact, DebugIdBundleArtifact, Project} from 'sentry/types'; import {useApiQuery} from 'sentry/utils/queryClient'; import {decodeScalar} from 'sentry/utils/queryString'; import useApi from 'sentry/utils/useApi'; import useOrganization from 'sentry/utils/useOrganization'; import {normalizeUrl} from 'sentry/utils/withDomainRequired'; import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader'; import {DebugIdBundleDeleteButton} from 'sentry/views/settings/projectSourceMaps/debugIdBundleDeleteButton'; import {DebugIdBundleDetails} from 'sentry/views/settings/projectSourceMaps/debugIdBundleDetails'; import {useDeleteDebugIdBundle} from 'sentry/views/settings/projectSourceMaps/useDeleteDebugIdBundle'; enum DebugIdBundleArtifactType { INVALID = 0, SOURCE = 1, MINIFIED_SOURCE = 2, SOURCE_MAP = 3, INDEXED_RAM_BUNDLE = 4, } const debugIdBundleTypeLabels = { [DebugIdBundleArtifactType.INVALID]: t('Invalid'), [DebugIdBundleArtifactType.SOURCE]: t('Source'), [DebugIdBundleArtifactType.MINIFIED_SOURCE]: t('Minified'), [DebugIdBundleArtifactType.SOURCE_MAP]: t('Source Map'), [DebugIdBundleArtifactType.INDEXED_RAM_BUNDLE]: t('Indexed RAM Bundle'), }; function ArtifactsTableRow({ name, downloadRole, downloadUrl, size, type, orgSlug, artifactColumnDetails, }: { artifactColumnDetails: React.ReactNode; downloadRole: string; downloadUrl: string; name: string; orgSlug: string; size: number; type?: string; }) { return ( {name || `(${t('empty')})`} {artifactColumnDetails} {type && {type}} {({hasRole}) => { return ( , } )} disabled={hasRole} isHoverable > } disabled={!hasRole} href={downloadUrl} title={hasRole ? t('Download Artifact') : undefined} aria-label={t('Download Artifact')} /> ); }} ); } type Props = RouteComponentProps< {bundleId: string; orgId: string; projectId: string}, {} > & { project: Project; }; export function ProjectSourceMapsArtifacts({params, location, router, project}: Props) { const api = useApi(); const organization = useOrganization(); // query params const query = decodeScalar(location.query.query); const cursor = location.query.cursor ?? ''; // endpoints const artifactsEndpoint = `/projects/${organization.slug}/${ project.slug }/releases/${encodeURIComponent(params.bundleId)}/files/`; const debugIdBundlesArtifactsEndpoint = `/projects/${organization.slug}/${ project.slug }/artifact-bundles/${encodeURIComponent(params.bundleId)}/files/`; // debug id bundles tab url const debugIdsUrl = normalizeUrl( `/settings/${organization.slug}/projects/${project.slug}/source-maps/artifact-bundles/${params.bundleId}/` ); const tabDebugIdBundlesActive = location.pathname === debugIdsUrl; const { data: artifactsData, getResponseHeader: artifactsHeaders, isLoading: artifactsLoading, } = useApiQuery( [ artifactsEndpoint, { query: {query, cursor}, }, ], { staleTime: 0, keepPreviousData: true, enabled: !tabDebugIdBundlesActive, } ); const { data: debugIdBundlesArtifactsData, getResponseHeader: debugIdBundlesArtifactsHeaders, isLoading: debugIdBundlesArtifactsLoading, } = useApiQuery( [ debugIdBundlesArtifactsEndpoint, { query: {query, cursor}, }, ], { staleTime: 0, keepPreviousData: true, enabled: tabDebugIdBundlesActive, } ); const {mutate: deleteDebugIdArtifacts} = useDeleteDebugIdBundle({ onSuccess: () => router.push( `/settings/${organization.slug}/projects/${project.slug}/source-maps/artifact-bundles/` ), }); const handleDeleteDebugIdBundle = useCallback(() => { if (!debugIdBundlesArtifactsData) { return; } deleteDebugIdArtifacts({ projectSlug: project.slug, bundleId: debugIdBundlesArtifactsData.bundleId, }); }, [debugIdBundlesArtifactsData, deleteDebugIdArtifacts, project.slug]); const handleSearch = useCallback( (newQuery: string) => { router.push({ ...location, query: {...location.query, cursor: undefined, query: newQuery}, }); }, [router, location] ); return ( ) } subtitle={ !tabDebugIdBundlesActive && ( {params.bundleId} ) } /> {tabDebugIdBundlesActive && debugIdBundlesArtifactsData && ( )} {t('Type')}] : []), {t('File Size')}, '', ]} emptyMessage={ query ? t('No artifacts match your search query.') : tabDebugIdBundlesActive ? t('There are no artifacts in this bundle.') : t('There are no artifacts in this archive.') } isEmpty={ (tabDebugIdBundlesActive ? debugIdBundlesArtifactsData?.files ?? [] : artifactsData ?? [] ).length === 0 } isLoading={ tabDebugIdBundlesActive ? debugIdBundlesArtifactsLoading : artifactsLoading } > {tabDebugIdBundlesActive ? (debugIdBundlesArtifactsData?.files ?? []).map(data => { const downloadUrl = `${api.baseUrl}/projects/${organization.slug}/${ project.slug }/artifact-bundles/${encodeURIComponent(params.bundleId)}/files/${ data.id }/?download=1`; return ( {data.sourcemap ? ( {t('Sourcemap Reference:')} {data.sourcemap} ) : null} {data.debugId ? ( {t('Debug ID:')} {data.debugId} ) : null} } /> ); }) : artifactsData?.map(data => { const downloadUrl = `${api.baseUrl}/projects/${organization.slug}/${ project.slug }/releases/${encodeURIComponent(params.bundleId)}/files/${ data.id }/?download=1`; return ( {data.dist ?? t('none')} } /> ); })} ); } const StyledPanelTable = styled(PanelTable)<{hasTypeColumn: boolean}>` grid-template-columns: minmax(220px, 1fr) minmax(120px, max-content) minmax( 74px, max-content ); ${p => p.hasTypeColumn && ` grid-template-columns: minmax(220px, 1fr) minmax(120px, max-content) minmax(120px, max-content) minmax(74px, max-content); `} `; const Column = styled('div')` display: flex; align-items: center; overflow: hidden; `; const ActionsColumn = styled(Column)` justify-content: flex-end; `; const SearchBarWithMarginBottom = styled(SearchBar)` margin-bottom: ${space(3)}; `; const DetailsPanel = styled(Panel)` padding: ${space(1)} ${space(2)}; `; const ArtifactColumn = styled('div')` overflow-wrap: break-word; word-break: break-all; line-height: 140%; display: flex; flex-direction: column; justify-content: center; `; const Name = styled('div')` display: flex; justify-content: flex-start; align-items: center; `; const TypeColumn = styled('div')` display: flex; justify-content: flex-end; text-align: right; align-items: center; color: ${p => p.theme.subText}; `; const SizeColumn = styled('div')` display: flex; justify-content: flex-end; text-align: right; align-items: center; color: ${p => p.theme.subText}; `; const TimeAndDistWrapper = styled('div')` width: 100%; display: flex; margin-top: ${space(1)}; align-items: center; `; const TimeWrapper = styled('div')` display: grid; gap: ${space(0.5)}; grid-template-columns: min-content 1fr; font-size: ${p => p.theme.fontSizeMedium}; align-items: center; color: ${p => p.theme.subText}; `; const StyledTag = styled(Tag)` margin-left: ${space(1)}; `; const SubText = styled('span')` color: ${p => p.theme.subText}; `; const VersionAndDetails = styled('div')` display: flex; flex-direction: column; gap: ${space(1)}; word-break: break-word; `;