[Resolver] aria-level and aria-flowto support enhancements (#71887)

* `IndexedProcessTree` now owns the concern of defining the order of siblings
* `IsometricTaxiLayout` now owns the concept of `ariaLevels`
* added `datetime` method to `process_event` model which returns a time in ms since unix epoch for the event
* renamed some resolver selectors
* added resolver selector: `ariaLevel`
* added 'data' selector: `followingSibling` (used for aria-flowto)
* added resolver selector `ariaFlowtoNodeID` which takes a nodeID, and returns its following sibling's node id (if that sibling is visible.) By only returning visible siblings, we ensure that `aria-flowto` will point to an html ID that is in the dom.
This commit is contained in:
Robert Austin 2020-07-15 16:35:50 -04:00 committed by GitHub
parent a44cc08731
commit 05b0789312
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 894 additions and 263 deletions

View file

@ -32,9 +32,9 @@ export function eventName(event: ResolverEvent): string {
}
}
export function eventId(event: ResolverEvent): string {
export function eventId(event: ResolverEvent): number | undefined | string {
if (isLegacyEvent(event)) {
return event.endgame.serial_event_id ? String(event.endgame.serial_event_id) : '';
return event.endgame.serial_event_id;
}
return event.event.id;
}

View file

@ -2,6 +2,7 @@
exports[`resolver graph layout when rendering no nodes renders right 1`] = `
Object {
"ariaLevels": Map {},
"edgeLineSegments": Array [],
"processNodePositions": Map {},
}
@ -9,6 +10,22 @@ Object {
exports[`resolver graph layout when rendering one node renders right 1`] = `
Object {
"ariaLevels": Map {
Object {
"@timestamp": 1582233383000,
"agent": Object {
"id": "",
"type": "",
"version": "",
},
"endgame": Object {
"event_subtype_full": "creation_event",
"event_type_full": "process_event",
"process_name": "",
"unique_pid": 0,
},
} => 1,
},
"edgeLineSegments": Array [],
"processNodePositions": Map {
Object {
@ -34,6 +51,134 @@ Object {
exports[`resolver graph layout when rendering two forks, and one fork has an extra long tine renders right 1`] = `
Object {
"ariaLevels": Map {
Object {
"@timestamp": 1582233383000,
"agent": Object {
"id": "",
"type": "",
"version": "",
},
"endgame": Object {
"event_subtype_full": "creation_event",
"event_type_full": "process_event",
"process_name": "",
"unique_pid": 0,
},
} => 1,
Object {
"@timestamp": 1582233383000,
"agent": Object {
"id": "",
"type": "",
"version": "",
},
"endgame": Object {
"event_subtype_full": "already_running",
"event_type_full": "process_event",
"unique_pid": 1,
"unique_ppid": 0,
},
} => 2,
Object {
"@timestamp": 1582233383000,
"agent": Object {
"id": "",
"type": "",
"version": "",
},
"endgame": Object {
"event_subtype_full": "creation_event",
"event_type_full": "process_event",
"unique_pid": 2,
"unique_ppid": 0,
},
} => 2,
Object {
"@timestamp": 1582233383000,
"agent": Object {
"id": "",
"type": "",
"version": "",
},
"endgame": Object {
"event_subtype_full": "termination_event",
"event_type_full": "process_event",
"unique_pid": 8,
"unique_ppid": 0,
},
} => 2,
Object {
"@timestamp": 1582233383000,
"agent": Object {
"id": "",
"type": "",
"version": "",
},
"endgame": Object {
"event_subtype_full": "creation_event",
"event_type_full": "process_event",
"unique_pid": 3,
"unique_ppid": 1,
},
} => 3,
Object {
"@timestamp": 1582233383000,
"agent": Object {
"id": "",
"type": "",
"version": "",
},
"endgame": Object {
"event_subtype_full": "creation_event",
"event_type_full": "process_event",
"unique_pid": 4,
"unique_ppid": 1,
},
} => 3,
Object {
"@timestamp": 1582233383000,
"agent": Object {
"id": "",
"type": "",
"version": "",
},
"endgame": Object {
"event_subtype_full": "creation_event",
"event_type_full": "process_event",
"unique_pid": 5,
"unique_ppid": 2,
},
} => 3,
Object {
"@timestamp": 1582233383000,
"agent": Object {
"id": "",
"type": "",
"version": "",
},
"endgame": Object {
"event_subtype_full": "creation_event",
"event_type_full": "process_event",
"unique_pid": 6,
"unique_ppid": 2,
},
} => 3,
Object {
"@timestamp": 1582233383000,
"agent": Object {
"id": "",
"type": "",
"version": "",
},
"endgame": Object {
"event_subtype_full": "creation_event",
"event_type_full": "process_event",
"unique_pid": 7,
"unique_ppid": 6,
},
} => 4,
},
"edgeLineSegments": Array [
Object {
"metadata": Object {
@ -406,6 +551,36 @@ Object {
exports[`resolver graph layout when rendering two nodes, one being the parent of the other renders right 1`] = `
Object {
"ariaLevels": Map {
Object {
"@timestamp": 1582233383000,
"agent": Object {
"id": "",
"type": "",
"version": "",
},
"endgame": Object {
"event_subtype_full": "creation_event",
"event_type_full": "process_event",
"process_name": "",
"unique_pid": 0,
},
} => 1,
Object {
"@timestamp": 1582233383000,
"agent": Object {
"id": "",
"type": "",
"version": "",
},
"endgame": Object {
"event_subtype_full": "already_running",
"event_type_full": "process_event",
"unique_pid": 1,
"unique_ppid": 0,
},
} => 2,
},
"edgeLineSegments": Array [
Object {
"metadata": Object {

View file

@ -4,99 +4,46 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { uniquePidForProcess, uniqueParentPidForProcess } from '../process_event';
import { IndexedProcessTree, AdjacentProcessMap } from '../../types';
import { uniquePidForProcess, uniqueParentPidForProcess, orderByTime } from '../process_event';
import { IndexedProcessTree } from '../../types';
import { ResolverEvent } from '../../../../common/endpoint/types';
import { levelOrder as baseLevelOrder } from '../../lib/tree_sequencers';
/**
* Create a new IndexedProcessTree from an array of ProcessEvents
* Create a new IndexedProcessTree from an array of ProcessEvents.
* siblings will be ordered by timestamp
*/
export function factory(processes: ResolverEvent[]): IndexedProcessTree {
export function factory(
// Array of processes to index as a tree
processes: ResolverEvent[]
): IndexedProcessTree {
const idToChildren = new Map<string | undefined, ResolverEvent[]>();
const idToValue = new Map<string, ResolverEvent>();
const idToAdjacent = new Map<string, AdjacentProcessMap>();
function emptyAdjacencyMap(id: string): AdjacentProcessMap {
return {
self: id,
parent: null,
firstChild: null,
previousSibling: null,
nextSibling: null,
level: 1,
};
}
const roots: ResolverEvent[] = [];
for (const process of processes) {
const uniqueProcessPid = uniquePidForProcess(process);
idToValue.set(uniqueProcessPid, process);
const currentProcessAdjacencyMap: AdjacentProcessMap =
idToAdjacent.get(uniqueProcessPid) || emptyAdjacencyMap(uniqueProcessPid);
idToAdjacent.set(uniqueProcessPid, currentProcessAdjacencyMap);
const uniqueParentPid = uniqueParentPidForProcess(process);
const currentProcessSiblings = idToChildren.get(uniqueParentPid);
if (currentProcessSiblings) {
const previousProcessId = uniquePidForProcess(
currentProcessSiblings[currentProcessSiblings.length - 1]
);
currentProcessSiblings.push(process);
/**
* Update adjacency maps for current and previous entries
*/
idToAdjacent.get(previousProcessId)!.nextSibling = uniqueProcessPid;
currentProcessAdjacencyMap.previousSibling = previousProcessId;
if (uniqueParentPid) {
currentProcessAdjacencyMap.parent = uniqueParentPid;
}
} else {
if (uniqueParentPid) {
idToChildren.set(uniqueParentPid, [process]);
/**
* Get the parent's map, otherwise set an empty one
*/
const parentAdjacencyMap =
idToAdjacent.get(uniqueParentPid) ||
(idToAdjacent.set(uniqueParentPid, emptyAdjacencyMap(uniqueParentPid)),
idToAdjacent.get(uniqueParentPid))!;
// set firstChild for parent
parentAdjacencyMap.firstChild = uniqueProcessPid;
// set parent for current
currentProcessAdjacencyMap.parent = uniqueParentPid || null;
} else {
// In this case (no unique parent id), it must be a root
roots.push(process);
// if its defined and not ''
if (uniqueParentPid) {
let siblings = idToChildren.get(uniqueParentPid);
if (!siblings) {
siblings = [];
idToChildren.set(uniqueParentPid, siblings);
}
siblings.push(process);
}
}
/**
* Scan adjacency maps from the top down and assign levels
*/
function traverseLevels(currentProcessMap: AdjacentProcessMap, level: number = 1): void {
const nextLevel = level + 1;
if (currentProcessMap.nextSibling) {
traverseLevels(idToAdjacent.get(currentProcessMap.nextSibling)!, level);
}
if (currentProcessMap.firstChild) {
traverseLevels(idToAdjacent.get(currentProcessMap.firstChild)!, nextLevel);
}
currentProcessMap.level = level;
}
for (const treeRoot of roots) {
traverseLevels(idToAdjacent.get(uniquePidForProcess(treeRoot))!);
// sort the children of each node
for (const siblings of idToChildren.values()) {
siblings.sort(orderByTime);
}
return {
idToChildren,
idToProcess: idToValue,
idToAdjacent,
};
}
@ -109,6 +56,13 @@ export function children(tree: IndexedProcessTree, process: ResolverEvent): Reso
return currentProcessSiblings === undefined ? [] : currentProcessSiblings;
}
/**
* Get the indexed process event for the ID
*/
export function processEvent(tree: IndexedProcessTree, entityID: string): ResolverEvent | null {
return tree.idToProcess.get(entityID) ?? null;
}
/**
* Returns the parent ProcessEvent, if any, for the passed in `childProcess`
*/
@ -124,6 +78,31 @@ 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
*/
@ -138,7 +117,10 @@ export function root(tree: IndexedProcessTree) {
if (size(tree) === 0) {
return null;
}
// any node will do
let current: ResolverEvent = tree.idToProcess.values().next().value;
// iteratively swap current w/ its parent
while (parent(tree, current) !== undefined) {
current = parent(tree, current)!;
}

View file

@ -148,5 +148,24 @@ describe('resolver graph layout', () => {
it('renders right', () => {
expect(layout()).toMatchSnapshot();
});
it('should have node a at level 1', () => {
expect(layout().ariaLevels.get(processA)).toBe(1);
});
it('should have nodes b and c at level 2', () => {
expect(layout().ariaLevels.get(processB)).toBe(2);
expect(layout().ariaLevels.get(processC)).toBe(2);
});
it('should have nodes d, e, f, and g at level 3', () => {
expect(layout().ariaLevels.get(processD)).toBe(3);
expect(layout().ariaLevels.get(processE)).toBe(3);
expect(layout().ariaLevels.get(processF)).toBe(3);
expect(layout().ariaLevels.get(processG)).toBe(3);
});
it('should have node h at level 4', () => {
expect(layout().ariaLevels.get(processH)).toBe(4);
});
it('should have 9 items in the map of aria levels', () => {
expect(layout().ariaLevels.size).toBe(9);
});
});
});

View file

@ -73,9 +73,34 @@ export function isometricTaxiLayout(indexedProcessTree: IndexedProcessTree): Iso
return {
processNodePositions: transformedPositions,
edgeLineSegments: transformedEdgeLineSegments,
ariaLevels: ariaLevels(indexedProcessTree),
};
}
/**
* Calculate a level (starting at 1) for each node.
*/
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);
if (parentNode === undefined) {
// nodes at the root have a level of 1
map.set(node, 1);
} else {
const parentLevel: number | undefined = map.get(parentNode);
// because we're iterating in level order, we should have processed the parent of any node that has one.
if (parentLevel === undefined) {
throw new Error('failed to calculate aria levels');
}
map.set(node, parentLevel + 1);
}
}
return map;
}
/**
* In laying out the graph, we precalculate the 'width' of each subtree. The 'width' of the subtree is determined by its
* descedants and the rule that each process node must be at least 1 unit apart. Enforcing that all nodes are at least

View file

@ -3,10 +3,10 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { eventType } from './process_event';
import { eventType, orderByTime } from './process_event';
import { mockProcessEvent } from './process_event_test_helpers';
import { LegacyEndpointEvent } from '../../../common/endpoint/types';
import { LegacyEndpointEvent, ResolverEvent } from '../../../common/endpoint/types';
describe('process event', () => {
describe('eventType', () => {
@ -24,4 +24,86 @@ describe('process event', () => {
expect(eventType(event)).toEqual('processCreated');
});
});
describe('orderByTime', () => {
let mock: (time: number, eventID: string) => ResolverEvent;
let events: ResolverEvent[];
beforeEach(() => {
mock = (time, eventID) => {
return {
'@timestamp': time,
event: {
id: eventID,
},
} as ResolverEvent;
};
// 2 events each for numbers -1, 0, 1, and NaN
// each event has a unique id, a through h
// order is arbitrary
events = [
mock(-1, 'a'),
mock(0, 'c'),
mock(1, 'e'),
mock(NaN, 'g'),
mock(-1, 'b'),
mock(0, 'd'),
mock(1, 'f'),
mock(NaN, 'h'),
];
});
it('sorts events as expected', () => {
events.sort(orderByTime);
expect(events).toMatchInlineSnapshot(`
Array [
Object {
"@timestamp": -1,
"event": Object {
"id": "a",
},
},
Object {
"@timestamp": -1,
"event": Object {
"id": "b",
},
},
Object {
"@timestamp": 0,
"event": Object {
"id": "c",
},
},
Object {
"@timestamp": 0,
"event": Object {
"id": "d",
},
},
Object {
"@timestamp": 1,
"event": Object {
"id": "e",
},
},
Object {
"@timestamp": 1,
"event": Object {
"id": "f",
},
},
Object {
"@timestamp": NaN,
"event": Object {
"id": "g",
},
},
Object {
"@timestamp": NaN,
"event": Object {
"id": "h",
},
},
]
`);
});
});
});

View file

@ -28,6 +28,19 @@ export function isTerminatedProcess(passedEvent: ResolverEvent) {
return eventType(passedEvent) === 'processTerminated';
}
/**
* ms since unix epoc, based on timestamp.
* may return NaN if the timestamp wasn't present or was invalid.
*/
export function datetime(passedEvent: ResolverEvent): number | null {
const timestamp = event.eventTimestamp(passedEvent);
const time = timestamp === undefined ? 0 : new Date(timestamp).getTime();
// if the date could not be parsed, return null
return isNaN(time) ? null : time;
}
/**
* Returns a custom event type for a process event based on the event's metadata.
*/
@ -161,3 +174,22 @@ export function argsForProcess(passedEvent: ResolverEvent): string | undefined {
}
return passedEvent?.process?.args;
}
/**
* used to sort events
*/
export function orderByTime(first: ResolverEvent, second: ResolverEvent): number {
const firstDatetime: number | null = datetime(first);
const secondDatetime: number | null = datetime(second);
if (firstDatetime === secondDatetime) {
// break ties using an arbitrary (stable) comparison of `eventId` (which should be unique)
return String(event.eventId(first)).localeCompare(String(event.eventId(second)));
} else if (firstDatetime === null || secondDatetime === null) {
// sort `null`'s as higher than numbers
return (firstDatetime === null ? 1 : 0) - (secondDatetime === null ? 1 : 0);
} else {
// sort in ascending order.
return firstDatetime - secondDatetime;
}
}

View file

@ -8,7 +8,6 @@ import rbush from 'rbush';
import { createSelector, defaultMemoize } from 'reselect';
import {
DataState,
AdjacentProcessMap,
Vector2,
IndexedEntity,
IndexedEdgeLineSegment,
@ -21,7 +20,7 @@ import {
isTerminatedProcess,
uniquePidForProcess,
} from '../../models/process_event';
import { factory as indexedProcessTreeFactory } from '../../models/indexed_process_tree';
import * as indexedProcessTreeModel from '../../models/indexed_process_tree';
import { isEqual } from '../../models/aabb';
import {
@ -107,7 +106,7 @@ export const indexedProcessTree = createSelector(graphableProcesses, function in
graphableProcesses
/* eslint-enable no-shadow */
) {
return indexedProcessTreeFactory(graphableProcesses);
return indexedProcessTreeModel.factory(graphableProcesses);
});
/**
@ -170,27 +169,6 @@ export function relatedEventsReady(data: DataState): Map<string, boolean> {
return data.relatedEventsReady;
}
export const processAdjacencies = createSelector(
indexedProcessTree,
graphableProcesses,
function selectProcessAdjacencies(
/* eslint-disable no-shadow */
indexedProcessTree,
graphableProcesses
/* eslint-enable no-shadow */
) {
const processToAdjacencyMap = new Map<ResolverEvent, AdjacentProcessMap>();
const { idToAdjacent } = indexedProcessTree;
for (const graphableProcess of graphableProcesses) {
const processPid = uniquePidForProcess(graphableProcess);
const adjacencyMap = idToAdjacent.get(processPid)!;
processToAdjacencyMap.set(graphableProcess, adjacencyMap);
}
return { processToAdjacencyMap };
}
);
/**
* `true` if there were more children than we got in the last request.
*/
@ -230,7 +208,7 @@ export const relatedEventInfoByEntityId: (
) {
if (!relatedEventsStats) {
// If there are no related event stats, there are no related event info objects
return (entityId: string) => null;
return () => null;
}
return (entityId) => {
const stats = relatedEventsStats.get(entityId);
@ -334,7 +312,8 @@ export function databaseDocumentIDToFetch(state: DataState): string | null {
return null;
}
}
export const processNodePositionsAndEdgeLineSegments = createSelector(
export const layout = createSelector(
indexedProcessTree,
function processNodePositionsAndEdgeLineSegments(
/* eslint-disable no-shadow */
@ -345,9 +324,62 @@ export const processNodePositionsAndEdgeLineSegments = createSelector(
}
);
const indexedProcessNodePositionsAndEdgeLineSegments = createSelector(
processNodePositionsAndEdgeLineSegments,
function visibleProcessNodePositionsAndEdgeLineSegments({
/**
* Given a nodeID (aka entity_id) get the indexed process event.
* Legacy functions take process events instead of nodeID, use this to get
* process events for them.
*/
export const processEventForID: (
state: DataState
) => (nodeID: string) => ResolverEvent | null = createSelector(
indexedProcessTree,
(tree) => (nodeID: string) => indexedProcessTreeModel.processEvent(tree, nodeID)
);
/**
* Takes a nodeID (aka entity_id) and returns the associated aria level as a number or null if the node ID isn't in the tree.
*/
export const ariaLevel: (state: DataState) => (nodeID: string) => number | null = createSelector(
layout,
processEventForID,
({ ariaLevels }, processEventGetter) => (nodeID: string) => {
const node = processEventGetter(nodeID);
return node ? ariaLevels.get(node) ?? null : null;
}
);
/**
* Returns the following sibling if there is one, or `null`.
*/
export const followingSibling: (
state: DataState
) => (nodeID: string) => string | null = createSelector(
indexedProcessTree,
processEventForID,
(tree, eventGetter) => {
return (nodeID: string) => {
const event = eventGetter(nodeID);
// event not found
if (event === null) {
return null;
}
const nextSibling = indexedProcessTreeModel.nextSibling(tree, event);
// next sibling not found
if (nextSibling === undefined) {
return null;
}
// return the node ID
return uniquePidForProcess(nextSibling);
};
}
);
const spatiallyIndexedLayout: (state: DataState) => rbush<IndexedEntity> = createSelector(
layout,
function ({
/* eslint-disable no-shadow */
processNodePositions,
edgeLineSegments,
@ -394,47 +426,46 @@ const indexedProcessNodePositionsAndEdgeLineSegments = createSelector(
}
);
export const visibleProcessNodePositionsAndEdgeLineSegments = createSelector(
indexedProcessNodePositionsAndEdgeLineSegments,
function visibleProcessNodePositionsAndEdgeLineSegments(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;
}
};
}
);
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;
}
};
});
/**
* If there is a pending request that's for a entity ID that doesn't matche the `entityID`, then we should cancel it.

View file

@ -9,7 +9,7 @@ import { ResolverAction } from '../actions';
import { resolverReducer } from '../reducer';
import { ResolverState } from '../../types';
import { LegacyEndpointEvent, ResolverEvent } from '../../../../common/endpoint/types';
import { visibleProcessNodePositionsAndEdgeLineSegments } from '../selectors';
import { visibleNodesAndEdgeLines } from '../selectors';
import { mockProcessEvent } from '../../models/process_event_test_helpers';
import { mock as mockResolverTree } from '../../models/resolver_tree';
@ -119,15 +119,11 @@ describe('resolver visible entities', () => {
store.dispatch(cameraAction);
});
it('the visibleProcessNodePositions list should only include 2 nodes', () => {
const { processNodePositions } = visibleProcessNodePositionsAndEdgeLineSegments(
store.getState()
)(0);
const { processNodePositions } = visibleNodesAndEdgeLines(store.getState())(0);
expect([...processNodePositions.keys()].length).toEqual(2);
});
it('the visibleEdgeLineSegments list should only include one edge line', () => {
const { connectingEdgeLineSegments } = visibleProcessNodePositionsAndEdgeLineSegments(
store.getState()
)(0);
const { connectingEdgeLineSegments } = visibleNodesAndEdgeLines(store.getState())(0);
expect(connectingEdgeLineSegments.length).toEqual(1);
});
});
@ -151,15 +147,11 @@ describe('resolver visible entities', () => {
store.dispatch(cameraAction);
});
it('the visibleProcessNodePositions list should include all process nodes', () => {
const { processNodePositions } = visibleProcessNodePositionsAndEdgeLineSegments(
store.getState()
)(0);
const { processNodePositions } = visibleNodesAndEdgeLines(store.getState())(0);
expect([...processNodePositions.keys()].length).toEqual(5);
});
it('the visibleEdgeLineSegments list include all lines', () => {
const { connectingEdgeLineSegments } = visibleProcessNodePositionsAndEdgeLineSegments(
store.getState()
)(0);
const { connectingEdgeLineSegments } = visibleNodesAndEdgeLines(store.getState())(0);
expect(connectingEdgeLineSegments.length).toEqual(4);
});
});

View file

@ -5,7 +5,7 @@
*/
import { animatePanning } from './camera/methods';
import { processNodePositionsAndEdgeLineSegments } from './selectors';
import { layout } from './selectors';
import { ResolverState } from '../types';
import { ResolverEvent } from '../../../common/endpoint/types';
@ -19,7 +19,7 @@ export function animateProcessIntoView(
startTime: number,
process: ResolverEvent
): ResolverState {
const { processNodePositions } = processNodePositionsAndEdgeLineSegments(state);
const { processNodePositions } = layout(state);
const position = processNodePositions.get(process);
if (position) {
return {

View file

@ -0,0 +1,259 @@
/*
* 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 { ResolverState } from '../types';
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';
describe('resolver selectors', () => {
const actions: ResolverAction[] = [];
/**
* Get state, given an ordered collection of actions.
*/
const state: () => ResolverState = () => {
const store = createStore(resolverReducer);
for (const action of actions) {
store.dispatch(action);
}
return store.getState();
};
describe('ariaFlowtoNodeID', () => {
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: treeWith2AncestorsAndNoChildren({
originID,
firstAncestorID,
secondAncestorID,
}),
// this value doesn't matter
databaseDocumentID: '',
},
});
});
describe('when all nodes are in view', () => {
beforeEach(() => {
const size = 1000000;
actions.push({
// set the size of the camera
type: 'userSetRasterSize',
payload: [size, size],
});
});
it('should return no flowto for the second ancestor', () => {
expect(selectors.ariaFlowtoNodeID(state())(0)(secondAncestorID)).toBe(null);
});
it('should return no flowto for the first ancestor', () => {
expect(selectors.ariaFlowtoNodeID(state())(0)(firstAncestorID)).toBe(null);
});
it('should return no flowto for the origin', () => {
expect(selectors.ariaFlowtoNodeID(state())(0)(originID)).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: treeWithNoAncestorsAnd2Children({ originID, firstChildID, secondChildID }),
// this value doesn't matter
databaseDocumentID: '',
},
});
});
describe('when all nodes are in view', () => {
beforeEach(() => {
const rasterSize = 1000000;
actions.push({
// set the size of the camera
type: 'userSetRasterSize',
payload: [rasterSize, rasterSize],
});
});
it('should return no flowto for the origin', () => {
expect(selectors.ariaFlowtoNodeID(state())(0)(originID)).toBe(null);
});
it('should return the second child as the flowto for the first child', () => {
expect(selectors.ariaFlowtoNodeID(state())(0)(firstChildID)).toBe(secondChildID);
});
it('should return no flowto for second child', () => {
expect(selectors.ariaFlowtoNodeID(state())(0)(secondChildID)).toBe(null);
});
});
describe('when only the origin and first child are in view', () => {
beforeEach(() => {
// set the raster size
const rasterSize = 1000000;
actions.push({
// set the size of the camera
type: 'userSetRasterSize',
payload: [rasterSize, rasterSize],
});
// get the layout
const layout = selectors.layout(state());
// find the position of the second child
const secondChild = selectors.processEventForID(state())(secondChildID);
const positionOfSecondChild = layout.processNodePositions.get(secondChild!)!;
// the child is indexed by an AABB that extends -720/2 to the left
const leftSideOfSecondChildAABB = positionOfSecondChild[0] - 720 / 2;
// adjust the camera so that it doesn't quite see the second child
actions.push({
// set the position of the camera so that the left edge of the second child is at the right edge
// of the viewable area
type: 'userSetPositionOfCamera',
payload: [rasterSize / -2 + leftSideOfSecondChildAABB, 0],
});
});
it('the origin should be in view', () => {
const origin = selectors.processEventForID(state())(originID)!;
expect(
selectors.visibleNodesAndEdgeLines(state())(0).processNodePositions.has(origin)
).toBe(true);
});
it('the first child should be in view', () => {
const firstChild = selectors.processEventForID(state())(firstChildID)!;
expect(
selectors.visibleNodesAndEdgeLines(state())(0).processNodePositions.has(firstChild)
).toBe(true);
});
it('the second child should not be in view', () => {
const secondChild = selectors.processEventForID(state())(secondChildID)!;
expect(
selectors.visibleNodesAndEdgeLines(state())(0).processNodePositions.has(secondChild)
).toBe(false);
});
it('should return nothing as the flowto for the first child', () => {
expect(selectors.ariaFlowtoNodeID(state())(0)(firstChildID)).toBe(null);
});
});
});
});
});
/**
* 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;
}

View file

@ -4,11 +4,13 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { createSelector } from 'reselect';
import { createSelector, defaultMemoize } from 'reselect';
import * as cameraSelectors from './camera/selectors';
import * as dataSelectors from './data/selectors';
import * as uiSelectors from './ui/selectors';
import { ResolverState } from '../types';
import { ResolverState, IsometricTaxiLayout } from '../types';
import { uniquePidForProcess } from '../models/process_event';
import { ResolverEvent } from '../../../common/endpoint/types';
/**
* A matrix that when applied to a Vector2 will convert it from world coordinates to screen coordinates.
@ -51,9 +53,24 @@ export const userIsPanning = composeSelectors(cameraStateSelector, cameraSelecto
*/
export const isAnimating = composeSelectors(cameraStateSelector, cameraSelectors.isAnimating);
export const processNodePositionsAndEdgeLineSegments = composeSelectors(
/**
* Given a nodeID (aka entity_id) get the indexed process event.
* Legacy functions take process events instead of nodeID, use this to get
* process events for them.
*/
export const processEventForID: (
state: ResolverState
) => (nodeID: string) => ResolverEvent | null = composeSelectors(
dataStateSelector,
dataSelectors.processNodePositionsAndEdgeLineSegments
dataSelectors.processEventForID
);
/**
* The position of nodes and edges.
*/
export const layout: (state: ResolverState) => IsometricTaxiLayout = composeSelectors(
dataStateSelector,
dataSelectors.layout
);
/**
@ -74,11 +91,6 @@ export const resolverComponentInstanceID = composeSelectors(
dataSelectors.resolverComponentInstanceID
);
export const processAdjacencies = composeSelectors(
dataStateSelector,
dataSelectors.processAdjacencies
);
export const terminatedProcesses = composeSelectors(
dataStateSelector,
dataSelectors.terminatedProcesses
@ -212,10 +224,8 @@ function composeSelectors<OuterState, InnerState, ReturnValue>(
}
const boundingBox = composeSelectors(cameraStateSelector, cameraSelectors.viewableBoundingBox);
const indexedProcessNodesAndEdgeLineSegments = composeSelectors(
dataStateSelector,
dataSelectors.visibleProcessNodePositionsAndEdgeLineSegments
);
const nodesAndEdgelines = composeSelectors(dataStateSelector, dataSelectors.nodesAndEdgelines);
/**
* Total count of related events for a process.
@ -230,15 +240,50 @@ export const relatedEventTotalForProcess = composeSelectors(
* The bounding box represents what the camera can see. The camera position is a function of time because it can be
* animated. So in order to get the currently visible entities, we need to pass in time.
*/
export const visibleProcessNodePositionsAndEdgeLineSegments = createSelector(
indexedProcessNodesAndEdgeLineSegments,
boundingBox,
function (
/* eslint-disable no-shadow */
indexedProcessNodesAndEdgeLineSegments,
boundingBox
/* eslint-enable no-shadow */
) {
return (time: number) => indexedProcessNodesAndEdgeLineSegments(boundingBox(time));
export const visibleNodesAndEdgeLines = createSelector(nodesAndEdgelines, boundingBox, function (
/* eslint-disable no-shadow */
nodesAndEdgelines,
boundingBox
/* eslint-enable no-shadow */
) {
return (time: number) => nodesAndEdgelines(boundingBox(time));
});
/**
* Takes a nodeID (aka entity_id) and returns the associated aria level as a number or null if the node ID isn't in the tree.
*/
export const ariaLevel: (
state: ResolverState
) => (nodeID: string) => number | null = composeSelectors(
dataStateSelector,
dataSelectors.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.
*/
export const ariaFlowtoNodeID: (
state: ResolverState
) => (time: number) => (nodeID: string) => string | null = createSelector(
visibleNodesAndEdgeLines,
composeSelectors(dataStateSelector, dataSelectors.followingSibling),
(visibleNodesAndEdgeLinesAtTime, followingSibling) => {
return defaultMemoize((time: number) => {
// get the visible nodes at `time`
const { processNodePositions } = visibleNodesAndEdgeLinesAtTime(time);
// get a `Set` containing their node IDs
const nodesVisibleAtTime: Set<string> = new Set(
[...processNodePositions.keys()].map(uniquePidForProcess)
);
// return the ID of `nodeID`'s following sibling, if it is visible
return (nodeID: string): string | null => {
const sibling: string | null = followingSibling(nodeID);
return sibling === null || nodesVisibleAtTime.has(sibling) === false ? null : sibling;
};
});
}
);

View file

@ -269,38 +269,18 @@ export interface ProcessEvent {
};
}
/**
* A map of Process Ids that indicate which processes are adjacent to a given process along
* directions in two axes: up/down and previous/next.
*/
export interface AdjacentProcessMap {
readonly self: string;
parent: string | null;
firstChild: string | null;
previousSibling: string | null;
nextSibling: string | null;
/**
* To support aria-level, this must be >= 1
*/
level: number;
}
/**
* A represention of a process tree with indices for O(1) access to children and values by id.
*/
export interface IndexedProcessTree {
/**
* Map of ID to a process's children
* Map of ID to a process's ordered children
*/
idToChildren: Map<string | undefined, ResolverEvent[]>;
/**
* Map of ID to process
*/
idToProcess: Map<string, ResolverEvent>;
/**
* Map of ID to adjacent processes
*/
idToAdjacent: Map<string, AdjacentProcessMap>;
}
/**
@ -454,4 +434,9 @@ export interface IsometricTaxiLayout {
* A map of edgline segments, which graphically connect nodes.
*/
edgeLineSegments: EdgeLineSegment[];
/**
* defines the aria levels for nodes.
*/
ariaLevels: Map<ResolverEvent, number>;
}

View file

@ -53,10 +53,13 @@ export const ResolverMap = React.memo(function ({
useStateSyncingActions({ databaseDocumentID, resolverComponentInstanceID });
const { timestamp } = useContext(SideEffectContext);
// use this for the entire render in order to keep things in sync
const timeAtRender = timestamp();
const { processNodePositions, connectingEdgeLineSegments } = useSelector(
selectors.visibleProcessNodePositionsAndEdgeLineSegments
)(timestamp());
const { processToAdjacencyMap } = useSelector(selectors.processAdjacencies);
selectors.visibleNodesAndEdgeLines
)(timeAtRender);
const relatedEventsStats = useSelector(selectors.relatedEventsStats);
const terminatedProcesses = useSelector(selectors.terminatedProcesses);
const { projectionMatrix, ref, onMouseDown } = useCamera();
@ -100,24 +103,19 @@ export const ResolverMap = React.memo(function ({
/>
))}
{[...processNodePositions].map(([processEvent, position]) => {
const adjacentNodeMap = processToAdjacencyMap.get(processEvent);
const processEntityId = entityId(processEvent);
if (!adjacentNodeMap) {
// This should never happen
throw new Error('Issue calculating adjacency node map.');
}
return (
<ProcessEventDot
key={processEntityId}
position={position}
projectionMatrix={projectionMatrix}
event={processEvent}
adjacentNodeMap={adjacentNodeMap}
relatedEventsStatsForProcess={
relatedEventsStats ? relatedEventsStats.get(entityId(processEvent)) : undefined
}
isProcessTerminated={terminatedProcesses.has(processEntityId)}
isProcessOrigin={false}
timeAtRender={timeAtRender}
/>
);
})}

View file

@ -146,7 +146,7 @@ export const ProcessListWithCounts = memo(function ProcessListWithCounts({
[pushToQueryParams, handleBringIntoViewClick, isProcessOrigin, isProcessTerminated]
);
const { processNodePositions } = useSelector(selectors.processNodePositionsAndEdgeLineSegments);
const { processNodePositions } = useSelector(selectors.layout);
const processTableView: ProcessTableView[] = useMemo(
() =>
[...processNodePositions.keys()].map((processEvent) => {

View file

@ -38,7 +38,6 @@ interface MatchingEventEntry {
eventType: string;
eventCategory: string;
name: { subject: string; descriptor?: string };
entityId: string;
setQueryParams: () => void;
}
@ -202,9 +201,11 @@ export const ProcessEventListNarrowedByType = memo(function ProcessEventListNarr
eventCategory: `${eventType}`,
eventType: `${event.ecsEventType(resolverEvent)}`,
name: event.descriptiveName(resolverEvent),
entityId,
setQueryParams: () => {
pushToQueryParams({ crumbId: entityId, crumbEvent: processEntityId });
pushToQueryParams({
crumbId: entityId === undefined ? '' : String(entityId),
crumbEvent: processEntityId,
});
},
};
});

View file

@ -12,11 +12,12 @@ import { htmlIdGenerator, EuiButton, EuiI18nNumber, EuiFlexGroup, EuiFlexItem }
import { useSelector } from 'react-redux';
import { NodeSubMenu, subMenuAssets } from './submenu';
import { applyMatrix3 } from '../models/vector2';
import { Vector2, Matrix3, AdjacentProcessMap } from '../types';
import { Vector2, Matrix3 } from '../types';
import { SymbolIds, useResolverTheme, calculateResolverFontSize } from './assets';
import { ResolverEvent, ResolverNodeStats } from '../../../common/endpoint/types';
import { useResolverDispatch } from './use_resolver_dispatch';
import * as eventModel from '../../../common/endpoint/models/event';
import * as processEventModel from '../models/process_event';
import * as selectors from '../store/selectors';
import { useResolverQueryParams } from './use_resolver_query_params';
@ -70,10 +71,10 @@ const UnstyledProcessEventDot = React.memo(
position,
event,
projectionMatrix,
adjacentNodeMap,
isProcessTerminated,
isProcessOrigin,
relatedEventsStatsForProcess,
timeAtRender,
}: {
/**
* A `className` string provided by `styled`
@ -91,10 +92,6 @@ const UnstyledProcessEventDot = React.memo(
* projectionMatrix which can be used to convert `position` to screen coordinates.
*/
projectionMatrix: Matrix3;
/**
* map of what nodes are "adjacent" to this one in "up, down, previous, next" directions
*/
adjacentNodeMap: AdjacentProcessMap;
/**
* Whether or not to show the process as terminated.
*/
@ -109,7 +106,16 @@ const UnstyledProcessEventDot = React.memo(
* Statistics for the number of related events and alerts for this process node
*/
relatedEventsStatsForProcess?: ResolverNodeStats;
/**
* The time (unix epoch) at render.
*/
timeAtRender: number;
}) => {
const resolverComponentInstanceID = useSelector(selectors.resolverComponentInstanceID);
// This should be unique to each instance of Resolver
const htmlIDPrefix = `resolver:${resolverComponentInstanceID}`;
/**
* Convert the position, which is in 'world' coordinates, to screen coordinates.
*/
@ -118,12 +124,22 @@ const UnstyledProcessEventDot = React.memo(
const [xScale] = projectionMatrix;
// Node (html id=) IDs
const selfId = adjacentNodeMap.self;
const activeDescendantId = useSelector(selectors.uiActiveDescendantId);
const selectedDescendantId = useSelector(selectors.uiSelectedDescendantId);
const nodeID = processEventModel.uniquePidForProcess(event);
// Entity ID of self
const selfEntityId = eventModel.entityId(event);
// define a standard way of giving HTML IDs to nodes based on their entity_id/nodeID.
// this is used to link nodes via aria attributes
const nodeHTMLID = useCallback((id: string) => htmlIdGenerator(htmlIDPrefix)(`${id}:node`), [
htmlIDPrefix,
]);
const ariaLevel: number | null = useSelector(selectors.ariaLevel)(nodeID);
// the node ID to 'flowto'
const ariaFlowtoNodeID: string | null = useSelector(selectors.ariaFlowtoNodeID)(timeAtRender)(
nodeID
);
const isShowingEventActions = xScale > 0.8;
const isShowingDescriptionText = xScale >= 0.55;
@ -204,16 +220,10 @@ const UnstyledProcessEventDot = React.memo(
strokeColor,
} = cubeAssetsForNode(isProcessTerminated, isProcessOrigin);
const resolverNodeIdGenerator = useMemo(() => htmlIdGenerator('resolverNode'), []);
const labelHTMLID = htmlIdGenerator('resolver')(`${nodeID}:label`);
const nodeId = useMemo(() => resolverNodeIdGenerator(selfId), [
resolverNodeIdGenerator,
selfId,
]);
const labelId = useMemo(() => resolverNodeIdGenerator(), [resolverNodeIdGenerator]);
const descriptionId = useMemo(() => resolverNodeIdGenerator(), [resolverNodeIdGenerator]);
const isActiveDescendant = nodeId === activeDescendantId;
const isSelectedDescendant = nodeId === selectedDescendantId;
const isAriaCurrent = nodeID === activeDescendantId;
const isAriaSelected = nodeID === selectedDescendantId;
const dispatch = useResolverDispatch();
@ -221,34 +231,35 @@ const UnstyledProcessEventDot = React.memo(
dispatch({
type: 'userFocusedOnResolverNode',
payload: {
nodeId,
nodeId: nodeHTMLID(nodeID),
},
});
}, [dispatch, nodeId]);
}, [dispatch, nodeHTMLID, nodeID]);
const handleRelatedEventRequest = useCallback(() => {
dispatch({
type: 'userRequestedRelatedEventData',
payload: selfId,
payload: nodeID,
});
}, [dispatch, selfId]);
}, [dispatch, nodeID]);
const { pushToQueryParams } = useResolverQueryParams();
const handleClick = useCallback(() => {
if (animationTarget.current !== null) {
// This works but the types are missing in the typescript DOM lib
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(animationTarget.current as any).beginElement();
}
dispatch({
type: 'userSelectedResolverNode',
payload: {
nodeId,
selectedProcessId: selfId,
nodeId: nodeHTMLID(nodeID),
selectedProcessId: nodeID,
},
});
pushToQueryParams({ crumbId: selfEntityId, crumbEvent: 'all' });
}, [animationTarget, dispatch, nodeId, selfEntityId, pushToQueryParams, selfId]);
pushToQueryParams({ crumbId: nodeID, crumbEvent: 'all' });
}, [animationTarget, dispatch, pushToQueryParams, nodeID, nodeHTMLID]);
/**
* Enumerates the stats for related events to display with the node as options,
@ -280,12 +291,12 @@ const UnstyledProcessEventDot = React.memo(
},
});
pushToQueryParams({ crumbId: selfEntityId, crumbEvent: category });
pushToQueryParams({ crumbId: nodeID, crumbEvent: category });
},
});
}
return relatedStatsList;
}, [relatedEventsStatsForProcess, dispatch, event, pushToQueryParams, selfEntityId]);
}, [relatedEventsStatsForProcess, dispatch, event, pushToQueryParams, nodeID]);
const relatedEventStatusOrOptions = !relatedEventsStatsForProcess
? subMenuAssets.initialMenuStatus
@ -302,15 +313,14 @@ const UnstyledProcessEventDot = React.memo(
data-test-subj={'resolverNode'}
className={`${className} kbn-resetFocusState`}
role="treeitem"
aria-level={adjacentNodeMap.level}
aria-flowto={adjacentNodeMap.nextSibling === null ? undefined : adjacentNodeMap.nextSibling}
aria-labelledby={labelId}
aria-describedby={descriptionId}
aria-haspopup={'true'}
aria-current={isActiveDescendant ? 'true' : undefined}
aria-selected={isSelectedDescendant ? 'true' : undefined}
aria-level={ariaLevel === null ? undefined : ariaLevel}
aria-flowto={ariaFlowtoNodeID === null ? undefined : nodeHTMLID(ariaFlowtoNodeID)}
aria-labelledby={labelHTMLID}
aria-haspopup="true"
aria-current={isAriaCurrent ? 'true' : undefined}
aria-selected={isAriaSelected ? 'true' : undefined}
style={nodeViewportStyle}
id={nodeId}
id={nodeHTMLID(nodeID)}
tabIndex={-1}
>
<svg
@ -373,8 +383,7 @@ const UnstyledProcessEventDot = React.memo(
</StyledDescriptionText>
<div
className={xScale >= 2 ? 'euiButton' : 'euiButton euiButton--small'}
data-test-subject="nodeLabel"
id={labelId}
id={labelHTMLID}
onClick={handleClick}
onFocus={handleFocus}
tabIndex={-1}
@ -386,9 +395,7 @@ const UnstyledProcessEventDot = React.memo(
>
<EuiButton
color={labelButtonFill}
data-test-subject="nodeLabel"
fill={isLabelFilled}
id={labelId}
size="s"
style={{
maxHeight: `${Math.min(26 + xScale * 3, 32)}px`,

View file

@ -189,9 +189,7 @@ describe('useCamera on an unpainted element', () => {
throw new Error('failed to create tree');
}
const processes: ResolverEvent[] = [
...selectors
.processNodePositionsAndEdgeLineSegments(store.getState())
.processNodePositions.keys(),
...selectors.layout(store.getState()).processNodePositions.keys(),
];
process = processes[processes.length - 1];
if (!process) {

View file

@ -20,8 +20,8 @@ export function useResolverQueryParams() {
const history = useHistory();
const urlSearch = useLocation().search;
const resolverComponentInstanceID = useSelector(selectors.resolverComponentInstanceID);
const uniqueCrumbIdKey: string = `${resolverComponentInstanceID}CrumbId`;
const uniqueCrumbEventKey: string = `${resolverComponentInstanceID}CrumbEvent`;
const uniqueCrumbIdKey: string = `resolver-id:${resolverComponentInstanceID}`;
const uniqueCrumbEventKey: string = `resolver-event:${resolverComponentInstanceID}`;
const pushToQueryParams = useCallback(
(newCrumbs: CrumbInfo) => {
// Construct a new set of params from the current set (minus empty params)

View file

@ -61,7 +61,7 @@ export class PaginationBuilder {
const lastResult = results[results.length - 1];
const cursor = {
timestamp: lastResult['@timestamp'],
eventID: eventId(lastResult),
eventID: eventId(lastResult) === undefined ? '' : String(eventId(lastResult)),
};
return PaginationBuilder.urlEncodeCursor(cursor);
}