projectSourceMapsArtifacts.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. import {Fragment, useCallback} from 'react';
  2. import type {RouteComponentProps} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import {Role} from 'sentry/components/acl/role';
  5. import Tag from 'sentry/components/badge/tag';
  6. import {LinkButton} from 'sentry/components/button';
  7. import FileSize from 'sentry/components/fileSize';
  8. import Link from 'sentry/components/links/link';
  9. import Pagination from 'sentry/components/pagination';
  10. import Panel from 'sentry/components/panels/panel';
  11. import {PanelTable} from 'sentry/components/panels/panelTable';
  12. import SearchBar from 'sentry/components/searchBar';
  13. import TimeSince from 'sentry/components/timeSince';
  14. import {Tooltip} from 'sentry/components/tooltip';
  15. import {IconClock, IconDownload} from 'sentry/icons';
  16. import {t, tct} from 'sentry/locale';
  17. import {space} from 'sentry/styles/space';
  18. import type {Artifact, DebugIdBundleArtifact, Project} from 'sentry/types';
  19. import {useApiQuery} from 'sentry/utils/queryClient';
  20. import {decodeScalar} from 'sentry/utils/queryString';
  21. import useApi from 'sentry/utils/useApi';
  22. import useOrganization from 'sentry/utils/useOrganization';
  23. import {normalizeUrl} from 'sentry/utils/withDomainRequired';
  24. import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
  25. import {DebugIdBundleDeleteButton} from 'sentry/views/settings/projectSourceMaps/debugIdBundleDeleteButton';
  26. import {DebugIdBundleDetails} from 'sentry/views/settings/projectSourceMaps/debugIdBundleDetails';
  27. import {useDeleteDebugIdBundle} from 'sentry/views/settings/projectSourceMaps/useDeleteDebugIdBundle';
  28. enum DebugIdBundleArtifactType {
  29. INVALID = 0,
  30. SOURCE = 1,
  31. MINIFIED_SOURCE = 2,
  32. SOURCE_MAP = 3,
  33. INDEXED_RAM_BUNDLE = 4,
  34. }
  35. const debugIdBundleTypeLabels = {
  36. [DebugIdBundleArtifactType.INVALID]: t('Invalid'),
  37. [DebugIdBundleArtifactType.SOURCE]: t('Source'),
  38. [DebugIdBundleArtifactType.MINIFIED_SOURCE]: t('Minified'),
  39. [DebugIdBundleArtifactType.SOURCE_MAP]: t('Source Map'),
  40. [DebugIdBundleArtifactType.INDEXED_RAM_BUNDLE]: t('Indexed RAM Bundle'),
  41. };
  42. function ArtifactsTableRow({
  43. name,
  44. downloadRole,
  45. downloadUrl,
  46. size,
  47. type,
  48. orgSlug,
  49. artifactColumnDetails,
  50. }: {
  51. artifactColumnDetails: React.ReactNode;
  52. downloadRole: string;
  53. downloadUrl: string;
  54. name: string;
  55. orgSlug: string;
  56. size: number;
  57. type?: string;
  58. }) {
  59. return (
  60. <Fragment>
  61. <ArtifactColumn>
  62. <Name>{name || `(${t('empty')})`}</Name>
  63. {artifactColumnDetails}
  64. </ArtifactColumn>
  65. {type && <TypeColumn>{type}</TypeColumn>}
  66. <SizeColumn>
  67. <FileSize bytes={size} />
  68. </SizeColumn>
  69. <ActionsColumn>
  70. <Role role={downloadRole}>
  71. {({hasRole}) => {
  72. return (
  73. <Tooltip
  74. title={tct(
  75. 'Artifacts can only be downloaded by users with organization [downloadRole] role[orHigher]. This can be changed in [settingsLink:Debug Files Access] settings.',
  76. {
  77. downloadRole,
  78. orHigher: downloadRole !== 'owner' ? ` ${t('or higher')}` : '',
  79. settingsLink: <Link to={`/settings/${orgSlug}/#debugFilesRole`} />,
  80. }
  81. )}
  82. disabled={hasRole}
  83. isHoverable
  84. >
  85. <LinkButton
  86. size="sm"
  87. icon={<IconDownload size="sm" />}
  88. disabled={!hasRole}
  89. href={downloadUrl}
  90. title={hasRole ? t('Download Artifact') : undefined}
  91. aria-label={t('Download Artifact')}
  92. />
  93. </Tooltip>
  94. );
  95. }}
  96. </Role>
  97. </ActionsColumn>
  98. </Fragment>
  99. );
  100. }
  101. type Props = RouteComponentProps<
  102. {bundleId: string; orgId: string; projectId: string},
  103. {}
  104. > & {
  105. project: Project;
  106. };
  107. export function ProjectSourceMapsArtifacts({params, location, router, project}: Props) {
  108. const api = useApi();
  109. const organization = useOrganization();
  110. // query params
  111. const query = decodeScalar(location.query.query);
  112. const cursor = location.query.cursor ?? '';
  113. // endpoints
  114. const artifactsEndpoint = `/projects/${organization.slug}/${
  115. project.slug
  116. }/releases/${encodeURIComponent(params.bundleId)}/files/`;
  117. const debugIdBundlesArtifactsEndpoint = `/projects/${organization.slug}/${
  118. project.slug
  119. }/artifact-bundles/${encodeURIComponent(params.bundleId)}/files/`;
  120. // debug id bundles tab url
  121. const debugIdsUrl = normalizeUrl(
  122. `/settings/${organization.slug}/projects/${project.slug}/source-maps/artifact-bundles/${params.bundleId}/`
  123. );
  124. const tabDebugIdBundlesActive = location.pathname === debugIdsUrl;
  125. const {
  126. data: artifactsData,
  127. getResponseHeader: artifactsHeaders,
  128. isLoading: artifactsLoading,
  129. } = useApiQuery<Artifact[]>(
  130. [
  131. artifactsEndpoint,
  132. {
  133. query: {query, cursor},
  134. },
  135. ],
  136. {
  137. staleTime: 0,
  138. keepPreviousData: true,
  139. enabled: !tabDebugIdBundlesActive,
  140. }
  141. );
  142. const {
  143. data: debugIdBundlesArtifactsData,
  144. getResponseHeader: debugIdBundlesArtifactsHeaders,
  145. isLoading: debugIdBundlesArtifactsLoading,
  146. } = useApiQuery<DebugIdBundleArtifact>(
  147. [
  148. debugIdBundlesArtifactsEndpoint,
  149. {
  150. query: {query, cursor},
  151. },
  152. ],
  153. {
  154. staleTime: 0,
  155. keepPreviousData: true,
  156. enabled: tabDebugIdBundlesActive,
  157. }
  158. );
  159. const {mutate: deleteDebugIdArtifacts} = useDeleteDebugIdBundle({
  160. onSuccess: () =>
  161. router.push(
  162. `/settings/${organization.slug}/projects/${project.slug}/source-maps/artifact-bundles/`
  163. ),
  164. });
  165. const handleDeleteDebugIdBundle = useCallback(() => {
  166. if (!debugIdBundlesArtifactsData) {
  167. return;
  168. }
  169. deleteDebugIdArtifacts({
  170. projectSlug: project.slug,
  171. bundleId: debugIdBundlesArtifactsData.bundleId,
  172. });
  173. }, [debugIdBundlesArtifactsData, deleteDebugIdArtifacts, project.slug]);
  174. const handleSearch = useCallback(
  175. (newQuery: string) => {
  176. router.push({
  177. ...location,
  178. query: {...location.query, cursor: undefined, query: newQuery},
  179. });
  180. },
  181. [router, location]
  182. );
  183. return (
  184. <Fragment>
  185. <SettingsPageHeader
  186. title={tabDebugIdBundlesActive ? params.bundleId : t('Release Bundle')}
  187. action={
  188. tabDebugIdBundlesActive && (
  189. <DebugIdBundleDeleteButton size="sm" onDelete={handleDeleteDebugIdBundle} />
  190. )
  191. }
  192. subtitle={
  193. !tabDebugIdBundlesActive && (
  194. <VersionAndDetails>{params.bundleId}</VersionAndDetails>
  195. )
  196. }
  197. />
  198. {tabDebugIdBundlesActive && debugIdBundlesArtifactsData && (
  199. <DetailsPanel>
  200. <DebugIdBundleDetails debugIdBundle={debugIdBundlesArtifactsData} />
  201. </DetailsPanel>
  202. )}
  203. <SearchBarWithMarginBottom
  204. placeholder={
  205. tabDebugIdBundlesActive ? t('Filter by Path or ID') : t('Filter by Path')
  206. }
  207. onSearch={handleSearch}
  208. query={query}
  209. />
  210. <StyledPanelTable
  211. hasTypeColumn={tabDebugIdBundlesActive}
  212. headers={[
  213. t('Artifact'),
  214. ...(tabDebugIdBundlesActive
  215. ? [<TypeColumn key="type">{t('Type')}</TypeColumn>]
  216. : []),
  217. <SizeColumn key="file-size">{t('File Size')}</SizeColumn>,
  218. '',
  219. ]}
  220. emptyMessage={
  221. query
  222. ? t('No artifacts match your search query.')
  223. : tabDebugIdBundlesActive
  224. ? t('There are no artifacts in this bundle.')
  225. : t('There are no artifacts in this archive.')
  226. }
  227. isEmpty={
  228. (tabDebugIdBundlesActive
  229. ? debugIdBundlesArtifactsData?.files ?? []
  230. : artifactsData ?? []
  231. ).length === 0
  232. }
  233. isLoading={
  234. tabDebugIdBundlesActive ? debugIdBundlesArtifactsLoading : artifactsLoading
  235. }
  236. >
  237. {tabDebugIdBundlesActive
  238. ? (debugIdBundlesArtifactsData?.files ?? []).map(data => {
  239. const downloadUrl = `${api.baseUrl}/projects/${organization.slug}/${
  240. project.slug
  241. }/artifact-bundles/${encodeURIComponent(params.bundleId)}/files/${
  242. data.id
  243. }/?download=1`;
  244. return (
  245. <ArtifactsTableRow
  246. key={data.id}
  247. size={data.fileSize}
  248. name={data.filePath}
  249. type={debugIdBundleTypeLabels[data.fileType]}
  250. downloadRole={organization.debugFilesRole}
  251. downloadUrl={downloadUrl}
  252. orgSlug={organization.slug}
  253. artifactColumnDetails={
  254. <Fragment>
  255. {data.sourcemap ? (
  256. <div>
  257. <SubText>{t('Sourcemap Reference:')}</SubText> {data.sourcemap}
  258. </div>
  259. ) : null}
  260. {data.debugId ? (
  261. <div>
  262. <SubText>{t('Debug ID:')}</SubText> {data.debugId}
  263. </div>
  264. ) : null}
  265. </Fragment>
  266. }
  267. />
  268. );
  269. })
  270. : artifactsData?.map(data => {
  271. const downloadUrl = `${api.baseUrl}/projects/${organization.slug}/${
  272. project.slug
  273. }/releases/${encodeURIComponent(params.bundleId)}/files/${
  274. data.id
  275. }/?download=1`;
  276. return (
  277. <ArtifactsTableRow
  278. key={data.id}
  279. size={data.size}
  280. name={data.name}
  281. downloadRole={organization.debugFilesRole}
  282. downloadUrl={downloadUrl}
  283. orgSlug={organization.slug}
  284. artifactColumnDetails={
  285. <TimeAndDistWrapper>
  286. <TimeWrapper>
  287. <IconClock size="sm" />
  288. <TimeSince date={data.dateCreated} />
  289. </TimeWrapper>
  290. <StyledTag
  291. type={data.dist ? 'info' : undefined}
  292. tooltipText={data.dist ? undefined : t('No distribution set')}
  293. >
  294. {data.dist ?? t('none')}
  295. </StyledTag>
  296. </TimeAndDistWrapper>
  297. }
  298. />
  299. );
  300. })}
  301. </StyledPanelTable>
  302. <Pagination
  303. pageLinks={
  304. tabDebugIdBundlesActive
  305. ? debugIdBundlesArtifactsHeaders?.('Link') ?? ''
  306. : artifactsHeaders?.('Link') ?? ''
  307. }
  308. />
  309. </Fragment>
  310. );
  311. }
  312. const StyledPanelTable = styled(PanelTable)<{hasTypeColumn: boolean}>`
  313. grid-template-columns: minmax(220px, 1fr) minmax(120px, max-content) minmax(
  314. 74px,
  315. max-content
  316. );
  317. ${p =>
  318. p.hasTypeColumn &&
  319. `
  320. grid-template-columns:
  321. minmax(220px, 1fr) minmax(120px, max-content) minmax(120px, max-content)
  322. minmax(74px, max-content);
  323. `}
  324. `;
  325. const Column = styled('div')`
  326. display: flex;
  327. align-items: center;
  328. overflow: hidden;
  329. `;
  330. const ActionsColumn = styled(Column)`
  331. justify-content: flex-end;
  332. `;
  333. const SearchBarWithMarginBottom = styled(SearchBar)`
  334. margin-bottom: ${space(3)};
  335. `;
  336. const DetailsPanel = styled(Panel)`
  337. padding: ${space(1)} ${space(2)};
  338. `;
  339. const ArtifactColumn = styled('div')`
  340. overflow-wrap: break-word;
  341. word-break: break-all;
  342. line-height: 140%;
  343. display: flex;
  344. flex-direction: column;
  345. justify-content: center;
  346. `;
  347. const Name = styled('div')`
  348. display: flex;
  349. justify-content: flex-start;
  350. align-items: center;
  351. `;
  352. const TypeColumn = styled('div')`
  353. display: flex;
  354. justify-content: flex-end;
  355. text-align: right;
  356. align-items: center;
  357. color: ${p => p.theme.subText};
  358. `;
  359. const SizeColumn = styled('div')`
  360. display: flex;
  361. justify-content: flex-end;
  362. text-align: right;
  363. align-items: center;
  364. color: ${p => p.theme.subText};
  365. `;
  366. const TimeAndDistWrapper = styled('div')`
  367. width: 100%;
  368. display: flex;
  369. margin-top: ${space(1)};
  370. align-items: center;
  371. `;
  372. const TimeWrapper = styled('div')`
  373. display: grid;
  374. gap: ${space(0.5)};
  375. grid-template-columns: min-content 1fr;
  376. font-size: ${p => p.theme.fontSizeMedium};
  377. align-items: center;
  378. color: ${p => p.theme.subText};
  379. `;
  380. const StyledTag = styled(Tag)`
  381. margin-left: ${space(1)};
  382. `;
  383. const SubText = styled('span')`
  384. color: ${p => p.theme.subText};
  385. `;
  386. const VersionAndDetails = styled('div')`
  387. display: flex;
  388. flex-direction: column;
  389. gap: ${space(1)};
  390. word-break: break-word;
  391. `;