projectSourceMapsArtifacts.tsx 11 KB

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