@@ -96,12 +111,21 @@ const PermissionPage = ({ onEscapeHatchClick }: Props) => {
}
body={
-
- {i18n.translate('xpack.apm.permission.description', {
- defaultMessage:
- "We've detected your current role in Kibana does not grant you access to the APM data. Please check with your Kibana administrator to get the proper privileges granted in order to start using APM."
- })}
-
+ <>
+
+ {i18n.translate('xpack.apm.permission.description', {
+ defaultMessage:
+ "Your user doesn't have access to all APM indices. You can still use the APM app but some data may be missing. You must be granted access to the following indices:"
+ })}
+
+
+ {indicesWithoutPermission.map(index => (
+
+ {index}
+
+ ))}
+
+ >
}
actions={
<>
@@ -117,7 +141,6 @@ const PermissionPage = ({ onEscapeHatchClick }: Props) => {
)}
-
{
style={{ fontSize }}
>
{i18n.translate('xpack.apm.permission.dismissWarning', {
- defaultMessage: 'Dismiss warning'
+ defaultMessage: 'Dismiss'
})}
diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/index.tsx
index 6dcab6c6b97c1..69557241c42aa 100644
--- a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/index.tsx
+++ b/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/index.tsx
@@ -16,10 +16,8 @@ import {
import { i18n } from '@kbn/i18n';
import { Location } from 'history';
import React, { useEffect, useState } from 'react';
-import styled from 'styled-components';
import { IBucket } from '../../../../../server/lib/transactions/distribution/get_buckets/transform';
import { IUrlParams } from '../../../../context/UrlParamsContext/types';
-import { px, units } from '../../../../style/variables';
import { history } from '../../../../utils/history';
import { fromQuery, toQuery } from '../../../shared/Links/url_helpers';
import { LoadingStatePrompt } from '../../../shared/LoadingStatePrompt';
@@ -29,20 +27,6 @@ import { MaybeViewTraceLink } from './MaybeViewTraceLink';
import { TransactionTabs } from './TransactionTabs';
import { IWaterfall } from './WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers';
-const PaginationContainer = styled.div`
- margin-left: ${px(units.quarter)};
- display: flex;
- align-items: center;
-
- > span:first-of-type {
- font-weight: 600;
- }
-
- > span:last-of-type {
- margin-right: ${px(units.half)};
- }
-`;
-
interface Props {
urlParams: IUrlParams;
location: Location;
@@ -102,7 +86,7 @@ export const WaterfallWithSummmary: React.FC = ({
return (
-
+
{i18n.translate('xpack.apm.transactionDetails.traceSampleTitle', {
@@ -111,16 +95,12 @@ export const WaterfallWithSummmary: React.FC = ({
{traceSamples && (
-
- {sampleActivePage + 1}
- /{traceSamples.length}
-
-
+
)}
diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/ElasticDocsLink.tsx b/x-pack/legacy/plugins/apm/public/components/shared/Links/ElasticDocsLink.tsx
index d97a2331e457b..7645162ab2655 100644
--- a/x-pack/legacy/plugins/apm/public/components/shared/Links/ElasticDocsLink.tsx
+++ b/x-pack/legacy/plugins/apm/public/components/shared/Links/ElasticDocsLink.tsx
@@ -19,9 +19,11 @@ interface Props extends EuiLinkAnchorProps {
export function ElasticDocsLink({ section, path, children, ...rest }: Props) {
const { version } = useApmPluginContext().packageInfo;
const href = `https://www.elastic.co/guide/en${section}/${version}${path}`;
- return (
+ return typeof children === 'function' ? (
+ children(href)
+ ) : (
- {typeof children === 'function' ? children(href) : children}
+ children
);
}
diff --git a/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx b/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx
index 64deff9f1ae39..0054f963ba8f2 100644
--- a/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx
+++ b/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx
@@ -38,7 +38,7 @@ import { setHelpExtension } from './setHelpExtension';
import { toggleAppLinkInNav } from './toggleAppLinkInNav';
import { setReadonlyBadge } from './updateBadge';
import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public';
-import { Permission } from '../components/app/Permission';
+import { APMIndicesPermission } from '../components/app/APMIndicesPermission';
export const REACT_APP_ROOT_ID = 'react-apm-root';
@@ -53,14 +53,13 @@ const App = () => {
- {/* Check if user has the appropriate permissions to use the APM UI. */}
-
+
{routes.map((route, i) => (
))}
-
+
);
};
diff --git a/x-pack/legacy/plugins/apm/server/lib/helpers/es_client.ts b/x-pack/legacy/plugins/apm/server/lib/helpers/es_client.ts
index 2795c95a50034..06cf0047b0286 100644
--- a/x-pack/legacy/plugins/apm/server/lib/helpers/es_client.ts
+++ b/x-pack/legacy/plugins/apm/server/lib/helpers/es_client.ts
@@ -6,26 +6,38 @@
/* eslint-disable no-console */
import {
- SearchParams,
IndexDocumentParams,
+ IndicesCreateParams,
IndicesDeleteParams,
- IndicesCreateParams
+ SearchParams
} from 'elasticsearch';
-import { merge, uniqueId } from 'lodash';
-import { cloneDeep, isString } from 'lodash';
+import { cloneDeep, isString, merge, uniqueId } from 'lodash';
import { KibanaRequest } from 'src/core/server';
-import { OBSERVER_VERSION_MAJOR } from '../../../common/elasticsearch_fieldnames';
import {
- ESSearchResponse,
- ESSearchRequest
+ ESSearchRequest,
+ ESSearchResponse
} from '../../../../../../plugins/apm/typings/elasticsearch';
-import { APMRequestHandlerContext } from '../../routes/typings';
+import { OBSERVER_VERSION_MAJOR } from '../../../common/elasticsearch_fieldnames';
import { pickKeys } from '../../../public/utils/pickKeys';
+import { APMRequestHandlerContext } from '../../routes/typings';
import { getApmIndices } from '../settings/apm_indices/get_apm_indices';
// `type` was deprecated in 7.0
export type APMIndexDocumentParams = Omit, 'type'>;
+interface IndexPrivileges {
+ has_all_requested: boolean;
+ username: string;
+ index: Record;
+}
+
+interface IndexPrivilegesParams {
+ index: Array<{
+ names: string[] | string;
+ privileges: string[];
+ }>;
+}
+
export function isApmIndex(
apmIndices: string[],
indexParam: SearchParams['index']
@@ -181,6 +193,17 @@ export function getESClient(
},
indicesCreate: (params: IndicesCreateParams) => {
return withTime(() => callMethod('indices.create', params));
+ },
+ hasPrivileges: (
+ params: IndexPrivilegesParams
+ ): Promise => {
+ return withTime(() =>
+ callMethod('transport.request', {
+ method: 'POST',
+ path: '/_security/user/_has_privileges',
+ body: params
+ })
+ );
}
};
}
diff --git a/x-pack/legacy/plugins/apm/server/lib/security/getPermissions.ts b/x-pack/legacy/plugins/apm/server/lib/security/getPermissions.ts
deleted file mode 100644
index ed2a1f64e7f84..0000000000000
--- a/x-pack/legacy/plugins/apm/server/lib/security/getPermissions.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License;
- * you may not use this file except in compliance with the Elastic License.
- */
-import { Setup } from '../helpers/setup_request';
-
-export async function getPermissions(setup: Setup) {
- const { client, indices } = setup;
-
- const params = {
- index: Object.values(indices),
- body: {
- size: 0,
- query: {
- match_all: {}
- }
- }
- };
-
- try {
- await client.search(params);
- return { hasPermission: true };
- } catch (e) {
- // If 403, it means the user doesnt have permission.
- if (e.status === 403) {
- return { hasPermission: false };
- }
- // if any other error happens, throw it.
- throw e;
- }
-}
diff --git a/x-pack/legacy/plugins/apm/server/lib/security/get_indices_privileges.ts b/x-pack/legacy/plugins/apm/server/lib/security/get_indices_privileges.ts
new file mode 100644
index 0000000000000..1a80a13b2ad19
--- /dev/null
+++ b/x-pack/legacy/plugins/apm/server/lib/security/get_indices_privileges.ts
@@ -0,0 +1,24 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import { Setup } from '../helpers/setup_request';
+
+export async function getIndicesPrivileges(setup: Setup) {
+ const { client, indices } = setup;
+ const response = await client.hasPrivileges({
+ index: [
+ {
+ names: [
+ indices['apm_oss.errorIndices'],
+ indices['apm_oss.metricsIndices'],
+ indices['apm_oss.transactionIndices'],
+ indices['apm_oss.spanIndices']
+ ],
+ privileges: ['read']
+ }
+ ]
+ });
+ return response.index;
+}
diff --git a/x-pack/legacy/plugins/apm/server/routes/create_apm_api.ts b/x-pack/legacy/plugins/apm/server/routes/create_apm_api.ts
index 3ac9629f59edf..f65e271389938 100644
--- a/x-pack/legacy/plugins/apm/server/routes/create_apm_api.ts
+++ b/x-pack/legacy/plugins/apm/server/routes/create_apm_api.ts
@@ -59,7 +59,7 @@ import {
} from './ui_filters';
import { createApi } from './create_api';
import { serviceMapRoute, serviceMapServiceNodeRoute } from './service_map';
-import { permissionsRoute } from './security';
+import { indicesPrivilegesRoute } from './security';
const createApmApi = () => {
const api = createApi()
@@ -128,7 +128,7 @@ const createApmApi = () => {
.add(serviceMapServiceNodeRoute)
// security
- .add(permissionsRoute);
+ .add(indicesPrivilegesRoute);
return api;
};
diff --git a/x-pack/legacy/plugins/apm/server/routes/security.ts b/x-pack/legacy/plugins/apm/server/routes/security.ts
index 5f803fb438e31..0a8222b665d83 100644
--- a/x-pack/legacy/plugins/apm/server/routes/security.ts
+++ b/x-pack/legacy/plugins/apm/server/routes/security.ts
@@ -6,12 +6,12 @@
import { createRoute } from './create_route';
import { setupRequest } from '../lib/helpers/setup_request';
-import { getPermissions } from '../lib/security/getPermissions';
+import { getIndicesPrivileges } from '../lib/security/get_indices_privileges';
-export const permissionsRoute = createRoute(() => ({
- path: '/api/apm/security/permissions',
+export const indicesPrivilegesRoute = createRoute(() => ({
+ path: '/api/apm/security/indices_privileges',
handler: async ({ context, request }) => {
const setup = await setupRequest(context, request);
- return getPermissions(setup);
+ return getIndicesPrivileges(setup);
}
}));
diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.ts
index 737db985f99d0..6ac0d84bc9a73 100644
--- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.ts
+++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.ts
@@ -46,6 +46,7 @@ export function savedVisualization(): ExpressionFunctionDefinition<
type: EmbeddableExpressionType,
input: {
id,
+ disableTriggers: true,
...buildEmbeddableFilters(filters),
},
embeddableType: EmbeddableTypes.visualization,
diff --git a/x-pack/legacy/plugins/canvas/scripts/test_browser.js b/x-pack/legacy/plugins/canvas/scripts/test_browser.js
index 971a04d9d97c2..e04fac0615284 100644
--- a/x-pack/legacy/plugins/canvas/scripts/test_browser.js
+++ b/x-pack/legacy/plugins/canvas/scripts/test_browser.js
@@ -4,4 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
-require('./_helpers').runGulpTask('canvas:test:browser');
+require('./_helpers').runGulpTask('canvas:test:karma');
diff --git a/x-pack/legacy/plugins/canvas/scripts/test_dev.js b/x-pack/legacy/plugins/canvas/scripts/test_dev.js
index 656168b23af4b..8b03d7930d473 100644
--- a/x-pack/legacy/plugins/canvas/scripts/test_dev.js
+++ b/x-pack/legacy/plugins/canvas/scripts/test_dev.js
@@ -4,4 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
-require('./_helpers').runGulpTask('canvas:test:dev');
+require('./_helpers').runGulpTask('canvas:karma:debug');
diff --git a/x-pack/legacy/plugins/canvas/scripts/test_server.js b/x-pack/legacy/plugins/canvas/scripts/test_server.js
index a05994c4d8606..4eb72b566f988 100644
--- a/x-pack/legacy/plugins/canvas/scripts/test_server.js
+++ b/x-pack/legacy/plugins/canvas/scripts/test_server.js
@@ -4,4 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
-require('./_helpers').runGulpTask('canvas:test:server');
+require('./_helpers').runGulpTask('canvas:test:mocha');
diff --git a/x-pack/legacy/plugins/ingest_manager/index.ts b/x-pack/legacy/plugins/ingest_manager/index.ts
new file mode 100644
index 0000000000000..c20cc7225d780
--- /dev/null
+++ b/x-pack/legacy/plugins/ingest_manager/index.ts
@@ -0,0 +1,39 @@
+/*
+ * 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 {
+ savedObjectMappings,
+ OUTPUT_SAVED_OBJECT_TYPE,
+ AGENT_CONFIG_SAVED_OBJECT_TYPE,
+ DATASOURCE_SAVED_OBJECT_TYPE,
+} from '../../../plugins/ingest_manager/server';
+
+// TODO https://github.com/elastic/kibana/issues/46373
+// const INDEX_NAMES = {
+// INGEST: '.kibana',
+// };
+
+export function ingestManager(kibana: any) {
+ return new kibana.Plugin({
+ id: 'ingestManager',
+ uiExports: {
+ savedObjectSchemas: {
+ [AGENT_CONFIG_SAVED_OBJECT_TYPE]: {
+ isNamespaceAgnostic: true,
+ // indexPattern: INDEX_NAMES.INGEST,
+ },
+ [OUTPUT_SAVED_OBJECT_TYPE]: {
+ isNamespaceAgnostic: true,
+ // indexPattern: INDEX_NAMES.INGEST,
+ },
+ [DATASOURCE_SAVED_OBJECT_TYPE]: {
+ isNamespaceAgnostic: true,
+ // indexPattern: INDEX_NAMES.INGEST,
+ },
+ },
+ mappings: savedObjectMappings,
+ },
+ });
+}
diff --git a/x-pack/legacy/plugins/ml/server/lib/check_privileges/check_privileges.ts b/x-pack/legacy/plugins/ml/server/lib/check_privileges/check_privileges.ts
index 6b426169799a7..617778afbe121 100644
--- a/x-pack/legacy/plugins/ml/server/lib/check_privileges/check_privileges.ts
+++ b/x-pack/legacy/plugins/ml/server/lib/check_privileges/check_privileges.ts
@@ -4,9 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
+import { IScopedClusterClient } from 'kibana/server';
import { Privileges, getDefaultPrivileges } from '../../../common/types/privileges';
import { XPackMainPlugin } from '../../../../xpack_main/server/xpack_main';
-import { callWithRequestType } from '../../../common/types/kibana';
import { isSecurityDisabled } from '../../lib/security_utils';
import { upgradeCheckProvider } from './upgrade';
import { checkLicense } from '../check_license';
@@ -24,12 +24,12 @@ interface Response {
}
export function privilegesProvider(
- callWithRequest: callWithRequestType,
+ callAsCurrentUser: IScopedClusterClient['callAsCurrentUser'],
xpackMainPlugin: XPackMainPlugin,
isMlEnabledInSpace: () => Promise,
ignoreSpaces: boolean = false
) {
- const { isUpgradeInProgress } = upgradeCheckProvider(callWithRequest);
+ const { isUpgradeInProgress } = upgradeCheckProvider(callAsCurrentUser);
async function getPrivileges(): Promise {
// get the default privileges, forced to be false.
const privileges = getDefaultPrivileges();
@@ -74,7 +74,7 @@ export function privilegesProvider(
} else {
// security enabled
// load all ml privileges for this user.
- const { cluster } = await callWithRequest('ml.privilegeCheck', { body: mlPrivileges });
+ const { cluster } = await callAsCurrentUser('ml.privilegeCheck', { body: mlPrivileges });
setGettingPrivileges(cluster, privileges);
if (upgradeInProgress === false) {
// if an upgrade is in progress, don't apply the "setting"
diff --git a/x-pack/legacy/plugins/ml/server/lib/check_privileges/upgrade.ts b/x-pack/legacy/plugins/ml/server/lib/check_privileges/upgrade.ts
index 9e62780c51b3e..a1d66f00f26e1 100644
--- a/x-pack/legacy/plugins/ml/server/lib/check_privileges/upgrade.ts
+++ b/x-pack/legacy/plugins/ml/server/lib/check_privileges/upgrade.ts
@@ -4,14 +4,14 @@
* you may not use this file except in compliance with the Elastic License.
*/
+import { IScopedClusterClient } from 'kibana/server';
import { mlLog } from '../../client/log';
-import { callWithRequestType } from '../../../common/types/kibana';
-export function upgradeCheckProvider(callWithRequest: callWithRequestType) {
+export function upgradeCheckProvider(callAsCurrentUser: IScopedClusterClient['callAsCurrentUser']) {
async function isUpgradeInProgress(): Promise {
let upgradeInProgress = false;
try {
- const info = await callWithRequest('ml.info');
+ const info = await callAsCurrentUser('ml.info');
// if ml indices are currently being migrated, upgrade_mode will be set to true
// pass this back with the privileges to allow for the disabling of UI controls.
upgradeInProgress = info.upgrade_mode === true;
diff --git a/x-pack/legacy/plugins/ml/server/routes/apidoc.json b/x-pack/legacy/plugins/ml/server/routes/apidoc.json
index 35215f8008ec3..d975ac6472535 100644
--- a/x-pack/legacy/plugins/ml/server/routes/apidoc.json
+++ b/x-pack/legacy/plugins/ml/server/routes/apidoc.json
@@ -78,6 +78,12 @@
"DeleteFilter",
"GetFiltersStats",
"Indices",
- "FieldCaps"
+ "FieldCaps",
+ "SystemRoutes",
+ "HasPrivileges",
+ "MlCapabilities",
+ "MlNodeCount",
+ "MlInfo",
+ "MlEsSearch"
]
}
diff --git a/x-pack/legacy/plugins/ml/server/routes/system.js b/x-pack/legacy/plugins/ml/server/routes/system.js
deleted file mode 100644
index fd4f3f9b61917..0000000000000
--- a/x-pack/legacy/plugins/ml/server/routes/system.js
+++ /dev/null
@@ -1,205 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License;
- * you may not use this file except in compliance with the Elastic License.
- */
-
-import { callWithRequestFactory } from '../client/call_with_request_factory';
-import { callWithInternalUserFactory } from '../client/call_with_internal_user_factory';
-import { privilegesProvider } from '../lib/check_privileges';
-import { spacesUtilsProvider } from '../lib/spaces_utils';
-
-import { mlLog } from '../client/log';
-
-import { wrapError } from '../client/errors';
-import Boom from 'boom';
-
-import { isSecurityDisabled } from '../lib/security_utils';
-
-export function systemRoutes({
- commonRouteConfig,
- elasticsearchPlugin,
- route,
- xpackMainPlugin,
- spacesPlugin,
- cloud,
-}) {
- const callWithInternalUser = callWithInternalUserFactory(elasticsearchPlugin);
-
- function getNodeCount() {
- const filterPath = 'nodes.*.attributes';
- return callWithInternalUser('nodes.info', { filterPath }).then(resp => {
- let count = 0;
- if (typeof resp.nodes === 'object') {
- Object.keys(resp.nodes).forEach(k => {
- if (resp.nodes[k].attributes !== undefined) {
- const maxOpenJobs = resp.nodes[k].attributes['ml.max_open_jobs'];
- if (maxOpenJobs !== null && maxOpenJobs > 0) {
- count++;
- }
- }
- });
- }
- return { count };
- });
- }
-
- route({
- method: 'POST',
- path: '/api/ml/_has_privileges',
- async handler(request) {
- const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request);
- try {
- let upgradeInProgress = false;
- try {
- const info = await callWithRequest('ml.info');
- // if ml indices are currently being migrated, upgrade_mode will be set to true
- // pass this back with the privileges to allow for the disabling of UI controls.
- upgradeInProgress = info.upgrade_mode === true;
- } catch (error) {
- // if the ml.info check fails, it could be due to the user having insufficient privileges
- // most likely they do not have the ml_user role and therefore will be blocked from using
- // ML at all. However, we need to catch this error so the privilege check doesn't fail.
- if (error.status === 403) {
- mlLog.info(
- 'Unable to determine whether upgrade is being performed due to insufficient user privileges'
- );
- } else {
- mlLog.warn('Unable to determine whether upgrade is being performed');
- }
- }
-
- if (isSecurityDisabled(xpackMainPlugin)) {
- // if xpack.security.enabled has been explicitly set to false
- // return that security is disabled and don't call the privilegeCheck endpoint
- return {
- securityDisabled: true,
- upgradeInProgress,
- };
- } else {
- const body = request.payload;
- const resp = await callWithRequest('ml.privilegeCheck', { body });
- resp.upgradeInProgress = upgradeInProgress;
- return resp;
- }
- } catch (error) {
- return wrapError(error);
- }
- },
- config: {
- ...commonRouteConfig,
- },
- });
-
- route({
- method: 'GET',
- path: '/api/ml/ml_capabilities',
- async handler(request) {
- const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request);
- try {
- const ignoreSpaces = request.query && request.query.ignoreSpaces === 'true';
- // if spaces is disabled force isMlEnabledInSpace to be true
- const { isMlEnabledInSpace } =
- spacesPlugin !== undefined
- ? spacesUtilsProvider(spacesPlugin, request)
- : { isMlEnabledInSpace: async () => true };
-
- const { getPrivileges } = privilegesProvider(
- callWithRequest,
- xpackMainPlugin,
- isMlEnabledInSpace,
- ignoreSpaces
- );
- return await getPrivileges();
- } catch (error) {
- return wrapError(error);
- }
- },
- config: {
- ...commonRouteConfig,
- },
- });
-
- route({
- method: 'GET',
- path: '/api/ml/ml_node_count',
- handler(request) {
- const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request);
- return new Promise((resolve, reject) => {
- // check for basic license first for consistency with other
- // security disabled checks
- if (isSecurityDisabled(xpackMainPlugin)) {
- getNodeCount()
- .then(resolve)
- .catch(reject);
- } else {
- // if security is enabled, check that the user has permission to
- // view jobs before calling getNodeCount.
- // getNodeCount calls the _nodes endpoint as the internal user
- // and so could give the user access to more information than
- // they are entitled to.
- const body = {
- cluster: [
- 'cluster:monitor/xpack/ml/job/get',
- 'cluster:monitor/xpack/ml/job/stats/get',
- 'cluster:monitor/xpack/ml/datafeeds/get',
- 'cluster:monitor/xpack/ml/datafeeds/stats/get',
- ],
- };
- callWithRequest('ml.privilegeCheck', { body })
- .then(resp => {
- if (
- resp.cluster['cluster:monitor/xpack/ml/job/get'] &&
- resp.cluster['cluster:monitor/xpack/ml/job/stats/get'] &&
- resp.cluster['cluster:monitor/xpack/ml/datafeeds/get'] &&
- resp.cluster['cluster:monitor/xpack/ml/datafeeds/stats/get']
- ) {
- getNodeCount()
- .then(resolve)
- .catch(reject);
- } else {
- // if the user doesn't have permission to create jobs
- // return a 403
- reject(Boom.forbidden());
- }
- })
- .catch(reject);
- }
- }).catch(error => wrapError(error));
- },
- config: {
- ...commonRouteConfig,
- },
- });
-
- route({
- method: 'GET',
- path: '/api/ml/info',
- async handler(request) {
- const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request);
-
- try {
- const info = await callWithRequest('ml.info');
- const cloudId = cloud && cloud.cloudId;
- return { ...info, cloudId };
- } catch (error) {
- return wrapError(error);
- }
- },
- config: {
- ...commonRouteConfig,
- },
- });
-
- route({
- method: 'POST',
- path: '/api/ml/es_search',
- handler(request) {
- const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request);
- return callWithRequest('search', request.payload).catch(resp => wrapError(resp));
- },
- config: {
- ...commonRouteConfig,
- },
- });
-}
diff --git a/x-pack/legacy/plugins/ml/server/routes/system.ts b/x-pack/legacy/plugins/ml/server/routes/system.ts
new file mode 100644
index 0000000000000..5861b53d74875
--- /dev/null
+++ b/x-pack/legacy/plugins/ml/server/routes/system.ts
@@ -0,0 +1,247 @@
+/*
+ * 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 { schema } from '@kbn/config-schema';
+
+import { Request } from 'hapi';
+import { RequestHandlerContext } from 'kibana/server';
+import { wrapError } from '../client/error_wrapper';
+import { mlLog } from '../client/log';
+import { privilegesProvider } from '../lib/check_privileges';
+import { isSecurityDisabled } from '../lib/security_utils';
+import { spacesUtilsProvider } from '../lib/spaces_utils';
+import { licensePreRoutingFactory } from '../new_platform/licence_check_pre_routing_factory';
+import { RouteInitialization } from '../new_platform/plugin';
+
+/**
+ * System routes
+ */
+export function systemRoutes({
+ router,
+ xpackMainPlugin,
+ spacesPlugin,
+ cloud,
+}: RouteInitialization) {
+ async function getNodeCount(context: RequestHandlerContext) {
+ const filterPath = 'nodes.*.attributes';
+ const resp = await context.ml!.mlClient.callAsInternalUser('nodes.info', {
+ filterPath,
+ });
+
+ let count = 0;
+ if (typeof resp.nodes === 'object') {
+ Object.keys(resp.nodes).forEach(k => {
+ if (resp.nodes[k].attributes !== undefined) {
+ const maxOpenJobs = resp.nodes[k].attributes['ml.max_open_jobs'];
+ if (maxOpenJobs !== null && maxOpenJobs > 0) {
+ count++;
+ }
+ }
+ });
+ }
+ return { count };
+ }
+
+ /**
+ * @apiGroup SystemRoutes
+ *
+ * @api {post} /api/ml/_has_privileges Check privileges
+ * @apiName HasPrivileges
+ * @apiDescription Checks if the user has required privileges
+ */
+ router.post(
+ {
+ path: '/api/ml/_has_privileges',
+ validate: {
+ body: schema.maybe(schema.any()),
+ },
+ },
+ licensePreRoutingFactory(xpackMainPlugin, async (context, request, response) => {
+ try {
+ let upgradeInProgress = false;
+ try {
+ const info = await context.ml!.mlClient.callAsCurrentUser('ml.info');
+ // if ml indices are currently being migrated, upgrade_mode will be set to true
+ // pass this back with the privileges to allow for the disabling of UI controls.
+ upgradeInProgress = info.upgrade_mode === true;
+ } catch (error) {
+ // if the ml.info check fails, it could be due to the user having insufficient privileges
+ // most likely they do not have the ml_user role and therefore will be blocked from using
+ // ML at all. However, we need to catch this error so the privilege check doesn't fail.
+ if (error.status === 403) {
+ mlLog.info(
+ 'Unable to determine whether upgrade is being performed due to insufficient user privileges'
+ );
+ } else {
+ mlLog.warn('Unable to determine whether upgrade is being performed');
+ }
+ }
+
+ if (isSecurityDisabled(xpackMainPlugin)) {
+ // if xpack.security.enabled has been explicitly set to false
+ // return that security is disabled and don't call the privilegeCheck endpoint
+ return response.ok({
+ body: {
+ securityDisabled: true,
+ upgradeInProgress,
+ },
+ });
+ } else {
+ const body = request.body;
+ const resp = await context.ml!.mlClient.callAsCurrentUser('ml.privilegeCheck', { body });
+ resp.upgradeInProgress = upgradeInProgress;
+ return response.ok({
+ body: resp,
+ });
+ }
+ } catch (error) {
+ return response.customError(wrapError(error));
+ }
+ })
+ );
+
+ /**
+ * @apiGroup SystemRoutes
+ *
+ * @api {get} /api/ml/ml_capabilities Check ML capabilities
+ * @apiName MlCapabilities
+ * @apiDescription Checks ML capabilities
+ */
+ router.get(
+ {
+ path: '/api/ml/ml_capabilities',
+ validate: {
+ query: schema.object({
+ ignoreSpaces: schema.maybe(schema.string()),
+ }),
+ },
+ },
+ licensePreRoutingFactory(xpackMainPlugin, async (context, request, response) => {
+ try {
+ const ignoreSpaces = request.query && request.query.ignoreSpaces === 'true';
+ // if spaces is disabled force isMlEnabledInSpace to be true
+ const { isMlEnabledInSpace } =
+ spacesPlugin !== undefined
+ ? spacesUtilsProvider(spacesPlugin, (request as unknown) as Request)
+ : { isMlEnabledInSpace: async () => true };
+
+ const { getPrivileges } = privilegesProvider(
+ context.ml!.mlClient.callAsCurrentUser,
+ xpackMainPlugin,
+ isMlEnabledInSpace,
+ ignoreSpaces
+ );
+ return response.ok({
+ body: await getPrivileges(),
+ });
+ } catch (error) {
+ return response.customError(wrapError(error));
+ }
+ })
+ );
+
+ /**
+ * @apiGroup SystemRoutes
+ *
+ * @api {get} /api/ml/ml_node_count Get the amount of ML nodes
+ * @apiName MlNodeCount
+ * @apiDescription Returns the amount of ML nodes.
+ */
+ router.get(
+ {
+ path: '/api/ml/ml_node_count',
+ validate: false,
+ },
+ licensePreRoutingFactory(xpackMainPlugin, async (context, request, response) => {
+ try {
+ // check for basic license first for consistency with other
+ // security disabled checks
+ if (isSecurityDisabled(xpackMainPlugin)) {
+ return response.ok({
+ body: await getNodeCount(context),
+ });
+ } else {
+ // if security is enabled, check that the user has permission to
+ // view jobs before calling getNodeCount.
+ // getNodeCount calls the _nodes endpoint as the internal user
+ // and so could give the user access to more information than
+ // they are entitled to.
+ const requiredPrivileges = [
+ 'cluster:monitor/xpack/ml/job/get',
+ 'cluster:monitor/xpack/ml/job/stats/get',
+ 'cluster:monitor/xpack/ml/datafeeds/get',
+ 'cluster:monitor/xpack/ml/datafeeds/stats/get',
+ ];
+ const body = { cluster: requiredPrivileges };
+ const resp = await context.ml!.mlClient.callAsCurrentUser('ml.privilegeCheck', { body });
+
+ if (resp.has_all_requested) {
+ return response.ok({
+ body: await getNodeCount(context),
+ });
+ } else {
+ // if the user doesn't have permission to create jobs
+ // return a 403
+ return response.forbidden();
+ }
+ }
+ } catch (e) {
+ return response.customError(wrapError(e));
+ }
+ })
+ );
+
+ /**
+ * @apiGroup SystemRoutes
+ *
+ * @api {get} /api/ml/info Get ML info
+ * @apiName MlInfo
+ * @apiDescription Returns defaults and limits used by machine learning.
+ */
+ router.get(
+ {
+ path: '/api/ml/info',
+ validate: false,
+ },
+ licensePreRoutingFactory(xpackMainPlugin, async (context, request, response) => {
+ try {
+ const info = await context.ml!.mlClient.callAsCurrentUser('ml.info');
+ const cloudId = cloud && cloud.cloudId;
+ return response.ok({
+ body: { ...info, cloudId },
+ });
+ } catch (error) {
+ return response.customError(wrapError(error));
+ }
+ })
+ );
+
+ /**
+ * @apiGroup SystemRoutes
+ *
+ * @apiDeprecated
+ *
+ * @api {post} /api/ml/es_search ES Search wrapper
+ * @apiName MlEsSearch
+ */
+ router.post(
+ {
+ path: '/api/ml/es_search',
+ validate: {
+ body: schema.maybe(schema.any()),
+ },
+ },
+ licensePreRoutingFactory(xpackMainPlugin, async (context, request, response) => {
+ try {
+ return response.ok({
+ body: await context.ml!.mlClient.callAsCurrentUser('search', request.body),
+ });
+ } catch (error) {
+ return response.customError(wrapError(error));
+ }
+ })
+ );
+}
diff --git a/x-pack/legacy/plugins/reporting/export_types/common/layouts/print_layout.ts b/x-pack/legacy/plugins/reporting/export_types/common/layouts/print_layout.ts
index 44361181e3262..6007c2960057a 100644
--- a/x-pack/legacy/plugins/reporting/export_types/common/layouts/print_layout.ts
+++ b/x-pack/legacy/plugins/reporting/export_types/common/layouts/print_layout.ts
@@ -6,7 +6,7 @@
import path from 'path';
import { EvaluateFn, SerializableOrJSHandle } from 'puppeteer';
import { LevelLogger } from '../../../server/lib';
-import { HeadlessChromiumDriver } from '../../../server/browsers/chromium/driver';
+import { HeadlessChromiumDriver } from '../../../server/browsers';
import { ServerFacade } from '../../../types';
import { LayoutTypes } from '../constants';
import { getDefaultLayoutSelectors, Layout, LayoutSelectorDictionary, Size } from './layout';
@@ -75,7 +75,7 @@ export class PrintLayout extends Layout {
args: [this.selectors.screenshot, elementSize.height, elementSize.width],
};
- await browser.evaluate(evalOptions);
+ await browser.evaluate(evalOptions, { context: 'PositionElements' }, logger);
}
public getPdfImageSize() {
diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/check_for_toast.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/check_for_toast.ts
index 50599a927ec67..c888870bd2bc3 100644
--- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/check_for_toast.ts
+++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/check_for_toast.ts
@@ -6,9 +6,10 @@
import { i18n } from '@kbn/i18n';
import { ElementHandle } from 'puppeteer';
-import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers/chromium/driver';
+import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers';
import { LevelLogger } from '../../../../server/lib';
import { LayoutInstance } from '../../layouts/layout';
+import { CONTEXT_CHECKFORTOASTMESSAGE } from './constants';
export const checkForToastMessage = async (
browser: HeadlessBrowser,
@@ -20,13 +21,17 @@ export const checkForToastMessage = async (
.then(async () => {
// Check for a toast message on the page. If there is one, capture the
// message and throw an error, to fail the screenshot.
- const toastHeaderText: string = await browser.evaluate({
- fn: selector => {
- const nodeList = document.querySelectorAll(selector);
- return nodeList.item(0).innerText;
+ const toastHeaderText: string = await browser.evaluate(
+ {
+ fn: selector => {
+ const nodeList = document.querySelectorAll(selector);
+ return nodeList.item(0).innerText;
+ },
+ args: [layout.selectors.toastHeader],
},
- args: [layout.selectors.toastHeader],
- });
+ { context: CONTEXT_CHECKFORTOASTMESSAGE },
+ logger
+ );
// Log an error to track the event in kibana server logs
logger.error(
diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/constants.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/constants.ts
new file mode 100644
index 0000000000000..bbc97ca57940c
--- /dev/null
+++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/constants.ts
@@ -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.
+ */
+
+export const CONTEXT_GETNUMBEROFITEMS = 'GetNumberOfItems';
+export const CONTEXT_INJECTCSS = 'InjectCss';
+export const CONTEXT_WAITFORRENDER = 'WaitForRender';
+export const CONTEXT_GETTIMERANGE = 'GetTimeRange';
+export const CONTEXT_ELEMENTATTRIBUTES = 'ElementPositionAndAttributes';
+export const CONTEXT_CHECKFORTOASTMESSAGE = 'CheckForToastMessage';
+export const CONTEXT_WAITFORELEMENTSTOBEINDOM = 'WaitForElementsToBeInDOM';
+export const CONTEXT_SKIPTELEMETRY = 'SkipTelemetry';
diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_element_position_data.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_element_position_data.ts
index ce51dc2317c79..4302f4c631e3c 100644
--- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_element_position_data.ts
+++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_element_position_data.ts
@@ -4,48 +4,55 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers/chromium/driver';
+import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers';
import { LayoutInstance } from '../../layouts/layout';
import { AttributesMap, ElementsPositionAndAttribute } from './types';
+import { Logger } from '../../../../types';
+import { CONTEXT_ELEMENTATTRIBUTES } from './constants';
export const getElementPositionAndAttributes = async (
browser: HeadlessBrowser,
- layout: LayoutInstance
+ layout: LayoutInstance,
+ logger: Logger
): Promise => {
- const elementsPositionAndAttributes: ElementsPositionAndAttribute[] = await browser.evaluate({
- fn: (selector, attributes) => {
- const elements: NodeListOf = document.querySelectorAll(selector);
+ const elementsPositionAndAttributes: ElementsPositionAndAttribute[] = await browser.evaluate(
+ {
+ fn: (selector: string, attributes: any) => {
+ const elements: NodeListOf = document.querySelectorAll(selector);
- // NodeList isn't an array, just an iterator, unable to use .map/.forEach
- const results: ElementsPositionAndAttribute[] = [];
- for (let i = 0; i < elements.length; i++) {
- const element = elements[i];
- const boundingClientRect = element.getBoundingClientRect() as DOMRect;
- results.push({
- position: {
- boundingClientRect: {
- // modern browsers support x/y, but older ones don't
- top: boundingClientRect.y || boundingClientRect.top,
- left: boundingClientRect.x || boundingClientRect.left,
- width: boundingClientRect.width,
- height: boundingClientRect.height,
+ // NodeList isn't an array, just an iterator, unable to use .map/.forEach
+ const results: ElementsPositionAndAttribute[] = [];
+ for (let i = 0; i < elements.length; i++) {
+ const element = elements[i];
+ const boundingClientRect = element.getBoundingClientRect() as DOMRect;
+ results.push({
+ position: {
+ boundingClientRect: {
+ // modern browsers support x/y, but older ones don't
+ top: boundingClientRect.y || boundingClientRect.top,
+ left: boundingClientRect.x || boundingClientRect.left,
+ width: boundingClientRect.width,
+ height: boundingClientRect.height,
+ },
+ scroll: {
+ x: window.scrollX,
+ y: window.scrollY,
+ },
},
- scroll: {
- x: window.scrollX,
- y: window.scrollY,
- },
- },
- attributes: Object.keys(attributes).reduce((result: AttributesMap, key) => {
- const attribute = attributes[key];
- result[key] = element.getAttribute(attribute);
- return result;
- }, {}),
- });
- }
- return results;
+ attributes: Object.keys(attributes).reduce((result: AttributesMap, key) => {
+ const attribute = attributes[key];
+ (result as any)[key] = element.getAttribute(attribute);
+ return result;
+ }, {} as AttributesMap),
+ });
+ }
+ return results;
+ },
+ args: [layout.selectors.screenshot, { title: 'data-title', description: 'data-description' }],
},
- args: [layout.selectors.screenshot, { title: 'data-title', description: 'data-description' }],
- });
+ { context: CONTEXT_ELEMENTATTRIBUTES },
+ logger
+ );
if (elementsPositionAndAttributes.length === 0) {
throw new Error(
diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_number_of_items.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_number_of_items.ts
index 166d57f972a5c..1beae719cd6b0 100644
--- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_number_of_items.ts
+++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_number_of_items.ts
@@ -4,9 +4,10 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers/chromium/driver';
+import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers';
import { LevelLogger } from '../../../../server/lib';
import { LayoutInstance } from '../../layouts/layout';
+import { CONTEXT_GETNUMBEROFITEMS } from './constants';
export const getNumberOfItems = async (
browser: HeadlessBrowser,
@@ -17,20 +18,24 @@ export const getNumberOfItems = async (
// returns the value of the `itemsCountAttribute` if it's there, otherwise
// we just count the number of `itemSelector`
- const itemsCount: number = await browser.evaluate({
- fn: (selector, countAttribute) => {
- const elementWithCount = document.querySelector(`[${countAttribute}]`);
- if (elementWithCount && elementWithCount != null) {
- const count = elementWithCount.getAttribute(countAttribute);
- if (count && count != null) {
- return parseInt(count, 10);
+ const itemsCount: number = await browser.evaluate(
+ {
+ fn: (selector, countAttribute) => {
+ const elementWithCount = document.querySelector(`[${countAttribute}]`);
+ if (elementWithCount && elementWithCount != null) {
+ const count = elementWithCount.getAttribute(countAttribute);
+ if (count && count != null) {
+ return parseInt(count, 10);
+ }
}
- }
- return document.querySelectorAll(selector).length;
+ return document.querySelectorAll(selector).length;
+ },
+ args: [layout.selectors.renderComplete, layout.selectors.itemsCountAttribute],
},
- args: [layout.selectors.renderComplete, layout.selectors.itemsCountAttribute],
- });
+ { context: CONTEXT_GETNUMBEROFITEMS },
+ logger
+ );
return itemsCount;
};
diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_screenshots.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_screenshots.ts
index ff9f4549c0d4f..b21d1e752ba3f 100644
--- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_screenshots.ts
+++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_screenshots.ts
@@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers/chromium/driver';
+import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers';
import { LevelLogger } from '../../../../server/lib';
import { Screenshot, ElementsPositionAndAttribute } from './types';
diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_time_range.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_time_range.ts
index db63748c534d5..c1c43ed452594 100644
--- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_time_range.ts
+++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_time_range.ts
@@ -4,9 +4,10 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers/chromium/driver';
+import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers';
import { LevelLogger } from '../../../../server/lib';
import { LayoutInstance } from '../../layouts/layout';
+import { CONTEXT_GETTIMERANGE } from './constants';
import { TimeRange } from './types';
export const getTimeRange = async (
@@ -16,23 +17,27 @@ export const getTimeRange = async (
): Promise => {
logger.debug('getting timeRange');
- const timeRange: TimeRange | null = await browser.evaluate({
- fn: durationAttribute => {
- const durationElement = document.querySelector(`[${durationAttribute}]`);
+ const timeRange: TimeRange | null = await browser.evaluate(
+ {
+ fn: durationAttribute => {
+ const durationElement = document.querySelector(`[${durationAttribute}]`);
- if (!durationElement) {
- return null;
- }
+ if (!durationElement) {
+ return null;
+ }
- const duration = durationElement.getAttribute(durationAttribute);
- if (!duration) {
- return null;
- }
+ const duration = durationElement.getAttribute(durationAttribute);
+ if (!duration) {
+ return null;
+ }
- return { duration };
+ return { duration };
+ },
+ args: [layout.selectors.timefilterDurationAttribute],
},
- args: [layout.selectors.timefilterDurationAttribute],
- });
+ { context: CONTEXT_GETTIMERANGE },
+ logger
+ );
if (timeRange) {
logger.info(`timeRange: ${timeRange.duration}`);
diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/index.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/index.ts
index 62b5e29e88ecf..5a04f1a497abf 100644
--- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/index.ts
+++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/index.ts
@@ -4,92 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import * as Rx from 'rxjs';
-import { concatMap, first, mergeMap, take, toArray } from 'rxjs/operators';
-import { CaptureConfig, HeadlessChromiumDriverFactory, ServerFacade } from '../../../../types';
-import { getElementPositionAndAttributes } from './get_element_position_data';
-import { getNumberOfItems } from './get_number_of_items';
-import { getScreenshots } from './get_screenshots';
-import { getTimeRange } from './get_time_range';
-import { injectCustomCss } from './inject_css';
-import { openUrl } from './open_url';
-import { scanPage } from './scan_page';
-import { skipTelemetry } from './skip_telemetry';
-import { ScreenshotObservableOpts, ScreenshotResults } from './types';
-import { waitForElementsToBeInDOM } from './wait_for_dom_elements';
-import { waitForRenderComplete } from './wait_for_render';
-
-export function screenshotsObservableFactory(
- server: ServerFacade,
- browserDriverFactory: HeadlessChromiumDriverFactory
-) {
- const config = server.config();
- const captureConfig: CaptureConfig = config.get('xpack.reporting.capture');
-
- return function screenshotsObservable({
- logger,
- urls,
- conditionalHeaders,
- layout,
- browserTimezone,
- }: ScreenshotObservableOpts): Rx.Observable {
- const create$ = browserDriverFactory.createPage(
- { viewport: layout.getBrowserViewport(), browserTimezone },
- logger
- );
- return Rx.from(urls).pipe(
- concatMap(url => {
- return create$.pipe(
- mergeMap(({ driver, exit$ }) => {
- const screenshot$ = Rx.of(1).pipe(
- mergeMap(() => openUrl(driver, url, conditionalHeaders, logger)),
- mergeMap(() => skipTelemetry(driver, logger)),
- mergeMap(() => scanPage(driver, layout, logger)),
- mergeMap(() => getNumberOfItems(driver, layout, logger)),
- mergeMap(async itemsCount => {
- const viewport = layout.getViewport(itemsCount);
- await Promise.all([
- driver.setViewport(viewport, logger),
- waitForElementsToBeInDOM(driver, itemsCount, layout, logger),
- ]);
- }),
- mergeMap(async () => {
- // Waiting till _after_ elements have rendered before injecting our CSS
- // allows for them to be displayed properly in many cases
- await injectCustomCss(driver, layout, logger);
-
- if (layout.positionElements) {
- // position panel elements for print layout
- await layout.positionElements(driver, logger);
- }
-
- await waitForRenderComplete(captureConfig, driver, layout, logger);
- }),
- mergeMap(() => getTimeRange(driver, layout, logger)),
- mergeMap(
- async (timeRange): Promise => {
- const elementsPositionAndAttributes = await getElementPositionAndAttributes(
- driver,
- layout
- );
- const screenshots = await getScreenshots({
- browser: driver,
- elementsPositionAndAttributes,
- logger,
- });
-
- return { timeRange, screenshots };
- }
- )
- );
-
- return Rx.race(screenshot$, exit$);
- }),
- first()
- );
- }),
- take(urls.length),
- toArray()
- );
- };
-}
+export { screenshotsObservableFactory } from './observable';
diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/inject_css.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/inject_css.ts
index d27b6d0752cf9..40204804a276f 100644
--- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/inject_css.ts
+++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/inject_css.ts
@@ -7,8 +7,9 @@
import fs from 'fs';
import { promisify } from 'util';
import { LevelLogger } from '../../../../server/lib';
-import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers/chromium/driver';
+import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers';
import { Layout } from '../../layouts/layout';
+import { CONTEXT_INJECTCSS } from './constants';
const fsp = { readFile: promisify(fs.readFile) };
@@ -21,13 +22,17 @@ export const injectCustomCss = async (
const filePath = layout.getCssOverridesPath();
const buffer = await fsp.readFile(filePath);
- await browser.evaluate({
- fn: css => {
- const node = document.createElement('style');
- node.type = 'text/css';
- node.innerHTML = css; // eslint-disable-line no-unsanitized/property
- document.getElementsByTagName('head')[0].appendChild(node);
+ await browser.evaluate(
+ {
+ fn: css => {
+ const node = document.createElement('style');
+ node.type = 'text/css';
+ node.innerHTML = css; // eslint-disable-line no-unsanitized/property
+ document.getElementsByTagName('head')[0].appendChild(node);
+ },
+ args: [buffer.toString()],
},
- args: [buffer.toString()],
- });
+ { context: CONTEXT_INJECTCSS },
+ logger
+ );
};
diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.test.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.test.ts
new file mode 100644
index 0000000000000..9f8e218f4f614
--- /dev/null
+++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.test.ts
@@ -0,0 +1,194 @@
+/*
+ * 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.
+ */
+
+jest.mock('../../../../server/browsers/chromium/puppeteer', () => ({
+ puppeteerLaunch: () => ({
+ // Fixme needs event emitters
+ newPage: () => ({
+ setDefaultTimeout: jest.fn(),
+ }),
+ process: jest.fn(),
+ close: jest.fn(),
+ }),
+}));
+
+import * as Rx from 'rxjs';
+// eslint-disable-next-line @kbn/eslint/no-restricted-paths
+import { loggingServiceMock } from '../../../../../../../../src/core/server/mocks';
+import { LevelLogger } from '../../../../server/lib';
+import {
+ createMockBrowserDriverFactory,
+ createMockLayoutInstance,
+ createMockServer,
+ mockSelectors,
+} from '../../../../test_helpers';
+import { ConditionalHeaders, HeadlessChromiumDriver } from '../../../../types';
+import { screenshotsObservableFactory } from './observable';
+import { ElementsPositionAndAttribute } from './types';
+
+/*
+ * Mocks
+ */
+const mockLogger = jest.fn(loggingServiceMock.create);
+const logger = new LevelLogger(mockLogger());
+
+const __LEGACY = createMockServer({ settings: { 'xpack.reporting.capture': { loadDelay: 13 } } });
+const mockLayout = createMockLayoutInstance(__LEGACY);
+
+/*
+ * Tests
+ */
+describe('Screenshot Observable Pipeline', () => {
+ let mockBrowserDriverFactory: any;
+
+ beforeEach(async () => {
+ mockBrowserDriverFactory = await createMockBrowserDriverFactory(logger, {});
+ });
+
+ it('pipelines a single url into screenshot and timeRange', async () => {
+ const getScreenshots$ = screenshotsObservableFactory(__LEGACY, mockBrowserDriverFactory);
+ const result = await getScreenshots$({
+ logger,
+ urls: ['/welcome/home/start/index.htm'],
+ conditionalHeaders: {} as ConditionalHeaders,
+ layout: mockLayout,
+ browserTimezone: 'UTC',
+ }).toPromise();
+
+ expect(result).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "screenshots": Array [
+ Object {
+ "base64EncodedData": "allyourBase64 of boundingClientRect,scroll",
+ "description": "Default ",
+ "title": "Default Mock Title",
+ },
+ ],
+ "timeRange": "Default GetTimeRange Result",
+ },
+ ]
+ `);
+ });
+
+ it('pipelines multiple urls into', async () => {
+ // mock implementations
+ const mockScreenshot = jest.fn().mockImplementation((item: ElementsPositionAndAttribute) => {
+ return Promise.resolve(`allyourBase64 screenshots`);
+ });
+
+ // mocks
+ mockBrowserDriverFactory = await createMockBrowserDriverFactory(logger, {
+ screenshot: mockScreenshot,
+ });
+
+ // test
+ const getScreenshots$ = screenshotsObservableFactory(__LEGACY, mockBrowserDriverFactory);
+ const result = await getScreenshots$({
+ logger,
+ urls: ['/welcome/home/start/index2.htm', '/welcome/home/start/index.php3?page=./home.php'],
+ conditionalHeaders: {} as ConditionalHeaders,
+ layout: mockLayout,
+ browserTimezone: 'UTC',
+ }).toPromise();
+
+ expect(result).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "screenshots": Array [
+ Object {
+ "base64EncodedData": "allyourBase64 screenshots",
+ "description": "Default ",
+ "title": "Default Mock Title",
+ },
+ ],
+ "timeRange": "Default GetTimeRange Result",
+ },
+ Object {
+ "screenshots": Array [
+ Object {
+ "base64EncodedData": "allyourBase64 screenshots",
+ "description": "Default ",
+ "title": "Default Mock Title",
+ },
+ ],
+ "timeRange": "Default GetTimeRange Result",
+ },
+ ]
+ `);
+ });
+
+ describe('error handling', () => {
+ it('fails if error toast message is found', async () => {
+ // mock implementations
+ const mockWaitForSelector = jest.fn().mockImplementation((selectorArg: string) => {
+ const { toastHeader } = mockSelectors;
+ if (selectorArg === toastHeader) {
+ return Promise.resolve(true);
+ }
+ // make the error toast message get found before anything else
+ return Rx.interval(100).toPromise();
+ });
+
+ // mocks
+ mockBrowserDriverFactory = await createMockBrowserDriverFactory(logger, {
+ waitForSelector: mockWaitForSelector,
+ });
+
+ // test
+ const getScreenshots$ = screenshotsObservableFactory(__LEGACY, mockBrowserDriverFactory);
+ const getScreenshot = async () => {
+ return await getScreenshots$({
+ logger,
+ urls: [
+ '/welcome/home/start/index2.htm',
+ '/welcome/home/start/index.php3?page=./home.php3',
+ ],
+ conditionalHeaders: {} as ConditionalHeaders,
+ layout: mockLayout,
+ browserTimezone: 'UTC',
+ }).toPromise();
+ };
+
+ await expect(getScreenshot()).rejects.toMatchInlineSnapshot(
+ `[Error: Encountered an unexpected message on the page: Toast Message]`
+ );
+ });
+
+ it('fails if exit$ fires a timeout or error signal', async () => {
+ // mocks
+ const mockGetCreatePage = (driver: HeadlessChromiumDriver) =>
+ jest
+ .fn()
+ .mockImplementation(() =>
+ Rx.of({ driver, exit$: Rx.throwError('Instant timeout has fired!') })
+ );
+
+ const mockWaitForSelector = jest.fn().mockImplementation((selectorArg: string) => {
+ return Rx.never().toPromise();
+ });
+
+ mockBrowserDriverFactory = await createMockBrowserDriverFactory(logger, {
+ getCreatePage: mockGetCreatePage,
+ waitForSelector: mockWaitForSelector,
+ });
+
+ // test
+ const getScreenshots$ = screenshotsObservableFactory(__LEGACY, mockBrowserDriverFactory);
+ const getScreenshot = async () => {
+ return await getScreenshots$({
+ logger,
+ urls: ['/welcome/home/start/index.php3?page=./home.php3'],
+ conditionalHeaders: {} as ConditionalHeaders,
+ layout: mockLayout,
+ browserTimezone: 'UTC',
+ }).toPromise();
+ };
+
+ await expect(getScreenshot()).rejects.toMatchInlineSnapshot(`"Instant timeout has fired!"`);
+ });
+ });
+});
diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts
new file mode 100644
index 0000000000000..d429931602951
--- /dev/null
+++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts
@@ -0,0 +1,96 @@
+/*
+ * 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 * as Rx from 'rxjs';
+import { concatMap, first, mergeMap, take, toArray } from 'rxjs/operators';
+import { CaptureConfig, HeadlessChromiumDriverFactory, ServerFacade } from '../../../../types';
+import { getElementPositionAndAttributes } from './get_element_position_data';
+import { getNumberOfItems } from './get_number_of_items';
+import { getScreenshots } from './get_screenshots';
+import { getTimeRange } from './get_time_range';
+import { injectCustomCss } from './inject_css';
+import { openUrl } from './open_url';
+import { scanPage } from './scan_page';
+import { ScreenshotObservableOpts, ScreenshotResults } from './types';
+import { waitForElementsToBeInDOM } from './wait_for_dom_elements';
+import { waitForRenderComplete } from './wait_for_render';
+import { skipTelemetry } from './skip_telemetry';
+
+export function screenshotsObservableFactory(
+ server: ServerFacade,
+ browserDriverFactory: HeadlessChromiumDriverFactory
+) {
+ const config = server.config();
+ const captureConfig: CaptureConfig = config.get('xpack.reporting.capture');
+
+ return function screenshotsObservable({
+ logger,
+ urls,
+ conditionalHeaders,
+ layout,
+ browserTimezone,
+ }: ScreenshotObservableOpts): Rx.Observable {
+ const create$ = browserDriverFactory.createPage(
+ { viewport: layout.getBrowserViewport(), browserTimezone },
+ logger
+ );
+ return Rx.from(urls).pipe(
+ concatMap(url => {
+ return create$.pipe(
+ mergeMap(({ driver, exit$ }) => {
+ const screenshot$ = Rx.of(1).pipe(
+ mergeMap(() => openUrl(driver, url, conditionalHeaders, logger)),
+ mergeMap(() => skipTelemetry(driver, logger)),
+ mergeMap(() => scanPage(driver, layout, logger)),
+ mergeMap(() => getNumberOfItems(driver, layout, logger)),
+ mergeMap(async itemsCount => {
+ const viewport = layout.getViewport(itemsCount);
+ await Promise.all([
+ driver.setViewport(viewport, logger),
+ waitForElementsToBeInDOM(driver, itemsCount, layout, logger),
+ ]);
+ }),
+ mergeMap(async () => {
+ // Waiting till _after_ elements have rendered before injecting our CSS
+ // allows for them to be displayed properly in many cases
+ await injectCustomCss(driver, layout, logger);
+
+ if (layout.positionElements) {
+ // position panel elements for print layout
+ await layout.positionElements(driver, logger);
+ }
+
+ await waitForRenderComplete(captureConfig, driver, layout, logger);
+ }),
+ mergeMap(() => getTimeRange(driver, layout, logger)),
+ mergeMap(
+ async (timeRange): Promise => {
+ const elementsPositionAndAttributes = await getElementPositionAndAttributes(
+ driver,
+ layout,
+ logger
+ );
+ const screenshots = await getScreenshots({
+ browser: driver,
+ elementsPositionAndAttributes,
+ logger,
+ });
+
+ return { timeRange, screenshots };
+ }
+ )
+ );
+
+ return Rx.race(screenshot$, exit$);
+ }),
+ first()
+ );
+ }),
+ take(urls.length),
+ toArray()
+ );
+ };
+}
diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts
index 288e8b81acdc9..e465499f839f9 100644
--- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts
+++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts
@@ -6,7 +6,7 @@
import { ConditionalHeaders } from '../../../../types';
import { LevelLogger } from '../../../../server/lib';
-import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers/chromium/driver';
+import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers';
import { WAITFOR_SELECTOR } from '../../constants';
export const openUrl = async (
diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/scan_page.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/scan_page.ts
index 81ff01bb204b8..010ffe8f23afc 100644
--- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/scan_page.ts
+++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/scan_page.ts
@@ -5,7 +5,7 @@
*/
import * as Rx from 'rxjs';
-import { HeadlessChromiumDriver } from '../../../../server/browsers/chromium/driver';
+import { HeadlessChromiumDriver } from '../../../../server/browsers';
import { LevelLogger } from '../../../../server/lib';
import { LayoutInstance } from '../../layouts/layout';
import { checkForToastMessage } from './check_for_toast';
diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/skip_telemetry.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/skip_telemetry.ts
index 367354032a843..1762a78f22720 100644
--- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/skip_telemetry.ts
+++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/skip_telemetry.ts
@@ -6,24 +6,29 @@
import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers/chromium/driver';
import { LevelLogger } from '../../../../server/lib';
+import { CONTEXT_SKIPTELEMETRY } from './constants';
const LAST_REPORT_STORAGE_KEY = 'xpack.data';
export async function skipTelemetry(browser: HeadlessBrowser, logger: LevelLogger) {
- const storageData = await browser.evaluate({
- fn: storageKey => {
- // set something
- const optOutJSON = JSON.stringify({ lastReport: Date.now() });
- localStorage.setItem(storageKey, optOutJSON);
+ const storageData = await browser.evaluate(
+ {
+ fn: storageKey => {
+ // set something
+ const optOutJSON = JSON.stringify({ lastReport: Date.now() });
+ localStorage.setItem(storageKey, optOutJSON);
- // get it
- const session = localStorage.getItem(storageKey);
+ // get it
+ const session = localStorage.getItem(storageKey);
- // return it
- return session;
+ // return it
+ return session;
+ },
+ args: [LAST_REPORT_STORAGE_KEY],
},
- args: [LAST_REPORT_STORAGE_KEY],
- });
+ { context: CONTEXT_SKIPTELEMETRY },
+ logger
+ );
logger.debug(`added data to localStorage to skip telmetry: ${storageData}`);
}
diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_dom_elements.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_dom_elements.ts
index 3e9498179e407..c958585f78e0d 100644
--- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_dom_elements.ts
+++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_dom_elements.ts
@@ -4,9 +4,10 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers/chromium/driver';
+import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers';
import { LevelLogger } from '../../../../server/lib';
import { LayoutInstance } from '../../layouts/layout';
+import { CONTEXT_WAITFORELEMENTSTOBEINDOM } from './constants';
export const waitForElementsToBeInDOM = async (
browser: HeadlessBrowser,
@@ -16,13 +17,17 @@ export const waitForElementsToBeInDOM = async (
): Promise => {
logger.debug(`waiting for ${itemsCount} rendered elements to be in the DOM`);
- await browser.waitFor({
- fn: selector => {
- return document.querySelectorAll(selector).length;
+ await browser.waitFor(
+ {
+ fn: selector => {
+ return document.querySelectorAll(selector).length;
+ },
+ args: [layout.selectors.renderComplete],
+ toEqual: itemsCount,
},
- args: [layout.selectors.renderComplete],
- toEqual: itemsCount,
- });
+ { context: CONTEXT_WAITFORELEMENTSTOBEINDOM },
+ logger
+ );
logger.info(`found ${itemsCount} rendered elements in the DOM`);
return itemsCount;
diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_render.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_render.ts
index df0d591ff913c..632f008ca63bc 100644
--- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_render.ts
+++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_render.ts
@@ -5,9 +5,10 @@
*/
import { CaptureConfig } from '../../../../types';
-import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers/chromium/driver';
+import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers';
import { LevelLogger } from '../../../../server/lib';
import { LayoutInstance } from '../../layouts/layout';
+import { CONTEXT_WAITFORRENDER } from './constants';
export const waitForRenderComplete = async (
captureConfig: CaptureConfig,
@@ -18,48 +19,52 @@ export const waitForRenderComplete = async (
logger.debug('waiting for rendering to complete');
return await browser
- .evaluate({
- fn: (selector, visLoadDelay) => {
- // wait for visualizations to finish loading
- const visualizations: NodeListOf = document.querySelectorAll(selector);
- const visCount = visualizations.length;
- const renderedTasks = [];
+ .evaluate(
+ {
+ fn: (selector, visLoadDelay) => {
+ // wait for visualizations to finish loading
+ const visualizations: NodeListOf = document.querySelectorAll(selector);
+ const visCount = visualizations.length;
+ const renderedTasks = [];
- function waitForRender(visualization: Element) {
- return new Promise(resolve => {
- visualization.addEventListener('renderComplete', () => resolve());
- });
- }
+ function waitForRender(visualization: Element) {
+ return new Promise(resolve => {
+ visualization.addEventListener('renderComplete', () => resolve());
+ });
+ }
- function waitForRenderDelay() {
- return new Promise(resolve => {
- setTimeout(resolve, visLoadDelay);
- });
- }
+ function waitForRenderDelay() {
+ return new Promise(resolve => {
+ setTimeout(resolve, visLoadDelay);
+ });
+ }
- for (let i = 0; i < visCount; i++) {
- const visualization = visualizations[i];
- const isRendered = visualization.getAttribute('data-render-complete');
+ for (let i = 0; i < visCount; i++) {
+ const visualization = visualizations[i];
+ const isRendered = visualization.getAttribute('data-render-complete');
- if (isRendered === 'disabled') {
- renderedTasks.push(waitForRenderDelay());
- } else if (isRendered === 'false') {
- renderedTasks.push(waitForRender(visualization));
+ if (isRendered === 'disabled') {
+ renderedTasks.push(waitForRenderDelay());
+ } else if (isRendered === 'false') {
+ renderedTasks.push(waitForRender(visualization));
+ }
}
- }
- // The renderComplete fires before the visualizations are in the DOM, so
- // we wait for the event loop to flush before telling reporting to continue. This
- // seems to correct a timing issue that was causing reporting to occasionally
- // capture the first visualization before it was actually in the DOM.
- // Note: 100 proved too short, see https://github.com/elastic/kibana/issues/22581,
- // bumping to 250.
- const hackyWaitForVisualizations = () => new Promise(r => setTimeout(r, 250));
+ // The renderComplete fires before the visualizations are in the DOM, so
+ // we wait for the event loop to flush before telling reporting to continue. This
+ // seems to correct a timing issue that was causing reporting to occasionally
+ // capture the first visualization before it was actually in the DOM.
+ // Note: 100 proved too short, see https://github.com/elastic/kibana/issues/22581,
+ // bumping to 250.
+ const hackyWaitForVisualizations = () => new Promise(r => setTimeout(r, 250));
- return Promise.all(renderedTasks).then(hackyWaitForVisualizations);
+ return Promise.all(renderedTasks).then(hackyWaitForVisualizations);
+ },
+ args: [layout.selectors.renderComplete, captureConfig.loadDelay],
},
- args: [layout.selectors.renderComplete, captureConfig.loadDelay],
- })
+ { context: CONTEXT_WAITFORRENDER },
+ logger
+ )
.then(() => {
logger.debug('rendering is complete');
});
diff --git a/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts b/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts
index de8449ff29132..0592124b9897b 100644
--- a/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts
+++ b/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts
@@ -28,6 +28,15 @@ interface WaitForSelectorOpts {
silent?: boolean;
}
+interface EvaluateOpts {
+ fn: EvaluateFn;
+ args: SerializableOrJSHandle[];
+}
+
+interface EvaluateMetaOpts {
+ context: string;
+}
+
const WAIT_FOR_DELAY_MS: number = 100;
export class HeadlessChromiumDriver {
@@ -158,11 +167,15 @@ export class HeadlessChromiumDriver {
return screenshot.toString('base64');
}
- public async evaluate({ fn, args = [] }: { fn: EvaluateFn; args: SerializableOrJSHandle[] }) {
+ public async evaluate(
+ { fn, args = [] }: EvaluateOpts,
+ meta: EvaluateMetaOpts,
+ logger: LevelLogger
+ ) {
+ logger.debug(`evaluate ${meta.context}`);
const result = await this.page.evaluate(fn, ...args);
return result;
}
-
public async waitForSelector(
selector: string,
opts: WaitForSelectorOpts = {},
@@ -179,10 +192,14 @@ export class HeadlessChromiumDriver {
// Provide some troubleshooting info to see if we're on the login page,
// "Kibana could not load correctly", etc
logger.error(`waitForSelector ${selector} failed on ${this.page.url()}`);
- const pageText = await this.evaluate({
- fn: () => document.querySelector('body')!.innerText,
- args: [],
- });
+ const pageText = await this.evaluate(
+ {
+ fn: () => document.querySelector('body')!.innerText,
+ args: [],
+ },
+ { context: `waitForSelector${selector}` },
+ logger
+ );
logger.debug(`Page plain text: ${pageText.replace(/\n/g, '\\n')}`); // replace newline with escaped for single log line
}
throw err;
@@ -192,17 +209,21 @@ export class HeadlessChromiumDriver {
return resp;
}
- public async waitFor({
- fn,
- args,
- toEqual,
- }: {
- fn: EvaluateFn;
- args: SerializableOrJSHandle[];
- toEqual: T;
- }) {
+ public async waitFor(
+ {
+ fn,
+ args,
+ toEqual,
+ }: {
+ fn: EvaluateFn;
+ args: SerializableOrJSHandle[];
+ toEqual: T;
+ },
+ context: EvaluateMetaOpts,
+ logger: LevelLogger
+ ) {
while (true) {
- const result = await this.evaluate({ fn, args });
+ const result = await this.evaluate({ fn, args }, context, logger);
if (result === toEqual) {
return;
}
diff --git a/x-pack/legacy/plugins/reporting/server/browsers/index.ts b/x-pack/legacy/plugins/reporting/server/browsers/index.ts
index 402fabea56c84..1e42e2736962e 100644
--- a/x-pack/legacy/plugins/reporting/server/browsers/index.ts
+++ b/x-pack/legacy/plugins/reporting/server/browsers/index.ts
@@ -10,6 +10,9 @@ export { ensureAllBrowsersDownloaded } from './download';
export { createBrowserDriverFactory } from './create_browser_driver_factory';
export { getDefaultChromiumSandboxDisabled } from './default_chromium_sandbox_disabled';
+export { HeadlessChromiumDriver } from './chromium/driver';
+export { HeadlessChromiumDriverFactory } from './chromium/driver_factory';
+
export const chromium = {
paths: chromiumDefinition.paths,
createDriverFactory: chromiumDefinition.createDriverFactory,
diff --git a/x-pack/legacy/plugins/reporting/server/types.d.ts b/x-pack/legacy/plugins/reporting/server/types.d.ts
index 20673423aa448..59b7bc2020ad9 100644
--- a/x-pack/legacy/plugins/reporting/server/types.d.ts
+++ b/x-pack/legacy/plugins/reporting/server/types.d.ts
@@ -5,17 +5,12 @@
*/
import { Legacy } from 'kibana';
-import {
- ElasticsearchServiceSetup,
- SavedObjectsServiceStart,
- UiSettingsServiceStart,
-} from 'src/core/server';
+import { ElasticsearchServiceSetup } from 'src/core/server';
import { UsageCollectionSetup } from 'src/plugins/usage_collection/server';
import { PluginStart as DataPluginStart } from '../../../../../src/plugins/data/server';
import { SecurityPluginSetup } from '../../../../plugins/security/server';
import { XPackMainPlugin } from '../../xpack_main/server/xpack_main';
-import { EnqueueJobFn, ESQueueInstance, ReportingPluginSpecOptions } from '../types';
-import { HeadlessChromiumDriverFactory } from './browsers/chromium/driver_factory';
+import { ReportingPluginSpecOptions } from '../types';
export interface ReportingSetupDeps {
elasticsearch: ElasticsearchServiceSetup;
diff --git a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts
new file mode 100644
index 0000000000000..6d9ae2153255f
--- /dev/null
+++ b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts
@@ -0,0 +1,132 @@
+/*
+ * 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 { Page } from 'puppeteer';
+import * as Rx from 'rxjs';
+import * as contexts from '../export_types/common/lib/screenshots/constants';
+import { ElementsPositionAndAttribute } from '../export_types/common/lib/screenshots/types';
+import { HeadlessChromiumDriver, HeadlessChromiumDriverFactory } from '../server/browsers';
+import { createDriverFactory } from '../server/browsers/chromium';
+import { BrowserConfig, Logger, NetworkPolicy } from '../types';
+
+interface CreateMockBrowserDriverFactoryOpts {
+ evaluate: jest.Mock, any[]>;
+ waitForSelector: jest.Mock, any[]>;
+ screenshot: jest.Mock, any[]>;
+ getCreatePage: (driver: HeadlessChromiumDriver) => jest.Mock;
+}
+
+export const mockSelectors = {
+ renderComplete: 'renderedSelector',
+ itemsCountAttribute: 'itemsSelector',
+ screenshot: 'screenshotSelector',
+ timefilterDurationAttribute: 'timefilterDurationSelector',
+ toastHeader: 'toastHeaderSelector',
+};
+
+const getMockElementsPositionAndAttributes = (
+ title: string,
+ description: string
+): ElementsPositionAndAttribute[] => [
+ {
+ position: {
+ boundingClientRect: { top: 0, left: 0, width: 10, height: 11 },
+ scroll: { x: 0, y: 0 },
+ },
+ attributes: { title, description },
+ },
+];
+
+const mockWaitForSelector = jest.fn();
+mockWaitForSelector.mockImplementation((selectorArg: string) => {
+ const { renderComplete, itemsCountAttribute, toastHeader } = mockSelectors;
+ if (selectorArg === `${renderComplete},[${itemsCountAttribute}]`) {
+ return Promise.resolve(true);
+ } else if (selectorArg === toastHeader) {
+ return Rx.never().toPromise();
+ }
+ throw new Error(selectorArg);
+});
+const mockBrowserEvaluate = jest.fn();
+mockBrowserEvaluate.mockImplementation(() => {
+ const lastCallIndex = mockBrowserEvaluate.mock.calls.length - 1;
+ const { context: mockCall } = mockBrowserEvaluate.mock.calls[lastCallIndex][1];
+
+ if (mockCall === contexts.CONTEXT_SKIPTELEMETRY) {
+ return Promise.resolve();
+ }
+ if (mockCall === contexts.CONTEXT_GETNUMBEROFITEMS) {
+ return Promise.resolve(1);
+ }
+ if (mockCall === contexts.CONTEXT_INJECTCSS) {
+ return Promise.resolve();
+ }
+ if (mockCall === contexts.CONTEXT_WAITFORRENDER) {
+ return Promise.resolve();
+ }
+ if (mockCall === contexts.CONTEXT_GETTIMERANGE) {
+ return Promise.resolve('Default GetTimeRange Result');
+ }
+ if (mockCall === contexts.CONTEXT_ELEMENTATTRIBUTES) {
+ return Promise.resolve(getMockElementsPositionAndAttributes('Default Mock Title', 'Default '));
+ }
+ if (mockCall === contexts.CONTEXT_CHECKFORTOASTMESSAGE) {
+ return Promise.resolve('Toast Message');
+ }
+ throw new Error(mockCall);
+});
+const mockScreenshot = jest.fn();
+mockScreenshot.mockImplementation((item: ElementsPositionAndAttribute) => {
+ return Promise.resolve(`allyourBase64 of ${Object.keys(item)}`);
+});
+const getCreatePage = (driver: HeadlessChromiumDriver) =>
+ jest.fn().mockImplementation(() => Rx.of({ driver, exit$: Rx.never() }));
+
+const defaultOpts: CreateMockBrowserDriverFactoryOpts = {
+ evaluate: mockBrowserEvaluate,
+ waitForSelector: mockWaitForSelector,
+ screenshot: mockScreenshot,
+ getCreatePage,
+};
+
+export const createMockBrowserDriverFactory = async (
+ logger: Logger,
+ opts: Partial
+): Promise => {
+ const browserConfig = {
+ inspect: true,
+ userDataDir: '/usr/data/dir',
+ viewport: { width: 12, height: 12 },
+ disableSandbox: false,
+ proxy: { enabled: false },
+ } as BrowserConfig;
+
+ const binaryPath = '/usr/local/share/common/secure/';
+ const queueTimeout = 55;
+ const networkPolicy = {} as NetworkPolicy;
+
+ const mockBrowserDriverFactory = await createDriverFactory(
+ binaryPath,
+ logger,
+ browserConfig,
+ queueTimeout,
+ networkPolicy
+ );
+
+ const mockPage = {} as Page;
+ const mockBrowserDriver = new HeadlessChromiumDriver(mockPage, { inspect: true, networkPolicy });
+
+ // mock the driver methods as either default mocks or passed-in
+ mockBrowserDriver.waitForSelector = opts.waitForSelector ? opts.waitForSelector : defaultOpts.waitForSelector; // prettier-ignore
+ mockBrowserDriver.evaluate = opts.evaluate ? opts.evaluate : defaultOpts.evaluate;
+ mockBrowserDriver.screenshot = opts.screenshot ? opts.screenshot : defaultOpts.screenshot;
+
+ mockBrowserDriverFactory.createPage = opts.getCreatePage
+ ? opts.getCreatePage(mockBrowserDriver)
+ : getCreatePage(mockBrowserDriver);
+
+ return mockBrowserDriverFactory;
+};
diff --git a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_layoutinstance.ts b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_layoutinstance.ts
new file mode 100644
index 0000000000000..a2eb03c3fe300
--- /dev/null
+++ b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_layoutinstance.ts
@@ -0,0 +1,25 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { createLayout } from '../export_types/common/layouts';
+import { LayoutTypes } from '../export_types/common/constants';
+import { LayoutInstance } from '../export_types/common/layouts/layout';
+import { ServerFacade } from '../types';
+
+export const createMockLayoutInstance = (__LEGACY: ServerFacade) => {
+ const mockLayout = createLayout(__LEGACY, {
+ id: LayoutTypes.PRESERVE_LAYOUT,
+ dimensions: { height: 12, width: 12 },
+ }) as LayoutInstance;
+ mockLayout.selectors = {
+ renderComplete: 'renderedSelector',
+ itemsCountAttribute: 'itemsSelector',
+ screenshot: 'screenshotSelector',
+ timefilterDurationAttribute: 'timefilterDurationSelector',
+ toastHeader: 'toastHeaderSelector',
+ };
+ return mockLayout;
+};
diff --git a/x-pack/legacy/plugins/reporting/test_helpers/index.ts b/x-pack/legacy/plugins/reporting/test_helpers/index.ts
index 7fbc5661d5211..91c348ba1db3d 100644
--- a/x-pack/legacy/plugins/reporting/test_helpers/index.ts
+++ b/x-pack/legacy/plugins/reporting/test_helpers/index.ts
@@ -6,3 +6,5 @@
export { createMockServer } from './create_mock_server';
export { createMockReportingCore } from './create_mock_reportingplugin';
+export { createMockBrowserDriverFactory, mockSelectors } from './create_mock_browserdriverfactory';
+export { createMockLayoutInstance } from './create_mock_layoutinstance';
diff --git a/x-pack/legacy/plugins/reporting/types.d.ts b/x-pack/legacy/plugins/reporting/types.d.ts
index 1549c173b3d6e..38406186c8173 100644
--- a/x-pack/legacy/plugins/reporting/types.d.ts
+++ b/x-pack/legacy/plugins/reporting/types.d.ts
@@ -311,8 +311,9 @@ export interface ExportTypeDefinition<
}
export { CancellationToken } from './common/cancellation_token';
-export { HeadlessChromiumDriver } from './server/browsers/chromium/driver';
-export { HeadlessChromiumDriverFactory } from './server/browsers/chromium/driver_factory';
+
+export { HeadlessChromiumDriver, HeadlessChromiumDriverFactory } from './server/browsers';
+
export { ExportTypesRegistry } from './server/lib/export_types_registry';
// Prefer to import this type using: `import { LevelLogger } from 'relative/path/server/lib';`
export { LevelLogger as Logger };
diff --git a/x-pack/legacy/plugins/siem/cypress/integration/lib/url_state/index.ts b/x-pack/legacy/plugins/siem/cypress/integration/lib/url_state/index.ts
index 99d90e3c42aca..fa754cd4b8db4 100644
--- a/x-pack/legacy/plugins/siem/cypress/integration/lib/url_state/index.ts
+++ b/x-pack/legacy/plugins/siem/cypress/integration/lib/url_state/index.ts
@@ -12,24 +12,6 @@
*/
export const ABSOLUTE_DATE_RANGE = {
- endTime: '1564691609186',
- endTimeFormat: '2019-08-01T20:33:29.186Z',
- endTimeTimeline: '1564779809186',
- endTimeTimelineFormat: '2019-08-02T21:03:29.186Z',
- endTimeTimelineTyped: 'Aug 02, 2019 @ 21:03:29.186',
- endTimeTyped: 'Aug 01, 2019 @ 14:33:29.186',
- newEndTime: '1564693409186',
- newEndTimeFormat: '2019-08-01T21:03:29.186Z',
- newEndTimeTyped: 'Aug 01, 2019 @ 15:03:29.186',
- newStartTime: '1564691609186',
- newStartTimeFormat: '2019-08-01T20:33:29.186Z',
- newStartTimeTyped: 'Aug 01, 2019 @ 14:33:29.186',
- startTime: '1564689809186',
- startTimeFormat: '2019-08-01T20:03:29.186Z',
- startTimeTimeline: '1564776209186',
- startTimeTimelineFormat: '2019-08-02T20:03:29.186Z',
- startTimeTimelineTyped: 'Aug 02, 2019 @ 14:03:29.186',
- startTimeTyped: 'Aug 01, 2019 @ 14:03:29.186',
url:
'/app/siem#/network/?timerange=(global:(linkTo:!(timeline),timerange:(from:1564689809186,kind:absolute,to:1564691609186)),timeline:(linkTo:!(global),timerange:(from:1564689809186,kind:absolute,to:1564691609186)))',
@@ -41,6 +23,8 @@ export const ABSOLUTE_DATE_RANGE = {
urlKqlHostsHosts: `/app/siem#/hosts/allHosts?query=(language:kuery,query:'source.ip:%20"10.142.0.9"')&timerange=(global:(linkTo:!(timeline),timerange:(from:1564689809186,kind:absolute,to:1564691609186)),timeline:(linkTo:!(global),timerange:(from:1564689809186,kind:absolute,to:1564691609186)))`,
urlHost:
'/app/siem#/hosts/authentications?timerange=(global:(linkTo:!(timeline),timerange:(from:1564689809186,kind:absolute,to:1564691609186)),timeline:(linkTo:!(global),timerange:(from:1564689809186,kind:absolute,to:1564691609186)))',
+ urlHostNew:
+ '/app/siem#/hosts/authentications?timerange=(global:(linkTo:!(timeline),timerange:(from:1564689809186,kind:absolute,to:1577914409186)),timeline:(linkTo:!(global),timerange:(from:1564689809186,kind:absolute,to:1577914409186)))',
};
export const DATE_PICKER_START_DATE_POPOVER_BUTTON =
'div[data-test-subj="globalDatePicker"] button[data-test-subj="superDatePickerstartDatePopoverButton"]';
diff --git a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/data_providers.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/data_providers.spec.ts
index c3fedfb06939b..7d1ee43b1b509 100644
--- a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/data_providers.spec.ts
+++ b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/data_providers.spec.ts
@@ -11,7 +11,7 @@ import {
dragFirstHostToTimeline,
dragFirstHostToEmptyTimelineDataProviders,
} from '../../../tasks/hosts/all_hosts';
-import { HOSTS_NAMES } from '../../../screens/hosts/all_hosts';
+import { HOSTS_NAMES_DRAGGABLE } from '../../../screens/hosts/all_hosts';
import { DEFAULT_TIMEOUT, loginAndWaitForPage } from '../../../tasks/login';
import { createNewTimeline } from '../../../tasks/timeline/main';
import { openTimeline } from '../../../tasks/siem_main';
@@ -42,7 +42,7 @@ describe('timeline data providers', () => {
.first()
.invoke('text')
.then(dataProviderText => {
- cy.get(HOSTS_NAMES)
+ cy.get(HOSTS_NAMES_DRAGGABLE)
.first()
.invoke('text')
.should(hostname => {
diff --git a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/url_state/url_state.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/url_state/url_state.spec.ts
index cbd1b2a074a59..4345938c8867e 100644
--- a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/url_state/url_state.spec.ts
+++ b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/url_state/url_state.spec.ts
@@ -4,32 +4,54 @@
* you may not use this file except in compliance with the Elastic License.
*/
+import { ABSOLUTE_DATE_RANGE } from '../../../urls/state';
+import { DEFAULT_TIMEOUT, loginAndWaitForPage } from '../../../tasks/login';
+import { HOSTS_PAGE } from '../../../urls/navigation';
import {
- ABSOLUTE_DATE_RANGE,
- DATE_PICKER_ABSOLUTE_INPUT,
- DATE_PICKER_ABSOLUTE_TAB,
- DATE_PICKER_APPLY_BUTTON,
- DATE_PICKER_APPLY_BUTTON_TIMELINE,
- DATE_PICKER_END_DATE_POPOVER_BUTTON,
+ setStartDate,
+ setEndDate,
+ updateDates,
+ setTimelineStartDate,
+ setTimelineEndDate,
+ updateTimelineDates,
+} from '../../../tasks/calendar';
+import { waitForIpsTableToBeLoaded } from '../../../tasks/network/flows';
+import { openTimeline } from '../../../tasks/siem_main';
+import {
+ DATE_PICKER_START_DATE_POPOVER_BUTTON_TIMELINE,
DATE_PICKER_END_DATE_POPOVER_BUTTON_TIMELINE,
DATE_PICKER_START_DATE_POPOVER_BUTTON,
- DATE_PICKER_START_DATE_POPOVER_BUTTON_TIMELINE,
- KQL_INPUT,
- TIMELINE_TITLE,
- HOST_DETAIL_SIEM_KIBANA,
- BREADCRUMBS,
-} from '../../lib/url_state';
-import { DEFAULT_TIMEOUT, loginAndWaitForPage } from '../../lib/util/helpers';
-import {
- assertAtLeastOneEventMatchesSearch,
- executeKQL,
- hostExistsQuery,
- toggleTimelineVisibility,
-} from '../../lib/timeline/helpers';
-import { NAVIGATION_NETWORK, NAVIGATION_HOSTS } from '../../lib/navigation/selectors';
-import { HOSTS_PAGE } from '../../lib/urls';
-import { waitForAllHostsWidget } from '../../lib/hosts/helpers';
-import { NAVIGATION_HOSTS_ALL_HOSTS, NAVIGATION_HOSTS_ANOMALIES } from '../../lib/hosts/selectors';
+ DATE_PICKER_END_DATE_POPOVER_BUTTON,
+} from '../../../screens/calendar';
+import { kqlSearch, navigateFromHeaderTo, clearSearchBar } from '../../../tasks/header';
+import { HOSTS, NETWORK, KQL_INPUT, BREADCRUMBS } from '../../../screens/header';
+import { openAllHosts } from '../../../tasks/hosts/main';
+import { ANOMALIES_TAB } from '../../../screens/hosts/main';
+import { waitForAllHostsToBeLoaded, openFirstHostDetails } from '../../../tasks/hosts/all_hosts';
+import { HOSTS_NAMES } from '../../../screens/hosts/all_hosts';
+import { executeTimelineKQL, addNameToTimeline } from '../../../tasks/timeline/main';
+import { SERVER_SIDE_EVENT_COUNT, TIMELINE_TITLE } from '../../../screens/timeline/main';
+
+const ABSOLUTE_DATE = {
+ endTime: '1564691609186',
+ endTimeFormat: '2019-08-01T20:33:29.186Z',
+ endTimeTimeline: '1564779809186',
+ endTimeTimelineFormat: '2019-08-02T21:03:29.186Z',
+ endTimeTimelineTyped: 'Aug 02, 2019 @ 21:03:29.186',
+ endTimeTyped: 'Aug 01, 2019 @ 14:33:29.186',
+ newEndTime: '1564693409186',
+ newEndTimeFormat: '2019-08-01T21:03:29.186Z',
+ newEndTimeTyped: 'Aug 01, 2019 @ 15:03:29.186',
+ newStartTime: '1564691609186',
+ newStartTimeFormat: '2019-08-01T20:33:29.186Z',
+ newStartTimeTyped: 'Aug 01, 2019 @ 14:33:29.186',
+ startTime: '1564689809186',
+ startTimeFormat: '2019-08-01T20:03:29.186Z',
+ startTimeTimeline: '1564776209186',
+ startTimeTimelineFormat: '2019-08-02T20:03:29.186Z',
+ startTimeTimelineTyped: 'Aug 02, 2019 @ 14:03:29.186',
+ startTimeTyped: 'Aug 01, 2019 @ 14:03:29.186',
+};
describe('url state', () => {
it('sets the global start and end dates from the url', () => {
@@ -37,162 +59,122 @@ describe('url state', () => {
cy.get(DATE_PICKER_START_DATE_POPOVER_BUTTON).should(
'have.attr',
'title',
- ABSOLUTE_DATE_RANGE.startTimeFormat
+ ABSOLUTE_DATE.startTimeFormat
);
cy.get(DATE_PICKER_END_DATE_POPOVER_BUTTON).should(
'have.attr',
'title',
- ABSOLUTE_DATE_RANGE.endTimeFormat
+ ABSOLUTE_DATE.endTimeFormat
);
});
it('sets the url state when start and end date are set', () => {
loginAndWaitForPage(ABSOLUTE_DATE_RANGE.url);
-
- cy.get(DATE_PICKER_START_DATE_POPOVER_BUTTON).click({ force: true });
-
- cy.get(DATE_PICKER_ABSOLUTE_TAB)
- .first()
- .click({ force: true });
-
- cy.get(DATE_PICKER_ABSOLUTE_INPUT, { timeout: DEFAULT_TIMEOUT })
- .clear()
- .type(`${ABSOLUTE_DATE_RANGE.newStartTimeTyped}`);
-
- cy.get(DATE_PICKER_APPLY_BUTTON, { timeout: DEFAULT_TIMEOUT })
- .click({ force: true })
- .invoke('text', { timeout: DEFAULT_TIMEOUT })
- .should('not.equal', 'Updating');
-
- cy.get('[data-test-subj="table-topNFlowSource-loading-false"]', {
- timeout: DEFAULT_TIMEOUT,
- }).should('exist');
-
- cy.get(DATE_PICKER_END_DATE_POPOVER_BUTTON).click({ force: true });
-
- cy.get(DATE_PICKER_ABSOLUTE_TAB)
- .first()
- .click({ force: true });
-
- cy.get(DATE_PICKER_ABSOLUTE_INPUT, { timeout: DEFAULT_TIMEOUT })
- .clear()
- .type(`${ABSOLUTE_DATE_RANGE.newEndTimeTyped}`);
-
- cy.get(DATE_PICKER_APPLY_BUTTON, { timeout: DEFAULT_TIMEOUT })
- .click({ force: true })
- .invoke('text', { timeout: DEFAULT_TIMEOUT })
- .should('not.equal', 'Updating');
+ setStartDate(ABSOLUTE_DATE.newStartTimeTyped);
+ updateDates();
+ waitForIpsTableToBeLoaded();
+ setEndDate(ABSOLUTE_DATE.newEndTimeTyped);
+ updateDates();
cy.url().should(
'include',
`(global:(linkTo:!(timeline),timerange:(from:${new Date(
- ABSOLUTE_DATE_RANGE.newStartTimeTyped
- ).valueOf()},kind:absolute,to:${new Date(ABSOLUTE_DATE_RANGE.newEndTimeTyped).valueOf()}))`
+ ABSOLUTE_DATE.newStartTimeTyped
+ ).valueOf()},kind:absolute,to:${new Date(ABSOLUTE_DATE.newEndTimeTyped).valueOf()}))`
);
});
it('sets the timeline start and end dates from the url when locked to global time', () => {
loginAndWaitForPage(ABSOLUTE_DATE_RANGE.url);
- toggleTimelineVisibility();
+ openTimeline();
+
cy.get(DATE_PICKER_START_DATE_POPOVER_BUTTON_TIMELINE).should(
'have.attr',
'title',
- ABSOLUTE_DATE_RANGE.startTimeFormat
+ ABSOLUTE_DATE.startTimeFormat
);
cy.get(DATE_PICKER_END_DATE_POPOVER_BUTTON_TIMELINE).should(
'have.attr',
'title',
- ABSOLUTE_DATE_RANGE.endTimeFormat
+ ABSOLUTE_DATE.endTimeFormat
);
});
it('sets the timeline start and end dates independently of the global start and end dates when times are unlocked', () => {
loginAndWaitForPage(ABSOLUTE_DATE_RANGE.urlUnlinked);
+
cy.get(DATE_PICKER_START_DATE_POPOVER_BUTTON).should(
'have.attr',
'title',
- ABSOLUTE_DATE_RANGE.startTimeFormat
+ ABSOLUTE_DATE.startTimeFormat
);
cy.get(DATE_PICKER_END_DATE_POPOVER_BUTTON).should(
'have.attr',
'title',
- ABSOLUTE_DATE_RANGE.endTimeFormat
+ ABSOLUTE_DATE.endTimeFormat
);
- toggleTimelineVisibility();
+ openTimeline();
+
cy.get(DATE_PICKER_START_DATE_POPOVER_BUTTON_TIMELINE).should(
'have.attr',
'title',
- ABSOLUTE_DATE_RANGE.startTimeTimelineFormat
+ ABSOLUTE_DATE.startTimeTimelineFormat
);
cy.get(DATE_PICKER_END_DATE_POPOVER_BUTTON_TIMELINE).should(
'have.attr',
'title',
- ABSOLUTE_DATE_RANGE.endTimeTimelineFormat
+ ABSOLUTE_DATE.endTimeTimelineFormat
);
});
it('sets the url state when timeline/global date pickers are unlinked and timeline start and end date are set', () => {
loginAndWaitForPage(ABSOLUTE_DATE_RANGE.urlUnlinked);
-
- toggleTimelineVisibility();
- cy.get(DATE_PICKER_START_DATE_POPOVER_BUTTON_TIMELINE, { timeout: DEFAULT_TIMEOUT }).click({
- force: true,
- });
-
- cy.get(DATE_PICKER_ABSOLUTE_TAB)
- .first()
- .click({ force: true });
-
- cy.get(DATE_PICKER_ABSOLUTE_INPUT, { timeout: 5000 }).type(
- `{selectall}{backspace}${ABSOLUTE_DATE_RANGE.newStartTimeTyped}`
- );
-
- cy.get(DATE_PICKER_APPLY_BUTTON_TIMELINE).click({ force: true });
-
- cy.get(DATE_PICKER_END_DATE_POPOVER_BUTTON_TIMELINE).click({ force: true });
-
- cy.get(DATE_PICKER_ABSOLUTE_TAB)
- .first()
- .click({ force: true });
-
- cy.get(DATE_PICKER_ABSOLUTE_INPUT, { timeout: 5000 }).type(
- `{selectall}{backspace}${ABSOLUTE_DATE_RANGE.newEndTimeTyped}{enter}`
- );
-
- cy.get(DATE_PICKER_APPLY_BUTTON_TIMELINE).click({ force: true });
+ openTimeline();
+ setTimelineStartDate(ABSOLUTE_DATE.newStartTimeTyped);
+ updateTimelineDates();
+ setTimelineEndDate(ABSOLUTE_DATE.newEndTimeTyped);
+ updateTimelineDates();
cy.url().should(
'include',
`timeline:(linkTo:!(),timerange:(from:${new Date(
- ABSOLUTE_DATE_RANGE.newStartTimeTyped
- ).valueOf()},kind:absolute,to:${new Date(ABSOLUTE_DATE_RANGE.newEndTimeTyped).valueOf()}))`
+ ABSOLUTE_DATE.newStartTimeTyped
+ ).valueOf()},kind:absolute,to:${new Date(ABSOLUTE_DATE.newEndTimeTyped).valueOf()}))`
);
});
it('sets kql on network page', () => {
loginAndWaitForPage(ABSOLUTE_DATE_RANGE.urlKqlNetworkNetwork);
- cy.get(KQL_INPUT, { timeout: 5000 }).should('have.attr', 'value', 'source.ip: "10.142.0.9"');
+ cy.get(KQL_INPUT, { timeout: DEFAULT_TIMEOUT }).should(
+ 'have.attr',
+ 'value',
+ 'source.ip: "10.142.0.9"'
+ );
});
it('sets kql on hosts page', () => {
loginAndWaitForPage(ABSOLUTE_DATE_RANGE.urlKqlHostsHosts);
- cy.get(KQL_INPUT, { timeout: 5000 }).should('have.attr', 'value', 'source.ip: "10.142.0.9"');
+ cy.get(KQL_INPUT, { timeout: DEFAULT_TIMEOUT }).should(
+ 'have.attr',
+ 'value',
+ 'source.ip: "10.142.0.9"'
+ );
});
it('sets the url state when kql is set', () => {
loginAndWaitForPage(ABSOLUTE_DATE_RANGE.url);
- cy.get(KQL_INPUT, { timeout: 5000 }).type('source.ip: "10.142.0.9" {enter}');
+ kqlSearch('source.ip: "10.142.0.9" {enter}');
+
cy.url().should('include', `query=(language:kuery,query:'source.ip:%20%2210.142.0.9%22%20')`);
});
it('sets the url state when kql is set and check if href reflect this change', () => {
loginAndWaitForPage(ABSOLUTE_DATE_RANGE.url);
- cy.get(KQL_INPUT, { timeout: 5000 }).type('source.ip: "10.142.0.9" {enter}');
- cy.get(NAVIGATION_HOSTS)
- .first()
- .click({ force: true });
- cy.get(NAVIGATION_NETWORK).should(
+ kqlSearch('source.ip: "10.142.0.9" {enter}');
+ navigateFromHeaderTo(HOSTS);
+
+ cy.get(NETWORK).should(
'have.attr',
'href',
"#/link-to/network?query=(language:kuery,query:'source.ip:%20%2210.142.0.9%22%20')&timerange=(global:(linkTo:!(timeline),timerange:(from:1564689809186,kind:absolute,to:1564691609186)),timeline:(linkTo:!(global),timerange:(from:1564689809186,kind:absolute,to:1564691609186)))"
@@ -200,65 +182,74 @@ describe('url state', () => {
});
it('sets KQL in host page and detail page and check if href match on breadcrumb, tabs and subTabs', () => {
- loginAndWaitForPage(ABSOLUTE_DATE_RANGE.urlHost);
- cy.get(KQL_INPUT, { timeout: 5000 }).type('host.name: "siem-kibana" {enter}');
- cy.get(NAVIGATION_HOSTS_ALL_HOSTS, { timeout: 5000 })
- .first()
- .click({ force: true });
- waitForAllHostsWidget();
- cy.get(NAVIGATION_HOSTS).should(
+ loginAndWaitForPage(ABSOLUTE_DATE_RANGE.urlHostNew);
+ kqlSearch('host.name: "siem-kibana" {enter}');
+ openAllHosts();
+ waitForAllHostsToBeLoaded();
+
+ cy.get(HOSTS).should(
'have.attr',
'href',
- "#/link-to/hosts?query=(language:kuery,query:'host.name:%20%22siem-kibana%22%20')&timerange=(global:(linkTo:!(timeline),timerange:(from:1564689809186,kind:absolute,to:1564691609186)),timeline:(linkTo:!(global),timerange:(from:1564689809186,kind:absolute,to:1564691609186)))"
+ "#/link-to/hosts?query=(language:kuery,query:'host.name:%20%22siem-kibana%22%20')&timerange=(global:(linkTo:!(timeline),timerange:(from:1564689809186,kind:absolute,to:1577914409186)),timeline:(linkTo:!(global),timerange:(from:1564689809186,kind:absolute,to:1577914409186)))"
);
- cy.get(NAVIGATION_NETWORK).should(
+ cy.get(NETWORK).should(
'have.attr',
'href',
- "#/link-to/network?query=(language:kuery,query:'host.name:%20%22siem-kibana%22%20')&timerange=(global:(linkTo:!(timeline),timerange:(from:1564689809186,kind:absolute,to:1564691609186)),timeline:(linkTo:!(global),timerange:(from:1564689809186,kind:absolute,to:1564691609186)))"
+ "#/link-to/network?query=(language:kuery,query:'host.name:%20%22siem-kibana%22%20')&timerange=(global:(linkTo:!(timeline),timerange:(from:1564689809186,kind:absolute,to:1577914409186)),timeline:(linkTo:!(global),timerange:(from:1564689809186,kind:absolute,to:1577914409186)))"
);
- cy.get(HOST_DETAIL_SIEM_KIBANA, { timeout: 5000 })
+ cy.get(HOSTS_NAMES, { timeout: DEFAULT_TIMEOUT })
.first()
.invoke('text')
.should('eq', 'siem-kibana');
- cy.get(HOST_DETAIL_SIEM_KIBANA)
- .first()
- .click({ force: true });
- cy.get(KQL_INPUT, { timeout: 5000 }).clear();
- cy.get(KQL_INPUT, { timeout: 5000 }).type('agent.type: "auditbeat" {enter}');
- cy.get(NAVIGATION_HOSTS_ANOMALIES).should(
+
+ openFirstHostDetails();
+ clearSearchBar();
+ kqlSearch('agent.type: "auditbeat" {enter}');
+
+ cy.get(ANOMALIES_TAB).should(
'have.attr',
'href',
- "#/hosts/siem-kibana/anomalies?query=(language:kuery,query:'agent.type:%20%22auditbeat%22%20')&timerange=(global:(linkTo:!(timeline),timerange:(from:1564689809186,kind:absolute,to:1564691609186)),timeline:(linkTo:!(global),timerange:(from:1564689809186,kind:absolute,to:1564691609186)))"
+ "#/hosts/siem-kibana/anomalies?query=(language:kuery,query:'agent.type:%20%22auditbeat%22%20')&timerange=(global:(linkTo:!(timeline),timerange:(from:1564689809186,kind:absolute,to:1577914409186)),timeline:(linkTo:!(global),timerange:(from:1564689809186,kind:absolute,to:1577914409186)))"
);
cy.get(BREADCRUMBS)
.eq(1)
.should(
'have.attr',
'href',
- "#/link-to/hosts?query=(language:kuery,query:'agent.type:%20%22auditbeat%22%20')&timerange=(global:(linkTo:!(timeline),timerange:(from:1564689809186,kind:absolute,to:1564691609186)),timeline:(linkTo:!(global),timerange:(from:1564689809186,kind:absolute,to:1564691609186)))"
+ "#/link-to/hosts?query=(language:kuery,query:'agent.type:%20%22auditbeat%22%20')&timerange=(global:(linkTo:!(timeline),timerange:(from:1564689809186,kind:absolute,to:1577914409186)),timeline:(linkTo:!(global),timerange:(from:1564689809186,kind:absolute,to:1577914409186)))"
);
cy.get(BREADCRUMBS)
.eq(2)
.should(
'have.attr',
'href',
- "#/link-to/hosts/siem-kibana?query=(language:kuery,query:'agent.type:%20%22auditbeat%22%20')&timerange=(global:(linkTo:!(timeline),timerange:(from:1564689809186,kind:absolute,to:1564691609186)),timeline:(linkTo:!(global),timerange:(from:1564689809186,kind:absolute,to:1564691609186)))"
+ "#/link-to/hosts/siem-kibana?query=(language:kuery,query:'agent.type:%20%22auditbeat%22%20')&timerange=(global:(linkTo:!(timeline),timerange:(from:1564689809186,kind:absolute,to:1577914409186)),timeline:(linkTo:!(global),timerange:(from:1564689809186,kind:absolute,to:1577914409186)))"
);
});
it('Do not clears kql when navigating to a new page', () => {
loginAndWaitForPage(ABSOLUTE_DATE_RANGE.urlKqlHostsHosts);
- cy.get(NAVIGATION_NETWORK).click({ force: true });
- cy.get(KQL_INPUT, { timeout: 5000 }).should('have.attr', 'value', 'source.ip: "10.142.0.9"');
+ navigateFromHeaderTo(NETWORK);
+
+ cy.get(KQL_INPUT, { timeout: DEFAULT_TIMEOUT }).should(
+ 'have.attr',
+ 'value',
+ 'source.ip: "10.142.0.9"'
+ );
});
it('sets and reads the url state for timeline by id', () => {
loginAndWaitForPage(HOSTS_PAGE);
- toggleTimelineVisibility();
- executeKQL(hostExistsQuery);
- assertAtLeastOneEventMatchesSearch();
+ openTimeline();
+ executeTimelineKQL('host.name: *');
+
+ cy.get(SERVER_SIDE_EVENT_COUNT, { timeout: DEFAULT_TIMEOUT })
+ .invoke('text')
+ .should('be.above', 0);
+
const bestTimelineName = 'The Best Timeline';
- cy.get(TIMELINE_TITLE, { timeout: 5000 }).type(bestTimelineName);
+ addNameToTimeline(bestTimelineName);
+
cy.url().should('include', 'timeline=');
cy.visit(
`/app/siem#/timelines?timerange=(global:(linkTo:!(),timerange:(from:1565274377369,kind:absolute,to:1565360777369)),timeline:(linkTo:!(),timerange:(from:1565274377369,kind:absolute,to:1565360777369)))`
diff --git a/x-pack/legacy/plugins/siem/cypress/screens/calendar.ts b/x-pack/legacy/plugins/siem/cypress/screens/calendar.ts
new file mode 100644
index 0000000000000..caff721c683e9
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/cypress/screens/calendar.ts
@@ -0,0 +1,21 @@
+/*
+ * 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 const DATE_PICKER_START_DATE_POPOVER_BUTTON =
+ 'div[data-test-subj="globalDatePicker"] button[data-test-subj="superDatePickerstartDatePopoverButton"]';
+export const DATE_PICKER_END_DATE_POPOVER_BUTTON =
+ '[data-test-subj="globalDatePicker"] [data-test-subj="superDatePickerendDatePopoverButton"]';
+export const DATE_PICKER_ABSOLUTE_TAB = '[data-test-subj="superDatePickerAbsoluteTab"]';
+export const DATE_PICKER_APPLY_BUTTON =
+ '[data-test-subj="globalDatePicker"] button[data-test-subj="querySubmitButton"]';
+export const DATE_PICKER_APPLY_BUTTON_TIMELINE =
+ '[data-test-subj="timeline-properties"] button[data-test-subj="superDatePickerApplyTimeButton"]';
+export const DATE_PICKER_ABSOLUTE_INPUT = '[data-test-subj="superDatePickerAbsoluteDateInput"]';
+export const DATE_PICKER_START_DATE_POPOVER_BUTTON_TIMELINE =
+ '[data-test-subj="timeline-properties"] [data-test-subj="superDatePickerstartDatePopoverButton"]';
+
+export const DATE_PICKER_END_DATE_POPOVER_BUTTON_TIMELINE =
+ '[data-test-subj="timeline-properties"] [data-test-subj="superDatePickerendDatePopoverButton"]';
diff --git a/x-pack/legacy/plugins/siem/cypress/screens/header.ts b/x-pack/legacy/plugins/siem/cypress/screens/header.ts
index 344fa1829bdec..4ffb497a62432 100644
--- a/x-pack/legacy/plugins/siem/cypress/screens/header.ts
+++ b/x-pack/legacy/plugins/siem/cypress/screens/header.ts
@@ -15,3 +15,5 @@ export const OVERVIEW = '[data-test-subj="navigation-overview"]';
export const TIMELINES = '[data-test-subj="navigation-timelines"]';
export const REFRESH_BUTTON = '[data-test-subj="querySubmitButton"]';
+
+export const BREADCRUMBS = '[data-test-subj="breadcrumbs"] a';
diff --git a/x-pack/legacy/plugins/siem/cypress/screens/hosts/all_hosts.ts b/x-pack/legacy/plugins/siem/cypress/screens/hosts/all_hosts.ts
index f316356580814..61f39ca7a8b0c 100644
--- a/x-pack/legacy/plugins/siem/cypress/screens/hosts/all_hosts.ts
+++ b/x-pack/legacy/plugins/siem/cypress/screens/hosts/all_hosts.ts
@@ -6,4 +6,6 @@
export const ALL_HOSTS_TABLE = '[data-test-subj="table-allHosts-loading-false"]';
-export const HOSTS_NAMES = '[data-test-subj="draggable-content-host.name"]';
+export const HOSTS_NAMES_DRAGGABLE = '[data-test-subj="draggable-content-host.name"]';
+
+export const HOSTS_NAMES = '[data-test-subj="draggable-content-host.name"] a.euiLink';
diff --git a/x-pack/legacy/plugins/siem/cypress/screens/hosts/main.ts b/x-pack/legacy/plugins/siem/cypress/screens/hosts/main.ts
index 2187ca40a38a4..25696be526e5f 100644
--- a/x-pack/legacy/plugins/siem/cypress/screens/hosts/main.ts
+++ b/x-pack/legacy/plugins/siem/cypress/screens/hosts/main.ts
@@ -10,4 +10,8 @@ export const AUTHENTICATIONS_TAB = '[data-test-subj="navigation-authentications"
export const UNCOMMON_PROCESSES_TAB = '[data-test-subj="navigation-uncommonProcesses"]';
+export const ALL_HOSTS_TAB = '[data-test-subj="navigation-allHosts';
+
+export const ANOMALIES_TAB = '[data-test-subj="navigation-anomalies"]';
+
export const KQL_SEARCH_BAR = '[data-test-subj="queryInput"]';
diff --git a/x-pack/legacy/plugins/siem/cypress/screens/network/flows.ts b/x-pack/legacy/plugins/siem/cypress/screens/network/flows.ts
new file mode 100644
index 0000000000000..6a2160438c455
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/cypress/screens/network/flows.ts
@@ -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 const IPS_TABLE_LOADED = '[data-test-subj="table-topNFlowSource-loading-false"]';
diff --git a/x-pack/legacy/plugins/siem/cypress/screens/timeline/main.ts b/x-pack/legacy/plugins/siem/cypress/screens/timeline/main.ts
index 7b47c159c4a0a..6df269b7691a8 100644
--- a/x-pack/legacy/plugins/siem/cypress/screens/timeline/main.ts
+++ b/x-pack/legacy/plugins/siem/cypress/screens/timeline/main.ts
@@ -49,3 +49,5 @@ export const TIMESTAMP_HEADER_FIELD = '[data-test-subj="header-text-@timestamp"]
export const ID_HEADER_FIELD = '[data-test-subj="timeline"] [data-test-subj="header-text-_id"]';
export const ID_FIELD = '[data-test-subj="timeline"] [data-test-subj="field-name-_id"]';
+
+export const TIMELINE_TITLE = '[data-test-subj="timeline-title"]';
diff --git a/x-pack/legacy/plugins/siem/cypress/tasks/calendar.ts b/x-pack/legacy/plugins/siem/cypress/tasks/calendar.ts
new file mode 100644
index 0000000000000..16231317d6aef
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/cypress/tasks/calendar.ts
@@ -0,0 +1,79 @@
+/*
+ * 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 {
+ DATE_PICKER_START_DATE_POPOVER_BUTTON_TIMELINE,
+ DATE_PICKER_END_DATE_POPOVER_BUTTON_TIMELINE,
+ DATE_PICKER_APPLY_BUTTON_TIMELINE,
+ DATE_PICKER_START_DATE_POPOVER_BUTTON,
+ DATE_PICKER_ABSOLUTE_TAB,
+ DATE_PICKER_ABSOLUTE_INPUT,
+ DATE_PICKER_APPLY_BUTTON,
+ DATE_PICKER_END_DATE_POPOVER_BUTTON,
+} from '../screens/calendar';
+
+import { DEFAULT_TIMEOUT } from '../tasks/login';
+
+export const setStartDate = (date: string) => {
+ cy.get(DATE_PICKER_START_DATE_POPOVER_BUTTON).click({ force: true });
+
+ cy.get(DATE_PICKER_ABSOLUTE_TAB)
+ .first()
+ .click({ force: true });
+
+ cy.get(DATE_PICKER_ABSOLUTE_INPUT, { timeout: DEFAULT_TIMEOUT })
+ .clear()
+ .type(date);
+};
+
+export const setEndDate = (date: string) => {
+ cy.get(DATE_PICKER_END_DATE_POPOVER_BUTTON).click({ force: true });
+
+ cy.get(DATE_PICKER_ABSOLUTE_TAB)
+ .first()
+ .click({ force: true });
+
+ cy.get(DATE_PICKER_ABSOLUTE_INPUT, { timeout: DEFAULT_TIMEOUT })
+ .clear()
+ .type(date);
+};
+
+export const updateDates = () => {
+ cy.get(DATE_PICKER_APPLY_BUTTON, { timeout: DEFAULT_TIMEOUT })
+ .click({ force: true })
+ .invoke('text', { timeout: DEFAULT_TIMEOUT })
+ .should('not.equal', 'Updating');
+};
+
+export const setTimelineStartDate = (date: string) => {
+ cy.get(DATE_PICKER_START_DATE_POPOVER_BUTTON_TIMELINE, { timeout: DEFAULT_TIMEOUT }).click({
+ force: true,
+ });
+
+ cy.get(DATE_PICKER_ABSOLUTE_TAB)
+ .first()
+ .click({ force: true });
+
+ cy.get(DATE_PICKER_ABSOLUTE_INPUT, { timeout: DEFAULT_TIMEOUT }).type(
+ `{selectall}{backspace}${date}{enter}`
+ );
+};
+
+export const setTimelineEndDate = (date: string) => {
+ cy.get(DATE_PICKER_END_DATE_POPOVER_BUTTON_TIMELINE).click({ force: true });
+
+ cy.get(DATE_PICKER_ABSOLUTE_TAB)
+ .first()
+ .click({ force: true });
+
+ cy.get(DATE_PICKER_ABSOLUTE_INPUT, { timeout: DEFAULT_TIMEOUT }).type(
+ `{selectall}{backspace}${date}{enter}`
+ );
+};
+
+export const updateTimelineDates = () => {
+ cy.get(DATE_PICKER_APPLY_BUTTON_TIMELINE).click({ force: true });
+};
diff --git a/x-pack/legacy/plugins/siem/cypress/tasks/header.ts b/x-pack/legacy/plugins/siem/cypress/tasks/header.ts
index 1c2f21c40dfba..4067779136d9e 100644
--- a/x-pack/legacy/plugins/siem/cypress/tasks/header.ts
+++ b/x-pack/legacy/plugins/siem/cypress/tasks/header.ts
@@ -5,7 +5,7 @@
*/
import { DEFAULT_TIMEOUT } from '../tasks/login';
-import { REFRESH_BUTTON, KQL_INPUT } from '../screens/header';
+import { KQL_INPUT, REFRESH_BUTTON } from '../screens/header';
export const navigateFromHeaderTo = (page: string) => {
cy.get(page).click({ force: true });
@@ -23,3 +23,7 @@ export const refreshPage = () => {
.invoke('text', { timeout: DEFAULT_TIMEOUT })
.should('not.equal', 'Updating');
};
+
+export const kqlSearch = (search: string) => {
+ cy.get(KQL_INPUT, { timeout: DEFAULT_TIMEOUT }).type(search);
+};
diff --git a/x-pack/legacy/plugins/siem/cypress/tasks/hosts/all_hosts.ts b/x-pack/legacy/plugins/siem/cypress/tasks/hosts/all_hosts.ts
index 43e2a7e1bef11..7146c132db4a0 100644
--- a/x-pack/legacy/plugins/siem/cypress/tasks/hosts/all_hosts.ts
+++ b/x-pack/legacy/plugins/siem/cypress/tasks/hosts/all_hosts.ts
@@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { ALL_HOSTS_TABLE, HOSTS_NAMES } from '../../screens/hosts/all_hosts';
+import { ALL_HOSTS_TABLE, HOSTS_NAMES_DRAGGABLE, HOSTS_NAMES } from '../../screens/hosts/all_hosts';
import {
TIMELINE_DATA_PROVIDERS,
TIMELINE_DATA_PROVIDERS_EMPTY,
@@ -17,20 +17,20 @@ export const waitForAllHostsToBeLoaded = () => {
};
export const dragAndDropFirstHostToTimeline = () => {
- cy.get(HOSTS_NAMES)
+ cy.get(HOSTS_NAMES_DRAGGABLE)
.first()
.then(firstHost => drag(firstHost));
cy.get(TIMELINE_DATA_PROVIDERS).then(dataProvidersDropArea => drop(dataProvidersDropArea));
};
export const dragFirstHostToTimeline = () => {
- cy.get(HOSTS_NAMES)
+ cy.get(HOSTS_NAMES_DRAGGABLE)
.first()
.then(host => drag(host));
};
export const dragFirstHostToEmptyTimelineDataProviders = () => {
- cy.get(HOSTS_NAMES)
+ cy.get(HOSTS_NAMES_DRAGGABLE)
.first()
.then(host => drag(host));
@@ -38,3 +38,9 @@ export const dragFirstHostToEmptyTimelineDataProviders = () => {
dragWithoutDrop(dataProvidersDropArea)
);
};
+
+export const openFirstHostDetails = () => {
+ cy.get(HOSTS_NAMES)
+ .first()
+ .click({ force: true });
+};
diff --git a/x-pack/legacy/plugins/siem/cypress/tasks/hosts/main.ts b/x-pack/legacy/plugins/siem/cypress/tasks/hosts/main.ts
index 11cd0c8405f26..cbf410b4bc232 100644
--- a/x-pack/legacy/plugins/siem/cypress/tasks/hosts/main.ts
+++ b/x-pack/legacy/plugins/siem/cypress/tasks/hosts/main.ts
@@ -6,7 +6,12 @@
import { DEFAULT_TIMEOUT } from '../../integration/lib/util/helpers';
-import { EVENTS_TAB, AUTHENTICATIONS_TAB, UNCOMMON_PROCESSES_TAB } from '../../screens/hosts/main';
+import {
+ EVENTS_TAB,
+ AUTHENTICATIONS_TAB,
+ UNCOMMON_PROCESSES_TAB,
+ ALL_HOSTS_TAB,
+} from '../../screens/hosts/main';
/** Clicks the Events tab on the hosts page */
export const openEvents = () =>
@@ -17,3 +22,6 @@ export const openAuthentications = () =>
export const openUncommonProcesses = () =>
cy.get(UNCOMMON_PROCESSES_TAB, { timeout: DEFAULT_TIMEOUT }).click({ force: true });
+
+export const openAllHosts = () =>
+ cy.get(ALL_HOSTS_TAB, { timeout: DEFAULT_TIMEOUT }).click({ force: true });
diff --git a/x-pack/legacy/plugins/siem/cypress/tasks/network/flows.ts b/x-pack/legacy/plugins/siem/cypress/tasks/network/flows.ts
new file mode 100644
index 0000000000000..c7b031aabc8cd
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/cypress/tasks/network/flows.ts
@@ -0,0 +1,12 @@
+/*
+ * 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 { IPS_TABLE_LOADED } from '../../screens/network/flows';
+import { DEFAULT_TIMEOUT } from '../../tasks/login';
+
+export const waitForIpsTableToBeLoaded = () => {
+ cy.get(IPS_TABLE_LOADED, { timeout: DEFAULT_TIMEOUT }).should('exist');
+};
diff --git a/x-pack/legacy/plugins/siem/cypress/tasks/timeline/main.ts b/x-pack/legacy/plugins/siem/cypress/tasks/timeline/main.ts
index d26c0f2911f75..80b5e4379212c 100644
--- a/x-pack/legacy/plugins/siem/cypress/tasks/timeline/main.ts
+++ b/x-pack/legacy/plugins/siem/cypress/tasks/timeline/main.ts
@@ -19,6 +19,7 @@ import {
ID_TOGGLE_FIELD,
ID_HEADER_FIELD,
ID_FIELD,
+ TIMELINE_TITLE,
} from '../../screens/timeline/main';
import { drag, drop } from '../../tasks/common';
@@ -86,3 +87,7 @@ export const dragAndDropIdToggleFieldToTimeline = () => {
drop(headersDropArea)
);
};
+
+export const addNameToTimeline = (name: string) => {
+ cy.get(TIMELINE_TITLE, { timeout: DEFAULT_TIMEOUT }).type(name);
+};
diff --git a/x-pack/legacy/plugins/siem/cypress/urls/state.ts b/x-pack/legacy/plugins/siem/cypress/urls/state.ts
new file mode 100644
index 0000000000000..18f4628aa7137
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/cypress/urls/state.ts
@@ -0,0 +1,21 @@
+/*
+ * 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 const ABSOLUTE_DATE_RANGE = {
+ url:
+ '/app/siem#/network/?timerange=(global:(linkTo:!(timeline),timerange:(from:1564689809186,kind:absolute,to:1564691609186)),timeline:(linkTo:!(global),timerange:(from:1564689809186,kind:absolute,to:1564691609186)))',
+
+ urlUnlinked:
+ '/app/siem#/network/?timerange=(global:(linkTo:!(),timerange:(from:1564689809186,kind:absolute,to:1564691609186)),timeline:(linkTo:!(),timerange:(from:1564776209186,kind:absolute,to:1564779809186)))',
+ urlKqlNetworkNetwork: `/app/siem#/network/?query=(language:kuery,query:'source.ip:%20"10.142.0.9"')&timerange=(global:(linkTo:!(timeline),timerange:(from:1564689809186,kind:absolute,to:1564691609186)),timeline:(linkTo:!(global),timerange:(from:1564689809186,kind:absolute,to:1564691609186)))`,
+ urlKqlNetworkHosts: `/app/siem#/network/?query=(language:kuery,query:'source.ip:%20"10.142.0.9"')&timerange=(global:(linkTo:!(timeline),timerange:(from:1564689809186,kind:absolute,to:1564691609186)),timeline:(linkTo:!(global),timerange:(from:1564689809186,kind:absolute,to:1564691609186)))`,
+ urlKqlHostsNetwork: `/app/siem#/hosts/allHosts?query=(language:kuery,query:'source.ip:%20"10.142.0.9"')&timerange=(global:(linkTo:!(timeline),timerange:(from:1564689809186,kind:absolute,to:1564691609186)),timeline:(linkTo:!(global),timerange:(from:1564689809186,kind:absolute,to:1564691609186)))`,
+ urlKqlHostsHosts: `/app/siem#/hosts/allHosts?query=(language:kuery,query:'source.ip:%20"10.142.0.9"')&timerange=(global:(linkTo:!(timeline),timerange:(from:1564689809186,kind:absolute,to:1564691609186)),timeline:(linkTo:!(global),timerange:(from:1564689809186,kind:absolute,to:1564691609186)))`,
+ urlHost:
+ '/app/siem#/hosts/authentications?timerange=(global:(linkTo:!(timeline),timerange:(from:1564689809186,kind:absolute,to:1564691609186)),timeline:(linkTo:!(global),timerange:(from:1564689809186,kind:absolute,to:1564691609186)))',
+ urlHostNew:
+ '/app/siem#/hosts/authentications?timerange=(global:(linkTo:!(timeline),timerange:(from:1564689809186,kind:absolute,to:1577914409186)),timeline:(linkTo:!(global),timerange:(from:1564689809186,kind:absolute,to:1577914409186)))',
+};
diff --git a/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.tsx b/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.tsx
index b05529f9a497f..8e2c5bdd12d68 100644
--- a/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.tsx
+++ b/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.tsx
@@ -157,7 +157,6 @@ const EventsViewerComponent: React.FC = ({
totalCountMinusDeleted
) ?? i18n.UNIT(totalCountMinusDeleted)}`;
- // TODO: Reset eventDeletedIds/eventLoadingIds on refresh/loadmore (getUpdatedAt)
return (
<>
= FetchMoreQueryOptions &
+export type FetchMoreOptionsArgs = FetchMoreQueryOptions &
FetchMoreOptions;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -40,6 +40,19 @@ export class QueryTemplate<
tiebreaker?: string
) => FetchMoreOptionsArgs;
+ private refetch!: (variables?: TVariables) => Promise>;
+
+ private executeBeforeFetchMore!: ({ id }: { id?: string }) => void;
+
+ private executeBeforeRefetch!: ({ id }: { id?: string }) => void;
+
+ public setExecuteBeforeFetchMore = (val: ({ id }: { id?: string }) => void) => {
+ this.executeBeforeFetchMore = val;
+ };
+ public setExecuteBeforeRefetch = (val: ({ id }: { id?: string }) => void) => {
+ this.executeBeforeRefetch = val;
+ };
+
public setFetchMore = (
val: (fetchMoreOptions: FetchMoreOptionsArgs) => PromiseApolloQueryResult
) => {
@@ -52,6 +65,17 @@ export class QueryTemplate<
this.fetchMoreOptions = val;
};
- public wrappedLoadMore = (newCursor: string, tiebreaker?: string) =>
- this.fetchMore(this.fetchMoreOptions(newCursor, tiebreaker));
+ public setRefetch = (val: (variables?: TVariables) => Promise>) => {
+ this.refetch = val;
+ };
+
+ public wrappedLoadMore = (newCursor: string, tiebreaker?: string) => {
+ this.executeBeforeFetchMore({ id: this.props.id });
+ return this.fetchMore(this.fetchMoreOptions(newCursor, tiebreaker));
+ };
+
+ public wrappedRefetch = (variables?: TVariables) => {
+ this.executeBeforeRefetch({ id: this.props.id });
+ return this.refetch(variables);
+ };
}
diff --git a/x-pack/legacy/plugins/siem/public/containers/timeline/index.tsx b/x-pack/legacy/plugins/siem/public/containers/timeline/index.tsx
index 68d87ef565fb7..ccd8babd41e68 100644
--- a/x-pack/legacy/plugins/siem/public/containers/timeline/index.tsx
+++ b/x-pack/legacy/plugins/siem/public/containers/timeline/index.tsx
@@ -8,7 +8,7 @@ import { getOr } from 'lodash/fp';
import memoizeOne from 'memoize-one';
import React from 'react';
import { Query } from 'react-apollo';
-import { compose } from 'redux';
+import { compose, Dispatch } from 'redux';
import { connect, ConnectedProps } from 'react-redux';
import { IIndexPattern } from '../../../../../../../src/plugins/data/common/index_patterns';
@@ -26,6 +26,8 @@ import { createFilter } from '../helpers';
import { QueryTemplate, QueryTemplateProps } from '../query_template';
import { EventType } from '../../store/timeline/model';
import { timelineQuery } from './index.gql_query';
+import { timelineActions } from '../../store/timeline';
+import { SIGNALS_PAGE_TIMELINE_ID } from '../../pages/detection_engine/components/signals';
export interface TimelineArgs {
events: TimelineItem[];
@@ -39,6 +41,10 @@ export interface TimelineArgs {
getUpdatedAt: () => number;
}
+export interface CustomReduxProps {
+ clearSignalsState: ({ id }: { id?: string }) => void;
+}
+
export interface OwnProps extends QueryTemplateProps {
children?: (args: TimelineArgs) => React.ReactNode;
eventType?: EventType;
@@ -50,7 +56,7 @@ export interface OwnProps extends QueryTemplateProps {
fields: string[];
}
-type TimelineQueryProps = OwnProps & PropsFromRedux & WithKibanaProps;
+type TimelineQueryProps = OwnProps & PropsFromRedux & WithKibanaProps & CustomReduxProps;
class TimelineQueryComponent extends QueryTemplate<
TimelineQueryProps,
@@ -68,6 +74,7 @@ class TimelineQueryComponent extends QueryTemplate<
public render() {
const {
children,
+ clearSignalsState,
eventType = 'raw',
id,
indexPattern,
@@ -97,6 +104,7 @@ class TimelineQueryComponent extends QueryTemplate<
defaultIndex,
inspect: isInspected,
};
+
return (
query={timelineQuery}
@@ -105,6 +113,10 @@ class TimelineQueryComponent extends QueryTemplate<
variables={variables}
>
{({ data, loading, fetchMore, refetch }) => {
+ this.setRefetch(refetch);
+ this.setExecuteBeforeRefetch(clearSignalsState);
+ this.setExecuteBeforeFetchMore(clearSignalsState);
+
const timelineEdges = getOr([], 'source.Timeline.edges', data);
this.setFetchMore(fetchMore);
this.setFetchMoreOptions((newCursor: string, tiebreaker?: string) => ({
@@ -138,7 +150,7 @@ class TimelineQueryComponent extends QueryTemplate<
return children!({
id,
inspect: getOr(null, 'source.Timeline.inspect', data),
- refetch,
+ refetch: this.wrappedRefetch,
loading,
totalCount: getOr(0, 'source.Timeline.totalCount', data),
pageInfo: getOr({}, 'source.Timeline.pageInfo', data),
@@ -168,7 +180,16 @@ const makeMapStateToProps = () => {
return mapStateToProps;
};
-const connector = connect(makeMapStateToProps);
+const mapDispatchToProps = (dispatch: Dispatch) => ({
+ clearSignalsState: ({ id }: { id?: string }) => {
+ if (id != null && id === SIGNALS_PAGE_TIMELINE_ID) {
+ dispatch(timelineActions.clearEventsLoading({ id }));
+ dispatch(timelineActions.clearEventsDeleted({ id }));
+ }
+ },
+});
+
+const connector = connect(makeMapStateToProps, mapDispatchToProps);
type PropsFromRedux = ConnectedProps;
diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/index.tsx
index 7cd26ac0cc41b..75f19218d9b38 100644
--- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/index.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/index.tsx
@@ -47,7 +47,7 @@ import {
} from './types';
import { dispatchUpdateTimeline } from '../../../../components/open_timeline/helpers';
-const SIGNALS_PAGE_TIMELINE_ID = 'signals-page';
+export const SIGNALS_PAGE_TIMELINE_ID = 'signals-page';
interface OwnProps {
canUserCRUD: boolean;
diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/signals_utility_bar/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/signals_utility_bar/index.tsx
index 13d77385c53d4..86772eb0e155d 100644
--- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/signals_utility_bar/index.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/signals_utility_bar/index.tsx
@@ -114,6 +114,7 @@ const SignalsUtilityBarComponent: React.FC = ({
export const SignalsUtilityBar = React.memo(
SignalsUtilityBarComponent,
(prevProps, nextProps) =>
+ prevProps.areEventsLoading === nextProps.areEventsLoading &&
prevProps.selectedEventIds === nextProps.selectedEventIds &&
prevProps.totalCount === nextProps.totalCount &&
prevProps.showClearSelection === nextProps.showClearSelection
diff --git a/x-pack/legacy/plugins/siem/public/store/timeline/helpers.ts b/x-pack/legacy/plugins/siem/public/store/timeline/helpers.ts
index f56bbc34ce165..fa70c1b04608d 100644
--- a/x-pack/legacy/plugins/siem/public/store/timeline/helpers.ts
+++ b/x-pack/legacy/plugins/siem/public/store/timeline/helpers.ts
@@ -1161,11 +1161,22 @@ export const setDeletedTimelineEvents = ({
? union(timeline.deletedEventIds, eventIds)
: timeline.deletedEventIds.filter(currentEventId => !eventIds.includes(currentEventId));
+ const selectedEventIds = Object.fromEntries(
+ Object.entries(timeline.selectedEventIds).filter(
+ ([selectedEventId]) => !deletedEventIds.includes(selectedEventId)
+ )
+ );
+
+ const isSelectAllChecked =
+ Object.keys(selectedEventIds).length > 0 ? timeline.isSelectAllChecked : false;
+
return {
...timelineById,
[id]: {
...timeline,
deletedEventIds,
+ selectedEventIds,
+ isSelectAllChecked,
},
};
};
diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts
index f380b82c1e05f..1578c71dddc6a 100644
--- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts
+++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts
@@ -20,6 +20,7 @@ import {
import { ShardsResponse } from '../../../types';
import { RuleAlertType, IRuleSavedAttributesSavedObjectAttributes } from '../../rules/types';
import { RuleAlertParamsRest, PrepackagedRules } from '../../types';
+import { TEST_BOUNDARY } from './utils';
export const mockPrepackagedRule = (): PrepackagedRules => ({
rule_id: 'rule-1',
@@ -224,6 +225,24 @@ export const getFindResultWithMultiHits = ({
};
};
+export const getImportRulesRequest = (payload?: Buffer): ServerInjectOptions => ({
+ method: 'POST',
+ url: `${DETECTION_ENGINE_RULES_URL}/_import`,
+ headers: {
+ 'Content-Type': `multipart/form-data; boundary=${TEST_BOUNDARY}`,
+ },
+ payload,
+});
+
+export const getImportRulesRequestOverwriteTrue = (payload?: Buffer): ServerInjectOptions => ({
+ method: 'POST',
+ url: `${DETECTION_ENGINE_RULES_URL}/_import?overwrite=true`,
+ headers: {
+ 'Content-Type': `multipart/form-data; boundary=${TEST_BOUNDARY}`,
+ },
+ payload,
+});
+
export const getDeleteRequest = (): ServerInjectOptions => ({
method: 'DELETE',
url: `${DETECTION_ENGINE_RULES_URL}?rule_id=rule-1`,
diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/utils.ts
new file mode 100644
index 0000000000000..f8c8e1f231ffa
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/utils.ts
@@ -0,0 +1,53 @@
+/*
+ * 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 { OutputRuleAlertRest } from '../../types';
+
+export const TEST_BOUNDARY = 'test_multipart_boundary';
+
+// Not parsable due to extra colon following `name` property - name::
+export const UNPARSABLE_LINE =
+ '{"name"::"Simple Rule Query","description":"Simple Rule Query","risk_score":1,"rule_id":"rule-1","severity":"high","type":"query","query":"user.name: root or user.name: admin"}';
+
+/**
+ * This is a typical simple rule for testing that is easy for most basic testing
+ * @param ruleId
+ */
+export const getSimpleRule = (ruleId = 'rule-1'): Partial => ({
+ name: 'Simple Rule Query',
+ description: 'Simple Rule Query',
+ risk_score: 1,
+ rule_id: ruleId,
+ severity: 'high',
+ type: 'query',
+ query: 'user.name: root or user.name: admin',
+});
+
+/**
+ * Given an array of rule_id strings this will return a ndjson buffer which is useful
+ * for testing uploads.
+ * @param ruleIds Array of strings of rule_ids
+ * @param isNdjson Boolean to determine file extension
+ */
+export const getSimpleRuleAsMultipartContent = (ruleIds: string[], isNdjson = true): Buffer => {
+ const arrayOfRules = ruleIds.map(ruleId => {
+ const simpleRule = getSimpleRule(ruleId);
+ return JSON.stringify(simpleRule);
+ });
+ const stringOfRules = arrayOfRules.join('\r\n');
+
+ const resultingPayload =
+ `--${TEST_BOUNDARY}\r\n` +
+ `Content-Disposition: form-data; name="file"; filename="rules.${
+ isNdjson ? 'ndjson' : 'json'
+ }\r\n` +
+ 'Content-Type: application/octet-stream\r\n' +
+ '\r\n' +
+ `${stringOfRules}\r\n` +
+ `--${TEST_BOUNDARY}--\r\n`;
+
+ return Buffer.from(resultingPayload);
+};
diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts
new file mode 100644
index 0000000000000..e3283a750869c
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts
@@ -0,0 +1,457 @@
+/*
+ * 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 { omit } from 'lodash/fp';
+
+import {
+ getSimpleRuleAsMultipartContent,
+ TEST_BOUNDARY,
+ UNPARSABLE_LINE,
+ getSimpleRule,
+} from '../__mocks__/utils';
+import { ImportSuccessError } from '../utils';
+import {
+ getImportRulesRequest,
+ getImportRulesRequestOverwriteTrue,
+ getFindResult,
+ getResult,
+ getEmptyIndex,
+ getFindResultWithSingleHit,
+ getNonEmptyIndex,
+} from '../__mocks__/request_responses';
+import { createMockServer, createMockConfig, clientsServiceMock } from '../__mocks__';
+import { importRulesRoute } from './import_rules_route';
+import { DEFAULT_SIGNALS_INDEX } from '../../../../../common/constants';
+
+describe('import_rules_route', () => {
+ let server = createMockServer();
+ let config = createMockConfig();
+ let getClients = clientsServiceMock.createGetScoped();
+ let clients = clientsServiceMock.createClients();
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+
+ server = createMockServer();
+ config = createMockConfig();
+ config = () => ({
+ get: jest.fn(value => {
+ switch (value) {
+ case 'savedObjects.maxImportPayloadBytes': {
+ return 10000;
+ }
+ case 'savedObjects.maxImportExportSize': {
+ return 10000;
+ }
+ case 'xpack.siem.signalsIndex': {
+ return DEFAULT_SIGNALS_INDEX;
+ }
+ default: {
+ const dummyMock = jest.fn();
+ return dummyMock();
+ }
+ }
+ }),
+ has: jest.fn(),
+ });
+ getClients = clientsServiceMock.createGetScoped();
+ clients = clientsServiceMock.createClients();
+
+ getClients.mockResolvedValue(clients);
+ clients.clusterClient.callAsCurrentUser.mockResolvedValue(getNonEmptyIndex());
+ clients.spacesClient.getSpaceId.mockReturnValue('default');
+
+ importRulesRoute(server.route, config, getClients);
+ });
+
+ describe('status codes with actionsClient and alertClient', () => {
+ test('returns 200 when importing a single rule with a valid actionClient and alertClient', async () => {
+ clients.alertsClient.find.mockResolvedValue(getFindResult());
+ clients.alertsClient.get.mockResolvedValue(getResult());
+ clients.alertsClient.create.mockResolvedValue(getResult());
+ const requestPayload = getSimpleRuleAsMultipartContent(['rule-1']);
+ const { statusCode } = await server.inject(getImportRulesRequest(requestPayload));
+ expect(statusCode).toEqual(200);
+ });
+
+ test('returns 404 if alertClient is not available on the route', async () => {
+ getClients.mockResolvedValue(omit('alertsClient', clients));
+ const { route, inject } = createMockServer();
+ importRulesRoute(route, config, getClients);
+ const requestPayload = getSimpleRuleAsMultipartContent(['rule-1']);
+ const { statusCode } = await inject(getImportRulesRequest(requestPayload));
+ expect(statusCode).toEqual(404);
+ });
+
+ test('returns 404 if actionsClient is not available on the route', async () => {
+ getClients.mockResolvedValue(omit('actionsClient', clients));
+ const { route, inject } = createMockServer();
+ importRulesRoute(route, config, getClients);
+ const requestPayload = getSimpleRuleAsMultipartContent(['rule-1']);
+ const { statusCode } = await inject(getImportRulesRequest(requestPayload));
+ expect(statusCode).toEqual(404);
+ });
+ });
+
+ describe('validation', () => {
+ test('returns reported error if index does not exist', async () => {
+ clients.clusterClient.callAsCurrentUser.mockResolvedValue(getEmptyIndex());
+ clients.alertsClient.find.mockResolvedValue(getFindResult());
+ clients.alertsClient.get.mockResolvedValue(getResult());
+ clients.alertsClient.create.mockResolvedValue(getResult());
+ const requestPayload = getSimpleRuleAsMultipartContent(['rule-1']);
+ const { statusCode, payload } = await server.inject(getImportRulesRequest(requestPayload));
+ const parsed: ImportSuccessError = JSON.parse(payload);
+
+ expect(parsed).toEqual({
+ errors: [
+ {
+ error: {
+ message:
+ 'To create a rule, the index must exist first. Index .siem-signals-default does not exist',
+ status_code: 409,
+ },
+ rule_id: 'rule-1',
+ },
+ ],
+ success: false,
+ success_count: 0,
+ });
+ expect(statusCode).toEqual(200);
+ });
+
+ test('returns 400 when a thrown error is caught', async () => {
+ const mockFn = jest.fn();
+ const mockThrowError = (): Error => {
+ throw new Error();
+ };
+ clients.clusterClient.callAsCurrentUser.mockResolvedValue(
+ mockFn.mockImplementation(mockThrowError)
+ );
+ clients.alertsClient.find.mockResolvedValue(getFindResult());
+ clients.alertsClient.get.mockResolvedValue(getResult());
+ clients.alertsClient.create.mockResolvedValue(getResult());
+ const requestPayload = getSimpleRuleAsMultipartContent(['rule-1']);
+ const { statusCode, payload } = await server.inject(getImportRulesRequest(requestPayload));
+ const parsed: ImportSuccessError = JSON.parse(payload);
+
+ expect(parsed).toEqual({
+ errors: [
+ {
+ error: {
+ message: "Cannot read property 'total' of undefined",
+ status_code: 400,
+ },
+ rule_id: 'rule-1',
+ },
+ ],
+ success: false,
+ success_count: 0,
+ });
+ expect(statusCode).toEqual(200);
+ });
+
+ test('returns 400 if file extension type is not .ndjson', async () => {
+ clients.alertsClient.find.mockResolvedValue(getFindResult());
+ clients.alertsClient.get.mockResolvedValue(getResult());
+ clients.alertsClient.create.mockResolvedValue(getResult());
+ const requestPayload = getSimpleRuleAsMultipartContent(['rule-1'], false);
+ const { statusCode, payload } = await server.inject(getImportRulesRequest(requestPayload));
+ const parsed: ImportSuccessError = JSON.parse(payload);
+
+ expect(parsed).toEqual({
+ message: 'Invalid file extension .json',
+ status_code: 400,
+ });
+ expect(statusCode).toEqual(400);
+ });
+ });
+
+ describe('single rule import', () => {
+ test('returns 200 if rule imported successfully', async () => {
+ clients.alertsClient.find.mockResolvedValue(getFindResult());
+ clients.alertsClient.get.mockResolvedValue(getResult());
+ clients.alertsClient.create.mockResolvedValue(getResult());
+ const requestPayload = getSimpleRuleAsMultipartContent(['rule-1']);
+ const { statusCode, payload } = await server.inject(getImportRulesRequest(requestPayload));
+ const parsed: ImportSuccessError = JSON.parse(payload);
+
+ expect(parsed).toEqual({
+ errors: [],
+ success: true,
+ success_count: 1,
+ });
+ expect(statusCode).toEqual(200);
+ });
+
+ test('returns reported conflict if error parsing rule', async () => {
+ const multipartPayload =
+ `--${TEST_BOUNDARY}\r\n` +
+ `Content-Disposition: form-data; name="file"; filename="rules.ndjson"\r\n` +
+ 'Content-Type: application/octet-stream\r\n' +
+ '\r\n' +
+ `${UNPARSABLE_LINE}\r\n` +
+ `--${TEST_BOUNDARY}--\r\n`;
+
+ clients.alertsClient.find.mockResolvedValue(getFindResult());
+ clients.alertsClient.get.mockResolvedValue(getResult());
+ clients.alertsClient.create.mockResolvedValue(getResult());
+ const requestPayload = Buffer.from(multipartPayload);
+ const { statusCode, payload } = await server.inject(getImportRulesRequest(requestPayload));
+ const parsed: ImportSuccessError = JSON.parse(payload);
+
+ expect(parsed).toEqual({
+ errors: [
+ {
+ error: {
+ message: 'Unexpected token : in JSON at position 8',
+ status_code: 400,
+ },
+ rule_id: '(unknown)',
+ },
+ ],
+ success: false,
+ success_count: 0,
+ });
+ expect(statusCode).toEqual(200);
+ });
+
+ describe('rule with existing rule_id', () => {
+ test('returns with reported conflict if `overwrite` is set to `false`', async () => {
+ clients.alertsClient.find.mockResolvedValue(getFindResult());
+
+ const requestPayload = getSimpleRuleAsMultipartContent(['rule-1']);
+ const { statusCode, payload } = await server.inject(getImportRulesRequest(requestPayload));
+ const parsed: ImportSuccessError = JSON.parse(payload);
+
+ expect(parsed).toEqual({
+ errors: [],
+ success: true,
+ success_count: 1,
+ });
+ expect(statusCode).toEqual(200);
+
+ clients.alertsClient.find.mockResolvedValue(getFindResultWithSingleHit());
+ clients.alertsClient.get.mockResolvedValue(getResult());
+
+ const { statusCode: statusCodeRequest2, payload: payloadRequest2 } = await server.inject(
+ getImportRulesRequest(requestPayload)
+ );
+ const parsedRequest2: ImportSuccessError = JSON.parse(payloadRequest2);
+
+ expect(parsedRequest2).toEqual({
+ errors: [
+ {
+ error: {
+ message: 'rule_id: "rule-1" already exists',
+ status_code: 409,
+ },
+ rule_id: 'rule-1',
+ },
+ ],
+ success: false,
+ success_count: 0,
+ });
+ expect(statusCodeRequest2).toEqual(200);
+ });
+
+ test('returns with NO reported conflict if `overwrite` is set to `true`', async () => {
+ clients.alertsClient.find.mockResolvedValue(getFindResult());
+
+ const requestPayload = getSimpleRuleAsMultipartContent(['rule-1']);
+ const { statusCode, payload } = await server.inject(getImportRulesRequest(requestPayload));
+ const parsed: ImportSuccessError = JSON.parse(payload);
+
+ expect(parsed).toEqual({
+ errors: [],
+ success: true,
+ success_count: 1,
+ });
+ expect(statusCode).toEqual(200);
+
+ clients.alertsClient.find.mockResolvedValue(getFindResultWithSingleHit());
+ clients.alertsClient.get.mockResolvedValue(getResult());
+
+ const { statusCode: statusCodeRequest2, payload: payloadRequest2 } = await server.inject(
+ getImportRulesRequestOverwriteTrue(requestPayload)
+ );
+ const parsedRequest2: ImportSuccessError = JSON.parse(payloadRequest2);
+
+ expect(parsedRequest2).toEqual({
+ errors: [],
+ success: true,
+ success_count: 1,
+ });
+ expect(statusCodeRequest2).toEqual(200);
+ });
+ });
+ });
+
+ describe('multi rule import', () => {
+ test('returns 200 if all rules imported successfully', async () => {
+ clients.alertsClient.find.mockResolvedValue(getFindResult());
+
+ const requestPayload = getSimpleRuleAsMultipartContent(['rule-1', 'rule-2']);
+ const { statusCode, payload } = await server.inject(getImportRulesRequest(requestPayload));
+ const parsed: ImportSuccessError = JSON.parse(payload);
+
+ expect(parsed).toEqual({
+ errors: [],
+ success: true,
+ success_count: 2,
+ });
+ expect(statusCode).toEqual(200);
+ });
+
+ test('returns 200 with reported conflict if error parsing rule', async () => {
+ const multipartPayload =
+ `--${TEST_BOUNDARY}\r\n` +
+ `Content-Disposition: form-data; name="file"; filename="rules.ndjson"\r\n` +
+ 'Content-Type: application/octet-stream\r\n' +
+ '\r\n' +
+ `${UNPARSABLE_LINE}\r\n` +
+ `${JSON.stringify(getSimpleRule('rule-2'))}\r\n` +
+ `--${TEST_BOUNDARY}--\r\n`;
+
+ clients.alertsClient.find.mockResolvedValue(getFindResult());
+
+ const requestPayload = Buffer.from(multipartPayload);
+ const { statusCode, payload } = await server.inject(getImportRulesRequest(requestPayload));
+ const parsed: ImportSuccessError = JSON.parse(payload);
+
+ expect(parsed).toEqual({
+ errors: [
+ {
+ error: {
+ message: 'Unexpected token : in JSON at position 8',
+ status_code: 400,
+ },
+ rule_id: '(unknown)',
+ },
+ ],
+ success: false,
+ success_count: 1,
+ });
+ expect(statusCode).toEqual(200);
+ });
+
+ describe('rules with matching rule_id', () => {
+ test('returns with reported conflict if `overwrite` is set to `false`', async () => {
+ clients.alertsClient.find.mockResolvedValue(getFindResult());
+ clients.alertsClient.get.mockResolvedValue(getResult());
+
+ const requestPayload = getSimpleRuleAsMultipartContent(['rule-1', 'rule-1']);
+ const { statusCode, payload } = await server.inject(getImportRulesRequest(requestPayload));
+ const parsed: ImportSuccessError = JSON.parse(payload);
+
+ expect(parsed).toEqual({
+ errors: [
+ {
+ error: {
+ message: 'More than one rule with rule-id: "rule-1" found',
+ status_code: 400,
+ },
+ rule_id: 'rule-1',
+ },
+ ],
+ success: false,
+ success_count: 1,
+ });
+ expect(statusCode).toEqual(200);
+ });
+
+ test('returns with NO reported conflict if `overwrite` is set to `true`', async () => {
+ clients.alertsClient.find.mockResolvedValue(getFindResult());
+ clients.alertsClient.get.mockResolvedValue(getResult());
+
+ const requestPayload = getSimpleRuleAsMultipartContent(['rule-1', 'rule-1']);
+ const { statusCode, payload } = await server.inject(
+ getImportRulesRequestOverwriteTrue(requestPayload)
+ );
+ const parsed: ImportSuccessError = JSON.parse(payload);
+
+ expect(parsed).toEqual({
+ errors: [],
+ success: true,
+ success_count: 1,
+ });
+ expect(statusCode).toEqual(200);
+ });
+ });
+
+ describe('rules with existing rule_id', () => {
+ test('returns with reported conflict if `overwrite` is set to `false`', async () => {
+ clients.alertsClient.find.mockResolvedValue(getFindResult());
+
+ const requestPayload = getSimpleRuleAsMultipartContent(['rule-1']);
+ const { statusCode, payload } = await server.inject(getImportRulesRequest(requestPayload));
+ const parsedResult: ImportSuccessError = JSON.parse(payload);
+
+ expect(parsedResult).toEqual({
+ errors: [],
+ success: true,
+ success_count: 1,
+ });
+ expect(statusCode).toEqual(200);
+
+ clients.alertsClient.find.mockResolvedValueOnce(getFindResultWithSingleHit());
+ clients.alertsClient.get.mockResolvedValue(getResult());
+
+ const requestPayload2 = getSimpleRuleAsMultipartContent(['rule-1', 'rule-2', 'rule-3']);
+ const { statusCode: statusCodeRequest2, payload: payloadRequest2 } = await server.inject(
+ getImportRulesRequest(requestPayload2)
+ );
+ const parsed: ImportSuccessError = JSON.parse(payloadRequest2);
+
+ expect(parsed).toEqual({
+ errors: [
+ {
+ error: {
+ message: 'rule_id: "rule-1" already exists',
+ status_code: 409,
+ },
+ rule_id: 'rule-1',
+ },
+ ],
+ success: false,
+ success_count: 2,
+ });
+ expect(statusCodeRequest2).toEqual(200);
+ });
+
+ test('returns 200 with NO reported conflict if `overwrite` is set to `true`', async () => {
+ clients.alertsClient.find.mockResolvedValue(getFindResult());
+
+ const requestPayload = getSimpleRuleAsMultipartContent(['rule-1']);
+ const { statusCode, payload } = await server.inject(getImportRulesRequest(requestPayload));
+ const parsedResult: ImportSuccessError = JSON.parse(payload);
+
+ expect(parsedResult).toEqual({
+ errors: [],
+ success: true,
+ success_count: 1,
+ });
+ expect(statusCode).toEqual(200);
+
+ clients.alertsClient.find.mockResolvedValueOnce(getFindResultWithSingleHit());
+ clients.alertsClient.get.mockResolvedValue(getResult());
+
+ const requestPayload2 = getSimpleRuleAsMultipartContent(['rule-1', 'rule-2', 'rule-3']);
+ const { statusCode: statusCodeRequest2, payload: payloadRequest2 } = await server.inject(
+ getImportRulesRequestOverwriteTrue(requestPayload2)
+ );
+ const parsed: ImportSuccessError = JSON.parse(payloadRequest2);
+
+ expect(parsed).toEqual({
+ errors: [],
+ success: true,
+ success_count: 3,
+ });
+ expect(statusCodeRequest2).toEqual(200);
+ });
+ });
+ });
+});
diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts
index a9188cc2adc12..cdb09ff7b04ed 100644
--- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts
+++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts
@@ -7,7 +7,6 @@
import Hapi from 'hapi';
import { chunk, isEmpty } from 'lodash/fp';
import { extname } from 'path';
-import uuid from 'uuid';
import { createPromiseFromStreams } from '../../../../../../../../../src/legacy/utils/streams';
import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants';
@@ -21,6 +20,7 @@ import { createRulesStreamFromNdJson } from '../../rules/create_rules_stream_fro
import { ImportRuleAlertRest } from '../../types';
import { patchRules } from '../../rules/patch_rules';
import { importRulesQuerySchema, importRulesPayloadSchema } from '../schemas/import_rules_schema';
+import { getTupleDuplicateErrorsAndUniqueRules } from './utils';
import { GetScopedClients } from '../../../../services';
type PromiseFromStreams = ImportRuleAlertRest | Error;
@@ -76,25 +76,9 @@ export const createImportRulesRoute = (
const objectLimit = config().get('savedObjects.maxImportExportSize');
const readStream = createRulesStreamFromNdJson(request.payload.file, objectLimit);
const parsedObjects = await createPromiseFromStreams([readStream]);
- const uniqueParsedObjects = Array.from(
- parsedObjects
- .reduce(
- (acc, parsedRule) => {
- if (parsedRule instanceof Error) {
- acc.set(uuid.v4(), parsedRule);
- } else {
- const { rule_id: ruleId } = parsedRule;
- if (ruleId != null) {
- acc.set(ruleId, parsedRule);
- } else {
- acc.set(uuid.v4(), parsedRule);
- }
- }
- return acc;
- }, // using map (preserves ordering)
- new Map()
- )
- .values()
+ const [duplicateIdErrors, uniqueParsedObjects] = getTupleDuplicateErrorsAndUniqueRules(
+ parsedObjects,
+ request.query.overwrite
);
const chunkParseObjects = chunk(CHUNK_PARSED_OBJECT_SIZE, uniqueParsedObjects);
@@ -251,7 +235,11 @@ export const createImportRulesRoute = (
return [...accum, importsWorkerPromise];
}, [])
);
- importRuleResponse = [...importRuleResponse, ...newImportRuleResponse];
+ importRuleResponse = [
+ ...duplicateIdErrors,
+ ...importRuleResponse,
+ ...newImportRuleResponse,
+ ];
}
const errorsResp = importRuleResponse.filter(resp => !isEmpty(resp.error));
diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.test.ts
index fb3262c476b40..2b0da8251b387 100644
--- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.test.ts
+++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.test.ts
@@ -3,7 +3,7 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
-
+import { Readable } from 'stream';
import {
transformAlertToRule,
getIdError,
@@ -16,12 +16,18 @@ import {
transformAlertsToRules,
transformOrImportError,
getDuplicates,
+ getTupleDuplicateErrorsAndUniqueRules,
} from './utils';
import { getResult } from '../__mocks__/request_responses';
import { INTERNAL_IDENTIFIER } from '../../../../../common/constants';
-import { OutputRuleAlertRest } from '../../types';
+import { OutputRuleAlertRest, ImportRuleAlertRest } from '../../types';
import { BulkError, ImportSuccessError } from '../utils';
import { sampleRule } from '../../signals/__mocks__/es_results';
+import { getSimpleRule } from '../__mocks__/utils';
+import { createRulesStreamFromNdJson } from '../../rules/create_rules_stream_from_ndjson';
+import { createPromiseFromStreams } from '../../../../../../../../../src/legacy/utils/streams';
+
+type PromiseFromStreams = ImportRuleAlertRest | Error;
describe('utils', () => {
describe('transformAlertToRule', () => {
@@ -1224,4 +1230,95 @@ describe('utils', () => {
expect(output).toEqual(expected);
});
});
+
+ describe('getTupleDuplicateErrorsAndUniqueRules', () => {
+ test('returns tuple of empty duplicate errors array and rule array with instance of Syntax Error when imported rule contains parse error', async () => {
+ const multipartPayload =
+ '{"name"::"Simple Rule Query","description":"Simple Rule Query","risk_score":1,"rule_id":"rule-1","severity":"high","type":"query","query":"user.name: root or user.name: admin"}\n';
+ const ndJsonStream = new Readable({
+ read() {
+ this.push(multipartPayload);
+ this.push(null);
+ },
+ });
+ const rulesObjectsStream = createRulesStreamFromNdJson(ndJsonStream, 1000);
+ const parsedObjects = await createPromiseFromStreams([
+ rulesObjectsStream,
+ ]);
+ const [errors, output] = getTupleDuplicateErrorsAndUniqueRules(parsedObjects, false);
+ const isInstanceOfError = output[0] instanceof Error;
+
+ expect(isInstanceOfError).toEqual(true);
+ expect(errors.length).toEqual(0);
+ });
+
+ test('returns tuple of duplicate conflict error and single rule when rules with matching rule-ids passed in and `overwrite` is false', async () => {
+ const rule = getSimpleRule('rule-1');
+ const rule2 = getSimpleRule('rule-1');
+ const ndJsonStream = new Readable({
+ read() {
+ this.push(`${JSON.stringify(rule)}\n`);
+ this.push(`${JSON.stringify(rule2)}\n`);
+ this.push(null);
+ },
+ });
+ const rulesObjectsStream = createRulesStreamFromNdJson(ndJsonStream, 1000);
+ const parsedObjects = await createPromiseFromStreams([
+ rulesObjectsStream,
+ ]);
+ const [errors, output] = getTupleDuplicateErrorsAndUniqueRules(parsedObjects, false);
+
+ expect(output.length).toEqual(1);
+ expect(errors).toEqual([
+ {
+ error: {
+ message: 'More than one rule with rule-id: "rule-1" found',
+ status_code: 400,
+ },
+ rule_id: 'rule-1',
+ },
+ ]);
+ });
+
+ test('returns tuple of empty duplicate errors array and single rule when rules with matching rule-ids passed in and `overwrite` is true', async () => {
+ const rule = getSimpleRule('rule-1');
+ const rule2 = getSimpleRule('rule-1');
+ const ndJsonStream = new Readable({
+ read() {
+ this.push(`${JSON.stringify(rule)}\n`);
+ this.push(`${JSON.stringify(rule2)}\n`);
+ this.push(null);
+ },
+ });
+ const rulesObjectsStream = createRulesStreamFromNdJson(ndJsonStream, 1000);
+ const parsedObjects = await createPromiseFromStreams([
+ rulesObjectsStream,
+ ]);
+ const [errors, output] = getTupleDuplicateErrorsAndUniqueRules(parsedObjects, true);
+
+ expect(output.length).toEqual(1);
+ expect(errors.length).toEqual(0);
+ });
+
+ test('returns tuple of empty duplicate errors array and single rule when rules without a rule-id is passed in', async () => {
+ const simpleRule = getSimpleRule();
+ delete simpleRule.rule_id;
+ const multipartPayload = `${JSON.stringify(simpleRule)}\n`;
+ const ndJsonStream = new Readable({
+ read() {
+ this.push(multipartPayload);
+ this.push(null);
+ },
+ });
+ const rulesObjectsStream = createRulesStreamFromNdJson(ndJsonStream, 1000);
+ const parsedObjects = await createPromiseFromStreams([
+ rulesObjectsStream,
+ ]);
+ const [errors, output] = getTupleDuplicateErrorsAndUniqueRules(parsedObjects, false);
+ const isInstanceOfError = output[0] instanceof Error;
+
+ expect(isInstanceOfError).toEqual(true);
+ expect(errors.length).toEqual(0);
+ });
+ });
});
diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts
index df9e3021e400f..21fc5a12db536 100644
--- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts
+++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts
@@ -7,6 +7,7 @@
import { pickBy } from 'lodash/fp';
import { Dictionary } from 'lodash';
import { SavedObject } from 'kibana/server';
+import uuid from 'uuid';
import { INTERNAL_IDENTIFIER } from '../../../../../common/constants';
import {
RuleAlertType,
@@ -17,7 +18,7 @@ import {
isRuleStatusFindTypes,
isRuleStatusSavedObjectType,
} from '../../rules/types';
-import { OutputRuleAlertRest } from '../../types';
+import { OutputRuleAlertRest, ImportRuleAlertRest } from '../../types';
import {
createBulkErrorObject,
BulkError,
@@ -27,6 +28,8 @@ import {
OutputError,
} from '../utils';
+type PromiseFromStreams = ImportRuleAlertRest | Error;
+
export const getIdError = ({
id,
ruleId,
@@ -224,3 +227,41 @@ export const getDuplicates = (lodashDict: Dictionary): string[] => {
}
return [];
};
+
+export const getTupleDuplicateErrorsAndUniqueRules = (
+ rules: PromiseFromStreams[],
+ isOverwrite: boolean
+): [BulkError[], PromiseFromStreams[]] => {
+ const { errors, rulesAcc } = rules.reduce(
+ (acc, parsedRule) => {
+ if (parsedRule instanceof Error) {
+ acc.rulesAcc.set(uuid.v4(), parsedRule);
+ } else {
+ const { rule_id: ruleId } = parsedRule;
+ if (ruleId != null) {
+ if (acc.rulesAcc.has(ruleId) && !isOverwrite) {
+ acc.errors.set(
+ uuid.v4(),
+ createBulkErrorObject({
+ ruleId,
+ statusCode: 400,
+ message: `More than one rule with rule-id: "${ruleId}" found`,
+ })
+ );
+ }
+ acc.rulesAcc.set(ruleId, parsedRule);
+ } else {
+ acc.rulesAcc.set(uuid.v4(), parsedRule);
+ }
+ }
+
+ return acc;
+ }, // using map (preserves ordering)
+ {
+ errors: new Map(),
+ rulesAcc: new Map(),
+ }
+ );
+
+ return [Array.from(errors.values()), Array.from(rulesAcc.values())];
+};
diff --git a/x-pack/package.json b/x-pack/package.json
index 305aaa1d9457b..9d6b5d76a58e7 100644
--- a/x-pack/package.json
+++ b/x-pack/package.json
@@ -11,8 +11,8 @@
"build": "gulp build",
"testonly": "echo 'Deprecated, use `yarn test`' && gulp test",
"test": "gulp test",
- "test:browser:dev": "gulp testbrowser-dev",
- "test:browser": "gulp testbrowser",
+ "test:karma:debug": "gulp test:karma:debug",
+ "test:karma": "gulp test:karma",
"test:jest": "node scripts/jest",
"test:mocha": "node scripts/mocha"
},
diff --git a/x-pack/plugins/cloud/public/plugin.ts b/x-pack/plugins/cloud/public/plugin.ts
index f6408afb31493..2b8247066bfc3 100644
--- a/x-pack/plugins/cloud/public/plugin.ts
+++ b/x-pack/plugins/cloud/public/plugin.ts
@@ -31,6 +31,9 @@ export class CloudPlugin implements Plugin {
if (home) {
home.environment.update({ cloud: isCloudEnabled });
+ if (isCloudEnabled) {
+ home.tutorials.setVariable('cloud', { id });
+ }
}
return {
diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/index.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/index.tsx
index 7bb3b13525914..8530d6206d398 100644
--- a/x-pack/plugins/endpoint/public/applications/endpoint/index.tsx
+++ b/x-pack/plugins/endpoint/public/applications/endpoint/index.tsx
@@ -8,20 +8,22 @@ import * as React from 'react';
import ReactDOM from 'react-dom';
import { CoreStart, AppMountParameters } from 'kibana/public';
import { I18nProvider, FormattedMessage } from '@kbn/i18n/react';
-import { Route, BrowserRouter, Switch } from 'react-router-dom';
-import { Provider } from 'react-redux';
+import { Route, Switch, BrowserRouter, useLocation } from 'react-router-dom';
+import { Provider, useDispatch } from 'react-redux';
import { Store } from 'redux';
+import { memo } from 'react';
import { appStoreFactory } from './store';
import { AlertIndex } from './view/alerts';
import { ManagementList } from './view/managing';
import { PolicyList } from './view/policy';
+import { AppAction } from './store/action';
+import { EndpointAppLocation } from './types';
/**
* This module will be loaded asynchronously to reduce the bundle size of your plugin's main bundle.
*/
export function renderApp(coreStart: CoreStart, { appBasePath, element }: AppMountParameters) {
coreStart.http.get('/api/endpoint/hello-world');
-
const store = appStoreFactory(coreStart);
ReactDOM.render( , element);
@@ -31,6 +33,13 @@ export function renderApp(coreStart: CoreStart, { appBasePath, element }: AppMou
};
}
+const RouteCapture = memo(({ children }) => {
+ const location: EndpointAppLocation = useLocation();
+ const dispatch: (action: AppAction) => unknown = useDispatch();
+ dispatch({ type: 'userChangedUrl', payload: location });
+ return <>{children}>;
+});
+
interface RouterProps {
basename: string;
store: Store;
@@ -40,25 +49,27 @@ const AppRoot: React.FunctionComponent = React.memo(({ basename, st
-
- (
-
-
-
- )}
- />
-
-
-
- (
-
- )}
- />
-
+
+
+ (
+
+
+
+ )}
+ />
+
+ } />
+
+ (
+
+ )}
+ />
+
+
diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/action.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/action.ts
index 464a04eff5ebd..a628a95003a7f 100644
--- a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/action.ts
+++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/action.ts
@@ -4,11 +4,12 @@
* you may not use this file except in compliance with the Elastic License.
*/
+import { Immutable } from '../../../../../common/types';
import { AlertListData } from '../../types';
interface ServerReturnedAlertsData {
- type: 'serverReturnedAlertsData';
- payload: AlertListData;
+ readonly type: 'serverReturnedAlertsData';
+ readonly payload: Immutable;
}
export type AlertAction = ServerReturnedAlertsData;
diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/alert_list.test.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/alert_list.test.ts
new file mode 100644
index 0000000000000..6ba7a34ae81d1
--- /dev/null
+++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/alert_list.test.ts
@@ -0,0 +1,85 @@
+/*
+ * 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 { Store, createStore, applyMiddleware } from 'redux';
+import { History } from 'history';
+import { alertListReducer } from './reducer';
+import { AlertListState } from '../../types';
+import { alertMiddlewareFactory } from './middleware';
+import { AppAction } from '../action';
+import { coreMock } from 'src/core/public/mocks';
+import { AlertResultList } from '../../../../../common/types';
+import { isOnAlertPage } from './selectors';
+import { createBrowserHistory } from 'history';
+
+describe('alert list tests', () => {
+ let store: Store;
+ let coreStart: ReturnType;
+ let history: History;
+ beforeEach(() => {
+ coreStart = coreMock.createStart();
+ history = createBrowserHistory();
+ const middleware = alertMiddlewareFactory(coreStart);
+ store = createStore(alertListReducer, applyMiddleware(middleware));
+ });
+ describe('when the user navigates to the alert list page', () => {
+ beforeEach(() => {
+ coreStart.http.get.mockImplementation(async () => {
+ const response: AlertResultList = {
+ alerts: [
+ {
+ '@timestamp': new Date(1542341895000),
+ agent: {
+ id: 'ced9c68e-b94a-4d66-bb4c-6106514f0a2f',
+ version: '3.0.0',
+ },
+ event: {
+ action: 'open',
+ },
+ file_classification: {
+ malware_classification: {
+ score: 3,
+ },
+ },
+ host: {
+ hostname: 'HD-c15-bc09190a',
+ ip: '10.179.244.14',
+ os: {
+ name: 'Windows',
+ },
+ },
+ thread: {},
+ },
+ ],
+ total: 1,
+ request_page_size: 10,
+ request_page_index: 0,
+ result_from_index: 0,
+ };
+ return response;
+ });
+
+ // Simulates user navigating to the /alerts page
+ store.dispatch({
+ type: 'userChangedUrl',
+ payload: {
+ ...history.location,
+ pathname: '/alerts',
+ },
+ });
+ });
+
+ it("should recognize it's on the alert list page", () => {
+ const actual = isOnAlertPage(store.getState());
+ expect(actual).toBe(true);
+ });
+
+ it('should return alertListData', () => {
+ const actualResponseLength = store.getState().alerts.length;
+ expect(actualResponseLength).toEqual(1);
+ });
+ });
+});
diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/alert_list_pagination.test.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/alert_list_pagination.test.ts
new file mode 100644
index 0000000000000..77708a3c77e2b
--- /dev/null
+++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/alert_list_pagination.test.ts
@@ -0,0 +1,98 @@
+/*
+ * 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 { Store, createStore, applyMiddleware } from 'redux';
+import { History } from 'history';
+import { alertListReducer } from './reducer';
+import { AlertListState } from '../../types';
+import { alertMiddlewareFactory } from './middleware';
+import { AppAction } from '../action';
+import { coreMock } from 'src/core/public/mocks';
+import { createBrowserHistory } from 'history';
+import {
+ urlFromNewPageSizeParam,
+ paginationDataFromUrl,
+ urlFromNewPageIndexParam,
+} from './selectors';
+
+describe('alert list pagination', () => {
+ let store: Store;
+ let coreStart: ReturnType;
+ let history: History;
+ beforeEach(() => {
+ coreStart = coreMock.createStart();
+ history = createBrowserHistory();
+ const middleware = alertMiddlewareFactory(coreStart);
+ store = createStore(alertListReducer, applyMiddleware(middleware));
+ });
+ describe('when the user navigates to the alert list page', () => {
+ describe('when a new page size is passed', () => {
+ beforeEach(() => {
+ const urlPageSizeSelector = urlFromNewPageSizeParam(store.getState());
+ history.push(urlPageSizeSelector(1));
+ store.dispatch({ type: 'userChangedUrl', payload: history.location });
+ });
+ it('should modify the url correctly', () => {
+ const actualPaginationQuery = paginationDataFromUrl(store.getState());
+ expect(actualPaginationQuery).toMatchInlineSnapshot(`
+ Object {
+ "page_size": "1",
+ }
+ `);
+ });
+
+ describe('and then a new page index is passed', () => {
+ beforeEach(() => {
+ const urlPageIndexSelector = urlFromNewPageIndexParam(store.getState());
+ history.push(urlPageIndexSelector(1));
+ store.dispatch({ type: 'userChangedUrl', payload: history.location });
+ });
+ it('should modify the url in the correct order', () => {
+ const actualPaginationQuery = paginationDataFromUrl(store.getState());
+ expect(actualPaginationQuery).toMatchInlineSnapshot(`
+ Object {
+ "page_index": "1",
+ "page_size": "1",
+ }
+ `);
+ });
+ });
+ });
+
+ describe('when a new page index is passed', () => {
+ beforeEach(() => {
+ const urlPageIndexSelector = urlFromNewPageIndexParam(store.getState());
+ history.push(urlPageIndexSelector(1));
+ store.dispatch({ type: 'userChangedUrl', payload: history.location });
+ });
+ it('should modify the url correctly', () => {
+ const actualPaginationQuery = paginationDataFromUrl(store.getState());
+ expect(actualPaginationQuery).toMatchInlineSnapshot(`
+ Object {
+ "page_index": "1",
+ }
+ `);
+ });
+
+ describe('and then a new page size is passed', () => {
+ beforeEach(() => {
+ const urlPageSizeSelector = urlFromNewPageSizeParam(store.getState());
+ history.push(urlPageSizeSelector(1));
+ store.dispatch({ type: 'userChangedUrl', payload: history.location });
+ });
+ it('should modify the url correctly and reset index to `0`', () => {
+ const actualPaginationQuery = paginationDataFromUrl(store.getState());
+ expect(actualPaginationQuery).toMatchInlineSnapshot(`
+ Object {
+ "page_index": "0",
+ "page_size": "1",
+ }
+ `);
+ });
+ });
+ });
+ });
+});
diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/middleware.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/middleware.ts
index 4a7fac147852b..059507c8f0658 100644
--- a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/middleware.ts
+++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/middleware.ts
@@ -4,19 +4,19 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { parse } from 'query-string';
-import { HttpFetchQuery } from 'src/core/public';
+import { HttpFetchQuery } from 'kibana/public';
+import { AlertResultList } from '../../../../../common/types';
import { AppAction } from '../action';
-import { MiddlewareFactory, AlertListData } from '../../types';
-
-export const alertMiddlewareFactory: MiddlewareFactory = coreStart => {
- const qp = parse(window.location.search.slice(1), { sort: false });
+import { MiddlewareFactory, AlertListState } from '../../types';
+import { isOnAlertPage, paginationDataFromUrl } from './selectors';
+export const alertMiddlewareFactory: MiddlewareFactory = coreStart => {
return api => next => async (action: AppAction) => {
next(action);
- if (action.type === 'userNavigatedToPage' && action.payload === 'alertsPage') {
- const response: AlertListData = await coreStart.http.get('/api/endpoint/alerts', {
- query: qp as HttpFetchQuery,
+ const state = api.getState();
+ if (action.type === 'userChangedUrl' && isOnAlertPage(state)) {
+ const response: AlertResultList = await coreStart.http.get(`/api/endpoint/alerts`, {
+ query: paginationDataFromUrl(state) as HttpFetchQuery,
});
api.dispatch({ type: 'serverReturnedAlertsData', payload: response });
}
diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/reducer.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/reducer.ts
index de79476245d29..6369bb9fb2d0d 100644
--- a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/reducer.ts
+++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/reducer.ts
@@ -15,6 +15,7 @@ const initialState = (): AlertListState => {
request_page_index: 0,
result_from_index: 0,
total: 0,
+ location: undefined,
};
};
@@ -27,6 +28,11 @@ export const alertListReducer: Reducer = (
...state,
...action.payload,
};
+ } else if (action.type === 'userChangedUrl') {
+ return {
+ ...state,
+ location: action.payload,
+ };
}
return state;
diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/selectors.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/selectors.ts
index 51903a0a641e8..6ad053888729c 100644
--- a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/selectors.ts
+++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/selectors.ts
@@ -4,6 +4,76 @@
* you may not use this file except in compliance with the Elastic License.
*/
+import qs from 'querystring';
import { AlertListState } from '../../types';
+/**
+ * Returns the Alert Data array from state
+ */
export const alertListData = (state: AlertListState) => state.alerts;
+
+/**
+ * Returns the alert list pagination data from state
+ */
+export const alertListPagination = (state: AlertListState) => {
+ return {
+ pageIndex: state.request_page_index,
+ pageSize: state.request_page_size,
+ resultFromIndex: state.result_from_index,
+ total: state.total,
+ };
+};
+
+/**
+ * Returns a boolean based on whether or not the user is on the alerts page
+ */
+export const isOnAlertPage = (state: AlertListState): boolean => {
+ return state.location ? state.location.pathname === '/alerts' : false;
+};
+
+/**
+ * Returns the query object received from parsing the URL query params
+ */
+export const paginationDataFromUrl = (state: AlertListState): qs.ParsedUrlQuery => {
+ if (state.location) {
+ // Removes the `?` from the beginning of query string if it exists
+ const query = qs.parse(state.location.search.slice(1));
+ return {
+ ...(query.page_size ? { page_size: query.page_size } : {}),
+ ...(query.page_index ? { page_index: query.page_index } : {}),
+ };
+ } else {
+ return {};
+ }
+};
+
+/**
+ * Returns a function that takes in a new page size and returns a new query param string
+ */
+export const urlFromNewPageSizeParam: (
+ state: AlertListState
+) => (newPageSize: number) => string = state => {
+ return newPageSize => {
+ const urlPaginationData = paginationDataFromUrl(state);
+ urlPaginationData.page_size = newPageSize.toString();
+
+ // Only set the url back to page zero if the user has changed the page index already
+ if (urlPaginationData.page_index !== undefined) {
+ urlPaginationData.page_index = '0';
+ }
+ return '?' + qs.stringify(urlPaginationData);
+ };
+};
+
+/**
+ * Returns a function that takes in a new page index and returns a new query param string
+ */
+export const urlFromNewPageIndexParam: (
+ state: AlertListState
+) => (newPageIndex: number) => string = state => {
+ return newPageIndex => {
+ const urlPaginationData = paginationDataFromUrl(state);
+ urlPaginationData.page_index = newPageIndex.toString();
+ return '?' + qs.stringify(urlPaginationData);
+ };
+};
diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/index.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/index.ts
index 8fe61ae01d319..3aeeeaf1c09e2 100644
--- a/x-pack/plugins/endpoint/public/applications/endpoint/store/index.ts
+++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/index.ts
@@ -53,7 +53,6 @@ export const appStoreFactory = (coreStart: CoreStart): Store => {
appReducer,
composeWithReduxDevTools(
applyMiddleware(
- alertMiddlewareFactory(coreStart),
substateMiddlewareFactory(
globalState => globalState.managementList,
managementMiddlewareFactory(coreStart)
@@ -61,6 +60,10 @@ export const appStoreFactory = (coreStart: CoreStart): Store => {
substateMiddlewareFactory(
globalState => globalState.policyList,
policyListMiddlewareFactory(coreStart)
+ ),
+ substateMiddlewareFactory(
+ globalState => globalState.alertList,
+ alertMiddlewareFactory(coreStart)
)
)
)
diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/routing/action.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/routing/action.ts
index 9080af8c91817..c7e9970e58c30 100644
--- a/x-pack/plugins/endpoint/public/applications/endpoint/store/routing/action.ts
+++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/routing/action.ts
@@ -4,7 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { PageId } from '../../../../../common/types';
+import { PageId, Immutable } from '../../../../../common/types';
+import { EndpointAppLocation } from '../alerts';
interface UserNavigatedToPage {
readonly type: 'userNavigatedToPage';
@@ -16,4 +17,9 @@ interface UserNavigatedFromPage {
readonly payload: PageId;
}
-export type RoutingAction = UserNavigatedToPage | UserNavigatedFromPage;
+interface UserChangedUrl {
+ readonly type: 'userChangedUrl';
+ readonly payload: Immutable;
+}
+
+export type RoutingAction = UserNavigatedToPage | UserNavigatedFromPage | UserChangedUrl;
diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/selectors.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/selectors.ts
deleted file mode 100644
index 2766707271cde..0000000000000
--- a/x-pack/plugins/endpoint/public/applications/endpoint/store/selectors.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License;
- * 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(
- 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(
- selector: (state: OuterState) => InnerState,
- secondSelector: (state: InnerState) => ReturnValue
-): (state: OuterState) => ReturnValue {
- return state => secondSelector(selector(state));
-}
diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/types.ts b/x-pack/plugins/endpoint/public/applications/endpoint/types.ts
index 6b20012592fd9..d07521d09a119 100644
--- a/x-pack/plugins/endpoint/public/applications/endpoint/types.ts
+++ b/x-pack/plugins/endpoint/public/applications/endpoint/types.ts
@@ -8,7 +8,7 @@ import { Dispatch, MiddlewareAPI } from 'redux';
import { CoreStart } from 'kibana/public';
import { EndpointMetadata } from '../../../common/types';
import { AppAction } from './store/action';
-import { AlertResultList } from '../../../common/types';
+import { AlertResultList, Immutable } from '../../../common/types';
export type MiddlewareFactory = (
coreStart: CoreStart
@@ -63,8 +63,6 @@ export interface GlobalState {
readonly policyList: PolicyListState;
}
-export type AlertListData = AlertResultList;
-export type AlertListState = AlertResultList;
export type CreateStructuredSelector = <
SelectorMap extends { [key: string]: (...args: never[]) => unknown }
>(
@@ -74,3 +72,16 @@ export type CreateStructuredSelector = <
) => {
[Key in keyof SelectorMap]: ReturnType;
};
+
+export interface EndpointAppLocation {
+ pathname: string;
+ search: string;
+ state: never;
+ hash: string;
+ key?: string;
+}
+
+export type AlertListData = AlertResultList;
+export type AlertListState = Immutable & {
+ readonly location?: Immutable;
+};
diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/hooks/use_alerts_selector.ts b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/hooks/use_alerts_selector.ts
new file mode 100644
index 0000000000000..d3962f908757c
--- /dev/null
+++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/hooks/use_alerts_selector.ts
@@ -0,0 +1,12 @@
+/*
+ * 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 { useSelector } from 'react-redux';
+import { GlobalState, AlertListState } from '../../../types';
+
+export function useAlertListSelector(selector: (state: AlertListState) => TSelected) {
+ return useSelector((state: GlobalState) => selector(state.alertList));
+}
diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/index.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/index.tsx
index 8c32426dcc868..045b82200b11b 100644
--- a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/index.tsx
+++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/index.tsx
@@ -4,41 +4,94 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { memo, useState, useMemo } from 'react';
+import { memo, useState, useMemo, useCallback } from 'react';
import React from 'react';
-import { EuiDataGrid } from '@elastic/eui';
-import { useSelector } from 'react-redux';
+import { EuiDataGrid, EuiDataGridColumn, EuiPage, EuiPageBody, EuiPageContent } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
-import * as selectors from '../../store/selectors';
-import { usePageId } from '../use_page_id';
+import { useHistory } from 'react-router-dom';
+import * as selectors from '../../store/alerts/selectors';
+import { useAlertListSelector } from './hooks/use_alerts_selector';
export const AlertIndex = memo(() => {
- usePageId('alertsPage');
+ const history = useHistory();
- const columns: Array<{ id: string }> = useMemo(() => {
+ const columns: EuiDataGridColumn[] = useMemo(() => {
return [
- { id: 'alert_type' },
- { id: 'event_type' },
- { id: 'os' },
- { id: 'ip_address' },
- { id: 'host_name' },
- { id: 'timestamp' },
- { id: 'archived' },
- { id: 'malware_score' },
+ {
+ id: 'alert_type',
+ display: i18n.translate('xpack.endpoint.application.endpoint.alerts.alertType', {
+ defaultMessage: 'Alert Type',
+ }),
+ },
+ {
+ id: 'event_type',
+ display: i18n.translate('xpack.endpoint.application.endpoint.alerts.eventType', {
+ defaultMessage: 'Event Type',
+ }),
+ },
+ {
+ id: 'os',
+ display: i18n.translate('xpack.endpoint.application.endpoint.alerts.os', {
+ defaultMessage: 'OS',
+ }),
+ },
+ {
+ id: 'ip_address',
+ display: i18n.translate('xpack.endpoint.application.endpoint.alerts.ipAddress', {
+ defaultMessage: 'IP Address',
+ }),
+ },
+ {
+ id: 'host_name',
+ display: i18n.translate('xpack.endpoint.application.endpoint.alerts.hostName', {
+ defaultMessage: 'Host Name',
+ }),
+ },
+ {
+ id: 'timestamp',
+ display: i18n.translate('xpack.endpoint.application.endpoint.alerts.timestamp', {
+ defaultMessage: 'Timestamp',
+ }),
+ },
+ {
+ id: 'archived',
+ display: i18n.translate('xpack.endpoint.application.endpoint.alerts.archived', {
+ defaultMessage: 'Archived',
+ }),
+ },
+ {
+ id: 'malware_score',
+ display: i18n.translate('xpack.endpoint.application.endpoint.alerts.malwareScore', {
+ defaultMessage: 'Malware Score',
+ }),
+ },
];
}, []);
- const [visibleColumns, setVisibleColumns] = useState(() => columns.map(({ id }) => id));
+ const { pageIndex, pageSize, total } = useAlertListSelector(selectors.alertListPagination);
+ const urlFromNewPageSizeParam = useAlertListSelector(selectors.urlFromNewPageSizeParam);
+ const urlFromNewPageIndexParam = useAlertListSelector(selectors.urlFromNewPageIndexParam);
+ const alertListData = useAlertListSelector(selectors.alertListData);
+
+ const onChangeItemsPerPage = useCallback(
+ newPageSize => history.push(urlFromNewPageSizeParam(newPageSize)),
+ [history, urlFromNewPageSizeParam]
+ );
+
+ const onChangePage = useCallback(
+ newPageIndex => history.push(urlFromNewPageIndexParam(newPageIndex)),
+ [history, urlFromNewPageIndexParam]
+ );
- const json = useSelector(selectors.alertListData);
+ const [visibleColumns, setVisibleColumns] = useState(() => columns.map(({ id }) => id));
const renderCellValue = useMemo(() => {
return ({ rowIndex, columnId }: { rowIndex: number; columnId: string }) => {
- if (rowIndex > json.length) {
+ if (rowIndex > total) {
return null;
}
- const row = json[rowIndex];
+ const row = alertListData[rowIndex % pageSize];
if (columnId === 'alert_type') {
return i18n.translate(
@@ -64,23 +117,36 @@ export const AlertIndex = memo(() => {
}
return null;
};
- }, [json]);
+ }, [alertListData, pageSize, total]);
+
+ const pagination = useMemo(() => {
+ return {
+ pageIndex,
+ pageSize,
+ pageSizeOptions: [10, 20, 50],
+ onChangeItemsPerPage,
+ onChangePage,
+ };
+ }, [onChangeItemsPerPage, onChangePage, pageIndex, pageSize]);
return (
-
+
+
+
+
+
+
+
);
});
diff --git a/x-pack/plugins/infra/server/plugin.ts b/x-pack/plugins/infra/server/plugin.ts
index ac45321e870e6..bcdbccf6f2294 100644
--- a/x-pack/plugins/infra/server/plugin.ts
+++ b/x-pack/plugins/infra/server/plugin.ts
@@ -30,6 +30,7 @@ import { InfraStaticSourceConfiguration } from './lib/sources/types';
export const config = {
schema: schema.object({
+ enabled: schema.boolean({ defaultValue: true }),
query: schema.object({
partitionSize: schema.number({ defaultValue: 75 }),
partitionFactor: schema.number({ defaultValue: 1.2 }),
diff --git a/x-pack/plugins/ingest_manager/README.md b/x-pack/plugins/ingest_manager/README.md
new file mode 100644
index 0000000000000..60c2a457a2806
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/README.md
@@ -0,0 +1,20 @@
+# Ingest Manager
+
+## Getting started
+See the Kibana docs for [how to set up your dev environment](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#setting-up-your-development-environment), [run Elasticsearch](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#running-elasticsearch), and [start Kibana](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#running-kibana).
+
+One common workflow is:
+
+ 1. `yarn es snapshot`
+ 1. In another shell: `yarn start --xpack.ingestManager.enabled=true` (or set in `config.yml`)
+
+## HTTP API
+ 1. Nothing by default. If `xpack.ingestManager.enabled=true`, it adds the `DATASOURCE_API_ROUTES` and `AGENT_CONFIG_API_ROUTES` values in [`common/constants/routes.ts`](./common/constants/routes.ts)
+ 1. [Integration tests](../../test/api_integration/apis/ingest_manager/endpoints.ts)
+ 1. In later versions the EPM and Fleet routes will be added when their flags are enabled. See the [currently disabled logic to add those routes](https://github.com/jfsiii/kibana/blob/feature-ingest-manager/x-pack/plugins/ingest_manager/server/plugin.ts#L86-L90).
+
+## Plugin architecture
+Follows the `common`, `server`, `public` structure from the [Architecture Style Guide
+](https://github.com/elastic/kibana/blob/master/style_guides/architecture_style_guide.md#file-and-folder-structure).
+
+We use New Platform approach (structure, APIs, etc) where possible. There's a `kibana.json` manifest, and the server uses the `server/{index,plugin}.ts` approach from [`MIGRATION.md`](https://github.com/elastic/kibana/blob/master/src/core/MIGRATION.md#architecture).
\ No newline at end of file
diff --git a/x-pack/plugins/ingest_manager/common/constants/agent_config.ts b/x-pack/plugins/ingest_manager/common/constants/agent_config.ts
new file mode 100644
index 0000000000000..d0854d6ffeec7
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/common/constants/agent_config.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import { AgentConfigStatus } from '../types';
+
+export const AGENT_CONFIG_SAVED_OBJECT_TYPE = 'agent_configs';
+
+export const DEFAULT_AGENT_CONFIG_ID = 'default';
+
+export const DEFAULT_AGENT_CONFIG = {
+ name: 'Default config',
+ namespace: 'default',
+ description: 'Default agent configuration created by Kibana',
+ status: AgentConfigStatus.Active,
+ datasources: [],
+};
diff --git a/x-pack/plugins/ingest_manager/common/constants/datasource.ts b/x-pack/plugins/ingest_manager/common/constants/datasource.ts
new file mode 100644
index 0000000000000..0ff472b2afeb0
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/common/constants/datasource.ts
@@ -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 const DATASOURCE_SAVED_OBJECT_TYPE = 'datasources';
diff --git a/x-pack/plugins/ingest_manager/common/constants/index.ts b/x-pack/plugins/ingest_manager/common/constants/index.ts
new file mode 100644
index 0000000000000..aa3b204be4889
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/common/constants/index.ts
@@ -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.
+ */
+export * from './plugin';
+export * from './routes';
+
+export * from './agent_config';
+export * from './datasource';
+export * from './output';
diff --git a/x-pack/plugins/ingest_manager/common/constants/output.ts b/x-pack/plugins/ingest_manager/common/constants/output.ts
new file mode 100644
index 0000000000000..e0262d0ca811c
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/common/constants/output.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import { OutputType } from '../types';
+
+export const OUTPUT_SAVED_OBJECT_TYPE = 'outputs';
+
+export const DEFAULT_OUTPUT_ID = 'default';
+
+export const DEFAULT_OUTPUT = {
+ name: DEFAULT_OUTPUT_ID,
+ type: OutputType.Elasticsearch,
+ hosts: [''],
+ ingest_pipeline: DEFAULT_OUTPUT_ID,
+ api_key: '',
+};
diff --git a/x-pack/plugins/ingest_manager/common/constants/plugin.ts b/x-pack/plugins/ingest_manager/common/constants/plugin.ts
new file mode 100644
index 0000000000000..7922e6cadfa28
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/common/constants/plugin.ts
@@ -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 const PLUGIN_ID = 'ingestManager';
diff --git a/x-pack/plugins/ingest_manager/common/constants/routes.ts b/x-pack/plugins/ingest_manager/common/constants/routes.ts
new file mode 100644
index 0000000000000..efd6ef17ba05b
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/common/constants/routes.ts
@@ -0,0 +1,44 @@
+/*
+ * 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.
+ */
+// Base API paths
+export const API_ROOT = `/api/ingest_manager`;
+export const DATASOURCE_API_ROOT = `${API_ROOT}/datasources`;
+export const AGENT_CONFIG_API_ROOT = `${API_ROOT}/agent_configs`;
+export const EPM_API_ROOT = `${API_ROOT}/epm`;
+export const FLEET_API_ROOT = `${API_ROOT}/fleet`;
+
+// EPM API routes
+export const EPM_API_ROUTES = {
+ LIST_PATTERN: `${EPM_API_ROOT}/list`,
+ INFO_PATTERN: `${EPM_API_ROOT}/package/{pkgkey}`,
+ INSTALL_PATTERN: `${EPM_API_ROOT}/install/{pkgkey}`,
+ DELETE_PATTERN: `${EPM_API_ROOT}/delete/{pkgkey}`,
+ CATEGORIES_PATTERN: `${EPM_API_ROOT}/categories`,
+};
+
+// Datasource API routes
+export const DATASOURCE_API_ROUTES = {
+ LIST_PATTERN: `${DATASOURCE_API_ROOT}`,
+ INFO_PATTERN: `${DATASOURCE_API_ROOT}/{datasourceId}`,
+ CREATE_PATTERN: `${DATASOURCE_API_ROOT}`,
+ UPDATE_PATTERN: `${DATASOURCE_API_ROOT}/{datasourceId}`,
+ DELETE_PATTERN: `${DATASOURCE_API_ROOT}/delete`,
+};
+
+// Agent config API routes
+export const AGENT_CONFIG_API_ROUTES = {
+ LIST_PATTERN: `${AGENT_CONFIG_API_ROOT}`,
+ INFO_PATTERN: `${AGENT_CONFIG_API_ROOT}/{agentConfigId}`,
+ CREATE_PATTERN: `${AGENT_CONFIG_API_ROOT}`,
+ UPDATE_PATTERN: `${AGENT_CONFIG_API_ROOT}/{agentConfigId}`,
+ DELETE_PATTERN: `${AGENT_CONFIG_API_ROOT}/delete`,
+};
+
+// Fleet setup API routes
+export const FLEET_SETUP_API_ROUTES = {
+ INFO_PATTERN: `${FLEET_API_ROOT}/setup`,
+ CREATE_PATTERN: `${FLEET_API_ROOT}/setup`,
+};
diff --git a/x-pack/plugins/ingest_manager/common/index.ts b/x-pack/plugins/ingest_manager/common/index.ts
new file mode 100644
index 0000000000000..3d1c70ba2635e
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/common/index.ts
@@ -0,0 +1,8 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+export * from './constants';
+export * from './services';
+export * from './types';
diff --git a/x-pack/plugins/ingest_manager/common/services/index.ts b/x-pack/plugins/ingest_manager/common/services/index.ts
new file mode 100644
index 0000000000000..1b3ae4706e3a7
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/common/services/index.ts
@@ -0,0 +1,6 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+export * from './routes';
diff --git a/x-pack/plugins/ingest_manager/common/services/routes.ts b/x-pack/plugins/ingest_manager/common/services/routes.ts
new file mode 100644
index 0000000000000..bcd1646fe1f0c
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/common/services/routes.ts
@@ -0,0 +1,77 @@
+/*
+ * 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 {
+ EPM_API_ROOT,
+ EPM_API_ROUTES,
+ DATASOURCE_API_ROUTES,
+ AGENT_CONFIG_API_ROUTES,
+} from '../constants';
+
+export const epmRouteService = {
+ getCategoriesPath: () => {
+ return EPM_API_ROUTES.CATEGORIES_PATTERN;
+ },
+
+ getListPath: () => {
+ return EPM_API_ROUTES.LIST_PATTERN;
+ },
+
+ getInfoPath: (pkgkey: string) => {
+ return EPM_API_ROUTES.INFO_PATTERN.replace('{pkgkey}', pkgkey);
+ },
+
+ getFilePath: (filePath: string) => {
+ return `${EPM_API_ROOT}${filePath}`;
+ },
+
+ getInstallPath: (pkgkey: string) => {
+ return EPM_API_ROUTES.INSTALL_PATTERN.replace('{pkgkey}', pkgkey).replace(/\/$/, ''); // trim trailing slash
+ },
+
+ getRemovePath: (pkgkey: string) => {
+ return EPM_API_ROUTES.DELETE_PATTERN.replace('{pkgkey}', pkgkey).replace(/\/$/, ''); // trim trailing slash
+ },
+};
+
+export const datasourceRouteService = {
+ getListPath: () => {
+ return DATASOURCE_API_ROUTES.LIST_PATTERN;
+ },
+
+ getInfoPath: (datasourceId: string) => {
+ return DATASOURCE_API_ROUTES.INFO_PATTERN.replace('{datasourceId}', datasourceId);
+ },
+
+ getCreatePath: () => {
+ return DATASOURCE_API_ROUTES.CREATE_PATTERN;
+ },
+
+ getUpdatePath: (datasourceId: string) => {
+ return DATASOURCE_API_ROUTES.UPDATE_PATTERN.replace('{datasourceId}', datasourceId);
+ },
+};
+
+export const agentConfigRouteService = {
+ getListPath: () => {
+ return AGENT_CONFIG_API_ROUTES.LIST_PATTERN;
+ },
+
+ getInfoPath: (agentConfigId: string) => {
+ return AGENT_CONFIG_API_ROUTES.INFO_PATTERN.replace('{agentConfigId}', agentConfigId);
+ },
+
+ getCreatePath: () => {
+ return AGENT_CONFIG_API_ROUTES.CREATE_PATTERN;
+ },
+
+ getUpdatePath: (agentConfigId: string) => {
+ return AGENT_CONFIG_API_ROUTES.UPDATE_PATTERN.replace('{agentConfigId}', agentConfigId);
+ },
+
+ getDeletePath: () => {
+ return AGENT_CONFIG_API_ROUTES.DELETE_PATTERN;
+ },
+};
diff --git a/x-pack/plugins/ingest_manager/common/types/index.ts b/x-pack/plugins/ingest_manager/common/types/index.ts
new file mode 100644
index 0000000000000..4abb1b659f036
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/common/types/index.ts
@@ -0,0 +1,20 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export * from './models';
+export * from './rest_spec';
+
+export interface IngestManagerConfigType {
+ enabled: boolean;
+ epm: {
+ enabled: boolean;
+ registryUrl: string;
+ };
+ fleet: {
+ enabled: boolean;
+ defaultOutputHost: string;
+ };
+}
diff --git a/x-pack/plugins/ingest_manager/common/types/models/agent_config.ts b/x-pack/plugins/ingest_manager/common/types/models/agent_config.ts
new file mode 100644
index 0000000000000..1cc8b32afe3c1
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/common/types/models/agent_config.ts
@@ -0,0 +1,32 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { DatasourceSchema } from './datasource';
+
+export enum AgentConfigStatus {
+ Active = 'active',
+ Inactive = 'inactive',
+}
+
+interface AgentConfigBaseSchema {
+ name: string;
+ namespace: string;
+ description?: string;
+}
+
+export type NewAgentConfigSchema = AgentConfigBaseSchema;
+
+export type AgentConfigSchema = AgentConfigBaseSchema & {
+ id: string;
+ status: AgentConfigStatus;
+ datasources: Array;
+ updated_on: string;
+ updated_by: string;
+};
+
+export type NewAgentConfig = NewAgentConfigSchema;
+
+export type AgentConfig = AgentConfigSchema;
diff --git a/x-pack/plugins/ingest_manager/common/types/models/datasource.ts b/x-pack/plugins/ingest_manager/common/types/models/datasource.ts
new file mode 100644
index 0000000000000..f28037845c7f7
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/common/types/models/datasource.ts
@@ -0,0 +1,43 @@
+/*
+ * 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.
+ */
+
+interface DatasourceBaseSchema {
+ name: string;
+ namespace: string;
+ read_alias: string;
+ agent_config_id: string;
+ package: {
+ assets: Array<{
+ id: string;
+ type: string;
+ }>;
+ description: string;
+ name: string;
+ title: string;
+ version: string;
+ };
+ streams: Array<{
+ config: Record;
+ input: {
+ type: string;
+ config: Record;
+ fields: Array>;
+ ilm_policy: string;
+ index_template: string;
+ ingest_pipelines: string[];
+ };
+ output_id: string;
+ processors: string[];
+ }>;
+}
+
+export type NewDatasourceSchema = DatasourceBaseSchema;
+
+export type DatasourceSchema = DatasourceBaseSchema & { id: string };
+
+export type NewDatasource = NewDatasourceSchema;
+
+export type Datasource = DatasourceSchema;
diff --git a/x-pack/plugins/ingest_manager/common/types/models/index.ts b/x-pack/plugins/ingest_manager/common/types/models/index.ts
new file mode 100644
index 0000000000000..959dfe1d937b9
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/common/types/models/index.ts
@@ -0,0 +1,8 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+export * from './agent_config';
+export * from './datasource';
+export * from './output';
diff --git a/x-pack/plugins/ingest_manager/common/types/models/output.ts b/x-pack/plugins/ingest_manager/common/types/models/output.ts
new file mode 100644
index 0000000000000..5f96fe33b5e16
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/common/types/models/output.ts
@@ -0,0 +1,33 @@
+/*
+ * 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 enum OutputType {
+ Elasticsearch = 'elasticsearch',
+}
+
+interface OutputBaseSchema {
+ name: string;
+ type: OutputType;
+ username?: string;
+ password?: string;
+ index_name?: string;
+ ingest_pipeline?: string;
+ hosts?: string[];
+ api_key?: string;
+ admin_username?: string;
+ admin_password?: string;
+ config?: Record;
+}
+
+export type NewOutputSchema = OutputBaseSchema;
+
+export type OutputSchema = OutputBaseSchema & {
+ id: string;
+};
+
+export type NewOutput = NewOutputSchema;
+
+export type Output = OutputSchema;
diff --git a/x-pack/plugins/ingest_manager/common/types/rest_spec/agent_config.ts b/x-pack/plugins/ingest_manager/common/types/rest_spec/agent_config.ts
new file mode 100644
index 0000000000000..5d281b03260db
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/common/types/rest_spec/agent_config.ts
@@ -0,0 +1,59 @@
+/*
+ * 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 { AgentConfig, NewAgentConfigSchema } from '../models';
+import { ListWithKuerySchema } from './common';
+
+export interface GetAgentConfigsRequestSchema {
+ query: ListWithKuerySchema;
+}
+
+export interface GetAgentConfigsResponse {
+ items: AgentConfig[];
+ total: number;
+ page: number;
+ perPage: number;
+ success: boolean;
+}
+
+export interface GetOneAgentConfigRequestSchema {
+ params: {
+ agentConfigId: string;
+ };
+}
+
+export interface GetOneAgentConfigResponse {
+ item: AgentConfig;
+ success: boolean;
+}
+
+export interface CreateAgentConfigRequestSchema {
+ body: NewAgentConfigSchema;
+}
+
+export interface CreateAgentConfigResponse {
+ item: AgentConfig;
+ success: boolean;
+}
+
+export type UpdateAgentConfigRequestSchema = GetOneAgentConfigRequestSchema & {
+ body: NewAgentConfigSchema;
+};
+
+export interface UpdateAgentConfigResponse {
+ item: AgentConfig;
+ success: boolean;
+}
+
+export interface DeleteAgentConfigsRequestSchema {
+ body: {
+ agentConfigIds: string[];
+ };
+}
+
+export type DeleteAgentConfigsResponse = Array<{
+ id: string;
+ success: boolean;
+}>;
diff --git a/x-pack/plugins/ingest_manager/common/types/rest_spec/common.ts b/x-pack/plugins/ingest_manager/common/types/rest_spec/common.ts
new file mode 100644
index 0000000000000..d247933d4011f
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/common/types/rest_spec/common.ts
@@ -0,0 +1,13 @@
+/*
+ * 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 interface ListWithKuerySchema {
+ page: number;
+ perPage: number;
+ kuery?: string;
+}
+
+export type ListWithKuery = ListWithKuerySchema;
diff --git a/x-pack/plugins/ingest_manager/common/types/rest_spec/datasource.ts b/x-pack/plugins/ingest_manager/common/types/rest_spec/datasource.ts
new file mode 100644
index 0000000000000..78859f2008005
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/common/types/rest_spec/datasource.ts
@@ -0,0 +1,36 @@
+/*
+ * 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 { NewDatasourceSchema } from '../models';
+import { ListWithKuerySchema } from './common';
+
+export interface GetDatasourcesRequestSchema {
+ query: ListWithKuerySchema;
+}
+
+export interface GetOneDatasourceRequestSchema {
+ params: {
+ datasourceId: string;
+ };
+}
+
+export interface CreateDatasourceRequestSchema {
+ body: NewDatasourceSchema;
+}
+
+export type UpdateDatasourceRequestSchema = GetOneDatasourceRequestSchema & {
+ body: NewDatasourceSchema;
+};
+
+export interface DeleteDatasourcesRequestSchema {
+ body: {
+ datasourceIds: string[];
+ };
+}
+
+export type DeleteDatasourcesResponse = Array<{
+ id: string;
+ success: boolean;
+}>;
diff --git a/x-pack/plugins/ingest_manager/common/types/rest_spec/fleet_setup.ts b/x-pack/plugins/ingest_manager/common/types/rest_spec/fleet_setup.ts
new file mode 100644
index 0000000000000..926021baab0ef
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/common/types/rest_spec/fleet_setup.ts
@@ -0,0 +1,19 @@
+/*
+ * 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.
+ */
+
+// eslint-disable-next-line @typescript-eslint/no-empty-interface
+export interface GetFleetSetupRequestSchema {}
+
+export interface CreateFleetSetupRequestSchema {
+ body: {
+ admin_username: string;
+ admin_password: string;
+ };
+}
+
+export interface CreateFleetSetupResponse {
+ isInitialized: boolean;
+}
diff --git a/x-pack/plugins/ingest_manager/common/types/rest_spec/index.ts b/x-pack/plugins/ingest_manager/common/types/rest_spec/index.ts
new file mode 100644
index 0000000000000..7d0d7e67f2db0
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/common/types/rest_spec/index.ts
@@ -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.
+ */
+export * from './common';
+export * from './datasource';
+export * from './agent_config';
+export * from './fleet_setup';
diff --git a/x-pack/plugins/ingest_manager/kibana.json b/x-pack/plugins/ingest_manager/kibana.json
new file mode 100644
index 0000000000000..cef1a293c104b
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/kibana.json
@@ -0,0 +1,9 @@
+{
+ "id": "ingestManager",
+ "version": "kibana",
+ "server": true,
+ "ui": true,
+ "configPath": ["xpack", "ingestManager"],
+ "requiredPlugins": ["licensing", "data", "encryptedSavedObjects"],
+ "optionalPlugins": ["security", "features"]
+}
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/index.ts
new file mode 100644
index 0000000000000..5133d82588494
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/index.ts
@@ -0,0 +1,6 @@
+/*
+ * 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 { Loading } from './loading';
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/loading.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/loading.tsx
new file mode 100644
index 0000000000000..c1fae19c5dab0
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/loading.tsx
@@ -0,0 +1,15 @@
+/*
+ * 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 from 'react';
+import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui';
+
+export const Loading: React.FunctionComponent<{}> = () => (
+
+
+
+
+
+);
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/constants/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/constants/index.ts
new file mode 100644
index 0000000000000..1af39a60455e0
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/constants/index.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export {
+ PLUGIN_ID,
+ EPM_API_ROUTES,
+ DEFAULT_AGENT_CONFIG_ID,
+ AGENT_CONFIG_SAVED_OBJECT_TYPE,
+} from '../../../../common';
+
+export const BASE_PATH = '/app/ingestManager';
+export const EPM_PATH = '/epm';
+export const AGENT_CONFIG_PATH = '/configs';
+export const AGENT_CONFIG_DETAILS_PATH = `${AGENT_CONFIG_PATH}/`;
+export const FLEET_PATH = '/fleet';
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/index.ts
new file mode 100644
index 0000000000000..a224b599c13af
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/index.ts
@@ -0,0 +1,13 @@
+/*
+ * 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 { useCore, CoreContext } from './use_core';
+export { useConfig, ConfigContext } from './use_config';
+export { useDeps, DepsContext } from './use_deps';
+export { useLink } from './use_link';
+export { usePagination } from './use_pagination';
+export { useDebounce } from './use_debounce';
+export * from './use_request';
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_config.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_config.ts
new file mode 100644
index 0000000000000..d3f27a180cfd0
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_config.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { useContext } from 'react';
+import { IngestManagerConfigType } from '../../../plugin';
+
+export const ConfigContext = React.createContext(null);
+
+export function useConfig() {
+ const config = useContext(ConfigContext);
+ if (config === null) {
+ throw new Error('ConfigContext not initialized');
+ }
+ return config;
+}
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_core.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_core.ts
new file mode 100644
index 0000000000000..c6e91444d21f5
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_core.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { useContext } from 'react';
+import { CoreStart } from 'kibana/public';
+
+export const CoreContext = React.createContext(null);
+
+export function useCore() {
+ const core = useContext(CoreContext);
+ if (core === null) {
+ throw new Error('CoreContext not initialized');
+ }
+ return core;
+}
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_debounce.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_debounce.tsx
new file mode 100644
index 0000000000000..f701ebeaadbe5
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_debounce.tsx
@@ -0,0 +1,23 @@
+/*
+ * 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 { useState, useEffect } from 'react';
+
+export function useDebounce(value: T, delay: number) {
+ const [debouncedValue, setDebouncedValue] = useState(value);
+
+ useEffect(() => {
+ const handler = setTimeout(() => {
+ setDebouncedValue(value);
+ }, delay);
+
+ return () => {
+ clearTimeout(handler);
+ };
+ }, [value, delay]);
+
+ return debouncedValue;
+}
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_deps.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_deps.ts
new file mode 100644
index 0000000000000..a2e2f278930e3
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_deps.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { useContext } from 'react';
+import { IngestManagerSetupDeps } from '../../../plugin';
+
+export const DepsContext = React.createContext(null);
+
+export function useDeps() {
+ const deps = useContext(DepsContext);
+ if (deps === null) {
+ throw new Error('DepsContext not initialized');
+ }
+ return deps;
+}
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_link.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_link.ts
new file mode 100644
index 0000000000000..333606cec8028
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_link.ts
@@ -0,0 +1,13 @@
+/*
+ * 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 { BASE_PATH } from '../constants';
+import { useCore } from './';
+
+export function useLink(path: string = '/') {
+ const core = useCore();
+ return core.http.basePath.prepend(`${BASE_PATH}#${path}`);
+}
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_pagination.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_pagination.tsx
new file mode 100644
index 0000000000000..ae0352a33b2ff
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_pagination.tsx
@@ -0,0 +1,25 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { useState } from 'react';
+
+export interface Pagination {
+ currentPage: number;
+ pageSize: number;
+}
+
+export function usePagination() {
+ const [pagination, setPagination] = useState({
+ currentPage: 1,
+ pageSize: 20,
+ });
+
+ return {
+ pagination,
+ setPagination,
+ pageSizeOptions: 20,
+ };
+}
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agent_config.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agent_config.ts
new file mode 100644
index 0000000000000..389909e1d99ef
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agent_config.ts
@@ -0,0 +1,61 @@
+/*
+ * 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 { HttpFetchQuery } from 'kibana/public';
+import { useRequest, sendRequest } from './use_request';
+import { agentConfigRouteService } from '../../services';
+import {
+ GetAgentConfigsResponse,
+ GetOneAgentConfigResponse,
+ CreateAgentConfigRequestSchema,
+ CreateAgentConfigResponse,
+ UpdateAgentConfigRequestSchema,
+ UpdateAgentConfigResponse,
+ DeleteAgentConfigsRequestSchema,
+ DeleteAgentConfigsResponse,
+} from '../../types';
+
+export const useGetAgentConfigs = (query: HttpFetchQuery = {}) => {
+ return useRequest({
+ path: agentConfigRouteService.getListPath(),
+ method: 'get',
+ query,
+ });
+};
+
+export const useGetOneAgentConfig = (agentConfigId: string) => {
+ return useRequest({
+ path: agentConfigRouteService.getInfoPath(agentConfigId),
+ method: 'get',
+ });
+};
+
+export const sendCreateAgentConfig = (body: CreateAgentConfigRequestSchema['body']) => {
+ return sendRequest({
+ path: agentConfigRouteService.getCreatePath(),
+ method: 'post',
+ body: JSON.stringify(body),
+ });
+};
+
+export const sendUpdateAgentConfig = (
+ agentConfigId: string,
+ body: UpdateAgentConfigRequestSchema['body']
+) => {
+ return sendRequest({
+ path: agentConfigRouteService.getUpdatePath(agentConfigId),
+ method: 'put',
+ body: JSON.stringify(body),
+ });
+};
+
+export const sendDeleteAgentConfigs = (body: DeleteAgentConfigsRequestSchema['body']) => {
+ return sendRequest({
+ path: agentConfigRouteService.getDeletePath(),
+ method: 'post',
+ body: JSON.stringify(body),
+ });
+};
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/index.ts
new file mode 100644
index 0000000000000..68d67080d90ba
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/index.ts
@@ -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 { setHttpClient, sendRequest } from './use_request';
+export * from './agent_config';
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/use_request.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/use_request.ts
new file mode 100644
index 0000000000000..12b4d0bdf7df6
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/use_request.ts
@@ -0,0 +1,35 @@
+/*
+ * 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 { HttpSetup } from 'kibana/public';
+import {
+ SendRequestConfig,
+ SendRequestResponse,
+ UseRequestConfig,
+ sendRequest as _sendRequest,
+ useRequest as _useRequest,
+} from '../../../../../../../../src/plugins/es_ui_shared/public';
+
+let httpClient: HttpSetup;
+
+export const setHttpClient = (client: HttpSetup) => {
+ httpClient = client;
+};
+
+export const sendRequest = (
+ config: SendRequestConfig
+): Promise> => {
+ if (!httpClient) {
+ throw new Error('sendRequest has no http client set');
+ }
+ return _sendRequest(httpClient, config);
+};
+
+export const useRequest = (config: UseRequestConfig) => {
+ if (!httpClient) {
+ throw new Error('sendRequest has no http client set');
+ }
+ return _useRequest(httpClient, config);
+};
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx
new file mode 100644
index 0000000000000..935eb42d0347e
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx
@@ -0,0 +1,108 @@
+/*
+ * 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 from 'react';
+import ReactDOM from 'react-dom';
+import { useObservable } from 'react-use';
+import { HashRouter as Router, Redirect, Switch, Route, RouteProps } from 'react-router-dom';
+import { CoreStart, AppMountParameters } from 'kibana/public';
+import { EuiErrorBoundary } from '@elastic/eui';
+import { EuiThemeProvider } from '../../../../../legacy/common/eui_styled_components';
+import { IngestManagerSetupDeps, IngestManagerConfigType } from '../../plugin';
+import { EPM_PATH, FLEET_PATH, AGENT_CONFIG_PATH } from './constants';
+import { DefaultLayout } from './layouts';
+import { IngestManagerOverview, EPMApp, AgentConfigApp, FleetApp } from './sections';
+import { CoreContext, DepsContext, ConfigContext, setHttpClient, useConfig } from './hooks';
+
+export interface ProtectedRouteProps extends RouteProps {
+ isAllowed?: boolean;
+ restrictedPath?: string;
+}
+
+export const ProtectedRoute: React.FunctionComponent = ({
+ isAllowed = false,
+ restrictedPath = '/',
+ ...routeProps
+}: ProtectedRouteProps) => {
+ return isAllowed ? : ;
+};
+
+const IngestManagerRoutes = ({ ...rest }) => {
+ const { epm, fleet } = useConfig();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const IngestManagerApp = ({
+ basepath,
+ coreStart,
+ deps,
+ config,
+}: {
+ basepath: string;
+ coreStart: CoreStart;
+ deps: IngestManagerSetupDeps;
+ config: IngestManagerConfigType;
+}) => {
+ const isDarkMode = useObservable(coreStart.uiSettings.get$('theme:darkMode'));
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export function renderApp(
+ coreStart: CoreStart,
+ { element, appBasePath }: AppMountParameters,
+ deps: IngestManagerSetupDeps,
+ config: IngestManagerConfigType
+) {
+ setHttpClient(coreStart.http);
+ ReactDOM.render(
+ ,
+ element
+ );
+
+ return () => {
+ ReactDOM.unmountComponentAtNode(element);
+ };
+}
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/layouts/default.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/layouts/default.tsx
new file mode 100644
index 0000000000000..eaf49fed3d933
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/layouts/default.tsx
@@ -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 React from 'react';
+import {
+ EuiPage,
+ EuiPageBody,
+ EuiTabs,
+ EuiTab,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiIcon,
+} from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
+import euiStyled from '../../../../../../legacy/common/eui_styled_components';
+import { Section } from '../sections';
+import { useLink, useConfig } from '../hooks';
+import { EPM_PATH, FLEET_PATH, AGENT_CONFIG_PATH } from '../constants';
+
+interface Props {
+ section: Section;
+ children?: React.ReactNode;
+}
+
+const Nav = euiStyled.nav`
+ background: ${props => props.theme.eui.euiColorEmptyShade};
+ border-bottom: ${props => props.theme.eui.euiBorderThin};
+ padding: ${props =>
+ `${props.theme.eui.euiSize} ${props.theme.eui.euiSizeL} ${props.theme.eui.euiSize} ${props.theme.eui.euiSizeL}`};
+ .euiTabs {
+ padding-left: 3px;
+ margin-left: -3px;
+ };
+`;
+
+export const DefaultLayout: React.FunctionComponent = ({ section, children }) => {
+ const { epm, fleet } = useConfig();
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {children}
+
+
+ );
+};
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/layouts/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/layouts/index.tsx
new file mode 100644
index 0000000000000..858951bd0d38f
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/layouts/index.tsx
@@ -0,0 +1,6 @@
+/*
+ * 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 { DefaultLayout } from './default';
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_delete_provider.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_delete_provider.tsx
new file mode 100644
index 0000000000000..6f51415a562a3
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_delete_provider.tsx
@@ -0,0 +1,217 @@
+/*
+ * 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, { Fragment, useRef, useState } from 'react';
+import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+import { FormattedMessage } from '@kbn/i18n/react';
+import { sendDeleteAgentConfigs, useCore, sendRequest } from '../../../hooks';
+
+interface Props {
+ children: (deleteAgentConfigs: deleteAgentConfigs) => React.ReactElement;
+}
+
+export type deleteAgentConfigs = (agentConfigs: string[], onSuccess?: OnSuccessCallback) => void;
+
+type OnSuccessCallback = (agentConfigsUnenrolled: string[]) => void;
+
+export const AgentConfigDeleteProvider: React.FunctionComponent = ({ children }) => {
+ const { notifications } = useCore();
+ const [agentConfigs, setAgentConfigs] = useState([]);
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const [isLoadingAgentsCount, setIsLoadingAgentsCount] = useState(false);
+ const [agentsCount, setAgentsCount] = useState(0);
+ const [isLoading, setIsLoading] = useState(false);
+ const onSuccessCallback = useRef(null);
+
+ const deleteAgentConfigsPrompt: deleteAgentConfigs = (
+ agentConfigsToDelete,
+ onSuccess = () => undefined
+ ) => {
+ if (
+ agentConfigsToDelete === undefined ||
+ (Array.isArray(agentConfigsToDelete) && agentConfigsToDelete.length === 0)
+ ) {
+ throw new Error('No agent configs specified for deletion');
+ }
+ setIsModalOpen(true);
+ setAgentConfigs(agentConfigsToDelete);
+ fetchAgentsCount(agentConfigsToDelete);
+ onSuccessCallback.current = onSuccess;
+ };
+
+ const closeModal = () => {
+ setAgentConfigs([]);
+ setIsLoading(false);
+ setIsLoadingAgentsCount(false);
+ setIsModalOpen(false);
+ };
+
+ const deleteAgentConfigs = async () => {
+ setIsLoading(true);
+
+ try {
+ const { data } = await sendDeleteAgentConfigs({
+ agentConfigIds: agentConfigs,
+ });
+ const successfulResults = data?.filter(result => result.success) || [];
+ const failedResults = data?.filter(result => !result.success) || [];
+
+ if (successfulResults.length) {
+ const hasMultipleSuccesses = successfulResults.length > 1;
+ const successMessage = hasMultipleSuccesses
+ ? i18n.translate(
+ 'xpack.ingestManager.deleteAgentConfigs.successMultipleNotificationTitle',
+ {
+ defaultMessage: 'Deleted {count} agent configs',
+ values: { count: successfulResults.length },
+ }
+ )
+ : i18n.translate(
+ 'xpack.ingestManager.deleteAgentConfigs.successSingleNotificationTitle',
+ {
+ defaultMessage: "Deleted agent config '{id}'",
+ values: { id: successfulResults[0].id },
+ }
+ );
+ notifications.toasts.addSuccess(successMessage);
+ }
+
+ if (failedResults.length) {
+ const hasMultipleFailures = failedResults.length > 1;
+ const failureMessage = hasMultipleFailures
+ ? i18n.translate(
+ 'xpack.ingestManager.deleteAgentConfigs.failureMultipleNotificationTitle',
+ {
+ defaultMessage: 'Error deleting {count} agent configs',
+ values: { count: failedResults.length },
+ }
+ )
+ : i18n.translate(
+ 'xpack.ingestManager.deleteAgentConfigs.failureSingleNotificationTitle',
+ {
+ defaultMessage: "Error deleting agent config '{id}'",
+ values: { id: failedResults[0].id },
+ }
+ );
+ notifications.toasts.addDanger(failureMessage);
+ }
+
+ if (onSuccessCallback.current) {
+ onSuccessCallback.current(successfulResults.map(result => result.id));
+ }
+ } catch (e) {
+ notifications.toasts.addDanger(
+ i18n.translate('xpack.ingestManager.deleteAgentConfigs.fatalErrorNotificationTitle', {
+ defaultMessage: 'Error deleting agent configs',
+ })
+ );
+ }
+ closeModal();
+ };
+
+ const fetchAgentsCount = async (agentConfigsToCheck: string[]) => {
+ if (isLoadingAgentsCount) {
+ return;
+ }
+ setIsLoadingAgentsCount(true);
+ const { data } = await sendRequest<{ total: number }>({
+ path: `/api/fleet/agents`,
+ method: 'get',
+ query: {
+ kuery: `agents.policy_id : (${agentConfigsToCheck.join(' or ')})`,
+ },
+ });
+ setAgentsCount(data?.total || 0);
+ setIsLoadingAgentsCount(false);
+ };
+
+ const renderModal = () => {
+ if (!isModalOpen) {
+ return null;
+ }
+
+ return (
+
+
+ }
+ onCancel={closeModal}
+ onConfirm={deleteAgentConfigs}
+ cancelButtonText={
+
+ }
+ confirmButtonText={
+ isLoading || isLoadingAgentsCount ? (
+
+ ) : agentsCount ? (
+
+ ) : (
+
+ )
+ }
+ buttonColor="danger"
+ confirmButtonDisabled={isLoading || isLoadingAgentsCount}
+ >
+ {isLoadingAgentsCount ? (
+
+ ) : agentsCount ? (
+
+ ) : (
+
+ )}
+
+
+ );
+ };
+
+ return (
+
+ {children(deleteAgentConfigsPrompt)}
+ {renderModal()}
+
+ );
+};
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_form.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_form.tsx
new file mode 100644
index 0000000000000..5a25dc8bc92b5
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_form.tsx
@@ -0,0 +1,96 @@
+/*
+ * 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, { useState } from 'react';
+import { EuiFieldText, EuiForm, EuiFormRow } from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
+import { NewAgentConfig } from '../../../types';
+
+interface ValidationResults {
+ [key: string]: JSX.Element[];
+}
+
+export const agentConfigFormValidation = (
+ agentConfig: Partial
+): ValidationResults => {
+ const errors: ValidationResults = {};
+
+ if (!agentConfig.name?.trim()) {
+ errors.name = [
+ ,
+ ];
+ }
+
+ return errors;
+};
+
+interface Props {
+ agentConfig: Partial;
+ updateAgentConfig: (u: Partial) => void;
+ validation: ValidationResults;
+}
+
+export const AgentConfigForm: React.FunctionComponent = ({
+ agentConfig,
+ updateAgentConfig,
+ validation,
+}) => {
+ const [touchedFields, setTouchedFields] = useState<{ [key: string]: boolean }>({});
+ const fields: Array<{ name: 'name' | 'description' | 'namespace'; label: JSX.Element }> = [
+ {
+ name: 'name',
+ label: (
+
+ ),
+ },
+ {
+ name: 'description',
+ label: (
+
+ ),
+ },
+ {
+ name: 'namespace',
+ label: (
+
+ ),
+ },
+ ];
+
+ return (
+
+ {fields.map(({ name, label }) => {
+ return (
+
+ updateAgentConfig({ [name]: e.target.value })}
+ isInvalid={Boolean(touchedFields[name] && validation[name])}
+ onBlur={() => setTouchedFields({ ...touchedFields, [name]: true })}
+ />
+
+ );
+ })}
+
+ );
+};
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/index.ts
new file mode 100644
index 0000000000000..d838221cd844e
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/index.ts
@@ -0,0 +1,8 @@
+/*
+ * 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 { AgentConfigForm, agentConfigFormValidation } from './config_form';
+export { AgentConfigDeleteProvider } from './config_delete_provider';
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/index.tsx
new file mode 100644
index 0000000000000..c80c4496198be
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/index.tsx
@@ -0,0 +1,18 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import React from 'react';
+import { HashRouter as Router, Switch, Route } from 'react-router-dom';
+import { AgentConfigListPage } from './list_page';
+
+export const AgentConfigApp: React.FunctionComponent = () => (
+
+
+
+
+
+
+
+);
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/components/create_config.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/components/create_config.tsx
new file mode 100644
index 0000000000000..c6fea7b22bcd1
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/components/create_config.tsx
@@ -0,0 +1,143 @@
+/*
+ * 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, { useState } from 'react';
+import { i18n } from '@kbn/i18n';
+import { FormattedMessage } from '@kbn/i18n/react';
+import {
+ EuiFlyout,
+ EuiFlyoutHeader,
+ EuiTitle,
+ EuiFlyoutBody,
+ EuiFlyoutFooter,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiButtonEmpty,
+ EuiButton,
+} from '@elastic/eui';
+import { NewAgentConfig } from '../../../../types';
+import { useCore, sendCreateAgentConfig } from '../../../../hooks';
+import { AgentConfigForm, agentConfigFormValidation } from '../../components';
+
+interface Props {
+ onClose: () => void;
+}
+
+export const CreateAgentConfigFlyout: React.FunctionComponent = ({ onClose }) => {
+ const { notifications } = useCore();
+
+ const [agentConfig, setAgentConfig] = useState({
+ name: '',
+ description: '',
+ namespace: '',
+ });
+ const [isLoading, setIsLoading] = useState(false);
+ const validation = agentConfigFormValidation(agentConfig);
+
+ const updateAgentConfig = (updatedFields: Partial) => {
+ setAgentConfig({
+ ...agentConfig,
+ ...updatedFields,
+ });
+ };
+
+ const createAgentConfig = async () => {
+ return await sendCreateAgentConfig(agentConfig);
+ };
+
+ const header = (
+
+
+
+
+
+
+
+ );
+
+ const body = (
+
+
+
+ );
+
+ const footer = (
+
+
+
+
+
+
+
+
+ 0}
+ onClick={async () => {
+ setIsLoading(true);
+ try {
+ const { data, error } = await createAgentConfig();
+ if (data?.success) {
+ notifications.toasts.addSuccess(
+ i18n.translate(
+ 'xpack.ingestManager.createAgentConfig.successNotificationTitle',
+ {
+ defaultMessage: "Agent config '{name}' created",
+ values: { name: agentConfig.name },
+ }
+ )
+ );
+ } else {
+ notifications.toasts.addDanger(
+ error
+ ? error.message
+ : i18n.translate(
+ 'xpack.ingestManager.createAgentConfig.errorNotificationTitle',
+ {
+ defaultMessage: 'Unable to create agent config',
+ }
+ )
+ );
+ }
+ } catch (e) {
+ notifications.toasts.addDanger(
+ i18n.translate('xpack.ingestManager.createAgentConfig.errorNotificationTitle', {
+ defaultMessage: 'Unable to create agent config',
+ })
+ );
+ }
+ setIsLoading(false);
+ onClose();
+ }}
+ >
+
+
+
+
+
+ );
+
+ return (
+
+ {header}
+ {body}
+ {footer}
+
+ );
+};
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/components/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/components/index.ts
new file mode 100644
index 0000000000000..43668b4ffb804
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/components/index.ts
@@ -0,0 +1,6 @@
+/*
+ * 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 { CreateAgentConfigFlyout } from './create_config';
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/index.tsx
new file mode 100644
index 0000000000000..ca9fb195166f6
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/index.tsx
@@ -0,0 +1,280 @@
+/*
+ * 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, { useState } from 'react';
+import {
+ EuiPageBody,
+ EuiPageContent,
+ EuiTitle,
+ EuiSpacer,
+ EuiText,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiButton,
+ EuiEmptyPrompt,
+ // @ts-ignore
+ EuiSearchBar,
+ EuiBasicTable,
+ EuiLink,
+ EuiBadge,
+} from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+import { FormattedMessage } from '@kbn/i18n/react';
+import { AgentConfig } from '../../../types';
+import { DEFAULT_AGENT_CONFIG_ID, AGENT_CONFIG_DETAILS_PATH } from '../../../constants';
+// import { SearchBar } from '../../../components';
+import { useGetAgentConfigs, usePagination, useLink } from '../../../hooks';
+import { AgentConfigDeleteProvider } from '../components';
+import { CreateAgentConfigFlyout } from './components';
+
+export const AgentConfigListPage: React.FunctionComponent<{}> = () => {
+ // Create agent config flyout state
+ const [isCreateAgentConfigFlyoutOpen, setIsCreateAgentConfigFlyoutOpen] = useState(
+ false
+ );
+
+ // Table and search states
+ const [search, setSearch] = useState('');
+ const { pagination, setPagination } = usePagination();
+ const [selectedAgentConfigs, setSelectedAgentConfigs] = useState([]);
+
+ // Fetch agent configs
+ const { isLoading, data: agentConfigData, sendRequest } = useGetAgentConfigs();
+
+ // Base path for config details
+ const DETAILS_URI = useLink(AGENT_CONFIG_DETAILS_PATH);
+
+ // Some configs retrieved, set up table props
+ const columns = [
+ {
+ field: 'name',
+ name: i18n.translate('xpack.ingestManager.agentConfigList.nameColumnTitle', {
+ defaultMessage: 'Name',
+ }),
+ render: (name: string, agentConfig: AgentConfig) => name || agentConfig.id,
+ },
+ {
+ field: 'namespace',
+ name: i18n.translate('xpack.ingestManager.agentConfigList.namespaceColumnTitle', {
+ defaultMessage: 'Namespace',
+ }),
+ render: (namespace: string) => (namespace ? {namespace} : null),
+ },
+ {
+ field: 'description',
+ name: i18n.translate('xpack.ingestManager.agentConfigList.descriptionColumnTitle', {
+ defaultMessage: 'Description',
+ }),
+ },
+ {
+ field: 'datasources',
+ name: i18n.translate('xpack.ingestManager.agentConfigList.datasourcesCountColumnTitle', {
+ defaultMessage: 'Datasources',
+ }),
+ render: (datasources: AgentConfig['datasources']) => (datasources ? datasources.length : 0),
+ },
+ {
+ name: i18n.translate('xpack.ingestManager.agentConfigList.actionsColumnTitle', {
+ defaultMessage: 'Actions',
+ }),
+ actions: [
+ {
+ render: ({ id }: AgentConfig) => {
+ return (
+
+
+
+ );
+ },
+ },
+ ],
+ width: '100px',
+ },
+ ];
+
+ const emptyPrompt = (
+
+
+
+ }
+ actions={
+ setIsCreateAgentConfigFlyoutOpen(true)}
+ >
+
+
+ }
+ />
+ );
+
+ return (
+
+
+ {isCreateAgentConfigFlyoutOpen ? (
+ {
+ setIsCreateAgentConfigFlyoutOpen(false);
+ sendRequest();
+ }}
+ />
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {selectedAgentConfigs.length ? (
+
+
+ {deleteAgentConfigsPrompt => (
+ {
+ deleteAgentConfigsPrompt(
+ selectedAgentConfigs.map(agentConfig => agentConfig.id),
+ () => {
+ sendRequest();
+ setSelectedAgentConfigs([]);
+ }
+ );
+ }}
+ >
+
+
+ )}
+
+
+ ) : null}
+
+ {/* {
+ setPagination({
+ ...pagination,
+ currentPage: 1,
+ });
+ setSearch(newSearch);
+ }}
+ fieldPrefix={AGENT_CONFIG_SAVED_OBJECT_TYPE}
+ /> */}
+
+
+ sendRequest()}>
+
+
+
+
+ setIsCreateAgentConfigFlyoutOpen(true)}
+ >
+
+
+
+
+
+
+
+ ) : !search.trim() && agentConfigData?.total === 0 ? (
+ emptyPrompt
+ ) : (
+ setSearch('')}>
+
+
+ ),
+ }}
+ />
+ )
+ }
+ items={agentConfigData ? agentConfigData.items : []}
+ itemId="id"
+ columns={columns}
+ isSelectable={true}
+ selection={{
+ selectable: (agentConfig: AgentConfig) => agentConfig.id !== DEFAULT_AGENT_CONFIG_ID,
+ onSelectionChange: (newSelectedAgentConfigs: AgentConfig[]) => {
+ setSelectedAgentConfigs(newSelectedAgentConfigs);
+ },
+ }}
+ pagination={{
+ pageIndex: pagination.currentPage - 1,
+ pageSize: pagination.pageSize,
+ totalItemCount: agentConfigData ? agentConfigData.total : 0,
+ }}
+ onChange={({ page }: { page: { index: number; size: number } }) => {
+ const newPagination = {
+ ...pagination,
+ currentPage: page.index + 1,
+ pageSize: page.size,
+ };
+ setPagination(newPagination);
+ sendRequest(); // todo: fix this to send pagination options
+ }}
+ />
+
+
+ );
+};
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/index.tsx
new file mode 100644
index 0000000000000..ca8c22be9c34c
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/index.tsx
@@ -0,0 +1,13 @@
+/*
+ * 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 from 'react';
+import { useConfig } from '../../hooks';
+
+export const EPMApp: React.FunctionComponent = () => {
+ const { epm } = useConfig();
+ return epm.enabled ? hello world - epm app
: null;
+};
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/index.tsx
new file mode 100644
index 0000000000000..978414769004d
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/index.tsx
@@ -0,0 +1,12 @@
+/*
+ * 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 from 'react';
+import { useConfig } from '../../hooks';
+
+export const FleetApp: React.FunctionComponent = () => {
+ const { fleet } = useConfig();
+ return fleet.enabled ? hello world - fleet app
: null;
+};
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/index.tsx
new file mode 100644
index 0000000000000..c691bb609d435
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/index.tsx
@@ -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.
+ */
+export { IngestManagerOverview } from './overview';
+export { EPMApp } from './epm';
+export { AgentConfigApp } from './agent_config';
+export { FleetApp } from './fleet';
+
+export type Section = 'overview' | 'epm' | 'agent_config' | 'fleet';
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/index.tsx
new file mode 100644
index 0000000000000..da4a78a39e2fe
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/index.tsx
@@ -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 from 'react';
+
+export const IngestManagerOverview: React.FunctionComponent = () => {
+ return Ingest manager overview page
;
+};
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/services/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/services/index.ts
new file mode 100644
index 0000000000000..6502b0fff7123
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/services/index.ts
@@ -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 { agentConfigRouteService } from '../../../../common';
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts
new file mode 100644
index 0000000000000..8597d6fd59323
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts
@@ -0,0 +1,19 @@
+/*
+ * 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 {
+ // Object types
+ AgentConfig,
+ NewAgentConfig,
+ // API schemas
+ GetAgentConfigsResponse,
+ GetOneAgentConfigResponse,
+ CreateAgentConfigRequestSchema,
+ CreateAgentConfigResponse,
+ UpdateAgentConfigRequestSchema,
+ UpdateAgentConfigResponse,
+ DeleteAgentConfigsRequestSchema,
+ DeleteAgentConfigsResponse,
+} from '../../../../common';
diff --git a/x-pack/plugins/ingest_manager/public/index.ts b/x-pack/plugins/ingest_manager/public/index.ts
new file mode 100644
index 0000000000000..a9e40a2a42302
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/public/index.ts
@@ -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 { PluginInitializerContext } from 'kibana/public';
+import { IngestManagerPlugin } from './plugin';
+
+export const plugin = (initializerContext: PluginInitializerContext) => {
+ return new IngestManagerPlugin(initializerContext);
+};
diff --git a/x-pack/plugins/ingest_manager/public/plugin.ts b/x-pack/plugins/ingest_manager/public/plugin.ts
new file mode 100644
index 0000000000000..ae244e7ebec3d
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/public/plugin.ts
@@ -0,0 +1,57 @@
+/*
+ * 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 {
+ AppMountParameters,
+ CoreSetup,
+ CoreStart,
+ Plugin,
+ PluginInitializerContext,
+} from 'kibana/public';
+import { i18n } from '@kbn/i18n';
+import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/utils';
+import { DataPublicPluginSetup } from '../../../../src/plugins/data/public';
+import { LicensingPluginSetup } from '../../licensing/public';
+import { PLUGIN_ID } from '../common/constants';
+import { IngestManagerConfigType } from '../common/types';
+
+export { IngestManagerConfigType } from '../common/types';
+
+export type IngestManagerSetup = void;
+export type IngestManagerStart = void;
+
+export interface IngestManagerSetupDeps {
+ licensing: LicensingPluginSetup;
+ data: DataPublicPluginSetup;
+}
+
+export class IngestManagerPlugin implements Plugin {
+ private config: IngestManagerConfigType;
+
+ constructor(private readonly initializerContext: PluginInitializerContext) {
+ this.config = this.initializerContext.config.get();
+ }
+
+ public setup(core: CoreSetup, deps: IngestManagerSetupDeps) {
+ const config = this.config;
+
+ // Register main Ingest Manager app
+ core.application.register({
+ id: PLUGIN_ID,
+ category: DEFAULT_APP_CATEGORIES.management,
+ title: i18n.translate('xpack.ingestManager.appTitle', { defaultMessage: 'Ingest Manager' }),
+ euiIconType: 'savedObjectsApp',
+ async mount(params: AppMountParameters) {
+ const [coreStart] = await core.getStartServices();
+ const { renderApp } = await import('./applications/ingest_manager');
+ return renderApp(coreStart, params, deps, config);
+ },
+ });
+ }
+
+ public start(core: CoreStart) {}
+
+ public stop() {}
+}
diff --git a/x-pack/plugins/ingest_manager/server/constants/index.ts b/x-pack/plugins/ingest_manager/server/constants/index.ts
new file mode 100644
index 0000000000000..6b54afa1d81cb
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/server/constants/index.ts
@@ -0,0 +1,22 @@
+/*
+ * 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 {
+ // Routes
+ PLUGIN_ID,
+ EPM_API_ROUTES,
+ DATASOURCE_API_ROUTES,
+ AGENT_CONFIG_API_ROUTES,
+ FLEET_SETUP_API_ROUTES,
+ // Saved object types
+ AGENT_CONFIG_SAVED_OBJECT_TYPE,
+ DATASOURCE_SAVED_OBJECT_TYPE,
+ OUTPUT_SAVED_OBJECT_TYPE,
+ // Defaults
+ DEFAULT_AGENT_CONFIG_ID,
+ DEFAULT_AGENT_CONFIG,
+ DEFAULT_OUTPUT_ID,
+ DEFAULT_OUTPUT,
+} from '../../common';
diff --git a/x-pack/plugins/ingest_manager/server/index.ts b/x-pack/plugins/ingest_manager/server/index.ts
new file mode 100644
index 0000000000000..5228f1e0e3469
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/server/index.ts
@@ -0,0 +1,43 @@
+/*
+ * 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 { schema } from '@kbn/config-schema';
+import { PluginInitializerContext } from 'kibana/server';
+import { IngestManagerPlugin } from './plugin';
+
+export const config = {
+ exposeToBrowser: {
+ epm: true,
+ fleet: true,
+ },
+ schema: schema.object({
+ enabled: schema.boolean({ defaultValue: false }),
+ epm: schema.object({
+ enabled: schema.boolean({ defaultValue: false }),
+ registryUrl: schema.uri({ defaultValue: 'https://epr-staging.elastic.co' }),
+ }),
+ fleet: schema.object({
+ enabled: schema.boolean({ defaultValue: false }),
+ defaultOutputHost: schema.string({ defaultValue: 'http://localhost:9200' }),
+ }),
+ }),
+};
+
+export const plugin = (initializerContext: PluginInitializerContext) => {
+ return new IngestManagerPlugin(initializerContext);
+};
+
+// Saved object information bootstrapped by legacy `ingest_manager` plugin
+// TODO: Remove once saved object mappings can be done from NP
+export { savedObjectMappings } from './saved_objects';
+export {
+ OUTPUT_SAVED_OBJECT_TYPE,
+ AGENT_CONFIG_SAVED_OBJECT_TYPE,
+ DATASOURCE_SAVED_OBJECT_TYPE,
+} from './constants';
+
+// TODO: Temporary exports for Fleet dependencies, remove once Fleet moved into this plugin
+export { agentConfigService, outputService } from './services';
diff --git a/x-pack/plugins/ingest_manager/server/integration_tests/router.test.ts b/x-pack/plugins/ingest_manager/server/integration_tests/router.test.ts
new file mode 100644
index 0000000000000..38976957173f4
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/server/integration_tests/router.test.ts
@@ -0,0 +1,190 @@
+/*
+ * 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 { resolve } from 'path';
+import * as kbnTestServer from '../../../../../src/test_utils/kbn_server';
+
+function createXPackRoot(config: {} = {}) {
+ return kbnTestServer.createRoot({
+ plugins: {
+ scanDirs: [],
+ paths: [
+ resolve(__dirname, '../../../../../x-pack/plugins/encrypted_saved_objects'),
+ resolve(__dirname, '../../../../../x-pack/plugins/ingest_manager'),
+ resolve(__dirname, '../../../../../x-pack/plugins/licensing'),
+ ],
+ },
+ migrations: { skip: true },
+ xpack: config,
+ });
+}
+
+describe('ingestManager', () => {
+ describe('default. manager, EPM, and Fleet all disabled', () => {
+ let root: ReturnType;
+ beforeAll(async () => {
+ root = createXPackRoot();
+ await root.setup();
+ await root.start();
+ }, 30000);
+
+ afterAll(async () => await root.shutdown());
+
+ it('does not have agent config api', async () => {
+ await kbnTestServer.request.get(root, '/api/ingest_manager/agent_configs').expect(404);
+ });
+
+ it('does not have datasources api', async () => {
+ await kbnTestServer.request.get(root, '/api/ingest_manager/datasources').expect(404);
+ });
+
+ it('does not have EPM api', async () => {
+ await kbnTestServer.request.get(root, '/api/ingest_manager/epm').expect(404);
+ });
+
+ it('does not have Fleet api', async () => {
+ await kbnTestServer.request.get(root, '/api/ingest_manager/fleet').expect(404);
+ });
+ });
+
+ describe('manager only (no EPM, no Fleet)', () => {
+ let root: ReturnType;
+ beforeAll(async () => {
+ const ingestManagerConfig = {
+ enabled: true,
+ };
+ root = createXPackRoot({
+ ingestManager: ingestManagerConfig,
+ });
+ await root.setup();
+ await root.start();
+ }, 30000);
+
+ afterAll(async () => await root.shutdown());
+
+ it('has agent config api', async () => {
+ await kbnTestServer.request.get(root, '/api/ingest_manager/agent_configs').expect(200);
+ });
+
+ it('has datasources api', async () => {
+ await kbnTestServer.request.get(root, '/api/ingest_manager/datasources').expect(200);
+ });
+
+ it('does not have EPM api', async () => {
+ await kbnTestServer.request.get(root, '/api/ingest_manager/epm/list').expect(404);
+ });
+
+ it('does not have Fleet api', async () => {
+ await kbnTestServer.request.get(root, '/api/ingest_manager/fleet/setup').expect(404);
+ });
+ });
+
+ // For now, only the manager routes (/agent_configs & /datasources) are added
+ // EPM and ingest will be conditionally added when we enable these lines
+ // https://github.com/jfsiii/kibana/blob/f73b54ebb7e0f6fc00efd8a6800a01eb2d9fb772/x-pack/plugins/ingest_manager/server/plugin.ts#L84
+ // adding tests to confirm the Fleet & EPM routes are never added
+
+ describe('manager and EPM; no Fleet', () => {
+ let root: ReturnType;
+ beforeAll(async () => {
+ const ingestManagerConfig = {
+ enabled: true,
+ epm: { enabled: true },
+ };
+ root = createXPackRoot({
+ ingestManager: ingestManagerConfig,
+ });
+ await root.setup();
+ await root.start();
+ }, 30000);
+
+ afterAll(async () => await root.shutdown());
+
+ it('has agent config api', async () => {
+ await kbnTestServer.request.get(root, '/api/ingest_manager/agent_configs').expect(200);
+ });
+
+ it('has datasources api', async () => {
+ await kbnTestServer.request.get(root, '/api/ingest_manager/datasources').expect(200);
+ });
+
+ it('does not have EPM api', async () => {
+ await kbnTestServer.request.get(root, '/api/ingest_manager/epm/list').expect(404);
+ });
+
+ it('does not have Fleet api', async () => {
+ await kbnTestServer.request.get(root, '/api/ingest_manager/fleet/setup').expect(404);
+ });
+ });
+
+ describe('manager and Fleet; no EPM)', () => {
+ let root: ReturnType;
+ beforeAll(async () => {
+ const ingestManagerConfig = {
+ enabled: true,
+ epm: { enabled: true },
+ fleet: { enabled: true },
+ };
+ root = createXPackRoot({
+ ingestManager: ingestManagerConfig,
+ });
+ await root.setup();
+ await root.start();
+ }, 30000);
+
+ afterAll(async () => await root.shutdown());
+
+ it('has agent config api', async () => {
+ await kbnTestServer.request.get(root, '/api/ingest_manager/agent_configs').expect(200);
+ });
+
+ it('has datasources api', async () => {
+ await kbnTestServer.request.get(root, '/api/ingest_manager/datasources').expect(200);
+ });
+
+ it('does not have EPM api', async () => {
+ await kbnTestServer.request.get(root, '/api/ingest_manager/epm/list').expect(404);
+ });
+
+ it('does not have Fleet api', async () => {
+ await kbnTestServer.request.get(root, '/api/ingest_manager/fleet/setup').expect(404);
+ });
+ });
+
+ describe('all flags enabled: manager, EPM, and Fleet)', () => {
+ let root: ReturnType;
+ beforeAll(async () => {
+ const ingestManagerConfig = {
+ enabled: true,
+ epm: { enabled: true },
+ fleet: { enabled: true },
+ };
+ root = createXPackRoot({
+ ingestManager: ingestManagerConfig,
+ });
+ await root.setup();
+ await root.start();
+ }, 30000);
+
+ afterAll(async () => await root.shutdown());
+
+ it('has agent config api', async () => {
+ await kbnTestServer.request.get(root, '/api/ingest_manager/agent_configs').expect(200);
+ });
+
+ it('has datasources api', async () => {
+ await kbnTestServer.request.get(root, '/api/ingest_manager/datasources').expect(200);
+ });
+
+ it('does not have EPM api', async () => {
+ await kbnTestServer.request.get(root, '/api/ingest_manager/epm/list').expect(404);
+ });
+
+ it('does not have Fleet api', async () => {
+ await kbnTestServer.request.get(root, '/api/ingest_manager/fleet/setup').expect(404);
+ });
+ });
+});
diff --git a/x-pack/plugins/ingest_manager/server/plugin.ts b/x-pack/plugins/ingest_manager/server/plugin.ts
new file mode 100644
index 0000000000000..4f30a171ab0c0
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/server/plugin.ts
@@ -0,0 +1,102 @@
+/*
+ * 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 { Observable } from 'rxjs';
+import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'kibana/server';
+import { LicensingPluginSetup } from '../../licensing/server';
+import { EncryptedSavedObjectsPluginStart } from '../../encrypted_saved_objects/server';
+import { SecurityPluginSetup } from '../../security/server';
+import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server';
+import { PLUGIN_ID } from './constants';
+import { appContextService } from './services';
+import { registerDatasourceRoutes, registerAgentConfigRoutes } from './routes';
+import { IngestManagerConfigType } from '../common';
+
+export interface IngestManagerSetupDeps {
+ licensing: LicensingPluginSetup;
+ security?: SecurityPluginSetup;
+ features?: FeaturesPluginSetup;
+}
+
+export interface IngestManagerAppContext {
+ encryptedSavedObjects: EncryptedSavedObjectsPluginStart;
+ security?: SecurityPluginSetup;
+ config$?: Observable;
+}
+
+export class IngestManagerPlugin implements Plugin {
+ private config$: Observable;
+ private security: SecurityPluginSetup | undefined;
+
+ constructor(private readonly initializerContext: PluginInitializerContext) {
+ this.config$ = this.initializerContext.config.create();
+ }
+
+ public async setup(core: CoreSetup, deps: IngestManagerSetupDeps) {
+ if (deps.security) {
+ this.security = deps.security;
+ }
+
+ // Register feature
+ // TODO: Flesh out privileges
+ if (deps.features) {
+ deps.features.registerFeature({
+ id: PLUGIN_ID,
+ name: 'Ingest Manager',
+ icon: 'savedObjectsApp',
+ navLinkId: PLUGIN_ID,
+ app: [PLUGIN_ID, 'kibana'],
+ privileges: {
+ all: {
+ api: [PLUGIN_ID],
+ savedObject: {
+ all: [],
+ read: [],
+ },
+ ui: ['show'],
+ },
+ read: {
+ api: [PLUGIN_ID],
+ savedObject: {
+ all: [],
+ read: [],
+ },
+ ui: ['show'],
+ },
+ },
+ });
+ }
+
+ // Create router
+ const router = core.http.createRouter();
+
+ // Register routes
+ registerAgentConfigRoutes(router);
+ registerDatasourceRoutes(router);
+
+ // Optional route registration depending on Kibana config
+ // restore when EPM & Fleet features are added
+ // const config = await this.config$.pipe(first()).toPromise();
+ // if (config.epm.enabled) registerEPMRoutes(router);
+ // if (config.fleet.enabled) registerFleetSetupRoutes(router);
+ }
+
+ public async start(
+ core: CoreStart,
+ plugins: {
+ encryptedSavedObjects: EncryptedSavedObjectsPluginStart;
+ }
+ ) {
+ appContextService.start({
+ encryptedSavedObjects: plugins.encryptedSavedObjects,
+ security: this.security,
+ config$: this.config$,
+ });
+ }
+
+ public async stop() {
+ appContextService.stop();
+ }
+}
diff --git a/x-pack/plugins/ingest_manager/server/routes/agent_config/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/agent_config/handlers.ts
new file mode 100644
index 0000000000000..67da6a4cf2f1d
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/server/routes/agent_config/handlers.ts
@@ -0,0 +1,144 @@
+/*
+ * 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 { TypeOf } from '@kbn/config-schema';
+import { RequestHandler } from 'kibana/server';
+import { appContextService, agentConfigService } from '../../services';
+import {
+ GetAgentConfigsRequestSchema,
+ GetAgentConfigsResponse,
+ GetOneAgentConfigRequestSchema,
+ GetOneAgentConfigResponse,
+ CreateAgentConfigRequestSchema,
+ CreateAgentConfigResponse,
+ UpdateAgentConfigRequestSchema,
+ UpdateAgentConfigResponse,
+ DeleteAgentConfigsRequestSchema,
+ DeleteAgentConfigsResponse,
+} from '../../types';
+
+export const getAgentConfigsHandler: RequestHandler<
+ undefined,
+ TypeOf
+> = async (context, request, response) => {
+ const soClient = context.core.savedObjects.client;
+ try {
+ const { items, total, page, perPage } = await agentConfigService.list(soClient, request.query);
+ const body: GetAgentConfigsResponse = {
+ items,
+ total,
+ page,
+ perPage,
+ success: true,
+ };
+ return response.ok({ body });
+ } catch (e) {
+ return response.customError({
+ statusCode: 500,
+ body: { message: e.message },
+ });
+ }
+};
+
+export const getOneAgentConfigHandler: RequestHandler> = async (context, request, response) => {
+ const soClient = context.core.savedObjects.client;
+ try {
+ const agentConfig = await agentConfigService.get(soClient, request.params.agentConfigId);
+ if (agentConfig) {
+ const body: GetOneAgentConfigResponse = {
+ item: agentConfig,
+ success: true,
+ };
+ return response.ok({
+ body,
+ });
+ } else {
+ return response.customError({
+ statusCode: 404,
+ body: { message: 'Agent config not found' },
+ });
+ }
+ } catch (e) {
+ return response.customError({
+ statusCode: 500,
+ body: { message: e.message },
+ });
+ }
+};
+
+export const createAgentConfigHandler: RequestHandler<
+ undefined,
+ undefined,
+ TypeOf
+> = async (context, request, response) => {
+ const soClient = context.core.savedObjects.client;
+ const user = await appContextService.getSecurity()?.authc.getCurrentUser(request);
+ try {
+ const agentConfig = await agentConfigService.create(soClient, request.body, {
+ user: user || undefined,
+ });
+ const body: CreateAgentConfigResponse = { item: agentConfig, success: true };
+ return response.ok({
+ body,
+ });
+ } catch (e) {
+ return response.customError({
+ statusCode: 500,
+ body: { message: e.message },
+ });
+ }
+};
+
+export const updateAgentConfigHandler: RequestHandler<
+ TypeOf,
+ unknown,
+ TypeOf
+> = async (context, request, response) => {
+ const soClient = context.core.savedObjects.client;
+ const user = await appContextService.getSecurity()?.authc.getCurrentUser(request);
+ try {
+ const agentConfig = await agentConfigService.update(
+ soClient,
+ request.params.agentConfigId,
+ request.body,
+ {
+ user: user || undefined,
+ }
+ );
+ const body: UpdateAgentConfigResponse = { item: agentConfig, success: true };
+ return response.ok({
+ body,
+ });
+ } catch (e) {
+ return response.customError({
+ statusCode: 500,
+ body: { message: e.message },
+ });
+ }
+};
+
+export const deleteAgentConfigsHandler: RequestHandler<
+ unknown,
+ unknown,
+ TypeOf
+> = async (context, request, response) => {
+ const soClient = context.core.savedObjects.client;
+ try {
+ const body: DeleteAgentConfigsResponse = await agentConfigService.delete(
+ soClient,
+ request.body.agentConfigIds
+ );
+ return response.ok({
+ body,
+ });
+ } catch (e) {
+ return response.customError({
+ statusCode: 500,
+ body: { message: e.message },
+ });
+ }
+};
diff --git a/x-pack/plugins/ingest_manager/server/routes/agent_config/index.ts b/x-pack/plugins/ingest_manager/server/routes/agent_config/index.ts
new file mode 100644
index 0000000000000..67ad915b71e45
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/server/routes/agent_config/index.ts
@@ -0,0 +1,73 @@
+/*
+ * 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 { IRouter } from 'kibana/server';
+import { PLUGIN_ID, AGENT_CONFIG_API_ROUTES } from '../../constants';
+import {
+ GetAgentConfigsRequestSchema,
+ GetOneAgentConfigRequestSchema,
+ CreateAgentConfigRequestSchema,
+ UpdateAgentConfigRequestSchema,
+ DeleteAgentConfigsRequestSchema,
+} from '../../types';
+import {
+ getAgentConfigsHandler,
+ getOneAgentConfigHandler,
+ createAgentConfigHandler,
+ updateAgentConfigHandler,
+ deleteAgentConfigsHandler,
+} from './handlers';
+
+export const registerRoutes = (router: IRouter) => {
+ // List
+ router.get(
+ {
+ path: AGENT_CONFIG_API_ROUTES.LIST_PATTERN,
+ validate: GetAgentConfigsRequestSchema,
+ options: { tags: [`access:${PLUGIN_ID}`] },
+ },
+ getAgentConfigsHandler
+ );
+
+ // Get one
+ router.get(
+ {
+ path: AGENT_CONFIG_API_ROUTES.INFO_PATTERN,
+ validate: GetOneAgentConfigRequestSchema,
+ options: { tags: [`access:${PLUGIN_ID}`] },
+ },
+ getOneAgentConfigHandler
+ );
+
+ // Create
+ router.post(
+ {
+ path: AGENT_CONFIG_API_ROUTES.CREATE_PATTERN,
+ validate: CreateAgentConfigRequestSchema,
+ options: { tags: [`access:${PLUGIN_ID}`] },
+ },
+ createAgentConfigHandler
+ );
+
+ // Update
+ router.put(
+ {
+ path: AGENT_CONFIG_API_ROUTES.UPDATE_PATTERN,
+ validate: UpdateAgentConfigRequestSchema,
+ options: { tags: [`access:${PLUGIN_ID}`] },
+ },
+ updateAgentConfigHandler
+ );
+
+ // Delete
+ router.post(
+ {
+ path: AGENT_CONFIG_API_ROUTES.DELETE_PATTERN,
+ validate: DeleteAgentConfigsRequestSchema,
+ options: { tags: [`access:${PLUGIN_ID}`] },
+ },
+ deleteAgentConfigsHandler
+ );
+};
diff --git a/x-pack/plugins/ingest_manager/server/routes/datasource/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/datasource/handlers.ts
new file mode 100644
index 0000000000000..78cad2e21c5fa
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/server/routes/datasource/handlers.ts
@@ -0,0 +1,131 @@
+/*
+ * 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 { TypeOf } from '@kbn/config-schema';
+import { RequestHandler } from 'kibana/server';
+import { datasourceService } from '../../services';
+import {
+ GetDatasourcesRequestSchema,
+ GetOneDatasourceRequestSchema,
+ CreateDatasourceRequestSchema,
+ UpdateDatasourceRequestSchema,
+ DeleteDatasourcesRequestSchema,
+ DeleteDatasourcesResponse,
+} from '../../types';
+
+export const getDatasourcesHandler: RequestHandler<
+ undefined,
+ TypeOf
+> = async (context, request, response) => {
+ const soClient = context.core.savedObjects.client;
+ try {
+ const { items, total, page, perPage } = await datasourceService.list(soClient, request.query);
+ return response.ok({
+ body: {
+ items,
+ total,
+ page,
+ perPage,
+ success: true,
+ },
+ });
+ } catch (e) {
+ return response.customError({
+ statusCode: 500,
+ body: { message: e.message },
+ });
+ }
+};
+
+export const getOneDatasourceHandler: RequestHandler> = async (context, request, response) => {
+ const soClient = context.core.savedObjects.client;
+ try {
+ const datasource = await datasourceService.get(soClient, request.params.datasourceId);
+ if (datasource) {
+ return response.ok({
+ body: {
+ item: datasource,
+ success: true,
+ },
+ });
+ } else {
+ return response.customError({
+ statusCode: 404,
+ body: { message: 'Datasource not found' },
+ });
+ }
+ } catch (e) {
+ return response.customError({
+ statusCode: 500,
+ body: { message: e.message },
+ });
+ }
+};
+
+export const createDatasourceHandler: RequestHandler<
+ undefined,
+ undefined,
+ TypeOf
+> = async (context, request, response) => {
+ const soClient = context.core.savedObjects.client;
+ try {
+ const datasource = await datasourceService.create(soClient, request.body);
+ return response.ok({
+ body: { item: datasource, success: true },
+ });
+ } catch (e) {
+ return response.customError({
+ statusCode: 500,
+ body: { message: e.message },
+ });
+ }
+};
+
+export const updateDatasourceHandler: RequestHandler<
+ TypeOf,
+ unknown,
+ TypeOf
+> = async (context, request, response) => {
+ const soClient = context.core.savedObjects.client;
+ try {
+ const datasource = await datasourceService.update(
+ soClient,
+ request.params.datasourceId,
+ request.body
+ );
+ return response.ok({
+ body: { item: datasource, success: true },
+ });
+ } catch (e) {
+ return response.customError({
+ statusCode: 500,
+ body: { message: e.message },
+ });
+ }
+};
+
+export const deleteDatasourcesHandler: RequestHandler<
+ unknown,
+ unknown,
+ TypeOf
+> = async (context, request, response) => {
+ const soClient = context.core.savedObjects.client;
+ try {
+ const body: DeleteDatasourcesResponse = await datasourceService.delete(
+ soClient,
+ request.body.datasourceIds
+ );
+ return response.ok({
+ body,
+ });
+ } catch (e) {
+ return response.customError({
+ statusCode: 500,
+ body: { message: e.message },
+ });
+ }
+};
diff --git a/x-pack/plugins/ingest_manager/server/routes/datasource/index.ts b/x-pack/plugins/ingest_manager/server/routes/datasource/index.ts
new file mode 100644
index 0000000000000..d9e3ba9de8838
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/server/routes/datasource/index.ts
@@ -0,0 +1,73 @@
+/*
+ * 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 { IRouter } from 'kibana/server';
+import { PLUGIN_ID, DATASOURCE_API_ROUTES } from '../../constants';
+import {
+ GetDatasourcesRequestSchema,
+ GetOneDatasourceRequestSchema,
+ CreateDatasourceRequestSchema,
+ UpdateDatasourceRequestSchema,
+ DeleteDatasourcesRequestSchema,
+} from '../../types';
+import {
+ getDatasourcesHandler,
+ getOneDatasourceHandler,
+ createDatasourceHandler,
+ updateDatasourceHandler,
+ deleteDatasourcesHandler,
+} from './handlers';
+
+export const registerRoutes = (router: IRouter) => {
+ // List
+ router.get(
+ {
+ path: DATASOURCE_API_ROUTES.LIST_PATTERN,
+ validate: GetDatasourcesRequestSchema,
+ options: { tags: [`access:${PLUGIN_ID}`] },
+ },
+ getDatasourcesHandler
+ );
+
+ // Get one
+ router.get(
+ {
+ path: DATASOURCE_API_ROUTES.INFO_PATTERN,
+ validate: GetOneDatasourceRequestSchema,
+ options: { tags: [`access:${PLUGIN_ID}`] },
+ },
+ getOneDatasourceHandler
+ );
+
+ // Create
+ router.post(
+ {
+ path: DATASOURCE_API_ROUTES.CREATE_PATTERN,
+ validate: CreateDatasourceRequestSchema,
+ options: { tags: [`access:${PLUGIN_ID}`] },
+ },
+ createDatasourceHandler
+ );
+
+ // Update
+ router.put(
+ {
+ path: DATASOURCE_API_ROUTES.UPDATE_PATTERN,
+ validate: UpdateDatasourceRequestSchema,
+ options: { tags: [`access:${PLUGIN_ID}`] },
+ },
+ updateDatasourceHandler
+ );
+
+ // Delete
+ router.post(
+ {
+ path: DATASOURCE_API_ROUTES.DELETE_PATTERN,
+ validate: DeleteDatasourcesRequestSchema,
+ options: { tags: [`access:${PLUGIN_ID}`] },
+ },
+ deleteDatasourcesHandler
+ );
+};
diff --git a/x-pack/plugins/ingest_manager/server/routes/epm/index.ts b/x-pack/plugins/ingest_manager/server/routes/epm/index.ts
new file mode 100644
index 0000000000000..7bdcafe633843
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/server/routes/epm/index.ts
@@ -0,0 +1,75 @@
+/*
+ * 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 { IRouter } from 'kibana/server';
+import { PLUGIN_ID, EPM_API_ROUTES } from '../../constants';
+
+export const registerRoutes = (router: IRouter) => {
+ router.get(
+ {
+ path: EPM_API_ROUTES.CATEGORIES_PATTERN,
+ validate: false,
+ options: { tags: [`access:${PLUGIN_ID}`] },
+ },
+ async (context, req, res) => {
+ return res.ok({ body: { hello: 'world' } });
+ }
+ );
+
+ router.get(
+ {
+ path: EPM_API_ROUTES.LIST_PATTERN,
+ validate: false,
+ options: { tags: [`access:${PLUGIN_ID}`] },
+ },
+ async (context, req, res) => {
+ return res.ok({ body: { hello: 'world' } });
+ }
+ );
+
+ router.get(
+ {
+ path: `${EPM_API_ROUTES.INFO_PATTERN}/{filePath*}`,
+ validate: false,
+ options: { tags: [`access:${PLUGIN_ID}`] },
+ },
+ async (context, req, res) => {
+ return res.ok({ body: { hello: 'world' } });
+ }
+ );
+
+ router.get(
+ {
+ path: EPM_API_ROUTES.INFO_PATTERN,
+ validate: false,
+ options: { tags: [`access:${PLUGIN_ID}`] },
+ },
+ async (context, req, res) => {
+ return res.ok({ body: { hello: 'world' } });
+ }
+ );
+
+ router.get(
+ {
+ path: EPM_API_ROUTES.INSTALL_PATTERN,
+ validate: false,
+ options: { tags: [`access:${PLUGIN_ID}`] },
+ },
+ async (context, req, res) => {
+ return res.ok({ body: { hello: 'world' } });
+ }
+ );
+
+ router.get(
+ {
+ path: EPM_API_ROUTES.DELETE_PATTERN,
+ validate: false,
+ options: { tags: [`access:${PLUGIN_ID}`] },
+ },
+ async (context, req, res) => {
+ return res.ok({ body: { hello: 'world' } });
+ }
+ );
+};
diff --git a/x-pack/plugins/ingest_manager/server/routes/fleet_setup/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/fleet_setup/handlers.ts
new file mode 100644
index 0000000000000..72fe34eb23c5f
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/server/routes/fleet_setup/handlers.ts
@@ -0,0 +1,55 @@
+/*
+ * 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 { TypeOf } from '@kbn/config-schema';
+import { RequestHandler } from 'kibana/server';
+import { DEFAULT_OUTPUT_ID } from '../../constants';
+import { outputService, agentConfigService } from '../../services';
+import { CreateFleetSetupRequestSchema, CreateFleetSetupResponse } from '../../types';
+
+export const getFleetSetupHandler: RequestHandler = async (context, request, response) => {
+ const soClient = context.core.savedObjects.client;
+ const successBody: CreateFleetSetupResponse = { isInitialized: true };
+ const failureBody: CreateFleetSetupResponse = { isInitialized: false };
+ try {
+ const output = await outputService.get(soClient, DEFAULT_OUTPUT_ID);
+ if (output) {
+ return response.ok({
+ body: successBody,
+ });
+ } else {
+ return response.ok({
+ body: failureBody,
+ });
+ }
+ } catch (e) {
+ return response.ok({
+ body: failureBody,
+ });
+ }
+};
+
+export const createFleetSetupHandler: RequestHandler<
+ undefined,
+ undefined,
+ TypeOf
+> = async (context, request, response) => {
+ const soClient = context.core.savedObjects.client;
+ try {
+ await outputService.createDefaultOutput(soClient, {
+ username: request.body.admin_username,
+ password: request.body.admin_password,
+ });
+ await agentConfigService.ensureDefaultAgentConfig(soClient);
+ return response.ok({
+ body: { isInitialized: true },
+ });
+ } catch (e) {
+ return response.customError({
+ statusCode: 500,
+ body: { message: e.message },
+ });
+ }
+};
diff --git a/x-pack/plugins/ingest_manager/server/routes/fleet_setup/index.ts b/x-pack/plugins/ingest_manager/server/routes/fleet_setup/index.ts
new file mode 100644
index 0000000000000..c23164db6b6eb
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/server/routes/fleet_setup/index.ts
@@ -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 { IRouter } from 'kibana/server';
+import { PLUGIN_ID, FLEET_SETUP_API_ROUTES } from '../../constants';
+import { GetFleetSetupRequestSchema, CreateFleetSetupRequestSchema } from '../../types';
+import { getFleetSetupHandler, createFleetSetupHandler } from './handlers';
+
+export const registerRoutes = (router: IRouter) => {
+ // Get
+ router.get(
+ {
+ path: FLEET_SETUP_API_ROUTES.INFO_PATTERN,
+ validate: GetFleetSetupRequestSchema,
+ options: { tags: [`access:${PLUGIN_ID}`] },
+ },
+ getFleetSetupHandler
+ );
+
+ // Create
+ router.post(
+ {
+ path: FLEET_SETUP_API_ROUTES.CREATE_PATTERN,
+ validate: CreateFleetSetupRequestSchema,
+ options: { tags: [`access:${PLUGIN_ID}`] },
+ },
+ createFleetSetupHandler
+ );
+};
diff --git a/x-pack/plugins/ingest_manager/server/routes/index.ts b/x-pack/plugins/ingest_manager/server/routes/index.ts
new file mode 100644
index 0000000000000..b458ef31dee45
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/server/routes/index.ts
@@ -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.
+ */
+export { registerRoutes as registerAgentConfigRoutes } from './agent_config';
+export { registerRoutes as registerDatasourceRoutes } from './datasource';
+export { registerRoutes as registerEPMRoutes } from './epm';
+export { registerRoutes as registerFleetSetupRoutes } from './fleet_setup';
diff --git a/x-pack/plugins/ingest_manager/server/saved_objects.ts b/x-pack/plugins/ingest_manager/server/saved_objects.ts
new file mode 100644
index 0000000000000..976556f388acf
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/server/saved_objects.ts
@@ -0,0 +1,78 @@
+/*
+ * 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 {
+ OUTPUT_SAVED_OBJECT_TYPE,
+ AGENT_CONFIG_SAVED_OBJECT_TYPE,
+ DATASOURCE_SAVED_OBJECT_TYPE,
+} from './constants';
+
+/*
+ * Saved object mappings
+ *
+ * Please update typings in `/common/types` if mappings are updated.
+ */
+export const savedObjectMappings = {
+ [AGENT_CONFIG_SAVED_OBJECT_TYPE]: {
+ properties: {
+ id: { type: 'keyword' },
+ name: { type: 'text' },
+ namespace: { type: 'keyword' },
+ description: { type: 'text' },
+ status: { type: 'keyword' },
+ datasources: { type: 'keyword' },
+ updated_on: { type: 'keyword' },
+ updated_by: { type: 'keyword' },
+ },
+ },
+ [OUTPUT_SAVED_OBJECT_TYPE]: {
+ properties: {
+ id: { type: 'keyword' },
+ name: { type: 'keyword' },
+ type: { type: 'keyword' },
+ username: { type: 'keyword' },
+ password: { type: 'keyword' },
+ index_name: { type: 'keyword' },
+ ingest_pipeline: { type: 'keyword' },
+ hosts: { type: 'keyword' },
+ api_key: { type: 'keyword' },
+ admin_username: { type: 'binary' },
+ admin_password: { type: 'binary' },
+ config: { type: 'flattened' },
+ },
+ },
+ [DATASOURCE_SAVED_OBJECT_TYPE]: {
+ properties: {
+ id: { type: 'keyword' },
+ name: { type: 'keyword' },
+ namespace: { type: 'keyword' },
+ read_alias: { type: 'keyword' },
+ agent_config_id: { type: 'keyword' },
+ package: {
+ properties: {
+ assets: {
+ properties: {
+ id: { type: 'keyword' },
+ type: { type: 'keyword' },
+ },
+ },
+ description: { type: 'keyword' },
+ name: { type: 'keyword' },
+ title: { type: 'keyword' },
+ version: { type: 'keyword' },
+ },
+ },
+ streams: {
+ properties: {
+ config: { type: 'flattened' },
+ id: { type: 'keyword' },
+ input: { type: 'flattened' },
+ output_id: { type: 'keyword' },
+ processors: { type: 'keyword' },
+ },
+ },
+ },
+ },
+};
diff --git a/x-pack/plugins/ingest_manager/server/services/agent_config.ts b/x-pack/plugins/ingest_manager/server/services/agent_config.ts
new file mode 100644
index 0000000000000..0690e115ca862
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/server/services/agent_config.ts
@@ -0,0 +1,258 @@
+/*
+ * 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 { SavedObjectsClientContract } from 'kibana/server';
+import { AuthenticatedUser } from '../../../security/server';
+import {
+ DEFAULT_AGENT_CONFIG_ID,
+ DEFAULT_AGENT_CONFIG,
+ AGENT_CONFIG_SAVED_OBJECT_TYPE,
+} from '../constants';
+import {
+ NewAgentConfig,
+ AgentConfig,
+ AgentConfigStatus,
+ AgentConfigUpdateHandler,
+ ListWithKuery,
+ DeleteAgentConfigsResponse,
+} from '../types';
+import { datasourceService } from './datasource';
+
+const SAVED_OBJECT_TYPE = AGENT_CONFIG_SAVED_OBJECT_TYPE;
+
+class AgentConfigService {
+ private eventsHandler: AgentConfigUpdateHandler[] = [];
+
+ public registerAgentConfigUpdateHandler(handler: AgentConfigUpdateHandler) {
+ this.eventsHandler.push(handler);
+ }
+
+ public triggerAgentConfigUpdatedEvent: AgentConfigUpdateHandler = async (
+ action,
+ agentConfigId
+ ) => {
+ for (const handler of this.eventsHandler) {
+ await handler(action, agentConfigId);
+ }
+ };
+
+ private async _update(
+ soClient: SavedObjectsClientContract,
+ id: string,
+ agentConfig: Partial,
+ user?: AuthenticatedUser
+ ): Promise {
+ await soClient.update(SAVED_OBJECT_TYPE, id, {
+ ...agentConfig,
+ updated_on: new Date().toString(),
+ updated_by: user ? user.username : 'system',
+ });
+
+ await this.triggerAgentConfigUpdatedEvent('updated', id);
+
+ return (await this.get(soClient, id)) as AgentConfig;
+ }
+
+ public async ensureDefaultAgentConfig(soClient: SavedObjectsClientContract) {
+ let defaultAgentConfig;
+
+ try {
+ defaultAgentConfig = await this.get(soClient, DEFAULT_AGENT_CONFIG_ID);
+ } catch (err) {
+ if (!err.isBoom || err.output.statusCode !== 404) {
+ throw err;
+ }
+ }
+
+ if (!defaultAgentConfig) {
+ const newDefaultAgentConfig: NewAgentConfig = {
+ ...DEFAULT_AGENT_CONFIG,
+ };
+
+ await this.create(soClient, newDefaultAgentConfig, {
+ id: DEFAULT_AGENT_CONFIG_ID,
+ });
+ }
+ }
+
+ public async create(
+ soClient: SavedObjectsClientContract,
+ agentConfig: NewAgentConfig,
+ options?: { id?: string; user?: AuthenticatedUser }
+ ): Promise {
+ const newSo = await soClient.create(
+ SAVED_OBJECT_TYPE,
+ {
+ ...agentConfig,
+ updated_on: new Date().toISOString(),
+ updated_by: options?.user?.username || 'system',
+ } as AgentConfig,
+ options
+ );
+
+ await this.triggerAgentConfigUpdatedEvent('created', newSo.id);
+
+ return {
+ id: newSo.id,
+ ...newSo.attributes,
+ };
+ }
+
+ public async get(soClient: SavedObjectsClientContract, id: string): Promise {
+ const agentConfigSO = await soClient.get(SAVED_OBJECT_TYPE, id);
+ if (!agentConfigSO) {
+ return null;
+ }
+
+ if (agentConfigSO.error) {
+ throw new Error(agentConfigSO.error.message);
+ }
+
+ return {
+ id: agentConfigSO.id,
+ ...agentConfigSO.attributes,
+ datasources:
+ (await datasourceService.getByIDs(
+ soClient,
+ (agentConfigSO.attributes.datasources as string[]) || []
+ )) || [],
+ };
+ }
+
+ public async list(
+ soClient: SavedObjectsClientContract,
+ options: ListWithKuery
+ ): Promise<{ items: AgentConfig[]; total: number; page: number; perPage: number }> {
+ const { page = 1, perPage = 20, kuery } = options;
+
+ const agentConfigs = await soClient.find({
+ type: SAVED_OBJECT_TYPE,
+ page,
+ perPage,
+ // To ensure users don't need to know about SO data structure...
+ filter: kuery
+ ? kuery.replace(
+ new RegExp(`${SAVED_OBJECT_TYPE}\.`, 'g'),
+ `${SAVED_OBJECT_TYPE}.attributes.`
+ )
+ : undefined,
+ });
+
+ return {
+ items: agentConfigs.saved_objects.map(agentConfigSO => {
+ return {
+ id: agentConfigSO.id,
+ ...agentConfigSO.attributes,
+ };
+ }),
+ total: agentConfigs.total,
+ page,
+ perPage,
+ };
+ }
+
+ public async update(
+ soClient: SavedObjectsClientContract,
+ id: string,
+ agentConfig: Partial,
+ options?: { user?: AuthenticatedUser }
+ ): Promise {
+ const oldAgentConfig = await this.get(soClient, id);
+
+ if (!oldAgentConfig) {
+ throw new Error('Agent config not found');
+ }
+
+ if (
+ oldAgentConfig.status === AgentConfigStatus.Inactive &&
+ agentConfig.status !== AgentConfigStatus.Active
+ ) {
+ throw new Error(
+ `Agent config ${id} cannot be updated because it is ${oldAgentConfig.status}`
+ );
+ }
+
+ return this._update(soClient, id, agentConfig, options?.user);
+ }
+
+ public async assignDatasources(
+ soClient: SavedObjectsClientContract,
+ id: string,
+ datasourceIds: string[],
+ options?: { user?: AuthenticatedUser }
+ ): Promise {
+ const oldAgentConfig = await this.get(soClient, id);
+
+ if (!oldAgentConfig) {
+ throw new Error('Agent config not found');
+ }
+
+ return await this._update(
+ soClient,
+ id,
+ {
+ ...oldAgentConfig,
+ datasources: [...((oldAgentConfig.datasources || []) as string[])].concat(datasourceIds),
+ },
+ options?.user
+ );
+ }
+
+ public async unassignDatasources(
+ soClient: SavedObjectsClientContract,
+ id: string,
+ datasourceIds: string[],
+ options?: { user?: AuthenticatedUser }
+ ): Promise {
+ const oldAgentConfig = await this.get(soClient, id);
+
+ if (!oldAgentConfig) {
+ throw new Error('Agent config not found');
+ }
+
+ return await this._update(
+ soClient,
+ id,
+ {
+ ...oldAgentConfig,
+ datasources: [...((oldAgentConfig.datasources || []) as string[])].filter(
+ dsId => !datasourceIds.includes(dsId)
+ ),
+ },
+ options?.user
+ );
+ }
+
+ public async delete(
+ soClient: SavedObjectsClientContract,
+ ids: string[]
+ ): Promise {
+ const result: DeleteAgentConfigsResponse = [];
+
+ if (ids.includes(DEFAULT_AGENT_CONFIG_ID)) {
+ throw new Error('The default agent configuration cannot be deleted');
+ }
+
+ for (const id of ids) {
+ try {
+ await soClient.delete(SAVED_OBJECT_TYPE, id);
+ await this.triggerAgentConfigUpdatedEvent('deleted', id);
+ result.push({
+ id,
+ success: true,
+ });
+ } catch (e) {
+ result.push({
+ id,
+ success: false,
+ });
+ }
+ }
+
+ return result;
+ }
+}
+
+export const agentConfigService = new AgentConfigService();
diff --git a/x-pack/plugins/ingest_manager/server/services/app_context.ts b/x-pack/plugins/ingest_manager/server/services/app_context.ts
new file mode 100644
index 0000000000000..69a014fca37fb
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/server/services/app_context.ts
@@ -0,0 +1,50 @@
+/*
+ * 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 { BehaviorSubject, Observable } from 'rxjs';
+import { first } from 'rxjs/operators';
+import { EncryptedSavedObjectsPluginStart } from '../../../encrypted_saved_objects/server';
+import { SecurityPluginSetup } from '../../../security/server';
+import { IngestManagerConfigType } from '../../common';
+import { IngestManagerAppContext } from '../plugin';
+
+class AppContextService {
+ private encryptedSavedObjects: EncryptedSavedObjectsPluginStart | undefined;
+ private security: SecurityPluginSetup | undefined;
+ private config$?: Observable;
+ private configSubject$?: BehaviorSubject;
+
+ public async start(appContext: IngestManagerAppContext) {
+ this.encryptedSavedObjects = appContext.encryptedSavedObjects;
+ this.security = appContext.security;
+
+ if (appContext.config$) {
+ this.config$ = appContext.config$;
+ const initialValue = await this.config$.pipe(first()).toPromise();
+ this.configSubject$ = new BehaviorSubject(initialValue);
+ this.config$.subscribe(this.configSubject$);
+ }
+ }
+
+ public stop() {}
+
+ public getEncryptedSavedObjects() {
+ return this.encryptedSavedObjects;
+ }
+
+ public getSecurity() {
+ return this.security;
+ }
+
+ public getConfig() {
+ return this.configSubject$?.value;
+ }
+
+ public getConfig$() {
+ return this.config$;
+ }
+}
+
+export const appContextService = new AppContextService();
diff --git a/x-pack/plugins/ingest_manager/server/services/datasource.ts b/x-pack/plugins/ingest_manager/server/services/datasource.ts
new file mode 100644
index 0000000000000..b305ccaab777b
--- /dev/null
+++ b/x-pack/plugins/ingest_manager/server/services/datasource.ts
@@ -0,0 +1,132 @@
+/*
+ * 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 { SavedObjectsClientContract } from 'kibana/server';
+import { DATASOURCE_SAVED_OBJECT_TYPE } from '../constants';
+import { NewDatasource, Datasource, DeleteDatasourcesResponse, ListWithKuery } from '../types';
+
+const SAVED_OBJECT_TYPE = DATASOURCE_SAVED_OBJECT_TYPE;
+
+class DatasourceService {
+ public async create(
+ soClient: SavedObjectsClientContract,
+ datasource: NewDatasource,
+ options?: { id?: string }
+ ): Promise {
+ const newSo = await soClient.create