123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531 |
- import {createRef, Fragment, PureComponent} from 'react';
- import {
- AutoSizer,
- CellMeasurer,
- CellMeasurerCache,
- List,
- ListRowProps,
- } from 'react-virtualized';
- import styled from '@emotion/styled';
- import isEqual from 'lodash/isEqual';
- import isNil from 'lodash/isNil';
- import GuideAnchor from 'sentry/components/assistant/guideAnchor';
- import Button from 'sentry/components/button';
- import Checkbox from 'sentry/components/checkbox';
- import EventDataSection from 'sentry/components/events/eventDataSection';
- import {getImageRange, parseAddress} from 'sentry/components/events/interfaces/utils';
- import {Panel, PanelBody} from 'sentry/components/panels';
- import SearchBar from 'sentry/components/searchBar';
- import {IconWarning} from 'sentry/icons';
- import {t, tct} from 'sentry/locale';
- import DebugMetaStore, {DebugMetaActions} from 'sentry/stores/debugMetaStore';
- import space from 'sentry/styles/space';
- import {Frame, Organization, Project} from 'sentry/types';
- import {Event} from 'sentry/types/event';
- import EmptyMessage from 'sentry/views/settings/components/emptyMessage';
- import {shouldSkipSection} from '../debugMeta-v2/utils';
- import DebugImage from './debugImage';
- import ImageForBar from './imageForBar';
- import {getFileName} from './utils';
- const MIN_FILTER_LEN = 3;
- const PANEL_MAX_HEIGHT = 400;
- type Image = React.ComponentProps<typeof DebugImage>['image'];
- type DefaultProps = {
- data: {
- images: Array<Image>;
- };
- };
- type Props = DefaultProps & {
- event: Event;
- organization: Organization;
- projectId: Project['id'];
- };
- type State = {
- debugImages: Array<Image>;
- filter: string;
- filteredImages: Array<Image>;
- showDetails: boolean;
- showUnused: boolean;
- foundFrame?: Frame;
- panelBodyHeight?: number;
- };
- function normalizeId(id: string | undefined): string {
- return id ? id.trim().toLowerCase().replace(/[- ]/g, '') : '';
- }
- const cache = new CellMeasurerCache({
- fixedWidth: true,
- defaultHeight: 81,
- });
- class DebugMeta extends PureComponent<Props, State> {
- static defaultProps: DefaultProps = {
- data: {images: []},
- };
- state: State = {
- filter: '',
- debugImages: [],
- filteredImages: [],
- showUnused: false,
- showDetails: false,
- };
- componentDidMount() {
- this.unsubscribeFromStore = DebugMetaStore.listen(this.onStoreChange, undefined);
- cache.clearAll();
- this.filterImages();
- }
- componentDidUpdate(_prevProps: Props, prevState: State) {
- if (
- prevState.showUnused !== this.state.showUnused ||
- prevState.filter !== this.state.filter
- ) {
- this.filterImages();
- }
- if (
- !isEqual(prevState.foundFrame, this.state.foundFrame) ||
- this.state.showDetails !== prevState.showDetails ||
- prevState.showUnused !== this.state.showUnused ||
- (prevState.filter && !this.state.filter)
- ) {
- this.updateGrid();
- }
- if (prevState.filteredImages.length === 0 && this.state.filteredImages.length > 0) {
- this.getPanelBodyHeight();
- }
- }
- componentWillUnmount() {
- if (this.unsubscribeFromStore) {
- this.unsubscribeFromStore();
- }
- }
- unsubscribeFromStore: any;
- panelBodyRef = createRef<HTMLDivElement>();
- listRef: List | null = null;
- updateGrid() {
- cache.clearAll();
- this.listRef?.forceUpdateGrid();
- }
- getPanelBodyHeight() {
- const panelBodyHeight = this.panelBodyRef?.current?.offsetHeight;
- if (!panelBodyHeight) {
- return;
- }
- this.setState({panelBodyHeight});
- }
- onStoreChange = (store: {filter: string}) => {
- this.setState({
- filter: store.filter,
- });
- };
- filterImage(image: Image) {
- const {showUnused, filter} = this.state;
- const searchTerm = filter.trim().toLowerCase();
- if (searchTerm.length < MIN_FILTER_LEN) {
- if (showUnused) {
- return true;
- }
- // A debug status of `null` indicates that this information is not yet
- // available in an old event. Default to showing the image.
- if (image.debug_status !== 'unused') {
- return true;
- }
- // An unwind status of `null` indicates that symbolicator did not unwind.
- // Ignore the status in this case.
- if (!isNil(image.unwind_status) && image.unwind_status !== 'unused') {
- return true;
- }
- return false;
- }
- // When searching for an address, check for the address range of the image
- // instead of an exact match. Note that images cannot be found by index
- // if they are at 0x0. For those relative addressing has to be used.
- if (searchTerm.indexOf('0x') === 0) {
- const needle = parseAddress(searchTerm);
- if (needle > 0 && image.image_addr !== '0x0') {
- const [startAddress, endAddress] = getImageRange(image);
- return needle >= startAddress && needle < endAddress;
- }
- }
- // the searchTerm ending at "!" is the end of the ID search.
- const relMatch = searchTerm.match(/^\s*(.*?)!/); // debug_id!address
- const idSearchTerm = normalizeId(relMatch?.[1] || searchTerm);
- return (
- // Prefix match for identifiers
- normalizeId(image.code_id).indexOf(idSearchTerm) === 0 ||
- normalizeId(image.debug_id).indexOf(idSearchTerm) === 0 ||
- // Any match for file paths
- (image.code_file?.toLowerCase() || '').indexOf(searchTerm) >= 0 ||
- (image.debug_file?.toLowerCase() || '').indexOf(searchTerm) >= 0
- );
- }
- filterImages() {
- const foundFrame = this.getFrame();
- // skip null values indicating invalid debug images
- const debugImages = this.getDebugImages();
- if (!debugImages.length) {
- return;
- }
- const filteredImages = debugImages.filter(image => this.filterImage(image));
- this.setState({debugImages, filteredImages, foundFrame});
- }
- isValidImage(image: Image) {
- // in particular proguard images do not have a code file, skip them
- if (image === null || image.code_file === null || image.type === 'proguard') {
- return false;
- }
- if (getFileName(image.code_file) === 'dyld_sim') {
- // this is only for simulator builds
- return false;
- }
- return true;
- }
- getFrame(): Frame | undefined {
- const {
- event: {entries},
- } = this.props;
- const frames: Array<Frame> | undefined = entries.find(
- ({type}) => type === 'exception'
- )?.data?.values?.[0]?.stacktrace?.frames;
- if (!frames) {
- return undefined;
- }
- const searchTerm = normalizeId(this.state.filter);
- const relMatch = searchTerm.match(/^\s*(.*?)!(.*)$/); // debug_id!address
- if (relMatch) {
- const debugImages = this.getDebugImages().map(
- (image, idx) => [idx, image] as [number, Image]
- );
- const filteredImages = debugImages.filter(([_, image]) => this.filterImage(image));
- if (filteredImages.length === 1) {
- return frames.find(
- frame =>
- frame.addrMode === `rel:${filteredImages[0][0]}` &&
- frame.instructionAddr?.toLowerCase() === relMatch[2]
- );
- }
- return undefined;
- }
- return frames.find(frame => frame.instructionAddr?.toLowerCase() === searchTerm);
- }
- getDebugImages() {
- const {
- data: {images},
- } = this.props;
- // There are a bunch of images in debug_meta that are not relevant to this
- // component. Filter those out to reduce the noise. Most importantly, this
- // includes proguard images, which are rendered separately.
- const filtered = images.filter(image => this.isValidImage(image));
- // Sort images by their start address. We assume that images have
- // non-overlapping ranges. Each address is given as hex string (e.g.
- // "0xbeef").
- filtered.sort((a, b) => parseAddress(a.image_addr) - parseAddress(b.image_addr));
- return filtered;
- }
- getNoImagesMessage() {
- const {filter, showUnused, debugImages} = this.state;
- if (debugImages.length === 0) {
- return t('No loaded images available.');
- }
- if (!showUnused && !filter) {
- return tct(
- 'No images are referenced in the stack trace. [toggle: Show Unreferenced]',
- {
- toggle: (
- <Button
- priority="link"
- onClick={this.handleShowUnused}
- aria-label={t('Show Unreferenced')}
- />
- ),
- }
- );
- }
- return t('Sorry, no images match your query.');
- }
- renderToolbar() {
- const {filter, showDetails, showUnused} = this.state;
- return (
- <ToolbarWrapper>
- <Label>
- <Checkbox checked={showDetails} onChange={this.handleChangeShowDetails} />
- {t('details')}
- </Label>
- <Label>
- <Checkbox
- checked={showUnused || !!filter}
- disabled={!!filter}
- onChange={this.handleChangeShowUnused}
- />
- {t('show unreferenced')}
- </Label>
- <SearchInputWrapper>
- <StyledSearchBar
- onChange={this.handleChangeFilter}
- query={filter}
- placeholder={t('Search images\u2026')}
- />
- </SearchInputWrapper>
- </ToolbarWrapper>
- );
- }
- renderRow = ({index, key, parent, style}: ListRowProps) => {
- const {organization, projectId} = this.props;
- const {filteredImages, showDetails} = this.state;
- return (
- <CellMeasurer
- cache={cache}
- columnIndex={0}
- key={key}
- parent={parent}
- rowIndex={index}
- >
- <DebugImage
- style={style}
- image={filteredImages[index]}
- organization={organization}
- projectId={projectId}
- showDetails={showDetails}
- />
- </CellMeasurer>
- );
- };
- getListHeight() {
- const {showUnused, showDetails, panelBodyHeight} = this.state;
- if (
- !panelBodyHeight ||
- panelBodyHeight > PANEL_MAX_HEIGHT ||
- showUnused ||
- showDetails
- ) {
- return PANEL_MAX_HEIGHT;
- }
- return panelBodyHeight;
- }
- renderImageList() {
- const {filteredImages, showDetails, panelBodyHeight} = this.state;
- const {organization, projectId} = this.props;
- if (!panelBodyHeight) {
- return filteredImages.map(filteredImage => (
- <DebugImage
- key={filteredImage.debug_id}
- image={filteredImage}
- organization={organization}
- projectId={projectId}
- showDetails={showDetails}
- />
- ));
- }
- return (
- <AutoSizer disableHeight>
- {({width}) => (
- <StyledList
- ref={(el: List | null) => {
- this.listRef = el;
- }}
- deferredMeasurementCache={cache}
- height={this.getListHeight()}
- overscanRowCount={5}
- rowCount={filteredImages.length}
- rowHeight={cache.rowHeight}
- rowRenderer={this.renderRow}
- width={width}
- isScrolling={false}
- />
- )}
- </AutoSizer>
- );
- }
- handleChangeShowUnused = (event: React.ChangeEvent<HTMLInputElement>) => {
- const showUnused = event.target.checked;
- this.setState({showUnused});
- };
- handleShowUnused = () => {
- this.setState({showUnused: true});
- };
- handleChangeShowDetails = (event: React.ChangeEvent<HTMLInputElement>) => {
- const showDetails = event.target.checked;
- this.setState({showDetails});
- };
- handleChangeFilter = (value = '') => {
- DebugMetaActions.updateFilter(value);
- };
- render() {
- const {filteredImages, foundFrame} = this.state;
- const {data} = this.props;
- const {images} = data;
- if (shouldSkipSection(filteredImages, images)) {
- return null;
- }
- return (
- <EventDataSection
- type="images-loaded"
- title={
- <GuideAnchor target="images-loaded" position="bottom">
- <h3>{t('Images Loaded')}</h3>
- </GuideAnchor>
- }
- actions={this.renderToolbar()}
- wrapTitle={false}
- isCentered
- >
- <DebugImagesPanel>
- {filteredImages.length > 0 ? (
- <Fragment>
- {foundFrame && (
- <ImageForBar
- frame={foundFrame}
- onShowAllImages={this.handleChangeFilter}
- />
- )}
- <PanelBody ref={this.panelBodyRef}>{this.renderImageList()}</PanelBody>
- </Fragment>
- ) : (
- <EmptyMessage icon={<IconWarning size="xl" />}>
- {this.getNoImagesMessage()}
- </EmptyMessage>
- )}
- </DebugImagesPanel>
- </EventDataSection>
- );
- }
- }
- export default DebugMeta;
- // XXX(ts): Emotion11 has some trouble with List's defaultProps
- //
- // It gives the list have a dynamic height; otherwise, in the case of filtered
- // options, a list will be displayed with an empty space
- const StyledList = styled(List as any)<React.ComponentProps<typeof List>>`
- height: auto !important;
- max-height: ${p => p.height}px;
- outline: none;
- `;
- const Label = styled('label')`
- font-weight: normal;
- margin-right: 1em;
- margin-bottom: 0;
- white-space: nowrap;
- > input {
- margin-right: 1ex;
- }
- `;
- const DebugImagesPanel = styled(Panel)`
- margin-bottom: ${space(1)};
- max-height: ${PANEL_MAX_HEIGHT}px;
- overflow: hidden;
- `;
- const ToolbarWrapper = styled('div')`
- display: flex;
- align-items: center;
- @media (max-width: ${p => p.theme.breakpoints.small}) {
- flex-wrap: wrap;
- margin-top: ${space(1)};
- }
- `;
- const SearchInputWrapper = styled('div')`
- width: 100%;
- @media (max-width: ${p => p.theme.breakpoints.small}) {
- width: 100%;
- max-width: 100%;
- margin-top: ${space(1)};
- }
- @media (min-width: ${p => p.theme.breakpoints.small}) and (max-width: ${p =>
- p.theme.breakpoints.xlarge}) {
- max-width: 180px;
- display: inline-block;
- }
- @media (min-width: ${props => props.theme.breakpoints.xlarge}) {
- width: 330px;
- max-width: none;
- }
- @media (min-width: 1550px) {
- width: 510px;
- }
- `;
- // TODO(matej): remove this once we refactor SearchBar to not use css classes
- // - it could accept size as a prop
- const StyledSearchBar = styled(SearchBar)`
- .search-input {
- height: 30px;
- }
- `;
|