mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
612 lines
16 KiB
TypeScript
612 lines
16 KiB
TypeScript
import {createRef, Fragment, PureComponent} from 'react';
|
|
import type {WithRouterProps} from 'react-router';
|
|
import type {ListRowProps} from 'react-virtualized';
|
|
import {AutoSizer, CellMeasurer, CellMeasurerCache, List} from 'react-virtualized';
|
|
import styled from '@emotion/styled';
|
|
|
|
import {openModal, openReprocessEventModal} from 'sentry/actionCreators/modal';
|
|
import {Button} from 'sentry/components/button';
|
|
import type {SelectOption, SelectSection} from 'sentry/components/compactSelect';
|
|
import {EventDataSection} from 'sentry/components/events/eventDataSection';
|
|
import {getImageRange, parseAddress} from 'sentry/components/events/interfaces/utils';
|
|
import PanelTable from 'sentry/components/panels/panelTable';
|
|
import {t} from 'sentry/locale';
|
|
import DebugMetaStore from 'sentry/stores/debugMetaStore';
|
|
import {space} from 'sentry/styles/space';
|
|
import type {Group, Organization, Project} from 'sentry/types';
|
|
import type {Image} from 'sentry/types/debugImage';
|
|
import {ImageStatus} from 'sentry/types/debugImage';
|
|
import type {Event} from 'sentry/types/event';
|
|
import {defined} from 'sentry/utils';
|
|
// eslint-disable-next-line no-restricted-imports
|
|
import withSentryRouter from 'sentry/utils/withSentryRouter';
|
|
import SectionToggleButton from 'sentry/views/issueDetails/sectionToggleButton';
|
|
|
|
import SearchBarAction from '../searchBarAction';
|
|
|
|
import Status from './debugImage/status';
|
|
import DebugImage from './debugImage';
|
|
import layout from './layout';
|
|
import {
|
|
combineStatus,
|
|
getFileName,
|
|
IMAGE_AND_CANDIDATE_LIST_MAX_HEIGHT,
|
|
normalizeId,
|
|
shouldSkipSection,
|
|
} from './utils';
|
|
|
|
const IMAGE_INFO_UNAVAILABLE = '-1';
|
|
|
|
type DefaultProps = {
|
|
data: {
|
|
images: Array<Image | null>;
|
|
};
|
|
};
|
|
|
|
type Images = Array<React.ComponentProps<typeof DebugImage>['image']>;
|
|
|
|
type Props = DefaultProps &
|
|
WithRouterProps & {
|
|
event: Event;
|
|
organization: Organization;
|
|
projectSlug: Project['slug'];
|
|
groupId?: Group['id'];
|
|
};
|
|
|
|
type State = {
|
|
filterOptions: SelectSection<string>[];
|
|
filterSelections: SelectOption<string>[];
|
|
filteredImages: Images;
|
|
filteredImagesByFilter: Images;
|
|
filteredImagesBySearch: Images;
|
|
isOpen: boolean;
|
|
scrollbarWidth: number;
|
|
searchTerm: string;
|
|
panelTableHeight?: number;
|
|
};
|
|
|
|
const cache = new CellMeasurerCache({
|
|
fixedWidth: true,
|
|
defaultHeight: 81,
|
|
});
|
|
|
|
class DebugMetaWithRouter extends PureComponent<Props, State> {
|
|
static defaultProps: DefaultProps = {
|
|
data: {images: []},
|
|
};
|
|
|
|
state: State = {
|
|
searchTerm: '',
|
|
scrollbarWidth: 0,
|
|
isOpen: false,
|
|
filterOptions: [],
|
|
filterSelections: [],
|
|
filteredImages: [],
|
|
filteredImagesByFilter: [],
|
|
filteredImagesBySearch: [],
|
|
};
|
|
|
|
componentDidMount() {
|
|
this.unsubscribeFromDebugMetaStore = DebugMetaStore.listen(
|
|
this.onDebugMetaStoreChange,
|
|
undefined
|
|
);
|
|
|
|
cache.clearAll();
|
|
this.getRelevantImages();
|
|
this.openImageDetailsModal();
|
|
}
|
|
|
|
componentDidUpdate(prevProps: Props, prevState: State) {
|
|
if (
|
|
this.state.isOpen ||
|
|
(prevState.filteredImages.length === 0 && this.state.filteredImages.length > 0)
|
|
) {
|
|
this.getPanelBodyHeight();
|
|
}
|
|
|
|
this.openImageDetailsModal();
|
|
|
|
if (this.props.event?.id !== prevProps.event?.id) {
|
|
this.getRelevantImages();
|
|
this.updateGrid();
|
|
}
|
|
}
|
|
|
|
componentWillUnmount() {
|
|
if (this.unsubscribeFromDebugMetaStore) {
|
|
this.unsubscribeFromDebugMetaStore();
|
|
}
|
|
}
|
|
|
|
unsubscribeFromDebugMetaStore: any;
|
|
|
|
panelTableRef = createRef<HTMLDivElement>();
|
|
listRef: List | null = null;
|
|
|
|
onDebugMetaStoreChange = (store: {filter: string}) => {
|
|
const {searchTerm} = this.state;
|
|
|
|
if (store.filter !== searchTerm) {
|
|
this.setState(
|
|
{searchTerm: store.filter, isOpen: true},
|
|
this.filterImagesBySearchTerm
|
|
);
|
|
}
|
|
};
|
|
|
|
getScrollbarWidth() {
|
|
const panelTableWidth = this.panelTableRef?.current?.clientWidth ?? 0;
|
|
|
|
const gridInnerWidth =
|
|
this.panelTableRef?.current?.querySelector(
|
|
'.ReactVirtualized__Grid__innerScrollContainer'
|
|
)?.clientWidth ?? 0;
|
|
|
|
const scrollbarWidth = panelTableWidth - gridInnerWidth;
|
|
|
|
if (scrollbarWidth !== this.state.scrollbarWidth) {
|
|
this.setState({scrollbarWidth});
|
|
}
|
|
}
|
|
|
|
updateGrid = () => {
|
|
if (this.listRef) {
|
|
cache.clearAll();
|
|
this.listRef.forceUpdateGrid();
|
|
this.getScrollbarWidth();
|
|
}
|
|
};
|
|
|
|
isValidImage(image: Image | null) {
|
|
// 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;
|
|
}
|
|
|
|
filterImage(image: Image, searchTerm: string) {
|
|
// 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 as any); // TODO(PRISCILA): remove any
|
|
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() || '').includes(searchTerm) ||
|
|
(image.debug_file?.toLowerCase() || '').includes(searchTerm)
|
|
);
|
|
}
|
|
|
|
filterImagesBySearchTerm() {
|
|
const {filteredImages, filterSelections, searchTerm} = this.state;
|
|
const filteredImagesBySearch = filteredImages.filter(image =>
|
|
this.filterImage(image, searchTerm.toLowerCase())
|
|
);
|
|
|
|
const filteredImagesByFilter = this.getFilteredImagesByFilter(
|
|
filteredImagesBySearch,
|
|
filterSelections
|
|
);
|
|
|
|
this.setState(
|
|
{
|
|
filteredImagesBySearch,
|
|
filteredImagesByFilter,
|
|
},
|
|
this.updateGrid
|
|
);
|
|
}
|
|
|
|
openImageDetailsModal = async () => {
|
|
const {filteredImages} = this.state;
|
|
|
|
if (!filteredImages.length) {
|
|
return;
|
|
}
|
|
|
|
const {location, organization, projectSlug, groupId, event} = this.props;
|
|
const {query} = location;
|
|
|
|
const {imageCodeId, imageDebugId} = query;
|
|
|
|
if (!imageCodeId && !imageDebugId) {
|
|
return;
|
|
}
|
|
|
|
const image =
|
|
imageCodeId !== IMAGE_INFO_UNAVAILABLE || imageDebugId !== IMAGE_INFO_UNAVAILABLE
|
|
? filteredImages.find(
|
|
({code_id, debug_id}) => code_id === imageCodeId || debug_id === imageDebugId
|
|
)
|
|
: undefined;
|
|
|
|
const mod = await import(
|
|
'sentry/components/events/interfaces/debugMeta/debugImageDetails'
|
|
);
|
|
|
|
const {DebugImageDetails, modalCss} = mod;
|
|
|
|
openModal(
|
|
deps => (
|
|
<DebugImageDetails
|
|
{...deps}
|
|
image={image}
|
|
organization={organization}
|
|
projSlug={projectSlug}
|
|
event={event}
|
|
onReprocessEvent={
|
|
defined(groupId) ? this.handleReprocessEvent(groupId) : undefined
|
|
}
|
|
/>
|
|
),
|
|
{
|
|
modalCss,
|
|
onClose: this.handleCloseImageDetailsModal,
|
|
}
|
|
);
|
|
};
|
|
|
|
toggleImagesLoaded = () => {
|
|
this.setState(state => ({
|
|
isOpen: !state.isOpen,
|
|
}));
|
|
};
|
|
|
|
getPanelBodyHeight() {
|
|
const panelTableHeight = this.panelTableRef?.current?.offsetHeight;
|
|
|
|
if (!panelTableHeight) {
|
|
return;
|
|
}
|
|
|
|
this.setState({panelTableHeight});
|
|
}
|
|
|
|
getRelevantImages() {
|
|
const {data} = this.props;
|
|
const {images} = data;
|
|
|
|
// 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 relevantImages = images.filter(this.isValidImage);
|
|
|
|
if (!relevantImages.length) {
|
|
return;
|
|
}
|
|
|
|
const formattedRelevantImages = relevantImages.map(releventImage => {
|
|
const {debug_status, unwind_status} = releventImage as Image;
|
|
return {
|
|
...releventImage,
|
|
status: combineStatus(debug_status, unwind_status),
|
|
};
|
|
}) as Images;
|
|
|
|
// Sort images by their start address. We assume that images have
|
|
// non-overlapping ranges. Each address is given as hex string (e.g.
|
|
// "0xbeef").
|
|
formattedRelevantImages.sort(
|
|
(a, b) => parseAddress(a.image_addr) - parseAddress(b.image_addr)
|
|
);
|
|
|
|
const unusedImages: Images = [];
|
|
|
|
const usedImages = formattedRelevantImages.filter(image => {
|
|
if (image.debug_status === ImageStatus.UNUSED) {
|
|
unusedImages.push(image as Images[0]);
|
|
return false;
|
|
}
|
|
return true;
|
|
}) as Images;
|
|
|
|
const filteredImages = [...usedImages, ...unusedImages];
|
|
|
|
const filterOptions = this.getFilterOptions(filteredImages);
|
|
const defaultFilterSelections = (
|
|
'options' in filterOptions[0] ? filterOptions[0].options : []
|
|
).filter(opt => opt.value !== ImageStatus.UNUSED);
|
|
|
|
this.setState({
|
|
filteredImages,
|
|
filterOptions,
|
|
filterSelections: defaultFilterSelections,
|
|
filteredImagesByFilter: this.getFilteredImagesByFilter(
|
|
filteredImages,
|
|
defaultFilterSelections
|
|
),
|
|
filteredImagesBySearch: filteredImages,
|
|
});
|
|
}
|
|
|
|
getFilterOptions(images: Images): SelectSection<string>[] {
|
|
return [
|
|
{
|
|
label: t('Status'),
|
|
options: [...new Set(images.map(image => image.status))].map(status => ({
|
|
value: status,
|
|
textValue: status,
|
|
label: <Status status={status} />,
|
|
})),
|
|
},
|
|
];
|
|
}
|
|
|
|
getFilteredImagesByFilter(
|
|
filteredImages: Images,
|
|
filterOptions: SelectOption<string>[]
|
|
) {
|
|
const checkedOptions = new Set(filterOptions.map(option => option.value));
|
|
|
|
if (![...checkedOptions].length) {
|
|
return filteredImages;
|
|
}
|
|
|
|
return filteredImages.filter(image => checkedOptions.has(image.status));
|
|
}
|
|
|
|
handleChangeFilter = (filterSelections: SelectOption<string>[]) => {
|
|
const {filteredImagesBySearch} = this.state;
|
|
const filteredImagesByFilter = this.getFilteredImagesByFilter(
|
|
filteredImagesBySearch,
|
|
filterSelections
|
|
);
|
|
|
|
this.setState({filterSelections, filteredImagesByFilter}, this.updateGrid);
|
|
};
|
|
|
|
handleChangeSearchTerm = (searchTerm = '') => {
|
|
DebugMetaStore.updateFilter(searchTerm);
|
|
};
|
|
|
|
handleResetFilter = () => {
|
|
this.setState({filterSelections: []}, this.filterImagesBySearchTerm);
|
|
};
|
|
|
|
handleResetSearchBar = () => {
|
|
this.setState(prevState => ({
|
|
searchTerm: '',
|
|
filteredImagesByFilter: prevState.filteredImages,
|
|
filteredImagesBySearch: prevState.filteredImages,
|
|
}));
|
|
};
|
|
|
|
handleOpenImageDetailsModal = (
|
|
code_id: Image['code_id'],
|
|
debug_id: Image['debug_id']
|
|
) => {
|
|
const {location, router} = this.props;
|
|
|
|
router.push({
|
|
...location,
|
|
query: {
|
|
...location.query,
|
|
imageCodeId: code_id ?? IMAGE_INFO_UNAVAILABLE,
|
|
imageDebugId: debug_id ?? IMAGE_INFO_UNAVAILABLE,
|
|
},
|
|
});
|
|
};
|
|
|
|
handleCloseImageDetailsModal = () => {
|
|
const {location, router} = this.props;
|
|
|
|
router.push({
|
|
...location,
|
|
query: {...location.query, imageCodeId: undefined, imageDebugId: undefined},
|
|
});
|
|
};
|
|
|
|
handleReprocessEvent = (groupId: Group['id']) => () => {
|
|
const {organization} = this.props;
|
|
openReprocessEventModal({
|
|
organization,
|
|
groupId,
|
|
onClose: this.openImageDetailsModal,
|
|
});
|
|
};
|
|
|
|
renderRow = ({index, key, parent, style}: ListRowProps) => {
|
|
const {filteredImagesByFilter: images} = this.state;
|
|
|
|
return (
|
|
<CellMeasurer
|
|
cache={cache}
|
|
columnIndex={0}
|
|
key={key}
|
|
parent={parent}
|
|
rowIndex={index}
|
|
>
|
|
<DebugImage
|
|
style={style}
|
|
image={images[index]}
|
|
onOpenImageDetailsModal={this.handleOpenImageDetailsModal}
|
|
/>
|
|
</CellMeasurer>
|
|
);
|
|
};
|
|
|
|
renderList() {
|
|
const {filteredImagesByFilter: images, panelTableHeight} = this.state;
|
|
|
|
if (!panelTableHeight) {
|
|
return images.map((image, index) => (
|
|
<DebugImage
|
|
key={index}
|
|
image={image}
|
|
onOpenImageDetailsModal={this.handleOpenImageDetailsModal}
|
|
/>
|
|
));
|
|
}
|
|
|
|
return (
|
|
<AutoSizer disableHeight onResize={this.updateGrid}>
|
|
{({width}) => (
|
|
<StyledList
|
|
ref={(el: List | null) => {
|
|
this.listRef = el;
|
|
}}
|
|
deferredMeasurementCache={cache}
|
|
height={IMAGE_AND_CANDIDATE_LIST_MAX_HEIGHT}
|
|
overscanRowCount={5}
|
|
rowCount={images.length}
|
|
rowHeight={cache.rowHeight}
|
|
rowRenderer={this.renderRow}
|
|
width={width}
|
|
isScrolling={false}
|
|
/>
|
|
)}
|
|
</AutoSizer>
|
|
);
|
|
}
|
|
|
|
getEmptyMessage() {
|
|
const {searchTerm, filteredImagesByFilter: images, filterSelections} = this.state;
|
|
|
|
if (images.length) {
|
|
return {};
|
|
}
|
|
|
|
if (searchTerm && !images.length) {
|
|
const hasActiveFilter = filterSelections.length > 0;
|
|
|
|
return {
|
|
emptyMessage: t('Sorry, no images match your search query'),
|
|
emptyAction: hasActiveFilter ? (
|
|
<Button onClick={this.handleResetFilter} priority="primary">
|
|
{t('Reset filter')}
|
|
</Button>
|
|
) : (
|
|
<Button onClick={this.handleResetSearchBar} priority="primary">
|
|
{t('Clear search bar')}
|
|
</Button>
|
|
),
|
|
};
|
|
}
|
|
|
|
return {
|
|
emptyMessage: t('There are no images to be displayed'),
|
|
};
|
|
}
|
|
|
|
render() {
|
|
const {
|
|
searchTerm,
|
|
filterOptions,
|
|
scrollbarWidth,
|
|
isOpen,
|
|
filterSelections,
|
|
filteredImagesByFilter: filteredImages,
|
|
} = this.state;
|
|
const {data} = this.props;
|
|
const {images} = data;
|
|
|
|
if (shouldSkipSection(filteredImages, images)) {
|
|
return null;
|
|
}
|
|
|
|
const showFilters = filterOptions.some(
|
|
section => 'options' in section && section.options.length > 1
|
|
);
|
|
|
|
const actions = (
|
|
<SectionToggleButton isExpanded={isOpen} onExpandChange={this.toggleImagesLoaded} />
|
|
);
|
|
|
|
return (
|
|
<EventDataSection
|
|
type="images-loaded"
|
|
guideTarget="images-loaded"
|
|
title={t('Images Loaded')}
|
|
help={t(
|
|
'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.'
|
|
)}
|
|
actions={actions}
|
|
>
|
|
{isOpen && (
|
|
<Fragment>
|
|
<StyledSearchBarAction
|
|
placeholder={t('Search images loaded')}
|
|
onChange={value => this.handleChangeSearchTerm(value)}
|
|
query={searchTerm}
|
|
filterOptions={showFilters ? filterOptions : undefined}
|
|
onFilterChange={this.handleChangeFilter}
|
|
filterSelections={filterSelections}
|
|
/>
|
|
<StyledPanelTable
|
|
isEmpty={!filteredImages.length}
|
|
scrollbarWidth={scrollbarWidth}
|
|
headers={[t('Status'), t('Image'), t('Processing'), t('Details'), '']}
|
|
{...this.getEmptyMessage()}
|
|
>
|
|
<div ref={this.panelTableRef}>{this.renderList()}</div>
|
|
</StyledPanelTable>
|
|
</Fragment>
|
|
)}
|
|
</EventDataSection>
|
|
);
|
|
}
|
|
}
|
|
|
|
export const DebugMeta = withSentryRouter(DebugMetaWithRouter);
|
|
|
|
const StyledPanelTable = styled(PanelTable)<{scrollbarWidth?: number}>`
|
|
overflow: hidden;
|
|
> * {
|
|
:nth-child(-n + 5) {
|
|
${p => p.theme.overflowEllipsis};
|
|
border-bottom: 1px solid ${p => p.theme.border};
|
|
:nth-child(5n) {
|
|
height: 100%;
|
|
${p => !p.scrollbarWidth && `display: none`}
|
|
}
|
|
}
|
|
|
|
:nth-child(n + 6) {
|
|
grid-column: 1/-1;
|
|
${p =>
|
|
!p.isEmpty &&
|
|
`
|
|
display: grid;
|
|
padding: 0;
|
|
`}
|
|
}
|
|
}
|
|
|
|
${p => layout(p.theme, p.scrollbarWidth)}
|
|
`;
|
|
|
|
// XXX(ts): Emotion11 has some trouble with List's defaultProps
|
|
const StyledList = styled(List as any)<React.ComponentProps<typeof List>>`
|
|
height: auto !important;
|
|
max-height: ${p => p.height}px;
|
|
overflow-y: auto !important;
|
|
outline: none;
|
|
`;
|
|
|
|
const StyledSearchBarAction = styled(SearchBarAction)`
|
|
z-index: 1;
|
|
margin-bottom: ${space(1)};
|
|
`;
|