diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/components/__snapshots__/page_view.test.tsx.snap b/x-pack/plugins/endpoint/public/applications/endpoint/components/__snapshots__/page_view.test.tsx.snap index 34420e653049c..dfc69fc46ebdc 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/components/__snapshots__/page_view.test.tsx.snap +++ b/x-pack/plugins/endpoint/public/applications/endpoint/components/__snapshots__/page_view.test.tsx.snap @@ -1,32 +1,41 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`PageView component should display body header custom element 1`] = ` -.c0 { +.c0.endpoint--isListView { padding: 0; } -.c0 .endpoint-header { +.c0.endpoint--isListView .endpoint-header { padding: 24px; } -.c0 .endpoint-page-content { +.c0.endpoint--isListView .endpoint-page-content { border-left: none; border-right: none; } +.c0.endpoint--isDetailsView .endpoint-page-content { + padding: 0; + border: none; + background: none; +} + body header

} + viewType="list" > - +

body header @@ -82,28 +91,37 @@ exports[`PageView component should display body header custom element 1`] = ` `; exports[`PageView component should display body header wrapped in EuiTitle 1`] = ` -.c0 { +.c0.endpoint--isListView { padding: 0; } -.c0 .endpoint-header { +.c0.endpoint--isListView .endpoint-header { padding: 24px; } -.c0 .endpoint-page-content { +.c0.endpoint--isListView .endpoint-page-content { border-left: none; border-right: none; } +.c0.endpoint--isDetailsView .endpoint-page-content { + padding: 0; + border: none; + background: none; +} + - +

- -

- body header -

-
+ + +

+ body header +

+
+
@@ -163,29 +184,38 @@ exports[`PageView component should display body header wrapped in EuiTitle 1`] = `; exports[`PageView component should display header left and right 1`] = ` -.c0 { +.c0.endpoint--isListView { padding: 0; } -.c0 .endpoint-header { +.c0.endpoint--isListView .endpoint-header { padding: 24px; } -.c0 .endpoint-page-content { +.c0.endpoint--isListView .endpoint-page-content { border-left: none; border-right: none; } +.c0.endpoint--isDetailsView .endpoint-page-content { + padding: 0; + border: none; + background: none; +} + - +
- -

+ - page title -

-
+

+ page title +

+ +
- +.c0.endpoint--isDetailsView .endpoint-page-content { + padding: 0; + border: none; + background: none; +} + + +
- +
- -

+ - page title -

-
+

+ page title +

+ +
@@ -401,28 +456,37 @@ exports[`PageView component should display only header left 1`] = ` `; exports[`PageView component should display only header right but include an empty left side 1`] = ` -.c0 { +.c0.endpoint--isListView { padding: 0; } -.c0 .endpoint-header { +.c0.endpoint--isListView .endpoint-header { padding: 24px; } -.c0 .endpoint-page-content { +.c0.endpoint--isListView .endpoint-page-content { border-left: none; border-right: none; } +.c0.endpoint--isDetailsView .endpoint-page-content { + padding: 0; + border: none; + background: none; +} + - +
title here

} + viewType="list" > - +
{ mount(ui, { wrappingComponent: EuiThemeProvider }); it('should display only body if not header props used', () => { - expect(render(body content)).toMatchSnapshot(); + expect(render(body content)).toMatchSnapshot(); }); it('should display header left and right', () => { expect( render( - + body content ) ).toMatchSnapshot(); }); it('should display only header left', () => { - expect(render(body content)).toMatchSnapshot(); + expect( + render( + + body content + + ) + ).toMatchSnapshot(); }); it('should display only header right but include an empty left side', () => { expect( - render(body content) + render( + + body content + + ) ).toMatchSnapshot(); }); it(`should use custom element for header left and not wrap in EuiTitle`, () => { expect( - render(title here

}>body content
) + render( + title here

}> + body content +
+ ) ).toMatchSnapshot(); }); it('should display body header wrapped in EuiTitle', () => { - expect(render(body content)).toMatchSnapshot(); + expect( + render( + + body content + + ) + ).toMatchSnapshot(); }); it('should display body header custom element', () => { expect( - render(body header

}>body content
) + render( + body header

}> + body content +
+ ) ).toMatchSnapshot(); }); it('should pass through EuiPage props', () => { expect( render( props.theme.eui.euiSizeL}; + .endpoint-header { + padding: ${props => props.theme.eui.euiSizeL}; + } + .endpoint-page-content { + border-left: none; + border-right: none; + } } - .endpoint-page-content { - border-left: none; - border-right: none; + &.endpoint--isDetailsView { + .endpoint-page-content { + padding: 0; + border: none; + background: none; + } } `; const isStringOrNumber = /(string|number)/; +/** + * The `PageView` component used to render `headerLeft` when it is set as a `string` + * Can be used when wanting to customize the `headerLeft` value but still use the standard + * title component + */ +export const PageViewHeaderTitle = memo<{ children: ReactNode }>(({ children }) => { + return ( + +

{children}

+
+ ); +}); + +/** + * The `PageView` component used to render `bodyHeader` when it is set as a `string` + * Can be used when wanting to customize the `bodyHeader` value but still use the standard + * title component + */ +export const PageViewBodyHeaderTitle = memo<{ children: ReactNode }>( + ({ children, ...otherProps }) => { + return ( + +

{children}

+
+ ); + } +); + /** * Page View layout for use in Endpoint */ export const PageView = memo< EuiPageProps & { + /** + * The type of view + */ + viewType: 'list' | 'details'; /** * content to be placed on the left side of the header. If a `string` is used, then it will * be wrapped with `

`, else it will just be used as is. @@ -52,17 +93,18 @@ export const PageView = memo< bodyHeader?: ReactNode; children?: ReactNode; } ->(({ children, headerLeft, headerRight, bodyHeader, ...otherProps }) => { +>(({ viewType, children, headerLeft, headerRight, bodyHeader, ...otherProps }) => { return ( - + {(headerLeft || headerRight) && ( {isStringOrNumber.test(typeof headerLeft) ? ( - -

{headerLeft}

-
+ {headerLeft} ) : ( headerLeft )} @@ -77,11 +119,9 @@ export const PageView = memo< {bodyHeader && ( - + {isStringOrNumber.test(typeof bodyHeader) ? ( - -

{bodyHeader}

-
+ {bodyHeader} ) : ( bodyHeader )} diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/models/policy.ts b/x-pack/plugins/endpoint/public/applications/endpoint/models/policy.ts new file mode 100644 index 0000000000000..e1ac9defc858e --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/models/policy.ts @@ -0,0 +1,90 @@ +/* + * 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 { PolicyConfig } from '../types'; + +/** + * Generate a new Policy model. + * NOTE: in the near future, this will likely be removed and an API call to EPM will be used to retrieve + * the latest from the Endpoint package + */ +export const generatePolicy = (): PolicyConfig => { + return { + windows: { + events: { + process: true, + network: true, + }, + malware: { + mode: 'prevent', + }, + logging: { + stdout: 'debug', + file: 'info', + }, + advanced: { + elasticsearch: { + indices: { + control: 'control-index', + event: 'event-index', + logging: 'logging-index', + }, + kernel: { + connect: true, + process: true, + }, + }, + }, + }, + mac: { + events: { + process: true, + }, + malware: { + mode: 'detect', + }, + logging: { + stdout: 'debug', + file: 'info', + }, + advanced: { + elasticsearch: { + indices: { + control: 'control-index', + event: 'event-index', + logging: 'logging-index', + }, + kernel: { + connect: true, + process: true, + }, + }, + }, + }, + linux: { + events: { + process: true, + }, + logging: { + stdout: 'debug', + file: 'info', + }, + advanced: { + elasticsearch: { + indices: { + control: 'control-index', + event: 'event-index', + logging: 'logging-index', + }, + kernel: { + connect: true, + process: true, + }, + }, + }, + }, + }; +}; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/models/policy_details_config.ts b/x-pack/plugins/endpoint/public/applications/endpoint/models/policy_details_config.ts index 1900516cb539b..1145d1d19242a 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/models/policy_details_config.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/models/policy_details_config.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { PolicyConfig } from '../types'; +import { UIPolicyConfig } from '../types'; /** * A typed Object.entries() function where the keys and values are typed based on the given object @@ -14,10 +14,10 @@ const entries = (o: T): Array<[keyof T, T[keyof T]]> => type DeepPartial = { [K in keyof T]?: DeepPartial }; /** - * Returns a deep copy of PolicyDetailsConfig + * Returns a deep copy of `UIPolicyConfig` object */ -export function clone(policyDetailsConfig: PolicyConfig): PolicyConfig { - const clonedConfig: DeepPartial = {}; +export function clone(policyDetailsConfig: UIPolicyConfig): UIPolicyConfig { + const clonedConfig: DeepPartial = {}; for (const [key, val] of entries(policyDetailsConfig)) { if (typeof val === 'object') { const valClone: Partial = {}; @@ -41,5 +41,5 @@ export function clone(policyDetailsConfig: PolicyConfig): PolicyConfig { /** * clonedConfig is typed as DeepPartial so we can construct the copy from an empty object */ - return clonedConfig as PolicyConfig; + return clonedConfig as UIPolicyConfig; } diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/services/ingest.ts b/x-pack/plugins/endpoint/public/applications/endpoint/services/ingest.ts index fbb92f8bbe915..583ebc55d896b 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/services/ingest.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/services/ingest.ts @@ -5,11 +5,17 @@ */ import { HttpFetchOptions, HttpStart } from 'kibana/public'; -import { GetDatasourcesRequest } from '../../../../../ingest_manager/common/types/rest_spec'; -import { PolicyData } from '../types'; +import { + CreateDatasourceResponse, + GetAgentStatusResponse, + GetDatasourcesRequest, +} from '../../../../../ingest_manager/common/types/rest_spec'; +import { NewPolicyData, PolicyData } from '../types'; const INGEST_API_ROOT = `/api/ingest_manager`; const INGEST_API_DATASOURCES = `${INGEST_API_ROOT}/datasources`; +const INGEST_API_FLEET = `${INGEST_API_ROOT}/fleet`; +const INGEST_API_FLEET_AGENT_STATUS = `${INGEST_API_FLEET}/agent-status`; // FIXME: Import from ingest after - https://github.com/elastic/kibana/issues/60677 export interface GetDatasourcesResponse { @@ -26,6 +32,11 @@ export interface GetDatasourceResponse { success: boolean; } +// FIXME: Import from Ingest after - https://github.com/elastic/kibana/issues/60677 +export type UpdateDatasourceResponse = CreateDatasourceResponse & { + item: PolicyData; +}; + /** * Retrieves a list of endpoint specific datasources (those created with a `package.name` of * `endpoint`) from Ingest @@ -60,3 +71,44 @@ export const sendGetDatasource = ( ) => { return http.get(`${INGEST_API_DATASOURCES}/${datasourceId}`, options); }; + +/** + * Updates a datasources + * + * @param http + * @param datasourceId + * @param datasource + * @param options + */ +export const sendPutDatasource = ( + http: HttpStart, + datasourceId: string, + datasource: NewPolicyData, + options: Exclude = {} +): Promise => { + return http.put(`${INGEST_API_DATASOURCES}/${datasourceId}`, { + ...options, + body: JSON.stringify(datasource), + }); +}; + +/** + * Get a status summary for all Agents that are currently assigned to a given agent configuration + * + * @param http + * @param configId + * @param options + */ +export const sendGetFleetAgentStatusForConfig = ( + http: HttpStart, + /** the Agent (fleet) configuration id */ + configId: string, + options: Exclude = {} +): Promise => { + return http.get(INGEST_API_FLEET_AGENT_STATUS, { + ...options, + query: { + configId, + }, + }); +}; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_details/action.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_details/action.ts index e7e523a9287b8..9905145048a8a 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_details/action.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_details/action.ts @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { PolicyData, PolicyConfig } from '../../types'; +import { PolicyData, PolicyDetailsState, ServerApiError, UIPolicyConfig } from '../../types'; +import { GetAgentStatusResponse } from '../../../../../../ingest_manager/common/types/rest_spec'; interface ServerReturnedPolicyDetailsData { type: 'serverReturnedPolicyDetailsData'; @@ -13,14 +14,50 @@ interface ServerReturnedPolicyDetailsData { }; } +interface ServerFailedToReturnPolicyDetailsData { + type: 'serverFailedToReturnPolicyDetailsData'; + payload: ServerApiError; +} + /** * When users change a policy via forms, this action is dispatched with a payload that modifies the configuration of a cloned policy config. */ interface UserChangedPolicyConfig { type: 'userChangedPolicyConfig'; payload: { - policyConfig: PolicyConfig; + policyConfig: UIPolicyConfig; + }; +} + +interface ServerReturnedPolicyDetailsAgentSummaryData { + type: 'serverReturnedPolicyDetailsAgentSummaryData'; + payload: { + agentStatusSummary: GetAgentStatusResponse['results']; + }; +} + +interface ServerReturnedPolicyDetailsUpdateFailure { + type: 'serverReturnedPolicyDetailsUpdateFailure'; + payload: PolicyDetailsState['updateStatus']; +} + +interface ServerReturnedUpdatedPolicyDetailsData { + type: 'serverReturnedUpdatedPolicyDetailsData'; + payload: { + policyItem: PolicyData; + updateStatus: PolicyDetailsState['updateStatus']; }; } -export type PolicyDetailsAction = ServerReturnedPolicyDetailsData | UserChangedPolicyConfig; +interface UserClickedPolicyDetailsSaveButton { + type: 'userClickedPolicyDetailsSaveButton'; +} + +export type PolicyDetailsAction = + | ServerReturnedPolicyDetailsData + | UserClickedPolicyDetailsSaveButton + | ServerReturnedPolicyDetailsAgentSummaryData + | ServerReturnedPolicyDetailsUpdateFailure + | ServerReturnedUpdatedPolicyDetailsData + | ServerFailedToReturnPolicyDetailsData + | UserChangedPolicyConfig; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_details/index.test.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_details/index.test.ts index b20df84fdf575..cf14092953227 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_details/index.test.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_details/index.test.ts @@ -9,6 +9,7 @@ import { createStore, Dispatch, Store } from 'redux'; import { policyDetailsReducer, PolicyDetailsAction } from './index'; import { policyConfig, windowsEventing } from './selectors'; import { clone } from '../../models/policy_details_config'; +import { generatePolicy } from '../../models/policy'; describe('policy details: ', () => { let store: Store; @@ -30,7 +31,18 @@ describe('policy details: ', () => { config_id: '', enabled: true, output_id: '', - inputs: [], + inputs: [ + { + type: 'endpoint', + enabled: true, + streams: [], + config: { + policy: { + value: generatePolicy(), + }, + }, + }, + ], namespace: '', package: { name: '', @@ -39,32 +51,6 @@ describe('policy details: ', () => { }, revision: 1, }, - policyConfig: { - windows: { - malware: { - mode: 'detect', - }, - eventing: { - process: false, - network: false, - }, - }, - mac: { - malware: { - mode: '', - }, - eventing: { - process: false, - network: false, - }, - }, - linux: { - eventing: { - process: false, - network: false, - }, - }, - }, }, }); }); @@ -77,7 +63,7 @@ describe('policy details: ', () => { } const newPayload1 = clone(config); - newPayload1.windows.eventing.process = true; + newPayload1.windows.events.process = true; dispatch({ type: 'userChangedPolicyConfig', diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_details/middleware.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_details/middleware.ts index 1942538aa9df9..18248e272aada 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_details/middleware.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_details/middleware.ts @@ -4,9 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import { MiddlewareFactory, PolicyDetailsState } from '../../types'; -import { policyIdFromParams, isOnPolicyDetailsPage } from './selectors'; -import { sendGetDatasource } from '../../services/ingest'; +import { MiddlewareFactory, PolicyData, PolicyDetailsState } from '../../types'; +import { policyIdFromParams, isOnPolicyDetailsPage, policyDetails } from './selectors'; +import { + sendGetDatasource, + sendGetFleetAgentStatusForConfig, + sendPutDatasource, + UpdateDatasourceResponse, +} from '../../services/ingest'; +import { generatePolicy } from '../../models/policy'; export const policyDetailsMiddlewareFactory: MiddlewareFactory = coreStart => { const http = coreStart.http; @@ -17,25 +23,78 @@ export const policyDetailsMiddlewareFactory: MiddlewareFactory { return { policyItem: undefined, - policyConfig: undefined, isLoading: false, + agentStatusSummary: { + error: 0, + events: 0, + offline: 0, + online: 0, + total: 0, + }, }; }; @@ -20,7 +27,10 @@ export const policyDetailsReducer: Reducer = ( state = initialPolicyDetailsState(), action ) => { - if (action.type === 'serverReturnedPolicyDetailsData') { + if ( + action.type === 'serverReturnedPolicyDetailsData' || + action.type === 'serverReturnedUpdatedPolicyDetailsData' + ) { return { ...state, ...action.payload, @@ -28,19 +38,67 @@ export const policyDetailsReducer: Reducer = ( }; } - if (action.type === 'userChangedUrl') { + if (action.type === 'serverFailedToReturnPolicyDetailsData') { return { ...state, - location: action.payload, + isLoading: false, + apiError: action.payload, }; } - if (action.type === 'userChangedPolicyConfig') { + if (action.type === 'serverReturnedPolicyDetailsAgentSummaryData') { + return { + ...state, + ...action.payload, + }; + } + + if (action.type === 'serverReturnedPolicyDetailsUpdateFailure') { + return { + ...state, + isLoading: false, + updateStatus: action.payload, + }; + } + + if (action.type === 'userClickedPolicyDetailsSaveButton') { return { ...state, - policyConfig: action.payload.policyConfig, + isLoading: true, + updateApiError: undefined, }; } + if (action.type === 'userChangedUrl') { + const newState = { + ...state, + location: action.payload, + }; + + if (isOnPolicyDetailsPage(newState)) { + return newState; + } + return { + ...initialPolicyDetailsState(), + location: action.payload, + }; + } + + if (action.type === 'userChangedPolicyConfig') { + const newState = { ...state, policyItem: { ...(state.policyItem as PolicyData) } }; + const newPolicy = (newState.policyItem.inputs[0].config.policy.value = { + ...fullPolicy(state), + }); + + Object.entries(action.payload.policyConfig).forEach(([section, newSettings]) => { + newPolicy[section as keyof UIPolicyConfig] = { + ...newPolicy[section as keyof UIPolicyConfig], + ...newSettings, + }; + }); + + return newState; + } + return state; }; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_details/selectors.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_details/selectors.ts index 6a5d4077b3c32..0d505931c9ec5 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_details/selectors.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/policy_details/selectors.ts @@ -5,8 +5,8 @@ */ import { createSelector } from 'reselect'; -import { PolicyDetailsState } from '../../types'; -import { Immutable } from '../../../../../common/types'; +import { PolicyConfig, PolicyDetailsState, UIPolicyConfig } from '../../types'; +import { generatePolicy } from '../../models/policy'; /** Returns the policy details */ export const policyDetails = (state: PolicyDetailsState) => state.policyItem; @@ -32,20 +32,64 @@ export const policyIdFromParams: (state: PolicyDetailsState) => string = createS } ); +/** + * Returns the full Endpoint Policy, which will include private settings not shown on the UI. + * Note: this will return a default full policy if the `policyItem` is `undefined` + */ +export const fullPolicy: (s: PolicyDetailsState) => PolicyConfig = createSelector( + policyDetails, + policyData => { + return policyData?.inputs[0]?.config?.policy?.value ?? generatePolicy(); + } +); + +const fullWindowsPolicySettings: ( + s: PolicyDetailsState +) => PolicyConfig['windows'] = createSelector(fullPolicy, policy => policy?.windows); + +const fullMacPolicySettings: (s: PolicyDetailsState) => PolicyConfig['mac'] = createSelector( + fullPolicy, + policy => policy?.mac +); + +const fullLinuxPolicySettings: (s: PolicyDetailsState) => PolicyConfig['linux'] = createSelector( + fullPolicy, + policy => policy?.linux +); + /** Returns the policy configuration */ -export const policyConfig = (state: Immutable) => state.policyConfig; +export const policyConfig: (s: PolicyDetailsState) => UIPolicyConfig = createSelector( + fullWindowsPolicySettings, + fullMacPolicySettings, + fullLinuxPolicySettings, + (windows, mac, linux) => { + return { + windows: { + events: windows.events, + malware: windows.malware, + }, + mac: { + events: mac.events, + malware: mac.malware, + }, + linux: { + events: linux.events, + }, + }; + } +); /** Returns an object of all the windows eventing configuration */ export const windowsEventing = (state: PolicyDetailsState) => { const config = policyConfig(state); - return config && config.windows.eventing; + return config && config.windows.events; }; /** Returns the total number of possible windows eventing configurations */ export const totalWindowsEventing = (state: PolicyDetailsState): number => { const config = policyConfig(state); if (config) { - return Object.keys(config.windows.eventing).length; + return Object.keys(config.windows.events).length; } return 0; }; @@ -54,9 +98,21 @@ export const totalWindowsEventing = (state: PolicyDetailsState): number => { export const selectedWindowsEventing = (state: PolicyDetailsState): number => { const config = policyConfig(state); if (config) { - return Object.values(config.windows.eventing).reduce((count, event) => { + return Object.values(config.windows.events).reduce((count, event) => { return event === true ? count + 1 : count; }, 0); } return 0; }; + +/** is there an api call in flight */ +export const isLoading = (state: PolicyDetailsState) => state.isLoading; + +/** API error when fetching Policy data */ +export const apiError = (state: PolicyDetailsState) => state.apiError; + +/** Policy Agent Summary Stats */ +export const agentStatusSummary = (state: PolicyDetailsState) => state.agentStatusSummary; + +/** Status for an update to the policy */ +export const updateStatus = (state: PolicyDetailsState) => state.updateStatus; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/types.ts b/x-pack/plugins/endpoint/public/applications/endpoint/types.ts index 7947a35068234..4215edb4d6810 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/types.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/types.ts @@ -17,7 +17,8 @@ import { import { EndpointPluginStartDependencies } from '../../plugin'; import { AppAction } from './store/action'; import { CoreStart } from '../../../../../../src/core/public'; -import { Datasource } from '../../../../ingest_manager/common/types/models'; +import { Datasource, NewDatasource } from '../../../../ingest_manager/common/types/models'; +import { GetAgentStatusResponse } from '../../../../ingest_manager/common/types/rest_spec'; export { AppAction }; export type MiddlewareFactory = ( @@ -53,9 +54,27 @@ export interface ServerApiError { } /** - * An Endpoint Policy. + * New policy data. Used when updating the policy record via ingest APIs */ -export type PolicyData = Datasource; +export type NewPolicyData = NewDatasource & { + inputs: [ + { + type: 'endpoint'; + enabled: boolean; + streams: []; + config: { + policy: { + value: PolicyConfig; + }; + }; + } + ]; +}; + +/** + * Endpoint Policy data, which extends Ingest's `Datasource` type + */ +export type PolicyData = Datasource & NewPolicyData; /** * Policy list store state @@ -81,57 +100,100 @@ export interface PolicyListState { export interface PolicyDetailsState { /** A single policy item */ policyItem?: PolicyData; - /** data is being retrieved from server */ - policyConfig?: PolicyConfig; + /** API error if loading data failed */ + apiError?: ServerApiError; isLoading: boolean; /** current location of the application */ location?: Immutable; + /** A summary of stats for the agents associated with a given Fleet Agent Configuration */ + agentStatusSummary: GetAgentStatusResponse['results']; + /** Status of an update to the policy */ + updateStatus?: { + success: boolean; + error?: ServerApiError; + }; } /** - * Policy Details configuration + * Endpoint Policy configuration */ export interface PolicyConfig { - windows: WindowsPolicyConfig; - mac: MacPolicyConfig; - linux: LinuxPolicyConfig; + windows: { + events: { + process: boolean; + network: boolean; + }; + /** malware mode can be detect, prevent or prevent and notify user */ + malware: { + mode: string; + }; + logging: { + stdout: string; + file: string; + }; + advanced: PolicyConfigAdvancedOptions; + }; + mac: { + events: { + process: boolean; + }; + malware: { + mode: string; + }; + logging: { + stdout: string; + file: string; + }; + advanced: PolicyConfigAdvancedOptions; + }; + linux: { + events: { + process: boolean; + }; + logging: { + stdout: string; + file: string; + }; + advanced: PolicyConfigAdvancedOptions; + }; } -/** - * Windows-specific policy configuration - */ -interface WindowsPolicyConfig { - /** malware mode can be detect, prevent or prevent and notify user */ - malware: { - mode: string; - }; - eventing: { - process: boolean; - network: boolean; +interface PolicyConfigAdvancedOptions { + elasticsearch: { + indices: { + control: string; + event: string; + logging: string; + }; + kernel: { + connect: boolean; + process: boolean; + }; }; } /** - * Mac-specific policy configuration + * Windows-specific policy configuration that is supported via the UI */ -interface MacPolicyConfig { - /** malware mode can be detect, prevent or prevent and notify user */ - malware: { - mode: string; - }; - eventing: { - process: boolean; - network: boolean; - }; -} +type WindowsPolicyConfig = Pick; + /** - * Linux-specific policy configuration + * Mac-specific policy configuration that is supported via the UI */ -interface LinuxPolicyConfig { - eventing: { - process: boolean; - network: boolean; - }; +type MacPolicyConfig = Pick; + +/** + * Linux-specific policy configuration that is supported via the UI + */ +type LinuxPolicyConfig = Pick; + +/** + * The set of Policy configuration settings that are show/edited via the UI + */ +export interface UIPolicyConfig { + windows: WindowsPolicyConfig; + mac: MacPolicyConfig; + linux: LinuxPolicyConfig; } /** OS used in Policy */ diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/agents_summary.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/agents_summary.tsx new file mode 100644 index 0000000000000..d0751cf9fb886 --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/agents_summary.tsx @@ -0,0 +1,88 @@ +/* + * 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, useMemo } from 'react'; +import { + EuiDescriptionList, + EuiFlexGroup, + EuiFlexItem, + EuiHealth, + EuiI18nNumber, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +export interface AgentsSummaryProps { + total: number; + online: number; + offline: number; + error: number; +} + +/** + * Display a summary of stats (counts) associated with a group of agents (ex. those associated with a Policy) + */ +export const AgentsSummary = memo(props => { + const stats = useMemo< + Array<{ key: keyof AgentsSummaryProps; title: string; health: string }> + >(() => { + return [ + { + key: 'total', + title: i18n.translate('xpack.endpoint.policyDetails.agentsSummary.totalTitle', { + defaultMessage: 'Hosts', + }), + health: '', + }, + { + key: 'online', + title: i18n.translate('xpack.endpoint.policyDetails.agentsSummary.onlineTitle', { + defaultMessage: 'Online', + }), + health: 'success', + }, + { + key: 'offline', + title: i18n.translate('xpack.endpoint.policyDetails.agentsSummary.offlineTitle', { + defaultMessage: 'Offline', + }), + health: 'warning', + }, + { + key: 'error', + title: i18n.translate('xpack.endpoint.policyDetails.agentsSummary.errorTitle', { + defaultMessage: 'Error', + }), + health: 'danger', + }, + ]; + }, []); + + return ( + + {stats.map(({ key, title, health }) => { + return ( + + + {health && } + + + ), + }, + ]} + /> + + ); + })} + + ); +}); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_details.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_details.tsx index a64b3293ec6cd..f2c79155f3c23 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_details.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_details.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { EuiFlexGroup, EuiFlexItem, @@ -12,32 +12,154 @@ import { EuiButtonEmpty, EuiText, EuiSpacer, + EuiOverlayMask, + EuiConfirmModal, + EuiCallOut, + EuiLoadingSpinner, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; +import { useDispatch } from 'react-redux'; +import { useHistory } from 'react-router-dom'; import { usePolicyDetailsSelector } from './policy_hooks'; -import { policyDetails } from '../../store/policy_details/selectors'; +import { + policyDetails, + agentStatusSummary, + updateStatus, + isLoading, + apiError, +} from '../../store/policy_details/selectors'; import { WindowsEventing } from './policy_forms/eventing/windows'; -import { PageView } from '../../components/page_view'; +import { PageView, PageViewHeaderTitle } from '../../components/page_view'; +import { AppAction } from '../../types'; +import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; +import { AgentsSummary } from './agents_summary'; +import { VerticalDivider } from './vertical_divider'; export const PolicyDetails = React.memo(() => { + const dispatch = useDispatch<(action: AppAction) => void>(); + const { notifications, services } = useKibana(); + const history = useHistory(); + + // Store values const policyItem = usePolicyDetailsSelector(policyDetails); + const policyAgentStatusSummary = usePolicyDetailsSelector(agentStatusSummary); + const policyUpdateStatus = usePolicyDetailsSelector(updateStatus); + const isPolicyLoading = usePolicyDetailsSelector(isLoading); + const policyApiError = usePolicyDetailsSelector(apiError); + + // Local state + const [showConfirm, setShowConfirm] = useState(false); + const policyName = policyItem?.name ?? ''; - const headerLeftContent = - policyItem?.name ?? - i18n.translate('xpack.endpoint.policyDetails.notFound', { - defaultMessage: 'Policy Not Found', + // Handle showing udpate statuses + useEffect(() => { + if (policyUpdateStatus) { + if (policyUpdateStatus.success) { + notifications.toasts.success({ + toastLifeTimeMs: 10000, + title: i18n.translate('xpack.endpoint.policy.details.updateSuccessTitle', { + defaultMessage: 'Success!', + }), + body: ( + + ), + }); + } else { + notifications.toasts.danger({ + toastLifeTimeMs: 10000, + title: i18n.translate('xpack.endpoint.policy.details.updateErrorTitle', { + defaultMessage: 'Failed!', + }), + body: <>{policyUpdateStatus.error!.message}, + }); + } + } + }, [notifications.toasts, policyItem, policyName, policyUpdateStatus]); + + const handleBackToListOnClick = useCallback( + ev => { + ev.preventDefault(); + history.push(`/policy`); + }, + [history] + ); + + const handleSaveOnClick = useCallback(() => { + setShowConfirm(true); + }, []); + + const handleSaveConfirmation = useCallback(() => { + dispatch({ + type: 'userClickedPolicyDetailsSaveButton', }); + setShowConfirm(false); + }, [dispatch]); + + const handleSaveCancel = useCallback(() => { + setShowConfirm(false); + }, []); + + // Before proceeding - check if we have a policy data. + // If not, and we are still loading, show spinner. + // Else, if we have an error, then show error on the page. + if (!policyItem) { + return ( + + {isPolicyLoading ? ( + + ) : policyApiError ? ( + + {policyApiError?.message} + + ) : null} + + ); + } + + const headerLeftContent = ( +
+ {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} + + + + {policyItem.name} +
+ ); const headerRightContent = ( - + + + + + + + - + @@ -45,18 +167,85 @@ export const PolicyDetails = React.memo(() => { ); return ( - - -

- -

-
- - -
+ <> + {showConfirm && ( + + )} + + +

+ +

+
+ + +
+ + ); +}); + +const ConfirmUpdate = React.memo<{ + hostCount: number; + onConfirm: () => void; + onCancel: () => void; +}>(({ hostCount, onCancel, onConfirm }) => { + return ( + + + {hostCount > 0 && ( + <> + + + + + + )} +

+ +

+
+
); }); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_forms/eventing/checkbox.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_forms/eventing/checkbox.tsx index add137ea57a5e..8b7fb89ed1646 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_forms/eventing/checkbox.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_forms/eventing/checkbox.tsx @@ -27,7 +27,11 @@ export const EventingCheckbox: React.FC<{ (event: React.ChangeEvent) => { if (policyDetailsConfig) { const newPayload = clone(policyDetailsConfig); - newPayload[os].eventing[protectionField] = event.target.checked; + if (os === OS.linux || os === OS.mac) { + newPayload[os].events.process = event.target.checked; + } else { + newPayload[os].events[protectionField] = event.target.checked; + } dispatch({ type: 'userChangedPolicyConfig', diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_list.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_list.tsx index 7af302de8576e..5ee1539ce9788 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_list.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/policy/policy_list.tsx @@ -151,6 +151,7 @@ export const PolicyList = React.memo(() => { return ( ` + width: 0; + height: 100%; + border-left: ${props => { + return props.theme.eui.euiBorderThin; + }}; + margin-left: ${props => props.theme.eui.paddingSizes[props?.spacing ?? 'none'] || 0}; + margin-right: ${props => props.theme.eui.paddingSizes[props?.spacing ?? 'none'] || 0}; +`;