import pick from 'lodash/pick'; import {createStore} from 'reflux'; import {mergeGroups} from 'sentry/actionCreators/group'; import { addErrorMessage, addLoadingMessage, addSuccessMessage, } from 'sentry/actionCreators/indicator'; import {Client} from 'sentry/api'; import {Group, Organization, Project} from 'sentry/types'; import {Event} from 'sentry/types/event'; import toArray from 'sentry/utils/toArray'; import {CommonStoreDefinition} from './types'; // Between 0-100 const MIN_SCORE = 0.6; // @param score: {[key: string]: number} const checkBelowThreshold = (scores = {}) => { const scoreKeys = Object.keys(scores); return !scoreKeys.map(key => scores[key]).find(score => score >= MIN_SCORE); }; type State = { // "Compare" button state enableFingerprintCompare: boolean; error: boolean; filteredSimilarItems: SimilarItem[]; loading: boolean; mergeDisabled: boolean; mergeList: Array; mergeState: Map; // List of fingerprints that belong to issue mergedItems: Fingerprint[]; mergedLinks: string; similarItems: SimilarItem[]; similarLinks: string; // Disabled state of "Unmerge" button in "Merged" tab (for Issues) unmergeDisabled: boolean; // If "Collapse All" was just used, this will be true unmergeLastCollapsed: boolean; // Map of {[fingerprint]: Array} that is selected to be unmerged unmergeList: Map; // Map of state for each fingerprint (i.e. "collapsed") unmergeState: Map; }; type ScoreMap = Record; type ApiFingerprint = { id: string; latestEvent: Event; childId?: string; childLabel?: string; eventCount?: number; label?: string; lastSeen?: string; parentId?: string; parentLabel?: string; state?: string; }; type ChildFingerprint = { childId: string; childLabel?: string; eventCount?: number; lastSeen?: string; latestEvent?: Event; }; export type Fingerprint = { children: Array; eventCount: number; id: string; latestEvent: Event; label?: string; lastSeen?: string; parentId?: string; parentLabel?: string; state?: string; }; export type SimilarItem = { isBelowThreshold: boolean; issue: Group; aggregate?: { exception: number; message: number; }; score?: Record; scoresByInterface?: { exception: Array<[string, number | null]>; message: Array<[string, any | null]>; }; }; type ResponseProcessors = { merged: (item: ApiFingerprint[]) => Fingerprint[]; similar: (data: [Group, ScoreMap]) => { aggregate: Record; isBelowThreshold: boolean; issue: Group; score: ScoreMap; scoresByInterface: Record>; }; }; type DataKey = keyof ResponseProcessors; type ResultsAsArrayDataMerged = Parameters[0]; type ResultsAsArrayDataSimilar = Array[0]>; type ResultsAsArray = Array<{ data: ResultsAsArrayDataMerged | ResultsAsArrayDataSimilar; dataKey: DataKey; links: string | null; }>; type IdState = { busy?: boolean; checked?: boolean; collapsed?: boolean; }; type InternalDefinition = { api: Client; }; type UnmergeResponse = Pick< State, | 'unmergeDisabled' | 'unmergeState' | 'unmergeList' | 'enableFingerprintCompare' | 'unmergeLastCollapsed' >; interface GroupingStoreDefinition extends CommonStoreDefinition, InternalDefinition { getInitialState(): State; init(): void; isAllUnmergedSelected(): boolean; onFetch( toFetchArray?: Array<{ dataKey: DataKey; endpoint: string; queryParams?: Record; }> ): Promise; onMerge(props: { projectId: Project['id']; params?: { groupId: Group['id']; orgId: Organization['id']; }; query?: string; }): undefined | Promise; onToggleCollapseFingerprint(fingerprint: string): void; onToggleCollapseFingerprints(): void; onToggleMerge(id: string): void; onToggleUnmerge(props: [string, string] | string): void; onUnmerge(props: { groupId: Group['id']; orgSlug: Organization['slug']; errorMessage?: string; loadingMessage?: string; successMessage?: string; }): Promise; setStateForId( map: Map, idOrIds: Array | string, newState: IdState ): Array; triggerFetchState(): Pick< State, | 'similarItems' | 'filteredSimilarItems' | 'mergedItems' | 'mergedLinks' | 'similarLinks' | 'mergeState' | 'unmergeState' | 'loading' | 'error' >; triggerMergeState(): Pick; triggerUnmergeState(): UnmergeResponse; } const storeConfig: GroupingStoreDefinition = { api: new Client(), init() { // XXX: Do not use `this.listenTo` in this store. We avoid usage of reflux // listeners due to their leaky nature in tests. const state = this.getInitialState(); Object.entries(state).forEach(([key, value]) => { this[key] = value; }); }, getInitialState() { return { // List of fingerprints that belong to issue mergedItems: [], // Map of {[fingerprint]: Array} that is selected to be unmerged unmergeList: new Map(), // Map of state for each fingerprint (i.e. "collapsed") unmergeState: new Map(), // Disabled state of "Unmerge" button in "Merged" tab (for Issues) unmergeDisabled: true, // If "Collapse All" was just used, this will be true unmergeLastCollapsed: false, // "Compare" button state enableFingerprintCompare: false, similarItems: [], filteredSimilarItems: [], similarLinks: '', mergeState: new Map(), mergeList: [], mergedLinks: '', mergeDisabled: false, loading: true, error: false, }; }, setStateForId(map, idOrIds, newState) { const ids = toArray(idOrIds); return ids.map(id => { const state = (map.has(id) && map.get(id)) || {}; const mergedState = {...state, ...newState}; map.set(id, mergedState); return mergedState; }); }, isAllUnmergedSelected() { const lockedItems = (Array.from(this.unmergeState.values()) as Array).filter( ({busy}) => busy ) || []; return ( this.unmergeList.size === this.mergedItems.filter(({latestEvent}) => !!latestEvent).length - lockedItems.length ); }, // Fetches data onFetch(toFetchArray) { const requests = toFetchArray || this.toFetchArray; // Reset state and trigger update this.init(); this.triggerFetchState(); const promises = requests.map( ({endpoint, queryParams, dataKey}) => new Promise((resolve, reject) => { this.api.request(endpoint, { method: 'GET', data: queryParams, success: (data, _, resp) => { resolve({ dataKey, data, links: resp ? resp.getResponseHeader('Link') : null, }); }, error: err => { const error = err.responseJSON?.detail || true; reject(error); }, }); }) ); const responseProcessors: ResponseProcessors = { merged: items => { const newItemsMap: Record = {}; const newItems: Fingerprint[] = []; items.forEach(item => { if (!newItemsMap[item.id]) { const newItem = { eventCount: 0, children: [], // lastSeen and latestEvent properties are correct // since the server returns items in // descending order of lastSeen ...item, }; // Check for locked items this.setStateForId(this.unmergeState, item.id, { busy: item.state === 'locked', }); newItemsMap[item.id] = newItem; newItems.push(newItem); } const newItem = newItemsMap[item.id]; const {childId, childLabel, eventCount, lastSeen, latestEvent} = item; if (eventCount) { newItem.eventCount += eventCount; } if (childId) { newItem.children.push({ childId, childLabel, lastSeen, latestEvent, eventCount, }); } }); return newItems; }, similar: ([issue, scoreMap]) => { // Hide items with a low scores const isBelowThreshold = checkBelowThreshold(scoreMap); // List of scores indexed by interface (i.e., exception and message) // Note: for v2, the interface is always "similarity". When v2 is // rolled out we can get rid of this grouping entirely. const scoresByInterface = Object.keys(scoreMap) .map(scoreKey => [scoreKey, scoreMap[scoreKey]]) .reduce((acc, [scoreKey, score]) => { // v1 layout: ':...' const [interfaceName] = String(scoreKey).split(':'); if (!acc[interfaceName]) { acc[interfaceName] = []; } acc[interfaceName].push([scoreKey, score]); return acc; }, {}); // Aggregate score by interface const aggregate = Object.keys(scoresByInterface) .map(interfaceName => [interfaceName, scoresByInterface[interfaceName]]) .reduce((acc, [interfaceName, allScores]) => { // `null` scores means feature was not present in both issues, do not // include in aggregate const scores = allScores.filter(([, score]) => score !== null); const avg = scores.reduce((sum, [, score]) => sum + score, 0) / scores.length; acc[interfaceName] = avg; return acc; }, {}); return { issue, score: scoreMap, scoresByInterface, aggregate, isBelowThreshold, }; }, }; if (toFetchArray) { this.toFetchArray = toFetchArray; } return Promise.all(promises).then( resultsArray => { (resultsArray as ResultsAsArray).forEach(({dataKey, data, links}) => { const items = dataKey === 'similar' ? (data as ResultsAsArrayDataSimilar).map(responseProcessors[dataKey]) : responseProcessors[dataKey](data as ResultsAsArrayDataMerged); this[`${dataKey}Items`] = items; this[`${dataKey}Links`] = links; }); this.loading = false; this.error = false; this.triggerFetchState(); }, () => { this.loading = false; this.error = true; this.triggerFetchState(); } ); }, // Toggle merge checkbox onToggleMerge(id) { let checked = false; // Don't do anything if item is busy const state = this.mergeState.has(id) ? this.mergeState.get(id) : undefined; if (state?.busy === true) { return; } if (this.mergeList.includes(id)) { this.mergeList = this.mergeList.filter(item => item !== id); } else { this.mergeList = [...this.mergeList, id]; checked = true; } this.setStateForId(this.mergeState, id, { checked, }); this.triggerMergeState(); }, // Toggle unmerge check box onToggleUnmerge([fingerprint, eventId]) { let checked = false; // Uncheck an item to unmerge const state = this.unmergeState.get(fingerprint); if (state?.busy === true) { return; } if (this.unmergeList.has(fingerprint)) { this.unmergeList.delete(fingerprint); } else { this.unmergeList.set(fingerprint, eventId); checked = true; } // Update "checked" state for row this.setStateForId(this.unmergeState, fingerprint, { checked, }); // Unmerge should be disabled if 0 or all items are selected, or if there's // only one item to select this.unmergeDisabled = this.mergedItems.size <= 1 || this.unmergeList.size === 0 || this.isAllUnmergedSelected(); this.enableFingerprintCompare = this.unmergeList.size === 2; this.triggerUnmergeState(); }, onUnmerge({groupId, loadingMessage, orgSlug, successMessage, errorMessage}) { const ids = Array.from(this.unmergeList.keys()) as Array; return new Promise((resolve, reject) => { if (this.isAllUnmergedSelected()) { reject(new Error('Not allowed to unmerge ALL events')); return; } // Disable unmerge button this.unmergeDisabled = true; // Disable rows this.setStateForId(this.unmergeState, ids, { checked: false, busy: true, }); this.triggerUnmergeState(); addLoadingMessage(loadingMessage); this.api.request(`/organizations/${orgSlug}/issues/${groupId}/hashes/`, { method: 'DELETE', query: { id: ids, }, success: () => { addSuccessMessage(successMessage); // Busy rows after successful Unmerge this.setStateForId(this.unmergeState, ids, { checked: false, busy: true, }); this.unmergeList.clear(); }, error: error => { errorMessage = error?.responseJSON?.detail || errorMessage; addErrorMessage(errorMessage); this.setStateForId(this.unmergeState, ids, { checked: true, busy: false, }); }, complete: () => { this.unmergeDisabled = false; resolve(this.triggerUnmergeState()); }, }); }); }, // For cross-project views, we need to pass projectId instead of // depending on router params (since we will only have orgId in that case) onMerge({params, query, projectId}) { if (!params) { return undefined; } const ids = this.mergeList; this.mergeDisabled = true; this.setStateForId(this.mergeState, ids as Array, { busy: true, }); this.triggerMergeState(); const promise = new Promise(resolve => { // Disable merge button const {orgId, groupId} = params; mergeGroups( this.api, { orgId, projectId, itemIds: [...ids, groupId], query, }, { success: data => { if (data?.merge?.parent) { this.trigger({ mergedParent: data.merge.parent, }); } // Hide rows after successful merge this.setStateForId(this.mergeState, ids as Array, { checked: false, busy: true, }); this.mergeList = []; }, error: () => { this.setStateForId(this.mergeState, ids as Array, { checked: true, busy: false, }); }, complete: () => { this.mergeDisabled = false; resolve(this.triggerMergeState()); }, } ); }); return promise; }, // Toggle collapsed state of all fingerprints onToggleCollapseFingerprints() { this.setStateForId( this.unmergeState, this.mergedItems.map(({id}) => id), { collapsed: !this.unmergeLastCollapsed, } ); this.unmergeLastCollapsed = !this.unmergeLastCollapsed; this.trigger({ unmergeLastCollapsed: this.unmergeLastCollapsed, unmergeState: this.unmergeState, }); }, onToggleCollapseFingerprint(fingerprint) { const collapsed = this.unmergeState.has(fingerprint) && this.unmergeState.get(fingerprint).collapsed; this.setStateForId(this.unmergeState, fingerprint, {collapsed: !collapsed}); this.trigger({ unmergeState: this.unmergeState, }); }, triggerFetchState() { const state = { similarItems: this.similarItems.filter(({isBelowThreshold}) => !isBelowThreshold), filteredSimilarItems: this.similarItems.filter( ({isBelowThreshold}) => isBelowThreshold ), ...pick(this, [ 'mergedItems', 'mergedLinks', 'similarLinks', 'mergeState', 'unmergeState', 'loading', 'error', 'enableFingerprintCompare', 'unmergeList', ]), }; this.trigger(state); return state; }, triggerUnmergeState() { const state = pick(this, [ 'unmergeDisabled', 'unmergeState', 'unmergeList', 'enableFingerprintCompare', 'unmergeLastCollapsed', ]); this.trigger(state); return state; }, triggerMergeState() { const state = pick(this, ['mergeDisabled', 'mergeState', 'mergeList']); this.trigger(state); return state; }, getState(): State { return { ...pick(this, [ 'enableFingerprintCompare', 'error', 'filteredSimilarItems', 'loading', 'mergeDisabled', 'mergeList', 'mergeState', 'mergeState', 'mergedItems', 'mergedLinks', 'similarItems', 'similarLinks', 'unmergeDisabled', 'unmergeLastCollapsed', 'unmergeList', 'unmergeState', ]), }; }, }; const GroupingStore = createStore(storeConfig); export default GroupingStore;