[Resolver] Origin process (#72382)

Co-authored-by: Brent Kimmel <brent.kimmel@elastic.co>

* Center the origin node
* Nodes appear selected when they are selected. also the aria attributes are working.
* Reposition the submenu when the user pans.
This commit is contained in:
Robert Austin 2020-07-21 17:47:22 -04:00 committed by GitHub
parent bb7d12864f
commit b930cef489
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 214 additions and 139 deletions

View file

@ -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';
@ -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', () => {

View file

@ -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,
@ -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.
*/
@ -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);
@ -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) {
@ -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) {
@ -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) {
@ -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;
@ -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,
};
}

View file

@ -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;
}
/**
@ -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;
}
/**

View file

@ -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.
@ -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.
*/
@ -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.

View file

@ -4,7 +4,6 @@
* 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';
@ -12,51 +11,38 @@ 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;
}
};

View file

@ -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
@ -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.

View file

@ -10,32 +10,21 @@ import { ResolverUIState } from '../../types';
/**
* id of the "current" tree node (fake-focused)
*/
export const activeDescendantId = createSelector(
export const ariaActiveDescendant = createSelector(
(uiState: ResolverUIState) => uiState,
/* eslint-disable no-shadow */
({ activeDescendantId }) => {
return activeDescendantId;
({ ariaActiveDescendant }) => {
return ariaActiveDescendant;
}
);
/**
* id of the currently "selected" tree node
*/
export const selectedDescendantId = createSelector(
export const selectedNode = createSelector(
(uiState: ResolverUIState) => uiState,
/* eslint-disable no-shadow */
({ selectedDescendantId }) => {
return selectedDescendantId;
}
);
/**
* id of the currently "selected" tree node
*/
export const selectedDescendantProcessId = createSelector(
(uiState: ResolverUIState) => uiState,
/* eslint-disable no-shadow */
({ processEntityIdOfSelectedDescendant }: ResolverUIState) => {
return processEntityIdOfSelectedDescendant;
({ selectedNode }: ResolverUIState) => {
return selectedNode;
}
);

View file

@ -34,17 +34,13 @@ export interface ResolverState {
*/
export interface ResolverUIState {
/**
* The ID attribute of the resolver's aria-activedescendent.
* The nodeID for the process that is selected (in the aria-activedescendent sense of being selected.)
*/
readonly activeDescendantId: string | null;
readonly ariaActiveDescendant: string | null;
/**
* The ID attribute of the resolver's currently selected descendant.
* nodeID of the selected node
*/
readonly selectedDescendantId: string | null;
/**
* The entity_id of the process for the resolver's currently selected descendant.
*/
readonly processEntityIdOfSelectedDescendant: string | null;
readonly selectedNode: string | null;
}
/**

View file

@ -422,7 +422,7 @@ const processTypeToCube: Record<ResolverProcessType, keyof NodeStyleMap> = {
export const useResolverTheme = (): {
colorMap: ColorMap;
nodeAssets: NodeStyleMap;
cubeAssetsForNode: (isProcessTerimnated: boolean, isProcessOrigin: boolean) => NodeStyleConfig;
cubeAssetsForNode: (isProcessTerimnated: boolean, isProcessTrigger: boolean) => NodeStyleConfig;
} => {
const isDarkMode = useUiSetting<boolean>(DEFAULT_DARK_MODE);
const theme = isDarkMode ? euiThemeAmsterdamDark : euiThemeAmsterdamLight;
@ -497,10 +497,14 @@ export const useResolverTheme = (): {
},
};
function cubeAssetsForNode(isProcessTerminated: boolean, isProcessOrigin: boolean) {
function cubeAssetsForNode(isProcessTerminated: boolean, isProcessTrigger: boolean) {
if (isProcessTerminated) {
return nodeAssets[processTypeToCube.processTerminated];
} else if (isProcessOrigin) {
if (isProcessTrigger) {
return nodeAssets.terminatedTriggerCube;
} else {
return nodeAssets[processTypeToCube.processTerminated];
}
} else if (isProcessTrigger) {
return nodeAssets[processTypeToCube.processCausedAlert];
} else {
return nodeAssets[processTypeToCube.processRan];

View file

@ -64,7 +64,7 @@ export const ResolverMap = React.memo(function ({
const { projectionMatrix, ref, onMouseDown } = useCamera();
const isLoading = useSelector(selectors.isLoading);
const hasError = useSelector(selectors.hasError);
const activeDescendantId = useSelector(selectors.uiActiveDescendantId);
const activeDescendantId = useSelector(selectors.ariaActiveDescendant);
const { colorMap } = useResolverTheme();
return (
@ -110,7 +110,6 @@ export const ResolverMap = React.memo(function ({
projectionMatrix={projectionMatrix}
event={processEvent}
isProcessTerminated={terminatedProcesses.has(processEntityId)}
isProcessOrigin={false}
timeAtRender={timeAtRender}
/>
);

View file

@ -60,10 +60,10 @@ const PanelContent = memo(function PanelContent() {
// The "selected" node (and its corresponding event) in the tree control.
// It may need to be synchronized with the ID indicated as selected via the `idFromParams`
// memo above. When this is the case, it is handled by the layout effect below.
const selectedDescendantProcessId = useSelector(selectors.uiSelectedDescendantProcessId);
const selectedNode = useSelector(selectors.selectedNode);
const uiSelectedEvent = useMemo(() => {
return graphableProcesses.find((evt) => event.entityId(evt) === selectedDescendantProcessId);
}, [graphableProcesses, selectedDescendantProcessId]);
return graphableProcesses.find((evt) => event.entityId(evt) === selectedNode);
}, [graphableProcesses, selectedNode]);
// Until an event is dispatched during update, the event indicated as selected by params may
// be different than the one in state.

View file

@ -72,7 +72,6 @@ const UnstyledProcessEventDot = React.memo(
event,
projectionMatrix,
isProcessTerminated,
isProcessOrigin,
timeAtRender,
}: {
/**
@ -95,10 +94,6 @@ const UnstyledProcessEventDot = React.memo(
* Whether or not to show the process as terminated.
*/
isProcessTerminated: boolean;
/**
* Whether or not to show the process as the originating event.
*/
isProcessOrigin: boolean;
/**
* The time (unix epoch) at render.
@ -117,8 +112,8 @@ const UnstyledProcessEventDot = React.memo(
const [xScale] = projectionMatrix;
// Node (html id=) IDs
const activeDescendantId = useSelector(selectors.uiActiveDescendantId);
const selectedDescendantId = useSelector(selectors.uiSelectedDescendantId);
const ariaActiveDescendant = useSelector(selectors.ariaActiveDescendant);
const selectedNode = useSelector(selectors.selectedNode);
const nodeID = processEventModel.uniquePidForProcess(event);
const relatedEventStats = useSelector(selectors.relatedEventsStats)(nodeID);
@ -212,23 +207,26 @@ const UnstyledProcessEventDot = React.memo(
isLabelFilled,
labelButtonFill,
strokeColor,
} = cubeAssetsForNode(isProcessTerminated, isProcessOrigin);
} = cubeAssetsForNode(
isProcessTerminated,
/**
* There is no definition for 'trigger process' yet. return false.
*/ false
);
const labelHTMLID = htmlIdGenerator('resolver')(`${nodeID}:label`);
const isAriaCurrent = nodeID === activeDescendantId;
const isAriaSelected = nodeID === selectedDescendantId;
const isAriaCurrent = nodeID === ariaActiveDescendant;
const isAriaSelected = nodeID === selectedNode;
const dispatch = useResolverDispatch();
const handleFocus = useCallback(() => {
dispatch({
type: 'userFocusedOnResolverNode',
payload: {
nodeId: nodeHTMLID(nodeID),
},
payload: nodeID,
});
}, [dispatch, nodeHTMLID, nodeID]);
}, [dispatch, nodeID]);
const handleRelatedEventRequest = useCallback(() => {
dispatch({
@ -247,13 +245,10 @@ const UnstyledProcessEventDot = React.memo(
}
dispatch({
type: 'userSelectedResolverNode',
payload: {
nodeId: nodeHTMLID(nodeID),
selectedProcessId: nodeID,
},
payload: nodeID,
});
pushToQueryParams({ crumbId: nodeID, crumbEvent: '' });
}, [animationTarget, dispatch, pushToQueryParams, nodeID, nodeHTMLID]);
}, [animationTarget, dispatch, pushToQueryParams, nodeID]);
/**
* Enumerates the stats for related events to display with the node as options,
@ -422,6 +417,7 @@ const UnstyledProcessEventDot = React.memo(
buttonFill={colorMap.resolverBackground}
menuAction={handleRelatedEventRequest}
menuTitle={subMenuAssets.relatedEvents.title}
projectionMatrix={projectionMatrix}
optionsWithActions={relatedEventStatusOrOptions}
/>
)}

View file

@ -5,7 +5,7 @@
*/
import { i18n } from '@kbn/i18n';
import React, { ReactNode, useState, useMemo, useCallback } from 'react';
import React, { ReactNode, useState, useMemo, useCallback, useRef, useLayoutEffect } from 'react';
import {
EuiI18nNumber,
EuiSelectable,
@ -15,6 +15,7 @@ import {
htmlIdGenerator,
} from '@elastic/eui';
import styled from 'styled-components';
import { Matrix3 } from '../types';
/**
* i18n-translated titles for submenus and identifiers for display of states:
@ -133,6 +134,7 @@ const NodeSubMenuComponents = React.memo(
menuAction,
optionsWithActions,
className,
projectionMatrix,
}: {
menuTitle: string;
className?: string;
@ -140,9 +142,16 @@ const NodeSubMenuComponents = React.memo(
buttonBorderColor: ButtonColor;
buttonFill: string;
count?: number;
/**
* Receive the projection matrix, so we can see when the camera position changed, so we can force the submenu to reposition itself.
*/
projectionMatrix: Matrix3;
} & {
optionsWithActions?: ResolverSubmenuOptionList | string | undefined;
}) => {
// keep a ref to the popover so we can call its reposition method
const popoverRef = useRef<EuiPopover>(null);
const [menuIsOpen, setMenuOpen] = useState(false);
const handleMenuOpenClick = useCallback(
(clickEvent: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
@ -169,6 +178,28 @@ const NodeSubMenuComponents = React.memo(
const isMenuLoading = optionsWithActions === 'waitingForRelatedEventData';
// The last projection matrix that was used to position the popover
const projectionMatrixAtLastRender = useRef<Matrix3>();
useLayoutEffect(() => {
if (
/**
* If there is a popover component reference,
* and this isn't the first render,
* and the projectionMatrix has changed since last render,
* then force the popover to reposition itself.
*/
popoverRef.current &&
!projectionMatrixAtLastRender.current &&
projectionMatrixAtLastRender.current !== projectionMatrix
) {
popoverRef.current.positionPopoverFixed();
}
// no matter what, keep track of the last project matrix that was used to size the popover
projectionMatrixAtLastRender.current = projectionMatrix;
}, [projectionMatrixAtLastRender, projectionMatrix]);
if (!optionsWithActions) {
/**
* When called with a `menuAction`
@ -216,6 +247,7 @@ const NodeSubMenuComponents = React.memo(
isOpen={menuIsOpen}
closePopover={closePopover}
repositionOnScroll
ref={popoverRef}
>
{menuIsOpen && typeof optionsWithActions === 'object' && (
<OptionList isLoading={isMenuLoading} subMenuOptions={optionsWithActions} />