apiSource.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521
  1. import {Component} from 'react';
  2. // eslint-disable-next-line no-restricted-imports
  3. import {withRouter, WithRouterProps} from 'react-router';
  4. import * as Sentry from '@sentry/react';
  5. import debounce from 'lodash/debounce';
  6. import flatten from 'lodash/flatten';
  7. import {Client, ResponseMeta} from 'sentry/api';
  8. import {t} from 'sentry/locale';
  9. import {
  10. DocIntegration,
  11. EventIdResponse,
  12. IntegrationProvider,
  13. Member,
  14. Organization,
  15. PluginWithProjectList,
  16. Project,
  17. SentryApp,
  18. ShortIdResponse,
  19. Team,
  20. } from 'sentry/types';
  21. import {defined} from 'sentry/utils';
  22. import {createFuzzySearch, Fuse} from 'sentry/utils/fuzzySearch';
  23. import {singleLineRenderer as markedSingleLine} from 'sentry/utils/marked';
  24. import withLatestContext from 'sentry/utils/withLatestContext';
  25. import {ChildProps, Result, ResultItem} from './types';
  26. import {strGetFn} from './utils';
  27. // event ids must have string length of 32
  28. const shouldSearchEventIds = (query?: string) =>
  29. typeof query === 'string' && query.length === 32;
  30. // STRING-HEXVAL
  31. const shouldSearchShortIds = (query: string) => /[\w\d]+-[\w\d]+/.test(query);
  32. // Helper functions to create result objects
  33. async function createOrganizationResults(
  34. organizationsPromise: Promise<Organization[]>
  35. ): Promise<ResultItem[]> {
  36. const organizations = (await organizationsPromise) || [];
  37. return flatten(
  38. organizations.map(org => [
  39. {
  40. title: t('%s Dashboard', org.slug),
  41. description: t('Organization Dashboard'),
  42. model: org,
  43. sourceType: 'organization',
  44. resultType: 'route',
  45. to: `/${org.slug}/`,
  46. },
  47. {
  48. title: t('%s Settings', org.slug),
  49. description: t('Organization Settings'),
  50. model: org,
  51. sourceType: 'organization',
  52. resultType: 'settings',
  53. to: `/settings/${org.slug}/`,
  54. },
  55. ])
  56. );
  57. }
  58. async function createProjectResults(
  59. projectsPromise: Promise<Project[]>,
  60. orgId?: string
  61. ): Promise<ResultItem[]> {
  62. const projects = (await projectsPromise) || [];
  63. return flatten(
  64. projects.map(project => {
  65. const projectResults: ResultItem[] = [
  66. {
  67. title: t('%s Settings', project.slug),
  68. description: t('Project Settings'),
  69. model: project,
  70. sourceType: 'project',
  71. resultType: 'settings',
  72. to: `/settings/${orgId}/projects/${project.slug}/`,
  73. },
  74. {
  75. title: t('%s Alerts', project.slug),
  76. description: t('List of project alert rules'),
  77. model: project,
  78. sourceType: 'project',
  79. resultType: 'route',
  80. to: `/organizations/${orgId}/alerts/rules/?project=${project.id}`,
  81. },
  82. ];
  83. projectResults.unshift({
  84. title: t('%s Dashboard', project.slug),
  85. description: t('Project Details'),
  86. model: project,
  87. sourceType: 'project',
  88. resultType: 'route',
  89. to: `/organizations/${orgId}/projects/${project.slug}/?project=${project.id}`,
  90. });
  91. return projectResults;
  92. })
  93. );
  94. }
  95. async function createTeamResults(
  96. teamsPromise: Promise<Team[]>,
  97. orgId?: string
  98. ): Promise<ResultItem[]> {
  99. const teams = (await teamsPromise) || [];
  100. return teams.map(team => ({
  101. title: `#${team.slug}`,
  102. description: 'Team Settings',
  103. model: team,
  104. sourceType: 'team',
  105. resultType: 'settings',
  106. to: `/settings/${orgId}/teams/${team.slug}/`,
  107. }));
  108. }
  109. async function createMemberResults(
  110. membersPromise: Promise<Member[]>,
  111. orgId?: string
  112. ): Promise<ResultItem[]> {
  113. const members = (await membersPromise) || [];
  114. return members.map(member => ({
  115. title: member.name,
  116. description: member.email,
  117. model: member,
  118. sourceType: 'member',
  119. resultType: 'settings',
  120. to: `/settings/${orgId}/members/${member.id}/`,
  121. }));
  122. }
  123. async function createPluginResults(
  124. pluginsPromise: Promise<PluginWithProjectList[]>,
  125. orgId?: string
  126. ): Promise<ResultItem[]> {
  127. const plugins = (await pluginsPromise) || [];
  128. return plugins
  129. .filter(plugin => {
  130. // show a plugin if it is not hidden (aka legacy) or if we have projects with it configured
  131. return !plugin.isHidden || !!plugin.projectList.length;
  132. })
  133. .map(plugin => ({
  134. title: plugin.isHidden ? `${plugin.name} (Legacy)` : plugin.name,
  135. description: (
  136. <span
  137. dangerouslySetInnerHTML={{
  138. __html: markedSingleLine(plugin.description ?? ''),
  139. }}
  140. />
  141. ),
  142. model: plugin,
  143. sourceType: 'plugin',
  144. resultType: 'integration',
  145. to: `/settings/${orgId}/plugins/${plugin.id}/`,
  146. }));
  147. }
  148. async function createIntegrationResults(
  149. integrationsPromise: Promise<{providers: IntegrationProvider[]}>,
  150. orgId?: string
  151. ): Promise<ResultItem[]> {
  152. const {providers} = (await integrationsPromise) || {};
  153. return (
  154. (providers &&
  155. providers.map(provider => ({
  156. title: provider.name,
  157. description: (
  158. <span
  159. dangerouslySetInnerHTML={{
  160. __html: markedSingleLine(provider.metadata.description),
  161. }}
  162. />
  163. ),
  164. model: provider,
  165. sourceType: 'integration',
  166. resultType: 'integration',
  167. to: `/settings/${orgId}/integrations/${provider.slug}/`,
  168. configUrl: `/api/0/organizations/${orgId}/integrations/?provider_key=${provider.slug}&includeConfig=0`,
  169. }))) ||
  170. []
  171. );
  172. }
  173. async function createSentryAppResults(
  174. sentryAppPromise: Promise<SentryApp[]>,
  175. orgId?: string
  176. ): Promise<ResultItem[]> {
  177. const sentryApps = (await sentryAppPromise) || [];
  178. return sentryApps.map(sentryApp => ({
  179. title: sentryApp.name,
  180. description: (
  181. <span
  182. dangerouslySetInnerHTML={{
  183. __html: markedSingleLine(sentryApp.overview || ''),
  184. }}
  185. />
  186. ),
  187. model: sentryApp,
  188. sourceType: 'sentryApp',
  189. resultType: 'sentryApp',
  190. to: `/settings/${orgId}/sentry-apps/${sentryApp.slug}/`,
  191. }));
  192. }
  193. async function createDocIntegrationResults(
  194. docIntegrationPromise: Promise<DocIntegration[]>,
  195. orgId?: string
  196. ): Promise<ResultItem[]> {
  197. const docIntegrations = (await docIntegrationPromise) || [];
  198. return docIntegrations.map(docIntegration => ({
  199. title: docIntegration.name,
  200. description: (
  201. <span
  202. dangerouslySetInnerHTML={{
  203. __html: markedSingleLine(docIntegration.description || ''),
  204. }}
  205. />
  206. ),
  207. model: docIntegration,
  208. sourceType: 'docIntegration',
  209. resultType: 'docIntegration',
  210. to: `/settings/${orgId}/document-integrations/${docIntegration.slug}/`,
  211. }));
  212. }
  213. async function createShortIdLookupResult(
  214. shortIdLookupPromise: Promise<ShortIdResponse>
  215. ): Promise<Result | null> {
  216. const shortIdLookup = await shortIdLookupPromise;
  217. if (!shortIdLookup) {
  218. return null;
  219. }
  220. const issue = shortIdLookup && shortIdLookup.group;
  221. return {
  222. item: {
  223. title: `${
  224. (issue && issue.metadata && issue.metadata.type) || shortIdLookup.shortId
  225. }`,
  226. description: `${(issue && issue.metadata && issue.metadata.value) || t('Issue')}`,
  227. model: shortIdLookup.group,
  228. sourceType: 'issue',
  229. resultType: 'issue',
  230. to: `/${shortIdLookup.organizationSlug}/${shortIdLookup.projectSlug}/issues/${shortIdLookup.groupId}/`,
  231. },
  232. score: 1,
  233. refIndex: 0,
  234. };
  235. }
  236. async function createEventIdLookupResult(
  237. eventIdLookupPromise: Promise<EventIdResponse>
  238. ): Promise<Result | null> {
  239. const eventIdLookup = await eventIdLookupPromise;
  240. if (!eventIdLookup) {
  241. return null;
  242. }
  243. const event = eventIdLookup && eventIdLookup.event;
  244. return {
  245. item: {
  246. title: `${(event && event.metadata && event.metadata.type) || t('Event')}`,
  247. description: `${event && event.metadata && event.metadata.value}`,
  248. sourceType: 'event',
  249. resultType: 'event',
  250. to: `/${eventIdLookup.organizationSlug}/${eventIdLookup.projectSlug}/issues/${eventIdLookup.groupId}/events/${eventIdLookup.eventId}/`,
  251. },
  252. score: 1,
  253. refIndex: 0,
  254. };
  255. }
  256. type Props = WithRouterProps<{orgId: string}> & {
  257. children: (props: ChildProps) => React.ReactElement;
  258. /**
  259. * search term
  260. */
  261. query: string;
  262. organization?: Organization;
  263. /**
  264. * fuse.js options
  265. */
  266. searchOptions?: Fuse.IFuseOptions<ResultItem>;
  267. };
  268. type State = {
  269. directResults: null | Result[];
  270. fuzzy: null | Fuse<ResultItem>;
  271. loading: boolean;
  272. searchResults: null | Result[];
  273. };
  274. class ApiSource extends Component<Props, State> {
  275. static defaultProps = {
  276. searchOptions: {},
  277. };
  278. state: State = {
  279. loading: false,
  280. searchResults: null,
  281. directResults: null,
  282. fuzzy: null,
  283. };
  284. componentDidMount() {
  285. if (typeof this.props.query !== 'undefined') {
  286. this.doSearch(this.props.query);
  287. }
  288. }
  289. UNSAFE_componentWillReceiveProps(nextProps: Props) {
  290. // Limit the number of times we perform API queries by only attempting API queries
  291. // using first two characters, otherwise perform in-memory search.
  292. //
  293. // Otherwise it'd be constant :spinning_loading_wheel:
  294. if (
  295. (nextProps.query.length <= 2 &&
  296. nextProps.query.substr(0, 2) !== this.props.query.substr(0, 2)) ||
  297. // Also trigger a search if next query value satisfies an eventid/shortid query
  298. shouldSearchShortIds(nextProps.query) ||
  299. shouldSearchEventIds(nextProps.query)
  300. ) {
  301. this.setState({loading: true});
  302. this.doSearch(nextProps.query);
  303. }
  304. }
  305. api = new Client();
  306. // Debounced method to handle querying all API endpoints (when necessary)
  307. doSearch = debounce((query: string) => {
  308. const {params, organization} = this.props;
  309. const orgId = (params && params.orgId) || (organization && organization.slug);
  310. let searchUrls = ['/organizations/'];
  311. let directUrls: (string | null)[] = [];
  312. // Only run these queries when we have an org in context
  313. if (orgId) {
  314. searchUrls = [
  315. ...searchUrls,
  316. `/organizations/${orgId}/projects/`,
  317. `/organizations/${orgId}/teams/`,
  318. `/organizations/${orgId}/members/`,
  319. `/organizations/${orgId}/plugins/configs/`,
  320. `/organizations/${orgId}/config/integrations/`,
  321. '/sentry-apps/?status=published',
  322. '/doc-integrations/',
  323. ];
  324. directUrls = [
  325. shouldSearchShortIds(query) ? `/organizations/${orgId}/shortids/${query}/` : null,
  326. shouldSearchEventIds(query) ? `/organizations/${orgId}/eventids/${query}/` : null,
  327. ];
  328. }
  329. const searchRequests = searchUrls.map(url =>
  330. this.api
  331. .requestPromise(url, {
  332. query: {
  333. query,
  334. },
  335. })
  336. .then(
  337. resp => resp,
  338. err => {
  339. this.handleRequestError(err, {orgId, url});
  340. return null;
  341. }
  342. )
  343. );
  344. const directRequests = directUrls.map(url => {
  345. if (!url) {
  346. return Promise.resolve(null);
  347. }
  348. return this.api.requestPromise(url).then(
  349. resp => resp,
  350. (err: ResponseMeta) => {
  351. // No need to log 404 errors
  352. if (err && err.status === 404) {
  353. return null;
  354. }
  355. this.handleRequestError(err, {orgId, url});
  356. return null;
  357. }
  358. );
  359. });
  360. this.handleSearchRequest(searchRequests, directRequests);
  361. }, 150);
  362. handleRequestError = (err: ResponseMeta, {url, orgId}) => {
  363. Sentry.withScope(scope => {
  364. scope.setExtra(
  365. 'url',
  366. url.replace(`/organizations/${orgId}/`, '/organizations/:orgId/')
  367. );
  368. Sentry.captureException(
  369. new Error(`API Source Failed: ${err?.responseJSON?.detail}`)
  370. );
  371. });
  372. };
  373. // Handles a list of search request promises, and then updates state with response objects
  374. async handleSearchRequest(
  375. searchRequests: Promise<ResultItem[]>[],
  376. directRequests: Promise<Result | null>[]
  377. ) {
  378. const {searchOptions} = this.props;
  379. // Note we don't wait for all requests to resolve here (e.g. `await Promise.all(reqs)`)
  380. // so that we can start processing before all API requests are resolved
  381. //
  382. // This isn't particularly helpful in its current form because we still wait for all requests to finish before
  383. // updating state, but you could potentially optimize rendering direct results before all requests are finished.
  384. const [
  385. organizations,
  386. projects,
  387. teams,
  388. members,
  389. plugins,
  390. integrations,
  391. sentryApps,
  392. docIntegrations,
  393. ] = searchRequests;
  394. const [shortIdLookup, eventIdLookup] = directRequests;
  395. const [searchResults, directResults] = await Promise.all([
  396. this.getSearchableResults([
  397. organizations,
  398. projects,
  399. teams,
  400. members,
  401. plugins,
  402. integrations,
  403. sentryApps,
  404. docIntegrations,
  405. ]),
  406. this.getDirectResults([shortIdLookup, eventIdLookup]),
  407. ]);
  408. // TODO(XXX): Might consider adding logic to maintain consistent ordering
  409. // of results so things don't switch positions
  410. const fuzzy = await createFuzzySearch(searchResults, {
  411. ...searchOptions,
  412. keys: ['title', 'description'],
  413. getFn: strGetFn,
  414. });
  415. this.setState({
  416. loading: false,
  417. fuzzy,
  418. directResults,
  419. });
  420. }
  421. // Process API requests that create result objects that should be searchable
  422. async getSearchableResults(requests) {
  423. const {params, organization} = this.props;
  424. const orgId = (params && params.orgId) || (organization && organization.slug);
  425. const [
  426. organizations,
  427. projects,
  428. teams,
  429. members,
  430. plugins,
  431. integrations,
  432. sentryApps,
  433. docIntegrations,
  434. ] = requests;
  435. const searchResults = flatten(
  436. await Promise.all([
  437. createOrganizationResults(organizations),
  438. createProjectResults(projects, orgId),
  439. createTeamResults(teams, orgId),
  440. createMemberResults(members, orgId),
  441. createIntegrationResults(integrations, orgId),
  442. createPluginResults(plugins, orgId),
  443. createSentryAppResults(sentryApps, orgId),
  444. createDocIntegrationResults(docIntegrations, orgId),
  445. ])
  446. );
  447. return searchResults;
  448. }
  449. // Create result objects from API requests that do not require fuzzy search
  450. // i.e. these responses only return 1 object or they should always be displayed regardless of query input
  451. async getDirectResults(requests: Promise<any>[]): Promise<Result[]> {
  452. const [shortIdLookup, eventIdLookup] = requests;
  453. const directResults = (
  454. await Promise.all([
  455. createShortIdLookupResult(shortIdLookup),
  456. createEventIdLookupResult(eventIdLookup),
  457. ])
  458. ).filter(defined);
  459. if (!directResults.length) {
  460. return [];
  461. }
  462. return directResults;
  463. }
  464. render() {
  465. const {children, query} = this.props;
  466. const {fuzzy, directResults} = this.state;
  467. const results = fuzzy?.search(query) ?? [];
  468. return children({
  469. isLoading: this.state.loading,
  470. results: flatten([results, directResults].filter(defined)) || [],
  471. });
  472. }
  473. }
  474. export {ApiSource};
  475. export default withLatestContext(withRouter(ApiSource));