mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -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 { initialGroupingState } from '../store/grouping/reducer';
|
||||
import type { SourcererState } from '../store/sourcerer';
|
||||
import { EMPTY_RESOLVER } from '../../resolver/store/helpers';
|
||||
|
||||
const mockFieldMap: DataViewSpec['fields'] = Object.fromEntries(
|
||||
mockIndexFields.map((field) => [field.name, field])
|
||||
|
@ -419,6 +420,14 @@ export const mockGlobalState: State = {
|
|||
},
|
||||
},
|
||||
groups: initialGroupingState,
|
||||
analyzer: {
|
||||
analyzerById: {
|
||||
[TableId.test]: EMPTY_RESOLVER,
|
||||
[TimelineId.test]: EMPTY_RESOLVER,
|
||||
[TimelineId.active]: EMPTY_RESOLVER,
|
||||
flyout: EMPTY_RESOLVER,
|
||||
},
|
||||
},
|
||||
sourcerer: {
|
||||
...mockSourcererState,
|
||||
defaultDataView: {
|
||||
|
|
|
@ -13,6 +13,7 @@ import { useSourcererDataView } from '../containers/sourcerer';
|
|||
import { useDeepEqualSelector } from '../hooks/use_selector';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { initialGroupingState } from './grouping/reducer';
|
||||
import { initialAnalyzerState } from '../../resolver/store/helpers';
|
||||
|
||||
jest.mock('../hooks/use_selector');
|
||||
jest.mock('../lib/kibana', () => ({
|
||||
|
@ -47,6 +48,9 @@ describe('createInitialState', () => {
|
|||
},
|
||||
{
|
||||
groups: initialGroupingState,
|
||||
},
|
||||
{
|
||||
analyzer: initialAnalyzerState,
|
||||
}
|
||||
);
|
||||
beforeEach(() => {
|
||||
|
@ -84,6 +88,9 @@ describe('createInitialState', () => {
|
|||
},
|
||||
{
|
||||
groups: initialGroupingState,
|
||||
},
|
||||
{
|
||||
analyzer: initialAnalyzerState,
|
||||
}
|
||||
);
|
||||
(useDeepEqualSelector as jest.Mock).mockImplementation((cb) => cb(state));
|
||||
|
|
|
@ -10,6 +10,7 @@ import { combineReducers } from 'redux';
|
|||
|
||||
import type { DataTableState } from '@kbn/securitysolution-data-table';
|
||||
import { dataTableReducer } from '@kbn/securitysolution-data-table';
|
||||
import { enableMapSet } from 'immer';
|
||||
import { appReducer, initialAppState } from './app';
|
||||
import { dragAndDropReducer, initialDragAndDropState } from './drag_and_drop';
|
||||
import { createInitialInputsState, inputsReducer } from './inputs';
|
||||
|
@ -31,6 +32,10 @@ import { getScopePatternListSelection } from './sourcerer/helpers';
|
|||
import { globalUrlParamReducer, initialGlobalUrlParam } from './global_url_param';
|
||||
import { groupsReducer } from './grouping/reducer';
|
||||
import type { GroupState } from './grouping/types';
|
||||
import { analyzerReducer } from '../../resolver/store/reducer';
|
||||
import type { AnalyzerOuterState } from '../../resolver/types';
|
||||
|
||||
enableMapSet();
|
||||
|
||||
export type SubPluginsInitReducer = HostsPluginReducer &
|
||||
UsersPluginReducer &
|
||||
|
@ -57,7 +62,8 @@ export const createInitialState = (
|
|||
enableExperimental: ExperimentalFeatures;
|
||||
},
|
||||
dataTableState: DataTableState,
|
||||
groupsState: GroupState
|
||||
groupsState: GroupState,
|
||||
analyzerState: AnalyzerOuterState
|
||||
): State => {
|
||||
const initialPatterns = {
|
||||
[SourcererScopeName.default]: getScopePatternListSelection(
|
||||
|
@ -112,6 +118,7 @@ export const createInitialState = (
|
|||
globalUrlParam: initialGlobalUrlParam,
|
||||
dataTable: dataTableState.dataTable,
|
||||
groups: groupsState.groups,
|
||||
analyzer: analyzerState.analyzer,
|
||||
};
|
||||
|
||||
return preloadedState;
|
||||
|
@ -131,5 +138,6 @@ export const createReducer: (
|
|||
globalUrlParam: globalUrlParamReducer,
|
||||
dataTable: dataTableReducer,
|
||||
groups: groupsReducer,
|
||||
analyzer: analyzerReducer,
|
||||
...pluginsReducer,
|
||||
});
|
||||
|
|
|
@ -18,11 +18,9 @@ import type {
|
|||
import { applyMiddleware, createStore as createReduxStore } from 'redux';
|
||||
import { composeWithDevTools } from 'redux-devtools-extension/developmentOnly';
|
||||
import type { EnhancerOptions } from 'redux-devtools-extension';
|
||||
|
||||
import { createEpicMiddleware } from 'redux-observable';
|
||||
import type { Observable } from 'rxjs';
|
||||
import { BehaviorSubject, pluck } from 'rxjs';
|
||||
|
||||
import type { Storage } from '@kbn/kibana-utils-plugin/public';
|
||||
import type { CoreStart } from '@kbn/core/public';
|
||||
import reduceReducers from 'reduce-reducers';
|
||||
|
@ -53,6 +51,9 @@ import { initDataView } from './sourcerer/model';
|
|||
import type { AppObservableLibs, StartedSubPlugins, StartPlugins } from '../../types';
|
||||
import type { ExperimentalFeatures } from '../../../common/experimental_features';
|
||||
import { createSourcererDataView } from '../containers/sourcerer/create_sourcerer_data_view';
|
||||
import type { AnalyzerOuterState } from '../../resolver/types';
|
||||
import { resolverMiddlewareFactory } from '../../resolver/store/middleware';
|
||||
import { dataAccessLayerFactory } from '../../resolver/data_access_layer/factory';
|
||||
import { sourcererActions } from './sourcerer';
|
||||
|
||||
let store: Store<State, Action> | null = null;
|
||||
|
@ -132,6 +133,12 @@ export const createStoreFactory = async (
|
|||
groups: initialGroupingState,
|
||||
};
|
||||
|
||||
const analyzerInitialState: AnalyzerOuterState = {
|
||||
analyzer: {
|
||||
analyzerById: {},
|
||||
},
|
||||
};
|
||||
|
||||
const timelineReducer = reduceReducers(
|
||||
timelineInitialState.timeline,
|
||||
startPlugins.timelines?.getTimelineReducer() ?? {},
|
||||
|
@ -151,7 +158,8 @@ export const createStoreFactory = async (
|
|||
enableExperimental,
|
||||
},
|
||||
dataTableInitialState,
|
||||
groupsInitialState
|
||||
groupsInitialState,
|
||||
analyzerInitialState
|
||||
);
|
||||
|
||||
const rootReducer = {
|
||||
|
@ -162,6 +170,7 @@ export const createStoreFactory = async (
|
|||
|
||||
return createStore(initialState, rootReducer, libs$.pipe(pluck('kibana')), storage, [
|
||||
...(subPlugins.management.store.middleware ?? []),
|
||||
...[resolverMiddlewareFactory(dataAccessLayerFactory(coreStart)) ?? []],
|
||||
]);
|
||||
};
|
||||
|
||||
|
@ -261,6 +270,7 @@ export const createStore = (
|
|||
): Store<State, Action> => {
|
||||
const enhancerOptions: EnhancerOptions = {
|
||||
name: 'Kibana Security Solution',
|
||||
actionsBlacklist: ['USER_MOVED_POINTER', 'USER_SET_RASTER_SIZE'],
|
||||
actionSanitizer: actionSanitizer as EnhancerOptions['actionSanitizer'],
|
||||
stateSanitizer: stateSanitizer as EnhancerOptions['stateSanitizer'],
|
||||
};
|
||||
|
|
|
@ -23,6 +23,7 @@ import type { ManagementPluginState } from '../../management';
|
|||
import type { UsersPluginState } from '../../explore/users/store';
|
||||
import type { GlobalUrlParam } from './global_url_param';
|
||||
import type { GroupState } from './grouping/types';
|
||||
import type { AnalyzerOuterState } from '../../resolver/types';
|
||||
|
||||
export type State = HostsPluginState &
|
||||
UsersPluginState &
|
||||
|
@ -36,7 +37,8 @@ export type State = HostsPluginState &
|
|||
sourcerer: SourcererState;
|
||||
globalUrlParam: GlobalUrlParam;
|
||||
} & DataTableState &
|
||||
GroupState;
|
||||
GroupState &
|
||||
AnalyzerOuterState;
|
||||
/**
|
||||
* The Redux store type for the Security app.
|
||||
*/
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
*/
|
||||
|
||||
import type { FC } from 'react';
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { EuiEmptyPrompt } from '@elastic/eui';
|
||||
|
||||
import { ANALYZER_ERROR_MESSAGE } from './translations';
|
||||
|
@ -24,10 +24,11 @@ export const ANALYZE_GRAPH_ID = 'analyze_graph';
|
|||
*/
|
||||
export const AnalyzeGraph: FC = () => {
|
||||
const { eventId } = useLeftPanelContext();
|
||||
const scopeId = 'flyout'; // TO-DO: update to use context
|
||||
const scopeId = 'flyout'; // Different scope Id to distinguish flyout and data table analyzers
|
||||
const { from, to, shouldUpdate, selectedPatterns } = useTimelineDataFilters(
|
||||
isActiveTimeline(scopeId)
|
||||
);
|
||||
const filters = useMemo(() => ({ from, to }), [from, to]);
|
||||
|
||||
if (!eventId) {
|
||||
return (
|
||||
|
@ -48,7 +49,7 @@ export const AnalyzeGraph: FC = () => {
|
|||
resolverComponentInstanceID={scopeId}
|
||||
indices={selectedPatterns}
|
||||
shouldUpdate={shouldUpdate}
|
||||
filters={{ from, to }}
|
||||
filters={filters}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -5,8 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { KibanaReactContextValue } from '@kbn/kibana-react-plugin/public';
|
||||
import type { StartServices } from '../../types';
|
||||
import type { CoreStart } from '@kbn/core/public';
|
||||
import type { DataAccessLayer, TimeRange } from '../types';
|
||||
import type {
|
||||
ResolverNode,
|
||||
|
@ -31,9 +30,7 @@ function getRangeFilter(timeRange: TimeRange | undefined) {
|
|||
/**
|
||||
* The data access layer for resolver. All communication with the Kibana server is done through this object. This object is provided to Resolver. In tests, a mock data access layer can be used instead.
|
||||
*/
|
||||
export function dataAccessLayerFactory(
|
||||
context: KibanaReactContextValue<StartServices>
|
||||
): DataAccessLayer {
|
||||
export function dataAccessLayerFactory(context: CoreStart): DataAccessLayer {
|
||||
const dataAccessLayer: DataAccessLayer = {
|
||||
/**
|
||||
* Used to get non-process related events for a node.
|
||||
|
@ -48,7 +45,7 @@ export function dataAccessLayerFactory(
|
|||
timeRange?: TimeRange;
|
||||
indexPatterns: string[];
|
||||
}): Promise<ResolverRelatedEvents> {
|
||||
const response: ResolverPaginatedEvents = await context.services.http.post(
|
||||
const response: ResolverPaginatedEvents = await context.http.post(
|
||||
'/api/endpoint/resolver/events',
|
||||
{
|
||||
query: {},
|
||||
|
@ -95,7 +92,7 @@ export function dataAccessLayerFactory(
|
|||
},
|
||||
};
|
||||
if (category === 'alert') {
|
||||
return context.services.http.post('/api/endpoint/resolver/events', {
|
||||
return context.http.post('/api/endpoint/resolver/events', {
|
||||
query: commonFields.query,
|
||||
body: JSON.stringify({
|
||||
...commonFields.body,
|
||||
|
@ -104,7 +101,7 @@ export function dataAccessLayerFactory(
|
|||
}),
|
||||
});
|
||||
} else {
|
||||
return context.services.http.post('/api/endpoint/resolver/events', {
|
||||
return context.http.post('/api/endpoint/resolver/events', {
|
||||
query: commonFields.query,
|
||||
body: JSON.stringify({
|
||||
...commonFields.body,
|
||||
|
@ -151,7 +148,7 @@ export function dataAccessLayerFactory(
|
|||
}),
|
||||
}),
|
||||
};
|
||||
const response: ResolverPaginatedEvents = await context.services.http.post(
|
||||
const response: ResolverPaginatedEvents = await context.http.post(
|
||||
'/api/endpoint/resolver/events',
|
||||
query
|
||||
);
|
||||
|
@ -197,7 +194,7 @@ export function dataAccessLayerFactory(
|
|||
},
|
||||
};
|
||||
if (eventCategory.includes('alert') === false) {
|
||||
const response: ResolverPaginatedEvents = await context.services.http.post(
|
||||
const response: ResolverPaginatedEvents = await context.http.post(
|
||||
'/api/endpoint/resolver/events',
|
||||
{
|
||||
query: { limit: 1 },
|
||||
|
@ -211,7 +208,7 @@ export function dataAccessLayerFactory(
|
|||
const [oneEvent] = response.events;
|
||||
return oneEvent ?? null;
|
||||
} else {
|
||||
const response: ResolverPaginatedEvents = await context.services.http.post(
|
||||
const response: ResolverPaginatedEvents = await context.http.post(
|
||||
'/api/endpoint/resolver/events',
|
||||
{
|
||||
query: { limit: 1 },
|
||||
|
@ -252,7 +249,7 @@ export function dataAccessLayerFactory(
|
|||
ancestors: number;
|
||||
descendants: number;
|
||||
}): Promise<ResolverNode[]> {
|
||||
return context.services.http.post('/api/endpoint/resolver/tree', {
|
||||
return context.http.post('/api/endpoint/resolver/tree', {
|
||||
body: JSON.stringify({
|
||||
ancestors,
|
||||
descendants,
|
||||
|
@ -276,7 +273,7 @@ export function dataAccessLayerFactory(
|
|||
indices: string[];
|
||||
signal: AbortSignal;
|
||||
}): Promise<ResolverEntityIndex> {
|
||||
return context.services.http.get('/api/endpoint/resolver/entity', {
|
||||
return context.http.get('/api/endpoint/resolver/entity', {
|
||||
signal,
|
||||
query: {
|
||||
_id,
|
||||
|
|
|
@ -5,17 +5,25 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { CameraAction } from './camera';
|
||||
import type { DataAction } from './data/action';
|
||||
import actionCreatorFactory from 'typescript-fsa';
|
||||
|
||||
const actionCreator = actionCreatorFactory('x-pack/security_solution/analyzer');
|
||||
|
||||
export const createResolver = actionCreator<{ id: string }>('CREATE_RESOLVER');
|
||||
|
||||
export const clearResolver = actionCreator<{ id: string }>('CLEAR_RESOLVER');
|
||||
|
||||
/**
|
||||
* The action dispatched when the app requests related event data for one
|
||||
* subject (whose entity_id should be included as `payload`)
|
||||
*/
|
||||
interface UserRequestedRelatedEventData {
|
||||
readonly type: 'userRequestedRelatedEventData';
|
||||
readonly payload: string;
|
||||
}
|
||||
export const userRequestedRelatedEventData = actionCreator<{
|
||||
/**
|
||||
* Id that identify the scope of analyzer
|
||||
*/
|
||||
id: string;
|
||||
readonly nodeID: string;
|
||||
}>('REQUEST_RELATED_EVENT');
|
||||
|
||||
/**
|
||||
* When the user switches the "active descendant" of the Resolver.
|
||||
|
@ -24,20 +32,20 @@ interface UserRequestedRelatedEventData {
|
|||
* the element that is focused on by the user's interactions with the UI, but
|
||||
* not necessarily "selected" (see UserSelectedResolverNode below)
|
||||
*/
|
||||
interface UserFocusedOnResolverNode {
|
||||
readonly type: 'userFocusedOnResolverNode';
|
||||
|
||||
readonly payload: {
|
||||
/**
|
||||
* Used to identify the node that should be brought into view.
|
||||
*/
|
||||
readonly nodeID: string;
|
||||
/**
|
||||
* The time (since epoch in milliseconds) when the action was dispatched.
|
||||
*/
|
||||
readonly time: number;
|
||||
};
|
||||
}
|
||||
export const userFocusedOnResolverNode = actionCreator<{
|
||||
/**
|
||||
* Id that identify the scope of analyzer
|
||||
*/
|
||||
readonly id: string;
|
||||
/**
|
||||
* Used to identify the node that should be brought into view.
|
||||
*/
|
||||
readonly nodeID: string;
|
||||
/**
|
||||
* The time (since epoch in milliseconds) when the action was dispatched.
|
||||
*/
|
||||
readonly time: number;
|
||||
}>('FOCUS_ON_NODE');
|
||||
|
||||
/**
|
||||
* When the user "selects" a node in the Resolver
|
||||
|
@ -45,62 +53,53 @@ interface UserFocusedOnResolverNode {
|
|||
* user most recently "picked" (by e.g. pressing a button corresponding
|
||||
* to the element in a list) as opposed to "active" or "current" (see UserFocusedOnResolverNode above).
|
||||
*/
|
||||
interface UserSelectedResolverNode {
|
||||
readonly type: 'userSelectedResolverNode';
|
||||
readonly payload: {
|
||||
/**
|
||||
* Used to identify the node that should be brought into view.
|
||||
*/
|
||||
readonly nodeID: string;
|
||||
/**
|
||||
* The time (since epoch in milliseconds) when the action was dispatched.
|
||||
*/
|
||||
readonly time: number;
|
||||
};
|
||||
}
|
||||
export const userSelectedResolverNode = actionCreator<{
|
||||
/**
|
||||
* Id that identify the scope of analyzer
|
||||
*/
|
||||
readonly id: string;
|
||||
/**
|
||||
* Used to identify the node that should be brought into view.
|
||||
*/
|
||||
readonly nodeID: string;
|
||||
/**
|
||||
* The time (since epoch in milliseconds) when the action was dispatched.
|
||||
*/
|
||||
readonly time: number;
|
||||
}>('SELECT_RESOLVER_NODE');
|
||||
|
||||
/**
|
||||
* Used by `useStateSyncingActions` hook.
|
||||
* This is dispatched when external sources provide new parameters for Resolver.
|
||||
* When the component receives a new 'databaseDocumentID' prop, this is fired.
|
||||
*/
|
||||
interface AppReceivedNewExternalProperties {
|
||||
type: 'appReceivedNewExternalProperties';
|
||||
export const appReceivedNewExternalProperties = actionCreator<{
|
||||
/**
|
||||
* Defines the externally provided properties that Resolver acknowledges.
|
||||
* Id that identify the scope of analyzer
|
||||
*/
|
||||
payload: {
|
||||
/**
|
||||
* the `_id` of an ES document. This defines the origin of the Resolver graph.
|
||||
*/
|
||||
databaseDocumentID: string;
|
||||
/**
|
||||
* An ID that uniquely identifies this Resolver instance from other concurrent Resolvers.
|
||||
*/
|
||||
resolverComponentInstanceID: string;
|
||||
readonly id: string;
|
||||
/**
|
||||
* the `_id` of an ES document. This defines the origin of the Resolver graph.
|
||||
*/
|
||||
readonly databaseDocumentID: string;
|
||||
/**
|
||||
* An ID that uniquely identifies this Resolver instance from other concurrent Resolvers.
|
||||
*/
|
||||
readonly resolverComponentInstanceID: string;
|
||||
|
||||
/**
|
||||
* The `search` part of the URL of this page.
|
||||
*/
|
||||
locationSearch: string;
|
||||
/**
|
||||
* The `search` part of the URL of this page.
|
||||
*/
|
||||
readonly locationSearch: string;
|
||||
|
||||
/**
|
||||
* Indices that the backend will use to find the document.
|
||||
*/
|
||||
indices: string[];
|
||||
/**
|
||||
* Indices that the backend will use to find the document.
|
||||
*/
|
||||
readonly indices: string[];
|
||||
|
||||
shouldUpdate: boolean;
|
||||
filters: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
};
|
||||
readonly shouldUpdate: boolean;
|
||||
readonly filters: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type ResolverAction =
|
||||
| CameraAction
|
||||
| DataAction
|
||||
| AppReceivedNewExternalProperties
|
||||
| UserFocusedOnResolverNode
|
||||
| UserSelectedResolverNode
|
||||
| UserRequestedRelatedEventData;
|
||||
}>('APP_RECEIVED_NEW_EXTERNAL_PROPERTIES');
|
||||
|
|
|
@ -5,112 +5,132 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import actionCreatorFactory from 'typescript-fsa';
|
||||
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.
|
||||
*/
|
||||
readonly time: number;
|
||||
}
|
||||
}>('USER_ZOOMED');
|
||||
|
||||
interface UserSetZoomLevel {
|
||||
readonly type: 'userSetZoomLevel';
|
||||
export const userSetRasterSize = actionCreator<{
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
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';
|
||||
readonly id: string;
|
||||
/**
|
||||
* The dimensions of the Resolver component in pixels. The Resolver component should not be scrollable itself.
|
||||
*/
|
||||
readonly payload: Vector2;
|
||||
}
|
||||
readonly dimensions: Vector2;
|
||||
}>('USER_SET_RASTER_SIZE');
|
||||
|
||||
/**
|
||||
* When the user warps the camera to an exact point instantly.
|
||||
*/
|
||||
interface UserSetPositionOfCamera {
|
||||
readonly type: 'userSetPositionOfCamera';
|
||||
export const userSetPositionOfCamera = actionCreator<{
|
||||
/**
|
||||
* Id that identify the scope of analyzer
|
||||
*/
|
||||
readonly id: string;
|
||||
/**
|
||||
* The world transform of the camera
|
||||
*/
|
||||
readonly payload: Vector2;
|
||||
}
|
||||
readonly cameraView: Vector2;
|
||||
}>('USER_SET_CAMERA_POSITION');
|
||||
|
||||
interface UserStartedPanning {
|
||||
readonly type: 'userStartedPanning';
|
||||
export const userStartedPanning = actionCreator<{
|
||||
/**
|
||||
* Id that identify the scope of analyzer
|
||||
*/
|
||||
readonly id: string;
|
||||
/**
|
||||
* A vector in screen coordinates (each unit is a pixel and the Y axis increases towards the bottom of the screen)
|
||||
* relative to the Resolver component.
|
||||
* Represents a starting position during panning for a pointing device.
|
||||
*/
|
||||
readonly screenCoordinates: Vector2;
|
||||
/**
|
||||
* Time (since epoch in milliseconds) when this action was dispatched.
|
||||
*/
|
||||
readonly time: number;
|
||||
}>('USER_STARTED_PANNING');
|
||||
|
||||
readonly payload: {
|
||||
/**
|
||||
* A vector in screen coordinates (each unit is a pixel and the Y axis increases towards the bottom of the screen)
|
||||
* relative to the Resolver component.
|
||||
* Represents a starting position during panning for a pointing device.
|
||||
*/
|
||||
readonly screenCoordinates: Vector2;
|
||||
} & TimestampedPayload;
|
||||
}
|
||||
export const userStoppedPanning = actionCreator<{
|
||||
/**
|
||||
* Id that identify the scope of analyzer
|
||||
*/
|
||||
readonly id: string;
|
||||
readonly time: number;
|
||||
}>('USER_STOPPED_PANNING');
|
||||
|
||||
interface UserStoppedPanning {
|
||||
readonly type: 'userStoppedPanning';
|
||||
|
||||
readonly payload: TimestampedPayload;
|
||||
}
|
||||
|
||||
interface UserNudgedCamera {
|
||||
readonly type: 'userNudgedCamera';
|
||||
export const userNudgedCamera = actionCreator<{
|
||||
/**
|
||||
* Id that identify the scope of analyzer
|
||||
*/
|
||||
readonly id: string;
|
||||
/**
|
||||
* String that represents the direction in which Resolver can be panned
|
||||
*/
|
||||
readonly payload: {
|
||||
/**
|
||||
* A cardinal direction to move the users perspective in.
|
||||
*/
|
||||
readonly direction: Vector2;
|
||||
} & TimestampedPayload;
|
||||
}
|
||||
/**
|
||||
* A cardinal direction to move the users perspective in.
|
||||
*/
|
||||
readonly direction: Vector2;
|
||||
/**
|
||||
* Time (since epoch in milliseconds) when this action was dispatched.
|
||||
*/
|
||||
readonly time: number;
|
||||
}>('USER_NUDGE_CAMERA');
|
||||
|
||||
interface UserMovedPointer {
|
||||
readonly type: 'userMovedPointer';
|
||||
readonly payload: {
|
||||
/**
|
||||
* A vector in screen coordinates relative to the Resolver component.
|
||||
* The payload should be contain clientX and clientY minus the client position of the Resolver component.
|
||||
*/
|
||||
screenCoordinates: Vector2;
|
||||
} & TimestampedPayload;
|
||||
}
|
||||
|
||||
export type CameraAction =
|
||||
| UserSetZoomLevel
|
||||
| UserSetRasterSize
|
||||
| UserSetPositionOfCamera
|
||||
| UserStartedPanning
|
||||
| UserStoppedPanning
|
||||
| UserZoomed
|
||||
| UserMovedPointer
|
||||
| UserClickedZoomOut
|
||||
| UserClickedZoomIn
|
||||
| UserNudgedCamera;
|
||||
export const userMovedPointer = actionCreator<{
|
||||
/**
|
||||
* Id that identify the scope of analyzer
|
||||
*/
|
||||
readonly id: string;
|
||||
/**
|
||||
* A vector in screen coordinates relative to the Resolver component.
|
||||
* The payload should be contain clientX and clientY minus the client position of the Resolver component.
|
||||
*/
|
||||
readonly screenCoordinates: Vector2;
|
||||
/**
|
||||
* Time (since epoch in milliseconds) when this action was dispatched.
|
||||
*/
|
||||
readonly time: number;
|
||||
}>('USER_MOVED_POINTER');
|
||||
|
|
|
@ -5,49 +5,46 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { Store, Reducer } from 'redux';
|
||||
import type { Store, Reducer, AnyAction } from 'redux';
|
||||
import { createStore } from 'redux';
|
||||
import { cameraReducer, cameraInitialState } from './reducer';
|
||||
import type { CameraState, Vector2 } from '../../types';
|
||||
import { cameraReducer } from './reducer';
|
||||
import type { AnalyzerState, Vector2 } from '../../types';
|
||||
import * as selectors from './selectors';
|
||||
import { animatePanning } from './methods';
|
||||
import { lerp } from '../../lib/math';
|
||||
import type { ResolverAction } from '../actions';
|
||||
import { panAnimationDuration } from './scaling_constants';
|
||||
|
||||
type TestAction =
|
||||
| ResolverAction
|
||||
| {
|
||||
readonly type: 'animatePanning';
|
||||
readonly payload: {
|
||||
/**
|
||||
* The start time of the animation.
|
||||
*/
|
||||
readonly time: number;
|
||||
/**
|
||||
* The duration of the animation.
|
||||
*/
|
||||
readonly duration: number;
|
||||
/**
|
||||
* The target translation the camera will animate towards.
|
||||
*/
|
||||
readonly targetTranslation: Vector2;
|
||||
};
|
||||
};
|
||||
import { EMPTY_RESOLVER } from '../helpers';
|
||||
|
||||
describe('when the camera is created', () => {
|
||||
let store: Store<CameraState, TestAction>;
|
||||
let store: Store<AnalyzerState, AnyAction>;
|
||||
const id = 'test-id';
|
||||
beforeEach(() => {
|
||||
const testReducer: Reducer<CameraState, TestAction> = (
|
||||
state = cameraInitialState(),
|
||||
const testReducer: Reducer<AnalyzerState, AnyAction> = (
|
||||
state = {
|
||||
analyzerById: {
|
||||
[id]: EMPTY_RESOLVER,
|
||||
},
|
||||
},
|
||||
action
|
||||
): CameraState => {
|
||||
): AnalyzerState => {
|
||||
// If the test action is fired, call the animatePanning method
|
||||
if (action.type === 'animatePanning') {
|
||||
const {
|
||||
payload: { time, targetTranslation, duration },
|
||||
} = action;
|
||||
return animatePanning(state, time, targetTranslation, duration);
|
||||
return {
|
||||
analyzerById: {
|
||||
[id]: {
|
||||
...state.analyzerById[id],
|
||||
camera: animatePanning(
|
||||
state.analyzerById[id].camera,
|
||||
time,
|
||||
targetTranslation,
|
||||
duration
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return cameraReducer(state, action);
|
||||
};
|
||||
|
@ -55,17 +52,17 @@ describe('when the camera is created', () => {
|
|||
});
|
||||
|
||||
it('should be at 0,0', () => {
|
||||
expect(selectors.translation(store.getState())(0)).toEqual([0, 0]);
|
||||
expect(selectors.translation(store.getState().analyzerById[id].camera)(0)).toEqual([0, 0]);
|
||||
});
|
||||
it('should have scale of [1,1]', () => {
|
||||
expect(selectors.scale(store.getState())(0)).toEqual([1, 1]);
|
||||
expect(selectors.scale(store.getState().analyzerById[id].camera)(0)).toEqual([1, 1]);
|
||||
});
|
||||
|
||||
describe('When attempting to pan to current position and scale', () => {
|
||||
const duration = panAnimationDuration;
|
||||
const startTime = 0;
|
||||
beforeEach(() => {
|
||||
const action: TestAction = {
|
||||
const action: AnyAction = {
|
||||
type: 'animatePanning',
|
||||
payload: {
|
||||
time: startTime,
|
||||
|
@ -85,10 +82,14 @@ describe('when the camera is created', () => {
|
|||
const state = store.getState();
|
||||
for (let progress = 0; progress <= 1; progress += 0.1) {
|
||||
translationAtIntervals.push(
|
||||
selectors.translation(state)(lerp(startTime, startTime + duration, progress))
|
||||
selectors.translation(state.analyzerById[id].camera)(
|
||||
lerp(startTime, startTime + duration, progress)
|
||||
)
|
||||
);
|
||||
scaleAtIntervals.push(
|
||||
selectors.scale(state)(lerp(startTime, startTime + duration, progress))
|
||||
selectors.scale(state.analyzerById[id].camera)(
|
||||
lerp(startTime, startTime + duration, progress)
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
@ -110,7 +111,7 @@ describe('when the camera is created', () => {
|
|||
beforeEach(() => {
|
||||
// The distance the camera moves must be nontrivial in order to trigger a scale animation
|
||||
targetTranslation = [1000, 1000];
|
||||
const action: TestAction = {
|
||||
const action: AnyAction = {
|
||||
type: 'animatePanning',
|
||||
payload: {
|
||||
time: startTime,
|
||||
|
@ -129,10 +130,14 @@ describe('when the camera is created', () => {
|
|||
const state = store.getState();
|
||||
for (let progress = 0; progress <= 1; progress += 0.1) {
|
||||
translationAtIntervals.push(
|
||||
selectors.translation(state)(lerp(startTime, startTime + duration, progress))
|
||||
selectors.translation(state.analyzerById[id].camera)(
|
||||
lerp(startTime, startTime + duration, progress)
|
||||
)
|
||||
);
|
||||
scaleAtIntervals.push(
|
||||
selectors.scale(state)(lerp(startTime, startTime + duration, progress))
|
||||
selectors.scale(state.analyzerById[id].camera)(
|
||||
lerp(startTime, startTime + duration, progress)
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -20,4 +20,3 @@
|
|||
* would not be in the camera's viewport would be ignored.
|
||||
*/
|
||||
export { cameraReducer } from './reducer';
|
||||
export type { CameraAction } from './action';
|
||||
|
|
|
@ -5,26 +5,36 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { Store } from 'redux';
|
||||
import type { Store, AnyAction, Reducer } from 'redux';
|
||||
import { createStore } from 'redux';
|
||||
import type { CameraAction } from './action';
|
||||
import type { CameraState } from '../../types';
|
||||
import type { AnalyzerState } from '../../types';
|
||||
import { cameraReducer } from './reducer';
|
||||
import { inverseProjectionMatrix } from './selectors';
|
||||
import { applyMatrix3 } from '../../models/vector2';
|
||||
import { scaleToZoom } from './scale_to_zoom';
|
||||
import { EMPTY_RESOLVER } from '../helpers';
|
||||
import { userSetZoomLevel, userSetPositionOfCamera, userSetRasterSize } from './action';
|
||||
|
||||
describe('inverseProjectionMatrix', () => {
|
||||
let store: Store<CameraState, CameraAction>;
|
||||
let store: Store<AnalyzerState, AnyAction>;
|
||||
let compare: (worldPosition: [number, number], expectedRasterPosition: [number, number]) => void;
|
||||
const id = 'test-id';
|
||||
beforeEach(() => {
|
||||
store = createStore(cameraReducer, undefined);
|
||||
const testReducer: Reducer<AnalyzerState, AnyAction> = (
|
||||
state = {
|
||||
analyzerById: {
|
||||
[id]: EMPTY_RESOLVER,
|
||||
},
|
||||
},
|
||||
action
|
||||
): AnalyzerState => cameraReducer(state, action);
|
||||
store = createStore(testReducer, undefined);
|
||||
compare = (rasterPosition: [number, number], expectedWorldPosition: [number, number]) => {
|
||||
// time isn't really relevant as we aren't testing animation
|
||||
const time = 0;
|
||||
const [worldX, worldY] = applyMatrix3(
|
||||
rasterPosition,
|
||||
inverseProjectionMatrix(store.getState())(time)
|
||||
inverseProjectionMatrix(store.getState().analyzerById[id].camera)(time)
|
||||
);
|
||||
expect(worldX).toBeCloseTo(expectedWorldPosition[0]);
|
||||
expect(worldY).toBeCloseTo(expectedWorldPosition[1]);
|
||||
|
@ -33,8 +43,7 @@ describe('inverseProjectionMatrix', () => {
|
|||
|
||||
describe('when the raster size is 0x0 pixels', () => {
|
||||
beforeEach(() => {
|
||||
const action: CameraAction = { type: 'userSetRasterSize', payload: [0, 0] };
|
||||
store.dispatch(action);
|
||||
store.dispatch(userSetRasterSize({ id, dimensions: [0, 0] }));
|
||||
});
|
||||
it('should convert 0,0 in raster space to 0,0 (center) in world space', () => {
|
||||
compare([10, 0], [0, 0]);
|
||||
|
@ -43,8 +52,7 @@ describe('inverseProjectionMatrix', () => {
|
|||
|
||||
describe('when the raster size is 300 x 200 pixels', () => {
|
||||
beforeEach(() => {
|
||||
const action: CameraAction = { type: 'userSetRasterSize', payload: [300, 200] };
|
||||
store.dispatch(action);
|
||||
store.dispatch(userSetRasterSize({ id, dimensions: [300, 200] }));
|
||||
});
|
||||
it('should convert 150,100 in raster space to 0,0 (center) in world space', () => {
|
||||
compare([150, 100], [0, 0]);
|
||||
|
@ -75,8 +83,7 @@ describe('inverseProjectionMatrix', () => {
|
|||
});
|
||||
describe('when the user has zoomed to 0.5', () => {
|
||||
beforeEach(() => {
|
||||
const action: CameraAction = { type: 'userSetZoomLevel', payload: scaleToZoom(0.5) };
|
||||
store.dispatch(action);
|
||||
store.dispatch(userSetZoomLevel({ id, zoomLevel: scaleToZoom(0.5) }));
|
||||
});
|
||||
it('should convert 150, 100 (center) to 0, 0 (center) in world space', () => {
|
||||
compare([150, 100], [0, 0]);
|
||||
|
@ -84,8 +91,7 @@ describe('inverseProjectionMatrix', () => {
|
|||
});
|
||||
describe('when the user has panned to the right and up by 50', () => {
|
||||
beforeEach(() => {
|
||||
const action: CameraAction = { type: 'userSetPositionOfCamera', payload: [50, 50] };
|
||||
store.dispatch(action);
|
||||
store.dispatch(userSetPositionOfCamera({ id, cameraView: [50, 50] }));
|
||||
});
|
||||
it('should convert 100,150 in raster space to 0,0 (center) in world space', () => {
|
||||
compare([100, 150], [0, 0]);
|
||||
|
@ -99,14 +105,12 @@ describe('inverseProjectionMatrix', () => {
|
|||
});
|
||||
describe('when the user has panned to the right by 350 and up by 250', () => {
|
||||
beforeEach(() => {
|
||||
const action: CameraAction = { type: 'userSetPositionOfCamera', payload: [350, 250] };
|
||||
store.dispatch(action);
|
||||
store.dispatch(userSetPositionOfCamera({ id, cameraView: [350, 250] }));
|
||||
});
|
||||
describe('when the user has scaled to 2', () => {
|
||||
// the viewport will only cover half, or 150x100 instead of 300x200
|
||||
beforeEach(() => {
|
||||
const action: CameraAction = { type: 'userSetZoomLevel', payload: scaleToZoom(2) };
|
||||
store.dispatch(action);
|
||||
store.dispatch(userSetZoomLevel({ id, zoomLevel: scaleToZoom(2) }));
|
||||
});
|
||||
// we expect the viewport to be
|
||||
// minX = 350 - (150/2) = 275
|
||||
|
|
|
@ -5,65 +5,68 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { Store } from 'redux';
|
||||
import type { Store, Reducer, AnyAction } from 'redux';
|
||||
import { createStore } from 'redux';
|
||||
import { cameraReducer } from './reducer';
|
||||
import type { CameraState, Vector2 } from '../../types';
|
||||
import type { CameraAction } from './action';
|
||||
import type { AnalyzerState, Vector2 } from '../../types';
|
||||
import { translation } from './selectors';
|
||||
import { EMPTY_RESOLVER } from '../helpers';
|
||||
import {
|
||||
userStartedPanning,
|
||||
userStoppedPanning,
|
||||
userNudgedCamera,
|
||||
userSetRasterSize,
|
||||
userMovedPointer,
|
||||
} from './action';
|
||||
|
||||
describe('panning interaction', () => {
|
||||
let store: Store<CameraState, CameraAction>;
|
||||
let store: Store<AnalyzerState, AnyAction>;
|
||||
let translationShouldBeCloseTo: (expectedTranslation: Vector2) => void;
|
||||
let time: number;
|
||||
const id = 'test-id';
|
||||
|
||||
beforeEach(() => {
|
||||
// The time isn't relevant as we don't use animations in this suite.
|
||||
time = 0;
|
||||
store = createStore(cameraReducer, undefined);
|
||||
const testReducer: Reducer<AnalyzerState, AnyAction> = (
|
||||
state = {
|
||||
analyzerById: {
|
||||
[id]: EMPTY_RESOLVER,
|
||||
},
|
||||
},
|
||||
action
|
||||
): AnalyzerState => cameraReducer(state, action);
|
||||
store = createStore(testReducer, undefined);
|
||||
translationShouldBeCloseTo = (expectedTranslation) => {
|
||||
const actualTranslation = translation(store.getState())(time);
|
||||
const actualTranslation = translation(store.getState().analyzerById[id].camera)(time);
|
||||
expect(expectedTranslation[0]).toBeCloseTo(actualTranslation[0]);
|
||||
expect(expectedTranslation[1]).toBeCloseTo(actualTranslation[1]);
|
||||
};
|
||||
});
|
||||
describe('when the raster size is 300 x 200 pixels', () => {
|
||||
beforeEach(() => {
|
||||
const action: CameraAction = { type: 'userSetRasterSize', payload: [300, 200] };
|
||||
store.dispatch(action);
|
||||
store.dispatch(userSetRasterSize({ id, dimensions: [300, 200] }));
|
||||
});
|
||||
it('should have a translation of 0,0', () => {
|
||||
translationShouldBeCloseTo([0, 0]);
|
||||
});
|
||||
describe('when the user has started panning at (100, 100)', () => {
|
||||
beforeEach(() => {
|
||||
const action: CameraAction = {
|
||||
type: 'userStartedPanning',
|
||||
payload: { screenCoordinates: [100, 100], time },
|
||||
};
|
||||
store.dispatch(action);
|
||||
store.dispatch(userStartedPanning({ id, screenCoordinates: [100, 100], time }));
|
||||
});
|
||||
it('should have a translation of 0,0', () => {
|
||||
translationShouldBeCloseTo([0, 0]);
|
||||
});
|
||||
describe('when the user moves their pointer 50px up and right (towards the top right of the screen)', () => {
|
||||
beforeEach(() => {
|
||||
const action: CameraAction = {
|
||||
type: 'userMovedPointer',
|
||||
payload: { screenCoordinates: [150, 50], time },
|
||||
};
|
||||
store.dispatch(action);
|
||||
store.dispatch(userMovedPointer({ id, screenCoordinates: [150, 50], time }));
|
||||
});
|
||||
it('should have a translation of [-50, -50] as the camera is now focused on things lower and to the left.', () => {
|
||||
translationShouldBeCloseTo([-50, -50]);
|
||||
});
|
||||
describe('when the user then stops panning', () => {
|
||||
beforeEach(() => {
|
||||
const action: CameraAction = {
|
||||
type: 'userStoppedPanning',
|
||||
payload: { time },
|
||||
};
|
||||
store.dispatch(action);
|
||||
store.dispatch(userStoppedPanning({ id, time }));
|
||||
});
|
||||
it('should still have a translation of [-50, -50]', () => {
|
||||
translationShouldBeCloseTo([-50, -50]);
|
||||
|
@ -74,11 +77,7 @@ describe('panning interaction', () => {
|
|||
});
|
||||
describe('when the user nudges the camera up', () => {
|
||||
beforeEach(() => {
|
||||
const action: CameraAction = {
|
||||
type: 'userNudgedCamera',
|
||||
payload: { direction: [0, 1], time },
|
||||
};
|
||||
store.dispatch(action);
|
||||
store.dispatch(userNudgedCamera({ id, direction: [0, 1], time }));
|
||||
});
|
||||
it('the camera eventually moves up so that objects appear closer to the bottom of the screen', () => {
|
||||
const aBitIntoTheFuture = time + 100;
|
||||
|
@ -86,7 +85,9 @@ describe('panning interaction', () => {
|
|||
/**
|
||||
* Check the position once the animation has advanced 100ms
|
||||
*/
|
||||
const actual: Vector2 = translation(store.getState())(aBitIntoTheFuture);
|
||||
const actual: Vector2 = translation(store.getState().analyzerById[id].camera)(
|
||||
aBitIntoTheFuture
|
||||
);
|
||||
expect(actual).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
0,
|
||||
|
|
|
@ -5,26 +5,37 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { Store } from 'redux';
|
||||
import type { Store, AnyAction, Reducer } from 'redux';
|
||||
import { createStore } from 'redux';
|
||||
import type { CameraAction } from './action';
|
||||
import type { CameraState } from '../../types';
|
||||
// import type { AnyAction } from './action';
|
||||
import type { AnalyzerState } from '../../types';
|
||||
import { cameraReducer } from './reducer';
|
||||
import { projectionMatrix } from './selectors';
|
||||
import { applyMatrix3 } from '../../models/vector2';
|
||||
import { scaleToZoom } from './scale_to_zoom';
|
||||
import { userSetZoomLevel, userSetPositionOfCamera, userSetRasterSize } from './action';
|
||||
import { EMPTY_RESOLVER } from '../helpers';
|
||||
|
||||
describe('projectionMatrix', () => {
|
||||
let store: Store<CameraState, CameraAction>;
|
||||
let store: Store<AnalyzerState, AnyAction>;
|
||||
let compare: (worldPosition: [number, number], expectedRasterPosition: [number, number]) => void;
|
||||
const id = 'test-id';
|
||||
beforeEach(() => {
|
||||
store = createStore(cameraReducer, undefined);
|
||||
const testReducer: Reducer<AnalyzerState, AnyAction> = (
|
||||
state = {
|
||||
analyzerById: {
|
||||
[id]: EMPTY_RESOLVER,
|
||||
},
|
||||
},
|
||||
action
|
||||
): AnalyzerState => cameraReducer(state, action);
|
||||
store = createStore(testReducer, undefined);
|
||||
compare = (worldPosition: [number, number], expectedRasterPosition: [number, number]) => {
|
||||
// time isn't really relevant as we aren't testing animation
|
||||
const time = 0;
|
||||
const [rasterX, rasterY] = applyMatrix3(
|
||||
worldPosition,
|
||||
projectionMatrix(store.getState())(time)
|
||||
projectionMatrix(store.getState().analyzerById[id].camera)(time)
|
||||
);
|
||||
expect(rasterX).toBeCloseTo(expectedRasterPosition[0]);
|
||||
expect(rasterY).toBeCloseTo(expectedRasterPosition[1]);
|
||||
|
@ -37,8 +48,7 @@ describe('projectionMatrix', () => {
|
|||
});
|
||||
describe('when the raster size is 300 x 200 pixels', () => {
|
||||
beforeEach(() => {
|
||||
const action: CameraAction = { type: 'userSetRasterSize', payload: [300, 200] };
|
||||
store.dispatch(action);
|
||||
store.dispatch(userSetRasterSize({ id, dimensions: [300, 200] }));
|
||||
});
|
||||
it('should convert 0,0 (center) in world space to 150,100 in raster space', () => {
|
||||
compare([0, 0], [150, 100]);
|
||||
|
@ -69,8 +79,7 @@ describe('projectionMatrix', () => {
|
|||
});
|
||||
describe('when the user has zoomed to 0.5', () => {
|
||||
beforeEach(() => {
|
||||
const action: CameraAction = { type: 'userSetZoomLevel', payload: scaleToZoom(0.5) };
|
||||
store.dispatch(action);
|
||||
store.dispatch(userSetZoomLevel({ id, zoomLevel: scaleToZoom(0.5) }));
|
||||
});
|
||||
it('should convert 0, 0 (center) in world space to 150, 100 (center)', () => {
|
||||
compare([0, 0], [150, 100]);
|
||||
|
@ -78,8 +87,7 @@ describe('projectionMatrix', () => {
|
|||
});
|
||||
describe('when the user has panned to the right and up by 50', () => {
|
||||
beforeEach(() => {
|
||||
const action: CameraAction = { type: 'userSetPositionOfCamera', payload: [50, 50] };
|
||||
store.dispatch(action);
|
||||
store.dispatch(userSetPositionOfCamera({ id, cameraView: [50, 50] }));
|
||||
});
|
||||
it('should convert 0,0 (center) in world space to 100,150 in raster space', () => {
|
||||
compare([0, 0], [100, 150]);
|
||||
|
@ -93,11 +101,7 @@ describe('projectionMatrix', () => {
|
|||
});
|
||||
describe('when the user has panned to the right by 350 and up by 250', () => {
|
||||
beforeEach(() => {
|
||||
const action: CameraAction = {
|
||||
type: 'userSetPositionOfCamera',
|
||||
payload: [350, 250],
|
||||
};
|
||||
store.dispatch(action);
|
||||
store.dispatch(userSetPositionOfCamera({ id, cameraView: [350, 250] }));
|
||||
});
|
||||
it('should convert 350,250 in world space to 150,100 (center) in raster space', () => {
|
||||
compare([350, 250], [150, 100]);
|
||||
|
@ -105,8 +109,7 @@ describe('projectionMatrix', () => {
|
|||
describe('when the user has scaled to 2', () => {
|
||||
// the viewport will only cover half, or 150x100 instead of 300x200
|
||||
beforeEach(() => {
|
||||
const action: CameraAction = { type: 'userSetZoomLevel', payload: scaleToZoom(2) };
|
||||
store.dispatch(action);
|
||||
store.dispatch(userSetZoomLevel({ id, zoomLevel: scaleToZoom(2) }));
|
||||
});
|
||||
// we expect the viewport to be
|
||||
// minX = 350 - (150/2) = 275
|
||||
|
|
|
@ -5,197 +5,203 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { Reducer } from 'redux';
|
||||
import type { Draft } from 'immer';
|
||||
import { reducerWithInitialState } from 'typescript-fsa-reducers';
|
||||
import { unitsPerNudge, nudgeAnimationDuration } from './scaling_constants';
|
||||
import { animatePanning } from './methods';
|
||||
import * as vector2 from '../../models/vector2';
|
||||
import * as selectors from './selectors';
|
||||
import { clamp } from '../../lib/math';
|
||||
|
||||
import type { CameraState, Vector2 } from '../../types';
|
||||
import { scaleToZoom } from './scale_to_zoom';
|
||||
import type { ResolverAction } from '../actions';
|
||||
|
||||
/**
|
||||
* Used in tests.
|
||||
*/
|
||||
export function cameraInitialState(): CameraState {
|
||||
const state: CameraState = {
|
||||
scalingFactor: scaleToZoom(1), // Defaulted to 1 to 1 scale
|
||||
rasterSize: [0, 0] as const,
|
||||
translationNotCountingCurrentPanning: [0, 0] as const,
|
||||
latestFocusedWorldCoordinates: null,
|
||||
animation: undefined,
|
||||
panning: undefined,
|
||||
};
|
||||
return state;
|
||||
}
|
||||
|
||||
export const cameraReducer: Reducer<CameraState, ResolverAction> = (
|
||||
state = cameraInitialState(),
|
||||
action
|
||||
) => {
|
||||
if (action.type === 'userSetZoomLevel') {
|
||||
/**
|
||||
* 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)
|
||||
);
|
||||
import { initialAnalyzerState, immerCase } from '../helpers';
|
||||
import {
|
||||
userSetZoomLevel,
|
||||
userClickedZoomOut,
|
||||
userClickedZoomIn,
|
||||
userZoomed,
|
||||
userStartedPanning,
|
||||
userStoppedPanning,
|
||||
userSetPositionOfCamera,
|
||||
userNudgedCamera,
|
||||
userSetRasterSize,
|
||||
userMovedPointer,
|
||||
} from './action';
|
||||
|
||||
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(
|
||||
newWorldCoordinatesAtLastFocusedPosition,
|
||||
state.latestFocusedWorldCoordinates
|
||||
);
|
||||
|
||||
/**
|
||||
* Adjust for the change in position due to scale.
|
||||
*/
|
||||
const translationNotCountingCurrentPanning: Vector2 = vector2.subtract(
|
||||
stateWithNewScaling.translationNotCountingCurrentPanning,
|
||||
delta
|
||||
);
|
||||
|
||||
const nextState: CameraState = {
|
||||
...stateWithNewScaling,
|
||||
translationNotCountingCurrentPanning,
|
||||
};
|
||||
|
||||
return nextState;
|
||||
} else {
|
||||
return stateWithNewScaling;
|
||||
}
|
||||
} else if (action.type === 'userSetPositionOfCamera') {
|
||||
/**
|
||||
* 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 = {
|
||||
const state: Draft<CameraState> = draft.analyzerById[id].camera;
|
||||
state.scalingFactor = clamp(zoomLevel, 0, 1);
|
||||
return draft;
|
||||
})
|
||||
)
|
||||
.withHandling(
|
||||
immerCase(userClickedZoomIn, (draft, { id }) => {
|
||||
const state: Draft<CameraState> = draft.analyzerById[id].camera;
|
||||
state.scalingFactor = clamp(state.scalingFactor + 0.1, 0, 1);
|
||||
return draft;
|
||||
})
|
||||
)
|
||||
.withHandling(
|
||||
immerCase(userClickedZoomOut, (draft, { id }) => {
|
||||
const state: Draft<CameraState> = draft.analyzerById[id].camera;
|
||||
state.scalingFactor = clamp(state.scalingFactor - 0.1, 0, 1);
|
||||
return draft;
|
||||
})
|
||||
)
|
||||
.withHandling(
|
||||
immerCase(userZoomed, (draft, { id, zoomChange, time }) => {
|
||||
const state: Draft<CameraState> = draft.analyzerById[id].camera;
|
||||
const stateWithNewScaling: Draft<CameraState> = {
|
||||
...state,
|
||||
panning: {
|
||||
origin: state.panning.origin,
|
||||
currentOffset: action.payload.screenCoordinates,
|
||||
},
|
||||
scalingFactor: clamp(state.scalingFactor + zoomChange, 0, 1),
|
||||
};
|
||||
}
|
||||
const nextState: CameraState = {
|
||||
...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.
|
||||
* 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.
|
||||
*/
|
||||
latestFocusedWorldCoordinates: vector2.applyMatrix3(
|
||||
action.payload.screenCoordinates,
|
||||
selectors.inverseProjectionMatrix(stateWithUpdatedPanning)(action.payload.time)
|
||||
),
|
||||
};
|
||||
return nextState;
|
||||
} else {
|
||||
return state;
|
||||
}
|
||||
};
|
||||
if (state.latestFocusedWorldCoordinates !== null && !selectors.isAnimating(state)(time)) {
|
||||
const rasterOfLastFocusedWorldCoordinates = vector2.applyMatrix3(
|
||||
state.latestFocusedWorldCoordinates,
|
||||
selectors.projectionMatrix(state)(time)
|
||||
);
|
||||
const newWorldCoordinatesAtLastFocusedPosition = vector2.applyMatrix3(
|
||||
rasterOfLastFocusedWorldCoordinates,
|
||||
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.
|
||||
*/
|
||||
|
||||
import type { CameraAction } from './action';
|
||||
import { cameraReducer } from './reducer';
|
||||
import type { Store } from 'redux';
|
||||
import type { Store, AnyAction, Reducer } from 'redux';
|
||||
import { createStore } from 'redux';
|
||||
import type { CameraState, AABB } from '../../types';
|
||||
import type { AnalyzerState, CameraState, AABB } from '../../types';
|
||||
import { viewableBoundingBox, inverseProjectionMatrix, scalingFactor } from './selectors';
|
||||
import { expectVectorsToBeClose } from './test_helpers';
|
||||
import { scaleToZoom } from './scale_to_zoom';
|
||||
import { applyMatrix3 } from '../../models/vector2';
|
||||
import { EMPTY_RESOLVER } from '../helpers';
|
||||
import {
|
||||
userSetZoomLevel,
|
||||
userClickedZoomOut,
|
||||
userClickedZoomIn,
|
||||
userZoomed,
|
||||
userSetPositionOfCamera,
|
||||
userSetRasterSize,
|
||||
userMovedPointer,
|
||||
} from './action';
|
||||
|
||||
describe('zooming', () => {
|
||||
let store: Store<CameraState, CameraAction>;
|
||||
let store: Store<AnalyzerState, AnyAction>;
|
||||
let time: number;
|
||||
const id = 'test-id';
|
||||
|
||||
const cameraShouldBeBoundBy = (expectedViewableBoundingBox: AABB): [string, () => void] => {
|
||||
return [
|
||||
`the camera view should be bound by an AABB with a minimum point of ${expectedViewableBoundingBox.minimum} and a maximum point of ${expectedViewableBoundingBox.maximum}`,
|
||||
() => {
|
||||
const actual = viewableBoundingBox(store.getState())(time);
|
||||
const actual = viewableBoundingBox(store.getState().analyzerById[id].camera)(time);
|
||||
expect(actual.minimum[0]).toBeCloseTo(expectedViewableBoundingBox.minimum[0]);
|
||||
expect(actual.minimum[1]).toBeCloseTo(expectedViewableBoundingBox.minimum[1]);
|
||||
expect(actual.maximum[0]).toBeCloseTo(expectedViewableBoundingBox.maximum[0]);
|
||||
|
@ -34,12 +44,19 @@ describe('zooming', () => {
|
|||
beforeEach(() => {
|
||||
// Time isn't relevant as we aren't testing animation
|
||||
time = 0;
|
||||
store = createStore(cameraReducer, undefined);
|
||||
const testReducer: Reducer<AnalyzerState, AnyAction> = (
|
||||
state = {
|
||||
analyzerById: {
|
||||
[id]: EMPTY_RESOLVER,
|
||||
},
|
||||
},
|
||||
action
|
||||
): AnalyzerState => cameraReducer(state, action);
|
||||
store = createStore(testReducer, undefined);
|
||||
});
|
||||
describe('when the raster size is 300 x 200 pixels', () => {
|
||||
beforeEach(() => {
|
||||
const action: CameraAction = { type: 'userSetRasterSize', payload: [300, 200] };
|
||||
store.dispatch(action);
|
||||
store.dispatch(userSetRasterSize({ id, dimensions: [300, 200] }));
|
||||
});
|
||||
it(
|
||||
...cameraShouldBeBoundBy({
|
||||
|
@ -49,8 +66,7 @@ describe('zooming', () => {
|
|||
);
|
||||
describe('when the user has scaled in to 2x', () => {
|
||||
beforeEach(() => {
|
||||
const action: CameraAction = { type: 'userSetZoomLevel', payload: scaleToZoom(2) };
|
||||
store.dispatch(action);
|
||||
store.dispatch(userSetZoomLevel({ id, zoomLevel: scaleToZoom(2) }));
|
||||
});
|
||||
it(
|
||||
...cameraShouldBeBoundBy({
|
||||
|
@ -61,14 +77,10 @@ describe('zooming', () => {
|
|||
});
|
||||
describe('when the user zooms in all the way', () => {
|
||||
beforeEach(() => {
|
||||
const action: CameraAction = {
|
||||
type: 'userZoomed',
|
||||
payload: { zoomChange: 1, time },
|
||||
};
|
||||
store.dispatch(action);
|
||||
store.dispatch(userZoomed({ id, zoomChange: 1, time }));
|
||||
});
|
||||
it('should zoom to maximum scale factor', () => {
|
||||
const actual = viewableBoundingBox(store.getState())(time);
|
||||
const actual = viewableBoundingBox(store.getState().analyzerById[id].camera)(time);
|
||||
expect(actual).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"maximum": Array [
|
||||
|
@ -85,20 +97,19 @@ describe('zooming', () => {
|
|||
});
|
||||
it('the raster position 200, 50 should map to the world position 50, 50', () => {
|
||||
expectVectorsToBeClose(
|
||||
applyMatrix3([200, 50], inverseProjectionMatrix(store.getState())(time)),
|
||||
applyMatrix3(
|
||||
[200, 50],
|
||||
inverseProjectionMatrix(store.getState().analyzerById[id].camera)(time)
|
||||
),
|
||||
[50, 50]
|
||||
);
|
||||
});
|
||||
describe('when the user has moved their mouse to the raster position 200, 50', () => {
|
||||
beforeEach(() => {
|
||||
const action: CameraAction = {
|
||||
type: 'userMovedPointer',
|
||||
payload: { screenCoordinates: [200, 50], time },
|
||||
};
|
||||
store.dispatch(action);
|
||||
store.dispatch(userMovedPointer({ id, screenCoordinates: [200, 50], time }));
|
||||
});
|
||||
it('should have focused the world position 50, 50', () => {
|
||||
const coords = store.getState().latestFocusedWorldCoordinates;
|
||||
const coords = store.getState().analyzerById[id].camera.latestFocusedWorldCoordinates;
|
||||
if (coords !== null) {
|
||||
expectVectorsToBeClose(coords, [50, 50]);
|
||||
} else {
|
||||
|
@ -107,15 +118,14 @@ describe('zooming', () => {
|
|||
});
|
||||
describe('when the user zooms in by 0.5 zoom units', () => {
|
||||
beforeEach(() => {
|
||||
const action: CameraAction = {
|
||||
type: 'userZoomed',
|
||||
payload: { zoomChange: 0.5, time },
|
||||
};
|
||||
store.dispatch(action);
|
||||
store.dispatch(userZoomed({ id, zoomChange: 0.5, time }));
|
||||
});
|
||||
it('the raster position 200, 50 should map to the world position 50, 50', () => {
|
||||
expectVectorsToBeClose(
|
||||
applyMatrix3([200, 50], inverseProjectionMatrix(store.getState())(time)),
|
||||
applyMatrix3(
|
||||
[200, 50],
|
||||
inverseProjectionMatrix(store.getState().analyzerById[id].camera)(time)
|
||||
),
|
||||
[50, 50]
|
||||
);
|
||||
});
|
||||
|
@ -123,8 +133,7 @@ describe('zooming', () => {
|
|||
});
|
||||
describe('when the user pans right by 100 pixels', () => {
|
||||
beforeEach(() => {
|
||||
const action: CameraAction = { type: 'userSetPositionOfCamera', payload: [100, 0] };
|
||||
store.dispatch(action);
|
||||
store.dispatch(userSetPositionOfCamera({ id, cameraView: [100, 0] }));
|
||||
});
|
||||
it(
|
||||
...cameraShouldBeBoundBy({
|
||||
|
@ -135,20 +144,19 @@ describe('zooming', () => {
|
|||
it('should be centered on 100, 0', () => {
|
||||
const worldCenterPoint = applyMatrix3(
|
||||
[150, 100],
|
||||
inverseProjectionMatrix(store.getState())(time)
|
||||
inverseProjectionMatrix(store.getState().analyzerById[id].camera)(time)
|
||||
);
|
||||
expect(worldCenterPoint[0]).toBeCloseTo(100);
|
||||
expect(worldCenterPoint[1]).toBeCloseTo(0);
|
||||
});
|
||||
describe('when the user scales to 2x', () => {
|
||||
beforeEach(() => {
|
||||
const action: CameraAction = { type: 'userSetZoomLevel', payload: scaleToZoom(2) };
|
||||
store.dispatch(action);
|
||||
store.dispatch(userSetZoomLevel({ id, zoomLevel: scaleToZoom(2) }));
|
||||
});
|
||||
it('should be centered on 100, 0', () => {
|
||||
const worldCenterPoint = applyMatrix3(
|
||||
[150, 100],
|
||||
inverseProjectionMatrix(store.getState())(time)
|
||||
inverseProjectionMatrix(store.getState().analyzerById[id].camera)(time)
|
||||
);
|
||||
expect(worldCenterPoint[0]).toBeCloseTo(100);
|
||||
expect(worldCenterPoint[1]).toBeCloseTo(0);
|
||||
|
@ -160,23 +168,21 @@ describe('zooming', () => {
|
|||
let previousScalingFactor: CameraState['scalingFactor'];
|
||||
describe('when user clicks on zoom in button', () => {
|
||||
beforeEach(() => {
|
||||
previousScalingFactor = scalingFactor(store.getState());
|
||||
const action: CameraAction = { type: 'userClickedZoomIn' };
|
||||
store.dispatch(action);
|
||||
previousScalingFactor = scalingFactor(store.getState().analyzerById[id].camera);
|
||||
store.dispatch(userClickedZoomIn({ id }));
|
||||
});
|
||||
it('the scaling factor should increase by 0.1 units', () => {
|
||||
const actual = scalingFactor(store.getState());
|
||||
const actual = scalingFactor(store.getState().analyzerById[id].camera);
|
||||
expect(actual).toEqual(previousScalingFactor + 0.1);
|
||||
});
|
||||
});
|
||||
describe('when user clicks on zoom out button', () => {
|
||||
beforeEach(() => {
|
||||
previousScalingFactor = scalingFactor(store.getState());
|
||||
const action: CameraAction = { type: 'userClickedZoomOut' };
|
||||
store.dispatch(action);
|
||||
previousScalingFactor = scalingFactor(store.getState().analyzerById[id].camera);
|
||||
store.dispatch(userClickedZoomOut({ id }));
|
||||
});
|
||||
it('the scaling factor should decrease by 0.1 units', () => {
|
||||
const actual = scalingFactor(store.getState());
|
||||
const actual = scalingFactor(store.getState().analyzerById[id].camera);
|
||||
expect(actual).toEqual(previousScalingFactor - 0.1);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import actionCreatorFactory from 'typescript-fsa';
|
||||
import type {
|
||||
NewResolverTree,
|
||||
SafeEndpointEvent,
|
||||
|
@ -13,198 +14,209 @@ import type {
|
|||
} from '../../../../common/endpoint/types';
|
||||
import type { TreeFetcherParameters, PanelViewAndParameters, TimeFilters } from '../../types';
|
||||
|
||||
interface ServerReturnedResolverData {
|
||||
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;
|
||||
const actionCreator = actionCreatorFactory('x-pack/security_solution/analyzer');
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
detectedBounds?: TimeFilters;
|
||||
};
|
||||
}
|
||||
export const serverReturnedResolverData = actionCreator<{
|
||||
/**
|
||||
* Id that identify the scope of analyzer
|
||||
*/
|
||||
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';
|
||||
readonly payload: {
|
||||
parameters: PanelViewAndParameters;
|
||||
};
|
||||
}
|
||||
interface AppRequestedResolverData {
|
||||
readonly type: 'appRequestedResolverData';
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
detectedBounds?: TimeFilters;
|
||||
}>('SERVER_RETURNED_RESOLVER_DATA');
|
||||
|
||||
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.
|
||||
*/
|
||||
readonly payload: TreeFetcherParameters;
|
||||
}
|
||||
readonly parameters: TreeFetcherParameters;
|
||||
}>('APP_REQUESTED_RESOLVER_DATA');
|
||||
|
||||
interface UserRequestedAdditionalRelatedEvents {
|
||||
readonly type: 'userRequestedAdditionalRelatedEvents';
|
||||
}
|
||||
export const userRequestedAdditionalRelatedEvents = actionCreator<{
|
||||
/**
|
||||
* Id that identify the scope of analyzer
|
||||
*/
|
||||
readonly id: string;
|
||||
}>('USER_REQUESTED_ADDITIONAL_RELATED_EVENTS');
|
||||
|
||||
interface ServerFailedToReturnNodeEventsInCategory {
|
||||
readonly type: 'serverFailedToReturnNodeEventsInCategory';
|
||||
readonly payload: {
|
||||
/**
|
||||
* The cursor, if any, that can be used to retrieve more events.
|
||||
*/
|
||||
cursor: string | null;
|
||||
/**
|
||||
* The nodeID that `events` are related to.
|
||||
*/
|
||||
nodeID: string;
|
||||
/**
|
||||
* The category that `events` have in common.
|
||||
*/
|
||||
eventCategory: string;
|
||||
};
|
||||
}
|
||||
export const serverFailedToReturnNodeEventsInCategory = actionCreator<{
|
||||
/**
|
||||
* Id that identify the scope of analyzer
|
||||
*/
|
||||
readonly id: string;
|
||||
/**
|
||||
* The cursor, if any, that can be used to retrieve more events.
|
||||
*/
|
||||
readonly cursor: string | null;
|
||||
/**
|
||||
* The nodeID that `events` are related to.
|
||||
*/
|
||||
readonly nodeID: string;
|
||||
/**
|
||||
* The category that `events` have in common.
|
||||
*/
|
||||
readonly eventCategory: string;
|
||||
}>('SERVER_FAILED_TO_RETUEN_NODE_EVENTS_IN_CATEGORY');
|
||||
|
||||
interface ServerFailedToReturnResolverData {
|
||||
readonly type: 'serverFailedToReturnResolverData';
|
||||
export const serverFailedToReturnResolverData = actionCreator<{
|
||||
/**
|
||||
* Id that identify the scope of analyzer
|
||||
*/
|
||||
readonly id: string;
|
||||
/**
|
||||
* entity ID used to make the failed request
|
||||
*/
|
||||
readonly payload: TreeFetcherParameters;
|
||||
}
|
||||
readonly parameters: TreeFetcherParameters;
|
||||
}>('SERVER_FAILED_TO_RETURN_RESOLVER_DATA');
|
||||
|
||||
interface AppAbortedResolverDataRequest {
|
||||
readonly type: 'appAbortedResolverDataRequest';
|
||||
export const appAbortedResolverDataRequest = actionCreator<{
|
||||
/**
|
||||
* Id that identify the scope of analyzer
|
||||
*/
|
||||
readonly id: string;
|
||||
/**
|
||||
* entity ID used to make the aborted request
|
||||
*/
|
||||
readonly payload: TreeFetcherParameters;
|
||||
}
|
||||
readonly parameters: TreeFetcherParameters;
|
||||
}>('APP_ABORTED_RESOLVER_DATA_REQUEST');
|
||||
|
||||
interface ServerReturnedNodeEventsInCategory {
|
||||
readonly type: 'serverReturnedNodeEventsInCategory';
|
||||
readonly payload: {
|
||||
/**
|
||||
* Events with `event.category` that include `eventCategory` and that are related to `nodeID`.
|
||||
*/
|
||||
events: SafeEndpointEvent[];
|
||||
/**
|
||||
* The cursor, if any, that can be used to retrieve more events.
|
||||
*/
|
||||
cursor: string | null;
|
||||
/**
|
||||
* The nodeID that `events` are related to.
|
||||
*/
|
||||
nodeID: string;
|
||||
/**
|
||||
* The category that `events` have in common.
|
||||
*/
|
||||
eventCategory: string;
|
||||
};
|
||||
}
|
||||
export const serverReturnedNodeEventsInCategory = actionCreator<{
|
||||
/**
|
||||
* Id that identify the scope of analyzer
|
||||
*/
|
||||
readonly id: string;
|
||||
/**
|
||||
* Events with `event.category` that include `eventCategory` and that are related to `nodeID`.
|
||||
*/
|
||||
readonly events: SafeEndpointEvent[];
|
||||
/**
|
||||
* The cursor, if any, that can be used to retrieve more events.
|
||||
*/
|
||||
readonly cursor: string | null;
|
||||
/**
|
||||
* The nodeID that `events` are related to.
|
||||
*/
|
||||
readonly nodeID: 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.
|
||||
*/
|
||||
interface ServerReturnedNodeData {
|
||||
readonly type: 'serverReturnedNodeData';
|
||||
readonly payload: {
|
||||
/**
|
||||
* A map of the node's ID to an array of events
|
||||
*/
|
||||
nodeData: SafeResolverEvent[];
|
||||
/**
|
||||
* The list of IDs that were originally sent to the server. This won't necessarily equal nodeData.keys() because
|
||||
* data could have been deleted in Elasticsearch since the original graph nodes were returned or the server's
|
||||
* API limit could have been reached.
|
||||
*/
|
||||
requestedIDs: Set<string>;
|
||||
/**
|
||||
* The number of events that we requested from the server (the limit in the request).
|
||||
* This will be used to compute a flag about whether we reached the limit with the number of events returned by
|
||||
* 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
|
||||
* that we'll request their data in a subsequent request.
|
||||
*/
|
||||
numberOfRequestedEvents: number;
|
||||
};
|
||||
}
|
||||
export const serverReturnedNodeData = actionCreator<{
|
||||
/**
|
||||
* Id that identify the scope of analyzer
|
||||
*/
|
||||
readonly id: string;
|
||||
/**
|
||||
* A map of the node's ID to an array of events
|
||||
*/
|
||||
readonly nodeData: SafeResolverEvent[];
|
||||
/**
|
||||
* The list of IDs that were originally sent to the server. This won't necessarily equal nodeData.keys() because
|
||||
* data could have been deleted in Elasticsearch since the original graph nodes were returned or the server's
|
||||
* API limit could have been reached.
|
||||
*/
|
||||
readonly requestedIDs: Set<string>;
|
||||
/**
|
||||
* The number of events that we requested from the server (the limit in the request).
|
||||
* This will be used to compute a flag about whether we reached the limit with the number of events returned by
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
interface AppRequestingNodeData {
|
||||
readonly type: 'appRequestingNodeData';
|
||||
readonly payload: {
|
||||
/**
|
||||
* The list of IDs that will be sent to the server to retrieve data for.
|
||||
*/
|
||||
requestedIDs: Set<string>;
|
||||
};
|
||||
}
|
||||
export const appRequestingNodeData = actionCreator<{
|
||||
/**
|
||||
* Id that identify the scope of analyzer
|
||||
*/
|
||||
readonly id: string;
|
||||
/**
|
||||
* The list of IDs that will be sent to the server to retrieve data for.
|
||||
*/
|
||||
requestedIDs: Set<string>;
|
||||
}>('APP_REQUESTING_NODE_DATA');
|
||||
|
||||
/**
|
||||
* When the user clicks on a node that was in an error state to reload the node data.
|
||||
*/
|
||||
interface UserReloadedResolverNode {
|
||||
readonly type: 'userReloadedResolverNode';
|
||||
export const userReloadedResolverNode = actionCreator<{
|
||||
/**
|
||||
* Id that identify the scope of analyzer
|
||||
*/
|
||||
readonly id: string;
|
||||
/**
|
||||
* The nodeID (aka entity_id) that was select.
|
||||
*/
|
||||
readonly payload: string;
|
||||
}
|
||||
readonly nodeID: string;
|
||||
}>('USER_RELOADED_RESOLVER_NODE');
|
||||
|
||||
/**
|
||||
* When the server returns an error after the app requests node data for a set of nodes.
|
||||
*/
|
||||
interface ServerFailedToReturnNodeData {
|
||||
readonly type: 'serverFailedToReturnNodeData';
|
||||
readonly payload: {
|
||||
/**
|
||||
* The list of IDs that were sent to the server to retrieve data for.
|
||||
*/
|
||||
requestedIDs: Set<string>;
|
||||
};
|
||||
}
|
||||
export const serverFailedToReturnNodeData = actionCreator<{
|
||||
/**
|
||||
* Id that identify the scope of analyzer
|
||||
*/
|
||||
readonly id: string;
|
||||
/**
|
||||
* The list of IDs that were sent to the server to retrieve data for.
|
||||
*/
|
||||
readonly requestedIDs: Set<string>;
|
||||
}>('SERVER_FAILED_TO_RETURN_NODE_DATA');
|
||||
|
||||
interface AppRequestedCurrentRelatedEventData {
|
||||
type: 'appRequestedCurrentRelatedEventData';
|
||||
}
|
||||
export const appRequestedCurrentRelatedEventData = actionCreator<{ readonly id: string }>(
|
||||
'APP_REQUESTED_CURRENT_RELATED_EVENT_DATA'
|
||||
);
|
||||
|
||||
interface ServerFailedToReturnCurrentRelatedEventData {
|
||||
type: 'serverFailedToReturnCurrentRelatedEventData';
|
||||
}
|
||||
export const serverFailedToReturnCurrentRelatedEventData = actionCreator<{ readonly id: string }>(
|
||||
'SERVER_FAILED_TO_RETURN_CURRENT_RELATED_EVENT_DATA'
|
||||
);
|
||||
|
||||
interface ServerReturnedCurrentRelatedEventData {
|
||||
readonly type: 'serverReturnedCurrentRelatedEventData';
|
||||
readonly payload: SafeResolverEvent;
|
||||
}
|
||||
|
||||
export type DataAction =
|
||||
| ServerReturnedResolverData
|
||||
| ServerFailedToReturnResolverData
|
||||
| AppRequestedCurrentRelatedEventData
|
||||
| ServerReturnedCurrentRelatedEventData
|
||||
| ServerFailedToReturnCurrentRelatedEventData
|
||||
| ServerReturnedNodeEventsInCategory
|
||||
| AppRequestedResolverData
|
||||
| UserRequestedAdditionalRelatedEvents
|
||||
| ServerFailedToReturnNodeEventsInCategory
|
||||
| AppAbortedResolverDataRequest
|
||||
| ServerReturnedNodeData
|
||||
| ServerFailedToReturnNodeData
|
||||
| AppRequestingNodeData
|
||||
| UserReloadedResolverNode
|
||||
| AppRequestedNodeEventsInCategory;
|
||||
export const serverReturnedCurrentRelatedEventData = actionCreator<{
|
||||
/**
|
||||
* Id that identify the scope of analyzer
|
||||
*/
|
||||
readonly id: string;
|
||||
readonly relatedEvent: SafeResolverEvent;
|
||||
}>('SERVER_RETURNED_CURRENT_RELATED_EVENT_DATA');
|
||||
|
|
|
@ -5,17 +5,18 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { Store } from 'redux';
|
||||
import type { Store, AnyAction, Reducer } from 'redux';
|
||||
import { createStore } from 'redux';
|
||||
import { RelatedEventCategory } from '../../../../common/endpoint/generate_data';
|
||||
import { dataReducer } from './reducer';
|
||||
import * as selectors from './selectors';
|
||||
import type { DataState, GeneratedTreeMetadata, TimeFilters } from '../../types';
|
||||
import type { DataAction } from './action';
|
||||
import type { AnalyzerState, GeneratedTreeMetadata, TimeFilters } from '../../types';
|
||||
import { generateTreeWithDAL } from '../../data_access_layer/mocks/generator_tree';
|
||||
import { endpointSourceSchema, winlogSourceSchema } from '../../mocks/tree_schema';
|
||||
import type { NewResolverTree, ResolverSchema } from '../../../../common/endpoint/types';
|
||||
import { ancestorsWithAncestryField, descendantsLimit } from '../../models/resolver_tree';
|
||||
import { EMPTY_RESOLVER } from '../helpers';
|
||||
import { serverReturnedResolverData } from './action';
|
||||
|
||||
type SourceAndSchemaFunction = () => { schema: ResolverSchema; dataSource: string };
|
||||
|
||||
|
@ -23,24 +24,33 @@ type SourceAndSchemaFunction = () => { schema: ResolverSchema; dataSource: strin
|
|||
* Test the data reducer and selector.
|
||||
*/
|
||||
describe('Resolver Data Middleware', () => {
|
||||
let store: Store<DataState, DataAction>;
|
||||
let store: Store<AnalyzerState, AnyAction>;
|
||||
let dispatchTree: (
|
||||
tree: NewResolverTree,
|
||||
sourceAndSchema: SourceAndSchemaFunction,
|
||||
detectedBounds?: TimeFilters
|
||||
) => void;
|
||||
const id = 'test-id';
|
||||
|
||||
beforeEach(() => {
|
||||
store = createStore(dataReducer, undefined);
|
||||
const testReducer: Reducer<AnalyzerState, AnyAction> = (
|
||||
state = {
|
||||
analyzerById: {
|
||||
[id]: EMPTY_RESOLVER,
|
||||
},
|
||||
},
|
||||
action
|
||||
): AnalyzerState => dataReducer(state, action);
|
||||
store = createStore(testReducer, undefined);
|
||||
dispatchTree = (
|
||||
tree: NewResolverTree,
|
||||
sourceAndSchema: SourceAndSchemaFunction,
|
||||
detectedBounds?: TimeFilters
|
||||
) => {
|
||||
const { schema, dataSource } = sourceAndSchema();
|
||||
const action: DataAction = {
|
||||
type: 'serverReturnedResolverData',
|
||||
payload: {
|
||||
store.dispatch(
|
||||
serverReturnedResolverData({
|
||||
id,
|
||||
result: tree,
|
||||
dataSource,
|
||||
schema,
|
||||
|
@ -50,9 +60,8 @@ describe('Resolver Data Middleware', () => {
|
|||
filters: {},
|
||||
},
|
||||
detectedBounds,
|
||||
},
|
||||
};
|
||||
store.dispatch(action);
|
||||
})
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
|
@ -74,15 +83,15 @@ describe('Resolver Data Middleware', () => {
|
|||
dispatchTree(generatedTreeMetadata.formattedTree, schema);
|
||||
});
|
||||
it('should indicate that there are no more ancestors to retrieve', () => {
|
||||
expect(selectors.hasMoreAncestors(store.getState())).toBeFalsy();
|
||||
expect(selectors.hasMoreAncestors(store.getState().analyzerById[id].data)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should indicate that there are no more descendants to retrieve', () => {
|
||||
expect(selectors.hasMoreChildren(store.getState())).toBeFalsy();
|
||||
expect(selectors.hasMoreChildren(store.getState().analyzerById[id].data)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should indicate that there were no more generations to retrieve', () => {
|
||||
expect(selectors.hasMoreGenerations(store.getState())).toBeFalsy();
|
||||
expect(selectors.hasMoreGenerations(store.getState().analyzerById[id].data)).toBeFalsy();
|
||||
});
|
||||
});
|
||||
describe('when a tree with detected bounds is loaded', () => {
|
||||
|
@ -91,7 +100,7 @@ describe('Resolver Data Middleware', () => {
|
|||
from: 'Sep 19, 2022 @ 20:49:13.452',
|
||||
to: 'Sep 19, 2022 @ 20:49:13.452',
|
||||
});
|
||||
expect(selectors.detectedBounds(store.getState())).toBeTruthy();
|
||||
expect(selectors.detectedBounds(store.getState().analyzerById[id].data)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should clear the previous detected bounds when a new response without detected bounds is recevied', () => {
|
||||
|
@ -99,9 +108,9 @@ describe('Resolver Data Middleware', () => {
|
|||
from: 'Sep 19, 2022 @ 20:49:13.452',
|
||||
to: 'Sep 19, 2022 @ 20:49:13.452',
|
||||
});
|
||||
expect(selectors.detectedBounds(store.getState())).toBeTruthy();
|
||||
expect(selectors.detectedBounds(store.getState().analyzerById[id].data)).toBeTruthy();
|
||||
dispatchTree(generatedTreeMetadata.formattedTree, endpointSourceSchema);
|
||||
expect(selectors.detectedBounds(store.getState())).toBeFalsy();
|
||||
expect(selectors.detectedBounds(store.getState().analyzerById[id].data)).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -123,15 +132,15 @@ describe('Resolver Data Middleware', () => {
|
|||
dispatchTree(generatedTreeMetadata.formattedTree, endpointSourceSchema);
|
||||
});
|
||||
it('should indicate that there are more ancestors to retrieve', () => {
|
||||
expect(selectors.hasMoreAncestors(store.getState())).toBeTruthy();
|
||||
expect(selectors.hasMoreAncestors(store.getState().analyzerById[id].data)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should indicate that there are more descendants to retrieve', () => {
|
||||
expect(selectors.hasMoreChildren(store.getState())).toBeTruthy();
|
||||
expect(selectors.hasMoreChildren(store.getState().analyzerById[id].data)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should indicate that there were no more generations to retrieve', () => {
|
||||
expect(selectors.hasMoreGenerations(store.getState())).toBeFalsy();
|
||||
expect(selectors.hasMoreGenerations(store.getState().analyzerById[id].data)).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -140,15 +149,15 @@ describe('Resolver Data Middleware', () => {
|
|||
dispatchTree(generatedTreeMetadata.formattedTree, winlogSourceSchema);
|
||||
});
|
||||
it('should indicate that there are more ancestors to retrieve', () => {
|
||||
expect(selectors.hasMoreAncestors(store.getState())).toBeTruthy();
|
||||
expect(selectors.hasMoreAncestors(store.getState().analyzerById[id].data)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should indicate that there are more descendants to retrieve', () => {
|
||||
expect(selectors.hasMoreChildren(store.getState())).toBeTruthy();
|
||||
expect(selectors.hasMoreChildren(store.getState().analyzerById[id].data)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should indicate that there were more generations to retrieve', () => {
|
||||
expect(selectors.hasMoreGenerations(store.getState())).toBeTruthy();
|
||||
expect(selectors.hasMoreGenerations(store.getState().analyzerById[id].data)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -168,15 +177,15 @@ describe('Resolver Data Middleware', () => {
|
|||
dispatchTree(generatedTreeMetadata.formattedTree, endpointSourceSchema);
|
||||
});
|
||||
it('should indicate that there are no more ancestors to retrieve', () => {
|
||||
expect(selectors.hasMoreAncestors(store.getState())).toBeFalsy();
|
||||
expect(selectors.hasMoreAncestors(store.getState().analyzerById[id].data)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should indicate that there are more descendants to retrieve', () => {
|
||||
expect(selectors.hasMoreChildren(store.getState())).toBeTruthy();
|
||||
expect(selectors.hasMoreChildren(store.getState().analyzerById[id].data)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should indicate that there were no more generations to retrieve', () => {
|
||||
expect(selectors.hasMoreGenerations(store.getState())).toBeFalsy();
|
||||
expect(selectors.hasMoreGenerations(store.getState().analyzerById[id].data)).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -185,15 +194,15 @@ describe('Resolver Data Middleware', () => {
|
|||
dispatchTree(generatedTreeMetadata.formattedTree, winlogSourceSchema);
|
||||
});
|
||||
it('should indicate that there are no more ancestors to retrieve', () => {
|
||||
expect(selectors.hasMoreAncestors(store.getState())).toBeFalsy();
|
||||
expect(selectors.hasMoreAncestors(store.getState().analyzerById[id].data)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should indicate that there are more descendants to retrieve', () => {
|
||||
expect(selectors.hasMoreChildren(store.getState())).toBeTruthy();
|
||||
expect(selectors.hasMoreChildren(store.getState().analyzerById[id].data)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should indicate that there were no more generations to retrieve', () => {
|
||||
expect(selectors.hasMoreGenerations(store.getState())).toBeFalsy();
|
||||
expect(selectors.hasMoreGenerations(store.getState().analyzerById[id].data)).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -217,13 +226,15 @@ describe('Resolver Data Middleware', () => {
|
|||
it('should have the correct total related events for a child node', () => {
|
||||
// get the first level of children, and there should only be a single child
|
||||
const childNode = Array.from(metadata.generatedTree.childrenLevels[0].values())[0];
|
||||
const total = selectors.relatedEventTotalCount(store.getState())(childNode.id);
|
||||
const total = selectors.relatedEventTotalCount(store.getState().analyzerById[id].data)(
|
||||
childNode.id
|
||||
);
|
||||
expect(total).toEqual(5);
|
||||
});
|
||||
it('should have the correct related events stats for a child node', () => {
|
||||
// get the first level of children, and there should only be a single child
|
||||
const childNode = Array.from(metadata.generatedTree.childrenLevels[0].values())[0];
|
||||
const stats = selectors.nodeStats(store.getState())(childNode.id);
|
||||
const stats = selectors.nodeStats(store.getState().analyzerById[id].data)(childNode.id);
|
||||
expect(stats).toEqual({
|
||||
total: 5,
|
||||
byCategory: {
|
||||
|
|
|
@ -5,254 +5,266 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { Reducer } from 'redux';
|
||||
import type { Draft } from 'immer';
|
||||
import { reducerWithInitialState } from 'typescript-fsa-reducers';
|
||||
import type { DataState } from '../../types';
|
||||
import type { ResolverAction } from '../actions';
|
||||
import * as treeFetcherParameters from '../../models/tree_fetcher_parameters';
|
||||
import * as selectors from './selectors';
|
||||
import * as nodeEventsInCategoryModel from './node_events_in_category_model';
|
||||
import * as nodeDataModel from '../../models/node_data';
|
||||
import { initialAnalyzerState, immerCase } from '../helpers';
|
||||
import { appReceivedNewExternalProperties } from '../actions';
|
||||
import {
|
||||
serverReturnedResolverData,
|
||||
appRequestedResolverData,
|
||||
appAbortedResolverDataRequest,
|
||||
serverFailedToReturnResolverData,
|
||||
serverReturnedNodeEventsInCategory,
|
||||
userRequestedAdditionalRelatedEvents,
|
||||
serverFailedToReturnNodeEventsInCategory,
|
||||
serverReturnedNodeData,
|
||||
userReloadedResolverNode,
|
||||
appRequestingNodeData,
|
||||
serverFailedToReturnNodeData,
|
||||
appRequestedCurrentRelatedEventData,
|
||||
serverReturnedCurrentRelatedEventData,
|
||||
serverFailedToReturnCurrentRelatedEventData,
|
||||
} from './action';
|
||||
|
||||
const initialState: DataState = {
|
||||
currentRelatedEvent: {
|
||||
loading: false,
|
||||
data: null,
|
||||
},
|
||||
resolverComponentInstanceID: undefined,
|
||||
indices: [],
|
||||
detectedBounds: undefined,
|
||||
};
|
||||
/* eslint-disable complexity */
|
||||
export const dataReducer: Reducer<DataState, ResolverAction> = (state = initialState, action) => {
|
||||
if (action.type === 'appReceivedNewExternalProperties') {
|
||||
const nextState: DataState = {
|
||||
...state,
|
||||
tree: {
|
||||
...state.tree,
|
||||
currentParameters: {
|
||||
databaseDocumentID: action.payload.databaseDocumentID,
|
||||
indices: action.payload.indices,
|
||||
filters: action.payload.filters,
|
||||
},
|
||||
},
|
||||
resolverComponentInstanceID: action.payload.resolverComponentInstanceID,
|
||||
locationSearch: action.payload.locationSearch,
|
||||
indices: action.payload.indices,
|
||||
};
|
||||
const panelViewAndParameters = selectors.panelViewAndParameters(nextState);
|
||||
return {
|
||||
...nextState,
|
||||
// If the panel view or parameters have changed, the `nodeEventsInCategory` may no longer be relevant. In that case, remove them.
|
||||
nodeEventsInCategory:
|
||||
nextState.nodeEventsInCategory &&
|
||||
nodeEventsInCategoryModel.isRelevantToPanelViewAndParameters(
|
||||
nextState.nodeEventsInCategory,
|
||||
panelViewAndParameters
|
||||
)
|
||||
? nextState.nodeEventsInCategory
|
||||
: undefined,
|
||||
};
|
||||
} else if (action.type === 'appRequestedResolverData') {
|
||||
// keep track of what we're requesting, this way we know when to request and when not to.
|
||||
const nextState: DataState = {
|
||||
...state,
|
||||
tree: {
|
||||
export const dataReducer = reducerWithInitialState(initialAnalyzerState)
|
||||
.withHandling(
|
||||
immerCase(
|
||||
appReceivedNewExternalProperties,
|
||||
(
|
||||
draft,
|
||||
{ id, resolverComponentInstanceID, locationSearch, databaseDocumentID, indices, filters }
|
||||
) => {
|
||||
const state: Draft<DataState> = draft.analyzerById[id]?.data;
|
||||
state.tree = {
|
||||
...state.tree,
|
||||
currentParameters: {
|
||||
databaseDocumentID,
|
||||
indices,
|
||||
filters,
|
||||
},
|
||||
};
|
||||
state.resolverComponentInstanceID = resolverComponentInstanceID;
|
||||
state.locationSearch = locationSearch;
|
||||
state.indices = indices;
|
||||
|
||||
const panelViewAndParameters = selectors.panelViewAndParameters(state);
|
||||
if (
|
||||
!state.nodeEventsInCategory ||
|
||||
!nodeEventsInCategoryModel.isRelevantToPanelViewAndParameters(
|
||||
state.nodeEventsInCategory,
|
||||
panelViewAndParameters
|
||||
)
|
||||
) {
|
||||
state.nodeEventsInCategory = undefined;
|
||||
}
|
||||
return draft;
|
||||
}
|
||||
)
|
||||
)
|
||||
.withHandling(
|
||||
immerCase(appRequestedResolverData, (draft, { id, parameters }) => {
|
||||
const state: Draft<DataState> = draft.analyzerById[id].data;
|
||||
// keep track of what we're requesting, this way we know when to request and when not to.
|
||||
state.tree = {
|
||||
...state.tree,
|
||||
pendingRequestParameters: {
|
||||
databaseDocumentID: action.payload.databaseDocumentID,
|
||||
indices: action.payload.indices,
|
||||
filters: action.payload.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,
|
||||
databaseDocumentID: parameters.databaseDocumentID,
|
||||
indices: parameters.indices,
|
||||
filters: parameters.filters,
|
||||
},
|
||||
};
|
||||
return nextState;
|
||||
} else {
|
||||
return state;
|
||||
}
|
||||
} else if (action.type === 'serverReturnedResolverData') {
|
||||
/** Only handle this if we are expecting a response */
|
||||
const nextState: DataState = {
|
||||
...state,
|
||||
|
||||
tree: {
|
||||
...state.tree,
|
||||
/**
|
||||
* Store the last received data, as well as the databaseDocumentID it relates to.
|
||||
*/
|
||||
lastResponse: {
|
||||
result: action.payload.result,
|
||||
dataSource: action.payload.dataSource,
|
||||
schema: action.payload.schema,
|
||||
parameters: action.payload.parameters,
|
||||
successful: true,
|
||||
},
|
||||
|
||||
// This assumes that if we just received something, there is no longer a pending request.
|
||||
// This cannot model multiple in-flight requests
|
||||
pendingRequestParameters: undefined,
|
||||
},
|
||||
detectedBounds: action.payload.detectedBounds,
|
||||
};
|
||||
return nextState;
|
||||
} else if (action.type === 'serverFailedToReturnResolverData') {
|
||||
/** Only handle this if we are expecting a response */
|
||||
if (state.tree?.pendingRequestParameters !== undefined) {
|
||||
const nextState: DataState = {
|
||||
...state,
|
||||
tree: {
|
||||
return draft;
|
||||
})
|
||||
)
|
||||
.withHandling(
|
||||
immerCase(appAbortedResolverDataRequest, (draft, { id, parameters }) => {
|
||||
const state: Draft<DataState> = draft.analyzerById[id].data;
|
||||
if (treeFetcherParameters.equal(parameters, state.tree?.pendingRequestParameters)) {
|
||||
// the request we were awaiting was aborted
|
||||
state.tree = {
|
||||
...state.tree,
|
||||
pendingRequestParameters: undefined,
|
||||
};
|
||||
}
|
||||
return draft;
|
||||
})
|
||||
)
|
||||
.withHandling(
|
||||
immerCase(
|
||||
serverReturnedResolverData,
|
||||
(draft, { id, result, dataSource, schema, parameters, detectedBounds }) => {
|
||||
const state: Draft<DataState> = draft.analyzerById[id].data;
|
||||
/** Only handle this if we are expecting a response */
|
||||
state.tree = {
|
||||
...state.tree,
|
||||
/**
|
||||
* Store the last received data, as well as the databaseDocumentID it relates to.
|
||||
*/
|
||||
lastResponse: {
|
||||
result,
|
||||
dataSource,
|
||||
schema,
|
||||
parameters,
|
||||
successful: true,
|
||||
},
|
||||
// This assumes that if we just received something, there is no longer a pending request.
|
||||
// This cannot model multiple in-flight requests
|
||||
pendingRequestParameters: undefined,
|
||||
};
|
||||
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,
|
||||
pendingRequestParameters: undefined,
|
||||
lastResponse: {
|
||||
parameters: state.tree.pendingRequestParameters,
|
||||
parameters: state.tree?.pendingRequestParameters,
|
||||
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 {
|
||||
// the action is stale, ignore it
|
||||
return state;
|
||||
}
|
||||
} else if (action.type === 'userRequestedAdditionalRelatedEvents') {
|
||||
if (state.nodeEventsInCategory) {
|
||||
const nextState: DataState = {
|
||||
...state,
|
||||
nodeEventsInCategory: {
|
||||
...state.nodeEventsInCategory,
|
||||
lastCursorRequested: state.nodeEventsInCategory?.cursor,
|
||||
},
|
||||
};
|
||||
return nextState;
|
||||
} else {
|
||||
return state;
|
||||
}
|
||||
} else if (action.type === 'serverFailedToReturnNodeEventsInCategory') {
|
||||
if (state.nodeEventsInCategory) {
|
||||
const nextState: DataState = {
|
||||
...state,
|
||||
nodeEventsInCategory: {
|
||||
return draft;
|
||||
})
|
||||
)
|
||||
.withHandling(
|
||||
immerCase(
|
||||
serverReturnedNodeEventsInCategory,
|
||||
(draft, { id, events, cursor, nodeID, eventCategory }) => {
|
||||
// The data in the action could be irrelevant if the panel view or parameters have changed since the corresponding request was made. In that case, ignore this action.
|
||||
const state: Draft<DataState> = draft.analyzerById[id].data;
|
||||
if (
|
||||
nodeEventsInCategoryModel.isRelevantToPanelViewAndParameters(
|
||||
{ events, cursor, nodeID, eventCategory },
|
||||
selectors.panelViewAndParameters(state)
|
||||
)
|
||||
) {
|
||||
if (state.nodeEventsInCategory) {
|
||||
// If there are already `nodeEventsInCategory` in state then combine those with the new data in the payload.
|
||||
const updated = nodeEventsInCategoryModel.updatedWith(state.nodeEventsInCategory, {
|
||||
events,
|
||||
cursor,
|
||||
nodeID,
|
||||
eventCategory,
|
||||
});
|
||||
// The 'updatedWith' method will fail if the old and new data don't represent events from the same node and event category
|
||||
if (updated) {
|
||||
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,
|
||||
error: true,
|
||||
},
|
||||
};
|
||||
return nextState;
|
||||
} else {
|
||||
return state;
|
||||
}
|
||||
} else if (action.type === 'serverReturnedNodeData') {
|
||||
const updatedNodeData = nodeDataModel.updateWithReceivedNodes({
|
||||
storedNodeInfo: state.nodeData,
|
||||
receivedEvents: action.payload.nodeData,
|
||||
requestedNodes: action.payload.requestedIDs,
|
||||
numberOfRequestedEvents: action.payload.numberOfRequestedEvents,
|
||||
});
|
||||
|
||||
return {
|
||||
...state,
|
||||
nodeData: updatedNodeData,
|
||||
};
|
||||
} else if (action.type === 'userReloadedResolverNode') {
|
||||
const updatedNodeData = nodeDataModel.setReloadedNodes(state.nodeData, action.payload);
|
||||
return {
|
||||
...state,
|
||||
nodeData: updatedNodeData,
|
||||
};
|
||||
} else if (action.type === 'appRequestingNodeData') {
|
||||
const updatedNodeData = nodeDataModel.setRequestedNodes(
|
||||
state.nodeData,
|
||||
action.payload.requestedIDs
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
nodeData: updatedNodeData,
|
||||
};
|
||||
} else if (action.type === 'serverFailedToReturnNodeData') {
|
||||
const updatedData = nodeDataModel.setErrorNodes(state.nodeData, action.payload.requestedIDs);
|
||||
|
||||
return {
|
||||
...state,
|
||||
nodeData: updatedData,
|
||||
};
|
||||
} else if (action.type === 'appRequestedCurrentRelatedEventData') {
|
||||
const nextState: DataState = {
|
||||
...state,
|
||||
currentRelatedEvent: {
|
||||
};
|
||||
}
|
||||
return draft;
|
||||
})
|
||||
)
|
||||
.withHandling(
|
||||
immerCase(
|
||||
serverReturnedNodeData,
|
||||
(draft, { id, nodeData, requestedIDs, numberOfRequestedEvents }) => {
|
||||
const state: Draft<DataState> = draft.analyzerById[id].data;
|
||||
const updatedNodeData = nodeDataModel.updateWithReceivedNodes({
|
||||
storedNodeInfo: state.nodeData,
|
||||
receivedEvents: nodeData,
|
||||
requestedNodes: requestedIDs,
|
||||
numberOfRequestedEvents,
|
||||
});
|
||||
state.nodeData = updatedNodeData;
|
||||
return draft;
|
||||
}
|
||||
)
|
||||
)
|
||||
.withHandling(
|
||||
immerCase(userReloadedResolverNode, (draft, { id, nodeID }) => {
|
||||
const state: Draft<DataState> = draft.analyzerById[id].data;
|
||||
const updatedNodeData = nodeDataModel.setReloadedNodes(state.nodeData, nodeID);
|
||||
state.nodeData = updatedNodeData;
|
||||
return draft;
|
||||
})
|
||||
)
|
||||
.withHandling(
|
||||
immerCase(appRequestingNodeData, (draft, { id, requestedIDs }) => {
|
||||
const state: Draft<DataState> = draft.analyzerById[id].data;
|
||||
const updatedNodeData = nodeDataModel.setRequestedNodes(state.nodeData, requestedIDs);
|
||||
state.nodeData = updatedNodeData;
|
||||
return draft;
|
||||
})
|
||||
)
|
||||
.withHandling(
|
||||
immerCase(serverFailedToReturnNodeData, (draft, { id, requestedIDs }) => {
|
||||
const state: Draft<DataState> = draft.analyzerById[id].data;
|
||||
const updatedData = nodeDataModel.setErrorNodes(state.nodeData, requestedIDs);
|
||||
state.nodeData = updatedData;
|
||||
return draft;
|
||||
})
|
||||
)
|
||||
.withHandling(
|
||||
immerCase(appRequestedCurrentRelatedEventData, (draft, { id }) => {
|
||||
draft.analyzerById[id].data.currentRelatedEvent = {
|
||||
loading: true,
|
||||
data: null,
|
||||
},
|
||||
};
|
||||
return nextState;
|
||||
} else if (action.type === 'serverReturnedCurrentRelatedEventData') {
|
||||
const nextState: DataState = {
|
||||
...state,
|
||||
currentRelatedEvent: {
|
||||
};
|
||||
return draft;
|
||||
})
|
||||
)
|
||||
.withHandling(
|
||||
immerCase(serverReturnedCurrentRelatedEventData, (draft, { id, relatedEvent }) => {
|
||||
draft.analyzerById[id].data.currentRelatedEvent = {
|
||||
loading: false,
|
||||
data: {
|
||||
...action.payload,
|
||||
...relatedEvent,
|
||||
},
|
||||
},
|
||||
};
|
||||
return nextState;
|
||||
} else if (action.type === 'serverFailedToReturnCurrentRelatedEventData') {
|
||||
const nextState: DataState = {
|
||||
...state,
|
||||
currentRelatedEvent: {
|
||||
};
|
||||
return draft;
|
||||
})
|
||||
)
|
||||
.withHandling(
|
||||
immerCase(serverFailedToReturnCurrentRelatedEventData, (draft, { id }) => {
|
||||
draft.analyzerById[id].data.currentRelatedEvent = {
|
||||
loading: false,
|
||||
data: null,
|
||||
},
|
||||
};
|
||||
return nextState;
|
||||
} else {
|
||||
return state;
|
||||
}
|
||||
};
|
||||
};
|
||||
return draft;
|
||||
})
|
||||
)
|
||||
.build();
|
||||
|
|
|
@ -6,8 +6,8 @@
|
|||
*/
|
||||
|
||||
import * as selectors from './selectors';
|
||||
import type { DataState } from '../../types';
|
||||
import type { ResolverAction } from '../actions';
|
||||
import type { DataState, AnalyzerState } from '../../types';
|
||||
import type { Reducer, AnyAction } from 'redux';
|
||||
import { dataReducer } from './reducer';
|
||||
import { createStore } from 'redux';
|
||||
import {
|
||||
|
@ -22,6 +22,15 @@ import { mockTreeFetcherParameters } from '../../mocks/tree_fetcher_parameters';
|
|||
import type { SafeResolverEvent } from '../../../../common/endpoint/types';
|
||||
import { mockEndpointEvent } from '../../mocks/endpoint_event';
|
||||
import { maxDate } from '../../models/time_range';
|
||||
import { EMPTY_RESOLVER } from '../helpers';
|
||||
import {
|
||||
serverReturnedResolverData,
|
||||
appRequestedResolverData,
|
||||
appAbortedResolverDataRequest,
|
||||
serverFailedToReturnResolverData,
|
||||
serverReturnedNodeData,
|
||||
} from './action';
|
||||
import { appReceivedNewExternalProperties } from '../actions';
|
||||
|
||||
function mockNodeDataWithAllProcessesTerminated({
|
||||
originID,
|
||||
|
@ -83,17 +92,26 @@ function mockNodeDataWithAllProcessesTerminated({
|
|||
}
|
||||
|
||||
describe('data state', () => {
|
||||
let actions: ResolverAction[];
|
||||
let actions: AnyAction[];
|
||||
const id = 'test-id';
|
||||
|
||||
/**
|
||||
* Get state, given an ordered collection of actions.
|
||||
*/
|
||||
const state: () => DataState = () => {
|
||||
const store = createStore(dataReducer);
|
||||
const testReducer: Reducer<AnalyzerState, AnyAction> = (
|
||||
analyzerState = {
|
||||
analyzerById: {
|
||||
[id]: EMPTY_RESOLVER,
|
||||
},
|
||||
},
|
||||
action
|
||||
): AnalyzerState => dataReducer(analyzerState, action);
|
||||
const store = createStore(testReducer, undefined);
|
||||
for (const action of actions) {
|
||||
store.dispatch(action);
|
||||
}
|
||||
return store.getState();
|
||||
return store.getState().analyzerById[id].data;
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -136,19 +154,17 @@ describe('data state', () => {
|
|||
const resolverComponentInstanceID = 'resolverComponentInstanceID';
|
||||
beforeEach(() => {
|
||||
actions = [
|
||||
{
|
||||
type: 'appReceivedNewExternalProperties',
|
||||
payload: {
|
||||
databaseDocumentID,
|
||||
resolverComponentInstanceID,
|
||||
appReceivedNewExternalProperties({
|
||||
id,
|
||||
databaseDocumentID,
|
||||
resolverComponentInstanceID,
|
||||
|
||||
// `locationSearch` doesn't matter for this test
|
||||
locationSearch: '',
|
||||
indices: [],
|
||||
shouldUpdate: false,
|
||||
filters: {},
|
||||
},
|
||||
},
|
||||
// `locationSearch` doesn't matter for this test
|
||||
locationSearch: '',
|
||||
indices: [],
|
||||
shouldUpdate: false,
|
||||
filters: {},
|
||||
}),
|
||||
];
|
||||
});
|
||||
it('should need to request the tree', () => {
|
||||
|
@ -169,10 +185,10 @@ describe('data state', () => {
|
|||
const databaseDocumentID = 'databaseDocumentID';
|
||||
beforeEach(() => {
|
||||
actions = [
|
||||
{
|
||||
type: 'appRequestedResolverData',
|
||||
payload: { databaseDocumentID, indices: [], filters: {} },
|
||||
},
|
||||
appRequestedResolverData({
|
||||
id,
|
||||
parameters: { databaseDocumentID, indices: [], filters: {} },
|
||||
}),
|
||||
];
|
||||
});
|
||||
it('should be loading', () => {
|
||||
|
@ -199,23 +215,21 @@ describe('data state', () => {
|
|||
const resolverComponentInstanceID = 'resolverComponentInstanceID';
|
||||
beforeEach(() => {
|
||||
actions = [
|
||||
{
|
||||
type: 'appReceivedNewExternalProperties',
|
||||
payload: {
|
||||
databaseDocumentID,
|
||||
resolverComponentInstanceID,
|
||||
appReceivedNewExternalProperties({
|
||||
id,
|
||||
databaseDocumentID,
|
||||
resolverComponentInstanceID,
|
||||
|
||||
// `locationSearch` doesn't matter for this test
|
||||
locationSearch: '',
|
||||
indices: [],
|
||||
shouldUpdate: false,
|
||||
filters: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'appRequestedResolverData',
|
||||
payload: { databaseDocumentID, indices: [], filters: {} },
|
||||
},
|
||||
// `locationSearch` doesn't matter for this test
|
||||
locationSearch: '',
|
||||
indices: [],
|
||||
shouldUpdate: false,
|
||||
filters: {},
|
||||
}),
|
||||
appRequestedResolverData({
|
||||
id,
|
||||
parameters: { databaseDocumentID, indices: [], filters: {} },
|
||||
}),
|
||||
];
|
||||
});
|
||||
it('should be loading', () => {
|
||||
|
@ -236,10 +250,12 @@ describe('data state', () => {
|
|||
});
|
||||
describe('when the pending request fails', () => {
|
||||
beforeEach(() => {
|
||||
actions.push({
|
||||
type: 'serverFailedToReturnResolverData',
|
||||
payload: { databaseDocumentID, indices: [], filters: {} },
|
||||
});
|
||||
actions.push(
|
||||
serverFailedToReturnResolverData({
|
||||
id,
|
||||
parameters: { databaseDocumentID, indices: [], filters: {} },
|
||||
})
|
||||
);
|
||||
});
|
||||
it('should not be loading', () => {
|
||||
expect(selectors.isTreeLoading(state())).toBe(false);
|
||||
|
@ -267,36 +283,32 @@ describe('data state', () => {
|
|||
beforeEach(() => {
|
||||
actions = [
|
||||
// receive the document ID, this would cause the middleware to starts the request
|
||||
{
|
||||
type: 'appReceivedNewExternalProperties',
|
||||
payload: {
|
||||
databaseDocumentID: firstDatabaseDocumentID,
|
||||
resolverComponentInstanceID: resolverComponentInstanceID1,
|
||||
// `locationSearch` doesn't matter for this test
|
||||
locationSearch: '',
|
||||
indices: [],
|
||||
shouldUpdate: false,
|
||||
filters: {},
|
||||
},
|
||||
},
|
||||
appReceivedNewExternalProperties({
|
||||
id,
|
||||
databaseDocumentID: firstDatabaseDocumentID,
|
||||
resolverComponentInstanceID: resolverComponentInstanceID1,
|
||||
// `locationSearch` doesn't matter for this test
|
||||
locationSearch: '',
|
||||
indices: [],
|
||||
shouldUpdate: false,
|
||||
filters: {},
|
||||
}),
|
||||
// this happens when the middleware starts the request
|
||||
{
|
||||
type: 'appRequestedResolverData',
|
||||
payload: { databaseDocumentID: firstDatabaseDocumentID, indices: [], filters: {} },
|
||||
},
|
||||
appRequestedResolverData({
|
||||
id,
|
||||
parameters: { databaseDocumentID: firstDatabaseDocumentID, indices: [], filters: {} },
|
||||
}),
|
||||
// receive a different databaseDocumentID. this should cause the middleware to abort the existing request and start a new one
|
||||
{
|
||||
type: 'appReceivedNewExternalProperties',
|
||||
payload: {
|
||||
databaseDocumentID: secondDatabaseDocumentID,
|
||||
resolverComponentInstanceID: resolverComponentInstanceID2,
|
||||
// `locationSearch` doesn't matter for this test
|
||||
locationSearch: '',
|
||||
indices: [],
|
||||
shouldUpdate: false,
|
||||
filters: {},
|
||||
},
|
||||
},
|
||||
appReceivedNewExternalProperties({
|
||||
id,
|
||||
databaseDocumentID: secondDatabaseDocumentID,
|
||||
resolverComponentInstanceID: resolverComponentInstanceID2,
|
||||
// `locationSearch` doesn't matter for this test
|
||||
locationSearch: '',
|
||||
indices: [],
|
||||
shouldUpdate: false,
|
||||
filters: {},
|
||||
}),
|
||||
];
|
||||
});
|
||||
it('should be loading', () => {
|
||||
|
@ -327,10 +339,12 @@ describe('data state', () => {
|
|||
});
|
||||
describe('and when the old request was aborted', () => {
|
||||
beforeEach(() => {
|
||||
actions.push({
|
||||
type: 'appAbortedResolverDataRequest',
|
||||
payload: { databaseDocumentID: firstDatabaseDocumentID, indices: [], filters: {} },
|
||||
});
|
||||
actions.push(
|
||||
appAbortedResolverDataRequest({
|
||||
id,
|
||||
parameters: { databaseDocumentID: firstDatabaseDocumentID, indices: [], filters: {} },
|
||||
})
|
||||
);
|
||||
});
|
||||
it('should not require a pending request to be aborted', () => {
|
||||
expect(selectors.treeRequestParametersToAbort(state())).toBe(null);
|
||||
|
@ -355,10 +369,16 @@ describe('data state', () => {
|
|||
});
|
||||
describe('and when the next request starts', () => {
|
||||
beforeEach(() => {
|
||||
actions.push({
|
||||
type: 'appRequestedResolverData',
|
||||
payload: { databaseDocumentID: secondDatabaseDocumentID, indices: [], filters: {} },
|
||||
});
|
||||
actions.push(
|
||||
appRequestedResolverData({
|
||||
id,
|
||||
parameters: {
|
||||
databaseDocumentID: secondDatabaseDocumentID,
|
||||
indices: [],
|
||||
filters: {},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
it('should not have a document ID to fetch', () => {
|
||||
expect(selectors.treeParametersToFetch(state())).toBe(null);
|
||||
|
@ -394,30 +414,26 @@ describe('data state', () => {
|
|||
describe('when resolver receives external properties without time range filters', () => {
|
||||
beforeEach(() => {
|
||||
actions = [
|
||||
{
|
||||
type: 'appReceivedNewExternalProperties',
|
||||
payload: {
|
||||
databaseDocumentID,
|
||||
resolverComponentInstanceID,
|
||||
locationSearch: '',
|
||||
indices: [],
|
||||
shouldUpdate: false,
|
||||
filters: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'appRequestedResolverData',
|
||||
payload: { databaseDocumentID, indices: [], filters: {} },
|
||||
},
|
||||
{
|
||||
type: 'serverReturnedResolverData',
|
||||
payload: {
|
||||
result: resolverTree,
|
||||
dataSource,
|
||||
schema,
|
||||
parameters: { databaseDocumentID, indices: [], filters: {} },
|
||||
},
|
||||
},
|
||||
appReceivedNewExternalProperties({
|
||||
id,
|
||||
databaseDocumentID,
|
||||
resolverComponentInstanceID,
|
||||
locationSearch: '',
|
||||
indices: [],
|
||||
shouldUpdate: false,
|
||||
filters: {},
|
||||
}),
|
||||
appRequestedResolverData({
|
||||
id,
|
||||
parameters: { databaseDocumentID, indices: [], filters: {} },
|
||||
}),
|
||||
serverReturnedResolverData({
|
||||
id,
|
||||
result: resolverTree,
|
||||
dataSource,
|
||||
schema,
|
||||
parameters: { databaseDocumentID, indices: [], filters: {} },
|
||||
}),
|
||||
];
|
||||
});
|
||||
it('uses the default time range filters', () => {
|
||||
|
@ -432,38 +448,34 @@ describe('data state', () => {
|
|||
beforeEach(() => {
|
||||
actions = [
|
||||
...actions,
|
||||
{
|
||||
type: 'appReceivedNewExternalProperties',
|
||||
payload: {
|
||||
databaseDocumentID,
|
||||
resolverComponentInstanceID,
|
||||
locationSearch: '',
|
||||
indices: [],
|
||||
shouldUpdate: false,
|
||||
filters: timeRangeFilters,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'appRequestedResolverData',
|
||||
payload: {
|
||||
appReceivedNewExternalProperties({
|
||||
id,
|
||||
databaseDocumentID,
|
||||
resolverComponentInstanceID,
|
||||
locationSearch: '',
|
||||
indices: [],
|
||||
shouldUpdate: false,
|
||||
filters: timeRangeFilters,
|
||||
}),
|
||||
appRequestedResolverData({
|
||||
id,
|
||||
parameters: {
|
||||
databaseDocumentID,
|
||||
indices: [],
|
||||
filters: timeRangeFilters,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'serverReturnedResolverData',
|
||||
payload: {
|
||||
result: resolverTree,
|
||||
dataSource,
|
||||
schema,
|
||||
parameters: {
|
||||
databaseDocumentID,
|
||||
indices: [],
|
||||
filters: timeRangeFilters,
|
||||
},
|
||||
}),
|
||||
serverReturnedResolverData({
|
||||
id,
|
||||
result: resolverTree,
|
||||
dataSource,
|
||||
schema,
|
||||
parameters: {
|
||||
databaseDocumentID,
|
||||
indices: [],
|
||||
filters: timeRangeFilters,
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
});
|
||||
it('uses the received time range filters', () => {
|
||||
|
@ -480,20 +492,18 @@ describe('data state', () => {
|
|||
beforeEach(() => {
|
||||
const { schema, dataSource } = endpointSourceSchema();
|
||||
actions = [
|
||||
{
|
||||
type: 'serverReturnedResolverData',
|
||||
payload: {
|
||||
result: mockTreeWith2AncestorsAndNoChildren({
|
||||
originID,
|
||||
firstAncestorID,
|
||||
secondAncestorID,
|
||||
}),
|
||||
dataSource,
|
||||
schema,
|
||||
// this value doesn't matter
|
||||
parameters: mockTreeFetcherParameters(),
|
||||
},
|
||||
},
|
||||
serverReturnedResolverData({
|
||||
id,
|
||||
result: mockTreeWith2AncestorsAndNoChildren({
|
||||
originID,
|
||||
firstAncestorID,
|
||||
secondAncestorID,
|
||||
}),
|
||||
dataSource,
|
||||
schema,
|
||||
// this value doesn't matter
|
||||
parameters: mockTreeFetcherParameters(),
|
||||
}),
|
||||
];
|
||||
});
|
||||
it('should have no flowto candidate for the origin', () => {
|
||||
|
@ -517,16 +527,14 @@ describe('data state', () => {
|
|||
});
|
||||
beforeEach(() => {
|
||||
actions = [
|
||||
{
|
||||
type: 'serverReturnedNodeData',
|
||||
payload: {
|
||||
nodeData,
|
||||
requestedIDs: new Set([originID, firstAncestorID, secondAncestorID]),
|
||||
// mock the requested size being larger than the returned number of events so we
|
||||
// avoid the case where the limit was reached
|
||||
numberOfRequestedEvents: nodeData.length + 1,
|
||||
},
|
||||
},
|
||||
serverReturnedNodeData({
|
||||
id,
|
||||
nodeData,
|
||||
requestedIDs: new Set([originID, firstAncestorID, secondAncestorID]),
|
||||
// mock the requested size being larger than the returned number of events so we
|
||||
// avoid the case where the limit was reached
|
||||
numberOfRequestedEvents: nodeData.length + 1,
|
||||
}),
|
||||
];
|
||||
});
|
||||
it('should have origin as terminated', () => {
|
||||
|
@ -551,16 +559,14 @@ describe('data state', () => {
|
|||
});
|
||||
const { schema, dataSource } = endpointSourceSchema();
|
||||
actions = [
|
||||
{
|
||||
type: 'serverReturnedResolverData',
|
||||
payload: {
|
||||
result: resolverTree,
|
||||
dataSource,
|
||||
schema,
|
||||
// this value doesn't matter
|
||||
parameters: mockTreeFetcherParameters(),
|
||||
},
|
||||
},
|
||||
serverReturnedResolverData({
|
||||
id,
|
||||
result: resolverTree,
|
||||
dataSource,
|
||||
schema,
|
||||
// this value doesn't matter
|
||||
parameters: mockTreeFetcherParameters(),
|
||||
}),
|
||||
];
|
||||
});
|
||||
it('should have no flowto candidate for the origin', () => {
|
||||
|
@ -585,16 +591,14 @@ describe('data state', () => {
|
|||
});
|
||||
const { schema, dataSource } = endpointSourceSchema();
|
||||
actions = [
|
||||
{
|
||||
type: 'serverReturnedResolverData',
|
||||
payload: {
|
||||
result: resolverTree,
|
||||
dataSource,
|
||||
schema,
|
||||
// this value doesn't matter
|
||||
parameters: mockTreeFetcherParameters(),
|
||||
},
|
||||
},
|
||||
serverReturnedResolverData({
|
||||
id,
|
||||
result: resolverTree,
|
||||
dataSource,
|
||||
schema,
|
||||
// this value doesn't matter
|
||||
parameters: mockTreeFetcherParameters(),
|
||||
}),
|
||||
];
|
||||
});
|
||||
it('should be able to calculate the aria flowto candidates for all processes nodes', () => {
|
||||
|
@ -621,16 +625,14 @@ describe('data state', () => {
|
|||
});
|
||||
const { schema, dataSource } = endpointSourceSchema();
|
||||
actions = [
|
||||
{
|
||||
type: 'serverReturnedResolverData',
|
||||
payload: {
|
||||
result: tree,
|
||||
dataSource,
|
||||
schema,
|
||||
// this value doesn't matter
|
||||
parameters: mockTreeFetcherParameters(),
|
||||
},
|
||||
},
|
||||
serverReturnedResolverData({
|
||||
id,
|
||||
result: tree,
|
||||
dataSource,
|
||||
schema,
|
||||
// this value doesn't matter
|
||||
parameters: mockTreeFetcherParameters(),
|
||||
}),
|
||||
];
|
||||
});
|
||||
it('should have 4 graphable processes', () => {
|
||||
|
@ -642,16 +644,14 @@ describe('data state', () => {
|
|||
const { schema, dataSource } = endpointSourceSchema();
|
||||
const tree = mockTreeWithNoProcessEvents();
|
||||
actions = [
|
||||
{
|
||||
type: 'serverReturnedResolverData',
|
||||
payload: {
|
||||
result: tree,
|
||||
dataSource,
|
||||
schema,
|
||||
// this value doesn't matter
|
||||
parameters: mockTreeFetcherParameters(),
|
||||
},
|
||||
},
|
||||
serverReturnedResolverData({
|
||||
id,
|
||||
result: tree,
|
||||
dataSource,
|
||||
schema,
|
||||
// this value doesn't matter
|
||||
parameters: mockTreeFetcherParameters(),
|
||||
}),
|
||||
];
|
||||
});
|
||||
it('should return an empty layout', () => {
|
||||
|
@ -673,19 +673,17 @@ describe('data state', () => {
|
|||
});
|
||||
const { schema, dataSource } = endpointSourceSchema();
|
||||
actions = [
|
||||
{
|
||||
type: 'serverReturnedResolverData',
|
||||
payload: {
|
||||
result: resolverTree,
|
||||
dataSource,
|
||||
schema,
|
||||
parameters: {
|
||||
databaseDocumentID: '',
|
||||
indices: ['someNonDefaultIndex'],
|
||||
filters: {},
|
||||
},
|
||||
serverReturnedResolverData({
|
||||
id,
|
||||
result: resolverTree,
|
||||
dataSource,
|
||||
schema,
|
||||
parameters: {
|
||||
databaseDocumentID: '',
|
||||
indices: ['someNonDefaultIndex'],
|
||||
filters: {},
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
});
|
||||
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();
|
||||
actions = [
|
||||
{
|
||||
type: 'serverReturnedResolverData',
|
||||
payload: {
|
||||
result: resolverTree,
|
||||
dataSource,
|
||||
schema,
|
||||
parameters: {
|
||||
databaseDocumentID: '',
|
||||
indices: ['defaultIndex'],
|
||||
filters: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'appReceivedNewExternalProperties',
|
||||
payload: {
|
||||
serverReturnedResolverData({
|
||||
id,
|
||||
result: resolverTree,
|
||||
dataSource,
|
||||
schema,
|
||||
parameters: {
|
||||
databaseDocumentID: '',
|
||||
resolverComponentInstanceID: '',
|
||||
locationSearch: '',
|
||||
indices: ['someNonDefaultIndex', 'someOtherIndex'],
|
||||
shouldUpdate: false,
|
||||
indices: ['defaultIndex'],
|
||||
filters: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'appRequestedResolverData',
|
||||
payload: {
|
||||
}),
|
||||
appReceivedNewExternalProperties({
|
||||
id,
|
||||
databaseDocumentID: '',
|
||||
resolverComponentInstanceID: '',
|
||||
locationSearch: '',
|
||||
indices: ['someNonDefaultIndex', 'someOtherIndex'],
|
||||
shouldUpdate: false,
|
||||
filters: {},
|
||||
}),
|
||||
appRequestedResolverData({
|
||||
id,
|
||||
parameters: {
|
||||
databaseDocumentID: '',
|
||||
indices: ['someNonDefaultIndex', 'someOtherIndex'],
|
||||
filters: {},
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
});
|
||||
it('should have an empty array for tree parameter indices, and the same set of indices as the last tree response', () => {
|
||||
|
|
|
@ -5,19 +5,22 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { Store } from 'redux';
|
||||
import type { Store, AnyAction, Reducer } from 'redux';
|
||||
import { createStore } from 'redux';
|
||||
import type { ResolverAction } from '../actions';
|
||||
import { resolverReducer } from '../reducer';
|
||||
import type { ResolverState } from '../../types';
|
||||
import { analyzerReducer } from '../reducer';
|
||||
import type { AnalyzerState } from '../../types';
|
||||
import type { ResolverNode } from '../../../../common/endpoint/types';
|
||||
import { visibleNodesAndEdgeLines } from '../selectors';
|
||||
import { mock as mockResolverTree } from '../../models/resolver_tree';
|
||||
import { mockTreeFetcherParameters } from '../../mocks/tree_fetcher_parameters';
|
||||
import { endpointSourceSchema } from '../../mocks/tree_schema';
|
||||
import { mockResolverNode } from '../../mocks/resolver_node';
|
||||
import { serverReturnedResolverData } from './action';
|
||||
import { userSetRasterSize } from '../camera/action';
|
||||
import { EMPTY_RESOLVER } from '../helpers';
|
||||
|
||||
describe('resolver visible entities', () => {
|
||||
const id = 'test-id';
|
||||
let nodeA: ResolverNode;
|
||||
let nodeB: ResolverNode;
|
||||
let nodeC: ResolverNode;
|
||||
|
@ -25,7 +28,7 @@ describe('resolver visible entities', () => {
|
|||
let nodeE: ResolverNode;
|
||||
let nodeF: ResolverNode;
|
||||
let nodeG: ResolverNode;
|
||||
let store: Store<ResolverState, ResolverAction>;
|
||||
let store: Store<AnalyzerState, AnyAction>;
|
||||
|
||||
beforeEach(() => {
|
||||
/*
|
||||
|
@ -92,31 +95,41 @@ describe('resolver visible entities', () => {
|
|||
stats: { total: 0, byCategory: {} },
|
||||
timestamp: 0,
|
||||
});
|
||||
store = createStore(resolverReducer, undefined);
|
||||
const testReducer: Reducer<AnalyzerState, AnyAction> = (
|
||||
analyzerState = {
|
||||
analyzerById: {
|
||||
[id]: EMPTY_RESOLVER,
|
||||
},
|
||||
},
|
||||
action
|
||||
): AnalyzerState => analyzerReducer(analyzerState, action);
|
||||
store = createStore(testReducer, undefined);
|
||||
});
|
||||
describe('when rendering a large tree with a small viewport', () => {
|
||||
beforeEach(() => {
|
||||
const nodes: ResolverNode[] = [nodeA, nodeB, nodeC, nodeD, nodeE, nodeF, nodeG];
|
||||
const { schema, dataSource } = endpointSourceSchema();
|
||||
const action: ResolverAction = {
|
||||
type: 'serverReturnedResolverData',
|
||||
payload: {
|
||||
store.dispatch(
|
||||
serverReturnedResolverData({
|
||||
id,
|
||||
result: mockResolverTree({ nodes })!,
|
||||
dataSource,
|
||||
schema,
|
||||
parameters: mockTreeFetcherParameters(),
|
||||
},
|
||||
};
|
||||
const cameraAction: ResolverAction = { type: 'userSetRasterSize', payload: [300, 200] };
|
||||
store.dispatch(action);
|
||||
store.dispatch(cameraAction);
|
||||
})
|
||||
);
|
||||
store.dispatch(userSetRasterSize({ id, dimensions: [300, 200] }));
|
||||
});
|
||||
it('the visibleProcessNodePositions list should only include 2 nodes', () => {
|
||||
const { processNodePositions } = visibleNodesAndEdgeLines(store.getState())(0);
|
||||
const { processNodePositions } = visibleNodesAndEdgeLines(store.getState().analyzerById[id])(
|
||||
0
|
||||
);
|
||||
expect([...processNodePositions.keys()].length).toEqual(2);
|
||||
});
|
||||
it('the visibleEdgeLineSegments list should only include one edge line', () => {
|
||||
const { connectingEdgeLineSegments } = visibleNodesAndEdgeLines(store.getState())(0);
|
||||
const { connectingEdgeLineSegments } = visibleNodesAndEdgeLines(
|
||||
store.getState().analyzerById[id]
|
||||
)(0);
|
||||
expect(connectingEdgeLineSegments.length).toEqual(1);
|
||||
});
|
||||
});
|
||||
|
@ -124,25 +137,27 @@ describe('resolver visible entities', () => {
|
|||
beforeEach(() => {
|
||||
const nodes: ResolverNode[] = [nodeA, nodeB, nodeC, nodeD, nodeE, nodeF, nodeG];
|
||||
const { schema, dataSource } = endpointSourceSchema();
|
||||
const action: ResolverAction = {
|
||||
type: 'serverReturnedResolverData',
|
||||
payload: {
|
||||
store.dispatch(
|
||||
serverReturnedResolverData({
|
||||
id,
|
||||
result: mockResolverTree({ nodes })!,
|
||||
dataSource,
|
||||
schema,
|
||||
parameters: mockTreeFetcherParameters(),
|
||||
},
|
||||
};
|
||||
const cameraAction: ResolverAction = { type: 'userSetRasterSize', payload: [2000, 2000] };
|
||||
store.dispatch(action);
|
||||
store.dispatch(cameraAction);
|
||||
})
|
||||
);
|
||||
store.dispatch(userSetRasterSize({ id, dimensions: [2000, 2000] }));
|
||||
});
|
||||
it('the visibleProcessNodePositions list should include all process nodes', () => {
|
||||
const { processNodePositions } = visibleNodesAndEdgeLines(store.getState())(0);
|
||||
const { processNodePositions } = visibleNodesAndEdgeLines(store.getState().analyzerById[id])(
|
||||
0
|
||||
);
|
||||
expect([...processNodePositions.keys()].length).toEqual(5);
|
||||
});
|
||||
it('the visibleEdgeLineSegments list include all lines', () => {
|
||||
const { connectingEdgeLineSegments } = visibleNodesAndEdgeLines(store.getState())(0);
|
||||
const { connectingEdgeLineSegments } = visibleNodesAndEdgeLines(
|
||||
store.getState().analyzerById[id]
|
||||
)(0);
|
||||
expect(connectingEdgeLineSegments.length).toEqual(4);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
||||
import type { Store } from 'redux';
|
||||
import type { Store, AnyAction } from 'redux';
|
||||
import { createStore, applyMiddleware } from 'redux';
|
||||
import { composeWithDevTools } from 'redux-devtools-extension/developmentOnly';
|
||||
import type { ResolverState, DataAccessLayer } from '../types';
|
||||
import { resolverReducer } from './reducer';
|
||||
import type { AnalyzerState, DataAccessLayer } from '../types';
|
||||
import { analyzerReducer } from './reducer';
|
||||
import { resolverMiddlewareFactory } from './middleware';
|
||||
import type { ResolverAction } from './actions';
|
||||
|
||||
export const resolverStoreFactory = (
|
||||
dataAccessLayer: DataAccessLayer
|
||||
): Store<ResolverState, ResolverAction> => {
|
||||
const actionsDenylist: Array<ResolverAction['type']> = ['userMovedPointer'];
|
||||
): Store<AnalyzerState, AnyAction> => {
|
||||
const actionsDenylist: Array<AnyAction['type']> = ['userMovedPointer'];
|
||||
const composeEnhancers = composeWithDevTools({
|
||||
name: 'Resolver',
|
||||
actionsBlacklist: actionsDenylist,
|
||||
});
|
||||
const middlewareEnhancer = applyMiddleware(resolverMiddlewareFactory(dataAccessLayer));
|
||||
|
||||
return createStore(resolverReducer, composeEnhancers(middlewareEnhancer));
|
||||
return createStore(analyzerReducer, composeEnhancers(middlewareEnhancer));
|
||||
};
|
||||
|
|
|
@ -9,9 +9,14 @@ import type { Dispatch, MiddlewareAPI } from 'redux';
|
|||
import { isEqual } from 'lodash';
|
||||
import type { SafeResolverEvent } from '../../../../common/endpoint/types';
|
||||
|
||||
import type { ResolverState, DataAccessLayer, PanelViewAndParameters } from '../../types';
|
||||
import type { DataAccessLayer, PanelViewAndParameters } from '../../types';
|
||||
import type { State } from '../../../common/store/types';
|
||||
import * as selectors from '../selectors';
|
||||
import type { ResolverAction } from '../actions';
|
||||
import {
|
||||
appRequestedCurrentRelatedEventData,
|
||||
serverFailedToReturnCurrentRelatedEventData,
|
||||
serverReturnedCurrentRelatedEventData,
|
||||
} from '../data/action';
|
||||
|
||||
/**
|
||||
*
|
||||
|
@ -19,23 +24,25 @@ import type { ResolverAction } from '../actions';
|
|||
* If the current view is the `eventDetail` view it will request the event details from the server.
|
||||
* @export
|
||||
* @param {DataAccessLayer} dataAccessLayer
|
||||
* @param {MiddlewareAPI<Dispatch<ResolverAction>, ResolverState>} api
|
||||
* @param {MiddlewareAPI<Dispatch<Action>, State>} api
|
||||
* @returns {() => void}
|
||||
*/
|
||||
export function CurrentRelatedEventFetcher(
|
||||
dataAccessLayer: DataAccessLayer,
|
||||
api: MiddlewareAPI<Dispatch<ResolverAction>, ResolverState>
|
||||
): () => void {
|
||||
let last: PanelViewAndParameters | undefined;
|
||||
|
||||
return async () => {
|
||||
api: MiddlewareAPI<Dispatch, State>
|
||||
): (id: string) => void {
|
||||
const last: { [id: string]: PanelViewAndParameters | undefined } = {};
|
||||
return async (id: string) => {
|
||||
const state = api.getState();
|
||||
|
||||
const newParams = selectors.panelViewAndParameters(state);
|
||||
const indices = selectors.eventIndices(state);
|
||||
if (!last[id]) {
|
||||
last[id] = undefined;
|
||||
}
|
||||
const newParams = selectors.panelViewAndParameters(state.analyzer.analyzerById[id]);
|
||||
const indices = selectors.eventIndices(state.analyzer.analyzerById[id]);
|
||||
|
||||
const oldParams = last;
|
||||
last = newParams;
|
||||
const oldParams = last[id];
|
||||
last[id] = newParams;
|
||||
|
||||
// If the panel view params have changed and the current panel view is the `eventDetail`, then fetch the event details for that eventID.
|
||||
if (!isEqual(newParams, oldParams) && newParams.panelView === 'eventDetail') {
|
||||
|
@ -45,12 +52,12 @@ export function CurrentRelatedEventFetcher(
|
|||
const currentEventTimestamp = newParams.panelParameters.eventTimestamp;
|
||||
const winlogRecordID = newParams.panelParameters.winlogRecordID;
|
||||
|
||||
api.dispatch({
|
||||
type: 'appRequestedCurrentRelatedEventData',
|
||||
});
|
||||
const detectedBounds = selectors.detectedBounds(state);
|
||||
api.dispatch(appRequestedCurrentRelatedEventData({ id }));
|
||||
const detectedBounds = selectors.detectedBounds(state.analyzer.analyzerById[id]);
|
||||
const timeRangeFilters =
|
||||
detectedBounds !== undefined ? undefined : selectors.timeRangeFilters(state);
|
||||
detectedBounds !== undefined
|
||||
? undefined
|
||||
: selectors.timeRangeFilters(state.analyzer.analyzerById[id]);
|
||||
let result: SafeResolverEvent | null = null;
|
||||
try {
|
||||
result = await dataAccessLayer.event({
|
||||
|
@ -63,20 +70,13 @@ export function CurrentRelatedEventFetcher(
|
|||
timeRange: timeRangeFilters,
|
||||
});
|
||||
} catch (error) {
|
||||
api.dispatch({
|
||||
type: 'serverFailedToReturnCurrentRelatedEventData',
|
||||
});
|
||||
api.dispatch(serverFailedToReturnCurrentRelatedEventData({ id }));
|
||||
}
|
||||
|
||||
if (result) {
|
||||
api.dispatch({
|
||||
type: 'serverReturnedCurrentRelatedEventData',
|
||||
payload: result,
|
||||
});
|
||||
api.dispatch(serverReturnedCurrentRelatedEventData({ id, relatedEvent: result }));
|
||||
} else {
|
||||
api.dispatch({
|
||||
type: 'serverFailedToReturnCurrentRelatedEventData',
|
||||
});
|
||||
api.dispatch(serverFailedToReturnCurrentRelatedEventData({ id }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -5,21 +5,49 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { Dispatch, MiddlewareAPI } from 'redux';
|
||||
import type { ResolverState, DataAccessLayer } from '../../types';
|
||||
import type { Dispatch, MiddlewareAPI, AnyAction } from 'redux';
|
||||
import type { DataAccessLayer } from '../../types';
|
||||
import { ResolverTreeFetcher } from './resolver_tree_fetcher';
|
||||
|
||||
import type { ResolverAction } from '../actions';
|
||||
import type { State } from '../../../common/store/types';
|
||||
import { RelatedEventsFetcher } from './related_events_fetcher';
|
||||
import { CurrentRelatedEventFetcher } from './current_related_event_fetcher';
|
||||
import { NodeDataFetcher } from './node_data_fetcher';
|
||||
import * as Actions from '../actions';
|
||||
import * as DataActions from '../data/action';
|
||||
import * as CameraActions from '../camera/action';
|
||||
|
||||
type MiddlewareFactory<S = ResolverState> = (
|
||||
type MiddlewareFactory<S = State> = (
|
||||
dataAccessLayer: DataAccessLayer
|
||||
) => (
|
||||
api: MiddlewareAPI<Dispatch<ResolverAction>, S>
|
||||
) => (next: Dispatch<ResolverAction>) => (action: ResolverAction) => unknown;
|
||||
api: MiddlewareAPI<Dispatch<AnyAction>, S>
|
||||
) => (next: Dispatch<AnyAction>) => (action: AnyAction) => unknown;
|
||||
|
||||
const resolverActions = [
|
||||
...Object.values(Actions).map((action) => action.type),
|
||||
...Object.values(DataActions).map((action) => action.type),
|
||||
...Object.values(CameraActions).map((action) => action.type),
|
||||
];
|
||||
|
||||
/**
|
||||
* Helper function to determine if analyzer is active (resolver middleware should be run)
|
||||
* analyzer is considered active if: action is not clean up
|
||||
* @param state analyzerbyId state
|
||||
* @param action dispatched action
|
||||
* @returns boolean of whether the analyzer of id has an store in redux
|
||||
*/
|
||||
function isAnalyzerActive(action: AnyAction): boolean {
|
||||
// middleware shouldn't run after clear resolver
|
||||
return !Actions.clearResolver.match(action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to check whether an action is a resolver action
|
||||
* @param action dispatched action
|
||||
* @returns boolean of whether the action is a resolver action
|
||||
*/
|
||||
function isResolverAction(action: AnyAction): boolean {
|
||||
return resolverActions.includes(action.type);
|
||||
}
|
||||
/**
|
||||
* The `redux` middleware that the application uses to trigger side effects.
|
||||
* All data fetching should be done here.
|
||||
|
@ -32,13 +60,16 @@ export const resolverMiddlewareFactory: MiddlewareFactory = (dataAccessLayer: Da
|
|||
const relatedEventsFetcher = RelatedEventsFetcher(dataAccessLayer, api);
|
||||
const currentRelatedEventFetcher = CurrentRelatedEventFetcher(dataAccessLayer, api);
|
||||
const nodeDataFetcher = NodeDataFetcher(dataAccessLayer, api);
|
||||
return async (action: ResolverAction) => {
|
||||
|
||||
return async (action: AnyAction) => {
|
||||
next(action);
|
||||
|
||||
resolverTreeFetcher();
|
||||
relatedEventsFetcher();
|
||||
nodeDataFetcher();
|
||||
currentRelatedEventFetcher();
|
||||
if (action.payload?.id && isAnalyzerActive(action) && isResolverAction(action)) {
|
||||
resolverTreeFetcher(action.payload.id);
|
||||
relatedEventsFetcher(action.payload.id);
|
||||
nodeDataFetcher(action.payload.id);
|
||||
currentRelatedEventFetcher(action.payload.id);
|
||||
}
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
|
@ -7,10 +7,14 @@
|
|||
|
||||
import type { Dispatch, MiddlewareAPI } from 'redux';
|
||||
import type { SafeResolverEvent } from '../../../../common/endpoint/types';
|
||||
|
||||
import type { ResolverState, DataAccessLayer } from '../../types';
|
||||
import type { DataAccessLayer } from '../../types';
|
||||
import type { State } from '../../../common/store/types';
|
||||
import * as selectors from '../selectors';
|
||||
import type { ResolverAction } from '../actions';
|
||||
import {
|
||||
appRequestingNodeData,
|
||||
serverFailedToReturnNodeData,
|
||||
serverReturnedNodeData,
|
||||
} from '../data/action';
|
||||
|
||||
/**
|
||||
* Max number of nodes to request from the server
|
||||
|
@ -25,11 +29,10 @@ const nodeDataLimit = 5000;
|
|||
*/
|
||||
export function NodeDataFetcher(
|
||||
dataAccessLayer: DataAccessLayer,
|
||||
api: MiddlewareAPI<Dispatch<ResolverAction>, ResolverState>
|
||||
): () => void {
|
||||
return async () => {
|
||||
api: MiddlewareAPI<Dispatch, State>
|
||||
): (id: string) => void {
|
||||
return async (id: string) => {
|
||||
const state = api.getState();
|
||||
|
||||
/**
|
||||
* Using the greatest positive number here so that we will request the node data for the nodes in view
|
||||
* before the animation finishes. This will be a better user experience since we'll start the request while
|
||||
|
@ -37,8 +40,10 @@ export function NodeDataFetcher(
|
|||
*
|
||||
* This gets the visible nodes that we haven't already requested or received data for
|
||||
*/
|
||||
const newIDsToRequest: Set<string> = selectors.newIDsToRequest(state)(Number.POSITIVE_INFINITY);
|
||||
const indices = selectors.eventIndices(state);
|
||||
const newIDsToRequest: Set<string> = selectors.newIDsToRequest(state.analyzer.analyzerById[id])(
|
||||
Number.POSITIVE_INFINITY
|
||||
);
|
||||
const indices = selectors.eventIndices(state.analyzer.analyzerById[id]);
|
||||
|
||||
if (newIDsToRequest.size <= 0) {
|
||||
return;
|
||||
|
@ -51,18 +56,15 @@ export function NodeDataFetcher(
|
|||
* When we dispatch this, this middleware will run again but the visible nodes will be the same, the nodeData
|
||||
* state will have the new visible nodes in it, and newIDsToRequest will be an empty set.
|
||||
*/
|
||||
api.dispatch({
|
||||
type: 'appRequestingNodeData',
|
||||
payload: {
|
||||
requestedIDs: newIDsToRequest,
|
||||
},
|
||||
});
|
||||
api.dispatch(appRequestingNodeData({ id, requestedIDs: newIDsToRequest }));
|
||||
|
||||
let results: SafeResolverEvent[] | undefined;
|
||||
try {
|
||||
const detectedBounds = selectors.detectedBounds(state);
|
||||
const detectedBounds = selectors.detectedBounds(state.analyzer.analyzerById[id]);
|
||||
const timeRangeFilters =
|
||||
detectedBounds !== undefined ? undefined : selectors.timeRangeFilters(state);
|
||||
detectedBounds !== undefined
|
||||
? undefined
|
||||
: selectors.timeRangeFilters(state.analyzer.analyzerById[id]);
|
||||
results = await dataAccessLayer.nodeData({
|
||||
ids: Array.from(newIDsToRequest),
|
||||
timeRange: timeRangeFilters,
|
||||
|
@ -73,12 +75,7 @@ export function NodeDataFetcher(
|
|||
/**
|
||||
* Dispatch an action indicating all the nodes that we failed to retrieve data for
|
||||
*/
|
||||
api.dispatch({
|
||||
type: 'serverFailedToReturnNodeData',
|
||||
payload: {
|
||||
requestedIDs: newIDsToRequest,
|
||||
},
|
||||
});
|
||||
api.dispatch(serverFailedToReturnNodeData({ id, requestedIDs: newIDsToRequest }));
|
||||
}
|
||||
|
||||
if (results) {
|
||||
|
@ -87,9 +84,9 @@ export function NodeDataFetcher(
|
|||
* not have received events for each node so the original IDs will help with identifying nodes that we have
|
||||
* no data for.
|
||||
*/
|
||||
api.dispatch({
|
||||
type: 'serverReturnedNodeData',
|
||||
payload: {
|
||||
api.dispatch(
|
||||
serverReturnedNodeData({
|
||||
id,
|
||||
nodeData: results,
|
||||
requestedIDs: newIDsToRequest,
|
||||
/**
|
||||
|
@ -114,8 +111,8 @@ export function NodeDataFetcher(
|
|||
* if that node is still in view we'll request its node data.
|
||||
*/
|
||||
numberOfRequestedEvents: nodeDataLimit,
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -9,32 +9,42 @@ import type { Dispatch, MiddlewareAPI } from 'redux';
|
|||
import { isEqual } from 'lodash';
|
||||
import type { ResolverPaginatedEvents } from '../../../../common/endpoint/types';
|
||||
|
||||
import type { ResolverState, DataAccessLayer, PanelViewAndParameters } from '../../types';
|
||||
import type { DataAccessLayer, PanelViewAndParameters } from '../../types';
|
||||
import * as selectors from '../selectors';
|
||||
import type { ResolverAction } from '../actions';
|
||||
import type { State } from '../../../common/store/types';
|
||||
import {
|
||||
serverFailedToReturnNodeEventsInCategory,
|
||||
serverReturnedNodeEventsInCategory,
|
||||
} from '../data/action';
|
||||
|
||||
export function RelatedEventsFetcher(
|
||||
dataAccessLayer: DataAccessLayer,
|
||||
api: MiddlewareAPI<Dispatch<ResolverAction>, ResolverState>
|
||||
): () => void {
|
||||
let last: PanelViewAndParameters | undefined;
|
||||
|
||||
api: MiddlewareAPI<Dispatch, State>
|
||||
): (id: string) => void {
|
||||
const last: { [id: string]: PanelViewAndParameters | undefined } = {};
|
||||
// Call this after each state change.
|
||||
// This fetches the ResolverTree for the current entityID
|
||||
// if the entityID changes while
|
||||
return async () => {
|
||||
return async (id: string) => {
|
||||
const state = api.getState();
|
||||
|
||||
const newParams = selectors.panelViewAndParameters(state);
|
||||
const isLoadingMoreEvents = selectors.isLoadingMoreNodeEventsInCategory(state);
|
||||
const indices = selectors.eventIndices(state);
|
||||
if (!last[id]) {
|
||||
last[id] = undefined;
|
||||
}
|
||||
const newParams = selectors.panelViewAndParameters(state.analyzer.analyzerById[id]);
|
||||
const isLoadingMoreEvents = selectors.isLoadingMoreNodeEventsInCategory(
|
||||
state.analyzer.analyzerById[id]
|
||||
);
|
||||
const indices = selectors.eventIndices(state.analyzer.analyzerById[id]);
|
||||
|
||||
const oldParams = last;
|
||||
const detectedBounds = selectors.detectedBounds(state);
|
||||
const oldParams = last[id];
|
||||
const detectedBounds = selectors.detectedBounds(state.analyzer.analyzerById[id]);
|
||||
const timeRangeFilters =
|
||||
detectedBounds !== undefined ? undefined : selectors.timeRangeFilters(state);
|
||||
detectedBounds !== undefined
|
||||
? undefined
|
||||
: selectors.timeRangeFilters(state.analyzer.analyzerById[id]);
|
||||
// Update this each time before fetching data (or even if we don't fetch data) so that subsequent actions that call this (concurrently) will have up to date info.
|
||||
last = newParams;
|
||||
last[id] = newParams;
|
||||
|
||||
async function fetchEvents({
|
||||
nodeID,
|
||||
|
@ -65,26 +75,21 @@ export function RelatedEventsFetcher(
|
|||
});
|
||||
}
|
||||
} catch (error) {
|
||||
api.dispatch({
|
||||
type: 'serverFailedToReturnNodeEventsInCategory',
|
||||
payload: {
|
||||
nodeID,
|
||||
eventCategory,
|
||||
cursor,
|
||||
},
|
||||
});
|
||||
api.dispatch(
|
||||
serverFailedToReturnNodeEventsInCategory({ id, nodeID, eventCategory, cursor })
|
||||
);
|
||||
}
|
||||
|
||||
if (result) {
|
||||
api.dispatch({
|
||||
type: 'serverReturnedNodeEventsInCategory',
|
||||
payload: {
|
||||
api.dispatch(
|
||||
serverReturnedNodeEventsInCategory({
|
||||
id,
|
||||
events: result.events,
|
||||
eventCategory,
|
||||
cursor: result.nextEvent,
|
||||
nodeID,
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -92,12 +97,6 @@ export function RelatedEventsFetcher(
|
|||
if (!isEqual(newParams, oldParams)) {
|
||||
if (newParams.panelView === 'nodeEventsInCategory') {
|
||||
const nodeID = newParams.panelParameters.nodeID;
|
||||
api.dispatch({
|
||||
type: 'appRequestedNodeEventsInCategory',
|
||||
payload: {
|
||||
parameters: newParams,
|
||||
},
|
||||
});
|
||||
await fetchEvents({
|
||||
nodeID,
|
||||
eventCategory: newParams.panelParameters.eventCategory,
|
||||
|
@ -105,7 +104,7 @@ export function RelatedEventsFetcher(
|
|||
});
|
||||
}
|
||||
} else if (isLoadingMoreEvents) {
|
||||
const nodeEventsInCategory = state.data.nodeEventsInCategory;
|
||||
const nodeEventsInCategory = state.analyzer.analyzerById[id].data.nodeEventsInCategory;
|
||||
if (nodeEventsInCategory !== undefined) {
|
||||
await fetchEvents(nodeEventsInCategory);
|
||||
}
|
||||
|
|
|
@ -12,12 +12,18 @@ import type {
|
|||
NewResolverTree,
|
||||
ResolverSchema,
|
||||
} from '../../../../common/endpoint/types';
|
||||
import type { ResolverState, DataAccessLayer } from '../../types';
|
||||
import type { DataAccessLayer } from '../../types';
|
||||
import * as selectors from '../selectors';
|
||||
import { firstNonNullValue } from '../../../../common/endpoint/models/ecs_safety_helpers';
|
||||
import type { ResolverAction } from '../actions';
|
||||
import { ancestorsRequestAmount, descendantsRequestAmount } from '../../models/resolver_tree';
|
||||
|
||||
import {
|
||||
appRequestedResolverData,
|
||||
serverFailedToReturnResolverData,
|
||||
appAbortedResolverDataRequest,
|
||||
serverReturnedResolverData,
|
||||
} from '../data/action';
|
||||
import type { State } from '../../../common/store/types';
|
||||
/**
|
||||
* A function that handles syncing ResolverTree data w/ the current entity ID.
|
||||
* This will make a request anytime the entityID changes (to something other than undefined.)
|
||||
|
@ -27,17 +33,20 @@ import { ancestorsRequestAmount, descendantsRequestAmount } from '../../models/r
|
|||
*/
|
||||
export function ResolverTreeFetcher(
|
||||
dataAccessLayer: DataAccessLayer,
|
||||
api: MiddlewareAPI<Dispatch<ResolverAction>, ResolverState>
|
||||
): () => void {
|
||||
api: MiddlewareAPI<Dispatch, State>
|
||||
): (id: string) => void {
|
||||
let lastRequestAbortController: AbortController | undefined;
|
||||
// Call this after each state change.
|
||||
// This fetches the ResolverTree for the current entityID
|
||||
// if the entityID changes while
|
||||
return async () => {
|
||||
return async (id: string) => {
|
||||
// const id = 'alerts-page';
|
||||
const state = api.getState();
|
||||
const databaseParameters = selectors.treeParametersToFetch(state);
|
||||
|
||||
if (selectors.treeRequestParametersToAbort(state) && lastRequestAbortController) {
|
||||
const databaseParameters = selectors.treeParametersToFetch(state.analyzer.analyzerById[id]);
|
||||
if (
|
||||
selectors.treeRequestParametersToAbort(state.analyzer.analyzerById[id]) &&
|
||||
lastRequestAbortController
|
||||
) {
|
||||
lastRequestAbortController.abort();
|
||||
// calling abort will cause an action to be fired
|
||||
} else if (databaseParameters !== null) {
|
||||
|
@ -46,14 +55,11 @@ export function ResolverTreeFetcher(
|
|||
let dataSource: string | undefined;
|
||||
let dataSourceSchema: ResolverSchema | undefined;
|
||||
let result: ResolverNode[] | undefined;
|
||||
const timeRangeFilters = selectors.timeRangeFilters(state);
|
||||
const timeRangeFilters = selectors.timeRangeFilters(state.analyzer.analyzerById[id]);
|
||||
|
||||
// Inform the state that we've made the request. Without this, the middleware will try to make the request again
|
||||
// immediately.
|
||||
api.dispatch({
|
||||
type: 'appRequestedResolverData',
|
||||
payload: databaseParameters,
|
||||
});
|
||||
api.dispatch(appRequestedResolverData({ id, parameters: databaseParameters }));
|
||||
try {
|
||||
const matchingEntities: ResolverEntityIndex = await dataAccessLayer.entities({
|
||||
_id: databaseParameters.databaseDocumentID,
|
||||
|
@ -62,10 +68,12 @@ export function ResolverTreeFetcher(
|
|||
});
|
||||
if (matchingEntities.length < 1) {
|
||||
// If no entity_id could be found for the _id, bail out with a failure.
|
||||
api.dispatch({
|
||||
type: 'serverFailedToReturnResolverData',
|
||||
payload: databaseParameters,
|
||||
});
|
||||
api.dispatch(
|
||||
serverFailedToReturnResolverData({
|
||||
id,
|
||||
parameters: databaseParameters,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
({ id: entityIDToFetch, schema: dataSourceSchema, name: dataSource } = matchingEntities[0]);
|
||||
|
@ -98,9 +106,9 @@ export function ResolverTreeFetcher(
|
|||
.sort();
|
||||
const oldestTimestamp = timestamps[0];
|
||||
const newestTimestamp = timestamps.slice(-1);
|
||||
api.dispatch({
|
||||
type: 'serverReturnedResolverData',
|
||||
payload: {
|
||||
api.dispatch(
|
||||
serverReturnedResolverData({
|
||||
id,
|
||||
result: { ...resolverTree, nodes: unboundedTree },
|
||||
dataSource,
|
||||
schema: dataSourceSchema,
|
||||
|
@ -109,43 +117,38 @@ export function ResolverTreeFetcher(
|
|||
from: String(oldestTimestamp),
|
||||
to: String(newestTimestamp),
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// 0 results with unbounded query, fail as before
|
||||
} else {
|
||||
api.dispatch({
|
||||
type: 'serverReturnedResolverData',
|
||||
payload: {
|
||||
api.dispatch(
|
||||
serverReturnedResolverData({
|
||||
id,
|
||||
result: resolverTree,
|
||||
dataSource,
|
||||
schema: dataSourceSchema,
|
||||
parameters: databaseParameters,
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
} else {
|
||||
api.dispatch({
|
||||
type: 'serverReturnedResolverData',
|
||||
payload: {
|
||||
api.dispatch(
|
||||
serverReturnedResolverData({
|
||||
id,
|
||||
result: resolverTree,
|
||||
dataSource,
|
||||
schema: dataSourceSchema,
|
||||
parameters: databaseParameters,
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/DOMException#exception-AbortError
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
api.dispatch({
|
||||
type: 'appAbortedResolverDataRequest',
|
||||
payload: databaseParameters,
|
||||
});
|
||||
api.dispatch(appAbortedResolverDataRequest({ id, parameters: databaseParameters }));
|
||||
} else {
|
||||
api.dispatch({
|
||||
type: 'serverFailedToReturnResolverData',
|
||||
payload: databaseParameters,
|
||||
});
|
||||
api.dispatch(serverFailedToReturnResolverData({ id, parameters: databaseParameters }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,81 +4,97 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { Reducer } from 'redux';
|
||||
import { combineReducers } from 'redux';
|
||||
import type { Reducer, AnyAction } from 'redux';
|
||||
import { reducerWithInitialState } from 'typescript-fsa-reducers';
|
||||
import reduceReducers from 'reduce-reducers';
|
||||
import { immerCase, EMPTY_RESOLVER } from './helpers';
|
||||
import { animatePanning } from './camera/methods';
|
||||
import { layout } from './selectors';
|
||||
import { cameraReducer } from './camera/reducer';
|
||||
import { dataReducer } from './data/reducer';
|
||||
import type { ResolverAction } from './actions';
|
||||
import type { ResolverState, ResolverUIState } from '../types';
|
||||
import type { AnalyzerState } from '../types';
|
||||
import { panAnimationDuration } from './camera/scaling_constants';
|
||||
import { nodePosition } from '../models/indexed_process_tree/isometric_taxi_layout';
|
||||
import {
|
||||
appReceivedNewExternalProperties,
|
||||
userFocusedOnResolverNode,
|
||||
userSelectedResolverNode,
|
||||
createResolver,
|
||||
clearResolver,
|
||||
} from './actions';
|
||||
import { serverReturnedResolverData } from './data/action';
|
||||
|
||||
const uiReducer: Reducer<ResolverUIState, ResolverAction> = (
|
||||
state = {
|
||||
ariaActiveDescendant: null,
|
||||
selectedNode: null,
|
||||
},
|
||||
action
|
||||
) => {
|
||||
if (action.type === 'serverReturnedResolverData') {
|
||||
const next: ResolverUIState = {
|
||||
...state,
|
||||
ariaActiveDescendant: action.payload.result.originID,
|
||||
selectedNode: action.payload.result.originID,
|
||||
};
|
||||
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;
|
||||
}
|
||||
export const initialAnalyzerState: AnalyzerState = {
|
||||
analyzerById: {},
|
||||
};
|
||||
|
||||
const concernReducers = combineReducers({
|
||||
camera: cameraReducer,
|
||||
data: dataReducer,
|
||||
ui: uiReducer,
|
||||
});
|
||||
export const resolverReducer: Reducer<ResolverState, ResolverAction> = (state, action) => {
|
||||
const nextState = concernReducers(state, action);
|
||||
if (action.type === 'userSelectedResolverNode' || action.type === 'userFocusedOnResolverNode') {
|
||||
const position = nodePosition(layout(nextState), action.payload.nodeID);
|
||||
if (position) {
|
||||
const withAnimation: ResolverState = {
|
||||
...nextState,
|
||||
camera: animatePanning(
|
||||
nextState.camera,
|
||||
action.payload.time,
|
||||
const uiReducer = reducerWithInitialState(initialAnalyzerState)
|
||||
.withHandling(
|
||||
immerCase(createResolver, (draft, { id }) => {
|
||||
if (!draft.analyzerById[id]) {
|
||||
draft.analyzerById[id] = EMPTY_RESOLVER;
|
||||
}
|
||||
return draft;
|
||||
})
|
||||
)
|
||||
.withHandling(
|
||||
immerCase(clearResolver, (draft, { id }) => {
|
||||
delete draft.analyzerById[id];
|
||||
return draft;
|
||||
})
|
||||
)
|
||||
.withHandling(
|
||||
immerCase(
|
||||
appReceivedNewExternalProperties,
|
||||
(draft, { id, resolverComponentInstanceID, locationSearch }) => {
|
||||
draft.analyzerById[id].ui.locationSearch = locationSearch;
|
||||
draft.analyzerById[id].ui.resolverComponentInstanceID = resolverComponentInstanceID;
|
||||
}
|
||||
)
|
||||
)
|
||||
.withHandling(
|
||||
immerCase(serverReturnedResolverData, (draft, { id, result }) => {
|
||||
draft.analyzerById[id].ui.ariaActiveDescendant = result.originID;
|
||||
draft.analyzerById[id].ui.selectedNode = result.originID;
|
||||
return draft;
|
||||
})
|
||||
)
|
||||
.withHandling(
|
||||
immerCase(userFocusedOnResolverNode, (draft, { id, nodeID, time }) => {
|
||||
draft.analyzerById[id].ui.ariaActiveDescendant = nodeID;
|
||||
const position = nodePosition(layout(draft.analyzerById[id]), nodeID);
|
||||
if (position) {
|
||||
draft.analyzerById[id].camera = animatePanning(
|
||||
draft.analyzerById[id].camera,
|
||||
time,
|
||||
position,
|
||||
panAnimationDuration
|
||||
),
|
||||
};
|
||||
return withAnimation;
|
||||
} else {
|
||||
return nextState;
|
||||
}
|
||||
} else {
|
||||
return nextState;
|
||||
}
|
||||
};
|
||||
);
|
||||
}
|
||||
return draft;
|
||||
})
|
||||
)
|
||||
.withHandling(
|
||||
immerCase(userSelectedResolverNode, (draft, { id, nodeID, time }) => {
|
||||
draft.analyzerById[id].ui.selectedNode = nodeID;
|
||||
draft.analyzerById[id].ui.ariaActiveDescendant = nodeID;
|
||||
const position = nodePosition(layout(draft.analyzerById[id]), nodeID);
|
||||
if (position) {
|
||||
draft.analyzerById[id].camera = animatePanning(
|
||||
draft.analyzerById[id].camera,
|
||||
time,
|
||||
position,
|
||||
panAnimationDuration
|
||||
);
|
||||
}
|
||||
return draft;
|
||||
})
|
||||
)
|
||||
.build();
|
||||
|
||||
export const analyzerReducer = reduceReducers(
|
||||
initialAnalyzerState,
|
||||
cameraReducer,
|
||||
dataReducer,
|
||||
uiReducer
|
||||
) as unknown as Reducer<AnalyzerState, AnyAction>;
|
||||
|
|
|
@ -5,10 +5,10 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { ResolverState } from '../types';
|
||||
import type { AnalyzerState } from '../types';
|
||||
import type { Reducer, AnyAction } from 'redux';
|
||||
import { createStore } from 'redux';
|
||||
import type { ResolverAction } from './actions';
|
||||
import { resolverReducer } from './reducer';
|
||||
import { analyzerReducer } from './reducer';
|
||||
import * as selectors from './selectors';
|
||||
import {
|
||||
mockTreeWith2AncestorsAndNoChildren,
|
||||
|
@ -17,15 +17,26 @@ import {
|
|||
import type { ResolverNode } from '../../../common/endpoint/types';
|
||||
import { mockTreeFetcherParameters } from '../mocks/tree_fetcher_parameters';
|
||||
import { endpointSourceSchema } from '../mocks/tree_schema';
|
||||
import { serverReturnedResolverData } from './data/action';
|
||||
import { userSetPositionOfCamera, userSetRasterSize } from './camera/action';
|
||||
import { EMPTY_RESOLVER } from './helpers';
|
||||
|
||||
describe('resolver selectors', () => {
|
||||
const actions: ResolverAction[] = [];
|
||||
|
||||
const actions: AnyAction[] = [];
|
||||
const id = 'test-id';
|
||||
/**
|
||||
* Get state, given an ordered collection of actions.
|
||||
*/
|
||||
const state: () => ResolverState = () => {
|
||||
const store = createStore(resolverReducer);
|
||||
const testReducer: Reducer<AnalyzerState, AnyAction> = (
|
||||
analyzerState = {
|
||||
analyzerById: {
|
||||
[id]: EMPTY_RESOLVER,
|
||||
},
|
||||
},
|
||||
action
|
||||
): AnalyzerState => analyzerReducer(analyzerState, action);
|
||||
const state: () => AnalyzerState = () => {
|
||||
const store = createStore(testReducer, undefined);
|
||||
for (const action of actions) {
|
||||
store.dispatch(action);
|
||||
}
|
||||
|
@ -38,9 +49,9 @@ describe('resolver selectors', () => {
|
|||
const secondAncestorID = 'a';
|
||||
beforeEach(() => {
|
||||
const { schema, dataSource } = endpointSourceSchema();
|
||||
actions.push({
|
||||
type: 'serverReturnedResolverData',
|
||||
payload: {
|
||||
actions.push(
|
||||
serverReturnedResolverData({
|
||||
id,
|
||||
result: mockTreeWith2AncestorsAndNoChildren({
|
||||
originID,
|
||||
firstAncestorID,
|
||||
|
@ -50,26 +61,27 @@ describe('resolver selectors', () => {
|
|||
schema,
|
||||
// this value doesn't matter
|
||||
parameters: mockTreeFetcherParameters(),
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
describe('when all nodes are in view', () => {
|
||||
beforeEach(() => {
|
||||
const size = 1000000;
|
||||
actions.push({
|
||||
// set the size of the camera
|
||||
type: 'userSetRasterSize',
|
||||
payload: [size, size],
|
||||
});
|
||||
// set the size of the camera
|
||||
actions.push(userSetRasterSize({ id, dimensions: [size, size] }));
|
||||
});
|
||||
it('should return no flowto for the second ancestor', () => {
|
||||
expect(selectors.ariaFlowtoNodeID(state())(0)(secondAncestorID)).toBe(null);
|
||||
expect(selectors.ariaFlowtoNodeID(state().analyzerById[id])(0)(secondAncestorID)).toBe(
|
||||
null
|
||||
);
|
||||
});
|
||||
it('should return no flowto for the first ancestor', () => {
|
||||
expect(selectors.ariaFlowtoNodeID(state())(0)(firstAncestorID)).toBe(null);
|
||||
expect(selectors.ariaFlowtoNodeID(state().analyzerById[id])(0)(firstAncestorID)).toBe(
|
||||
null
|
||||
);
|
||||
});
|
||||
it('should return no flowto for the origin', () => {
|
||||
expect(selectors.ariaFlowtoNodeID(state())(0)(originID)).toBe(null);
|
||||
expect(selectors.ariaFlowtoNodeID(state().analyzerById[id])(0)(originID)).toBe(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -84,51 +96,57 @@ describe('resolver selectors', () => {
|
|||
secondChildID,
|
||||
});
|
||||
const { schema, dataSource } = endpointSourceSchema();
|
||||
actions.push({
|
||||
type: 'serverReturnedResolverData',
|
||||
payload: {
|
||||
actions.push(
|
||||
serverReturnedResolverData({
|
||||
id,
|
||||
result: resolverTree,
|
||||
dataSource,
|
||||
schema,
|
||||
// this value doesn't matter
|
||||
parameters: mockTreeFetcherParameters(),
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
describe('when all nodes are in view', () => {
|
||||
beforeEach(() => {
|
||||
const rasterSize = 1000000;
|
||||
actions.push({
|
||||
// set the size of the camera
|
||||
type: 'userSetRasterSize',
|
||||
payload: [rasterSize, rasterSize],
|
||||
});
|
||||
// set the size of the camera
|
||||
actions.push(
|
||||
userSetRasterSize({
|
||||
id,
|
||||
dimensions: [rasterSize, rasterSize],
|
||||
})
|
||||
);
|
||||
});
|
||||
it('should return no flowto for the origin', () => {
|
||||
expect(selectors.ariaFlowtoNodeID(state())(0)(originID)).toBe(null);
|
||||
expect(selectors.ariaFlowtoNodeID(state().analyzerById[id])(0)(originID)).toBe(null);
|
||||
});
|
||||
it('should return the second child as the flowto for the first child', () => {
|
||||
expect(selectors.ariaFlowtoNodeID(state())(0)(firstChildID)).toBe(secondChildID);
|
||||
expect(selectors.ariaFlowtoNodeID(state().analyzerById[id])(0)(firstChildID)).toBe(
|
||||
secondChildID
|
||||
);
|
||||
});
|
||||
it('should return no flowto for second child', () => {
|
||||
expect(selectors.ariaFlowtoNodeID(state())(0)(secondChildID)).toBe(null);
|
||||
expect(selectors.ariaFlowtoNodeID(state().analyzerById[id])(0)(secondChildID)).toBe(null);
|
||||
});
|
||||
});
|
||||
describe('when only the origin and first child are in view', () => {
|
||||
beforeEach(() => {
|
||||
// set the raster size
|
||||
const rasterSize = 1000000;
|
||||
actions.push({
|
||||
// set the size of the camera
|
||||
type: 'userSetRasterSize',
|
||||
payload: [rasterSize, rasterSize],
|
||||
});
|
||||
// set the size of the camera
|
||||
actions.push(
|
||||
userSetRasterSize({
|
||||
id,
|
||||
dimensions: [rasterSize, rasterSize],
|
||||
})
|
||||
);
|
||||
|
||||
// get the layout
|
||||
const layout = selectors.layout(state());
|
||||
const layout = selectors.layout(state().analyzerById[id]);
|
||||
|
||||
// find the position of the second child
|
||||
const secondChild = selectors.graphNodeForID(state())(secondChildID);
|
||||
const secondChild = selectors.graphNodeForID(state().analyzerById[id])(secondChildID);
|
||||
const positionOfSecondChild = layout.processNodePositions.get(
|
||||
secondChild as ResolverNode
|
||||
)!;
|
||||
|
@ -137,39 +155,41 @@ describe('resolver selectors', () => {
|
|||
const leftSideOfSecondChildAABB = positionOfSecondChild[0] - 720 / 2;
|
||||
|
||||
// adjust the camera so that it doesn't quite see the second child
|
||||
actions.push({
|
||||
// set the position of the camera so that the left edge of the second child is at the right edge
|
||||
// of the viewable area
|
||||
type: 'userSetPositionOfCamera',
|
||||
payload: [rasterSize / -2 + leftSideOfSecondChildAABB, 0],
|
||||
});
|
||||
actions.push(
|
||||
userSetPositionOfCamera({
|
||||
// set the position of the camera so that the left edge of the second child is at the right edge
|
||||
// of the viewable area
|
||||
id,
|
||||
cameraView: [rasterSize / -2 + leftSideOfSecondChildAABB, 0],
|
||||
})
|
||||
);
|
||||
});
|
||||
it('the origin should be in view', () => {
|
||||
const origin = selectors.graphNodeForID(state())(originID);
|
||||
const origin = selectors.graphNodeForID(state().analyzerById[id])(originID);
|
||||
expect(
|
||||
selectors
|
||||
.visibleNodesAndEdgeLines(state())(0)
|
||||
.visibleNodesAndEdgeLines(state().analyzerById[id])(0)
|
||||
.processNodePositions.has(origin as ResolverNode)
|
||||
).toBe(true);
|
||||
});
|
||||
it('the first child should be in view', () => {
|
||||
const firstChild = selectors.graphNodeForID(state())(firstChildID);
|
||||
const firstChild = selectors.graphNodeForID(state().analyzerById[id])(firstChildID);
|
||||
expect(
|
||||
selectors
|
||||
.visibleNodesAndEdgeLines(state())(0)
|
||||
.visibleNodesAndEdgeLines(state().analyzerById[id])(0)
|
||||
.processNodePositions.has(firstChild as ResolverNode)
|
||||
).toBe(true);
|
||||
});
|
||||
it('the second child should not be in view', () => {
|
||||
const secondChild = selectors.graphNodeForID(state())(secondChildID);
|
||||
const secondChild = selectors.graphNodeForID(state().analyzerById[id])(secondChildID);
|
||||
expect(
|
||||
selectors
|
||||
.visibleNodesAndEdgeLines(state())(0)
|
||||
.visibleNodesAndEdgeLines(state().analyzerById[id])(0)
|
||||
.processNodePositions.has(secondChild as ResolverNode)
|
||||
).toBe(false);
|
||||
});
|
||||
it('should return nothing as the flowto for the first child', () => {
|
||||
expect(selectors.ariaFlowtoNodeID(state())(0)(firstChildID)).toBe(null);
|
||||
expect(selectors.ariaFlowtoNodeID(state().analyzerById[id])(0)(firstChildID)).toBe(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -6,10 +6,12 @@
|
|||
*/
|
||||
|
||||
import { createSelector, defaultMemoize } from 'reselect';
|
||||
import type { State } from '../../common/store/types';
|
||||
import * as cameraSelectors from './camera/selectors';
|
||||
import * as dataSelectors from './data/selectors';
|
||||
import * as uiSelectors from './ui/selectors';
|
||||
import type {
|
||||
AnalyzerById,
|
||||
ResolverState,
|
||||
IsometricTaxiLayout,
|
||||
DataState,
|
||||
|
@ -19,6 +21,16 @@ import type {
|
|||
import type { EventStats } from '../../../common/endpoint/types';
|
||||
import * as nodeModel from '../../../common/endpoint/models/node';
|
||||
|
||||
export const selectAnalyzerById = (state: State): AnalyzerById => state.analyzer.analyzerById;
|
||||
|
||||
export const selectAnalyzer = (state: State, id: string): ResolverState =>
|
||||
state.analyzer.analyzerById[id];
|
||||
|
||||
export const analyzerByIdSelector = createSelector(
|
||||
selectAnalyzerById,
|
||||
(analyzerById: AnalyzerById) => analyzerById
|
||||
);
|
||||
|
||||
/**
|
||||
* A matrix that when applied to a Vector2 will convert it from world coordinates to screen coordinates.
|
||||
* See https://en.wikipedia.org/wiki/Orthographic_projection
|
||||
|
|
|
@ -6,28 +6,28 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { Store } from 'redux';
|
||||
import { createStore, applyMiddleware } from 'redux';
|
||||
import type { Store, AnyAction } from 'redux';
|
||||
import type { ReactWrapper } from 'enzyme';
|
||||
import { mount } from 'enzyme';
|
||||
import type { History as HistoryPackageHistoryInterface } from 'history';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { coreMock } from '@kbn/core/public/mocks';
|
||||
import { createStore } from '../../../common/store/store';
|
||||
import { spyMiddlewareFactory } from '../spy_middleware_factory';
|
||||
import { resolverMiddlewareFactory } from '../../store/middleware';
|
||||
import { resolverReducer } from '../../store/reducer';
|
||||
import { MockResolver } from './mock_resolver';
|
||||
import type {
|
||||
ResolverState,
|
||||
DataAccessLayer,
|
||||
SpyMiddleware,
|
||||
SideEffectSimulator,
|
||||
TimeFilters,
|
||||
} from '../../types';
|
||||
import type { ResolverAction } from '../../store/actions';
|
||||
import type { DataAccessLayer, SpyMiddleware, SideEffectSimulator, TimeFilters } from '../../types';
|
||||
import { sideEffectSimulatorFactory } from '../../view/side_effect_simulator_factory';
|
||||
import { uiSetting } from '../../mocks/ui_setting';
|
||||
|
||||
import { EMPTY_RESOLVER } from '../../store/helpers';
|
||||
import type { State } from '../../../common/store/types';
|
||||
import {
|
||||
createSecuritySolutionStorageMock,
|
||||
kibanaObservable,
|
||||
mockGlobalState,
|
||||
SUB_PLUGINS_REDUCER,
|
||||
} from '../../../common/mock';
|
||||
import { createResolver } from '../../store/actions';
|
||||
/**
|
||||
* Test a Resolver instance using jest, enzyme, and a mock data layer.
|
||||
*/
|
||||
|
@ -36,7 +36,7 @@ export class Simulator {
|
|||
* The redux store, creating in the constructor using the `dataAccessLayer`.
|
||||
* This code subscribes to state transitions.
|
||||
*/
|
||||
private readonly store: Store<ResolverState, ResolverAction>;
|
||||
private readonly store: Store<State, AnyAction>;
|
||||
/**
|
||||
* A fake 'History' API used with `react-router` to simulate a browser history.
|
||||
*/
|
||||
|
@ -111,18 +111,22 @@ export class Simulator {
|
|||
// create the spy middleware (for debugging tests)
|
||||
this.spyMiddleware = spyMiddlewareFactory();
|
||||
|
||||
/**
|
||||
* Create the real resolver middleware with a fake data access layer.
|
||||
* By providing different data access layers, you can simulate different data and server environments.
|
||||
*/
|
||||
const middlewareEnhancer = applyMiddleware(
|
||||
resolverMiddlewareFactory(dataAccessLayer),
|
||||
// install the spyMiddleware
|
||||
this.spyMiddleware.middleware
|
||||
);
|
||||
|
||||
// Create a redux store w/ the top level Resolver reducer and the enhancer that includes the Resolver middleware and the `spyMiddleware`
|
||||
this.store = createStore(resolverReducer, middlewareEnhancer);
|
||||
const { storage } = createSecuritySolutionStorageMock();
|
||||
this.store = createStore(
|
||||
{
|
||||
...mockGlobalState,
|
||||
analyzer: {
|
||||
analyzerById: {
|
||||
[resolverComponentInstanceID]: EMPTY_RESOLVER,
|
||||
},
|
||||
},
|
||||
},
|
||||
SUB_PLUGINS_REDUCER,
|
||||
kibanaObservable,
|
||||
storage,
|
||||
[resolverMiddlewareFactory(dataAccessLayer), this.spyMiddleware.middleware]
|
||||
);
|
||||
|
||||
// If needed, create a fake 'history' instance.
|
||||
// Resolver will use to read and write query string values.
|
||||
|
@ -169,6 +173,7 @@ export class Simulator {
|
|||
* Change the component instance ID (updates the React component props.)
|
||||
*/
|
||||
public set resolverComponentInstanceID(value: string) {
|
||||
this.store.dispatch(createResolver({ id: value }));
|
||||
this.wrapper.setProps({ resolverComponentInstanceID: value });
|
||||
}
|
||||
|
||||
|
|
|
@ -11,13 +11,16 @@ import React, { useEffect, useState, useCallback } from 'react';
|
|||
import { Router } from 'react-router-dom';
|
||||
import { I18nProvider } from '@kbn/i18n-react';
|
||||
import { Provider } from 'react-redux';
|
||||
import type { Store } from 'redux';
|
||||
import type { Store, AnyAction } from 'redux';
|
||||
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
|
||||
import type { CoreStart } from '@kbn/core/public';
|
||||
import type { ResolverState, SideEffectSimulator, ResolverProps } from '../../types';
|
||||
import type { ResolverAction } from '../../store/actions';
|
||||
import { enableMapSet } from 'immer';
|
||||
import type { SideEffectSimulator, ResolverProps } from '../../types';
|
||||
import { ResolverWithoutProviders } from '../../view/resolver_without_providers';
|
||||
import { SideEffectContext } from '../../view/side_effect_context';
|
||||
import type { State } from '../../../common/store/types';
|
||||
|
||||
enableMapSet();
|
||||
|
||||
type MockResolverProps = {
|
||||
/**
|
||||
|
@ -37,7 +40,7 @@ type MockResolverProps = {
|
|||
*/
|
||||
history: React.ComponentProps<typeof Router>['history'];
|
||||
/** Pass a resolver store. See `storeFactory` and `mockDataAccessLayer` */
|
||||
store: Store<ResolverState, ResolverAction>;
|
||||
store: Store<State, AnyAction>;
|
||||
/**
|
||||
* Pass the side effect simulator which handles animations and resizing. See `sideEffectSimulatorFactory`
|
||||
*/
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { ResolverAction } from '../store/actions';
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { SpyMiddleware, SpyMiddlewareStateActionPair } from '../types';
|
||||
|
||||
/**
|
||||
|
@ -25,7 +25,7 @@ export const spyMiddlewareFactory: () => SpyMiddleware = () => {
|
|||
};
|
||||
|
||||
return {
|
||||
middleware: (api) => (next) => (action: ResolverAction) => {
|
||||
middleware: (api) => (next) => (action: AnyAction) => {
|
||||
// handle the action first so we get the state after the reducer
|
||||
next(action);
|
||||
|
||||
|
|
|
@ -7,10 +7,9 @@
|
|||
|
||||
import type { ResizeObserver } from '@juggle/resize-observer';
|
||||
import type React from 'react';
|
||||
import type { Store, Middleware, Dispatch } from 'redux';
|
||||
import type { Store, Middleware, Dispatch, AnyAction } from 'redux';
|
||||
import type { BBox } from 'rbush';
|
||||
import type { Provider } from 'react-redux';
|
||||
import type { ResolverAction } from './store/actions';
|
||||
import type {
|
||||
ResolverNode,
|
||||
ResolverRelatedEvents,
|
||||
|
@ -21,7 +20,18 @@ import type {
|
|||
ResolverSchema,
|
||||
} from '../../common/endpoint/types';
|
||||
import type { Tree } from '../../common/endpoint/generate_data';
|
||||
import type { State } from '../common/store/types';
|
||||
|
||||
export interface AnalyzerOuterState {
|
||||
analyzer: AnalyzerState;
|
||||
}
|
||||
export interface AnalyzerState {
|
||||
analyzerById: AnalyzerById;
|
||||
}
|
||||
|
||||
export interface AnalyzerById {
|
||||
[id: string]: ResolverState;
|
||||
}
|
||||
/**
|
||||
* Redux state for the Resolver feature. Properties on this interface are populated via multiple reducers using redux's `combineReducers`.
|
||||
*/
|
||||
|
@ -380,7 +390,7 @@ export interface DataState {
|
|||
/**
|
||||
* Represents an ordered pair. Used for x-y coordinates and the like.
|
||||
*/
|
||||
export type Vector2 = readonly [number, number];
|
||||
export type Vector2 = [number, number];
|
||||
|
||||
/**
|
||||
* A rectangle with sides that align with the `x` and `y` axises.
|
||||
|
@ -646,7 +656,7 @@ export type ResolverProcessType =
|
|||
| 'processError'
|
||||
| 'unknownEvent';
|
||||
|
||||
export type ResolverStore = Store<ResolverState, ResolverAction>;
|
||||
export type ResolverStore = Store<State, AnyAction>;
|
||||
|
||||
/**
|
||||
* Describes the basic Resolver graph layout.
|
||||
|
@ -827,11 +837,11 @@ export interface ResolverProps {
|
|||
export interface SpyMiddlewareStateActionPair {
|
||||
/** An action dispatched, `state` is the state that the reducer returned when handling this action.
|
||||
*/
|
||||
action: ResolverAction;
|
||||
action: AnyAction;
|
||||
/**
|
||||
* A resolver state that was returned by the reducer when handling `action`.
|
||||
*/
|
||||
state: ResolverState;
|
||||
state: State;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -841,7 +851,7 @@ export interface SpyMiddleware {
|
|||
/**
|
||||
* A middleware to use with `applyMiddleware`.
|
||||
*/
|
||||
middleware: Middleware<{}, ResolverState, Dispatch<ResolverAction>>;
|
||||
middleware: Middleware<{}, State, Dispatch<AnyAction>>;
|
||||
/**
|
||||
* A generator that returns all state and action pairs that pass through the middleware.
|
||||
*/
|
||||
|
@ -866,7 +876,7 @@ export interface ResolverPluginSetup {
|
|||
* Takes a `DataAccessLayer`, which could be a mock one, and returns an redux Store.
|
||||
* All data acess (e.g. HTTP requests) are done through the store.
|
||||
*/
|
||||
storeFactory: (dataAccessLayer: DataAccessLayer) => Store<ResolverState, ResolverAction>;
|
||||
storeFactory: (dataAccessLayer: DataAccessLayer) => Store<AnalyzerState, AnyAction>;
|
||||
|
||||
/**
|
||||
* The Resolver component without the required Providers.
|
||||
|
|
|
@ -27,11 +27,18 @@ import { useSelector, useDispatch } from 'react-redux';
|
|||
import { SideEffectContext } from './side_effect_context';
|
||||
import type { Vector2 } from '../types';
|
||||
import * as selectors from '../store/selectors';
|
||||
import type { ResolverAction } from '../store/actions';
|
||||
import { useColors } from './use_colors';
|
||||
import { StyledDescriptionList } from './panels/styles';
|
||||
import { CubeForProcess } from './panels/cube_for_process';
|
||||
import { GeneratedText } from './generated_text';
|
||||
import {
|
||||
userClickedZoomIn,
|
||||
userClickedZoomOut,
|
||||
userSetZoomLevel,
|
||||
userNudgedCamera,
|
||||
userSetPositionOfCamera,
|
||||
} from '../store/camera/action';
|
||||
import type { State } from '../../common/store/types';
|
||||
|
||||
// EuiRange is currently only horizontally positioned. This reorients the track to a vertical position
|
||||
const StyledEuiRange = styled(EuiRange)<EuiRangeProps>`
|
||||
|
@ -118,15 +125,22 @@ const StyledGraphControls = styled.div<Partial<StyledGraphControlProps>>`
|
|||
|
||||
export const GraphControls = React.memo(
|
||||
({
|
||||
id,
|
||||
className,
|
||||
}: {
|
||||
/**
|
||||
* Id that identify the scope of analyzer
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* A className string provided by `styled`
|
||||
*/
|
||||
className?: string;
|
||||
}) => {
|
||||
const dispatch: (action: ResolverAction) => unknown = useDispatch();
|
||||
const scalingFactor = useSelector(selectors.scalingFactor);
|
||||
const dispatch = useDispatch();
|
||||
const scalingFactor = useSelector((state: State) =>
|
||||
selectors.scalingFactor(state.analyzer.analyzerById[id])
|
||||
);
|
||||
const { timestamp } = useContext(SideEffectContext);
|
||||
const [activePopover, setPopover] = useState<null | 'schemaInfo' | 'nodeLegend'>(null);
|
||||
const colorMap = useColors();
|
||||
|
@ -150,33 +164,28 @@ export const GraphControls = React.memo(
|
|||
(event as React.ChangeEvent<HTMLInputElement>).target.value
|
||||
);
|
||||
if (isNaN(valueAsNumber) === false) {
|
||||
dispatch({
|
||||
type: 'userSetZoomLevel',
|
||||
payload: valueAsNumber,
|
||||
});
|
||||
dispatch(
|
||||
userSetZoomLevel({
|
||||
id,
|
||||
zoomLevel: valueAsNumber,
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
[dispatch]
|
||||
[dispatch, id]
|
||||
);
|
||||
|
||||
const handleCenterClick = useCallback(() => {
|
||||
dispatch({
|
||||
type: 'userSetPositionOfCamera',
|
||||
payload: [0, 0],
|
||||
});
|
||||
}, [dispatch]);
|
||||
dispatch(userSetPositionOfCamera({ id, cameraView: [0, 0] }));
|
||||
}, [dispatch, id]);
|
||||
|
||||
const handleZoomOutClick = useCallback(() => {
|
||||
dispatch({
|
||||
type: 'userClickedZoomOut',
|
||||
});
|
||||
}, [dispatch]);
|
||||
dispatch(userClickedZoomOut({ id }));
|
||||
}, [dispatch, id]);
|
||||
|
||||
const handleZoomInClick = useCallback(() => {
|
||||
dispatch({
|
||||
type: 'userClickedZoomIn',
|
||||
});
|
||||
}, [dispatch]);
|
||||
dispatch(userClickedZoomIn({ id }));
|
||||
}, [dispatch, id]);
|
||||
|
||||
const [handleNorth, handleEast, handleSouth, handleWest] = useMemo(() => {
|
||||
const directionVectors: readonly Vector2[] = [
|
||||
|
@ -187,14 +196,10 @@ export const GraphControls = React.memo(
|
|||
];
|
||||
return directionVectors.map((direction) => {
|
||||
return () => {
|
||||
const action: ResolverAction = {
|
||||
type: 'userNudgedCamera',
|
||||
payload: { direction, time: timestamp() },
|
||||
};
|
||||
dispatch(action);
|
||||
dispatch(userNudgedCamera({ id, direction, time: timestamp() }));
|
||||
};
|
||||
});
|
||||
}, [dispatch, timestamp]);
|
||||
}, [dispatch, timestamp, id]);
|
||||
|
||||
return (
|
||||
<StyledGraphControls
|
||||
|
@ -204,11 +209,13 @@ export const GraphControls = React.memo(
|
|||
>
|
||||
<StyledGraphControlsColumn>
|
||||
<SchemaInformation
|
||||
id={id}
|
||||
closePopover={closePopover}
|
||||
isOpen={activePopover === 'schemaInfo'}
|
||||
setActivePopover={setActivePopover}
|
||||
/>
|
||||
<NodeLegend
|
||||
id={id}
|
||||
closePopover={closePopover}
|
||||
isOpen={activePopover === 'nodeLegend'}
|
||||
setActivePopover={setActivePopover}
|
||||
|
@ -309,16 +316,20 @@ export const GraphControls = React.memo(
|
|||
);
|
||||
|
||||
const SchemaInformation = ({
|
||||
id,
|
||||
closePopover,
|
||||
setActivePopover,
|
||||
isOpen,
|
||||
}: {
|
||||
id: string;
|
||||
closePopover: () => void;
|
||||
setActivePopover: (value: 'schemaInfo' | null) => void;
|
||||
isOpen: boolean;
|
||||
}) => {
|
||||
const colorMap = useColors();
|
||||
const sourceAndSchema = useSelector(selectors.resolverTreeSourceAndSchema);
|
||||
const sourceAndSchema = useSelector((state: State) =>
|
||||
selectors.resolverTreeSourceAndSchema(state.analyzer.analyzerById[id])
|
||||
);
|
||||
const setAsActivePopover = useCallback(() => setActivePopover('schemaInfo'), [setActivePopover]);
|
||||
|
||||
const schemaInfoButtonTitle = i18n.translate(
|
||||
|
@ -431,10 +442,12 @@ const SchemaInformation = ({
|
|||
// This component defines the cube legend that allows users to identify the meaning of the cubes
|
||||
// Should be updated to be dynamic if and when non process based resolvers are possible
|
||||
const NodeLegend = ({
|
||||
id,
|
||||
closePopover,
|
||||
setActivePopover,
|
||||
isOpen,
|
||||
}: {
|
||||
id: string;
|
||||
closePopover: () => void;
|
||||
setActivePopover: (value: 'nodeLegend') => void;
|
||||
isOpen: boolean;
|
||||
|
@ -489,6 +502,7 @@ const NodeLegend = ({
|
|||
style={{ width: '20% ' }}
|
||||
>
|
||||
<CubeForProcess
|
||||
id={id}
|
||||
size="2.5em"
|
||||
data-test-subj="resolver:node-detail:title-icon"
|
||||
state="running"
|
||||
|
@ -512,6 +526,7 @@ const NodeLegend = ({
|
|||
style={{ width: '20% ' }}
|
||||
>
|
||||
<CubeForProcess
|
||||
id={id}
|
||||
size="2.5em"
|
||||
data-test-subj="resolver:node-detail:title-icon"
|
||||
state="terminated"
|
||||
|
@ -535,6 +550,7 @@ const NodeLegend = ({
|
|||
style={{ width: '20% ' }}
|
||||
>
|
||||
<CubeForProcess
|
||||
id={id}
|
||||
size="2.5em"
|
||||
data-test-subj="resolver:node-detail:title-icon"
|
||||
state="loading"
|
||||
|
@ -558,6 +574,7 @@ const NodeLegend = ({
|
|||
style={{ width: '20% ' }}
|
||||
>
|
||||
<CubeForProcess
|
||||
id={id}
|
||||
size="2.5em"
|
||||
data-test-subj="resolver:node-detail:title-icon"
|
||||
state="error"
|
||||
|
|
|
@ -7,38 +7,29 @@
|
|||
|
||||
/* eslint-disable react/display-name */
|
||||
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { useKibana } from '@kbn/kibana-react-plugin/public';
|
||||
import { resolverStoreFactory } from '../store';
|
||||
import type { StartServices } from '../../types';
|
||||
import type { DataAccessLayer, ResolverProps } from '../types';
|
||||
import { dataAccessLayerFactory } from '../data_access_layer/factory';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import type { ResolverProps } from '../types';
|
||||
import { ResolverWithoutProviders } from './resolver_without_providers';
|
||||
|
||||
import { createResolver } from '../store/actions';
|
||||
import type { State } from '../../common/store/types';
|
||||
/**
|
||||
* The `Resolver` component to use. This sets up the DataAccessLayer provider. Use `ResolverWithoutProviders` in tests or in other scenarios where you want to provide a different (or fake) data access layer.
|
||||
*/
|
||||
export const Resolver = React.memo((props: ResolverProps) => {
|
||||
const context = useKibana<StartServices>();
|
||||
const dataAccessLayer: DataAccessLayer = useMemo(
|
||||
() => dataAccessLayerFactory(context),
|
||||
[context]
|
||||
const store = useSelector(
|
||||
(state: State) => state.analyzer.analyzerById[props.resolverComponentInstanceID]
|
||||
);
|
||||
|
||||
const store = useMemo(() => resolverStoreFactory(dataAccessLayer), [dataAccessLayer]);
|
||||
|
||||
const [activeStore, updateActiveStore] = useState(store);
|
||||
const dispatch = useDispatch();
|
||||
if (!store) {
|
||||
dispatch(createResolver({ id: props.resolverComponentInstanceID }));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (props.shouldUpdate) {
|
||||
updateActiveStore(resolverStoreFactory(dataAccessLayer));
|
||||
dispatch(createResolver({ id: props.resolverComponentInstanceID }));
|
||||
}
|
||||
}, [dataAccessLayer, props.shouldUpdate]);
|
||||
|
||||
return (
|
||||
<Provider store={activeStore}>
|
||||
<ResolverWithoutProviders {...props} />
|
||||
</Provider>
|
||||
);
|
||||
}, [dispatch, props.shouldUpdate, props.resolverComponentInstanceID]);
|
||||
return <ResolverWithoutProviders {...props} />;
|
||||
});
|
||||
|
|
|
@ -9,8 +9,6 @@ import styled from 'styled-components';
|
|||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
/* eslint-disable react/display-name */
|
||||
|
||||
import React, { memo } from 'react';
|
||||
|
||||
interface StyledSVGCube {
|
||||
|
@ -24,12 +22,14 @@ import type { NodeDataStatus } from '../../types';
|
|||
* Icon representing a process node.
|
||||
*/
|
||||
export const CubeForProcess = memo(function ({
|
||||
id,
|
||||
className,
|
||||
size = '2.15em',
|
||||
state,
|
||||
isOrigin,
|
||||
'data-test-subj': dataTestSubj,
|
||||
}: {
|
||||
id: string;
|
||||
'data-test-subj'?: string;
|
||||
/**
|
||||
* The state of the process's node data (for endpoint the process's lifecycle events)
|
||||
|
@ -40,8 +40,8 @@ export const CubeForProcess = memo(function ({
|
|||
isOrigin?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const { cubeSymbol, strokeColor } = useCubeAssets(state, false);
|
||||
const { processCubeActiveBacking } = useSymbolIDs();
|
||||
const { cubeSymbol, strokeColor } = useCubeAssets(id, state, false);
|
||||
const { processCubeActiveBacking } = useSymbolIDs({ id });
|
||||
|
||||
return (
|
||||
<StyledSVG
|
||||
|
|
|
@ -32,7 +32,6 @@ import * as eventModel from '../../../../common/endpoint/models/event';
|
|||
import * as selectors from '../../store/selectors';
|
||||
import { PanelLoading } from './panel_loading';
|
||||
import { PanelContentError } from './panel_content_error';
|
||||
import type { ResolverState } from '../../types';
|
||||
import { DescriptiveName } from './descriptive_name';
|
||||
import { useLinkProps } from '../use_link_props';
|
||||
import type { SafeResolverEvent } from '../../../../common/endpoint/types';
|
||||
|
@ -40,6 +39,7 @@ import { deepObjectEntries } from './deep_object_entries';
|
|||
import { useFormattedDate } from './use_formatted_date';
|
||||
import * as nodeDataModel from '../../models/node_data';
|
||||
import { expandDottedObject } from '../../../../common/utils/expand_dotted';
|
||||
import type { State } from '../../../common/store/types';
|
||||
|
||||
const eventDetailRequestError = i18n.translate(
|
||||
'xpack.securitySolution.resolver.panel.eventDetail.requestError',
|
||||
|
@ -49,31 +49,42 @@ const eventDetailRequestError = i18n.translate(
|
|||
);
|
||||
|
||||
export const EventDetail = memo(function EventDetail({
|
||||
id,
|
||||
nodeID,
|
||||
eventCategory: eventType,
|
||||
}: {
|
||||
id: string;
|
||||
nodeID: string;
|
||||
/** The event type to show in the breadcrumbs */
|
||||
eventCategory: string;
|
||||
}) {
|
||||
const isEventLoading = useSelector(selectors.isCurrentRelatedEventLoading);
|
||||
const isTreeLoading = useSelector(selectors.isTreeLoading);
|
||||
const processEvent = useSelector((state: ResolverState) =>
|
||||
nodeDataModel.firstEvent(selectors.nodeDataForID(state)(nodeID))
|
||||
const isEventLoading = useSelector((state: State) =>
|
||||
selectors.isCurrentRelatedEventLoading(state.analyzer.analyzerById[id])
|
||||
);
|
||||
const isTreeLoading = useSelector((state: State) =>
|
||||
selectors.isTreeLoading(state.analyzer.analyzerById[id])
|
||||
);
|
||||
const processEvent = useSelector((state: State) =>
|
||||
nodeDataModel.firstEvent(selectors.nodeDataForID(state.analyzer.analyzerById[id])(nodeID))
|
||||
);
|
||||
const nodeStatus = useSelector((state: State) =>
|
||||
selectors.nodeDataStatus(state.analyzer.analyzerById[id])(nodeID)
|
||||
);
|
||||
const nodeStatus = useSelector((state: ResolverState) => selectors.nodeDataStatus(state)(nodeID));
|
||||
|
||||
const isNodeDataLoading = nodeStatus === 'loading';
|
||||
const isLoading = isEventLoading || isTreeLoading || isNodeDataLoading;
|
||||
|
||||
const event = useSelector(selectors.currentRelatedEventData);
|
||||
const event = useSelector((state: State) =>
|
||||
selectors.currentRelatedEventData(state.analyzer.analyzerById[id])
|
||||
);
|
||||
|
||||
return isLoading ? (
|
||||
<StyledPanel hasBorder>
|
||||
<PanelLoading />
|
||||
<PanelLoading id={id} />
|
||||
</StyledPanel>
|
||||
) : event ? (
|
||||
<EventDetailContents
|
||||
id={id}
|
||||
nodeID={nodeID}
|
||||
event={event}
|
||||
processEvent={processEvent}
|
||||
|
@ -81,7 +92,7 @@ export const EventDetail = memo(function EventDetail({
|
|||
/>
|
||||
) : (
|
||||
<StyledPanel hasBorder>
|
||||
<PanelContentError translatedErrorMessage={eventDetailRequestError} />
|
||||
<PanelContentError id={id} translatedErrorMessage={eventDetailRequestError} />
|
||||
</StyledPanel>
|
||||
);
|
||||
});
|
||||
|
@ -91,11 +102,13 @@ export const EventDetail = memo(function EventDetail({
|
|||
* it appears in the underlying ResolverEvent
|
||||
*/
|
||||
const EventDetailContents = memo(function ({
|
||||
id,
|
||||
nodeID,
|
||||
event,
|
||||
eventType,
|
||||
processEvent,
|
||||
}: {
|
||||
id: string;
|
||||
nodeID: string;
|
||||
event: SafeResolverEvent;
|
||||
/**
|
||||
|
@ -116,6 +129,7 @@ const EventDetailContents = memo(function ({
|
|||
return (
|
||||
<StyledPanel hasBorder data-test-subj="resolver:panel:event-detail">
|
||||
<EventDetailBreadcrumbs
|
||||
id={id}
|
||||
nodeID={nodeID}
|
||||
nodeName={nodeName}
|
||||
event={event}
|
||||
|
@ -222,37 +236,42 @@ function EventDetailFields({ event }: { event: SafeResolverEvent }) {
|
|||
}
|
||||
|
||||
function EventDetailBreadcrumbs({
|
||||
id,
|
||||
nodeID,
|
||||
nodeName,
|
||||
event,
|
||||
breadcrumbEventCategory,
|
||||
}: {
|
||||
id: string;
|
||||
nodeID: string;
|
||||
nodeName: string | null | undefined;
|
||||
event: SafeResolverEvent;
|
||||
breadcrumbEventCategory: string;
|
||||
}) {
|
||||
const countByCategory = useSelector((state: ResolverState) =>
|
||||
selectors.relatedEventCountOfTypeForNode(state)(nodeID, breadcrumbEventCategory)
|
||||
const countByCategory = useSelector((state: State) =>
|
||||
selectors.relatedEventCountOfTypeForNode(state.analyzer.analyzerById[id])(
|
||||
nodeID,
|
||||
breadcrumbEventCategory
|
||||
)
|
||||
);
|
||||
const relatedEventCount: number | undefined = useSelector((state: ResolverState) =>
|
||||
selectors.relatedEventTotalCount(state)(nodeID)
|
||||
const relatedEventCount: number | undefined = useSelector((state: State) =>
|
||||
selectors.relatedEventTotalCount(state.analyzer.analyzerById[id])(nodeID)
|
||||
);
|
||||
const nodesLinkNavProps = useLinkProps({
|
||||
const nodesLinkNavProps = useLinkProps(id, {
|
||||
panelView: 'nodes',
|
||||
});
|
||||
|
||||
const nodeDetailLinkNavProps = useLinkProps({
|
||||
const nodeDetailLinkNavProps = useLinkProps(id, {
|
||||
panelView: 'nodeDetail',
|
||||
panelParameters: { nodeID },
|
||||
});
|
||||
|
||||
const nodeEventsLinkNavProps = useLinkProps({
|
||||
const nodeEventsLinkNavProps = useLinkProps(id, {
|
||||
panelView: 'nodeEvents',
|
||||
panelParameters: { nodeID },
|
||||
});
|
||||
|
||||
const nodeEventsInCategoryLinkNavProps = useLinkProps({
|
||||
const nodeEventsInCategoryLinkNavProps = useLinkProps(id, {
|
||||
panelView: 'nodeEventsInCategory',
|
||||
panelParameters: { nodeID, eventCategory: breadcrumbEventCategory },
|
||||
});
|
||||
|
|
|
@ -16,20 +16,23 @@ import { NodeDetail } from './node_detail';
|
|||
import { NodeList } from './node_list';
|
||||
import { EventDetail } from './event_detail';
|
||||
import type { PanelViewAndParameters } from '../../types';
|
||||
|
||||
import type { State } from '../../../common/store/types';
|
||||
/**
|
||||
* Show the panel that matches the `panelViewAndParameters` (derived from the browser's location.search)
|
||||
*/
|
||||
|
||||
export const PanelRouter = memo(function () {
|
||||
const params: PanelViewAndParameters = useSelector(selectors.panelViewAndParameters);
|
||||
export const PanelRouter = memo(function ({ id }: { id: string }) {
|
||||
const params: PanelViewAndParameters = useSelector((state: State) =>
|
||||
selectors.panelViewAndParameters(state.analyzer.analyzerById[id])
|
||||
);
|
||||
if (params.panelView === 'nodeDetail') {
|
||||
return <NodeDetail nodeID={params.panelParameters.nodeID} />;
|
||||
return <NodeDetail id={id} nodeID={params.panelParameters.nodeID} />;
|
||||
} else if (params.panelView === 'nodeEvents') {
|
||||
return <NodeEvents nodeID={params.panelParameters.nodeID} />;
|
||||
return <NodeEvents id={id} nodeID={params.panelParameters.nodeID} />;
|
||||
} else if (params.panelView === 'nodeEventsInCategory') {
|
||||
return (
|
||||
<NodeEventsInCategory
|
||||
id={id}
|
||||
nodeID={params.panelParameters.nodeID}
|
||||
eventCategory={params.panelParameters.eventCategory}
|
||||
/>
|
||||
|
@ -37,12 +40,13 @@ export const PanelRouter = memo(function () {
|
|||
} else if (params.panelView === 'eventDetail') {
|
||||
return (
|
||||
<EventDetail
|
||||
id={id}
|
||||
nodeID={params.panelParameters.nodeID}
|
||||
eventCategory={params.panelParameters.eventCategory}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
/* The default 'Event List' / 'List of all processes' view */
|
||||
return <NodeList />;
|
||||
return <NodeList id={id} />;
|
||||
}
|
||||
});
|
||||
|
|
|
@ -26,12 +26,12 @@ import * as nodeDataModel from '../../models/node_data';
|
|||
import { CubeForProcess } from './cube_for_process';
|
||||
import type { SafeResolverEvent } from '../../../../common/endpoint/types';
|
||||
import { useCubeAssets } from '../use_cube_assets';
|
||||
import type { ResolverState } from '../../types';
|
||||
import { PanelLoading } from './panel_loading';
|
||||
import { StyledPanel } from '../styles';
|
||||
import { useLinkProps } from '../use_link_props';
|
||||
import { useFormattedDate } from './use_formatted_date';
|
||||
import { PanelContentError } from './panel_content_error';
|
||||
import type { State } from '../../../common/store/types';
|
||||
|
||||
const StyledCubeForProcess = styled(CubeForProcess)`
|
||||
position: relative;
|
||||
|
@ -41,23 +41,25 @@ const nodeDetailError = i18n.translate('xpack.securitySolution.resolver.panel.no
|
|||
defaultMessage: 'Node details were unable to be retrieved',
|
||||
});
|
||||
|
||||
export const NodeDetail = memo(function ({ nodeID }: { nodeID: string }) {
|
||||
const processEvent = useSelector((state: ResolverState) =>
|
||||
nodeDataModel.firstEvent(selectors.nodeDataForID(state)(nodeID))
|
||||
export const NodeDetail = memo(function ({ id, nodeID }: { id: string; nodeID: string }) {
|
||||
const processEvent = useSelector((state: State) =>
|
||||
nodeDataModel.firstEvent(selectors.nodeDataForID(state.analyzer.analyzerById[id])(nodeID))
|
||||
);
|
||||
const nodeStatus = useSelector((state: State) =>
|
||||
selectors.nodeDataStatus(state.analyzer.analyzerById[id])(nodeID)
|
||||
);
|
||||
const nodeStatus = useSelector((state: ResolverState) => selectors.nodeDataStatus(state)(nodeID));
|
||||
|
||||
return nodeStatus === 'loading' ? (
|
||||
<StyledPanel hasBorder>
|
||||
<PanelLoading />
|
||||
<PanelLoading id={id} />
|
||||
</StyledPanel>
|
||||
) : processEvent ? (
|
||||
<StyledPanel hasBorder data-test-subj="resolver:panel:node-detail">
|
||||
<NodeDetailView nodeID={nodeID} processEvent={processEvent} />
|
||||
<NodeDetailView id={id} nodeID={nodeID} processEvent={processEvent} />
|
||||
</StyledPanel>
|
||||
) : (
|
||||
<StyledPanel hasBorder>
|
||||
<PanelContentError translatedErrorMessage={nodeDetailError} />
|
||||
<PanelContentError id={id} translatedErrorMessage={nodeDetailError} />
|
||||
</StyledPanel>
|
||||
);
|
||||
});
|
||||
|
@ -67,16 +69,20 @@ export const NodeDetail = memo(function ({ nodeID }: { nodeID: string }) {
|
|||
* Created, PID, User/Domain, etc.
|
||||
*/
|
||||
const NodeDetailView = memo(function ({
|
||||
id,
|
||||
processEvent,
|
||||
nodeID,
|
||||
}: {
|
||||
id: string;
|
||||
processEvent: SafeResolverEvent;
|
||||
nodeID: string;
|
||||
}) {
|
||||
const processName = eventModel.processNameSafeVersion(processEvent);
|
||||
const nodeState = useSelector((state: ResolverState) => selectors.nodeDataStatus(state)(nodeID));
|
||||
const relatedEventTotal = useSelector((state: ResolverState) => {
|
||||
return selectors.relatedEventTotalCount(state)(nodeID);
|
||||
const nodeState = useSelector((state: State) =>
|
||||
selectors.nodeDataStatus(state.analyzer.analyzerById[id])(nodeID)
|
||||
);
|
||||
const relatedEventTotal = useSelector((state: State) => {
|
||||
return selectors.relatedEventTotalCount(state.analyzer.analyzerById[id])(nodeID);
|
||||
});
|
||||
const eventTime = eventModel.eventTimestamp(processEvent);
|
||||
const dateTime = useFormattedDate(eventTime);
|
||||
|
@ -175,7 +181,7 @@ const NodeDetailView = memo(function ({
|
|||
return processDescriptionListData;
|
||||
}, [dateTime, processEvent]);
|
||||
|
||||
const nodesLinkNavProps = useLinkProps({
|
||||
const nodesLinkNavProps = useLinkProps(id, {
|
||||
panelView: 'nodes',
|
||||
});
|
||||
|
||||
|
@ -202,9 +208,9 @@ const NodeDetailView = memo(function ({
|
|||
},
|
||||
];
|
||||
}, [processName, nodesLinkNavProps]);
|
||||
const { descriptionText } = useCubeAssets(nodeState, false);
|
||||
const { descriptionText } = useCubeAssets(id, nodeState, false);
|
||||
|
||||
const nodeDetailNavProps = useLinkProps({
|
||||
const nodeDetailNavProps = useLinkProps(id, {
|
||||
panelView: 'nodeEvents',
|
||||
panelParameters: { nodeID },
|
||||
});
|
||||
|
@ -217,6 +223,7 @@ const NodeDetailView = memo(function ({
|
|||
<EuiTitle size="xs">
|
||||
<StyledTitle aria-describedby={titleID}>
|
||||
<StyledCubeForProcess
|
||||
id={id}
|
||||
data-test-subj="resolver:node-detail:title-icon"
|
||||
state={nodeState}
|
||||
/>
|
||||
|
|
|
@ -17,34 +17,37 @@ import { Breadcrumbs } from './breadcrumbs';
|
|||
import * as event from '../../../../common/endpoint/models/event';
|
||||
import type { EventStats } from '../../../../common/endpoint/types';
|
||||
import * as selectors from '../../store/selectors';
|
||||
import type { ResolverState } from '../../types';
|
||||
import { StyledPanel } from '../styles';
|
||||
import { PanelLoading } from './panel_loading';
|
||||
import { useLinkProps } from '../use_link_props';
|
||||
import * as nodeDataModel from '../../models/node_data';
|
||||
import type { State } from '../../../common/store/types';
|
||||
|
||||
export function NodeEvents({ nodeID }: { nodeID: string }) {
|
||||
const processEvent = useSelector((state: ResolverState) =>
|
||||
nodeDataModel.firstEvent(selectors.nodeDataForID(state)(nodeID))
|
||||
export function NodeEvents({ id, nodeID }: { id: string; nodeID: string }) {
|
||||
const processEvent = useSelector((state: State) =>
|
||||
nodeDataModel.firstEvent(selectors.nodeDataForID(state.analyzer.analyzerById[id])(nodeID))
|
||||
);
|
||||
const nodeStats = useSelector((state: State) =>
|
||||
selectors.nodeStats(state.analyzer.analyzerById[id])(nodeID)
|
||||
);
|
||||
const nodeStats = useSelector((state: ResolverState) => selectors.nodeStats(state)(nodeID));
|
||||
|
||||
if (processEvent === undefined || nodeStats === undefined) {
|
||||
return (
|
||||
<StyledPanel hasBorder>
|
||||
<PanelLoading />
|
||||
<PanelLoading id={id} />
|
||||
</StyledPanel>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<StyledPanel hasBorder>
|
||||
<NodeEventsBreadcrumbs
|
||||
id={id}
|
||||
nodeName={event.processNameSafeVersion(processEvent)}
|
||||
nodeID={nodeID}
|
||||
totalEventCount={nodeStats.total}
|
||||
/>
|
||||
<EuiSpacer size="l" />
|
||||
<EventCategoryLinks nodeID={nodeID} relatedStats={nodeStats} />
|
||||
<EventCategoryLinks id={id} nodeID={nodeID} relatedStats={nodeStats} />
|
||||
</StyledPanel>
|
||||
);
|
||||
}
|
||||
|
@ -62,9 +65,11 @@ export function NodeEvents({ nodeID }: { nodeID: string }) {
|
|||
*
|
||||
*/
|
||||
const EventCategoryLinks = memo(function ({
|
||||
id,
|
||||
nodeID,
|
||||
relatedStats,
|
||||
}: {
|
||||
id: string;
|
||||
nodeID: string;
|
||||
relatedStats: EventStats;
|
||||
}) {
|
||||
|
@ -104,23 +109,25 @@ const EventCategoryLinks = memo(function ({
|
|||
sortable: true,
|
||||
render(eventType: string) {
|
||||
return (
|
||||
<NodeEventsLink nodeID={nodeID} eventType={eventType}>
|
||||
<NodeEventsLink id={id} nodeID={nodeID} eventType={eventType}>
|
||||
{eventType}
|
||||
</NodeEventsLink>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[nodeID]
|
||||
[nodeID, id]
|
||||
);
|
||||
return <EuiInMemoryTable<EventCountsTableView> items={rows} columns={columns} sorting />;
|
||||
});
|
||||
|
||||
const NodeEventsBreadcrumbs = memo(function ({
|
||||
id,
|
||||
nodeID,
|
||||
nodeName,
|
||||
totalEventCount,
|
||||
}: {
|
||||
id: string;
|
||||
nodeID: string;
|
||||
nodeName: React.ReactNode;
|
||||
totalEventCount: number;
|
||||
|
@ -135,13 +142,13 @@ const NodeEventsBreadcrumbs = memo(function ({
|
|||
defaultMessage: 'Events',
|
||||
}
|
||||
),
|
||||
...useLinkProps({
|
||||
...useLinkProps(id, {
|
||||
panelView: 'nodes',
|
||||
}),
|
||||
},
|
||||
{
|
||||
text: nodeName,
|
||||
...useLinkProps({
|
||||
...useLinkProps(id, {
|
||||
panelView: 'nodeDetail',
|
||||
panelParameters: { nodeID },
|
||||
}),
|
||||
|
@ -154,7 +161,7 @@ const NodeEventsBreadcrumbs = memo(function ({
|
|||
defaultMessage="{totalCount} Events"
|
||||
/>
|
||||
),
|
||||
...useLinkProps({
|
||||
...useLinkProps(id, {
|
||||
panelView: 'nodeEvents',
|
||||
panelParameters: { nodeID },
|
||||
}),
|
||||
|
@ -166,15 +173,17 @@ const NodeEventsBreadcrumbs = memo(function ({
|
|||
|
||||
const NodeEventsLink = memo(
|
||||
({
|
||||
id,
|
||||
nodeID,
|
||||
eventType,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
nodeID: string;
|
||||
eventType: string;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const props = useLinkProps({
|
||||
const props = useLinkProps(id, {
|
||||
panelView: 'nodeEventsInCategory',
|
||||
panelParameters: {
|
||||
nodeID,
|
||||
|
|
|
@ -18,7 +18,7 @@ import {
|
|||
EuiButton,
|
||||
EuiCallOut,
|
||||
} from '@elastic/eui';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { StyledPanel } from '../styles';
|
||||
import { BoldCode, StyledTime } from './styles';
|
||||
|
@ -26,33 +26,41 @@ import { Breadcrumbs } from './breadcrumbs';
|
|||
import * as eventModel from '../../../../common/endpoint/models/event';
|
||||
import type { SafeResolverEvent } from '../../../../common/endpoint/types';
|
||||
import * as selectors from '../../store/selectors';
|
||||
import type { ResolverState } from '../../types';
|
||||
import { PanelLoading } from './panel_loading';
|
||||
import { DescriptiveName } from './descriptive_name';
|
||||
import { useLinkProps } from '../use_link_props';
|
||||
import { useResolverDispatch } from '../use_resolver_dispatch';
|
||||
import { useFormattedDate } from './use_formatted_date';
|
||||
import { expandDottedObject } from '../../../../common/utils/expand_dotted';
|
||||
import type { State } from '../../../common/store/types';
|
||||
import { userRequestedAdditionalRelatedEvents } from '../../store/data/action';
|
||||
|
||||
/**
|
||||
* Render a list of events that are related to `nodeID` and that have a category of `eventType`.
|
||||
*/
|
||||
export const NodeEventsInCategory = memo(function ({
|
||||
id,
|
||||
nodeID,
|
||||
eventCategory,
|
||||
}: {
|
||||
id: string;
|
||||
nodeID: string;
|
||||
eventCategory: string;
|
||||
}) {
|
||||
const node = useSelector((state: ResolverState) => selectors.graphNodeForID(state)(nodeID));
|
||||
const isLoading = useSelector(selectors.isLoadingNodeEventsInCategory);
|
||||
const hasError = useSelector(selectors.hadErrorLoadingNodeEventsInCategory);
|
||||
const node = useSelector((state: State) =>
|
||||
selectors.graphNodeForID(state.analyzer.analyzerById[id])(nodeID)
|
||||
);
|
||||
const isLoading = useSelector((state: State) =>
|
||||
selectors.isLoadingNodeEventsInCategory(state.analyzer.analyzerById[id])
|
||||
);
|
||||
const hasError = useSelector((state: State) =>
|
||||
selectors.hadErrorLoadingNodeEventsInCategory(state.analyzer.analyzerById[id])
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading ? (
|
||||
<StyledPanel hasBorder>
|
||||
<PanelLoading />
|
||||
<PanelLoading id={id} />
|
||||
</StyledPanel>
|
||||
) : (
|
||||
<StyledPanel hasBorder data-test-subj="resolver:panel:events-in-category">
|
||||
|
@ -78,12 +86,13 @@ export const NodeEventsInCategory = memo(function ({
|
|||
) : (
|
||||
<>
|
||||
<NodeEventsInCategoryBreadcrumbs
|
||||
id={id}
|
||||
nodeName={node.name}
|
||||
eventCategory={eventCategory}
|
||||
nodeID={nodeID}
|
||||
/>
|
||||
<EuiSpacer size="l" />
|
||||
<NodeEventList eventCategory={eventCategory} nodeID={nodeID} />
|
||||
<NodeEventList id={id} eventCategory={eventCategory} nodeID={nodeID} />
|
||||
</>
|
||||
)}
|
||||
</StyledPanel>
|
||||
|
@ -96,10 +105,12 @@ export const NodeEventsInCategory = memo(function ({
|
|||
* Rendered for each event in the list.
|
||||
*/
|
||||
const NodeEventsListItem = memo(function ({
|
||||
id,
|
||||
event,
|
||||
nodeID,
|
||||
eventCategory,
|
||||
}: {
|
||||
id: string;
|
||||
event: SafeResolverEvent;
|
||||
nodeID: string;
|
||||
eventCategory: string;
|
||||
|
@ -113,7 +124,7 @@ const NodeEventsListItem = memo(function ({
|
|||
i18n.translate('xpack.securitySolution.enpdoint.resolver.panelutils.noTimestampRetrieved', {
|
||||
defaultMessage: 'No timestamp retrieved',
|
||||
});
|
||||
const linkProps = useLinkProps({
|
||||
const linkProps = useLinkProps(id, {
|
||||
panelView: 'eventDetail',
|
||||
panelParameters: {
|
||||
nodeID,
|
||||
|
@ -159,26 +170,32 @@ const NodeEventsListItem = memo(function ({
|
|||
* Renders a list of events with a separator in between.
|
||||
*/
|
||||
const NodeEventList = memo(function NodeEventList({
|
||||
id,
|
||||
eventCategory,
|
||||
nodeID,
|
||||
}: {
|
||||
id: string;
|
||||
eventCategory: string;
|
||||
nodeID: string;
|
||||
}) {
|
||||
const events = useSelector(selectors.nodeEventsInCategory);
|
||||
const dispatch = useResolverDispatch();
|
||||
const events = useSelector((state: State) =>
|
||||
selectors.nodeEventsInCategory(state.analyzer.analyzerById[id])
|
||||
);
|
||||
const dispatch = useDispatch();
|
||||
const handleLoadMore = useCallback(() => {
|
||||
dispatch({
|
||||
type: 'userRequestedAdditionalRelatedEvents',
|
||||
});
|
||||
}, [dispatch]);
|
||||
const isLoading = useSelector(selectors.isLoadingMoreNodeEventsInCategory);
|
||||
const hasMore = useSelector(selectors.lastRelatedEventResponseContainsCursor);
|
||||
dispatch(userRequestedAdditionalRelatedEvents({ id }));
|
||||
}, [dispatch, id]);
|
||||
const isLoading = useSelector((state: State) =>
|
||||
selectors.isLoadingMoreNodeEventsInCategory(state.analyzer.analyzerById[id])
|
||||
);
|
||||
const hasMore = useSelector((state: State) =>
|
||||
selectors.lastRelatedEventResponseContainsCursor(state.analyzer.analyzerById[id])
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{events.map((event, index) => (
|
||||
<Fragment key={index}>
|
||||
<NodeEventsListItem nodeID={nodeID} eventCategory={eventCategory} event={event} />
|
||||
<NodeEventsListItem id={id} nodeID={nodeID} eventCategory={eventCategory} event={event} />
|
||||
{index === events.length - 1 ? null : <EuiHorizontalRule margin="m" />}
|
||||
</Fragment>
|
||||
))}
|
||||
|
@ -207,32 +224,34 @@ const NodeEventList = memo(function NodeEventList({
|
|||
* Renders `Breadcrumbs`.
|
||||
*/
|
||||
const NodeEventsInCategoryBreadcrumbs = memo(function ({
|
||||
id,
|
||||
nodeName,
|
||||
eventCategory,
|
||||
nodeID,
|
||||
}: {
|
||||
id: string;
|
||||
nodeName: React.ReactNode;
|
||||
eventCategory: string;
|
||||
nodeID: string;
|
||||
}) {
|
||||
const eventCount = useSelector((state: ResolverState) =>
|
||||
selectors.totalRelatedEventCountForNode(state)(nodeID)
|
||||
const eventCount = useSelector((state: State) =>
|
||||
selectors.totalRelatedEventCountForNode(state.analyzer.analyzerById[id])(nodeID)
|
||||
);
|
||||
|
||||
const eventsInCategoryCount = useSelector((state: ResolverState) =>
|
||||
selectors.relatedEventCountOfTypeForNode(state)(nodeID, eventCategory)
|
||||
const eventsInCategoryCount = useSelector((state: State) =>
|
||||
selectors.relatedEventCountOfTypeForNode(state.analyzer.analyzerById[id])(nodeID, eventCategory)
|
||||
);
|
||||
|
||||
const nodesLinkNavProps = useLinkProps({
|
||||
const nodesLinkNavProps = useLinkProps(id, {
|
||||
panelView: 'nodes',
|
||||
});
|
||||
|
||||
const nodeDetailNavProps = useLinkProps({
|
||||
const nodeDetailNavProps = useLinkProps(id, {
|
||||
panelView: 'nodeDetail',
|
||||
panelParameters: { nodeID },
|
||||
});
|
||||
|
||||
const nodeEventsNavProps = useLinkProps({
|
||||
const nodeEventsNavProps = useLinkProps(id, {
|
||||
panelView: 'nodeEvents',
|
||||
panelParameters: { nodeID },
|
||||
});
|
||||
|
|
|
@ -28,12 +28,12 @@ import * as selectors from '../../store/selectors';
|
|||
import { Breadcrumbs } from './breadcrumbs';
|
||||
import { CubeForProcess } from './cube_for_process';
|
||||
import { LimitWarning } from '../limit_warnings';
|
||||
import type { ResolverState } from '../../types';
|
||||
import { useLinkProps } from '../use_link_props';
|
||||
import { useColors } from '../use_colors';
|
||||
import type { ResolverAction } from '../../store/actions';
|
||||
import { useFormattedDate } from './use_formatted_date';
|
||||
import { CopyablePanelField } from './copyable_panel_field';
|
||||
import { userSelectedResolverNode } from '../../store/actions';
|
||||
import type { State } from '../../../common/store/types';
|
||||
|
||||
interface ProcessTableView {
|
||||
name?: string;
|
||||
|
@ -44,7 +44,7 @@ interface ProcessTableView {
|
|||
/**
|
||||
* The "default" view for the panel: A list of all the processes currently in the graph.
|
||||
*/
|
||||
export const NodeList = memo(() => {
|
||||
export const NodeList = memo(({ id }: { id: string }) => {
|
||||
const columns = useMemo<Array<EuiBasicTableColumn<ProcessTableView>>>(
|
||||
() => [
|
||||
{
|
||||
|
@ -58,7 +58,7 @@ export const NodeList = memo(() => {
|
|||
sortable: true,
|
||||
truncateText: true,
|
||||
render(name: string | undefined, item: ProcessTableView) {
|
||||
return <NodeDetailLink name={name} nodeID={item.nodeID} />;
|
||||
return <NodeDetailLink id={id} name={name} nodeID={item.nodeID} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -76,26 +76,29 @@ export const NodeList = memo(() => {
|
|||
},
|
||||
},
|
||||
],
|
||||
[]
|
||||
[id]
|
||||
);
|
||||
|
||||
const processTableView: ProcessTableView[] = useSelector(
|
||||
useCallback((state: ResolverState) => {
|
||||
const { processNodePositions } = selectors.layout(state);
|
||||
const view: ProcessTableView[] = [];
|
||||
for (const treeNode of processNodePositions.keys()) {
|
||||
const name = nodeModel.nodeName(treeNode);
|
||||
const nodeID = nodeModel.nodeID(treeNode);
|
||||
if (nodeID !== undefined) {
|
||||
view.push({
|
||||
name,
|
||||
timestamp: nodeModel.timestampAsDate(treeNode),
|
||||
nodeID,
|
||||
});
|
||||
useCallback(
|
||||
(state: State) => {
|
||||
const { processNodePositions } = selectors.layout(state.analyzer.analyzerById[id]);
|
||||
const view: ProcessTableView[] = [];
|
||||
for (const treeNode of processNodePositions.keys()) {
|
||||
const name = nodeModel.nodeName(treeNode);
|
||||
const nodeID = nodeModel.nodeID(treeNode);
|
||||
if (nodeID !== undefined) {
|
||||
view.push({
|
||||
name,
|
||||
timestamp: nodeModel.timestampAsDate(treeNode),
|
||||
nodeID,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return view;
|
||||
}, [])
|
||||
return view;
|
||||
},
|
||||
[id]
|
||||
)
|
||||
);
|
||||
|
||||
const numberOfProcesses = processTableView.length;
|
||||
|
@ -110,9 +113,15 @@ export const NodeList = memo(() => {
|
|||
];
|
||||
}, []);
|
||||
|
||||
const children = useSelector(selectors.hasMoreChildren);
|
||||
const ancestors = useSelector(selectors.hasMoreAncestors);
|
||||
const generations = useSelector(selectors.hasMoreGenerations);
|
||||
const children = useSelector((state: State) =>
|
||||
selectors.hasMoreChildren(state.analyzer.analyzerById[id])
|
||||
);
|
||||
const ancestors = useSelector((state: State) =>
|
||||
selectors.hasMoreAncestors(state.analyzer.analyzerById[id])
|
||||
);
|
||||
const generations = useSelector((state: State) =>
|
||||
selectors.hasMoreGenerations(state.analyzer.analyzerById[id])
|
||||
);
|
||||
const showWarning = children === true || ancestors === true || generations === true;
|
||||
const rowProps = useMemo(() => ({ 'data-test-subj': 'resolver:node-list:item' }), []);
|
||||
return (
|
||||
|
@ -131,27 +140,29 @@ export const NodeList = memo(() => {
|
|||
);
|
||||
});
|
||||
|
||||
function NodeDetailLink({ name, nodeID }: { name?: string; nodeID: string }) {
|
||||
const isOrigin = useSelector((state: ResolverState) => {
|
||||
return selectors.originID(state) === nodeID;
|
||||
function NodeDetailLink({ id, name, nodeID }: { id: string; name?: string; nodeID: string }) {
|
||||
const isOrigin = useSelector((state: State) => {
|
||||
return selectors.originID(state.analyzer.analyzerById[id]) === nodeID;
|
||||
});
|
||||
const nodeState = useSelector((state: ResolverState) => selectors.nodeDataStatus(state)(nodeID));
|
||||
const nodeState = useSelector((state: State) =>
|
||||
selectors.nodeDataStatus(state.analyzer.analyzerById[id])(nodeID)
|
||||
);
|
||||
const { descriptionText } = useColors();
|
||||
const linkProps = useLinkProps({ panelView: 'nodeDetail', panelParameters: { nodeID } });
|
||||
const dispatch: (action: ResolverAction) => void = useDispatch();
|
||||
const linkProps = useLinkProps(id, { panelView: 'nodeDetail', panelParameters: { nodeID } });
|
||||
const dispatch = useDispatch();
|
||||
const { timestamp } = useContext(SideEffectContext);
|
||||
const handleOnClick = useCallback(
|
||||
(mouseEvent: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
linkProps.onClick(mouseEvent);
|
||||
dispatch({
|
||||
type: 'userSelectedResolverNode',
|
||||
payload: {
|
||||
dispatch(
|
||||
userSelectedResolverNode({
|
||||
id,
|
||||
nodeID,
|
||||
time: timestamp(),
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
},
|
||||
[timestamp, linkProps, dispatch, nodeID]
|
||||
[timestamp, linkProps, dispatch, nodeID, id]
|
||||
);
|
||||
return (
|
||||
<EuiButtonEmpty
|
||||
|
@ -172,6 +183,7 @@ function NodeDetailLink({ name, nodeID }: { name?: string; nodeID: string }) {
|
|||
) : (
|
||||
<StyledButtonTextContainer>
|
||||
<CubeForProcess
|
||||
id={id}
|
||||
state={nodeState}
|
||||
isOrigin={isOrigin}
|
||||
data-test-subj="resolver:node-list:node-link:icon"
|
||||
|
|
|
@ -18,13 +18,13 @@ import { useLinkProps } from '../use_link_props';
|
|||
* @param {string} translatedErrorMessage The message to display in the panel when something goes wrong
|
||||
*/
|
||||
export const PanelContentError = memo(function ({
|
||||
id,
|
||||
translatedErrorMessage,
|
||||
}: {
|
||||
id: string;
|
||||
translatedErrorMessage: string;
|
||||
}) {
|
||||
const nodesLinkNavProps = useLinkProps({
|
||||
panelView: 'nodes',
|
||||
});
|
||||
const nodesLinkNavProps = useLinkProps(id, { panelView: 'nodes' });
|
||||
|
||||
const crumbs = useMemo(() => {
|
||||
return [
|
||||
|
|
|
@ -16,7 +16,7 @@ const StyledSpinnerFlexItem = styled.span`
|
|||
margin-right: 5px;
|
||||
`;
|
||||
|
||||
export function PanelLoading() {
|
||||
export function PanelLoading({ id }: { id: string }) {
|
||||
const waitingString = i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.panel.relatedDetail.wait',
|
||||
{
|
||||
|
@ -29,7 +29,7 @@ export function PanelLoading() {
|
|||
defaultMessage: 'Events',
|
||||
}
|
||||
);
|
||||
const nodesLinkNavProps = useLinkProps({
|
||||
const nodesLinkNavProps = useLinkProps(id, {
|
||||
panelView: 'nodes',
|
||||
});
|
||||
const waitCrumbs = useMemo(() => {
|
||||
|
|
|
@ -8,14 +8,13 @@
|
|||
import React, { useCallback, useMemo, useContext } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { htmlIdGenerator, EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { NodeSubMenu } from './styles';
|
||||
import { applyMatrix3 } from '../models/vector2';
|
||||
import type { Vector2, Matrix3, ResolverState } from '../types';
|
||||
import type { Vector2, Matrix3 } from '../types';
|
||||
import type { ResolverNode } from '../../../common/endpoint/types';
|
||||
import { useResolverDispatch } from './use_resolver_dispatch';
|
||||
import { SideEffectContext } from './side_effect_context';
|
||||
import * as nodeModel from '../../../common/endpoint/models/node';
|
||||
import * as eventModel from '../../../common/endpoint/models/event';
|
||||
|
@ -26,6 +25,9 @@ import { useCubeAssets } from './use_cube_assets';
|
|||
import { useSymbolIDs } from './use_symbol_ids';
|
||||
import { useColors } from './use_colors';
|
||||
import { useLinkProps } from './use_link_props';
|
||||
import { userSelectedResolverNode, userFocusedOnResolverNode } from '../store/actions';
|
||||
import { userReloadedResolverNode } from '../store/data/action';
|
||||
import type { State } from '../../common/store/types';
|
||||
|
||||
interface StyledActionsContainer {
|
||||
readonly color: string;
|
||||
|
@ -121,6 +123,7 @@ const StyledOuterGroup = styled.g<{ isNodeLoading: boolean }>`
|
|||
*/
|
||||
const UnstyledProcessEventDot = React.memo(
|
||||
({
|
||||
id,
|
||||
className,
|
||||
position,
|
||||
node,
|
||||
|
@ -128,6 +131,10 @@ const UnstyledProcessEventDot = React.memo(
|
|||
projectionMatrix,
|
||||
timeAtRender,
|
||||
}: {
|
||||
/**
|
||||
* Id that identify the scope of analyzer
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* A `className` string provided by `styled`
|
||||
*/
|
||||
|
@ -154,11 +161,11 @@ const UnstyledProcessEventDot = React.memo(
|
|||
*/
|
||||
timeAtRender: number;
|
||||
}) => {
|
||||
const resolverComponentInstanceID = useSelector(selectors.resolverComponentInstanceID);
|
||||
const resolverComponentInstanceID = id;
|
||||
// This should be unique to each instance of Resolver
|
||||
const htmlIDPrefix = `resolver:${resolverComponentInstanceID}`;
|
||||
|
||||
const symbolIDs = useSymbolIDs();
|
||||
const symbolIDs = useSymbolIDs({ id });
|
||||
const { timestamp } = useContext(SideEffectContext);
|
||||
|
||||
/**
|
||||
|
@ -169,25 +176,33 @@ const UnstyledProcessEventDot = React.memo(
|
|||
const [xScale] = projectionMatrix;
|
||||
|
||||
// Node (html id=) IDs
|
||||
const ariaActiveDescendant = useSelector(selectors.ariaActiveDescendant);
|
||||
const selectedNode = useSelector(selectors.selectedNode);
|
||||
const originID = useSelector(selectors.originID);
|
||||
const nodeStats = useSelector((state: ResolverState) => selectors.nodeStats(state)(nodeID));
|
||||
const ariaActiveDescendant = useSelector((state: State) =>
|
||||
selectors.ariaActiveDescendant(state.analyzer.analyzerById[id])
|
||||
);
|
||||
const selectedNode = useSelector((state: State) =>
|
||||
selectors.selectedNode(state.analyzer.analyzerById[id])
|
||||
);
|
||||
const originID = useSelector((state: State) =>
|
||||
selectors.originID(state.analyzer.analyzerById[id])
|
||||
);
|
||||
const nodeStats = useSelector((state: State) =>
|
||||
selectors.nodeStats(state.analyzer.analyzerById[id])(nodeID)
|
||||
);
|
||||
|
||||
// define a standard way of giving HTML IDs to nodes based on their entity_id/nodeID.
|
||||
// this is used to link nodes via aria attributes
|
||||
const nodeHTMLID = useCallback(
|
||||
(id: string) => htmlIdGenerator(htmlIDPrefix)(`${id}:node`),
|
||||
(nodeId: string) => htmlIdGenerator(htmlIDPrefix)(`${nodeId}:node`),
|
||||
[htmlIDPrefix]
|
||||
);
|
||||
|
||||
const ariaLevel: number | null = useSelector((state: ResolverState) =>
|
||||
selectors.ariaLevel(state)(nodeID)
|
||||
const ariaLevel: number | null = useSelector((state: State) =>
|
||||
selectors.ariaLevel(state.analyzer.analyzerById[id])(nodeID)
|
||||
);
|
||||
|
||||
// the node ID to 'flowto'
|
||||
const ariaFlowtoNodeID: string | null = useSelector((state: ResolverState) =>
|
||||
selectors.ariaFlowtoNodeID(state)(timeAtRender)(nodeID)
|
||||
const ariaFlowtoNodeID: string | null = useSelector((state: State) =>
|
||||
selectors.ariaFlowtoNodeID(state.analyzer.analyzerById[id])(timeAtRender)(nodeID)
|
||||
);
|
||||
|
||||
const isShowingEventActions = xScale > 0.8;
|
||||
|
@ -260,8 +275,8 @@ const UnstyledProcessEventDot = React.memo(
|
|||
} = React.createRef();
|
||||
const colorMap = useColors();
|
||||
|
||||
const nodeState = useSelector((state: ResolverState) =>
|
||||
selectors.nodeDataStatus(state)(nodeID)
|
||||
const nodeState = useSelector((state: State) =>
|
||||
selectors.nodeDataStatus(state.analyzer.analyzerById[id])(nodeID)
|
||||
);
|
||||
const isNodeLoading = nodeState === 'loading';
|
||||
const {
|
||||
|
@ -272,6 +287,7 @@ const UnstyledProcessEventDot = React.memo(
|
|||
labelButtonFill,
|
||||
strokeColor,
|
||||
} = useCubeAssets(
|
||||
id,
|
||||
nodeState,
|
||||
/**
|
||||
* There is no definition for 'trigger process' yet. return false.
|
||||
|
@ -284,22 +300,22 @@ const UnstyledProcessEventDot = React.memo(
|
|||
const isAriaSelected = nodeID === selectedNode;
|
||||
const isOrigin = nodeID === originID;
|
||||
|
||||
const dispatch = useResolverDispatch();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const processDetailNavProps = useLinkProps({
|
||||
const processDetailNavProps = useLinkProps(id, {
|
||||
panelView: 'nodeDetail',
|
||||
panelParameters: { nodeID },
|
||||
});
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
dispatch({
|
||||
type: 'userFocusedOnResolverNode',
|
||||
payload: {
|
||||
dispatch(
|
||||
userFocusedOnResolverNode({
|
||||
id,
|
||||
nodeID,
|
||||
time: timestamp(),
|
||||
},
|
||||
});
|
||||
}, [dispatch, nodeID, timestamp]);
|
||||
})
|
||||
);
|
||||
}, [dispatch, nodeID, timestamp, id]);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(clickEvent) => {
|
||||
|
@ -308,30 +324,29 @@ const UnstyledProcessEventDot = React.memo(
|
|||
}
|
||||
|
||||
if (nodeState === 'error') {
|
||||
dispatch({
|
||||
type: 'userReloadedResolverNode',
|
||||
payload: nodeID,
|
||||
});
|
||||
dispatch(userReloadedResolverNode({ id, nodeID }));
|
||||
} else {
|
||||
dispatch({
|
||||
type: 'userSelectedResolverNode',
|
||||
payload: {
|
||||
dispatch(
|
||||
userSelectedResolverNode({
|
||||
id,
|
||||
nodeID,
|
||||
time: timestamp(),
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
processDetailNavProps.onClick(clickEvent);
|
||||
}
|
||||
},
|
||||
[animationTarget, dispatch, nodeID, processDetailNavProps, nodeState, timestamp]
|
||||
[animationTarget, dispatch, nodeID, processDetailNavProps, nodeState, timestamp, id]
|
||||
);
|
||||
|
||||
const grandTotal: number | null = useSelector((state: ResolverState) =>
|
||||
selectors.statsTotalForNode(state)(node)
|
||||
const grandTotal: number | null = useSelector((state: State) =>
|
||||
selectors.statsTotalForNode(state.analyzer.analyzerById[id])(node)
|
||||
);
|
||||
const nodeName = nodeModel.nodeName(node);
|
||||
const processEvent = useSelector((state: ResolverState) =>
|
||||
nodeDataModel.firstEvent(selectors.nodeDataForID(state)(String(node.id)))
|
||||
const processEvent = useSelector((state: State) =>
|
||||
nodeDataModel.firstEvent(
|
||||
selectors.nodeDataForID(state.analyzer.analyzerById[id])(String(node.id))
|
||||
)
|
||||
);
|
||||
const processName = useMemo(() => {
|
||||
if (processEvent !== undefined) {
|
||||
|
@ -509,6 +524,7 @@ const UnstyledProcessEventDot = React.memo(
|
|||
<EuiFlexItem grow={false} className="related-dropdown">
|
||||
{grandTotal !== null && grandTotal > 0 && (
|
||||
<NodeSubMenu
|
||||
id={id}
|
||||
buttonFill={colorMap.resolverBackground}
|
||||
nodeStats={nodeStats}
|
||||
nodeID={nodeID}
|
||||
|
|
|
@ -22,13 +22,13 @@ import { useStateSyncingActions } from './use_state_syncing_actions';
|
|||
import { StyledMapContainer, GraphContainer } from './styles';
|
||||
import * as nodeModel from '../../../common/endpoint/models/node';
|
||||
import { SideEffectContext } from './side_effect_context';
|
||||
import type { ResolverProps, ResolverState } from '../types';
|
||||
import type { ResolverProps } from '../types';
|
||||
import { PanelRouter } from './panels';
|
||||
import { useColors } from './use_colors';
|
||||
import { useSyncSelectedNode } from './use_sync_selected_node';
|
||||
import { ResolverNoProcessEvents } from './resolver_no_process_events';
|
||||
import { useAutotuneTimerange } from './use_autotune_timerange';
|
||||
|
||||
import type { State } from '../../common/store/types';
|
||||
/**
|
||||
* The highest level connected Resolver component. Needs a `Provider` in its ancestry to work.
|
||||
*/
|
||||
|
@ -47,7 +47,7 @@ export const ResolverWithoutProviders = React.memo(
|
|||
}: ResolverProps,
|
||||
refToForward
|
||||
) {
|
||||
useResolverQueryParamCleaner();
|
||||
useResolverQueryParamCleaner(resolverComponentInstanceID);
|
||||
/**
|
||||
* This is responsible for dispatching actions that include any external data.
|
||||
* `databaseDocumentID`
|
||||
|
@ -59,22 +59,28 @@ export const ResolverWithoutProviders = React.memo(
|
|||
shouldUpdate,
|
||||
filters,
|
||||
});
|
||||
useAutotuneTimerange();
|
||||
useAutotuneTimerange({ id: resolverComponentInstanceID });
|
||||
/**
|
||||
* This will keep the selectedNode in the view in sync with the nodeID specified in the url
|
||||
*/
|
||||
useSyncSelectedNode();
|
||||
useSyncSelectedNode({ id: resolverComponentInstanceID });
|
||||
|
||||
const { timestamp } = useContext(SideEffectContext);
|
||||
|
||||
// use this for the entire render in order to keep things in sync
|
||||
const timeAtRender = timestamp();
|
||||
|
||||
const { processNodePositions, connectingEdgeLineSegments } = useSelector(
|
||||
(state: ResolverState) => selectors.visibleNodesAndEdgeLines(state)(timeAtRender)
|
||||
const { processNodePositions, connectingEdgeLineSegments } = useSelector((state: State) =>
|
||||
selectors.visibleNodesAndEdgeLines(state.analyzer.analyzerById[resolverComponentInstanceID])(
|
||||
timeAtRender
|
||||
)
|
||||
);
|
||||
|
||||
const { projectionMatrix, ref: cameraRef, onMouseDown } = useCamera();
|
||||
const {
|
||||
projectionMatrix,
|
||||
ref: cameraRef,
|
||||
onMouseDown,
|
||||
} = useCamera({ id: resolverComponentInstanceID });
|
||||
|
||||
const ref = useCallback(
|
||||
(element: HTMLDivElement | null) => {
|
||||
|
@ -90,10 +96,18 @@ export const ResolverWithoutProviders = React.memo(
|
|||
},
|
||||
[cameraRef, refToForward]
|
||||
);
|
||||
const isLoading = useSelector(selectors.isTreeLoading);
|
||||
const hasError = useSelector(selectors.hadErrorLoadingTree);
|
||||
const activeDescendantId = useSelector(selectors.ariaActiveDescendant);
|
||||
const resolverTreeHasNodes = useSelector(selectors.resolverTreeHasNodes);
|
||||
const isLoading = useSelector((state: State) =>
|
||||
selectors.isTreeLoading(state.analyzer.analyzerById[resolverComponentInstanceID])
|
||||
);
|
||||
const hasError = useSelector((state: State) =>
|
||||
selectors.hadErrorLoadingTree(state.analyzer.analyzerById[resolverComponentInstanceID])
|
||||
);
|
||||
const activeDescendantId = useSelector((state: State) =>
|
||||
selectors.ariaActiveDescendant(state.analyzer.analyzerById[resolverComponentInstanceID])
|
||||
);
|
||||
const resolverTreeHasNodes = useSelector((state: State) =>
|
||||
selectors.resolverTreeHasNodes(state.analyzer.analyzerById[resolverComponentInstanceID])
|
||||
);
|
||||
const colorMap = useColors();
|
||||
|
||||
return (
|
||||
|
@ -141,6 +155,7 @@ export const ResolverWithoutProviders = React.memo(
|
|||
}
|
||||
return (
|
||||
<ProcessEventDot
|
||||
id={resolverComponentInstanceID}
|
||||
key={nodeID}
|
||||
nodeID={nodeID}
|
||||
position={position}
|
||||
|
@ -151,13 +166,13 @@ export const ResolverWithoutProviders = React.memo(
|
|||
);
|
||||
})}
|
||||
</GraphContainer>
|
||||
<PanelRouter />
|
||||
<PanelRouter id={resolverComponentInstanceID} />
|
||||
</>
|
||||
) : (
|
||||
<ResolverNoProcessEvents />
|
||||
)}
|
||||
<GraphControls />
|
||||
<SymbolDefinitions />
|
||||
<GraphControls id={resolverComponentInstanceID} />
|
||||
<SymbolDefinitions id={resolverComponentInstanceID} />
|
||||
</StyledMapContainer>
|
||||
);
|
||||
})
|
||||
|
|
|
@ -10,9 +10,9 @@ import { useDispatch } from 'react-redux';
|
|||
import type { EventStats } from '../../../common/endpoint/types';
|
||||
import { useColors } from './use_colors';
|
||||
import { useLinkProps } from './use_link_props';
|
||||
import type { ResolverAction } from '../store/actions';
|
||||
import { SideEffectContext } from './side_effect_context';
|
||||
import { FormattedCount } from '../../common/components/formatted_number';
|
||||
import { userSelectedResolverNode } from '../store/actions';
|
||||
|
||||
/* eslint-disable react/display-name */
|
||||
|
||||
|
@ -22,10 +22,12 @@ import { FormattedCount } from '../../common/components/formatted_number';
|
|||
*/
|
||||
export const NodeSubMenuComponents = React.memo(
|
||||
({
|
||||
id,
|
||||
className,
|
||||
nodeID,
|
||||
nodeStats,
|
||||
}: {
|
||||
id: string;
|
||||
className?: string;
|
||||
// eslint-disable-next-line react/no-unused-prop-types
|
||||
buttonFill: string;
|
||||
|
@ -60,7 +62,7 @@ export const NodeSubMenuComponents = React.memo(
|
|||
return opta.category.localeCompare(optb.category);
|
||||
})
|
||||
.map((pill) => {
|
||||
return <NodeSubmenuPill pill={pill} nodeID={nodeID} key={pill.category} />;
|
||||
return <NodeSubmenuPill id={id} pill={pill} nodeID={nodeID} key={pill.category} />;
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
|
@ -68,13 +70,15 @@ export const NodeSubMenuComponents = React.memo(
|
|||
);
|
||||
|
||||
const NodeSubmenuPill = ({
|
||||
id,
|
||||
pill,
|
||||
nodeID,
|
||||
}: {
|
||||
id: string;
|
||||
pill: { prefix: JSX.Element; category: string };
|
||||
nodeID: string;
|
||||
}) => {
|
||||
const linkProps = useLinkProps({
|
||||
const linkProps = useLinkProps(id, {
|
||||
panelView: 'nodeEventsInCategory',
|
||||
panelParameters: { nodeID, eventCategory: pill.category },
|
||||
});
|
||||
|
@ -86,21 +90,21 @@ const NodeSubmenuPill = ({
|
|||
};
|
||||
}, [pillBorderStroke, pillFill]);
|
||||
|
||||
const dispatch: (action: ResolverAction) => void = useDispatch();
|
||||
const dispatch = useDispatch();
|
||||
const { timestamp } = useContext(SideEffectContext);
|
||||
|
||||
const handleOnClick = useCallback(
|
||||
(mouseEvent: React.MouseEvent<HTMLButtonElement>) => {
|
||||
linkProps.onClick(mouseEvent);
|
||||
dispatch({
|
||||
type: 'userSelectedResolverNode',
|
||||
payload: {
|
||||
dispatch(
|
||||
userSelectedResolverNode({
|
||||
id,
|
||||
nodeID,
|
||||
time: timestamp(),
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
},
|
||||
[timestamp, linkProps, dispatch, nodeID]
|
||||
[timestamp, linkProps, dispatch, nodeID, id]
|
||||
);
|
||||
return (
|
||||
<li
|
||||
|
|
|
@ -66,8 +66,8 @@ const hoveredProcessBackgroundTitle = i18n.translate(
|
|||
* PaintServers: Where color palettes, gradients, patterns and other similar concerns
|
||||
* are exposed to the component
|
||||
*/
|
||||
const PaintServers = memo(({ isDarkMode }: { isDarkMode: boolean }) => {
|
||||
const paintServerIDs = usePaintServerIDs();
|
||||
const PaintServers = memo(({ id, isDarkMode }: { id: string; isDarkMode: boolean }) => {
|
||||
const paintServerIDs = usePaintServerIDs({ id });
|
||||
return (
|
||||
<>
|
||||
<linearGradient
|
||||
|
@ -165,9 +165,9 @@ const PaintServers = memo(({ isDarkMode }: { isDarkMode: boolean }) => {
|
|||
/**
|
||||
* Defs entries that define shapes, masks and other spatial elements
|
||||
*/
|
||||
const SymbolsAndShapes = memo(({ isDarkMode }: { isDarkMode: boolean }) => {
|
||||
const symbolIDs = useSymbolIDs();
|
||||
const paintServerIDs = usePaintServerIDs();
|
||||
const SymbolsAndShapes = memo(({ id, isDarkMode }: { id: string; isDarkMode: boolean }) => {
|
||||
const symbolIDs = useSymbolIDs({ id });
|
||||
const paintServerIDs = usePaintServerIDs({ id });
|
||||
return (
|
||||
<>
|
||||
<symbol
|
||||
|
@ -433,13 +433,13 @@ const SymbolsAndShapes = memo(({ isDarkMode }: { isDarkMode: boolean }) => {
|
|||
* 2. Separation of concerns between creative assets and more functional areas of the app
|
||||
* 3. `<use>` elements can be handled by compositor (faster)
|
||||
*/
|
||||
export const SymbolDefinitions = memo(() => {
|
||||
export const SymbolDefinitions = memo(({ id }: { id: string }) => {
|
||||
const isDarkMode = useUiSetting<boolean>('theme:darkMode');
|
||||
return (
|
||||
<HiddenSVG>
|
||||
<defs>
|
||||
<PaintServers isDarkMode={isDarkMode} />
|
||||
<SymbolsAndShapes isDarkMode={isDarkMode} />
|
||||
<PaintServers id={id} isDarkMode={isDarkMode} />
|
||||
<SymbolsAndShapes id={id} isDarkMode={isDarkMode} />
|
||||
</defs>
|
||||
</HiddenSVG>
|
||||
);
|
||||
|
|
|
@ -10,12 +10,12 @@ import { useSelector } from 'react-redux';
|
|||
import * as selectors from '../store/selectors';
|
||||
import { useAppToasts } from '../../common/hooks/use_app_toasts';
|
||||
import { useFormattedDate } from './panels/use_formatted_date';
|
||||
import type { ResolverState } from '../types';
|
||||
import type { State } from '../../common/store/types';
|
||||
|
||||
export function useAutotuneTimerange() {
|
||||
export function useAutotuneTimerange({ id }: { id: string }) {
|
||||
const { addSuccess } = useAppToasts();
|
||||
const { from: detectedFrom, to: detectedTo } = useSelector((state: ResolverState) => {
|
||||
const detectedBounds = selectors.detectedBounds(state);
|
||||
const { from: detectedFrom, to: detectedTo } = useSelector((state: State) => {
|
||||
const detectedBounds = selectors.detectedBounds(state.analyzer.analyzerById[id]);
|
||||
return {
|
||||
from: detectedBounds?.from ? detectedBounds.from : undefined,
|
||||
to: detectedBounds?.to ? detectedBounds.to : undefined,
|
||||
|
|
|
@ -7,7 +7,6 @@
|
|||
|
||||
// Extend jest with a custom matcher
|
||||
import '../test_utilities/extend_jest';
|
||||
|
||||
import type { ReactWrapper } from 'enzyme';
|
||||
import { mount } from 'enzyme';
|
||||
import React from 'react';
|
||||
|
@ -20,15 +19,18 @@ import { SideEffectContext } from './side_effect_context';
|
|||
import { applyMatrix3 } from '../models/vector2';
|
||||
import { sideEffectSimulatorFactory } from './side_effect_simulator_factory';
|
||||
import { mock as mockResolverTree } from '../models/resolver_tree';
|
||||
import type { ResolverAction } from '../store/actions';
|
||||
import { createStore } from 'redux';
|
||||
import { resolverReducer } from '../store/reducer';
|
||||
import { createStore, combineReducers } from 'redux';
|
||||
import { mockTreeFetcherParameters } from '../mocks/tree_fetcher_parameters';
|
||||
import * as nodeModel from '../../../common/endpoint/models/node';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { mockResolverNode } from '../mocks/resolver_node';
|
||||
import { endpointSourceSchema } from '../mocks/tree_schema';
|
||||
import { panAnimationDuration } from '../store/camera/scaling_constants';
|
||||
import { serverReturnedResolverData } from '../store/data/action';
|
||||
import { userSelectedResolverNode } from '../store/actions';
|
||||
import { mockReducer } from '../store/helpers';
|
||||
|
||||
const id = 'test-id';
|
||||
|
||||
describe('useCamera on an unpainted element', () => {
|
||||
/** Enzyme full DOM wrapper for the element the camera is attached to. */
|
||||
|
@ -108,7 +110,7 @@ describe('useCamera on an unpainted element', () => {
|
|||
*/
|
||||
useAlternateElement?: boolean;
|
||||
}) {
|
||||
const camera = useCamera();
|
||||
const camera = useCamera({ id });
|
||||
const { ref, onMouseDown } = camera;
|
||||
projectionMatrix = camera.projectionMatrix;
|
||||
return useAlternateElement ? (
|
||||
|
@ -125,7 +127,8 @@ describe('useCamera on an unpainted element', () => {
|
|||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
store = createStore(resolverReducer);
|
||||
const outerReducer = combineReducers({ analyzer: mockReducer(id) });
|
||||
store = createStore(outerReducer, undefined);
|
||||
|
||||
simulator = sideEffectSimulatorFactory();
|
||||
|
||||
|
@ -263,21 +266,22 @@ describe('useCamera on an unpainted element', () => {
|
|||
const tree = mockResolverTree({ nodes });
|
||||
if (tree !== null) {
|
||||
const { schema, dataSource } = endpointSourceSchema();
|
||||
const serverResponseAction: ResolverAction = {
|
||||
type: 'serverReturnedResolverData',
|
||||
payload: {
|
||||
store.dispatch(
|
||||
serverReturnedResolverData({
|
||||
id,
|
||||
result: tree,
|
||||
dataSource,
|
||||
schema,
|
||||
parameters: mockTreeFetcherParameters(),
|
||||
},
|
||||
};
|
||||
store.dispatch(serverResponseAction);
|
||||
})
|
||||
);
|
||||
} else {
|
||||
throw new Error('failed to create tree');
|
||||
}
|
||||
const resolverNodes: ResolverNode[] = [
|
||||
...selectors.layout(store.getState()).processNodePositions.keys(),
|
||||
...selectors
|
||||
.layout(store.getState().analyzer.analyzerById[id])
|
||||
.processNodePositions.keys(),
|
||||
];
|
||||
node = resolverNodes[resolverNodes.length - 1];
|
||||
if (!process) {
|
||||
|
@ -288,14 +292,7 @@ describe('useCamera on an unpainted element', () => {
|
|||
if (!nodeID) {
|
||||
throw new Error('could not find nodeID for process');
|
||||
}
|
||||
const cameraAction: ResolverAction = {
|
||||
type: 'userSelectedResolverNode',
|
||||
payload: {
|
||||
time: simulator.controls.time,
|
||||
nodeID,
|
||||
},
|
||||
};
|
||||
store.dispatch(cameraAction);
|
||||
store.dispatch(userSelectedResolverNode({ id, time: simulator.controls.time, nodeID }));
|
||||
});
|
||||
|
||||
it('should request animation frames in a loop', () => {
|
||||
|
|
|
@ -7,13 +7,20 @@
|
|||
|
||||
import type React from 'react';
|
||||
import { useCallback, useState, useEffect, useRef, useLayoutEffect, useContext } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { SideEffectContext } from './side_effect_context';
|
||||
import type { Matrix3 } from '../types';
|
||||
import { useResolverDispatch } from './use_resolver_dispatch';
|
||||
import * as selectors from '../store/selectors';
|
||||
import {
|
||||
userStartedPanning,
|
||||
userMovedPointer,
|
||||
userStoppedPanning,
|
||||
userZoomed,
|
||||
userSetRasterSize,
|
||||
} from '../store/camera/action';
|
||||
import type { State } from '../../common/store/types';
|
||||
|
||||
export function useCamera(): {
|
||||
export function useCamera({ id }: { id: string }): {
|
||||
/**
|
||||
* A function to pass to a React element's `ref` property. Used to attach
|
||||
* native event listeners and to measure the DOM node.
|
||||
|
@ -26,7 +33,7 @@ export function useCamera(): {
|
|||
*/
|
||||
projectionMatrix: Matrix3;
|
||||
} {
|
||||
const dispatch = useResolverDispatch();
|
||||
const dispatch = useDispatch();
|
||||
const sideEffectors = useContext(SideEffectContext);
|
||||
|
||||
const [ref, setRef] = useState<null | HTMLDivElement>(null);
|
||||
|
@ -36,7 +43,15 @@ export function useCamera(): {
|
|||
* to determine where it belongs on the screen.
|
||||
* The projection matrix changes over time if the camera is currently animating.
|
||||
*/
|
||||
const projectionMatrixAtTime = useSelector(selectors.projectionMatrix);
|
||||
|
||||
const projectionMatrixAtTime = useSelector(
|
||||
useCallback(
|
||||
(state: State) => {
|
||||
return selectors.projectionMatrix(state.analyzer.analyzerById[id]);
|
||||
},
|
||||
[id]
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Use a ref to refer to the `projectionMatrixAtTime` function. The rAF loop
|
||||
|
@ -57,8 +72,12 @@ export function useCamera(): {
|
|||
projectionMatrixAtTime(sideEffectors.timestamp())
|
||||
);
|
||||
|
||||
const userIsPanning = useSelector(selectors.userIsPanning);
|
||||
const isAnimatingAtTime = useSelector(selectors.isAnimating);
|
||||
const userIsPanning = useSelector((state: State) =>
|
||||
selectors.userIsPanning(state.analyzer.analyzerById[id])
|
||||
);
|
||||
const isAnimatingAtTime = useSelector((state: State) =>
|
||||
selectors.isAnimating(state.analyzer.analyzerById[id])
|
||||
);
|
||||
|
||||
const [elementBoundingClientRect, clientRectCallback] = useAutoUpdatingClientRect();
|
||||
|
||||
|
@ -82,41 +101,39 @@ export function useCamera(): {
|
|||
(event: React.MouseEvent<HTMLDivElement>) => {
|
||||
const maybeCoordinates = relativeCoordinatesFromMouseEvent(event);
|
||||
if (maybeCoordinates !== null) {
|
||||
dispatch({
|
||||
type: 'userStartedPanning',
|
||||
payload: { screenCoordinates: maybeCoordinates, time: sideEffectors.timestamp() },
|
||||
});
|
||||
dispatch(
|
||||
userStartedPanning({
|
||||
id,
|
||||
screenCoordinates: maybeCoordinates,
|
||||
time: sideEffectors.timestamp(),
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
[dispatch, relativeCoordinatesFromMouseEvent, sideEffectors]
|
||||
[dispatch, relativeCoordinatesFromMouseEvent, sideEffectors, id]
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
const maybeCoordinates = relativeCoordinatesFromMouseEvent(event);
|
||||
if (maybeCoordinates) {
|
||||
dispatch({
|
||||
type: 'userMovedPointer',
|
||||
payload: {
|
||||
dispatch(
|
||||
userMovedPointer({
|
||||
id,
|
||||
screenCoordinates: maybeCoordinates,
|
||||
time: sideEffectors.timestamp(),
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
[dispatch, relativeCoordinatesFromMouseEvent, sideEffectors]
|
||||
[dispatch, relativeCoordinatesFromMouseEvent, sideEffectors, id]
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (userIsPanning) {
|
||||
dispatch({
|
||||
type: 'userStoppedPanning',
|
||||
payload: {
|
||||
time: sideEffectors.timestamp(),
|
||||
},
|
||||
});
|
||||
dispatch(userStoppedPanning({ id, time: sideEffectors.timestamp() }));
|
||||
}
|
||||
}, [dispatch, sideEffectors, userIsPanning]);
|
||||
}, [dispatch, sideEffectors, userIsPanning, id]);
|
||||
|
||||
const handleWheel = useCallback(
|
||||
(event: WheelEvent) => {
|
||||
|
@ -127,20 +144,21 @@ export function useCamera(): {
|
|||
event.deltaMode === 0
|
||||
) {
|
||||
event.preventDefault();
|
||||
dispatch({
|
||||
type: 'userZoomed',
|
||||
payload: {
|
||||
dispatch(
|
||||
userZoomed({
|
||||
id,
|
||||
/**
|
||||
*
|
||||
* we use elementBoundingClientRect to interpret pixel deltas as a fraction of the element's height
|
||||
* when pinch-zooming in on a mac, deltaY is a negative number but we want the payload to be positive
|
||||
*/
|
||||
zoomChange: event.deltaY / -elementBoundingClientRect.height,
|
||||
time: sideEffectors.timestamp(),
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
[elementBoundingClientRect, dispatch, sideEffectors]
|
||||
[elementBoundingClientRect, dispatch, sideEffectors, id]
|
||||
);
|
||||
|
||||
const refCallback = useCallback(
|
||||
|
@ -252,12 +270,14 @@ export function useCamera(): {
|
|||
|
||||
useEffect(() => {
|
||||
if (elementBoundingClientRect !== null) {
|
||||
dispatch({
|
||||
type: 'userSetRasterSize',
|
||||
payload: [elementBoundingClientRect.width, elementBoundingClientRect.height],
|
||||
});
|
||||
dispatch(
|
||||
userSetRasterSize({
|
||||
id,
|
||||
dimensions: [elementBoundingClientRect.width, elementBoundingClientRect.height],
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [dispatch, elementBoundingClientRect]);
|
||||
}, [dispatch, elementBoundingClientRect, id]);
|
||||
|
||||
return {
|
||||
ref: refCallback,
|
||||
|
|
|
@ -18,10 +18,11 @@ import { useColors } from './use_colors';
|
|||
* Provides colors and HTML IDs used to render the 'cube' graphic that accompanies nodes.
|
||||
*/
|
||||
export function useCubeAssets(
|
||||
id: string,
|
||||
cubeType: NodeDataStatus,
|
||||
isProcessTrigger: boolean
|
||||
): NodeStyleConfig {
|
||||
const SymbolIds = useSymbolIDs();
|
||||
const SymbolIds = useSymbolIDs({ id });
|
||||
const colorMap = useColors();
|
||||
|
||||
const nodeAssets: NodeStyleMap = useMemo(
|
||||
|
|
|
@ -10,7 +10,8 @@ import type { MouseEventHandler } from 'react';
|
|||
import { useNavigateOrReplace } from './use_navigate_or_replace';
|
||||
|
||||
import * as selectors from '../store/selectors';
|
||||
import type { PanelViewAndParameters, ResolverState } from '../types';
|
||||
import type { PanelViewAndParameters } from '../types';
|
||||
import type { State } from '../../common/store/types';
|
||||
|
||||
type EventHandlerCallback = MouseEventHandler<HTMLButtonElement | HTMLAnchorElement>;
|
||||
|
||||
|
@ -20,12 +21,15 @@ type EventHandlerCallback = MouseEventHandler<HTMLButtonElement | HTMLAnchorElem
|
|||
* the `href` points to `panelViewAndParameters`.
|
||||
* Existing `search` parameters are maintained.
|
||||
*/
|
||||
export function useLinkProps(panelViewAndParameters: PanelViewAndParameters): {
|
||||
export function useLinkProps(
|
||||
id: string,
|
||||
panelViewAndParameters: PanelViewAndParameters
|
||||
): {
|
||||
href: string;
|
||||
onClick: EventHandlerCallback;
|
||||
} {
|
||||
const search = useSelector((state: ResolverState) =>
|
||||
selectors.relativeHref(state)(panelViewAndParameters)
|
||||
const search = useSelector((state: State) =>
|
||||
selectors.relativeHref(state.analyzer.analyzerById[id])(panelViewAndParameters)
|
||||
);
|
||||
|
||||
return useNavigateOrReplace({
|
||||
|
|
|
@ -7,16 +7,12 @@
|
|||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import * as selectors from '../store/selectors';
|
||||
|
||||
/**
|
||||
* Access the HTML IDs for this Resolver's reusable SVG 'paint servers'.
|
||||
* In the future these IDs may come from an outside provider (and may be shared by multiple Resolver instances.)
|
||||
*/
|
||||
export function usePaintServerIDs() {
|
||||
const resolverComponentInstanceID = useSelector(selectors.resolverComponentInstanceID);
|
||||
export function usePaintServerIDs({ id }: { id: string }) {
|
||||
const resolverComponentInstanceID = id;
|
||||
return useMemo(() => {
|
||||
const prefix = `${resolverComponentInstanceID}-symbols`;
|
||||
return {
|
||||
|
|
|
@ -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 { useLocation, useHistory } from 'react-router-dom';
|
||||
import { useSelector } from 'react-redux';
|
||||
import * as selectors from '../store/selectors';
|
||||
import { parameterName } from '../store/parameter_name';
|
||||
/**
|
||||
* Cleanup any query string keys that were added by this Resolver instance.
|
||||
* This works by having a React effect that just has behavior in the 'cleanup' function.
|
||||
*/
|
||||
export function useResolverQueryParamCleaner() {
|
||||
export function useResolverQueryParamCleaner(id: string) {
|
||||
/**
|
||||
* Keep a reference to the current search value. This is used in the cleanup function.
|
||||
* This value of useLocation().search isn't used directly since that would change and
|
||||
|
@ -25,9 +23,8 @@ export function useResolverQueryParamCleaner() {
|
|||
searchRef.current = useLocation().search;
|
||||
|
||||
const history = useHistory();
|
||||
const resolverComponentInstanceID = useSelector(selectors.resolverComponentInstanceID);
|
||||
|
||||
const resolverKey = parameterName(resolverComponentInstanceID);
|
||||
const resolverKey = parameterName(id);
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
|
|
|
@ -7,8 +7,8 @@
|
|||
|
||||
import { useLayoutEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useResolverDispatch } from './use_resolver_dispatch';
|
||||
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { appReceivedNewExternalProperties } from '../store/actions';
|
||||
/**
|
||||
* This is a hook that is meant to be used once at the top level of Resolver.
|
||||
* It dispatches actions that keep the store in sync with external properties.
|
||||
|
@ -29,20 +29,20 @@ export function useStateSyncingActions({
|
|||
shouldUpdate: boolean;
|
||||
filters: object;
|
||||
}) {
|
||||
const dispatch = useResolverDispatch();
|
||||
const dispatch = useDispatch();
|
||||
const locationSearch = useLocation().search;
|
||||
useLayoutEffect(() => {
|
||||
dispatch({
|
||||
type: 'appReceivedNewExternalProperties',
|
||||
payload: {
|
||||
dispatch(
|
||||
appReceivedNewExternalProperties({
|
||||
id: resolverComponentInstanceID,
|
||||
databaseDocumentID,
|
||||
resolverComponentInstanceID,
|
||||
locationSearch,
|
||||
indices,
|
||||
shouldUpdate,
|
||||
filters,
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
}, [
|
||||
dispatch,
|
||||
databaseDocumentID,
|
||||
|
|
|
@ -7,18 +7,13 @@
|
|||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import * as selectors from '../store/selectors';
|
||||
|
||||
/**
|
||||
* Access the HTML IDs for this Resolver's reusable SVG symbols.
|
||||
* In the future these IDs may come from an outside provider (and may be shared by multiple Resolver instances.)
|
||||
*/
|
||||
export function useSymbolIDs() {
|
||||
const resolverComponentInstanceID = useSelector(selectors.resolverComponentInstanceID);
|
||||
export function useSymbolIDs({ id }: { id: string }) {
|
||||
return useMemo(() => {
|
||||
const prefix = `${resolverComponentInstanceID}-symbols`;
|
||||
const prefix = `${id}-symbols`;
|
||||
return {
|
||||
processNodeLabel: `${prefix}-nodeSymbol`,
|
||||
runningProcessCube: `${prefix}-runningCube`,
|
||||
|
@ -29,5 +24,5 @@ export function useSymbolIDs() {
|
|||
loadingCube: `${prefix}-loadingCube`,
|
||||
errorCube: `${prefix}-errorCube`,
|
||||
};
|
||||
}, [resolverComponentInstanceID]);
|
||||
}, [id]);
|
||||
}
|
||||
|
|
|
@ -10,8 +10,9 @@ import { useSelector, useDispatch } from 'react-redux';
|
|||
import { useLocation } from 'react-router-dom';
|
||||
import * as selectors from '../store/selectors';
|
||||
import { SideEffectContext } from './side_effect_context';
|
||||
import type { ResolverAction } from '../store/actions';
|
||||
import { panelViewAndParameters } from '../store/panel_view_and_parameters';
|
||||
import { userSelectedResolverNode } from '../store/actions';
|
||||
import type { State } from '../../common/store/types';
|
||||
|
||||
/**
|
||||
* This custom hook, will maintain the state of the active/selected node with the what the selected nodeID is in url state.
|
||||
|
@ -19,13 +20,17 @@ import { panelViewAndParameters } from '../store/panel_view_and_parameters';
|
|||
* In the scenario where the nodeList is visible in the panel, there is no selectedNode, but this would naturally default to the origin node based on `serverReturnedResolverData` on initial load and refresh
|
||||
* This custom hook should only be called once on resolver load, following that the url nodeID should always equal the selectedNode. This is currently called in `resolver_without_providers.tsx`.
|
||||
*/
|
||||
export function useSyncSelectedNode() {
|
||||
const dispatch: (action: ResolverAction) => void = useDispatch();
|
||||
const resolverComponentInstanceID = useSelector(selectors.resolverComponentInstanceID);
|
||||
export function useSyncSelectedNode({ id }: { id: string }) {
|
||||
const dispatch = useDispatch();
|
||||
const resolverComponentInstanceID = id;
|
||||
const locationSearch = useLocation().search;
|
||||
const sideEffectors = useContext(SideEffectContext);
|
||||
const selectedNode = useSelector(selectors.selectedNode);
|
||||
const idToNodeMap = useSelector(selectors.graphNodeForID);
|
||||
const selectedNode = useSelector((state: State) =>
|
||||
selectors.selectedNode(state.analyzer.analyzerById[id])
|
||||
);
|
||||
const idToNodeMap = useSelector((state: State) =>
|
||||
selectors.graphNodeForID(state.analyzer.analyzerById[id])
|
||||
);
|
||||
|
||||
const currentPanelParameters = panelViewAndParameters({
|
||||
locationSearch,
|
||||
|
@ -41,13 +46,13 @@ export function useSyncSelectedNode() {
|
|||
useEffect(() => {
|
||||
// use this for the entire render in order to keep things in sync
|
||||
if (urlNodeID && idToNodeMap(urlNodeID) && urlNodeID !== selectedNode) {
|
||||
dispatch({
|
||||
type: 'userSelectedResolverNode',
|
||||
payload: {
|
||||
dispatch(
|
||||
userSelectedResolverNode({
|
||||
id,
|
||||
nodeID: urlNodeID,
|
||||
time: sideEffectors.timestamp(),
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [
|
||||
currentPanelParameters.panelView,
|
||||
|
@ -56,5 +61,6 @@ export function useSyncSelectedNode() {
|
|||
idToNodeMap,
|
||||
selectedNode,
|
||||
sideEffectors,
|
||||
id,
|
||||
]);
|
||||
}
|
||||
|
|
|
@ -131,6 +131,9 @@ const GraphOverlayComponent: React.FC<GraphOverlayProps> = ({
|
|||
const { from, to, shouldUpdate, selectedPatterns } = useTimelineDataFilters(
|
||||
isActiveTimeline(scopeId)
|
||||
);
|
||||
const filters = useMemo(() => {
|
||||
return { from, to };
|
||||
}, [from, to]);
|
||||
|
||||
const sessionContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
|
@ -142,6 +145,24 @@ const GraphOverlayComponent: React.FC<GraphOverlayProps> = ({
|
|||
}
|
||||
}, [fullScreen]);
|
||||
|
||||
const resolver = useMemo(
|
||||
() =>
|
||||
graphEventId !== undefined ? (
|
||||
<StyledResolver
|
||||
databaseDocumentID={graphEventId}
|
||||
resolverComponentInstanceID={scopeId}
|
||||
indices={selectedPatterns}
|
||||
shouldUpdate={shouldUpdate}
|
||||
filters={filters}
|
||||
/>
|
||||
) : (
|
||||
<EuiFlexGroup alignItems="center" justifyContent="center" style={{ height: '100%' }}>
|
||||
<EuiLoadingSpinner size="xl" />
|
||||
</EuiFlexGroup>
|
||||
),
|
||||
[graphEventId, scopeId, selectedPatterns, shouldUpdate, filters]
|
||||
);
|
||||
|
||||
if (!isActiveTimeline(scopeId) && sessionViewConfig !== null) {
|
||||
return (
|
||||
<OverlayContainer data-test-subj="overlayContainer" ref={sessionContainerRef}>
|
||||
|
@ -164,19 +185,7 @@ const GraphOverlayComponent: React.FC<GraphOverlayProps> = ({
|
|||
<EuiFlexItem grow={false}>{Navigation}</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiHorizontalRule margin="none" />
|
||||
{graphEventId !== undefined ? (
|
||||
<StyledResolver
|
||||
databaseDocumentID={graphEventId}
|
||||
resolverComponentInstanceID={scopeId}
|
||||
indices={selectedPatterns}
|
||||
shouldUpdate={shouldUpdate}
|
||||
filters={{ from, to }}
|
||||
/>
|
||||
) : (
|
||||
<EuiFlexGroup alignItems="center" justifyContent="center" style={{ height: '100%' }}>
|
||||
<EuiLoadingSpinner size="xl" />
|
||||
</EuiFlexGroup>
|
||||
)}
|
||||
{resolver}
|
||||
</FullScreenOverlayContainer>
|
||||
);
|
||||
} else {
|
||||
|
@ -187,19 +196,7 @@ const GraphOverlayComponent: React.FC<GraphOverlayProps> = ({
|
|||
<EuiFlexItem grow={false}>{Navigation}</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiHorizontalRule margin="none" />
|
||||
{graphEventId !== undefined ? (
|
||||
<StyledResolver
|
||||
databaseDocumentID={graphEventId}
|
||||
resolverComponentInstanceID={scopeId}
|
||||
indices={selectedPatterns}
|
||||
shouldUpdate={shouldUpdate}
|
||||
filters={{ from, to }}
|
||||
/>
|
||||
) : (
|
||||
<EuiFlexGroup alignItems="center" justifyContent="center" style={{ height: '100%' }}>
|
||||
<EuiLoadingSpinner size="xl" />
|
||||
</EuiFlexGroup>
|
||||
)}
|
||||
{resolver}
|
||||
</OverlayContainer>
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue