Merge/restyle nodes table (#69098)
Adds panel views and drilldowns to Resolver
This commit is contained in:
parent
bdb65920f0
commit
700f53d3a3
|
@ -26,6 +26,9 @@ interface EventOptions {
|
|||
eventType?: string;
|
||||
eventCategory?: string | string[];
|
||||
processName?: string;
|
||||
pid?: number;
|
||||
parentPid?: number;
|
||||
extensions?: object;
|
||||
}
|
||||
|
||||
const Windows: OSFields[] = [
|
||||
|
@ -471,12 +474,36 @@ export class EndpointDocGenerator {
|
|||
* @param options - Allows event field values to be specified
|
||||
*/
|
||||
public generateEvent(options: EventOptions = {}): EndpointEvent {
|
||||
const processName = options.processName ? options.processName : randomProcessName();
|
||||
const detailRecordForEventType =
|
||||
options.extensions ||
|
||||
((eventCategory) => {
|
||||
if (eventCategory === 'registry') {
|
||||
return { registry: { key: `HKLM/Windows/Software/${this.randomString(5)}` } };
|
||||
}
|
||||
if (eventCategory === 'network') {
|
||||
return {
|
||||
network: {
|
||||
direction: this.randomChoice(['inbound', 'outbound']),
|
||||
forwarded_ip: `${this.randomIP()}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (eventCategory === 'file') {
|
||||
return { file: { path: 'C:\\My Documents\\business\\January\\processName' } };
|
||||
}
|
||||
if (eventCategory === 'dns') {
|
||||
return { dns: { question: { name: `${this.randomIP()}` } } };
|
||||
}
|
||||
return {};
|
||||
})(options.eventCategory);
|
||||
return {
|
||||
'@timestamp': options.timestamp ? options.timestamp : new Date().getTime(),
|
||||
agent: { ...this.commonInfo.agent, type: 'endpoint' },
|
||||
ecs: {
|
||||
version: '1.4.0',
|
||||
},
|
||||
...detailRecordForEventType,
|
||||
event: {
|
||||
category: options.eventCategory ? options.eventCategory : 'process',
|
||||
kind: 'event',
|
||||
|
@ -485,9 +512,30 @@ export class EndpointDocGenerator {
|
|||
},
|
||||
host: this.commonInfo.host,
|
||||
process: {
|
||||
pid:
|
||||
'pid' in options && typeof options.pid !== 'undefined' ? options.pid : this.randomN(5000),
|
||||
executable: `C:\\${processName}`,
|
||||
args: `"C:\\${processName}" \\${this.randomString(3)}`,
|
||||
code_signature: {
|
||||
status: 'trusted',
|
||||
subject_name: 'Microsoft',
|
||||
},
|
||||
hash: { md5: this.seededUUIDv4() },
|
||||
entity_id: options.entityID ? options.entityID : this.randomString(10),
|
||||
parent: options.parentEntityID ? { entity_id: options.parentEntityID } : undefined,
|
||||
name: options.processName ? options.processName : randomProcessName(),
|
||||
parent: options.parentEntityID
|
||||
? {
|
||||
entity_id: options.parentEntityID,
|
||||
pid:
|
||||
'parentPid' in options && typeof options.parentPid !== 'undefined'
|
||||
? options.parentPid
|
||||
: this.randomN(5000),
|
||||
}
|
||||
: undefined,
|
||||
name: processName,
|
||||
},
|
||||
user: {
|
||||
domain: this.randomString(10),
|
||||
name: this.randomString(10),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -692,6 +740,8 @@ export class EndpointDocGenerator {
|
|||
ancestor = this.generateEvent({
|
||||
timestamp,
|
||||
parentEntityID: ancestor.process.entity_id,
|
||||
parentPid: ancestor.process.pid,
|
||||
pid: this.randomN(5000),
|
||||
});
|
||||
events.push(ancestor);
|
||||
timestamp = timestamp + 1000;
|
||||
|
@ -1117,7 +1167,7 @@ export class EndpointDocGenerator {
|
|||
return [...this.randomNGenerator(255, 6)].map((x) => x.toString(16)).join('-');
|
||||
}
|
||||
|
||||
private randomIP(): string {
|
||||
public randomIP(): string {
|
||||
return [10, ...this.randomNGenerator(255, 3)].map((x) => x.toString()).join('.');
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* 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 { EndpointDocGenerator } from '../generate_data';
|
||||
import { descriptiveName } from './event';
|
||||
|
||||
describe('Event descriptive names', () => {
|
||||
let generator: EndpointDocGenerator;
|
||||
beforeEach(() => {
|
||||
generator = new EndpointDocGenerator('seed');
|
||||
});
|
||||
|
||||
it('returns the right name for a registry event', () => {
|
||||
const extensions = { registry: { key: `HKLM/Windows/Software/abc` } };
|
||||
const event = generator.generateEvent({ eventCategory: 'registry', extensions });
|
||||
expect(descriptiveName(event)).toEqual({ subject: `HKLM/Windows/Software/abc` });
|
||||
});
|
||||
|
||||
it('returns the right name for a network event', () => {
|
||||
const randomIP = `${generator.randomIP()}`;
|
||||
const extensions = { network: { direction: 'outbound', forwarded_ip: randomIP } };
|
||||
const event = generator.generateEvent({ eventCategory: 'network', extensions });
|
||||
expect(descriptiveName(event)).toEqual({ subject: `${randomIP}`, descriptor: 'outbound' });
|
||||
});
|
||||
|
||||
it('returns the right name for a file event', () => {
|
||||
const extensions = { file: { path: 'C:\\My Documents\\business\\January\\processName' } };
|
||||
const event = generator.generateEvent({ eventCategory: 'file', extensions });
|
||||
expect(descriptiveName(event)).toEqual({
|
||||
subject: 'C:\\My Documents\\business\\January\\processName',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the right name for a dns event', () => {
|
||||
const extensions = { dns: { question: { name: `${generator.randomIP()}` } } };
|
||||
const event = generator.generateEvent({ eventCategory: 'dns', extensions });
|
||||
expect(descriptiveName(event)).toEqual({ subject: extensions.dns.question.name });
|
||||
});
|
||||
});
|
|
@ -52,3 +52,95 @@ export function parentEntityId(event: ResolverEvent): string | undefined {
|
|||
}
|
||||
return event.process.parent?.entity_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param event The event to get the category for
|
||||
*/
|
||||
export function primaryEventCategory(event: ResolverEvent): string | undefined {
|
||||
// Returning "Process" as a catch-all here because it seems pretty general
|
||||
if (isLegacyEvent(event)) {
|
||||
const legacyFullType = event.endgame.event_type_full;
|
||||
if (legacyFullType) {
|
||||
return legacyFullType;
|
||||
}
|
||||
} else {
|
||||
const eventCategories = event.event.category;
|
||||
const category = typeof eventCategories === 'string' ? eventCategories : eventCategories[0];
|
||||
|
||||
return category;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ECS event type will be things like 'creation', 'deletion', 'access', etc.
|
||||
* see: https://www.elastic.co/guide/en/ecs/current/ecs-event.html
|
||||
* @param event The ResolverEvent to get the ecs type for
|
||||
*/
|
||||
export function ecsEventType(event: ResolverEvent): Array<string | undefined> {
|
||||
if (isLegacyEvent(event)) {
|
||||
return [event.endgame.event_subtype_full];
|
||||
}
|
||||
return typeof event.event.type === 'string' ? [event.event.type] : event.event.type;
|
||||
}
|
||||
|
||||
/**
|
||||
* #Descriptive Names For Related Events:
|
||||
*
|
||||
* The following section provides facilities for deriving **Descriptive Names** for ECS-compliant event data.
|
||||
* There are drawbacks to trying to do this: It *will* require ongoing maintenance. It presents temptations to overarticulate.
|
||||
* On balance, however, it seems that the benefit of giving the user some form of information they can recognize & scan outweighs the drawbacks.
|
||||
*/
|
||||
type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T;
|
||||
/**
|
||||
* Based on the ECS category of the event, attempt to provide a more descriptive name
|
||||
* (e.g. the `event.registry.key` for `registry` or the `dns.question.name` for `dns`, etc.).
|
||||
* This function returns the data in the form of `{subject, descriptor}` where `subject` will
|
||||
* tend to be the more distinctive term (e.g. 137.213.212.7 for a network event) and the
|
||||
* `descriptor` can be used to present more useful/meaningful view (e.g. `inbound 137.213.212.7`
|
||||
* in the example above).
|
||||
* see: https://www.elastic.co/guide/en/ecs/current/ecs-field-reference.html
|
||||
* @param event The ResolverEvent to get the descriptive name for
|
||||
* @returns { descriptiveName } An attempt at providing a readable name to the user
|
||||
*/
|
||||
export function descriptiveName(event: ResolverEvent): { subject: string; descriptor?: string } {
|
||||
if (isLegacyEvent(event)) {
|
||||
return { subject: eventName(event) };
|
||||
}
|
||||
|
||||
// To be somewhat defensive, we'll check for the presence of these.
|
||||
const partialEvent: DeepPartial<ResolverEvent> = event;
|
||||
|
||||
/**
|
||||
* This list of attempts can be expanded/adjusted as the underlying model changes over time:
|
||||
*/
|
||||
|
||||
// Stable, per ECS 1.5: https://www.elastic.co/guide/en/ecs/current/ecs-allowed-values-event-category.html
|
||||
|
||||
if (partialEvent.network?.forwarded_ip) {
|
||||
return {
|
||||
subject: String(partialEvent.network?.forwarded_ip),
|
||||
descriptor: String(partialEvent.network?.direction),
|
||||
};
|
||||
}
|
||||
|
||||
if (partialEvent.file?.path) {
|
||||
return {
|
||||
subject: String(partialEvent.file?.path),
|
||||
};
|
||||
}
|
||||
|
||||
// Extended categories (per ECS 1.5):
|
||||
const pathOrKey = partialEvent.registry?.path || partialEvent.registry?.key;
|
||||
if (pathOrKey) {
|
||||
return {
|
||||
subject: String(pathOrKey),
|
||||
};
|
||||
}
|
||||
|
||||
if (partialEvent.dns?.question?.name) {
|
||||
return { subject: String(partialEvent.dns?.question?.name) };
|
||||
}
|
||||
|
||||
// Fall back on entityId if we can't fish a more descriptive name out.
|
||||
return { subject: entityId(event) };
|
||||
}
|
||||
|
|
|
@ -444,14 +444,38 @@ export interface EndpointEvent {
|
|||
kind: string;
|
||||
};
|
||||
host: Host;
|
||||
network?: {
|
||||
direction: unknown;
|
||||
forwarded_ip: unknown;
|
||||
};
|
||||
dns?: {
|
||||
question: { name: unknown };
|
||||
};
|
||||
process: {
|
||||
entity_id: string;
|
||||
name: string;
|
||||
executable?: string;
|
||||
args?: string;
|
||||
code_signature?: {
|
||||
status?: string;
|
||||
subject_name: string;
|
||||
};
|
||||
pid?: number;
|
||||
hash?: {
|
||||
md5: string;
|
||||
};
|
||||
parent?: {
|
||||
entity_id: string;
|
||||
name?: string;
|
||||
pid?: number;
|
||||
};
|
||||
};
|
||||
user?: {
|
||||
domain?: string;
|
||||
name: string;
|
||||
};
|
||||
file?: { path: unknown };
|
||||
registry?: { path: unknown; key: unknown };
|
||||
}
|
||||
|
||||
export type ResolverEvent = EndpointEvent | LegacyEndpointEvent;
|
||||
|
|
|
@ -13,11 +13,12 @@ import {
|
|||
} from '../../../common/endpoint_alerts/types';
|
||||
import { ImmutableMiddlewareFactory } from '../../common/store';
|
||||
import { cloneHttpFetchQuery } from '../../common/utils/clone_http_fetch_query';
|
||||
|
||||
import {
|
||||
isOnAlertPage,
|
||||
apiQueryParams,
|
||||
hasSelectedAlert,
|
||||
uiQueryParams,
|
||||
hasSelectedAlert,
|
||||
isAlertPageTabChange,
|
||||
} from './selectors';
|
||||
|
||||
|
@ -25,6 +26,26 @@ export const alertMiddlewareFactory: ImmutableMiddlewareFactory<AlertListState>
|
|||
coreStart,
|
||||
depsStart
|
||||
) => {
|
||||
let lastSelectedAlert: string | null = null;
|
||||
/**
|
||||
* @returns <boolean> true once per change of `selectedAlert` in query params.
|
||||
*
|
||||
* As opposed to `hasSelectedAlert` which always returns true if the alert is present
|
||||
* query params, which can cause unnecessary requests and re-renders in some cases.
|
||||
*/
|
||||
const selectedAlertHasChanged = (params: ReturnType<typeof uiQueryParams>): boolean => {
|
||||
const { selected_alert: selectedAlert } = params;
|
||||
const shouldNotChange = selectedAlert === lastSelectedAlert;
|
||||
if (shouldNotChange) {
|
||||
return false;
|
||||
}
|
||||
if (typeof selectedAlert !== 'string') {
|
||||
return false;
|
||||
}
|
||||
lastSelectedAlert = selectedAlert;
|
||||
return true;
|
||||
};
|
||||
|
||||
async function fetchIndexPatterns(): Promise<IIndexPattern[]> {
|
||||
const { indexPatterns } = depsStart.data;
|
||||
const fields = await indexPatterns.getFieldsForWildcard({
|
||||
|
@ -50,7 +71,7 @@ export const alertMiddlewareFactory: ImmutableMiddlewareFactory<AlertListState>
|
|||
});
|
||||
api.dispatch({ type: 'serverReturnedAlertsData', payload: listResponse });
|
||||
|
||||
if (hasSelectedAlert(state)) {
|
||||
if (hasSelectedAlert(state) && selectedAlertHasChanged(uiQueryParams(state))) {
|
||||
const uiParams = uiQueryParams(state);
|
||||
const detailsResponse: AlertDetails = await coreStart.http.get(
|
||||
`/api/endpoint/alerts/${uiParams.selected_alert}`
|
||||
|
|
|
@ -55,9 +55,8 @@ export function factory(processes: ResolverEvent[]): IndexedProcessTree {
|
|||
currentProcessAdjacencyMap.parent = uniqueParentPid;
|
||||
}
|
||||
} else {
|
||||
idToChildren.set(uniqueParentPid, [process]);
|
||||
|
||||
if (uniqueParentPid) {
|
||||
idToChildren.set(uniqueParentPid, [process]);
|
||||
/**
|
||||
* Get the parent's map, otherwise set an empty one
|
||||
*/
|
||||
|
|
|
@ -78,6 +78,17 @@ export function uniquePidForProcess(passedEvent: ResolverEvent): string {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the pid for the process on the host
|
||||
*/
|
||||
export function processPid(passedEvent: ResolverEvent): number | undefined {
|
||||
if (event.isLegacyEvent(passedEvent)) {
|
||||
return passedEvent.endgame.pid;
|
||||
} else {
|
||||
return passedEvent.process.pid;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the process event's parent pid
|
||||
*/
|
||||
|
@ -88,3 +99,61 @@ export function uniqueParentPidForProcess(passedEvent: ResolverEvent): string |
|
|||
return passedEvent.process.parent?.entity_id;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the process event's parent pid
|
||||
*/
|
||||
export function processParentPid(passedEvent: ResolverEvent): number | undefined {
|
||||
if (event.isLegacyEvent(passedEvent)) {
|
||||
return passedEvent.endgame.ppid;
|
||||
} else {
|
||||
return passedEvent.process.parent?.pid;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the process event's path on its host
|
||||
*/
|
||||
export function processPath(passedEvent: ResolverEvent): string | undefined {
|
||||
if (event.isLegacyEvent(passedEvent)) {
|
||||
return passedEvent.endgame.process_path;
|
||||
} else {
|
||||
return passedEvent.process.executable;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the username for the account that ran the process
|
||||
*/
|
||||
export function userInfoForProcess(
|
||||
passedEvent: ResolverEvent
|
||||
): { user?: string; domain?: string } | undefined {
|
||||
return passedEvent.user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the MD5 hash for the `passedEvent` param, or undefined if it can't be located
|
||||
* @param {ResolverEvent} passedEvent The `ResolverEvent` to get the MD5 value for
|
||||
* @returns {string | undefined} The MD5 string for the event
|
||||
*/
|
||||
export function md5HashForProcess(passedEvent: ResolverEvent): string | undefined {
|
||||
if (event.isLegacyEvent(passedEvent)) {
|
||||
// There is not currently a key for this on Legacy event types
|
||||
return undefined;
|
||||
}
|
||||
return passedEvent?.process?.hash?.md5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the command line path and arguments used to run the `passedEvent` if any
|
||||
*
|
||||
* @param {ResolverEvent} passedEvent The `ResolverEvent` to get the arguemnts value for
|
||||
* @returns {string | undefined} The arguments (including the path) used to run the process
|
||||
*/
|
||||
export function argsForProcess(passedEvent: ResolverEvent): string | undefined {
|
||||
if (event.isLegacyEvent(passedEvent)) {
|
||||
// There is not currently a key for this on Legacy event types
|
||||
return undefined;
|
||||
}
|
||||
return passedEvent?.process?.args;
|
||||
}
|
||||
|
|
|
@ -24,6 +24,35 @@ interface UserBroughtProcessIntoView {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatched to notify state that a different panel needs to be displayed
|
||||
*/
|
||||
interface AppDisplayedDifferentPanel {
|
||||
readonly type: 'appDisplayedDifferentPanel';
|
||||
/**
|
||||
* The name of the panel to display
|
||||
*/
|
||||
readonly payload: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* When an examination of query params in the UI indicates that state needs to
|
||||
* be updated to reflect the new selection
|
||||
*/
|
||||
interface AppDetectedNewIdFromQueryParams {
|
||||
readonly type: 'appDetectedNewIdFromQueryParams';
|
||||
readonly payload: {
|
||||
/**
|
||||
* Used to identify the process the process that should be synced with state.
|
||||
*/
|
||||
readonly process: ResolverEvent;
|
||||
/**
|
||||
* The time (since epoch in milliseconds) when the action was dispatched.
|
||||
*/
|
||||
readonly time: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Used when the alert list selects an alert and the flyout shows resolver.
|
||||
*/
|
||||
|
@ -45,12 +74,21 @@ interface AppRequestedResolverData {
|
|||
}
|
||||
|
||||
/**
|
||||
* The action dispatched when the app requests related event data for one or more
|
||||
* subjects (whose ids should be included as an array @ `payload`)
|
||||
* The action dispatched when the app requests related event data for one
|
||||
* subject (whose entity_id should be included as `payload`)
|
||||
*/
|
||||
interface UserRequestedRelatedEventData {
|
||||
readonly type: 'userRequestedRelatedEventData';
|
||||
readonly payload: ResolverEvent;
|
||||
readonly payload: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The action dispatched when the app requests related event data for one
|
||||
* subject (whose entity_id should be included as `payload`)
|
||||
*/
|
||||
interface AppDetectedMissingEventData {
|
||||
readonly type: 'appDetectedMissingEventData';
|
||||
readonly payload: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -80,9 +118,13 @@ interface UserSelectedResolverNode {
|
|||
readonly type: 'userSelectedResolverNode';
|
||||
readonly payload: {
|
||||
/**
|
||||
* Used to identify the process node that the user selected
|
||||
* The HTML ID used to identify the process node's element that the user selected
|
||||
*/
|
||||
readonly nodeId: string;
|
||||
/**
|
||||
* The process entity_id for the process the node represents
|
||||
*/
|
||||
readonly selectedProcessId: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -118,4 +160,7 @@ export type ResolverAction =
|
|||
| UserSelectedResolverNode
|
||||
| UserRequestedRelatedEventData
|
||||
| UserSelectedRelatedEventCategory
|
||||
| UserSelectedRelatedAlerts;
|
||||
| UserSelectedRelatedAlerts
|
||||
| AppDetectedNewIdFromQueryParams
|
||||
| AppDisplayedDifferentPanel
|
||||
| AppDetectedMissingEventData;
|
||||
|
|
|
@ -4,7 +4,11 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { ResolverEvent, ResolverNodeStats } from '../../../../common/endpoint/types';
|
||||
import {
|
||||
ResolverEvent,
|
||||
ResolverNodeStats,
|
||||
ResolverRelatedEvents,
|
||||
} from '../../../../common/endpoint/types';
|
||||
|
||||
interface ServerReturnedResolverData {
|
||||
readonly type: 'serverReturnedResolverData';
|
||||
|
@ -21,10 +25,19 @@ interface ServerFailedToReturnResolverData {
|
|||
*/
|
||||
interface ServerFailedToReturnRelatedEventData {
|
||||
readonly type: 'serverFailedToReturnRelatedEventData';
|
||||
readonly payload: ResolverEvent;
|
||||
readonly payload: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* When related events are returned from the server
|
||||
*/
|
||||
interface ServerReturnedRelatedEventData {
|
||||
readonly type: 'serverReturnedRelatedEventData';
|
||||
readonly payload: ResolverRelatedEvents;
|
||||
}
|
||||
|
||||
export type DataAction =
|
||||
| ServerReturnedResolverData
|
||||
| ServerFailedToReturnResolverData
|
||||
| ServerFailedToReturnRelatedEventData;
|
||||
| ServerFailedToReturnRelatedEventData
|
||||
| ServerReturnedRelatedEventData;
|
||||
|
|
|
@ -11,6 +11,8 @@ function initialState(): DataState {
|
|||
return {
|
||||
results: [],
|
||||
relatedEventsStats: new Map(),
|
||||
relatedEvents: new Map(),
|
||||
relatedEventsReady: new Map(),
|
||||
isLoading: false,
|
||||
hasError: false,
|
||||
};
|
||||
|
@ -36,6 +38,20 @@ export const dataReducer: Reducer<DataState, ResolverAction> = (state = initialS
|
|||
...state,
|
||||
hasError: true,
|
||||
};
|
||||
} else if (
|
||||
action.type === 'userRequestedRelatedEventData' ||
|
||||
action.type === 'appDetectedMissingEventData'
|
||||
) {
|
||||
return {
|
||||
...state,
|
||||
relatedEventsReady: new Map([...state.relatedEventsReady, [action.payload, false]]),
|
||||
};
|
||||
} else if (action.type === 'serverReturnedRelatedEventData') {
|
||||
return {
|
||||
...state,
|
||||
relatedEventsReady: new Map([...state.relatedEventsReady, [action.payload.entityID, true]]),
|
||||
relatedEvents: new Map([...state.relatedEvents, [action.payload.entityID, action.payload]]),
|
||||
};
|
||||
} else {
|
||||
return state;
|
||||
}
|
||||
|
|
|
@ -435,6 +435,21 @@ export function relatedEventsStats(data: DataState) {
|
|||
return data.relatedEventsStats;
|
||||
}
|
||||
|
||||
/**
|
||||
* returns {Map<string, ResolverRelatedEvents>} a map of entity_ids to related event data.
|
||||
*/
|
||||
export function relatedEventsByEntityId(data: DataState) {
|
||||
return data.relatedEvents;
|
||||
}
|
||||
|
||||
/**
|
||||
* returns {Map<string, boolean>} 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`
|
||||
*/
|
||||
export function relatedEventsReady(data: DataState) {
|
||||
return data.relatedEventsReady;
|
||||
}
|
||||
|
||||
export const processAdjacencies = createSelector(
|
||||
indexedProcessTree,
|
||||
graphableProcesses,
|
||||
|
|
|
@ -14,6 +14,7 @@ import {
|
|||
ResolverAncestry,
|
||||
LifecycleNode,
|
||||
ResolverNodeStats,
|
||||
ResolverRelatedEvents,
|
||||
} from '../../../common/endpoint/types';
|
||||
import * as event from '../../../common/endpoint/models/event';
|
||||
|
||||
|
@ -92,6 +93,31 @@ export const resolverMiddlewareFactory: MiddlewareFactory = (context) => {
|
|||
});
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
(action.type === 'userRequestedRelatedEventData' ||
|
||||
action.type === 'appDetectedMissingEventData') &&
|
||||
context
|
||||
) {
|
||||
const entityIdToFetchFor = action.payload;
|
||||
let result: ResolverRelatedEvents;
|
||||
try {
|
||||
result = await context.services.http.get(
|
||||
`/api/endpoint/resolver/${entityIdToFetchFor}/events`,
|
||||
{
|
||||
query: { events: 100 },
|
||||
}
|
||||
);
|
||||
|
||||
api.dispatch({
|
||||
type: 'serverReturnedRelatedEventData',
|
||||
payload: result,
|
||||
});
|
||||
} catch (e) {
|
||||
api.dispatch({
|
||||
type: 'serverFailedToReturnRelatedEventData',
|
||||
payload: action.payload,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
|
@ -19,7 +19,12 @@ import { uniquePidForProcess } from '../models/process_event';
|
|||
const resolverNodeIdGenerator = htmlIdGenerator('resolverNode');
|
||||
|
||||
const uiReducer: Reducer<ResolverUIState, ResolverAction> = (
|
||||
uiState = { activeDescendantId: null, selectedDescendantId: null },
|
||||
uiState = {
|
||||
activeDescendantId: null,
|
||||
selectedDescendantId: null,
|
||||
processEntityIdOfSelectedDescendant: null,
|
||||
panelToDisplay: null,
|
||||
},
|
||||
action
|
||||
) => {
|
||||
if (action.type === 'userFocusedOnResolverNode') {
|
||||
|
@ -31,17 +36,29 @@ const uiReducer: Reducer<ResolverUIState, ResolverAction> = (
|
|||
return {
|
||||
...uiState,
|
||||
selectedDescendantId: action.payload.nodeId,
|
||||
processEntityIdOfSelectedDescendant: action.payload.selectedProcessId,
|
||||
};
|
||||
} else if (action.type === 'userBroughtProcessIntoView') {
|
||||
} else if (action.type === 'appDisplayedDifferentPanel') {
|
||||
return {
|
||||
...uiState,
|
||||
panelToDisplay: action.payload,
|
||||
};
|
||||
} else if (
|
||||
action.type === 'userBroughtProcessIntoView' ||
|
||||
action.type === 'appDetectedNewIdFromQueryParams'
|
||||
) {
|
||||
/**
|
||||
* This action has a process payload (instead of a processId), so we use
|
||||
* `uniquePidForProcess` and `resolverNodeIdGenerator` to resolve the determinant
|
||||
* html id of the node being brought into view.
|
||||
*/
|
||||
const processNodeId = resolverNodeIdGenerator(uniquePidForProcess(action.payload.process));
|
||||
const processEntityId = uniquePidForProcess(action.payload.process);
|
||||
const processNodeId = resolverNodeIdGenerator(processEntityId);
|
||||
return {
|
||||
...uiState,
|
||||
activeDescendantId: processNodeId,
|
||||
selectedDescendantId: processNodeId,
|
||||
processEntityIdOfSelectedDescendant: processEntityId,
|
||||
};
|
||||
} else {
|
||||
return uiState;
|
||||
|
@ -56,7 +73,10 @@ const concernReducers = combineReducers({
|
|||
|
||||
export const resolverReducer: Reducer<ResolverState, ResolverAction> = (state, action) => {
|
||||
const nextState = concernReducers(state, action);
|
||||
if (action.type === 'userBroughtProcessIntoView') {
|
||||
if (
|
||||
action.type === 'userBroughtProcessIntoView' ||
|
||||
action.type === 'appDetectedNewIdFromQueryParams'
|
||||
) {
|
||||
return animateProcessIntoView(nextState, action.payload.time, action.payload.process);
|
||||
} else {
|
||||
return nextState;
|
||||
|
|
|
@ -68,6 +68,22 @@ export const relatedEventsStats = composeSelectors(
|
|||
dataSelectors.relatedEventsStats
|
||||
);
|
||||
|
||||
/**
|
||||
* Map of related events... by entity id
|
||||
*/
|
||||
export const relatedEventsByEntityId = composeSelectors(
|
||||
dataStateSelector,
|
||||
dataSelectors.relatedEventsByEntityId
|
||||
);
|
||||
|
||||
/**
|
||||
* Entity ids to booleans for waiting status
|
||||
*/
|
||||
export const relatedEventsReady = composeSelectors(
|
||||
dataStateSelector,
|
||||
dataSelectors.relatedEventsReady
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns the id of the "current" tree node (fake-focused)
|
||||
*/
|
||||
|
@ -84,6 +100,19 @@ export const uiSelectedDescendantId = composeSelectors(
|
|||
uiSelectors.selectedDescendantId
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns the entity_id of the "selected" tree node's process
|
||||
*/
|
||||
export const uiSelectedDescendantProcessId = composeSelectors(
|
||||
uiStateSelector,
|
||||
uiSelectors.selectedDescendantProcessId
|
||||
);
|
||||
|
||||
/**
|
||||
* The current panel to display
|
||||
*/
|
||||
export const currentPanelView = composeSelectors(uiStateSelector, uiSelectors.currentPanelView);
|
||||
|
||||
/**
|
||||
* Returns the camera state from within ResolverState
|
||||
*/
|
||||
|
@ -115,6 +144,14 @@ export const isLoading = composeSelectors(dataStateSelector, dataSelectors.isLoa
|
|||
*/
|
||||
export const hasError = composeSelectors(dataStateSelector, dataSelectors.hasError);
|
||||
|
||||
/**
|
||||
* An array containing all the processes currently in the Resolver than can be graphed
|
||||
*/
|
||||
export const graphableProcesses = composeSelectors(
|
||||
dataStateSelector,
|
||||
dataSelectors.graphableProcesses
|
||||
);
|
||||
|
||||
/**
|
||||
* Calls the `secondSelector` with the result of the `selector`. Use this when re-exporting a
|
||||
* concern-specific selector. `selector` should return the concern-specific state.
|
||||
|
|
|
@ -28,3 +28,19 @@ export const selectedDescendantId = createSelector(
|
|||
return selectedDescendantId;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* id of the currently "selected" tree node
|
||||
*/
|
||||
export const selectedDescendantProcessId = createSelector(
|
||||
(uiState: ResolverUIState) => uiState,
|
||||
/* eslint-disable no-shadow */
|
||||
({ processEntityIdOfSelectedDescendant }: ResolverUIState) => {
|
||||
return processEntityIdOfSelectedDescendant;
|
||||
}
|
||||
);
|
||||
|
||||
// Select the current panel to be displayed
|
||||
export const currentPanelView = (uiState: ResolverUIState) => {
|
||||
return uiState.panelToDisplay;
|
||||
};
|
||||
|
|
|
@ -8,7 +8,11 @@ import { Store } from 'redux';
|
|||
|
||||
import { ResolverAction } from './store/actions';
|
||||
export { ResolverAction } from './store/actions';
|
||||
import { ResolverEvent, ResolverNodeStats } from '../../common/endpoint/types';
|
||||
import {
|
||||
ResolverEvent,
|
||||
ResolverNodeStats,
|
||||
ResolverRelatedEvents,
|
||||
} from '../../common/endpoint/types';
|
||||
|
||||
/**
|
||||
* Redux state for the Resolver feature. Properties on this interface are populated via multiple reducers using redux's `combineReducers`.
|
||||
|
@ -42,6 +46,14 @@ export interface ResolverUIState {
|
|||
* The ID attribute of the resolver's currently selected descendant.
|
||||
*/
|
||||
readonly selectedDescendantId: string | null;
|
||||
/**
|
||||
* The entity_id of the process for the resolver's currently selected descendant.
|
||||
*/
|
||||
readonly processEntityIdOfSelectedDescendant: string | null;
|
||||
/**
|
||||
* Which panel the ui should display
|
||||
*/
|
||||
readonly panelToDisplay: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -136,6 +148,8 @@ export type CameraState = {
|
|||
export interface DataState {
|
||||
readonly results: readonly ResolverEvent[];
|
||||
readonly relatedEventsStats: Map<string, ResolverNodeStats>;
|
||||
readonly relatedEvents: Map<string, ResolverRelatedEvents>;
|
||||
readonly relatedEventsReady: Map<string, boolean>;
|
||||
isLoading: boolean;
|
||||
hasError: boolean;
|
||||
}
|
||||
|
|
|
@ -12,6 +12,9 @@ import styled from 'styled-components';
|
|||
import { i18n } from '@kbn/i18n';
|
||||
import { useUiSetting } from '../../common/lib/kibana';
|
||||
import { DEFAULT_DARK_MODE } from '../../../common/constants';
|
||||
import { ResolverEvent } from '../../../common/endpoint/types';
|
||||
import * as processModel from '../models/process_event';
|
||||
import { ResolverProcessType } from '../types';
|
||||
|
||||
type ResolverColorNames =
|
||||
| 'descriptionText'
|
||||
|
@ -405,7 +408,37 @@ export const SymbolDefinitions = styled(SymbolDefinitionsComponent)`
|
|||
height: 0;
|
||||
`;
|
||||
|
||||
export const useResolverTheme = (): { colorMap: ColorMap; nodeAssets: NodeStyleMap } => {
|
||||
const processTypeToCube: Record<ResolverProcessType, keyof NodeStyleMap> = {
|
||||
processCreated: 'runningProcessCube',
|
||||
processRan: 'runningProcessCube',
|
||||
processTerminated: 'terminatedProcessCube',
|
||||
unknownProcessEvent: 'runningProcessCube',
|
||||
processCausedAlert: 'runningTriggerCube',
|
||||
unknownEvent: 'runningProcessCube',
|
||||
};
|
||||
|
||||
/**
|
||||
* This will return which type the ResolverEvent will display as in the Node component
|
||||
* it will be something like 'runningProcessCube' or 'terminatedProcessCube'
|
||||
*
|
||||
* @param processEvent {ResolverEvent} the event to get the Resolver Component Node type of
|
||||
*/
|
||||
export function nodeType(processEvent: ResolverEvent): keyof NodeStyleMap {
|
||||
const processType = processModel.eventType(processEvent);
|
||||
if (processType in processTypeToCube) {
|
||||
return processTypeToCube[processType];
|
||||
}
|
||||
return 'runningProcessCube';
|
||||
}
|
||||
|
||||
/**
|
||||
* A hook to bring Resolver theming information into components.
|
||||
*/
|
||||
export const useResolverTheme = (): {
|
||||
colorMap: ColorMap;
|
||||
nodeAssets: NodeStyleMap;
|
||||
cubeAssetsForNode: (arg0: ResolverEvent) => NodeStyleConfig;
|
||||
} => {
|
||||
const isDarkMode = useUiSetting<boolean>(DEFAULT_DARK_MODE);
|
||||
const theme = isDarkMode ? euiThemeAmsterdamDark : euiThemeAmsterdamLight;
|
||||
|
||||
|
@ -478,7 +511,15 @@ export const useResolverTheme = (): { colorMap: ColorMap; nodeAssets: NodeStyleM
|
|||
},
|
||||
};
|
||||
|
||||
return { colorMap, nodeAssets };
|
||||
/**
|
||||
* Export assets to reuse symbols/icons in other places in the app (e.g. tables, etc.)
|
||||
* @param processEvent : The process event to fetch node assets for
|
||||
*/
|
||||
function cubeAssetsForNode(processEvent: ResolverEvent) {
|
||||
return nodeAssets[nodeType(processEvent)];
|
||||
}
|
||||
|
||||
return { colorMap, nodeAssets, cubeAssetsForNode };
|
||||
};
|
||||
|
||||
export const calculateResolverFontSize = (
|
||||
|
|
|
@ -53,9 +53,9 @@ const StyledResolver = styled.div<StyledResolver>`
|
|||
|
||||
const StyledPanel = styled(Panel)`
|
||||
position: absolute;
|
||||
left: 1em;
|
||||
top: 1em;
|
||||
max-height: calc(100% - 2em);
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
overflow: auto;
|
||||
width: 25em;
|
||||
max-width: 50%;
|
||||
|
|
|
@ -3,191 +3,283 @@
|
|||
* 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, { memo, useCallback, useMemo, useContext } from 'react';
|
||||
import {
|
||||
EuiPanel,
|
||||
EuiBadge,
|
||||
EuiBasicTableColumn,
|
||||
EuiTitle,
|
||||
EuiHorizontalRule,
|
||||
EuiInMemoryTable,
|
||||
} from '@elastic/eui';
|
||||
import euiVars from '@elastic/eui/dist/eui_theme_light.json';
|
||||
|
||||
import React, {
|
||||
memo,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useContext,
|
||||
useLayoutEffect,
|
||||
useState,
|
||||
useEffect,
|
||||
} from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { SideEffectContext } from './side_effect_context';
|
||||
import { ResolverEvent } from '../../../common/endpoint/types';
|
||||
import * as event from '../../../common/endpoint/models/event';
|
||||
import { useResolverDispatch } from './use_resolver_dispatch';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
// eslint-disable-next-line import/no-nodejs-modules
|
||||
import querystring from 'querystring';
|
||||
import { EuiPanel } from '@elastic/eui';
|
||||
import { displayNameRecord } from './process_event_dot';
|
||||
import * as selectors from '../store/selectors';
|
||||
import { useResolverDispatch } from './use_resolver_dispatch';
|
||||
import * as event from '../../../common/endpoint/models/event';
|
||||
import { ResolverEvent } from '../../../common/endpoint/types';
|
||||
import { SideEffectContext } from './side_effect_context';
|
||||
import { ProcessEventListNarrowedByType } from './panels/panel_content_related_list';
|
||||
import { EventCountsForProcess } from './panels/panel_content_related_counts';
|
||||
import { ProcessDetails } from './panels/panel_content_process_detail';
|
||||
import { ProcessListWithCounts } from './panels/panel_content_process_list';
|
||||
import { RelatedEventDetail } from './panels/panel_content_related_detail';
|
||||
import { CrumbInfo } from './panels/panel_content_utilities';
|
||||
|
||||
const HorizontalRule = memo(function HorizontalRule() {
|
||||
return (
|
||||
<EuiHorizontalRule
|
||||
style={{
|
||||
/**
|
||||
* Cannot use `styled` to override this because the specificity of EuiHorizontalRule's
|
||||
* CSS selectors is too high.
|
||||
*/
|
||||
marginLeft: `-${euiVars.euiPanelPaddingModifiers.paddingMedium}`,
|
||||
marginRight: `-${euiVars.euiPanelPaddingModifiers.paddingMedium}`,
|
||||
/**
|
||||
* The default width is 100%, but this should be greater.
|
||||
*/
|
||||
width: 'auto',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const Panel = memo(function Event({ className }: { className?: string }) {
|
||||
interface ProcessTableView {
|
||||
name: string;
|
||||
timestamp?: Date;
|
||||
event: ResolverEvent;
|
||||
}
|
||||
|
||||
const { processNodePositions } = useSelector(selectors.processNodePositionsAndEdgeLineSegments);
|
||||
const { timestamp } = useContext(SideEffectContext);
|
||||
|
||||
const processTableView: ProcessTableView[] = useMemo(
|
||||
() =>
|
||||
[...processNodePositions.keys()].map((processEvent) => {
|
||||
let dateTime;
|
||||
const eventTime = event.eventTimestamp(processEvent);
|
||||
const name = event.eventName(processEvent);
|
||||
if (eventTime) {
|
||||
const date = new Date(eventTime);
|
||||
if (isFinite(date.getTime())) {
|
||||
dateTime = date;
|
||||
}
|
||||
}
|
||||
return {
|
||||
name,
|
||||
timestamp: dateTime,
|
||||
event: processEvent,
|
||||
};
|
||||
}),
|
||||
[processNodePositions]
|
||||
);
|
||||
|
||||
const formatter = new Intl.DateTimeFormat(i18n.getLocale(), {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
|
||||
/**
|
||||
* 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 history = useHistory();
|
||||
const urlSearch = history.location.search;
|
||||
const dispatch = useResolverDispatch();
|
||||
|
||||
const handleBringIntoViewClick = useCallback(
|
||||
(processTableViewItem) => {
|
||||
const queryParams: CrumbInfo = useMemo(() => {
|
||||
return { crumbId: '', crumbEvent: '', ...querystring.parse(urlSearch.slice(1)) };
|
||||
}, [urlSearch]);
|
||||
|
||||
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 selectedDescendantProcessId = useSelector(selectors.uiSelectedDescendantProcessId);
|
||||
const uiSelectedEvent = useMemo(() => {
|
||||
return graphableProcesses.find((evt) => event.entityId(evt) === selectedDescendantProcessId);
|
||||
}, [graphableProcesses, selectedDescendantProcessId]);
|
||||
|
||||
// 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 { timestamp } = useContext(SideEffectContext);
|
||||
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: 'userBroughtProcessIntoView',
|
||||
type: 'appDetectedNewIdFromQueryParams',
|
||||
payload: {
|
||||
time: timestamp(),
|
||||
process: processTableViewItem.event,
|
||||
process: paramsSelectedEvent,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [dispatch, uiSelectedEvent, paramsSelectedEvent, lastUpdatedProcess, timestamp]);
|
||||
|
||||
/**
|
||||
* 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 pushToQueryParams = useCallback(
|
||||
(newCrumbs: CrumbInfo) => {
|
||||
// Construct a new set of params from the current set (minus empty params)
|
||||
// by assigning the new set of params provided in `newCrumbs`
|
||||
const crumbsToPass = {
|
||||
...querystring.parse(urlSearch.slice(1)),
|
||||
...newCrumbs,
|
||||
};
|
||||
|
||||
// If either was passed in as empty, remove it from the record
|
||||
if (crumbsToPass.crumbId === '') {
|
||||
delete crumbsToPass.crumbId;
|
||||
}
|
||||
if (crumbsToPass.crumbEvent === '') {
|
||||
delete crumbsToPass.crumbEvent;
|
||||
}
|
||||
|
||||
const relativeURL = { search: querystring.stringify(crumbsToPass) };
|
||||
// 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);
|
||||
},
|
||||
[dispatch, timestamp]
|
||||
[history, urlSearch]
|
||||
);
|
||||
|
||||
const columns = useMemo<Array<EuiBasicTableColumn<ProcessTableView>>>(
|
||||
() => [
|
||||
{
|
||||
field: 'name',
|
||||
name: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.panel.tabel.row.processNameTitle',
|
||||
{
|
||||
defaultMessage: 'Process Name',
|
||||
}
|
||||
),
|
||||
sortable: true,
|
||||
truncateText: true,
|
||||
render(name: string) {
|
||||
return name === '' ? (
|
||||
<EuiBadge color="warning">
|
||||
{i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.panel.table.row.valueMissingDescription',
|
||||
{
|
||||
defaultMessage: 'Value is missing',
|
||||
}
|
||||
)}
|
||||
</EuiBadge>
|
||||
) : (
|
||||
name
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'timestamp',
|
||||
name: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.panel.tabel.row.timestampTitle',
|
||||
{
|
||||
defaultMessage: 'Timestamp',
|
||||
}
|
||||
),
|
||||
dataType: 'date',
|
||||
sortable: true,
|
||||
render(eventDate?: Date) {
|
||||
return eventDate ? (
|
||||
formatter.format(eventDate)
|
||||
) : (
|
||||
<EuiBadge color="warning">
|
||||
{i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.panel.tabel.row.timestampInvalidLabel',
|
||||
{
|
||||
defaultMessage: 'invalid',
|
||||
}
|
||||
)}
|
||||
</EuiBadge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.panel.tabel.row.actionsTitle',
|
||||
{
|
||||
defaultMessage: 'Actions',
|
||||
}
|
||||
),
|
||||
actions: [
|
||||
{
|
||||
name: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.panel.tabel.row.actions.bringIntoViewButtonLabel',
|
||||
{
|
||||
defaultMessage: 'Bring into view',
|
||||
}
|
||||
),
|
||||
description: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.panel.tabel.row.bringIntoViewLabel',
|
||||
{
|
||||
defaultMessage: 'Bring the process into view on the map.',
|
||||
}
|
||||
),
|
||||
type: 'icon',
|
||||
icon: 'flag',
|
||||
onClick: handleBringIntoViewClick,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[formatter, handleBringIntoViewClick]
|
||||
);
|
||||
// GO JONNY GO
|
||||
const relatedEventStats = useSelector(selectors.relatedEventsStats);
|
||||
const { crumbId, crumbEvent } = queryParams;
|
||||
const relatedStatsForIdFromParams = useMemo(() => {
|
||||
if (idFromParams) {
|
||||
return relatedEventStats.get(idFromParams);
|
||||
}
|
||||
return undefined;
|
||||
}, [relatedEventStats, 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 in displayNameRecord && uiSelectedEvent) {
|
||||
return 'processEventListNarrowedByType';
|
||||
}
|
||||
}
|
||||
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
// dispatch `appDisplayedDifferentPanel` to sync state with which panel gets displayed
|
||||
dispatch({
|
||||
type: 'appDisplayedDifferentPanel',
|
||||
payload: panelToShow,
|
||||
});
|
||||
}, [panelToShow, dispatch]);
|
||||
|
||||
const currentPanelView = useSelector(selectors.currentPanelView);
|
||||
|
||||
const panelInstance = useMemo(() => {
|
||||
if (currentPanelView === 'processDetails') {
|
||||
return (
|
||||
<ProcessDetails processEvent={uiSelectedEvent!} pushToQueryParams={pushToQueryParams} />
|
||||
);
|
||||
}
|
||||
|
||||
if (currentPanelView === 'eventCountsForProcess') {
|
||||
return (
|
||||
<EventCountsForProcess
|
||||
processEvent={uiSelectedEvent!}
|
||||
pushToQueryParams={pushToQueryParams}
|
||||
relatedStats={relatedStatsForIdFromParams!}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentPanelView === 'processEventListNarrowedByType') {
|
||||
return (
|
||||
<ProcessEventListNarrowedByType
|
||||
processEvent={uiSelectedEvent!}
|
||||
pushToQueryParams={pushToQueryParams}
|
||||
relatedStats={relatedStatsForIdFromParams!}
|
||||
eventType={crumbEvent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentPanelView === 'relatedEventDetail') {
|
||||
const parentCount: number = Object.values(
|
||||
relatedStatsForIdFromParams?.events.byCategory || {}
|
||||
).reduce((sum, val) => sum + val, 0);
|
||||
return (
|
||||
<RelatedEventDetail
|
||||
relatedEventId={crumbId}
|
||||
parentEvent={uiSelectedEvent!}
|
||||
pushToQueryParams={pushToQueryParams}
|
||||
countForParent={parentCount}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// The default 'Event List' / 'List of all processes' view
|
||||
return <ProcessListWithCounts pushToQueryParams={pushToQueryParams} />;
|
||||
}, [
|
||||
uiSelectedEvent,
|
||||
crumbEvent,
|
||||
crumbId,
|
||||
pushToQueryParams,
|
||||
relatedStatsForIdFromParams,
|
||||
currentPanelView,
|
||||
]);
|
||||
|
||||
return <>{panelInstance}</>;
|
||||
});
|
||||
PanelContent.displayName = 'PanelContent';
|
||||
|
||||
export const Panel = memo(function Event({ className }: { className?: string }) {
|
||||
return (
|
||||
<EuiPanel className={className}>
|
||||
<EuiTitle size="xs">
|
||||
<h4>
|
||||
{i18n.translate('xpack.securitySolution.endpoint.resolver.panel.title', {
|
||||
defaultMessage: 'Processes',
|
||||
})}
|
||||
</h4>
|
||||
</EuiTitle>
|
||||
<HorizontalRule />
|
||||
<EuiInMemoryTable<ProcessTableView> items={processTableView} columns={columns} sorting />
|
||||
<PanelContent />
|
||||
</EuiPanel>
|
||||
);
|
||||
});
|
||||
Panel.displayName = 'Panel';
|
||||
|
|
|
@ -0,0 +1,61 @@
|
|||
/*
|
||||
* 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 { i18n } from '@kbn/i18n';
|
||||
import { EuiSpacer, EuiText, EuiButtonEmpty } from '@elastic/eui';
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import { CrumbInfo, StyledBreadcrumbs } from './panel_content_utilities';
|
||||
|
||||
/**
|
||||
* Display an error in the panel when something goes wrong and give the user a way to "retreat" back to a default state.
|
||||
*
|
||||
* @param {function} pushToQueryparams A function to update the hash value in the URL to control panel state
|
||||
* @param {string} translatedErrorMessage The message to display in the panel when something goes wrong
|
||||
*/
|
||||
export const PanelContentError = memo(function ({
|
||||
translatedErrorMessage,
|
||||
pushToQueryParams,
|
||||
}: {
|
||||
translatedErrorMessage: string;
|
||||
pushToQueryParams: (arg0: CrumbInfo) => unknown;
|
||||
}) {
|
||||
const crumbs = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
text: i18n.translate('xpack.securitySolution.endpoint.resolver.panel.error.events', {
|
||||
defaultMessage: 'Events',
|
||||
}),
|
||||
onClick: () => {
|
||||
pushToQueryParams({ crumbId: '', crumbEvent: '' });
|
||||
},
|
||||
},
|
||||
{
|
||||
text: i18n.translate('xpack.securitySolution.endpoint.resolver.panel.error.error', {
|
||||
defaultMessage: 'Error',
|
||||
}),
|
||||
onClick: () => {},
|
||||
},
|
||||
];
|
||||
}, [pushToQueryParams]);
|
||||
return (
|
||||
<>
|
||||
<StyledBreadcrumbs breadcrumbs={crumbs} />
|
||||
<EuiSpacer size="l" />
|
||||
<EuiText textAlign="center">{translatedErrorMessage}</EuiText>
|
||||
<EuiSpacer size="l" />
|
||||
<EuiButtonEmpty
|
||||
onClick={() => {
|
||||
pushToQueryParams({ crumbId: '', crumbEvent: '' });
|
||||
}}
|
||||
>
|
||||
{i18n.translate('xpack.securitySolution.endpoint.resolver.panel.error.goBack', {
|
||||
defaultMessage: 'Click this link to return to the list of all processes.',
|
||||
})}
|
||||
</EuiButtonEmpty>
|
||||
</>
|
||||
);
|
||||
});
|
||||
PanelContentError.displayName = 'TableServiceError';
|
|
@ -0,0 +1,211 @@
|
|||
/*
|
||||
* 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, { memo, useMemo } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import {
|
||||
htmlIdGenerator,
|
||||
EuiSpacer,
|
||||
EuiTitle,
|
||||
EuiText,
|
||||
EuiTextColor,
|
||||
EuiDescriptionList,
|
||||
} from '@elastic/eui';
|
||||
import styled from 'styled-components';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import * as event from '../../../../common/endpoint/models/event';
|
||||
import { CrumbInfo, formatDate, StyledBreadcrumbs } from './panel_content_utilities';
|
||||
import {
|
||||
processPath,
|
||||
processPid,
|
||||
userInfoForProcess,
|
||||
processParentPid,
|
||||
md5HashForProcess,
|
||||
argsForProcess,
|
||||
} from '../../models/process_event';
|
||||
import { CubeForProcess } from './process_cube_icon';
|
||||
import { ResolverEvent } from '../../../../common/endpoint/types';
|
||||
import { useResolverTheme } from '../assets';
|
||||
|
||||
const StyledDescriptionList = styled(EuiDescriptionList)`
|
||||
&.euiDescriptionList.euiDescriptionList--column dt.euiDescriptionList__title.desc-title {
|
||||
max-width: 8em;
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* 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({
|
||||
processEvent,
|
||||
pushToQueryParams,
|
||||
}: {
|
||||
processEvent: ResolverEvent;
|
||||
pushToQueryParams: (arg0: CrumbInfo) => unknown;
|
||||
}) {
|
||||
const processName = event.eventName(processEvent);
|
||||
const processInfoEntry = useMemo(() => {
|
||||
const eventTime = event.eventTimestamp(processEvent);
|
||||
const dateTime = eventTime ? formatDate(eventTime) : '';
|
||||
|
||||
const createdEntry = {
|
||||
title: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.panel.processDescList.created',
|
||||
{
|
||||
defaultMessage: 'Created',
|
||||
}
|
||||
),
|
||||
description: dateTime,
|
||||
};
|
||||
|
||||
const pathEntry = {
|
||||
title: i18n.translate('xpack.securitySolution.endpoint.resolver.panel.processDescList.path', {
|
||||
defaultMessage: 'Path',
|
||||
}),
|
||||
description: processPath(processEvent),
|
||||
};
|
||||
|
||||
const pidEntry = {
|
||||
title: i18n.translate('xpack.securitySolution.endpoint.resolver.panel.processDescList.pid', {
|
||||
defaultMessage: 'PID',
|
||||
}),
|
||||
description: processPid(processEvent),
|
||||
};
|
||||
|
||||
const userEntry = {
|
||||
title: i18n.translate('xpack.securitySolution.endpoint.resolver.panel.processDescList.user', {
|
||||
defaultMessage: 'User',
|
||||
}),
|
||||
description: (userInfoForProcess(processEvent) as { name: string }).name,
|
||||
};
|
||||
|
||||
const domainEntry = {
|
||||
title: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.panel.processDescList.domain',
|
||||
{
|
||||
defaultMessage: 'Domain',
|
||||
}
|
||||
),
|
||||
description: (userInfoForProcess(processEvent) as { domain: string }).domain,
|
||||
};
|
||||
|
||||
const parentPidEntry = {
|
||||
title: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.panel.processDescList.parentPid',
|
||||
{
|
||||
defaultMessage: 'Parent PID',
|
||||
}
|
||||
),
|
||||
description: processParentPid(processEvent),
|
||||
};
|
||||
|
||||
const md5Entry = {
|
||||
title: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.panel.processDescList.md5hash',
|
||||
{
|
||||
defaultMessage: 'MD5',
|
||||
}
|
||||
),
|
||||
description: md5HashForProcess(processEvent),
|
||||
};
|
||||
|
||||
const commandLineEntry = {
|
||||
title: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.panel.processDescList.commandLine',
|
||||
{
|
||||
defaultMessage: 'Command Line',
|
||||
}
|
||||
),
|
||||
description: argsForProcess(processEvent),
|
||||
};
|
||||
|
||||
// This is the data in {title, description} form for the EUIDescriptionList to display
|
||||
const processDescriptionListData = [
|
||||
createdEntry,
|
||||
pathEntry,
|
||||
pidEntry,
|
||||
userEntry,
|
||||
domainEntry,
|
||||
parentPidEntry,
|
||||
md5Entry,
|
||||
commandLineEntry,
|
||||
]
|
||||
.filter((entry) => {
|
||||
return entry.description;
|
||||
})
|
||||
.map((entry) => {
|
||||
return {
|
||||
...entry,
|
||||
description: String(entry.description),
|
||||
};
|
||||
});
|
||||
|
||||
return processDescriptionListData;
|
||||
}, [processEvent]);
|
||||
|
||||
const crumbs = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
text: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.panel.processDescList.events',
|
||||
{
|
||||
defaultMessage: 'Events',
|
||||
}
|
||||
),
|
||||
onClick: () => {
|
||||
pushToQueryParams({ crumbId: '', crumbEvent: '' });
|
||||
},
|
||||
},
|
||||
{
|
||||
text: (
|
||||
<>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.detailsForProcessName"
|
||||
values={{ processName }}
|
||||
defaultMessage="Details for: {processName}"
|
||||
/>
|
||||
</>
|
||||
),
|
||||
onClick: () => {},
|
||||
},
|
||||
];
|
||||
}, [processName, pushToQueryParams]);
|
||||
const { cubeAssetsForNode } = useResolverTheme();
|
||||
const { descriptionText } = useMemo(() => {
|
||||
if (!processEvent) {
|
||||
return { descriptionText: '' };
|
||||
}
|
||||
return cubeAssetsForNode(processEvent);
|
||||
}, [processEvent, cubeAssetsForNode]);
|
||||
|
||||
const titleId = useMemo(() => htmlIdGenerator('resolverTable')(), []);
|
||||
return (
|
||||
<>
|
||||
<StyledBreadcrumbs breadcrumbs={crumbs} />
|
||||
<EuiSpacer size="l" />
|
||||
<EuiTitle size="xs">
|
||||
<h4 aria-describedby={titleId}>
|
||||
<CubeForProcess processEvent={processEvent} />
|
||||
{processName}
|
||||
</h4>
|
||||
</EuiTitle>
|
||||
<EuiText>
|
||||
<EuiTextColor color="subdued">
|
||||
<span id={titleId}>{descriptionText}</span>
|
||||
</EuiTextColor>
|
||||
</EuiText>
|
||||
<EuiSpacer size="l" />
|
||||
<StyledDescriptionList
|
||||
type="column"
|
||||
align="left"
|
||||
titleProps={{ className: 'desc-title' }}
|
||||
compressed
|
||||
listItems={processInfoEntry}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
ProcessDetails.displayName = 'ProcessDetails';
|
|
@ -0,0 +1,164 @@
|
|||
/*
|
||||
* 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, { memo, useContext, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
EuiBasicTableColumn,
|
||||
EuiBadge,
|
||||
EuiButtonEmpty,
|
||||
EuiSpacer,
|
||||
EuiInMemoryTable,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { useSelector } from 'react-redux';
|
||||
import * as event from '../../../../common/endpoint/models/event';
|
||||
import * as selectors from '../../store/selectors';
|
||||
import { CrumbInfo, formatter, StyledBreadcrumbs } from './panel_content_utilities';
|
||||
import { useResolverDispatch } from '../use_resolver_dispatch';
|
||||
import { SideEffectContext } from '../side_effect_context';
|
||||
import { CubeForProcess } from './process_cube_icon';
|
||||
import { ResolverEvent } from '../../../../common/endpoint/types';
|
||||
|
||||
/**
|
||||
* The "default" view for the panel: A list of all the processes currently in the graph.
|
||||
*
|
||||
* @param {function} pushToQueryparams A function to update the hash value in the URL to control panel state
|
||||
*/
|
||||
export const ProcessListWithCounts = memo(function ProcessListWithCounts({
|
||||
pushToQueryParams,
|
||||
}: {
|
||||
pushToQueryParams: (arg0: CrumbInfo) => unknown;
|
||||
}) {
|
||||
interface ProcessTableView {
|
||||
name: string;
|
||||
timestamp?: Date;
|
||||
event: ResolverEvent;
|
||||
}
|
||||
|
||||
const dispatch = useResolverDispatch();
|
||||
const { timestamp } = useContext(SideEffectContext);
|
||||
const handleBringIntoViewClick = useCallback(
|
||||
(processTableViewItem) => {
|
||||
dispatch({
|
||||
type: 'userBroughtProcessIntoView',
|
||||
payload: {
|
||||
time: timestamp(),
|
||||
process: processTableViewItem.event,
|
||||
},
|
||||
});
|
||||
pushToQueryParams({ crumbId: event.entityId(processTableViewItem.event), crumbEvent: '' });
|
||||
},
|
||||
[dispatch, timestamp, pushToQueryParams]
|
||||
);
|
||||
|
||||
const columns = useMemo<Array<EuiBasicTableColumn<ProcessTableView>>>(
|
||||
() => [
|
||||
{
|
||||
field: 'name',
|
||||
name: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.panel.table.row.processNameTitle',
|
||||
{
|
||||
defaultMessage: 'Process Name',
|
||||
}
|
||||
),
|
||||
sortable: true,
|
||||
truncateText: true,
|
||||
render(name: string, item: ProcessTableView) {
|
||||
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({ crumbId: event.entityId(item.event), crumbEvent: '' });
|
||||
}}
|
||||
>
|
||||
<CubeForProcess processEvent={item.event} />
|
||||
{name}
|
||||
</EuiButtonEmpty>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'timestamp',
|
||||
name: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.panel.table.row.timestampTitle',
|
||||
{
|
||||
defaultMessage: 'Timestamp',
|
||||
}
|
||||
),
|
||||
dataType: 'date',
|
||||
sortable: true,
|
||||
render(eventDate?: Date) {
|
||||
return eventDate ? (
|
||||
formatter.format(eventDate)
|
||||
) : (
|
||||
<EuiBadge color="warning">
|
||||
{i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.panel.table.row.timestampInvalidLabel',
|
||||
{
|
||||
defaultMessage: 'invalid',
|
||||
}
|
||||
)}
|
||||
</EuiBadge>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[pushToQueryParams, handleBringIntoViewClick]
|
||||
);
|
||||
|
||||
const { processNodePositions } = useSelector(selectors.processNodePositionsAndEdgeLineSegments);
|
||||
const processTableView: ProcessTableView[] = useMemo(
|
||||
() =>
|
||||
[...processNodePositions.keys()].map((processEvent) => {
|
||||
let dateTime;
|
||||
const eventTime = event.eventTimestamp(processEvent);
|
||||
const name = event.eventName(processEvent);
|
||||
if (eventTime) {
|
||||
const date = new Date(eventTime);
|
||||
if (isFinite(date.getTime())) {
|
||||
dateTime = date;
|
||||
}
|
||||
}
|
||||
return {
|
||||
name,
|
||||
timestamp: dateTime,
|
||||
event: processEvent,
|
||||
};
|
||||
}),
|
||||
[processNodePositions]
|
||||
);
|
||||
|
||||
const crumbs = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
text: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.panel.processListWithCounts.events',
|
||||
{
|
||||
defaultMessage: 'All Process Events',
|
||||
}
|
||||
),
|
||||
onClick: () => {},
|
||||
},
|
||||
];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledBreadcrumbs breadcrumbs={crumbs} />
|
||||
<EuiSpacer size="l" />
|
||||
<EuiInMemoryTable<ProcessTableView> items={processTableView} columns={columns} sorting />
|
||||
</>
|
||||
);
|
||||
});
|
||||
ProcessListWithCounts.displayName = 'ProcessListWithCounts';
|
|
@ -0,0 +1,144 @@
|
|||
/*
|
||||
* 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, { memo, useMemo } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { EuiBasicTableColumn, EuiButtonEmpty, EuiSpacer, EuiInMemoryTable } from '@elastic/eui';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { CrumbInfo, StyledBreadcrumbs } from './panel_content_utilities';
|
||||
|
||||
import * as event from '../../../../common/endpoint/models/event';
|
||||
import { ResolverEvent, ResolverNodeStats } from '../../../../common/endpoint/types';
|
||||
|
||||
/**
|
||||
* This view gives counts for all the related events of a process grouped by related event type.
|
||||
* It should look something like:
|
||||
*
|
||||
* | Count | Event Type |
|
||||
* | :--------------------- | :------------------------- |
|
||||
* | 5 | DNS |
|
||||
* | 12 | Registry |
|
||||
* | 2 | Network |
|
||||
*
|
||||
*/
|
||||
export const EventCountsForProcess = memo(function EventCountsForProcess({
|
||||
processEvent,
|
||||
pushToQueryParams,
|
||||
relatedStats,
|
||||
}: {
|
||||
processEvent: ResolverEvent;
|
||||
pushToQueryParams: (arg0: CrumbInfo) => unknown;
|
||||
relatedStats: ResolverNodeStats;
|
||||
}) {
|
||||
interface EventCountsTableView {
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
const relatedEventsState = { stats: relatedStats.events.byCategory };
|
||||
const processName = processEvent && event.eventName(processEvent);
|
||||
const processEntityId = event.entityId(processEvent);
|
||||
/**
|
||||
* totalCount: This will reflect the aggregated total by category for all related events
|
||||
* e.g. [dns,file],[dns,file],[registry] will have an aggregate total of 5. This is to keep the
|
||||
* total number consistent with the "broken out" totals we see elsewhere in the app.
|
||||
* E.g. on the rleated list by type, the above would show as:
|
||||
* 2 dns
|
||||
* 2 file
|
||||
* 1 registry
|
||||
* So it would be extremely disorienting to show the user a "3" above that as a total.
|
||||
*/
|
||||
const totalCount = Object.values(relatedStats.events.byCategory).reduce(
|
||||
(sum, val) => sum + val,
|
||||
0
|
||||
);
|
||||
const eventsString = i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.panel.processEventCounts.events',
|
||||
{
|
||||
defaultMessage: 'Events',
|
||||
}
|
||||
);
|
||||
const crumbs = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
text: eventsString,
|
||||
onClick: () => {
|
||||
pushToQueryParams({ crumbId: '', crumbEvent: '' });
|
||||
},
|
||||
},
|
||||
{
|
||||
text: processName,
|
||||
onClick: () => {
|
||||
pushToQueryParams({ crumbId: processEntityId, crumbEvent: '' });
|
||||
},
|
||||
},
|
||||
{
|
||||
text: (
|
||||
<>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.endpoint.resolver.panel.relatedCounts.numberOfEventsInCrumb"
|
||||
values={{ totalCount }}
|
||||
defaultMessage="{totalCount} Events"
|
||||
/>
|
||||
</>
|
||||
),
|
||||
onClick: () => {
|
||||
pushToQueryParams({ crumbId: processEntityId, crumbEvent: '' });
|
||||
},
|
||||
},
|
||||
];
|
||||
}, [processName, totalCount, processEntityId, pushToQueryParams, eventsString]);
|
||||
const rows = useMemo(() => {
|
||||
return Object.entries(relatedEventsState.stats).map(
|
||||
([eventType, count]): EventCountsTableView => {
|
||||
return {
|
||||
name: eventType,
|
||||
count,
|
||||
};
|
||||
}
|
||||
);
|
||||
}, [relatedEventsState]);
|
||||
const columns = useMemo<Array<EuiBasicTableColumn<EventCountsTableView>>>(
|
||||
() => [
|
||||
{
|
||||
field: 'count',
|
||||
name: i18n.translate('xpack.securitySolution.endpoint.resolver.panel.table.row.count', {
|
||||
defaultMessage: 'Count',
|
||||
}),
|
||||
width: '20%',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
field: 'name',
|
||||
name: i18n.translate('xpack.securitySolution.endpoint.resolver.panel.table.row.eventType', {
|
||||
defaultMessage: 'Event Type',
|
||||
}),
|
||||
width: '80%',
|
||||
sortable: true,
|
||||
render(name: string) {
|
||||
return (
|
||||
<EuiButtonEmpty
|
||||
onClick={() => {
|
||||
pushToQueryParams({ crumbId: event.entityId(processEvent), crumbEvent: name });
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</EuiButtonEmpty>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[pushToQueryParams, processEvent]
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<StyledBreadcrumbs breadcrumbs={crumbs} />
|
||||
<EuiSpacer size="l" />
|
||||
<EuiInMemoryTable<EventCountsTableView> items={rows} columns={columns} sorting />
|
||||
</>
|
||||
);
|
||||
});
|
||||
EventCountsForProcess.displayName = 'EventCountsForProcess';
|
|
@ -0,0 +1,365 @@
|
|||
/*
|
||||
* 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, { memo, useMemo, useEffect, Fragment } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { EuiSpacer, EuiText, EuiDescriptionList, EuiTextColor, EuiTitle } from '@elastic/eui';
|
||||
import styled from 'styled-components';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { CrumbInfo, formatDate, StyledBreadcrumbs, BoldCode } 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';
|
||||
|
||||
/**
|
||||
* A helper function to turn objects into EuiDescriptionList entries.
|
||||
* This reflects the strategy of more or less "dumping" metadata for related processes
|
||||
* in description lists with little/no 'prettification'. This has the obvious drawback of
|
||||
* data perhaps appearing inscrutable/daunting, but the benefit of presenting these fields
|
||||
* to the user "as they occur" in ECS, which may help them with e.g. EQL queries.
|
||||
*
|
||||
* Given an object like: {a:{b: 1}, c: 'd'} it will yield title/description entries like so:
|
||||
* {title: "a.b", description: "1"}, {title: "c", description: "d"}
|
||||
*
|
||||
* @param {object} obj The object to turn into `<dt><dd>` entries
|
||||
*/
|
||||
const objectToDescriptionListEntries = function* (
|
||||
obj: object,
|
||||
prefix = ''
|
||||
): Generator<{ title: string; description: string }> {
|
||||
const nextPrefix = prefix.length ? `${prefix}.` : '';
|
||||
for (const [metaKey, metaValue] of Object.entries(obj)) {
|
||||
if (typeof metaValue === 'number' || typeof metaValue === 'string') {
|
||||
yield { title: nextPrefix + metaKey, description: `${metaValue}` };
|
||||
} else if (metaValue instanceof Array) {
|
||||
yield {
|
||||
title: nextPrefix + metaKey,
|
||||
description: metaValue
|
||||
.filter((arrayEntry) => {
|
||||
return typeof arrayEntry === 'number' || typeof arrayEntry === 'string';
|
||||
})
|
||||
.join(','),
|
||||
};
|
||||
} else if (typeof metaValue === 'object') {
|
||||
yield* objectToDescriptionListEntries(metaValue, nextPrefix + metaKey);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Adding some styles to prevent horizontal scrollbars, per request from UX review
|
||||
const StyledDescriptionList = memo(styled(EuiDescriptionList)`
|
||||
&.euiDescriptionList.euiDescriptionList--column dt.euiDescriptionList__title.desc-title {
|
||||
max-width: 8em;
|
||||
}
|
||||
&.euiDescriptionList.euiDescriptionList--column dd.euiDescriptionList__description {
|
||||
max-width: calc(100% - 8.5em);
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
`);
|
||||
|
||||
// Styling subtitles, per UX review:
|
||||
const StyledFlexTitle = memo(styled('h3')`
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
font-size: 1.2em;
|
||||
`);
|
||||
const StyledTitleRule = memo(styled('hr')`
|
||||
&.euiHorizontalRule.euiHorizontalRule--full.euiHorizontalRule--marginSmall.override {
|
||||
display: block;
|
||||
flex: 1;
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
`);
|
||||
|
||||
const TitleHr = memo(() => {
|
||||
return (
|
||||
<StyledTitleRule className="euiHorizontalRule euiHorizontalRule--full euiHorizontalRule--marginSmall override" />
|
||||
);
|
||||
});
|
||||
TitleHr.displayName = 'TitleHR';
|
||||
|
||||
/**
|
||||
* 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 RelatedEventDetail({
|
||||
relatedEventId,
|
||||
parentEvent,
|
||||
pushToQueryParams,
|
||||
countForParent,
|
||||
}: {
|
||||
relatedEventId: string;
|
||||
parentEvent: ResolverEvent;
|
||||
pushToQueryParams: (arg0: CrumbInfo) => unknown;
|
||||
countForParent: number | undefined;
|
||||
}) {
|
||||
const processName = (parentEvent && event.eventName(parentEvent)) || '*';
|
||||
const processEntityId = parentEvent && event.entityId(parentEvent);
|
||||
const totalCount = countForParent || 0;
|
||||
const eventsString = i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.events',
|
||||
{
|
||||
defaultMessage: 'Events',
|
||||
}
|
||||
);
|
||||
const naString = i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.NA',
|
||||
{
|
||||
defaultMessage: 'N/A',
|
||||
}
|
||||
);
|
||||
|
||||
const relatedsReadyMap = useSelector(selectors.relatedEventsReady);
|
||||
const relatedsReady = relatedsReadyMap.get(processEntityId!);
|
||||
const dispatch = useResolverDispatch();
|
||||
|
||||
/**
|
||||
* If we don't have the related events for the parent yet, use this effect
|
||||
* to request them.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (typeof relatedsReady === 'undefined') {
|
||||
dispatch({
|
||||
type: 'appDetectedMissingEventData',
|
||||
payload: processEntityId,
|
||||
});
|
||||
}
|
||||
}, [relatedsReady, dispatch, processEntityId]);
|
||||
|
||||
const relatedEventsForThisProcess = useSelector(selectors.relatedEventsByEntityId).get(
|
||||
processEntityId!
|
||||
);
|
||||
|
||||
const [relatedEventToShowDetailsFor, countBySameCategory, relatedEventCategory] = useMemo(() => {
|
||||
if (!relatedEventsForThisProcess) {
|
||||
return [undefined, 0];
|
||||
}
|
||||
const specificEvent = relatedEventsForThisProcess.events.find(
|
||||
(evt) => event.eventId(evt) === relatedEventId
|
||||
);
|
||||
// For breadcrumbs:
|
||||
const specificCategory = specificEvent && event.primaryEventCategory(specificEvent);
|
||||
const countOfCategory = relatedEventsForThisProcess.events.reduce((sumtotal, evt) => {
|
||||
return event.primaryEventCategory(evt) === specificCategory ? sumtotal + 1 : sumtotal;
|
||||
}, 0);
|
||||
return [specificEvent, countOfCategory, specificCategory || naString];
|
||||
}, [relatedEventsForThisProcess, naString, relatedEventId]);
|
||||
|
||||
const [sections, formattedDate] = useMemo(() => {
|
||||
if (!relatedEventToShowDetailsFor) {
|
||||
// This could happen if user relaods from URL param and requests an eventId that no longer exists
|
||||
return [[], naString];
|
||||
}
|
||||
// Assuming these details (agent, ecs, process) aren't as helpful, can revisit
|
||||
const {
|
||||
agent,
|
||||
ecs,
|
||||
process,
|
||||
...relevantData
|
||||
} = relatedEventToShowDetailsFor as ResolverEvent & {
|
||||
ecs: unknown;
|
||||
};
|
||||
let displayDate = '';
|
||||
const sectionData: Array<{
|
||||
sectionTitle: string;
|
||||
entries: Array<{ title: string; description: string }>;
|
||||
}> = Object.entries(relevantData)
|
||||
.map(([sectionTitle, val]) => {
|
||||
if (sectionTitle === '@timestamp') {
|
||||
displayDate = formatDate(val);
|
||||
return { sectionTitle: '', entries: [] };
|
||||
}
|
||||
if (typeof val !== 'object') {
|
||||
return { sectionTitle, entries: [{ title: sectionTitle, description: `${val}` }] };
|
||||
}
|
||||
return { sectionTitle, entries: [...objectToDescriptionListEntries(val)] };
|
||||
})
|
||||
.filter((v) => v.sectionTitle !== '' && v.entries.length);
|
||||
return [sectionData, displayDate];
|
||||
}, [relatedEventToShowDetailsFor, naString]);
|
||||
|
||||
const waitCrumbs = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
text: eventsString,
|
||||
onClick: () => {
|
||||
pushToQueryParams({ crumbId: '', crumbEvent: '' });
|
||||
},
|
||||
},
|
||||
];
|
||||
}, [pushToQueryParams, eventsString]);
|
||||
|
||||
const { subject = '', descriptor = '' } = relatedEventToShowDetailsFor
|
||||
? event.descriptiveName(relatedEventToShowDetailsFor)
|
||||
: {};
|
||||
const crumbs = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
text: eventsString,
|
||||
onClick: () => {
|
||||
pushToQueryParams({ crumbId: '', crumbEvent: '' });
|
||||
},
|
||||
},
|
||||
{
|
||||
text: processName,
|
||||
onClick: () => {
|
||||
pushToQueryParams({ crumbId: processEntityId!, crumbEvent: '' });
|
||||
},
|
||||
},
|
||||
{
|
||||
text: (
|
||||
<>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.numberOfEvents"
|
||||
values={{ totalCount }}
|
||||
defaultMessage="{totalCount} Events"
|
||||
/>
|
||||
</>
|
||||
),
|
||||
onClick: () => {
|
||||
pushToQueryParams({ crumbId: processEntityId!, crumbEvent: 'all' });
|
||||
},
|
||||
},
|
||||
{
|
||||
text: (
|
||||
<>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.countByCategory"
|
||||
values={{ count: countBySameCategory, category: relatedEventCategory }}
|
||||
defaultMessage="{count} {category}"
|
||||
/>
|
||||
</>
|
||||
),
|
||||
onClick: () => {
|
||||
pushToQueryParams({
|
||||
crumbId: processEntityId!,
|
||||
crumbEvent: relatedEventCategory || 'all',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
text: relatedEventToShowDetailsFor ? (
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.eventDescriptiveName"
|
||||
values={{ subject, descriptor }}
|
||||
defaultMessage="{descriptor} {subject}"
|
||||
/>
|
||||
) : (
|
||||
naString
|
||||
),
|
||||
onClick: () => {},
|
||||
},
|
||||
];
|
||||
}, [
|
||||
processName,
|
||||
processEntityId,
|
||||
eventsString,
|
||||
pushToQueryParams,
|
||||
totalCount,
|
||||
countBySameCategory,
|
||||
naString,
|
||||
relatedEventCategory,
|
||||
relatedEventToShowDetailsFor,
|
||||
subject,
|
||||
descriptor,
|
||||
]);
|
||||
|
||||
/**
|
||||
* 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Could happen if user e.g. loads a URL with a bad crumbEvent
|
||||
*/
|
||||
if (!relatedEventToShowDetailsFor) {
|
||||
const errString = i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.panel.relatedDetail.missing',
|
||||
{
|
||||
defaultMessage: 'Related event not found.',
|
||||
}
|
||||
);
|
||||
return (
|
||||
<PanelContentError translatedErrorMessage={errString} pushToQueryParams={pushToQueryParams} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledBreadcrumbs truncate={false} breadcrumbs={crumbs} />
|
||||
<EuiSpacer size="l" />
|
||||
<EuiText size="s">
|
||||
<BoldCode>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.categoryAndType"
|
||||
values={{
|
||||
category: relatedEventCategory,
|
||||
eventType: String(event.ecsEventType(relatedEventToShowDetailsFor)),
|
||||
}}
|
||||
defaultMessage="{category} {eventType}"
|
||||
/>
|
||||
</BoldCode>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.atTime"
|
||||
values={{ date: formattedDate }}
|
||||
defaultMessage="@ {date}"
|
||||
/>
|
||||
</EuiText>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiText>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.eventDescriptiveNameInTitle"
|
||||
values={{ subject, descriptor }}
|
||||
defaultMessage="{descriptor} {subject}"
|
||||
/>
|
||||
</EuiText>
|
||||
<EuiSpacer size="l" />
|
||||
{sections.map(({ sectionTitle, entries }, index) => {
|
||||
return (
|
||||
<Fragment key={index}>
|
||||
{index === 0 ? null : <EuiSpacer size="m" />}
|
||||
<EuiTitle size="xs">
|
||||
<EuiTextColor color="secondary">
|
||||
<StyledFlexTitle>
|
||||
{sectionTitle}
|
||||
<TitleHr />
|
||||
</StyledFlexTitle>
|
||||
</EuiTextColor>
|
||||
</EuiTitle>
|
||||
<StyledDescriptionList
|
||||
type="column"
|
||||
align="left"
|
||||
titleProps={{ className: 'desc-title' }}
|
||||
compressed
|
||||
listItems={entries}
|
||||
/>
|
||||
{index === sections.length - 1 ? null : <EuiSpacer size="m" />}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
RelatedEventDetail.displayName = 'RelatedEventDetail';
|
|
@ -0,0 +1,247 @@
|
|||
/*
|
||||
* 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, { memo, useMemo, useEffect, Fragment } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { EuiTitle, EuiSpacer, EuiText, EuiButtonEmpty, EuiHorizontalRule } from '@elastic/eui';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { CrumbInfo, formatDate, StyledBreadcrumbs, BoldCode } 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';
|
||||
|
||||
/**
|
||||
* This view presents a list of related events of a given type for a given process.
|
||||
* It will appear like:
|
||||
*
|
||||
* | |
|
||||
* | :----------------------------------------------------- |
|
||||
* | **registry deletion** @ *3:32PM..* *HKLM/software...* |
|
||||
* | **file creation** @ *3:34PM..* *C:/directory/file.exe* |
|
||||
*/
|
||||
|
||||
interface MatchingEventEntry {
|
||||
formattedDate: string;
|
||||
eventType: string;
|
||||
eventCategory: string;
|
||||
name: { subject: string; descriptor?: string };
|
||||
entityId: string;
|
||||
setQueryParams: () => void;
|
||||
}
|
||||
|
||||
const DisplayList = memo(function DisplayList({
|
||||
crumbs,
|
||||
matchingEventEntries,
|
||||
}: {
|
||||
crumbs: Array<{ text: string | JSX.Element; onClick: () => void }>;
|
||||
matchingEventEntries: MatchingEventEntry[];
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<StyledBreadcrumbs breadcrumbs={crumbs} />
|
||||
<EuiSpacer size="l" />
|
||||
<>
|
||||
{matchingEventEntries.map((eventView, index) => {
|
||||
const { subject, descriptor = '' } = eventView.name;
|
||||
return (
|
||||
<Fragment key={index}>
|
||||
<EuiText>
|
||||
<BoldCode>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.categoryAndType"
|
||||
values={{
|
||||
category: eventView.eventCategory,
|
||||
eventType: eventView.eventType,
|
||||
}}
|
||||
defaultMessage="{category} {eventType}"
|
||||
/>
|
||||
</BoldCode>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.atTime"
|
||||
values={{ date: eventView.formattedDate }}
|
||||
defaultMessage="@ {date}"
|
||||
/>
|
||||
</EuiText>
|
||||
<EuiSpacer size="xs" />
|
||||
<EuiButtonEmpty onClick={eventView.setQueryParams}>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.endpoint.resolver.panel.processEventListByType.eventDescriptiveName"
|
||||
values={{ subject, descriptor }}
|
||||
defaultMessage="{descriptor} {subject}"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
{index === matchingEventEntries.length - 1 ? null : <EuiHorizontalRule margin="m" />}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export const ProcessEventListNarrowedByType = memo(function ProcessEventListNarrowedByType({
|
||||
processEvent,
|
||||
eventType,
|
||||
relatedStats,
|
||||
pushToQueryParams,
|
||||
}: {
|
||||
processEvent: ResolverEvent;
|
||||
pushToQueryParams: (arg0: CrumbInfo) => unknown;
|
||||
eventType: string;
|
||||
relatedStats: ResolverNodeStats;
|
||||
}) {
|
||||
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 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 relatedEventsForThisProcess = useSelector(selectors.relatedEventsByEntityId).get(
|
||||
processEntityId
|
||||
);
|
||||
const dispatch = useResolverDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof relatedsReady === 'undefined') {
|
||||
dispatch({
|
||||
type: 'appDetectedMissingEventData',
|
||||
payload: processEntityId,
|
||||
});
|
||||
}
|
||||
}, [relatedsReady, dispatch, processEntityId]);
|
||||
|
||||
const waitCrumbs = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
text: eventsString,
|
||||
onClick: () => {
|
||||
pushToQueryParams({ crumbId: '', crumbEvent: '' });
|
||||
},
|
||||
},
|
||||
];
|
||||
}, [pushToQueryParams, eventsString]);
|
||||
|
||||
const relatedEventsToDisplay = useMemo(() => {
|
||||
return relatedEventsForThisProcess?.events || [];
|
||||
}, [relatedEventsForThisProcess?.events]);
|
||||
|
||||
/**
|
||||
* A list entry will be displayed for each of these
|
||||
*/
|
||||
const matchingEventEntries: MatchingEventEntry[] = useMemo(() => {
|
||||
const relateds = relatedEventsToDisplay
|
||||
.reduce((a: ResolverEvent[], candidate) => {
|
||||
if (event.primaryEventCategory(candidate) === eventType) {
|
||||
a.push(candidate);
|
||||
}
|
||||
return a;
|
||||
}, [])
|
||||
.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),
|
||||
entityId,
|
||||
setQueryParams: () => {
|
||||
pushToQueryParams({ crumbId: entityId, crumbEvent: processEntityId });
|
||||
},
|
||||
};
|
||||
});
|
||||
return relateds;
|
||||
}, [relatedEventsToDisplay, eventType, processEntityId, pushToQueryParams]);
|
||||
|
||||
const crumbs = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
text: eventsString,
|
||||
onClick: () => {
|
||||
pushToQueryParams({ crumbId: '', crumbEvent: '' });
|
||||
},
|
||||
},
|
||||
{
|
||||
text: processName,
|
||||
onClick: () => {
|
||||
pushToQueryParams({ crumbId: processEntityId, crumbEvent: '' });
|
||||
},
|
||||
},
|
||||
{
|
||||
text: (
|
||||
<>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventList.numberOfEvents"
|
||||
values={{ totalCount }}
|
||||
defaultMessage="{totalCount} Events"
|
||||
/>
|
||||
</>
|
||||
),
|
||||
onClick: () => {
|
||||
pushToQueryParams({ crumbId: processEntityId, crumbEvent: 'all' });
|
||||
},
|
||||
},
|
||||
{
|
||||
text: (
|
||||
<>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventList.countByCategory"
|
||||
values={{ count: matchingEventEntries.length, category: eventType }}
|
||||
defaultMessage="{count} {category}"
|
||||
/>
|
||||
</>
|
||||
),
|
||||
onClick: () => {},
|
||||
},
|
||||
];
|
||||
}, [
|
||||
eventType,
|
||||
eventsString,
|
||||
matchingEventEntries.length,
|
||||
processEntityId,
|
||||
processName,
|
||||
pushToQueryParams,
|
||||
totalCount,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Wait here until the effect resolves...
|
||||
*/
|
||||
if (!relatedsReady) {
|
||||
return (
|
||||
<>
|
||||
<StyledBreadcrumbs breadcrumbs={waitCrumbs} />
|
||||
<EuiSpacer size="l" />
|
||||
<EuiTitle>
|
||||
<h4>{waitingString}</h4>
|
||||
</EuiTitle>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <DisplayList crumbs={crumbs} matchingEventEntries={matchingEventEntries} />;
|
||||
});
|
||||
ProcessEventListNarrowedByType.displayName = 'ProcessEventListNarrowedByType';
|
|
@ -0,0 +1,90 @@
|
|||
/*
|
||||
* 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 { i18n } from '@kbn/i18n';
|
||||
import { EuiBreadcrumbs, Breadcrumb, EuiCode } from '@elastic/eui';
|
||||
import styled from 'styled-components';
|
||||
import React, { memo } from 'react';
|
||||
import { useResolverTheme } from '../assets';
|
||||
|
||||
/**
|
||||
* A bold version of EuiCode to display certain titles with
|
||||
*/
|
||||
export const BoldCode = styled(EuiCode)`
|
||||
&.euiCodeBlock code.euiCodeBlock__code {
|
||||
font-weight: 900;
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* The two query parameters we read/write on to control which view the table presents:
|
||||
*/
|
||||
export interface CrumbInfo {
|
||||
readonly crumbId: string;
|
||||
readonly crumbEvent: string;
|
||||
}
|
||||
|
||||
const ThemedBreadcrumbs = styled(EuiBreadcrumbs)<{ background: string; text: string }>`
|
||||
&.euiBreadcrumbs.euiBreadcrumbs--responsive {
|
||||
background-color: ${(props) => props.background};
|
||||
color: ${(props) => props.text};
|
||||
padding: 1em;
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Breadcrumb menu with adjustments per direction from UX team
|
||||
*/
|
||||
export const StyledBreadcrumbs = memo(function StyledBreadcrumbs({
|
||||
breadcrumbs,
|
||||
truncate,
|
||||
}: {
|
||||
breadcrumbs: Breadcrumb[];
|
||||
truncate?: boolean;
|
||||
}) {
|
||||
const {
|
||||
colorMap: { resolverEdge, resolverEdgeText },
|
||||
} = useResolverTheme();
|
||||
return (
|
||||
<ThemedBreadcrumbs
|
||||
background={resolverEdge}
|
||||
text={resolverEdgeText}
|
||||
breadcrumbs={breadcrumbs}
|
||||
truncate={truncate}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Long formatter (to second) for DateTime
|
||||
*/
|
||||
export const formatter = new Intl.DateTimeFormat(i18n.getLocale(), {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
|
||||
const invalidDateText = i18n.translate(
|
||||
'xpack.securitySolution.enpdoint.resolver.panelutils.invaliddate',
|
||||
{
|
||||
defaultMessage: 'Invalid Date',
|
||||
}
|
||||
);
|
||||
/**
|
||||
* @param {ConstructorParameters<typeof Date>[0]} timestamp To be passed through Date->Intl.DateTimeFormat
|
||||
* @returns {string} A nicely formatted string for a date
|
||||
*/
|
||||
export function formatDate(timestamp: ConstructorParameters<typeof Date>[0]) {
|
||||
const date = new Date(timestamp);
|
||||
if (isFinite(date.getTime())) {
|
||||
return formatter.format(date);
|
||||
} else {
|
||||
return invalidDateText;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* 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, { memo } from 'react';
|
||||
import { ResolverEvent } from '../../../../common/endpoint/types';
|
||||
import { useResolverTheme } from '../assets';
|
||||
|
||||
/**
|
||||
* During user testing, one user indicated they wanted to see stronger visual relationships between
|
||||
* Nodes on the graph and what's in the table. Using the same symbol in both places (as below) could help with that.
|
||||
*/
|
||||
export const CubeForProcess = memo(function CubeForProcess({
|
||||
processEvent,
|
||||
}: {
|
||||
processEvent: ResolverEvent;
|
||||
}) {
|
||||
const { cubeAssetsForNode } = useResolverTheme();
|
||||
const { cubeSymbol, descriptionText } = cubeAssetsForNode(processEvent);
|
||||
|
||||
return (
|
||||
<>
|
||||
<svg
|
||||
style={{ position: 'relative', top: '0.4em', marginRight: '.25em' }}
|
||||
className="table-process-icon"
|
||||
width="1.5em"
|
||||
height="1.5em"
|
||||
viewBox="0 0 1 1"
|
||||
>
|
||||
<desc>{descriptionText}</desc>
|
||||
<use
|
||||
role="presentation"
|
||||
xlinkHref={cubeSymbol}
|
||||
x={0}
|
||||
y={0}
|
||||
width={1}
|
||||
height={1}
|
||||
opacity="1"
|
||||
className="cube"
|
||||
/>
|
||||
</svg>
|
||||
</>
|
||||
);
|
||||
});
|
|
@ -7,195 +7,185 @@
|
|||
import React, { useCallback, useMemo } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import {
|
||||
htmlIdGenerator,
|
||||
EuiButton,
|
||||
EuiI18nNumber,
|
||||
EuiKeyboardAccessible,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
} from '@elastic/eui';
|
||||
import { htmlIdGenerator, EuiButton, EuiI18nNumber, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
// eslint-disable-next-line import/no-nodejs-modules
|
||||
import querystring from 'querystring';
|
||||
import { NodeSubMenu, subMenuAssets } from './submenu';
|
||||
import { applyMatrix3 } from '../lib/vector2';
|
||||
import { Vector2, Matrix3, AdjacentProcessMap, ResolverProcessType } from '../types';
|
||||
import { SymbolIds, useResolverTheme, NodeStyleMap, calculateResolverFontSize } from './assets';
|
||||
import { Vector2, Matrix3, AdjacentProcessMap } from '../types';
|
||||
import { SymbolIds, useResolverTheme, calculateResolverFontSize, nodeType } from './assets';
|
||||
import { ResolverEvent, ResolverNodeStats } from '../../../common/endpoint/types';
|
||||
import { useResolverDispatch } from './use_resolver_dispatch';
|
||||
import * as eventModel from '../../../common/endpoint/models/event';
|
||||
import * as processModel from '../models/process_event';
|
||||
import * as selectors from '../store/selectors';
|
||||
import { CrumbInfo } from './panels/panel_content_utilities';
|
||||
|
||||
/**
|
||||
* A map of all known event types (in ugly schema format) to beautifully i18n'd display names
|
||||
*/
|
||||
export const displayNameRecord = {
|
||||
application: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.applicationEventTypeDisplayName',
|
||||
{
|
||||
defaultMessage: 'Application',
|
||||
}
|
||||
),
|
||||
apm: i18n.translate('xpack.securitySolution.endpoint.resolver.apmEventTypeDisplayName', {
|
||||
defaultMessage: 'APM',
|
||||
}),
|
||||
audit: i18n.translate('xpack.securitySolution.endpoint.resolver.auditEventTypeDisplayName', {
|
||||
defaultMessage: 'Audit',
|
||||
}),
|
||||
authentication: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.authenticationEventTypeDisplayName',
|
||||
{
|
||||
defaultMessage: 'Authentication',
|
||||
}
|
||||
),
|
||||
certificate: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.certificateEventTypeDisplayName',
|
||||
{
|
||||
defaultMessage: 'Certificate',
|
||||
}
|
||||
),
|
||||
cloud: i18n.translate('xpack.securitySolution.endpoint.resolver.cloudEventTypeDisplayName', {
|
||||
defaultMessage: 'Cloud',
|
||||
}),
|
||||
database: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.databaseEventTypeDisplayName',
|
||||
{
|
||||
defaultMessage: 'Database',
|
||||
}
|
||||
),
|
||||
driver: i18n.translate('xpack.securitySolution.endpoint.resolver.driverEventTypeDisplayName', {
|
||||
defaultMessage: 'Driver',
|
||||
}),
|
||||
email: i18n.translate('xpack.securitySolution.endpoint.resolver.emailEventTypeDisplayName', {
|
||||
defaultMessage: 'Email',
|
||||
}),
|
||||
file: i18n.translate('xpack.securitySolution.endpoint.resolver.fileEventTypeDisplayName', {
|
||||
defaultMessage: 'File',
|
||||
}),
|
||||
host: i18n.translate('xpack.securitySolution.endpoint.resolver.hostEventTypeDisplayName', {
|
||||
defaultMessage: 'Host',
|
||||
}),
|
||||
iam: i18n.translate('xpack.securitySolution.endpoint.resolver.iamEventTypeDisplayName', {
|
||||
defaultMessage: 'IAM',
|
||||
}),
|
||||
iam_group: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.iam_groupEventTypeDisplayName',
|
||||
{
|
||||
defaultMessage: 'IAM Group',
|
||||
}
|
||||
),
|
||||
intrusion_detection: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.intrusion_detectionEventTypeDisplayName',
|
||||
{
|
||||
defaultMessage: 'Intrusion Detection',
|
||||
}
|
||||
),
|
||||
malware: i18n.translate('xpack.securitySolution.endpoint.resolver.malwareEventTypeDisplayName', {
|
||||
defaultMessage: 'Malware',
|
||||
}),
|
||||
network_flow: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.network_flowEventTypeDisplayName',
|
||||
{
|
||||
defaultMessage: 'Network Flow',
|
||||
}
|
||||
),
|
||||
network: i18n.translate('xpack.securitySolution.endpoint.resolver.networkEventTypeDisplayName', {
|
||||
defaultMessage: 'Network',
|
||||
}),
|
||||
package: i18n.translate('xpack.securitySolution.endpoint.resolver.packageEventTypeDisplayName', {
|
||||
defaultMessage: 'Package',
|
||||
}),
|
||||
process: i18n.translate('xpack.securitySolution.endpoint.resolver.processEventTypeDisplayName', {
|
||||
defaultMessage: 'Process',
|
||||
}),
|
||||
registry: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.registryEventTypeDisplayName',
|
||||
{
|
||||
defaultMessage: 'Registry',
|
||||
}
|
||||
),
|
||||
session: i18n.translate('xpack.securitySolution.endpoint.resolver.sessionEventTypeDisplayName', {
|
||||
defaultMessage: 'Session',
|
||||
}),
|
||||
service: i18n.translate('xpack.securitySolution.endpoint.resolver.serviceEventTypeDisplayName', {
|
||||
defaultMessage: 'Service',
|
||||
}),
|
||||
socket: i18n.translate('xpack.securitySolution.endpoint.resolver.socketEventTypeDisplayName', {
|
||||
defaultMessage: 'Socket',
|
||||
}),
|
||||
vulnerability: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.vulnerabilityEventTypeDisplayName',
|
||||
{
|
||||
defaultMessage: 'Vulnerability',
|
||||
}
|
||||
),
|
||||
web: i18n.translate('xpack.securitySolution.endpoint.resolver.webEventTypeDisplayName', {
|
||||
defaultMessage: 'Web',
|
||||
}),
|
||||
alert: i18n.translate('xpack.securitySolution.endpoint.resolver.alertEventTypeDisplayName', {
|
||||
defaultMessage: 'Alert',
|
||||
}),
|
||||
security: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.securityEventTypeDisplayName',
|
||||
{
|
||||
defaultMessage: 'Security',
|
||||
}
|
||||
),
|
||||
dns: i18n.translate('xpack.securitySolution.endpoint.resolver.dnsEventTypeDisplayName', {
|
||||
defaultMessage: 'DNS',
|
||||
}),
|
||||
clr: i18n.translate('xpack.securitySolution.endpoint.resolver.clrEventTypeDisplayName', {
|
||||
defaultMessage: 'CLR',
|
||||
}),
|
||||
image_load: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.image_loadEventTypeDisplayName',
|
||||
{
|
||||
defaultMessage: 'Image Load',
|
||||
}
|
||||
),
|
||||
powershell: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.powershellEventTypeDisplayName',
|
||||
{
|
||||
defaultMessage: 'Powershell',
|
||||
}
|
||||
),
|
||||
wmi: i18n.translate('xpack.securitySolution.endpoint.resolver.wmiEventTypeDisplayName', {
|
||||
defaultMessage: 'WMI',
|
||||
}),
|
||||
api: i18n.translate('xpack.securitySolution.endpoint.resolver.apiEventTypeDisplayName', {
|
||||
defaultMessage: 'API',
|
||||
}),
|
||||
user: i18n.translate('xpack.securitySolution.endpoint.resolver.userEventTypeDisplayName', {
|
||||
defaultMessage: 'User',
|
||||
}),
|
||||
} as const;
|
||||
|
||||
const unknownEventTypeMessage = i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.userEventTypeDisplayUnknown',
|
||||
{
|
||||
defaultMessage: 'Unknown',
|
||||
}
|
||||
);
|
||||
|
||||
type EventDisplayName = typeof displayNameRecord[keyof typeof displayNameRecord] &
|
||||
typeof unknownEventTypeMessage;
|
||||
|
||||
/**
|
||||
* Take a gross `schemaName` and return a beautiful translated one.
|
||||
*/
|
||||
const getDisplayName: (schemaName: string) => string = function nameInSchemaToDisplayName(
|
||||
schemaName: string
|
||||
const getDisplayName: (schemaName: string) => EventDisplayName = function nameInSchemaToDisplayName(
|
||||
schemaName
|
||||
) {
|
||||
const displayNameRecord: Record<string, string> = {
|
||||
application: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.applicationEventTypeDisplayName',
|
||||
{
|
||||
defaultMessage: 'Application',
|
||||
}
|
||||
),
|
||||
apm: i18n.translate('xpack.securitySolution.endpoint.resolver.apmEventTypeDisplayName', {
|
||||
defaultMessage: 'APM',
|
||||
}),
|
||||
audit: i18n.translate('xpack.securitySolution.endpoint.resolver.auditEventTypeDisplayName', {
|
||||
defaultMessage: 'Audit',
|
||||
}),
|
||||
authentication: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.authenticationEventTypeDisplayName',
|
||||
{
|
||||
defaultMessage: 'Authentication',
|
||||
}
|
||||
),
|
||||
certificate: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.certificateEventTypeDisplayName',
|
||||
{
|
||||
defaultMessage: 'Certificate',
|
||||
}
|
||||
),
|
||||
cloud: i18n.translate('xpack.securitySolution.endpoint.resolver.cloudEventTypeDisplayName', {
|
||||
defaultMessage: 'Cloud',
|
||||
}),
|
||||
database: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.databaseEventTypeDisplayName',
|
||||
{
|
||||
defaultMessage: 'Database',
|
||||
}
|
||||
),
|
||||
driver: i18n.translate('xpack.securitySolution.endpoint.resolver.driverEventTypeDisplayName', {
|
||||
defaultMessage: 'Driver',
|
||||
}),
|
||||
email: i18n.translate('xpack.securitySolution.endpoint.resolver.emailEventTypeDisplayName', {
|
||||
defaultMessage: 'Email',
|
||||
}),
|
||||
file: i18n.translate('xpack.securitySolution.endpoint.resolver.fileEventTypeDisplayName', {
|
||||
defaultMessage: 'File',
|
||||
}),
|
||||
host: i18n.translate('xpack.securitySolution.endpoint.resolver.hostEventTypeDisplayName', {
|
||||
defaultMessage: 'Host',
|
||||
}),
|
||||
iam: i18n.translate('xpack.securitySolution.endpoint.resolver.iamEventTypeDisplayName', {
|
||||
defaultMessage: 'IAM',
|
||||
}),
|
||||
iam_group: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.iam_groupEventTypeDisplayName',
|
||||
{
|
||||
defaultMessage: 'IAM Group',
|
||||
}
|
||||
),
|
||||
intrusion_detection: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.intrusion_detectionEventTypeDisplayName',
|
||||
{
|
||||
defaultMessage: 'Intrusion Detection',
|
||||
}
|
||||
),
|
||||
malware: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.malwareEventTypeDisplayName',
|
||||
{
|
||||
defaultMessage: 'Malware',
|
||||
}
|
||||
),
|
||||
network_flow: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.network_flowEventTypeDisplayName',
|
||||
{
|
||||
defaultMessage: 'Network Flow',
|
||||
}
|
||||
),
|
||||
network: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.networkEventTypeDisplayName',
|
||||
{
|
||||
defaultMessage: 'Network',
|
||||
}
|
||||
),
|
||||
package: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.packageEventTypeDisplayName',
|
||||
{
|
||||
defaultMessage: 'Package',
|
||||
}
|
||||
),
|
||||
process: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.processEventTypeDisplayName',
|
||||
{
|
||||
defaultMessage: 'Process',
|
||||
}
|
||||
),
|
||||
registry: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.registryEventTypeDisplayName',
|
||||
{
|
||||
defaultMessage: 'Registry',
|
||||
}
|
||||
),
|
||||
session: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.sessionEventTypeDisplayName',
|
||||
{
|
||||
defaultMessage: 'Session',
|
||||
}
|
||||
),
|
||||
service: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.serviceEventTypeDisplayName',
|
||||
{
|
||||
defaultMessage: 'Service',
|
||||
}
|
||||
),
|
||||
socket: i18n.translate('xpack.securitySolution.endpoint.resolver.socketEventTypeDisplayName', {
|
||||
defaultMessage: 'Socket',
|
||||
}),
|
||||
vulnerability: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.vulnerabilityEventTypeDisplayName',
|
||||
{
|
||||
defaultMessage: 'Vulnerability',
|
||||
}
|
||||
),
|
||||
web: i18n.translate('xpack.securitySolution.endpoint.resolver.webEventTypeDisplayName', {
|
||||
defaultMessage: 'Web',
|
||||
}),
|
||||
alert: i18n.translate('xpack.securitySolution.endpoint.resolver.alertEventTypeDisplayName', {
|
||||
defaultMessage: 'Alert',
|
||||
}),
|
||||
security: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.securityEventTypeDisplayName',
|
||||
{
|
||||
defaultMessage: 'Security',
|
||||
}
|
||||
),
|
||||
dns: i18n.translate('xpack.securitySolution.endpoint.resolver.dnsEventTypeDisplayName', {
|
||||
defaultMessage: 'DNS',
|
||||
}),
|
||||
clr: i18n.translate('xpack.securitySolution.endpoint.resolver.clrEventTypeDisplayName', {
|
||||
defaultMessage: 'CLR',
|
||||
}),
|
||||
image_load: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.image_loadEventTypeDisplayName',
|
||||
{
|
||||
defaultMessage: 'Image Load',
|
||||
}
|
||||
),
|
||||
powershell: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.powershellEventTypeDisplayName',
|
||||
{
|
||||
defaultMessage: 'Powershell',
|
||||
}
|
||||
),
|
||||
wmi: i18n.translate('xpack.securitySolution.endpoint.resolver.wmiEventTypeDisplayName', {
|
||||
defaultMessage: 'WMI',
|
||||
}),
|
||||
api: i18n.translate('xpack.securitySolution.endpoint.resolver.apiEventTypeDisplayName', {
|
||||
defaultMessage: 'API',
|
||||
}),
|
||||
user: i18n.translate('xpack.securitySolution.endpoint.resolver.userEventTypeDisplayName', {
|
||||
defaultMessage: 'User',
|
||||
}),
|
||||
};
|
||||
return (
|
||||
displayNameRecord[schemaName] ||
|
||||
i18n.translate('xpack.securitySolution.endpoint.resolver.userEventTypeDisplayUnknown', {
|
||||
defaultMessage: 'Unknown',
|
||||
})
|
||||
);
|
||||
if (schemaName in displayNameRecord) {
|
||||
return displayNameRecord[schemaName as keyof typeof displayNameRecord];
|
||||
}
|
||||
return unknownEventTypeMessage;
|
||||
};
|
||||
|
||||
interface StyledActionsContainer {
|
||||
|
@ -283,11 +273,14 @@ const ProcessEventDotComponents = React.memo(
|
|||
|
||||
const [magFactorX] = projectionMatrix;
|
||||
|
||||
// Node (html id=) IDs
|
||||
const selfId = adjacentNodeMap.self;
|
||||
|
||||
const activeDescendantId = useSelector(selectors.uiActiveDescendantId);
|
||||
const selectedDescendantId = useSelector(selectors.uiSelectedDescendantId);
|
||||
|
||||
// Entity ID of self
|
||||
const selfEntityId = eventModel.entityId(event);
|
||||
|
||||
const isShowingEventActions = magFactorX > 0.8;
|
||||
const isShowingDescriptionText = magFactorX >= 0.55;
|
||||
|
||||
|
@ -401,6 +394,50 @@ const ProcessEventDotComponents = React.memo(
|
|||
});
|
||||
}, [dispatch, nodeId]);
|
||||
|
||||
const handleRelatedEventRequest = useCallback(() => {
|
||||
dispatch({
|
||||
type: 'userRequestedRelatedEventData',
|
||||
payload: selfId,
|
||||
});
|
||||
}, [dispatch, selfId]);
|
||||
|
||||
const handleRelatedAlertsRequest = useCallback(() => {
|
||||
dispatch({
|
||||
type: 'userSelectedRelatedAlerts',
|
||||
payload: event,
|
||||
});
|
||||
}, [dispatch, event]);
|
||||
|
||||
const history = useHistory();
|
||||
const urlSearch = history.location.search;
|
||||
|
||||
/**
|
||||
* This updates the breadcrumb nav, the table view
|
||||
*/
|
||||
const pushToQueryParams = useCallback(
|
||||
(newCrumbs: CrumbInfo) => {
|
||||
// Construct a new set of params from the current set (minus empty params)
|
||||
// by assigning the new set of params provided in `newCrumbs`
|
||||
const crumbsToPass = {
|
||||
...querystring.parse(urlSearch.slice(1)),
|
||||
...newCrumbs,
|
||||
};
|
||||
|
||||
// If either was passed in as empty, remove it from the record
|
||||
if (crumbsToPass.crumbId === '') {
|
||||
delete crumbsToPass.crumbId;
|
||||
}
|
||||
if (crumbsToPass.crumbEvent === '') {
|
||||
delete crumbsToPass.crumbEvent;
|
||||
}
|
||||
|
||||
const relativeURL = { search: querystring.stringify(crumbsToPass) };
|
||||
|
||||
return history.replace(relativeURL);
|
||||
},
|
||||
[history, urlSearch]
|
||||
);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (animationTarget.current !== null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
@ -410,39 +447,31 @@ const ProcessEventDotComponents = React.memo(
|
|||
type: 'userSelectedResolverNode',
|
||||
payload: {
|
||||
nodeId,
|
||||
selectedProcessId: selfId,
|
||||
},
|
||||
});
|
||||
}, [animationTarget, dispatch, nodeId]);
|
||||
pushToQueryParams({ crumbId: selfEntityId, crumbEvent: 'all' });
|
||||
}, [animationTarget, dispatch, nodeId, selfEntityId, pushToQueryParams, selfId]);
|
||||
|
||||
const handleRelatedEventRequest = useCallback(() => {
|
||||
dispatch({
|
||||
type: 'userRequestedRelatedEventData',
|
||||
payload: event,
|
||||
});
|
||||
}, [dispatch, event]);
|
||||
|
||||
const handleRelatedAlertsRequest = useCallback(() => {
|
||||
dispatch({
|
||||
type: 'userSelectedRelatedAlerts',
|
||||
payload: event,
|
||||
});
|
||||
}, [dispatch, event]);
|
||||
/**
|
||||
* 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 [relatedEventOptions, grandTotal] = useMemo(() => {
|
||||
const relatedStatsList = [];
|
||||
|
||||
if (!relatedEventsStats) {
|
||||
// Return an empty set of options if there are no stats to report
|
||||
return [];
|
||||
return [[], 0];
|
||||
}
|
||||
let runningTotal = 0;
|
||||
// If we have entries to show, map them into options to display in the selectable list
|
||||
for (const category in relatedEventsStats.events.byCategory) {
|
||||
if (Object.hasOwnProperty.call(relatedEventsStats.events.byCategory, category)) {
|
||||
const total = relatedEventsStats.events.byCategory[category];
|
||||
runningTotal += total;
|
||||
const displayName = getDisplayName(category);
|
||||
relatedStatsList.push({
|
||||
prefix: <EuiI18nNumber value={total || 0} />,
|
||||
|
@ -455,12 +484,14 @@ const ProcessEventDotComponents = React.memo(
|
|||
category,
|
||||
},
|
||||
});
|
||||
|
||||
pushToQueryParams({ crumbId: selfEntityId, crumbEvent: category });
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return relatedStatsList;
|
||||
}, [relatedEventsStats, dispatch, event]);
|
||||
return [relatedStatsList, runningTotal];
|
||||
}, [relatedEventsStats, dispatch, event, pushToQueryParams, selfEntityId]);
|
||||
|
||||
const relatedEventStatusOrOptions = (() => {
|
||||
if (!relatedEventsStats) {
|
||||
|
@ -475,144 +506,144 @@ const ProcessEventDotComponents = React.memo(
|
|||
* Key event handling (e.g. 'Enter'/'Space') is provisioned by the `EuiKeyboardAccessible` component
|
||||
*/
|
||||
return (
|
||||
<EuiKeyboardAccessible>
|
||||
<div
|
||||
data-test-subj={'resolverNode'}
|
||||
className={`${className} kbn-resetFocusState`}
|
||||
role="treeitem"
|
||||
aria-level={adjacentNodeMap.level}
|
||||
aria-flowto={
|
||||
adjacentNodeMap.nextSibling === null ? undefined : adjacentNodeMap.nextSibling
|
||||
}
|
||||
aria-labelledby={labelId}
|
||||
aria-describedby={descriptionId}
|
||||
aria-haspopup={'true'}
|
||||
aria-current={isActiveDescendant ? 'true' : undefined}
|
||||
aria-selected={isSelectedDescendant ? 'true' : undefined}
|
||||
style={nodeViewportStyle}
|
||||
id={nodeId}
|
||||
onClick={handleClick}
|
||||
onFocus={handleFocus}
|
||||
tabIndex={-1}
|
||||
<div
|
||||
data-test-subj={'resolverNode'}
|
||||
className={`${className} kbn-resetFocusState`}
|
||||
role="treeitem"
|
||||
aria-level={adjacentNodeMap.level}
|
||||
aria-flowto={adjacentNodeMap.nextSibling === null ? undefined : adjacentNodeMap.nextSibling}
|
||||
aria-labelledby={labelId}
|
||||
aria-describedby={descriptionId}
|
||||
aria-haspopup={'true'}
|
||||
aria-current={isActiveDescendant ? 'true' : undefined}
|
||||
aria-selected={isSelectedDescendant ? 'true' : undefined}
|
||||
style={nodeViewportStyle}
|
||||
id={nodeId}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<svg
|
||||
viewBox="-15 -15 90 30"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
left: '0',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
viewBox="-15 -15 90 30"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
<g>
|
||||
<use
|
||||
xlinkHref={`#${SymbolIds.processCubeActiveBacking}`}
|
||||
fill={backingFill} // Only visible on hover
|
||||
x={-15.35}
|
||||
y={-15.35}
|
||||
stroke={strokeColor}
|
||||
width={markerSize * 1.5}
|
||||
height={markerSize * 1.5}
|
||||
className="backing"
|
||||
/>
|
||||
<use
|
||||
role="presentation"
|
||||
xlinkHref={cubeSymbol}
|
||||
x={markerPositionXOffset}
|
||||
y={markerPositionYOffset}
|
||||
width={markerSize}
|
||||
height={markerSize}
|
||||
opacity="1"
|
||||
className="cube"
|
||||
>
|
||||
<animateTransform
|
||||
attributeType="XML"
|
||||
attributeName="transform"
|
||||
type="scale"
|
||||
values="1 1; 1 .83; 1 .8; 1 .83; 1 1"
|
||||
dur="0.2s"
|
||||
repeatCount="1"
|
||||
className="squish"
|
||||
ref={animationTarget}
|
||||
/>
|
||||
</use>
|
||||
</g>
|
||||
</svg>
|
||||
<StyledActionsContainer
|
||||
color={colorMap.full}
|
||||
fontSize={scaledTypeSize}
|
||||
topPct={actionableButtonsTopOffset}
|
||||
>
|
||||
<StyledDescriptionText
|
||||
backgroundColor={colorMap.resolverBackground}
|
||||
color={colorMap.descriptionText}
|
||||
isDisplaying={isShowingDescriptionText}
|
||||
>
|
||||
{descriptionText}
|
||||
</StyledDescriptionText>
|
||||
<div
|
||||
className={magFactorX >= 2 ? 'euiButton' : 'euiButton euiButton--small'}
|
||||
data-test-subject="nodeLabel"
|
||||
id={labelId}
|
||||
onClick={handleClick}
|
||||
onFocus={handleFocus}
|
||||
tabIndex={-1}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
left: '0',
|
||||
backgroundColor: colorMap.resolverBackground,
|
||||
alignSelf: 'flex-start',
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
<g>
|
||||
<use
|
||||
xlinkHref={`#${SymbolIds.processCubeActiveBacking}`}
|
||||
fill={backingFill} // Only visible on hover
|
||||
x={-15.35}
|
||||
y={-15.35}
|
||||
stroke={strokeColor}
|
||||
width={markerSize * 1.5}
|
||||
height={markerSize * 1.5}
|
||||
className="backing"
|
||||
/>
|
||||
<use
|
||||
role="presentation"
|
||||
xlinkHref={cubeSymbol}
|
||||
x={markerPositionXOffset}
|
||||
y={markerPositionYOffset}
|
||||
width={markerSize}
|
||||
height={markerSize}
|
||||
opacity="1"
|
||||
className="cube"
|
||||
>
|
||||
<animateTransform
|
||||
attributeType="XML"
|
||||
attributeName="transform"
|
||||
type="scale"
|
||||
values="1 1; 1 .83; 1 .8; 1 .83; 1 1"
|
||||
dur="0.2s"
|
||||
begin="click"
|
||||
repeatCount="1"
|
||||
className="squish"
|
||||
ref={animationTarget}
|
||||
/>
|
||||
</use>
|
||||
</g>
|
||||
</svg>
|
||||
<StyledActionsContainer
|
||||
color={colorMap.full}
|
||||
fontSize={scaledTypeSize}
|
||||
topPct={actionableButtonsTopOffset}
|
||||
>
|
||||
<StyledDescriptionText
|
||||
backgroundColor={colorMap.resolverBackground}
|
||||
color={colorMap.descriptionText}
|
||||
isDisplaying={isShowingDescriptionText}
|
||||
>
|
||||
{descriptionText}
|
||||
</StyledDescriptionText>
|
||||
<div
|
||||
<EuiButton
|
||||
color={labelButtonFill}
|
||||
data-test-subject="nodeLabel"
|
||||
fill={isLabelFilled}
|
||||
id={labelId}
|
||||
size="s"
|
||||
style={{
|
||||
backgroundColor: colorMap.resolverBackground,
|
||||
alignSelf: 'flex-start',
|
||||
padding: 0,
|
||||
maxHeight: `${Math.min(26 + magFactorX * 3, 32)}px`,
|
||||
maxWidth: `${isShowingEventActions ? 400 : 210 * magFactorX}px`,
|
||||
}}
|
||||
tabIndex={-1}
|
||||
title={eventModel.eventName(event)}
|
||||
>
|
||||
<EuiButton
|
||||
color={labelButtonFill}
|
||||
data-test-subject="nodeLabel"
|
||||
fill={isLabelFilled}
|
||||
id={labelId}
|
||||
size="s"
|
||||
style={{
|
||||
maxHeight: `${Math.min(26 + magFactorX * 3, 32)}px`,
|
||||
maxWidth: `${isShowingEventActions ? 400 : 210 * magFactorX}px`,
|
||||
}}
|
||||
tabIndex={-1}
|
||||
title={eventModel.eventName(event)}
|
||||
>
|
||||
<span className="euiButton__content">
|
||||
<span className="euiButton__text" data-test-subj={'euiButton__text'}>
|
||||
{eventModel.eventName(event)}
|
||||
</span>
|
||||
<span className="euiButton__content">
|
||||
<span className="euiButton__text" data-test-subj={'euiButton__text'}>
|
||||
{eventModel.eventName(event)}
|
||||
</span>
|
||||
</EuiButton>
|
||||
</div>
|
||||
<EuiFlexGroup
|
||||
justifyContent="flexStart"
|
||||
gutterSize="xs"
|
||||
style={{
|
||||
alignSelf: 'flex-start',
|
||||
background: colorMap.resolverBackground,
|
||||
display: `${isShowingEventActions ? 'flex' : 'none'}`,
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
<EuiFlexItem grow={false} className="related-dropdown">
|
||||
<NodeSubMenu
|
||||
buttonBorderColor={labelButtonFill}
|
||||
buttonFill={colorMap.resolverBackground}
|
||||
menuAction={handleRelatedEventRequest}
|
||||
menuTitle={subMenuAssets.relatedEvents.title}
|
||||
optionsWithActions={relatedEventStatusOrOptions}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<NodeSubMenu
|
||||
buttonBorderColor={labelButtonFill}
|
||||
buttonFill={colorMap.resolverBackground}
|
||||
menuTitle={subMenuAssets.relatedAlerts.title}
|
||||
menuAction={handleRelatedAlertsRequest}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</StyledActionsContainer>
|
||||
</div>
|
||||
</EuiKeyboardAccessible>
|
||||
</span>
|
||||
</EuiButton>
|
||||
</div>
|
||||
<EuiFlexGroup
|
||||
justifyContent="flexStart"
|
||||
gutterSize="xs"
|
||||
style={{
|
||||
alignSelf: 'flex-start',
|
||||
background: colorMap.resolverBackground,
|
||||
display: `${isShowingEventActions ? 'flex' : 'none'}`,
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
<EuiFlexItem grow={false} className="related-dropdown">
|
||||
<NodeSubMenu
|
||||
count={grandTotal}
|
||||
buttonBorderColor={labelButtonFill}
|
||||
buttonFill={colorMap.resolverBackground}
|
||||
menuAction={handleRelatedEventRequest}
|
||||
menuTitle={subMenuAssets.relatedEvents.title}
|
||||
optionsWithActions={relatedEventStatusOrOptions}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<NodeSubMenu
|
||||
buttonBorderColor={labelButtonFill}
|
||||
buttonFill={colorMap.resolverBackground}
|
||||
menuTitle={subMenuAssets.relatedAlerts.title}
|
||||
menuAction={handleRelatedAlertsRequest}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</StyledActionsContainer>
|
||||
</div>
|
||||
);
|
||||
/* eslint-enable jsx-a11y/click-events-have-key-events */
|
||||
}
|
||||
|
@ -673,21 +704,3 @@ export const ProcessEventDot = styled(ProcessEventDotComponents)`
|
|||
color: white;
|
||||
}
|
||||
`;
|
||||
|
||||
const processTypeToCube: Record<ResolverProcessType, keyof NodeStyleMap> = {
|
||||
processCreated: 'runningProcessCube',
|
||||
processRan: 'runningProcessCube',
|
||||
processTerminated: 'terminatedProcessCube',
|
||||
unknownProcessEvent: 'runningProcessCube',
|
||||
processCausedAlert: 'runningTriggerCube',
|
||||
unknownEvent: 'runningProcessCube',
|
||||
};
|
||||
|
||||
function nodeType(processEvent: ResolverEvent): keyof NodeStyleMap {
|
||||
const processType = processModel.eventType(processEvent);
|
||||
|
||||
if (processType in processTypeToCube) {
|
||||
return processTypeToCube[processType];
|
||||
}
|
||||
return 'runningProcessCube';
|
||||
}
|
||||
|
|
|
@ -6,7 +6,14 @@
|
|||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import React, { ReactNode, useState, useMemo, useCallback } from 'react';
|
||||
import { EuiSelectable, EuiButton, EuiPopover, ButtonColor, htmlIdGenerator } from '@elastic/eui';
|
||||
import {
|
||||
EuiI18nNumber,
|
||||
EuiSelectable,
|
||||
EuiButton,
|
||||
EuiPopover,
|
||||
ButtonColor,
|
||||
htmlIdGenerator,
|
||||
} from '@elastic/eui';
|
||||
import styled from 'styled-components';
|
||||
|
||||
/**
|
||||
|
@ -74,21 +81,44 @@ const OptionList = React.memo(
|
|||
};
|
||||
})
|
||||
);
|
||||
return useMemo(
|
||||
() => (
|
||||
<EuiSelectable
|
||||
singleSelection={true}
|
||||
options={options}
|
||||
onChange={(newOptions) => {
|
||||
setOptions(newOptions);
|
||||
}}
|
||||
listProps={{ showIcons: true, bordered: true }}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{(list) => <OptionListItem>{list}</OptionListItem>}
|
||||
</EuiSelectable>
|
||||
),
|
||||
[isLoading, options]
|
||||
|
||||
const actionsByLabel: Record<string, () => unknown> = useMemo(() => {
|
||||
if (typeof subMenuOptions !== 'object') {
|
||||
return {};
|
||||
}
|
||||
return subMenuOptions.reduce((titleActionRecord, opt) => {
|
||||
const { optionTitle, action } = opt;
|
||||
return { ...titleActionRecord, [optionTitle]: action };
|
||||
}, {});
|
||||
}, [subMenuOptions]);
|
||||
|
||||
type ChangeOptions = Array<{ label: string; prepend?: ReactNode; checked?: string }>;
|
||||
const selectableProps = useMemo(() => {
|
||||
return {
|
||||
listProps: { showIcons: true, bordered: true },
|
||||
onChange: (newOptions: ChangeOptions) => {
|
||||
const selectedOption = newOptions.find((opt) => opt.checked === 'on');
|
||||
if (selectedOption) {
|
||||
const { label } = selectedOption;
|
||||
const actionToTake = actionsByLabel[label];
|
||||
if (typeof actionToTake === 'function') {
|
||||
actionToTake();
|
||||
}
|
||||
}
|
||||
setOptions(newOptions);
|
||||
},
|
||||
};
|
||||
}, [actionsByLabel]);
|
||||
|
||||
return (
|
||||
<EuiSelectable
|
||||
singleSelection={true}
|
||||
options={options}
|
||||
{...selectableProps}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{(list) => <OptionListItem>{list}</OptionListItem>}
|
||||
</EuiSelectable>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
@ -102,6 +132,7 @@ OptionList.displayName = 'OptionList';
|
|||
*/
|
||||
const NodeSubMenuComponents = React.memo(
|
||||
({
|
||||
count,
|
||||
buttonBorderColor,
|
||||
menuTitle,
|
||||
menuAction,
|
||||
|
@ -113,6 +144,7 @@ const NodeSubMenuComponents = React.memo(
|
|||
menuAction?: () => unknown;
|
||||
buttonBorderColor: ButtonColor;
|
||||
buttonFill: string;
|
||||
count?: number;
|
||||
} & {
|
||||
optionsWithActions?: ResolverSubmenuOptionList | string | undefined;
|
||||
}) => {
|
||||
|
@ -176,7 +208,7 @@ const NodeSubMenuComponents = React.memo(
|
|||
iconSide="right"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{menuTitle}
|
||||
{count ? <EuiI18nNumber value={count} /> : ''} {menuTitle}
|
||||
</EuiButton>
|
||||
);
|
||||
|
||||
|
|
Loading…
Reference in a new issue