[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:
parent
a44cc08731
commit
05b0789312
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)!;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
};
|
||||
});
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
@ -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`,
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue