Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Endpoint] Basic Functionality Alert List #55800

Merged
merged 9 commits into from
Jan 29, 2020
52 changes: 52 additions & 0 deletions x-pack/plugins/endpoint/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,24 @@
* you may not use this file except in compliance with the Elastic License.
*/

/**
* A deep readonly type that will make all children of a given object readonly recursively
*/
export type Immutable<T> = T extends undefined | null | boolean | string | number
? T
: T extends Array<infer U>
? ImmutableArray<U>
: T extends Map<infer K, infer V>
? ImmutableMap<K, V>
: T extends Set<infer M>
? ImmutableSet<M>
: ImmutableObject<T>;

export type ImmutableArray<T> = ReadonlyArray<Immutable<T>>;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please add a comment to all exported identifiers

export type ImmutableMap<K, V> = ReadonlyMap<Immutable<K>, Immutable<V>>;
export type ImmutableSet<T> = ReadonlySet<Immutable<T>>;
export type ImmutableObject<T> = { readonly [K in keyof T]: Immutable<T[K]> };

export class EndpointAppConstants {
static ENDPOINT_INDEX_NAME = 'endpoint-agent*';
}
Expand Down Expand Up @@ -44,3 +62,37 @@ export interface EndpointMetadata {
};
};
}

export interface AlertData {
value: {
source: {
endgame: {
data: {
file_operation: string;
malware_classification: {
score: number;
};
};
metadata: {
key: string;
};
timestamp_utc: Date;
};
labels: {
endpoint_id: string;
};
host: {
hostname: string;
ip: string;
os: {
name: string;
};
};
};
};
}

/**
* The PageId type is used for the payload when firing userNavigatedToPage actions
*/
export type PageId = 'alertsPage' | 'endpointListPage';
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Route, BrowserRouter, Switch } from 'react-router-dom';
import { Provider } from 'react-redux';
import { Store } from 'redux';
import { appStoreFactory } from './store';
import { AlertIndex } from './view/alerts';

/**
* This module will be loaded asynchronously to reduce the bundle size of your plugin's main bundle.
Expand Down Expand Up @@ -64,6 +65,7 @@ const AppRoot: React.FunctionComponent<RouterProps> = React.memo(({ basename, st
);
}}
/>
<Route path="/alerts" component={AlertIndex} />
<Route
render={() => (
<FormattedMessage id="xpack.endpoint.notFound" defaultMessage="Page Not Found" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/

import { AnyAction, Dispatch, Middleware, MiddlewareAPI } from 'redux';
import { GlobalState } from '../store';
import { GlobalState } from '../types';

interface QueuedAction<TAction = AnyAction> {
/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/*
* 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';
import { AlertAction } from './alerts';
import { RoutingAction } from './routing';

export type AppAction = EndpointListAction | AlertAction | RoutingAction;
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/*
* 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 { AlertData, Immutable } from '../../../../../common/types';

type ServerReturnedAlertsData = Immutable<{
type: 'serverReturnedAlertsData';
payload: AlertData[];
}>;

export type AlertAction = ServerReturnedAlertsData;
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,5 @@
* you may not use this file except in compliance with the Elastic License.
*/

import { EndpointListAction } from './endpoint_list';

export type AppAction = EndpointListAction;
export { alertListReducer } from './reducer';
export { AlertAction } from './action';
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/*
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are using a simple middleware to handle side-effects here, as opposed to using oatSaga. The idea is that we shouldn't overcomplicate the app from the start. oatSaga, though simpler than redux-saga, still has a fair amount of complexity in its usage. There is no need to have this complexity now, though over time we may feel like adding some more functionality. The other supporting argument to using this simpler approach is that newcomers to the team will most likely be familiar with Redux and the basic concepts of middlewares, but they won't know about oatSaga. Thoughts @oatkiller @kevinlog @paul-tavares @parkiino.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm good with this approach as well - much simpler

Copy link
Contributor

@oatkiller oatkiller Jan 24, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image

seems like oatsaga has more adoption
image

Copy link
Contributor

@kevinlog kevinlog Jan 24, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@peluja1012 what are some of the main differences here? It looks like we're still using appSagaFactory below which, as far as I can tell uses the existing createSagaMiddleware. Is the plan to drop some of the other functionality from https://github.com/elastic/kibana/blob/master/x-pack/plugins/endpoint/public/applications/endpoint/lib/saga.ts ?

Let me know what I'm missing @oatkiller @paul-tavares @parkiino

I'm all for simplicity, just wanna better understand what we're proposing that we remove.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we're proposing to remove anything. Just that the alerting feature will (initially anyway) forgo lib/saga and instead just make its simple middleware:

The logic consists of:

  1. call the next middleware
  2. Check if the action is a certain action (page loaded)
  3. if so, make an http request and dispatch an action when thats done.

Redux middleware, by default, provides the ability to run code on each action. lib/saga's main addition to that is to provide the stream of actions as an async generator.

* 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 { AlertData, ImmutableArray } from '../../../../../common/types';
import { AppAction } from '../action';
import { MiddlewareFactory } from '../../types';

export const alertMiddlewareFactory: MiddlewareFactory = coreStart => {
return api => next => async (action: AppAction) => {
next(action);
if (action.type === 'userNavigatedToPage' && action.payload === 'alertsPage') {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm assuming by your above TODO, that all of this is still moving code for now, but would we be to define these action types in a types.ts file, so they're easy to use anywhere?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alexk307 We define actions in files like this one https://github.com/elastic/kibana/pull/55800/files#diff-c971622c4d5a45ad624ef764513a0131. The type checker will throw and error if you use an invalid action type in this if-statement. So if we were to do if (action.type === 'fooAction'), the type checker will throw an error because fooAction is not of type AppAction.

const response: ImmutableArray<AlertData> = await coreStart.http.get('/api/endpoint/alerts');
api.dispatch({ type: 'serverReturnedAlertsData', payload: response });
}
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* 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 { Reducer } from 'redux';
import { AlertListState } from '../../types';
import { AppAction } from '../action';

const initialState = (): AlertListState => {
return {
alerts: [],
};
};

export const alertListReducer: Reducer<AlertListState, AppAction> = (
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you using index vs page vs `list consistently here?

Some suggestions:

The feature (aka concern) is called 'alerting' and the directory takes this name.
The component that shows a list of alerts: 'alertList'
The route is called 'alertsIndex' assuming that it's written 'alerts/'
The component that renders the page is called 'alertingPage'.
@peluja1012 can explain that we might end up with a single route that shows an alert list page, an alert detail page, or both at the same time.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will address this in the next PR where I add a stub alert detail

state = initialState(),
action
) => {
if (action.type === 'serverReturnedAlertsData') {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You may want to consider (in the future) "reseting" the state when a user leaves the Alerts list so that data stored here does not take up memory in the browser.

return {
...state,
alerts: action.payload,
};
}

return state;
};
Original file line number Diff line number Diff line change
@@ -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 { AlertListState } from '../../types';

export const alertListData = (state: AlertListState) => state.alerts;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

consider naming this type AlertingState and this reducer alertingReducer and the naming the piece of global state alerting

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will address this in the next PR where I add a stub alert detail

Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/

import { Reducer } from 'redux';
import { EndpointListState } from './types';
import { EndpointListAction } from './action';
import { AppAction } from '../action';

const initialState = (): EndpointListState => {
return {
Expand All @@ -16,7 +17,10 @@ const initialState = (): EndpointListState => {
};
};

export const endpointListReducer = (state = initialState(), action: EndpointListAction) => {
export const endpointListReducer: Reducer<EndpointListState, AppAction> = (
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q. Why AppAction and not EndpointListAction?
is it so that in the future we could take advantage of other concern's Actions?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

its because combineReducers passes in all actions, not just EndpointListAction

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah Ok - but were you getting TS errors?

I think the benefit was that types shown here would be narrowed down to only the ones this concern cares about.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We weren't getting errors but I think to your previous point it might be good to take advantage of being able to use other concern's or generic/global app actions in the future and this would keep that ability. Kind of like how we have the page navigated actions now. I do see what you're saying though, i don't necessarily disagree with it

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

even if we filter out non-concern specific actions in the future, they aren't yet filtered out, so this type is more correct

state = initialState(),
action
) => {
if (action.type === 'serverReturnedEndpointList') {
return {
...state,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ import { createStore, compose, applyMiddleware, Store } from 'redux';
import { CoreStart } from 'kibana/public';
import { appSagaFactory } from './saga';
import { appReducer } from './reducer';

export { GlobalState } from './reducer';
import { alertMiddlewareFactory } from './alerts/middleware';

const composeWithReduxDevTools = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
? (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({ name: 'EndpointApp' })
Expand All @@ -19,7 +18,9 @@ export const appStoreFactory = (coreStart: CoreStart): [Store, () => void] => {
const sagaReduxMiddleware = appSagaFactory(coreStart);
const store = createStore(
appReducer,
composeWithReduxDevTools(applyMiddleware(sagaReduxMiddleware))
composeWithReduxDevTools(
applyMiddleware(alertMiddlewareFactory(coreStart), appSagaFactory(coreStart))
)
);

sagaReduxMiddleware.start();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,12 @@
* 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;
}
import { endpointListReducer } from './endpoint_list';
import { AppAction } from './action';
import { alertListReducer } from './alerts';
import { GlobalState } from '../types';

export const appReducer: Reducer<GlobalState, AppAction> = combineReducers({
endpointList: endpointListReducer,
alertList: alertListReducer,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/*
* 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 { PageId } from '../../../../../common/types';

interface UserNavigatedToPage {
readonly type: 'userNavigatedToPage';
readonly payload: PageId;
}

export type RoutingAction = UserNavigatedToPage;
Original file line number Diff line number Diff line change
@@ -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 { RoutingAction } from './action';
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* 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 { GlobalState } from '../types';
import * as alertListSelectors from './alerts/selectors';

export const alertListData = composeSelectors(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI.
@parkiino will be pushing through a PR soon that takes a different approach for using a selector from a React component. Instead of having to re-define all your "context bound" selectors again here so that they receive the correct namespace from the GlobalStore, instead we're proposing having a hook like:

File: store/hooks.ts:

export function useEndpointListSelector<TSelected>(
  selector: (state: EndpointListState) => TSelected
) {
  return useSelector(function(state: GlobalState) {
    return selector(state.endpointList);
  });
}

The idea is that each of the concerns will have an associated hook. To use this in a component, you would:

import { endpointListData } from "../store/endpoint_list/selectors;
import { useEndpointListSelector } from "../store/hooks;

const EndpointsListPage = () => {
    const listData = useEndpointListSelector(endpointListData);

    return (<div>...</div>)
}

(Credit to @oatkiller for the idea during a recent discussion 😃 )

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@paul-tavares If we use this approach, how would a middleware (or saga) use the selector if needed to grab some data from state?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@peluja1012 - From a saga or middleware we have access directly to the store and can get back GlobalState - so you would use the concern selector directly and feed it its namespace (ex. endpointListData(store.getState().endpointList)).

When we discussed this, the goal was to avoid the need to define all concerns selectors a second time in order for us to continue to use react-redux useSelector(). The hook does that nicely 😃

alertListStateSelector,
alertListSelectors.alertListData
);

/**
* Returns the alert list state from within Global State
*/
function alertListStateSelector(state: GlobalState) {
return state.alertList;
}

/**
* Calls the `secondSelector` with the result of the `selector`. Use this when re-exporting a
* concern-specific selector. `selector` should return the concern-specific state.
*/
function composeSelectors<OuterState, InnerState, ReturnValue>(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

selector: (state: OuterState) => InnerState,
secondSelector: (state: InnerState) => ReturnValue
): (state: OuterState) => ReturnValue {
return state => secondSelector(selector(state));
}
26 changes: 26 additions & 0 deletions x-pack/plugins/endpoint/public/applications/endpoint/types.ts
Original file line number Diff line number Diff line change
@@ -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 { Dispatch, MiddlewareAPI } from 'redux';
import { CoreStart } from 'kibana/public';
import { Immutable, AlertData } from '../../../common/types';
import { EndpointListState } from './store/endpoint_list';
import { AppAction } from './store/action';

export type MiddlewareFactory = (
coreStart: CoreStart
) => (
api: MiddlewareAPI<Dispatch<AppAction>, GlobalState>
) => (next: Dispatch<AppAction>) => (action: AppAction) => unknown;

export type AlertListState = Immutable<{
alerts: AlertData[];
}>;

export interface GlobalState {
readonly endpointList: EndpointListState;
readonly alertList: AlertListState;
}
Loading