[Security Solution] Move analyzer store to security solution (#157654)

## Summary
This PR moves analyzer (resolver)'s redux store to security solution
store.


![image](bc239981-44cd-4066-b28a-507b62e352b8)

**Major logic change**
- Add analyzer reducer and middleware to common/store 
-
[x-pack/plugins/security_solution/public/common/store](https://github.com/elastic/kibana/pull/157654/files#diff-2c8de3bd4500e4c55a254b94af91ea30df8a8757dd2c9604c79a5c71b8369753R138)
- Resolver components
-
[x-pack/plugins/security_solution/public/resolver/view/index.tsx](https://github.com/elastic/kibana/pull/157654/files#diff-9c8adeaca5ee3424f8ed1af03c22bfe87810d34642669e800d161e5b111102ccR10)
-
[x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx](https://github.com/elastic/kibana/pull/157654/files#diff-908634a9ddb84596d711c9188ed93dd176fbc615693f86269f8a84993bab1059R25)
- Analyzer middleware
-
[x-pack/plugins/security_solution/public/resolver/store/middleware](https://github.com/elastic/kibana/pull/157654/files#diff-f374e923b8755f7acfa17af6f51fa321a6d7a66715c0422c8b55a3f4cc5ea438R8)
- Data access layer
-
[x-pack/plugins/security_solution/public/resolver/data_access_layer/factory.ts](https://github.com/elastic/kibana/pull/157654/files#diff-c3d07d63a65cf59271282040e72018ccbca8bf47fac769356b8677e204a6455cR8)
- Resolver simulator and mocks
-
[x-pack/plugins/security_solution/public/resolver/test_utilities](https://github.com/elastic/kibana/pull/157654/files#diff-0aa230bf24debbe843c746eb48a6bb0f3810f5fa5627e1f6bce59faf8a6ec0aaR9)
 
**Rest**
- `x-pack/plugins/security_solution/public/resolver/store`
- Refactored actions and reducers to leverage `actionCreator` from
`@typescript-fsa-reducers`
- `x-pack/plugins/security_solution/public/resolver/View`
- Type change, update reference to actions, adding `id` as additional
param to selectors

### Checklist
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
This commit is contained in:
christineweng 2023-06-20 14:58:17 -05:00 committed by GitHub
parent c4dc82572b
commit 5c9a0ab88d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
62 changed files with 2254 additions and 1858 deletions

View file

@ -44,6 +44,7 @@ import { usersModel } from '../../explore/users/store';
import { UsersFields } from '../../../common/search_strategy/security_solution/users/common';
import { initialGroupingState } from '../store/grouping/reducer';
import type { SourcererState } from '../store/sourcerer';
import { EMPTY_RESOLVER } from '../../resolver/store/helpers';
const mockFieldMap: DataViewSpec['fields'] = Object.fromEntries(
mockIndexFields.map((field) => [field.name, field])
@ -419,6 +420,14 @@ export const mockGlobalState: State = {
},
},
groups: initialGroupingState,
analyzer: {
analyzerById: {
[TableId.test]: EMPTY_RESOLVER,
[TimelineId.test]: EMPTY_RESOLVER,
[TimelineId.active]: EMPTY_RESOLVER,
flyout: EMPTY_RESOLVER,
},
},
sourcerer: {
...mockSourcererState,
defaultDataView: {

View file

@ -13,6 +13,7 @@ import { useSourcererDataView } from '../containers/sourcerer';
import { useDeepEqualSelector } from '../hooks/use_selector';
import { renderHook } from '@testing-library/react-hooks';
import { initialGroupingState } from './grouping/reducer';
import { initialAnalyzerState } from '../../resolver/store/helpers';
jest.mock('../hooks/use_selector');
jest.mock('../lib/kibana', () => ({
@ -47,6 +48,9 @@ describe('createInitialState', () => {
},
{
groups: initialGroupingState,
},
{
analyzer: initialAnalyzerState,
}
);
beforeEach(() => {
@ -84,6 +88,9 @@ describe('createInitialState', () => {
},
{
groups: initialGroupingState,
},
{
analyzer: initialAnalyzerState,
}
);
(useDeepEqualSelector as jest.Mock).mockImplementation((cb) => cb(state));

View file

@ -10,6 +10,7 @@ import { combineReducers } from 'redux';
import type { DataTableState } from '@kbn/securitysolution-data-table';
import { dataTableReducer } from '@kbn/securitysolution-data-table';
import { enableMapSet } from 'immer';
import { appReducer, initialAppState } from './app';
import { dragAndDropReducer, initialDragAndDropState } from './drag_and_drop';
import { createInitialInputsState, inputsReducer } from './inputs';
@ -31,6 +32,10 @@ import { getScopePatternListSelection } from './sourcerer/helpers';
import { globalUrlParamReducer, initialGlobalUrlParam } from './global_url_param';
import { groupsReducer } from './grouping/reducer';
import type { GroupState } from './grouping/types';
import { analyzerReducer } from '../../resolver/store/reducer';
import type { AnalyzerOuterState } from '../../resolver/types';
enableMapSet();
export type SubPluginsInitReducer = HostsPluginReducer &
UsersPluginReducer &
@ -57,7 +62,8 @@ export const createInitialState = (
enableExperimental: ExperimentalFeatures;
},
dataTableState: DataTableState,
groupsState: GroupState
groupsState: GroupState,
analyzerState: AnalyzerOuterState
): State => {
const initialPatterns = {
[SourcererScopeName.default]: getScopePatternListSelection(
@ -112,6 +118,7 @@ export const createInitialState = (
globalUrlParam: initialGlobalUrlParam,
dataTable: dataTableState.dataTable,
groups: groupsState.groups,
analyzer: analyzerState.analyzer,
};
return preloadedState;
@ -131,5 +138,6 @@ export const createReducer: (
globalUrlParam: globalUrlParamReducer,
dataTable: dataTableReducer,
groups: groupsReducer,
analyzer: analyzerReducer,
...pluginsReducer,
});

View file

@ -18,11 +18,9 @@ import type {
import { applyMiddleware, createStore as createReduxStore } from 'redux';
import { composeWithDevTools } from 'redux-devtools-extension/developmentOnly';
import type { EnhancerOptions } from 'redux-devtools-extension';
import { createEpicMiddleware } from 'redux-observable';
import type { Observable } from 'rxjs';
import { BehaviorSubject, pluck } from 'rxjs';
import type { Storage } from '@kbn/kibana-utils-plugin/public';
import type { CoreStart } from '@kbn/core/public';
import reduceReducers from 'reduce-reducers';
@ -53,6 +51,9 @@ import { initDataView } from './sourcerer/model';
import type { AppObservableLibs, StartedSubPlugins, StartPlugins } from '../../types';
import type { ExperimentalFeatures } from '../../../common/experimental_features';
import { createSourcererDataView } from '../containers/sourcerer/create_sourcerer_data_view';
import type { AnalyzerOuterState } from '../../resolver/types';
import { resolverMiddlewareFactory } from '../../resolver/store/middleware';
import { dataAccessLayerFactory } from '../../resolver/data_access_layer/factory';
import { sourcererActions } from './sourcerer';
let store: Store<State, Action> | null = null;
@ -132,6 +133,12 @@ export const createStoreFactory = async (
groups: initialGroupingState,
};
const analyzerInitialState: AnalyzerOuterState = {
analyzer: {
analyzerById: {},
},
};
const timelineReducer = reduceReducers(
timelineInitialState.timeline,
startPlugins.timelines?.getTimelineReducer() ?? {},
@ -151,7 +158,8 @@ export const createStoreFactory = async (
enableExperimental,
},
dataTableInitialState,
groupsInitialState
groupsInitialState,
analyzerInitialState
);
const rootReducer = {
@ -162,6 +170,7 @@ export const createStoreFactory = async (
return createStore(initialState, rootReducer, libs$.pipe(pluck('kibana')), storage, [
...(subPlugins.management.store.middleware ?? []),
...[resolverMiddlewareFactory(dataAccessLayerFactory(coreStart)) ?? []],
]);
};
@ -261,6 +270,7 @@ export const createStore = (
): Store<State, Action> => {
const enhancerOptions: EnhancerOptions = {
name: 'Kibana Security Solution',
actionsBlacklist: ['USER_MOVED_POINTER', 'USER_SET_RASTER_SIZE'],
actionSanitizer: actionSanitizer as EnhancerOptions['actionSanitizer'],
stateSanitizer: stateSanitizer as EnhancerOptions['stateSanitizer'],
};

View file

@ -23,6 +23,7 @@ import type { ManagementPluginState } from '../../management';
import type { UsersPluginState } from '../../explore/users/store';
import type { GlobalUrlParam } from './global_url_param';
import type { GroupState } from './grouping/types';
import type { AnalyzerOuterState } from '../../resolver/types';
export type State = HostsPluginState &
UsersPluginState &
@ -36,7 +37,8 @@ export type State = HostsPluginState &
sourcerer: SourcererState;
globalUrlParam: GlobalUrlParam;
} & DataTableState &
GroupState;
GroupState &
AnalyzerOuterState;
/**
* The Redux store type for the Security app.
*/

View file

@ -6,7 +6,7 @@
*/
import type { FC } from 'react';
import React from 'react';
import React, { useMemo } from 'react';
import { EuiEmptyPrompt } from '@elastic/eui';
import { ANALYZER_ERROR_MESSAGE } from './translations';
@ -24,10 +24,11 @@ export const ANALYZE_GRAPH_ID = 'analyze_graph';
*/
export const AnalyzeGraph: FC = () => {
const { eventId } = useLeftPanelContext();
const scopeId = 'flyout'; // TO-DO: update to use context
const scopeId = 'flyout'; // Different scope Id to distinguish flyout and data table analyzers
const { from, to, shouldUpdate, selectedPatterns } = useTimelineDataFilters(
isActiveTimeline(scopeId)
);
const filters = useMemo(() => ({ from, to }), [from, to]);
if (!eventId) {
return (
@ -48,7 +49,7 @@ export const AnalyzeGraph: FC = () => {
resolverComponentInstanceID={scopeId}
indices={selectedPatterns}
shouldUpdate={shouldUpdate}
filters={{ from, to }}
filters={filters}
/>
</div>
);

View file

@ -5,8 +5,7 @@
* 2.0.
*/
import type { KibanaReactContextValue } from '@kbn/kibana-react-plugin/public';
import type { StartServices } from '../../types';
import type { CoreStart } from '@kbn/core/public';
import type { DataAccessLayer, TimeRange } from '../types';
import type {
ResolverNode,
@ -31,9 +30,7 @@ function getRangeFilter(timeRange: TimeRange | undefined) {
/**
* The data access layer for resolver. All communication with the Kibana server is done through this object. This object is provided to Resolver. In tests, a mock data access layer can be used instead.
*/
export function dataAccessLayerFactory(
context: KibanaReactContextValue<StartServices>
): DataAccessLayer {
export function dataAccessLayerFactory(context: CoreStart): DataAccessLayer {
const dataAccessLayer: DataAccessLayer = {
/**
* Used to get non-process related events for a node.
@ -48,7 +45,7 @@ export function dataAccessLayerFactory(
timeRange?: TimeRange;
indexPatterns: string[];
}): Promise<ResolverRelatedEvents> {
const response: ResolverPaginatedEvents = await context.services.http.post(
const response: ResolverPaginatedEvents = await context.http.post(
'/api/endpoint/resolver/events',
{
query: {},
@ -95,7 +92,7 @@ export function dataAccessLayerFactory(
},
};
if (category === 'alert') {
return context.services.http.post('/api/endpoint/resolver/events', {
return context.http.post('/api/endpoint/resolver/events', {
query: commonFields.query,
body: JSON.stringify({
...commonFields.body,
@ -104,7 +101,7 @@ export function dataAccessLayerFactory(
}),
});
} else {
return context.services.http.post('/api/endpoint/resolver/events', {
return context.http.post('/api/endpoint/resolver/events', {
query: commonFields.query,
body: JSON.stringify({
...commonFields.body,
@ -151,7 +148,7 @@ export function dataAccessLayerFactory(
}),
}),
};
const response: ResolverPaginatedEvents = await context.services.http.post(
const response: ResolverPaginatedEvents = await context.http.post(
'/api/endpoint/resolver/events',
query
);
@ -197,7 +194,7 @@ export function dataAccessLayerFactory(
},
};
if (eventCategory.includes('alert') === false) {
const response: ResolverPaginatedEvents = await context.services.http.post(
const response: ResolverPaginatedEvents = await context.http.post(
'/api/endpoint/resolver/events',
{
query: { limit: 1 },
@ -211,7 +208,7 @@ export function dataAccessLayerFactory(
const [oneEvent] = response.events;
return oneEvent ?? null;
} else {
const response: ResolverPaginatedEvents = await context.services.http.post(
const response: ResolverPaginatedEvents = await context.http.post(
'/api/endpoint/resolver/events',
{
query: { limit: 1 },
@ -252,7 +249,7 @@ export function dataAccessLayerFactory(
ancestors: number;
descendants: number;
}): Promise<ResolverNode[]> {
return context.services.http.post('/api/endpoint/resolver/tree', {
return context.http.post('/api/endpoint/resolver/tree', {
body: JSON.stringify({
ancestors,
descendants,
@ -276,7 +273,7 @@ export function dataAccessLayerFactory(
indices: string[];
signal: AbortSignal;
}): Promise<ResolverEntityIndex> {
return context.services.http.get('/api/endpoint/resolver/entity', {
return context.http.get('/api/endpoint/resolver/entity', {
signal,
query: {
_id,

View file

@ -5,17 +5,25 @@
* 2.0.
*/
import type { CameraAction } from './camera';
import type { DataAction } from './data/action';
import actionCreatorFactory from 'typescript-fsa';
const actionCreator = actionCreatorFactory('x-pack/security_solution/analyzer');
export const createResolver = actionCreator<{ id: string }>('CREATE_RESOLVER');
export const clearResolver = actionCreator<{ id: string }>('CLEAR_RESOLVER');
/**
* 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: string;
}
export const userRequestedRelatedEventData = actionCreator<{
/**
* Id that identify the scope of analyzer
*/
id: string;
readonly nodeID: string;
}>('REQUEST_RELATED_EVENT');
/**
* When the user switches the "active descendant" of the Resolver.
@ -24,10 +32,11 @@ interface UserRequestedRelatedEventData {
* the element that is focused on by the user's interactions with the UI, but
* not necessarily "selected" (see UserSelectedResolverNode below)
*/
interface UserFocusedOnResolverNode {
readonly type: 'userFocusedOnResolverNode';
readonly payload: {
export const userFocusedOnResolverNode = actionCreator<{
/**
* Id that identify the scope of analyzer
*/
readonly id: string;
/**
* Used to identify the node that should be brought into view.
*/
@ -36,8 +45,7 @@ interface UserFocusedOnResolverNode {
* The time (since epoch in milliseconds) when the action was dispatched.
*/
readonly time: number;
};
}
}>('FOCUS_ON_NODE');
/**
* When the user "selects" a node in the Resolver
@ -45,9 +53,11 @@ interface UserFocusedOnResolverNode {
* user most recently "picked" (by e.g. pressing a button corresponding
* to the element in a list) as opposed to "active" or "current" (see UserFocusedOnResolverNode above).
*/
interface UserSelectedResolverNode {
readonly type: 'userSelectedResolverNode';
readonly payload: {
export const userSelectedResolverNode = actionCreator<{
/**
* Id that identify the scope of analyzer
*/
readonly id: string;
/**
* Used to identify the node that should be brought into view.
*/
@ -56,51 +66,40 @@ interface UserSelectedResolverNode {
* The time (since epoch in milliseconds) when the action was dispatched.
*/
readonly time: number;
};
}
}>('SELECT_RESOLVER_NODE');
/**
* Used by `useStateSyncingActions` hook.
* This is dispatched when external sources provide new parameters for Resolver.
* When the component receives a new 'databaseDocumentID' prop, this is fired.
*/
interface AppReceivedNewExternalProperties {
type: 'appReceivedNewExternalProperties';
export const appReceivedNewExternalProperties = actionCreator<{
/**
* Defines the externally provided properties that Resolver acknowledges.
* Id that identify the scope of analyzer
*/
payload: {
readonly id: string;
/**
* the `_id` of an ES document. This defines the origin of the Resolver graph.
*/
databaseDocumentID: string;
readonly databaseDocumentID: string;
/**
* An ID that uniquely identifies this Resolver instance from other concurrent Resolvers.
*/
resolverComponentInstanceID: string;
readonly resolverComponentInstanceID: string;
/**
* The `search` part of the URL of this page.
*/
locationSearch: string;
readonly locationSearch: string;
/**
* Indices that the backend will use to find the document.
*/
indices: string[];
readonly indices: string[];
shouldUpdate: boolean;
filters: {
readonly shouldUpdate: boolean;
readonly filters: {
from?: string;
to?: string;
};
};
}
export type ResolverAction =
| CameraAction
| DataAction
| AppReceivedNewExternalProperties
| UserFocusedOnResolverNode
| UserSelectedResolverNode
| UserRequestedRelatedEventData;
}>('APP_RECEIVED_NEW_EXTERNAL_PROPERTIES');

View file

@ -5,112 +5,132 @@
* 2.0.
*/
import actionCreatorFactory from 'typescript-fsa';
import type { Vector2 } from '../../types';
interface TimestampedPayload {
/**
* Time (since epoch in milliseconds) when this action was dispatched.
*/
readonly time: number;
}
const actionCreator = actionCreatorFactory('x-pack/security_solution/analyzer');
interface UserSetZoomLevel {
readonly type: 'userSetZoomLevel';
export const userSetZoomLevel = actionCreator<{
/**
* Id that identify the scope of analyzer
*/
readonly id: string;
/**
* A number whose value is always between 0 and 1 and will be the new scaling factor for the projection.
*/
readonly payload: number;
}
readonly zoomLevel: number;
}>('USER_SET_ZOOM_LEVEL');
interface UserClickedZoomOut {
readonly type: 'userClickedZoomOut';
}
export const userClickedZoomOut = actionCreator<{
/**
* Id that identify the scope of analyzer
*/
readonly id: string;
}>('USER_CLICKED_ZOOM_OUT');
interface UserClickedZoomIn {
readonly type: 'userClickedZoomIn';
}
export const userClickedZoomIn = actionCreator<{
/**
* Id that identify the scope of analyzer
*/
readonly id: string;
}>('USER_CLICKED_ZOOM_IN');
interface UserZoomed {
readonly type: 'userZoomed';
readonly payload: {
export const userZoomed = actionCreator<{
/**
* Id that identify the scope of analyzer
*/
readonly id: string;
/**
* A value to zoom in by. Should be a fraction of `1`. For a `'wheel'` event when `event.deltaMode` is `'pixel'`,
* pass `event.deltaY / -renderHeight` where `renderHeight` is the height of the Resolver element in pixels.
*/
readonly zoomChange: number;
} & TimestampedPayload;
}
/**
* Time (since epoch in milliseconds) when this action was dispatched.
*/
readonly time: number;
}>('USER_ZOOMED');
interface UserSetRasterSize {
readonly type: 'userSetRasterSize';
export const userSetRasterSize = actionCreator<{
/**
* Id that identify the scope of analyzer
*/
readonly id: string;
/**
* The dimensions of the Resolver component in pixels. The Resolver component should not be scrollable itself.
*/
readonly payload: Vector2;
}
readonly dimensions: Vector2;
}>('USER_SET_RASTER_SIZE');
/**
* When the user warps the camera to an exact point instantly.
*/
interface UserSetPositionOfCamera {
readonly type: 'userSetPositionOfCamera';
export const userSetPositionOfCamera = actionCreator<{
/**
* Id that identify the scope of analyzer
*/
readonly id: string;
/**
* The world transform of the camera
*/
readonly payload: Vector2;
}
readonly cameraView: Vector2;
}>('USER_SET_CAMERA_POSITION');
interface UserStartedPanning {
readonly type: 'userStartedPanning';
readonly payload: {
export const userStartedPanning = actionCreator<{
/**
* Id that identify the scope of analyzer
*/
readonly id: string;
/**
* A vector in screen coordinates (each unit is a pixel and the Y axis increases towards the bottom of the screen)
* relative to the Resolver component.
* Represents a starting position during panning for a pointing device.
*/
readonly screenCoordinates: Vector2;
} & TimestampedPayload;
}
/**
* Time (since epoch in milliseconds) when this action was dispatched.
*/
readonly time: number;
}>('USER_STARTED_PANNING');
interface UserStoppedPanning {
readonly type: 'userStoppedPanning';
export const userStoppedPanning = actionCreator<{
/**
* Id that identify the scope of analyzer
*/
readonly id: string;
readonly time: number;
}>('USER_STOPPED_PANNING');
readonly payload: TimestampedPayload;
}
interface UserNudgedCamera {
readonly type: 'userNudgedCamera';
export const userNudgedCamera = actionCreator<{
/**
* Id that identify the scope of analyzer
*/
readonly id: string;
/**
* String that represents the direction in which Resolver can be panned
*/
readonly payload: {
/**
* A cardinal direction to move the users perspective in.
*/
readonly direction: Vector2;
} & TimestampedPayload;
}
/**
* Time (since epoch in milliseconds) when this action was dispatched.
*/
readonly time: number;
}>('USER_NUDGE_CAMERA');
interface UserMovedPointer {
readonly type: 'userMovedPointer';
readonly payload: {
export const userMovedPointer = actionCreator<{
/**
* Id that identify the scope of analyzer
*/
readonly id: string;
/**
* A vector in screen coordinates relative to the Resolver component.
* The payload should be contain clientX and clientY minus the client position of the Resolver component.
*/
screenCoordinates: Vector2;
} & TimestampedPayload;
}
export type CameraAction =
| UserSetZoomLevel
| UserSetRasterSize
| UserSetPositionOfCamera
| UserStartedPanning
| UserStoppedPanning
| UserZoomed
| UserMovedPointer
| UserClickedZoomOut
| UserClickedZoomIn
| UserNudgedCamera;
readonly screenCoordinates: Vector2;
/**
* Time (since epoch in milliseconds) when this action was dispatched.
*/
readonly time: number;
}>('USER_MOVED_POINTER');

View file

@ -5,49 +5,46 @@
* 2.0.
*/
import type { Store, Reducer } from 'redux';
import type { Store, Reducer, AnyAction } from 'redux';
import { createStore } from 'redux';
import { cameraReducer, cameraInitialState } from './reducer';
import type { CameraState, Vector2 } from '../../types';
import { cameraReducer } from './reducer';
import type { AnalyzerState, Vector2 } from '../../types';
import * as selectors from './selectors';
import { animatePanning } from './methods';
import { lerp } from '../../lib/math';
import type { ResolverAction } from '../actions';
import { panAnimationDuration } from './scaling_constants';
type TestAction =
| ResolverAction
| {
readonly type: 'animatePanning';
readonly payload: {
/**
* The start time of the animation.
*/
readonly time: number;
/**
* The duration of the animation.
*/
readonly duration: number;
/**
* The target translation the camera will animate towards.
*/
readonly targetTranslation: Vector2;
};
};
import { EMPTY_RESOLVER } from '../helpers';
describe('when the camera is created', () => {
let store: Store<CameraState, TestAction>;
let store: Store<AnalyzerState, AnyAction>;
const id = 'test-id';
beforeEach(() => {
const testReducer: Reducer<CameraState, TestAction> = (
state = cameraInitialState(),
const testReducer: Reducer<AnalyzerState, AnyAction> = (
state = {
analyzerById: {
[id]: EMPTY_RESOLVER,
},
},
action
): CameraState => {
): AnalyzerState => {
// If the test action is fired, call the animatePanning method
if (action.type === 'animatePanning') {
const {
payload: { time, targetTranslation, duration },
} = action;
return animatePanning(state, time, targetTranslation, duration);
return {
analyzerById: {
[id]: {
...state.analyzerById[id],
camera: animatePanning(
state.analyzerById[id].camera,
time,
targetTranslation,
duration
),
},
},
};
}
return cameraReducer(state, action);
};
@ -55,17 +52,17 @@ describe('when the camera is created', () => {
});
it('should be at 0,0', () => {
expect(selectors.translation(store.getState())(0)).toEqual([0, 0]);
expect(selectors.translation(store.getState().analyzerById[id].camera)(0)).toEqual([0, 0]);
});
it('should have scale of [1,1]', () => {
expect(selectors.scale(store.getState())(0)).toEqual([1, 1]);
expect(selectors.scale(store.getState().analyzerById[id].camera)(0)).toEqual([1, 1]);
});
describe('When attempting to pan to current position and scale', () => {
const duration = panAnimationDuration;
const startTime = 0;
beforeEach(() => {
const action: TestAction = {
const action: AnyAction = {
type: 'animatePanning',
payload: {
time: startTime,
@ -85,10 +82,14 @@ describe('when the camera is created', () => {
const state = store.getState();
for (let progress = 0; progress <= 1; progress += 0.1) {
translationAtIntervals.push(
selectors.translation(state)(lerp(startTime, startTime + duration, progress))
selectors.translation(state.analyzerById[id].camera)(
lerp(startTime, startTime + duration, progress)
)
);
scaleAtIntervals.push(
selectors.scale(state)(lerp(startTime, startTime + duration, progress))
selectors.scale(state.analyzerById[id].camera)(
lerp(startTime, startTime + duration, progress)
)
);
}
});
@ -110,7 +111,7 @@ describe('when the camera is created', () => {
beforeEach(() => {
// The distance the camera moves must be nontrivial in order to trigger a scale animation
targetTranslation = [1000, 1000];
const action: TestAction = {
const action: AnyAction = {
type: 'animatePanning',
payload: {
time: startTime,
@ -129,10 +130,14 @@ describe('when the camera is created', () => {
const state = store.getState();
for (let progress = 0; progress <= 1; progress += 0.1) {
translationAtIntervals.push(
selectors.translation(state)(lerp(startTime, startTime + duration, progress))
selectors.translation(state.analyzerById[id].camera)(
lerp(startTime, startTime + duration, progress)
)
);
scaleAtIntervals.push(
selectors.scale(state)(lerp(startTime, startTime + duration, progress))
selectors.scale(state.analyzerById[id].camera)(
lerp(startTime, startTime + duration, progress)
)
);
}
});

View file

@ -20,4 +20,3 @@
* would not be in the camera's viewport would be ignored.
*/
export { cameraReducer } from './reducer';
export type { CameraAction } from './action';

View file

@ -5,26 +5,36 @@
* 2.0.
*/
import type { Store } from 'redux';
import type { Store, AnyAction, Reducer } from 'redux';
import { createStore } from 'redux';
import type { CameraAction } from './action';
import type { CameraState } from '../../types';
import type { AnalyzerState } from '../../types';
import { cameraReducer } from './reducer';
import { inverseProjectionMatrix } from './selectors';
import { applyMatrix3 } from '../../models/vector2';
import { scaleToZoom } from './scale_to_zoom';
import { EMPTY_RESOLVER } from '../helpers';
import { userSetZoomLevel, userSetPositionOfCamera, userSetRasterSize } from './action';
describe('inverseProjectionMatrix', () => {
let store: Store<CameraState, CameraAction>;
let store: Store<AnalyzerState, AnyAction>;
let compare: (worldPosition: [number, number], expectedRasterPosition: [number, number]) => void;
const id = 'test-id';
beforeEach(() => {
store = createStore(cameraReducer, undefined);
const testReducer: Reducer<AnalyzerState, AnyAction> = (
state = {
analyzerById: {
[id]: EMPTY_RESOLVER,
},
},
action
): AnalyzerState => cameraReducer(state, action);
store = createStore(testReducer, undefined);
compare = (rasterPosition: [number, number], expectedWorldPosition: [number, number]) => {
// time isn't really relevant as we aren't testing animation
const time = 0;
const [worldX, worldY] = applyMatrix3(
rasterPosition,
inverseProjectionMatrix(store.getState())(time)
inverseProjectionMatrix(store.getState().analyzerById[id].camera)(time)
);
expect(worldX).toBeCloseTo(expectedWorldPosition[0]);
expect(worldY).toBeCloseTo(expectedWorldPosition[1]);
@ -33,8 +43,7 @@ describe('inverseProjectionMatrix', () => {
describe('when the raster size is 0x0 pixels', () => {
beforeEach(() => {
const action: CameraAction = { type: 'userSetRasterSize', payload: [0, 0] };
store.dispatch(action);
store.dispatch(userSetRasterSize({ id, dimensions: [0, 0] }));
});
it('should convert 0,0 in raster space to 0,0 (center) in world space', () => {
compare([10, 0], [0, 0]);
@ -43,8 +52,7 @@ describe('inverseProjectionMatrix', () => {
describe('when the raster size is 300 x 200 pixels', () => {
beforeEach(() => {
const action: CameraAction = { type: 'userSetRasterSize', payload: [300, 200] };
store.dispatch(action);
store.dispatch(userSetRasterSize({ id, dimensions: [300, 200] }));
});
it('should convert 150,100 in raster space to 0,0 (center) in world space', () => {
compare([150, 100], [0, 0]);
@ -75,8 +83,7 @@ describe('inverseProjectionMatrix', () => {
});
describe('when the user has zoomed to 0.5', () => {
beforeEach(() => {
const action: CameraAction = { type: 'userSetZoomLevel', payload: scaleToZoom(0.5) };
store.dispatch(action);
store.dispatch(userSetZoomLevel({ id, zoomLevel: scaleToZoom(0.5) }));
});
it('should convert 150, 100 (center) to 0, 0 (center) in world space', () => {
compare([150, 100], [0, 0]);
@ -84,8 +91,7 @@ describe('inverseProjectionMatrix', () => {
});
describe('when the user has panned to the right and up by 50', () => {
beforeEach(() => {
const action: CameraAction = { type: 'userSetPositionOfCamera', payload: [50, 50] };
store.dispatch(action);
store.dispatch(userSetPositionOfCamera({ id, cameraView: [50, 50] }));
});
it('should convert 100,150 in raster space to 0,0 (center) in world space', () => {
compare([100, 150], [0, 0]);
@ -99,14 +105,12 @@ describe('inverseProjectionMatrix', () => {
});
describe('when the user has panned to the right by 350 and up by 250', () => {
beforeEach(() => {
const action: CameraAction = { type: 'userSetPositionOfCamera', payload: [350, 250] };
store.dispatch(action);
store.dispatch(userSetPositionOfCamera({ id, cameraView: [350, 250] }));
});
describe('when the user has scaled to 2', () => {
// the viewport will only cover half, or 150x100 instead of 300x200
beforeEach(() => {
const action: CameraAction = { type: 'userSetZoomLevel', payload: scaleToZoom(2) };
store.dispatch(action);
store.dispatch(userSetZoomLevel({ id, zoomLevel: scaleToZoom(2) }));
});
// we expect the viewport to be
// minX = 350 - (150/2) = 275

View file

@ -5,65 +5,68 @@
* 2.0.
*/
import type { Store } from 'redux';
import type { Store, Reducer, AnyAction } from 'redux';
import { createStore } from 'redux';
import { cameraReducer } from './reducer';
import type { CameraState, Vector2 } from '../../types';
import type { CameraAction } from './action';
import type { AnalyzerState, Vector2 } from '../../types';
import { translation } from './selectors';
import { EMPTY_RESOLVER } from '../helpers';
import {
userStartedPanning,
userStoppedPanning,
userNudgedCamera,
userSetRasterSize,
userMovedPointer,
} from './action';
describe('panning interaction', () => {
let store: Store<CameraState, CameraAction>;
let store: Store<AnalyzerState, AnyAction>;
let translationShouldBeCloseTo: (expectedTranslation: Vector2) => void;
let time: number;
const id = 'test-id';
beforeEach(() => {
// The time isn't relevant as we don't use animations in this suite.
time = 0;
store = createStore(cameraReducer, undefined);
const testReducer: Reducer<AnalyzerState, AnyAction> = (
state = {
analyzerById: {
[id]: EMPTY_RESOLVER,
},
},
action
): AnalyzerState => cameraReducer(state, action);
store = createStore(testReducer, undefined);
translationShouldBeCloseTo = (expectedTranslation) => {
const actualTranslation = translation(store.getState())(time);
const actualTranslation = translation(store.getState().analyzerById[id].camera)(time);
expect(expectedTranslation[0]).toBeCloseTo(actualTranslation[0]);
expect(expectedTranslation[1]).toBeCloseTo(actualTranslation[1]);
};
});
describe('when the raster size is 300 x 200 pixels', () => {
beforeEach(() => {
const action: CameraAction = { type: 'userSetRasterSize', payload: [300, 200] };
store.dispatch(action);
store.dispatch(userSetRasterSize({ id, dimensions: [300, 200] }));
});
it('should have a translation of 0,0', () => {
translationShouldBeCloseTo([0, 0]);
});
describe('when the user has started panning at (100, 100)', () => {
beforeEach(() => {
const action: CameraAction = {
type: 'userStartedPanning',
payload: { screenCoordinates: [100, 100], time },
};
store.dispatch(action);
store.dispatch(userStartedPanning({ id, screenCoordinates: [100, 100], time }));
});
it('should have a translation of 0,0', () => {
translationShouldBeCloseTo([0, 0]);
});
describe('when the user moves their pointer 50px up and right (towards the top right of the screen)', () => {
beforeEach(() => {
const action: CameraAction = {
type: 'userMovedPointer',
payload: { screenCoordinates: [150, 50], time },
};
store.dispatch(action);
store.dispatch(userMovedPointer({ id, screenCoordinates: [150, 50], time }));
});
it('should have a translation of [-50, -50] as the camera is now focused on things lower and to the left.', () => {
translationShouldBeCloseTo([-50, -50]);
});
describe('when the user then stops panning', () => {
beforeEach(() => {
const action: CameraAction = {
type: 'userStoppedPanning',
payload: { time },
};
store.dispatch(action);
store.dispatch(userStoppedPanning({ id, time }));
});
it('should still have a translation of [-50, -50]', () => {
translationShouldBeCloseTo([-50, -50]);
@ -74,11 +77,7 @@ describe('panning interaction', () => {
});
describe('when the user nudges the camera up', () => {
beforeEach(() => {
const action: CameraAction = {
type: 'userNudgedCamera',
payload: { direction: [0, 1], time },
};
store.dispatch(action);
store.dispatch(userNudgedCamera({ id, direction: [0, 1], time }));
});
it('the camera eventually moves up so that objects appear closer to the bottom of the screen', () => {
const aBitIntoTheFuture = time + 100;
@ -86,7 +85,9 @@ describe('panning interaction', () => {
/**
* Check the position once the animation has advanced 100ms
*/
const actual: Vector2 = translation(store.getState())(aBitIntoTheFuture);
const actual: Vector2 = translation(store.getState().analyzerById[id].camera)(
aBitIntoTheFuture
);
expect(actual).toMatchInlineSnapshot(`
Array [
0,

View file

@ -5,26 +5,37 @@
* 2.0.
*/
import type { Store } from 'redux';
import type { Store, AnyAction, Reducer } from 'redux';
import { createStore } from 'redux';
import type { CameraAction } from './action';
import type { CameraState } from '../../types';
// import type { AnyAction } from './action';
import type { AnalyzerState } from '../../types';
import { cameraReducer } from './reducer';
import { projectionMatrix } from './selectors';
import { applyMatrix3 } from '../../models/vector2';
import { scaleToZoom } from './scale_to_zoom';
import { userSetZoomLevel, userSetPositionOfCamera, userSetRasterSize } from './action';
import { EMPTY_RESOLVER } from '../helpers';
describe('projectionMatrix', () => {
let store: Store<CameraState, CameraAction>;
let store: Store<AnalyzerState, AnyAction>;
let compare: (worldPosition: [number, number], expectedRasterPosition: [number, number]) => void;
const id = 'test-id';
beforeEach(() => {
store = createStore(cameraReducer, undefined);
const testReducer: Reducer<AnalyzerState, AnyAction> = (
state = {
analyzerById: {
[id]: EMPTY_RESOLVER,
},
},
action
): AnalyzerState => cameraReducer(state, action);
store = createStore(testReducer, undefined);
compare = (worldPosition: [number, number], expectedRasterPosition: [number, number]) => {
// time isn't really relevant as we aren't testing animation
const time = 0;
const [rasterX, rasterY] = applyMatrix3(
worldPosition,
projectionMatrix(store.getState())(time)
projectionMatrix(store.getState().analyzerById[id].camera)(time)
);
expect(rasterX).toBeCloseTo(expectedRasterPosition[0]);
expect(rasterY).toBeCloseTo(expectedRasterPosition[1]);
@ -37,8 +48,7 @@ describe('projectionMatrix', () => {
});
describe('when the raster size is 300 x 200 pixels', () => {
beforeEach(() => {
const action: CameraAction = { type: 'userSetRasterSize', payload: [300, 200] };
store.dispatch(action);
store.dispatch(userSetRasterSize({ id, dimensions: [300, 200] }));
});
it('should convert 0,0 (center) in world space to 150,100 in raster space', () => {
compare([0, 0], [150, 100]);
@ -69,8 +79,7 @@ describe('projectionMatrix', () => {
});
describe('when the user has zoomed to 0.5', () => {
beforeEach(() => {
const action: CameraAction = { type: 'userSetZoomLevel', payload: scaleToZoom(0.5) };
store.dispatch(action);
store.dispatch(userSetZoomLevel({ id, zoomLevel: scaleToZoom(0.5) }));
});
it('should convert 0, 0 (center) in world space to 150, 100 (center)', () => {
compare([0, 0], [150, 100]);
@ -78,8 +87,7 @@ describe('projectionMatrix', () => {
});
describe('when the user has panned to the right and up by 50', () => {
beforeEach(() => {
const action: CameraAction = { type: 'userSetPositionOfCamera', payload: [50, 50] };
store.dispatch(action);
store.dispatch(userSetPositionOfCamera({ id, cameraView: [50, 50] }));
});
it('should convert 0,0 (center) in world space to 100,150 in raster space', () => {
compare([0, 0], [100, 150]);
@ -93,11 +101,7 @@ describe('projectionMatrix', () => {
});
describe('when the user has panned to the right by 350 and up by 250', () => {
beforeEach(() => {
const action: CameraAction = {
type: 'userSetPositionOfCamera',
payload: [350, 250],
};
store.dispatch(action);
store.dispatch(userSetPositionOfCamera({ id, cameraView: [350, 250] }));
});
it('should convert 350,250 in world space to 150,100 (center) in raster space', () => {
compare([350, 250], [150, 100]);
@ -105,8 +109,7 @@ describe('projectionMatrix', () => {
describe('when the user has scaled to 2', () => {
// the viewport will only cover half, or 150x100 instead of 300x200
beforeEach(() => {
const action: CameraAction = { type: 'userSetZoomLevel', payload: scaleToZoom(2) };
store.dispatch(action);
store.dispatch(userSetZoomLevel({ id, zoomLevel: scaleToZoom(2) }));
});
// we expect the viewport to be
// minX = 350 - (150/2) = 275

View file

@ -5,62 +5,60 @@
* 2.0.
*/
import type { Reducer } from 'redux';
import type { Draft } from 'immer';
import { reducerWithInitialState } from 'typescript-fsa-reducers';
import { unitsPerNudge, nudgeAnimationDuration } from './scaling_constants';
import { animatePanning } from './methods';
import * as vector2 from '../../models/vector2';
import * as selectors from './selectors';
import { clamp } from '../../lib/math';
import type { CameraState, Vector2 } from '../../types';
import { scaleToZoom } from './scale_to_zoom';
import type { ResolverAction } from '../actions';
import { initialAnalyzerState, immerCase } from '../helpers';
import {
userSetZoomLevel,
userClickedZoomOut,
userClickedZoomIn,
userZoomed,
userStartedPanning,
userStoppedPanning,
userSetPositionOfCamera,
userNudgedCamera,
userSetRasterSize,
userMovedPointer,
} from './action';
/**
* Used in tests.
*/
export function cameraInitialState(): CameraState {
const state: CameraState = {
scalingFactor: scaleToZoom(1), // Defaulted to 1 to 1 scale
rasterSize: [0, 0] as const,
translationNotCountingCurrentPanning: [0, 0] as const,
latestFocusedWorldCoordinates: null,
animation: undefined,
panning: undefined,
};
return state;
}
export const cameraReducer: Reducer<CameraState, ResolverAction> = (
state = cameraInitialState(),
action
) => {
if (action.type === 'userSetZoomLevel') {
export const cameraReducer = reducerWithInitialState(initialAnalyzerState)
.withHandling(
immerCase(userSetZoomLevel, (draft, { id, zoomLevel }) => {
/**
* Handle the scale being explicitly set, for example by a 'reset zoom' feature, or by a range slider with exact scale values
*/
const nextState: CameraState = {
const state: Draft<CameraState> = draft.analyzerById[id].camera;
state.scalingFactor = clamp(zoomLevel, 0, 1);
return draft;
})
)
.withHandling(
immerCase(userClickedZoomIn, (draft, { id }) => {
const state: Draft<CameraState> = draft.analyzerById[id].camera;
state.scalingFactor = clamp(state.scalingFactor + 0.1, 0, 1);
return draft;
})
)
.withHandling(
immerCase(userClickedZoomOut, (draft, { id }) => {
const state: Draft<CameraState> = draft.analyzerById[id].camera;
state.scalingFactor = clamp(state.scalingFactor - 0.1, 0, 1);
return draft;
})
)
.withHandling(
immerCase(userZoomed, (draft, { id, zoomChange, time }) => {
const state: Draft<CameraState> = draft.analyzerById[id].camera;
const stateWithNewScaling: Draft<CameraState> = {
...state,
scalingFactor: clamp(action.payload, 0, 1),
scalingFactor: clamp(state.scalingFactor + zoomChange, 0, 1),
};
return nextState;
} else if (action.type === 'userClickedZoomIn') {
return {
...state,
scalingFactor: clamp(state.scalingFactor + 0.1, 0, 1),
};
} else if (action.type === 'userClickedZoomOut') {
return {
...state,
scalingFactor: clamp(state.scalingFactor - 0.1, 0, 1),
};
} else if (action.type === 'userZoomed') {
const stateWithNewScaling: CameraState = {
...state,
scalingFactor: clamp(state.scalingFactor + action.payload.zoomChange, 0, 1),
};
/**
* Zooming fundamentally just changes the scale, but that would always zoom in (or out) around the center of the map. The user might be interested in
* something else, like a node. If the user has moved their pointer on to the map, we can keep the pointer over the same point in the map by adjusting the
@ -70,17 +68,14 @@ export const cameraReducer: Reducer<CameraState, ResolverAction> = (
* using CTRL and the mousewheel, or by pinching the trackpad on a Mac. The node will stay under your mouse cursor and other things in the map will get
* nearer or further from the mouse cursor. This lets you keep your context when changing zoom levels.
*/
if (
state.latestFocusedWorldCoordinates !== null &&
!selectors.isAnimating(state)(action.payload.time)
) {
if (state.latestFocusedWorldCoordinates !== null && !selectors.isAnimating(state)(time)) {
const rasterOfLastFocusedWorldCoordinates = vector2.applyMatrix3(
state.latestFocusedWorldCoordinates,
selectors.projectionMatrix(state)(action.payload.time)
selectors.projectionMatrix(state)(time)
);
const newWorldCoordinatesAtLastFocusedPosition = vector2.applyMatrix3(
rasterOfLastFocusedWorldCoordinates,
selectors.inverseProjectionMatrix(stateWithNewScaling)(action.payload.time)
selectors.inverseProjectionMatrix(stateWithNewScaling)(time)
);
/**
@ -98,54 +93,61 @@ export const cameraReducer: Reducer<CameraState, ResolverAction> = (
stateWithNewScaling.translationNotCountingCurrentPanning,
delta
);
const nextState: CameraState = {
draft.analyzerById[id].camera = {
...stateWithNewScaling,
translationNotCountingCurrentPanning,
};
return nextState;
} else {
return stateWithNewScaling;
draft.analyzerById[id].camera = stateWithNewScaling;
}
} else if (action.type === 'userSetPositionOfCamera') {
return draft;
})
)
.withHandling(
immerCase(userSetPositionOfCamera, (draft, { id, cameraView }) => {
/**
* Handle the case where the position of the camera is explicitly set, for example by a 'back to center' feature.
*/
const nextState: CameraState = {
...state,
animation: undefined,
translationNotCountingCurrentPanning: action.payload,
};
return nextState;
} else if (action.type === 'userStartedPanning') {
if (selectors.isAnimating(state)(action.payload.time)) {
return state;
const state: Draft<CameraState> = draft.analyzerById[id].camera;
state.animation = undefined;
state.translationNotCountingCurrentPanning[0] = cameraView[0];
state.translationNotCountingCurrentPanning[1] = cameraView[1];
return draft;
})
)
.withHandling(
immerCase(userStartedPanning, (draft, { id, screenCoordinates, time }) => {
const state: Draft<CameraState> = draft.analyzerById[id].camera;
if (selectors.isAnimating(state)(time)) {
return draft;
}
/**
* When the user begins panning with a mousedown event we mark the starting position for later comparisons.
*/
const nextState: CameraState = {
...state,
animation: undefined,
panning: {
origin: action.payload.screenCoordinates,
currentOffset: action.payload.screenCoordinates,
},
state.animation = undefined;
state.panning = {
...state.panning,
origin: screenCoordinates,
currentOffset: screenCoordinates,
};
return nextState;
} else if (action.type === 'userStoppedPanning') {
return draft;
})
)
.withHandling(
immerCase(userStoppedPanning, (draft, { id, time }) => {
/**
* When the user stops panning (by letting up on the mouse) we calculate the new translation of the camera.
*/
const nextState: CameraState = {
...state,
translationNotCountingCurrentPanning: selectors.translation(state)(action.payload.time),
panning: undefined,
};
return nextState;
} else if (action.type === 'userNudgedCamera') {
const { direction, time } = action.payload;
const state: Draft<CameraState> = draft.analyzerById[id].camera;
state.translationNotCountingCurrentPanning = selectors.translation(state)(time);
state.panning = undefined;
return draft;
})
)
.withHandling(
immerCase(userNudgedCamera, (draft, { id, direction, time }) => {
const state: Draft<CameraState> = draft.analyzerById[id].camera;
/**
* Nudge less when zoomed in.
*/
@ -154,34 +156,39 @@ export const cameraReducer: Reducer<CameraState, ResolverAction> = (
direction
);
return animatePanning(
draft.analyzerById[id].camera = animatePanning(
state,
time,
vector2.add(state.translationNotCountingCurrentPanning, nudge),
nudgeAnimationDuration
);
} else if (action.type === 'userSetRasterSize') {
return draft;
})
)
.withHandling(
immerCase(userSetRasterSize, (draft, { id, dimensions }) => {
/**
* Handle resizes of the Resolver component. We need to know the size in order to convert between screen
* and world coordinates.
*/
const nextState: CameraState = {
...state,
rasterSize: action.payload,
};
return nextState;
} else if (action.type === 'userMovedPointer') {
let stateWithUpdatedPanning: CameraState = state;
draft.analyzerById[id].camera.rasterSize = dimensions;
return draft;
})
)
.withHandling(
immerCase(userMovedPointer, (draft, { id, screenCoordinates, time }) => {
const state: Draft<CameraState> = draft.analyzerById[id].camera;
let stateWithUpdatedPanning: Draft<CameraState> = draft.analyzerById[id].camera;
if (state.panning) {
stateWithUpdatedPanning = {
...state,
panning: {
origin: state.panning.origin,
currentOffset: action.payload.screenCoordinates,
currentOffset: screenCoordinates,
},
};
}
const nextState: CameraState = {
draft.analyzerById[id].camera = {
...stateWithUpdatedPanning,
/**
* keep track of the last world coordinates the user moved over.
@ -190,12 +197,11 @@ export const cameraReducer: Reducer<CameraState, ResolverAction> = (
* In order to do this, we need to know the position of the mouse when changing the scale.
*/
latestFocusedWorldCoordinates: vector2.applyMatrix3(
action.payload.screenCoordinates,
selectors.inverseProjectionMatrix(stateWithUpdatedPanning)(action.payload.time)
screenCoordinates,
selectors.inverseProjectionMatrix(stateWithUpdatedPanning)(time)
),
};
return nextState;
} else {
return state;
}
};
return draft;
})
)
.build();

View file

@ -5,25 +5,35 @@
* 2.0.
*/
import type { CameraAction } from './action';
import { cameraReducer } from './reducer';
import type { Store } from 'redux';
import type { Store, AnyAction, Reducer } from 'redux';
import { createStore } from 'redux';
import type { CameraState, AABB } from '../../types';
import type { AnalyzerState, CameraState, AABB } from '../../types';
import { viewableBoundingBox, inverseProjectionMatrix, scalingFactor } from './selectors';
import { expectVectorsToBeClose } from './test_helpers';
import { scaleToZoom } from './scale_to_zoom';
import { applyMatrix3 } from '../../models/vector2';
import { EMPTY_RESOLVER } from '../helpers';
import {
userSetZoomLevel,
userClickedZoomOut,
userClickedZoomIn,
userZoomed,
userSetPositionOfCamera,
userSetRasterSize,
userMovedPointer,
} from './action';
describe('zooming', () => {
let store: Store<CameraState, CameraAction>;
let store: Store<AnalyzerState, AnyAction>;
let time: number;
const id = 'test-id';
const cameraShouldBeBoundBy = (expectedViewableBoundingBox: AABB): [string, () => void] => {
return [
`the camera view should be bound by an AABB with a minimum point of ${expectedViewableBoundingBox.minimum} and a maximum point of ${expectedViewableBoundingBox.maximum}`,
() => {
const actual = viewableBoundingBox(store.getState())(time);
const actual = viewableBoundingBox(store.getState().analyzerById[id].camera)(time);
expect(actual.minimum[0]).toBeCloseTo(expectedViewableBoundingBox.minimum[0]);
expect(actual.minimum[1]).toBeCloseTo(expectedViewableBoundingBox.minimum[1]);
expect(actual.maximum[0]).toBeCloseTo(expectedViewableBoundingBox.maximum[0]);
@ -34,12 +44,19 @@ describe('zooming', () => {
beforeEach(() => {
// Time isn't relevant as we aren't testing animation
time = 0;
store = createStore(cameraReducer, undefined);
const testReducer: Reducer<AnalyzerState, AnyAction> = (
state = {
analyzerById: {
[id]: EMPTY_RESOLVER,
},
},
action
): AnalyzerState => cameraReducer(state, action);
store = createStore(testReducer, undefined);
});
describe('when the raster size is 300 x 200 pixels', () => {
beforeEach(() => {
const action: CameraAction = { type: 'userSetRasterSize', payload: [300, 200] };
store.dispatch(action);
store.dispatch(userSetRasterSize({ id, dimensions: [300, 200] }));
});
it(
...cameraShouldBeBoundBy({
@ -49,8 +66,7 @@ describe('zooming', () => {
);
describe('when the user has scaled in to 2x', () => {
beforeEach(() => {
const action: CameraAction = { type: 'userSetZoomLevel', payload: scaleToZoom(2) };
store.dispatch(action);
store.dispatch(userSetZoomLevel({ id, zoomLevel: scaleToZoom(2) }));
});
it(
...cameraShouldBeBoundBy({
@ -61,14 +77,10 @@ describe('zooming', () => {
});
describe('when the user zooms in all the way', () => {
beforeEach(() => {
const action: CameraAction = {
type: 'userZoomed',
payload: { zoomChange: 1, time },
};
store.dispatch(action);
store.dispatch(userZoomed({ id, zoomChange: 1, time }));
});
it('should zoom to maximum scale factor', () => {
const actual = viewableBoundingBox(store.getState())(time);
const actual = viewableBoundingBox(store.getState().analyzerById[id].camera)(time);
expect(actual).toMatchInlineSnapshot(`
Object {
"maximum": Array [
@ -85,20 +97,19 @@ describe('zooming', () => {
});
it('the raster position 200, 50 should map to the world position 50, 50', () => {
expectVectorsToBeClose(
applyMatrix3([200, 50], inverseProjectionMatrix(store.getState())(time)),
applyMatrix3(
[200, 50],
inverseProjectionMatrix(store.getState().analyzerById[id].camera)(time)
),
[50, 50]
);
});
describe('when the user has moved their mouse to the raster position 200, 50', () => {
beforeEach(() => {
const action: CameraAction = {
type: 'userMovedPointer',
payload: { screenCoordinates: [200, 50], time },
};
store.dispatch(action);
store.dispatch(userMovedPointer({ id, screenCoordinates: [200, 50], time }));
});
it('should have focused the world position 50, 50', () => {
const coords = store.getState().latestFocusedWorldCoordinates;
const coords = store.getState().analyzerById[id].camera.latestFocusedWorldCoordinates;
if (coords !== null) {
expectVectorsToBeClose(coords, [50, 50]);
} else {
@ -107,15 +118,14 @@ describe('zooming', () => {
});
describe('when the user zooms in by 0.5 zoom units', () => {
beforeEach(() => {
const action: CameraAction = {
type: 'userZoomed',
payload: { zoomChange: 0.5, time },
};
store.dispatch(action);
store.dispatch(userZoomed({ id, zoomChange: 0.5, time }));
});
it('the raster position 200, 50 should map to the world position 50, 50', () => {
expectVectorsToBeClose(
applyMatrix3([200, 50], inverseProjectionMatrix(store.getState())(time)),
applyMatrix3(
[200, 50],
inverseProjectionMatrix(store.getState().analyzerById[id].camera)(time)
),
[50, 50]
);
});
@ -123,8 +133,7 @@ describe('zooming', () => {
});
describe('when the user pans right by 100 pixels', () => {
beforeEach(() => {
const action: CameraAction = { type: 'userSetPositionOfCamera', payload: [100, 0] };
store.dispatch(action);
store.dispatch(userSetPositionOfCamera({ id, cameraView: [100, 0] }));
});
it(
...cameraShouldBeBoundBy({
@ -135,20 +144,19 @@ describe('zooming', () => {
it('should be centered on 100, 0', () => {
const worldCenterPoint = applyMatrix3(
[150, 100],
inverseProjectionMatrix(store.getState())(time)
inverseProjectionMatrix(store.getState().analyzerById[id].camera)(time)
);
expect(worldCenterPoint[0]).toBeCloseTo(100);
expect(worldCenterPoint[1]).toBeCloseTo(0);
});
describe('when the user scales to 2x', () => {
beforeEach(() => {
const action: CameraAction = { type: 'userSetZoomLevel', payload: scaleToZoom(2) };
store.dispatch(action);
store.dispatch(userSetZoomLevel({ id, zoomLevel: scaleToZoom(2) }));
});
it('should be centered on 100, 0', () => {
const worldCenterPoint = applyMatrix3(
[150, 100],
inverseProjectionMatrix(store.getState())(time)
inverseProjectionMatrix(store.getState().analyzerById[id].camera)(time)
);
expect(worldCenterPoint[0]).toBeCloseTo(100);
expect(worldCenterPoint[1]).toBeCloseTo(0);
@ -160,23 +168,21 @@ describe('zooming', () => {
let previousScalingFactor: CameraState['scalingFactor'];
describe('when user clicks on zoom in button', () => {
beforeEach(() => {
previousScalingFactor = scalingFactor(store.getState());
const action: CameraAction = { type: 'userClickedZoomIn' };
store.dispatch(action);
previousScalingFactor = scalingFactor(store.getState().analyzerById[id].camera);
store.dispatch(userClickedZoomIn({ id }));
});
it('the scaling factor should increase by 0.1 units', () => {
const actual = scalingFactor(store.getState());
const actual = scalingFactor(store.getState().analyzerById[id].camera);
expect(actual).toEqual(previousScalingFactor + 0.1);
});
});
describe('when user clicks on zoom out button', () => {
beforeEach(() => {
previousScalingFactor = scalingFactor(store.getState());
const action: CameraAction = { type: 'userClickedZoomOut' };
store.dispatch(action);
previousScalingFactor = scalingFactor(store.getState().analyzerById[id].camera);
store.dispatch(userClickedZoomOut({ id }));
});
it('the scaling factor should decrease by 0.1 units', () => {
const actual = scalingFactor(store.getState());
const actual = scalingFactor(store.getState().analyzerById[id].camera);
expect(actual).toEqual(previousScalingFactor - 0.1);
});
});

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import actionCreatorFactory from 'typescript-fsa';
import type {
NewResolverTree,
SafeEndpointEvent,
@ -13,9 +14,13 @@ import type {
} from '../../../../common/endpoint/types';
import type { TreeFetcherParameters, PanelViewAndParameters, TimeFilters } from '../../types';
interface ServerReturnedResolverData {
readonly type: 'serverReturnedResolverData';
readonly payload: {
const actionCreator = actionCreatorFactory('x-pack/security_solution/analyzer');
export const serverReturnedResolverData = actionCreator<{
/**
* Id that identify the scope of analyzer
*/
id: string;
/**
* The result of fetching data
*/
@ -38,99 +43,116 @@ interface ServerReturnedResolverData {
* an unbounded request is made, and the time range of the result set displayed to the user through this value.
*/
detectedBounds?: TimeFilters;
};
}
}>('SERVER_RETURNED_RESOLVER_DATA');
interface AppRequestedNodeEventsInCategory {
readonly type: 'appRequestedNodeEventsInCategory';
readonly payload: {
export const appRequestedNodeEventsInCategory = actionCreator<{
/**
* Id that identify the scope of analyzer
*/
readonly id: string;
parameters: PanelViewAndParameters;
};
}
interface AppRequestedResolverData {
readonly type: 'appRequestedResolverData';
}>('APP_REQUESTED_NODE_EVENTS_IN_CATEGORY');
export const appRequestedResolverData = actionCreator<{
/**
* Id that identify the scope of analyzer
*/
readonly id: string;
/**
* entity ID used to make the request.
*/
readonly payload: TreeFetcherParameters;
}
readonly parameters: TreeFetcherParameters;
}>('APP_REQUESTED_RESOLVER_DATA');
interface UserRequestedAdditionalRelatedEvents {
readonly type: 'userRequestedAdditionalRelatedEvents';
}
export const userRequestedAdditionalRelatedEvents = actionCreator<{
/**
* Id that identify the scope of analyzer
*/
readonly id: string;
}>('USER_REQUESTED_ADDITIONAL_RELATED_EVENTS');
interface ServerFailedToReturnNodeEventsInCategory {
readonly type: 'serverFailedToReturnNodeEventsInCategory';
readonly payload: {
export const serverFailedToReturnNodeEventsInCategory = actionCreator<{
/**
* Id that identify the scope of analyzer
*/
readonly id: string;
/**
* The cursor, if any, that can be used to retrieve more events.
*/
cursor: string | null;
readonly cursor: string | null;
/**
* The nodeID that `events` are related to.
*/
nodeID: string;
readonly nodeID: string;
/**
* The category that `events` have in common.
*/
eventCategory: string;
};
}
readonly eventCategory: string;
}>('SERVER_FAILED_TO_RETUEN_NODE_EVENTS_IN_CATEGORY');
interface ServerFailedToReturnResolverData {
readonly type: 'serverFailedToReturnResolverData';
export const serverFailedToReturnResolverData = actionCreator<{
/**
* Id that identify the scope of analyzer
*/
readonly id: string;
/**
* entity ID used to make the failed request
*/
readonly payload: TreeFetcherParameters;
}
readonly parameters: TreeFetcherParameters;
}>('SERVER_FAILED_TO_RETURN_RESOLVER_DATA');
interface AppAbortedResolverDataRequest {
readonly type: 'appAbortedResolverDataRequest';
export const appAbortedResolverDataRequest = actionCreator<{
/**
* Id that identify the scope of analyzer
*/
readonly id: string;
/**
* entity ID used to make the aborted request
*/
readonly payload: TreeFetcherParameters;
}
readonly parameters: TreeFetcherParameters;
}>('APP_ABORTED_RESOLVER_DATA_REQUEST');
interface ServerReturnedNodeEventsInCategory {
readonly type: 'serverReturnedNodeEventsInCategory';
readonly payload: {
export const serverReturnedNodeEventsInCategory = actionCreator<{
/**
* Id that identify the scope of analyzer
*/
readonly id: string;
/**
* Events with `event.category` that include `eventCategory` and that are related to `nodeID`.
*/
events: SafeEndpointEvent[];
readonly events: SafeEndpointEvent[];
/**
* The cursor, if any, that can be used to retrieve more events.
*/
cursor: string | null;
readonly cursor: string | null;
/**
* The nodeID that `events` are related to.
*/
nodeID: string;
readonly nodeID: string;
/**
* The category that `events` have in common.
*/
eventCategory: string;
};
}
readonly eventCategory: string;
}>('SERVER_RETURNED_NODE_EVENTS_IN_CATEGORY');
/**
* When events are returned for a set of graph nodes. For Endpoint graphs the events returned are process lifecycle events.
*/
interface ServerReturnedNodeData {
readonly type: 'serverReturnedNodeData';
readonly payload: {
export const serverReturnedNodeData = actionCreator<{
/**
* Id that identify the scope of analyzer
*/
readonly id: string;
/**
* A map of the node's ID to an array of events
*/
nodeData: SafeResolverEvent[];
readonly nodeData: SafeResolverEvent[];
/**
* The list of IDs that were originally sent to the server. This won't necessarily equal nodeData.keys() because
* data could have been deleted in Elasticsearch since the original graph nodes were returned or the server's
* API limit could have been reached.
*/
requestedIDs: Set<string>;
readonly requestedIDs: Set<string>;
/**
* The number of events that we requested from the server (the limit in the request).
* This will be used to compute a flag about whether we reached the limit with the number of events returned by
@ -138,73 +160,63 @@ interface ServerReturnedNodeData {
* we might be missing events for some of the requested node IDs. We'll mark those nodes in such a way
* that we'll request their data in a subsequent request.
*/
numberOfRequestedEvents: number;
};
}
readonly numberOfRequestedEvents: number;
}>('SERVER_RETURNED_NODE_DATA');
/**
* When the middleware kicks off the request for node data to the server.
*/
interface AppRequestingNodeData {
readonly type: 'appRequestingNodeData';
readonly payload: {
export const appRequestingNodeData = actionCreator<{
/**
* Id that identify the scope of analyzer
*/
readonly id: string;
/**
* The list of IDs that will be sent to the server to retrieve data for.
*/
requestedIDs: Set<string>;
};
}
}>('APP_REQUESTING_NODE_DATA');
/**
* When the user clicks on a node that was in an error state to reload the node data.
*/
interface UserReloadedResolverNode {
readonly type: 'userReloadedResolverNode';
export const userReloadedResolverNode = actionCreator<{
/**
* Id that identify the scope of analyzer
*/
readonly id: string;
/**
* The nodeID (aka entity_id) that was select.
*/
readonly payload: string;
}
readonly nodeID: string;
}>('USER_RELOADED_RESOLVER_NODE');
/**
* When the server returns an error after the app requests node data for a set of nodes.
*/
interface ServerFailedToReturnNodeData {
readonly type: 'serverFailedToReturnNodeData';
readonly payload: {
export const serverFailedToReturnNodeData = actionCreator<{
/**
* Id that identify the scope of analyzer
*/
readonly id: string;
/**
* The list of IDs that were sent to the server to retrieve data for.
*/
requestedIDs: Set<string>;
};
}
readonly requestedIDs: Set<string>;
}>('SERVER_FAILED_TO_RETURN_NODE_DATA');
interface AppRequestedCurrentRelatedEventData {
type: 'appRequestedCurrentRelatedEventData';
}
export const appRequestedCurrentRelatedEventData = actionCreator<{ readonly id: string }>(
'APP_REQUESTED_CURRENT_RELATED_EVENT_DATA'
);
interface ServerFailedToReturnCurrentRelatedEventData {
type: 'serverFailedToReturnCurrentRelatedEventData';
}
export const serverFailedToReturnCurrentRelatedEventData = actionCreator<{ readonly id: string }>(
'SERVER_FAILED_TO_RETURN_CURRENT_RELATED_EVENT_DATA'
);
interface ServerReturnedCurrentRelatedEventData {
readonly type: 'serverReturnedCurrentRelatedEventData';
readonly payload: SafeResolverEvent;
}
export type DataAction =
| ServerReturnedResolverData
| ServerFailedToReturnResolverData
| AppRequestedCurrentRelatedEventData
| ServerReturnedCurrentRelatedEventData
| ServerFailedToReturnCurrentRelatedEventData
| ServerReturnedNodeEventsInCategory
| AppRequestedResolverData
| UserRequestedAdditionalRelatedEvents
| ServerFailedToReturnNodeEventsInCategory
| AppAbortedResolverDataRequest
| ServerReturnedNodeData
| ServerFailedToReturnNodeData
| AppRequestingNodeData
| UserReloadedResolverNode
| AppRequestedNodeEventsInCategory;
export const serverReturnedCurrentRelatedEventData = actionCreator<{
/**
* Id that identify the scope of analyzer
*/
readonly id: string;
readonly relatedEvent: SafeResolverEvent;
}>('SERVER_RETURNED_CURRENT_RELATED_EVENT_DATA');

View file

@ -5,17 +5,18 @@
* 2.0.
*/
import type { Store } from 'redux';
import type { Store, AnyAction, Reducer } from 'redux';
import { createStore } from 'redux';
import { RelatedEventCategory } from '../../../../common/endpoint/generate_data';
import { dataReducer } from './reducer';
import * as selectors from './selectors';
import type { DataState, GeneratedTreeMetadata, TimeFilters } from '../../types';
import type { DataAction } from './action';
import type { AnalyzerState, GeneratedTreeMetadata, TimeFilters } from '../../types';
import { generateTreeWithDAL } from '../../data_access_layer/mocks/generator_tree';
import { endpointSourceSchema, winlogSourceSchema } from '../../mocks/tree_schema';
import type { NewResolverTree, ResolverSchema } from '../../../../common/endpoint/types';
import { ancestorsWithAncestryField, descendantsLimit } from '../../models/resolver_tree';
import { EMPTY_RESOLVER } from '../helpers';
import { serverReturnedResolverData } from './action';
type SourceAndSchemaFunction = () => { schema: ResolverSchema; dataSource: string };
@ -23,24 +24,33 @@ type SourceAndSchemaFunction = () => { schema: ResolverSchema; dataSource: strin
* Test the data reducer and selector.
*/
describe('Resolver Data Middleware', () => {
let store: Store<DataState, DataAction>;
let store: Store<AnalyzerState, AnyAction>;
let dispatchTree: (
tree: NewResolverTree,
sourceAndSchema: SourceAndSchemaFunction,
detectedBounds?: TimeFilters
) => void;
const id = 'test-id';
beforeEach(() => {
store = createStore(dataReducer, undefined);
const testReducer: Reducer<AnalyzerState, AnyAction> = (
state = {
analyzerById: {
[id]: EMPTY_RESOLVER,
},
},
action
): AnalyzerState => dataReducer(state, action);
store = createStore(testReducer, undefined);
dispatchTree = (
tree: NewResolverTree,
sourceAndSchema: SourceAndSchemaFunction,
detectedBounds?: TimeFilters
) => {
const { schema, dataSource } = sourceAndSchema();
const action: DataAction = {
type: 'serverReturnedResolverData',
payload: {
store.dispatch(
serverReturnedResolverData({
id,
result: tree,
dataSource,
schema,
@ -50,9 +60,8 @@ describe('Resolver Data Middleware', () => {
filters: {},
},
detectedBounds,
},
};
store.dispatch(action);
})
);
};
});
@ -74,15 +83,15 @@ describe('Resolver Data Middleware', () => {
dispatchTree(generatedTreeMetadata.formattedTree, schema);
});
it('should indicate that there are no more ancestors to retrieve', () => {
expect(selectors.hasMoreAncestors(store.getState())).toBeFalsy();
expect(selectors.hasMoreAncestors(store.getState().analyzerById[id].data)).toBeFalsy();
});
it('should indicate that there are no more descendants to retrieve', () => {
expect(selectors.hasMoreChildren(store.getState())).toBeFalsy();
expect(selectors.hasMoreChildren(store.getState().analyzerById[id].data)).toBeFalsy();
});
it('should indicate that there were no more generations to retrieve', () => {
expect(selectors.hasMoreGenerations(store.getState())).toBeFalsy();
expect(selectors.hasMoreGenerations(store.getState().analyzerById[id].data)).toBeFalsy();
});
});
describe('when a tree with detected bounds is loaded', () => {
@ -91,7 +100,7 @@ describe('Resolver Data Middleware', () => {
from: 'Sep 19, 2022 @ 20:49:13.452',
to: 'Sep 19, 2022 @ 20:49:13.452',
});
expect(selectors.detectedBounds(store.getState())).toBeTruthy();
expect(selectors.detectedBounds(store.getState().analyzerById[id].data)).toBeTruthy();
});
it('should clear the previous detected bounds when a new response without detected bounds is recevied', () => {
@ -99,9 +108,9 @@ describe('Resolver Data Middleware', () => {
from: 'Sep 19, 2022 @ 20:49:13.452',
to: 'Sep 19, 2022 @ 20:49:13.452',
});
expect(selectors.detectedBounds(store.getState())).toBeTruthy();
expect(selectors.detectedBounds(store.getState().analyzerById[id].data)).toBeTruthy();
dispatchTree(generatedTreeMetadata.formattedTree, endpointSourceSchema);
expect(selectors.detectedBounds(store.getState())).toBeFalsy();
expect(selectors.detectedBounds(store.getState().analyzerById[id].data)).toBeFalsy();
});
});
});
@ -123,15 +132,15 @@ describe('Resolver Data Middleware', () => {
dispatchTree(generatedTreeMetadata.formattedTree, endpointSourceSchema);
});
it('should indicate that there are more ancestors to retrieve', () => {
expect(selectors.hasMoreAncestors(store.getState())).toBeTruthy();
expect(selectors.hasMoreAncestors(store.getState().analyzerById[id].data)).toBeTruthy();
});
it('should indicate that there are more descendants to retrieve', () => {
expect(selectors.hasMoreChildren(store.getState())).toBeTruthy();
expect(selectors.hasMoreChildren(store.getState().analyzerById[id].data)).toBeTruthy();
});
it('should indicate that there were no more generations to retrieve', () => {
expect(selectors.hasMoreGenerations(store.getState())).toBeFalsy();
expect(selectors.hasMoreGenerations(store.getState().analyzerById[id].data)).toBeFalsy();
});
});
@ -140,15 +149,15 @@ describe('Resolver Data Middleware', () => {
dispatchTree(generatedTreeMetadata.formattedTree, winlogSourceSchema);
});
it('should indicate that there are more ancestors to retrieve', () => {
expect(selectors.hasMoreAncestors(store.getState())).toBeTruthy();
expect(selectors.hasMoreAncestors(store.getState().analyzerById[id].data)).toBeTruthy();
});
it('should indicate that there are more descendants to retrieve', () => {
expect(selectors.hasMoreChildren(store.getState())).toBeTruthy();
expect(selectors.hasMoreChildren(store.getState().analyzerById[id].data)).toBeTruthy();
});
it('should indicate that there were more generations to retrieve', () => {
expect(selectors.hasMoreGenerations(store.getState())).toBeTruthy();
expect(selectors.hasMoreGenerations(store.getState().analyzerById[id].data)).toBeTruthy();
});
});
});
@ -168,15 +177,15 @@ describe('Resolver Data Middleware', () => {
dispatchTree(generatedTreeMetadata.formattedTree, endpointSourceSchema);
});
it('should indicate that there are no more ancestors to retrieve', () => {
expect(selectors.hasMoreAncestors(store.getState())).toBeFalsy();
expect(selectors.hasMoreAncestors(store.getState().analyzerById[id].data)).toBeFalsy();
});
it('should indicate that there are more descendants to retrieve', () => {
expect(selectors.hasMoreChildren(store.getState())).toBeTruthy();
expect(selectors.hasMoreChildren(store.getState().analyzerById[id].data)).toBeTruthy();
});
it('should indicate that there were no more generations to retrieve', () => {
expect(selectors.hasMoreGenerations(store.getState())).toBeFalsy();
expect(selectors.hasMoreGenerations(store.getState().analyzerById[id].data)).toBeFalsy();
});
});
@ -185,15 +194,15 @@ describe('Resolver Data Middleware', () => {
dispatchTree(generatedTreeMetadata.formattedTree, winlogSourceSchema);
});
it('should indicate that there are no more ancestors to retrieve', () => {
expect(selectors.hasMoreAncestors(store.getState())).toBeFalsy();
expect(selectors.hasMoreAncestors(store.getState().analyzerById[id].data)).toBeFalsy();
});
it('should indicate that there are more descendants to retrieve', () => {
expect(selectors.hasMoreChildren(store.getState())).toBeTruthy();
expect(selectors.hasMoreChildren(store.getState().analyzerById[id].data)).toBeTruthy();
});
it('should indicate that there were no more generations to retrieve', () => {
expect(selectors.hasMoreGenerations(store.getState())).toBeFalsy();
expect(selectors.hasMoreGenerations(store.getState().analyzerById[id].data)).toBeFalsy();
});
});
});
@ -217,13 +226,15 @@ describe('Resolver Data Middleware', () => {
it('should have the correct total related events for a child node', () => {
// get the first level of children, and there should only be a single child
const childNode = Array.from(metadata.generatedTree.childrenLevels[0].values())[0];
const total = selectors.relatedEventTotalCount(store.getState())(childNode.id);
const total = selectors.relatedEventTotalCount(store.getState().analyzerById[id].data)(
childNode.id
);
expect(total).toEqual(5);
});
it('should have the correct related events stats for a child node', () => {
// get the first level of children, and there should only be a single child
const childNode = Array.from(metadata.generatedTree.childrenLevels[0].values())[0];
const stats = selectors.nodeStats(store.getState())(childNode.id);
const stats = selectors.nodeStats(store.getState().analyzerById[id].data)(childNode.id);
expect(stats).toEqual({
total: 5,
byCategory: {

View file

@ -5,254 +5,266 @@
* 2.0.
*/
import type { Reducer } from 'redux';
import type { Draft } from 'immer';
import { reducerWithInitialState } from 'typescript-fsa-reducers';
import type { DataState } from '../../types';
import type { ResolverAction } from '../actions';
import * as treeFetcherParameters from '../../models/tree_fetcher_parameters';
import * as selectors from './selectors';
import * as nodeEventsInCategoryModel from './node_events_in_category_model';
import * as nodeDataModel from '../../models/node_data';
import { initialAnalyzerState, immerCase } from '../helpers';
import { appReceivedNewExternalProperties } from '../actions';
import {
serverReturnedResolverData,
appRequestedResolverData,
appAbortedResolverDataRequest,
serverFailedToReturnResolverData,
serverReturnedNodeEventsInCategory,
userRequestedAdditionalRelatedEvents,
serverFailedToReturnNodeEventsInCategory,
serverReturnedNodeData,
userReloadedResolverNode,
appRequestingNodeData,
serverFailedToReturnNodeData,
appRequestedCurrentRelatedEventData,
serverReturnedCurrentRelatedEventData,
serverFailedToReturnCurrentRelatedEventData,
} from './action';
const initialState: DataState = {
currentRelatedEvent: {
loading: false,
data: null,
},
resolverComponentInstanceID: undefined,
indices: [],
detectedBounds: undefined,
};
/* eslint-disable complexity */
export const dataReducer: Reducer<DataState, ResolverAction> = (state = initialState, action) => {
if (action.type === 'appReceivedNewExternalProperties') {
const nextState: DataState = {
...state,
tree: {
export const dataReducer = reducerWithInitialState(initialAnalyzerState)
.withHandling(
immerCase(
appReceivedNewExternalProperties,
(
draft,
{ id, resolverComponentInstanceID, locationSearch, databaseDocumentID, indices, filters }
) => {
const state: Draft<DataState> = draft.analyzerById[id]?.data;
state.tree = {
...state.tree,
currentParameters: {
databaseDocumentID: action.payload.databaseDocumentID,
indices: action.payload.indices,
filters: action.payload.filters,
databaseDocumentID,
indices,
filters,
},
},
resolverComponentInstanceID: action.payload.resolverComponentInstanceID,
locationSearch: action.payload.locationSearch,
indices: action.payload.indices,
};
const panelViewAndParameters = selectors.panelViewAndParameters(nextState);
return {
...nextState,
// If the panel view or parameters have changed, the `nodeEventsInCategory` may no longer be relevant. In that case, remove them.
nodeEventsInCategory:
nextState.nodeEventsInCategory &&
nodeEventsInCategoryModel.isRelevantToPanelViewAndParameters(
nextState.nodeEventsInCategory,
state.resolverComponentInstanceID = resolverComponentInstanceID;
state.locationSearch = locationSearch;
state.indices = indices;
const panelViewAndParameters = selectors.panelViewAndParameters(state);
if (
!state.nodeEventsInCategory ||
!nodeEventsInCategoryModel.isRelevantToPanelViewAndParameters(
state.nodeEventsInCategory,
panelViewAndParameters
)
? nextState.nodeEventsInCategory
: undefined,
};
} else if (action.type === 'appRequestedResolverData') {
) {
state.nodeEventsInCategory = undefined;
}
return draft;
}
)
)
.withHandling(
immerCase(appRequestedResolverData, (draft, { id, parameters }) => {
const state: Draft<DataState> = draft.analyzerById[id].data;
// keep track of what we're requesting, this way we know when to request and when not to.
const nextState: DataState = {
...state,
tree: {
state.tree = {
...state.tree,
pendingRequestParameters: {
databaseDocumentID: action.payload.databaseDocumentID,
indices: action.payload.indices,
filters: action.payload.filters,
},
databaseDocumentID: parameters.databaseDocumentID,
indices: parameters.indices,
filters: parameters.filters,
},
};
return nextState;
} else if (action.type === 'appAbortedResolverDataRequest') {
if (treeFetcherParameters.equal(action.payload, state.tree?.pendingRequestParameters)) {
return draft;
})
)
.withHandling(
immerCase(appAbortedResolverDataRequest, (draft, { id, parameters }) => {
const state: Draft<DataState> = draft.analyzerById[id].data;
if (treeFetcherParameters.equal(parameters, state.tree?.pendingRequestParameters)) {
// the request we were awaiting was aborted
const nextState: DataState = {
...state,
tree: {
state.tree = {
...state.tree,
pendingRequestParameters: undefined,
},
};
return nextState;
} else {
return state;
}
} else if (action.type === 'serverReturnedResolverData') {
return draft;
})
)
.withHandling(
immerCase(
serverReturnedResolverData,
(draft, { id, result, dataSource, schema, parameters, detectedBounds }) => {
const state: Draft<DataState> = draft.analyzerById[id].data;
/** Only handle this if we are expecting a response */
const nextState: DataState = {
...state,
tree: {
state.tree = {
...state.tree,
/**
* Store the last received data, as well as the databaseDocumentID it relates to.
*/
lastResponse: {
result: action.payload.result,
dataSource: action.payload.dataSource,
schema: action.payload.schema,
parameters: action.payload.parameters,
result,
dataSource,
schema,
parameters,
successful: true,
},
// This assumes that if we just received something, there is no longer a pending request.
// This cannot model multiple in-flight requests
pendingRequestParameters: undefined,
},
detectedBounds: action.payload.detectedBounds,
};
return nextState;
} else if (action.type === 'serverFailedToReturnResolverData') {
state.detectedBounds = detectedBounds;
return draft;
}
)
)
.withHandling(
immerCase(serverFailedToReturnResolverData, (draft, { id }) => {
/** Only handle this if we are expecting a response */
const state: Draft<DataState> = draft.analyzerById[id].data;
if (state.tree?.pendingRequestParameters !== undefined) {
const nextState: DataState = {
...state,
tree: {
state.tree = {
...state.tree,
pendingRequestParameters: undefined,
lastResponse: {
parameters: state.tree.pendingRequestParameters,
parameters: state.tree?.pendingRequestParameters,
successful: false,
},
},
};
return nextState;
} else {
return state;
}
} else if (action.type === 'serverReturnedNodeEventsInCategory') {
return draft;
})
)
.withHandling(
immerCase(
serverReturnedNodeEventsInCategory,
(draft, { id, events, cursor, nodeID, eventCategory }) => {
// 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.
const state: Draft<DataState> = draft.analyzerById[id].data;
if (
nodeEventsInCategoryModel.isRelevantToPanelViewAndParameters(
action.payload,
{ events, cursor, nodeID, eventCategory },
selectors.panelViewAndParameters(state)
)
) {
if (state.nodeEventsInCategory) {
// If there are already `nodeEventsInCategory` in state then combine those with the new data in the payload.
const updated = nodeEventsInCategoryModel.updatedWith(
state.nodeEventsInCategory,
action.payload
);
const updated = nodeEventsInCategoryModel.updatedWith(state.nodeEventsInCategory, {
events,
cursor,
nodeID,
eventCategory,
});
// The 'updatedWith' method will fail if the old and new data don't represent events from the same node and event category
if (updated) {
const next: DataState = {
...state,
nodeEventsInCategory: {
state.nodeEventsInCategory = {
...updated,
},
};
return next;
} else {
// this should never happen. This reducer ensures that any `nodeEventsInCategory` that are in state are relevant to the `panelViewAndParameters`.
// this should never happen. This reducer ensures that any `nodeEventsInCategory` that are in state: DataState are relevant to the `panelViewAndParameters`.
throw new Error('Could not handle related event data because of an internal error.');
}
} else {
// There is no existing data, use the new data.
const next: DataState = {
...state,
nodeEventsInCategory: action.payload,
};
return next;
state.nodeEventsInCategory = { events, cursor, nodeID, eventCategory };
}
} else {
// the action is stale, ignore it
return state;
// else the action is stale, ignore it
}
} else if (action.type === 'userRequestedAdditionalRelatedEvents') {
return draft;
}
)
)
.withHandling(
immerCase(userRequestedAdditionalRelatedEvents, (draft, { id }) => {
const state: Draft<DataState> = draft.analyzerById[id].data;
if (state.nodeEventsInCategory) {
const nextState: DataState = {
...state,
nodeEventsInCategory: {
...state.nodeEventsInCategory,
lastCursorRequested: state.nodeEventsInCategory?.cursor,
},
};
return nextState;
} else {
return state;
state.nodeEventsInCategory.lastCursorRequested = state.nodeEventsInCategory?.cursor;
}
} else if (action.type === 'serverFailedToReturnNodeEventsInCategory') {
return draft;
})
)
.withHandling(
immerCase(serverFailedToReturnNodeEventsInCategory, (draft, { id }) => {
const state: Draft<DataState> = draft.analyzerById[id].data;
if (state.nodeEventsInCategory) {
const nextState: DataState = {
...state,
nodeEventsInCategory: {
state.nodeEventsInCategory = {
...state.nodeEventsInCategory,
error: true,
},
};
return nextState;
} else {
return state;
}
} else if (action.type === 'serverReturnedNodeData') {
return draft;
})
)
.withHandling(
immerCase(
serverReturnedNodeData,
(draft, { id, nodeData, requestedIDs, numberOfRequestedEvents }) => {
const state: Draft<DataState> = draft.analyzerById[id].data;
const updatedNodeData = nodeDataModel.updateWithReceivedNodes({
storedNodeInfo: state.nodeData,
receivedEvents: action.payload.nodeData,
requestedNodes: action.payload.requestedIDs,
numberOfRequestedEvents: action.payload.numberOfRequestedEvents,
receivedEvents: nodeData,
requestedNodes: requestedIDs,
numberOfRequestedEvents,
});
return {
...state,
nodeData: updatedNodeData,
};
} else if (action.type === 'userReloadedResolverNode') {
const updatedNodeData = nodeDataModel.setReloadedNodes(state.nodeData, action.payload);
return {
...state,
nodeData: updatedNodeData,
};
} else if (action.type === 'appRequestingNodeData') {
const updatedNodeData = nodeDataModel.setRequestedNodes(
state.nodeData,
action.payload.requestedIDs
);
return {
...state,
nodeData: updatedNodeData,
};
} else if (action.type === 'serverFailedToReturnNodeData') {
const updatedData = nodeDataModel.setErrorNodes(state.nodeData, action.payload.requestedIDs);
return {
...state,
nodeData: updatedData,
};
} else if (action.type === 'appRequestedCurrentRelatedEventData') {
const nextState: DataState = {
...state,
currentRelatedEvent: {
state.nodeData = updatedNodeData;
return draft;
}
)
)
.withHandling(
immerCase(userReloadedResolverNode, (draft, { id, nodeID }) => {
const state: Draft<DataState> = draft.analyzerById[id].data;
const updatedNodeData = nodeDataModel.setReloadedNodes(state.nodeData, nodeID);
state.nodeData = updatedNodeData;
return draft;
})
)
.withHandling(
immerCase(appRequestingNodeData, (draft, { id, requestedIDs }) => {
const state: Draft<DataState> = draft.analyzerById[id].data;
const updatedNodeData = nodeDataModel.setRequestedNodes(state.nodeData, requestedIDs);
state.nodeData = updatedNodeData;
return draft;
})
)
.withHandling(
immerCase(serverFailedToReturnNodeData, (draft, { id, requestedIDs }) => {
const state: Draft<DataState> = draft.analyzerById[id].data;
const updatedData = nodeDataModel.setErrorNodes(state.nodeData, requestedIDs);
state.nodeData = updatedData;
return draft;
})
)
.withHandling(
immerCase(appRequestedCurrentRelatedEventData, (draft, { id }) => {
draft.analyzerById[id].data.currentRelatedEvent = {
loading: true,
data: null,
},
};
return nextState;
} else if (action.type === 'serverReturnedCurrentRelatedEventData') {
const nextState: DataState = {
...state,
currentRelatedEvent: {
return draft;
})
)
.withHandling(
immerCase(serverReturnedCurrentRelatedEventData, (draft, { id, relatedEvent }) => {
draft.analyzerById[id].data.currentRelatedEvent = {
loading: false,
data: {
...action.payload,
},
...relatedEvent,
},
};
return nextState;
} else if (action.type === 'serverFailedToReturnCurrentRelatedEventData') {
const nextState: DataState = {
...state,
currentRelatedEvent: {
return draft;
})
)
.withHandling(
immerCase(serverFailedToReturnCurrentRelatedEventData, (draft, { id }) => {
draft.analyzerById[id].data.currentRelatedEvent = {
loading: false,
data: null,
},
};
return nextState;
} else {
return state;
}
};
return draft;
})
)
.build();

View file

@ -6,8 +6,8 @@
*/
import * as selectors from './selectors';
import type { DataState } from '../../types';
import type { ResolverAction } from '../actions';
import type { DataState, AnalyzerState } from '../../types';
import type { Reducer, AnyAction } from 'redux';
import { dataReducer } from './reducer';
import { createStore } from 'redux';
import {
@ -22,6 +22,15 @@ import { mockTreeFetcherParameters } from '../../mocks/tree_fetcher_parameters';
import type { SafeResolverEvent } from '../../../../common/endpoint/types';
import { mockEndpointEvent } from '../../mocks/endpoint_event';
import { maxDate } from '../../models/time_range';
import { EMPTY_RESOLVER } from '../helpers';
import {
serverReturnedResolverData,
appRequestedResolverData,
appAbortedResolverDataRequest,
serverFailedToReturnResolverData,
serverReturnedNodeData,
} from './action';
import { appReceivedNewExternalProperties } from '../actions';
function mockNodeDataWithAllProcessesTerminated({
originID,
@ -83,17 +92,26 @@ function mockNodeDataWithAllProcessesTerminated({
}
describe('data state', () => {
let actions: ResolverAction[];
let actions: AnyAction[];
const id = 'test-id';
/**
* Get state, given an ordered collection of actions.
*/
const state: () => DataState = () => {
const store = createStore(dataReducer);
const testReducer: Reducer<AnalyzerState, AnyAction> = (
analyzerState = {
analyzerById: {
[id]: EMPTY_RESOLVER,
},
},
action
): AnalyzerState => dataReducer(analyzerState, action);
const store = createStore(testReducer, undefined);
for (const action of actions) {
store.dispatch(action);
}
return store.getState();
return store.getState().analyzerById[id].data;
};
/**
@ -136,9 +154,8 @@ describe('data state', () => {
const resolverComponentInstanceID = 'resolverComponentInstanceID';
beforeEach(() => {
actions = [
{
type: 'appReceivedNewExternalProperties',
payload: {
appReceivedNewExternalProperties({
id,
databaseDocumentID,
resolverComponentInstanceID,
@ -147,8 +164,7 @@ describe('data state', () => {
indices: [],
shouldUpdate: false,
filters: {},
},
},
}),
];
});
it('should need to request the tree', () => {
@ -169,10 +185,10 @@ describe('data state', () => {
const databaseDocumentID = 'databaseDocumentID';
beforeEach(() => {
actions = [
{
type: 'appRequestedResolverData',
payload: { databaseDocumentID, indices: [], filters: {} },
},
appRequestedResolverData({
id,
parameters: { databaseDocumentID, indices: [], filters: {} },
}),
];
});
it('should be loading', () => {
@ -199,9 +215,8 @@ describe('data state', () => {
const resolverComponentInstanceID = 'resolverComponentInstanceID';
beforeEach(() => {
actions = [
{
type: 'appReceivedNewExternalProperties',
payload: {
appReceivedNewExternalProperties({
id,
databaseDocumentID,
resolverComponentInstanceID,
@ -210,12 +225,11 @@ describe('data state', () => {
indices: [],
shouldUpdate: false,
filters: {},
},
},
{
type: 'appRequestedResolverData',
payload: { databaseDocumentID, indices: [], filters: {} },
},
}),
appRequestedResolverData({
id,
parameters: { databaseDocumentID, indices: [], filters: {} },
}),
];
});
it('should be loading', () => {
@ -236,10 +250,12 @@ describe('data state', () => {
});
describe('when the pending request fails', () => {
beforeEach(() => {
actions.push({
type: 'serverFailedToReturnResolverData',
payload: { databaseDocumentID, indices: [], filters: {} },
});
actions.push(
serverFailedToReturnResolverData({
id,
parameters: { databaseDocumentID, indices: [], filters: {} },
})
);
});
it('should not be loading', () => {
expect(selectors.isTreeLoading(state())).toBe(false);
@ -267,9 +283,8 @@ describe('data state', () => {
beforeEach(() => {
actions = [
// receive the document ID, this would cause the middleware to starts the request
{
type: 'appReceivedNewExternalProperties',
payload: {
appReceivedNewExternalProperties({
id,
databaseDocumentID: firstDatabaseDocumentID,
resolverComponentInstanceID: resolverComponentInstanceID1,
// `locationSearch` doesn't matter for this test
@ -277,17 +292,15 @@ describe('data state', () => {
indices: [],
shouldUpdate: false,
filters: {},
},
},
}),
// this happens when the middleware starts the request
{
type: 'appRequestedResolverData',
payload: { databaseDocumentID: firstDatabaseDocumentID, indices: [], filters: {} },
},
appRequestedResolverData({
id,
parameters: { databaseDocumentID: firstDatabaseDocumentID, indices: [], filters: {} },
}),
// receive a different databaseDocumentID. this should cause the middleware to abort the existing request and start a new one
{
type: 'appReceivedNewExternalProperties',
payload: {
appReceivedNewExternalProperties({
id,
databaseDocumentID: secondDatabaseDocumentID,
resolverComponentInstanceID: resolverComponentInstanceID2,
// `locationSearch` doesn't matter for this test
@ -295,8 +308,7 @@ describe('data state', () => {
indices: [],
shouldUpdate: false,
filters: {},
},
},
}),
];
});
it('should be loading', () => {
@ -327,10 +339,12 @@ describe('data state', () => {
});
describe('and when the old request was aborted', () => {
beforeEach(() => {
actions.push({
type: 'appAbortedResolverDataRequest',
payload: { databaseDocumentID: firstDatabaseDocumentID, indices: [], filters: {} },
});
actions.push(
appAbortedResolverDataRequest({
id,
parameters: { databaseDocumentID: firstDatabaseDocumentID, indices: [], filters: {} },
})
);
});
it('should not require a pending request to be aborted', () => {
expect(selectors.treeRequestParametersToAbort(state())).toBe(null);
@ -355,10 +369,16 @@ describe('data state', () => {
});
describe('and when the next request starts', () => {
beforeEach(() => {
actions.push({
type: 'appRequestedResolverData',
payload: { databaseDocumentID: secondDatabaseDocumentID, indices: [], filters: {} },
});
actions.push(
appRequestedResolverData({
id,
parameters: {
databaseDocumentID: secondDatabaseDocumentID,
indices: [],
filters: {},
},
})
);
});
it('should not have a document ID to fetch', () => {
expect(selectors.treeParametersToFetch(state())).toBe(null);
@ -394,30 +414,26 @@ describe('data state', () => {
describe('when resolver receives external properties without time range filters', () => {
beforeEach(() => {
actions = [
{
type: 'appReceivedNewExternalProperties',
payload: {
appReceivedNewExternalProperties({
id,
databaseDocumentID,
resolverComponentInstanceID,
locationSearch: '',
indices: [],
shouldUpdate: false,
filters: {},
},
},
{
type: 'appRequestedResolverData',
payload: { databaseDocumentID, indices: [], filters: {} },
},
{
type: 'serverReturnedResolverData',
payload: {
}),
appRequestedResolverData({
id,
parameters: { databaseDocumentID, indices: [], filters: {} },
}),
serverReturnedResolverData({
id,
result: resolverTree,
dataSource,
schema,
parameters: { databaseDocumentID, indices: [], filters: {} },
},
},
}),
];
});
it('uses the default time range filters', () => {
@ -432,28 +448,25 @@ describe('data state', () => {
beforeEach(() => {
actions = [
...actions,
{
type: 'appReceivedNewExternalProperties',
payload: {
appReceivedNewExternalProperties({
id,
databaseDocumentID,
resolverComponentInstanceID,
locationSearch: '',
indices: [],
shouldUpdate: false,
filters: timeRangeFilters,
},
},
{
type: 'appRequestedResolverData',
payload: {
}),
appRequestedResolverData({
id,
parameters: {
databaseDocumentID,
indices: [],
filters: timeRangeFilters,
},
},
{
type: 'serverReturnedResolverData',
payload: {
}),
serverReturnedResolverData({
id,
result: resolverTree,
dataSource,
schema,
@ -462,8 +475,7 @@ describe('data state', () => {
indices: [],
filters: timeRangeFilters,
},
},
},
}),
];
});
it('uses the received time range filters', () => {
@ -480,9 +492,8 @@ describe('data state', () => {
beforeEach(() => {
const { schema, dataSource } = endpointSourceSchema();
actions = [
{
type: 'serverReturnedResolverData',
payload: {
serverReturnedResolverData({
id,
result: mockTreeWith2AncestorsAndNoChildren({
originID,
firstAncestorID,
@ -492,8 +503,7 @@ describe('data state', () => {
schema,
// this value doesn't matter
parameters: mockTreeFetcherParameters(),
},
},
}),
];
});
it('should have no flowto candidate for the origin', () => {
@ -517,16 +527,14 @@ describe('data state', () => {
});
beforeEach(() => {
actions = [
{
type: 'serverReturnedNodeData',
payload: {
serverReturnedNodeData({
id,
nodeData,
requestedIDs: new Set([originID, firstAncestorID, secondAncestorID]),
// 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,
},
},
}),
];
});
it('should have origin as terminated', () => {
@ -551,16 +559,14 @@ describe('data state', () => {
});
const { schema, dataSource } = endpointSourceSchema();
actions = [
{
type: 'serverReturnedResolverData',
payload: {
serverReturnedResolverData({
id,
result: resolverTree,
dataSource,
schema,
// this value doesn't matter
parameters: mockTreeFetcherParameters(),
},
},
}),
];
});
it('should have no flowto candidate for the origin', () => {
@ -585,16 +591,14 @@ describe('data state', () => {
});
const { schema, dataSource } = endpointSourceSchema();
actions = [
{
type: 'serverReturnedResolverData',
payload: {
serverReturnedResolverData({
id,
result: resolverTree,
dataSource,
schema,
// this value doesn't matter
parameters: mockTreeFetcherParameters(),
},
},
}),
];
});
it('should be able to calculate the aria flowto candidates for all processes nodes', () => {
@ -621,16 +625,14 @@ describe('data state', () => {
});
const { schema, dataSource } = endpointSourceSchema();
actions = [
{
type: 'serverReturnedResolverData',
payload: {
serverReturnedResolverData({
id,
result: tree,
dataSource,
schema,
// this value doesn't matter
parameters: mockTreeFetcherParameters(),
},
},
}),
];
});
it('should have 4 graphable processes', () => {
@ -642,16 +644,14 @@ describe('data state', () => {
const { schema, dataSource } = endpointSourceSchema();
const tree = mockTreeWithNoProcessEvents();
actions = [
{
type: 'serverReturnedResolverData',
payload: {
serverReturnedResolverData({
id,
result: tree,
dataSource,
schema,
// this value doesn't matter
parameters: mockTreeFetcherParameters(),
},
},
}),
];
});
it('should return an empty layout', () => {
@ -673,9 +673,8 @@ describe('data state', () => {
});
const { schema, dataSource } = endpointSourceSchema();
actions = [
{
type: 'serverReturnedResolverData',
payload: {
serverReturnedResolverData({
id,
result: resolverTree,
dataSource,
schema,
@ -684,8 +683,7 @@ describe('data state', () => {
indices: ['someNonDefaultIndex'],
filters: {},
},
},
},
}),
];
});
it('should have an empty array for tree parameter indices, and a non empty array for event indices', () => {
@ -704,9 +702,8 @@ describe('data state', () => {
});
const { schema, dataSource } = endpointSourceSchema();
actions = [
{
type: 'serverReturnedResolverData',
payload: {
serverReturnedResolverData({
id,
result: resolverTree,
dataSource,
schema,
@ -715,27 +712,24 @@ describe('data state', () => {
indices: ['defaultIndex'],
filters: {},
},
},
},
{
type: 'appReceivedNewExternalProperties',
payload: {
}),
appReceivedNewExternalProperties({
id,
databaseDocumentID: '',
resolverComponentInstanceID: '',
locationSearch: '',
indices: ['someNonDefaultIndex', 'someOtherIndex'],
shouldUpdate: false,
filters: {},
},
},
{
type: 'appRequestedResolverData',
payload: {
}),
appRequestedResolverData({
id,
parameters: {
databaseDocumentID: '',
indices: ['someNonDefaultIndex', 'someOtherIndex'],
filters: {},
},
},
}),
];
});
it('should have an empty array for tree parameter indices, and the same set of indices as the last tree response', () => {

View file

@ -5,19 +5,22 @@
* 2.0.
*/
import type { Store } from 'redux';
import type { Store, AnyAction, Reducer } from 'redux';
import { createStore } from 'redux';
import type { ResolverAction } from '../actions';
import { resolverReducer } from '../reducer';
import type { ResolverState } from '../../types';
import { analyzerReducer } from '../reducer';
import type { AnalyzerState } from '../../types';
import type { ResolverNode } from '../../../../common/endpoint/types';
import { visibleNodesAndEdgeLines } from '../selectors';
import { mock as mockResolverTree } from '../../models/resolver_tree';
import { mockTreeFetcherParameters } from '../../mocks/tree_fetcher_parameters';
import { endpointSourceSchema } from '../../mocks/tree_schema';
import { mockResolverNode } from '../../mocks/resolver_node';
import { serverReturnedResolverData } from './action';
import { userSetRasterSize } from '../camera/action';
import { EMPTY_RESOLVER } from '../helpers';
describe('resolver visible entities', () => {
const id = 'test-id';
let nodeA: ResolverNode;
let nodeB: ResolverNode;
let nodeC: ResolverNode;
@ -25,7 +28,7 @@ describe('resolver visible entities', () => {
let nodeE: ResolverNode;
let nodeF: ResolverNode;
let nodeG: ResolverNode;
let store: Store<ResolverState, ResolverAction>;
let store: Store<AnalyzerState, AnyAction>;
beforeEach(() => {
/*
@ -92,31 +95,41 @@ describe('resolver visible entities', () => {
stats: { total: 0, byCategory: {} },
timestamp: 0,
});
store = createStore(resolverReducer, undefined);
const testReducer: Reducer<AnalyzerState, AnyAction> = (
analyzerState = {
analyzerById: {
[id]: EMPTY_RESOLVER,
},
},
action
): AnalyzerState => analyzerReducer(analyzerState, action);
store = createStore(testReducer, undefined);
});
describe('when rendering a large tree with a small viewport', () => {
beforeEach(() => {
const nodes: ResolverNode[] = [nodeA, nodeB, nodeC, nodeD, nodeE, nodeF, nodeG];
const { schema, dataSource } = endpointSourceSchema();
const action: ResolverAction = {
type: 'serverReturnedResolverData',
payload: {
store.dispatch(
serverReturnedResolverData({
id,
result: mockResolverTree({ nodes })!,
dataSource,
schema,
parameters: mockTreeFetcherParameters(),
},
};
const cameraAction: ResolverAction = { type: 'userSetRasterSize', payload: [300, 200] };
store.dispatch(action);
store.dispatch(cameraAction);
})
);
store.dispatch(userSetRasterSize({ id, dimensions: [300, 200] }));
});
it('the visibleProcessNodePositions list should only include 2 nodes', () => {
const { processNodePositions } = visibleNodesAndEdgeLines(store.getState())(0);
const { processNodePositions } = visibleNodesAndEdgeLines(store.getState().analyzerById[id])(
0
);
expect([...processNodePositions.keys()].length).toEqual(2);
});
it('the visibleEdgeLineSegments list should only include one edge line', () => {
const { connectingEdgeLineSegments } = visibleNodesAndEdgeLines(store.getState())(0);
const { connectingEdgeLineSegments } = visibleNodesAndEdgeLines(
store.getState().analyzerById[id]
)(0);
expect(connectingEdgeLineSegments.length).toEqual(1);
});
});
@ -124,25 +137,27 @@ describe('resolver visible entities', () => {
beforeEach(() => {
const nodes: ResolverNode[] = [nodeA, nodeB, nodeC, nodeD, nodeE, nodeF, nodeG];
const { schema, dataSource } = endpointSourceSchema();
const action: ResolverAction = {
type: 'serverReturnedResolverData',
payload: {
store.dispatch(
serverReturnedResolverData({
id,
result: mockResolverTree({ nodes })!,
dataSource,
schema,
parameters: mockTreeFetcherParameters(),
},
};
const cameraAction: ResolverAction = { type: 'userSetRasterSize', payload: [2000, 2000] };
store.dispatch(action);
store.dispatch(cameraAction);
})
);
store.dispatch(userSetRasterSize({ id, dimensions: [2000, 2000] }));
});
it('the visibleProcessNodePositions list should include all process nodes', () => {
const { processNodePositions } = visibleNodesAndEdgeLines(store.getState())(0);
const { processNodePositions } = visibleNodesAndEdgeLines(store.getState().analyzerById[id])(
0
);
expect([...processNodePositions.keys()].length).toEqual(5);
});
it('the visibleEdgeLineSegments list include all lines', () => {
const { connectingEdgeLineSegments } = visibleNodesAndEdgeLines(store.getState())(0);
const { connectingEdgeLineSegments } = visibleNodesAndEdgeLines(
store.getState().analyzerById[id]
)(0);
expect(connectingEdgeLineSegments.length).toEqual(4);
});
});

View file

@ -0,0 +1,68 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { Draft } from 'immer';
import produce from 'immer';
import type { Reducer, AnyAction } from 'redux';
import type { ActionCreator } from 'typescript-fsa';
import type { ReducerBuilder } from 'typescript-fsa-reducers';
import type { ResolverState, AnalyzerState } from '../types';
import { scaleToZoom } from './camera/scale_to_zoom';
import { analyzerReducer } from './reducer';
export const EMPTY_RESOLVER: ResolverState = {
data: {
currentRelatedEvent: {
loading: false,
data: null,
},
tree: {},
resolverComponentInstanceID: undefined,
indices: [],
detectedBounds: undefined,
},
camera: {
scalingFactor: scaleToZoom(1), // Defaulted to 1 to 1 scale
rasterSize: [0, 0],
translationNotCountingCurrentPanning: [0, 0],
latestFocusedWorldCoordinates: null,
animation: undefined,
panning: undefined,
},
ui: {
ariaActiveDescendant: null,
selectedNode: null,
},
};
/**
* Helper function to support use of immer within action creators.
* This allows reducers to be written in immer (direct mutation in appearance) over spread operators.
* More information on immer: https://immerjs.github.io/immer/
* @param actionCreator action creator
* @param handler reducer written in immer
* @returns reducer builder
*/
export function immerCase<S, P>(
actionCreator: ActionCreator<P>,
handler: (draft: Draft<S>, payload: P) => void
): (reducer: ReducerBuilder<S>) => ReducerBuilder<S> {
return (reducer) =>
reducer.case(actionCreator, (state, payload) =>
produce(state, (draft) => handler(draft, payload))
);
}
export const initialAnalyzerState: AnalyzerState = { analyzerById: {} };
export function mockReducer(id: string): Reducer<AnalyzerState, AnyAction> {
const testReducer: Reducer<AnalyzerState, AnyAction> = (
state = { analyzerById: { [id]: EMPTY_RESOLVER } },
action
): AnalyzerState => analyzerReducer(state, action);
return testReducer;
}

View file

@ -5,23 +5,22 @@
* 2.0.
*/
import type { Store } from 'redux';
import type { Store, AnyAction } from 'redux';
import { createStore, applyMiddleware } from 'redux';
import { composeWithDevTools } from 'redux-devtools-extension/developmentOnly';
import type { ResolverState, DataAccessLayer } from '../types';
import { resolverReducer } from './reducer';
import type { AnalyzerState, DataAccessLayer } from '../types';
import { analyzerReducer } from './reducer';
import { resolverMiddlewareFactory } from './middleware';
import type { ResolverAction } from './actions';
export const resolverStoreFactory = (
dataAccessLayer: DataAccessLayer
): Store<ResolverState, ResolverAction> => {
const actionsDenylist: Array<ResolverAction['type']> = ['userMovedPointer'];
): Store<AnalyzerState, AnyAction> => {
const actionsDenylist: Array<AnyAction['type']> = ['userMovedPointer'];
const composeEnhancers = composeWithDevTools({
name: 'Resolver',
actionsBlacklist: actionsDenylist,
});
const middlewareEnhancer = applyMiddleware(resolverMiddlewareFactory(dataAccessLayer));
return createStore(resolverReducer, composeEnhancers(middlewareEnhancer));
return createStore(analyzerReducer, composeEnhancers(middlewareEnhancer));
};

View file

@ -9,9 +9,14 @@ import type { Dispatch, MiddlewareAPI } from 'redux';
import { isEqual } from 'lodash';
import type { SafeResolverEvent } from '../../../../common/endpoint/types';
import type { ResolverState, DataAccessLayer, PanelViewAndParameters } from '../../types';
import type { DataAccessLayer, PanelViewAndParameters } from '../../types';
import type { State } from '../../../common/store/types';
import * as selectors from '../selectors';
import type { ResolverAction } from '../actions';
import {
appRequestedCurrentRelatedEventData,
serverFailedToReturnCurrentRelatedEventData,
serverReturnedCurrentRelatedEventData,
} from '../data/action';
/**
*
@ -19,23 +24,25 @@ import type { ResolverAction } from '../actions';
* If the current view is the `eventDetail` view it will request the event details from the server.
* @export
* @param {DataAccessLayer} dataAccessLayer
* @param {MiddlewareAPI<Dispatch<ResolverAction>, ResolverState>} api
* @param {MiddlewareAPI<Dispatch<Action>, State>} api
* @returns {() => void}
*/
export function CurrentRelatedEventFetcher(
dataAccessLayer: DataAccessLayer,
api: MiddlewareAPI<Dispatch<ResolverAction>, ResolverState>
): () => void {
let last: PanelViewAndParameters | undefined;
return async () => {
api: MiddlewareAPI<Dispatch, State>
): (id: string) => void {
const last: { [id: string]: PanelViewAndParameters | undefined } = {};
return async (id: string) => {
const state = api.getState();
const newParams = selectors.panelViewAndParameters(state);
const indices = selectors.eventIndices(state);
if (!last[id]) {
last[id] = undefined;
}
const newParams = selectors.panelViewAndParameters(state.analyzer.analyzerById[id]);
const indices = selectors.eventIndices(state.analyzer.analyzerById[id]);
const oldParams = last;
last = newParams;
const oldParams = last[id];
last[id] = 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') {
@ -45,12 +52,12 @@ export function CurrentRelatedEventFetcher(
const currentEventTimestamp = newParams.panelParameters.eventTimestamp;
const winlogRecordID = newParams.panelParameters.winlogRecordID;
api.dispatch({
type: 'appRequestedCurrentRelatedEventData',
});
const detectedBounds = selectors.detectedBounds(state);
api.dispatch(appRequestedCurrentRelatedEventData({ id }));
const detectedBounds = selectors.detectedBounds(state.analyzer.analyzerById[id]);
const timeRangeFilters =
detectedBounds !== undefined ? undefined : selectors.timeRangeFilters(state);
detectedBounds !== undefined
? undefined
: selectors.timeRangeFilters(state.analyzer.analyzerById[id]);
let result: SafeResolverEvent | null = null;
try {
result = await dataAccessLayer.event({
@ -63,20 +70,13 @@ export function CurrentRelatedEventFetcher(
timeRange: timeRangeFilters,
});
} catch (error) {
api.dispatch({
type: 'serverFailedToReturnCurrentRelatedEventData',
});
api.dispatch(serverFailedToReturnCurrentRelatedEventData({ id }));
}
if (result) {
api.dispatch({
type: 'serverReturnedCurrentRelatedEventData',
payload: result,
});
api.dispatch(serverReturnedCurrentRelatedEventData({ id, relatedEvent: result }));
} else {
api.dispatch({
type: 'serverFailedToReturnCurrentRelatedEventData',
});
api.dispatch(serverFailedToReturnCurrentRelatedEventData({ id }));
}
}
};

View file

@ -5,21 +5,49 @@
* 2.0.
*/
import type { Dispatch, MiddlewareAPI } from 'redux';
import type { ResolverState, DataAccessLayer } from '../../types';
import type { Dispatch, MiddlewareAPI, AnyAction } from 'redux';
import type { DataAccessLayer } from '../../types';
import { ResolverTreeFetcher } from './resolver_tree_fetcher';
import type { ResolverAction } from '../actions';
import type { State } from '../../../common/store/types';
import { RelatedEventsFetcher } from './related_events_fetcher';
import { CurrentRelatedEventFetcher } from './current_related_event_fetcher';
import { NodeDataFetcher } from './node_data_fetcher';
import * as Actions from '../actions';
import * as DataActions from '../data/action';
import * as CameraActions from '../camera/action';
type MiddlewareFactory<S = ResolverState> = (
type MiddlewareFactory<S = State> = (
dataAccessLayer: DataAccessLayer
) => (
api: MiddlewareAPI<Dispatch<ResolverAction>, S>
) => (next: Dispatch<ResolverAction>) => (action: ResolverAction) => unknown;
api: MiddlewareAPI<Dispatch<AnyAction>, S>
) => (next: Dispatch<AnyAction>) => (action: AnyAction) => unknown;
const resolverActions = [
...Object.values(Actions).map((action) => action.type),
...Object.values(DataActions).map((action) => action.type),
...Object.values(CameraActions).map((action) => action.type),
];
/**
* Helper function to determine if analyzer is active (resolver middleware should be run)
* analyzer is considered active if: action is not clean up
* @param state analyzerbyId state
* @param action dispatched action
* @returns boolean of whether the analyzer of id has an store in redux
*/
function isAnalyzerActive(action: AnyAction): boolean {
// middleware shouldn't run after clear resolver
return !Actions.clearResolver.match(action);
}
/**
* Helper function to check whether an action is a resolver action
* @param action dispatched action
* @returns boolean of whether the action is a resolver action
*/
function isResolverAction(action: AnyAction): boolean {
return resolverActions.includes(action.type);
}
/**
* The `redux` middleware that the application uses to trigger side effects.
* All data fetching should be done here.
@ -32,13 +60,16 @@ export const resolverMiddlewareFactory: MiddlewareFactory = (dataAccessLayer: Da
const relatedEventsFetcher = RelatedEventsFetcher(dataAccessLayer, api);
const currentRelatedEventFetcher = CurrentRelatedEventFetcher(dataAccessLayer, api);
const nodeDataFetcher = NodeDataFetcher(dataAccessLayer, api);
return async (action: ResolverAction) => {
return async (action: AnyAction) => {
next(action);
resolverTreeFetcher();
relatedEventsFetcher();
nodeDataFetcher();
currentRelatedEventFetcher();
if (action.payload?.id && isAnalyzerActive(action) && isResolverAction(action)) {
resolverTreeFetcher(action.payload.id);
relatedEventsFetcher(action.payload.id);
nodeDataFetcher(action.payload.id);
currentRelatedEventFetcher(action.payload.id);
}
};
};
};

View file

@ -7,10 +7,14 @@
import type { Dispatch, MiddlewareAPI } from 'redux';
import type { SafeResolverEvent } from '../../../../common/endpoint/types';
import type { ResolverState, DataAccessLayer } from '../../types';
import type { DataAccessLayer } from '../../types';
import type { State } from '../../../common/store/types';
import * as selectors from '../selectors';
import type { ResolverAction } from '../actions';
import {
appRequestingNodeData,
serverFailedToReturnNodeData,
serverReturnedNodeData,
} from '../data/action';
/**
* Max number of nodes to request from the server
@ -25,11 +29,10 @@ const nodeDataLimit = 5000;
*/
export function NodeDataFetcher(
dataAccessLayer: DataAccessLayer,
api: MiddlewareAPI<Dispatch<ResolverAction>, ResolverState>
): () => void {
return async () => {
api: MiddlewareAPI<Dispatch, State>
): (id: string) => void {
return async (id: string) => {
const state = api.getState();
/**
* Using the greatest positive number here so that we will request the node data for the nodes in view
* before the animation finishes. This will be a better user experience since we'll start the request while
@ -37,8 +40,10 @@ export function NodeDataFetcher(
*
* This gets the visible nodes that we haven't already requested or received data for
*/
const newIDsToRequest: Set<string> = selectors.newIDsToRequest(state)(Number.POSITIVE_INFINITY);
const indices = selectors.eventIndices(state);
const newIDsToRequest: Set<string> = selectors.newIDsToRequest(state.analyzer.analyzerById[id])(
Number.POSITIVE_INFINITY
);
const indices = selectors.eventIndices(state.analyzer.analyzerById[id]);
if (newIDsToRequest.size <= 0) {
return;
@ -51,18 +56,15 @@ export function NodeDataFetcher(
* When we dispatch this, this middleware will run again but the visible nodes will be the same, the nodeData
* state will have the new visible nodes in it, and newIDsToRequest will be an empty set.
*/
api.dispatch({
type: 'appRequestingNodeData',
payload: {
requestedIDs: newIDsToRequest,
},
});
api.dispatch(appRequestingNodeData({ id, requestedIDs: newIDsToRequest }));
let results: SafeResolverEvent[] | undefined;
try {
const detectedBounds = selectors.detectedBounds(state);
const detectedBounds = selectors.detectedBounds(state.analyzer.analyzerById[id]);
const timeRangeFilters =
detectedBounds !== undefined ? undefined : selectors.timeRangeFilters(state);
detectedBounds !== undefined
? undefined
: selectors.timeRangeFilters(state.analyzer.analyzerById[id]);
results = await dataAccessLayer.nodeData({
ids: Array.from(newIDsToRequest),
timeRange: timeRangeFilters,
@ -73,12 +75,7 @@ export function NodeDataFetcher(
/**
* Dispatch an action indicating all the nodes that we failed to retrieve data for
*/
api.dispatch({
type: 'serverFailedToReturnNodeData',
payload: {
requestedIDs: newIDsToRequest,
},
});
api.dispatch(serverFailedToReturnNodeData({ id, requestedIDs: newIDsToRequest }));
}
if (results) {
@ -87,9 +84,9 @@ export function NodeDataFetcher(
* not have received events for each node so the original IDs will help with identifying nodes that we have
* no data for.
*/
api.dispatch({
type: 'serverReturnedNodeData',
payload: {
api.dispatch(
serverReturnedNodeData({
id,
nodeData: results,
requestedIDs: newIDsToRequest,
/**
@ -114,8 +111,8 @@ export function NodeDataFetcher(
* if that node is still in view we'll request its node data.
*/
numberOfRequestedEvents: nodeDataLimit,
},
});
})
);
}
};
}

View file

@ -9,32 +9,42 @@ import type { Dispatch, MiddlewareAPI } from 'redux';
import { isEqual } from 'lodash';
import type { ResolverPaginatedEvents } from '../../../../common/endpoint/types';
import type { ResolverState, DataAccessLayer, PanelViewAndParameters } from '../../types';
import type { DataAccessLayer, PanelViewAndParameters } from '../../types';
import * as selectors from '../selectors';
import type { ResolverAction } from '../actions';
import type { State } from '../../../common/store/types';
import {
serverFailedToReturnNodeEventsInCategory,
serverReturnedNodeEventsInCategory,
} from '../data/action';
export function RelatedEventsFetcher(
dataAccessLayer: DataAccessLayer,
api: MiddlewareAPI<Dispatch<ResolverAction>, ResolverState>
): () => void {
let last: PanelViewAndParameters | undefined;
api: MiddlewareAPI<Dispatch, State>
): (id: string) => void {
const last: { [id: string]: PanelViewAndParameters | undefined } = {};
// Call this after each state change.
// This fetches the ResolverTree for the current entityID
// if the entityID changes while
return async () => {
return async (id: string) => {
const state = api.getState();
const newParams = selectors.panelViewAndParameters(state);
const isLoadingMoreEvents = selectors.isLoadingMoreNodeEventsInCategory(state);
const indices = selectors.eventIndices(state);
if (!last[id]) {
last[id] = undefined;
}
const newParams = selectors.panelViewAndParameters(state.analyzer.analyzerById[id]);
const isLoadingMoreEvents = selectors.isLoadingMoreNodeEventsInCategory(
state.analyzer.analyzerById[id]
);
const indices = selectors.eventIndices(state.analyzer.analyzerById[id]);
const oldParams = last;
const detectedBounds = selectors.detectedBounds(state);
const oldParams = last[id];
const detectedBounds = selectors.detectedBounds(state.analyzer.analyzerById[id]);
const timeRangeFilters =
detectedBounds !== undefined ? undefined : selectors.timeRangeFilters(state);
detectedBounds !== undefined
? undefined
: selectors.timeRangeFilters(state.analyzer.analyzerById[id]);
// 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;
last[id] = newParams;
async function fetchEvents({
nodeID,
@ -65,26 +75,21 @@ export function RelatedEventsFetcher(
});
}
} catch (error) {
api.dispatch({
type: 'serverFailedToReturnNodeEventsInCategory',
payload: {
nodeID,
eventCategory,
cursor,
},
});
api.dispatch(
serverFailedToReturnNodeEventsInCategory({ id, nodeID, eventCategory, cursor })
);
}
if (result) {
api.dispatch({
type: 'serverReturnedNodeEventsInCategory',
payload: {
api.dispatch(
serverReturnedNodeEventsInCategory({
id,
events: result.events,
eventCategory,
cursor: result.nextEvent,
nodeID,
},
});
})
);
}
}
@ -92,12 +97,6 @@ export function RelatedEventsFetcher(
if (!isEqual(newParams, oldParams)) {
if (newParams.panelView === 'nodeEventsInCategory') {
const nodeID = newParams.panelParameters.nodeID;
api.dispatch({
type: 'appRequestedNodeEventsInCategory',
payload: {
parameters: newParams,
},
});
await fetchEvents({
nodeID,
eventCategory: newParams.panelParameters.eventCategory,
@ -105,7 +104,7 @@ export function RelatedEventsFetcher(
});
}
} else if (isLoadingMoreEvents) {
const nodeEventsInCategory = state.data.nodeEventsInCategory;
const nodeEventsInCategory = state.analyzer.analyzerById[id].data.nodeEventsInCategory;
if (nodeEventsInCategory !== undefined) {
await fetchEvents(nodeEventsInCategory);
}

View file

@ -12,12 +12,18 @@ import type {
NewResolverTree,
ResolverSchema,
} from '../../../../common/endpoint/types';
import type { ResolverState, DataAccessLayer } from '../../types';
import type { DataAccessLayer } from '../../types';
import * as selectors from '../selectors';
import { firstNonNullValue } from '../../../../common/endpoint/models/ecs_safety_helpers';
import type { ResolverAction } from '../actions';
import { ancestorsRequestAmount, descendantsRequestAmount } from '../../models/resolver_tree';
import {
appRequestedResolverData,
serverFailedToReturnResolverData,
appAbortedResolverDataRequest,
serverReturnedResolverData,
} from '../data/action';
import type { State } from '../../../common/store/types';
/**
* A function that handles syncing ResolverTree data w/ the current entity ID.
* This will make a request anytime the entityID changes (to something other than undefined.)
@ -27,17 +33,20 @@ import { ancestorsRequestAmount, descendantsRequestAmount } from '../../models/r
*/
export function ResolverTreeFetcher(
dataAccessLayer: DataAccessLayer,
api: MiddlewareAPI<Dispatch<ResolverAction>, ResolverState>
): () => void {
api: MiddlewareAPI<Dispatch, State>
): (id: string) => void {
let lastRequestAbortController: AbortController | undefined;
// Call this after each state change.
// This fetches the ResolverTree for the current entityID
// if the entityID changes while
return async () => {
return async (id: string) => {
// const id = 'alerts-page';
const state = api.getState();
const databaseParameters = selectors.treeParametersToFetch(state);
if (selectors.treeRequestParametersToAbort(state) && lastRequestAbortController) {
const databaseParameters = selectors.treeParametersToFetch(state.analyzer.analyzerById[id]);
if (
selectors.treeRequestParametersToAbort(state.analyzer.analyzerById[id]) &&
lastRequestAbortController
) {
lastRequestAbortController.abort();
// calling abort will cause an action to be fired
} else if (databaseParameters !== null) {
@ -46,14 +55,11 @@ export function ResolverTreeFetcher(
let dataSource: string | undefined;
let dataSourceSchema: ResolverSchema | undefined;
let result: ResolverNode[] | undefined;
const timeRangeFilters = selectors.timeRangeFilters(state);
const timeRangeFilters = selectors.timeRangeFilters(state.analyzer.analyzerById[id]);
// Inform the state that we've made the request. Without this, the middleware will try to make the request again
// immediately.
api.dispatch({
type: 'appRequestedResolverData',
payload: databaseParameters,
});
api.dispatch(appRequestedResolverData({ id, parameters: databaseParameters }));
try {
const matchingEntities: ResolverEntityIndex = await dataAccessLayer.entities({
_id: databaseParameters.databaseDocumentID,
@ -62,10 +68,12 @@ export function ResolverTreeFetcher(
});
if (matchingEntities.length < 1) {
// If no entity_id could be found for the _id, bail out with a failure.
api.dispatch({
type: 'serverFailedToReturnResolverData',
payload: databaseParameters,
});
api.dispatch(
serverFailedToReturnResolverData({
id,
parameters: databaseParameters,
})
);
return;
}
({ id: entityIDToFetch, schema: dataSourceSchema, name: dataSource } = matchingEntities[0]);
@ -98,9 +106,9 @@ export function ResolverTreeFetcher(
.sort();
const oldestTimestamp = timestamps[0];
const newestTimestamp = timestamps.slice(-1);
api.dispatch({
type: 'serverReturnedResolverData',
payload: {
api.dispatch(
serverReturnedResolverData({
id,
result: { ...resolverTree, nodes: unboundedTree },
dataSource,
schema: dataSourceSchema,
@ -109,43 +117,38 @@ export function ResolverTreeFetcher(
from: String(oldestTimestamp),
to: String(newestTimestamp),
},
},
});
})
);
// 0 results with unbounded query, fail as before
} else {
api.dispatch({
type: 'serverReturnedResolverData',
payload: {
api.dispatch(
serverReturnedResolverData({
id,
result: resolverTree,
dataSource,
schema: dataSourceSchema,
parameters: databaseParameters,
},
});
})
);
}
} else {
api.dispatch({
type: 'serverReturnedResolverData',
payload: {
api.dispatch(
serverReturnedResolverData({
id,
result: resolverTree,
dataSource,
schema: dataSourceSchema,
parameters: databaseParameters,
},
});
})
);
}
} catch (error) {
// https://developer.mozilla.org/en-US/docs/Web/API/DOMException#exception-AbortError
if (error instanceof DOMException && error.name === 'AbortError') {
api.dispatch({
type: 'appAbortedResolverDataRequest',
payload: databaseParameters,
});
api.dispatch(appAbortedResolverDataRequest({ id, parameters: databaseParameters }));
} else {
api.dispatch({
type: 'serverFailedToReturnResolverData',
payload: databaseParameters,
});
api.dispatch(serverFailedToReturnResolverData({ id, parameters: databaseParameters }));
}
}
}

View file

@ -4,81 +4,97 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { Reducer } from 'redux';
import { combineReducers } from 'redux';
import type { Reducer, AnyAction } from 'redux';
import { reducerWithInitialState } from 'typescript-fsa-reducers';
import reduceReducers from 'reduce-reducers';
import { immerCase, EMPTY_RESOLVER } from './helpers';
import { animatePanning } from './camera/methods';
import { layout } from './selectors';
import { cameraReducer } from './camera/reducer';
import { dataReducer } from './data/reducer';
import type { ResolverAction } from './actions';
import type { ResolverState, ResolverUIState } from '../types';
import type { AnalyzerState } from '../types';
import { panAnimationDuration } from './camera/scaling_constants';
import { nodePosition } from '../models/indexed_process_tree/isometric_taxi_layout';
import {
appReceivedNewExternalProperties,
userFocusedOnResolverNode,
userSelectedResolverNode,
createResolver,
clearResolver,
} from './actions';
import { serverReturnedResolverData } from './data/action';
const uiReducer: Reducer<ResolverUIState, ResolverAction> = (
state = {
ariaActiveDescendant: null,
selectedNode: null,
},
action
) => {
if (action.type === 'serverReturnedResolverData') {
const next: ResolverUIState = {
...state,
ariaActiveDescendant: action.payload.result.originID,
selectedNode: action.payload.result.originID,
export const initialAnalyzerState: AnalyzerState = {
analyzerById: {},
};
return next;
} else if (action.type === 'userFocusedOnResolverNode') {
const next: ResolverUIState = {
...state,
ariaActiveDescendant: action.payload.nodeID,
};
return next;
} else if (action.type === 'userSelectedResolverNode') {
const next: ResolverUIState = {
...state,
selectedNode: action.payload.nodeID,
ariaActiveDescendant: action.payload.nodeID,
};
return next;
} else if (action.type === 'appReceivedNewExternalProperties') {
const next: ResolverUIState = {
...state,
locationSearch: action.payload.locationSearch,
resolverComponentInstanceID: action.payload.resolverComponentInstanceID,
};
return next;
} else {
return state;
const uiReducer = reducerWithInitialState(initialAnalyzerState)
.withHandling(
immerCase(createResolver, (draft, { id }) => {
if (!draft.analyzerById[id]) {
draft.analyzerById[id] = EMPTY_RESOLVER;
}
};
const concernReducers = combineReducers({
camera: cameraReducer,
data: dataReducer,
ui: uiReducer,
});
export const resolverReducer: Reducer<ResolverState, ResolverAction> = (state, action) => {
const nextState = concernReducers(state, action);
if (action.type === 'userSelectedResolverNode' || action.type === 'userFocusedOnResolverNode') {
const position = nodePosition(layout(nextState), action.payload.nodeID);
return draft;
})
)
.withHandling(
immerCase(clearResolver, (draft, { id }) => {
delete draft.analyzerById[id];
return draft;
})
)
.withHandling(
immerCase(
appReceivedNewExternalProperties,
(draft, { id, resolverComponentInstanceID, locationSearch }) => {
draft.analyzerById[id].ui.locationSearch = locationSearch;
draft.analyzerById[id].ui.resolverComponentInstanceID = resolverComponentInstanceID;
}
)
)
.withHandling(
immerCase(serverReturnedResolverData, (draft, { id, result }) => {
draft.analyzerById[id].ui.ariaActiveDescendant = result.originID;
draft.analyzerById[id].ui.selectedNode = result.originID;
return draft;
})
)
.withHandling(
immerCase(userFocusedOnResolverNode, (draft, { id, nodeID, time }) => {
draft.analyzerById[id].ui.ariaActiveDescendant = nodeID;
const position = nodePosition(layout(draft.analyzerById[id]), nodeID);
if (position) {
const withAnimation: ResolverState = {
...nextState,
camera: animatePanning(
nextState.camera,
action.payload.time,
draft.analyzerById[id].camera = animatePanning(
draft.analyzerById[id].camera,
time,
position,
panAnimationDuration
),
};
return withAnimation;
} else {
return nextState;
);
}
} else {
return nextState;
return draft;
})
)
.withHandling(
immerCase(userSelectedResolverNode, (draft, { id, nodeID, time }) => {
draft.analyzerById[id].ui.selectedNode = nodeID;
draft.analyzerById[id].ui.ariaActiveDescendant = nodeID;
const position = nodePosition(layout(draft.analyzerById[id]), nodeID);
if (position) {
draft.analyzerById[id].camera = animatePanning(
draft.analyzerById[id].camera,
time,
position,
panAnimationDuration
);
}
};
return draft;
})
)
.build();
export const analyzerReducer = reduceReducers(
initialAnalyzerState,
cameraReducer,
dataReducer,
uiReducer
) as unknown as Reducer<AnalyzerState, AnyAction>;

View file

@ -5,10 +5,10 @@
* 2.0.
*/
import type { ResolverState } from '../types';
import type { AnalyzerState } from '../types';
import type { Reducer, AnyAction } from 'redux';
import { createStore } from 'redux';
import type { ResolverAction } from './actions';
import { resolverReducer } from './reducer';
import { analyzerReducer } from './reducer';
import * as selectors from './selectors';
import {
mockTreeWith2AncestorsAndNoChildren,
@ -17,15 +17,26 @@ import {
import type { ResolverNode } from '../../../common/endpoint/types';
import { mockTreeFetcherParameters } from '../mocks/tree_fetcher_parameters';
import { endpointSourceSchema } from '../mocks/tree_schema';
import { serverReturnedResolverData } from './data/action';
import { userSetPositionOfCamera, userSetRasterSize } from './camera/action';
import { EMPTY_RESOLVER } from './helpers';
describe('resolver selectors', () => {
const actions: ResolverAction[] = [];
const actions: AnyAction[] = [];
const id = 'test-id';
/**
* Get state, given an ordered collection of actions.
*/
const state: () => ResolverState = () => {
const store = createStore(resolverReducer);
const testReducer: Reducer<AnalyzerState, AnyAction> = (
analyzerState = {
analyzerById: {
[id]: EMPTY_RESOLVER,
},
},
action
): AnalyzerState => analyzerReducer(analyzerState, action);
const state: () => AnalyzerState = () => {
const store = createStore(testReducer, undefined);
for (const action of actions) {
store.dispatch(action);
}
@ -38,9 +49,9 @@ describe('resolver selectors', () => {
const secondAncestorID = 'a';
beforeEach(() => {
const { schema, dataSource } = endpointSourceSchema();
actions.push({
type: 'serverReturnedResolverData',
payload: {
actions.push(
serverReturnedResolverData({
id,
result: mockTreeWith2AncestorsAndNoChildren({
originID,
firstAncestorID,
@ -50,26 +61,27 @@ describe('resolver selectors', () => {
schema,
// this value doesn't matter
parameters: mockTreeFetcherParameters(),
},
});
})
);
});
describe('when all nodes are in view', () => {
beforeEach(() => {
const size = 1000000;
actions.push({
// set the size of the camera
type: 'userSetRasterSize',
payload: [size, size],
});
actions.push(userSetRasterSize({ id, dimensions: [size, size] }));
});
it('should return no flowto for the second ancestor', () => {
expect(selectors.ariaFlowtoNodeID(state())(0)(secondAncestorID)).toBe(null);
expect(selectors.ariaFlowtoNodeID(state().analyzerById[id])(0)(secondAncestorID)).toBe(
null
);
});
it('should return no flowto for the first ancestor', () => {
expect(selectors.ariaFlowtoNodeID(state())(0)(firstAncestorID)).toBe(null);
expect(selectors.ariaFlowtoNodeID(state().analyzerById[id])(0)(firstAncestorID)).toBe(
null
);
});
it('should return no flowto for the origin', () => {
expect(selectors.ariaFlowtoNodeID(state())(0)(originID)).toBe(null);
expect(selectors.ariaFlowtoNodeID(state().analyzerById[id])(0)(originID)).toBe(null);
});
});
});
@ -84,51 +96,57 @@ describe('resolver selectors', () => {
secondChildID,
});
const { schema, dataSource } = endpointSourceSchema();
actions.push({
type: 'serverReturnedResolverData',
payload: {
actions.push(
serverReturnedResolverData({
id,
result: resolverTree,
dataSource,
schema,
// this value doesn't matter
parameters: mockTreeFetcherParameters(),
},
});
})
);
});
describe('when all nodes are in view', () => {
beforeEach(() => {
const rasterSize = 1000000;
actions.push({
// set the size of the camera
type: 'userSetRasterSize',
payload: [rasterSize, rasterSize],
});
actions.push(
userSetRasterSize({
id,
dimensions: [rasterSize, rasterSize],
})
);
});
it('should return no flowto for the origin', () => {
expect(selectors.ariaFlowtoNodeID(state())(0)(originID)).toBe(null);
expect(selectors.ariaFlowtoNodeID(state().analyzerById[id])(0)(originID)).toBe(null);
});
it('should return the second child as the flowto for the first child', () => {
expect(selectors.ariaFlowtoNodeID(state())(0)(firstChildID)).toBe(secondChildID);
expect(selectors.ariaFlowtoNodeID(state().analyzerById[id])(0)(firstChildID)).toBe(
secondChildID
);
});
it('should return no flowto for second child', () => {
expect(selectors.ariaFlowtoNodeID(state())(0)(secondChildID)).toBe(null);
expect(selectors.ariaFlowtoNodeID(state().analyzerById[id])(0)(secondChildID)).toBe(null);
});
});
describe('when only the origin and first child are in view', () => {
beforeEach(() => {
// set the raster size
const rasterSize = 1000000;
actions.push({
// set the size of the camera
type: 'userSetRasterSize',
payload: [rasterSize, rasterSize],
});
actions.push(
userSetRasterSize({
id,
dimensions: [rasterSize, rasterSize],
})
);
// get the layout
const layout = selectors.layout(state());
const layout = selectors.layout(state().analyzerById[id]);
// find the position of the second child
const secondChild = selectors.graphNodeForID(state())(secondChildID);
const secondChild = selectors.graphNodeForID(state().analyzerById[id])(secondChildID);
const positionOfSecondChild = layout.processNodePositions.get(
secondChild as ResolverNode
)!;
@ -137,39 +155,41 @@ describe('resolver selectors', () => {
const leftSideOfSecondChildAABB = positionOfSecondChild[0] - 720 / 2;
// adjust the camera so that it doesn't quite see the second child
actions.push({
actions.push(
userSetPositionOfCamera({
// set the position of the camera so that the left edge of the second child is at the right edge
// of the viewable area
type: 'userSetPositionOfCamera',
payload: [rasterSize / -2 + leftSideOfSecondChildAABB, 0],
});
id,
cameraView: [rasterSize / -2 + leftSideOfSecondChildAABB, 0],
})
);
});
it('the origin should be in view', () => {
const origin = selectors.graphNodeForID(state())(originID);
const origin = selectors.graphNodeForID(state().analyzerById[id])(originID);
expect(
selectors
.visibleNodesAndEdgeLines(state())(0)
.visibleNodesAndEdgeLines(state().analyzerById[id])(0)
.processNodePositions.has(origin as ResolverNode)
).toBe(true);
});
it('the first child should be in view', () => {
const firstChild = selectors.graphNodeForID(state())(firstChildID);
const firstChild = selectors.graphNodeForID(state().analyzerById[id])(firstChildID);
expect(
selectors
.visibleNodesAndEdgeLines(state())(0)
.visibleNodesAndEdgeLines(state().analyzerById[id])(0)
.processNodePositions.has(firstChild as ResolverNode)
).toBe(true);
});
it('the second child should not be in view', () => {
const secondChild = selectors.graphNodeForID(state())(secondChildID);
const secondChild = selectors.graphNodeForID(state().analyzerById[id])(secondChildID);
expect(
selectors
.visibleNodesAndEdgeLines(state())(0)
.visibleNodesAndEdgeLines(state().analyzerById[id])(0)
.processNodePositions.has(secondChild as ResolverNode)
).toBe(false);
});
it('should return nothing as the flowto for the first child', () => {
expect(selectors.ariaFlowtoNodeID(state())(0)(firstChildID)).toBe(null);
expect(selectors.ariaFlowtoNodeID(state().analyzerById[id])(0)(firstChildID)).toBe(null);
});
});
});

View file

@ -6,10 +6,12 @@
*/
import { createSelector, defaultMemoize } from 'reselect';
import type { State } from '../../common/store/types';
import * as cameraSelectors from './camera/selectors';
import * as dataSelectors from './data/selectors';
import * as uiSelectors from './ui/selectors';
import type {
AnalyzerById,
ResolverState,
IsometricTaxiLayout,
DataState,
@ -19,6 +21,16 @@ import type {
import type { EventStats } from '../../../common/endpoint/types';
import * as nodeModel from '../../../common/endpoint/models/node';
export const selectAnalyzerById = (state: State): AnalyzerById => state.analyzer.analyzerById;
export const selectAnalyzer = (state: State, id: string): ResolverState =>
state.analyzer.analyzerById[id];
export const analyzerByIdSelector = createSelector(
selectAnalyzerById,
(analyzerById: AnalyzerById) => analyzerById
);
/**
* A matrix that when applied to a Vector2 will convert it from world coordinates to screen coordinates.
* See https://en.wikipedia.org/wiki/Orthographic_projection

View file

@ -6,28 +6,28 @@
*/
import React from 'react';
import type { Store } from 'redux';
import { createStore, applyMiddleware } from 'redux';
import type { Store, AnyAction } from 'redux';
import type { ReactWrapper } from 'enzyme';
import { mount } from 'enzyme';
import type { History as HistoryPackageHistoryInterface } from 'history';
import { createMemoryHistory } from 'history';
import { coreMock } from '@kbn/core/public/mocks';
import { createStore } from '../../../common/store/store';
import { spyMiddlewareFactory } from '../spy_middleware_factory';
import { resolverMiddlewareFactory } from '../../store/middleware';
import { resolverReducer } from '../../store/reducer';
import { MockResolver } from './mock_resolver';
import type {
ResolverState,
DataAccessLayer,
SpyMiddleware,
SideEffectSimulator,
TimeFilters,
} from '../../types';
import type { ResolverAction } from '../../store/actions';
import type { DataAccessLayer, SpyMiddleware, SideEffectSimulator, TimeFilters } from '../../types';
import { sideEffectSimulatorFactory } from '../../view/side_effect_simulator_factory';
import { uiSetting } from '../../mocks/ui_setting';
import { EMPTY_RESOLVER } from '../../store/helpers';
import type { State } from '../../../common/store/types';
import {
createSecuritySolutionStorageMock,
kibanaObservable,
mockGlobalState,
SUB_PLUGINS_REDUCER,
} from '../../../common/mock';
import { createResolver } from '../../store/actions';
/**
* Test a Resolver instance using jest, enzyme, and a mock data layer.
*/
@ -36,7 +36,7 @@ export class Simulator {
* The redux store, creating in the constructor using the `dataAccessLayer`.
* This code subscribes to state transitions.
*/
private readonly store: Store<ResolverState, ResolverAction>;
private readonly store: Store<State, AnyAction>;
/**
* A fake 'History' API used with `react-router` to simulate a browser history.
*/
@ -111,18 +111,22 @@ export class Simulator {
// create the spy middleware (for debugging tests)
this.spyMiddleware = spyMiddlewareFactory();
/**
* Create the real resolver middleware with a fake data access layer.
* By providing different data access layers, you can simulate different data and server environments.
*/
const middlewareEnhancer = applyMiddleware(
resolverMiddlewareFactory(dataAccessLayer),
// install the spyMiddleware
this.spyMiddleware.middleware
);
// Create a redux store w/ the top level Resolver reducer and the enhancer that includes the Resolver middleware and the `spyMiddleware`
this.store = createStore(resolverReducer, middlewareEnhancer);
const { storage } = createSecuritySolutionStorageMock();
this.store = createStore(
{
...mockGlobalState,
analyzer: {
analyzerById: {
[resolverComponentInstanceID]: EMPTY_RESOLVER,
},
},
},
SUB_PLUGINS_REDUCER,
kibanaObservable,
storage,
[resolverMiddlewareFactory(dataAccessLayer), this.spyMiddleware.middleware]
);
// If needed, create a fake 'history' instance.
// Resolver will use to read and write query string values.
@ -169,6 +173,7 @@ export class Simulator {
* Change the component instance ID (updates the React component props.)
*/
public set resolverComponentInstanceID(value: string) {
this.store.dispatch(createResolver({ id: value }));
this.wrapper.setProps({ resolverComponentInstanceID: value });
}

View file

@ -11,13 +11,16 @@ import React, { useEffect, useState, useCallback } from 'react';
import { Router } from 'react-router-dom';
import { I18nProvider } from '@kbn/i18n-react';
import { Provider } from 'react-redux';
import type { Store } from 'redux';
import type { Store, AnyAction } from 'redux';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import type { CoreStart } from '@kbn/core/public';
import type { ResolverState, SideEffectSimulator, ResolverProps } from '../../types';
import type { ResolverAction } from '../../store/actions';
import { enableMapSet } from 'immer';
import type { SideEffectSimulator, ResolverProps } from '../../types';
import { ResolverWithoutProviders } from '../../view/resolver_without_providers';
import { SideEffectContext } from '../../view/side_effect_context';
import type { State } from '../../../common/store/types';
enableMapSet();
type MockResolverProps = {
/**
@ -37,7 +40,7 @@ type MockResolverProps = {
*/
history: React.ComponentProps<typeof Router>['history'];
/** Pass a resolver store. See `storeFactory` and `mockDataAccessLayer` */
store: Store<ResolverState, ResolverAction>;
store: Store<State, AnyAction>;
/**
* Pass the side effect simulator which handles animations and resizing. See `sideEffectSimulatorFactory`
*/

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import type { ResolverAction } from '../store/actions';
import type { AnyAction } from 'redux';
import type { SpyMiddleware, SpyMiddlewareStateActionPair } from '../types';
/**
@ -25,7 +25,7 @@ export const spyMiddlewareFactory: () => SpyMiddleware = () => {
};
return {
middleware: (api) => (next) => (action: ResolverAction) => {
middleware: (api) => (next) => (action: AnyAction) => {
// handle the action first so we get the state after the reducer
next(action);

View file

@ -7,10 +7,9 @@
import type { ResizeObserver } from '@juggle/resize-observer';
import type React from 'react';
import type { Store, Middleware, Dispatch } from 'redux';
import type { Store, Middleware, Dispatch, AnyAction } from 'redux';
import type { BBox } from 'rbush';
import type { Provider } from 'react-redux';
import type { ResolverAction } from './store/actions';
import type {
ResolverNode,
ResolverRelatedEvents,
@ -21,7 +20,18 @@ import type {
ResolverSchema,
} from '../../common/endpoint/types';
import type { Tree } from '../../common/endpoint/generate_data';
import type { State } from '../common/store/types';
export interface AnalyzerOuterState {
analyzer: AnalyzerState;
}
export interface AnalyzerState {
analyzerById: AnalyzerById;
}
export interface AnalyzerById {
[id: string]: ResolverState;
}
/**
* Redux state for the Resolver feature. Properties on this interface are populated via multiple reducers using redux's `combineReducers`.
*/
@ -380,7 +390,7 @@ export interface DataState {
/**
* Represents an ordered pair. Used for x-y coordinates and the like.
*/
export type Vector2 = readonly [number, number];
export type Vector2 = [number, number];
/**
* A rectangle with sides that align with the `x` and `y` axises.
@ -646,7 +656,7 @@ export type ResolverProcessType =
| 'processError'
| 'unknownEvent';
export type ResolverStore = Store<ResolverState, ResolverAction>;
export type ResolverStore = Store<State, AnyAction>;
/**
* Describes the basic Resolver graph layout.
@ -827,11 +837,11 @@ export interface ResolverProps {
export interface SpyMiddlewareStateActionPair {
/** An action dispatched, `state` is the state that the reducer returned when handling this action.
*/
action: ResolverAction;
action: AnyAction;
/**
* A resolver state that was returned by the reducer when handling `action`.
*/
state: ResolverState;
state: State;
}
/**
@ -841,7 +851,7 @@ export interface SpyMiddleware {
/**
* A middleware to use with `applyMiddleware`.
*/
middleware: Middleware<{}, ResolverState, Dispatch<ResolverAction>>;
middleware: Middleware<{}, State, Dispatch<AnyAction>>;
/**
* A generator that returns all state and action pairs that pass through the middleware.
*/
@ -866,7 +876,7 @@ export interface ResolverPluginSetup {
* Takes a `DataAccessLayer`, which could be a mock one, and returns an redux Store.
* All data acess (e.g. HTTP requests) are done through the store.
*/
storeFactory: (dataAccessLayer: DataAccessLayer) => Store<ResolverState, ResolverAction>;
storeFactory: (dataAccessLayer: DataAccessLayer) => Store<AnalyzerState, AnyAction>;
/**
* The Resolver component without the required Providers.

View file

@ -27,11 +27,18 @@ import { useSelector, useDispatch } from 'react-redux';
import { SideEffectContext } from './side_effect_context';
import type { Vector2 } from '../types';
import * as selectors from '../store/selectors';
import type { ResolverAction } from '../store/actions';
import { useColors } from './use_colors';
import { StyledDescriptionList } from './panels/styles';
import { CubeForProcess } from './panels/cube_for_process';
import { GeneratedText } from './generated_text';
import {
userClickedZoomIn,
userClickedZoomOut,
userSetZoomLevel,
userNudgedCamera,
userSetPositionOfCamera,
} from '../store/camera/action';
import type { State } from '../../common/store/types';
// EuiRange is currently only horizontally positioned. This reorients the track to a vertical position
const StyledEuiRange = styled(EuiRange)<EuiRangeProps>`
@ -118,15 +125,22 @@ const StyledGraphControls = styled.div<Partial<StyledGraphControlProps>>`
export const GraphControls = React.memo(
({
id,
className,
}: {
/**
* Id that identify the scope of analyzer
*/
id: string;
/**
* A className string provided by `styled`
*/
className?: string;
}) => {
const dispatch: (action: ResolverAction) => unknown = useDispatch();
const scalingFactor = useSelector(selectors.scalingFactor);
const dispatch = useDispatch();
const scalingFactor = useSelector((state: State) =>
selectors.scalingFactor(state.analyzer.analyzerById[id])
);
const { timestamp } = useContext(SideEffectContext);
const [activePopover, setPopover] = useState<null | 'schemaInfo' | 'nodeLegend'>(null);
const colorMap = useColors();
@ -150,33 +164,28 @@ export const GraphControls = React.memo(
(event as React.ChangeEvent<HTMLInputElement>).target.value
);
if (isNaN(valueAsNumber) === false) {
dispatch({
type: 'userSetZoomLevel',
payload: valueAsNumber,
});
dispatch(
userSetZoomLevel({
id,
zoomLevel: valueAsNumber,
})
);
}
},
[dispatch]
[dispatch, id]
);
const handleCenterClick = useCallback(() => {
dispatch({
type: 'userSetPositionOfCamera',
payload: [0, 0],
});
}, [dispatch]);
dispatch(userSetPositionOfCamera({ id, cameraView: [0, 0] }));
}, [dispatch, id]);
const handleZoomOutClick = useCallback(() => {
dispatch({
type: 'userClickedZoomOut',
});
}, [dispatch]);
dispatch(userClickedZoomOut({ id }));
}, [dispatch, id]);
const handleZoomInClick = useCallback(() => {
dispatch({
type: 'userClickedZoomIn',
});
}, [dispatch]);
dispatch(userClickedZoomIn({ id }));
}, [dispatch, id]);
const [handleNorth, handleEast, handleSouth, handleWest] = useMemo(() => {
const directionVectors: readonly Vector2[] = [
@ -187,14 +196,10 @@ export const GraphControls = React.memo(
];
return directionVectors.map((direction) => {
return () => {
const action: ResolverAction = {
type: 'userNudgedCamera',
payload: { direction, time: timestamp() },
};
dispatch(action);
dispatch(userNudgedCamera({ id, direction, time: timestamp() }));
};
});
}, [dispatch, timestamp]);
}, [dispatch, timestamp, id]);
return (
<StyledGraphControls
@ -204,11 +209,13 @@ export const GraphControls = React.memo(
>
<StyledGraphControlsColumn>
<SchemaInformation
id={id}
closePopover={closePopover}
isOpen={activePopover === 'schemaInfo'}
setActivePopover={setActivePopover}
/>
<NodeLegend
id={id}
closePopover={closePopover}
isOpen={activePopover === 'nodeLegend'}
setActivePopover={setActivePopover}
@ -309,16 +316,20 @@ export const GraphControls = React.memo(
);
const SchemaInformation = ({
id,
closePopover,
setActivePopover,
isOpen,
}: {
id: string;
closePopover: () => void;
setActivePopover: (value: 'schemaInfo' | null) => void;
isOpen: boolean;
}) => {
const colorMap = useColors();
const sourceAndSchema = useSelector(selectors.resolverTreeSourceAndSchema);
const sourceAndSchema = useSelector((state: State) =>
selectors.resolverTreeSourceAndSchema(state.analyzer.analyzerById[id])
);
const setAsActivePopover = useCallback(() => setActivePopover('schemaInfo'), [setActivePopover]);
const schemaInfoButtonTitle = i18n.translate(
@ -431,10 +442,12 @@ const SchemaInformation = ({
// This component defines the cube legend that allows users to identify the meaning of the cubes
// Should be updated to be dynamic if and when non process based resolvers are possible
const NodeLegend = ({
id,
closePopover,
setActivePopover,
isOpen,
}: {
id: string;
closePopover: () => void;
setActivePopover: (value: 'nodeLegend') => void;
isOpen: boolean;
@ -489,6 +502,7 @@ const NodeLegend = ({
style={{ width: '20% ' }}
>
<CubeForProcess
id={id}
size="2.5em"
data-test-subj="resolver:node-detail:title-icon"
state="running"
@ -512,6 +526,7 @@ const NodeLegend = ({
style={{ width: '20% ' }}
>
<CubeForProcess
id={id}
size="2.5em"
data-test-subj="resolver:node-detail:title-icon"
state="terminated"
@ -535,6 +550,7 @@ const NodeLegend = ({
style={{ width: '20% ' }}
>
<CubeForProcess
id={id}
size="2.5em"
data-test-subj="resolver:node-detail:title-icon"
state="loading"
@ -558,6 +574,7 @@ const NodeLegend = ({
style={{ width: '20% ' }}
>
<CubeForProcess
id={id}
size="2.5em"
data-test-subj="resolver:node-detail:title-icon"
state="error"

View file

@ -7,38 +7,29 @@
/* eslint-disable react/display-name */
import React, { useMemo, useState, useEffect } from 'react';
import { Provider } from 'react-redux';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { resolverStoreFactory } from '../store';
import type { StartServices } from '../../types';
import type { DataAccessLayer, ResolverProps } from '../types';
import { dataAccessLayerFactory } from '../data_access_layer/factory';
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import type { ResolverProps } from '../types';
import { ResolverWithoutProviders } from './resolver_without_providers';
import { createResolver } from '../store/actions';
import type { State } from '../../common/store/types';
/**
* The `Resolver` component to use. This sets up the DataAccessLayer provider. Use `ResolverWithoutProviders` in tests or in other scenarios where you want to provide a different (or fake) data access layer.
*/
export const Resolver = React.memo((props: ResolverProps) => {
const context = useKibana<StartServices>();
const dataAccessLayer: DataAccessLayer = useMemo(
() => dataAccessLayerFactory(context),
[context]
const store = useSelector(
(state: State) => state.analyzer.analyzerById[props.resolverComponentInstanceID]
);
const store = useMemo(() => resolverStoreFactory(dataAccessLayer), [dataAccessLayer]);
const [activeStore, updateActiveStore] = useState(store);
const dispatch = useDispatch();
if (!store) {
dispatch(createResolver({ id: props.resolverComponentInstanceID }));
}
useEffect(() => {
if (props.shouldUpdate) {
updateActiveStore(resolverStoreFactory(dataAccessLayer));
dispatch(createResolver({ id: props.resolverComponentInstanceID }));
}
}, [dataAccessLayer, props.shouldUpdate]);
return (
<Provider store={activeStore}>
<ResolverWithoutProviders {...props} />
</Provider>
);
}, [dispatch, props.shouldUpdate, props.resolverComponentInstanceID]);
return <ResolverWithoutProviders {...props} />;
});

View file

@ -9,8 +9,6 @@ import styled from 'styled-components';
import { i18n } from '@kbn/i18n';
/* eslint-disable react/display-name */
import React, { memo } from 'react';
interface StyledSVGCube {
@ -24,12 +22,14 @@ import type { NodeDataStatus } from '../../types';
* Icon representing a process node.
*/
export const CubeForProcess = memo(function ({
id,
className,
size = '2.15em',
state,
isOrigin,
'data-test-subj': dataTestSubj,
}: {
id: string;
'data-test-subj'?: string;
/**
* The state of the process's node data (for endpoint the process's lifecycle events)
@ -40,8 +40,8 @@ export const CubeForProcess = memo(function ({
isOrigin?: boolean;
className?: string;
}) {
const { cubeSymbol, strokeColor } = useCubeAssets(state, false);
const { processCubeActiveBacking } = useSymbolIDs();
const { cubeSymbol, strokeColor } = useCubeAssets(id, state, false);
const { processCubeActiveBacking } = useSymbolIDs({ id });
return (
<StyledSVG

View file

@ -32,7 +32,6 @@ import * as eventModel from '../../../../common/endpoint/models/event';
import * as selectors from '../../store/selectors';
import { PanelLoading } from './panel_loading';
import { PanelContentError } from './panel_content_error';
import type { ResolverState } from '../../types';
import { DescriptiveName } from './descriptive_name';
import { useLinkProps } from '../use_link_props';
import type { SafeResolverEvent } from '../../../../common/endpoint/types';
@ -40,6 +39,7 @@ import { deepObjectEntries } from './deep_object_entries';
import { useFormattedDate } from './use_formatted_date';
import * as nodeDataModel from '../../models/node_data';
import { expandDottedObject } from '../../../../common/utils/expand_dotted';
import type { State } from '../../../common/store/types';
const eventDetailRequestError = i18n.translate(
'xpack.securitySolution.resolver.panel.eventDetail.requestError',
@ -49,31 +49,42 @@ const eventDetailRequestError = i18n.translate(
);
export const EventDetail = memo(function EventDetail({
id,
nodeID,
eventCategory: eventType,
}: {
id: string;
nodeID: string;
/** The event type to show in the breadcrumbs */
eventCategory: string;
}) {
const isEventLoading = useSelector(selectors.isCurrentRelatedEventLoading);
const isTreeLoading = useSelector(selectors.isTreeLoading);
const processEvent = useSelector((state: ResolverState) =>
nodeDataModel.firstEvent(selectors.nodeDataForID(state)(nodeID))
const isEventLoading = useSelector((state: State) =>
selectors.isCurrentRelatedEventLoading(state.analyzer.analyzerById[id])
);
const isTreeLoading = useSelector((state: State) =>
selectors.isTreeLoading(state.analyzer.analyzerById[id])
);
const processEvent = useSelector((state: State) =>
nodeDataModel.firstEvent(selectors.nodeDataForID(state.analyzer.analyzerById[id])(nodeID))
);
const nodeStatus = useSelector((state: State) =>
selectors.nodeDataStatus(state.analyzer.analyzerById[id])(nodeID)
);
const nodeStatus = useSelector((state: ResolverState) => selectors.nodeDataStatus(state)(nodeID));
const isNodeDataLoading = nodeStatus === 'loading';
const isLoading = isEventLoading || isTreeLoading || isNodeDataLoading;
const event = useSelector(selectors.currentRelatedEventData);
const event = useSelector((state: State) =>
selectors.currentRelatedEventData(state.analyzer.analyzerById[id])
);
return isLoading ? (
<StyledPanel hasBorder>
<PanelLoading />
<PanelLoading id={id} />
</StyledPanel>
) : event ? (
<EventDetailContents
id={id}
nodeID={nodeID}
event={event}
processEvent={processEvent}
@ -81,7 +92,7 @@ export const EventDetail = memo(function EventDetail({
/>
) : (
<StyledPanel hasBorder>
<PanelContentError translatedErrorMessage={eventDetailRequestError} />
<PanelContentError id={id} translatedErrorMessage={eventDetailRequestError} />
</StyledPanel>
);
});
@ -91,11 +102,13 @@ export const EventDetail = memo(function EventDetail({
* it appears in the underlying ResolverEvent
*/
const EventDetailContents = memo(function ({
id,
nodeID,
event,
eventType,
processEvent,
}: {
id: string;
nodeID: string;
event: SafeResolverEvent;
/**
@ -116,6 +129,7 @@ const EventDetailContents = memo(function ({
return (
<StyledPanel hasBorder data-test-subj="resolver:panel:event-detail">
<EventDetailBreadcrumbs
id={id}
nodeID={nodeID}
nodeName={nodeName}
event={event}
@ -222,37 +236,42 @@ function EventDetailFields({ event }: { event: SafeResolverEvent }) {
}
function EventDetailBreadcrumbs({
id,
nodeID,
nodeName,
event,
breadcrumbEventCategory,
}: {
id: string;
nodeID: string;
nodeName: string | null | undefined;
event: SafeResolverEvent;
breadcrumbEventCategory: string;
}) {
const countByCategory = useSelector((state: ResolverState) =>
selectors.relatedEventCountOfTypeForNode(state)(nodeID, breadcrumbEventCategory)
const countByCategory = useSelector((state: State) =>
selectors.relatedEventCountOfTypeForNode(state.analyzer.analyzerById[id])(
nodeID,
breadcrumbEventCategory
)
);
const relatedEventCount: number | undefined = useSelector((state: ResolverState) =>
selectors.relatedEventTotalCount(state)(nodeID)
const relatedEventCount: number | undefined = useSelector((state: State) =>
selectors.relatedEventTotalCount(state.analyzer.analyzerById[id])(nodeID)
);
const nodesLinkNavProps = useLinkProps({
const nodesLinkNavProps = useLinkProps(id, {
panelView: 'nodes',
});
const nodeDetailLinkNavProps = useLinkProps({
const nodeDetailLinkNavProps = useLinkProps(id, {
panelView: 'nodeDetail',
panelParameters: { nodeID },
});
const nodeEventsLinkNavProps = useLinkProps({
const nodeEventsLinkNavProps = useLinkProps(id, {
panelView: 'nodeEvents',
panelParameters: { nodeID },
});
const nodeEventsInCategoryLinkNavProps = useLinkProps({
const nodeEventsInCategoryLinkNavProps = useLinkProps(id, {
panelView: 'nodeEventsInCategory',
panelParameters: { nodeID, eventCategory: breadcrumbEventCategory },
});

View file

@ -16,20 +16,23 @@ import { NodeDetail } from './node_detail';
import { NodeList } from './node_list';
import { EventDetail } from './event_detail';
import type { PanelViewAndParameters } from '../../types';
import type { State } from '../../../common/store/types';
/**
* Show the panel that matches the `panelViewAndParameters` (derived from the browser's location.search)
*/
export const PanelRouter = memo(function () {
const params: PanelViewAndParameters = useSelector(selectors.panelViewAndParameters);
export const PanelRouter = memo(function ({ id }: { id: string }) {
const params: PanelViewAndParameters = useSelector((state: State) =>
selectors.panelViewAndParameters(state.analyzer.analyzerById[id])
);
if (params.panelView === 'nodeDetail') {
return <NodeDetail nodeID={params.panelParameters.nodeID} />;
return <NodeDetail id={id} nodeID={params.panelParameters.nodeID} />;
} else if (params.panelView === 'nodeEvents') {
return <NodeEvents nodeID={params.panelParameters.nodeID} />;
return <NodeEvents id={id} nodeID={params.panelParameters.nodeID} />;
} else if (params.panelView === 'nodeEventsInCategory') {
return (
<NodeEventsInCategory
id={id}
nodeID={params.panelParameters.nodeID}
eventCategory={params.panelParameters.eventCategory}
/>
@ -37,12 +40,13 @@ export const PanelRouter = memo(function () {
} else if (params.panelView === 'eventDetail') {
return (
<EventDetail
id={id}
nodeID={params.panelParameters.nodeID}
eventCategory={params.panelParameters.eventCategory}
/>
);
} else {
/* The default 'Event List' / 'List of all processes' view */
return <NodeList />;
return <NodeList id={id} />;
}
});

View file

@ -26,12 +26,12 @@ import * as nodeDataModel from '../../models/node_data';
import { CubeForProcess } from './cube_for_process';
import type { SafeResolverEvent } from '../../../../common/endpoint/types';
import { useCubeAssets } from '../use_cube_assets';
import type { ResolverState } from '../../types';
import { PanelLoading } from './panel_loading';
import { StyledPanel } from '../styles';
import { useLinkProps } from '../use_link_props';
import { useFormattedDate } from './use_formatted_date';
import { PanelContentError } from './panel_content_error';
import type { State } from '../../../common/store/types';
const StyledCubeForProcess = styled(CubeForProcess)`
position: relative;
@ -41,23 +41,25 @@ const nodeDetailError = i18n.translate('xpack.securitySolution.resolver.panel.no
defaultMessage: 'Node details were unable to be retrieved',
});
export const NodeDetail = memo(function ({ nodeID }: { nodeID: string }) {
const processEvent = useSelector((state: ResolverState) =>
nodeDataModel.firstEvent(selectors.nodeDataForID(state)(nodeID))
export const NodeDetail = memo(function ({ id, nodeID }: { id: string; nodeID: string }) {
const processEvent = useSelector((state: State) =>
nodeDataModel.firstEvent(selectors.nodeDataForID(state.analyzer.analyzerById[id])(nodeID))
);
const nodeStatus = useSelector((state: State) =>
selectors.nodeDataStatus(state.analyzer.analyzerById[id])(nodeID)
);
const nodeStatus = useSelector((state: ResolverState) => selectors.nodeDataStatus(state)(nodeID));
return nodeStatus === 'loading' ? (
<StyledPanel hasBorder>
<PanelLoading />
<PanelLoading id={id} />
</StyledPanel>
) : processEvent ? (
<StyledPanel hasBorder data-test-subj="resolver:panel:node-detail">
<NodeDetailView nodeID={nodeID} processEvent={processEvent} />
<NodeDetailView id={id} nodeID={nodeID} processEvent={processEvent} />
</StyledPanel>
) : (
<StyledPanel hasBorder>
<PanelContentError translatedErrorMessage={nodeDetailError} />
<PanelContentError id={id} translatedErrorMessage={nodeDetailError} />
</StyledPanel>
);
});
@ -67,16 +69,20 @@ export const NodeDetail = memo(function ({ nodeID }: { nodeID: string }) {
* Created, PID, User/Domain, etc.
*/
const NodeDetailView = memo(function ({
id,
processEvent,
nodeID,
}: {
id: string;
processEvent: SafeResolverEvent;
nodeID: string;
}) {
const processName = eventModel.processNameSafeVersion(processEvent);
const nodeState = useSelector((state: ResolverState) => selectors.nodeDataStatus(state)(nodeID));
const relatedEventTotal = useSelector((state: ResolverState) => {
return selectors.relatedEventTotalCount(state)(nodeID);
const nodeState = useSelector((state: State) =>
selectors.nodeDataStatus(state.analyzer.analyzerById[id])(nodeID)
);
const relatedEventTotal = useSelector((state: State) => {
return selectors.relatedEventTotalCount(state.analyzer.analyzerById[id])(nodeID);
});
const eventTime = eventModel.eventTimestamp(processEvent);
const dateTime = useFormattedDate(eventTime);
@ -175,7 +181,7 @@ const NodeDetailView = memo(function ({
return processDescriptionListData;
}, [dateTime, processEvent]);
const nodesLinkNavProps = useLinkProps({
const nodesLinkNavProps = useLinkProps(id, {
panelView: 'nodes',
});
@ -202,9 +208,9 @@ const NodeDetailView = memo(function ({
},
];
}, [processName, nodesLinkNavProps]);
const { descriptionText } = useCubeAssets(nodeState, false);
const { descriptionText } = useCubeAssets(id, nodeState, false);
const nodeDetailNavProps = useLinkProps({
const nodeDetailNavProps = useLinkProps(id, {
panelView: 'nodeEvents',
panelParameters: { nodeID },
});
@ -217,6 +223,7 @@ const NodeDetailView = memo(function ({
<EuiTitle size="xs">
<StyledTitle aria-describedby={titleID}>
<StyledCubeForProcess
id={id}
data-test-subj="resolver:node-detail:title-icon"
state={nodeState}
/>

View file

@ -17,34 +17,37 @@ import { Breadcrumbs } from './breadcrumbs';
import * as event from '../../../../common/endpoint/models/event';
import type { EventStats } from '../../../../common/endpoint/types';
import * as selectors from '../../store/selectors';
import type { ResolverState } from '../../types';
import { StyledPanel } from '../styles';
import { PanelLoading } from './panel_loading';
import { useLinkProps } from '../use_link_props';
import * as nodeDataModel from '../../models/node_data';
import type { State } from '../../../common/store/types';
export function NodeEvents({ nodeID }: { nodeID: string }) {
const processEvent = useSelector((state: ResolverState) =>
nodeDataModel.firstEvent(selectors.nodeDataForID(state)(nodeID))
export function NodeEvents({ id, nodeID }: { id: string; nodeID: string }) {
const processEvent = useSelector((state: State) =>
nodeDataModel.firstEvent(selectors.nodeDataForID(state.analyzer.analyzerById[id])(nodeID))
);
const nodeStats = useSelector((state: State) =>
selectors.nodeStats(state.analyzer.analyzerById[id])(nodeID)
);
const nodeStats = useSelector((state: ResolverState) => selectors.nodeStats(state)(nodeID));
if (processEvent === undefined || nodeStats === undefined) {
return (
<StyledPanel hasBorder>
<PanelLoading />
<PanelLoading id={id} />
</StyledPanel>
);
} else {
return (
<StyledPanel hasBorder>
<NodeEventsBreadcrumbs
id={id}
nodeName={event.processNameSafeVersion(processEvent)}
nodeID={nodeID}
totalEventCount={nodeStats.total}
/>
<EuiSpacer size="l" />
<EventCategoryLinks nodeID={nodeID} relatedStats={nodeStats} />
<EventCategoryLinks id={id} nodeID={nodeID} relatedStats={nodeStats} />
</StyledPanel>
);
}
@ -62,9 +65,11 @@ export function NodeEvents({ nodeID }: { nodeID: string }) {
*
*/
const EventCategoryLinks = memo(function ({
id,
nodeID,
relatedStats,
}: {
id: string;
nodeID: string;
relatedStats: EventStats;
}) {
@ -104,23 +109,25 @@ const EventCategoryLinks = memo(function ({
sortable: true,
render(eventType: string) {
return (
<NodeEventsLink nodeID={nodeID} eventType={eventType}>
<NodeEventsLink id={id} nodeID={nodeID} eventType={eventType}>
{eventType}
</NodeEventsLink>
);
},
},
],
[nodeID]
[nodeID, id]
);
return <EuiInMemoryTable<EventCountsTableView> items={rows} columns={columns} sorting />;
});
const NodeEventsBreadcrumbs = memo(function ({
id,
nodeID,
nodeName,
totalEventCount,
}: {
id: string;
nodeID: string;
nodeName: React.ReactNode;
totalEventCount: number;
@ -135,13 +142,13 @@ const NodeEventsBreadcrumbs = memo(function ({
defaultMessage: 'Events',
}
),
...useLinkProps({
...useLinkProps(id, {
panelView: 'nodes',
}),
},
{
text: nodeName,
...useLinkProps({
...useLinkProps(id, {
panelView: 'nodeDetail',
panelParameters: { nodeID },
}),
@ -154,7 +161,7 @@ const NodeEventsBreadcrumbs = memo(function ({
defaultMessage="{totalCount} Events"
/>
),
...useLinkProps({
...useLinkProps(id, {
panelView: 'nodeEvents',
panelParameters: { nodeID },
}),
@ -166,15 +173,17 @@ const NodeEventsBreadcrumbs = memo(function ({
const NodeEventsLink = memo(
({
id,
nodeID,
eventType,
children,
}: {
id: string;
nodeID: string;
eventType: string;
children: React.ReactNode;
}) => {
const props = useLinkProps({
const props = useLinkProps(id, {
panelView: 'nodeEventsInCategory',
panelParameters: {
nodeID,

View file

@ -18,7 +18,7 @@ import {
EuiButton,
EuiCallOut,
} from '@elastic/eui';
import { useSelector } from 'react-redux';
import { useSelector, useDispatch } from 'react-redux';
import { FormattedMessage } from '@kbn/i18n-react';
import { StyledPanel } from '../styles';
import { BoldCode, StyledTime } from './styles';
@ -26,33 +26,41 @@ import { Breadcrumbs } from './breadcrumbs';
import * as eventModel from '../../../../common/endpoint/models/event';
import type { SafeResolverEvent } from '../../../../common/endpoint/types';
import * as selectors from '../../store/selectors';
import type { 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';
import { expandDottedObject } from '../../../../common/utils/expand_dotted';
import type { State } from '../../../common/store/types';
import { userRequestedAdditionalRelatedEvents } from '../../store/data/action';
/**
* Render a list of events that are related to `nodeID` and that have a category of `eventType`.
*/
export const NodeEventsInCategory = memo(function ({
id,
nodeID,
eventCategory,
}: {
id: string;
nodeID: string;
eventCategory: string;
}) {
const node = useSelector((state: ResolverState) => selectors.graphNodeForID(state)(nodeID));
const isLoading = useSelector(selectors.isLoadingNodeEventsInCategory);
const hasError = useSelector(selectors.hadErrorLoadingNodeEventsInCategory);
const node = useSelector((state: State) =>
selectors.graphNodeForID(state.analyzer.analyzerById[id])(nodeID)
);
const isLoading = useSelector((state: State) =>
selectors.isLoadingNodeEventsInCategory(state.analyzer.analyzerById[id])
);
const hasError = useSelector((state: State) =>
selectors.hadErrorLoadingNodeEventsInCategory(state.analyzer.analyzerById[id])
);
return (
<>
{isLoading ? (
<StyledPanel hasBorder>
<PanelLoading />
<PanelLoading id={id} />
</StyledPanel>
) : (
<StyledPanel hasBorder data-test-subj="resolver:panel:events-in-category">
@ -78,12 +86,13 @@ export const NodeEventsInCategory = memo(function ({
) : (
<>
<NodeEventsInCategoryBreadcrumbs
id={id}
nodeName={node.name}
eventCategory={eventCategory}
nodeID={nodeID}
/>
<EuiSpacer size="l" />
<NodeEventList eventCategory={eventCategory} nodeID={nodeID} />
<NodeEventList id={id} eventCategory={eventCategory} nodeID={nodeID} />
</>
)}
</StyledPanel>
@ -96,10 +105,12 @@ export const NodeEventsInCategory = memo(function ({
* Rendered for each event in the list.
*/
const NodeEventsListItem = memo(function ({
id,
event,
nodeID,
eventCategory,
}: {
id: string;
event: SafeResolverEvent;
nodeID: string;
eventCategory: string;
@ -113,7 +124,7 @@ const NodeEventsListItem = memo(function ({
i18n.translate('xpack.securitySolution.enpdoint.resolver.panelutils.noTimestampRetrieved', {
defaultMessage: 'No timestamp retrieved',
});
const linkProps = useLinkProps({
const linkProps = useLinkProps(id, {
panelView: 'eventDetail',
panelParameters: {
nodeID,
@ -159,26 +170,32 @@ const NodeEventsListItem = memo(function ({
* Renders a list of events with a separator in between.
*/
const NodeEventList = memo(function NodeEventList({
id,
eventCategory,
nodeID,
}: {
id: string;
eventCategory: string;
nodeID: string;
}) {
const events = useSelector(selectors.nodeEventsInCategory);
const dispatch = useResolverDispatch();
const events = useSelector((state: State) =>
selectors.nodeEventsInCategory(state.analyzer.analyzerById[id])
);
const dispatch = useDispatch();
const handleLoadMore = useCallback(() => {
dispatch({
type: 'userRequestedAdditionalRelatedEvents',
});
}, [dispatch]);
const isLoading = useSelector(selectors.isLoadingMoreNodeEventsInCategory);
const hasMore = useSelector(selectors.lastRelatedEventResponseContainsCursor);
dispatch(userRequestedAdditionalRelatedEvents({ id }));
}, [dispatch, id]);
const isLoading = useSelector((state: State) =>
selectors.isLoadingMoreNodeEventsInCategory(state.analyzer.analyzerById[id])
);
const hasMore = useSelector((state: State) =>
selectors.lastRelatedEventResponseContainsCursor(state.analyzer.analyzerById[id])
);
return (
<>
{events.map((event, index) => (
<Fragment key={index}>
<NodeEventsListItem nodeID={nodeID} eventCategory={eventCategory} event={event} />
<NodeEventsListItem id={id} nodeID={nodeID} eventCategory={eventCategory} event={event} />
{index === events.length - 1 ? null : <EuiHorizontalRule margin="m" />}
</Fragment>
))}
@ -207,32 +224,34 @@ const NodeEventList = memo(function NodeEventList({
* Renders `Breadcrumbs`.
*/
const NodeEventsInCategoryBreadcrumbs = memo(function ({
id,
nodeName,
eventCategory,
nodeID,
}: {
id: string;
nodeName: React.ReactNode;
eventCategory: string;
nodeID: string;
}) {
const eventCount = useSelector((state: ResolverState) =>
selectors.totalRelatedEventCountForNode(state)(nodeID)
const eventCount = useSelector((state: State) =>
selectors.totalRelatedEventCountForNode(state.analyzer.analyzerById[id])(nodeID)
);
const eventsInCategoryCount = useSelector((state: ResolverState) =>
selectors.relatedEventCountOfTypeForNode(state)(nodeID, eventCategory)
const eventsInCategoryCount = useSelector((state: State) =>
selectors.relatedEventCountOfTypeForNode(state.analyzer.analyzerById[id])(nodeID, eventCategory)
);
const nodesLinkNavProps = useLinkProps({
const nodesLinkNavProps = useLinkProps(id, {
panelView: 'nodes',
});
const nodeDetailNavProps = useLinkProps({
const nodeDetailNavProps = useLinkProps(id, {
panelView: 'nodeDetail',
panelParameters: { nodeID },
});
const nodeEventsNavProps = useLinkProps({
const nodeEventsNavProps = useLinkProps(id, {
panelView: 'nodeEvents',
panelParameters: { nodeID },
});

View file

@ -28,12 +28,12 @@ import * as selectors from '../../store/selectors';
import { Breadcrumbs } from './breadcrumbs';
import { CubeForProcess } from './cube_for_process';
import { LimitWarning } from '../limit_warnings';
import type { ResolverState } from '../../types';
import { useLinkProps } from '../use_link_props';
import { useColors } from '../use_colors';
import type { ResolverAction } from '../../store/actions';
import { useFormattedDate } from './use_formatted_date';
import { CopyablePanelField } from './copyable_panel_field';
import { userSelectedResolverNode } from '../../store/actions';
import type { State } from '../../../common/store/types';
interface ProcessTableView {
name?: string;
@ -44,7 +44,7 @@ interface ProcessTableView {
/**
* The "default" view for the panel: A list of all the processes currently in the graph.
*/
export const NodeList = memo(() => {
export const NodeList = memo(({ id }: { id: string }) => {
const columns = useMemo<Array<EuiBasicTableColumn<ProcessTableView>>>(
() => [
{
@ -58,7 +58,7 @@ export const NodeList = memo(() => {
sortable: true,
truncateText: true,
render(name: string | undefined, item: ProcessTableView) {
return <NodeDetailLink name={name} nodeID={item.nodeID} />;
return <NodeDetailLink id={id} name={name} nodeID={item.nodeID} />;
},
},
{
@ -76,12 +76,13 @@ export const NodeList = memo(() => {
},
},
],
[]
[id]
);
const processTableView: ProcessTableView[] = useSelector(
useCallback((state: ResolverState) => {
const { processNodePositions } = selectors.layout(state);
useCallback(
(state: State) => {
const { processNodePositions } = selectors.layout(state.analyzer.analyzerById[id]);
const view: ProcessTableView[] = [];
for (const treeNode of processNodePositions.keys()) {
const name = nodeModel.nodeName(treeNode);
@ -95,7 +96,9 @@ export const NodeList = memo(() => {
}
}
return view;
}, [])
},
[id]
)
);
const numberOfProcesses = processTableView.length;
@ -110,9 +113,15 @@ export const NodeList = memo(() => {
];
}, []);
const children = useSelector(selectors.hasMoreChildren);
const ancestors = useSelector(selectors.hasMoreAncestors);
const generations = useSelector(selectors.hasMoreGenerations);
const children = useSelector((state: State) =>
selectors.hasMoreChildren(state.analyzer.analyzerById[id])
);
const ancestors = useSelector((state: State) =>
selectors.hasMoreAncestors(state.analyzer.analyzerById[id])
);
const generations = useSelector((state: State) =>
selectors.hasMoreGenerations(state.analyzer.analyzerById[id])
);
const showWarning = children === true || ancestors === true || generations === true;
const rowProps = useMemo(() => ({ 'data-test-subj': 'resolver:node-list:item' }), []);
return (
@ -131,27 +140,29 @@ export const NodeList = memo(() => {
);
});
function NodeDetailLink({ name, nodeID }: { name?: string; nodeID: string }) {
const isOrigin = useSelector((state: ResolverState) => {
return selectors.originID(state) === nodeID;
function NodeDetailLink({ id, name, nodeID }: { id: string; name?: string; nodeID: string }) {
const isOrigin = useSelector((state: State) => {
return selectors.originID(state.analyzer.analyzerById[id]) === nodeID;
});
const nodeState = useSelector((state: ResolverState) => selectors.nodeDataStatus(state)(nodeID));
const nodeState = useSelector((state: State) =>
selectors.nodeDataStatus(state.analyzer.analyzerById[id])(nodeID)
);
const { descriptionText } = useColors();
const linkProps = useLinkProps({ panelView: 'nodeDetail', panelParameters: { nodeID } });
const dispatch: (action: ResolverAction) => void = useDispatch();
const linkProps = useLinkProps(id, { panelView: 'nodeDetail', panelParameters: { nodeID } });
const dispatch = useDispatch();
const { timestamp } = useContext(SideEffectContext);
const handleOnClick = useCallback(
(mouseEvent: React.MouseEvent<HTMLAnchorElement>) => {
linkProps.onClick(mouseEvent);
dispatch({
type: 'userSelectedResolverNode',
payload: {
dispatch(
userSelectedResolverNode({
id,
nodeID,
time: timestamp(),
})
);
},
});
},
[timestamp, linkProps, dispatch, nodeID]
[timestamp, linkProps, dispatch, nodeID, id]
);
return (
<EuiButtonEmpty
@ -172,6 +183,7 @@ function NodeDetailLink({ name, nodeID }: { name?: string; nodeID: string }) {
) : (
<StyledButtonTextContainer>
<CubeForProcess
id={id}
state={nodeState}
isOrigin={isOrigin}
data-test-subj="resolver:node-list:node-link:icon"

View file

@ -18,13 +18,13 @@ import { useLinkProps } from '../use_link_props';
* @param {string} translatedErrorMessage The message to display in the panel when something goes wrong
*/
export const PanelContentError = memo(function ({
id,
translatedErrorMessage,
}: {
id: string;
translatedErrorMessage: string;
}) {
const nodesLinkNavProps = useLinkProps({
panelView: 'nodes',
});
const nodesLinkNavProps = useLinkProps(id, { panelView: 'nodes' });
const crumbs = useMemo(() => {
return [

View file

@ -16,7 +16,7 @@ const StyledSpinnerFlexItem = styled.span`
margin-right: 5px;
`;
export function PanelLoading() {
export function PanelLoading({ id }: { id: string }) {
const waitingString = i18n.translate(
'xpack.securitySolution.endpoint.resolver.panel.relatedDetail.wait',
{
@ -29,7 +29,7 @@ export function PanelLoading() {
defaultMessage: 'Events',
}
);
const nodesLinkNavProps = useLinkProps({
const nodesLinkNavProps = useLinkProps(id, {
panelView: 'nodes',
});
const waitCrumbs = useMemo(() => {

View file

@ -8,14 +8,13 @@
import React, { useCallback, useMemo, useContext } from 'react';
import styled from 'styled-components';
import { htmlIdGenerator, EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { useSelector } from 'react-redux';
import { useSelector, useDispatch } from 'react-redux';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { NodeSubMenu } from './styles';
import { applyMatrix3 } from '../models/vector2';
import type { Vector2, Matrix3, ResolverState } from '../types';
import type { Vector2, Matrix3 } from '../types';
import type { ResolverNode } from '../../../common/endpoint/types';
import { useResolverDispatch } from './use_resolver_dispatch';
import { SideEffectContext } from './side_effect_context';
import * as nodeModel from '../../../common/endpoint/models/node';
import * as eventModel from '../../../common/endpoint/models/event';
@ -26,6 +25,9 @@ import { useCubeAssets } from './use_cube_assets';
import { useSymbolIDs } from './use_symbol_ids';
import { useColors } from './use_colors';
import { useLinkProps } from './use_link_props';
import { userSelectedResolverNode, userFocusedOnResolverNode } from '../store/actions';
import { userReloadedResolverNode } from '../store/data/action';
import type { State } from '../../common/store/types';
interface StyledActionsContainer {
readonly color: string;
@ -121,6 +123,7 @@ const StyledOuterGroup = styled.g<{ isNodeLoading: boolean }>`
*/
const UnstyledProcessEventDot = React.memo(
({
id,
className,
position,
node,
@ -128,6 +131,10 @@ const UnstyledProcessEventDot = React.memo(
projectionMatrix,
timeAtRender,
}: {
/**
* Id that identify the scope of analyzer
*/
id: string;
/**
* A `className` string provided by `styled`
*/
@ -154,11 +161,11 @@ const UnstyledProcessEventDot = React.memo(
*/
timeAtRender: number;
}) => {
const resolverComponentInstanceID = useSelector(selectors.resolverComponentInstanceID);
const resolverComponentInstanceID = id;
// This should be unique to each instance of Resolver
const htmlIDPrefix = `resolver:${resolverComponentInstanceID}`;
const symbolIDs = useSymbolIDs();
const symbolIDs = useSymbolIDs({ id });
const { timestamp } = useContext(SideEffectContext);
/**
@ -169,25 +176,33 @@ const UnstyledProcessEventDot = React.memo(
const [xScale] = projectionMatrix;
// Node (html id=) IDs
const ariaActiveDescendant = useSelector(selectors.ariaActiveDescendant);
const selectedNode = useSelector(selectors.selectedNode);
const originID = useSelector(selectors.originID);
const nodeStats = useSelector((state: ResolverState) => selectors.nodeStats(state)(nodeID));
const ariaActiveDescendant = useSelector((state: State) =>
selectors.ariaActiveDescendant(state.analyzer.analyzerById[id])
);
const selectedNode = useSelector((state: State) =>
selectors.selectedNode(state.analyzer.analyzerById[id])
);
const originID = useSelector((state: State) =>
selectors.originID(state.analyzer.analyzerById[id])
);
const nodeStats = useSelector((state: State) =>
selectors.nodeStats(state.analyzer.analyzerById[id])(nodeID)
);
// define a standard way of giving HTML IDs to nodes based on their entity_id/nodeID.
// this is used to link nodes via aria attributes
const nodeHTMLID = useCallback(
(id: string) => htmlIdGenerator(htmlIDPrefix)(`${id}:node`),
(nodeId: string) => htmlIdGenerator(htmlIDPrefix)(`${nodeId}:node`),
[htmlIDPrefix]
);
const ariaLevel: number | null = useSelector((state: ResolverState) =>
selectors.ariaLevel(state)(nodeID)
const ariaLevel: number | null = useSelector((state: State) =>
selectors.ariaLevel(state.analyzer.analyzerById[id])(nodeID)
);
// the node ID to 'flowto'
const ariaFlowtoNodeID: string | null = useSelector((state: ResolverState) =>
selectors.ariaFlowtoNodeID(state)(timeAtRender)(nodeID)
const ariaFlowtoNodeID: string | null = useSelector((state: State) =>
selectors.ariaFlowtoNodeID(state.analyzer.analyzerById[id])(timeAtRender)(nodeID)
);
const isShowingEventActions = xScale > 0.8;
@ -260,8 +275,8 @@ const UnstyledProcessEventDot = React.memo(
} = React.createRef();
const colorMap = useColors();
const nodeState = useSelector((state: ResolverState) =>
selectors.nodeDataStatus(state)(nodeID)
const nodeState = useSelector((state: State) =>
selectors.nodeDataStatus(state.analyzer.analyzerById[id])(nodeID)
);
const isNodeLoading = nodeState === 'loading';
const {
@ -272,6 +287,7 @@ const UnstyledProcessEventDot = React.memo(
labelButtonFill,
strokeColor,
} = useCubeAssets(
id,
nodeState,
/**
* There is no definition for 'trigger process' yet. return false.
@ -284,22 +300,22 @@ const UnstyledProcessEventDot = React.memo(
const isAriaSelected = nodeID === selectedNode;
const isOrigin = nodeID === originID;
const dispatch = useResolverDispatch();
const dispatch = useDispatch();
const processDetailNavProps = useLinkProps({
const processDetailNavProps = useLinkProps(id, {
panelView: 'nodeDetail',
panelParameters: { nodeID },
});
const handleFocus = useCallback(() => {
dispatch({
type: 'userFocusedOnResolverNode',
payload: {
dispatch(
userFocusedOnResolverNode({
id,
nodeID,
time: timestamp(),
},
});
}, [dispatch, nodeID, timestamp]);
})
);
}, [dispatch, nodeID, timestamp, id]);
const handleClick = useCallback(
(clickEvent) => {
@ -308,30 +324,29 @@ const UnstyledProcessEventDot = React.memo(
}
if (nodeState === 'error') {
dispatch({
type: 'userReloadedResolverNode',
payload: nodeID,
});
dispatch(userReloadedResolverNode({ id, nodeID }));
} else {
dispatch({
type: 'userSelectedResolverNode',
payload: {
dispatch(
userSelectedResolverNode({
id,
nodeID,
time: timestamp(),
},
});
})
);
processDetailNavProps.onClick(clickEvent);
}
},
[animationTarget, dispatch, nodeID, processDetailNavProps, nodeState, timestamp]
[animationTarget, dispatch, nodeID, processDetailNavProps, nodeState, timestamp, id]
);
const grandTotal: number | null = useSelector((state: ResolverState) =>
selectors.statsTotalForNode(state)(node)
const grandTotal: number | null = useSelector((state: State) =>
selectors.statsTotalForNode(state.analyzer.analyzerById[id])(node)
);
const nodeName = nodeModel.nodeName(node);
const processEvent = useSelector((state: ResolverState) =>
nodeDataModel.firstEvent(selectors.nodeDataForID(state)(String(node.id)))
const processEvent = useSelector((state: State) =>
nodeDataModel.firstEvent(
selectors.nodeDataForID(state.analyzer.analyzerById[id])(String(node.id))
)
);
const processName = useMemo(() => {
if (processEvent !== undefined) {
@ -509,6 +524,7 @@ const UnstyledProcessEventDot = React.memo(
<EuiFlexItem grow={false} className="related-dropdown">
{grandTotal !== null && grandTotal > 0 && (
<NodeSubMenu
id={id}
buttonFill={colorMap.resolverBackground}
nodeStats={nodeStats}
nodeID={nodeID}

View file

@ -22,13 +22,13 @@ import { useStateSyncingActions } from './use_state_syncing_actions';
import { StyledMapContainer, GraphContainer } from './styles';
import * as nodeModel from '../../../common/endpoint/models/node';
import { SideEffectContext } from './side_effect_context';
import type { ResolverProps, ResolverState } from '../types';
import type { ResolverProps } from '../types';
import { PanelRouter } from './panels';
import { useColors } from './use_colors';
import { useSyncSelectedNode } from './use_sync_selected_node';
import { ResolverNoProcessEvents } from './resolver_no_process_events';
import { useAutotuneTimerange } from './use_autotune_timerange';
import type { State } from '../../common/store/types';
/**
* The highest level connected Resolver component. Needs a `Provider` in its ancestry to work.
*/
@ -47,7 +47,7 @@ export const ResolverWithoutProviders = React.memo(
}: ResolverProps,
refToForward
) {
useResolverQueryParamCleaner();
useResolverQueryParamCleaner(resolverComponentInstanceID);
/**
* This is responsible for dispatching actions that include any external data.
* `databaseDocumentID`
@ -59,22 +59,28 @@ export const ResolverWithoutProviders = React.memo(
shouldUpdate,
filters,
});
useAutotuneTimerange();
useAutotuneTimerange({ id: resolverComponentInstanceID });
/**
* This will keep the selectedNode in the view in sync with the nodeID specified in the url
*/
useSyncSelectedNode();
useSyncSelectedNode({ id: resolverComponentInstanceID });
const { timestamp } = useContext(SideEffectContext);
// use this for the entire render in order to keep things in sync
const timeAtRender = timestamp();
const { processNodePositions, connectingEdgeLineSegments } = useSelector(
(state: ResolverState) => selectors.visibleNodesAndEdgeLines(state)(timeAtRender)
const { processNodePositions, connectingEdgeLineSegments } = useSelector((state: State) =>
selectors.visibleNodesAndEdgeLines(state.analyzer.analyzerById[resolverComponentInstanceID])(
timeAtRender
)
);
const { projectionMatrix, ref: cameraRef, onMouseDown } = useCamera();
const {
projectionMatrix,
ref: cameraRef,
onMouseDown,
} = useCamera({ id: resolverComponentInstanceID });
const ref = useCallback(
(element: HTMLDivElement | null) => {
@ -90,10 +96,18 @@ export const ResolverWithoutProviders = React.memo(
},
[cameraRef, refToForward]
);
const isLoading = useSelector(selectors.isTreeLoading);
const hasError = useSelector(selectors.hadErrorLoadingTree);
const activeDescendantId = useSelector(selectors.ariaActiveDescendant);
const resolverTreeHasNodes = useSelector(selectors.resolverTreeHasNodes);
const isLoading = useSelector((state: State) =>
selectors.isTreeLoading(state.analyzer.analyzerById[resolverComponentInstanceID])
);
const hasError = useSelector((state: State) =>
selectors.hadErrorLoadingTree(state.analyzer.analyzerById[resolverComponentInstanceID])
);
const activeDescendantId = useSelector((state: State) =>
selectors.ariaActiveDescendant(state.analyzer.analyzerById[resolverComponentInstanceID])
);
const resolverTreeHasNodes = useSelector((state: State) =>
selectors.resolverTreeHasNodes(state.analyzer.analyzerById[resolverComponentInstanceID])
);
const colorMap = useColors();
return (
@ -141,6 +155,7 @@ export const ResolverWithoutProviders = React.memo(
}
return (
<ProcessEventDot
id={resolverComponentInstanceID}
key={nodeID}
nodeID={nodeID}
position={position}
@ -151,13 +166,13 @@ export const ResolverWithoutProviders = React.memo(
);
})}
</GraphContainer>
<PanelRouter />
<PanelRouter id={resolverComponentInstanceID} />
</>
) : (
<ResolverNoProcessEvents />
)}
<GraphControls />
<SymbolDefinitions />
<GraphControls id={resolverComponentInstanceID} />
<SymbolDefinitions id={resolverComponentInstanceID} />
</StyledMapContainer>
);
})

View file

@ -10,9 +10,9 @@ import { useDispatch } from 'react-redux';
import type { EventStats } from '../../../common/endpoint/types';
import { useColors } from './use_colors';
import { useLinkProps } from './use_link_props';
import type { ResolverAction } from '../store/actions';
import { SideEffectContext } from './side_effect_context';
import { FormattedCount } from '../../common/components/formatted_number';
import { userSelectedResolverNode } from '../store/actions';
/* eslint-disable react/display-name */
@ -22,10 +22,12 @@ import { FormattedCount } from '../../common/components/formatted_number';
*/
export const NodeSubMenuComponents = React.memo(
({
id,
className,
nodeID,
nodeStats,
}: {
id: string;
className?: string;
// eslint-disable-next-line react/no-unused-prop-types
buttonFill: string;
@ -60,7 +62,7 @@ export const NodeSubMenuComponents = React.memo(
return opta.category.localeCompare(optb.category);
})
.map((pill) => {
return <NodeSubmenuPill pill={pill} nodeID={nodeID} key={pill.category} />;
return <NodeSubmenuPill id={id} pill={pill} nodeID={nodeID} key={pill.category} />;
})}
</ul>
);
@ -68,13 +70,15 @@ export const NodeSubMenuComponents = React.memo(
);
const NodeSubmenuPill = ({
id,
pill,
nodeID,
}: {
id: string;
pill: { prefix: JSX.Element; category: string };
nodeID: string;
}) => {
const linkProps = useLinkProps({
const linkProps = useLinkProps(id, {
panelView: 'nodeEventsInCategory',
panelParameters: { nodeID, eventCategory: pill.category },
});
@ -86,21 +90,21 @@ const NodeSubmenuPill = ({
};
}, [pillBorderStroke, pillFill]);
const dispatch: (action: ResolverAction) => void = useDispatch();
const dispatch = useDispatch();
const { timestamp } = useContext(SideEffectContext);
const handleOnClick = useCallback(
(mouseEvent: React.MouseEvent<HTMLButtonElement>) => {
linkProps.onClick(mouseEvent);
dispatch({
type: 'userSelectedResolverNode',
payload: {
dispatch(
userSelectedResolverNode({
id,
nodeID,
time: timestamp(),
})
);
},
});
},
[timestamp, linkProps, dispatch, nodeID]
[timestamp, linkProps, dispatch, nodeID, id]
);
return (
<li

View file

@ -66,8 +66,8 @@ const hoveredProcessBackgroundTitle = i18n.translate(
* PaintServers: Where color palettes, gradients, patterns and other similar concerns
* are exposed to the component
*/
const PaintServers = memo(({ isDarkMode }: { isDarkMode: boolean }) => {
const paintServerIDs = usePaintServerIDs();
const PaintServers = memo(({ id, isDarkMode }: { id: string; isDarkMode: boolean }) => {
const paintServerIDs = usePaintServerIDs({ id });
return (
<>
<linearGradient
@ -165,9 +165,9 @@ const PaintServers = memo(({ isDarkMode }: { isDarkMode: boolean }) => {
/**
* Defs entries that define shapes, masks and other spatial elements
*/
const SymbolsAndShapes = memo(({ isDarkMode }: { isDarkMode: boolean }) => {
const symbolIDs = useSymbolIDs();
const paintServerIDs = usePaintServerIDs();
const SymbolsAndShapes = memo(({ id, isDarkMode }: { id: string; isDarkMode: boolean }) => {
const symbolIDs = useSymbolIDs({ id });
const paintServerIDs = usePaintServerIDs({ id });
return (
<>
<symbol
@ -433,13 +433,13 @@ const SymbolsAndShapes = memo(({ isDarkMode }: { isDarkMode: boolean }) => {
* 2. Separation of concerns between creative assets and more functional areas of the app
* 3. `<use>` elements can be handled by compositor (faster)
*/
export const SymbolDefinitions = memo(() => {
export const SymbolDefinitions = memo(({ id }: { id: string }) => {
const isDarkMode = useUiSetting<boolean>('theme:darkMode');
return (
<HiddenSVG>
<defs>
<PaintServers isDarkMode={isDarkMode} />
<SymbolsAndShapes isDarkMode={isDarkMode} />
<PaintServers id={id} isDarkMode={isDarkMode} />
<SymbolsAndShapes id={id} isDarkMode={isDarkMode} />
</defs>
</HiddenSVG>
);

View file

@ -10,12 +10,12 @@ import { useSelector } from 'react-redux';
import * as selectors from '../store/selectors';
import { useAppToasts } from '../../common/hooks/use_app_toasts';
import { useFormattedDate } from './panels/use_formatted_date';
import type { ResolverState } from '../types';
import type { State } from '../../common/store/types';
export function useAutotuneTimerange() {
export function useAutotuneTimerange({ id }: { id: string }) {
const { addSuccess } = useAppToasts();
const { from: detectedFrom, to: detectedTo } = useSelector((state: ResolverState) => {
const detectedBounds = selectors.detectedBounds(state);
const { from: detectedFrom, to: detectedTo } = useSelector((state: State) => {
const detectedBounds = selectors.detectedBounds(state.analyzer.analyzerById[id]);
return {
from: detectedBounds?.from ? detectedBounds.from : undefined,
to: detectedBounds?.to ? detectedBounds.to : undefined,

View file

@ -7,7 +7,6 @@
// Extend jest with a custom matcher
import '../test_utilities/extend_jest';
import type { ReactWrapper } from 'enzyme';
import { mount } from 'enzyme';
import React from 'react';
@ -20,15 +19,18 @@ import { SideEffectContext } from './side_effect_context';
import { applyMatrix3 } from '../models/vector2';
import { sideEffectSimulatorFactory } from './side_effect_simulator_factory';
import { mock as mockResolverTree } from '../models/resolver_tree';
import type { ResolverAction } from '../store/actions';
import { createStore } from 'redux';
import { resolverReducer } from '../store/reducer';
import { createStore, combineReducers } from 'redux';
import { mockTreeFetcherParameters } from '../mocks/tree_fetcher_parameters';
import * as nodeModel from '../../../common/endpoint/models/node';
import { act } from 'react-dom/test-utils';
import { mockResolverNode } from '../mocks/resolver_node';
import { endpointSourceSchema } from '../mocks/tree_schema';
import { panAnimationDuration } from '../store/camera/scaling_constants';
import { serverReturnedResolverData } from '../store/data/action';
import { userSelectedResolverNode } from '../store/actions';
import { mockReducer } from '../store/helpers';
const id = 'test-id';
describe('useCamera on an unpainted element', () => {
/** Enzyme full DOM wrapper for the element the camera is attached to. */
@ -108,7 +110,7 @@ describe('useCamera on an unpainted element', () => {
*/
useAlternateElement?: boolean;
}) {
const camera = useCamera();
const camera = useCamera({ id });
const { ref, onMouseDown } = camera;
projectionMatrix = camera.projectionMatrix;
return useAlternateElement ? (
@ -125,7 +127,8 @@ describe('useCamera on an unpainted element', () => {
}
beforeEach(async () => {
store = createStore(resolverReducer);
const outerReducer = combineReducers({ analyzer: mockReducer(id) });
store = createStore(outerReducer, undefined);
simulator = sideEffectSimulatorFactory();
@ -263,21 +266,22 @@ describe('useCamera on an unpainted element', () => {
const tree = mockResolverTree({ nodes });
if (tree !== null) {
const { schema, dataSource } = endpointSourceSchema();
const serverResponseAction: ResolverAction = {
type: 'serverReturnedResolverData',
payload: {
store.dispatch(
serverReturnedResolverData({
id,
result: tree,
dataSource,
schema,
parameters: mockTreeFetcherParameters(),
},
};
store.dispatch(serverResponseAction);
})
);
} else {
throw new Error('failed to create tree');
}
const resolverNodes: ResolverNode[] = [
...selectors.layout(store.getState()).processNodePositions.keys(),
...selectors
.layout(store.getState().analyzer.analyzerById[id])
.processNodePositions.keys(),
];
node = resolverNodes[resolverNodes.length - 1];
if (!process) {
@ -288,14 +292,7 @@ describe('useCamera on an unpainted element', () => {
if (!nodeID) {
throw new Error('could not find nodeID for process');
}
const cameraAction: ResolverAction = {
type: 'userSelectedResolverNode',
payload: {
time: simulator.controls.time,
nodeID,
},
};
store.dispatch(cameraAction);
store.dispatch(userSelectedResolverNode({ id, time: simulator.controls.time, nodeID }));
});
it('should request animation frames in a loop', () => {

View file

@ -7,13 +7,20 @@
import type React from 'react';
import { useCallback, useState, useEffect, useRef, useLayoutEffect, useContext } from 'react';
import { useSelector } from 'react-redux';
import { useSelector, useDispatch } from 'react-redux';
import { SideEffectContext } from './side_effect_context';
import type { Matrix3 } from '../types';
import { useResolverDispatch } from './use_resolver_dispatch';
import * as selectors from '../store/selectors';
import {
userStartedPanning,
userMovedPointer,
userStoppedPanning,
userZoomed,
userSetRasterSize,
} from '../store/camera/action';
import type { State } from '../../common/store/types';
export function useCamera(): {
export function useCamera({ id }: { id: string }): {
/**
* A function to pass to a React element's `ref` property. Used to attach
* native event listeners and to measure the DOM node.
@ -26,7 +33,7 @@ export function useCamera(): {
*/
projectionMatrix: Matrix3;
} {
const dispatch = useResolverDispatch();
const dispatch = useDispatch();
const sideEffectors = useContext(SideEffectContext);
const [ref, setRef] = useState<null | HTMLDivElement>(null);
@ -36,7 +43,15 @@ export function useCamera(): {
* to determine where it belongs on the screen.
* The projection matrix changes over time if the camera is currently animating.
*/
const projectionMatrixAtTime = useSelector(selectors.projectionMatrix);
const projectionMatrixAtTime = useSelector(
useCallback(
(state: State) => {
return selectors.projectionMatrix(state.analyzer.analyzerById[id]);
},
[id]
)
);
/**
* Use a ref to refer to the `projectionMatrixAtTime` function. The rAF loop
@ -57,8 +72,12 @@ export function useCamera(): {
projectionMatrixAtTime(sideEffectors.timestamp())
);
const userIsPanning = useSelector(selectors.userIsPanning);
const isAnimatingAtTime = useSelector(selectors.isAnimating);
const userIsPanning = useSelector((state: State) =>
selectors.userIsPanning(state.analyzer.analyzerById[id])
);
const isAnimatingAtTime = useSelector((state: State) =>
selectors.isAnimating(state.analyzer.analyzerById[id])
);
const [elementBoundingClientRect, clientRectCallback] = useAutoUpdatingClientRect();
@ -82,41 +101,39 @@ export function useCamera(): {
(event: React.MouseEvent<HTMLDivElement>) => {
const maybeCoordinates = relativeCoordinatesFromMouseEvent(event);
if (maybeCoordinates !== null) {
dispatch({
type: 'userStartedPanning',
payload: { screenCoordinates: maybeCoordinates, time: sideEffectors.timestamp() },
});
dispatch(
userStartedPanning({
id,
screenCoordinates: maybeCoordinates,
time: sideEffectors.timestamp(),
})
);
}
},
[dispatch, relativeCoordinatesFromMouseEvent, sideEffectors]
[dispatch, relativeCoordinatesFromMouseEvent, sideEffectors, id]
);
const handleMouseMove = useCallback(
(event: MouseEvent) => {
const maybeCoordinates = relativeCoordinatesFromMouseEvent(event);
if (maybeCoordinates) {
dispatch({
type: 'userMovedPointer',
payload: {
dispatch(
userMovedPointer({
id,
screenCoordinates: maybeCoordinates,
time: sideEffectors.timestamp(),
},
});
})
);
}
},
[dispatch, relativeCoordinatesFromMouseEvent, sideEffectors]
[dispatch, relativeCoordinatesFromMouseEvent, sideEffectors, id]
);
const handleMouseUp = useCallback(() => {
if (userIsPanning) {
dispatch({
type: 'userStoppedPanning',
payload: {
time: sideEffectors.timestamp(),
},
});
dispatch(userStoppedPanning({ id, time: sideEffectors.timestamp() }));
}
}, [dispatch, sideEffectors, userIsPanning]);
}, [dispatch, sideEffectors, userIsPanning, id]);
const handleWheel = useCallback(
(event: WheelEvent) => {
@ -127,20 +144,21 @@ export function useCamera(): {
event.deltaMode === 0
) {
event.preventDefault();
dispatch({
type: 'userZoomed',
payload: {
dispatch(
userZoomed({
id,
/**
*
* we use elementBoundingClientRect to interpret pixel deltas as a fraction of the element's height
* when pinch-zooming in on a mac, deltaY is a negative number but we want the payload to be positive
*/
zoomChange: event.deltaY / -elementBoundingClientRect.height,
time: sideEffectors.timestamp(),
},
});
})
);
}
},
[elementBoundingClientRect, dispatch, sideEffectors]
[elementBoundingClientRect, dispatch, sideEffectors, id]
);
const refCallback = useCallback(
@ -252,12 +270,14 @@ export function useCamera(): {
useEffect(() => {
if (elementBoundingClientRect !== null) {
dispatch({
type: 'userSetRasterSize',
payload: [elementBoundingClientRect.width, elementBoundingClientRect.height],
});
dispatch(
userSetRasterSize({
id,
dimensions: [elementBoundingClientRect.width, elementBoundingClientRect.height],
})
);
}
}, [dispatch, elementBoundingClientRect]);
}, [dispatch, elementBoundingClientRect, id]);
return {
ref: refCallback,

View file

@ -18,10 +18,11 @@ import { useColors } from './use_colors';
* Provides colors and HTML IDs used to render the 'cube' graphic that accompanies nodes.
*/
export function useCubeAssets(
id: string,
cubeType: NodeDataStatus,
isProcessTrigger: boolean
): NodeStyleConfig {
const SymbolIds = useSymbolIDs();
const SymbolIds = useSymbolIDs({ id });
const colorMap = useColors();
const nodeAssets: NodeStyleMap = useMemo(

View file

@ -10,7 +10,8 @@ import type { MouseEventHandler } from 'react';
import { useNavigateOrReplace } from './use_navigate_or_replace';
import * as selectors from '../store/selectors';
import type { PanelViewAndParameters, ResolverState } from '../types';
import type { PanelViewAndParameters } from '../types';
import type { State } from '../../common/store/types';
type EventHandlerCallback = MouseEventHandler<HTMLButtonElement | HTMLAnchorElement>;
@ -20,12 +21,15 @@ type EventHandlerCallback = MouseEventHandler<HTMLButtonElement | HTMLAnchorElem
* the `href` points to `panelViewAndParameters`.
* Existing `search` parameters are maintained.
*/
export function useLinkProps(panelViewAndParameters: PanelViewAndParameters): {
export function useLinkProps(
id: string,
panelViewAndParameters: PanelViewAndParameters
): {
href: string;
onClick: EventHandlerCallback;
} {
const search = useSelector((state: ResolverState) =>
selectors.relativeHref(state)(panelViewAndParameters)
const search = useSelector((state: State) =>
selectors.relativeHref(state.analyzer.analyzerById[id])(panelViewAndParameters)
);
return useNavigateOrReplace({

View file

@ -7,16 +7,12 @@
import { useMemo } from 'react';
import { useSelector } from 'react-redux';
import * as selectors from '../store/selectors';
/**
* Access the HTML IDs for this Resolver's reusable SVG 'paint servers'.
* In the future these IDs may come from an outside provider (and may be shared by multiple Resolver instances.)
*/
export function usePaintServerIDs() {
const resolverComponentInstanceID = useSelector(selectors.resolverComponentInstanceID);
export function usePaintServerIDs({ id }: { id: string }) {
const resolverComponentInstanceID = id;
return useMemo(() => {
const prefix = `${resolverComponentInstanceID}-symbols`;
return {

View file

@ -1,14 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useDispatch } from 'react-redux';
import type { ResolverAction } from '../store/actions';
/**
* Call `useDispatch`, but only accept `ResolverAction` actions.
*/
export const useResolverDispatch: () => (action: ResolverAction) => unknown = useDispatch;

View file

@ -7,14 +7,12 @@
import { useRef, useEffect } from 'react';
import { useLocation, useHistory } from 'react-router-dom';
import { useSelector } from 'react-redux';
import * as selectors from '../store/selectors';
import { parameterName } from '../store/parameter_name';
/**
* Cleanup any query string keys that were added by this Resolver instance.
* This works by having a React effect that just has behavior in the 'cleanup' function.
*/
export function useResolverQueryParamCleaner() {
export function useResolverQueryParamCleaner(id: string) {
/**
* Keep a reference to the current search value. This is used in the cleanup function.
* This value of useLocation().search isn't used directly since that would change and
@ -25,9 +23,8 @@ export function useResolverQueryParamCleaner() {
searchRef.current = useLocation().search;
const history = useHistory();
const resolverComponentInstanceID = useSelector(selectors.resolverComponentInstanceID);
const resolverKey = parameterName(resolverComponentInstanceID);
const resolverKey = parameterName(id);
useEffect(() => {
/**

View file

@ -7,8 +7,8 @@
import { useLayoutEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { useResolverDispatch } from './use_resolver_dispatch';
import { useDispatch } from 'react-redux';
import { appReceivedNewExternalProperties } from '../store/actions';
/**
* This is a hook that is meant to be used once at the top level of Resolver.
* It dispatches actions that keep the store in sync with external properties.
@ -29,20 +29,20 @@ export function useStateSyncingActions({
shouldUpdate: boolean;
filters: object;
}) {
const dispatch = useResolverDispatch();
const dispatch = useDispatch();
const locationSearch = useLocation().search;
useLayoutEffect(() => {
dispatch({
type: 'appReceivedNewExternalProperties',
payload: {
dispatch(
appReceivedNewExternalProperties({
id: resolverComponentInstanceID,
databaseDocumentID,
resolverComponentInstanceID,
locationSearch,
indices,
shouldUpdate,
filters,
},
});
})
);
}, [
dispatch,
databaseDocumentID,

View file

@ -7,18 +7,13 @@
import { useMemo } from 'react';
import { useSelector } from 'react-redux';
import * as selectors from '../store/selectors';
/**
* Access the HTML IDs for this Resolver's reusable SVG symbols.
* In the future these IDs may come from an outside provider (and may be shared by multiple Resolver instances.)
*/
export function useSymbolIDs() {
const resolverComponentInstanceID = useSelector(selectors.resolverComponentInstanceID);
export function useSymbolIDs({ id }: { id: string }) {
return useMemo(() => {
const prefix = `${resolverComponentInstanceID}-symbols`;
const prefix = `${id}-symbols`;
return {
processNodeLabel: `${prefix}-nodeSymbol`,
runningProcessCube: `${prefix}-runningCube`,
@ -29,5 +24,5 @@ export function useSymbolIDs() {
loadingCube: `${prefix}-loadingCube`,
errorCube: `${prefix}-errorCube`,
};
}, [resolverComponentInstanceID]);
}, [id]);
}

View file

@ -10,8 +10,9 @@ import { useSelector, useDispatch } from 'react-redux';
import { useLocation } from 'react-router-dom';
import * as selectors from '../store/selectors';
import { SideEffectContext } from './side_effect_context';
import type { ResolverAction } from '../store/actions';
import { panelViewAndParameters } from '../store/panel_view_and_parameters';
import { userSelectedResolverNode } from '../store/actions';
import type { State } from '../../common/store/types';
/**
* This custom hook, will maintain the state of the active/selected node with the what the selected nodeID is in url state.
@ -19,13 +20,17 @@ import { panelViewAndParameters } from '../store/panel_view_and_parameters';
* In the scenario where the nodeList is visible in the panel, there is no selectedNode, but this would naturally default to the origin node based on `serverReturnedResolverData` on initial load and refresh
* This custom hook should only be called once on resolver load, following that the url nodeID should always equal the selectedNode. This is currently called in `resolver_without_providers.tsx`.
*/
export function useSyncSelectedNode() {
const dispatch: (action: ResolverAction) => void = useDispatch();
const resolverComponentInstanceID = useSelector(selectors.resolverComponentInstanceID);
export function useSyncSelectedNode({ id }: { id: string }) {
const dispatch = useDispatch();
const resolverComponentInstanceID = id;
const locationSearch = useLocation().search;
const sideEffectors = useContext(SideEffectContext);
const selectedNode = useSelector(selectors.selectedNode);
const idToNodeMap = useSelector(selectors.graphNodeForID);
const selectedNode = useSelector((state: State) =>
selectors.selectedNode(state.analyzer.analyzerById[id])
);
const idToNodeMap = useSelector((state: State) =>
selectors.graphNodeForID(state.analyzer.analyzerById[id])
);
const currentPanelParameters = panelViewAndParameters({
locationSearch,
@ -41,13 +46,13 @@ export function useSyncSelectedNode() {
useEffect(() => {
// use this for the entire render in order to keep things in sync
if (urlNodeID && idToNodeMap(urlNodeID) && urlNodeID !== selectedNode) {
dispatch({
type: 'userSelectedResolverNode',
payload: {
dispatch(
userSelectedResolverNode({
id,
nodeID: urlNodeID,
time: sideEffectors.timestamp(),
},
});
})
);
}
}, [
currentPanelParameters.panelView,
@ -56,5 +61,6 @@ export function useSyncSelectedNode() {
idToNodeMap,
selectedNode,
sideEffectors,
id,
]);
}

View file

@ -131,6 +131,9 @@ const GraphOverlayComponent: React.FC<GraphOverlayProps> = ({
const { from, to, shouldUpdate, selectedPatterns } = useTimelineDataFilters(
isActiveTimeline(scopeId)
);
const filters = useMemo(() => {
return { from, to };
}, [from, to]);
const sessionContainerRef = useRef<HTMLDivElement | null>(null);
@ -142,6 +145,24 @@ const GraphOverlayComponent: React.FC<GraphOverlayProps> = ({
}
}, [fullScreen]);
const resolver = useMemo(
() =>
graphEventId !== undefined ? (
<StyledResolver
databaseDocumentID={graphEventId}
resolverComponentInstanceID={scopeId}
indices={selectedPatterns}
shouldUpdate={shouldUpdate}
filters={filters}
/>
) : (
<EuiFlexGroup alignItems="center" justifyContent="center" style={{ height: '100%' }}>
<EuiLoadingSpinner size="xl" />
</EuiFlexGroup>
),
[graphEventId, scopeId, selectedPatterns, shouldUpdate, filters]
);
if (!isActiveTimeline(scopeId) && sessionViewConfig !== null) {
return (
<OverlayContainer data-test-subj="overlayContainer" ref={sessionContainerRef}>
@ -164,19 +185,7 @@ const GraphOverlayComponent: React.FC<GraphOverlayProps> = ({
<EuiFlexItem grow={false}>{Navigation}</EuiFlexItem>
</EuiFlexGroup>
<EuiHorizontalRule margin="none" />
{graphEventId !== undefined ? (
<StyledResolver
databaseDocumentID={graphEventId}
resolverComponentInstanceID={scopeId}
indices={selectedPatterns}
shouldUpdate={shouldUpdate}
filters={{ from, to }}
/>
) : (
<EuiFlexGroup alignItems="center" justifyContent="center" style={{ height: '100%' }}>
<EuiLoadingSpinner size="xl" />
</EuiFlexGroup>
)}
{resolver}
</FullScreenOverlayContainer>
);
} else {
@ -187,19 +196,7 @@ const GraphOverlayComponent: React.FC<GraphOverlayProps> = ({
<EuiFlexItem grow={false}>{Navigation}</EuiFlexItem>
</EuiFlexGroup>
<EuiHorizontalRule margin="none" />
{graphEventId !== undefined ? (
<StyledResolver
databaseDocumentID={graphEventId}
resolverComponentInstanceID={scopeId}
indices={selectedPatterns}
shouldUpdate={shouldUpdate}
filters={{ from, to }}
/>
) : (
<EuiFlexGroup alignItems="center" justifyContent="center" style={{ height: '100%' }}>
<EuiLoadingSpinner size="xl" />
</EuiFlexGroup>
)}
{resolver}
</OverlayContainer>
);
}