[Resolver] Selector performance (#72380)
* Memoize various selectors * Improve performance of the selectors that calculate the `aria-flowto` attribute. * more tests.
This commit is contained in:
parent
f331cc8b64
commit
6cf796a4fb
11 changed files with 427 additions and 280 deletions
|
@ -482,7 +482,6 @@ export interface LegacyEndpointEvent {
|
|||
type: string;
|
||||
version: string;
|
||||
};
|
||||
process?: object;
|
||||
rule?: object;
|
||||
user?: object;
|
||||
event?: {
|
||||
|
|
|
@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
(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,
|
||||
};
|
||||
}
|
||||
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,
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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<string, ResolverNodeStats> | null = createSelector(resolverTree, (tree?: ResolverTree) => {
|
||||
if (tree) {
|
||||
return resolverTreeModel.relatedEventsStats(tree);
|
||||
} else {
|
||||
return null;
|
||||
) => Map<string, ResolverNodeStats> | 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<string, ResolverRe
|
|||
* Returns a function that returns a function (when supplied with an entity id for a node)
|
||||
* that returns related events for a node that match an event.category (when supplied with the category)
|
||||
*/
|
||||
export const relatedEventsByCategory = createSelector(
|
||||
export const relatedEventsByCategory: (
|
||||
state: DataState
|
||||
) => (entityID: string) => (ecsCategory: string) => ResolverEvent[] = createSelector(
|
||||
relatedEventsByEntityId,
|
||||
function provideGettersByCategory(
|
||||
/* eslint-disable no-shadow */
|
||||
|
@ -173,16 +180,16 @@ export function relatedEventsReady(data: DataState): Map<string, boolean> {
|
|||
* `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<string, string | null> = new Map();
|
||||
|
||||
// event not found
|
||||
if (event === null) {
|
||||
return null;
|
||||
}
|
||||
const nextSibling = indexedProcessTreeModel.nextSibling(tree, event);
|
||||
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);
|
||||
|
||||
// next sibling not found
|
||||
if (nextSibling === undefined) {
|
||||
return null;
|
||||
/**
|
||||
* `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;
|
||||
}
|
||||
|
||||
// return the node ID
|
||||
return uniquePidForProcess(nextSibling);
|
||||
/**
|
||||
* 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 memoizedGetter(nodeID);
|
||||
};
|
||||
}
|
||||
);
|
||||
|
@ -385,7 +426,7 @@ const spatiallyIndexedLayout: (state: DataState) => rbush<IndexedEntity> = creat
|
|||
edgeLineSegments,
|
||||
/* eslint-enable no-shadow */
|
||||
}) {
|
||||
const tree: rbush<IndexedEntity> = new rbush();
|
||||
const spatialIndex: rbush<IndexedEntity> = new rbush();
|
||||
const processesToIndex: IndexedProcessNode[] = [];
|
||||
const edgeLineSegmentsToIndex: IndexedEdgeLineSegment[] = [];
|
||||
|
||||
|
@ -421,50 +462,49 @@ const spatiallyIndexedLayout: (state: DataState) => rbush<IndexedEntity> = 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<ResolverEvent, Vector2>(),
|
||||
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<ResolverEvent, Vector2>(
|
||||
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<ResolverEvent, Vector2>(
|
||||
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,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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<OuterState, InnerState, ReturnValue>(
|
||||
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<OuterState, InnerState, ReturnValue>(
|
||||
selector: (state: OuterState) => InnerState,
|
||||
secondSelector: (state: InnerState) => ReturnValue
|
||||
): (state: OuterState) => ReturnValue {
|
||||
return (state) => secondSelector(selector(state));
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
Loading…
Reference in a new issue