Skip to content

Commit

Permalink
[Resolver] Origin process (#72382)
Browse files Browse the repository at this point in the history
Co-authored-by: Brent Kimmel <[email protected]>

* Center the origin node
* Nodes appear selected when they are selected. also the aria attributes are working.
* Reposition the submenu when the user pans.
  • Loading branch information
Robert Austin committed Jul 23, 2020
1 parent ef49cc0 commit d8342f4
Show file tree
Hide file tree
Showing 13 changed files with 214 additions and 139 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/
import { IsometricTaxiLayout } from '../../types';
import { LegacyEndpointEvent } from '../../../../common/endpoint/types';
import { isometricTaxiLayout } from './isometric_taxi_layout';
import { isometricTaxiLayoutFactory } from './isometric_taxi_layout';
import { mockProcessEvent } from '../../models/process_event_test_helpers';
import { factory } from './index';

Expand Down Expand Up @@ -107,7 +107,7 @@ describe('resolver graph layout', () => {
unique_ppid: 0,
},
});
layout = () => isometricTaxiLayout(factory(events));
layout = () => isometricTaxiLayoutFactory(factory(events));
events = [];
});
describe('when rendering no nodes', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import * as vector2 from '../../models/vector2';
import {
IndexedProcessTree,
Vector2,
Expand All @@ -17,14 +16,17 @@ import {
} from '../../types';
import * as event from '../../../../common/endpoint/models/event';
import { ResolverEvent } from '../../../../common/endpoint/types';
import * as model from './index';
import * as vector2 from '../vector2';
import * as indexedProcessTreeModel from './index';
import { getFriendlyElapsedTime as elapsedTime } from '../../lib/date';
import { uniquePidForProcess } from '../process_event';

/**
* Graph the process tree
*/
export function isometricTaxiLayout(indexedProcessTree: IndexedProcessTree): IsometricTaxiLayout {
export function isometricTaxiLayoutFactory(
indexedProcessTree: IndexedProcessTree
): IsometricTaxiLayout {
/**
* Walk the tree in reverse level order, calculating the 'width' of subtrees.
*/
Expand Down Expand Up @@ -83,8 +85,8 @@ export function isometricTaxiLayout(indexedProcessTree: IndexedProcessTree): Iso
*/
function ariaLevels(indexedProcessTree: IndexedProcessTree): Map<ResolverEvent, number> {
const map: Map<ResolverEvent, number> = new Map();
for (const node of model.levelOrder(indexedProcessTree)) {
const parentNode = model.parent(indexedProcessTree, node);
for (const node of indexedProcessTreeModel.levelOrder(indexedProcessTree)) {
const parentNode = indexedProcessTreeModel.parent(indexedProcessTree, node);
if (parentNode === undefined) {
// nodes at the root have a level of 1
map.set(node, 1);
Expand Down Expand Up @@ -143,16 +145,19 @@ function ariaLevels(indexedProcessTree: IndexedProcessTree): Map<ResolverEvent,
function widthsOfProcessSubtrees(indexedProcessTree: IndexedProcessTree): ProcessWidths {
const widths = new Map<ResolverEvent, number>();

if (model.size(indexedProcessTree) === 0) {
if (indexedProcessTreeModel.size(indexedProcessTree) === 0) {
return widths;
}

const processesInReverseLevelOrder: ResolverEvent[] = [
...model.levelOrder(indexedProcessTree),
...indexedProcessTreeModel.levelOrder(indexedProcessTree),
].reverse();

for (const process of processesInReverseLevelOrder) {
const children = model.children(indexedProcessTree, uniquePidForProcess(process));
const children = indexedProcessTreeModel.children(
indexedProcessTree,
uniquePidForProcess(process)
);

const sumOfWidthOfChildren = function sumOfWidthOfChildren() {
return children.reduce(function sum(currentValue, child) {
Expand Down Expand Up @@ -229,7 +234,10 @@ function processEdgeLineSegments(
metadata: edgeLineMetadata,
};

const siblings = model.children(indexedProcessTree, uniquePidForProcess(parent));
const siblings = indexedProcessTreeModel.children(
indexedProcessTree,
uniquePidForProcess(parent)
);
const isFirstChild = process === siblings[0];

if (metadata.isOnlyChild) {
Expand Down Expand Up @@ -384,8 +392,8 @@ function* levelOrderWithWidths(
tree: IndexedProcessTree,
widths: ProcessWidths
): Iterable<ProcessWithWidthMetadata> {
for (const process of model.levelOrder(tree)) {
const parent = model.parent(tree, process);
for (const process of indexedProcessTreeModel.levelOrder(tree)) {
const parent = indexedProcessTreeModel.parent(tree, process);
const width = widths.get(process);

if (width === undefined) {
Expand Down Expand Up @@ -423,7 +431,7 @@ function* levelOrderWithWidths(
parentWidth,
};

const siblings = model.children(tree, uniquePidForProcess(parent));
const siblings = indexedProcessTreeModel.children(tree, uniquePidForProcess(parent));
if (siblings.length === 1) {
metadata.isOnlyChild = true;
metadata.lastChildWidth = width;
Expand Down Expand Up @@ -479,3 +487,32 @@ const distanceBetweenNodesInUnits = 2;
* The distance in pixels (at scale 1) between nodes. Change this to space out nodes more
*/
const distanceBetweenNodes = distanceBetweenNodesInUnits * unit;

export function nodePosition(model: IsometricTaxiLayout, node: ResolverEvent): Vector2 | undefined {
return model.processNodePositions.get(node);
}

/**
* Return a clone of `model` with all positions incremented by `translation`.
* Use this to move the layout around.
* e.g.
* ```
* translated(layout, [100, -200]) // return a copy of `layout`, thats been moved 100 to the right and 200 up
* ```
*/
export function translated(model: IsometricTaxiLayout, translation: Vector2): IsometricTaxiLayout {
return {
processNodePositions: new Map(
[...model.processNodePositions.entries()].map(([node, position]) => [
node,
vector2.add(position, translation),
])
),
edgeLineSegments: model.edgeLineSegments.map(({ points, metadata }) => ({
points: points.map((point) => vector2.add(point, translation)),
metadata,
})),
// these are unchanged
ariaLevels: model.ariaLevels,
};
}
23 changes: 7 additions & 16 deletions x-pack/plugins/security_solution/public/resolver/store/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,9 @@ interface AppDetectedMissingEventData {
*/
interface UserFocusedOnResolverNode {
readonly type: 'userFocusedOnResolverNode';
readonly payload: {
/**
* Used to identify the process node that the user focused on (in the DOM)
*/
readonly nodeId: string;
};

/** focused nodeID */
readonly payload: string;
}

/**
Expand All @@ -85,16 +82,10 @@ interface UserFocusedOnResolverNode {
*/
interface UserSelectedResolverNode {
readonly type: 'userSelectedResolverNode';
readonly payload: {
/**
* The HTML ID used to identify the process node's element that the user selected
*/
readonly nodeId: string;
/**
* The process entity_id for the process the node represents
*/
readonly selectedProcessId: string;
};
/**
* The nodeID (aka entity_id) that was select.
*/
readonly payload: string;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ import {
ResolverRelatedEvents,
} from '../../../../common/endpoint/types';
import * as resolverTreeModel from '../../models/resolver_tree';
import { isometricTaxiLayout } from '../../models/indexed_process_tree/isometric_taxi_layout';
import * as isometricTaxiLayoutModel from '../../models/indexed_process_tree/isometric_taxi_layout';
import { allEventCategories } from '../../../../common/endpoint/models/event';
import * as vector2 from '../../models/vector2';

/**
* If there is currently a request.
Expand Down Expand Up @@ -70,6 +71,21 @@ const resolverTreeResponse = (state: DataState): ResolverTree | undefined => {
}
};

/**
* the node ID of the node representing the databaseDocumentID.
* NB: this could be stale if the last response is stale
*/
export const originID: (state: DataState) => string | undefined = createSelector(
resolverTreeResponse,
function (resolverTree?) {
if (resolverTree) {
// This holds the entityID (aka nodeID) of the node related to the last fetched `_id`
return resolverTree.entityID;
}
return undefined;
}
);

/**
* Process events that will be displayed as terminated.
*/
Expand Down Expand Up @@ -317,13 +333,45 @@ export function databaseDocumentIDToFetch(state: DataState): string | null {
}
}

export const layout = createSelector(tree, function processNodePositionsAndEdgeLineSegments(
/* eslint-disable no-shadow */
indexedProcessTree
/* eslint-enable no-shadow */
) {
return isometricTaxiLayout(indexedProcessTree);
});
export const layout = createSelector(
tree,
originID,
function processNodePositionsAndEdgeLineSegments(
/* eslint-disable no-shadow */
indexedProcessTree,
originID
/* eslint-enable no-shadow */
) {
// use the isometric taxi layout as a base
const taxiLayout = isometricTaxiLayoutModel.isometricTaxiLayoutFactory(indexedProcessTree);

if (!originID) {
// no data has loaded.
return taxiLayout;
}

// find the origin node
const originNode = indexedProcessTreeModel.processEvent(indexedProcessTree, originID);

if (!originNode) {
// this should only happen if the `ResolverTree` from the server has an entity ID with no matching lifecycle events.
throw new Error('Origin node not found in ResolverTree');
}

// Find the position of the origin, we'll center the map on it intrinsically
const originPosition = isometricTaxiLayoutModel.nodePosition(taxiLayout, originNode);
// adjust the position of everything so that the origin node is at `(0, 0)`

if (originPosition === undefined) {
// not sure how this could happen.
return taxiLayout;
}

// Take the origin position, and multipy it by -1, then move the layout by that amount.
// This should center the layout around the origin.
return isometricTaxiLayoutModel.translated(taxiLayout, vector2.scale(originPosition, -1));
}
);

/**
* Given a nodeID (aka entity_id) get the indexed process event.
Expand Down
50 changes: 18 additions & 32 deletions x-pack/plugins/security_solution/public/resolver/store/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,59 +4,45 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { Reducer, combineReducers } from 'redux';
import { htmlIdGenerator } from '@elastic/eui';
import { animateProcessIntoView } from './methods';
import { cameraReducer } from './camera/reducer';
import { dataReducer } from './data/reducer';
import { ResolverAction } from './actions';
import { ResolverState, ResolverUIState } from '../types';
import { uniquePidForProcess } from '../models/process_event';

/**
* Despite the name "generator", this function is entirely determinant
* (i.e. it will return the same html id given the same prefix 'resolverNode'
* and nodeId)
*/
const resolverNodeIdGenerator = htmlIdGenerator('resolverNode');

const uiReducer: Reducer<ResolverUIState, ResolverAction> = (
uiState = {
activeDescendantId: null,
selectedDescendantId: null,
processEntityIdOfSelectedDescendant: null,
state = {
ariaActiveDescendant: null,
selectedNode: null,
},
action
) => {
if (action.type === 'userFocusedOnResolverNode') {
return {
...uiState,
activeDescendantId: action.payload.nodeId,
const next: ResolverUIState = {
...state,
ariaActiveDescendant: action.payload,
};
return next;
} else if (action.type === 'userSelectedResolverNode') {
return {
...uiState,
selectedDescendantId: action.payload.nodeId,
processEntityIdOfSelectedDescendant: action.payload.selectedProcessId,
const next: ResolverUIState = {
...state,
selectedNode: action.payload,
};
return next;
} else if (
action.type === 'userBroughtProcessIntoView' ||
action.type === 'appDetectedNewIdFromQueryParams'
) {
/**
* This action has a process payload (instead of a processId), so we use
* `uniquePidForProcess` and `resolverNodeIdGenerator` to resolve the determinant
* html id of the node being brought into view.
*/
const processEntityId = uniquePidForProcess(action.payload.process);
const processNodeId = resolverNodeIdGenerator(processEntityId);
return {
...uiState,
activeDescendantId: processNodeId,
selectedDescendantId: processNodeId,
processEntityIdOfSelectedDescendant: processEntityId,
const nodeID = uniquePidForProcess(action.payload.process);
const next: ResolverUIState = {
...state,
ariaActiveDescendant: nodeID,
selectedNode: nodeID,
};
return next;
} else {
return uiState;
return state;
}
};

Expand Down
27 changes: 12 additions & 15 deletions x-pack/plugins/security_solution/public/resolver/store/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,26 +144,15 @@ export const relatedEventInfoByEntityId = composeSelectors(
/**
* Returns the id of the "current" tree node (fake-focused)
*/
export const uiActiveDescendantId = composeSelectors(
export const ariaActiveDescendant = composeSelectors(
uiStateSelector,
uiSelectors.activeDescendantId
uiSelectors.ariaActiveDescendant
);

/**
* Returns the id of the "selected" tree node (the node that is currently "pressed" and possibly controlling other popups / components)
* Returns the nodeID of the selected node
*/
export const uiSelectedDescendantId = composeSelectors(
uiStateSelector,
uiSelectors.selectedDescendantId
);

/**
* Returns the entity_id of the "selected" tree node's process
*/
export const uiSelectedDescendantProcessId = composeSelectors(
uiStateSelector,
uiSelectors.selectedDescendantProcessId
);
export const selectedNode = composeSelectors(uiStateSelector, uiSelectors.selectedNode);

/**
* Returns the camera state from within ResolverState
Expand Down Expand Up @@ -251,6 +240,14 @@ export const ariaLevel: (
dataSelectors.ariaLevel
);

/**
* the node ID of the node representing the databaseDocumentID
*/
export const originID: (state: ResolverState) => string | undefined = composeSelectors(
dataStateSelector,
dataSelectors.originID
);

/**
* 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 flowto candidate that is currently visible, that will be returned, otherwise null.
Expand Down
Loading

0 comments on commit d8342f4

Please sign in to comment.