mirror of
https://github.com/elastic/kibana.git
synced 2025-04-25 02:09:32 -04:00
[Security Solution] Move analyzer store to security solution (#157654)
## Summary
This PR moves analyzer (resolver)'s redux store to security solution
store.

**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:
parent
c4dc82572b
commit
5c9a0ab88d
62 changed files with 2254 additions and 1858 deletions
|
@ -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: {
|
||||||
|
|
|
@ -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));
|
||||||
|
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
@ -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'],
|
||||||
};
|
};
|
||||||
|
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
|
||||||
|
|
|
@ -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;
|
|
||||||
|
|
|
@ -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)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -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';
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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;
|
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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();
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
|
@ -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', () => {
|
||||||
|
|
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
|
@ -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));
|
||||||
};
|
};
|
||||||
|
|
|
@ -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',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -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,
|
||||||
},
|
})
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>;
|
||||||
|
|
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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`
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 },
|
||||||
});
|
});
|
||||||
|
|
|
@ -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} />;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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 },
|
||||||
});
|
});
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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 [
|
||||||
|
|
|
@ -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(() => {
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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', () => {
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
|
|
@ -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(() => {
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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]);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue