[Endpoint] add Redux saga Middleware and app Store (#53906)
* Added saga library * Initialize endpoint app redux store
This commit is contained in:
parent
54c7d340ae
commit
93a11838ad
|
@ -9,6 +9,9 @@ import ReactDOM from 'react-dom';
|
|||
import { CoreStart, AppMountParameters } from 'kibana/public';
|
||||
import { I18nProvider, FormattedMessage } from '@kbn/i18n/react';
|
||||
import { Route, BrowserRouter, Switch } from 'react-router-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
import { Store } from 'redux';
|
||||
import { appStoreFactory } from './store';
|
||||
|
||||
/**
|
||||
* This module will be loaded asynchronously to reduce the bundle size of your plugin's main bundle.
|
||||
|
@ -16,7 +19,9 @@ import { Route, BrowserRouter, Switch } from 'react-router-dom';
|
|||
export function renderApp(coreStart: CoreStart, { appBasePath, element }: AppMountParameters) {
|
||||
coreStart.http.get('/api/endpoint/hello-world');
|
||||
|
||||
ReactDOM.render(<AppRoot basename={appBasePath} />, element);
|
||||
const store = appStoreFactory(coreStart);
|
||||
|
||||
ReactDOM.render(<AppRoot basename={appBasePath} store={store} />, element);
|
||||
|
||||
return () => {
|
||||
ReactDOM.unmountComponentAtNode(element);
|
||||
|
@ -25,38 +30,46 @@ export function renderApp(coreStart: CoreStart, { appBasePath, element }: AppMou
|
|||
|
||||
interface RouterProps {
|
||||
basename: string;
|
||||
store: Store;
|
||||
}
|
||||
|
||||
const AppRoot: React.FunctionComponent<RouterProps> = React.memo(({ basename }) => (
|
||||
<I18nProvider>
|
||||
<BrowserRouter basename={basename}>
|
||||
<Switch>
|
||||
<Route
|
||||
exact
|
||||
path="/"
|
||||
render={() => (
|
||||
<h1 data-test-subj="welcomeTitle">
|
||||
<FormattedMessage id="xpack.endpoint.welcomeTitle" defaultMessage="Hello World" />
|
||||
</h1>
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path="/management"
|
||||
render={() => (
|
||||
<h1 data-test-subj="endpointManagement">
|
||||
<FormattedMessage
|
||||
id="xpack.endpoint.endpointManagement"
|
||||
defaultMessage="Manage Endpoints"
|
||||
/>
|
||||
</h1>
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
render={() => (
|
||||
<FormattedMessage id="xpack.endpoint.notFound" defaultMessage="Page Not Found" />
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
</BrowserRouter>
|
||||
</I18nProvider>
|
||||
const AppRoot: React.FunctionComponent<RouterProps> = React.memo(({ basename, store }) => (
|
||||
<Provider store={store}>
|
||||
<I18nProvider>
|
||||
<BrowserRouter basename={basename}>
|
||||
<Switch>
|
||||
<Route
|
||||
exact
|
||||
path="/"
|
||||
render={() => (
|
||||
<h1 data-test-subj="welcomeTitle">
|
||||
<FormattedMessage id="xpack.endpoint.welcomeTitle" defaultMessage="Hello World" />
|
||||
</h1>
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path="/management"
|
||||
render={() => {
|
||||
// FIXME: This is temporary. Will be removed in next PR for endpoint list
|
||||
store.dispatch({ type: 'userEnteredEndpointListPage' });
|
||||
|
||||
return (
|
||||
<h1 data-test-subj="endpointManagement">
|
||||
<FormattedMessage
|
||||
id="xpack.endpoint.endpointManagement"
|
||||
defaultMessage="Manage Endpoints"
|
||||
/>
|
||||
</h1>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route
|
||||
render={() => (
|
||||
<FormattedMessage id="xpack.endpoint.notFound" defaultMessage="Page Not Found" />
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
</BrowserRouter>
|
||||
</I18nProvider>
|
||||
</Provider>
|
||||
));
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
export * from './saga';
|
|
@ -0,0 +1,101 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import { createSagaMiddleware, SagaContext } from './index';
|
||||
import { applyMiddleware, createStore, Reducer } from 'redux';
|
||||
|
||||
describe('saga', () => {
|
||||
const INCREMENT_COUNTER = 'INCREMENT';
|
||||
const DELAYED_INCREMENT_COUNTER = 'DELAYED INCREMENT COUNTER';
|
||||
const STOP_SAGA_PROCESSING = 'BREAK ASYNC ITERATOR';
|
||||
|
||||
const sleep = (ms = 1000) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
let reducerA: Reducer;
|
||||
let sideAffect: (a: unknown, s: unknown) => void;
|
||||
let sagaExe: (sagaContext: SagaContext) => Promise<void>;
|
||||
|
||||
beforeEach(() => {
|
||||
reducerA = jest.fn((prevState = { count: 0 }, { type }) => {
|
||||
switch (type) {
|
||||
case INCREMENT_COUNTER:
|
||||
return { ...prevState, count: prevState.count + 1 };
|
||||
default:
|
||||
return prevState;
|
||||
}
|
||||
});
|
||||
|
||||
sideAffect = jest.fn();
|
||||
|
||||
sagaExe = jest.fn(async ({ actionsAndState, dispatch }: SagaContext) => {
|
||||
for await (const { action, state } of actionsAndState()) {
|
||||
expect(action).toBeDefined();
|
||||
expect(state).toBeDefined();
|
||||
|
||||
if (action.type === STOP_SAGA_PROCESSING) {
|
||||
break;
|
||||
}
|
||||
|
||||
sideAffect(action, state);
|
||||
|
||||
if (action.type === DELAYED_INCREMENT_COUNTER) {
|
||||
await sleep(1);
|
||||
dispatch({
|
||||
type: INCREMENT_COUNTER,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('it returns Redux Middleware from createSagaMiddleware()', () => {
|
||||
const sagaMiddleware = createSagaMiddleware(async () => {});
|
||||
expect(sagaMiddleware).toBeInstanceOf(Function);
|
||||
});
|
||||
test('it does nothing if saga is not started', () => {
|
||||
const store = createStore(reducerA, applyMiddleware(createSagaMiddleware(sagaExe)));
|
||||
expect(store.getState().count).toEqual(0);
|
||||
expect(reducerA).toHaveBeenCalled();
|
||||
expect(sagaExe).toHaveBeenCalled();
|
||||
expect(sideAffect).not.toHaveBeenCalled();
|
||||
expect(store.getState()).toEqual({ count: 0 });
|
||||
});
|
||||
test('it updates store once running', async () => {
|
||||
const sagaMiddleware = createSagaMiddleware(sagaExe);
|
||||
const store = createStore(reducerA, applyMiddleware(sagaMiddleware));
|
||||
|
||||
expect(store.getState()).toEqual({ count: 0 });
|
||||
expect(sagaExe).toHaveBeenCalled();
|
||||
|
||||
store.dispatch({ type: DELAYED_INCREMENT_COUNTER });
|
||||
expect(store.getState()).toEqual({ count: 0 });
|
||||
|
||||
await sleep(100);
|
||||
|
||||
expect(sideAffect).toHaveBeenCalled();
|
||||
expect(store.getState()).toEqual({ count: 1 });
|
||||
});
|
||||
test('it stops processing if break out of loop', async () => {
|
||||
const sagaMiddleware = createSagaMiddleware(sagaExe);
|
||||
const store = createStore(reducerA, applyMiddleware(sagaMiddleware));
|
||||
|
||||
store.dispatch({ type: DELAYED_INCREMENT_COUNTER });
|
||||
await sleep(100);
|
||||
|
||||
expect(store.getState()).toEqual({ count: 1 });
|
||||
expect(sideAffect).toHaveBeenCalledTimes(2);
|
||||
|
||||
store.dispatch({ type: STOP_SAGA_PROCESSING });
|
||||
await sleep(100);
|
||||
|
||||
expect(store.getState()).toEqual({ count: 1 });
|
||||
expect(sideAffect).toHaveBeenCalledTimes(2);
|
||||
|
||||
store.dispatch({ type: DELAYED_INCREMENT_COUNTER });
|
||||
await sleep(100);
|
||||
|
||||
expect(store.getState()).toEqual({ count: 1 });
|
||||
expect(sideAffect).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
129
x-pack/plugins/endpoint/public/applications/endpoint/lib/saga.ts
Normal file
129
x-pack/plugins/endpoint/public/applications/endpoint/lib/saga.ts
Normal file
|
@ -0,0 +1,129 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { AnyAction, Dispatch, Middleware, MiddlewareAPI } from 'redux';
|
||||
import { GlobalState } from '../store';
|
||||
|
||||
interface QueuedAction<TAction = AnyAction> {
|
||||
/**
|
||||
* The Redux action that was dispatched
|
||||
*/
|
||||
action: TAction;
|
||||
/**
|
||||
* The Global state at the time the action was dispatched
|
||||
*/
|
||||
state: GlobalState;
|
||||
}
|
||||
|
||||
interface IteratorInstance {
|
||||
queue: QueuedAction[];
|
||||
nextResolve: null | ((inst: QueuedAction) => void);
|
||||
}
|
||||
|
||||
type Saga = (storeContext: SagaContext) => Promise<void>;
|
||||
|
||||
type StoreActionsAndState<TAction = AnyAction> = AsyncIterableIterator<QueuedAction<TAction>>;
|
||||
|
||||
export interface SagaContext<TAction extends AnyAction = AnyAction> {
|
||||
/**
|
||||
* A generator function that will `yield` `Promise`s that resolve with a `QueuedAction`
|
||||
*/
|
||||
actionsAndState: () => StoreActionsAndState<TAction>;
|
||||
dispatch: Dispatch<TAction>;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
/**
|
||||
* Creates Saga Middleware for use with Redux.
|
||||
*
|
||||
* @param {Saga} saga The `saga` should initialize a long-running `for await...of` loop against
|
||||
* the return value of the `actionsAndState()` method provided by the `SagaContext`.
|
||||
*
|
||||
* @return {Middleware}
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* type TPossibleActions = { type: 'add', payload: any[] };
|
||||
* //...
|
||||
* const endpointsSaga = async ({ actionsAndState, dispatch }: SagaContext<TPossibleActions>) => {
|
||||
* for await (const { action, state } of actionsAndState()) {
|
||||
* if (action.type === "userRequestedResource") {
|
||||
* const resourceData = await doApiFetch('of/some/resource');
|
||||
* dispatch({
|
||||
* type: 'add',
|
||||
* payload: [ resourceData ]
|
||||
* });
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* const endpointsSagaMiddleware = createSagaMiddleware(endpointsSaga);
|
||||
* //....
|
||||
* const store = createStore(reducers, [ endpointsSagaMiddleware ]);
|
||||
*/
|
||||
export function createSagaMiddleware(saga: Saga): Middleware {
|
||||
const iteratorInstances = new Set<IteratorInstance>();
|
||||
let runSaga: () => void = noop;
|
||||
|
||||
async function* getActionsAndStateIterator(): StoreActionsAndState {
|
||||
const instance: IteratorInstance = { queue: [], nextResolve: null };
|
||||
iteratorInstances.add(instance);
|
||||
try {
|
||||
while (true) {
|
||||
yield await nextActionAndState();
|
||||
}
|
||||
} finally {
|
||||
// If the consumer stops consuming this (e.g. `break` or `return` is called in the `for await`
|
||||
// then this `finally` block will run and unregister this instance and reset `runSaga`
|
||||
iteratorInstances.delete(instance);
|
||||
runSaga = noop;
|
||||
}
|
||||
|
||||
function nextActionAndState() {
|
||||
if (instance.queue.length) {
|
||||
return Promise.resolve(instance.queue.shift() as QueuedAction);
|
||||
} else {
|
||||
return new Promise<QueuedAction>(function(resolve) {
|
||||
instance.nextResolve = resolve;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function enqueue(value: QueuedAction) {
|
||||
for (const iteratorInstance of iteratorInstances) {
|
||||
iteratorInstance.queue.push(value);
|
||||
if (iteratorInstance.nextResolve !== null) {
|
||||
iteratorInstance.nextResolve(iteratorInstance.queue.shift() as QueuedAction);
|
||||
iteratorInstance.nextResolve = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function middleware({ getState, dispatch }: MiddlewareAPI) {
|
||||
if (runSaga === noop) {
|
||||
runSaga = saga.bind<null, SagaContext, any[], Promise<void>>(null, {
|
||||
actionsAndState: getActionsAndStateIterator,
|
||||
dispatch,
|
||||
});
|
||||
runSaga();
|
||||
}
|
||||
return (next: Dispatch<AnyAction>) => (action: AnyAction) => {
|
||||
// Call the next dispatch method in the middleware chain.
|
||||
const returnValue = next(action);
|
||||
|
||||
enqueue({
|
||||
action,
|
||||
state: getState(),
|
||||
});
|
||||
|
||||
// This will likely be the action itself, unless a middleware further in chain changed it.
|
||||
return returnValue;
|
||||
};
|
||||
}
|
||||
|
||||
return middleware;
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { EndpointListAction } from './endpoint_list';
|
||||
|
||||
export type AppAction = EndpointListAction;
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { EndpointListData } from './types';
|
||||
|
||||
interface ServerReturnedEndpointList {
|
||||
type: 'serverReturnedEndpointList';
|
||||
payload: EndpointListData;
|
||||
}
|
||||
|
||||
interface UserEnteredEndpointListPage {
|
||||
type: 'userEnteredEndpointListPage';
|
||||
}
|
||||
|
||||
interface UserExitedEndpointListPage {
|
||||
type: 'userExitedEndpointListPage';
|
||||
}
|
||||
|
||||
export type EndpointListAction =
|
||||
| ServerReturnedEndpointList
|
||||
| UserEnteredEndpointListPage
|
||||
| UserExitedEndpointListPage;
|
|
@ -0,0 +1,126 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { createStore, Dispatch, Store } from 'redux';
|
||||
import { EndpointListAction, EndpointData, endpointListReducer, EndpointListState } from './index';
|
||||
import { endpointListData } from './selectors';
|
||||
|
||||
describe('endpoint_list store concerns', () => {
|
||||
let store: Store<EndpointListState>;
|
||||
let dispatch: Dispatch<EndpointListAction>;
|
||||
const createTestStore = () => {
|
||||
store = createStore(endpointListReducer);
|
||||
dispatch = store.dispatch;
|
||||
};
|
||||
const generateEndpoint = (): EndpointData => {
|
||||
return {
|
||||
machine_id: Math.random()
|
||||
.toString(16)
|
||||
.substr(2),
|
||||
created_at: new Date(),
|
||||
host: {
|
||||
name: '',
|
||||
hostname: '',
|
||||
ip: '',
|
||||
mac_address: '',
|
||||
os: {
|
||||
name: '',
|
||||
full: '',
|
||||
},
|
||||
},
|
||||
endpoint: {
|
||||
domain: '',
|
||||
is_base_image: true,
|
||||
active_directory_distinguished_name: '',
|
||||
active_directory_hostname: '',
|
||||
upgrade: {
|
||||
status: '',
|
||||
updated_at: new Date(),
|
||||
},
|
||||
isolation: {
|
||||
status: false,
|
||||
request_status: true,
|
||||
updated_at: new Date(),
|
||||
},
|
||||
policy: {
|
||||
name: '',
|
||||
id: '',
|
||||
},
|
||||
sensor: {
|
||||
persistence: true,
|
||||
status: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
const loadDataToStore = () => {
|
||||
dispatch({
|
||||
type: 'serverReturnedEndpointList',
|
||||
payload: {
|
||||
endpoints: [generateEndpoint()],
|
||||
request_page_size: 1,
|
||||
request_index: 1,
|
||||
total: 10,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
describe('# Reducers', () => {
|
||||
beforeEach(() => {
|
||||
createTestStore();
|
||||
});
|
||||
|
||||
test('it creates default state', () => {
|
||||
expect(store.getState()).toEqual({
|
||||
endpoints: [],
|
||||
request_page_size: 10,
|
||||
request_index: 0,
|
||||
total: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('it handles `serverReturnedEndpointList', () => {
|
||||
const payload = {
|
||||
endpoints: [generateEndpoint()],
|
||||
request_page_size: 1,
|
||||
request_index: 1,
|
||||
total: 10,
|
||||
};
|
||||
dispatch({
|
||||
type: 'serverReturnedEndpointList',
|
||||
payload,
|
||||
});
|
||||
|
||||
const currentState = store.getState();
|
||||
expect(currentState.endpoints).toEqual(payload.endpoints);
|
||||
expect(currentState.request_page_size).toEqual(payload.request_page_size);
|
||||
expect(currentState.request_index).toEqual(payload.request_index);
|
||||
expect(currentState.total).toEqual(payload.total);
|
||||
});
|
||||
|
||||
test('it handles `userExitedEndpointListPage`', () => {
|
||||
loadDataToStore();
|
||||
|
||||
expect(store.getState().total).toEqual(10);
|
||||
|
||||
dispatch({ type: 'userExitedEndpointListPage' });
|
||||
expect(store.getState().endpoints.length).toEqual(0);
|
||||
expect(store.getState().request_index).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('# Selectors', () => {
|
||||
beforeEach(() => {
|
||||
createTestStore();
|
||||
loadDataToStore();
|
||||
});
|
||||
|
||||
test('it selects `endpointListData`', () => {
|
||||
const currentState = store.getState();
|
||||
expect(endpointListData(currentState)).toEqual(currentState.endpoints);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
export { endpointListReducer } from './reducer';
|
||||
export { EndpointListAction } from './action';
|
||||
export { endpointListSaga } from './saga';
|
||||
export * from './types';
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { EndpointListState } from './types';
|
||||
import { EndpointListAction } from './action';
|
||||
|
||||
const initialState = (): EndpointListState => {
|
||||
return {
|
||||
endpoints: [],
|
||||
request_page_size: 10,
|
||||
request_index: 0,
|
||||
total: 0,
|
||||
};
|
||||
};
|
||||
|
||||
export const endpointListReducer = (state = initialState(), action: EndpointListAction) => {
|
||||
if (action.type === 'serverReturnedEndpointList') {
|
||||
return {
|
||||
...state,
|
||||
...action.payload,
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === 'userExitedEndpointListPage') {
|
||||
return initialState();
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
|
@ -0,0 +1,111 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { CoreStart, HttpSetup } from 'kibana/public';
|
||||
import { applyMiddleware, createStore, Dispatch, Store } from 'redux';
|
||||
import { createSagaMiddleware, SagaContext } from '../../lib';
|
||||
import { endpointListSaga } from './saga';
|
||||
import { coreMock } from '../../../../../../../../src/core/public/mocks';
|
||||
import {
|
||||
EndpointData,
|
||||
EndpointListAction,
|
||||
EndpointListData,
|
||||
endpointListReducer,
|
||||
EndpointListState,
|
||||
} from './index';
|
||||
import { endpointListData } from './selectors';
|
||||
|
||||
describe('endpoint list saga', () => {
|
||||
const sleep = (ms = 100) => new Promise(wakeup => setTimeout(wakeup, ms));
|
||||
let fakeCoreStart: jest.Mocked<CoreStart>;
|
||||
let fakeHttpServices: jest.Mocked<HttpSetup>;
|
||||
let store: Store<EndpointListState>;
|
||||
let dispatch: Dispatch<EndpointListAction>;
|
||||
|
||||
// TODO: consolidate the below ++ helpers in `index.test.ts` into a `test_helpers.ts`??
|
||||
const generateEndpoint = (): EndpointData => {
|
||||
return {
|
||||
machine_id: Math.random()
|
||||
.toString(16)
|
||||
.substr(2),
|
||||
created_at: new Date(),
|
||||
host: {
|
||||
name: '',
|
||||
hostname: '',
|
||||
ip: '',
|
||||
mac_address: '',
|
||||
os: {
|
||||
name: '',
|
||||
full: '',
|
||||
},
|
||||
},
|
||||
endpoint: {
|
||||
domain: '',
|
||||
is_base_image: true,
|
||||
active_directory_distinguished_name: '',
|
||||
active_directory_hostname: '',
|
||||
upgrade: {
|
||||
status: '',
|
||||
updated_at: new Date(),
|
||||
},
|
||||
isolation: {
|
||||
status: false,
|
||||
request_status: true,
|
||||
updated_at: new Date(),
|
||||
},
|
||||
policy: {
|
||||
name: '',
|
||||
id: '',
|
||||
},
|
||||
sensor: {
|
||||
persistence: true,
|
||||
status: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
const getEndpointListApiResponse = (): EndpointListData => {
|
||||
return {
|
||||
endpoints: [generateEndpoint()],
|
||||
request_page_size: 1,
|
||||
request_index: 1,
|
||||
total: 10,
|
||||
};
|
||||
};
|
||||
|
||||
const endpointListSagaFactory = () => {
|
||||
return async (sagaContext: SagaContext) => {
|
||||
await endpointListSaga(sagaContext, fakeCoreStart).catch((e: Error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(e);
|
||||
return Promise.reject(e);
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
fakeCoreStart = coreMock.createStart({ basePath: '/mock' });
|
||||
fakeHttpServices = fakeCoreStart.http as jest.Mocked<HttpSetup>;
|
||||
store = createStore(
|
||||
endpointListReducer,
|
||||
applyMiddleware(createSagaMiddleware(endpointListSagaFactory()))
|
||||
);
|
||||
dispatch = store.dispatch;
|
||||
});
|
||||
|
||||
test('it handles `userEnteredEndpointListPage`', async () => {
|
||||
const apiResponse = getEndpointListApiResponse();
|
||||
|
||||
fakeHttpServices.post.mockResolvedValue(apiResponse);
|
||||
expect(fakeHttpServices.post).not.toHaveBeenCalled();
|
||||
|
||||
dispatch({ type: 'userEnteredEndpointListPage' });
|
||||
await sleep();
|
||||
|
||||
expect(fakeHttpServices.post).toHaveBeenCalledWith('/api/endpoint/endpoints');
|
||||
expect(endpointListData(store.getState())).toEqual(apiResponse.endpoints);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { CoreStart } from 'kibana/public';
|
||||
import { SagaContext } from '../../lib';
|
||||
import { EndpointListAction } from './action';
|
||||
|
||||
export const endpointListSaga = async (
|
||||
{ actionsAndState, dispatch }: SagaContext<EndpointListAction>,
|
||||
coreStart: CoreStart
|
||||
) => {
|
||||
const { post: httpPost } = coreStart.http;
|
||||
|
||||
for await (const { action } of actionsAndState()) {
|
||||
if (action.type === 'userEnteredEndpointListPage') {
|
||||
const response = await httpPost('/api/endpoint/endpoints');
|
||||
dispatch({
|
||||
type: 'serverReturnedEndpointList',
|
||||
payload: response,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { EndpointListState } from './types';
|
||||
|
||||
export const endpointListData = (state: EndpointListState) => state.endpoints;
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
// FIXME: temporary until server defined `interface` is moved
|
||||
export interface EndpointData {
|
||||
machine_id: string;
|
||||
created_at: Date;
|
||||
host: {
|
||||
name: string;
|
||||
hostname: string;
|
||||
ip: string;
|
||||
mac_address: string;
|
||||
os: {
|
||||
name: string;
|
||||
full: string;
|
||||
};
|
||||
};
|
||||
endpoint: {
|
||||
domain: string;
|
||||
is_base_image: boolean;
|
||||
active_directory_distinguished_name: string;
|
||||
active_directory_hostname: string;
|
||||
upgrade: {
|
||||
status?: string;
|
||||
updated_at?: Date;
|
||||
};
|
||||
isolation: {
|
||||
status: boolean;
|
||||
request_status?: string | boolean;
|
||||
updated_at?: Date;
|
||||
};
|
||||
policy: {
|
||||
name: string;
|
||||
id: string;
|
||||
};
|
||||
sensor: {
|
||||
persistence: boolean;
|
||||
status: object;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// FIXME: temporary until server defined `interface` is moved to a module we can reference
|
||||
export interface EndpointListData {
|
||||
endpoints: EndpointData[];
|
||||
request_page_size: number;
|
||||
request_index: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export type EndpointListState = EndpointListData;
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { createStore, compose, applyMiddleware } from 'redux';
|
||||
import { CoreStart } from 'kibana/public';
|
||||
import { appSagaFactory } from './saga';
|
||||
import { appReducer } from './reducer';
|
||||
|
||||
export { GlobalState } from './reducer';
|
||||
|
||||
const composeWithReduxDevTools = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
|
||||
? (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({ name: 'EndpointApp' })
|
||||
: compose;
|
||||
|
||||
export const appStoreFactory = (coreStart: CoreStart) => {
|
||||
const store = createStore(
|
||||
appReducer,
|
||||
composeWithReduxDevTools(applyMiddleware(appSagaFactory(coreStart)))
|
||||
);
|
||||
return store;
|
||||
};
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import { combineReducers, Reducer } from 'redux';
|
||||
import { endpointListReducer, EndpointListState } from './endpoint_list';
|
||||
import { AppAction } from './actions';
|
||||
|
||||
export interface GlobalState {
|
||||
endpointList: EndpointListState;
|
||||
}
|
||||
|
||||
export const appReducer: Reducer<GlobalState, AppAction> = combineReducers({
|
||||
endpointList: endpointListReducer,
|
||||
});
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { CoreStart } from 'kibana/public';
|
||||
import { createSagaMiddleware, SagaContext } from '../lib';
|
||||
import { endpointListSaga } from './endpoint_list';
|
||||
|
||||
export const appSagaFactory = (coreStart: CoreStart) => {
|
||||
return createSagaMiddleware(async (sagaContext: SagaContext) => {
|
||||
await Promise.all([
|
||||
// Concerns specific sagas here
|
||||
endpointListSaga(sagaContext, coreStart),
|
||||
]);
|
||||
});
|
||||
};
|
Loading…
Reference in a new issue