candidates.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417
  1. import {Component} from 'react';
  2. import styled from '@emotion/styled';
  3. import debounce from 'lodash/debounce';
  4. import isEqual from 'lodash/isEqual';
  5. import pick from 'lodash/pick';
  6. import Button from 'sentry/components/button';
  7. import ExternalLink from 'sentry/components/links/externalLink';
  8. import PanelTable from 'sentry/components/panels/panelTable';
  9. import QuestionTooltip from 'sentry/components/questionTooltip';
  10. import {t, tct} from 'sentry/locale';
  11. import space from 'sentry/styles/space';
  12. import {Organization, Project} from 'sentry/types';
  13. import {CandidateDownloadStatus, Image, ImageStatus} from 'sentry/types/debugImage';
  14. import {defined} from 'sentry/utils';
  15. import SearchBarAction from '../../searchBarAction';
  16. import Status from './candidate/status';
  17. import Candidate from './candidate';
  18. import {INTERNAL_SOURCE} from './utils';
  19. const filterOptionCategories = {
  20. status: t('Status'),
  21. source: t('Source'),
  22. };
  23. type FilterOptions = NonNullable<
  24. React.ComponentProps<typeof SearchBarAction>['filterOptions']
  25. >;
  26. type ImageCandidates = Image['candidates'];
  27. type Props = {
  28. baseUrl: string;
  29. candidates: ImageCandidates;
  30. hasReprocessWarning: boolean;
  31. isLoading: boolean;
  32. onDelete: (debugId: string) => void;
  33. organization: Organization;
  34. projSlug: Project['slug'];
  35. eventDateReceived?: string;
  36. imageStatus?: ImageStatus;
  37. };
  38. type State = {
  39. filterOptions: FilterOptions;
  40. filterSelections: FilterOptions;
  41. filteredCandidatesByFilter: ImageCandidates;
  42. filteredCandidatesBySearch: ImageCandidates;
  43. searchTerm: string;
  44. };
  45. class Candidates extends Component<Props, State> {
  46. state: State = {
  47. searchTerm: '',
  48. filterOptions: [],
  49. filterSelections: [],
  50. filteredCandidatesBySearch: [],
  51. filteredCandidatesByFilter: [],
  52. };
  53. componentDidMount() {
  54. this.getFilters();
  55. }
  56. componentDidUpdate(prevProps: Props, prevState: State) {
  57. if (!isEqual(prevProps.candidates, this.props.candidates)) {
  58. this.getFilters();
  59. return;
  60. }
  61. if (prevState.searchTerm !== this.state.searchTerm) {
  62. this.doSearch();
  63. }
  64. }
  65. filterCandidatesBySearch() {
  66. const {searchTerm, filterSelections} = this.state;
  67. const {candidates} = this.props;
  68. if (!searchTerm.trim()) {
  69. const filteredCandidatesByFilter = this.getFilteredCandidatedByFilter(
  70. candidates,
  71. filterSelections
  72. );
  73. this.setState({
  74. filteredCandidatesBySearch: candidates,
  75. filteredCandidatesByFilter,
  76. });
  77. return;
  78. }
  79. // Slightly hacky, but it works
  80. // the string is being `stringfy`d here in order to match exactly the same `stringfy`d string of the loop
  81. const searchFor = JSON.stringify(searchTerm)
  82. // it replaces double backslash generate by JSON.stringfy with single backslash
  83. .replace(/((^")|("$))/g, '')
  84. .toLocaleLowerCase();
  85. const filteredCandidatesBySearch = candidates.filter(obj =>
  86. Object.keys(pick(obj, ['source_name', 'location'])).some(key => {
  87. const info = obj[key];
  88. if (key === 'location' && typeof Number(info) === 'number') {
  89. return false;
  90. }
  91. if (!defined(info) || !String(info).trim()) {
  92. return false;
  93. }
  94. return JSON.stringify(info)
  95. .replace(/((^")|("$))/g, '')
  96. .toLocaleLowerCase()
  97. .trim()
  98. .includes(searchFor);
  99. })
  100. );
  101. const filteredCandidatesByFilter = this.getFilteredCandidatedByFilter(
  102. filteredCandidatesBySearch,
  103. filterSelections
  104. );
  105. this.setState({
  106. filteredCandidatesBySearch,
  107. filteredCandidatesByFilter,
  108. });
  109. }
  110. doSearch = debounce(this.filterCandidatesBySearch, 300);
  111. getFilters() {
  112. const {imageStatus} = this.props;
  113. const candidates = [...this.props.candidates];
  114. const filterOptions = this.getFilterOptions(candidates);
  115. const defaultFilterSelections = (
  116. filterOptions.find(section => section.value === 'status')?.options ?? []
  117. ).filter(
  118. opt =>
  119. opt.value !== `status-${CandidateDownloadStatus.NOT_FOUND}` ||
  120. imageStatus === ImageStatus.MISSING
  121. );
  122. this.setState({
  123. filterOptions,
  124. filterSelections: defaultFilterSelections,
  125. filteredCandidatesBySearch: candidates,
  126. filteredCandidatesByFilter: this.getFilteredCandidatedByFilter(
  127. candidates,
  128. defaultFilterSelections
  129. ),
  130. });
  131. }
  132. getFilterOptions(candidates: ImageCandidates) {
  133. const filterOptions: FilterOptions = [];
  134. const candidateStatus = [
  135. ...new Set(candidates.map(candidate => candidate.download.status)),
  136. ];
  137. if (candidateStatus.length > 1) {
  138. filterOptions.push({
  139. value: 'status',
  140. label: filterOptionCategories.status,
  141. options: candidateStatus.map(status => ({
  142. value: `status-${status}`,
  143. label: <Status status={status} />,
  144. })),
  145. });
  146. }
  147. const candidateSources = [
  148. ...new Set(candidates.map(candidate => candidate.source_name ?? t('Unknown'))),
  149. ];
  150. if (candidateSources.length > 1) {
  151. filterOptions.push({
  152. value: 'source',
  153. label: filterOptionCategories.source,
  154. options: candidateSources.map(sourceName => ({
  155. value: `source-${sourceName}`,
  156. label: sourceName,
  157. })),
  158. });
  159. }
  160. return filterOptions as FilterOptions;
  161. }
  162. getFilteredCandidatedByFilter(
  163. candidates: ImageCandidates,
  164. filterOptions: FilterOptions
  165. ) {
  166. const checkedStatusOptions = new Set(
  167. filterOptions
  168. .filter(option => option.value.split('-')[0] === 'status')
  169. .map(option => option.value.split('-')[1])
  170. );
  171. const checkedSourceOptions = new Set(
  172. filterOptions
  173. .filter(option => option.value.split('-')[0] === 'source')
  174. .map(option => option.value.split('-')[1])
  175. );
  176. if (filterOptions.length === 0) {
  177. return candidates;
  178. }
  179. if (checkedStatusOptions.size > 0) {
  180. const filteredByStatus = candidates.filter(candidate =>
  181. checkedStatusOptions.has(candidate.download.status)
  182. );
  183. if (checkedSourceOptions.size === 0) {
  184. return filteredByStatus;
  185. }
  186. return filteredByStatus.filter(candidate =>
  187. checkedSourceOptions.has(candidate?.source_name ?? '')
  188. );
  189. }
  190. return candidates.filter(candidate =>
  191. checkedSourceOptions.has(candidate?.source_name ?? '')
  192. );
  193. }
  194. getEmptyMessage() {
  195. const {searchTerm, filteredCandidatesByFilter: images, filterSelections} = this.state;
  196. if (!!images.length) {
  197. return {};
  198. }
  199. const hasActiveFilter = filterSelections.length > 0;
  200. if (searchTerm || hasActiveFilter) {
  201. return {
  202. emptyMessage: t('Sorry, no debug files match your search query'),
  203. emptyAction: hasActiveFilter ? (
  204. <Button onClick={this.handleResetFilter} priority="primary">
  205. {t('Reset filter')}
  206. </Button>
  207. ) : (
  208. <Button onClick={this.handleResetSearchBar} priority="primary">
  209. {t('Clear search bar')}
  210. </Button>
  211. ),
  212. };
  213. }
  214. return {
  215. emptyMessage: t('There are no debug files to be displayed'),
  216. };
  217. }
  218. handleChangeSearchTerm = (searchTerm = '') => {
  219. this.setState({searchTerm});
  220. };
  221. handleChangeFilter = (filterSelections: FilterOptions) => {
  222. const {filteredCandidatesBySearch} = this.state;
  223. const filteredCandidatesByFilter = this.getFilteredCandidatedByFilter(
  224. filteredCandidatesBySearch,
  225. filterSelections
  226. );
  227. this.setState({filterSelections, filteredCandidatesByFilter});
  228. };
  229. handleResetFilter = () => {
  230. this.setState({filterSelections: []}, this.filterCandidatesBySearch);
  231. };
  232. handleResetSearchBar = () => {
  233. const {candidates} = this.props;
  234. this.setState({
  235. searchTerm: '',
  236. filteredCandidatesByFilter: candidates,
  237. filteredCandidatesBySearch: candidates,
  238. });
  239. };
  240. render() {
  241. const {
  242. organization,
  243. projSlug,
  244. baseUrl,
  245. onDelete,
  246. isLoading,
  247. candidates,
  248. eventDateReceived,
  249. hasReprocessWarning,
  250. } = this.props;
  251. const {searchTerm, filterOptions, filterSelections, filteredCandidatesByFilter} =
  252. this.state;
  253. const haveCandidatesOkOrDeletedDebugFile = candidates.some(
  254. candidate =>
  255. (candidate.download.status === CandidateDownloadStatus.OK &&
  256. candidate.source === INTERNAL_SOURCE) ||
  257. candidate.download.status === CandidateDownloadStatus.DELETED
  258. );
  259. const haveCandidatesAtLeastOneAction =
  260. haveCandidatesOkOrDeletedDebugFile || hasReprocessWarning;
  261. return (
  262. <Wrapper>
  263. <Header>
  264. <Title>
  265. {t('Debug File Candidates')}
  266. <QuestionTooltip
  267. title={tct(
  268. 'These are the Debug Information Files (DIFs) corresponding to this image which have been looked up on [docLink:symbol servers] during the processing of the stacktrace.',
  269. {
  270. docLink: (
  271. <ExternalLink href="https://docs.sentry.io/platforms/native/data-management/debug-files/symbol-servers/" />
  272. ),
  273. }
  274. )}
  275. size="xs"
  276. position="top"
  277. isHoverable
  278. />
  279. </Title>
  280. {!!candidates.length && (
  281. <StyledSearchBarAction
  282. query={searchTerm}
  283. onChange={value => this.handleChangeSearchTerm(value)}
  284. placeholder={t('Search debug file candidates')}
  285. filterOptions={filterOptions}
  286. filterSelections={filterSelections}
  287. onFilterChange={this.handleChangeFilter}
  288. />
  289. )}
  290. </Header>
  291. <StyledPanelTable
  292. headers={
  293. haveCandidatesAtLeastOneAction
  294. ? [t('Status'), t('Information'), '']
  295. : [t('Status'), t('Information')]
  296. }
  297. isEmpty={!filteredCandidatesByFilter.length}
  298. isLoading={isLoading}
  299. {...this.getEmptyMessage()}
  300. >
  301. {filteredCandidatesByFilter.map((candidate, index) => (
  302. <Candidate
  303. key={index}
  304. candidate={candidate}
  305. organization={organization}
  306. baseUrl={baseUrl}
  307. projSlug={projSlug}
  308. eventDateReceived={eventDateReceived}
  309. hasReprocessWarning={hasReprocessWarning}
  310. haveCandidatesAtLeastOneAction={haveCandidatesAtLeastOneAction}
  311. onDelete={onDelete}
  312. />
  313. ))}
  314. </StyledPanelTable>
  315. </Wrapper>
  316. );
  317. }
  318. }
  319. export default Candidates;
  320. const Wrapper = styled('div')`
  321. display: grid;
  322. `;
  323. const Header = styled('div')`
  324. display: flex;
  325. flex-direction: column;
  326. @media (min-width: ${props => props.theme.breakpoints.small}) {
  327. flex-wrap: wrap;
  328. flex-direction: row;
  329. }
  330. `;
  331. const Title = styled('div')`
  332. padding-right: ${space(4)};
  333. display: grid;
  334. gap: ${space(0.5)};
  335. grid-template-columns: repeat(2, max-content);
  336. align-items: center;
  337. font-weight: 600;
  338. color: ${p => p.theme.gray400};
  339. height: 32px;
  340. flex: 1;
  341. @media (min-width: ${props => props.theme.breakpoints.small}) {
  342. margin-bottom: ${space(1)};
  343. }
  344. `;
  345. const StyledPanelTable = styled(PanelTable)`
  346. grid-template-columns: ${p =>
  347. p.headers.length === 3 ? 'max-content 1fr max-content' : 'max-content 1fr'};
  348. height: 100%;
  349. @media (min-width: ${props => props.theme.breakpoints.xxlarge}) {
  350. overflow: visible;
  351. }
  352. `;
  353. const StyledSearchBarAction = styled(SearchBarAction)`
  354. margin-bottom: ${space(1.5)};
  355. `;