[Security Solution] Pass filters from SIEM to resolver, update resolver when refresh is clicked (#85812)

* Pass filters from SIEM to resolver

* Fix test type errors

* Revert loading state change, update snapshots

* Make correct check in nodeData selector

* Fix inverted logic in nodeData selector

* Revert nodeData invalidation logic

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Kevin Qualters 2020-12-15 13:33:51 -05:00 committed by GitHub
parent ee37f6dd91
commit 47444e77c2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 506 additions and 96 deletions

View file

@ -43,8 +43,8 @@ export function dataAccessLayerFactory(
body: JSON.stringify({
indexPatterns,
timeRange: {
from: timeRange.from.toISOString(),
to: timeRange.to.toISOString(),
from: timeRange.from,
to: timeRange.to,
},
filter: JSON.stringify({
bool: {
@ -82,8 +82,8 @@ export function dataAccessLayerFactory(
query: { afterEvent: after, limit: 25 },
body: JSON.stringify({
timeRange: {
from: timeRange.from.toISOString(),
to: timeRange.to.toISOString(),
from: timeRange.from,
to: timeRange.to,
},
indexPatterns,
filter: JSON.stringify({
@ -119,8 +119,8 @@ export function dataAccessLayerFactory(
query: { limit },
body: JSON.stringify({
timeRange: {
from: timeRange.from.toISOString(),
to: timeRange.to.toISOString(),
from: timeRange.from,
to: timeRange.to,
},
indexPatterns,
filter: JSON.stringify({
@ -182,8 +182,8 @@ export function dataAccessLayerFactory(
body: JSON.stringify({
indexPatterns,
timeRange: {
from: timeRange.from.toISOString(),
to: timeRange.to.toISOString(),
from: timeRange.from,
to: timeRange.to,
},
filter: JSON.stringify(filter),
}),

View file

@ -13,5 +13,7 @@ export function mockTreeFetcherParameters(): TreeFetcherParameters {
return {
databaseDocumentID: '',
indices: [],
dataRequestID: 0,
filters: {},
};
}

View file

@ -9,14 +9,13 @@ import { maxDate, createRange } from './time_range';
describe('range', () => {
it('creates a range starting from 1970-01-01T00:00:00.000Z to +275760-09-13T00:00:00.000Z by default', () => {
const { from, to } = createRange();
expect(from.toISOString()).toBe('1970-01-01T00:00:00.000Z');
expect(to.toISOString()).toBe('+275760-09-13T00:00:00.000Z');
expect(from).toBe('1970-01-01T00:00:00.000Z');
expect(to).toBe('+275760-09-13T00:00:00.000Z');
});
it('creates an invalid to date using a number greater than 8640000000000000', () => {
const { to } = createRange({ to: new Date(maxDate + 1) });
expect(() => {
to.toISOString();
createRange({ to: new Date(maxDate + 1) });
}).toThrow(RangeError);
});
});

View file

@ -29,7 +29,7 @@ export function createRange({
to?: Date;
} = {}): TimeRange {
return {
from,
to,
from: from.toISOString(),
to: to.toISOString(),
};
}

View file

@ -10,25 +10,47 @@ import { equal } from './tree_fetcher_parameters';
describe('TreeFetcherParameters#equal:', () => {
const cases: Array<[TreeFetcherParameters, TreeFetcherParameters, boolean]> = [
// different databaseDocumentID
[{ databaseDocumentID: 'a', indices: [] }, { databaseDocumentID: 'b', indices: [] }, false],
[
{ databaseDocumentID: 'a', indices: [], filters: {} },
{ databaseDocumentID: 'b', indices: [], filters: {} },
false,
],
// different indices length
[{ databaseDocumentID: 'a', indices: [''] }, { databaseDocumentID: 'a', indices: [] }, false],
[
{ databaseDocumentID: 'a', indices: [''], filters: {} },
{ databaseDocumentID: 'a', indices: [], filters: {} },
false,
],
// same indices length, different databaseDocumentID
[{ databaseDocumentID: 'a', indices: [''] }, { databaseDocumentID: 'b', indices: [''] }, false],
[
{ databaseDocumentID: 'a', indices: [''], filters: {} },
{ databaseDocumentID: 'b', indices: [''], filters: {} },
false,
],
// 1 item in `indices`
[{ databaseDocumentID: 'b', indices: [''] }, { databaseDocumentID: 'b', indices: [''] }, true],
[
{ databaseDocumentID: 'b', indices: [''], filters: {} },
{ databaseDocumentID: 'b', indices: [''], filters: {} },
true,
],
// 2 item in `indices`
[
{ databaseDocumentID: 'b', indices: ['1', '2'] },
{ databaseDocumentID: 'b', indices: ['1', '2'] },
{ databaseDocumentID: 'b', indices: ['1', '2'], filters: {} },
{ databaseDocumentID: 'b', indices: ['1', '2'], filters: {} },
true,
],
// 2 item in `indices`, but order inversed
[
{ databaseDocumentID: 'b', indices: ['2', '1'] },
{ databaseDocumentID: 'b', indices: ['1', '2'] },
{ databaseDocumentID: 'b', indices: ['2', '1'], filters: {} },
{ databaseDocumentID: 'b', indices: ['1', '2'], filters: {} },
true,
],
// all parameters the same, except for the request id
[
{ databaseDocumentID: 'b', indices: [], dataRequestID: 0, filters: {} },
{ databaseDocumentID: 'b', indices: [], dataRequestID: 1, filters: {} },
false,
],
];
describe.each(cases)('%p when compared to %p', (first, second, expected) => {
it(`should ${expected ? '' : 'not'}be equal`, () => {

View file

@ -8,7 +8,7 @@ import { TreeFetcherParameters } from '../types';
/**
* Determine if two instances of `TreeFetcherParameters` are equivalent. Use this to determine if
* a change to a `TreeFetcherParameters` warrants invaliding a request or response.
* a change to a `TreeFetcherParameters` warrants invalidating a request or response.
*/
export function equal(param1: TreeFetcherParameters, param2?: TreeFetcherParameters): boolean {
if (!param2) {
@ -17,7 +17,10 @@ export function equal(param1: TreeFetcherParameters, param2?: TreeFetcherParamet
if (param1 === param2) {
return true;
}
if (param1.databaseDocumentID !== param2.databaseDocumentID) {
if (
param1.databaseDocumentID !== param2.databaseDocumentID ||
param1.dataRequestID !== param2.dataRequestID
) {
return false;
}
return arraysContainTheSameElements(param1.indices, param2.indices);

View file

@ -98,6 +98,12 @@ interface AppReceivedNewExternalProperties {
* Indices that the backend will use to find the document.
*/
indices: string[];
shouldUpdate: boolean;
filters: {
from?: string;
to?: string;
};
};
}

View file

@ -5,13 +5,12 @@
*/
import {
ResolverRelatedEvents,
NewResolverTree,
SafeEndpointEvent,
SafeResolverEvent,
ResolverSchema,
} from '../../../../common/endpoint/types';
import { TreeFetcherParameters } from '../../types';
import { TreeFetcherParameters, PanelViewAndParameters } from '../../types';
interface ServerReturnedResolverData {
readonly type: 'serverReturnedResolverData';
@ -35,6 +34,13 @@ interface ServerReturnedResolverData {
};
}
interface AppRequestedNodeEventsInCategory {
readonly type: 'appRequestedNodeEventsInCategory';
readonly payload: {
parameters: PanelViewAndParameters;
dataRequestID: number;
};
}
interface AppRequestedResolverData {
readonly type: 'appRequestedResolverData';
/**
@ -81,14 +87,6 @@ interface AppAbortedResolverDataRequest {
readonly payload: TreeFetcherParameters;
}
/**
* When related events are returned from the server
*/
interface ServerReturnedRelatedEventData {
readonly type: 'serverReturnedRelatedEventData';
readonly payload: ResolverRelatedEvents;
}
interface ServerReturnedNodeEventsInCategory {
readonly type: 'serverReturnedNodeEventsInCategory';
readonly payload: {
@ -108,6 +106,8 @@ interface ServerReturnedNodeEventsInCategory {
* The category that `events` have in common.
*/
eventCategory: string;
dataRequestID: number;
};
}
@ -135,6 +135,8 @@ interface ServerReturnedNodeData {
* that we'll request their data in a subsequent request.
*/
numberOfRequestedEvents: number;
dataRequestID: number;
};
}
@ -185,7 +187,7 @@ interface ServerFailedToReturnCurrentRelatedEventData {
interface ServerReturnedCurrentRelatedEventData {
readonly type: 'serverReturnedCurrentRelatedEventData';
readonly payload: SafeResolverEvent;
readonly payload: { data: SafeResolverEvent; dataRequestID: number };
}
export type DataAction =
@ -194,7 +196,6 @@ export type DataAction =
| AppRequestedCurrentRelatedEventData
| ServerReturnedCurrentRelatedEventData
| ServerFailedToReturnCurrentRelatedEventData
| ServerReturnedRelatedEventData
| ServerReturnedNodeEventsInCategory
| AppRequestedResolverData
| UserRequestedAdditionalRelatedEvents
@ -203,4 +204,5 @@ export type DataAction =
| ServerReturnedNodeData
| ServerFailedToReturnNodeData
| AppRequestingNodeData
| UserReloadedResolverNode;
| UserReloadedResolverNode
| AppRequestedNodeEventsInCategory;

View file

@ -40,6 +40,7 @@ export function updatedWith(
events: [...first.events, ...second.events],
cursor: second.cursor,
lastCursorRequested: null,
dataRequestID: second.dataRequestID,
};
} else {
return undefined;

View file

@ -36,6 +36,7 @@ describe('Resolver Data Middleware', () => {
parameters: {
databaseDocumentID: '',
indices: [],
filters: {},
},
},
};

View file

@ -17,19 +17,23 @@ const initialState: DataState = {
loading: false,
data: null,
},
relatedEvents: new Map(),
resolverComponentInstanceID: undefined,
refreshCount: 0,
};
/* eslint-disable complexity */
export const dataReducer: Reducer<DataState, ResolverAction> = (state = initialState, action) => {
if (action.type === 'appReceivedNewExternalProperties') {
const refreshCount = state.refreshCount + (action.payload.shouldUpdate ? 1 : 0);
const nextState: DataState = {
...state,
refreshCount,
tree: {
...state.tree,
currentParameters: {
databaseDocumentID: action.payload.databaseDocumentID,
indices: action.payload.indices,
filters: action.payload.filters,
dataRequestID: refreshCount,
},
},
resolverComponentInstanceID: action.payload.resolverComponentInstanceID,
@ -57,6 +61,8 @@ export const dataReducer: Reducer<DataState, ResolverAction> = (state = initialS
pendingRequestParameters: {
databaseDocumentID: action.payload.databaseDocumentID,
indices: action.payload.indices,
dataRequestID: action.payload.dataRequestID,
filters: action.payload.filters,
},
},
};
@ -117,12 +123,6 @@ export const dataReducer: Reducer<DataState, ResolverAction> = (state = initialS
} else {
return state;
}
} else if (action.type === 'serverReturnedRelatedEventData') {
const nextState: DataState = {
...state,
relatedEvents: new Map([...state.relatedEvents, [action.payload.entityID, action.payload]]),
};
return nextState;
} else if (action.type === 'serverReturnedNodeEventsInCategory') {
// The data in the action could be irrelevant if the panel view or parameters have changed since the corresponding request was made. In that case, ignore this action.
if (
@ -141,7 +141,9 @@ export const dataReducer: Reducer<DataState, ResolverAction> = (state = initialS
if (updated) {
const next: DataState = {
...state,
nodeEventsInCategory: updated,
nodeEventsInCategory: {
...updated,
},
};
return next;
} else {
@ -235,7 +237,7 @@ export const dataReducer: Reducer<DataState, ResolverAction> = (state = initialS
...state,
currentRelatedEvent: {
loading: false,
data: action.payload,
...action.payload,
},
};
return nextState;

View file

@ -139,6 +139,8 @@ describe('data state', () => {
// `locationSearch` doesn't matter for this test
locationSearch: '',
indices: [],
shouldUpdate: false,
filters: {},
},
},
];
@ -152,7 +154,7 @@ describe('data state', () => {
has an error: false
has more children: false
has more ancestors: false
parameters to fetch: {\\"databaseDocumentID\\":\\"databaseDocumentID\\",\\"indices\\":[]}
parameters to fetch: {\\"databaseDocumentID\\":\\"databaseDocumentID\\",\\"indices\\":[],\\"filters\\":{},\\"dataRequestID\\":0}
requires a pending request to be aborted: null"
`);
});
@ -163,7 +165,7 @@ describe('data state', () => {
actions = [
{
type: 'appRequestedResolverData',
payload: { databaseDocumentID, indices: [] },
payload: { databaseDocumentID, indices: [], filters: {} },
},
];
});
@ -182,7 +184,7 @@ describe('data state', () => {
has more children: false
has more ancestors: false
parameters to fetch: null
requires a pending request to be aborted: {\\"databaseDocumentID\\":\\"databaseDocumentID\\",\\"indices\\":[]}"
requires a pending request to be aborted: {\\"databaseDocumentID\\":\\"databaseDocumentID\\",\\"indices\\":[],\\"filters\\":{}}"
`);
});
});
@ -200,35 +202,34 @@ describe('data state', () => {
// `locationSearch` doesn't matter for this test
locationSearch: '',
indices: [],
shouldUpdate: false,
filters: {},
},
},
{
type: 'appRequestedResolverData',
payload: { databaseDocumentID, indices: [] },
payload: { databaseDocumentID, indices: [], filters: {} },
},
];
});
it('should be loading', () => {
expect(selectors.isTreeLoading(state())).toBe(true);
});
it('should not have a request to abort', () => {
expect(selectors.treeRequestParametersToAbort(state())).toBe(null);
});
it('should not have an error, more children, more ancestors, a request to make, or a pending request that should be aborted.', () => {
expect(viewAsAString(state())).toMatchInlineSnapshot(`
"is loading: true
has an error: false
has more children: false
has more ancestors: false
parameters to fetch: null
requires a pending request to be aborted: null"
parameters to fetch: {\\"databaseDocumentID\\":\\"databaseDocumentID\\",\\"indices\\":[],\\"filters\\":{},\\"dataRequestID\\":0}
requires a pending request to be aborted: {\\"databaseDocumentID\\":\\"databaseDocumentID\\",\\"indices\\":[],\\"filters\\":{}}"
`);
});
describe('when the pending request fails', () => {
beforeEach(() => {
actions.push({
type: 'serverFailedToReturnResolverData',
payload: { databaseDocumentID, indices: [] },
payload: { databaseDocumentID, indices: [], filters: {} },
});
});
it('should not be loading', () => {
@ -243,7 +244,7 @@ describe('data state', () => {
has an error: true
has more children: false
has more ancestors: false
parameters to fetch: null
parameters to fetch: {\\"databaseDocumentID\\":\\"databaseDocumentID\\",\\"indices\\":[],\\"filters\\":{},\\"dataRequestID\\":0}
requires a pending request to be aborted: null"
`);
});
@ -265,12 +266,14 @@ describe('data state', () => {
// `locationSearch` doesn't matter for this test
locationSearch: '',
indices: [],
shouldUpdate: false,
filters: {},
},
},
// this happens when the middleware starts the request
{
type: 'appRequestedResolverData',
payload: { databaseDocumentID: firstDatabaseDocumentID, indices: [] },
payload: { databaseDocumentID: firstDatabaseDocumentID, indices: [], filters: {} },
},
// receive a different databaseDocumentID. this should cause the middleware to abort the existing request and start a new one
{
@ -281,6 +284,8 @@ describe('data state', () => {
// `locationSearch` doesn't matter for this test
locationSearch: '',
indices: [],
shouldUpdate: false,
filters: {},
},
},
];
@ -307,15 +312,103 @@ describe('data state', () => {
has an error: false
has more children: false
has more ancestors: false
parameters to fetch: {\\"databaseDocumentID\\":\\"second databaseDocumentID\\",\\"indices\\":[]}
requires a pending request to be aborted: {\\"databaseDocumentID\\":\\"first databaseDocumentID\\",\\"indices\\":[]}"
parameters to fetch: {\\"databaseDocumentID\\":\\"second databaseDocumentID\\",\\"indices\\":[],\\"filters\\":{},\\"dataRequestID\\":0}
requires a pending request to be aborted: {\\"databaseDocumentID\\":\\"first databaseDocumentID\\",\\"indices\\":[],\\"filters\\":{}}"
`);
});
describe('when after initial load resolver is told to refresh', () => {
const databaseDocumentID = 'doc id';
const resolverComponentInstanceID = 'instance';
const originID = 'origin';
const firstChildID = 'first';
const secondChildID = 'second';
const { resolverTree } = mockTreeWithNoAncestorsAnd2Children({
originID,
firstChildID,
secondChildID,
});
const { schema, dataSource } = endpointSourceSchema();
beforeEach(() => {
actions = [
// receive the document ID, this would cause the middleware to start the request
{
type: 'appReceivedNewExternalProperties',
payload: {
databaseDocumentID,
resolverComponentInstanceID,
locationSearch: '',
indices: [],
shouldUpdate: false,
filters: {},
},
},
// this happens when the middleware starts the request
{
type: 'appRequestedResolverData',
payload: { databaseDocumentID, indices: [], dataRequestID: 99, filters: {} },
},
{
type: 'serverReturnedResolverData',
payload: {
result: resolverTree,
dataSource,
schema,
parameters: { databaseDocumentID, indices: [], dataRequestID: 0, filters: {} },
},
},
// receive all the same parameters except shouldUpdate is true
{
type: 'appReceivedNewExternalProperties',
payload: {
databaseDocumentID,
resolverComponentInstanceID,
locationSearch: '',
indices: [],
shouldUpdate: true,
filters: {},
},
},
{
type: 'appReceivedNewExternalProperties',
payload: {
databaseDocumentID,
resolverComponentInstanceID,
locationSearch: '',
indices: [],
shouldUpdate: false,
filters: {},
},
},
{
type: 'appRequestedResolverData',
payload: { databaseDocumentID, indices: [], dataRequestID: 2, filters: {} },
},
];
});
it('should need to request the tree using the same parameters as the first request', () => {
expect(selectors.treeParametersToFetch(state())?.databaseDocumentID).toBe(
databaseDocumentID
);
});
it('should have a newer id', () => {
expect(selectors.treeParametersToFetch(state())?.dataRequestID).toBe(1);
});
it('should not have an error, more children, or more ancestors.', () => {
expect(viewAsAString(state())).toMatchInlineSnapshot(`
"is loading: true
has an error: false
has more children: false
has more ancestors: false
parameters to fetch: {\\"databaseDocumentID\\":\\"doc id\\",\\"indices\\":[],\\"filters\\":{},\\"dataRequestID\\":1}
requires a pending request to be aborted: {\\"databaseDocumentID\\":\\"doc id\\",\\"indices\\":[],\\"dataRequestID\\":2,\\"filters\\":{}}"
`);
});
});
describe('and when the old request was aborted', () => {
beforeEach(() => {
actions.push({
type: 'appAbortedResolverDataRequest',
payload: { databaseDocumentID: firstDatabaseDocumentID, indices: [] },
payload: { databaseDocumentID: firstDatabaseDocumentID, indices: [], filters: {} },
});
});
it('should not require a pending request to be aborted', () => {
@ -335,7 +428,7 @@ describe('data state', () => {
has an error: false
has more children: false
has more ancestors: false
parameters to fetch: {\\"databaseDocumentID\\":\\"second databaseDocumentID\\",\\"indices\\":[]}
parameters to fetch: {\\"databaseDocumentID\\":\\"second databaseDocumentID\\",\\"indices\\":[],\\"filters\\":{},\\"dataRequestID\\":0}
requires a pending request to be aborted: null"
`);
});
@ -343,12 +436,9 @@ describe('data state', () => {
beforeEach(() => {
actions.push({
type: 'appRequestedResolverData',
payload: { databaseDocumentID: secondDatabaseDocumentID, indices: [] },
payload: { databaseDocumentID: secondDatabaseDocumentID, indices: [], filters: {} },
});
});
it('should not have a document ID to fetch', () => {
expect(selectors.treeParametersToFetch(state())).toBe(null);
});
it('should be loading', () => {
expect(selectors.isTreeLoading(state())).toBe(true);
});
@ -358,8 +448,8 @@ describe('data state', () => {
has an error: false
has more children: false
has more ancestors: false
parameters to fetch: null
requires a pending request to be aborted: null"
parameters to fetch: {\\"databaseDocumentID\\":\\"second databaseDocumentID\\",\\"indices\\":[],\\"filters\\":{},\\"dataRequestID\\":0}
requires a pending request to be aborted: {\\"databaseDocumentID\\":\\"second databaseDocumentID\\",\\"indices\\":[],\\"filters\\":{}}"
`);
});
});
@ -414,6 +504,7 @@ describe('data state', () => {
// mock the requested size being larger than the returned number of events so we
// avoid the case where the limit was reached
numberOfRequestedEvents: nodeData.length + 1,
dataRequestID: 0,
},
});
});

View file

@ -19,6 +19,7 @@ import {
IsometricTaxiLayout,
NodeData,
NodeDataStatus,
TimeRange,
} from '../../types';
import * as indexedProcessTreeModel from '../../models/indexed_process_tree';
import * as nodeModel from '../../../../common/endpoint/models/node';
@ -33,6 +34,7 @@ import {
import * as resolverTreeModel from '../../models/resolver_tree';
import * as treeFetcherParametersModel from '../../models/tree_fetcher_parameters';
import * as isometricTaxiLayoutModel from '../../models/indexed_process_tree/isometric_taxi_layout';
import * as timeRangeModel from '../../models/time_range';
import * as aabbModel from '../../models/aabb';
import * as vector2 from '../../models/vector2';
@ -93,6 +95,40 @@ export const originID: (state: DataState) => string | undefined = createSelector
}
);
function currentRelatedEventRequestID(state: DataState): number | undefined {
if (state.currentRelatedEvent) {
return state.currentRelatedEvent?.dataRequestID;
} else {
return undefined;
}
}
function currentNodeEventsInCategoryRequestID(state: DataState): number | undefined {
if (state.nodeEventsInCategory?.pendingRequest) {
return state.nodeEventsInCategory.pendingRequest?.dataRequestID;
} else if (state.nodeEventsInCategory) {
return state.nodeEventsInCategory?.dataRequestID;
} else {
return undefined;
}
}
export const eventsInCategoryResultIsStale = createSelector(
currentNodeEventsInCategoryRequestID,
refreshCount,
function eventsInCategoryResultIsStale(oldID, newID) {
return oldID !== undefined && oldID !== newID;
}
);
export const currentRelatedEventIsStale = createSelector(
currentRelatedEventRequestID,
refreshCount,
function currentRelatedEventIsStale(oldID, newID) {
return oldID !== undefined && oldID !== newID;
}
);
/**
* Returns a data structure for accessing events for specific nodes in a graph. For Endpoint graphs these nodes will be
* process lifecycle events.
@ -224,6 +260,10 @@ export const relatedEventCountByCategory: (
}
);
export function refreshCount(state: DataState) {
return state.refreshCount;
}
/**
* Returns true if there might be more generations in the graph that we didn't get because we reached
* the requested generations limit.
@ -305,6 +345,30 @@ export function treeParametersToFetch(state: DataState): TreeFetcherParameters |
}
}
export const timeRangeFilters = createSelector(
treeParametersToFetch,
function timeRangeFilters(treeParameters): TimeRange {
// Should always be provided from date picker, but provide valid defaults in any case.
const from = new Date(0);
const to = new Date(timeRangeModel.maxDate);
const timeRange = {
from: from.toISOString(),
to: to.toISOString(),
};
if (treeParameters !== null) {
if (treeParameters.filters.from) {
timeRange.from = treeParameters.filters.from;
}
if (treeParameters.filters.to) {
timeRange.to = treeParameters.filters.to;
}
return timeRange;
} else {
return timeRange;
}
}
);
/**
* The indices to use for the requests with the backend.
*/
@ -682,7 +746,7 @@ export const isLoadingNodeEventsInCategory = createSelector(
(state: DataState) => state.nodeEventsInCategory,
panelViewAndParameters,
// eslint-disable-next-line @typescript-eslint/no-shadow
function (nodeEventsInCategory, panelViewAndParameters) {
function (nodeEventsInCategory, panelViewAndParameters): boolean {
const { panelView } = panelViewAndParameters;
return panelView === 'nodeEventsInCategory' && nodeEventsInCategory === undefined;
}

View file

@ -10,7 +10,6 @@ import { SafeResolverEvent } from '../../../../common/endpoint/types';
import { ResolverState, DataAccessLayer, PanelViewAndParameters } from '../../types';
import * as selectors from '../selectors';
import { createRange } from './../../models/time_range';
import { ResolverAction } from '../actions';
/**
@ -35,10 +34,14 @@ export function CurrentRelatedEventFetcher(
const indices = selectors.treeParameterIndices(state);
const oldParams = last;
const newID = selectors.refreshCount(state);
last = newParams;
// If the panel view params have changed and the current panel view is the `eventDetail`, then fetch the event details for that eventID.
if (!isEqual(newParams, oldParams) && newParams.panelView === 'eventDetail') {
if (
(!isEqual(newParams, oldParams) && newParams.panelView === 'eventDetail') ||
(selectors.currentRelatedEventIsStale(state) && newParams.panelView === 'eventDetail')
) {
const currentEventID = newParams.panelParameters.eventID;
const currentNodeID = newParams.panelParameters.nodeID;
const currentEventCategory = newParams.panelParameters.eventCategory;
@ -48,8 +51,10 @@ export function CurrentRelatedEventFetcher(
api.dispatch({
type: 'appRequestedCurrentRelatedEventData',
});
const timeRangeFilters = selectors.timeRangeFilters(state);
let result: SafeResolverEvent | null = null;
let payload: { data: SafeResolverEvent; dataRequestID: number } | null = null;
try {
result = await dataAccessLayer.event({
nodeID: currentNodeID,
@ -58,7 +63,7 @@ export function CurrentRelatedEventFetcher(
eventID: currentEventID,
winlogRecordID,
indexPatterns: indices,
timeRange: createRange(),
timeRange: timeRangeFilters,
});
} catch (error) {
api.dispatch({
@ -67,9 +72,13 @@ export function CurrentRelatedEventFetcher(
}
if (result) {
payload = {
data: result,
dataRequestID: newID,
};
api.dispatch({
type: 'serverReturnedCurrentRelatedEventData',
payload: result,
payload,
});
} else {
api.dispatch({

View file

@ -10,7 +10,6 @@ import { SafeResolverEvent } from '../../../../common/endpoint/types';
import { ResolverState, DataAccessLayer } from '../../types';
import * as selectors from '../selectors';
import { ResolverAction } from '../actions';
import { createRange } from './../../models/time_range';
/**
* Max number of nodes to request from the server
@ -59,10 +58,12 @@ export function NodeDataFetcher(
});
let results: SafeResolverEvent[] | undefined;
const newID = selectors.refreshCount(state);
try {
const timeRangeFilters = selectors.timeRangeFilters(state);
results = await dataAccessLayer.nodeData({
ids: Array.from(newIDsToRequest),
timeRange: createRange(),
timeRange: timeRangeFilters,
indexPatterns: indices,
limit: nodeDataLimit,
});
@ -111,6 +112,7 @@ export function NodeDataFetcher(
* if that node is still in view we'll request its node data.
*/
numberOfRequestedEvents: nodeDataLimit,
dataRequestID: newID,
},
});
}

View file

@ -11,7 +11,6 @@ import { ResolverPaginatedEvents } from '../../../../common/endpoint/types';
import { ResolverState, DataAccessLayer, PanelViewAndParameters } from '../../types';
import * as selectors from '../selectors';
import { ResolverAction } from '../actions';
import { createRange } from './../../models/time_range';
export function RelatedEventsFetcher(
dataAccessLayer: DataAccessLayer,
@ -30,6 +29,9 @@ export function RelatedEventsFetcher(
const indices = selectors.treeParameterIndices(state);
const oldParams = last;
const newID = selectors.refreshCount(state);
const timeRangeFilters = selectors.timeRangeFilters(state);
// 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;
@ -37,12 +39,15 @@ export function RelatedEventsFetcher(
nodeID,
eventCategory,
cursor,
dataRequestID,
}: {
nodeID: string;
eventCategory: string;
cursor: string | null;
dataRequestID?: number;
}) {
let result: ResolverPaginatedEvents | null = null;
try {
if (cursor) {
result = await dataAccessLayer.eventsWithEntityIDAndCategory({
@ -50,14 +55,14 @@ export function RelatedEventsFetcher(
category: eventCategory,
after: cursor,
indexPatterns: indices,
timeRange: createRange(),
timeRange: timeRangeFilters,
});
} else {
result = await dataAccessLayer.eventsWithEntityIDAndCategory({
entityID: nodeID,
category: eventCategory,
indexPatterns: indices,
timeRange: createRange(),
timeRange: timeRangeFilters,
});
}
} catch (error) {
@ -79,19 +84,29 @@ export function RelatedEventsFetcher(
eventCategory,
cursor: result.nextEvent,
nodeID,
dataRequestID: newID,
},
});
}
}
// 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 (!isEqual(newParams, oldParams) || selectors.eventsInCategoryResultIsStale(state)) {
if (newParams.panelView === 'nodeEventsInCategory') {
const nodeID = newParams.panelParameters.nodeID;
api.dispatch({
type: 'appRequestedNodeEventsInCategory',
payload: {
parameters: newParams,
dataRequestID: newID,
},
});
fetchEvents({
nodeID,
eventCategory: newParams.panelParameters.eventCategory,
cursor: null,
// only use the id for initial requests, reuse for load more.
dataRequestID: newID,
});
}
} else if (isLoadingMoreEvents) {

View file

@ -15,7 +15,6 @@ import { ResolverState, DataAccessLayer } from '../../types';
import * as selectors from '../selectors';
import { ResolverAction } from '../actions';
import { ancestorsRequestAmount, descendantsRequestAmount } from '../../models/resolver_tree';
import { createRange } from './../../models/time_range';
/**
* A function that handles syncing ResolverTree data w/ the current entity ID.
@ -45,6 +44,8 @@ export function ResolverTreeFetcher(
let dataSource: string | undefined;
let dataSourceSchema: ResolverSchema | undefined;
let result: ResolverNode[] | undefined;
const timeRangeFilters = selectors.timeRangeFilters(state);
// Inform the state that we've made the request. Without this, the middleware will try to make the request again
// immediately.
api.dispatch({
@ -70,7 +71,7 @@ export function ResolverTreeFetcher(
result = await dataAccessLayer.resolverTree({
dataId: entityIDToFetch,
schema: dataSourceSchema,
timeRange: createRange(),
timeRange: timeRangeFilters,
indices: databaseParameters.indices,
ancestors: ancestorsRequestAmount(dataSourceSchema),
descendants: descendantsRequestAmount(),

View file

@ -132,6 +132,13 @@ export const currentRelatedEventData = composeSelectors(
dataSelectors.currentRelatedEventData
);
/**
* A counter indicating how many times a user has requested new data for resolver.
*/
export const refreshCount = composeSelectors(dataStateSelector, dataSelectors.refreshCount);
export const timeRangeFilters = composeSelectors(dataStateSelector, dataSelectors.timeRangeFilters);
/**
* Returns the id of the "current" tree node (fake-focused)
*/
@ -359,6 +366,16 @@ export const isLoadingMoreNodeEventsInCategory = composeSelectors(
dataSelectors.isLoadingMoreNodeEventsInCategory
);
export const eventsInCategoryResultIsStale = composeSelectors(
dataStateSelector,
dataSelectors.eventsInCategoryResultIsStale
);
export const currentRelatedEventIsStale = composeSelectors(
dataStateSelector,
dataSelectors.currentRelatedEventIsStale
);
/**
* Returns the state of the node, loading, running, or terminated.
*/

View file

@ -13,7 +13,13 @@ import { spyMiddlewareFactory } from '../spy_middleware_factory';
import { resolverMiddlewareFactory } from '../../store/middleware';
import { resolverReducer } from '../../store/reducer';
import { MockResolver } from './mock_resolver';
import { ResolverState, DataAccessLayer, SpyMiddleware, SideEffectSimulator } from '../../types';
import {
ResolverState,
DataAccessLayer,
SpyMiddleware,
SideEffectSimulator,
TimeFilters,
} from '../../types';
import { ResolverAction } from '../../store/actions';
import { sideEffectSimulatorFactory } from '../../view/side_effect_simulator_factory';
import { uiSetting } from '../../mocks/ui_setting';
@ -75,6 +81,8 @@ export class Simulator {
databaseDocumentID,
indices,
history,
filters,
shouldUpdate,
}: {
/**
* A (mock) data access layer that will be used to create the Resolver store.
@ -93,6 +101,8 @@ export class Simulator {
*/
databaseDocumentID: string;
history?: HistoryPackageHistoryInterface<never>;
filters: TimeFilters;
shouldUpdate: boolean;
}) {
// create the spy middleware (for debugging tests)
this.spyMiddleware = spyMiddlewareFactory();
@ -131,6 +141,8 @@ export class Simulator {
coreStart={coreStart}
databaseDocumentID={databaseDocumentID}
indices={indices}
filters={filters}
shouldUpdate={shouldUpdate}
/>
);
}
@ -170,6 +182,25 @@ export class Simulator {
return this.wrapper.prop('indices');
}
/**
* Change the shouldUpdate prop (updates the React component props.)
*/
public set shouldUpdate(value: boolean) {
this.wrapper.setProps({ shouldUpdate: value });
}
public get shouldUpdate(): boolean {
return this.wrapper.prop('shouldUpdate');
}
public set filters(value: TimeFilters) {
this.wrapper.setProps({ filters: value });
}
public get filters(): TimeFilters {
return this.wrapper.prop('filters');
}
/**
* Call this to console.log actions (and state). Use this to debug your tests.
* State and actions aren't exposed otherwise because the tests using this simulator should

View file

@ -100,6 +100,8 @@ export const MockResolver = React.memo((props: MockResolverProps) => {
databaseDocumentID={props.databaseDocumentID}
resolverComponentInstanceID={props.resolverComponentInstanceID}
indices={props.indices}
shouldUpdate={props.shouldUpdate}
filters={props.filters}
/>
</Provider>
</SideEffectContext.Provider>

View file

@ -208,6 +208,13 @@ export interface TreeFetcherParameters {
* The indices that the backend will use to search for the document ID.
*/
indices: string[];
/**
* The count of data invalidation actions at the time the data was requested.
*/
dataRequestID?: number;
filters: TimeFilters;
}
/**
@ -237,6 +244,23 @@ export interface NodeEventsInCategoryState {
lastCursorRequested?: null | string;
/**
* Request ID for the currently displayed events.
*/
dataRequestID?: number;
pendingRequest?: {
/**
* Parameters used for a request currently in progress.
*/
parameters: PanelViewAndParameters;
/**
* Request ID for any inflight requests
*/
dataRequestID: number;
};
/**
* Flag for showing an error message when fetching additional related events.
*/
@ -298,11 +322,6 @@ export interface NodeData {
* State for `data` reducer which handles receiving Resolver data from the back-end.
*/
export interface DataState {
/**
* @deprecated Use the API
*/
readonly relatedEvents: Map<string, ResolverRelatedEvents>;
/**
* Used when the panelView is `nodeEventsInCategory`.
* Store the `nodeEventsInCategory` data for the current panel view. If the panel view or parameters change, the reducer may delete this.
@ -310,6 +329,11 @@ export interface DataState {
*/
readonly nodeEventsInCategory?: NodeEventsInCategoryState;
/**
* A counter used to have resolver fetch updated data.
*/
readonly refreshCount: number;
/**
* Used when the panelView is `eventDetail`.
*
@ -317,6 +341,7 @@ export interface DataState {
readonly currentRelatedEvent: {
loading: boolean;
data: SafeResolverEvent | null;
dataRequestID?: number;
};
readonly tree?: {
@ -680,8 +705,8 @@ export interface IsometricTaxiLayout {
* Defines the type for bounding a search by a time box.
*/
export interface TimeRange {
from: Date;
to: Date;
from: string;
to: string;
}
/**
@ -791,6 +816,11 @@ export interface DataAccessLayer {
}) => Promise<ResolverEntityIndex>;
}
export interface TimeFilters {
from?: string;
to?: string;
}
/**
* The externally provided React props.
*/
@ -815,6 +845,13 @@ export interface ResolverProps {
* Indices that the backend should use to find the originating document.
*/
indices: string[];
filters: TimeFilters;
/**
* A flag to update data from an external source
*/
shouldUpdate: boolean;
}
/**

View file

@ -43,6 +43,8 @@ describe("Resolver, when rendered with the `indices` prop set to `[]` and the `d
dataAccessLayer,
resolverComponentInstanceID,
indices: [],
shouldUpdate: false,
filters: {},
});
});
@ -96,6 +98,8 @@ describe('Resolver, when analyzing a tree that has no ancestors and 2 children',
dataAccessLayer,
resolverComponentInstanceID,
indices: [],
shouldUpdate: false,
filters: {},
});
});
@ -299,6 +303,8 @@ describe('Resolver, when using a generated tree with 20 generations, 4 children
dataAccessLayer: { ...generatorDAL, nodeData: nodeDataError },
resolverComponentInstanceID,
indices: [],
shouldUpdate: false,
filters: {},
});
await findAndClickFirstLoadingNodeInPanel(simulator);
@ -354,6 +360,8 @@ describe('Resolver, when using a generated tree with 20 generations, 4 children
dataAccessLayer: generatorDAL,
resolverComponentInstanceID,
indices: [],
shouldUpdate: false,
filters: {},
});
await findAndClickFirstLoadingNodeInPanel(simulator);
@ -432,6 +440,8 @@ describe('Resolver, when analyzing a tree that has 2 related registry and 1 rela
dataAccessLayer,
resolverComponentInstanceID,
indices: [],
shouldUpdate: false,
filters: {},
});
});

View file

@ -35,6 +35,8 @@ describe('graph controls: when relsover is loaded with an origin node', () => {
databaseDocumentID,
resolverComponentInstanceID,
indices: [],
shouldUpdate: false,
filters: {},
});
originEntityID = entityIDs.origin;

View file

@ -29,6 +29,8 @@ describe('Resolver, when analyzing a tree that has no ancestors and 2 children',
dataAccessLayer,
resolverComponentInstanceID,
indices: [],
shouldUpdate: false,
filters: {},
});
});

View file

@ -65,6 +65,8 @@ describe(`Resolver: when analyzing a tree with no ancestors and two children and
resolverComponentInstanceID,
history: memoryHistory,
indices: [],
shouldUpdate: false,
filters: {},
});
return simulatorInstance;
}

View file

@ -222,7 +222,7 @@ function EventDetailBreadcrumbs({
breadcrumbEventCategory: string;
}) {
const countByCategory = useSelector((state: ResolverState) =>
selectors.relatedEventCountByCategory(state)(nodeID, breadcrumbEventCategory)
selectors.relatedEventCountOfTypeForNode(state)(nodeID, breadcrumbEventCategory)
);
const relatedEventCount: number | undefined = useSelector((state: ResolverState) =>
selectors.relatedEventTotalCount(state)(nodeID)

View file

@ -41,6 +41,8 @@ describe.skip(`Resolver: when analyzing a tree with only the origin and paginate
resolverComponentInstanceID,
history: memoryHistory,
indices: [],
shouldUpdate: false,
filters: {},
});
return simulatorInstance;
}

View file

@ -65,6 +65,8 @@ describe('Resolver: panel loading and resolution states', () => {
history: memoryHistory,
resolverComponentInstanceID,
indices: [],
shouldUpdate: false,
filters: {},
});
memoryHistory.push({
@ -111,6 +113,8 @@ describe('Resolver: panel loading and resolution states', () => {
history: memoryHistory,
resolverComponentInstanceID,
indices: [],
shouldUpdate: false,
filters: {},
});
memoryHistory.push({
search: queryStringWithEventDetailSelected,
@ -150,6 +154,8 @@ describe('Resolver: panel loading and resolution states', () => {
history: memoryHistory,
resolverComponentInstanceID,
indices: [],
shouldUpdate: false,
filters: {},
});
memoryHistory.push({
@ -207,6 +213,8 @@ describe('Resolver: panel loading and resolution states', () => {
history: memoryHistory,
resolverComponentInstanceID,
indices: [],
shouldUpdate: false,
filters: {},
});
memoryHistory.push({

View file

@ -34,6 +34,8 @@ describe('Resolver, when analyzing a tree that has no ancestors and 2 children',
dataAccessLayer,
resolverComponentInstanceID,
indices: [],
shouldUpdate: false,
filters: {},
});
});

View file

@ -27,6 +27,8 @@ describe('Resolver: data loading and resolution states', () => {
databaseDocumentID,
resolverComponentInstanceID,
indices: [],
shouldUpdate: false,
filters: {},
});
});
@ -58,6 +60,8 @@ describe('Resolver: data loading and resolution states', () => {
databaseDocumentID,
resolverComponentInstanceID,
indices: [],
shouldUpdate: false,
filters: {},
});
});
@ -88,6 +92,8 @@ describe('Resolver: data loading and resolution states', () => {
databaseDocumentID,
resolverComponentInstanceID,
indices: [],
shouldUpdate: false,
filters: {},
});
});
@ -118,6 +124,8 @@ describe('Resolver: data loading and resolution states', () => {
databaseDocumentID,
resolverComponentInstanceID,
indices: [],
shouldUpdate: false,
filters: {},
});
});
@ -150,6 +158,8 @@ describe('Resolver: data loading and resolution states', () => {
databaseDocumentID,
resolverComponentInstanceID,
indices: [],
shouldUpdate: false,
filters: {},
});
});

View file

@ -33,7 +33,14 @@ export const ResolverWithoutProviders = React.memo(
* Use `forwardRef` so that the `Simulator` used in testing can access the top level DOM element.
*/
React.forwardRef(function (
{ className, databaseDocumentID, resolverComponentInstanceID, indices }: ResolverProps,
{
className,
databaseDocumentID,
resolverComponentInstanceID,
indices,
shouldUpdate,
filters,
}: ResolverProps,
refToForward
) {
useResolverQueryParamCleaner();
@ -41,7 +48,13 @@ export const ResolverWithoutProviders = React.memo(
* This is responsible for dispatching actions that include any external data.
* `databaseDocumentID`
*/
useStateSyncingActions({ databaseDocumentID, resolverComponentInstanceID, indices });
useStateSyncingActions({
databaseDocumentID,
resolverComponentInstanceID,
indices,
shouldUpdate,
filters,
});
const { timestamp } = useContext(SideEffectContext);

View file

@ -16,6 +16,8 @@ export function useStateSyncingActions({
databaseDocumentID,
resolverComponentInstanceID,
indices,
filters,
shouldUpdate,
}: {
/**
* The `_id` of an event in ES. Used to determine the origin of the Resolver graph.
@ -23,13 +25,30 @@ export function useStateSyncingActions({
databaseDocumentID: string;
resolverComponentInstanceID: string;
indices: string[];
shouldUpdate: boolean;
filters: object;
}) {
const dispatch = useResolverDispatch();
const locationSearch = useLocation().search;
useLayoutEffect(() => {
dispatch({
type: 'appReceivedNewExternalProperties',
payload: { databaseDocumentID, resolverComponentInstanceID, locationSearch, indices },
payload: {
databaseDocumentID,
resolverComponentInstanceID,
locationSearch,
indices,
shouldUpdate,
filters,
},
});
}, [dispatch, databaseDocumentID, resolverComponentInstanceID, locationSearch, indices]);
}, [
dispatch,
databaseDocumentID,
resolverComponentInstanceID,
locationSearch,
indices,
shouldUpdate,
filters,
]);
}

View file

@ -31,10 +31,14 @@ import { timelineDefaults } from '../../store/timeline/defaults';
import { isFullScreen } from '../timeline/body/column_headers';
import { updateTimelineGraphEventId } from '../../../timelines/store/timeline/actions';
import { Resolver } from '../../../resolver/view';
import {
isLoadingSelector,
startSelector,
endSelector,
} from '../../../common/components/super_date_picker/selectors';
import * as i18n from './translations';
import { useUiSetting$ } from '../../../common/lib/kibana';
import { useSignalIndex } from '../../../detections/containers/detection_engine/alerts/use_signal_index';
import * as i18n from './translations';
const OverlayContainer = styled.div`
${({ $restrictWidth }: { $restrictWidth: boolean }) =>
@ -119,6 +123,31 @@ const GraphOverlayComponent: React.FC<OwnProps> = ({ isEventViewer, timelineId }
const { globalFullScreen, setGlobalFullScreen } = useGlobalFullScreen();
const { timelineFullScreen, setTimelineFullScreen } = useTimelineFullScreen();
const getStartSelector = useMemo(() => startSelector(), []);
const getEndSelector = useMemo(() => endSelector(), []);
const getIsLoadingSelector = useMemo(() => isLoadingSelector(), []);
const isActive = useMemo(() => timelineId === TimelineId.active, [timelineId]);
const shouldUpdate = useDeepEqualSelector((state) => {
if (isActive) {
return getIsLoadingSelector(state.inputs.timeline);
} else {
return getIsLoadingSelector(state.inputs.global);
}
});
const from = useDeepEqualSelector((state) => {
if (isActive) {
return getStartSelector(state.inputs.timeline);
} else {
return getStartSelector(state.inputs.global);
}
});
const to = useDeepEqualSelector((state) => {
if (isActive) {
return getEndSelector(state.inputs.timeline);
} else {
return getEndSelector(state.inputs.global);
}
});
const fullScreen = useMemo(
() => isFullScreen({ globalFullScreen, timelineId, timelineFullScreen }),
@ -173,6 +202,8 @@ const GraphOverlayComponent: React.FC<OwnProps> = ({ isEventViewer, timelineId }
databaseDocumentID={graphEventId}
resolverComponentInstanceID={timelineId}
indices={indices}
shouldUpdate={shouldUpdate}
filters={{ from, to }}
/>
) : (
<EuiFlexGroup alignItems="center" justifyContent="center" style={{ height: '100%' }}>

View file

@ -83,6 +83,8 @@ const AppRoot = React.memo(
databaseDocumentID=""
resolverComponentInstanceID="test"
indices={[]}
shouldUpdate={false}
filters={{}}
/>
</Wrapper>
</Provider>