index.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641
  1. import {createRef, Fragment, PureComponent} from 'react';
  2. // eslint-disable-next-line no-restricted-imports
  3. import {withRouter, WithRouterProps} from 'react-router';
  4. import {
  5. AutoSizer,
  6. CellMeasurer,
  7. CellMeasurerCache,
  8. List,
  9. ListRowProps,
  10. } from 'react-virtualized';
  11. import styled from '@emotion/styled';
  12. import {openModal, openReprocessEventModal} from 'sentry/actionCreators/modal';
  13. import GuideAnchor from 'sentry/components/assistant/guideAnchor';
  14. import Button from 'sentry/components/button';
  15. import EventDataSection from 'sentry/components/events/eventDataSection';
  16. import {getImageRange, parseAddress} from 'sentry/components/events/interfaces/utils';
  17. import {PanelTable} from 'sentry/components/panels';
  18. import QuestionTooltip from 'sentry/components/questionTooltip';
  19. import {t} from 'sentry/locale';
  20. import DebugMetaStore, {DebugMetaActions} from 'sentry/stores/debugMetaStore';
  21. import space from 'sentry/styles/space';
  22. import {Group, Organization, Project} from 'sentry/types';
  23. import {Image, ImageStatus} from 'sentry/types/debugImage';
  24. import {Event} from 'sentry/types/event';
  25. import {defined} from 'sentry/utils';
  26. import SearchBarAction from '../searchBarAction';
  27. import Status from './debugImage/status';
  28. import DebugImage from './debugImage';
  29. import layout from './layout';
  30. import {
  31. combineStatus,
  32. getFileName,
  33. IMAGE_AND_CANDIDATE_LIST_MAX_HEIGHT,
  34. normalizeId,
  35. shouldSkipSection,
  36. } from './utils';
  37. const IMAGE_INFO_UNAVAILABLE = '-1';
  38. type DefaultProps = {
  39. data: {
  40. images: Array<Image | null>;
  41. };
  42. };
  43. type FilterOptions = NonNullable<
  44. React.ComponentProps<typeof SearchBarAction>['filterOptions']
  45. >;
  46. type Images = Array<React.ComponentProps<typeof DebugImage>['image']>;
  47. type Props = DefaultProps &
  48. WithRouterProps & {
  49. event: Event;
  50. organization: Organization;
  51. projectId: Project['id'];
  52. groupId?: Group['id'];
  53. };
  54. type State = {
  55. filterOptions: FilterOptions;
  56. filterSelections: FilterOptions;
  57. filteredImages: Images;
  58. filteredImagesByFilter: Images;
  59. filteredImagesBySearch: Images;
  60. isOpen: boolean;
  61. scrollbarWidth: number;
  62. searchTerm: string;
  63. panelTableHeight?: number;
  64. };
  65. const cache = new CellMeasurerCache({
  66. fixedWidth: true,
  67. defaultHeight: 81,
  68. });
  69. class DebugMeta extends PureComponent<Props, State> {
  70. static defaultProps: DefaultProps = {
  71. data: {images: []},
  72. };
  73. state: State = {
  74. searchTerm: '',
  75. scrollbarWidth: 0,
  76. isOpen: false,
  77. filterOptions: [],
  78. filterSelections: [],
  79. filteredImages: [],
  80. filteredImagesByFilter: [],
  81. filteredImagesBySearch: [],
  82. };
  83. componentDidMount() {
  84. this.unsubscribeFromDebugMetaStore = DebugMetaStore.listen(
  85. this.onDebugMetaStoreChange,
  86. undefined
  87. );
  88. cache.clearAll();
  89. this.getRelevantImages();
  90. this.openImageDetailsModal();
  91. }
  92. componentDidUpdate(_prevProps: Props, prevState: State) {
  93. if (
  94. this.state.isOpen ||
  95. (prevState.filteredImages.length === 0 && this.state.filteredImages.length > 0)
  96. ) {
  97. this.getPanelBodyHeight();
  98. }
  99. this.openImageDetailsModal();
  100. }
  101. componentWillUnmount() {
  102. if (this.unsubscribeFromDebugMetaStore) {
  103. this.unsubscribeFromDebugMetaStore();
  104. }
  105. }
  106. unsubscribeFromDebugMetaStore: any;
  107. panelTableRef = createRef<HTMLDivElement>();
  108. listRef: List | null = null;
  109. onDebugMetaStoreChange = (store: {filter: string}) => {
  110. const {searchTerm} = this.state;
  111. if (store.filter !== searchTerm) {
  112. this.setState({searchTerm: store.filter}, this.filterImagesBySearchTerm);
  113. }
  114. };
  115. getScrollbarWidth() {
  116. const panelTableWidth = this.panelTableRef?.current?.clientWidth ?? 0;
  117. const gridInnerWidth =
  118. this.panelTableRef?.current?.querySelector(
  119. '.ReactVirtualized__Grid__innerScrollContainer'
  120. )?.clientWidth ?? 0;
  121. const scrollbarWidth = panelTableWidth - gridInnerWidth;
  122. if (scrollbarWidth !== this.state.scrollbarWidth) {
  123. this.setState({scrollbarWidth});
  124. }
  125. }
  126. updateGrid = () => {
  127. if (this.listRef) {
  128. cache.clearAll();
  129. this.listRef.forceUpdateGrid();
  130. this.getScrollbarWidth();
  131. }
  132. };
  133. isValidImage(image: Image | null) {
  134. // in particular proguard images do not have a code file, skip them
  135. if (image === null || image.code_file === null || image.type === 'proguard') {
  136. return false;
  137. }
  138. if (getFileName(image.code_file) === 'dyld_sim') {
  139. // this is only for simulator builds
  140. return false;
  141. }
  142. return true;
  143. }
  144. filterImage(image: Image, searchTerm: string) {
  145. // When searching for an address, check for the address range of the image
  146. // instead of an exact match. Note that images cannot be found by index
  147. // if they are at 0x0. For those relative addressing has to be used.
  148. if (searchTerm.indexOf('0x') === 0) {
  149. const needle = parseAddress(searchTerm);
  150. if (needle > 0 && image.image_addr !== '0x0') {
  151. const [startAddress, endAddress] = getImageRange(image as any); // TODO(PRISCILA): remove any
  152. return needle >= startAddress && needle < endAddress;
  153. }
  154. }
  155. // the searchTerm ending at "!" is the end of the ID search.
  156. const relMatch = searchTerm.match(/^\s*(.*?)!/); // debug_id!address
  157. const idSearchTerm = normalizeId(relMatch?.[1] || searchTerm);
  158. return (
  159. // Prefix match for identifiers
  160. normalizeId(image.code_id).indexOf(idSearchTerm) === 0 ||
  161. normalizeId(image.debug_id).indexOf(idSearchTerm) === 0 ||
  162. // Any match for file paths
  163. (image.code_file?.toLowerCase() || '').indexOf(searchTerm) >= 0 ||
  164. (image.debug_file?.toLowerCase() || '').indexOf(searchTerm) >= 0
  165. );
  166. }
  167. filterImagesBySearchTerm() {
  168. const {filteredImages, filterSelections, searchTerm} = this.state;
  169. const filteredImagesBySearch = filteredImages.filter(image =>
  170. this.filterImage(image, searchTerm.toLowerCase())
  171. );
  172. const filteredImagesByFilter = this.getFilteredImagesByFilter(
  173. filteredImagesBySearch,
  174. filterSelections
  175. );
  176. this.setState(
  177. {
  178. filteredImagesBySearch,
  179. filteredImagesByFilter,
  180. },
  181. this.updateGrid
  182. );
  183. }
  184. openImageDetailsModal = async () => {
  185. const {filteredImages} = this.state;
  186. if (!filteredImages.length) {
  187. return;
  188. }
  189. const {location, organization, projectId: projSlug, groupId, event} = this.props;
  190. const {query} = location;
  191. const {imageCodeId, imageDebugId} = query;
  192. if (!imageCodeId && !imageDebugId) {
  193. return;
  194. }
  195. const image =
  196. imageCodeId !== IMAGE_INFO_UNAVAILABLE || imageDebugId !== IMAGE_INFO_UNAVAILABLE
  197. ? filteredImages.find(
  198. ({code_id, debug_id}) => code_id === imageCodeId || debug_id === imageDebugId
  199. )
  200. : undefined;
  201. const mod = await import(
  202. 'sentry/components/events/interfaces/debugMeta-v2/debugImageDetails'
  203. );
  204. const {default: Modal, modalCss} = mod;
  205. openModal(
  206. deps => (
  207. <Modal
  208. {...deps}
  209. image={image}
  210. organization={organization}
  211. projSlug={projSlug}
  212. event={event}
  213. onReprocessEvent={
  214. defined(groupId) ? this.handleReprocessEvent(groupId) : undefined
  215. }
  216. />
  217. ),
  218. {
  219. modalCss,
  220. onClose: this.handleCloseImageDetailsModal,
  221. }
  222. );
  223. };
  224. toggleImagesLoaded = () => {
  225. this.setState(state => ({
  226. isOpen: !state.isOpen,
  227. }));
  228. };
  229. getPanelBodyHeight() {
  230. const panelTableHeight = this.panelTableRef?.current?.offsetHeight;
  231. if (!panelTableHeight) {
  232. return;
  233. }
  234. this.setState({panelTableHeight});
  235. }
  236. getRelevantImages() {
  237. const {data} = this.props;
  238. const {images} = data;
  239. // There are a bunch of images in debug_meta that are not relevant to this
  240. // component. Filter those out to reduce the noise. Most importantly, this
  241. // includes proguard images, which are rendered separately.
  242. const relevantImages = images.filter(this.isValidImage);
  243. if (!relevantImages.length) {
  244. return;
  245. }
  246. const formattedRelevantImages = relevantImages.map(releventImage => {
  247. const {debug_status, unwind_status} = releventImage as Image;
  248. return {
  249. ...releventImage,
  250. status: combineStatus(debug_status, unwind_status),
  251. };
  252. }) as Images;
  253. // Sort images by their start address. We assume that images have
  254. // non-overlapping ranges. Each address is given as hex string (e.g.
  255. // "0xbeef").
  256. formattedRelevantImages.sort(
  257. (a, b) => parseAddress(a.image_addr) - parseAddress(b.image_addr)
  258. );
  259. const unusedImages: Images = [];
  260. const usedImages = formattedRelevantImages.filter(image => {
  261. if (image.debug_status === ImageStatus.UNUSED) {
  262. unusedImages.push(image as Images[0]);
  263. return false;
  264. }
  265. return true;
  266. }) as Images;
  267. const filteredImages = [...usedImages, ...unusedImages];
  268. const filterOptions = this.getFilterOptions(filteredImages);
  269. const defaultFilterSelections = (filterOptions[0].options ?? []).filter(
  270. opt => opt.value !== ImageStatus.UNUSED
  271. );
  272. this.setState({
  273. filteredImages,
  274. filterOptions,
  275. filterSelections: defaultFilterSelections,
  276. filteredImagesByFilter: this.getFilteredImagesByFilter(
  277. filteredImages,
  278. defaultFilterSelections
  279. ),
  280. filteredImagesBySearch: filteredImages,
  281. });
  282. }
  283. getFilterOptions(images: Images): FilterOptions {
  284. return [
  285. {
  286. value: 'status',
  287. label: t('Status'),
  288. options: [...new Set(images.map(image => image.status))].map(status => ({
  289. value: status,
  290. label: <Status status={status} />,
  291. })),
  292. },
  293. ];
  294. }
  295. getFilteredImagesByFilter(filteredImages: Images, filterOptions: FilterOptions) {
  296. const checkedOptions = new Set(filterOptions.map(option => option.value));
  297. if (![...checkedOptions].length) {
  298. return filteredImages;
  299. }
  300. return filteredImages.filter(image => checkedOptions.has(image.status));
  301. }
  302. handleChangeFilter = (filterSelections: FilterOptions) => {
  303. const {filteredImagesBySearch} = this.state;
  304. const filteredImagesByFilter = this.getFilteredImagesByFilter(
  305. filteredImagesBySearch,
  306. filterSelections
  307. );
  308. this.setState({filterSelections, filteredImagesByFilter}, this.updateGrid);
  309. };
  310. handleChangeSearchTerm = (searchTerm = '') => {
  311. DebugMetaActions.updateFilter(searchTerm);
  312. };
  313. handleResetFilter = () => {
  314. this.setState({filterSelections: []}, this.filterImagesBySearchTerm);
  315. };
  316. handleResetSearchBar = () => {
  317. this.setState(prevState => ({
  318. searchTerm: '',
  319. filteredImagesByFilter: prevState.filteredImages,
  320. filteredImagesBySearch: prevState.filteredImages,
  321. }));
  322. };
  323. handleOpenImageDetailsModal = (
  324. code_id: Image['code_id'],
  325. debug_id: Image['debug_id']
  326. ) => {
  327. const {location, router} = this.props;
  328. router.push({
  329. ...location,
  330. query: {
  331. ...location.query,
  332. imageCodeId: code_id ?? IMAGE_INFO_UNAVAILABLE,
  333. imageDebugId: debug_id ?? IMAGE_INFO_UNAVAILABLE,
  334. },
  335. });
  336. };
  337. handleCloseImageDetailsModal = () => {
  338. const {location, router} = this.props;
  339. router.push({
  340. ...location,
  341. query: {...location.query, imageCodeId: undefined, imageDebugId: undefined},
  342. });
  343. };
  344. handleReprocessEvent = (groupId: Group['id']) => () => {
  345. const {organization} = this.props;
  346. openReprocessEventModal({
  347. organization,
  348. groupId,
  349. onClose: this.openImageDetailsModal,
  350. });
  351. };
  352. renderRow = ({index, key, parent, style}: ListRowProps) => {
  353. const {filteredImagesByFilter: images} = this.state;
  354. return (
  355. <CellMeasurer
  356. cache={cache}
  357. columnIndex={0}
  358. key={key}
  359. parent={parent}
  360. rowIndex={index}
  361. >
  362. <DebugImage
  363. style={style}
  364. image={images[index]}
  365. onOpenImageDetailsModal={this.handleOpenImageDetailsModal}
  366. />
  367. </CellMeasurer>
  368. );
  369. };
  370. renderList() {
  371. const {filteredImagesByFilter: images, panelTableHeight} = this.state;
  372. if (!panelTableHeight) {
  373. return images.map((image, index) => (
  374. <DebugImage
  375. key={index}
  376. image={image}
  377. onOpenImageDetailsModal={this.handleOpenImageDetailsModal}
  378. />
  379. ));
  380. }
  381. return (
  382. <AutoSizer disableHeight onResize={this.updateGrid}>
  383. {({width}) => (
  384. <StyledList
  385. ref={(el: List | null) => {
  386. this.listRef = el;
  387. }}
  388. deferredMeasurementCache={cache}
  389. height={IMAGE_AND_CANDIDATE_LIST_MAX_HEIGHT}
  390. overscanRowCount={5}
  391. rowCount={images.length}
  392. rowHeight={cache.rowHeight}
  393. rowRenderer={this.renderRow}
  394. width={width}
  395. isScrolling={false}
  396. />
  397. )}
  398. </AutoSizer>
  399. );
  400. }
  401. getEmptyMessage() {
  402. const {searchTerm, filteredImagesByFilter: images, filterSelections} = this.state;
  403. if (!!images.length) {
  404. return {};
  405. }
  406. if (searchTerm && !images.length) {
  407. const hasActiveFilter = filterSelections.length > 0;
  408. return {
  409. emptyMessage: t('Sorry, no images match your search query'),
  410. emptyAction: hasActiveFilter ? (
  411. <Button onClick={this.handleResetFilter} priority="primary">
  412. {t('Reset filter')}
  413. </Button>
  414. ) : (
  415. <Button onClick={this.handleResetSearchBar} priority="primary">
  416. {t('Clear search bar')}
  417. </Button>
  418. ),
  419. };
  420. }
  421. return {
  422. emptyMessage: t('There are no images to be displayed'),
  423. };
  424. }
  425. render() {
  426. const {
  427. searchTerm,
  428. filterOptions,
  429. scrollbarWidth,
  430. isOpen,
  431. filterSelections,
  432. filteredImagesByFilter: filteredImages,
  433. } = this.state;
  434. const {data} = this.props;
  435. const {images} = data;
  436. if (shouldSkipSection(filteredImages, images)) {
  437. return null;
  438. }
  439. const showFilters = filterOptions.some(section => (section.options ?? []).length > 1);
  440. const actions = (
  441. <ToggleButton onClick={this.toggleImagesLoaded} priority="link">
  442. {isOpen ? t('Hide Details') : t('Show Details')}
  443. </ToggleButton>
  444. );
  445. return (
  446. <EventDataSection
  447. type="images-loaded"
  448. title={
  449. <TitleWrapper>
  450. <GuideAnchor target="images-loaded" position="bottom">
  451. <Title>{t('Images Loaded')}</Title>
  452. </GuideAnchor>
  453. <QuestionTooltip
  454. size="xs"
  455. position="top"
  456. title={t(
  457. 'A list of dynamic libraries or shared objects loaded into process memory at the time of the crash. Images contribute application code that is referenced in stack traces.'
  458. )}
  459. />
  460. </TitleWrapper>
  461. }
  462. actions={actions}
  463. wrapTitle={false}
  464. isCentered
  465. >
  466. {isOpen && (
  467. <Fragment>
  468. <StyledSearchBarAction
  469. placeholder={t('Search images loaded')}
  470. onChange={value => this.handleChangeSearchTerm(value)}
  471. query={searchTerm}
  472. filterOptions={showFilters ? filterOptions : undefined}
  473. onFilterChange={this.handleChangeFilter}
  474. filterSelections={filterSelections}
  475. />
  476. <StyledPanelTable
  477. isEmpty={!filteredImages.length}
  478. scrollbarWidth={scrollbarWidth}
  479. headers={[t('Status'), t('Image'), t('Processing'), t('Details'), '']}
  480. {...this.getEmptyMessage()}
  481. >
  482. <div ref={this.panelTableRef}>{this.renderList()}</div>
  483. </StyledPanelTable>
  484. </Fragment>
  485. )}
  486. </EventDataSection>
  487. );
  488. }
  489. }
  490. export default withRouter(DebugMeta);
  491. const StyledPanelTable = styled(PanelTable)<{scrollbarWidth?: number}>`
  492. overflow: hidden;
  493. > * {
  494. :nth-child(-n + 5) {
  495. ${p => p.theme.overflowEllipsis};
  496. border-bottom: 1px solid ${p => p.theme.border};
  497. :nth-child(5n) {
  498. height: 100%;
  499. ${p => !p.scrollbarWidth && `display: none`}
  500. }
  501. }
  502. :nth-child(n + 6) {
  503. grid-column: 1/-1;
  504. ${p =>
  505. !p.isEmpty &&
  506. `
  507. display: grid;
  508. padding: 0;
  509. `}
  510. }
  511. }
  512. ${p => layout(p.theme, p.scrollbarWidth)}
  513. `;
  514. const TitleWrapper = styled('div')`
  515. display: grid;
  516. grid-template-columns: max-content 1fr;
  517. gap: ${space(0.5)};
  518. align-items: center;
  519. padding: ${space(0.75)} 0;
  520. `;
  521. const Title = styled('h3')`
  522. margin-bottom: 0;
  523. padding: 0 !important;
  524. height: 14px;
  525. `;
  526. // XXX(ts): Emotion11 has some trouble with List's defaultProps
  527. const StyledList = styled(List as any)<React.ComponentProps<typeof List>>`
  528. height: auto !important;
  529. max-height: ${p => p.height}px;
  530. overflow-y: auto !important;
  531. outline: none;
  532. `;
  533. const StyledSearchBarAction = styled(SearchBarAction)`
  534. z-index: 1;
  535. margin-bottom: ${space(1)};
  536. `;
  537. const ToggleButton = styled(Button)`
  538. font-weight: 700;
  539. color: ${p => p.theme.subText};
  540. &:hover,
  541. &:focus {
  542. color: ${p => p.theme.textColor};
  543. }
  544. `;