sourceMapsList.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396
  1. import {Fragment, useCallback, useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import Access from 'sentry/components/acl/access';
  4. import {Button, type ButtonProps} from 'sentry/components/button';
  5. import Confirm from 'sentry/components/confirm';
  6. import {DateTime} from 'sentry/components/dateTime';
  7. import EmptyMessage from 'sentry/components/emptyMessage';
  8. import KeyValueList from 'sentry/components/events/interfaces/keyValueList';
  9. import ExternalLink from 'sentry/components/links/externalLink';
  10. import Link from 'sentry/components/links/link';
  11. import LoadingIndicator from 'sentry/components/loadingIndicator';
  12. import Pagination from 'sentry/components/pagination';
  13. import Panel from 'sentry/components/panels/panel';
  14. import SearchBar from 'sentry/components/searchBar';
  15. import {Tooltip} from 'sentry/components/tooltip';
  16. import Version from 'sentry/components/version';
  17. import {IconDelete, IconUpload} from 'sentry/icons';
  18. import {t, tct} from 'sentry/locale';
  19. import {space} from 'sentry/styles/space';
  20. import type {KeyValueListData} from 'sentry/types/group';
  21. import type {RouteComponentProps} from 'sentry/types/legacyReactRouter';
  22. import type {Organization} from 'sentry/types/organization';
  23. import type {Project} from 'sentry/types/project';
  24. import type {SourceMapsArchive} from 'sentry/types/release';
  25. import type {DebugIdBundle, DebugIdBundleAssociation} from 'sentry/types/sourceMaps';
  26. import {keepPreviousData, useApiQuery} from 'sentry/utils/queryClient';
  27. import {decodeScalar} from 'sentry/utils/queryString';
  28. import useOrganization from 'sentry/utils/useOrganization';
  29. import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
  30. import TextBlock from 'sentry/views/settings/components/text/textBlock';
  31. import {useDeleteDebugIdBundle} from 'sentry/views/settings/projectSourceMaps/useDeleteDebugIdBundle';
  32. type Props = RouteComponentProps<
  33. {orgId: string; projectId: string; bundleId?: string},
  34. {}
  35. > & {
  36. project: Project;
  37. };
  38. type SourceMapUpload = {
  39. associations: DebugIdBundleAssociation[];
  40. date: string;
  41. dateModified: string;
  42. fileCount: number;
  43. id: string; // bundleId or release name
  44. type: 'debugId' | 'release';
  45. };
  46. function mergeReleaseAndDebugIdBundles(
  47. releases: SourceMapsArchive[] | undefined,
  48. debugIdBundles: DebugIdBundle[] | undefined
  49. ): SourceMapUpload[] {
  50. const debugIdUploads: SourceMapUpload[] = (debugIdBundles ?? []).map(debugIdBundle => ({
  51. ...debugIdBundle,
  52. id: debugIdBundle.bundleId,
  53. type: 'debugId',
  54. }));
  55. const nonEmptyReleases = (releases ?? []).filter(release => release.fileCount > 0);
  56. const releaseUploads: SourceMapUpload[] = nonEmptyReleases.map(release => ({
  57. associations: [{dist: null, release: release.name}],
  58. date: release.date,
  59. dateModified: release.date,
  60. fileCount: release.fileCount,
  61. type: 'release',
  62. id: release.name,
  63. }));
  64. return [...debugIdUploads, ...releaseUploads] as SourceMapUpload[];
  65. }
  66. interface UseSourceMapUploadsProps {
  67. cursor: string | undefined;
  68. organization: Organization;
  69. project: Project;
  70. query: string | undefined;
  71. }
  72. function useSourceMapUploads({
  73. organization,
  74. project,
  75. query,
  76. cursor,
  77. }: UseSourceMapUploadsProps) {
  78. const {
  79. data: archivesData,
  80. getResponseHeader: archivesHeaders,
  81. isPending: archivesLoading,
  82. refetch: archivesRefetch,
  83. } = useApiQuery<SourceMapsArchive[]>(
  84. [
  85. `/projects/${organization.slug}/${project.slug}/files/source-maps/`,
  86. {
  87. query: {query, cursor, sortBy: '-date_added'},
  88. },
  89. ],
  90. {
  91. staleTime: 0,
  92. placeholderData: keepPreviousData,
  93. }
  94. );
  95. const {
  96. data: debugIdBundlesData,
  97. getResponseHeader: debugIdBundlesHeaders,
  98. isPending: debugIdBundlesLoading,
  99. refetch: debugIdBundlesRefetch,
  100. } = useApiQuery<DebugIdBundle[]>(
  101. [
  102. `/projects/${organization.slug}/${project.slug}/files/artifact-bundles/`,
  103. {
  104. query: {query, cursor, sortBy: '-date_added'},
  105. },
  106. ],
  107. {
  108. staleTime: 0,
  109. placeholderData: keepPreviousData,
  110. }
  111. );
  112. return {
  113. data: mergeReleaseAndDebugIdBundles(archivesData, debugIdBundlesData),
  114. headers: (header: string) => {
  115. return debugIdBundlesHeaders?.(header) ?? archivesHeaders?.(header);
  116. },
  117. isPending: archivesLoading || debugIdBundlesLoading,
  118. refetch: () => {
  119. archivesRefetch();
  120. debugIdBundlesRefetch();
  121. },
  122. };
  123. }
  124. export function SourceMapsList({location, router, project}: Props) {
  125. const organization = useOrganization();
  126. const query = decodeScalar(location.query.query);
  127. const cursor = location.query.cursor ?? '';
  128. const {
  129. data: sourceMapUploads,
  130. headers,
  131. isPending,
  132. refetch,
  133. } = useSourceMapUploads({
  134. organization,
  135. project,
  136. query,
  137. cursor,
  138. });
  139. const {mutate: deleteSourceMaps} = useDeleteDebugIdBundle({
  140. onSuccess: () => refetch(),
  141. });
  142. const handleSearch = useCallback(
  143. (newQuery: string) => {
  144. router.push({
  145. ...location,
  146. query: {...location.query, cursor: undefined, query: newQuery},
  147. });
  148. },
  149. [router, location]
  150. );
  151. return (
  152. <Fragment>
  153. <SettingsPageHeader title={t('Source Map Uploads')} />
  154. <TextBlock>
  155. {tct(
  156. `These source map archives help Sentry identify where to look when code is minified. By providing this information, you can get better context for your stack traces when debugging. To learn more about source maps, [link: read the docs].`,
  157. {
  158. link: (
  159. <ExternalLink href="https://docs.sentry.io/platforms/javascript/sourcemaps/" />
  160. ),
  161. }
  162. )}
  163. </TextBlock>
  164. <SearchBarWithMarginBottom
  165. placeholder={t('Filter by Debug ID or Upload ID')}
  166. onSearch={handleSearch}
  167. query={query}
  168. />
  169. <SourceMapUploadsList
  170. project={project}
  171. sourceMapUploads={sourceMapUploads}
  172. isLoading={isPending}
  173. emptyMessage={t('No source map uploads found')}
  174. onDelete={id => {
  175. deleteSourceMaps({bundleId: id, projectSlug: project.slug});
  176. }}
  177. />
  178. <Pagination pageLinks={headers?.('Link') ?? ''} />
  179. </Fragment>
  180. );
  181. }
  182. interface SourceMapUploadsListProps {
  183. emptyMessage: React.ReactNode;
  184. isLoading: boolean;
  185. onDelete: (id: string) => void;
  186. project: Project;
  187. sourceMapUploads?: SourceMapUpload[];
  188. }
  189. export function SourceMapUploadsList({
  190. isLoading,
  191. sourceMapUploads,
  192. emptyMessage,
  193. onDelete,
  194. project,
  195. }: SourceMapUploadsListProps) {
  196. const organization = useOrganization();
  197. const sourceMapUploadDetailLink = useCallback(
  198. (sourceMapUpload: SourceMapUpload) => {
  199. return `/settings/${organization.slug}/projects/${project.slug}/source-maps/${encodeURIComponent(sourceMapUpload.id)}/`;
  200. },
  201. [organization, project]
  202. );
  203. if (isLoading) {
  204. return <LoadingIndicator />;
  205. }
  206. if (!sourceMapUploads || sourceMapUploads.length === 0) {
  207. return <EmptyMessage>{emptyMessage}</EmptyMessage>;
  208. }
  209. return (
  210. <List>
  211. {sourceMapUploads.map(sourceMapUpload => (
  212. <Item key={sourceMapUpload.id}>
  213. <ItemHeader>
  214. <ItemTitle to={sourceMapUploadDetailLink(sourceMapUpload)}>
  215. <IconUpload />
  216. {tct('[date] ([fileCount] files)', {
  217. date: <DateTime year date={sourceMapUpload.date} />,
  218. fileCount: sourceMapUpload.fileCount,
  219. })}
  220. </ItemTitle>
  221. <SourceMapUploadDeleteButton
  222. onDelete={
  223. sourceMapUpload.type === 'debugId'
  224. ? () => onDelete(sourceMapUpload.id)
  225. : undefined
  226. }
  227. />
  228. </ItemHeader>
  229. <ItemContent>
  230. <SourceMapUploadDetails sourceMapUpload={sourceMapUpload} />
  231. </ItemContent>
  232. </Item>
  233. ))}
  234. </List>
  235. );
  236. }
  237. export function SourceMapUploadDetails({
  238. sourceMapUpload,
  239. }: {
  240. sourceMapUpload: SourceMapUpload;
  241. }) {
  242. const [showAll, setShowAll] = useState(false);
  243. const detailsData = useMemo<KeyValueListData>(() => {
  244. const rows = sourceMapUpload.associations;
  245. const visibleAssociations = showAll ? rows : rows.slice(0, 3);
  246. return [
  247. {
  248. key: 'id',
  249. subject: t('Upload ID'),
  250. value: sourceMapUpload.id,
  251. },
  252. {
  253. key: 'releases',
  254. subject: t('Found in Releases'),
  255. actionButton: rows.length > 3 && (
  256. <Button size="xs" onClick={() => setShowAll(value => !value)}>
  257. {showAll ? t('Show Less') : t('Show All')}
  258. </Button>
  259. ),
  260. value:
  261. rows.length > 0 ? (
  262. <ReleasesWrapper className="val-string-multiline">
  263. {visibleAssociations.map(association => (
  264. <Fragment key={association.release}>
  265. <Version version={association.release ?? association.dist} />
  266. {association.dist && `(Dist: ${formatDist(association.dist)})`}
  267. </Fragment>
  268. ))}
  269. </ReleasesWrapper>
  270. ) : (
  271. t('No releases associated with this upload.')
  272. ),
  273. },
  274. ];
  275. }, [sourceMapUpload, showAll]);
  276. return <StyledKeyValueList data={detailsData} shouldSort={false} />;
  277. }
  278. const formatDist = (dist: string | string[] | null) => {
  279. if (Array.isArray(dist)) {
  280. return dist.join(', ');
  281. }
  282. if (dist === null) {
  283. return t('none');
  284. }
  285. return dist;
  286. };
  287. interface SourceMapUploadDeleteButtonProps {
  288. onDelete?: () => void;
  289. size?: ButtonProps['size'];
  290. }
  291. export function SourceMapUploadDeleteButton({
  292. onDelete,
  293. }: SourceMapUploadDeleteButtonProps) {
  294. const tooltipTitle = useCallback((hasAccess: boolean, canDelete: boolean) => {
  295. if (hasAccess) {
  296. if (canDelete) {
  297. return t('Delete Source Maps');
  298. }
  299. return t('Source maps cannot be deleted.');
  300. }
  301. return t('You do not have permission to delete Source Maps.');
  302. }, []);
  303. return (
  304. <Access access={['project:releases']}>
  305. {({hasAccess}) => (
  306. <Tooltip
  307. disabled={hasAccess && !!onDelete}
  308. title={tooltipTitle(hasAccess, !!onDelete)}
  309. >
  310. <Confirm
  311. onConfirm={onDelete}
  312. message={t('Are you sure you want to delete Source Maps?')}
  313. disabled={!hasAccess || !onDelete}
  314. >
  315. <Button icon={<IconDelete size="xs" />} size="xs" disabled={!hasAccess}>
  316. {t('Delete Source Maps')}
  317. </Button>
  318. </Confirm>
  319. </Tooltip>
  320. )}
  321. </Access>
  322. );
  323. }
  324. const ReleasesWrapper = styled('pre')`
  325. max-height: 200px;
  326. `;
  327. const StyledKeyValueList = styled(KeyValueList)`
  328. && {
  329. margin-bottom: 0;
  330. }
  331. `;
  332. const List = styled('div')`
  333. display: grid;
  334. grid-template-columns: 1fr;
  335. gap: ${space(2)};
  336. `;
  337. const Item = styled(Panel)`
  338. margin: 0;
  339. `;
  340. const ItemHeader = styled('div')`
  341. display: flex;
  342. align-items: center;
  343. justify-content: space-between;
  344. font-size: ${p => p.theme.fontSizeMedium};
  345. border-bottom: 1px solid ${p => p.theme.border};
  346. line-height: 1;
  347. padding: ${space(1)} ${space(2)};
  348. `;
  349. const ItemTitle = styled(Link)`
  350. display: flex;
  351. align-items: center;
  352. gap: ${space(1)};
  353. `;
  354. const ItemContent = styled('div')`
  355. padding: ${space(1)} ${space(2)};
  356. `;
  357. const SearchBarWithMarginBottom = styled(SearchBar)`
  358. margin-bottom: ${space(3)};
  359. `;