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

View file

@ -13,6 +13,7 @@ import { useSourcererDataView } from '../containers/sourcerer';
import { useDeepEqualSelector } from '../hooks/use_selector'; import { useDeepEqualSelector } from '../hooks/use_selector';
import { renderHook } from '@testing-library/react-hooks'; import { renderHook } from '@testing-library/react-hooks';
import { initialGroupingState } from './grouping/reducer'; import { initialGroupingState } from './grouping/reducer';
import { initialAnalyzerState } from '../../resolver/store/helpers';
jest.mock('../hooks/use_selector'); jest.mock('../hooks/use_selector');
jest.mock('../lib/kibana', () => ({ jest.mock('../lib/kibana', () => ({
@ -47,6 +48,9 @@ describe('createInitialState', () => {
}, },
{ {
groups: initialGroupingState, groups: initialGroupingState,
},
{
analyzer: initialAnalyzerState,
} }
); );
beforeEach(() => { beforeEach(() => {
@ -84,6 +88,9 @@ describe('createInitialState', () => {
}, },
{ {
groups: initialGroupingState, groups: initialGroupingState,
},
{
analyzer: initialAnalyzerState,
} }
); );
(useDeepEqualSelector as jest.Mock).mockImplementation((cb) => cb(state)); (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 type { DataTableState } from '@kbn/securitysolution-data-table';
import { dataTableReducer } from '@kbn/securitysolution-data-table'; import { dataTableReducer } from '@kbn/securitysolution-data-table';
import { enableMapSet } from 'immer';
import { appReducer, initialAppState } from './app'; import { appReducer, initialAppState } from './app';
import { dragAndDropReducer, initialDragAndDropState } from './drag_and_drop'; import { dragAndDropReducer, initialDragAndDropState } from './drag_and_drop';
import { createInitialInputsState, inputsReducer } from './inputs'; import { createInitialInputsState, inputsReducer } from './inputs';
@ -31,6 +32,10 @@ import { getScopePatternListSelection } from './sourcerer/helpers';
import { globalUrlParamReducer, initialGlobalUrlParam } from './global_url_param'; import { globalUrlParamReducer, initialGlobalUrlParam } from './global_url_param';
import { groupsReducer } from './grouping/reducer'; import { groupsReducer } from './grouping/reducer';
import type { GroupState } from './grouping/types'; import type { GroupState } from './grouping/types';
import { analyzerReducer } from '../../resolver/store/reducer';
import type { AnalyzerOuterState } from '../../resolver/types';
enableMapSet();
export type SubPluginsInitReducer = HostsPluginReducer & export type SubPluginsInitReducer = HostsPluginReducer &
UsersPluginReducer & UsersPluginReducer &
@ -57,7 +62,8 @@ export const createInitialState = (
enableExperimental: ExperimentalFeatures; enableExperimental: ExperimentalFeatures;
}, },
dataTableState: DataTableState, dataTableState: DataTableState,
groupsState: GroupState groupsState: GroupState,
analyzerState: AnalyzerOuterState
): State => { ): State => {
const initialPatterns = { const initialPatterns = {
[SourcererScopeName.default]: getScopePatternListSelection( [SourcererScopeName.default]: getScopePatternListSelection(
@ -112,6 +118,7 @@ export const createInitialState = (
globalUrlParam: initialGlobalUrlParam, globalUrlParam: initialGlobalUrlParam,
dataTable: dataTableState.dataTable, dataTable: dataTableState.dataTable,
groups: groupsState.groups, groups: groupsState.groups,
analyzer: analyzerState.analyzer,
}; };
return preloadedState; return preloadedState;
@ -131,5 +138,6 @@ export const createReducer: (
globalUrlParam: globalUrlParamReducer, globalUrlParam: globalUrlParamReducer,
dataTable: dataTableReducer, dataTable: dataTableReducer,
groups: groupsReducer, groups: groupsReducer,
analyzer: analyzerReducer,
...pluginsReducer, ...pluginsReducer,
}); });

View file

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

View file

@ -6,7 +6,7 @@
*/ */
import type { FC } from 'react'; import type { FC } from 'react';
import React from 'react'; import React, { useMemo } from 'react';
import { EuiEmptyPrompt } from '@elastic/eui'; import { EuiEmptyPrompt } from '@elastic/eui';
import { ANALYZER_ERROR_MESSAGE } from './translations'; import { ANALYZER_ERROR_MESSAGE } from './translations';
@ -24,10 +24,11 @@ export const ANALYZE_GRAPH_ID = 'analyze_graph';
*/ */
export const AnalyzeGraph: FC = () => { export const AnalyzeGraph: FC = () => {
const { eventId } = useLeftPanelContext(); 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( const { from, to, shouldUpdate, selectedPatterns } = useTimelineDataFilters(
isActiveTimeline(scopeId) isActiveTimeline(scopeId)
); );
const filters = useMemo(() => ({ from, to }), [from, to]);
if (!eventId) { if (!eventId) {
return ( return (
@ -48,7 +49,7 @@ export const AnalyzeGraph: FC = () => {
resolverComponentInstanceID={scopeId} resolverComponentInstanceID={scopeId}
indices={selectedPatterns} indices={selectedPatterns}
shouldUpdate={shouldUpdate} shouldUpdate={shouldUpdate}
filters={{ from, to }} filters={filters}
/> />
</div> </div>
); );

View file

@ -5,8 +5,7 @@
* 2.0. * 2.0.
*/ */
import type { KibanaReactContextValue } from '@kbn/kibana-react-plugin/public'; import type { CoreStart } from '@kbn/core/public';
import type { StartServices } from '../../types';
import type { DataAccessLayer, TimeRange } from '../types'; import type { DataAccessLayer, TimeRange } from '../types';
import type { import type {
ResolverNode, 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. * 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( export function dataAccessLayerFactory(context: CoreStart): DataAccessLayer {
context: KibanaReactContextValue<StartServices>
): DataAccessLayer {
const dataAccessLayer: DataAccessLayer = { const dataAccessLayer: DataAccessLayer = {
/** /**
* Used to get non-process related events for a node. * Used to get non-process related events for a node.
@ -48,7 +45,7 @@ export function dataAccessLayerFactory(
timeRange?: TimeRange; timeRange?: TimeRange;
indexPatterns: string[]; indexPatterns: string[];
}): Promise<ResolverRelatedEvents> { }): Promise<ResolverRelatedEvents> {
const response: ResolverPaginatedEvents = await context.services.http.post( const response: ResolverPaginatedEvents = await context.http.post(
'/api/endpoint/resolver/events', '/api/endpoint/resolver/events',
{ {
query: {}, query: {},
@ -95,7 +92,7 @@ export function dataAccessLayerFactory(
}, },
}; };
if (category === 'alert') { if (category === 'alert') {
return context.services.http.post('/api/endpoint/resolver/events', { return context.http.post('/api/endpoint/resolver/events', {
query: commonFields.query, query: commonFields.query,
body: JSON.stringify({ body: JSON.stringify({
...commonFields.body, ...commonFields.body,
@ -104,7 +101,7 @@ export function dataAccessLayerFactory(
}), }),
}); });
} else { } else {
return context.services.http.post('/api/endpoint/resolver/events', { return context.http.post('/api/endpoint/resolver/events', {
query: commonFields.query, query: commonFields.query,
body: JSON.stringify({ body: JSON.stringify({
...commonFields.body, ...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', '/api/endpoint/resolver/events',
query query
); );
@ -197,7 +194,7 @@ export function dataAccessLayerFactory(
}, },
}; };
if (eventCategory.includes('alert') === false) { if (eventCategory.includes('alert') === false) {
const response: ResolverPaginatedEvents = await context.services.http.post( const response: ResolverPaginatedEvents = await context.http.post(
'/api/endpoint/resolver/events', '/api/endpoint/resolver/events',
{ {
query: { limit: 1 }, query: { limit: 1 },
@ -211,7 +208,7 @@ export function dataAccessLayerFactory(
const [oneEvent] = response.events; const [oneEvent] = response.events;
return oneEvent ?? null; return oneEvent ?? null;
} else { } else {
const response: ResolverPaginatedEvents = await context.services.http.post( const response: ResolverPaginatedEvents = await context.http.post(
'/api/endpoint/resolver/events', '/api/endpoint/resolver/events',
{ {
query: { limit: 1 }, query: { limit: 1 },
@ -252,7 +249,7 @@ export function dataAccessLayerFactory(
ancestors: number; ancestors: number;
descendants: number; descendants: number;
}): Promise<ResolverNode[]> { }): Promise<ResolverNode[]> {
return context.services.http.post('/api/endpoint/resolver/tree', { return context.http.post('/api/endpoint/resolver/tree', {
body: JSON.stringify({ body: JSON.stringify({
ancestors, ancestors,
descendants, descendants,
@ -276,7 +273,7 @@ export function dataAccessLayerFactory(
indices: string[]; indices: string[];
signal: AbortSignal; signal: AbortSignal;
}): Promise<ResolverEntityIndex> { }): Promise<ResolverEntityIndex> {
return context.services.http.get('/api/endpoint/resolver/entity', { return context.http.get('/api/endpoint/resolver/entity', {
signal, signal,
query: { query: {
_id, _id,

View file

@ -5,17 +5,25 @@
* 2.0. * 2.0.
*/ */
import type { CameraAction } from './camera'; import actionCreatorFactory from 'typescript-fsa';
import type { DataAction } from './data/action';
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 * The action dispatched when the app requests related event data for one
* subject (whose entity_id should be included as `payload`) * subject (whose entity_id should be included as `payload`)
*/ */
interface UserRequestedRelatedEventData { export const userRequestedRelatedEventData = actionCreator<{
readonly type: 'userRequestedRelatedEventData'; /**
readonly payload: string; * 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. * When the user switches the "active descendant" of the Resolver.
@ -24,20 +32,20 @@ interface UserRequestedRelatedEventData {
* the element that is focused on by the user's interactions with the UI, but * the element that is focused on by the user's interactions with the UI, but
* not necessarily "selected" (see UserSelectedResolverNode below) * not necessarily "selected" (see UserSelectedResolverNode below)
*/ */
interface UserFocusedOnResolverNode { export const userFocusedOnResolverNode = actionCreator<{
readonly type: 'userFocusedOnResolverNode'; /**
* Id that identify the scope of analyzer
readonly payload: { */
/** readonly id: string;
* Used to identify the node that should be brought into view. /**
*/ * Used to identify the node that should be brought into view.
readonly nodeID: string; */
/** readonly nodeID: string;
* The time (since epoch in milliseconds) when the action was dispatched. /**
*/ * The time (since epoch in milliseconds) when the action was dispatched.
readonly time: number; */
}; readonly time: number;
} }>('FOCUS_ON_NODE');
/** /**
* When the user "selects" a node in the Resolver * When the user "selects" a node in the Resolver
@ -45,62 +53,53 @@ interface UserFocusedOnResolverNode {
* user most recently "picked" (by e.g. pressing a button corresponding * 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). * to the element in a list) as opposed to "active" or "current" (see UserFocusedOnResolverNode above).
*/ */
interface UserSelectedResolverNode { export const userSelectedResolverNode = actionCreator<{
readonly type: 'userSelectedResolverNode'; /**
readonly payload: { * Id that identify the scope of analyzer
/** */
* Used to identify the node that should be brought into view. readonly id: string;
*/ /**
readonly nodeID: string; * Used to identify the node that should be brought into view.
/** */
* The time (since epoch in milliseconds) when the action was dispatched. readonly nodeID: string;
*/ /**
readonly time: number; * The time (since epoch in milliseconds) when the action was dispatched.
}; */
} readonly time: number;
}>('SELECT_RESOLVER_NODE');
/** /**
* Used by `useStateSyncingActions` hook. * Used by `useStateSyncingActions` hook.
* This is dispatched when external sources provide new parameters for Resolver. * This is dispatched when external sources provide new parameters for Resolver.
* When the component receives a new 'databaseDocumentID' prop, this is fired. * When the component receives a new 'databaseDocumentID' prop, this is fired.
*/ */
interface AppReceivedNewExternalProperties { export const appReceivedNewExternalProperties = actionCreator<{
type: 'appReceivedNewExternalProperties';
/** /**
* 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. * 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. * 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. * 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 that the backend will use to find the document.
*/ */
indices: string[]; readonly indices: string[];
shouldUpdate: boolean; readonly shouldUpdate: boolean;
filters: { readonly filters: {
from?: string; from?: string;
to?: string; to?: string;
};
}; };
} }>('APP_RECEIVED_NEW_EXTERNAL_PROPERTIES');
export type ResolverAction =
| CameraAction
| DataAction
| AppReceivedNewExternalProperties
| UserFocusedOnResolverNode
| UserSelectedResolverNode
| UserRequestedRelatedEventData;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,197 +5,203 @@
* 2.0. * 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 { unitsPerNudge, nudgeAnimationDuration } from './scaling_constants';
import { animatePanning } from './methods'; import { animatePanning } from './methods';
import * as vector2 from '../../models/vector2'; import * as vector2 from '../../models/vector2';
import * as selectors from './selectors'; import * as selectors from './selectors';
import { clamp } from '../../lib/math'; import { clamp } from '../../lib/math';
import type { CameraState, Vector2 } from '../../types'; import type { CameraState, Vector2 } from '../../types';
import { scaleToZoom } from './scale_to_zoom'; import { initialAnalyzerState, immerCase } from '../helpers';
import type { ResolverAction } from '../actions'; import {
userSetZoomLevel,
/** userClickedZoomOut,
* Used in tests. userClickedZoomIn,
*/ userZoomed,
export function cameraInitialState(): CameraState { userStartedPanning,
const state: CameraState = { userStoppedPanning,
scalingFactor: scaleToZoom(1), // Defaulted to 1 to 1 scale userSetPositionOfCamera,
rasterSize: [0, 0] as const, userNudgedCamera,
translationNotCountingCurrentPanning: [0, 0] as const, userSetRasterSize,
latestFocusedWorldCoordinates: null, userMovedPointer,
animation: undefined, } from './action';
panning: undefined,
};
return state;
}
export const cameraReducer: Reducer<CameraState, ResolverAction> = (
state = cameraInitialState(),
action
) => {
if (action.type === 'userSetZoomLevel') {
/**
* 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 = {
...state,
scalingFactor: clamp(action.payload, 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
* panning when we zoom.
*
* You can see this in action by moving your pointer over a node that isn't directly in the center of the map and then changing the zoom level. Do it by
* 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)
) {
const rasterOfLastFocusedWorldCoordinates = vector2.applyMatrix3(
state.latestFocusedWorldCoordinates,
selectors.projectionMatrix(state)(action.payload.time)
);
const newWorldCoordinatesAtLastFocusedPosition = vector2.applyMatrix3(
rasterOfLastFocusedWorldCoordinates,
selectors.inverseProjectionMatrix(stateWithNewScaling)(action.payload.time)
);
export const cameraReducer = reducerWithInitialState(initialAnalyzerState)
.withHandling(
immerCase(userSetZoomLevel, (draft, { id, zoomLevel }) => {
/** /**
* The change in world position incurred by changing scale. * Handle the scale being explicitly set, for example by a 'reset zoom' feature, or by a range slider with exact scale values
*/ */
const delta = vector2.subtract( const state: Draft<CameraState> = draft.analyzerById[id].camera;
newWorldCoordinatesAtLastFocusedPosition, state.scalingFactor = clamp(zoomLevel, 0, 1);
state.latestFocusedWorldCoordinates return draft;
); })
)
/** .withHandling(
* Adjust for the change in position due to scale. immerCase(userClickedZoomIn, (draft, { id }) => {
*/ const state: Draft<CameraState> = draft.analyzerById[id].camera;
const translationNotCountingCurrentPanning: Vector2 = vector2.subtract( state.scalingFactor = clamp(state.scalingFactor + 0.1, 0, 1);
stateWithNewScaling.translationNotCountingCurrentPanning, return draft;
delta })
); )
.withHandling(
const nextState: CameraState = { immerCase(userClickedZoomOut, (draft, { id }) => {
...stateWithNewScaling, const state: Draft<CameraState> = draft.analyzerById[id].camera;
translationNotCountingCurrentPanning, state.scalingFactor = clamp(state.scalingFactor - 0.1, 0, 1);
}; return draft;
})
return nextState; )
} else { .withHandling(
return stateWithNewScaling; immerCase(userZoomed, (draft, { id, zoomChange, time }) => {
} const state: Draft<CameraState> = draft.analyzerById[id].camera;
} else if (action.type === 'userSetPositionOfCamera') { const stateWithNewScaling: Draft<CameraState> = {
/**
* 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;
}
/**
* 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,
},
};
return nextState;
} else if (action.type === 'userStoppedPanning') {
/**
* 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;
/**
* Nudge less when zoomed in.
*/
const nudge = vector2.multiply(
vector2.divide([unitsPerNudge, unitsPerNudge], selectors.scale(state)(time)),
direction
);
return animatePanning(
state,
time,
vector2.add(state.translationNotCountingCurrentPanning, nudge),
nudgeAnimationDuration
);
} else if (action.type === 'userSetRasterSize') {
/**
* 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;
if (state.panning) {
stateWithUpdatedPanning = {
...state, ...state,
panning: { scalingFactor: clamp(state.scalingFactor + zoomChange, 0, 1),
origin: state.panning.origin,
currentOffset: action.payload.screenCoordinates,
},
}; };
}
const nextState: CameraState = {
...stateWithUpdatedPanning,
/** /**
* keep track of the last world coordinates the user moved over. * 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
* When the scale of the projection matrix changes, we adjust the camera's world transform in order * 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
* to keep the same point under the pointer. * panning when we zoom.
* In order to do this, we need to know the position of the mouse when changing the scale. *
* You can see this in action by moving your pointer over a node that isn't directly in the center of the map and then changing the zoom level. Do it by
* 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.
*/ */
latestFocusedWorldCoordinates: vector2.applyMatrix3( if (state.latestFocusedWorldCoordinates !== null && !selectors.isAnimating(state)(time)) {
action.payload.screenCoordinates, const rasterOfLastFocusedWorldCoordinates = vector2.applyMatrix3(
selectors.inverseProjectionMatrix(stateWithUpdatedPanning)(action.payload.time) state.latestFocusedWorldCoordinates,
), selectors.projectionMatrix(state)(time)
}; );
return nextState; const newWorldCoordinatesAtLastFocusedPosition = vector2.applyMatrix3(
} else { rasterOfLastFocusedWorldCoordinates,
return state; selectors.inverseProjectionMatrix(stateWithNewScaling)(time)
} );
};
/**
* The change in world position incurred by changing scale.
*/
const delta = vector2.subtract(
newWorldCoordinatesAtLastFocusedPosition,
state.latestFocusedWorldCoordinates
);
/**
* Adjust for the change in position due to scale.
*/
const translationNotCountingCurrentPanning: Vector2 = vector2.subtract(
stateWithNewScaling.translationNotCountingCurrentPanning,
delta
);
draft.analyzerById[id].camera = {
...stateWithNewScaling,
translationNotCountingCurrentPanning,
};
} else {
draft.analyzerById[id].camera = stateWithNewScaling;
}
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 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.
*/
state.animation = undefined;
state.panning = {
...state.panning,
origin: screenCoordinates,
currentOffset: screenCoordinates,
};
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 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.
*/
const nudge = vector2.multiply(
vector2.divide([unitsPerNudge, unitsPerNudge], selectors.scale(state)(time)),
direction
);
draft.analyzerById[id].camera = animatePanning(
state,
time,
vector2.add(state.translationNotCountingCurrentPanning, nudge),
nudgeAnimationDuration
);
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.
*/
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: screenCoordinates,
},
};
}
draft.analyzerById[id].camera = {
...stateWithUpdatedPanning,
/**
* keep track of the last world coordinates the user moved over.
* When the scale of the projection matrix changes, we adjust the camera's world transform in order
* to keep the same point under the pointer.
* In order to do this, we need to know the position of the mouse when changing the scale.
*/
latestFocusedWorldCoordinates: vector2.applyMatrix3(
screenCoordinates,
selectors.inverseProjectionMatrix(stateWithUpdatedPanning)(time)
),
};
return draft;
})
)
.build();

View file

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

View file

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

View file

@ -5,17 +5,18 @@
* 2.0. * 2.0.
*/ */
import type { Store } from 'redux'; import type { Store, AnyAction, Reducer } from 'redux';
import { createStore } from 'redux'; import { createStore } from 'redux';
import { RelatedEventCategory } from '../../../../common/endpoint/generate_data'; import { RelatedEventCategory } from '../../../../common/endpoint/generate_data';
import { dataReducer } from './reducer'; import { dataReducer } from './reducer';
import * as selectors from './selectors'; import * as selectors from './selectors';
import type { DataState, GeneratedTreeMetadata, TimeFilters } from '../../types'; import type { AnalyzerState, GeneratedTreeMetadata, TimeFilters } from '../../types';
import type { DataAction } from './action';
import { generateTreeWithDAL } from '../../data_access_layer/mocks/generator_tree'; import { generateTreeWithDAL } from '../../data_access_layer/mocks/generator_tree';
import { endpointSourceSchema, winlogSourceSchema } from '../../mocks/tree_schema'; import { endpointSourceSchema, winlogSourceSchema } from '../../mocks/tree_schema';
import type { NewResolverTree, ResolverSchema } from '../../../../common/endpoint/types'; import type { NewResolverTree, ResolverSchema } from '../../../../common/endpoint/types';
import { ancestorsWithAncestryField, descendantsLimit } from '../../models/resolver_tree'; import { ancestorsWithAncestryField, descendantsLimit } from '../../models/resolver_tree';
import { EMPTY_RESOLVER } from '../helpers';
import { serverReturnedResolverData } from './action';
type SourceAndSchemaFunction = () => { schema: ResolverSchema; dataSource: string }; type SourceAndSchemaFunction = () => { schema: ResolverSchema; dataSource: string };
@ -23,24 +24,33 @@ type SourceAndSchemaFunction = () => { schema: ResolverSchema; dataSource: strin
* Test the data reducer and selector. * Test the data reducer and selector.
*/ */
describe('Resolver Data Middleware', () => { describe('Resolver Data Middleware', () => {
let store: Store<DataState, DataAction>; let store: Store<AnalyzerState, AnyAction>;
let dispatchTree: ( let dispatchTree: (
tree: NewResolverTree, tree: NewResolverTree,
sourceAndSchema: SourceAndSchemaFunction, sourceAndSchema: SourceAndSchemaFunction,
detectedBounds?: TimeFilters detectedBounds?: TimeFilters
) => void; ) => void;
const id = 'test-id';
beforeEach(() => { 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 = ( dispatchTree = (
tree: NewResolverTree, tree: NewResolverTree,
sourceAndSchema: SourceAndSchemaFunction, sourceAndSchema: SourceAndSchemaFunction,
detectedBounds?: TimeFilters detectedBounds?: TimeFilters
) => { ) => {
const { schema, dataSource } = sourceAndSchema(); const { schema, dataSource } = sourceAndSchema();
const action: DataAction = { store.dispatch(
type: 'serverReturnedResolverData', serverReturnedResolverData({
payload: { id,
result: tree, result: tree,
dataSource, dataSource,
schema, schema,
@ -50,9 +60,8 @@ describe('Resolver Data Middleware', () => {
filters: {}, filters: {},
}, },
detectedBounds, detectedBounds,
}, })
}; );
store.dispatch(action);
}; };
}); });
@ -74,15 +83,15 @@ describe('Resolver Data Middleware', () => {
dispatchTree(generatedTreeMetadata.formattedTree, schema); dispatchTree(generatedTreeMetadata.formattedTree, schema);
}); });
it('should indicate that there are no more ancestors to retrieve', () => { 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', () => { 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', () => { 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', () => { 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', from: 'Sep 19, 2022 @ 20:49:13.452',
to: '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', () => { 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', from: 'Sep 19, 2022 @ 20:49:13.452',
to: '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); 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); dispatchTree(generatedTreeMetadata.formattedTree, endpointSourceSchema);
}); });
it('should indicate that there are more ancestors to retrieve', () => { 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', () => { 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', () => { 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); dispatchTree(generatedTreeMetadata.formattedTree, winlogSourceSchema);
}); });
it('should indicate that there are more ancestors to retrieve', () => { 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', () => { 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', () => { 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); dispatchTree(generatedTreeMetadata.formattedTree, endpointSourceSchema);
}); });
it('should indicate that there are no more ancestors to retrieve', () => { 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', () => { 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', () => { 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); dispatchTree(generatedTreeMetadata.formattedTree, winlogSourceSchema);
}); });
it('should indicate that there are no more ancestors to retrieve', () => { 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', () => { 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', () => { 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', () => { 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 // 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 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); expect(total).toEqual(5);
}); });
it('should have the correct related events stats for a child node', () => { 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 // 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 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({ expect(stats).toEqual({
total: 5, total: 5,
byCategory: { byCategory: {

View file

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

View file

@ -6,8 +6,8 @@
*/ */
import * as selectors from './selectors'; import * as selectors from './selectors';
import type { DataState } from '../../types'; import type { DataState, AnalyzerState } from '../../types';
import type { ResolverAction } from '../actions'; import type { Reducer, AnyAction } from 'redux';
import { dataReducer } from './reducer'; import { dataReducer } from './reducer';
import { createStore } from 'redux'; import { createStore } from 'redux';
import { import {
@ -22,6 +22,15 @@ import { mockTreeFetcherParameters } from '../../mocks/tree_fetcher_parameters';
import type { SafeResolverEvent } from '../../../../common/endpoint/types'; import type { SafeResolverEvent } from '../../../../common/endpoint/types';
import { mockEndpointEvent } from '../../mocks/endpoint_event'; import { mockEndpointEvent } from '../../mocks/endpoint_event';
import { maxDate } from '../../models/time_range'; 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({ function mockNodeDataWithAllProcessesTerminated({
originID, originID,
@ -83,17 +92,26 @@ function mockNodeDataWithAllProcessesTerminated({
} }
describe('data state', () => { describe('data state', () => {
let actions: ResolverAction[]; let actions: AnyAction[];
const id = 'test-id';
/** /**
* Get state, given an ordered collection of actions. * Get state, given an ordered collection of actions.
*/ */
const state: () => DataState = () => { 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) { for (const action of actions) {
store.dispatch(action); store.dispatch(action);
} }
return store.getState(); return store.getState().analyzerById[id].data;
}; };
/** /**
@ -136,19 +154,17 @@ describe('data state', () => {
const resolverComponentInstanceID = 'resolverComponentInstanceID'; const resolverComponentInstanceID = 'resolverComponentInstanceID';
beforeEach(() => { beforeEach(() => {
actions = [ actions = [
{ appReceivedNewExternalProperties({
type: 'appReceivedNewExternalProperties', id,
payload: { databaseDocumentID,
databaseDocumentID, resolverComponentInstanceID,
resolverComponentInstanceID,
// `locationSearch` doesn't matter for this test // `locationSearch` doesn't matter for this test
locationSearch: '', locationSearch: '',
indices: [], indices: [],
shouldUpdate: false, shouldUpdate: false,
filters: {}, filters: {},
}, }),
},
]; ];
}); });
it('should need to request the tree', () => { it('should need to request the tree', () => {
@ -169,10 +185,10 @@ describe('data state', () => {
const databaseDocumentID = 'databaseDocumentID'; const databaseDocumentID = 'databaseDocumentID';
beforeEach(() => { beforeEach(() => {
actions = [ actions = [
{ appRequestedResolverData({
type: 'appRequestedResolverData', id,
payload: { databaseDocumentID, indices: [], filters: {} }, parameters: { databaseDocumentID, indices: [], filters: {} },
}, }),
]; ];
}); });
it('should be loading', () => { it('should be loading', () => {
@ -199,23 +215,21 @@ describe('data state', () => {
const resolverComponentInstanceID = 'resolverComponentInstanceID'; const resolverComponentInstanceID = 'resolverComponentInstanceID';
beforeEach(() => { beforeEach(() => {
actions = [ actions = [
{ appReceivedNewExternalProperties({
type: 'appReceivedNewExternalProperties', id,
payload: { databaseDocumentID,
databaseDocumentID, resolverComponentInstanceID,
resolverComponentInstanceID,
// `locationSearch` doesn't matter for this test // `locationSearch` doesn't matter for this test
locationSearch: '', locationSearch: '',
indices: [], indices: [],
shouldUpdate: false, shouldUpdate: false,
filters: {}, filters: {},
}, }),
}, appRequestedResolverData({
{ id,
type: 'appRequestedResolverData', parameters: { databaseDocumentID, indices: [], filters: {} },
payload: { databaseDocumentID, indices: [], filters: {} }, }),
},
]; ];
}); });
it('should be loading', () => { it('should be loading', () => {
@ -236,10 +250,12 @@ describe('data state', () => {
}); });
describe('when the pending request fails', () => { describe('when the pending request fails', () => {
beforeEach(() => { beforeEach(() => {
actions.push({ actions.push(
type: 'serverFailedToReturnResolverData', serverFailedToReturnResolverData({
payload: { databaseDocumentID, indices: [], filters: {} }, id,
}); parameters: { databaseDocumentID, indices: [], filters: {} },
})
);
}); });
it('should not be loading', () => { it('should not be loading', () => {
expect(selectors.isTreeLoading(state())).toBe(false); expect(selectors.isTreeLoading(state())).toBe(false);
@ -267,36 +283,32 @@ describe('data state', () => {
beforeEach(() => { beforeEach(() => {
actions = [ actions = [
// receive the document ID, this would cause the middleware to starts the request // receive the document ID, this would cause the middleware to starts the request
{ appReceivedNewExternalProperties({
type: 'appReceivedNewExternalProperties', id,
payload: { databaseDocumentID: firstDatabaseDocumentID,
databaseDocumentID: firstDatabaseDocumentID, resolverComponentInstanceID: resolverComponentInstanceID1,
resolverComponentInstanceID: resolverComponentInstanceID1, // `locationSearch` doesn't matter for this test
// `locationSearch` doesn't matter for this test locationSearch: '',
locationSearch: '', indices: [],
indices: [], shouldUpdate: false,
shouldUpdate: false, filters: {},
filters: {}, }),
},
},
// this happens when the middleware starts the request // this happens when the middleware starts the request
{ appRequestedResolverData({
type: 'appRequestedResolverData', id,
payload: { databaseDocumentID: firstDatabaseDocumentID, indices: [], filters: {} }, parameters: { databaseDocumentID: firstDatabaseDocumentID, indices: [], filters: {} },
}, }),
// receive a different databaseDocumentID. this should cause the middleware to abort the existing request and start a new one // receive a different databaseDocumentID. this should cause the middleware to abort the existing request and start a new one
{ appReceivedNewExternalProperties({
type: 'appReceivedNewExternalProperties', id,
payload: { databaseDocumentID: secondDatabaseDocumentID,
databaseDocumentID: secondDatabaseDocumentID, resolverComponentInstanceID: resolverComponentInstanceID2,
resolverComponentInstanceID: resolverComponentInstanceID2, // `locationSearch` doesn't matter for this test
// `locationSearch` doesn't matter for this test locationSearch: '',
locationSearch: '', indices: [],
indices: [], shouldUpdate: false,
shouldUpdate: false, filters: {},
filters: {}, }),
},
},
]; ];
}); });
it('should be loading', () => { it('should be loading', () => {
@ -327,10 +339,12 @@ describe('data state', () => {
}); });
describe('and when the old request was aborted', () => { describe('and when the old request was aborted', () => {
beforeEach(() => { beforeEach(() => {
actions.push({ actions.push(
type: 'appAbortedResolverDataRequest', appAbortedResolverDataRequest({
payload: { databaseDocumentID: firstDatabaseDocumentID, indices: [], filters: {} }, id,
}); parameters: { databaseDocumentID: firstDatabaseDocumentID, indices: [], filters: {} },
})
);
}); });
it('should not require a pending request to be aborted', () => { it('should not require a pending request to be aborted', () => {
expect(selectors.treeRequestParametersToAbort(state())).toBe(null); expect(selectors.treeRequestParametersToAbort(state())).toBe(null);
@ -355,10 +369,16 @@ describe('data state', () => {
}); });
describe('and when the next request starts', () => { describe('and when the next request starts', () => {
beforeEach(() => { beforeEach(() => {
actions.push({ actions.push(
type: 'appRequestedResolverData', appRequestedResolverData({
payload: { databaseDocumentID: secondDatabaseDocumentID, indices: [], filters: {} }, id,
}); parameters: {
databaseDocumentID: secondDatabaseDocumentID,
indices: [],
filters: {},
},
})
);
}); });
it('should not have a document ID to fetch', () => { it('should not have a document ID to fetch', () => {
expect(selectors.treeParametersToFetch(state())).toBe(null); expect(selectors.treeParametersToFetch(state())).toBe(null);
@ -394,30 +414,26 @@ describe('data state', () => {
describe('when resolver receives external properties without time range filters', () => { describe('when resolver receives external properties without time range filters', () => {
beforeEach(() => { beforeEach(() => {
actions = [ actions = [
{ appReceivedNewExternalProperties({
type: 'appReceivedNewExternalProperties', id,
payload: { databaseDocumentID,
databaseDocumentID, resolverComponentInstanceID,
resolverComponentInstanceID, locationSearch: '',
locationSearch: '', indices: [],
indices: [], shouldUpdate: false,
shouldUpdate: false, filters: {},
filters: {}, }),
}, appRequestedResolverData({
}, id,
{ parameters: { databaseDocumentID, indices: [], filters: {} },
type: 'appRequestedResolverData', }),
payload: { databaseDocumentID, indices: [], filters: {} }, serverReturnedResolverData({
}, id,
{ result: resolverTree,
type: 'serverReturnedResolverData', dataSource,
payload: { schema,
result: resolverTree, parameters: { databaseDocumentID, indices: [], filters: {} },
dataSource, }),
schema,
parameters: { databaseDocumentID, indices: [], filters: {} },
},
},
]; ];
}); });
it('uses the default time range filters', () => { it('uses the default time range filters', () => {
@ -432,38 +448,34 @@ describe('data state', () => {
beforeEach(() => { beforeEach(() => {
actions = [ actions = [
...actions, ...actions,
{ appReceivedNewExternalProperties({
type: 'appReceivedNewExternalProperties', id,
payload: { databaseDocumentID,
databaseDocumentID, resolverComponentInstanceID,
resolverComponentInstanceID, locationSearch: '',
locationSearch: '', indices: [],
indices: [], shouldUpdate: false,
shouldUpdate: false, filters: timeRangeFilters,
filters: timeRangeFilters, }),
}, appRequestedResolverData({
}, id,
{ parameters: {
type: 'appRequestedResolverData',
payload: {
databaseDocumentID, databaseDocumentID,
indices: [], indices: [],
filters: timeRangeFilters, filters: timeRangeFilters,
}, },
}, }),
{ serverReturnedResolverData({
type: 'serverReturnedResolverData', id,
payload: { result: resolverTree,
result: resolverTree, dataSource,
dataSource, schema,
schema, parameters: {
parameters: { databaseDocumentID,
databaseDocumentID, indices: [],
indices: [], filters: timeRangeFilters,
filters: timeRangeFilters,
},
}, },
}, }),
]; ];
}); });
it('uses the received time range filters', () => { it('uses the received time range filters', () => {
@ -480,20 +492,18 @@ describe('data state', () => {
beforeEach(() => { beforeEach(() => {
const { schema, dataSource } = endpointSourceSchema(); const { schema, dataSource } = endpointSourceSchema();
actions = [ actions = [
{ serverReturnedResolverData({
type: 'serverReturnedResolverData', id,
payload: { result: mockTreeWith2AncestorsAndNoChildren({
result: mockTreeWith2AncestorsAndNoChildren({ originID,
originID, firstAncestorID,
firstAncestorID, secondAncestorID,
secondAncestorID, }),
}), dataSource,
dataSource, schema,
schema, // this value doesn't matter
// this value doesn't matter parameters: mockTreeFetcherParameters(),
parameters: mockTreeFetcherParameters(), }),
},
},
]; ];
}); });
it('should have no flowto candidate for the origin', () => { it('should have no flowto candidate for the origin', () => {
@ -517,16 +527,14 @@ describe('data state', () => {
}); });
beforeEach(() => { beforeEach(() => {
actions = [ actions = [
{ serverReturnedNodeData({
type: 'serverReturnedNodeData', id,
payload: { nodeData,
nodeData, requestedIDs: new Set([originID, firstAncestorID, secondAncestorID]),
requestedIDs: new Set([originID, firstAncestorID, secondAncestorID]), // mock the requested size being larger than the returned number of events so we
// mock the requested size being larger than the returned number of events so we // avoid the case where the limit was reached
// avoid the case where the limit was reached numberOfRequestedEvents: nodeData.length + 1,
numberOfRequestedEvents: nodeData.length + 1, }),
},
},
]; ];
}); });
it('should have origin as terminated', () => { it('should have origin as terminated', () => {
@ -551,16 +559,14 @@ describe('data state', () => {
}); });
const { schema, dataSource } = endpointSourceSchema(); const { schema, dataSource } = endpointSourceSchema();
actions = [ actions = [
{ serverReturnedResolverData({
type: 'serverReturnedResolverData', id,
payload: { result: resolverTree,
result: resolverTree, dataSource,
dataSource, schema,
schema, // this value doesn't matter
// this value doesn't matter parameters: mockTreeFetcherParameters(),
parameters: mockTreeFetcherParameters(), }),
},
},
]; ];
}); });
it('should have no flowto candidate for the origin', () => { it('should have no flowto candidate for the origin', () => {
@ -585,16 +591,14 @@ describe('data state', () => {
}); });
const { schema, dataSource } = endpointSourceSchema(); const { schema, dataSource } = endpointSourceSchema();
actions = [ actions = [
{ serverReturnedResolverData({
type: 'serverReturnedResolverData', id,
payload: { result: resolverTree,
result: resolverTree, dataSource,
dataSource, schema,
schema, // this value doesn't matter
// this value doesn't matter parameters: mockTreeFetcherParameters(),
parameters: mockTreeFetcherParameters(), }),
},
},
]; ];
}); });
it('should be able to calculate the aria flowto candidates for all processes nodes', () => { 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(); const { schema, dataSource } = endpointSourceSchema();
actions = [ actions = [
{ serverReturnedResolverData({
type: 'serverReturnedResolverData', id,
payload: { result: tree,
result: tree, dataSource,
dataSource, schema,
schema, // this value doesn't matter
// this value doesn't matter parameters: mockTreeFetcherParameters(),
parameters: mockTreeFetcherParameters(), }),
},
},
]; ];
}); });
it('should have 4 graphable processes', () => { it('should have 4 graphable processes', () => {
@ -642,16 +644,14 @@ describe('data state', () => {
const { schema, dataSource } = endpointSourceSchema(); const { schema, dataSource } = endpointSourceSchema();
const tree = mockTreeWithNoProcessEvents(); const tree = mockTreeWithNoProcessEvents();
actions = [ actions = [
{ serverReturnedResolverData({
type: 'serverReturnedResolverData', id,
payload: { result: tree,
result: tree, dataSource,
dataSource, schema,
schema, // this value doesn't matter
// this value doesn't matter parameters: mockTreeFetcherParameters(),
parameters: mockTreeFetcherParameters(), }),
},
},
]; ];
}); });
it('should return an empty layout', () => { it('should return an empty layout', () => {
@ -673,19 +673,17 @@ describe('data state', () => {
}); });
const { schema, dataSource } = endpointSourceSchema(); const { schema, dataSource } = endpointSourceSchema();
actions = [ actions = [
{ serverReturnedResolverData({
type: 'serverReturnedResolverData', id,
payload: { result: resolverTree,
result: resolverTree, dataSource,
dataSource, schema,
schema, parameters: {
parameters: { databaseDocumentID: '',
databaseDocumentID: '', indices: ['someNonDefaultIndex'],
indices: ['someNonDefaultIndex'], filters: {},
filters: {},
},
}, },
}, }),
]; ];
}); });
it('should have an empty array for tree parameter indices, and a non empty array for event indices', () => { it('should have an empty array for tree parameter indices, and a non empty array for event indices', () => {
@ -704,38 +702,34 @@ describe('data state', () => {
}); });
const { schema, dataSource } = endpointSourceSchema(); const { schema, dataSource } = endpointSourceSchema();
actions = [ actions = [
{ serverReturnedResolverData({
type: 'serverReturnedResolverData', id,
payload: { result: resolverTree,
result: resolverTree, dataSource,
dataSource, schema,
schema, parameters: {
parameters: {
databaseDocumentID: '',
indices: ['defaultIndex'],
filters: {},
},
},
},
{
type: 'appReceivedNewExternalProperties',
payload: {
databaseDocumentID: '', databaseDocumentID: '',
resolverComponentInstanceID: '', indices: ['defaultIndex'],
locationSearch: '',
indices: ['someNonDefaultIndex', 'someOtherIndex'],
shouldUpdate: false,
filters: {}, filters: {},
}, },
}, }),
{ appReceivedNewExternalProperties({
type: 'appRequestedResolverData', id,
payload: { databaseDocumentID: '',
resolverComponentInstanceID: '',
locationSearch: '',
indices: ['someNonDefaultIndex', 'someOtherIndex'],
shouldUpdate: false,
filters: {},
}),
appRequestedResolverData({
id,
parameters: {
databaseDocumentID: '', databaseDocumentID: '',
indices: ['someNonDefaultIndex', 'someOtherIndex'], indices: ['someNonDefaultIndex', 'someOtherIndex'],
filters: {}, filters: {},
}, },
}, }),
]; ];
}); });
it('should have an empty array for tree parameter indices, and the same set of indices as the last tree response', () => { 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. * 2.0.
*/ */
import type { Store } from 'redux'; import type { Store, AnyAction, Reducer } from 'redux';
import { createStore } from 'redux'; import { createStore } from 'redux';
import type { ResolverAction } from '../actions'; import { analyzerReducer } from '../reducer';
import { resolverReducer } from '../reducer'; import type { AnalyzerState } from '../../types';
import type { ResolverState } from '../../types';
import type { ResolverNode } from '../../../../common/endpoint/types'; import type { ResolverNode } from '../../../../common/endpoint/types';
import { visibleNodesAndEdgeLines } from '../selectors'; import { visibleNodesAndEdgeLines } from '../selectors';
import { mock as mockResolverTree } from '../../models/resolver_tree'; import { mock as mockResolverTree } from '../../models/resolver_tree';
import { mockTreeFetcherParameters } from '../../mocks/tree_fetcher_parameters'; import { mockTreeFetcherParameters } from '../../mocks/tree_fetcher_parameters';
import { endpointSourceSchema } from '../../mocks/tree_schema'; import { endpointSourceSchema } from '../../mocks/tree_schema';
import { mockResolverNode } from '../../mocks/resolver_node'; import { mockResolverNode } from '../../mocks/resolver_node';
import { serverReturnedResolverData } from './action';
import { userSetRasterSize } from '../camera/action';
import { EMPTY_RESOLVER } from '../helpers';
describe('resolver visible entities', () => { describe('resolver visible entities', () => {
const id = 'test-id';
let nodeA: ResolverNode; let nodeA: ResolverNode;
let nodeB: ResolverNode; let nodeB: ResolverNode;
let nodeC: ResolverNode; let nodeC: ResolverNode;
@ -25,7 +28,7 @@ describe('resolver visible entities', () => {
let nodeE: ResolverNode; let nodeE: ResolverNode;
let nodeF: ResolverNode; let nodeF: ResolverNode;
let nodeG: ResolverNode; let nodeG: ResolverNode;
let store: Store<ResolverState, ResolverAction>; let store: Store<AnalyzerState, AnyAction>;
beforeEach(() => { beforeEach(() => {
/* /*
@ -92,31 +95,41 @@ describe('resolver visible entities', () => {
stats: { total: 0, byCategory: {} }, stats: { total: 0, byCategory: {} },
timestamp: 0, 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', () => { describe('when rendering a large tree with a small viewport', () => {
beforeEach(() => { beforeEach(() => {
const nodes: ResolverNode[] = [nodeA, nodeB, nodeC, nodeD, nodeE, nodeF, nodeG]; const nodes: ResolverNode[] = [nodeA, nodeB, nodeC, nodeD, nodeE, nodeF, nodeG];
const { schema, dataSource } = endpointSourceSchema(); const { schema, dataSource } = endpointSourceSchema();
const action: ResolverAction = { store.dispatch(
type: 'serverReturnedResolverData', serverReturnedResolverData({
payload: { id,
result: mockResolverTree({ nodes })!, result: mockResolverTree({ nodes })!,
dataSource, dataSource,
schema, schema,
parameters: mockTreeFetcherParameters(), parameters: mockTreeFetcherParameters(),
}, })
}; );
const cameraAction: ResolverAction = { type: 'userSetRasterSize', payload: [300, 200] }; store.dispatch(userSetRasterSize({ id, dimensions: [300, 200] }));
store.dispatch(action);
store.dispatch(cameraAction);
}); });
it('the visibleProcessNodePositions list should only include 2 nodes', () => { 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); expect([...processNodePositions.keys()].length).toEqual(2);
}); });
it('the visibleEdgeLineSegments list should only include one edge line', () => { 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); expect(connectingEdgeLineSegments.length).toEqual(1);
}); });
}); });
@ -124,25 +137,27 @@ describe('resolver visible entities', () => {
beforeEach(() => { beforeEach(() => {
const nodes: ResolverNode[] = [nodeA, nodeB, nodeC, nodeD, nodeE, nodeF, nodeG]; const nodes: ResolverNode[] = [nodeA, nodeB, nodeC, nodeD, nodeE, nodeF, nodeG];
const { schema, dataSource } = endpointSourceSchema(); const { schema, dataSource } = endpointSourceSchema();
const action: ResolverAction = { store.dispatch(
type: 'serverReturnedResolverData', serverReturnedResolverData({
payload: { id,
result: mockResolverTree({ nodes })!, result: mockResolverTree({ nodes })!,
dataSource, dataSource,
schema, schema,
parameters: mockTreeFetcherParameters(), parameters: mockTreeFetcherParameters(),
}, })
}; );
const cameraAction: ResolverAction = { type: 'userSetRasterSize', payload: [2000, 2000] }; store.dispatch(userSetRasterSize({ id, dimensions: [2000, 2000] }));
store.dispatch(action);
store.dispatch(cameraAction);
}); });
it('the visibleProcessNodePositions list should include all process nodes', () => { 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); expect([...processNodePositions.keys()].length).toEqual(5);
}); });
it('the visibleEdgeLineSegments list include all lines', () => { 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); 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. * 2.0.
*/ */
import type { Store } from 'redux'; import type { Store, AnyAction } from 'redux';
import { createStore, applyMiddleware } from 'redux'; import { createStore, applyMiddleware } from 'redux';
import { composeWithDevTools } from 'redux-devtools-extension/developmentOnly'; import { composeWithDevTools } from 'redux-devtools-extension/developmentOnly';
import type { ResolverState, DataAccessLayer } from '../types'; import type { AnalyzerState, DataAccessLayer } from '../types';
import { resolverReducer } from './reducer'; import { analyzerReducer } from './reducer';
import { resolverMiddlewareFactory } from './middleware'; import { resolverMiddlewareFactory } from './middleware';
import type { ResolverAction } from './actions';
export const resolverStoreFactory = ( export const resolverStoreFactory = (
dataAccessLayer: DataAccessLayer dataAccessLayer: DataAccessLayer
): Store<ResolverState, ResolverAction> => { ): Store<AnalyzerState, AnyAction> => {
const actionsDenylist: Array<ResolverAction['type']> = ['userMovedPointer']; const actionsDenylist: Array<AnyAction['type']> = ['userMovedPointer'];
const composeEnhancers = composeWithDevTools({ const composeEnhancers = composeWithDevTools({
name: 'Resolver', name: 'Resolver',
actionsBlacklist: actionsDenylist, actionsBlacklist: actionsDenylist,
}); });
const middlewareEnhancer = applyMiddleware(resolverMiddlewareFactory(dataAccessLayer)); 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 { isEqual } from 'lodash';
import type { SafeResolverEvent } from '../../../../common/endpoint/types'; 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 * 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. * If the current view is the `eventDetail` view it will request the event details from the server.
* @export * @export
* @param {DataAccessLayer} dataAccessLayer * @param {DataAccessLayer} dataAccessLayer
* @param {MiddlewareAPI<Dispatch<ResolverAction>, ResolverState>} api * @param {MiddlewareAPI<Dispatch<Action>, State>} api
* @returns {() => void} * @returns {() => void}
*/ */
export function CurrentRelatedEventFetcher( export function CurrentRelatedEventFetcher(
dataAccessLayer: DataAccessLayer, dataAccessLayer: DataAccessLayer,
api: MiddlewareAPI<Dispatch<ResolverAction>, ResolverState> api: MiddlewareAPI<Dispatch, State>
): () => void { ): (id: string) => void {
let last: PanelViewAndParameters | undefined; const last: { [id: string]: PanelViewAndParameters | undefined } = {};
return async (id: string) => {
return async () => {
const state = api.getState(); const state = api.getState();
const newParams = selectors.panelViewAndParameters(state); if (!last[id]) {
const indices = selectors.eventIndices(state); last[id] = undefined;
}
const newParams = selectors.panelViewAndParameters(state.analyzer.analyzerById[id]);
const indices = selectors.eventIndices(state.analyzer.analyzerById[id]);
const oldParams = last; const oldParams = last[id];
last = newParams; 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 the panel view params have changed and the current panel view is the `eventDetail`, then fetch the event details for that eventID.
if (!isEqual(newParams, oldParams) && newParams.panelView === 'eventDetail') { if (!isEqual(newParams, oldParams) && newParams.panelView === 'eventDetail') {
@ -45,12 +52,12 @@ export function CurrentRelatedEventFetcher(
const currentEventTimestamp = newParams.panelParameters.eventTimestamp; const currentEventTimestamp = newParams.panelParameters.eventTimestamp;
const winlogRecordID = newParams.panelParameters.winlogRecordID; const winlogRecordID = newParams.panelParameters.winlogRecordID;
api.dispatch({ api.dispatch(appRequestedCurrentRelatedEventData({ id }));
type: 'appRequestedCurrentRelatedEventData', const detectedBounds = selectors.detectedBounds(state.analyzer.analyzerById[id]);
});
const detectedBounds = selectors.detectedBounds(state);
const timeRangeFilters = const timeRangeFilters =
detectedBounds !== undefined ? undefined : selectors.timeRangeFilters(state); detectedBounds !== undefined
? undefined
: selectors.timeRangeFilters(state.analyzer.analyzerById[id]);
let result: SafeResolverEvent | null = null; let result: SafeResolverEvent | null = null;
try { try {
result = await dataAccessLayer.event({ result = await dataAccessLayer.event({
@ -63,20 +70,13 @@ export function CurrentRelatedEventFetcher(
timeRange: timeRangeFilters, timeRange: timeRangeFilters,
}); });
} catch (error) { } catch (error) {
api.dispatch({ api.dispatch(serverFailedToReturnCurrentRelatedEventData({ id }));
type: 'serverFailedToReturnCurrentRelatedEventData',
});
} }
if (result) { if (result) {
api.dispatch({ api.dispatch(serverReturnedCurrentRelatedEventData({ id, relatedEvent: result }));
type: 'serverReturnedCurrentRelatedEventData',
payload: result,
});
} else { } else {
api.dispatch({ api.dispatch(serverFailedToReturnCurrentRelatedEventData({ id }));
type: 'serverFailedToReturnCurrentRelatedEventData',
});
} }
} }
}; };

View file

@ -5,21 +5,49 @@
* 2.0. * 2.0.
*/ */
import type { Dispatch, MiddlewareAPI } from 'redux'; import type { Dispatch, MiddlewareAPI, AnyAction } from 'redux';
import type { ResolverState, DataAccessLayer } from '../../types'; import type { DataAccessLayer } from '../../types';
import { ResolverTreeFetcher } from './resolver_tree_fetcher'; import { ResolverTreeFetcher } from './resolver_tree_fetcher';
import type { State } from '../../../common/store/types';
import type { ResolverAction } from '../actions';
import { RelatedEventsFetcher } from './related_events_fetcher'; import { RelatedEventsFetcher } from './related_events_fetcher';
import { CurrentRelatedEventFetcher } from './current_related_event_fetcher'; import { CurrentRelatedEventFetcher } from './current_related_event_fetcher';
import { NodeDataFetcher } from './node_data_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 dataAccessLayer: DataAccessLayer
) => ( ) => (
api: MiddlewareAPI<Dispatch<ResolverAction>, S> api: MiddlewareAPI<Dispatch<AnyAction>, S>
) => (next: Dispatch<ResolverAction>) => (action: ResolverAction) => unknown; ) => (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. * The `redux` middleware that the application uses to trigger side effects.
* All data fetching should be done here. * All data fetching should be done here.
@ -32,13 +60,16 @@ export const resolverMiddlewareFactory: MiddlewareFactory = (dataAccessLayer: Da
const relatedEventsFetcher = RelatedEventsFetcher(dataAccessLayer, api); const relatedEventsFetcher = RelatedEventsFetcher(dataAccessLayer, api);
const currentRelatedEventFetcher = CurrentRelatedEventFetcher(dataAccessLayer, api); const currentRelatedEventFetcher = CurrentRelatedEventFetcher(dataAccessLayer, api);
const nodeDataFetcher = NodeDataFetcher(dataAccessLayer, api); const nodeDataFetcher = NodeDataFetcher(dataAccessLayer, api);
return async (action: ResolverAction) => {
return async (action: AnyAction) => {
next(action); next(action);
resolverTreeFetcher(); if (action.payload?.id && isAnalyzerActive(action) && isResolverAction(action)) {
relatedEventsFetcher(); resolverTreeFetcher(action.payload.id);
nodeDataFetcher(); relatedEventsFetcher(action.payload.id);
currentRelatedEventFetcher(); nodeDataFetcher(action.payload.id);
currentRelatedEventFetcher(action.payload.id);
}
}; };
}; };
}; };

View file

@ -7,10 +7,14 @@
import type { Dispatch, MiddlewareAPI } from 'redux'; import type { Dispatch, MiddlewareAPI } from 'redux';
import type { SafeResolverEvent } from '../../../../common/endpoint/types'; import type { SafeResolverEvent } from '../../../../common/endpoint/types';
import type { DataAccessLayer } from '../../types';
import type { ResolverState, DataAccessLayer } from '../../types'; import type { State } from '../../../common/store/types';
import * as selectors from '../selectors'; 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 * Max number of nodes to request from the server
@ -25,11 +29,10 @@ const nodeDataLimit = 5000;
*/ */
export function NodeDataFetcher( export function NodeDataFetcher(
dataAccessLayer: DataAccessLayer, dataAccessLayer: DataAccessLayer,
api: MiddlewareAPI<Dispatch<ResolverAction>, ResolverState> api: MiddlewareAPI<Dispatch, State>
): () => void { ): (id: string) => void {
return async () => { return async (id: string) => {
const state = api.getState(); const state = api.getState();
/** /**
* Using the greatest positive number here so that we will request the node data for the nodes in view * 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 * 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 * 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 newIDsToRequest: Set<string> = selectors.newIDsToRequest(state.analyzer.analyzerById[id])(
const indices = selectors.eventIndices(state); Number.POSITIVE_INFINITY
);
const indices = selectors.eventIndices(state.analyzer.analyzerById[id]);
if (newIDsToRequest.size <= 0) { if (newIDsToRequest.size <= 0) {
return; 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 * 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. * state will have the new visible nodes in it, and newIDsToRequest will be an empty set.
*/ */
api.dispatch({ api.dispatch(appRequestingNodeData({ id, requestedIDs: newIDsToRequest }));
type: 'appRequestingNodeData',
payload: {
requestedIDs: newIDsToRequest,
},
});
let results: SafeResolverEvent[] | undefined; let results: SafeResolverEvent[] | undefined;
try { try {
const detectedBounds = selectors.detectedBounds(state); const detectedBounds = selectors.detectedBounds(state.analyzer.analyzerById[id]);
const timeRangeFilters = const timeRangeFilters =
detectedBounds !== undefined ? undefined : selectors.timeRangeFilters(state); detectedBounds !== undefined
? undefined
: selectors.timeRangeFilters(state.analyzer.analyzerById[id]);
results = await dataAccessLayer.nodeData({ results = await dataAccessLayer.nodeData({
ids: Array.from(newIDsToRequest), ids: Array.from(newIDsToRequest),
timeRange: timeRangeFilters, timeRange: timeRangeFilters,
@ -73,12 +75,7 @@ export function NodeDataFetcher(
/** /**
* Dispatch an action indicating all the nodes that we failed to retrieve data for * Dispatch an action indicating all the nodes that we failed to retrieve data for
*/ */
api.dispatch({ api.dispatch(serverFailedToReturnNodeData({ id, requestedIDs: newIDsToRequest }));
type: 'serverFailedToReturnNodeData',
payload: {
requestedIDs: newIDsToRequest,
},
});
} }
if (results) { 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 * not have received events for each node so the original IDs will help with identifying nodes that we have
* no data for. * no data for.
*/ */
api.dispatch({ api.dispatch(
type: 'serverReturnedNodeData', serverReturnedNodeData({
payload: { id,
nodeData: results, nodeData: results,
requestedIDs: newIDsToRequest, requestedIDs: newIDsToRequest,
/** /**
@ -114,8 +111,8 @@ export function NodeDataFetcher(
* if that node is still in view we'll request its node data. * if that node is still in view we'll request its node data.
*/ */
numberOfRequestedEvents: nodeDataLimit, numberOfRequestedEvents: nodeDataLimit,
}, })
}); );
} }
}; };
} }

View file

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

View file

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

View file

@ -4,81 +4,97 @@
* 2.0; you may not use this file except in compliance with the Elastic License * 2.0; you may not use this file except in compliance with the Elastic License
* 2.0. * 2.0.
*/ */
import type { Reducer, AnyAction } from 'redux';
import type { Reducer } from 'redux'; import { reducerWithInitialState } from 'typescript-fsa-reducers';
import { combineReducers } from 'redux'; import reduceReducers from 'reduce-reducers';
import { immerCase, EMPTY_RESOLVER } from './helpers';
import { animatePanning } from './camera/methods'; import { animatePanning } from './camera/methods';
import { layout } from './selectors'; import { layout } from './selectors';
import { cameraReducer } from './camera/reducer'; import { cameraReducer } from './camera/reducer';
import { dataReducer } from './data/reducer'; import { dataReducer } from './data/reducer';
import type { ResolverAction } from './actions'; import type { AnalyzerState } from '../types';
import type { ResolverState, ResolverUIState } from '../types';
import { panAnimationDuration } from './camera/scaling_constants'; import { panAnimationDuration } from './camera/scaling_constants';
import { nodePosition } from '../models/indexed_process_tree/isometric_taxi_layout'; 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> = ( export const initialAnalyzerState: AnalyzerState = {
state = { analyzerById: {},
ariaActiveDescendant: null,
selectedNode: null,
},
action
) => {
if (action.type === 'serverReturnedResolverData') {
const next: ResolverUIState = {
...state,
ariaActiveDescendant: action.payload.result.originID,
selectedNode: action.payload.result.originID,
};
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 concernReducers = combineReducers({ const uiReducer = reducerWithInitialState(initialAnalyzerState)
camera: cameraReducer, .withHandling(
data: dataReducer, immerCase(createResolver, (draft, { id }) => {
ui: uiReducer, if (!draft.analyzerById[id]) {
}); draft.analyzerById[id] = EMPTY_RESOLVER;
export const resolverReducer: Reducer<ResolverState, ResolverAction> = (state, action) => { }
const nextState = concernReducers(state, action); return draft;
if (action.type === 'userSelectedResolverNode' || action.type === 'userFocusedOnResolverNode') { })
const position = nodePosition(layout(nextState), action.payload.nodeID); )
if (position) { .withHandling(
const withAnimation: ResolverState = { immerCase(clearResolver, (draft, { id }) => {
...nextState, delete draft.analyzerById[id];
camera: animatePanning( return draft;
nextState.camera, })
action.payload.time, )
.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) {
draft.analyzerById[id].camera = animatePanning(
draft.analyzerById[id].camera,
time,
position, position,
panAnimationDuration panAnimationDuration
), );
}; }
return withAnimation; return draft;
} else { })
return nextState; )
} .withHandling(
} else { immerCase(userSelectedResolverNode, (draft, { id, nodeID, time }) => {
return nextState; 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. * 2.0.
*/ */
import type { ResolverState } from '../types'; import type { AnalyzerState } from '../types';
import type { Reducer, AnyAction } from 'redux';
import { createStore } from 'redux'; import { createStore } from 'redux';
import type { ResolverAction } from './actions'; import { analyzerReducer } from './reducer';
import { resolverReducer } from './reducer';
import * as selectors from './selectors'; import * as selectors from './selectors';
import { import {
mockTreeWith2AncestorsAndNoChildren, mockTreeWith2AncestorsAndNoChildren,
@ -17,15 +17,26 @@ import {
import type { ResolverNode } from '../../../common/endpoint/types'; import type { ResolverNode } from '../../../common/endpoint/types';
import { mockTreeFetcherParameters } from '../mocks/tree_fetcher_parameters'; import { mockTreeFetcherParameters } from '../mocks/tree_fetcher_parameters';
import { endpointSourceSchema } from '../mocks/tree_schema'; 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', () => { describe('resolver selectors', () => {
const actions: ResolverAction[] = []; const actions: AnyAction[] = [];
const id = 'test-id';
/** /**
* Get state, given an ordered collection of actions. * Get state, given an ordered collection of actions.
*/ */
const state: () => ResolverState = () => { const testReducer: Reducer<AnalyzerState, AnyAction> = (
const store = createStore(resolverReducer); analyzerState = {
analyzerById: {
[id]: EMPTY_RESOLVER,
},
},
action
): AnalyzerState => analyzerReducer(analyzerState, action);
const state: () => AnalyzerState = () => {
const store = createStore(testReducer, undefined);
for (const action of actions) { for (const action of actions) {
store.dispatch(action); store.dispatch(action);
} }
@ -38,9 +49,9 @@ describe('resolver selectors', () => {
const secondAncestorID = 'a'; const secondAncestorID = 'a';
beforeEach(() => { beforeEach(() => {
const { schema, dataSource } = endpointSourceSchema(); const { schema, dataSource } = endpointSourceSchema();
actions.push({ actions.push(
type: 'serverReturnedResolverData', serverReturnedResolverData({
payload: { id,
result: mockTreeWith2AncestorsAndNoChildren({ result: mockTreeWith2AncestorsAndNoChildren({
originID, originID,
firstAncestorID, firstAncestorID,
@ -50,26 +61,27 @@ describe('resolver selectors', () => {
schema, schema,
// this value doesn't matter // this value doesn't matter
parameters: mockTreeFetcherParameters(), parameters: mockTreeFetcherParameters(),
}, })
}); );
}); });
describe('when all nodes are in view', () => { describe('when all nodes are in view', () => {
beforeEach(() => { beforeEach(() => {
const size = 1000000; const size = 1000000;
actions.push({ // set the size of the camera
// set the size of the camera actions.push(userSetRasterSize({ id, dimensions: [size, size] }));
type: 'userSetRasterSize',
payload: [size, size],
});
}); });
it('should return no flowto for the second ancestor', () => { 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', () => { 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', () => { 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, secondChildID,
}); });
const { schema, dataSource } = endpointSourceSchema(); const { schema, dataSource } = endpointSourceSchema();
actions.push({ actions.push(
type: 'serverReturnedResolverData', serverReturnedResolverData({
payload: { id,
result: resolverTree, result: resolverTree,
dataSource, dataSource,
schema, schema,
// this value doesn't matter // this value doesn't matter
parameters: mockTreeFetcherParameters(), parameters: mockTreeFetcherParameters(),
}, })
}); );
}); });
describe('when all nodes are in view', () => { describe('when all nodes are in view', () => {
beforeEach(() => { beforeEach(() => {
const rasterSize = 1000000; const rasterSize = 1000000;
actions.push({ // set the size of the camera
// set the size of the camera actions.push(
type: 'userSetRasterSize', userSetRasterSize({
payload: [rasterSize, rasterSize], id,
}); dimensions: [rasterSize, rasterSize],
})
);
}); });
it('should return no flowto for the origin', () => { 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', () => { 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', () => { 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', () => { describe('when only the origin and first child are in view', () => {
beforeEach(() => { beforeEach(() => {
// set the raster size // set the raster size
const rasterSize = 1000000; const rasterSize = 1000000;
actions.push({ // set the size of the camera
// set the size of the camera actions.push(
type: 'userSetRasterSize', userSetRasterSize({
payload: [rasterSize, rasterSize], id,
}); dimensions: [rasterSize, rasterSize],
})
);
// get the layout // get the layout
const layout = selectors.layout(state()); const layout = selectors.layout(state().analyzerById[id]);
// find the position of the second child // 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( const positionOfSecondChild = layout.processNodePositions.get(
secondChild as ResolverNode secondChild as ResolverNode
)!; )!;
@ -137,39 +155,41 @@ describe('resolver selectors', () => {
const leftSideOfSecondChildAABB = positionOfSecondChild[0] - 720 / 2; const leftSideOfSecondChildAABB = positionOfSecondChild[0] - 720 / 2;
// adjust the camera so that it doesn't quite see the second child // adjust the camera so that it doesn't quite see the second child
actions.push({ actions.push(
// set the position of the camera so that the left edge of the second child is at the right edge userSetPositionOfCamera({
// of the viewable area // set the position of the camera so that the left edge of the second child is at the right edge
type: 'userSetPositionOfCamera', // of the viewable area
payload: [rasterSize / -2 + leftSideOfSecondChildAABB, 0], id,
}); cameraView: [rasterSize / -2 + leftSideOfSecondChildAABB, 0],
})
);
}); });
it('the origin should be in view', () => { it('the origin should be in view', () => {
const origin = selectors.graphNodeForID(state())(originID); const origin = selectors.graphNodeForID(state().analyzerById[id])(originID);
expect( expect(
selectors selectors
.visibleNodesAndEdgeLines(state())(0) .visibleNodesAndEdgeLines(state().analyzerById[id])(0)
.processNodePositions.has(origin as ResolverNode) .processNodePositions.has(origin as ResolverNode)
).toBe(true); ).toBe(true);
}); });
it('the first child should be in view', () => { it('the first child should be in view', () => {
const firstChild = selectors.graphNodeForID(state())(firstChildID); const firstChild = selectors.graphNodeForID(state().analyzerById[id])(firstChildID);
expect( expect(
selectors selectors
.visibleNodesAndEdgeLines(state())(0) .visibleNodesAndEdgeLines(state().analyzerById[id])(0)
.processNodePositions.has(firstChild as ResolverNode) .processNodePositions.has(firstChild as ResolverNode)
).toBe(true); ).toBe(true);
}); });
it('the second child should not be in view', () => { it('the second child should not be in view', () => {
const secondChild = selectors.graphNodeForID(state())(secondChildID); const secondChild = selectors.graphNodeForID(state().analyzerById[id])(secondChildID);
expect( expect(
selectors selectors
.visibleNodesAndEdgeLines(state())(0) .visibleNodesAndEdgeLines(state().analyzerById[id])(0)
.processNodePositions.has(secondChild as ResolverNode) .processNodePositions.has(secondChild as ResolverNode)
).toBe(false); ).toBe(false);
}); });
it('should return nothing as the flowto for the first child', () => { 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 { createSelector, defaultMemoize } from 'reselect';
import type { State } from '../../common/store/types';
import * as cameraSelectors from './camera/selectors'; import * as cameraSelectors from './camera/selectors';
import * as dataSelectors from './data/selectors'; import * as dataSelectors from './data/selectors';
import * as uiSelectors from './ui/selectors'; import * as uiSelectors from './ui/selectors';
import type { import type {
AnalyzerById,
ResolverState, ResolverState,
IsometricTaxiLayout, IsometricTaxiLayout,
DataState, DataState,
@ -19,6 +21,16 @@ import type {
import type { EventStats } from '../../../common/endpoint/types'; import type { EventStats } from '../../../common/endpoint/types';
import * as nodeModel from '../../../common/endpoint/models/node'; 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. * 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 * See https://en.wikipedia.org/wiki/Orthographic_projection

View file

@ -6,28 +6,28 @@
*/ */
import React from 'react'; import React from 'react';
import type { Store } from 'redux'; import type { Store, AnyAction } from 'redux';
import { createStore, applyMiddleware } from 'redux';
import type { ReactWrapper } from 'enzyme'; import type { ReactWrapper } from 'enzyme';
import { mount } from 'enzyme'; import { mount } from 'enzyme';
import type { History as HistoryPackageHistoryInterface } from 'history'; import type { History as HistoryPackageHistoryInterface } from 'history';
import { createMemoryHistory } from 'history'; import { createMemoryHistory } from 'history';
import { coreMock } from '@kbn/core/public/mocks'; import { coreMock } from '@kbn/core/public/mocks';
import { createStore } from '../../../common/store/store';
import { spyMiddlewareFactory } from '../spy_middleware_factory'; import { spyMiddlewareFactory } from '../spy_middleware_factory';
import { resolverMiddlewareFactory } from '../../store/middleware'; import { resolverMiddlewareFactory } from '../../store/middleware';
import { resolverReducer } from '../../store/reducer';
import { MockResolver } from './mock_resolver'; import { MockResolver } from './mock_resolver';
import type { import type { DataAccessLayer, SpyMiddleware, SideEffectSimulator, TimeFilters } from '../../types';
ResolverState,
DataAccessLayer,
SpyMiddleware,
SideEffectSimulator,
TimeFilters,
} from '../../types';
import type { ResolverAction } from '../../store/actions';
import { sideEffectSimulatorFactory } from '../../view/side_effect_simulator_factory'; import { sideEffectSimulatorFactory } from '../../view/side_effect_simulator_factory';
import { uiSetting } from '../../mocks/ui_setting'; 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. * 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`. * The redux store, creating in the constructor using the `dataAccessLayer`.
* This code subscribes to state transitions. * 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. * 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) // create the spy middleware (for debugging tests)
this.spyMiddleware = spyMiddlewareFactory(); 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` // 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. // If needed, create a fake 'history' instance.
// Resolver will use to read and write query string values. // 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.) * Change the component instance ID (updates the React component props.)
*/ */
public set resolverComponentInstanceID(value: string) { public set resolverComponentInstanceID(value: string) {
this.store.dispatch(createResolver({ id: value }));
this.wrapper.setProps({ resolverComponentInstanceID: 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 { Router } from 'react-router-dom';
import { I18nProvider } from '@kbn/i18n-react'; import { I18nProvider } from '@kbn/i18n-react';
import { Provider } from 'react-redux'; 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 { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import type { CoreStart } from '@kbn/core/public'; import type { CoreStart } from '@kbn/core/public';
import type { ResolverState, SideEffectSimulator, ResolverProps } from '../../types'; import { enableMapSet } from 'immer';
import type { ResolverAction } from '../../store/actions'; import type { SideEffectSimulator, ResolverProps } from '../../types';
import { ResolverWithoutProviders } from '../../view/resolver_without_providers'; import { ResolverWithoutProviders } from '../../view/resolver_without_providers';
import { SideEffectContext } from '../../view/side_effect_context'; import { SideEffectContext } from '../../view/side_effect_context';
import type { State } from '../../../common/store/types';
enableMapSet();
type MockResolverProps = { type MockResolverProps = {
/** /**
@ -37,7 +40,7 @@ type MockResolverProps = {
*/ */
history: React.ComponentProps<typeof Router>['history']; history: React.ComponentProps<typeof Router>['history'];
/** Pass a resolver store. See `storeFactory` and `mockDataAccessLayer` */ /** 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` * Pass the side effect simulator which handles animations and resizing. See `sideEffectSimulatorFactory`
*/ */

View file

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

View file

@ -7,10 +7,9 @@
import type { ResizeObserver } from '@juggle/resize-observer'; import type { ResizeObserver } from '@juggle/resize-observer';
import type React from 'react'; 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 { BBox } from 'rbush';
import type { Provider } from 'react-redux'; import type { Provider } from 'react-redux';
import type { ResolverAction } from './store/actions';
import type { import type {
ResolverNode, ResolverNode,
ResolverRelatedEvents, ResolverRelatedEvents,
@ -21,7 +20,18 @@ import type {
ResolverSchema, ResolverSchema,
} from '../../common/endpoint/types'; } from '../../common/endpoint/types';
import type { Tree } from '../../common/endpoint/generate_data'; 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`. * 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. * 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. * A rectangle with sides that align with the `x` and `y` axises.
@ -646,7 +656,7 @@ export type ResolverProcessType =
| 'processError' | 'processError'
| 'unknownEvent'; | 'unknownEvent';
export type ResolverStore = Store<ResolverState, ResolverAction>; export type ResolverStore = Store<State, AnyAction>;
/** /**
* Describes the basic Resolver graph layout. * Describes the basic Resolver graph layout.
@ -827,11 +837,11 @@ export interface ResolverProps {
export interface SpyMiddlewareStateActionPair { export interface SpyMiddlewareStateActionPair {
/** An action dispatched, `state` is the state that the reducer returned when handling this action. /** 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`. * 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`. * 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. * 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. * 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. * 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. * 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 { SideEffectContext } from './side_effect_context';
import type { Vector2 } from '../types'; import type { Vector2 } from '../types';
import * as selectors from '../store/selectors'; import * as selectors from '../store/selectors';
import type { ResolverAction } from '../store/actions';
import { useColors } from './use_colors'; import { useColors } from './use_colors';
import { StyledDescriptionList } from './panels/styles'; import { StyledDescriptionList } from './panels/styles';
import { CubeForProcess } from './panels/cube_for_process'; import { CubeForProcess } from './panels/cube_for_process';
import { GeneratedText } from './generated_text'; 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 // EuiRange is currently only horizontally positioned. This reorients the track to a vertical position
const StyledEuiRange = styled(EuiRange)<EuiRangeProps>` const StyledEuiRange = styled(EuiRange)<EuiRangeProps>`
@ -118,15 +125,22 @@ const StyledGraphControls = styled.div<Partial<StyledGraphControlProps>>`
export const GraphControls = React.memo( export const GraphControls = React.memo(
({ ({
id,
className, className,
}: { }: {
/**
* Id that identify the scope of analyzer
*/
id: string;
/** /**
* A className string provided by `styled` * A className string provided by `styled`
*/ */
className?: string; className?: string;
}) => { }) => {
const dispatch: (action: ResolverAction) => unknown = useDispatch(); const dispatch = useDispatch();
const scalingFactor = useSelector(selectors.scalingFactor); const scalingFactor = useSelector((state: State) =>
selectors.scalingFactor(state.analyzer.analyzerById[id])
);
const { timestamp } = useContext(SideEffectContext); const { timestamp } = useContext(SideEffectContext);
const [activePopover, setPopover] = useState<null | 'schemaInfo' | 'nodeLegend'>(null); const [activePopover, setPopover] = useState<null | 'schemaInfo' | 'nodeLegend'>(null);
const colorMap = useColors(); const colorMap = useColors();
@ -150,33 +164,28 @@ export const GraphControls = React.memo(
(event as React.ChangeEvent<HTMLInputElement>).target.value (event as React.ChangeEvent<HTMLInputElement>).target.value
); );
if (isNaN(valueAsNumber) === false) { if (isNaN(valueAsNumber) === false) {
dispatch({ dispatch(
type: 'userSetZoomLevel', userSetZoomLevel({
payload: valueAsNumber, id,
}); zoomLevel: valueAsNumber,
})
);
} }
}, },
[dispatch] [dispatch, id]
); );
const handleCenterClick = useCallback(() => { const handleCenterClick = useCallback(() => {
dispatch({ dispatch(userSetPositionOfCamera({ id, cameraView: [0, 0] }));
type: 'userSetPositionOfCamera', }, [dispatch, id]);
payload: [0, 0],
});
}, [dispatch]);
const handleZoomOutClick = useCallback(() => { const handleZoomOutClick = useCallback(() => {
dispatch({ dispatch(userClickedZoomOut({ id }));
type: 'userClickedZoomOut', }, [dispatch, id]);
});
}, [dispatch]);
const handleZoomInClick = useCallback(() => { const handleZoomInClick = useCallback(() => {
dispatch({ dispatch(userClickedZoomIn({ id }));
type: 'userClickedZoomIn', }, [dispatch, id]);
});
}, [dispatch]);
const [handleNorth, handleEast, handleSouth, handleWest] = useMemo(() => { const [handleNorth, handleEast, handleSouth, handleWest] = useMemo(() => {
const directionVectors: readonly Vector2[] = [ const directionVectors: readonly Vector2[] = [
@ -187,14 +196,10 @@ export const GraphControls = React.memo(
]; ];
return directionVectors.map((direction) => { return directionVectors.map((direction) => {
return () => { return () => {
const action: ResolverAction = { dispatch(userNudgedCamera({ id, direction, time: timestamp() }));
type: 'userNudgedCamera',
payload: { direction, time: timestamp() },
};
dispatch(action);
}; };
}); });
}, [dispatch, timestamp]); }, [dispatch, timestamp, id]);
return ( return (
<StyledGraphControls <StyledGraphControls
@ -204,11 +209,13 @@ export const GraphControls = React.memo(
> >
<StyledGraphControlsColumn> <StyledGraphControlsColumn>
<SchemaInformation <SchemaInformation
id={id}
closePopover={closePopover} closePopover={closePopover}
isOpen={activePopover === 'schemaInfo'} isOpen={activePopover === 'schemaInfo'}
setActivePopover={setActivePopover} setActivePopover={setActivePopover}
/> />
<NodeLegend <NodeLegend
id={id}
closePopover={closePopover} closePopover={closePopover}
isOpen={activePopover === 'nodeLegend'} isOpen={activePopover === 'nodeLegend'}
setActivePopover={setActivePopover} setActivePopover={setActivePopover}
@ -309,16 +316,20 @@ export const GraphControls = React.memo(
); );
const SchemaInformation = ({ const SchemaInformation = ({
id,
closePopover, closePopover,
setActivePopover, setActivePopover,
isOpen, isOpen,
}: { }: {
id: string;
closePopover: () => void; closePopover: () => void;
setActivePopover: (value: 'schemaInfo' | null) => void; setActivePopover: (value: 'schemaInfo' | null) => void;
isOpen: boolean; isOpen: boolean;
}) => { }) => {
const colorMap = useColors(); 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 setAsActivePopover = useCallback(() => setActivePopover('schemaInfo'), [setActivePopover]);
const schemaInfoButtonTitle = i18n.translate( 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 // 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 // Should be updated to be dynamic if and when non process based resolvers are possible
const NodeLegend = ({ const NodeLegend = ({
id,
closePopover, closePopover,
setActivePopover, setActivePopover,
isOpen, isOpen,
}: { }: {
id: string;
closePopover: () => void; closePopover: () => void;
setActivePopover: (value: 'nodeLegend') => void; setActivePopover: (value: 'nodeLegend') => void;
isOpen: boolean; isOpen: boolean;
@ -489,6 +502,7 @@ const NodeLegend = ({
style={{ width: '20% ' }} style={{ width: '20% ' }}
> >
<CubeForProcess <CubeForProcess
id={id}
size="2.5em" size="2.5em"
data-test-subj="resolver:node-detail:title-icon" data-test-subj="resolver:node-detail:title-icon"
state="running" state="running"
@ -512,6 +526,7 @@ const NodeLegend = ({
style={{ width: '20% ' }} style={{ width: '20% ' }}
> >
<CubeForProcess <CubeForProcess
id={id}
size="2.5em" size="2.5em"
data-test-subj="resolver:node-detail:title-icon" data-test-subj="resolver:node-detail:title-icon"
state="terminated" state="terminated"
@ -535,6 +550,7 @@ const NodeLegend = ({
style={{ width: '20% ' }} style={{ width: '20% ' }}
> >
<CubeForProcess <CubeForProcess
id={id}
size="2.5em" size="2.5em"
data-test-subj="resolver:node-detail:title-icon" data-test-subj="resolver:node-detail:title-icon"
state="loading" state="loading"
@ -558,6 +574,7 @@ const NodeLegend = ({
style={{ width: '20% ' }} style={{ width: '20% ' }}
> >
<CubeForProcess <CubeForProcess
id={id}
size="2.5em" size="2.5em"
data-test-subj="resolver:node-detail:title-icon" data-test-subj="resolver:node-detail:title-icon"
state="error" state="error"

View file

@ -7,38 +7,29 @@
/* eslint-disable react/display-name */ /* eslint-disable react/display-name */
import React, { useMemo, useState, useEffect } from 'react'; import React, { useEffect } from 'react';
import { Provider } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useKibana } from '@kbn/kibana-react-plugin/public'; import type { ResolverProps } from '../types';
import { resolverStoreFactory } from '../store';
import type { StartServices } from '../../types';
import type { DataAccessLayer, ResolverProps } from '../types';
import { dataAccessLayerFactory } from '../data_access_layer/factory';
import { ResolverWithoutProviders } from './resolver_without_providers'; 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. * 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) => { export const Resolver = React.memo((props: ResolverProps) => {
const context = useKibana<StartServices>(); const store = useSelector(
const dataAccessLayer: DataAccessLayer = useMemo( (state: State) => state.analyzer.analyzerById[props.resolverComponentInstanceID]
() => dataAccessLayerFactory(context),
[context]
); );
const store = useMemo(() => resolverStoreFactory(dataAccessLayer), [dataAccessLayer]); const dispatch = useDispatch();
if (!store) {
const [activeStore, updateActiveStore] = useState(store); dispatch(createResolver({ id: props.resolverComponentInstanceID }));
}
useEffect(() => { useEffect(() => {
if (props.shouldUpdate) { if (props.shouldUpdate) {
updateActiveStore(resolverStoreFactory(dataAccessLayer)); dispatch(createResolver({ id: props.resolverComponentInstanceID }));
} }
}, [dataAccessLayer, props.shouldUpdate]); }, [dispatch, props.shouldUpdate, props.resolverComponentInstanceID]);
return <ResolverWithoutProviders {...props} />;
return (
<Provider store={activeStore}>
<ResolverWithoutProviders {...props} />
</Provider>
);
}); });

View file

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

View file

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

View file

@ -16,20 +16,23 @@ import { NodeDetail } from './node_detail';
import { NodeList } from './node_list'; import { NodeList } from './node_list';
import { EventDetail } from './event_detail'; import { EventDetail } from './event_detail';
import type { PanelViewAndParameters } from '../../types'; 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) * Show the panel that matches the `panelViewAndParameters` (derived from the browser's location.search)
*/ */
export const PanelRouter = memo(function () { export const PanelRouter = memo(function ({ id }: { id: string }) {
const params: PanelViewAndParameters = useSelector(selectors.panelViewAndParameters); const params: PanelViewAndParameters = useSelector((state: State) =>
selectors.panelViewAndParameters(state.analyzer.analyzerById[id])
);
if (params.panelView === 'nodeDetail') { if (params.panelView === 'nodeDetail') {
return <NodeDetail nodeID={params.panelParameters.nodeID} />; return <NodeDetail id={id} nodeID={params.panelParameters.nodeID} />;
} else if (params.panelView === 'nodeEvents') { } else if (params.panelView === 'nodeEvents') {
return <NodeEvents nodeID={params.panelParameters.nodeID} />; return <NodeEvents id={id} nodeID={params.panelParameters.nodeID} />;
} else if (params.panelView === 'nodeEventsInCategory') { } else if (params.panelView === 'nodeEventsInCategory') {
return ( return (
<NodeEventsInCategory <NodeEventsInCategory
id={id}
nodeID={params.panelParameters.nodeID} nodeID={params.panelParameters.nodeID}
eventCategory={params.panelParameters.eventCategory} eventCategory={params.panelParameters.eventCategory}
/> />
@ -37,12 +40,13 @@ export const PanelRouter = memo(function () {
} else if (params.panelView === 'eventDetail') { } else if (params.panelView === 'eventDetail') {
return ( return (
<EventDetail <EventDetail
id={id}
nodeID={params.panelParameters.nodeID} nodeID={params.panelParameters.nodeID}
eventCategory={params.panelParameters.eventCategory} eventCategory={params.panelParameters.eventCategory}
/> />
); );
} else { } else {
/* The default 'Event List' / 'List of all processes' view */ /* 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 { CubeForProcess } from './cube_for_process';
import type { SafeResolverEvent } from '../../../../common/endpoint/types'; import type { SafeResolverEvent } from '../../../../common/endpoint/types';
import { useCubeAssets } from '../use_cube_assets'; import { useCubeAssets } from '../use_cube_assets';
import type { ResolverState } from '../../types';
import { PanelLoading } from './panel_loading'; import { PanelLoading } from './panel_loading';
import { StyledPanel } from '../styles'; import { StyledPanel } from '../styles';
import { useLinkProps } from '../use_link_props'; import { useLinkProps } from '../use_link_props';
import { useFormattedDate } from './use_formatted_date'; import { useFormattedDate } from './use_formatted_date';
import { PanelContentError } from './panel_content_error'; import { PanelContentError } from './panel_content_error';
import type { State } from '../../../common/store/types';
const StyledCubeForProcess = styled(CubeForProcess)` const StyledCubeForProcess = styled(CubeForProcess)`
position: relative; position: relative;
@ -41,23 +41,25 @@ const nodeDetailError = i18n.translate('xpack.securitySolution.resolver.panel.no
defaultMessage: 'Node details were unable to be retrieved', defaultMessage: 'Node details were unable to be retrieved',
}); });
export const NodeDetail = memo(function ({ nodeID }: { nodeID: string }) { export const NodeDetail = memo(function ({ id, nodeID }: { id: string; nodeID: string }) {
const processEvent = useSelector((state: ResolverState) => const processEvent = useSelector((state: State) =>
nodeDataModel.firstEvent(selectors.nodeDataForID(state)(nodeID)) 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' ? ( return nodeStatus === 'loading' ? (
<StyledPanel hasBorder> <StyledPanel hasBorder>
<PanelLoading /> <PanelLoading id={id} />
</StyledPanel> </StyledPanel>
) : processEvent ? ( ) : processEvent ? (
<StyledPanel hasBorder data-test-subj="resolver:panel:node-detail"> <StyledPanel hasBorder data-test-subj="resolver:panel:node-detail">
<NodeDetailView nodeID={nodeID} processEvent={processEvent} /> <NodeDetailView id={id} nodeID={nodeID} processEvent={processEvent} />
</StyledPanel> </StyledPanel>
) : ( ) : (
<StyledPanel hasBorder> <StyledPanel hasBorder>
<PanelContentError translatedErrorMessage={nodeDetailError} /> <PanelContentError id={id} translatedErrorMessage={nodeDetailError} />
</StyledPanel> </StyledPanel>
); );
}); });
@ -67,16 +69,20 @@ export const NodeDetail = memo(function ({ nodeID }: { nodeID: string }) {
* Created, PID, User/Domain, etc. * Created, PID, User/Domain, etc.
*/ */
const NodeDetailView = memo(function ({ const NodeDetailView = memo(function ({
id,
processEvent, processEvent,
nodeID, nodeID,
}: { }: {
id: string;
processEvent: SafeResolverEvent; processEvent: SafeResolverEvent;
nodeID: string; nodeID: string;
}) { }) {
const processName = eventModel.processNameSafeVersion(processEvent); const processName = eventModel.processNameSafeVersion(processEvent);
const nodeState = useSelector((state: ResolverState) => selectors.nodeDataStatus(state)(nodeID)); const nodeState = useSelector((state: State) =>
const relatedEventTotal = useSelector((state: ResolverState) => { selectors.nodeDataStatus(state.analyzer.analyzerById[id])(nodeID)
return selectors.relatedEventTotalCount(state)(nodeID); );
const relatedEventTotal = useSelector((state: State) => {
return selectors.relatedEventTotalCount(state.analyzer.analyzerById[id])(nodeID);
}); });
const eventTime = eventModel.eventTimestamp(processEvent); const eventTime = eventModel.eventTimestamp(processEvent);
const dateTime = useFormattedDate(eventTime); const dateTime = useFormattedDate(eventTime);
@ -175,7 +181,7 @@ const NodeDetailView = memo(function ({
return processDescriptionListData; return processDescriptionListData;
}, [dateTime, processEvent]); }, [dateTime, processEvent]);
const nodesLinkNavProps = useLinkProps({ const nodesLinkNavProps = useLinkProps(id, {
panelView: 'nodes', panelView: 'nodes',
}); });
@ -202,9 +208,9 @@ const NodeDetailView = memo(function ({
}, },
]; ];
}, [processName, nodesLinkNavProps]); }, [processName, nodesLinkNavProps]);
const { descriptionText } = useCubeAssets(nodeState, false); const { descriptionText } = useCubeAssets(id, nodeState, false);
const nodeDetailNavProps = useLinkProps({ const nodeDetailNavProps = useLinkProps(id, {
panelView: 'nodeEvents', panelView: 'nodeEvents',
panelParameters: { nodeID }, panelParameters: { nodeID },
}); });
@ -217,6 +223,7 @@ const NodeDetailView = memo(function ({
<EuiTitle size="xs"> <EuiTitle size="xs">
<StyledTitle aria-describedby={titleID}> <StyledTitle aria-describedby={titleID}>
<StyledCubeForProcess <StyledCubeForProcess
id={id}
data-test-subj="resolver:node-detail:title-icon" data-test-subj="resolver:node-detail:title-icon"
state={nodeState} state={nodeState}
/> />

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -7,13 +7,20 @@
import type React from 'react'; import type React from 'react';
import { useCallback, useState, useEffect, useRef, useLayoutEffect, useContext } 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 { SideEffectContext } from './side_effect_context';
import type { Matrix3 } from '../types'; import type { Matrix3 } from '../types';
import { useResolverDispatch } from './use_resolver_dispatch';
import * as selectors from '../store/selectors'; 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 * A function to pass to a React element's `ref` property. Used to attach
* native event listeners and to measure the DOM node. * native event listeners and to measure the DOM node.
@ -26,7 +33,7 @@ export function useCamera(): {
*/ */
projectionMatrix: Matrix3; projectionMatrix: Matrix3;
} { } {
const dispatch = useResolverDispatch(); const dispatch = useDispatch();
const sideEffectors = useContext(SideEffectContext); const sideEffectors = useContext(SideEffectContext);
const [ref, setRef] = useState<null | HTMLDivElement>(null); const [ref, setRef] = useState<null | HTMLDivElement>(null);
@ -36,7 +43,15 @@ export function useCamera(): {
* to determine where it belongs on the screen. * to determine where it belongs on the screen.
* The projection matrix changes over time if the camera is currently animating. * 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 * Use a ref to refer to the `projectionMatrixAtTime` function. The rAF loop
@ -57,8 +72,12 @@ export function useCamera(): {
projectionMatrixAtTime(sideEffectors.timestamp()) projectionMatrixAtTime(sideEffectors.timestamp())
); );
const userIsPanning = useSelector(selectors.userIsPanning); const userIsPanning = useSelector((state: State) =>
const isAnimatingAtTime = useSelector(selectors.isAnimating); selectors.userIsPanning(state.analyzer.analyzerById[id])
);
const isAnimatingAtTime = useSelector((state: State) =>
selectors.isAnimating(state.analyzer.analyzerById[id])
);
const [elementBoundingClientRect, clientRectCallback] = useAutoUpdatingClientRect(); const [elementBoundingClientRect, clientRectCallback] = useAutoUpdatingClientRect();
@ -82,41 +101,39 @@ export function useCamera(): {
(event: React.MouseEvent<HTMLDivElement>) => { (event: React.MouseEvent<HTMLDivElement>) => {
const maybeCoordinates = relativeCoordinatesFromMouseEvent(event); const maybeCoordinates = relativeCoordinatesFromMouseEvent(event);
if (maybeCoordinates !== null) { if (maybeCoordinates !== null) {
dispatch({ dispatch(
type: 'userStartedPanning', userStartedPanning({
payload: { screenCoordinates: maybeCoordinates, time: sideEffectors.timestamp() }, id,
}); screenCoordinates: maybeCoordinates,
time: sideEffectors.timestamp(),
})
);
} }
}, },
[dispatch, relativeCoordinatesFromMouseEvent, sideEffectors] [dispatch, relativeCoordinatesFromMouseEvent, sideEffectors, id]
); );
const handleMouseMove = useCallback( const handleMouseMove = useCallback(
(event: MouseEvent) => { (event: MouseEvent) => {
const maybeCoordinates = relativeCoordinatesFromMouseEvent(event); const maybeCoordinates = relativeCoordinatesFromMouseEvent(event);
if (maybeCoordinates) { if (maybeCoordinates) {
dispatch({ dispatch(
type: 'userMovedPointer', userMovedPointer({
payload: { id,
screenCoordinates: maybeCoordinates, screenCoordinates: maybeCoordinates,
time: sideEffectors.timestamp(), time: sideEffectors.timestamp(),
}, })
}); );
} }
}, },
[dispatch, relativeCoordinatesFromMouseEvent, sideEffectors] [dispatch, relativeCoordinatesFromMouseEvent, sideEffectors, id]
); );
const handleMouseUp = useCallback(() => { const handleMouseUp = useCallback(() => {
if (userIsPanning) { if (userIsPanning) {
dispatch({ dispatch(userStoppedPanning({ id, time: sideEffectors.timestamp() }));
type: 'userStoppedPanning',
payload: {
time: sideEffectors.timestamp(),
},
});
} }
}, [dispatch, sideEffectors, userIsPanning]); }, [dispatch, sideEffectors, userIsPanning, id]);
const handleWheel = useCallback( const handleWheel = useCallback(
(event: WheelEvent) => { (event: WheelEvent) => {
@ -127,20 +144,21 @@ export function useCamera(): {
event.deltaMode === 0 event.deltaMode === 0
) { ) {
event.preventDefault(); event.preventDefault();
dispatch({ dispatch(
type: 'userZoomed', userZoomed({
payload: { id,
/** /**
*
* we use elementBoundingClientRect to interpret pixel deltas as a fraction of the element's height * 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 * 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, zoomChange: event.deltaY / -elementBoundingClientRect.height,
time: sideEffectors.timestamp(), time: sideEffectors.timestamp(),
}, })
}); );
} }
}, },
[elementBoundingClientRect, dispatch, sideEffectors] [elementBoundingClientRect, dispatch, sideEffectors, id]
); );
const refCallback = useCallback( const refCallback = useCallback(
@ -252,12 +270,14 @@ export function useCamera(): {
useEffect(() => { useEffect(() => {
if (elementBoundingClientRect !== null) { if (elementBoundingClientRect !== null) {
dispatch({ dispatch(
type: 'userSetRasterSize', userSetRasterSize({
payload: [elementBoundingClientRect.width, elementBoundingClientRect.height], id,
}); dimensions: [elementBoundingClientRect.width, elementBoundingClientRect.height],
})
);
} }
}, [dispatch, elementBoundingClientRect]); }, [dispatch, elementBoundingClientRect, id]);
return { return {
ref: refCallback, 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. * Provides colors and HTML IDs used to render the 'cube' graphic that accompanies nodes.
*/ */
export function useCubeAssets( export function useCubeAssets(
id: string,
cubeType: NodeDataStatus, cubeType: NodeDataStatus,
isProcessTrigger: boolean isProcessTrigger: boolean
): NodeStyleConfig { ): NodeStyleConfig {
const SymbolIds = useSymbolIDs(); const SymbolIds = useSymbolIDs({ id });
const colorMap = useColors(); const colorMap = useColors();
const nodeAssets: NodeStyleMap = useMemo( const nodeAssets: NodeStyleMap = useMemo(

View file

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

View file

@ -7,16 +7,12 @@
import { useMemo } from 'react'; 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'. * 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.) * In the future these IDs may come from an outside provider (and may be shared by multiple Resolver instances.)
*/ */
export function usePaintServerIDs() { export function usePaintServerIDs({ id }: { id: string }) {
const resolverComponentInstanceID = useSelector(selectors.resolverComponentInstanceID); const resolverComponentInstanceID = id;
return useMemo(() => { return useMemo(() => {
const prefix = `${resolverComponentInstanceID}-symbols`; const prefix = `${resolverComponentInstanceID}-symbols`;
return { 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 { useRef, useEffect } from 'react';
import { useLocation, useHistory } from 'react-router-dom'; import { useLocation, useHistory } from 'react-router-dom';
import { useSelector } from 'react-redux';
import * as selectors from '../store/selectors';
import { parameterName } from '../store/parameter_name'; import { parameterName } from '../store/parameter_name';
/** /**
* Cleanup any query string keys that were added by this Resolver instance. * 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. * 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. * 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 * 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; searchRef.current = useLocation().search;
const history = useHistory(); const history = useHistory();
const resolverComponentInstanceID = useSelector(selectors.resolverComponentInstanceID);
const resolverKey = parameterName(resolverComponentInstanceID); const resolverKey = parameterName(id);
useEffect(() => { useEffect(() => {
/** /**

View file

@ -7,8 +7,8 @@
import { useLayoutEffect } from 'react'; import { useLayoutEffect } from 'react';
import { useLocation } from 'react-router-dom'; 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. * 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. * It dispatches actions that keep the store in sync with external properties.
@ -29,20 +29,20 @@ export function useStateSyncingActions({
shouldUpdate: boolean; shouldUpdate: boolean;
filters: object; filters: object;
}) { }) {
const dispatch = useResolverDispatch(); const dispatch = useDispatch();
const locationSearch = useLocation().search; const locationSearch = useLocation().search;
useLayoutEffect(() => { useLayoutEffect(() => {
dispatch({ dispatch(
type: 'appReceivedNewExternalProperties', appReceivedNewExternalProperties({
payload: { id: resolverComponentInstanceID,
databaseDocumentID, databaseDocumentID,
resolverComponentInstanceID, resolverComponentInstanceID,
locationSearch, locationSearch,
indices, indices,
shouldUpdate, shouldUpdate,
filters, filters,
}, })
}); );
}, [ }, [
dispatch, dispatch,
databaseDocumentID, databaseDocumentID,

View file

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

View file

@ -10,8 +10,9 @@ import { useSelector, useDispatch } from 'react-redux';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import * as selectors from '../store/selectors'; import * as selectors from '../store/selectors';
import { SideEffectContext } from './side_effect_context'; import { SideEffectContext } from './side_effect_context';
import type { ResolverAction } from '../store/actions';
import { panelViewAndParameters } from '../store/panel_view_and_parameters'; 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. * 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 * 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`. * 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() { export function useSyncSelectedNode({ id }: { id: string }) {
const dispatch: (action: ResolverAction) => void = useDispatch(); const dispatch = useDispatch();
const resolverComponentInstanceID = useSelector(selectors.resolverComponentInstanceID); const resolverComponentInstanceID = id;
const locationSearch = useLocation().search; const locationSearch = useLocation().search;
const sideEffectors = useContext(SideEffectContext); const sideEffectors = useContext(SideEffectContext);
const selectedNode = useSelector(selectors.selectedNode); const selectedNode = useSelector((state: State) =>
const idToNodeMap = useSelector(selectors.graphNodeForID); selectors.selectedNode(state.analyzer.analyzerById[id])
);
const idToNodeMap = useSelector((state: State) =>
selectors.graphNodeForID(state.analyzer.analyzerById[id])
);
const currentPanelParameters = panelViewAndParameters({ const currentPanelParameters = panelViewAndParameters({
locationSearch, locationSearch,
@ -41,13 +46,13 @@ export function useSyncSelectedNode() {
useEffect(() => { useEffect(() => {
// use this for the entire render in order to keep things in sync // use this for the entire render in order to keep things in sync
if (urlNodeID && idToNodeMap(urlNodeID) && urlNodeID !== selectedNode) { if (urlNodeID && idToNodeMap(urlNodeID) && urlNodeID !== selectedNode) {
dispatch({ dispatch(
type: 'userSelectedResolverNode', userSelectedResolverNode({
payload: { id,
nodeID: urlNodeID, nodeID: urlNodeID,
time: sideEffectors.timestamp(), time: sideEffectors.timestamp(),
}, })
}); );
} }
}, [ }, [
currentPanelParameters.panelView, currentPanelParameters.panelView,
@ -56,5 +61,6 @@ export function useSyncSelectedNode() {
idToNodeMap, idToNodeMap,
selectedNode, selectedNode,
sideEffectors, sideEffectors,
id,
]); ]);
} }

View file

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