index.tsx 17 KB

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