groupingStore.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644
  1. import pick from 'lodash/pick';
  2. import {createStore} from 'reflux';
  3. import {mergeGroups} from 'sentry/actionCreators/group';
  4. import {
  5. addErrorMessage,
  6. addLoadingMessage,
  7. addSuccessMessage,
  8. } from 'sentry/actionCreators/indicator';
  9. import {Client} from 'sentry/api';
  10. import type {Event} from 'sentry/types/event';
  11. import type {Group} from 'sentry/types/group';
  12. import type {Organization} from 'sentry/types/organization';
  13. import type {Project} from 'sentry/types/project';
  14. import toArray from 'sentry/utils/array/toArray';
  15. import type {StrictStoreDefinition} from './types';
  16. // Between 0-100
  17. const MIN_SCORE = 0.6;
  18. // @param score: {[key: string]: number}
  19. const checkBelowThreshold = (scores = {}) => {
  20. const scoreKeys = Object.keys(scores);
  21. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  22. return !scoreKeys.map(key => scores[key]).find(score => score >= MIN_SCORE);
  23. };
  24. type State = {
  25. // "Compare" button state
  26. enableFingerprintCompare: boolean;
  27. error: boolean;
  28. filteredSimilarItems: SimilarItem[];
  29. loading: boolean;
  30. mergeDisabled: boolean;
  31. mergeList: string[];
  32. mergeState: Map<any, Readonly<{busy?: boolean; checked?: boolean}>>;
  33. // List of fingerprints that belong to issue
  34. mergedItems: Fingerprint[];
  35. mergedLinks: string;
  36. similarItems: SimilarItem[];
  37. similarLinks: string;
  38. // Disabled state of "Unmerge" button in "Merged" tab (for Issues)
  39. unmergeDisabled: boolean;
  40. // If "Collapse All" was just used, this will be true
  41. unmergeLastCollapsed: boolean;
  42. // Map of {[fingerprint]: Array<fingerprint, event id>} that is selected to be unmerged
  43. unmergeList: Map<any, any>;
  44. // Map of state for each fingerprint (i.e. "collapsed")
  45. unmergeState: Readonly<
  46. Map<any, Readonly<{busy?: boolean; checked?: boolean; collapsed?: boolean}>>
  47. >;
  48. };
  49. type ScoreMap = Record<string, number | null | string>;
  50. type ApiFingerprint = {
  51. id: string;
  52. latestEvent: Event;
  53. childId?: string;
  54. childLabel?: string;
  55. eventCount?: number;
  56. label?: string;
  57. lastSeen?: string;
  58. parentId?: string;
  59. parentLabel?: string;
  60. state?: string;
  61. };
  62. type ChildFingerprint = {
  63. childId: string;
  64. childLabel?: string;
  65. eventCount?: number;
  66. lastSeen?: string;
  67. latestEvent?: Event;
  68. };
  69. export type Fingerprint = {
  70. children: ChildFingerprint[];
  71. eventCount: number;
  72. id: string;
  73. latestEvent: Event;
  74. label?: string;
  75. lastSeen?: string;
  76. parentId?: string;
  77. parentLabel?: string;
  78. state?: string;
  79. };
  80. export type SimilarItem = {
  81. isBelowThreshold: boolean;
  82. issue: Group;
  83. aggregate?: {
  84. exception: number;
  85. message: number;
  86. shouldBeGrouped?: string;
  87. };
  88. score?: Record<string, number | null>;
  89. scoresByInterface?: {
  90. exception: Array<[string, number | null]>;
  91. message: Array<[string, any | null]>;
  92. shouldBeGrouped?: Array<[string, string | null]>;
  93. };
  94. };
  95. type ResponseProcessors = {
  96. merged: (item: ApiFingerprint[]) => Fingerprint[];
  97. similar: (data: [Group, ScoreMap]) => {
  98. aggregate: Record<string, number | string>;
  99. isBelowThreshold: boolean;
  100. issue: Group;
  101. score: ScoreMap;
  102. scoresByInterface: Record<string, Array<[string, number | null]>>;
  103. };
  104. };
  105. type DataKey = keyof ResponseProcessors;
  106. type ResultsAsArrayDataMerged = Parameters<ResponseProcessors['merged']>[0];
  107. type ResultsAsArrayDataSimilar = Array<Parameters<ResponseProcessors['similar']>[0]>;
  108. type ResultsAsArray = Array<{
  109. data: ResultsAsArrayDataMerged | ResultsAsArrayDataSimilar;
  110. dataKey: DataKey;
  111. links: string | null;
  112. }>;
  113. type IdState = {
  114. busy?: boolean;
  115. checked?: boolean;
  116. collapsed?: boolean;
  117. };
  118. type UnmergeResponse = Pick<
  119. State,
  120. | 'unmergeDisabled'
  121. | 'unmergeState'
  122. | 'unmergeList'
  123. | 'enableFingerprintCompare'
  124. | 'unmergeLastCollapsed'
  125. >;
  126. interface GroupingStoreDefinition extends StrictStoreDefinition<State> {
  127. api: Client;
  128. getInitialState(): State;
  129. init(): void;
  130. isAllUnmergedSelected(): boolean;
  131. onFetch(
  132. toFetchArray: Array<{
  133. dataKey: DataKey;
  134. endpoint: string;
  135. queryParams?: Record<string, any>;
  136. }>
  137. ): Promise<any>;
  138. onMerge(props: {
  139. projectId: Project['id'];
  140. params?: {
  141. groupId: Group['id'];
  142. orgId: Organization['id'];
  143. };
  144. query?: string;
  145. }): undefined | Promise<any>;
  146. onToggleCollapseFingerprint(fingerprint: string): void;
  147. onToggleCollapseFingerprints(): void;
  148. onToggleMerge(id: string): void;
  149. onToggleUnmerge(props: [string, string] | string): void;
  150. onUnmerge(props: {
  151. groupId: Group['id'];
  152. orgSlug: Organization['slug'];
  153. errorMessage?: string;
  154. loadingMessage?: string;
  155. successMessage?: string;
  156. }): Promise<UnmergeResponse>;
  157. /**
  158. * Updates mergeState
  159. */
  160. setStateForId(
  161. stateProperty: 'mergeState' | 'unmergeState',
  162. idOrIds: string[] | string,
  163. newState: IdState
  164. ): void;
  165. triggerFetchState(): Readonly<
  166. Pick<
  167. State,
  168. | 'similarItems'
  169. | 'filteredSimilarItems'
  170. | 'mergedItems'
  171. | 'mergedLinks'
  172. | 'similarLinks'
  173. | 'mergeState'
  174. | 'unmergeState'
  175. | 'loading'
  176. | 'error'
  177. >
  178. >;
  179. triggerMergeState(): Readonly<
  180. Pick<State, 'mergeState' | 'mergeDisabled' | 'mergeList'>
  181. >;
  182. triggerUnmergeState(): Readonly<UnmergeResponse>;
  183. }
  184. const storeConfig: GroupingStoreDefinition = {
  185. // This will be populated on init
  186. state: {} as State,
  187. api: new Client(),
  188. init() {
  189. // XXX: Do not use `this.listenTo` in this store. We avoid usage of reflux
  190. // listeners due to their leaky nature in tests.
  191. this.state = this.getInitialState();
  192. },
  193. getInitialState() {
  194. return {
  195. // List of fingerprints that belong to issue
  196. mergedItems: [],
  197. // Map of {[fingerprint]: Array<fingerprint, event id>} that is selected to be unmerged
  198. unmergeList: new Map(),
  199. // Map of state for each fingerprint (i.e. "collapsed")
  200. unmergeState: new Map(),
  201. // Disabled state of "Unmerge" button in "Merged" tab (for Issues)
  202. unmergeDisabled: true,
  203. // If "Collapse All" was just used, this will be true
  204. unmergeLastCollapsed: false,
  205. // "Compare" button state
  206. enableFingerprintCompare: false,
  207. similarItems: [],
  208. filteredSimilarItems: [],
  209. similarLinks: '',
  210. mergeState: new Map(),
  211. mergeList: [],
  212. mergedLinks: '',
  213. mergeDisabled: false,
  214. loading: true,
  215. error: false,
  216. };
  217. },
  218. setStateForId(stateProperty, idOrIds, newState) {
  219. const ids = toArray(idOrIds);
  220. const newMap = new Map(this.state[stateProperty]);
  221. ids.forEach(id => {
  222. const state = newMap.get(id) ?? {};
  223. const mergedState = {...state, ...newState};
  224. newMap.set(id, mergedState);
  225. });
  226. this.state = {...this.state, [stateProperty]: newMap};
  227. },
  228. isAllUnmergedSelected() {
  229. const lockedItems =
  230. (Array.from(this.state.unmergeState.values()) as IdState[]).filter(
  231. ({busy}) => busy
  232. ) || [];
  233. return (
  234. this.state.unmergeList.size ===
  235. this.state.mergedItems.filter(({latestEvent}) => !!latestEvent).length -
  236. lockedItems.length
  237. );
  238. },
  239. // Fetches data
  240. onFetch(toFetchArray) {
  241. // Reset state and trigger update
  242. this.init();
  243. this.triggerFetchState();
  244. const promises = toFetchArray.map(
  245. ({endpoint, queryParams, dataKey}) =>
  246. new Promise((resolve, reject) => {
  247. this.api.request(endpoint, {
  248. method: 'GET',
  249. data: queryParams,
  250. success: (data, _, resp) => {
  251. resolve({
  252. dataKey,
  253. data,
  254. links: resp ? resp.getResponseHeader('Link') : null,
  255. });
  256. },
  257. error: err => {
  258. const error = err.responseJSON?.detail || true;
  259. reject(error);
  260. },
  261. });
  262. })
  263. );
  264. const responseProcessors: ResponseProcessors = {
  265. merged: items => {
  266. const newItemsMap: Record<string, Fingerprint> = {};
  267. const newItems: Fingerprint[] = [];
  268. items.forEach(item => {
  269. if (!newItemsMap[item.id]) {
  270. const newItem = {
  271. eventCount: 0,
  272. children: [],
  273. // lastSeen and latestEvent properties are correct
  274. // since the server returns items in
  275. // descending order of lastSeen
  276. ...item,
  277. };
  278. // Check for locked items
  279. this.setStateForId('unmergeState', item.id, {
  280. busy: item.state === 'locked',
  281. });
  282. newItemsMap[item.id] = newItem;
  283. newItems.push(newItem);
  284. }
  285. const newItem = newItemsMap[item.id]!;
  286. const {childId, childLabel, eventCount, lastSeen, latestEvent} = item;
  287. if (eventCount) {
  288. newItem.eventCount += eventCount;
  289. }
  290. if (childId) {
  291. newItem.children.push({
  292. childId,
  293. childLabel,
  294. lastSeen,
  295. latestEvent,
  296. eventCount,
  297. });
  298. }
  299. });
  300. return newItems;
  301. },
  302. similar: ([issue, scoreMap]) => {
  303. // Check which similarity endpoint is being used
  304. const hasSimilarityEmbeddingsFeature = toFetchArray[0]?.endpoint.includes(
  305. 'similar-issues-embeddings'
  306. );
  307. // Hide items with a low scores
  308. const isBelowThreshold = hasSimilarityEmbeddingsFeature
  309. ? false
  310. : checkBelowThreshold(scoreMap);
  311. // List of scores indexed by interface (i.e., exception and message)
  312. // Note: for v2, the interface is always "similarity". When v2 is
  313. // rolled out we can get rid of this grouping entirely.
  314. const scoresByInterface = Object.keys(scoreMap)
  315. .map(scoreKey => [scoreKey, scoreMap[scoreKey]])
  316. .reduce((acc, [scoreKey, score]) => {
  317. // v1 layout: '<interface>:...'
  318. const [interfaceName] = String(scoreKey).split(':') as [string];
  319. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  320. if (!acc[interfaceName]) {
  321. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  322. acc[interfaceName] = [];
  323. }
  324. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  325. acc[interfaceName].push([scoreKey, score]);
  326. return acc;
  327. }, {});
  328. // Aggregate score by interface
  329. const aggregate = Object.keys(scoresByInterface)
  330. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  331. .map(interfaceName => [interfaceName, scoresByInterface[interfaceName]])
  332. .reduce((acc, [interfaceName, allScores]) => {
  333. // `null` scores means feature was not present in both issues, do not
  334. // include in aggregate
  335. // @ts-expect-error TS(7031): Binding element 'score' implicitly has an 'any' ty... Remove this comment to see the full error message
  336. const scores = allScores.filter(([, score]) => score !== null);
  337. const avg =
  338. scores.reduce((sum: any, [, score]: any) => sum + score, 0) / scores.length;
  339. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  340. acc[interfaceName] = hasSimilarityEmbeddingsFeature ? scores[0][1] : avg;
  341. return acc;
  342. }, {});
  343. return {
  344. issue,
  345. score: scoreMap,
  346. scoresByInterface,
  347. aggregate,
  348. isBelowThreshold,
  349. };
  350. },
  351. };
  352. return Promise.all(promises).then(
  353. resultsArray => {
  354. (resultsArray as ResultsAsArray).forEach(({dataKey, data, links}) => {
  355. const items =
  356. dataKey === 'similar'
  357. ? (data as ResultsAsArrayDataSimilar).map(responseProcessors[dataKey])
  358. : responseProcessors[dataKey](data as ResultsAsArrayDataMerged);
  359. this.state = {
  360. ...this.state,
  361. // Types here are pretty rough
  362. [`${dataKey}Items`]: items,
  363. [`${dataKey}Links`]: links,
  364. };
  365. });
  366. this.state = {...this.state, loading: false, error: false};
  367. this.triggerFetchState();
  368. },
  369. () => {
  370. this.state = {...this.state, loading: false, error: true};
  371. this.triggerFetchState();
  372. }
  373. );
  374. },
  375. // Toggle merge checkbox
  376. onToggleMerge(id) {
  377. let checked = false;
  378. // Don't do anything if item is busy
  379. const state = this.state.mergeState.has(id)
  380. ? this.state.mergeState.get(id)
  381. : undefined;
  382. if (state?.busy === true) {
  383. return;
  384. }
  385. if (this.state.mergeList.includes(id)) {
  386. this.state = {
  387. ...this.state,
  388. mergeList: this.state.mergeList.filter(item => item !== id),
  389. };
  390. } else {
  391. this.state = {...this.state, mergeList: [...this.state.mergeList, id]};
  392. checked = true;
  393. }
  394. this.setStateForId('mergeState', id, {checked});
  395. this.triggerMergeState();
  396. },
  397. // Toggle unmerge check box
  398. onToggleUnmerge([fingerprint, eventId]) {
  399. let checked = false;
  400. // Uncheck an item to unmerge
  401. const state = this.state.unmergeState.get(fingerprint);
  402. if (state?.busy === true) {
  403. return;
  404. }
  405. const newUnmergeList = new Map(this.state.unmergeList);
  406. if (newUnmergeList.has(fingerprint)) {
  407. newUnmergeList.delete(fingerprint);
  408. } else {
  409. newUnmergeList.set(fingerprint, eventId);
  410. checked = true;
  411. }
  412. this.state = {...this.state, unmergeList: newUnmergeList};
  413. // Update "checked" state for row
  414. this.setStateForId('unmergeState', fingerprint!, {checked});
  415. // Unmerge should be disabled if 0 or all items are selected, or if there's
  416. // only one item to select
  417. const unmergeDisabled =
  418. this.state.mergedItems.length === 1 ||
  419. this.state.unmergeList.size === 0 ||
  420. this.isAllUnmergedSelected();
  421. const enableFingerprintCompare = this.state.unmergeList.size === 2;
  422. this.state = {...this.state, unmergeDisabled, enableFingerprintCompare};
  423. this.triggerUnmergeState();
  424. },
  425. onUnmerge({groupId, loadingMessage, orgSlug, successMessage, errorMessage}) {
  426. const grouphashIds = Array.from(this.state.unmergeList.keys()) as string[];
  427. return new Promise((resolve, reject) => {
  428. if (this.isAllUnmergedSelected()) {
  429. reject(new Error('Not allowed to unmerge ALL events'));
  430. return;
  431. }
  432. // Disable unmerge button
  433. this.state = {...this.state, unmergeDisabled: true};
  434. // Disable rows
  435. this.setStateForId('unmergeState', grouphashIds, {checked: false, busy: true});
  436. this.triggerUnmergeState();
  437. addLoadingMessage(loadingMessage);
  438. this.api.request(`/organizations/${orgSlug}/issues/${groupId}/hashes/`, {
  439. method: 'PUT',
  440. query: {
  441. id: grouphashIds,
  442. },
  443. success: () => {
  444. addSuccessMessage(successMessage);
  445. // Busy rows after successful Unmerge
  446. this.setStateForId('unmergeState', grouphashIds, {checked: false, busy: true});
  447. this.state.unmergeList.clear();
  448. },
  449. error: error => {
  450. errorMessage = error?.responseJSON?.detail || errorMessage;
  451. addErrorMessage(errorMessage);
  452. this.setStateForId('unmergeState', grouphashIds, {checked: true, busy: false});
  453. },
  454. complete: () => {
  455. this.state = {...this.state, unmergeDisabled: false};
  456. resolve(this.triggerUnmergeState());
  457. },
  458. });
  459. });
  460. },
  461. // For cross-project views, we need to pass projectId instead of
  462. // depending on router params (since we will only have orgId in that case)
  463. onMerge({params, query, projectId}) {
  464. if (!params) {
  465. return undefined;
  466. }
  467. const ids = this.state.mergeList;
  468. this.state = {...this.state, mergeDisabled: true};
  469. this.setStateForId('mergeState', ids, {busy: true});
  470. this.triggerMergeState();
  471. const promise = new Promise(resolve => {
  472. // Disable merge button
  473. const {orgId, groupId} = params;
  474. mergeGroups(
  475. this.api,
  476. {
  477. orgId,
  478. projectId,
  479. itemIds: [...ids, groupId],
  480. query,
  481. },
  482. {
  483. success: data => {
  484. if (data?.merge?.parent) {
  485. this.trigger({
  486. mergedParent: data.merge.parent,
  487. });
  488. }
  489. // Hide rows after successful merge
  490. this.setStateForId('mergeState', ids, {checked: false, busy: true});
  491. this.state = {...this.state, mergeList: []};
  492. },
  493. error: () => {
  494. this.setStateForId('mergeState', ids, {checked: true, busy: false});
  495. },
  496. complete: () => {
  497. this.state = {...this.state, mergeDisabled: false};
  498. resolve(this.triggerMergeState());
  499. },
  500. }
  501. );
  502. });
  503. return promise;
  504. },
  505. // Toggle collapsed state of all fingerprints
  506. onToggleCollapseFingerprints() {
  507. this.setStateForId(
  508. 'unmergeState',
  509. this.state.mergedItems.map(({id}) => id),
  510. {
  511. collapsed: !this.state.unmergeLastCollapsed,
  512. }
  513. );
  514. this.state = {
  515. ...this.state,
  516. unmergeLastCollapsed: !this.state.unmergeLastCollapsed,
  517. };
  518. this.trigger({
  519. unmergeLastCollapsed: this.state.unmergeLastCollapsed,
  520. unmergeState: this.state.unmergeState,
  521. });
  522. },
  523. onToggleCollapseFingerprint(fingerprint) {
  524. const collapsed = this.state.unmergeState.get(fingerprint)?.collapsed;
  525. this.setStateForId('unmergeState', fingerprint, {collapsed: !collapsed});
  526. this.trigger({unmergeState: this.state.unmergeState});
  527. },
  528. triggerFetchState() {
  529. this.state = {
  530. ...this.state,
  531. similarItems: this.state.similarItems.filter(
  532. ({isBelowThreshold}) => !isBelowThreshold
  533. ),
  534. filteredSimilarItems: this.state.similarItems.filter(
  535. ({isBelowThreshold}) => isBelowThreshold
  536. ),
  537. };
  538. this.trigger(this.state);
  539. return this.state;
  540. },
  541. triggerUnmergeState() {
  542. const state = pick(this.state, [
  543. 'unmergeDisabled',
  544. 'unmergeState',
  545. 'unmergeList',
  546. 'enableFingerprintCompare',
  547. 'unmergeLastCollapsed',
  548. ]);
  549. this.trigger(state);
  550. return state;
  551. },
  552. triggerMergeState() {
  553. const state = pick(this.state, ['mergeDisabled', 'mergeState', 'mergeList']);
  554. this.trigger(state);
  555. return state;
  556. },
  557. getState(): State {
  558. return this.state;
  559. },
  560. };
  561. const GroupingStore = createStore(storeConfig);
  562. export default GroupingStore;