[7.x] [Security Solution] [Resolver] Resolver query string state (#76602) (#77918)

Co-authored-by: Robert Austin <robert.austin@elastic.co>
Co-authored-by: oatkiller <robert.austin@elastic.co>

Co-authored-by: Robert Austin <robert.austin@elastic.co>
This commit is contained in:
Kevin Qualters 2020-09-18 14:16:36 -04:00 committed by GitHub
parent 6a1b52eda8
commit 91d0a3b665
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 1427 additions and 861 deletions

View file

@ -4,36 +4,44 @@
* you may not use this file except in compliance with the Elastic License.
*/
/**
* The legacy `crumbEvent` and `crumbId` parameters.
* @deprecated
*/
export function breadcrumbParameters(
locationSearch: string,
resolverComponentInstanceID: string
): { crumbEvent: string; crumbId: string } {
const urlSearchParams = new URLSearchParams(locationSearch);
const { eventKey, idKey } = parameterNames(resolverComponentInstanceID);
return {
// Use `''` for backwards compatibility with deprecated code.
crumbEvent: urlSearchParams.get(eventKey) ?? '',
crumbId: urlSearchParams.get(idKey) ?? '',
};
}
import { PanelViewAndParameters } from '../types';
import * as schema from './schema';
/**
* Parameter names based on the `resolverComponentInstanceID`.
* Validates an `unknown` value, narrowing it to `PanelViewAndParameters`.
* Use this to validate that the value decoded from the URL is a valid `PanelViewAndParameters` object.
*/
function parameterNames(
resolverComponentInstanceID: string
): {
idKey: string;
eventKey: string;
} {
const idKey: string = `resolver-${resolverComponentInstanceID}-id`;
const eventKey: string = `resolver-${resolverComponentInstanceID}-event`;
return {
idKey,
eventKey,
};
}
export const isPanelViewAndParameters: (
value: unknown
) => value is PanelViewAndParameters = schema.oneOf([
schema.object({
panelView: schema.literal('nodes' as const),
}),
schema.object({
panelView: schema.literal('nodeDetail' as const),
panelParameters: schema.object({
nodeID: schema.string(),
}),
}),
schema.object({
panelView: schema.literal('nodeEvents' as const),
panelParameters: schema.object({
nodeID: schema.string(),
}),
}),
schema.object({
panelView: schema.literal('nodeEventsOfType' as const),
panelParameters: schema.object({
nodeID: schema.string(),
eventType: schema.string(),
}),
}),
schema.object({
panelView: schema.literal('eventDetail' as const),
panelParameters: schema.object({
nodeID: schema.string(),
eventType: schema.string(),
eventID: schema.string(),
}),
}),
]);

View file

@ -0,0 +1,135 @@
/*
* 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 * as schema from './schema';
interface SortDefinition {
page?: number;
sort?: Array<{ field: 'name' | 'ip' | 'date'; direction: 'asc' | 'desc' }>;
}
describe(`a validator made using the 'schema' module which validates that a value has the type:
{
page?: number;
sort?: Array<{ field: 'name' | 'ip' | 'date'; direction: 'asc' | 'desc' }>;
}`, () => {
let validator: (value: unknown) => value is SortDefinition;
beforeEach(() => {
validator = schema.object({
page: schema.oneOf([schema.literal(undefined), schema.number()]),
sort: schema.oneOf([
schema.array(
schema.object({
field: schema.oneOf([
schema.literal('name' as const),
schema.literal('ip' as const),
schema.literal('date' as const),
]),
direction: schema.oneOf([
schema.literal('asc' as const),
schema.literal('desc' as const),
]),
})
),
schema.literal(undefined),
]),
});
});
describe.each([
[
{
page: 1,
sort: [
{
field: 'name',
direction: 'asc',
},
{
field: 'date',
direction: 'desc',
},
],
},
true,
],
[
{
// page has the wrong type
page: '1',
sort: [
{
field: 'name',
direction: 'asc',
},
{
field: 'date',
direction: 'desc',
},
],
},
false,
],
[
{
sort: [
{
// missing direction
field: 'name',
},
],
},
false,
],
[
{
sort: [
{
field: 'name',
// invalid direction
direction: 'invalid',
},
],
},
false,
],
[
{
sort: [
{
// missing field
direction: 'desc',
},
],
},
false,
],
[
{
sort: [
{
// invalid field
field: 'invalid',
direction: 'desc',
},
],
},
false,
],
// nothing in the array
[{ sort: [] }, true],
// page only
[{ page: 1 }, true],
// empty object (valid because all keys are optional.)
[{}, true],
// entirely invalid types
[null, false],
[true, false],
['', false],
])('when the value to be validated is `%j`', (value, expected) => {
it(`should return ${expected}`, () => {
expect(validator(value)).toBe(expected);
});
});
});

View file

@ -0,0 +1,176 @@
/*
* 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.
*/
type Validator<T> = (value: unknown) => value is T;
type TypeOf<V extends Validator<unknown>> = V extends Validator<infer T> ? T : never;
/**
* Validate that `value` matches at least one of `validators`.
* Use this to create a predicate for a union type.
* e.g.
* ```
* import * as schema from './schema';
* const isAscOrDesc: (value: unknown) => value is 'asc' | 'desc' = schema.oneOf([
* schema.literal('asc' as const),
* schema.literal('desc' as const),
* ]);
* ```
*/
export function oneOf<V extends Array<Validator<unknown>>>(validators: V) {
return function (
value: unknown
): value is V extends Array<Validator<infer ElementType>> ? ElementType : never {
for (const validator of validators) {
if (validator(value)) {
return true;
}
}
return false;
};
}
/**
* Validate that `value` is an array and that each of its elements matches `elementValidator`.
* Use this to create a predicate for an array type.
* ```
* import * as schema from './schema';
* const isAscOrDesc: (value: unknown) => value is 'asc' | 'desc' = schema.oneOf([
* schema.literal('asc' as const),
* schema.literal('desc' as const),
* ]);
* ```
*/
export function array<V extends Validator<unknown>>(elementValidator: V) {
return function (
value: unknown
): value is Array<V extends Validator<infer ElementType> ? ElementType : never> {
if (Array.isArray(value)) {
for (const element of value as unknown[]) {
const result = elementValidator(element);
if (!result) {
return false;
}
}
return true;
}
return false;
};
}
/**
* The keys of `T` where `undefined` is assignable to the corresponding value.
* Used to figure out which keys could be made optional.
*/
type KeysWithOptionalValues<T extends { [key: string]: unknown }> = {
[K in keyof T]: undefined extends T[K] ? K : never;
}[keyof T];
/**
* `T` with required keys changed to optional if the corresponding value could be `undefined`.
* Converts a type like `{ key: number | undefined; requiredKey: string }` to a type like `{ key?: number | undefined; requiredKey: string }`
* This allows us to write object literals that omit a key if the value can accept `undefined`.
*/
type OptionalKeyWhenValueAcceptsUndefined<T extends { [key: string]: unknown }> = {
[K in Exclude<keyof T, KeysWithOptionalValues<T>>]: T[K];
} &
{
[K in KeysWithOptionalValues<T>]?: Exclude<T[K], undefined>;
};
/**
* Validate that `value` is an object with string keys. The value at each key is tested against its own validator.
*
* Use this to create a predicate for a type like `{ a: string[] }`. For example:
* ```ts
* import * as schema from './schema';
* const myValidator: (value: unknown) => value is { a: string[] } = schema.object({
* a: schema.array(schema.string()),
* });
* ```
*/
export function object<
ValidatorDictionary extends {
[key: string]: Validator<unknown>;
}
>(validatorDictionary: ValidatorDictionary) {
return function (
value: unknown
): value is /** If a key can point to `undefined`, then instead make the key optional and exclude `undefined` from the value type. */ OptionalKeyWhenValueAcceptsUndefined<
{
[K in keyof ValidatorDictionary]: TypeOf<ValidatorDictionary[K]>;
}
> {
// This only validates non-null objects
if (typeof value !== 'object' || value === null) {
return false;
}
// Rebind value as the result type so that we can interrogate it
const trusted = value as { [K in keyof ValidatorDictionary]: TypeOf<ValidatorDictionary[K]> };
// Get each validator in the validator dictionary and use it to validate the corresponding value
for (const key of Object.keys(validatorDictionary)) {
const validator = validatorDictionary[key];
if (!validator(trusted[key])) {
return false;
}
}
return true;
};
}
/**
* Validate that `value` is strictly equal to `acceptedValue`.
* Use this for a literal type, for example:
* ```
* import * as schema from './schema';
* const isAscOrDesc: (value: unknown) => value is 'asc' | 'desc' = schema.oneOf([
* schema.literal('asc' as const),
* schema.literal('desc' as const),
* ]);
* ```
*/
export function literal<T>(acceptedValue: T) {
return function (value: unknown): value is T {
return acceptedValue === value;
};
}
/**
* Validate that `value` is a string.
* NB: this is used as `string` externally via named export.
* Usage:
* ```
* import * as schema from './schema';
* const isString: (value: unknown) => value is string = schema.string();
* ```
*/
function anyString(): (value: unknown) => value is string {
return function (value: unknown): value is string {
return typeof value === 'string';
};
}
/**
* Validate that `value` is a number.
* NB: this just checks if `typeof value === 'number'`. It will return `true` for `NaN`.
* NB: this is used as `number` externally via named export.
* Usage:
* ```
* import * as schema from './schema';
* const isNumber: (value: unknown) => value is number = schema.number();
* ```
*/
function anyNumber(): (value: unknown) => value is number {
return function (value: unknown): value is number {
return typeof value === 'number';
};
}
/**
* Export `anyString` as `string`. We can't define a function named `string`.
* Export `anyNumber` as `number`. We can't define a function named `number`.
*/
export { anyString as string, anyNumber as number };

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { CameraAction } from './camera';
import { ResolverEvent, SafeResolverEvent } from '../../../common/endpoint/types';
import { ResolverEvent } from '../../../common/endpoint/types';
import { DataAction } from './data/action';
/**
@ -88,19 +88,6 @@ interface UserSelectedResolverNode {
readonly payload: string;
}
/**
* This action should dispatch to indicate that the user chose to
* focus on examining the related events of a particular ResolverEvent.
* Optionally, this can be bound by a category of related events (e.g. 'file' or 'dns')
*/
interface UserSelectedRelatedEventCategory {
readonly type: 'userSelectedRelatedEventCategory';
readonly payload: {
subject: SafeResolverEvent;
category?: string;
};
}
/**
* Used by `useStateSyncingActions` hook.
* This is dispatched when external sources provide new parameters for Resolver.
@ -141,6 +128,5 @@ export type ResolverAction =
| UserFocusedOnResolverNode
| UserSelectedResolverNode
| UserRequestedRelatedEventData
| UserSelectedRelatedEventCategory
| AppDetectedNewIdFromQueryParams
| AppDetectedMissingEventData;

View file

@ -152,6 +152,7 @@ export const tree = createSelector(graphableProcesses, function indexedTree(
/**
* This returns a map of entity_ids to stats about the related events and alerts.
* @deprecated
*/
export const relatedEventsStats: (
state: DataState
@ -189,6 +190,7 @@ export const relatedEventAggregateTotalByEntityId: (
/**
* returns a map of entity_ids to related event data.
* @deprecated
*/
export function relatedEventsByEntityId(data: DataState): Map<string, ResolverRelatedEvents> {
return data.relatedEvents;
@ -205,6 +207,7 @@ export function relatedEventsByEntityId(data: DataState): Map<string, ResolverRe
* {title: "a.b", description: "1"}, {title: "c", description: "d"}
*
* @param {object} obj The object to turn into `<dt><dd>` entries
* @deprecated
*/
const objectToDescriptionListEntries = function* (
obj: object,
@ -232,6 +235,7 @@ const objectToDescriptionListEntries = function* (
/**
* Returns a function that returns the information needed to display related event details based on
* the related event's entityID and its own ID.
* @deprecated
*/
export const relatedEventDisplayInfoByEntityAndSelfID: (
state: DataState
@ -262,9 +266,8 @@ export const relatedEventDisplayInfoByEntityAndSelfID: (
const countOfCategory = relatedEventsForThisProcess.events.reduce((sumtotal, evt) => {
return eventModel.primaryEventCategory(evt) === specificCategory ? sumtotal + 1 : sumtotal;
}, 0);
// Assuming these details (agent, ecs, process) aren't as helpful, can revisit
const { agent, ecs, process, ...relevantData } = specificEvent as ResolverEvent & {
const { agent, ecs, process, ...relevantData } = specificEvent as SafeResolverEvent & {
// Type this with various unknown keys so that ts will let us delete those keys
ecs: unknown;
process: unknown;
@ -291,12 +294,13 @@ export const relatedEventDisplayInfoByEntityAndSelfID: (
/**
* Returns a function that returns a function (when supplied with an entity id for a node)
* that returns related events for a node that match an event.category (when supplied with the category)
* @deprecated
*/
export const relatedEventsByCategory: (
state: DataState
) => (entityID: string) => (ecsCategory: string) => ResolverEvent[] = createSelector(
relatedEventsByEntityId,
function provideGettersByCategory(
function (
/* eslint-disable no-shadow */
relatedEventsByEntityId
/* eslint-enable no-shadow */
@ -327,6 +331,7 @@ export const relatedEventsByCategory: (
/**
* returns a map of entity_ids to booleans indicating if it is waiting on related event
* A value of `undefined` can be interpreted as `not yet requested`
* @deprecated
*/
export function relatedEventsReady(data: DataState): Map<string, boolean> {
return data.relatedEventsReady;
@ -334,6 +339,7 @@ export function relatedEventsReady(data: DataState): Map<string, boolean> {
/**
* `true` if there were more children than we got in the last request.
* @deprecated
*/
export function hasMoreChildren(state: DataState): boolean {
const resolverTree = resolverTreeResponse(state);
@ -342,6 +348,7 @@ export function hasMoreChildren(state: DataState): boolean {
/**
* `true` if there were more ancestors than we got in the last request.
* @deprecated
*/
export function hasMoreAncestors(state: DataState): boolean {
const resolverTree = resolverTreeResponse(state);
@ -357,6 +364,7 @@ interface RelatedInfoFunctions {
* A map of `entity_id`s to functions that provide information about
* related events by ECS `.category` Primarily to avoid having business logic
* in UI components.
* @deprecated
*/
export const relatedEventInfoByEntityId: (
state: DataState

View file

@ -128,6 +128,7 @@ export const relatedEventAggregateTotalByEntityId: (
/**
* Map of related events... by entity id
* @deprecated
*/
export const relatedEventsByEntityId = composeSelectors(
dataStateSelector,
@ -137,6 +138,7 @@ export const relatedEventsByEntityId = composeSelectors(
/**
* Returns a function that returns the information needed to display related event details based on
* the related event's entityID and its own ID.
* @deprecated
*/
export const relatedEventDisplayInfoByEntityAndSelfId = composeSelectors(
dataStateSelector,
@ -146,6 +148,7 @@ export const relatedEventDisplayInfoByEntityAndSelfId = composeSelectors(
/**
* Returns a function that returns a function (when supplied with an entity id for a node)
* that returns related events for a node that match an event.category (when supplied with the category)
* @deprecated
*/
export const relatedEventsByCategory = composeSelectors(
dataStateSelector,
@ -154,6 +157,7 @@ export const relatedEventsByCategory = composeSelectors(
/**
* Entity ids to booleans for waiting status
* @deprecated
*/
export const relatedEventsReady = composeSelectors(
dataStateSelector,
@ -164,6 +168,7 @@ export const relatedEventsReady = composeSelectors(
* Business logic lookup functions by ECS category by entity id.
* Example usage:
* const numberOfFileEvents = infoByEntityId.get(`someEntityId`)?.getAggregateTotalForCategory(`file`);
* @deprecated
*/
export const relatedEventInfoByEntityId = composeSelectors(
dataStateSelector,
@ -241,6 +246,7 @@ const nodesAndEdgelines = composeSelectors(dataStateSelector, dataSelectors.node
/**
* Total count of related events for a process.
* @deprecated
*/
export const relatedEventTotalForProcess = composeSelectors(
dataStateSelector,
@ -316,13 +322,27 @@ export const ariaFlowtoNodeID: (
}
);
export const panelViewAndParameters = composeSelectors(
uiStateSelector,
uiSelectors.panelViewAndParameters
);
export const relativeHref = composeSelectors(uiStateSelector, uiSelectors.relativeHref);
/**
* The legacy `crumbEvent` and `crumbId` parameters.
* @deprecated
*/
export const breadcrumbParameters = composeSelectors(
export const relatedEventsRelativeHrefs = composeSelectors(
uiStateSelector,
uiSelectors.breadcrumbParameters
uiSelectors.relatedEventsRelativeHrefs
);
/**
* @deprecated
*/
export const relatedEventDetailHrefs = composeSelectors(
uiStateSelector,
uiSelectors.relatedEventDetailHrefs
);
/**

View file

@ -4,9 +4,13 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { decode, encode } from 'rison-node';
import { createSelector } from 'reselect';
import { ResolverUIState } from '../../types';
import * as locationSearchModel from '../../models/location_search';
import { PanelViewAndParameters, ResolverUIState } from '../../types';
import { ResolverEvent } from '../../../../common/endpoint/types';
import { isPanelViewAndParameters } from '../../models/location_search';
import { eventId } from '../../../../common/endpoint/models/event';
/**
* id of the "current" tree node (fake-focused)
@ -31,20 +35,129 @@ export const selectedNode = createSelector(
);
/**
* The legacy `crumbEvent` and `crumbId` parameters.
* @deprecated
* Which view should show in the panel, as well as what parameters should be used.
* Calculated using the query string
*/
export const breadcrumbParameters = createSelector(
export const panelViewAndParameters = createSelector(
(state: ResolverUIState) => state.locationSearch,
(state: ResolverUIState) => state.resolverComponentInstanceID,
(locationSearch, resolverComponentInstanceID) => {
if (locationSearch === undefined || resolverComponentInstanceID === undefined) {
// Equivalent to `null`
return {
crumbId: '',
crumbEvent: '',
};
return defaultParameters();
}
return locationSearchModel.breadcrumbParameters(locationSearch, resolverComponentInstanceID);
const urlSearchParams = new URLSearchParams(locationSearch);
const value = urlSearchParams.get(parameterName(resolverComponentInstanceID));
if (value === null) {
// Equivalent to `null`
return defaultParameters();
}
const decodedValue: unknown = decode(value);
if (isPanelViewAndParameters(decodedValue)) {
return decodedValue;
}
return defaultParameters();
}
);
/**
* Return a relative href (which includes just the 'search' part) that contains an encoded version of `params `.
* All other values in the 'search' will be kept.
* Use this to get an `href` for an anchor tag.
*/
export const relativeHref: (
state: ResolverUIState
) => (params: PanelViewAndParameters) => string | undefined = createSelector(
(state: ResolverUIState) => state.locationSearch,
(state: ResolverUIState) => state.resolverComponentInstanceID,
(locationSearch, resolverComponentInstanceID) => {
return (params: PanelViewAndParameters) => {
/**
* This is only possible before the first `'appReceivedNewExternalProperties'` action is fired.
*/
if (locationSearch === undefined || resolverComponentInstanceID === undefined) {
return undefined;
}
const urlSearchParams = new URLSearchParams(locationSearch);
const value = encode(params);
urlSearchParams.set(parameterName(resolverComponentInstanceID), value);
return `?${urlSearchParams.toString()}`;
};
}
);
/**
* Returns a map of ecs category name to urls for use in panel navigation.
* @deprecated
*/
export const relatedEventsRelativeHrefs: (
state: ResolverUIState
) => (
categories: Record<string, number> | undefined,
nodeID: string
) => Map<string, string | undefined> = createSelector(relativeHref, (relativeHref) => {
return (categories: Record<string, number> | undefined, nodeID: string) => {
const hrefsByCategory = new Map<string, string | undefined>();
if (categories !== undefined) {
Object.keys(categories).map((category) => {
const categoryPanelParams: PanelViewAndParameters = {
panelView: 'nodeEventsOfType',
panelParameters: {
nodeID,
eventType: category,
},
};
hrefsByCategory.set(category, relativeHref(categoryPanelParams));
return category;
});
}
return hrefsByCategory;
};
});
/**
* Returns a map of event entity ids to urls for use in navigation.
* @deprecated
*/
export const relatedEventDetailHrefs: (
state: ResolverUIState
) => (
category: string,
nodeID: string,
events: ResolverEvent[]
) => Map<string, string | undefined> = createSelector(relativeHref, (relativeHref) => {
return (category: string, nodeID: string, events: ResolverEvent[]) => {
const hrefsByEntityID = new Map<string, string | undefined>();
events.map((event) => {
const entityID = String(eventId(event));
const eventDetailPanelParams: PanelViewAndParameters = {
panelView: 'eventDetail',
panelParameters: {
nodeID,
eventType: category,
eventID: entityID,
},
};
hrefsByEntityID.set(entityID, relativeHref(eventDetailPanelParams));
return event;
});
return hrefsByEntityID;
};
});
/**
* The parameter name that we use to read/write state to the query string
*/
export function parameterName(resolverComponentInstanceID: string): string {
return `resolver-${resolverComponentInstanceID}`;
}
/**
* The default parameters to use when no (valid) location search is available.
*/
export function defaultParameters(): PanelViewAndParameters {
// Note, this really should be a selector. it needs to know about the state of the app so it can select
// the origin event.
return {
panelView: 'nodes',
};
}

View file

@ -4,23 +4,20 @@
* you may not use this file except in compliance with the Elastic License.
*/
interface Options {
/**
* The entity_id of the selected node.
*/
selectedEntityID?: string;
}
import { encode } from 'rison-node';
import { PanelViewAndParameters } from '../types';
/**
* Calculate the expected URL search based on options.
*/
export function urlSearch(resolverComponentInstanceID: string, options?: Options): string {
export function urlSearch(
resolverComponentInstanceID: string,
options?: PanelViewAndParameters
): string {
if (!options) {
return '';
}
const params = new URLSearchParams();
if (options.selectedEntityID !== undefined) {
params.set(`resolver-${resolverComponentInstanceID}-id`, options.selectedEntityID);
}
params.set(`resolver-${resolverComponentInstanceID}`, encode(options));
return params.toString();
}

View file

@ -619,3 +619,81 @@ export interface ResolverPluginSetup {
};
};
}
/**
* Parameters to control what panel content is shown. Can be encoded and decoded from the URL using methods in
* `models/location_search`
*/
export type PanelViewAndParameters =
| {
/**
* The panel will show a index view (e.g. a list) of the nodes.
*/
panelView: 'nodes';
}
| {
/**
* The panel will show the details of a single node.
*/
panelView: 'nodeDetail';
panelParameters: {
/**
* The nodeID (e.g. `process.entity_id`) for the node that will be shown in detail
*/
nodeID: string;
};
}
| {
/**
* The panel will show a index view of the all events related to a specific node.
* This may show a summary of aggregation of the events related to the node.
*/
panelView: 'nodeEvents';
panelParameters: {
/**
* The nodeID (e.g. `process.entity_id`) for the node whose events will be shown.
*/
nodeID: string;
};
}
| {
/**
* The panel will show an index view of the events related to a specific node. Only events with a specific type will be shown.
*/
panelView: 'nodeEventsOfType';
panelParameters: {
/**
* The nodeID (e.g. `process.entity_id`) for the node whose events will be shown.
*/
nodeID: string;
/**
* A parameter used to filter the events. For example, events that don't contain `eventType` in their `event.category` field may be hidden.
*/
eventType: string;
};
}
| {
/**
* The panel will show details about a particular event. This is meant as a subview of 'nodeEventsOfType'.
*/
panelView: 'eventDetail';
panelParameters: {
/**
* The nodeID (e.g. `process.entity_id`) for the node related to the event being shown.
*/
nodeID: string;
/**
* A value used for the `nodeEventsOfType` view. Used to associate this view with a parent `nodeEventsOfType` view.
* e.g. The user views the `nodeEventsOfType` and follows a link to the `eventDetail` view. The `eventDetail` view can
* use `eventType` to populate breadcrumbs and allow the user to return to the previous filter.
*
* This cannot be inferred from the event itself, as an event may have any number of 'eventType's.
*/
eventType: string;
/**
* `event.id` that uniquely identifies the event to show.
*/
eventID: string;
};
};

View file

@ -177,7 +177,7 @@ describe('Resolver, when analyzing a tree that has no ancestors and 2 children',
);
// Click the second child node's primary button
if (button) {
button.simulate('click');
button.simulate('click', { button: 0 });
}
});
it('should render the second child node as selected, and the origin as not selected, and the query string should indicate that the second child is selected', async () => {
@ -194,7 +194,8 @@ describe('Resolver, when analyzing a tree that has no ancestors and 2 children',
).toYieldEqualTo({
// Just the second child should be marked as selected in the query string
search: urlSearch(resolverComponentInstanceID, {
selectedEntityID: entityIDs.secondChild,
panelParameters: { nodeID: entityIDs.secondChild },
panelView: 'nodeDetail',
}),
// The second child is rendered and has `[aria-selected]`
selectedSecondChildNodeCount: 1,
@ -291,7 +292,7 @@ describe('Resolver, when analyzing a tree that has two related events for the or
simulator.processNodeSubmenuButton(entityIDs.origin)
);
if (button) {
button.simulate('click');
button.simulate('click', { button: 0 });
}
});
it('should open the submenu and display exactly one option with the correct count', async () => {
@ -308,8 +309,8 @@ describe('Resolver, when analyzing a tree that has two related events for the or
simulator.processNodeSubmenuButton(entityIDs.origin)
);
if (button) {
button.simulate('click');
button.simulate('click'); // The first click opened the menu, this second click closes it
button.simulate('click', { button: 0 });
button.simulate('click', { button: 0 }); // The first click opened the menu, this second click closes it
}
});
it('should close the submenu', async () => {

View file

@ -1,126 +1,118 @@
/*
* 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 React from 'react';
import { EuiCallOut } from '@elastic/eui';
import { FormattedMessage } from 'react-intl';
const lineageLimitMessage = (
<>
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.eitherLineageLimitExceeded"
defaultMessage="Some process events in the visualization and event list below could not be displayed because the data limit has been reached."
/>
</>
);
const LineageTitleMessage = React.memo(function LineageTitleMessage({
numberOfEntries,
}: {
numberOfEntries: number;
}) {
return (
<>
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.relatedEventLimitTitle"
defaultMessage="This list includes {numberOfEntries} process events."
values={{ numberOfEntries }}
/>
</>
);
});
const RelatedEventsLimitMessage = React.memo(function RelatedEventsLimitMessage({
category,
numberOfEventsMissing,
}: {
numberOfEventsMissing: number;
category: string;
}) {
return (
<>
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.relatedEventLimitExceeded"
defaultMessage="{numberOfEventsMissing} {category} events could not be displayed because the data limit has been reached."
values={{ numberOfEventsMissing, category }}
/>
</>
);
});
const RelatedLimitTitleMessage = React.memo(function RelatedLimitTitleMessage({
category,
numberOfEventsDisplayed,
}: {
numberOfEventsDisplayed: number;
category: string;
}) {
return (
<>
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.relatedLimitsExceededTitle"
defaultMessage="This list includes {numberOfEventsDisplayed} {category} events."
values={{ numberOfEventsDisplayed, category }}
/>
</>
);
});
/**
* Limit warning for hitting the /events API limit
*/
export const RelatedEventLimitWarning = React.memo(function RelatedEventLimitWarning({
className,
eventType,
numberActuallyDisplayed,
numberMissing,
}: {
className?: string;
eventType: string;
numberActuallyDisplayed: number;
numberMissing: number;
}) {
/**
* Based on API limits, all related events may not be displayed.
*/
return (
<EuiCallOut
size="s"
className={className}
title={
<RelatedLimitTitleMessage
category={eventType}
numberOfEventsDisplayed={numberActuallyDisplayed}
/>
}
>
<p>
<RelatedEventsLimitMessage category={eventType} numberOfEventsMissing={numberMissing} />
</p>
</EuiCallOut>
);
});
/**
* Limit warning for hitting a limit of nodes in the tree
*/
export const LimitWarning = React.memo(function LimitWarning({
className,
numberDisplayed,
}: {
className?: string;
numberDisplayed: number;
}) {
return (
<EuiCallOut
size="s"
className={className}
title={<LineageTitleMessage numberOfEntries={numberDisplayed} />}
>
<p>{lineageLimitMessage}</p>
</EuiCallOut>
);
});
/*
* 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 React from 'react';
import { EuiCallOut } from '@elastic/eui';
import { FormattedMessage } from 'react-intl';
const lineageLimitMessage = (
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.eitherLineageLimitExceeded"
defaultMessage="Some process events in the visualization and event list below could not be displayed because the data limit has been reached."
/>
);
const LineageTitleMessage = React.memo(function LineageTitleMessage({
numberOfEntries,
}: {
numberOfEntries: number;
}) {
return (
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.relatedEventLimitTitle"
defaultMessage="This list includes {numberOfEntries} process events."
values={{ numberOfEntries }}
/>
);
});
const RelatedEventsLimitMessage = React.memo(function RelatedEventsLimitMessage({
category,
numberOfEventsMissing,
}: {
numberOfEventsMissing: number;
category: string;
}) {
return (
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.relatedEventLimitExceeded"
defaultMessage="{numberOfEventsMissing} {category} events could not be displayed because the data limit has been reached."
values={{ numberOfEventsMissing, category }}
/>
);
});
const RelatedLimitTitleMessage = React.memo(function RelatedLimitTitleMessage({
category,
numberOfEventsDisplayed,
}: {
numberOfEventsDisplayed: number;
category: string;
}) {
return (
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.relatedLimitsExceededTitle"
defaultMessage="This list includes {numberOfEventsDisplayed} {category} events."
values={{ numberOfEventsDisplayed, category }}
/>
);
});
/**
* Limit warning for hitting the /events API limit
*/
export const RelatedEventLimitWarning = React.memo(function RelatedEventLimitWarning({
className,
eventType,
numberActuallyDisplayed,
numberMissing,
}: {
className?: string;
eventType: string;
numberActuallyDisplayed: number;
numberMissing: number;
}) {
/**
* Based on API limits, all related events may not be displayed.
*/
return (
<EuiCallOut
size="s"
className={className}
title={
<RelatedLimitTitleMessage
category={eventType}
numberOfEventsDisplayed={numberActuallyDisplayed}
/>
}
>
<p>
<RelatedEventsLimitMessage category={eventType} numberOfEventsMissing={numberMissing} />
</p>
</EuiCallOut>
);
});
/**
* Limit warning for hitting a limit of nodes in the tree
*/
export const LimitWarning = React.memo(function LimitWarning({
className,
numberDisplayed,
}: {
className?: string;
numberDisplayed: number;
}) {
return (
<EuiCallOut
size="s"
className={className}
title={<LineageTitleMessage numberOfEntries={numberDisplayed} />}
>
<p>{lineageLimitMessage}</p>
</EuiCallOut>
);
});

View file

@ -3,7 +3,6 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { createMemoryHistory, History as HistoryPackageHistoryInterface } from 'history';
import { noAncestorsTwoChildren } from '../data_access_layer/mocks/no_ancestors_two_children';
@ -61,7 +60,8 @@ describe(`Resolver: when analyzing a tree with no ancestors and two children, an
});
const queryStringWithOriginSelected = urlSearch(resolverComponentInstanceID, {
selectedEntityID: 'origin',
panelParameters: { nodeID: 'origin' },
panelView: 'nodeDetail',
});
describe(`when the URL query string is ${queryStringWithOriginSelected}`, () => {
@ -111,7 +111,8 @@ describe(`Resolver: when analyzing a tree with no ancestors and two children, an
});
const queryStringWithFirstChildSelected = urlSearch(resolverComponentInstanceID, {
selectedEntityID: 'firstChild',
panelParameters: { nodeID: 'firstChild' },
panelView: 'nodeDetail',
});
describe(`when the URL query string is ${queryStringWithFirstChildSelected}`, () => {
@ -149,7 +150,7 @@ describe(`Resolver: when analyzing a tree with no ancestors and two children, an
const nodeLinks = await simulator().resolve('resolver:node-list:node-link:title');
expect(nodeLinks).toBeTruthy();
if (nodeLinks) {
nodeLinks.first().simulate('click');
nodeLinks.first().simulate('click', { button: 0 });
}
});
it('should show the details for the first node', async () => {
@ -168,7 +169,10 @@ describe(`Resolver: when analyzing a tree with no ancestors and two children, an
it("should have the first node's ID in the query string", async () => {
await expect(simulator().map(() => simulator().historyLocationSearch)).toYieldEqualTo(
urlSearch(resolverComponentInstanceID, {
selectedEntityID: entityIDs.origin,
panelView: 'nodeDetail',
panelParameters: {
nodeID: entityIDs.origin,
},
})
);
});
@ -178,7 +182,7 @@ describe(`Resolver: when analyzing a tree with no ancestors and two children, an
'resolver:node-detail:breadcrumbs:node-list-link'
);
if (nodeListLink) {
nodeListLink.simulate('click');
nodeListLink.simulate('click', { button: 0 });
}
});
it('should show the list of nodes with links to each node', async () => {

View file

@ -10,14 +10,15 @@ import { EuiSpacer, EuiText, EuiDescriptionList, EuiTextColor, EuiTitle } from '
import styled from 'styled-components';
import { useSelector } from 'react-redux';
import { FormattedMessage } from 'react-intl';
import { StyledPanel } from '../styles';
import { StyledBreadcrumbs, BoldCode, StyledTime, GeneratedText } from './panel_content_utilities';
import * as event from '../../../../common/endpoint/models/event';
import { ResolverEvent } from '../../../../common/endpoint/types';
import * as selectors from '../../store/selectors';
import { useResolverDispatch } from '../use_resolver_dispatch';
import { PanelContentError } from './panel_content_error';
import { PanelLoading } from './panel_loading';
import { ResolverState } from '../../types';
import { useReplaceBreadcrumbParameters } from '../use_replace_breadcrumb_parameters';
import { useNavigateOrReplace } from '../use_navigate_or_replace';
// Adding some styles to prevent horizontal scrollbars, per request from UX review
const StyledDescriptionList = memo(styled(EuiDescriptionList)`
@ -77,17 +78,26 @@ function entriesForDisplay(entries: Array<{ title: string; description: string }
* This view presents a detailed view of all the available data for a related event, split and titled by the "section"
* it appears in the underlying ResolverEvent
*/
export const RelatedEventDetail = memo(function ({
relatedEventId,
parentEvent,
countForParent,
export const EventDetail = memo(function ({
nodeID,
eventID,
}: {
relatedEventId: string;
parentEvent: ResolverEvent;
countForParent: number | undefined;
nodeID: string;
eventID: string;
}) {
const parentEvent = useSelector((state: ResolverState) =>
selectors.processEventForID(state)(nodeID)
);
const relatedEventsStats = useSelector((state: ResolverState) =>
selectors.relatedEventsStats(state)(nodeID)
);
const countForParent: number = Object.values(relatedEventsStats?.events.byCategory || {}).reduce(
(sum, val) => sum + val,
0
);
const processName = (parentEvent && event.eventName(parentEvent)) || '*';
const processEntityId = parentEvent && event.entityId(parentEvent);
const processEntityId = (parentEvent && event.entityId(parentEvent)) || '';
const totalCount = countForParent || 0;
const eventsString = i18n.translate(
'xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.events',
@ -105,13 +115,23 @@ export const RelatedEventDetail = memo(function ({
const relatedsReadyMap = useSelector(selectors.relatedEventsReady);
const relatedsReady = relatedsReadyMap.get(processEntityId!);
const dispatch = useResolverDispatch();
const nodesHref = useSelector((state: ResolverState) =>
selectors.relativeHref(state)({ panelView: 'nodes' })
);
const nodesLinkNavProps = useNavigateOrReplace({
search: nodesHref,
});
/**
* If we don't have the related events for the parent yet, use this effect
* to request them.
*/
useEffect(() => {
if (typeof relatedsReady === 'undefined') {
if (
typeof relatedsReady === 'undefined' &&
processEntityId !== null &&
processEntityId !== undefined
) {
dispatch({
type: 'appDetectedMissingEventData',
payload: processEntityId,
@ -126,38 +146,51 @@ export const RelatedEventDetail = memo(function ({
sections,
formattedDate,
] = useSelector((state: ResolverState) =>
selectors.relatedEventDisplayInfoByEntityAndSelfId(state)(processEntityId, relatedEventId)
selectors.relatedEventDisplayInfoByEntityAndSelfId(state)(nodeID, eventID)
);
const pushToQueryParams = useReplaceBreadcrumbParameters();
const waitCrumbs = useMemo(() => {
return [
{
text: eventsString,
onClick: () => {
pushToQueryParams({ crumbId: '', crumbEvent: '' });
},
},
];
}, [pushToQueryParams, eventsString]);
const { subject = '', descriptor = '' } = relatedEventToShowDetailsFor
? event.descriptiveName(relatedEventToShowDetailsFor)
: {};
const nodeDetailHref = useSelector((state: ResolverState) =>
selectors.relativeHref(state)({
panelView: 'nodeDetail',
panelParameters: { nodeID: processEntityId },
})
);
const nodeDetailLinkNavProps = useNavigateOrReplace({
search: nodeDetailHref,
});
const nodeEventsHref = useSelector((state: ResolverState) =>
selectors.relativeHref(state)({
panelView: 'nodeEvents',
panelParameters: { nodeID: processEntityId },
})
);
const nodeEventsLinkNavProps = useNavigateOrReplace({
search: nodeEventsHref,
});
const nodeEventsOfTypeHref = useSelector((state: ResolverState) =>
selectors.relativeHref(state)({
panelView: 'nodeEventsOfType',
panelParameters: { nodeID: processEntityId, eventType: relatedEventCategory },
})
);
const nodeEventsOfTypeLinkNavProps = useNavigateOrReplace({
search: nodeEventsOfTypeHref,
});
const crumbs = useMemo(() => {
return [
{
text: eventsString,
onClick: () => {
pushToQueryParams({ crumbId: '', crumbEvent: '' });
},
...nodesLinkNavProps,
},
{
text: processName,
onClick: () => {
pushToQueryParams({ crumbId: processEntityId!, crumbEvent: '' });
},
...nodeDetailLinkNavProps,
},
{
text: (
@ -169,9 +202,7 @@ export const RelatedEventDetail = memo(function ({
/>
</>
),
onClick: () => {
pushToQueryParams({ crumbId: processEntityId!, crumbEvent: 'all' });
},
...nodeEventsLinkNavProps,
},
{
text: (
@ -183,12 +214,7 @@ export const RelatedEventDetail = memo(function ({
/>
</>
),
onClick: () => {
pushToQueryParams({
crumbId: processEntityId!,
crumbEvent: relatedEventCategory || 'all',
});
},
...nodeEventsOfTypeLinkNavProps,
},
{
text: relatedEventToShowDetailsFor ? (
@ -205,9 +231,7 @@ export const RelatedEventDetail = memo(function ({
];
}, [
processName,
processEntityId,
eventsString,
pushToQueryParams,
totalCount,
countBySameCategory,
naString,
@ -215,27 +239,14 @@ export const RelatedEventDetail = memo(function ({
relatedEventToShowDetailsFor,
subject,
descriptor,
nodeEventsOfTypeLinkNavProps,
nodeEventsLinkNavProps,
nodeDetailLinkNavProps,
nodesLinkNavProps,
]);
/**
* If the ship hasn't come in yet, wait on the dock
*/
if (!relatedsReady) {
const waitingString = i18n.translate(
'xpack.securitySolution.endpoint.resolver.panel.relatedDetail.wait',
{
defaultMessage: 'Waiting For Events...',
}
);
return (
<>
<StyledBreadcrumbs breadcrumbs={waitCrumbs} />
<EuiSpacer size="l" />
<EuiTitle>
<h4>{waitingString}</h4>
</EuiTitle>
</>
);
return <PanelLoading />;
}
/**
@ -252,7 +263,7 @@ export const RelatedEventDetail = memo(function ({
}
return (
<>
<StyledPanel>
<StyledBreadcrumbs breadcrumbs={crumbs} />
<EuiSpacer size="l" />
<EuiText size="s">
@ -310,6 +321,6 @@ export const RelatedEventDetail = memo(function ({
</Fragment>
);
})}
</>
</StyledPanel>
);
});

View file

@ -4,216 +4,46 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { memo, useMemo, useContext, useLayoutEffect, useState } from 'react';
/* eslint-disable react/display-name */
import React, { memo } from 'react';
import { useSelector } from 'react-redux';
import { EuiPanel } from '@elastic/eui';
import * as selectors from '../../store/selectors';
import { useResolverDispatch } from '../use_resolver_dispatch';
import * as event from '../../../../common/endpoint/models/event';
import { ResolverEvent, ResolverNodeStats } from '../../../../common/endpoint/types';
import { SideEffectContext } from '../side_effect_context';
import { ProcessEventList } from './process_event_list';
import { EventCountsForProcess } from './event_counts_for_process';
import { ProcessDetails } from './process_details';
import { ProcessListWithCounts } from './process_list_with_counts';
import { RelatedEventDetail } from './related_event_detail';
import { ResolverState } from '../../types';
import { NodeEventsOfType } from './node_events_of_type';
import { NodeEvents } from './node_events';
import { NodeDetail } from './node_details';
import { NodeList } from './node_list';
import { EventDetail } from './event_detail';
import { PanelViewAndParameters } from '../../types';
/**
* The team decided to use this table to determine which breadcrumbs/view to display:
*
* | Crumb/Table | &crumbId | &crumbEvent |
* | :--------------------- | :------------------------- | :---------------------- |
* | all processes/default | null | null |
* | process detail | entity_id of process | null |
* | relateds count by type | entity_id of process | 'all' |
* | relateds list 1 type | entity_id of process | valid related event type |
* | related event detail | event_id of related event | entity_id of process |
*
* This component implements the strategy laid out above by determining the "right" view and doing some other housekeeping e.g. effects to keep the UI-selected node in line with what's indicated by the URL parameters.
*
* @returns {JSX.Element} The "right" table content to show based on the query params as described above
*/
const PanelContent = memo(function PanelContent() {
const dispatch = useResolverDispatch();
const { timestamp } = useContext(SideEffectContext);
const queryParams = useSelector(selectors.breadcrumbParameters);
const graphableProcesses = useSelector(selectors.graphableProcesses);
const graphableProcessEntityIds = useMemo(() => {
return new Set(graphableProcesses.map(event.entityId));
}, [graphableProcesses]);
// The entity id in query params of a graphable process (or false if none is found)
// For 1 case (the related detail, see below), the process id will be in crumbEvent instead of crumbId
const idFromParams = useMemo(() => {
if (graphableProcessEntityIds.has(queryParams.crumbId)) {
return queryParams.crumbId;
}
if (graphableProcessEntityIds.has(queryParams.crumbEvent)) {
return queryParams.crumbEvent;
}
return '';
}, [queryParams, graphableProcessEntityIds]);
// The "selected" node (and its corresponding event) in the tree control.
// It may need to be synchronized with the ID indicated as selected via the `idFromParams`
// memo above. When this is the case, it is handled by the layout effect below.
const selectedNode = useSelector(selectors.selectedNode);
const uiSelectedEvent = useMemo(() => {
return graphableProcesses.find((evt) => event.entityId(evt) === selectedNode);
}, [graphableProcesses, selectedNode]);
// Until an event is dispatched during update, the event indicated as selected by params may
// be different than the one in state.
const paramsSelectedEvent = useMemo(() => {
return graphableProcesses.find((evt) => event.entityId(evt) === idFromParams);
}, [graphableProcesses, idFromParams]);
const [lastUpdatedProcess, setLastUpdatedProcess] = useState<null | ResolverEvent>(null);
/**
* When the ui-selected node is _not_ the one indicated by the query params, but the id from params _is_ in the current tree,
* dispatch a selection action to amend the UI state to hold the query id as "selected".
* This is to cover cases where users e.g. share links to reconstitute a Resolver state or
* an effect pushes a new process id to the query params.
*/
useLayoutEffect(() => {
if (
paramsSelectedEvent &&
// Check state to ensure we don't dispatch this in a way that causes unnecessary re-renders, or disrupts animation:
paramsSelectedEvent !== lastUpdatedProcess &&
paramsSelectedEvent !== uiSelectedEvent
) {
setLastUpdatedProcess(paramsSelectedEvent);
dispatch({
type: 'appDetectedNewIdFromQueryParams',
payload: {
time: timestamp(),
process: paramsSelectedEvent,
},
});
}
}, [dispatch, uiSelectedEvent, paramsSelectedEvent, lastUpdatedProcess, timestamp]);
const relatedEventStats = useSelector(selectors.relatedEventsStats);
const { crumbId, crumbEvent } = queryParams;
const relatedStatsForIdFromParams: ResolverNodeStats | undefined = idFromParams
? relatedEventStats(idFromParams)
: undefined;
const parentCount = useSelector((state: ResolverState) => {
if (idFromParams === '') {
return 0;
}
return selectors.relatedEventAggregateTotalByEntityId(state)(idFromParams);
});
/**
* Determine which set of breadcrumbs to display based on the query parameters
* for the table & breadcrumb nav.
*
*/
const panelToShow = useMemo(() => {
if (crumbEvent === '' && crumbId === '') {
/**
* | Crumb/Table | &crumbId | &crumbEvent |
* | :--------------------- | :------------------------- | :---------------------- |
* | all processes/default | null | null |
*/
return 'processListWithCounts';
}
if (graphableProcessEntityIds.has(crumbId)) {
/**
* | Crumb/Table | &crumbId | &crumbEvent |
* | :--------------------- | :------------------------- | :---------------------- |
* | process detail | entity_id of process | null |
*/
if (crumbEvent === '' && uiSelectedEvent) {
return 'processDetails';
}
/**
* | Crumb/Table | &crumbId | &crumbEvent |
* | :--------------------- | :------------------------- | :---------------------- |
* | relateds count by type | entity_id of process | 'all' |
*/
if (crumbEvent === 'all' && uiSelectedEvent) {
return 'eventCountsForProcess';
}
/**
* | Crumb/Table | &crumbId | &crumbEvent |
* | :--------------------- | :------------------------- | :---------------------- |
* | relateds list 1 type | entity_id of process | valid related event type |
*/
if (crumbEvent && crumbEvent.length && uiSelectedEvent) {
return 'processEventList';
}
}
if (graphableProcessEntityIds.has(crumbEvent)) {
/**
* | Crumb/Table | &crumbId | &crumbEvent |
* | :--------------------- | :------------------------- | :---------------------- |
* | related event detail | event_id of related event | entity_id of process |
*/
return 'relatedEventDetail';
}
// The default 'Event List' / 'List of all processes' view
return 'processListWithCounts';
}, [uiSelectedEvent, crumbEvent, crumbId, graphableProcessEntityIds]);
const panelInstance = useMemo(() => {
if (panelToShow === 'processDetails') {
return <ProcessDetails processEvent={uiSelectedEvent!} />;
}
if (panelToShow === 'eventCountsForProcess') {
return (
<EventCountsForProcess
processEvent={uiSelectedEvent!}
relatedStats={relatedStatsForIdFromParams!}
/>
);
}
if (panelToShow === 'processEventList') {
return (
<ProcessEventList
processEvent={uiSelectedEvent!}
relatedStats={relatedStatsForIdFromParams!}
eventType={crumbEvent}
/>
);
}
if (panelToShow === 'relatedEventDetail') {
return (
<RelatedEventDetail
relatedEventId={crumbId}
parentEvent={uiSelectedEvent!}
countForParent={parentCount}
/>
);
}
// The default 'Event List' / 'List of all processes' view
return <ProcessListWithCounts />;
}, [uiSelectedEvent, crumbEvent, crumbId, relatedStatsForIdFromParams, panelToShow, parentCount]);
return <>{panelInstance}</>;
export const PanelRouter = memo(function () {
const params: PanelViewAndParameters = useSelector(selectors.panelViewAndParameters);
if (params.panelView === 'nodeDetail') {
return <NodeDetail nodeID={params.panelParameters.nodeID} />;
} else if (params.panelView === 'nodeEvents') {
return <NodeEvents nodeID={params.panelParameters.nodeID} />;
} else if (params.panelView === 'nodeEventsOfType') {
return (
<NodeEventsOfType
nodeID={params.panelParameters.nodeID}
eventType={params.panelParameters.eventType}
/>
);
} else if (params.panelView === 'eventDetail') {
return (
<EventDetail
nodeID={params.panelParameters.nodeID}
eventID={params.panelParameters.eventID}
/>
);
} else {
/* The default 'Event List' / 'List of all processes' view */
return <NodeList />;
}
});
PanelContent.displayName = 'PanelContent';
export const Panel = memo(function Event({ className }: { className?: string }) {
return (
<EuiPanel className={className}>
<PanelContent />
</EuiPanel>
);
});
Panel.displayName = 'Panel';

View file

@ -3,21 +3,16 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
/* eslint-disable react/display-name */
import React, { memo, useMemo, HTMLAttributes } from 'react';
import { useSelector } from 'react-redux';
import { i18n } from '@kbn/i18n';
import {
htmlIdGenerator,
EuiSpacer,
EuiTitle,
EuiText,
EuiTextColor,
EuiDescriptionList,
EuiLink,
} from '@elastic/eui';
import styled from 'styled-components';
import { htmlIdGenerator, EuiSpacer, EuiTitle, EuiText, EuiTextColor, EuiLink } from '@elastic/eui';
import { FormattedMessage } from 'react-intl';
import { EuiDescriptionListProps } from '@elastic/eui/src/components/description_list/description_list';
import { StyledDescriptionList, StyledTitle } from './styles';
import * as selectors from '../../store/selectors';
import * as event from '../../../../common/endpoint/models/event';
import { formatDate, StyledBreadcrumbs, GeneratedText } from './panel_content_utilities';
@ -33,23 +28,26 @@ import { CubeForProcess } from './cube_for_process';
import { ResolverEvent } from '../../../../common/endpoint/types';
import { useResolverTheme } from '../assets';
import { ResolverState } from '../../types';
import { useReplaceBreadcrumbParameters } from '../use_replace_breadcrumb_parameters';
import { PanelLoading } from './panel_loading';
import { StyledPanel } from '../styles';
import { useNavigateOrReplace } from '../use_navigate_or_replace';
const StyledDescriptionList = styled(EuiDescriptionList)`
&.euiDescriptionList.euiDescriptionList--column dt.euiDescriptionList__title.desc-title {
max-width: 10em;
}
`;
const StyledTitle = styled('h4')`
overflow-wrap: break-word;
`;
export const NodeDetail = memo(function ({ nodeID }: { nodeID: string }) {
const processEvent = useSelector((state: ResolverState) =>
selectors.processEventForID(state)(nodeID)
);
return (
<StyledPanel>
{processEvent === null ? <PanelLoading /> : <NodeDetailView processEvent={processEvent} />}
</StyledPanel>
);
});
/**
* A description list view of all the Metadata that goes with a particular process event, like:
* Created, PID, User/Domain, etc.
*/
export const ProcessDetails = memo(function ProcessDetails({
const NodeDetailView = memo(function NodeDetailView({
processEvent,
}: {
processEvent: ResolverEvent;
@ -130,7 +128,13 @@ export const ProcessDetails = memo(function ProcessDetails({
return processDescriptionListData;
}, [processEvent]);
const pushToQueryParams = useReplaceBreadcrumbParameters();
const nodesHref = useSelector((state: ResolverState) =>
selectors.relativeHref(state)({ panelView: 'nodes' })
);
const nodesLinkNavProps = useNavigateOrReplace({
search: nodesHref,
});
const crumbs = useMemo(() => {
return [
@ -142,24 +146,20 @@ export const ProcessDetails = memo(function ProcessDetails({
}
),
'data-test-subj': 'resolver:node-detail:breadcrumbs:node-list-link',
onClick: () => {
pushToQueryParams({ crumbId: '', crumbEvent: '' });
},
...nodesLinkNavProps,
},
{
text: (
<>
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.detailsForProcessName"
values={{ processName }}
defaultMessage="Details for: {processName}"
/>
</>
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.detailsForProcessName"
values={{ processName }}
defaultMessage="Details for: {processName}"
/>
),
onClick: () => {},
},
];
}, [processName, pushToQueryParams]);
}, [processName, nodesLinkNavProps]);
const { cubeAssetsForNode } = useResolverTheme();
const { descriptionText } = useMemo(() => {
if (!processEvent) {
@ -168,11 +168,16 @@ export const ProcessDetails = memo(function ProcessDetails({
return cubeAssetsForNode(isProcessTerminated, false);
}, [processEvent, cubeAssetsForNode, isProcessTerminated]);
const handleEventsLinkClick = useMemo(() => {
return () => {
pushToQueryParams({ crumbId: entityId, crumbEvent: 'all' });
};
}, [entityId, pushToQueryParams]);
const nodeDetailHref = useSelector((state: ResolverState) =>
selectors.relativeHref(state)({
panelView: 'nodeEvents',
panelParameters: { nodeID: entityId },
})
);
const nodeDetailNavProps = useNavigateOrReplace({
search: nodeDetailHref!,
});
const titleID = useMemo(() => htmlIdGenerator('resolverTable')(), []);
return (
@ -196,7 +201,7 @@ export const ProcessDetails = memo(function ProcessDetails({
</EuiTextColor>
</EuiText>
<EuiSpacer size="s" />
<EuiLink onClick={handleEventsLinkClick}>
<EuiLink {...nodeDetailNavProps}>
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.processDescList.numberOfEvents"
values={{ relatedEventTotal }}

View file

@ -8,11 +8,33 @@ import React, { memo, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiBasicTableColumn, EuiButtonEmpty, EuiSpacer, EuiInMemoryTable } from '@elastic/eui';
import { FormattedMessage } from 'react-intl';
import { useSelector } from 'react-redux';
import { StyledBreadcrumbs } from './panel_content_utilities';
import * as event from '../../../../common/endpoint/models/event';
import { ResolverEvent, ResolverNodeStats } from '../../../../common/endpoint/types';
import { useReplaceBreadcrumbParameters } from '../use_replace_breadcrumb_parameters';
import * as selectors from '../../store/selectors';
import { ResolverState } from '../../types';
import { StyledPanel } from '../styles';
import { useNavigateOrReplace } from '../use_navigate_or_replace';
import { PanelLoading } from './panel_loading';
export function NodeEvents({ nodeID }: { nodeID: string }) {
const processEvent = useSelector((state: ResolverState) =>
selectors.processEventForID(state)(nodeID)
);
const relatedEventsStats = useSelector((state: ResolverState) =>
selectors.relatedEventsStats(state)(nodeID)
);
if (processEvent === null || relatedEventsStats === undefined) {
return <PanelLoading />;
} else {
return (
<StyledPanel>
<EventCountsForProcess processEvent={processEvent} relatedStats={relatedEventsStats} />
</StyledPanel>
);
}
}
/**
* This view gives counts for all the related events of a process grouped by related event type.
@ -25,7 +47,7 @@ import { useReplaceBreadcrumbParameters } from '../use_replace_breadcrumb_parame
* | 2 | Network |
*
*/
export const EventCountsForProcess = memo(function EventCountsForProcess({
const EventCountsForProcess = memo(function EventCountsForProcess({
processEvent,
relatedStats,
}: {
@ -60,37 +82,64 @@ export const EventCountsForProcess = memo(function EventCountsForProcess({
defaultMessage: 'Events',
}
);
const pushToQueryParams = useReplaceBreadcrumbParameters();
const eventsHref = useSelector((state: ResolverState) =>
selectors.relativeHref(state)({ panelView: 'nodes' })
);
const eventLinkNavProps = useNavigateOrReplace({
search: eventsHref,
});
const processDetailHref = useSelector((state: ResolverState) =>
selectors.relativeHref(state)({
panelView: 'nodeDetail',
panelParameters: { nodeID: processEntityId },
})
);
const processDetailNavProps = useNavigateOrReplace({
search: processDetailHref,
});
const nodeDetailHref = useSelector((state: ResolverState) =>
selectors.relativeHref(state)({
panelView: 'nodeEvents',
panelParameters: { nodeID: processEntityId },
})
);
const nodeDetailNavProps = useNavigateOrReplace({
search: nodeDetailHref!,
});
const crumbs = useMemo(() => {
return [
{
text: eventsString,
onClick: () => {
pushToQueryParams({ crumbId: '', crumbEvent: '' });
},
...eventLinkNavProps,
},
{
text: processName,
onClick: () => {
pushToQueryParams({ crumbId: processEntityId, crumbEvent: '' });
},
...processDetailNavProps,
},
{
text: (
<>
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.relatedCounts.numberOfEventsInCrumb"
values={{ totalCount }}
defaultMessage="{totalCount} Events"
/>
</>
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.relatedCounts.numberOfEventsInCrumb"
values={{ totalCount }}
defaultMessage="{totalCount} Events"
/>
),
onClick: () => {
pushToQueryParams({ crumbId: processEntityId, crumbEvent: '' });
},
...nodeDetailNavProps,
},
];
}, [processName, totalCount, processEntityId, pushToQueryParams, eventsString]);
}, [
processName,
totalCount,
eventsString,
eventLinkNavProps,
nodeDetailNavProps,
processDetailNavProps,
]);
const rows = useMemo(() => {
return Object.entries(relatedEventsState.stats).map(
([eventType, count]): EventCountsTableView => {
@ -101,6 +150,17 @@ export const EventCountsForProcess = memo(function EventCountsForProcess({
}
);
}, [relatedEventsState]);
const eventDetailHref = useSelector((state: ResolverState) =>
selectors.relativeHref(state)({
panelView: 'eventDetail',
panelParameters: { nodeID: processEntityId, eventType: name, eventID: processEntityId },
})
);
const eventDetailNavProps = useNavigateOrReplace({
search: eventDetailHref,
});
const columns = useMemo<Array<EuiBasicTableColumn<EventCountsTableView>>>(
() => [
{
@ -119,19 +179,11 @@ export const EventCountsForProcess = memo(function EventCountsForProcess({
width: '80%',
sortable: true,
render(name: string) {
return (
<EuiButtonEmpty
onClick={() => {
pushToQueryParams({ crumbId: event.entityId(processEvent), crumbEvent: name });
}}
>
{name}
</EuiButtonEmpty>
);
return <EuiButtonEmpty {...eventDetailNavProps}>{name}</EuiButtonEmpty>;
},
},
],
[pushToQueryParams, processEvent]
[eventDetailNavProps]
);
return (
<>

View file

@ -4,19 +4,25 @@
* you may not use this file except in compliance with the Elastic License.
*/
/* eslint-disable react/display-name */
import React, { memo, useMemo, useEffect, Fragment } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiTitle, EuiSpacer, EuiText, EuiButtonEmpty, EuiHorizontalRule } from '@elastic/eui';
import { EuiSpacer, EuiText, EuiButtonEmpty, EuiHorizontalRule } from '@elastic/eui';
import { useSelector } from 'react-redux';
import { FormattedMessage } from 'react-intl';
import styled from 'styled-components';
import { StyledPanel } from '../styles';
import { formatDate, StyledBreadcrumbs, BoldCode, StyledTime } from './panel_content_utilities';
import * as event from '../../../../common/endpoint/models/event';
import { ResolverEvent, ResolverNodeStats } from '../../../../common/endpoint/types';
import * as selectors from '../../store/selectors';
import { useResolverDispatch } from '../use_resolver_dispatch';
import { RelatedEventLimitWarning } from '../limit_warnings';
import { useReplaceBreadcrumbParameters } from '../use_replace_breadcrumb_parameters';
import { ResolverState } from '../../types';
import { useNavigateOrReplace } from '../use_navigate_or_replace';
import { useRelatedEventDetailNavigation } from '../use_related_event_detail_navigation';
import { PanelLoading } from './panel_loading';
/**
* This view presents a list of related events of a given type for a given process.
@ -56,13 +62,17 @@ const StyledRelatedLimitWarning = styled(RelatedEventLimitWarning)`
}
`;
const DisplayList = memo(function DisplayList({
const NodeCategoryEntries = memo(function ({
crumbs,
matchingEventEntries,
eventType,
processEntityId,
}: {
crumbs: Array<{ text: string | JSX.Element; onClick: () => void }>;
crumbs: Array<{
text: string | JSX.Element | null;
onClick: (event: React.MouseEvent<HTMLAnchorElement | HTMLButtonElement, MouseEvent>) => void;
href?: string;
}>;
matchingEventEntries: MatchingEventEntry[];
eventType: string;
processEntityId: string;
@ -125,36 +135,55 @@ const DisplayList = memo(function DisplayList({
);
});
export const ProcessEventList = memo(function ProcessEventList({
export function NodeEventsOfType({ nodeID, eventType }: { nodeID: string; eventType: string }) {
const processEvent = useSelector((state: ResolverState) =>
selectors.processEventForID(state)(nodeID)
);
const relatedEventsStats = useSelector((state: ResolverState) =>
selectors.relatedEventsStats(state)(nodeID)
);
return (
<StyledPanel>
<NodeEventList
processEvent={processEvent}
eventType={eventType}
relatedStats={relatedEventsStats}
/>
</StyledPanel>
);
}
const NodeEventList = memo(function ({
processEvent,
eventType,
relatedStats,
}: {
processEvent: ResolverEvent;
processEvent: ResolverEvent | null;
eventType: string;
relatedStats: ResolverNodeStats;
relatedStats: ResolverNodeStats | undefined;
}) {
const processName = processEvent && event.eventName(processEvent);
const processEntityId = event.entityId(processEvent);
const totalCount = Object.values(relatedStats.events.byCategory).reduce(
(sum, val) => sum + val,
0
const processEntityId = processEvent ? event.entityId(processEvent) : '';
const nodesHref = useSelector((state: ResolverState) =>
selectors.relativeHref(state)({ panelView: 'nodes' })
);
const nodesLinkNavProps = useNavigateOrReplace({
search: nodesHref,
});
const totalCount = relatedStats
? Object.values(relatedStats.events.byCategory).reduce((sum, val) => sum + val, 0)
: 0;
const eventsString = i18n.translate(
'xpack.securitySolution.endpoint.resolver.panel.processEventListByType.events',
{
defaultMessage: 'Events',
}
);
const waitingString = i18n.translate(
'xpack.securitySolution.endpoint.resolver.panel.processEventListByType.wait',
{
defaultMessage: 'Waiting For Events...',
}
);
const relatedsReadyMap = useSelector(selectors.relatedEventsReady);
const relatedsReady = relatedsReadyMap.get(processEntityId);
const relatedsReady = processEntityId && relatedsReadyMap.get(processEntityId);
const dispatch = useResolverDispatch();
@ -167,83 +196,80 @@ export const ProcessEventList = memo(function ProcessEventList({
}
}, [relatedsReady, dispatch, processEntityId]);
const pushToQueryParams = useReplaceBreadcrumbParameters();
const waitCrumbs = useMemo(() => {
return [
{
text: eventsString,
onClick: () => {
pushToQueryParams({ crumbId: '', crumbEvent: '' });
},
},
];
}, [pushToQueryParams, eventsString]);
const relatedByCategory = useSelector(selectors.relatedEventsByCategory);
const eventsForCurrentCategory = relatedByCategory(processEntityId)(eventType);
const relatedEventDetailNavigation = useRelatedEventDetailNavigation({
nodeID: processEntityId,
category: eventType,
events: eventsForCurrentCategory,
});
/**
* A list entry will be displayed for each of these
*/
const matchingEventEntries: MatchingEventEntry[] = useMemo(() => {
const relateds = relatedByCategory(processEntityId)(eventType).map((resolverEvent) => {
return eventsForCurrentCategory.map((resolverEvent) => {
const eventTime = event.eventTimestamp(resolverEvent);
const formattedDate = typeof eventTime === 'undefined' ? '' : formatDate(eventTime);
const entityId = event.eventId(resolverEvent);
return {
formattedDate,
eventCategory: `${eventType}`,
eventType: `${event.ecsEventType(resolverEvent)}`,
name: event.descriptiveName(resolverEvent),
setQueryParams: () => {
pushToQueryParams({
crumbId: entityId === undefined ? '' : String(entityId),
crumbEvent: processEntityId,
});
},
setQueryParams: () => relatedEventDetailNavigation(entityId),
};
});
return relateds;
}, [relatedByCategory, eventType, processEntityId, pushToQueryParams]);
}, [eventType, eventsForCurrentCategory, relatedEventDetailNavigation]);
const nodeDetailHref = useSelector((state: ResolverState) =>
selectors.relativeHref(state)({
panelView: 'nodeDetail',
panelParameters: { nodeID: processEntityId },
})
);
const nodeDetailNavProps = useNavigateOrReplace({
search: nodeDetailHref,
});
const nodeEventsHref = useSelector((state: ResolverState) =>
selectors.relativeHref(state)({
panelView: 'nodeEvents',
panelParameters: { nodeID: processEntityId },
})
);
const nodeEventsNavProps = useNavigateOrReplace({
search: nodeEventsHref,
});
const crumbs = useMemo(() => {
return [
{
text: eventsString,
onClick: () => {
pushToQueryParams({ crumbId: '', crumbEvent: '' });
},
...nodesLinkNavProps,
},
{
text: processName,
onClick: () => {
pushToQueryParams({ crumbId: processEntityId, crumbEvent: '' });
},
...nodeDetailNavProps,
},
{
text: (
<>
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventList.numberOfEvents"
values={{ totalCount }}
defaultMessage="{totalCount} Events"
/>
</>
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventList.numberOfEvents"
values={{ totalCount }}
defaultMessage="{totalCount} Events"
/>
),
onClick: () => {
pushToQueryParams({ crumbId: processEntityId, crumbEvent: 'all' });
},
...nodeEventsNavProps,
},
{
text: (
<>
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventList.countByCategory"
values={{ count: matchingEventEntries.length, category: eventType }}
defaultMessage="{count} {category}"
/>
</>
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventList.countByCategory"
values={{ count: matchingEventEntries.length, category: eventType }}
defaultMessage="{count} {category}"
/>
),
onClick: () => {},
},
@ -252,29 +278,19 @@ export const ProcessEventList = memo(function ProcessEventList({
eventType,
eventsString,
matchingEventEntries.length,
processEntityId,
processName,
pushToQueryParams,
totalCount,
nodeDetailNavProps,
nodesLinkNavProps,
nodeEventsNavProps,
]);
/**
* Wait here until the effect resolves...
*/
if (!relatedsReady) {
return (
<>
<StyledBreadcrumbs breadcrumbs={waitCrumbs} />
<EuiSpacer size="l" />
<EuiTitle>
<h4>{waitingString}</h4>
</EuiTitle>
</>
);
return <PanelLoading />;
}
return (
<DisplayList
<NodeCategoryEntries
crumbs={crumbs}
processEntityId={processEntityId}
matchingEventEntries={matchingEventEntries}
@ -282,4 +298,3 @@ export const ProcessEventList = memo(function ProcessEventList({
/>
);
});
ProcessEventList.displayName = 'ProcessEventList';

View file

@ -6,7 +6,7 @@
/* eslint-disable react/display-name */
import React, { memo, useContext, useCallback, useMemo } from 'react';
import React, { memo, useMemo } from 'react';
import {
EuiBasicTableColumn,
EuiBadge,
@ -17,15 +17,15 @@ import {
import { i18n } from '@kbn/i18n';
import { useSelector } from 'react-redux';
import styled from 'styled-components';
import { StyledPanel } from '../styles';
import * as event from '../../../../common/endpoint/models/event';
import * as selectors from '../../store/selectors';
import { formatter, StyledBreadcrumbs } from './panel_content_utilities';
import { useResolverDispatch } from '../use_resolver_dispatch';
import { SideEffectContext } from '../side_effect_context';
import { CubeForProcess } from './cube_for_process';
import { SafeResolverEvent } from '../../../../common/endpoint/types';
import { LimitWarning } from '../limit_warnings';
import { useReplaceBreadcrumbParameters } from '../use_replace_breadcrumb_parameters';
import { ResolverState } from '../../types';
import { useNavigateOrReplace } from '../use_navigate_or_replace';
const StyledLimitWarning = styled(LimitWarning)`
flex-flow: row wrap;
@ -46,35 +46,17 @@ const StyledLimitWarning = styled(LimitWarning)`
display: inline;
}
`;
interface ProcessTableView {
name?: string;
timestamp?: Date;
event: SafeResolverEvent;
href: string | undefined;
}
/**
* The "default" view for the panel: A list of all the processes currently in the graph.
*/
export const ProcessListWithCounts = memo(() => {
interface ProcessTableView {
name?: string;
timestamp?: Date;
event: SafeResolverEvent;
}
const dispatch = useResolverDispatch();
const { timestamp } = useContext(SideEffectContext);
const isProcessTerminated = useSelector(selectors.isProcessTerminated);
const pushToQueryParams = useReplaceBreadcrumbParameters();
const handleBringIntoViewClick = useCallback(
(processTableViewItem) => {
dispatch({
type: 'userBroughtProcessIntoView',
payload: {
time: timestamp(),
process: processTableViewItem.event,
},
});
pushToQueryParams({ crumbId: event.entityId(processTableViewItem.event), crumbEvent: '' });
},
[dispatch, timestamp, pushToQueryParams]
);
export const NodeList = memo(() => {
const columns = useMemo<Array<EuiBasicTableColumn<ProcessTableView>>>(
() => [
{
@ -88,35 +70,7 @@ export const ProcessListWithCounts = memo(() => {
sortable: true,
truncateText: true,
render(name: string, item: ProcessTableView) {
const entityID = event.entityIDSafeVersion(item.event);
const isTerminated = entityID === undefined ? false : isProcessTerminated(entityID);
return name === '' ? (
<EuiBadge color="warning">
{i18n.translate(
'xpack.securitySolution.endpoint.resolver.panel.table.row.valueMissingDescription',
{
defaultMessage: 'Value is missing',
}
)}
</EuiBadge>
) : (
<EuiButtonEmpty
onClick={() => {
handleBringIntoViewClick(item);
pushToQueryParams({
// Take the user back to the list of nodes if this node has no ID
crumbId: event.entityIDSafeVersion(item.event) ?? '',
crumbEvent: '',
});
}}
>
<CubeForProcess
running={!isTerminated}
data-test-subj="resolver:node-list:node-link:icon"
/>
<span data-test-subj="resolver:node-list:node-link:title">{name}</span>
</EuiButtonEmpty>
);
return <NodeDetailLink name={name} item={item} />;
},
},
{
@ -145,10 +99,32 @@ export const ProcessListWithCounts = memo(() => {
},
},
],
[pushToQueryParams, handleBringIntoViewClick, isProcessTerminated]
[]
);
const { processNodePositions } = useSelector(selectors.layout);
const nodeHrefs: Map<SafeResolverEvent, string | null | undefined> = useSelector(
(state: ResolverState) => {
const relativeHref = selectors.relativeHref(state);
return new Map(
[...processNodePositions.keys()].map((processEvent) => {
const nodeID = event.entityIDSafeVersion(processEvent);
if (nodeID === undefined) {
return [processEvent, null];
}
return [
processEvent,
relativeHref({
panelView: 'nodeDetail',
panelParameters: {
nodeID,
},
}),
];
})
);
}
);
const processTableView: ProcessTableView[] = useMemo(
() =>
[...processNodePositions.keys()].map((processEvent) => {
@ -157,9 +133,10 @@ export const ProcessListWithCounts = memo(() => {
name,
timestamp: event.timestampAsDateSafeVersion(processEvent),
event: processEvent,
href: nodeHrefs.get(processEvent) ?? undefined,
};
}),
[processNodePositions]
[processNodePositions, nodeHrefs]
);
const numberOfProcesses = processTableView.length;
@ -179,7 +156,7 @@ export const ProcessListWithCounts = memo(() => {
const showWarning = children === true || ancestors === true;
const rowProps = useMemo(() => ({ 'data-test-subj': 'resolver:node-list:item' }), []);
return (
<>
<StyledPanel>
<StyledBreadcrumbs breadcrumbs={crumbs} />
{showWarning && <StyledLimitWarning numberDisplayed={numberOfProcesses} />}
<EuiSpacer size="l" />
@ -190,6 +167,35 @@ export const ProcessListWithCounts = memo(() => {
columns={columns}
sorting
/>
</>
</StyledPanel>
);
});
function NodeDetailLink({ name, item }: { name: string; item: ProcessTableView }) {
const entityID = event.entityIDSafeVersion(item.event);
const isTerminated = useSelector((state: ResolverState) =>
entityID === undefined ? false : selectors.isProcessTerminated(state)(entityID)
);
return (
<EuiButtonEmpty {...useNavigateOrReplace({ search: item.href })}>
{name === '' ? (
<EuiBadge color="warning">
{i18n.translate(
'xpack.securitySolution.endpoint.resolver.panel.table.row.valueMissingDescription',
{
defaultMessage: 'Value is missing',
}
)}
</EuiBadge>
) : (
<>
<CubeForProcess
running={!isTerminated}
data-test-subj="resolver:node-list:node-link:icon"
/>
<span data-test-subj="resolver:node-list:node-link:title">{name}</span>
</>
)}
</EuiButtonEmpty>
);
}

View file

@ -7,8 +7,11 @@
import { i18n } from '@kbn/i18n';
import { EuiSpacer, EuiText, EuiButtonEmpty } from '@elastic/eui';
import React, { memo, useMemo } from 'react';
import { useSelector } from 'react-redux';
import { useNavigateOrReplace } from '../use_navigate_or_replace';
import * as selectors from '../../store/selectors';
import { ResolverState } from '../../types';
import { StyledBreadcrumbs } from './panel_content_utilities';
import { useReplaceBreadcrumbParameters } from '../use_replace_breadcrumb_parameters';
/**
* Display an error in the panel when something goes wrong and give the user a way to "retreat" back to a default state.
@ -21,16 +24,19 @@ export const PanelContentError = memo(function ({
}: {
translatedErrorMessage: string;
}) {
const pushToQueryParams = useReplaceBreadcrumbParameters();
const nodesHref = useSelector((state: ResolverState) =>
selectors.relativeHref(state)({ panelView: 'nodes' })
);
const nodesLinkNavProps = useNavigateOrReplace({
search: nodesHref,
});
const crumbs = useMemo(() => {
return [
{
text: i18n.translate('xpack.securitySolution.endpoint.resolver.panel.error.events', {
defaultMessage: 'Events',
}),
onClick: () => {
pushToQueryParams({ crumbId: '', crumbEvent: '' });
},
...nodesLinkNavProps,
},
{
text: i18n.translate('xpack.securitySolution.endpoint.resolver.panel.error.error', {
@ -39,18 +45,14 @@ export const PanelContentError = memo(function ({
onClick: () => {},
},
];
}, [pushToQueryParams]);
}, [nodesLinkNavProps]);
return (
<>
<StyledBreadcrumbs breadcrumbs={crumbs} />
<EuiSpacer size="l" />
<EuiText textAlign="center">{translatedErrorMessage}</EuiText>
<EuiSpacer size="l" />
<EuiButtonEmpty
onClick={() => {
pushToQueryParams({ crumbId: '', crumbEvent: '' });
}}
>
<EuiButtonEmpty {...nodesLinkNavProps}>
{i18n.translate('xpack.securitySolution.endpoint.resolver.panel.error.goBack', {
defaultMessage: 'Click this link to return to the list of all processes.',
})}

View file

@ -0,0 +1,52 @@
/*
* 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 React, { useMemo } from 'react';
import { useSelector } from 'react-redux';
import { i18n } from '@kbn/i18n';
import { EuiSpacer, EuiTitle } from '@elastic/eui';
import * as selectors from '../../store/selectors';
import { StyledBreadcrumbs } from './panel_content_utilities';
import { useNavigateOrReplace } from '../use_navigate_or_replace';
import { ResolverState } from '../../types';
export function PanelLoading() {
const waitingString = i18n.translate(
'xpack.securitySolution.endpoint.resolver.panel.relatedDetail.wait',
{
defaultMessage: 'Waiting For Events...',
}
);
const eventsString = i18n.translate(
'xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.events',
{
defaultMessage: 'Events',
}
);
const nodesHref = useSelector((state: ResolverState) =>
selectors.relativeHref(state)({ panelView: 'nodes' })
);
const nodesLinkNavProps = useNavigateOrReplace({
search: nodesHref,
});
const waitCrumbs = useMemo(() => {
return [
{
text: eventsString,
...nodesLinkNavProps,
},
];
}, [nodesLinkNavProps, eventsString]);
return (
<>
<StyledBreadcrumbs breadcrumbs={waitCrumbs} />
<EuiSpacer size="l" />
<EuiTitle>
<h4>{waitingString}</h4>
</EuiTitle>
</>
);
}

View file

@ -0,0 +1,17 @@
/*
* 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 styled from 'styled-components';
import { EuiDescriptionList } from '@elastic/eui';
export const StyledDescriptionList = styled(EuiDescriptionList)`
&.euiDescriptionList.euiDescriptionList--column dt.euiDescriptionList__title.desc-title {
max-width: 10em;
}
`;
export const StyledTitle = styled('h4')`
overflow-wrap: break-word;
`;

View file

@ -4,14 +4,12 @@
* you may not use this file except in compliance with the Elastic License.
*/
/* eslint-disable react/display-name */
import React, { useCallback, useMemo } from 'react';
import styled from 'styled-components';
import { htmlIdGenerator, EuiButton, EuiI18nNumber, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { htmlIdGenerator, EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { useSelector } from 'react-redux';
import { FormattedMessage } from '@kbn/i18n/react';
import { NodeSubMenu, subMenuAssets } from './submenu';
import { NodeSubMenu } from './submenu';
import { applyMatrix3 } from '../models/vector2';
import { Vector2, Matrix3, ResolverState } from '../types';
import { SymbolIds, useResolverTheme, calculateResolverFontSize } from './assets';
@ -19,7 +17,7 @@ import { ResolverEvent, SafeResolverEvent } from '../../../common/endpoint/types
import { useResolverDispatch } from './use_resolver_dispatch';
import * as eventModel from '../../../common/endpoint/models/event';
import * as selectors from '../store/selectors';
import { useReplaceBreadcrumbParameters } from './use_replace_breadcrumb_parameters';
import { useNavigateOrReplace } from './use_navigate_or_replace';
interface StyledActionsContainer {
readonly color: string;
@ -236,6 +234,16 @@ const UnstyledProcessEventDot = React.memo(
const isOrigin = nodeID === originID;
const dispatch = useResolverDispatch();
const processDetailHref = useSelector((state: ResolverState) =>
selectors.relativeHref(state)({
panelView: 'nodeDetail',
panelParameters: { nodeID },
})
);
const processDetailNavProps = useNavigateOrReplace({
search: processDetailHref,
});
const handleFocus = useCallback(() => {
dispatch({
@ -251,57 +259,19 @@ const UnstyledProcessEventDot = React.memo(
});
}, [dispatch, nodeID]);
const pushToQueryParams = useReplaceBreadcrumbParameters();
const handleClick = useCallback(() => {
if (animationTarget.current?.beginElement) {
animationTarget.current.beginElement();
}
dispatch({
type: 'userSelectedResolverNode',
payload: nodeID,
});
pushToQueryParams({ crumbId: nodeID, crumbEvent: '' });
}, [animationTarget, dispatch, pushToQueryParams, nodeID]);
/**
* Enumerates the stats for related events to display with the node as options,
* generally in the form `number of related events in category` `category title`
* e.g. "10 DNS", "230 File"
*/
const relatedEventOptions = useMemo(() => {
const relatedStatsList = [];
if (!relatedEventStats) {
// Return an empty set of options if there are no stats to report
return [];
}
// If we have entries to show, map them into options to display in the selectable list
for (const [category, total] of Object.entries(relatedEventStats.events.byCategory)) {
relatedStatsList.push({
prefix: <EuiI18nNumber value={total || 0} />,
optionTitle: category,
action: () => {
dispatch({
type: 'userSelectedRelatedEventCategory',
payload: {
subject: event,
category,
},
});
pushToQueryParams({ crumbId: nodeID, crumbEvent: category });
},
const handleClick = useCallback(
(clickEvent) => {
if (animationTarget.current?.beginElement) {
animationTarget.current.beginElement();
}
dispatch({
type: 'userSelectedResolverNode',
payload: nodeID,
});
}
return relatedStatsList;
}, [relatedEventStats, dispatch, event, pushToQueryParams, nodeID]);
const relatedEventStatusOrOptions = !relatedEventStats
? subMenuAssets.initialMenuStatus
: relatedEventOptions;
processDetailNavProps.onClick(clickEvent);
},
[animationTarget, dispatch, nodeID, processDetailNavProps]
);
const grandTotal: number | null = useSelector((state: ResolverState) =>
selectors.relatedEventTotalForProcess(state)(event as ResolverEvent)
@ -331,9 +301,9 @@ const UnstyledProcessEventDot = React.memo(
viewBox="-15 -15 90 30"
preserveAspectRatio="xMidYMid meet"
onClick={
() => {
(clickEvent) => {
handleFocus();
handleClick();
handleClick(clickEvent);
} /* a11y note: this is strictly an alternate to the button, so no tabindex is necessary*/
}
role="img"
@ -468,9 +438,8 @@ const UnstyledProcessEventDot = React.memo(
buttonBorderColor={labelButtonFill}
buttonFill={colorMap.resolverBackground}
menuAction={handleRelatedEventRequest}
menuTitle={subMenuAssets.relatedEvents.title}
projectionMatrix={projectionMatrix}
optionsWithActions={relatedEventStatusOrOptions}
relatedEventStats={relatedEventStats}
nodeID={nodeID}
/>
)}

View file

@ -44,15 +44,19 @@ describe('Resolver, when analyzing a tree that has no ancestors and 2 children',
);
if (button) {
// Click the first button under the second child element.
button.simulate('click');
button.simulate('click', { button: 0 });
}
});
const expectedSearch = urlSearch(resolverComponentInstanceID, {
selectedEntityID: 'secondChild',
const queryStringWithOriginSelected = urlSearch(resolverComponentInstanceID, {
panelParameters: { nodeID: 'secondChild' },
panelView: 'nodeDetail',
});
it(`should have a url search of ${expectedSearch}`, async () => {
it(`should have a url search of ${queryStringWithOriginSelected}`, async () => {
await expect(simulator.map(() => simulator.historyLocationSearch)).toYieldEqualTo(
urlSearch(resolverComponentInstanceID, { selectedEntityID: 'secondChild' })
urlSearch(resolverComponentInstanceID, {
panelParameters: { nodeID: 'secondChild' },
panelView: 'nodeDetail',
})
);
});
describe('when the resolver component gets unmounted', () => {
@ -78,14 +82,18 @@ describe('Resolver, when analyzing a tree that has no ancestors and 2 children',
);
if (button) {
// Click the first button under the second child element.
button.simulate('click');
button.simulate('click', { button: 0 });
}
});
it(`should have a url search of ${urlSearch(newInstanceID, {
selectedEntityID: 'secondChild',
panelParameters: { nodeID: 'secondChild' },
panelView: 'nodeDetail',
})}`, async () => {
await expect(simulator.map(() => simulator.historyLocationSearch)).toYieldEqualTo(
urlSearch(newInstanceID, { selectedEntityID: 'secondChild' })
urlSearch(newInstanceID, {
panelParameters: { nodeID: 'secondChild' },
panelView: 'nodeDetail',
})
);
});
});

View file

@ -18,10 +18,11 @@ import { ProcessEventDot } from './process_event_dot';
import { useCamera } from './use_camera';
import { SymbolDefinitions, useResolverTheme } from './assets';
import { useStateSyncingActions } from './use_state_syncing_actions';
import { StyledMapContainer, StyledPanel, GraphContainer } from './styles';
import { StyledMapContainer, GraphContainer } from './styles';
import { entityIDSafeVersion } from '../../../common/endpoint/models/event';
import { SideEffectContext } from './side_effect_context';
import { ResolverProps, ResolverState } from '../types';
import { PanelRouter } from './panels';
/**
* The highest level connected Resolver component. Needs a `Provider` in its ancestry to work.
@ -126,7 +127,7 @@ export const ResolverWithoutProviders = React.memo(
})}
</GraphContainer>
)}
<StyledPanel />
<PanelRouter />
<GraphControls />
<SymbolDefinitions />
</StyledMapContainer>

View file

@ -3,8 +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 { EuiPanel } from '@elastic/eui';
import styled from 'styled-components';
import { Panel } from './panels';
/**
* The top level DOM element for Resolver
@ -40,7 +42,7 @@ export const StyledMapContainer = styled.div<{ backgroundColor: string }>`
/**
* The Panel, styled for use in `ResolverMap`.
*/
export const StyledPanel = styled(Panel)`
export const StyledPanel = styled(EuiPanel)`
position: absolute;
left: 0;
top: 0;

View file

@ -10,6 +10,8 @@ import { i18n } from '@kbn/i18n';
import React, { useState, useCallback, useRef, useLayoutEffect, useMemo } from 'react';
import { EuiI18nNumber, EuiButton, EuiPopover, ButtonColor } from '@elastic/eui';
import styled from 'styled-components';
import { ResolverNodeStats } from '../../../common/endpoint/types';
import { useRelatedEventByCategoryNavigation } from './use_related_event_by_category_navigation';
import { Matrix3 } from '../types';
import { useResolverTheme } from './assets';
@ -62,14 +64,12 @@ const SubButton = React.memo(
menuIsOpen,
action,
count,
title,
nodeID,
}: {
hasMenu: boolean;
menuIsOpen?: boolean;
action: (evt: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
count?: number;
title: string;
nodeID: string;
}) => {
const iconType = menuIsOpen === true ? 'arrowUp' : 'arrowDown';
@ -86,7 +86,7 @@ const SubButton = React.memo(
data-test-resolver-node-id={nodeID}
id={nodeID}
>
{count ? <EuiI18nNumber value={count} /> : ''} {title}
{count ? <EuiI18nNumber value={count} /> : ''} {subMenuAssets.relatedEvents.title}
</StyledActionButton>
);
}
@ -101,17 +101,16 @@ const NodeSubMenuComponents = React.memo(
({
count,
buttonBorderColor,
menuTitle,
menuAction,
optionsWithActions,
className,
projectionMatrix,
nodeID,
relatedEventStats,
}: {
menuTitle: string;
className?: string;
menuAction?: () => unknown;
buttonBorderColor: ButtonColor;
// eslint-disable-next-line react/no-unused-prop-types
buttonFill: string;
count?: number;
/**
@ -119,8 +118,7 @@ const NodeSubMenuComponents = React.memo(
*/
projectionMatrix: Matrix3;
nodeID: string;
} & {
optionsWithActions?: ResolverSubmenuOptionList | string | undefined;
relatedEventStats: ResolverNodeStats | undefined;
}) => {
// keep a ref to the popover so we can call its reposition method
const popoverRef = useRef<EuiPopover>(null);
@ -148,6 +146,23 @@ const NodeSubMenuComponents = React.memo(
// The last projection matrix that was used to position the popover
const projectionMatrixAtLastRender = useRef<Matrix3>();
const relatedEventCallbacks = useRelatedEventByCategoryNavigation({
nodeID,
categories: relatedEventStats?.events?.byCategory,
});
const relatedEventOptions = useMemo(() => {
if (relatedEventStats === undefined) {
return [];
} else {
return Object.entries(relatedEventStats.events.byCategory).map(([category, total]) => {
return {
prefix: <EuiI18nNumber value={total || 0} />,
optionTitle: category,
action: () => relatedEventCallbacks(category),
};
});
}
}, [relatedEventStats, relatedEventCallbacks]);
useLayoutEffect(() => {
if (
@ -167,7 +182,6 @@ const NodeSubMenuComponents = React.memo(
// no matter what, keep track of the last project matrix that was used to size the popover
projectionMatrixAtLastRender.current = projectionMatrix;
}, [projectionMatrixAtLastRender, projectionMatrix]);
const {
colorMap: { pillStroke: pillBorderStroke, resolverBackground: pillFill },
} = useResolverTheme();
@ -177,8 +191,7 @@ const NodeSubMenuComponents = React.memo(
backgroundColor: pillFill,
};
}, [pillBorderStroke, pillFill]);
if (!optionsWithActions) {
if (relatedEventStats === undefined) {
/**
* When called with a `menuAction`
* Render without dropdown and call the supplied action when host button is clicked
@ -191,14 +204,14 @@ const NodeSubMenuComponents = React.memo(
size="s"
tabIndex={-1}
>
{menuTitle}
{subMenuAssets.relatedEvents.title}
</EuiButton>
</div>
);
}
if (typeof optionsWithActions === 'string') {
return <></>;
if (relatedEventOptions === undefined) {
return null;
}
return (
@ -208,7 +221,6 @@ const NodeSubMenuComponents = React.memo(
menuIsOpen={menuIsOpen}
action={handleMenuOpenClick}
count={count}
title={menuTitle}
nodeID={nodeID}
/>
{menuIsOpen ? (
@ -217,7 +229,7 @@ const NodeSubMenuComponents = React.memo(
aria-hidden={!menuIsOpen}
aria-describedby={nodeID}
>
{optionsWithActions
{relatedEventOptions
.sort((opta, optb) => {
return opta.optionTitle.localeCompare(optb.optionTitle);
})

View file

@ -0,0 +1,59 @@
/*
* 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 { MouseEventHandler, useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import { LocationDescriptorObject } from 'history';
type EventHandlerCallback = MouseEventHandler<HTMLButtonElement | HTMLAnchorElement>;
export function useNavigateOrReplace(
to: LocationDescriptorObject<unknown>,
/** Additional onClick callback */
additionalOnClick?: EventHandlerCallback
): { href: string; onClick: EventHandlerCallback } {
const history = useHistory();
const onClick = useCallback(
(event) => {
try {
if (additionalOnClick) {
additionalOnClick(event);
}
} catch (error) {
event.preventDefault();
throw error;
}
if (event.defaultPrevented) {
return;
}
if (event.button !== 0) {
return;
}
if (
event.currentTarget instanceof HTMLAnchorElement &&
event.currentTarget.target !== '' &&
event.currentTarget.target !== '_self'
) {
return;
}
if (event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) {
return;
}
event.preventDefault();
history.push(to);
},
[history, additionalOnClick, to]
);
return {
href: history.createHref(to),
onClick,
};
}

View file

@ -1,21 +0,0 @@
/*
* 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 { useSelector } from 'react-redux';
import * as selectors from '../store/selectors';
/**
* Get the query string keys used by this Resolver instance.
*/
export function useQueryStringKeys(): { idKey: string; eventKey: string } {
const resolverComponentInstanceID = useSelector(selectors.resolverComponentInstanceID);
const idKey: string = `resolver-${resolverComponentInstanceID}-id`;
const eventKey: string = `resolver-${resolverComponentInstanceID}-event`;
return {
idKey,
eventKey,
};
}

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { useCallback } from 'react';
import { useSelector } from 'react-redux';
import { useHistory } from 'react-router-dom';
import { ResolverState } from '../types';
import * as selectors from '../store/selectors';
/**
* A hook that takes a nodeID and a record of categories, and returns a function that
* navigates to the proper url when called with a category.
* @deprecated
*/
export function useRelatedEventByCategoryNavigation({
nodeID,
categories,
}: {
nodeID: string;
categories: Record<string, number> | undefined;
}) {
const relatedEventUrls = useSelector((state: ResolverState) =>
selectors.relatedEventsRelativeHrefs(state)(categories, nodeID)
);
const history = useHistory();
return useCallback(
(category: string) => {
const urlForCategory = relatedEventUrls.get(category);
if (urlForCategory !== null && urlForCategory !== undefined) {
return history.replace({ search: urlForCategory });
}
},
[history, relatedEventUrls]
);
}

View file

@ -0,0 +1,40 @@
/*
* 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 { useCallback } from 'react';
import { useSelector } from 'react-redux';
import { useHistory } from 'react-router-dom';
import { ResolverState } from '../types';
import { ResolverEvent } from '../../../common/endpoint/types';
import * as selectors from '../store/selectors';
/**
* @deprecated
*/
export function useRelatedEventDetailNavigation({
nodeID,
category,
events,
}: {
nodeID: string;
category: string;
events: ResolverEvent[];
}) {
const relatedEventDetailUrls = useSelector((state: ResolverState) =>
selectors.relatedEventDetailHrefs(state)(category, nodeID, events)
);
const history = useHistory();
return useCallback(
(entityID: string | number | undefined) => {
if (entityID !== undefined) {
const urlForEntityID = relatedEventDetailUrls.get(String(entityID));
if (urlForEntityID !== null && urlForEntityID !== undefined) {
return history.replace({ search: urlForEntityID });
}
}
},
[history, relatedEventDetailUrls]
);
}

View file

@ -1,47 +0,0 @@
/*
* 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 { useCallback } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import { useQueryStringKeys } from './use_query_string_keys';
import { CrumbInfo } from '../types';
/**
* @deprecated
* Update the browser's `search` with data from `queryStringState`. The URL search parameter names
* will include Resolver's `resolverComponentInstanceID`.
*/
export function useReplaceBreadcrumbParameters(): (queryStringState: CrumbInfo) => void {
/**
* This updates the breadcrumb nav and the panel view. It's supplied to each
* panel content view to allow them to dispatch transitions to each other.
*/
const history = useHistory();
const urlSearch = useLocation().search;
const { idKey, eventKey } = useQueryStringKeys();
return useCallback(
(queryStringState: CrumbInfo) => {
const urlSearchParams = new URLSearchParams(urlSearch);
urlSearchParams.set(idKey, queryStringState.crumbId);
urlSearchParams.set(eventKey, queryStringState.crumbEvent);
// If either was passed in as empty, remove it
if (queryStringState.crumbId === '') {
urlSearchParams.delete(idKey);
}
if (queryStringState.crumbEvent === '') {
urlSearchParams.delete(eventKey);
}
const relativeURL = { search: urlSearchParams.toString() };
// We probably don't want to nuke the user's history with a huge
// trail of these, thus `.replace` instead of `.push`
return history.replace(relativeURL);
},
[history, urlSearch, idKey, eventKey]
);
}

View file

@ -6,8 +6,9 @@
import { useRef, useEffect } from 'react';
import { useLocation, useHistory } from 'react-router-dom';
import { useQueryStringKeys } from './use_query_string_keys';
import { useSelector } from 'react-redux';
import * as selectors from '../store/selectors';
import { parameterName } from '../store/ui/selectors';
/**
* Cleanup any query string keys that were added by this Resolver instance.
* This works by having a React effect that just has behavior in the 'cleanup' function.
@ -23,15 +24,15 @@ export function useResolverQueryParamCleaner() {
searchRef.current = useLocation().search;
const history = useHistory();
const resolverComponentInstanceID = useSelector(selectors.resolverComponentInstanceID);
const { idKey, eventKey } = useQueryStringKeys();
const resolverKey = parameterName(resolverComponentInstanceID);
useEffect(() => {
/**
* Keep track of the old query string keys so we can remove them.
*/
const oldIdKey = idKey;
const oldEventKey = eventKey;
const oldResolverKey = resolverKey;
/**
* When `idKey` or `eventKey` changes (such as when the `resolverComponentInstanceID` has changed) or when the component unmounts, remove any state from the query string.
*/
@ -44,10 +45,9 @@ export function useResolverQueryParamCleaner() {
/**
* Remove old keys from the url
*/
urlSearchParams.delete(oldIdKey);
urlSearchParams.delete(oldEventKey);
urlSearchParams.delete(oldResolverKey);
const relativeURL = { search: urlSearchParams.toString() };
history.replace(relativeURL);
};
}, [idKey, eventKey, history]);
}, [resolverKey, history]);
}

View file

@ -15913,7 +15913,6 @@
"xpack.securitySolution.endpoint.resolver.panel.processEventCounts.events": "イベント",
"xpack.securitySolution.endpoint.resolver.panel.processEventListByType.eventDescriptiveName": "{descriptor} {subject}",
"xpack.securitySolution.endpoint.resolver.panel.processEventListByType.events": "イベント",
"xpack.securitySolution.endpoint.resolver.panel.processEventListByType.wait": "イベントを待機しています...",
"xpack.securitySolution.endpoint.resolver.panel.relatedCounts.numberOfEventsInCrumb": "{totalCount}件のイベント",
"xpack.securitySolution.endpoint.resolver.panel.relatedDetail.missing": "関連イベントが見つかりません。",
"xpack.securitySolution.endpoint.resolver.panel.relatedDetail.wait": "イベントを待機しています...",

View file

@ -15923,7 +15923,6 @@
"xpack.securitySolution.endpoint.resolver.panel.processEventCounts.events": "事件",
"xpack.securitySolution.endpoint.resolver.panel.processEventListByType.eventDescriptiveName": "{descriptor} {subject}",
"xpack.securitySolution.endpoint.resolver.panel.processEventListByType.events": "事件",
"xpack.securitySolution.endpoint.resolver.panel.processEventListByType.wait": "等候事件......",
"xpack.securitySolution.endpoint.resolver.panel.relatedCounts.numberOfEventsInCrumb": "{totalCount} 个事件",
"xpack.securitySolution.endpoint.resolver.panel.relatedDetail.missing": "找不到相关事件。",
"xpack.securitySolution.endpoint.resolver.panel.relatedDetail.wait": "等候事件......",