mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[Endpoint] Host Details Policy Response Panel (#63518)
* Added link to Policy status that updates URL and show details panel * Custom Styled Flyout Panel sub-header component to display sub-headers * Move Middleware spy utils under `store/` for re-use * Changed `appStoreFactory()` to accept optional `additionalMiddleware` prop * `waitForAction` middleware test utility now return Action on Promise resolve * Updated PageView component to remove bottom margin
This commit is contained in:
parent
a9399c3d91
commit
cae0c964ac
16 changed files with 516 additions and 213 deletions
|
@ -12,6 +12,7 @@ import { coreMock } from '../../../../../../../src/core/public/mocks';
|
|||
import { EndpointPluginStartDependencies } from '../../../plugin';
|
||||
import { depsStartMock } from './dependencies_start_mock';
|
||||
import { AppRootProvider } from '../view/app_root_provider';
|
||||
import { createSpyMiddleware, MiddlewareActionSpyHelper } from '../store/test_utils';
|
||||
|
||||
type UiRender = (ui: React.ReactElement, options?: RenderOptions) => RenderResult;
|
||||
|
||||
|
@ -23,6 +24,7 @@ export interface AppContextTestRender {
|
|||
history: ReturnType<typeof createMemoryHistory>;
|
||||
coreStart: ReturnType<typeof coreMock.createStart>;
|
||||
depsStart: EndpointPluginStartDependencies;
|
||||
middlewareSpy: MiddlewareActionSpyHelper;
|
||||
/**
|
||||
* A wrapper around `AppRootContext` component. Uses the mocked modules as input to the
|
||||
* `AppRootContext`
|
||||
|
@ -45,7 +47,12 @@ export const createAppRootMockRenderer = (): AppContextTestRender => {
|
|||
const history = createMemoryHistory<never>();
|
||||
const coreStart = coreMock.createStart({ basePath: '/mock' });
|
||||
const depsStart = depsStartMock();
|
||||
const store = appStoreFactory({ coreStart, depsStart });
|
||||
const middlewareSpy = createSpyMiddleware();
|
||||
const store = appStoreFactory({
|
||||
coreStart,
|
||||
depsStart,
|
||||
additionalMiddleware: [middlewareSpy.actionSpyMiddleware],
|
||||
});
|
||||
const AppWrapper: React.FunctionComponent<{ children: React.ReactElement }> = ({ children }) => (
|
||||
<AppRootProvider store={store} history={history} coreStart={coreStart} depsStart={depsStart}>
|
||||
{children}
|
||||
|
@ -64,6 +71,7 @@ export const createAppRootMockRenderer = (): AppContextTestRender => {
|
|||
history,
|
||||
coreStart,
|
||||
depsStart,
|
||||
middlewareSpy,
|
||||
AppWrapper,
|
||||
render,
|
||||
};
|
||||
|
|
|
@ -37,7 +37,7 @@ export const uiQueryParams: (
|
|||
// Removes the `?` from the beginning of query string if it exists
|
||||
const query = querystring.parse(location.search.slice(1));
|
||||
|
||||
const keys: Array<keyof HostIndexUIQueryParams> = ['selected_host'];
|
||||
const keys: Array<keyof HostIndexUIQueryParams> = ['selected_host', 'show'];
|
||||
|
||||
for (const key of keys) {
|
||||
const value = query[key];
|
||||
|
@ -58,3 +58,11 @@ export const hasSelectedHost: (state: Immutable<HostListState>) => boolean = cre
|
|||
return selectedHost !== undefined;
|
||||
}
|
||||
);
|
||||
|
||||
/** What policy details panel view to show */
|
||||
export const showView: (state: HostListState) => 'policy_response' | 'details' = createSelector(
|
||||
uiQueryParams,
|
||||
searchParams => {
|
||||
return searchParams.show === 'policy_response' ? 'policy_response' : 'details';
|
||||
}
|
||||
);
|
||||
|
|
|
@ -19,7 +19,7 @@ import { alertMiddlewareFactory } from './alerts/middleware';
|
|||
import { hostMiddlewareFactory } from './hosts';
|
||||
import { policyListMiddlewareFactory } from './policy_list';
|
||||
import { policyDetailsMiddlewareFactory } from './policy_details';
|
||||
import { GlobalState } from '../types';
|
||||
import { GlobalState, MiddlewareFactory } from '../types';
|
||||
import { AppAction } from './action';
|
||||
import { EndpointPluginStartDependencies } from '../../../plugin';
|
||||
|
||||
|
@ -62,10 +62,15 @@ export const appStoreFactory: (middlewareDeps?: {
|
|||
* Give middleware access to plugin start dependencies.
|
||||
*/
|
||||
depsStart: EndpointPluginStartDependencies;
|
||||
/**
|
||||
* Any additional Redux Middlewares
|
||||
* (should only be used for testing - example: to inject the action spy middleware)
|
||||
*/
|
||||
additionalMiddleware?: Array<ReturnType<MiddlewareFactory>>;
|
||||
}) => Store = middlewareDeps => {
|
||||
let middleware;
|
||||
if (middlewareDeps) {
|
||||
const { coreStart, depsStart } = middlewareDeps;
|
||||
const { coreStart, depsStart, additionalMiddleware = [] } = middlewareDeps;
|
||||
middleware = composeWithReduxDevTools(
|
||||
applyMiddleware(
|
||||
substateMiddlewareFactory(
|
||||
|
@ -83,7 +88,9 @@ export const appStoreFactory: (middlewareDeps?: {
|
|||
substateMiddlewareFactory(
|
||||
globalState => globalState.alertList,
|
||||
alertMiddlewareFactory(coreStart, depsStart)
|
||||
)
|
||||
),
|
||||
// Additional Middleware should go last
|
||||
...additionalMiddleware
|
||||
)
|
||||
);
|
||||
} else {
|
||||
|
|
|
@ -12,13 +12,10 @@ import { policyListMiddlewareFactory } from './middleware';
|
|||
import { coreMock } from '../../../../../../../../src/core/public/mocks';
|
||||
import { isOnPolicyListPage, selectIsLoading, urlSearchParams } from './selectors';
|
||||
import { DepsStartMock, depsStartMock } from '../../mocks';
|
||||
import {
|
||||
createSpyMiddleware,
|
||||
MiddlewareActionSpyHelper,
|
||||
setPolicyListApiMockImplementation,
|
||||
} from './test_mock_utils';
|
||||
import { setPolicyListApiMockImplementation } from './test_mock_utils';
|
||||
import { INGEST_API_DATASOURCES } from './services/ingest';
|
||||
import { Immutable } from '../../../../../common/types';
|
||||
import { createSpyMiddleware, MiddlewareActionSpyHelper } from '../test_utils';
|
||||
|
||||
describe('policy list store concerns', () => {
|
||||
let fakeCoreStart: ReturnType<typeof coreMock.createStart>;
|
||||
|
|
|
@ -5,10 +5,9 @@
|
|||
*/
|
||||
|
||||
import { HttpStart } from 'kibana/public';
|
||||
import { Dispatch } from 'redux';
|
||||
import { INGEST_API_DATASOURCES } from './services/ingest';
|
||||
import { EndpointDocGenerator } from '../../../../../common/generate_data';
|
||||
import { AppAction, GetPolicyListResponse, GlobalState, MiddlewareFactory } from '../../types';
|
||||
import { GetPolicyListResponse } from '../../types';
|
||||
|
||||
const generator = new EndpointDocGenerator('policy-list');
|
||||
|
||||
|
@ -37,115 +36,3 @@ export const setPolicyListApiMockImplementation = (
|
|||
return Promise.reject(new Error(`MOCK: unknown policy list api: ${path}`));
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Utilities for testing Redux middleware
|
||||
*/
|
||||
export interface MiddlewareActionSpyHelper<S = GlobalState> {
|
||||
/**
|
||||
* Returns a promise that is fulfilled when the given action is dispatched or a timeout occurs.
|
||||
* The use of this method instead of a `sleep()` type of delay should avoid test case instability
|
||||
* especially when run in a CI environment.
|
||||
*
|
||||
* @param actionType
|
||||
*/
|
||||
waitForAction: (actionType: AppAction['type']) => Promise<void>;
|
||||
/**
|
||||
* A property holding the information around the calls that were processed by the internal
|
||||
* `actionSpyMiddlware`. This property holds the information typically found in Jets's mocked
|
||||
* function `mock` property - [see here for more information](https://jestjs.io/docs/en/mock-functions#mock-property)
|
||||
*
|
||||
* **Note**: this property will only be set **after* the `actionSpyMiddlware` has been
|
||||
* initialized (ex. via `createStore()`. Attempting to reference this property before that time
|
||||
* will throw an error.
|
||||
* Also - do not hold on to references to this property value if `jest.clearAllMocks()` or
|
||||
* `jest.resetAllMocks()` is called between usages of the value.
|
||||
*/
|
||||
dispatchSpy: jest.Mock<Dispatch<AppAction>>['mock'];
|
||||
/**
|
||||
* Redux middleware that enables spying on the action that are dispatched through the store
|
||||
*/
|
||||
actionSpyMiddleware: ReturnType<MiddlewareFactory<S>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new instance of middleware action helpers
|
||||
* Note: in most cases (testing concern specific middleware) this function should be given
|
||||
* the state type definition, else, the global state will be used.
|
||||
*
|
||||
* @example
|
||||
* // Use in Policy List middleware testing
|
||||
* const middlewareSpyUtils = createSpyMiddleware<PolicyListState>();
|
||||
* store = createStore(
|
||||
* policyListReducer,
|
||||
* applyMiddleware(
|
||||
* policyListMiddlewareFactory(fakeCoreStart, depsStart),
|
||||
* middlewareSpyUtils.actionSpyMiddleware
|
||||
* )
|
||||
* );
|
||||
* // Reference `dispatchSpy` ONLY after creating the store that includes `actionSpyMiddleware`
|
||||
* const { waitForAction, dispatchSpy } = middlewareSpyUtils;
|
||||
* //
|
||||
* // later in test
|
||||
* //
|
||||
* it('...', async () => {
|
||||
* //...
|
||||
* await waitForAction('serverReturnedPolicyListData');
|
||||
* // do assertions
|
||||
* // or check how action was called
|
||||
* expect(dispatchSpy.calls.length).toBe(2)
|
||||
* });
|
||||
*/
|
||||
export const createSpyMiddleware = <S = GlobalState>(): MiddlewareActionSpyHelper<S> => {
|
||||
type ActionWatcher = (action: AppAction) => void;
|
||||
|
||||
const watchers = new Set<ActionWatcher>();
|
||||
let spyDispatch: jest.Mock<Dispatch<AppAction>>;
|
||||
|
||||
return {
|
||||
waitForAction: async (actionType: string) => {
|
||||
// Error is defined here so that we get a better stack trace that points to the test from where it was used
|
||||
const err = new Error(`action '${actionType}' was not dispatched within the allocated time`);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const watch: ActionWatcher = action => {
|
||||
if (action.type === actionType) {
|
||||
watchers.delete(watch);
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
// We timeout before jest's default 5s, so that a better error stack is returned
|
||||
const timeout = setTimeout(() => {
|
||||
watchers.delete(watch);
|
||||
reject(err);
|
||||
}, 4500);
|
||||
watchers.add(watch);
|
||||
});
|
||||
},
|
||||
|
||||
get dispatchSpy() {
|
||||
if (!spyDispatch) {
|
||||
throw new Error(
|
||||
'Spy Middleware has not been initialized. Access this property only after using `actionSpyMiddleware` in a redux store'
|
||||
);
|
||||
}
|
||||
return spyDispatch.mock;
|
||||
},
|
||||
|
||||
actionSpyMiddleware: api => {
|
||||
return next => {
|
||||
spyDispatch = jest.fn(action => {
|
||||
next(action);
|
||||
// loop through the list of watcher (if any) and call them with this action
|
||||
for (const watch of watchers) {
|
||||
watch(action);
|
||||
}
|
||||
return action;
|
||||
});
|
||||
return spyDispatch;
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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 { Dispatch } from 'redux';
|
||||
import { AppAction, GlobalState, MiddlewareFactory } from '../types';
|
||||
|
||||
/**
|
||||
* Utilities for testing Redux middleware
|
||||
*/
|
||||
export interface MiddlewareActionSpyHelper<S = GlobalState, A extends AppAction = AppAction> {
|
||||
/**
|
||||
* Returns a promise that is fulfilled when the given action is dispatched or a timeout occurs.
|
||||
* The `action` will given to the promise `resolve` thus allowing for checks to be done.
|
||||
* The use of this method instead of a `sleep()` type of delay should avoid test case instability
|
||||
* especially when run in a CI environment.
|
||||
*
|
||||
* @param actionType
|
||||
*/
|
||||
waitForAction: <T extends A['type']>(actionType: T) => Promise<A extends { type: T } ? A : never>;
|
||||
/**
|
||||
* A property holding the information around the calls that were processed by the internal
|
||||
* `actionSpyMiddelware`. This property holds the information typically found in Jets's mocked
|
||||
* function `mock` property - [see here for more information](https://jestjs.io/docs/en/mock-functions#mock-property)
|
||||
*
|
||||
* **Note**: this property will only be set **after* the `actionSpyMiddlware` has been
|
||||
* initialized (ex. via `createStore()`. Attempting to reference this property before that time
|
||||
* will throw an error.
|
||||
* Also - do not hold on to references to this property value if `jest.clearAllMocks()` or
|
||||
* `jest.resetAllMocks()` is called between usages of the value.
|
||||
*/
|
||||
dispatchSpy: jest.Mock<Dispatch<A>>['mock'];
|
||||
/**
|
||||
* Redux middleware that enables spying on the action that are dispatched through the store
|
||||
*/
|
||||
actionSpyMiddleware: ReturnType<MiddlewareFactory<S>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new instance of middleware action helpers
|
||||
* Note: in most cases (testing concern specific middleware) this function should be given
|
||||
* the state type definition, else, the global state will be used.
|
||||
*
|
||||
* @example
|
||||
* // Use in Policy List middleware testing
|
||||
* const middlewareSpyUtils = createSpyMiddleware<PolicyListState>();
|
||||
* store = createStore(
|
||||
* policyListReducer,
|
||||
* applyMiddleware(
|
||||
* policyListMiddlewareFactory(fakeCoreStart, depsStart),
|
||||
* middlewareSpyUtils.actionSpyMiddleware
|
||||
* )
|
||||
* );
|
||||
* // Reference `dispatchSpy` ONLY after creating the store that includes `actionSpyMiddleware`
|
||||
* const { waitForAction, dispatchSpy } = middlewareSpyUtils;
|
||||
* //
|
||||
* // later in test
|
||||
* //
|
||||
* it('...', async () => {
|
||||
* //...
|
||||
* await waitForAction('serverReturnedPolicyListData');
|
||||
* // do assertions
|
||||
* // or check how action was called
|
||||
* expect(dispatchSpy.calls.length).toBe(2)
|
||||
* });
|
||||
*/
|
||||
export const createSpyMiddleware = <
|
||||
S = GlobalState,
|
||||
A extends AppAction = AppAction
|
||||
>(): MiddlewareActionSpyHelper<S, A> => {
|
||||
type ActionWatcher = (action: A) => void;
|
||||
|
||||
const watchers = new Set<ActionWatcher>();
|
||||
let spyDispatch: jest.Mock<Dispatch<A>>;
|
||||
|
||||
return {
|
||||
waitForAction: async actionType => {
|
||||
type ResolvedAction = A extends { type: typeof actionType } ? A : never;
|
||||
|
||||
// Error is defined here so that we get a better stack trace that points to the test from where it was used
|
||||
const err = new Error(`action '${actionType}' was not dispatched within the allocated time`);
|
||||
|
||||
return new Promise<ResolvedAction>((resolve, reject) => {
|
||||
const watch: ActionWatcher = action => {
|
||||
if (action.type === actionType) {
|
||||
watchers.delete(watch);
|
||||
clearTimeout(timeout);
|
||||
resolve(action as ResolvedAction);
|
||||
}
|
||||
};
|
||||
|
||||
// We timeout before jest's default 5s, so that a better error stack is returned
|
||||
const timeout = setTimeout(() => {
|
||||
watchers.delete(watch);
|
||||
reject(err);
|
||||
}, 4500);
|
||||
watchers.add(watch);
|
||||
});
|
||||
},
|
||||
|
||||
get dispatchSpy() {
|
||||
if (!spyDispatch) {
|
||||
throw new Error(
|
||||
'Spy Middleware has not been initialized. Access this property only after using `actionSpyMiddleware` in a redux store'
|
||||
);
|
||||
}
|
||||
return spyDispatch.mock;
|
||||
},
|
||||
|
||||
actionSpyMiddleware: api => {
|
||||
return next => {
|
||||
spyDispatch = jest.fn(action => {
|
||||
next(action);
|
||||
// loop through the list of watcher (if any) and call them with this action
|
||||
for (const watch of watchers) {
|
||||
watch(action);
|
||||
}
|
||||
return action;
|
||||
});
|
||||
return spyDispatch;
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
|
@ -52,6 +52,7 @@ export interface HostListPagination {
|
|||
}
|
||||
export interface HostIndexUIQueryParams {
|
||||
selected_host?: string;
|
||||
show?: string;
|
||||
}
|
||||
|
||||
export interface ServerApiError {
|
||||
|
|
|
@ -7,6 +7,7 @@ exports[`PageView component should display body header custom element 1`] = `
|
|||
|
||||
.c0.endpoint--isListView .endpoint-header {
|
||||
padding: 24px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.c0.endpoint--isListView .endpoint-page-content {
|
||||
|
@ -97,6 +98,7 @@ exports[`PageView component should display body header wrapped in EuiTitle 1`] =
|
|||
|
||||
.c0.endpoint--isListView .endpoint-header {
|
||||
padding: 24px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.c0.endpoint--isListView .endpoint-page-content {
|
||||
|
@ -190,6 +192,7 @@ exports[`PageView component should display header left and right 1`] = `
|
|||
|
||||
.c0.endpoint--isListView .endpoint-header {
|
||||
padding: 24px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.c0.endpoint--isListView .endpoint-page-content {
|
||||
|
@ -298,6 +301,7 @@ exports[`PageView component should display only body if not header props used 1`
|
|||
|
||||
.c0.endpoint--isListView .endpoint-header {
|
||||
padding: 24px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.c0.endpoint--isListView .endpoint-page-content {
|
||||
|
@ -365,6 +369,7 @@ exports[`PageView component should display only header left 1`] = `
|
|||
|
||||
.c0.endpoint--isListView .endpoint-header {
|
||||
padding: 24px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.c0.endpoint--isListView .endpoint-page-content {
|
||||
|
@ -462,6 +467,7 @@ exports[`PageView component should display only header right but include an empt
|
|||
|
||||
.c0.endpoint--isListView .endpoint-header {
|
||||
padding: 24px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.c0.endpoint--isListView .endpoint-page-content {
|
||||
|
@ -556,6 +562,7 @@ exports[`PageView component should pass through EuiPage props 1`] = `
|
|||
|
||||
.c0.endpoint--isListView .endpoint-header {
|
||||
padding: 24px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.c0.endpoint--isListView .endpoint-page-content {
|
||||
|
@ -640,6 +647,7 @@ exports[`PageView component should use custom element for header left and not wr
|
|||
|
||||
.c0.endpoint--isListView .endpoint-header {
|
||||
padding: 24px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.c0.endpoint--isListView .endpoint-page-content {
|
||||
|
|
|
@ -25,6 +25,7 @@ const StyledEuiPage = styled(EuiPage)`
|
|||
|
||||
.endpoint-header {
|
||||
padding: ${props => props.theme.eui.euiSizeL};
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.endpoint-page-content {
|
||||
border-left: none;
|
||||
|
|
|
@ -0,0 +1,72 @@
|
|||
/*
|
||||
* 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 React, { memo } from 'react';
|
||||
import { EuiFlyoutHeader, CommonProps, EuiButtonEmpty } from '@elastic/eui';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export type FlyoutSubHeaderProps = CommonProps & {
|
||||
children: React.ReactNode;
|
||||
backButton?: {
|
||||
title: string;
|
||||
onClick: (event: React.MouseEvent) => void;
|
||||
href?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const StyledEuiFlyoutHeader = styled(EuiFlyoutHeader)`
|
||||
padding: ${props => props.theme.eui.paddingSizes.s};
|
||||
|
||||
&.hasButtons {
|
||||
.buttons {
|
||||
padding-bottom: ${props => props.theme.eui.paddingSizes.s};
|
||||
}
|
||||
|
||||
.back-button-content {
|
||||
padding-left: 0;
|
||||
&-text {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.flyout-content {
|
||||
padding-left: ${props => props.theme.eui.paddingSizes.m};
|
||||
}
|
||||
`;
|
||||
|
||||
const BUTTON_CONTENT_PROPS = Object.freeze({ className: 'back-button-content' });
|
||||
const BUTTON_TEXT_PROPS = Object.freeze({ className: 'back-button-content-text' });
|
||||
|
||||
/**
|
||||
* A Eui Flyout Header component that has its styles adjusted to display a panel sub-header.
|
||||
* Component also provides a way to display a "back" button above the header title.
|
||||
*/
|
||||
export const FlyoutSubHeader = memo<FlyoutSubHeaderProps>(
|
||||
({ children, backButton, ...otherProps }) => {
|
||||
return (
|
||||
<StyledEuiFlyoutHeader hasBorder {...otherProps} className={backButton && `hasButtons`}>
|
||||
{backButton && (
|
||||
<div className="buttons">
|
||||
{/* eslint-disable-next-line @elastic/eui/href-or-on-click */}
|
||||
<EuiButtonEmpty
|
||||
data-test-subj="flyoutSubHeaderBackButton"
|
||||
iconType="arrowLeft"
|
||||
contentProps={BUTTON_CONTENT_PROPS}
|
||||
textProps={BUTTON_TEXT_PROPS}
|
||||
size="xs"
|
||||
href={backButton?.href ?? ''}
|
||||
onClick={backButton?.onClick}
|
||||
>
|
||||
{backButton?.title}
|
||||
</EuiButtonEmpty>
|
||||
</div>
|
||||
)}
|
||||
<div className={'flyout-content'}>{children}</div>
|
||||
</StyledEuiFlyoutHeader>
|
||||
);
|
||||
}
|
||||
);
|
|
@ -4,31 +4,25 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo, memo, useEffect } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import {
|
||||
EuiFlyout,
|
||||
EuiFlyoutBody,
|
||||
EuiFlyoutHeader,
|
||||
EuiTitle,
|
||||
EuiDescriptionList,
|
||||
EuiLoadingContent,
|
||||
EuiHorizontalRule,
|
||||
EuiHealth,
|
||||
EuiSpacer,
|
||||
EuiHorizontalRule,
|
||||
EuiLink,
|
||||
EuiListGroup,
|
||||
EuiListGroupItem,
|
||||
} from '@elastic/eui';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public';
|
||||
import { HostMetadata } from '../../../../../common/types';
|
||||
import { useHostListSelector } from './hooks';
|
||||
import { urlFromQueryParams } from './url_from_query_params';
|
||||
import { FormattedDateAndTime } from '../formatted_date_time';
|
||||
import { uiQueryParams, detailsData, detailsError } from './../../store/hosts/selectors';
|
||||
import { LinkToApp } from '../components/link_to_app';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { HostMetadata } from '../../../../../../common/types';
|
||||
import { FormattedDateAndTime } from '../../formatted_date_time';
|
||||
import { LinkToApp } from '../../components/link_to_app';
|
||||
import { useHostListSelector, useHostLogsUrl } from '../hooks';
|
||||
import { urlFromQueryParams } from '../url_from_query_params';
|
||||
import { uiQueryParams } from '../../../store/hosts/selectors';
|
||||
|
||||
const HostIds = styled(EuiListGroupItem)`
|
||||
margin-top: 0;
|
||||
|
@ -37,8 +31,10 @@ const HostIds = styled(EuiListGroupItem)`
|
|||
}
|
||||
`;
|
||||
|
||||
const HostDetails = memo(({ details }: { details: HostMetadata }) => {
|
||||
export const HostDetails = memo(({ details }: { details: HostMetadata }) => {
|
||||
const { appId, appPath, url } = useHostLogsUrl(details.host.id);
|
||||
const queryParams = useHostListSelector(uiQueryParams);
|
||||
const history = useHistory();
|
||||
const detailsResultsUpper = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
|
@ -62,6 +58,14 @@ const HostDetails = memo(({ details }: { details: HostMetadata }) => {
|
|||
];
|
||||
}, [details]);
|
||||
|
||||
const policyResponseUri = useMemo(() => {
|
||||
return urlFromQueryParams({
|
||||
...queryParams,
|
||||
selected_host: details.host.id,
|
||||
show: 'policy_response',
|
||||
});
|
||||
}, [details.host.id, queryParams]);
|
||||
|
||||
const detailsResultsLower = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
|
@ -74,7 +78,24 @@ const HostDetails = memo(({ details }: { details: HostMetadata }) => {
|
|||
title: i18n.translate('xpack.endpoint.host.details.policyStatus', {
|
||||
defaultMessage: 'Policy Status',
|
||||
}),
|
||||
description: <EuiHealth color="success">active</EuiHealth>,
|
||||
description: (
|
||||
<EuiHealth color="success">
|
||||
{/* eslint-disable-next-line @elastic/eui/href-or-on-click */}
|
||||
<EuiLink
|
||||
data-test-subj="policyStatusValue"
|
||||
href={'?' + policyResponseUri.search}
|
||||
onClick={(ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
history.push(policyResponseUri);
|
||||
}}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.endpoint.host.details.policyStatus.success"
|
||||
defaultMessage="Successful"
|
||||
/>
|
||||
</EuiLink>
|
||||
</EuiHealth>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: i18n.translate('xpack.endpoint.host.details.ipAddress', {
|
||||
|
@ -101,7 +122,15 @@ const HostDetails = memo(({ details }: { details: HostMetadata }) => {
|
|||
description: details.agent.version,
|
||||
},
|
||||
];
|
||||
}, [details.agent.version, details.endpoint.policy.id, details.host.hostname, details.host.ip]);
|
||||
}, [
|
||||
details.agent.version,
|
||||
details.endpoint.policy.id,
|
||||
details.host.hostname,
|
||||
details.host.ip,
|
||||
history,
|
||||
policyResponseUri,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiDescriptionList
|
||||
|
@ -132,69 +161,3 @@ const HostDetails = memo(({ details }: { details: HostMetadata }) => {
|
|||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export const HostDetailsFlyout = () => {
|
||||
const history = useHistory();
|
||||
const { notifications } = useKibana();
|
||||
const queryParams = useHostListSelector(uiQueryParams);
|
||||
const { selected_host: selectedHost, ...queryParamsWithoutSelectedHost } = queryParams;
|
||||
const details = useHostListSelector(detailsData);
|
||||
const error = useHostListSelector(detailsError);
|
||||
|
||||
const handleFlyoutClose = useCallback(() => {
|
||||
history.push(urlFromQueryParams(queryParamsWithoutSelectedHost));
|
||||
}, [history, queryParamsWithoutSelectedHost]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error !== undefined) {
|
||||
notifications.toasts.danger({
|
||||
title: (
|
||||
<FormattedMessage
|
||||
id="xpack.endpoint.host.details.errorTitle"
|
||||
defaultMessage="Could not find host"
|
||||
/>
|
||||
),
|
||||
body: (
|
||||
<FormattedMessage
|
||||
id="xpack.endpoint.host.details.errorBody"
|
||||
defaultMessage="Please exit the flyout and select an available host."
|
||||
/>
|
||||
),
|
||||
toastLifeTimeMs: 10000,
|
||||
});
|
||||
}
|
||||
}, [error, notifications.toasts]);
|
||||
|
||||
return (
|
||||
<EuiFlyout onClose={handleFlyoutClose} data-test-subj="hostDetailsFlyout">
|
||||
<EuiFlyoutHeader hasBorder>
|
||||
<EuiTitle size="s">
|
||||
<h2 data-test-subj="hostDetailsFlyoutTitle">
|
||||
{details === undefined ? <EuiLoadingContent lines={1} /> : details.host.hostname}
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlyoutHeader>
|
||||
<EuiFlyoutBody>
|
||||
{details === undefined ? (
|
||||
<>
|
||||
<EuiLoadingContent lines={3} /> <EuiSpacer size="l" /> <EuiLoadingContent lines={3} />
|
||||
</>
|
||||
) : (
|
||||
<HostDetails details={details} />
|
||||
)}
|
||||
</EuiFlyoutBody>
|
||||
</EuiFlyout>
|
||||
);
|
||||
};
|
||||
|
||||
const useHostLogsUrl = (hostId: string): { url: string; appId: string; appPath: string } => {
|
||||
const { services } = useKibana();
|
||||
return useMemo(() => {
|
||||
const appPath = `/stream?logFilter=(expression:'host.id:${hostId}',kind:kuery)`;
|
||||
return {
|
||||
url: `${services.application.getUrlForApp('logs')}${appPath}`,
|
||||
appId: 'logs',
|
||||
appPath,
|
||||
};
|
||||
}, [hostId, services.application]);
|
||||
};
|
|
@ -0,0 +1,134 @@
|
|||
/*
|
||||
* 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 React, { useCallback, useEffect, memo, useMemo } from 'react';
|
||||
import {
|
||||
EuiFlyout,
|
||||
EuiFlyoutBody,
|
||||
EuiFlyoutHeader,
|
||||
EuiTitle,
|
||||
EuiLoadingContent,
|
||||
EuiSpacer,
|
||||
} from '@elastic/eui';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { useKibana } from '../../../../../../../../../src/plugins/kibana_react/public';
|
||||
import { useHostListSelector } from '../hooks';
|
||||
import { urlFromQueryParams } from '../url_from_query_params';
|
||||
import { uiQueryParams, detailsData, detailsError, showView } from '../../../store/hosts/selectors';
|
||||
import { HostDetails } from './host_details';
|
||||
import { PolicyResponse } from './policy_response';
|
||||
import { HostMetadata } from '../../../../../../common/types';
|
||||
import { FlyoutSubHeader, FlyoutSubHeaderProps } from './components/flyout_sub_header';
|
||||
|
||||
export const HostDetailsFlyout = memo(() => {
|
||||
const history = useHistory();
|
||||
const { notifications } = useKibana();
|
||||
const queryParams = useHostListSelector(uiQueryParams);
|
||||
const { selected_host: selectedHost, ...queryParamsWithoutSelectedHost } = queryParams;
|
||||
const details = useHostListSelector(detailsData);
|
||||
const error = useHostListSelector(detailsError);
|
||||
const show = useHostListSelector(showView);
|
||||
|
||||
const handleFlyoutClose = useCallback(() => {
|
||||
history.push(urlFromQueryParams(queryParamsWithoutSelectedHost));
|
||||
}, [history, queryParamsWithoutSelectedHost]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error !== undefined) {
|
||||
notifications.toasts.danger({
|
||||
title: (
|
||||
<FormattedMessage
|
||||
id="xpack.endpoint.host.details.errorTitle"
|
||||
defaultMessage="Could not find host"
|
||||
/>
|
||||
),
|
||||
body: (
|
||||
<FormattedMessage
|
||||
id="xpack.endpoint.host.details.errorBody"
|
||||
defaultMessage="Please exit the flyout and select an available host."
|
||||
/>
|
||||
),
|
||||
toastLifeTimeMs: 10000,
|
||||
});
|
||||
}
|
||||
}, [error, notifications.toasts]);
|
||||
|
||||
return (
|
||||
<EuiFlyout onClose={handleFlyoutClose} data-test-subj="hostDetailsFlyout" size="s">
|
||||
<EuiFlyoutHeader hasBorder>
|
||||
<EuiTitle size="s">
|
||||
<h2 data-test-subj="hostDetailsFlyoutTitle">
|
||||
{details === undefined ? <EuiLoadingContent lines={1} /> : details.host.hostname}
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlyoutHeader>
|
||||
{details === undefined ? (
|
||||
<>
|
||||
<EuiFlyoutBody>
|
||||
<EuiLoadingContent lines={3} /> <EuiSpacer size="l" /> <EuiLoadingContent lines={3} />
|
||||
</EuiFlyoutBody>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{show === 'details' && (
|
||||
<>
|
||||
<EuiFlyoutBody data-test-subj="hostDetailsFlyoutBody">
|
||||
<HostDetails details={details} />
|
||||
</EuiFlyoutBody>
|
||||
</>
|
||||
)}
|
||||
{show === 'policy_response' && <PolicyResponseFlyoutPanel hostMeta={details} />}
|
||||
</>
|
||||
)}
|
||||
</EuiFlyout>
|
||||
);
|
||||
});
|
||||
|
||||
const PolicyResponseFlyoutPanel = memo<{
|
||||
hostMeta: HostMetadata;
|
||||
}>(({ hostMeta }) => {
|
||||
const history = useHistory();
|
||||
const { show, ...queryParams } = useHostListSelector(uiQueryParams);
|
||||
const backButtonProp = useMemo((): FlyoutSubHeaderProps['backButton'] => {
|
||||
const detailsUri = urlFromQueryParams({
|
||||
...queryParams,
|
||||
selected_host: hostMeta.host.id,
|
||||
});
|
||||
return {
|
||||
title: i18n.translate('xpack.endpoint.host.policyResponse.backLinkTitle', {
|
||||
defaultMessage: 'Endpoint Details',
|
||||
}),
|
||||
href: '?' + detailsUri.search,
|
||||
onClick: ev => {
|
||||
ev.preventDefault();
|
||||
history.push(detailsUri);
|
||||
},
|
||||
};
|
||||
}, [history, hostMeta.host.id, queryParams]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FlyoutSubHeader
|
||||
backButton={backButtonProp}
|
||||
data-test-subj="hostDetailsPolicyResponseFlyoutHeader"
|
||||
>
|
||||
<EuiTitle size="xxs" data-test-subj="hostDetailsPolicyResponseFlyoutTitle">
|
||||
<h3>
|
||||
<FormattedMessage
|
||||
id="xpack.endpoint.host.policyResponse.title"
|
||||
defaultMessage="Policy Response"
|
||||
/>
|
||||
</h3>
|
||||
</EuiTitle>
|
||||
</FlyoutSubHeader>
|
||||
<EuiFlyoutBody data-test-subj="hostDetailsPolicyResponseFlyoutBody">
|
||||
<PolicyResponse />
|
||||
</EuiFlyoutBody>
|
||||
</>
|
||||
);
|
||||
});
|
|
@ -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.
|
||||
*/
|
||||
import React, { memo } from 'react';
|
||||
|
||||
export const PolicyResponse = memo(() => {
|
||||
return <div>Policy Status to be displayed here soon.</div>;
|
||||
});
|
|
@ -5,10 +5,28 @@
|
|||
*/
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useMemo } from 'react';
|
||||
import { GlobalState, HostListState } from '../../types';
|
||||
import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public';
|
||||
|
||||
export function useHostListSelector<TSelected>(selector: (state: HostListState) => TSelected) {
|
||||
return useSelector(function(state: GlobalState) {
|
||||
return selector(state.hostList);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an object that contains Kibana Logs app and URL information for a given host id
|
||||
* @param hostId
|
||||
*/
|
||||
export const useHostLogsUrl = (hostId: string): { url: string; appId: string; appPath: string } => {
|
||||
const { services } = useKibana();
|
||||
return useMemo(() => {
|
||||
const appPath = `/stream?logFilter=(expression:'host.id:${hostId}',kind:kuery)`;
|
||||
return {
|
||||
url: `${services.application.getUrlForApp('logs')}${appPath}`,
|
||||
appId: 'logs',
|
||||
appPath,
|
||||
};
|
||||
}, [hostId, services.application]);
|
||||
};
|
||||
|
|
|
@ -21,10 +21,11 @@ describe('when on the hosts page', () => {
|
|||
let history: AppContextTestRender['history'];
|
||||
let store: AppContextTestRender['store'];
|
||||
let coreStart: AppContextTestRender['coreStart'];
|
||||
let middlewareSpy: AppContextTestRender['middlewareSpy'];
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockedContext = createAppRootMockRenderer();
|
||||
({ history, store, coreStart } = mockedContext);
|
||||
({ history, store, coreStart, middlewareSpy } = mockedContext);
|
||||
render = () => mockedContext.render(<HostList />);
|
||||
});
|
||||
|
||||
|
@ -132,6 +133,25 @@ describe('when on the hosts page', () => {
|
|||
expect(flyout).not.toBeNull();
|
||||
});
|
||||
});
|
||||
it('should display policy status value as a link', async () => {
|
||||
const renderResult = render();
|
||||
const policyStatusLink = await renderResult.findByTestId('policyStatusValue');
|
||||
expect(policyStatusLink).not.toBeNull();
|
||||
expect(policyStatusLink.textContent).toEqual('Successful');
|
||||
expect(policyStatusLink.getAttribute('href')).toEqual(
|
||||
'?selected_host=1&show=policy_response'
|
||||
);
|
||||
});
|
||||
it('should update the URL when policy status link is clicked', async () => {
|
||||
const renderResult = render();
|
||||
const policyStatusLink = await renderResult.findByTestId('policyStatusValue');
|
||||
const userChangedUrlChecker = middlewareSpy.waitForAction('userChangedUrl');
|
||||
reactTestingLibrary.act(() => {
|
||||
fireEvent.click(policyStatusLink);
|
||||
});
|
||||
const changedUrlAction = await userChangedUrlChecker;
|
||||
expect(changedUrlAction.payload.search).toEqual('?selected_host=1&show=policy_response');
|
||||
});
|
||||
it('should include the link to logs', async () => {
|
||||
const renderResult = render();
|
||||
const linkToLogs = await renderResult.findByTestId('hostDetailsLinkToLogs');
|
||||
|
@ -154,5 +174,48 @@ describe('when on the hosts page', () => {
|
|||
expect(coreStart.application.navigateToApp.mock.calls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
describe('when showing host Policy Response', () => {
|
||||
let renderResult: ReturnType<typeof render>;
|
||||
beforeEach(async () => {
|
||||
renderResult = render();
|
||||
const policyStatusLink = await renderResult.findByTestId('policyStatusValue');
|
||||
const userChangedUrlChecker = middlewareSpy.waitForAction('userChangedUrl');
|
||||
reactTestingLibrary.act(() => {
|
||||
fireEvent.click(policyStatusLink);
|
||||
});
|
||||
await userChangedUrlChecker;
|
||||
});
|
||||
it('should hide the host details panel', async () => {
|
||||
const hostDetailsFlyout = await renderResult.queryByTestId('hostDetailsFlyoutBody');
|
||||
expect(hostDetailsFlyout).toBeNull();
|
||||
});
|
||||
it('should display policy response sub-panel', async () => {
|
||||
expect(
|
||||
await renderResult.findByTestId('hostDetailsPolicyResponseFlyoutHeader')
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
await renderResult.findByTestId('hostDetailsPolicyResponseFlyoutBody')
|
||||
).not.toBeNull();
|
||||
});
|
||||
it('should include the sub-panel title', async () => {
|
||||
expect(
|
||||
(await renderResult.findByTestId('hostDetailsPolicyResponseFlyoutTitle')).textContent
|
||||
).toBe('Policy Response');
|
||||
});
|
||||
it('should include the back to details link', async () => {
|
||||
const subHeaderBackLink = await renderResult.findByTestId('flyoutSubHeaderBackButton');
|
||||
expect(subHeaderBackLink.textContent).toBe('Endpoint Details');
|
||||
expect(subHeaderBackLink.getAttribute('href')).toBe('?selected_host=1');
|
||||
});
|
||||
it('should update URL when back to details link is clicked', async () => {
|
||||
const subHeaderBackLink = await renderResult.findByTestId('flyoutSubHeaderBackButton');
|
||||
const userChangedUrlChecker = middlewareSpy.waitForAction('userChangedUrl');
|
||||
reactTestingLibrary.act(() => {
|
||||
fireEvent.click(subHeaderBackLink);
|
||||
});
|
||||
const changedUrlAction = await userChangedUrlChecker;
|
||||
expect(changedUrlAction.payload.search).toEqual('?selected_host=1');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -167,7 +167,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
|
|||
'',
|
||||
'0',
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
'active',
|
||||
'Successful',
|
||||
'10.101.149.262606:a000:ffc0:39:11ef:37b9:3371:578c',
|
||||
'rezzani-7.example.com',
|
||||
'6.8.0',
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue