mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
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:
parent
6a1b52eda8
commit
91d0a3b665
34 changed files with 1427 additions and 861 deletions
|
@ -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(),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 };
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
||||
/**
|
||||
|
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
});
|
|
@ -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';
|
||||
|
|
|
@ -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 }}
|
|
@ -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 (
|
||||
<>
|
|
@ -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';
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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.',
|
||||
})}
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
`;
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -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',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
})
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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]
|
||||
);
|
||||
}
|
|
@ -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]
|
||||
);
|
||||
}
|
|
@ -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]
|
||||
);
|
||||
}
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
@ -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": "イベントを待機しています...",
|
||||
|
|
|
@ -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": "等候事件......",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue