index.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405
  1. import {Fragment} from 'react';
  2. import {css} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import partition from 'lodash/partition';
  5. import sortBy from 'lodash/sortBy';
  6. import {addErrorMessage} from 'sentry/actionCreators/indicator';
  7. import {ModalRenderProps} from 'sentry/actionCreators/modal';
  8. import AsyncComponent from 'sentry/components/asyncComponent';
  9. import {Button} from 'sentry/components/button';
  10. import ButtonBar from 'sentry/components/buttonBar';
  11. import {t} from 'sentry/locale';
  12. import {space} from 'sentry/styles/space';
  13. import {Organization, Project} from 'sentry/types';
  14. import {DebugFile, DebugFileFeature} from 'sentry/types/debugFiles';
  15. import {CandidateDownloadStatus, Image, ImageStatus} from 'sentry/types/debugImage';
  16. import {Event} from 'sentry/types/event';
  17. import {displayReprocessEventAction} from 'sentry/utils/displayReprocessEventAction';
  18. import theme from 'sentry/utils/theme';
  19. import {getFileType} from 'sentry/views/settings/projectDebugFiles/utils';
  20. import {getFileName} from '../utils';
  21. import Candidates from './candidates';
  22. import GeneralInfo from './generalInfo';
  23. import ReprocessAlert from './reprocessAlert';
  24. import {INTERNAL_SOURCE, INTERNAL_SOURCE_LOCATION} from './utils';
  25. type ImageCandidates = Image['candidates'];
  26. type Props = AsyncComponent['props'] &
  27. ModalRenderProps & {
  28. event: Event;
  29. organization: Organization;
  30. projSlug: Project['slug'];
  31. image?: Image & {status: ImageStatus};
  32. onReprocessEvent?: () => void;
  33. };
  34. type State = AsyncComponent['state'] & {
  35. debugFiles: Array<DebugFile> | null;
  36. };
  37. export class DebugImageDetails extends AsyncComponent<Props, State> {
  38. getDefaultState(): State {
  39. return {
  40. ...super.getDefaultState(),
  41. debugFiles: [],
  42. };
  43. }
  44. componentDidUpdate(prevProps: Props, prevState: State) {
  45. if (!prevProps.image && !!this.props.image) {
  46. this.remountComponent();
  47. }
  48. super.componentDidUpdate(prevProps, prevState);
  49. }
  50. getUploadedDebugFiles(candidates: ImageCandidates) {
  51. return candidates.find(candidate => candidate.source === INTERNAL_SOURCE);
  52. }
  53. getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
  54. const {organization, projSlug, image} = this.props;
  55. if (!image) {
  56. return [];
  57. }
  58. const {debug_id, candidates = []} = image;
  59. const hasUploadedDebugFiles = this.getUploadedDebugFiles(candidates);
  60. const endpoints: ReturnType<AsyncComponent['getEndpoints']> = [];
  61. if (hasUploadedDebugFiles) {
  62. endpoints.push([
  63. 'debugFiles',
  64. `/projects/${organization.slug}/${projSlug}/files/dsyms/?debug_id=${debug_id}`,
  65. {
  66. query: {
  67. // FIXME(swatinem): Ideally we should not filter here at all,
  68. // though Symbolicator does not currently report `bcsymbolmap` and `il2cpp`
  69. // candidates, and we would thus show bogus "unapplied" entries for those,
  70. // which would probably confuse people more than not seeing successfully
  71. // fetched candidates for those two types of files.
  72. file_formats: [
  73. 'breakpad',
  74. 'macho',
  75. 'elf',
  76. 'pe',
  77. 'pdb',
  78. 'sourcebundle',
  79. 'wasm',
  80. 'portablepdb',
  81. ],
  82. },
  83. },
  84. ]);
  85. }
  86. return endpoints;
  87. }
  88. sortCandidates(
  89. candidates: ImageCandidates,
  90. unAppliedCandidates: ImageCandidates
  91. ): ImageCandidates {
  92. const [noPermissionCandidates, restNoPermissionCandidates] = partition(
  93. candidates,
  94. candidate => candidate.download.status === CandidateDownloadStatus.NO_PERMISSION
  95. );
  96. const [malFormedCandidates, restMalFormedCandidates] = partition(
  97. restNoPermissionCandidates,
  98. candidate => candidate.download.status === CandidateDownloadStatus.MALFORMED
  99. );
  100. const [errorCandidates, restErrorCandidates] = partition(
  101. restMalFormedCandidates,
  102. candidate => candidate.download.status === CandidateDownloadStatus.ERROR
  103. );
  104. const [okCandidates, restOKCandidates] = partition(
  105. restErrorCandidates,
  106. candidate => candidate.download.status === CandidateDownloadStatus.OK
  107. );
  108. const [deletedCandidates, notFoundCandidates] = partition(
  109. restOKCandidates,
  110. candidate => candidate.download.status === CandidateDownloadStatus.DELETED
  111. );
  112. return [
  113. ...sortBy(noPermissionCandidates, ['source_name', 'location']),
  114. ...sortBy(malFormedCandidates, ['source_name', 'location']),
  115. ...sortBy(errorCandidates, ['source_name', 'location']),
  116. ...sortBy(okCandidates, ['source_name', 'location']),
  117. ...sortBy(deletedCandidates, ['source_name', 'location']),
  118. ...sortBy(unAppliedCandidates, ['source_name', 'location']),
  119. ...sortBy(notFoundCandidates, ['source_name', 'location']),
  120. ];
  121. }
  122. getCandidates() {
  123. const {debugFiles, loading} = this.state;
  124. const {image} = this.props;
  125. const {candidates = []} = image ?? {};
  126. if (!debugFiles || loading) {
  127. return candidates;
  128. }
  129. const debugFileCandidates = candidates.map(({location, ...candidate}) => ({
  130. ...candidate,
  131. location: location?.includes(INTERNAL_SOURCE_LOCATION)
  132. ? location.split(INTERNAL_SOURCE_LOCATION)[1]
  133. : location,
  134. }));
  135. const candidateLocations = new Set(
  136. debugFileCandidates.map(({location}) => location).filter(location => !!location)
  137. );
  138. const [unAppliedDebugFiles, appliedDebugFiles] = partition(
  139. debugFiles,
  140. debugFile => !candidateLocations.has(debugFile.id)
  141. );
  142. const unAppliedCandidates = unAppliedDebugFiles.map(debugFile => {
  143. const {
  144. data,
  145. symbolType,
  146. objectName: filename,
  147. id: location,
  148. size,
  149. dateCreated,
  150. cpuName,
  151. } = debugFile;
  152. const features = data?.features ?? [];
  153. return {
  154. download: {
  155. status: CandidateDownloadStatus.UNAPPLIED,
  156. features: {
  157. has_sources: features.includes(DebugFileFeature.SOURCES),
  158. has_debug_info: features.includes(DebugFileFeature.DEBUG),
  159. has_unwind_info: features.includes(DebugFileFeature.UNWIND),
  160. has_symbols: features.includes(DebugFileFeature.SYMTAB),
  161. },
  162. },
  163. cpuName,
  164. location,
  165. filename,
  166. size,
  167. dateCreated,
  168. symbolType,
  169. fileType: getFileType(debugFile),
  170. source: INTERNAL_SOURCE,
  171. source_name: t('Sentry'),
  172. };
  173. });
  174. const [debugFileInternalOkCandidates, debugFileOtherCandidates] = partition(
  175. debugFileCandidates,
  176. debugFileCandidate =>
  177. debugFileCandidate.download.status === CandidateDownloadStatus.OK &&
  178. debugFileCandidate.source === INTERNAL_SOURCE
  179. );
  180. const convertedDebugFileInternalOkCandidates = debugFileInternalOkCandidates.map(
  181. debugFileOkCandidate => {
  182. const internalDebugFileInfo = appliedDebugFiles.find(
  183. appliedDebugFile => appliedDebugFile.id === debugFileOkCandidate.location
  184. );
  185. if (!internalDebugFileInfo) {
  186. return {
  187. ...debugFileOkCandidate,
  188. download: {
  189. ...debugFileOkCandidate.download,
  190. status: CandidateDownloadStatus.DELETED,
  191. },
  192. };
  193. }
  194. const {
  195. symbolType,
  196. objectName: filename,
  197. id: location,
  198. size,
  199. dateCreated,
  200. cpuName,
  201. } = internalDebugFileInfo;
  202. return {
  203. ...debugFileOkCandidate,
  204. cpuName,
  205. location,
  206. filename,
  207. size,
  208. dateCreated,
  209. symbolType,
  210. fileType: getFileType(internalDebugFileInfo),
  211. };
  212. }
  213. );
  214. return this.sortCandidates(
  215. [
  216. ...convertedDebugFileInternalOkCandidates,
  217. ...debugFileOtherCandidates,
  218. ] as ImageCandidates,
  219. unAppliedCandidates as ImageCandidates
  220. );
  221. }
  222. handleDelete = async (debugId: string) => {
  223. const {organization, projSlug} = this.props;
  224. this.setState({loading: true});
  225. try {
  226. await this.api.requestPromise(
  227. `/projects/${organization.slug}/${projSlug}/files/dsyms/?id=${debugId}`,
  228. {method: 'DELETE'}
  229. );
  230. this.fetchData();
  231. } catch {
  232. addErrorMessage(t('An error occurred while deleting the debug file.'));
  233. this.setState({loading: false});
  234. }
  235. };
  236. getDebugFilesSettingsLink() {
  237. const {organization, projSlug, image} = this.props;
  238. const orgSlug = organization.slug;
  239. const debugId = image?.debug_id;
  240. if (!orgSlug || !projSlug || !debugId) {
  241. return undefined;
  242. }
  243. return `/settings/${orgSlug}/projects/${projSlug}/debug-symbols/?query=${debugId}`;
  244. }
  245. renderBody() {
  246. const {Header, Body, Footer, image, organization, projSlug, event, onReprocessEvent} =
  247. this.props;
  248. const {loading} = this.state;
  249. const {code_file, status} = image ?? {};
  250. const debugFilesSettingsLink = this.getDebugFilesSettingsLink();
  251. const candidates = this.getCandidates();
  252. const baseUrl = this.api.baseUrl;
  253. const fileName = getFileName(code_file);
  254. const haveCandidatesUnappliedDebugFile = candidates.some(
  255. candidate => candidate.download.status === CandidateDownloadStatus.UNAPPLIED
  256. );
  257. const hasReprocessWarning =
  258. haveCandidatesUnappliedDebugFile &&
  259. displayReprocessEventAction(organization.features, event) &&
  260. !!onReprocessEvent;
  261. return (
  262. <Fragment>
  263. <Header closeButton>
  264. <Title>
  265. {t('Image')}
  266. <FileName>{fileName ?? t('Unknown')}</FileName>
  267. </Title>
  268. </Header>
  269. <Body>
  270. <Content>
  271. <GeneralInfo image={image} />
  272. {hasReprocessWarning && (
  273. <ReprocessAlert
  274. api={this.api}
  275. orgSlug={organization.slug}
  276. projSlug={projSlug}
  277. eventId={event.id}
  278. onReprocessEvent={onReprocessEvent}
  279. />
  280. )}
  281. <Candidates
  282. imageStatus={status}
  283. candidates={candidates}
  284. organization={organization}
  285. projSlug={projSlug}
  286. baseUrl={baseUrl}
  287. isLoading={loading}
  288. eventDateReceived={event.dateReceived}
  289. onDelete={this.handleDelete}
  290. hasReprocessWarning={hasReprocessWarning}
  291. />
  292. </Content>
  293. </Body>
  294. <Footer>
  295. <StyledButtonBar gap={1}>
  296. <Button
  297. href="https://docs.sentry.io/platforms/native/data-management/debug-files/"
  298. external
  299. >
  300. {t('Read the docs')}
  301. </Button>
  302. {debugFilesSettingsLink && (
  303. <Button
  304. title={t(
  305. 'Search for this debug file in all images for the %s project',
  306. projSlug
  307. )}
  308. to={debugFilesSettingsLink}
  309. >
  310. {t('Open in Settings')}
  311. </Button>
  312. )}
  313. </StyledButtonBar>
  314. </Footer>
  315. </Fragment>
  316. );
  317. }
  318. }
  319. const Content = styled('div')`
  320. display: grid;
  321. gap: ${space(3)};
  322. font-size: ${p => p.theme.fontSizeMedium};
  323. `;
  324. const Title = styled('div')`
  325. display: grid;
  326. grid-template-columns: max-content 1fr;
  327. gap: ${space(1)};
  328. align-items: center;
  329. font-size: ${p => p.theme.fontSizeExtraLarge};
  330. max-width: calc(100% - 40px);
  331. word-break: break-all;
  332. `;
  333. const FileName = styled('span')`
  334. font-family: ${p => p.theme.text.familyMono};
  335. `;
  336. const StyledButtonBar = styled(ButtonBar)`
  337. white-space: nowrap;
  338. `;
  339. export const modalCss = css`
  340. [role='document'] {
  341. overflow: initial;
  342. }
  343. @media (min-width: ${theme.breakpoints.small}) {
  344. width: 90%;
  345. }
  346. @media (min-width: ${theme.breakpoints.xlarge}) {
  347. width: 70%;
  348. }
  349. @media (min-width: ${theme.breakpoints.xxlarge}) {
  350. width: 50%;
  351. }
  352. `;