From 4d2de2adc63c0b1364d14a458b8d0df0102809f1 Mon Sep 17 00:00:00 2001 From: Robert Austin Date: Mon, 20 Jul 2020 09:38:30 -0400 Subject: [PATCH] [Resolver] Selector performance (#72380) * Memoize various selectors * Improve performance of the selectors that calculate the `aria-flowto` attribute. * more tests. --- .../common/endpoint/types.ts | 1 - .../models/indexed_process_tree/index.ts | 53 ++--- .../isometric_taxi_layout.ts | 11 +- .../public/resolver/store/camera/selectors.ts | 65 +++--- .../resolver/store/data/selectors.test.ts | 90 ++++++++ .../public/resolver/store/data/selectors.ts | 208 +++++++++++------- .../resolver/store/mocks/endpoint_event.ts | 37 ++++ .../resolver/store/mocks/resolver_tree.ts | 87 ++++++++ .../public/resolver/store/selectors.test.ts | 117 +--------- .../public/resolver/store/selectors.ts | 35 +-- .../panels/panel_content_related_detail.tsx | 3 +- 11 files changed, 427 insertions(+), 280 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/resolver/store/mocks/endpoint_event.ts create mode 100644 x-pack/plugins/security_solution/public/resolver/store/mocks/resolver_tree.ts diff --git a/x-pack/plugins/security_solution/common/endpoint/types.ts b/x-pack/plugins/security_solution/common/endpoint/types.ts index b477207b1c5a3..5e7e4d22f8c3c 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types.ts @@ -482,7 +482,6 @@ export interface LegacyEndpointEvent { type: string; version: string; }; - process?: object; rule?: object; user?: object; event?: { diff --git a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/index.ts b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/index.ts index 35a32d91d8a02..628d0267754f2 100644 --- a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/index.ts +++ b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/index.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +/* eslint-disable no-shadow */ + import { uniquePidForProcess, uniqueParentPidForProcess, orderByTime } from '../process_event'; import { IndexedProcessTree } from '../../types'; import { ResolverEvent } from '../../../../common/endpoint/types'; @@ -24,16 +26,15 @@ export function factory( const uniqueProcessPid = uniquePidForProcess(process); idToValue.set(uniqueProcessPid, process); - const uniqueParentPid = uniqueParentPidForProcess(process); - // if its defined and not '' - if (uniqueParentPid) { - let siblings = idToChildren.get(uniqueParentPid); - if (!siblings) { - siblings = []; - idToChildren.set(uniqueParentPid, siblings); - } - siblings.push(process); + // NB: If the value was null or undefined, use `undefined` + const uniqueParentPid: string | undefined = uniqueParentPidForProcess(process) ?? undefined; + + let childrenWithTheSameParent = idToChildren.get(uniqueParentPid); + if (!childrenWithTheSameParent) { + childrenWithTheSameParent = []; + idToChildren.set(uniqueParentPid, childrenWithTheSameParent); } + childrenWithTheSameParent.push(process); } // sort the children of each node @@ -50,9 +51,8 @@ export function factory( /** * Returns an array with any children `ProcessEvent`s of the passed in `process` */ -export function children(tree: IndexedProcessTree, process: ResolverEvent): ResolverEvent[] { - const id = uniquePidForProcess(process); - const currentProcessSiblings = tree.idToChildren.get(id); +export function children(tree: IndexedProcessTree, parentID: string | undefined): ResolverEvent[] { + const currentProcessSiblings = tree.idToChildren.get(parentID); return currentProcessSiblings === undefined ? [] : currentProcessSiblings; } @@ -78,31 +78,6 @@ export function parent( } } -/** - * Returns the following sibling - */ -export function nextSibling( - tree: IndexedProcessTree, - sibling: ResolverEvent -): ResolverEvent | undefined { - const parentNode = parent(tree, sibling); - if (parentNode) { - // The siblings of `sibling` are the children of its parent. - const siblings = children(tree, parentNode); - - // Find the sibling - const index = siblings.indexOf(sibling); - - // if the sibling wasn't found, or if it was the last element in the array, return undefined - if (index === -1 || index === siblings.length - 1) { - return undefined; - } - - // return the next sibling - return siblings[index + 1]; - } -} - /** * Number of processes in the tree */ @@ -133,6 +108,8 @@ export function root(tree: IndexedProcessTree) { export function* levelOrder(tree: IndexedProcessTree) { const rootNode = root(tree); if (rootNode !== null) { - yield* baseLevelOrder(rootNode, children.bind(null, tree)); + yield* baseLevelOrder(rootNode, (parentNode: ResolverEvent): ResolverEvent[] => + children(tree, uniquePidForProcess(parentNode)) + ); } } diff --git a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.ts b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.ts index 6058a40037ad2..11c888d1462f8 100644 --- a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.ts +++ b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.ts @@ -19,6 +19,7 @@ import * as event from '../../../../common/endpoint/models/event'; import { ResolverEvent } from '../../../../common/endpoint/types'; import * as model from './index'; import { getFriendlyElapsedTime as elapsedTime } from '../../lib/date'; +import { uniquePidForProcess } from '../process_event'; /** * Graph the process tree @@ -146,10 +147,12 @@ function widthsOfProcessSubtrees(indexedProcessTree: IndexedProcessTree): Proces return widths; } - const processesInReverseLevelOrder = [...model.levelOrder(indexedProcessTree)].reverse(); + const processesInReverseLevelOrder: ResolverEvent[] = [ + ...model.levelOrder(indexedProcessTree), + ].reverse(); for (const process of processesInReverseLevelOrder) { - const children = model.children(indexedProcessTree, process); + const children = model.children(indexedProcessTree, uniquePidForProcess(process)); const sumOfWidthOfChildren = function sumOfWidthOfChildren() { return children.reduce(function sum(currentValue, child) { @@ -226,7 +229,7 @@ function processEdgeLineSegments( metadata: edgeLineMetadata, }; - const siblings = model.children(indexedProcessTree, parent); + const siblings = model.children(indexedProcessTree, uniquePidForProcess(parent)); const isFirstChild = process === siblings[0]; if (metadata.isOnlyChild) { @@ -420,7 +423,7 @@ function* levelOrderWithWidths( parentWidth, }; - const siblings = model.children(tree, parent); + const siblings = model.children(tree, uniquePidForProcess(parent)); if (siblings.length === 1) { metadata.isOnlyChild = true; metadata.lastChildWidth = width; diff --git a/x-pack/plugins/security_solution/public/resolver/store/camera/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/camera/selectors.ts index 86d934bd95663..baa049ba42f92 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/camera/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/camera/selectors.ts @@ -164,7 +164,8 @@ export const scale: (state: CameraState) => (time: number) => Vector2 = createSe scalingConstants.maximum ); - return (time) => { + // memoizing this so the vector returned will be the same object reference if called with the same `time`. + return defaultMemoize((time) => { /** * If the animation has completed, return the `scaleNotCountingAnimation`, as * the animation always completes with the scale set back at starting value. @@ -247,12 +248,13 @@ export const scale: (state: CameraState) => (time: number) => Vector2 = createSe */ return [lerpedScale, lerpedScale]; } - }; + }); } else { /** * The scale should be the same in both axes. + * Memoizing this so the vector returned will be the same object reference every time. */ - return () => [scaleNotCountingAnimation, scaleNotCountingAnimation]; + return defaultMemoize(() => [scaleNotCountingAnimation, scaleNotCountingAnimation]); } /** @@ -277,22 +279,26 @@ export const clippingPlanes: ( ) => (time: number) => ClippingPlanes = createSelector( (state) => state.rasterSize, scale, - (rasterSize, scaleAtTime) => (time: number) => { - const [scaleX, scaleY] = scaleAtTime(time); - const renderWidth = rasterSize[0]; - const renderHeight = rasterSize[1]; - const clippingPlaneRight = renderWidth / 2 / scaleX; - const clippingPlaneTop = renderHeight / 2 / scaleY; - - return { - renderWidth, - renderHeight, - clippingPlaneRight, - clippingPlaneTop, - clippingPlaneLeft: -clippingPlaneRight, - clippingPlaneBottom: -clippingPlaneTop, - }; - } + (rasterSize, scaleAtTime) => + /** + * memoizing this for object reference equality. + */ + defaultMemoize((time: number) => { + const [scaleX, scaleY] = scaleAtTime(time); + const renderWidth = rasterSize[0]; + const renderHeight = rasterSize[1]; + const clippingPlaneRight = renderWidth / 2 / scaleX; + const clippingPlaneTop = renderHeight / 2 / scaleY; + + return { + renderWidth, + renderHeight, + clippingPlaneRight, + clippingPlaneTop, + clippingPlaneLeft: -clippingPlaneRight, + clippingPlaneBottom: -clippingPlaneTop, + }; + }) ); /** @@ -323,7 +329,10 @@ export const translation: (state: CameraState) => (time: number) => Vector2 = cr scale, (state) => state.animation, (panning, translationNotCountingCurrentPanning, scaleAtTime, animation) => { - return (time: number) => { + /** + * Memoizing this for object reference equality. + */ + return defaultMemoize((time: number) => { const [scaleX, scaleY] = scaleAtTime(time); if (animation !== undefined && animationIsActive(animation, time)) { return vector2.lerp( @@ -343,7 +352,7 @@ export const translation: (state: CameraState) => (time: number) => Vector2 = cr } else { return translationNotCountingCurrentPanning; } - }; + }); } ); @@ -357,7 +366,10 @@ export const inverseProjectionMatrix: ( clippingPlanes, translation, (clippingPlanesAtTime, translationAtTime) => { - return (time: number) => { + /** + * Memoizing this for object reference equality (and reduced memory churn.) + */ + return defaultMemoize((time: number) => { const { renderWidth, renderHeight, @@ -404,7 +416,7 @@ export const inverseProjectionMatrix: ( translateForCamera, multiply(scaleToClippingPlaneDimensions, multiply(invertY, screenToNDC)) ); - }; + }); } ); @@ -415,7 +427,8 @@ export const viewableBoundingBox: (state: CameraState) => (time: number) => AABB clippingPlanes, inverseProjectionMatrix, (clippingPlanesAtTime, matrixAtTime) => { - return (time: number) => { + // memoizing this so the AABB returned will be the same object reference if called with the same `time`. + return defaultMemoize((time: number) => { const { renderWidth, renderHeight } = clippingPlanesAtTime(time); const matrix = matrixAtTime(time); const bottomLeftCorner: Vector2 = [0, renderHeight]; @@ -424,7 +437,7 @@ export const viewableBoundingBox: (state: CameraState) => (time: number) => AABB minimum: vector2.applyMatrix3(bottomLeftCorner, matrix), maximum: vector2.applyMatrix3(topRightCorner, matrix), }; - }; + }); } ); @@ -436,6 +449,8 @@ export const projectionMatrix: (state: CameraState) => (time: number) => Matrix3 clippingPlanes, translation, (clippingPlanesAtTime, translationAtTime) => { + // memoizing this so the matrix returned will be the same object reference if called with the same `time`. + // this should also save on some memory allocation return defaultMemoize((time: number) => { const { renderWidth, diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts index cf23596db6134..683f8f1a5f84a 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts @@ -9,6 +9,13 @@ import { DataState } from '../../types'; import { dataReducer } from './reducer'; import { DataAction } from './action'; import { createStore } from 'redux'; +import { + mockTreeWithNoAncestorsAnd2Children, + mockTreeWith2AncestorsAndNoChildren, +} from '../mocks/resolver_tree'; +import { uniquePidForProcess } from '../../models/process_event'; +import { EndpointEvent } from '../../../../common/endpoint/types'; + describe('data state', () => { let actions: DataAction[] = []; @@ -263,4 +270,87 @@ describe('data state', () => { }); }); }); + describe('with a tree with no descendants and 2 ancestors', () => { + const originID = 'c'; + const firstAncestorID = 'b'; + const secondAncestorID = 'a'; + beforeEach(() => { + actions.push({ + type: 'serverReturnedResolverData', + payload: { + result: mockTreeWith2AncestorsAndNoChildren({ + originID, + firstAncestorID, + secondAncestorID, + }), + // this value doesn't matter + databaseDocumentID: '', + }, + }); + }); + it('should have no flowto candidate for the origin', () => { + expect(selectors.ariaFlowtoCandidate(state())(originID)).toBe(null); + }); + it('should have no flowto candidate for the first ancestor', () => { + expect(selectors.ariaFlowtoCandidate(state())(firstAncestorID)).toBe(null); + }); + it('should have no flowto candidate for the second ancestor ancestor', () => { + expect(selectors.ariaFlowtoCandidate(state())(secondAncestorID)).toBe(null); + }); + }); + describe('with a tree with 2 children and no ancestors', () => { + const originID = 'c'; + const firstChildID = 'd'; + const secondChildID = 'e'; + beforeEach(() => { + actions.push({ + type: 'serverReturnedResolverData', + payload: { + result: mockTreeWithNoAncestorsAnd2Children({ originID, firstChildID, secondChildID }), + // this value doesn't matter + databaseDocumentID: '', + }, + }); + }); + it('should have no flowto candidate for the origin', () => { + expect(selectors.ariaFlowtoCandidate(state())(originID)).toBe(null); + }); + it('should use the second child as the flowto candidate for the first child', () => { + expect(selectors.ariaFlowtoCandidate(state())(firstChildID)).toBe(secondChildID); + }); + it('should have no flowto candidate for the second child', () => { + expect(selectors.ariaFlowtoCandidate(state())(secondChildID)).toBe(null); + }); + }); + describe('with a tree where the root process has no parent info at all', () => { + const originID = 'c'; + const firstChildID = 'd'; + const secondChildID = 'e'; + beforeEach(() => { + const tree = mockTreeWithNoAncestorsAnd2Children({ originID, firstChildID, secondChildID }); + for (const event of tree.lifecycle) { + // delete the process.parent key, if present + // cast as `EndpointEvent` because `ResolverEvent` can also be `LegacyEndpointEvent` which has no `process` field + delete (event as EndpointEvent).process?.parent; + } + + actions.push({ + type: 'serverReturnedResolverData', + payload: { + result: tree, + // this value doesn't matter + databaseDocumentID: '', + }, + }); + }); + it('should be able to calculate the aria flowto candidates for all processes nodes', () => { + const graphables = selectors.graphableProcesses(state()); + expect(graphables.length).toBe(3); + for (const event of graphables) { + expect(() => { + selectors.ariaFlowtoCandidate(state())(uniquePidForProcess(event)); + }).not.toThrow(); + } + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts index dc17fc70ef8af..109b3abddcc77 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts @@ -19,9 +19,9 @@ import { isGraphableProcess, isTerminatedProcess, uniquePidForProcess, + uniqueParentPidForProcess, } from '../../models/process_event'; import * as indexedProcessTreeModel from '../../models/indexed_process_tree'; -import { isEqual } from '../../models/aabb'; import { ResolverEvent, @@ -62,7 +62,7 @@ export function hasError(state: DataState): boolean { * The last ResolverTree we received, if any. It may be stale (it might not be for the same databaseDocumentID that * we're currently interested in. */ -const resolverTree = (state: DataState): ResolverTree | undefined => { +const resolverTreeResponse = (state: DataState): ResolverTree | undefined => { if (state.lastResponse && state.lastResponse.successful) { return state.lastResponse.result; } else { @@ -73,7 +73,9 @@ const resolverTree = (state: DataState): ResolverTree | undefined => { /** * Process events that will be displayed as terminated. */ -export const terminatedProcesses = createSelector(resolverTree, function (tree?: ResolverTree) { +export const terminatedProcesses = createSelector(resolverTreeResponse, function ( + tree?: ResolverTree +) { if (!tree) { return new Set(); } @@ -90,7 +92,7 @@ export const terminatedProcesses = createSelector(resolverTree, function (tree?: /** * Process events that will be graphed. */ -export const graphableProcesses = createSelector(resolverTree, function (tree?) { +export const graphableProcesses = createSelector(resolverTreeResponse, function (tree?) { if (tree) { return resolverTreeModel.lifecycleEvents(tree).filter(isGraphableProcess); } else { @@ -101,7 +103,7 @@ export const graphableProcesses = createSelector(resolverTree, function (tree?) /** * The 'indexed process tree' contains the tree data, indexed in helpful ways. Used for O(1) access to stuff during graph layout. */ -export const indexedProcessTree = createSelector(graphableProcesses, function indexedTree( +export const tree = createSelector(graphableProcesses, function indexedTree( /* eslint-disable no-shadow */ graphableProcesses /* eslint-enable no-shadow */ @@ -114,13 +116,16 @@ export const indexedProcessTree = createSelector(graphableProcesses, function in */ export const relatedEventsStats: ( state: DataState -) => Map | null = createSelector(resolverTree, (tree?: ResolverTree) => { - if (tree) { - return resolverTreeModel.relatedEventsStats(tree); - } else { - return null; +) => Map | null = createSelector( + resolverTreeResponse, + (resolverTree?: ResolverTree) => { + if (resolverTree) { + return resolverTreeModel.relatedEventsStats(resolverTree); + } else { + return null; + } } -}); +); /** * returns a map of entity_ids to related event data. @@ -133,7 +138,9 @@ export function relatedEventsByEntityId(data: DataState): Map (entityID: string) => (ecsCategory: string) => ResolverEvent[] = createSelector( relatedEventsByEntityId, function provideGettersByCategory( /* eslint-disable no-shadow */ @@ -173,16 +180,16 @@ export function relatedEventsReady(data: DataState): Map { * `true` if there were more children than we got in the last request. */ export function hasMoreChildren(state: DataState): boolean { - const tree = resolverTree(state); - return tree ? resolverTreeModel.hasMoreChildren(tree) : false; + const resolverTree = resolverTreeResponse(state); + return resolverTree ? resolverTreeModel.hasMoreChildren(resolverTree) : false; } /** * `true` if there were more ancestors than we got in the last request. */ export function hasMoreAncestors(state: DataState): boolean { - const tree = resolverTree(state); - return tree ? resolverTreeModel.hasMoreAncestors(tree) : false; + const resolverTree = resolverTreeResponse(state); + return resolverTree ? resolverTreeModel.hasMoreAncestors(resolverTree) : false; } interface RelatedInfoFunctions { @@ -248,7 +255,7 @@ export const relatedEventInfoByEntityId: ( }); }; - const matchingEventsForCategory = defaultMemoize(unmemoizedMatchingEventsForCategory); + const matchingEventsForCategory = unmemoizedMatchingEventsForCategory; /** * The number of events that occurred before the API limit was reached. @@ -313,16 +320,13 @@ export function databaseDocumentIDToFetch(state: DataState): string | null { } } -export const layout = createSelector( - indexedProcessTree, - function processNodePositionsAndEdgeLineSegments( - /* eslint-disable no-shadow */ - indexedProcessTree - /* eslint-enable no-shadow */ - ) { - return isometricTaxiLayout(indexedProcessTree); - } -); +export const layout = createSelector(tree, function processNodePositionsAndEdgeLineSegments( + /* eslint-disable no-shadow */ + indexedProcessTree + /* eslint-enable no-shadow */ +) { + return isometricTaxiLayout(indexedProcessTree); +}); /** * Given a nodeID (aka entity_id) get the indexed process event. @@ -332,8 +336,9 @@ export const layout = createSelector( export const processEventForID: ( state: DataState ) => (nodeID: string) => ResolverEvent | null = createSelector( - indexedProcessTree, - (tree) => (nodeID: string) => indexedProcessTreeModel.processEvent(tree, nodeID) + tree, + (indexedProcessTree) => (nodeID: string) => + indexedProcessTreeModel.processEvent(indexedProcessTree, nodeID) ); /** @@ -349,30 +354,66 @@ export const ariaLevel: (state: DataState) => (nodeID: string) => number | null ); /** - * Returns the following sibling if there is one, or `null`. + * Returns the following sibling if there is one, or `null` if there isn't. + * For root nodes, other root nodes are treated as siblings. + * This is used to calculate the `aria-flowto` attribute. */ -export const followingSibling: ( +export const ariaFlowtoCandidate: ( state: DataState ) => (nodeID: string) => string | null = createSelector( - indexedProcessTree, + tree, processEventForID, - (tree, eventGetter) => { - return (nodeID: string) => { - const event = eventGetter(nodeID); + (indexedProcessTree, eventGetter) => { + // A map of preceding sibling IDs to following sibling IDs or `null`, if there is no following sibling + const memo: Map = new Map(); - // event not found - if (event === null) { - return null; + return function memoizedGetter(/** the unique ID of a node. **/ nodeID: string): string | null { + // Previous calculations are memoized. Check for a value in the memo. + const existingValue = memo.get(nodeID); + + /** + * `undefined` means the key wasn't in the map. + * Note: the value may be null, meaning that we checked and there is no following sibling. + * If there is a value in the map, return it. + */ + if (existingValue !== undefined) { + return existingValue; } - const nextSibling = indexedProcessTreeModel.nextSibling(tree, event); - // next sibling not found - if (nextSibling === undefined) { - return null; + /** + * Getting the following sibling of a node has an `O(n)` time complexity where `n` is the number of children the parent of the node has. + * For this reason, we calculate the following siblings of the node and all of its siblings at once and cache them. + */ + const nodeEvent: ResolverEvent | null = eventGetter(nodeID); + + if (!nodeEvent) { + // this should never happen. + throw new Error('could not find child event in process tree.'); + } + + // nodes with the same parent ID + const children = indexedProcessTreeModel.children( + indexedProcessTree, + uniqueParentPidForProcess(nodeEvent) + ); + + let previousChild: ResolverEvent | null = null; + // Loop over all nodes that have the same parent ID (even if the parent ID is undefined or points to a node that isn't in the tree.) + for (const child of children) { + if (previousChild !== null) { + // Set the `child` as the following sibling of `previousChild`. + memo.set(uniquePidForProcess(previousChild), uniquePidForProcess(child)); + } + // Set the child as the previous child. + previousChild = child; + } + + if (previousChild) { + // if there is a previous child, it has no following sibling. + memo.set(uniquePidForProcess(previousChild), null); } - // return the node ID - return uniquePidForProcess(nextSibling); + return memoizedGetter(nodeID); }; } ); @@ -385,7 +426,7 @@ const spatiallyIndexedLayout: (state: DataState) => rbush = creat edgeLineSegments, /* eslint-enable no-shadow */ }) { - const tree: rbush = new rbush(); + const spatialIndex: rbush = new rbush(); const processesToIndex: IndexedProcessNode[] = []; const edgeLineSegmentsToIndex: IndexedEdgeLineSegment[] = []; @@ -421,50 +462,49 @@ const spatiallyIndexedLayout: (state: DataState) => rbush = creat }; edgeLineSegmentsToIndex.push(indexedLineSegment); } - tree.load([...processesToIndex, ...edgeLineSegmentsToIndex]); - return tree; + spatialIndex.load([...processesToIndex, ...edgeLineSegmentsToIndex]); + return spatialIndex; } ); +/** + * Returns nodes and edge lines that could be visible in the `query`. + */ export const nodesAndEdgelines: ( state: DataState -) => (query: AABB) => VisibleEntites = createSelector(spatiallyIndexedLayout, function (tree) { - // memoize the results of this call to avoid unnecessarily rerunning - let lastBoundingBox: AABB | null = null; - let currentlyVisible: VisibleEntites = { - processNodePositions: new Map(), - connectingEdgeLineSegments: [], - }; - return (boundingBox: AABB) => { - if (lastBoundingBox !== null && isEqual(lastBoundingBox, boundingBox)) { - return currentlyVisible; - } else { - const { - minimum: [minX, minY], - maximum: [maxX, maxY], - } = boundingBox; - const entities = tree.search({ - minX, - minY, - maxX, - maxY, - }); - const visibleProcessNodePositions = new Map( - entities - .filter((entity): entity is IndexedProcessNode => entity.type === 'processNode') - .map((node) => [node.entity, node.position]) - ); - const connectingEdgeLineSegments = entities - .filter((entity): entity is IndexedEdgeLineSegment => entity.type === 'edgeLine') - .map((node) => node.entity); - currentlyVisible = { - processNodePositions: visibleProcessNodePositions, - connectingEdgeLineSegments, - }; - lastBoundingBox = boundingBox; - return currentlyVisible; - } - }; +) => ( + /** + * An axis aligned bounding box (in world corrdinates) to search in. Any entities that might collide with this box will be returned. + */ + query: AABB +) => VisibleEntites = createSelector(spatiallyIndexedLayout, function (spatialIndex) { + /** + * Memoized for performance and object reference equality. + */ + return defaultMemoize((boundingBox: AABB) => { + const { + minimum: [minX, minY], + maximum: [maxX, maxY], + } = boundingBox; + const entities = spatialIndex.search({ + minX, + minY, + maxX, + maxY, + }); + const visibleProcessNodePositions = new Map( + entities + .filter((entity): entity is IndexedProcessNode => entity.type === 'processNode') + .map((node) => [node.entity, node.position]) + ); + const connectingEdgeLineSegments = entities + .filter((entity): entity is IndexedEdgeLineSegment => entity.type === 'edgeLine') + .map((node) => node.entity); + return { + processNodePositions: visibleProcessNodePositions, + connectingEdgeLineSegments, + }; + }); }); /** diff --git a/x-pack/plugins/security_solution/public/resolver/store/mocks/endpoint_event.ts b/x-pack/plugins/security_solution/public/resolver/store/mocks/endpoint_event.ts new file mode 100644 index 0000000000000..b58ea73e1fdc7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/store/mocks/endpoint_event.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EndpointEvent } from '../../../../common/endpoint/types'; + +/** + * Simple mock endpoint event that works for tree layouts. + */ +export function mockEndpointEvent({ + entityID, + name, + parentEntityId, + timestamp, +}: { + entityID: string; + name: string; + parentEntityId: string | undefined; + timestamp: number; +}): EndpointEvent { + return { + '@timestamp': timestamp, + event: { + type: 'start', + category: 'process', + }, + process: { + entity_id: entityID, + name, + parent: { + entity_id: parentEntityId, + }, + }, + } as EndpointEvent; +} diff --git a/x-pack/plugins/security_solution/public/resolver/store/mocks/resolver_tree.ts b/x-pack/plugins/security_solution/public/resolver/store/mocks/resolver_tree.ts new file mode 100644 index 0000000000000..862cf47f73947 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/store/mocks/resolver_tree.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mockEndpointEvent } from './endpoint_event'; +import { ResolverTree, ResolverEvent } from '../../../../common/endpoint/types'; + +export function mockTreeWith2AncestorsAndNoChildren({ + originID, + firstAncestorID, + secondAncestorID, +}: { + secondAncestorID: string; + firstAncestorID: string; + originID: string; +}): ResolverTree { + const secondAncestor: ResolverEvent = mockEndpointEvent({ + entityID: secondAncestorID, + name: 'a', + parentEntityId: 'none', + timestamp: 0, + }); + const firstAncestor: ResolverEvent = mockEndpointEvent({ + entityID: firstAncestorID, + name: 'b', + parentEntityId: secondAncestorID, + timestamp: 1, + }); + const originEvent: ResolverEvent = mockEndpointEvent({ + entityID: originID, + name: 'c', + parentEntityId: firstAncestorID, + timestamp: 2, + }); + return ({ + entityID: originID, + children: { + childNodes: [], + }, + ancestry: { + ancestors: [{ lifecycle: [secondAncestor] }, { lifecycle: [firstAncestor] }], + }, + lifecycle: [originEvent], + } as unknown) as ResolverTree; +} + +export function mockTreeWithNoAncestorsAnd2Children({ + originID, + firstChildID, + secondChildID, +}: { + originID: string; + firstChildID: string; + secondChildID: string; +}): ResolverTree { + const origin: ResolverEvent = mockEndpointEvent({ + entityID: originID, + name: 'c', + parentEntityId: 'none', + timestamp: 0, + }); + const firstChild: ResolverEvent = mockEndpointEvent({ + entityID: firstChildID, + name: 'd', + parentEntityId: originID, + timestamp: 1, + }); + const secondChild: ResolverEvent = mockEndpointEvent({ + entityID: secondChildID, + name: 'e', + parentEntityId: originID, + timestamp: 2, + }); + + return ({ + entityID: originID, + children: { + childNodes: [{ lifecycle: [firstChild] }, { lifecycle: [secondChild] }], + }, + ancestry: { + ancestors: [], + }, + lifecycle: [origin], + } as unknown) as ResolverTree; +} diff --git a/x-pack/plugins/security_solution/public/resolver/store/selectors.test.ts b/x-pack/plugins/security_solution/public/resolver/store/selectors.test.ts index ba4a5a169c549..df365a078b27f 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/selectors.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/selectors.test.ts @@ -9,7 +9,10 @@ import { createStore } from 'redux'; import { ResolverAction } from './actions'; import { resolverReducer } from './reducer'; import * as selectors from './selectors'; -import { EndpointEvent, ResolverEvent, ResolverTree } from '../../../common/endpoint/types'; +import { + mockTreeWith2AncestorsAndNoChildren, + mockTreeWithNoAncestorsAnd2Children, +} from './mocks/resolver_tree'; describe('resolver selectors', () => { const actions: ResolverAction[] = []; @@ -33,7 +36,7 @@ describe('resolver selectors', () => { actions.push({ type: 'serverReturnedResolverData', payload: { - result: treeWith2AncestorsAndNoChildren({ + result: mockTreeWith2AncestorsAndNoChildren({ originID, firstAncestorID, secondAncestorID, @@ -71,7 +74,7 @@ describe('resolver selectors', () => { actions.push({ type: 'serverReturnedResolverData', payload: { - result: treeWithNoAncestorsAnd2Children({ originID, firstChildID, secondChildID }), + result: mockTreeWithNoAncestorsAnd2Children({ originID, firstChildID, secondChildID }), // this value doesn't matter databaseDocumentID: '', }, @@ -149,111 +152,3 @@ describe('resolver selectors', () => { }); }); }); -/** - * Simple mock endpoint event that works for tree layouts. - */ -function mockEndpointEvent({ - entityID, - name, - parentEntityId, - timestamp, -}: { - entityID: string; - name: string; - parentEntityId: string; - timestamp: number; -}): EndpointEvent { - return { - '@timestamp': timestamp, - event: { - type: 'start', - category: 'process', - }, - process: { - entity_id: entityID, - name, - parent: { - entity_id: parentEntityId, - }, - }, - } as EndpointEvent; -} - -function treeWith2AncestorsAndNoChildren({ - originID, - firstAncestorID, - secondAncestorID, -}: { - secondAncestorID: string; - firstAncestorID: string; - originID: string; -}): ResolverTree { - const secondAncestor: ResolverEvent = mockEndpointEvent({ - entityID: secondAncestorID, - name: 'a', - parentEntityId: 'none', - timestamp: 0, - }); - const firstAncestor: ResolverEvent = mockEndpointEvent({ - entityID: firstAncestorID, - name: 'b', - parentEntityId: secondAncestorID, - timestamp: 1, - }); - const originEvent: ResolverEvent = mockEndpointEvent({ - entityID: originID, - name: 'c', - parentEntityId: firstAncestorID, - timestamp: 2, - }); - return ({ - entityID: originID, - children: { - childNodes: [], - }, - ancestry: { - ancestors: [{ lifecycle: [secondAncestor] }, { lifecycle: [firstAncestor] }], - }, - lifecycle: [originEvent], - } as unknown) as ResolverTree; -} - -function treeWithNoAncestorsAnd2Children({ - originID, - firstChildID, - secondChildID, -}: { - originID: string; - firstChildID: string; - secondChildID: string; -}): ResolverTree { - const origin: ResolverEvent = mockEndpointEvent({ - entityID: originID, - name: 'c', - parentEntityId: 'none', - timestamp: 0, - }); - const firstChild: ResolverEvent = mockEndpointEvent({ - entityID: firstChildID, - name: 'd', - parentEntityId: originID, - timestamp: 1, - }); - const secondChild: ResolverEvent = mockEndpointEvent({ - entityID: secondChildID, - name: 'e', - parentEntityId: originID, - timestamp: 2, - }); - - return ({ - entityID: originID, - children: { - childNodes: [{ lifecycle: [firstChild] }, { lifecycle: [secondChild] }], - }, - ancestry: { - ancestors: [], - }, - lifecycle: [origin], - } as unknown) as ResolverTree; -} diff --git a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts index ff2179dc3a2ae..040e2920ce554 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts @@ -212,17 +212,6 @@ export const graphableProcesses = composeSelectors( dataSelectors.graphableProcesses ); -/** - * Calls the `secondSelector` with the result of the `selector`. Use this when re-exporting a - * concern-specific selector. `selector` should return the concern-specific state. - */ -function composeSelectors( - selector: (state: OuterState) => InnerState, - secondSelector: (state: InnerState) => ReturnValue -): (state: OuterState) => ReturnValue { - return (state) => secondSelector(selector(state)); -} - const boundingBox = composeSelectors(cameraStateSelector, cameraSelectors.viewableBoundingBox); const nodesAndEdgelines = composeSelectors(dataStateSelector, dataSelectors.nodesAndEdgelines); @@ -246,6 +235,7 @@ export const visibleNodesAndEdgeLines = createSelector(nodesAndEdgelines, boundi boundingBox /* eslint-enable no-shadow */ ) { + // `boundingBox` and `nodesAndEdgelines` are each memoized. return (time: number) => nodesAndEdgelines(boundingBox(time)); }); @@ -261,14 +251,14 @@ export const ariaLevel: ( /** * Takes a nodeID (aka entity_id) and returns the node ID of the node that aria should 'flowto' or null - * If the node has a following sibling that is currently visible, that will be returned, otherwise null. + * If the node has a flowto candidate that is currently visible, that will be returned, otherwise null. */ export const ariaFlowtoNodeID: ( state: ResolverState ) => (time: number) => (nodeID: string) => string | null = createSelector( visibleNodesAndEdgeLines, - composeSelectors(dataStateSelector, dataSelectors.followingSibling), - (visibleNodesAndEdgeLinesAtTime, followingSibling) => { + composeSelectors(dataStateSelector, dataSelectors.ariaFlowtoCandidate), + (visibleNodesAndEdgeLinesAtTime, ariaFlowtoCandidate) => { return defaultMemoize((time: number) => { // get the visible nodes at `time` const { processNodePositions } = visibleNodesAndEdgeLinesAtTime(time); @@ -280,10 +270,23 @@ export const ariaFlowtoNodeID: ( // return the ID of `nodeID`'s following sibling, if it is visible return (nodeID: string): string | null => { - const sibling: string | null = followingSibling(nodeID); + const flowtoNode: string | null = ariaFlowtoCandidate(nodeID); - return sibling === null || nodesVisibleAtTime.has(sibling) === false ? null : sibling; + return flowtoNode === null || nodesVisibleAtTime.has(flowtoNode) === false + ? null + : flowtoNode; }; }); } ); + +/** + * Calls the `secondSelector` with the result of the `selector`. Use this when re-exporting a + * concern-specific selector. `selector` should return the concern-specific state. + */ +function composeSelectors( + selector: (state: OuterState) => InnerState, + secondSelector: (state: InnerState) => ReturnValue +): (state: OuterState) => ReturnValue { + return (state) => secondSelector(selector(state)); +} diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_related_detail.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_related_detail.tsx index 4544381d94955..10e57a09b5da4 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_related_detail.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_related_detail.tsx @@ -169,7 +169,9 @@ export const RelatedEventDetail = memo(function RelatedEventDetail({ process, ...relevantData } = relatedEventToShowDetailsFor as ResolverEvent & { + // Type this with various unknown keys so that ts will let us delete those keys ecs: unknown; + process: unknown; }; let displayDate = ''; const sectionData: Array<{ @@ -371,4 +373,3 @@ export const RelatedEventDetail = memo(function RelatedEventDetail({ ); }); -RelatedEventDetail.displayName = 'RelatedEventDetail';