[Security Solution][Resolver] Resolver query panel load more (#79160)
This commit is contained in:
parent
52379a0e77
commit
32d45b6e55
|
@ -50,7 +50,7 @@ export function dataAccessLayerFactory(
|
|||
after?: string
|
||||
): Promise<ResolverPaginatedEvents> {
|
||||
return context.services.http.post('/api/endpoint/resolver/events', {
|
||||
query: { afterEvent: after },
|
||||
query: { afterEvent: after, limit: 25 },
|
||||
body: JSON.stringify({
|
||||
filter: `process.entity_id:"${entityID}" and event.category:"${category}"`,
|
||||
}),
|
||||
|
|
|
@ -0,0 +1,110 @@
|
|||
/*
|
||||
* 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 { DataAccessLayer } from '../../types';
|
||||
import { mockTreeWithOneNodeAndTwoPagesOfRelatedEvents } from '../../mocks/resolver_tree';
|
||||
import {
|
||||
ResolverRelatedEvents,
|
||||
ResolverTree,
|
||||
ResolverEntityIndex,
|
||||
SafeResolverEvent,
|
||||
} from '../../../../common/endpoint/types';
|
||||
import * as eventModel from '../../../../common/endpoint/models/event';
|
||||
|
||||
interface Metadata {
|
||||
/**
|
||||
* The `_id` of the document being analyzed.
|
||||
*/
|
||||
databaseDocumentID: string;
|
||||
/**
|
||||
* A record of entityIDs to be used in tests assertions.
|
||||
*/
|
||||
entityIDs: {
|
||||
/**
|
||||
* The entityID of the node related to the document being analyzed.
|
||||
*/
|
||||
origin: 'origin';
|
||||
};
|
||||
}
|
||||
export function oneNodeWithPaginatedEvents(): {
|
||||
dataAccessLayer: DataAccessLayer;
|
||||
metadata: Metadata;
|
||||
} {
|
||||
const metadata: Metadata = {
|
||||
databaseDocumentID: '_id',
|
||||
entityIDs: { origin: 'origin' },
|
||||
};
|
||||
const tree = mockTreeWithOneNodeAndTwoPagesOfRelatedEvents({
|
||||
originID: metadata.entityIDs.origin,
|
||||
});
|
||||
|
||||
return {
|
||||
metadata,
|
||||
dataAccessLayer: {
|
||||
/**
|
||||
* Fetch related events for an entity ID
|
||||
*/
|
||||
async relatedEvents(entityID: string): Promise<ResolverRelatedEvents> {
|
||||
/**
|
||||
* Respond with the mocked related events when the origin's related events are fetched.
|
||||
**/
|
||||
const events = entityID === metadata.entityIDs.origin ? tree.relatedEvents.events : [];
|
||||
|
||||
return {
|
||||
entityID,
|
||||
events,
|
||||
nextEvent: null,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* If called with an "after" cursor, return the 2nd page, else return the first.
|
||||
*/
|
||||
async eventsWithEntityIDAndCategory(
|
||||
entityID: string,
|
||||
category: string,
|
||||
after?: string
|
||||
): Promise<{ events: SafeResolverEvent[]; nextEvent: string | null }> {
|
||||
let events: SafeResolverEvent[] = [];
|
||||
const eventsOfCategory = tree.relatedEvents.events.filter(
|
||||
(event) => event.event?.category === category
|
||||
);
|
||||
if (after === undefined) {
|
||||
events = eventsOfCategory.slice(0, 25);
|
||||
} else {
|
||||
events = eventsOfCategory.slice(25);
|
||||
}
|
||||
return {
|
||||
events,
|
||||
nextEvent: typeof after === 'undefined' ? 'firstEventPage2' : null,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Any of the origin's related events by event.id
|
||||
*/
|
||||
async event(eventID: string): Promise<SafeResolverEvent | null> {
|
||||
return (
|
||||
tree.relatedEvents.events.find((event) => eventModel.eventID(event) === eventID) ?? null
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch a ResolverTree for a entityID
|
||||
*/
|
||||
async resolverTree(): Promise<ResolverTree> {
|
||||
return tree;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get entities matching a document.
|
||||
*/
|
||||
async entities(): Promise<ResolverEntityIndex> {
|
||||
return [{ entity_id: metadata.entityIDs.origin }];
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
|
@ -8,6 +8,47 @@ import { mockEndpointEvent } from './endpoint_event';
|
|||
import { ResolverTree, SafeResolverEvent } from '../../../common/endpoint/types';
|
||||
import * as eventModel from '../../../common/endpoint/models/event';
|
||||
|
||||
export function mockTreeWithOneNodeAndTwoPagesOfRelatedEvents({
|
||||
originID,
|
||||
}: {
|
||||
originID: string;
|
||||
}): ResolverTree {
|
||||
const originEvent: SafeResolverEvent = mockEndpointEvent({
|
||||
entityID: originID,
|
||||
processName: 'c',
|
||||
parentEntityID: undefined,
|
||||
timestamp: 1600863932318,
|
||||
});
|
||||
const events = [];
|
||||
// page size is currently 25
|
||||
const eventsToGenerate = 30;
|
||||
for (let i = 0; i < eventsToGenerate; i++) {
|
||||
const newEvent = mockEndpointEvent({
|
||||
entityID: originID,
|
||||
eventID: `test-${i}`,
|
||||
eventType: 'access',
|
||||
eventCategory: 'registry',
|
||||
timestamp: 1600863932318,
|
||||
});
|
||||
events.push(newEvent);
|
||||
}
|
||||
return {
|
||||
entityID: originID,
|
||||
children: {
|
||||
childNodes: [],
|
||||
nextChild: null,
|
||||
},
|
||||
ancestry: {
|
||||
nextAncestor: null,
|
||||
ancestors: [],
|
||||
},
|
||||
lifecycle: [originEvent],
|
||||
relatedEvents: { events, nextEvent: null },
|
||||
relatedAlerts: { alerts: [], nextAlert: null },
|
||||
stats: { events: { total: eventsToGenerate, byCategory: {} }, totalAlerts: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
export function mockTreeWith2AncestorsAndNoChildren({
|
||||
originID,
|
||||
firstAncestorID,
|
||||
|
|
|
@ -34,6 +34,28 @@ interface AppRequestedResolverData {
|
|||
readonly payload: TreeFetcherParameters;
|
||||
}
|
||||
|
||||
interface UserRequestedAdditionalRelatedEvents {
|
||||
readonly type: 'userRequestedAdditionalRelatedEvents';
|
||||
}
|
||||
|
||||
interface ServerFailedToReturnNodeEventsInCategory {
|
||||
readonly type: 'serverFailedToReturnNodeEventsInCategory';
|
||||
readonly payload: {
|
||||
/**
|
||||
* The cursor, if any, that can be used to retrieve more events.
|
||||
*/
|
||||
cursor: string | null;
|
||||
/**
|
||||
* The nodeID that `events` are related to.
|
||||
*/
|
||||
nodeID: string;
|
||||
/**
|
||||
* The category that `events` have in common.
|
||||
*/
|
||||
eventCategory: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ServerFailedToReturnResolverData {
|
||||
readonly type: 'serverFailedToReturnResolverData';
|
||||
/**
|
||||
|
@ -101,4 +123,6 @@ export type DataAction =
|
|||
| ServerReturnedRelatedEventData
|
||||
| ServerReturnedNodeEventsInCategory
|
||||
| AppRequestedResolverData
|
||||
| UserRequestedAdditionalRelatedEvents
|
||||
| ServerFailedToReturnNodeEventsInCategory
|
||||
| AppAbortedResolverDataRequest;
|
||||
|
|
|
@ -39,6 +39,7 @@ export function updatedWith(
|
|||
eventCategory: first.eventCategory,
|
||||
events: [...first.events, ...second.events],
|
||||
cursor: second.cursor,
|
||||
lastCursorRequested: null,
|
||||
};
|
||||
} else {
|
||||
return undefined;
|
||||
|
|
|
@ -19,7 +19,7 @@ const initialState: DataState = {
|
|||
relatedEvents: new Map(),
|
||||
resolverComponentInstanceID: undefined,
|
||||
};
|
||||
|
||||
/* eslint-disable complexity */
|
||||
export const dataReducer: Reducer<DataState, ResolverAction> = (state = initialState, action) => {
|
||||
if (action.type === 'appReceivedNewExternalProperties') {
|
||||
const nextState: DataState = {
|
||||
|
@ -157,6 +157,32 @@ export const dataReducer: Reducer<DataState, ResolverAction> = (state = initialS
|
|||
// the action is stale, ignore it
|
||||
return state;
|
||||
}
|
||||
} else if (action.type === 'userRequestedAdditionalRelatedEvents') {
|
||||
if (state.nodeEventsInCategory) {
|
||||
const nextState: DataState = {
|
||||
...state,
|
||||
nodeEventsInCategory: {
|
||||
...state.nodeEventsInCategory,
|
||||
lastCursorRequested: state.nodeEventsInCategory?.cursor,
|
||||
},
|
||||
};
|
||||
return nextState;
|
||||
} else {
|
||||
return state;
|
||||
}
|
||||
} else if (action.type === 'serverFailedToReturnNodeEventsInCategory') {
|
||||
if (state.nodeEventsInCategory) {
|
||||
const nextState: DataState = {
|
||||
...state,
|
||||
nodeEventsInCategory: {
|
||||
...state.nodeEventsInCategory,
|
||||
error: true,
|
||||
},
|
||||
};
|
||||
return nextState;
|
||||
} else {
|
||||
return state;
|
||||
}
|
||||
} else if (action.type === 'appRequestedCurrentRelatedEventData') {
|
||||
const nextState: DataState = {
|
||||
...state,
|
||||
|
|
|
@ -20,7 +20,7 @@ import {
|
|||
import { isGraphableProcess, isTerminatedProcess } from '../../models/process_event';
|
||||
import * as indexedProcessTreeModel from '../../models/indexed_process_tree';
|
||||
import * as eventModel from '../../../../common/endpoint/models/event';
|
||||
|
||||
import * as nodeEventsInCategoryModel from './node_events_in_category_model';
|
||||
import {
|
||||
ResolverTree,
|
||||
ResolverNodeStats,
|
||||
|
@ -665,3 +665,74 @@ export const panelViewAndParameters = createSelector(
|
|||
export const nodeEventsInCategory = (state: DataState) => {
|
||||
return state.nodeEventsInCategory?.events ?? [];
|
||||
};
|
||||
|
||||
export const lastRelatedEventResponseContainsCursor = createSelector(
|
||||
(state: DataState) => state.nodeEventsInCategory,
|
||||
panelViewAndParameters,
|
||||
/* eslint-disable-next-line no-shadow */
|
||||
function (nodeEventsInCategory, panelViewAndParameters) {
|
||||
if (
|
||||
nodeEventsInCategory !== undefined &&
|
||||
nodeEventsInCategoryModel.isRelevantToPanelViewAndParameters(
|
||||
nodeEventsInCategory,
|
||||
panelViewAndParameters
|
||||
)
|
||||
) {
|
||||
return nodeEventsInCategory.cursor !== null;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const hadErrorLoadingNodeEventsInCategory = createSelector(
|
||||
(state: DataState) => state.nodeEventsInCategory,
|
||||
panelViewAndParameters,
|
||||
/* eslint-disable-next-line no-shadow */
|
||||
function (nodeEventsInCategory, panelViewAndParameters) {
|
||||
if (
|
||||
nodeEventsInCategory !== undefined &&
|
||||
nodeEventsInCategoryModel.isRelevantToPanelViewAndParameters(
|
||||
nodeEventsInCategory,
|
||||
panelViewAndParameters
|
||||
)
|
||||
) {
|
||||
return nodeEventsInCategory && nodeEventsInCategory.error === true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const isLoadingNodeEventsInCategory = createSelector(
|
||||
(state: DataState) => state.nodeEventsInCategory,
|
||||
panelViewAndParameters,
|
||||
/* eslint-disable-next-line no-shadow */
|
||||
function (nodeEventsInCategory, panelViewAndParameters) {
|
||||
const { panelView } = panelViewAndParameters;
|
||||
return panelView === 'nodeEventsInCategory' && nodeEventsInCategory === undefined;
|
||||
}
|
||||
);
|
||||
|
||||
export const isLoadingMoreNodeEventsInCategory = createSelector(
|
||||
(state: DataState) => state.nodeEventsInCategory,
|
||||
panelViewAndParameters,
|
||||
/* eslint-disable-next-line no-shadow */
|
||||
function (nodeEventsInCategory, panelViewAndParameters) {
|
||||
if (
|
||||
nodeEventsInCategory !== undefined &&
|
||||
nodeEventsInCategoryModel.isRelevantToPanelViewAndParameters(
|
||||
nodeEventsInCategory,
|
||||
panelViewAndParameters
|
||||
)
|
||||
) {
|
||||
return (
|
||||
nodeEventsInCategory &&
|
||||
nodeEventsInCategory.lastCursorRequested !== null &&
|
||||
nodeEventsInCategory.cursor === nodeEventsInCategory.lastCursorRequested
|
||||
);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
import { Dispatch, MiddlewareAPI } from 'redux';
|
||||
import { isEqual } from 'lodash';
|
||||
import { ResolverPaginatedEvents, ResolverRelatedEvents } from '../../../../common/endpoint/types';
|
||||
import { ResolverPaginatedEvents } from '../../../../common/endpoint/types';
|
||||
|
||||
import { ResolverState, DataAccessLayer, PanelViewAndParameters } from '../../types';
|
||||
import * as selectors from '../selectors';
|
||||
|
@ -25,46 +25,69 @@ export function RelatedEventsFetcher(
|
|||
const state = api.getState();
|
||||
|
||||
const newParams = selectors.panelViewAndParameters(state);
|
||||
const isLoadingMoreEvents = selectors.isLoadingMoreNodeEventsInCategory(state);
|
||||
const oldParams = last;
|
||||
// Update this each time before fetching data (or even if we don't fetch data) so that subsequent actions that call this (concurrently) will have up to date info.
|
||||
last = newParams;
|
||||
|
||||
async function fetchEvents({
|
||||
nodeID,
|
||||
eventCategory,
|
||||
cursor,
|
||||
}: {
|
||||
nodeID: string;
|
||||
eventCategory: string;
|
||||
cursor: string | null;
|
||||
}) {
|
||||
let result: ResolverPaginatedEvents | null = null;
|
||||
try {
|
||||
if (cursor) {
|
||||
result = await dataAccessLayer.eventsWithEntityIDAndCategory(
|
||||
nodeID,
|
||||
eventCategory,
|
||||
cursor
|
||||
);
|
||||
} else {
|
||||
result = await dataAccessLayer.eventsWithEntityIDAndCategory(nodeID, eventCategory);
|
||||
}
|
||||
} catch (error) {
|
||||
api.dispatch({
|
||||
type: 'serverFailedToReturnNodeEventsInCategory',
|
||||
payload: {
|
||||
nodeID,
|
||||
eventCategory,
|
||||
cursor,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (result) {
|
||||
api.dispatch({
|
||||
type: 'serverReturnedNodeEventsInCategory',
|
||||
payload: {
|
||||
events: result.events,
|
||||
eventCategory,
|
||||
cursor: result.nextEvent,
|
||||
nodeID,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If the panel view params have changed and the current panel view is either `nodeEventsInCategory` or `eventDetail`, then fetch the related events for that nodeID.
|
||||
if (!isEqual(newParams, oldParams)) {
|
||||
if (newParams.panelView === 'nodeEventsInCategory') {
|
||||
const nodeID = newParams.panelParameters.nodeID;
|
||||
|
||||
const result:
|
||||
| ResolverPaginatedEvents
|
||||
| undefined = await dataAccessLayer.eventsWithEntityIDAndCategory(
|
||||
fetchEvents({
|
||||
nodeID,
|
||||
newParams.panelParameters.eventCategory
|
||||
);
|
||||
|
||||
if (result) {
|
||||
api.dispatch({
|
||||
type: 'serverReturnedNodeEventsInCategory',
|
||||
payload: {
|
||||
events: result.events,
|
||||
eventCategory: newParams.panelParameters.eventCategory,
|
||||
cursor: result.nextEvent,
|
||||
nodeID,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (newParams.panelView === 'eventDetail') {
|
||||
const nodeID = newParams.panelParameters.nodeID;
|
||||
|
||||
const result: ResolverRelatedEvents | undefined = await dataAccessLayer.relatedEvents(
|
||||
nodeID
|
||||
);
|
||||
|
||||
if (result) {
|
||||
api.dispatch({
|
||||
type: 'serverReturnedRelatedEventData',
|
||||
payload: result,
|
||||
});
|
||||
}
|
||||
eventCategory: newParams.panelParameters.eventCategory,
|
||||
cursor: null,
|
||||
});
|
||||
}
|
||||
} else if (isLoadingMoreEvents) {
|
||||
const nodeEventsInCategory = state.data.nodeEventsInCategory;
|
||||
if (nodeEventsInCategory !== undefined) {
|
||||
fetchEvents(nodeEventsInCategory);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -364,6 +364,37 @@ export const nodeEventsInCategory = composeSelectors(
|
|||
dataSelectors.nodeEventsInCategory
|
||||
);
|
||||
|
||||
/**
|
||||
* Flag used to show a Load More Data button in the nodeEventsOfType panel view.
|
||||
*/
|
||||
export const lastRelatedEventResponseContainsCursor = composeSelectors(
|
||||
dataStateSelector,
|
||||
dataSelectors.lastRelatedEventResponseContainsCursor
|
||||
);
|
||||
|
||||
/**
|
||||
* Flag to show an error message when loading more related events.
|
||||
*/
|
||||
export const hadErrorLoadingNodeEventsInCategory = composeSelectors(
|
||||
dataStateSelector,
|
||||
dataSelectors.hadErrorLoadingNodeEventsInCategory
|
||||
);
|
||||
/**
|
||||
* Flag used to show a loading view for the initial loading of related events.
|
||||
*/
|
||||
export const isLoadingNodeEventsInCategory = composeSelectors(
|
||||
dataStateSelector,
|
||||
dataSelectors.isLoadingNodeEventsInCategory
|
||||
);
|
||||
|
||||
/**
|
||||
* Flag used to show a loading state for any additional related events.
|
||||
*/
|
||||
export const isLoadingMoreNodeEventsInCategory = composeSelectors(
|
||||
dataStateSelector,
|
||||
dataSelectors.isLoadingMoreNodeEventsInCategory
|
||||
);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
|
|
|
@ -227,6 +227,17 @@ export interface NodeEventsInCategoryState {
|
|||
* The cursor, if any, that can be used to retrieve more events.
|
||||
*/
|
||||
cursor: null | string;
|
||||
|
||||
/**
|
||||
* The cursor, if any, that was last used to fetch additional related events.
|
||||
*/
|
||||
|
||||
lastCursorRequested?: null | string;
|
||||
|
||||
/**
|
||||
* Flag for showing an error message when fetching additional related events.
|
||||
*/
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -0,0 +1,98 @@
|
|||
/*
|
||||
* 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 { createMemoryHistory, History as HistoryPackageHistoryInterface } from 'history';
|
||||
|
||||
import { oneNodeWithPaginatedEvents } from '../../data_access_layer/mocks/one_node_with_paginated_related_events';
|
||||
import { Simulator } from '../../test_utilities/simulator';
|
||||
// Extend jest with a custom matcher
|
||||
import '../../test_utilities/extend_jest';
|
||||
import { urlSearch } from '../../test_utilities/url_search';
|
||||
|
||||
// the resolver component instance ID, used by the react code to distinguish piece of global state from those used by other resolver instances
|
||||
const resolverComponentInstanceID = 'resolverComponentInstanceID';
|
||||
|
||||
describe(`Resolver: when analyzing a tree with only the origin and paginated related events, and when the component instance ID is ${resolverComponentInstanceID}`, () => {
|
||||
/**
|
||||
* Get (or lazily create and get) the simulator.
|
||||
*/
|
||||
let simulator: () => Simulator;
|
||||
/** lazily populated by `simulator`. */
|
||||
let simulatorInstance: Simulator | undefined;
|
||||
let memoryHistory: HistoryPackageHistoryInterface<never>;
|
||||
|
||||
beforeEach(() => {
|
||||
// create a mock data access layer
|
||||
const { metadata: dataAccessLayerMetadata, dataAccessLayer } = oneNodeWithPaginatedEvents();
|
||||
|
||||
memoryHistory = createMemoryHistory();
|
||||
|
||||
// create a resolver simulator, using the data access layer and an arbitrary component instance ID
|
||||
simulator = () => {
|
||||
if (simulatorInstance) {
|
||||
return simulatorInstance;
|
||||
} else {
|
||||
simulatorInstance = new Simulator({
|
||||
databaseDocumentID: dataAccessLayerMetadata.databaseDocumentID,
|
||||
dataAccessLayer,
|
||||
resolverComponentInstanceID,
|
||||
history: memoryHistory,
|
||||
indices: [],
|
||||
});
|
||||
return simulatorInstance;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
simulatorInstance = undefined;
|
||||
});
|
||||
|
||||
describe(`when the URL query string is showing a resolver with nodeID origin, panel view nodeEventsInCategory, and eventCategory registry`, () => {
|
||||
beforeEach(() => {
|
||||
memoryHistory.push({
|
||||
search: urlSearch(resolverComponentInstanceID, {
|
||||
panelParameters: { nodeID: 'origin', eventCategory: 'registry' },
|
||||
panelView: 'nodeEventsInCategory',
|
||||
}),
|
||||
});
|
||||
});
|
||||
it('should show the load more data button', async () => {
|
||||
await expect(
|
||||
simulator().map(() => ({
|
||||
loadMoreButton: simulator().testSubject('resolver:nodeEventsInCategory:loadMore').length,
|
||||
visibleEvents: simulator().testSubject(
|
||||
'resolver:panel:node-events-in-category:event-link'
|
||||
).length,
|
||||
}))
|
||||
).toYieldEqualTo({
|
||||
loadMoreButton: 1,
|
||||
visibleEvents: 25,
|
||||
});
|
||||
});
|
||||
describe('when the user clicks the load more button', () => {
|
||||
beforeEach(async () => {
|
||||
const loadMore = await simulator().resolve('resolver:nodeEventsInCategory:loadMore');
|
||||
if (loadMore) {
|
||||
loadMore.simulate('click', { button: 0 });
|
||||
}
|
||||
});
|
||||
it('should hide the load more button and show all 30 events', async () => {
|
||||
await expect(
|
||||
simulator().map(() => ({
|
||||
loadMoreButton: simulator().testSubject('resolver:nodeEventsInCategory:loadMore')
|
||||
.length,
|
||||
visibleEvents: simulator().testSubject(
|
||||
'resolver:panel:node-events-in-category:event-link'
|
||||
).length,
|
||||
}))
|
||||
).toYieldEqualTo({
|
||||
loadMoreButton: 0,
|
||||
visibleEvents: 30,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -4,11 +4,17 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
/* eslint-disable react/display-name */
|
||||
|
||||
import React, { memo, Fragment } from 'react';
|
||||
import React, { memo, useCallback, Fragment } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { EuiSpacer, EuiText, EuiButtonEmpty, EuiHorizontalRule } from '@elastic/eui';
|
||||
import {
|
||||
EuiSpacer,
|
||||
EuiText,
|
||||
EuiButtonEmpty,
|
||||
EuiHorizontalRule,
|
||||
EuiFlexItem,
|
||||
EuiButton,
|
||||
EuiCallOut,
|
||||
} from '@elastic/eui';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { StyledPanel } from '../styles';
|
||||
|
@ -21,6 +27,7 @@ import { ResolverState } from '../../types';
|
|||
import { PanelLoading } from './panel_loading';
|
||||
import { DescriptiveName } from './descriptive_name';
|
||||
import { useLinkProps } from '../use_link_props';
|
||||
import { useResolverDispatch } from '../use_resolver_dispatch';
|
||||
import { useFormattedDate } from './use_formatted_date';
|
||||
|
||||
/**
|
||||
|
@ -44,29 +51,56 @@ export const NodeEventsInCategory = memo(function ({
|
|||
);
|
||||
const events = useSelector((state: ResolverState) => selectors.nodeEventsInCategory(state));
|
||||
|
||||
const isLoading = useSelector(selectors.isLoadingNodeEventsInCategory);
|
||||
const hasError = useSelector(selectors.hadErrorLoadingNodeEventsInCategory);
|
||||
return (
|
||||
<>
|
||||
{eventCount === undefined || processEvent === null ? (
|
||||
{isLoading || processEvent === null ? (
|
||||
<StyledPanel>
|
||||
<PanelLoading />
|
||||
</StyledPanel>
|
||||
) : (
|
||||
<StyledPanel data-test-subj="resolver:panel:events-in-category">
|
||||
<NodeEventsInCategoryBreadcrumbs
|
||||
nodeName={eventModel.processNameSafeVersion(processEvent)}
|
||||
eventCategory={eventCategory}
|
||||
eventCount={eventCount}
|
||||
nodeID={nodeID}
|
||||
eventsInCategoryCount={eventsInCategoryCount}
|
||||
/>
|
||||
<EuiSpacer size="l" />
|
||||
<NodeEventList eventCategory={eventCategory} nodeID={nodeID} events={events} />
|
||||
{hasError ? (
|
||||
<EuiCallOut
|
||||
title={i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.panel.nodeEventsByType.errorPrimary',
|
||||
{
|
||||
defaultMessage: 'Unable to load events.',
|
||||
}
|
||||
)}
|
||||
color="danger"
|
||||
iconType="alert"
|
||||
data-test-subj="resolver:nodeEventsInCategory:error"
|
||||
>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.endpoint.resolver.panel.nodeEventsByType.errorSecondary"
|
||||
defaultMessage="An error occurred when fetching the events."
|
||||
/>
|
||||
</p>
|
||||
</EuiCallOut>
|
||||
) : (
|
||||
<>
|
||||
<NodeEventsInCategoryBreadcrumbs
|
||||
nodeName={eventModel.processNameSafeVersion(processEvent)}
|
||||
eventCategory={eventCategory}
|
||||
eventCount={eventCount}
|
||||
nodeID={nodeID}
|
||||
eventsInCategoryCount={eventsInCategoryCount}
|
||||
/>
|
||||
<EuiSpacer size="l" />
|
||||
<NodeEventList eventCategory={eventCategory} nodeID={nodeID} events={events} />
|
||||
</>
|
||||
)}
|
||||
</StyledPanel>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
NodeEventsInCategory.displayName = 'NodeEventsInCategory';
|
||||
|
||||
/**
|
||||
* Rendered for each event in the list.
|
||||
*/
|
||||
|
@ -136,6 +170,14 @@ const NodeEventList = memo(function NodeEventList({
|
|||
events: SafeResolverEvent[];
|
||||
nodeID: string;
|
||||
}) {
|
||||
const dispatch = useResolverDispatch();
|
||||
const handleLoadMore = useCallback(() => {
|
||||
dispatch({
|
||||
type: 'userRequestedAdditionalRelatedEvents',
|
||||
});
|
||||
}, [dispatch]);
|
||||
const isLoading = useSelector(selectors.isLoadingMoreNodeEventsInCategory);
|
||||
const hasMore = useSelector(selectors.lastRelatedEventResponseContainsCursor);
|
||||
return (
|
||||
<>
|
||||
{events.map((event, index) => (
|
||||
|
@ -144,6 +186,23 @@ const NodeEventList = memo(function NodeEventList({
|
|||
{index === events.length - 1 ? null : <EuiHorizontalRule margin="m" />}
|
||||
</Fragment>
|
||||
))}
|
||||
{hasMore && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
color={'primary'}
|
||||
size="s"
|
||||
fill
|
||||
onClick={handleLoadMore}
|
||||
isLoading={isLoading}
|
||||
data-test-subj="resolver:nodeEventsInCategory:loadMore"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.endpoint.resolver.panel.nodeEventsByType.loadMore"
|
||||
defaultMessage="Load More Data"
|
||||
/>
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
@ -166,7 +225,7 @@ const NodeEventsInCategoryBreadcrumbs = memo(function ({
|
|||
/**
|
||||
* The events to list.
|
||||
*/
|
||||
eventCount: number;
|
||||
eventCount: number | undefined;
|
||||
nodeID: string;
|
||||
/**
|
||||
* The count of events in the category that this list is showing.
|
||||
|
|
Loading…
Reference in a new issue