{
case 'number.base':
return `expected value of type [number] but got [${typeDetect(value)}]`;
case 'number.min':
- return `Value is [${value}] but it must be equal to or greater than [${limit}].`;
+ return `Value must be equal to or greater than [${limit}].`;
case 'number.max':
- return `Value is [${value}] but it must be equal to or lower than [${limit}].`;
+ return `Value must be equal to or lower than [${limit}].`;
}
}
}
diff --git a/packages/kbn-config-schema/src/types/object_type.test.ts b/packages/kbn-config-schema/src/types/object_type.test.ts
index 64739d7a4c4daa..29e341983fde9d 100644
--- a/packages/kbn-config-schema/src/types/object_type.test.ts
+++ b/packages/kbn-config-schema/src/types/object_type.test.ts
@@ -49,7 +49,7 @@ test('fails if string input cannot be parsed', () => {
name: schema.string(),
});
expect(() => type.validate(`invalidjson`)).toThrowErrorMatchingInlineSnapshot(
- `"could not parse object value from [invalidjson]"`
+ `"could not parse object value from json input"`
);
});
@@ -181,7 +181,7 @@ test('called with wrong type', () => {
const type = schema.object({});
expect(() => type.validate('foo')).toThrowErrorMatchingInlineSnapshot(
- `"could not parse object value from [foo]"`
+ `"could not parse object value from json input"`
);
expect(() => type.validate(123)).toThrowErrorMatchingInlineSnapshot(
`"expected a plain object value, but found [number] instead."`
diff --git a/packages/kbn-config-schema/src/types/object_type.ts b/packages/kbn-config-schema/src/types/object_type.ts
index 4f3d68a6bac97d..f34acd0d2ce656 100644
--- a/packages/kbn-config-schema/src/types/object_type.ts
+++ b/packages/kbn-config-schema/src/types/object_type.ts
@@ -62,7 +62,7 @@ export class ObjectType extends Type>
case 'object.base':
return `expected a plain object value, but found [${typeDetect(value)}] instead.`;
case 'object.parse':
- return `could not parse object value from [${value}]`;
+ return `could not parse object value from json input`;
case 'object.allowUnknown':
return `definition for this key is missing`;
case 'object.child':
diff --git a/packages/kbn-config-schema/src/types/one_of_type.test.ts b/packages/kbn-config-schema/src/types/one_of_type.test.ts
index c9da1a6cd8494b..deb87a485cdfe5 100644
--- a/packages/kbn-config-schema/src/types/one_of_type.test.ts
+++ b/packages/kbn-config-schema/src/types/one_of_type.test.ts
@@ -75,13 +75,20 @@ test('handles object', () => {
test('handles object with wrong type', () => {
const type = schema.oneOf([schema.object({ age: schema.number() })]);
- expect(() => type.validate({ age: 'foo' })).toThrowErrorMatchingSnapshot();
+ expect(() => type.validate({ age: 'foo' })).toThrowErrorMatchingInlineSnapshot(`
+"types that failed validation:
+- [0.age]: expected value of type [number] but got [string]"
+`);
});
test('includes namespace in failure', () => {
const type = schema.oneOf([schema.object({ age: schema.number() })]);
- expect(() => type.validate({ age: 'foo' }, {}, 'foo-namespace')).toThrowErrorMatchingSnapshot();
+ expect(() => type.validate({ age: 'foo' }, {}, 'foo-namespace'))
+ .toThrowErrorMatchingInlineSnapshot(`
+"[foo-namespace]: types that failed validation:
+- [foo-namespace.0.age]: expected value of type [number] but got [string]"
+`);
});
test('handles multiple objects with same key', () => {
@@ -110,20 +117,33 @@ test('handles maybe', () => {
test('fails if not matching type', () => {
const type = schema.oneOf([schema.string()]);
- expect(() => type.validate(false)).toThrowErrorMatchingSnapshot();
- expect(() => type.validate(123)).toThrowErrorMatchingSnapshot();
+ expect(() => type.validate(false)).toThrowErrorMatchingInlineSnapshot(`
+"types that failed validation:
+- [0]: expected value of type [string] but got [boolean]"
+`);
+ expect(() => type.validate(123)).toThrowErrorMatchingInlineSnapshot(`
+"types that failed validation:
+- [0]: expected value of type [string] but got [number]"
+`);
});
test('fails if not matching multiple types', () => {
const type = schema.oneOf([schema.string(), schema.number()]);
- expect(() => type.validate(false)).toThrowErrorMatchingSnapshot();
+ expect(() => type.validate(false)).toThrowErrorMatchingInlineSnapshot(`
+"types that failed validation:
+- [0]: expected value of type [string] but got [boolean]
+- [1]: expected value of type [number] but got [boolean]"
+`);
});
test('fails if not matching literal', () => {
const type = schema.oneOf([schema.literal('foo')]);
- expect(() => type.validate('bar')).toThrowErrorMatchingSnapshot();
+ expect(() => type.validate('bar')).toThrowErrorMatchingInlineSnapshot(`
+"types that failed validation:
+- [0]: expected value to equal [foo]"
+`);
});
test('fails if nested union type fail', () => {
@@ -138,7 +158,7 @@ test('fails if nested union type fail', () => {
- [0]: expected value of type [boolean] but got [string]
- [1]: types that failed validation:
- [0]: types that failed validation:
- - [0]: could not parse object value from [aaa]
+ - [0]: could not parse object value from json input
- [1]: expected value of type [number] but got [string]"
`);
});
diff --git a/packages/kbn-config-schema/src/types/record_of_type.test.ts b/packages/kbn-config-schema/src/types/record_of_type.test.ts
index f3ab1925597b54..ef15e7b0f6ad6b 100644
--- a/packages/kbn-config-schema/src/types/record_of_type.test.ts
+++ b/packages/kbn-config-schema/src/types/record_of_type.test.ts
@@ -73,8 +73,8 @@ test('fails when not receiving expected key type', () => {
expect(() => type.validate(value)).toThrowErrorMatchingInlineSnapshot(`
"[key(\\"name\\")]: types that failed validation:
-- [0]: expected value to equal [nickName] but got [name]
-- [1]: expected value to equal [lastName] but got [name]"
+- [0]: expected value to equal [nickName]
+- [1]: expected value to equal [lastName]"
`);
});
@@ -88,8 +88,8 @@ test('fails after parsing when not receiving expected key type', () => {
expect(() => type.validate(value)).toThrowErrorMatchingInlineSnapshot(`
"[key(\\"name\\")]: types that failed validation:
-- [0]: expected value to equal [nickName] but got [name]
-- [1]: expected value to equal [lastName] but got [name]"
+- [0]: expected value to equal [nickName]
+- [1]: expected value to equal [lastName]"
`);
});
@@ -118,7 +118,7 @@ test('includes namespace in failure when wrong key type', () => {
};
expect(() => type.validate(value, {}, 'foo-namespace')).toThrowErrorMatchingInlineSnapshot(
- `"[foo-namespace.key(\\"name\\")]: value is [name] but it must have a minimum length of [10]."`
+ `"[foo-namespace.key(\\"name\\")]: value has length [4] but it must have a minimum length of [10]."`
);
});
@@ -169,7 +169,7 @@ test('error preserves full path', () => {
expect(() =>
type.validate({ grandParentKey: { parentKey: { a: 'some-value' } } })
).toThrowErrorMatchingInlineSnapshot(
- `"[grandParentKey.parentKey.key(\\"a\\")]: value is [a] but it must have a minimum length of [2]."`
+ `"[grandParentKey.parentKey.key(\\"a\\")]: value has length [1] but it must have a minimum length of [2]."`
);
expect(() =>
diff --git a/packages/kbn-config-schema/src/types/record_type.ts b/packages/kbn-config-schema/src/types/record_type.ts
index b795c83acdadbf..c6d4b4d71b4f1d 100644
--- a/packages/kbn-config-schema/src/types/record_type.ts
+++ b/packages/kbn-config-schema/src/types/record_type.ts
@@ -41,7 +41,7 @@ export class RecordOfType extends Type> {
case 'record.base':
return `expected value of type [object] but got [${typeDetect(value)}]`;
case 'record.parse':
- return `could not parse record value from [${value}]`;
+ return `could not parse record value from json input`;
case 'record.key':
case 'record.value':
const childPathWithIndex = path.slice();
diff --git a/packages/kbn-config-schema/src/types/stream_type.test.ts b/packages/kbn-config-schema/src/types/stream_type.test.ts
index 011fa6373df335..2e6f31ad09b34f 100644
--- a/packages/kbn-config-schema/src/types/stream_type.test.ts
+++ b/packages/kbn-config-schema/src/types/stream_type.test.ts
@@ -41,13 +41,17 @@ test('Passthrough is valid', () => {
});
test('is required by default', () => {
- expect(() => schema.buffer().validate(undefined)).toThrowErrorMatchingSnapshot();
+ expect(() => schema.buffer().validate(undefined)).toThrowErrorMatchingInlineSnapshot(
+ `"expected value of type [Buffer] but got [undefined]"`
+ );
});
test('includes namespace in failure', () => {
expect(() =>
schema.stream().validate(undefined, {}, 'foo-namespace')
- ).toThrowErrorMatchingSnapshot();
+ ).toThrowErrorMatchingInlineSnapshot(
+ `"[foo-namespace]: expected value of type [Stream] but got [undefined]"`
+ );
});
describe('#defaultValue', () => {
@@ -63,9 +67,15 @@ describe('#defaultValue', () => {
});
test('returns error when not a stream', () => {
- expect(() => schema.stream().validate(123)).toThrowErrorMatchingSnapshot();
+ expect(() => schema.stream().validate(123)).toThrowErrorMatchingInlineSnapshot(
+ `"expected value of type [Stream] but got [number]"`
+ );
- expect(() => schema.stream().validate([1, 2, 3])).toThrowErrorMatchingSnapshot();
+ expect(() => schema.stream().validate([1, 2, 3])).toThrowErrorMatchingInlineSnapshot(
+ `"expected value of type [Stream] but got [Array]"`
+ );
- expect(() => schema.stream().validate('abc')).toThrowErrorMatchingSnapshot();
+ expect(() => schema.stream().validate('abc')).toThrowErrorMatchingInlineSnapshot(
+ `"expected value of type [Stream] but got [string]"`
+ );
});
diff --git a/packages/kbn-config-schema/src/types/string_type.test.ts b/packages/kbn-config-schema/src/types/string_type.test.ts
index d599ea65c5ae22..c1d853fe82b82e 100644
--- a/packages/kbn-config-schema/src/types/string_type.test.ts
+++ b/packages/kbn-config-schema/src/types/string_type.test.ts
@@ -28,13 +28,17 @@ test('allows empty strings', () => {
});
test('is required by default', () => {
- expect(() => schema.string().validate(undefined)).toThrowErrorMatchingSnapshot();
+ expect(() => schema.string().validate(undefined)).toThrowErrorMatchingInlineSnapshot(
+ `"expected value of type [string] but got [undefined]"`
+ );
});
test('includes namespace in failure', () => {
expect(() =>
schema.string().validate(undefined, {}, 'foo-namespace')
- ).toThrowErrorMatchingSnapshot();
+ ).toThrowErrorMatchingInlineSnapshot(
+ `"[foo-namespace]: expected value of type [string] but got [undefined]"`
+ );
});
describe('#minLength', () => {
@@ -43,11 +47,17 @@ describe('#minLength', () => {
});
test('returns error when shorter string', () => {
- expect(() => schema.string({ minLength: 4 }).validate('foo')).toThrowErrorMatchingSnapshot();
+ expect(() =>
+ schema.string({ minLength: 4 }).validate('foo')
+ ).toThrowErrorMatchingInlineSnapshot(
+ `"value has length [3] but it must have a minimum length of [4]."`
+ );
});
test('returns error when empty string', () => {
- expect(() => schema.string({ minLength: 2 }).validate('')).toThrowErrorMatchingSnapshot();
+ expect(() => schema.string({ minLength: 2 }).validate('')).toThrowErrorMatchingInlineSnapshot(
+ `"value has length [0] but it must have a minimum length of [2]."`
+ );
});
});
@@ -57,7 +67,11 @@ describe('#maxLength', () => {
});
test('returns error when longer string', () => {
- expect(() => schema.string({ maxLength: 2 }).validate('foo')).toThrowErrorMatchingSnapshot();
+ expect(() =>
+ schema.string({ maxLength: 2 }).validate('foo')
+ ).toThrowErrorMatchingInlineSnapshot(
+ `"value has length [3] but it must have a maximum length of [2]."`
+ );
});
});
@@ -84,23 +98,37 @@ describe('#hostname', () => {
test('returns error when value is not a valid hostname', () => {
const hostNameSchema = schema.string({ hostname: true });
- expect(() => hostNameSchema.validate('host:name')).toThrowErrorMatchingSnapshot();
- expect(() => hostNameSchema.validate('localhost:5601')).toThrowErrorMatchingSnapshot();
- expect(() => hostNameSchema.validate('-')).toThrowErrorMatchingSnapshot();
- expect(() => hostNameSchema.validate('0:?:0:0:0:0:0:1')).toThrowErrorMatchingSnapshot();
+ expect(() => hostNameSchema.validate('host:name')).toThrowErrorMatchingInlineSnapshot(
+ `"value must be a valid hostname (see RFC 1123)."`
+ );
+ expect(() => hostNameSchema.validate('localhost:5601')).toThrowErrorMatchingInlineSnapshot(
+ `"value must be a valid hostname (see RFC 1123)."`
+ );
+ expect(() => hostNameSchema.validate('-')).toThrowErrorMatchingInlineSnapshot(
+ `"value must be a valid hostname (see RFC 1123)."`
+ );
+ expect(() => hostNameSchema.validate('0:?:0:0:0:0:0:1')).toThrowErrorMatchingInlineSnapshot(
+ `"value must be a valid hostname (see RFC 1123)."`
+ );
const tooLongHostName = 'a'.repeat(256);
- expect(() => hostNameSchema.validate(tooLongHostName)).toThrowErrorMatchingSnapshot();
+ expect(() => hostNameSchema.validate(tooLongHostName)).toThrowErrorMatchingInlineSnapshot(
+ `"value must be a valid hostname (see RFC 1123)."`
+ );
});
test('returns error when empty string', () => {
- expect(() => schema.string({ hostname: true }).validate('')).toThrowErrorMatchingSnapshot();
+ expect(() => schema.string({ hostname: true }).validate('')).toThrowErrorMatchingInlineSnapshot(
+ `"any.empty"`
+ );
});
test('supports string validation rules', () => {
expect(() =>
schema.string({ hostname: true, maxLength: 3 }).validate('www.example.com')
- ).toThrowErrorMatchingSnapshot();
+ ).toThrowErrorMatchingInlineSnapshot(
+ `"value has length [15] but it must have a maximum length of [3]."`
+ );
});
});
@@ -146,20 +174,30 @@ describe('#validate', () => {
test('throws when returns string', () => {
const validate = () => 'validator failure';
- expect(() => schema.string({ validate }).validate('foo')).toThrowErrorMatchingSnapshot();
+ expect(() => schema.string({ validate }).validate('foo')).toThrowErrorMatchingInlineSnapshot(
+ `"validator failure"`
+ );
});
test('throw when empty string', () => {
const validate = () => 'validator failure';
- expect(() => schema.string({ validate }).validate('')).toThrowErrorMatchingSnapshot();
+ expect(() => schema.string({ validate }).validate('')).toThrowErrorMatchingInlineSnapshot(
+ `"validator failure"`
+ );
});
});
test('returns error when not string', () => {
- expect(() => schema.string().validate(123)).toThrowErrorMatchingSnapshot();
+ expect(() => schema.string().validate(123)).toThrowErrorMatchingInlineSnapshot(
+ `"expected value of type [string] but got [number]"`
+ );
- expect(() => schema.string().validate([1, 2, 3])).toThrowErrorMatchingSnapshot();
+ expect(() => schema.string().validate([1, 2, 3])).toThrowErrorMatchingInlineSnapshot(
+ `"expected value of type [string] but got [Array]"`
+ );
- expect(() => schema.string().validate(/abc/)).toThrowErrorMatchingSnapshot();
+ expect(() => schema.string().validate(/abc/)).toThrowErrorMatchingInlineSnapshot(
+ `"expected value of type [string] but got [RegExp]"`
+ );
});
diff --git a/packages/kbn-config-schema/src/types/string_type.ts b/packages/kbn-config-schema/src/types/string_type.ts
index 6d5fa93c0299c6..7f49440b8d7e29 100644
--- a/packages/kbn-config-schema/src/types/string_type.ts
+++ b/packages/kbn-config-schema/src/types/string_type.ts
@@ -45,7 +45,7 @@ export class StringType extends Type {
if (options.minLength !== undefined) {
schema = schema.custom(value => {
if (value.length < options.minLength!) {
- return `value is [${value}] but it must have a minimum length of [${options.minLength}].`;
+ return `value has length [${value.length}] but it must have a minimum length of [${options.minLength}].`;
}
});
}
@@ -53,7 +53,7 @@ export class StringType extends Type {
if (options.maxLength !== undefined) {
schema = schema.custom(value => {
if (value.length > options.maxLength!) {
- return `value is [${value}] but it must have a maximum length of [${options.maxLength}].`;
+ return `value has length [${value.length}] but it must have a maximum length of [${options.maxLength}].`;
}
});
}
@@ -66,7 +66,7 @@ export class StringType extends Type {
case 'any.required':
return `expected value of type [string] but got [${typeDetect(value)}]`;
case 'string.hostname':
- return `value is [${value}] but it must be a valid hostname (see RFC 1123).`;
+ return `value must be a valid hostname (see RFC 1123).`;
}
}
}
diff --git a/packages/kbn-config-schema/src/types/uri_type.test.ts b/packages/kbn-config-schema/src/types/uri_type.test.ts
index 1345b47a63c1ff..72e5ca6f7171e6 100644
--- a/packages/kbn-config-schema/src/types/uri_type.test.ts
+++ b/packages/kbn-config-schema/src/types/uri_type.test.ts
@@ -20,7 +20,9 @@
import { schema } from '..';
test('is required by default', () => {
- expect(() => schema.uri().validate(undefined)).toThrowErrorMatchingSnapshot();
+ expect(() => schema.uri().validate(undefined)).toThrowErrorMatchingInlineSnapshot(
+ `"expected value of type [string] but got [undefined]."`
+ );
});
test('returns value for valid URI as per RFC3986', () => {
@@ -54,17 +56,23 @@ test('returns value for valid URI as per RFC3986', () => {
test('returns error when value is not a URI', () => {
const uriSchema = schema.uri();
- expect(() => uriSchema.validate('3domain.local')).toThrowErrorMatchingSnapshot();
+ expect(() => uriSchema.validate('3domain.local')).toThrowErrorMatchingInlineSnapshot(
+ `"value must be a valid URI (see RFC 3986)."`
+ );
expect(() =>
uriSchema.validate('http://8010:0:0:0:9:500:300C:200A')
- ).toThrowErrorMatchingSnapshot();
- expect(() => uriSchema.validate('-')).toThrowErrorMatchingSnapshot();
+ ).toThrowErrorMatchingInlineSnapshot(`"value must be a valid URI (see RFC 3986)."`);
+ expect(() => uriSchema.validate('-')).toThrowErrorMatchingInlineSnapshot(
+ `"value must be a valid URI (see RFC 3986)."`
+ );
expect(() =>
uriSchema.validate('https://example.com?baz[]=foo&baz[]=bar')
- ).toThrowErrorMatchingSnapshot();
+ ).toThrowErrorMatchingInlineSnapshot(`"value must be a valid URI (see RFC 3986)."`);
const tooLongUri = `http://${'a'.repeat(256)}`;
- expect(() => uriSchema.validate(tooLongUri)).toThrowErrorMatchingSnapshot();
+ expect(() => uriSchema.validate(tooLongUri)).toThrowErrorMatchingInlineSnapshot(
+ `"value must be a valid URI (see RFC 3986)."`
+ );
});
describe('#scheme', () => {
@@ -78,8 +86,12 @@ describe('#scheme', () => {
test('returns error when shorter string', () => {
const uriSchema = schema.uri({ scheme: ['http', 'https'] });
- expect(() => uriSchema.validate('ftp://elastic.co')).toThrowErrorMatchingSnapshot();
- expect(() => uriSchema.validate('file:///kibana.log')).toThrowErrorMatchingSnapshot();
+ expect(() => uriSchema.validate('ftp://elastic.co')).toThrowErrorMatchingInlineSnapshot(
+ `"expected URI with scheme [http|https]."`
+ );
+ expect(() => uriSchema.validate('file:///kibana.log')).toThrowErrorMatchingInlineSnapshot(
+ `"expected URI with scheme [http|https]."`
+ );
});
});
@@ -131,14 +143,20 @@ describe('#validate', () => {
expect(() =>
schema.uri({ validate }).validate('http://kibana.local')
- ).toThrowErrorMatchingSnapshot();
+ ).toThrowErrorMatchingInlineSnapshot(`"validator failure"`);
});
});
test('returns error when not string', () => {
- expect(() => schema.uri().validate(123)).toThrowErrorMatchingSnapshot();
+ expect(() => schema.uri().validate(123)).toThrowErrorMatchingInlineSnapshot(
+ `"expected value of type [string] but got [number]."`
+ );
- expect(() => schema.uri().validate([1, 2, 3])).toThrowErrorMatchingSnapshot();
+ expect(() => schema.uri().validate([1, 2, 3])).toThrowErrorMatchingInlineSnapshot(
+ `"expected value of type [string] but got [Array]."`
+ );
- expect(() => schema.uri().validate(/abc/)).toThrowErrorMatchingSnapshot();
+ expect(() => schema.uri().validate(/abc/)).toThrowErrorMatchingInlineSnapshot(
+ `"expected value of type [string] but got [RegExp]."`
+ );
});
diff --git a/packages/kbn-config-schema/src/types/uri_type.ts b/packages/kbn-config-schema/src/types/uri_type.ts
index df1ce9e869d3b4..f365ed35e3579a 100644
--- a/packages/kbn-config-schema/src/types/uri_type.ts
+++ b/packages/kbn-config-schema/src/types/uri_type.ts
@@ -36,9 +36,9 @@ export class URIType extends Type {
case 'string.base':
return `expected value of type [string] but got [${typeDetect(value)}].`;
case 'string.uriCustomScheme':
- return `expected URI with scheme [${scheme}] but got [${value}].`;
+ return `expected URI with scheme [${scheme}].`;
case 'string.uri':
- return `value is [${value}] but it must be a valid URI (see RFC 3986).`;
+ return `value must be a valid URI (see RFC 3986).`;
}
}
}
diff --git a/packages/kbn-storybook/storybook_config/webpack.config.js b/packages/kbn-storybook/storybook_config/webpack.config.js
index 72ff9162ffe6c9..1531c1d22b01bc 100644
--- a/packages/kbn-storybook/storybook_config/webpack.config.js
+++ b/packages/kbn-storybook/storybook_config/webpack.config.js
@@ -19,6 +19,7 @@
const { resolve } = require('path');
const webpack = require('webpack');
+const { stringifyRequest } = require('loader-utils');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const { REPO_ROOT, DLL_DIST_DIR } = require('../lib/constants');
// eslint-disable-next-line import/no-unresolved
@@ -72,6 +73,38 @@ module.exports = async ({ config }) => {
],
});
+ // Enable SASS
+ config.module.rules.push({
+ test: /\.scss$/,
+ exclude: /\.module.(s(a|c)ss)$/,
+ use: [
+ { loader: 'style-loader' },
+ { loader: 'css-loader', options: { importLoaders: 2 } },
+ {
+ loader: 'postcss-loader',
+ options: {
+ config: {
+ path: resolve(REPO_ROOT, 'src/optimize/'),
+ },
+ },
+ },
+ {
+ loader: 'sass-loader',
+ options: {
+ prependData(loaderContext) {
+ return `@import ${stringifyRequest(
+ loaderContext,
+ resolve(REPO_ROOT, 'src/legacy/ui/public/styles/_styling_constants.scss')
+ )};\n`;
+ },
+ sassOptions: {
+ includePaths: [resolve(REPO_ROOT, 'node_modules')],
+ },
+ },
+ },
+ ],
+ });
+
// Reference the built DLL file of static(ish) dependencies, which are removed
// during kbn:bootstrap and rebuilt if missing.
config.plugins.push(
@@ -96,7 +129,7 @@ module.exports = async ({ config }) => {
);
// Tell Webpack about the ts/x extensions
- config.resolve.extensions.push('.ts', '.tsx');
+ config.resolve.extensions.push('.ts', '.tsx', '.scss');
// Load custom Webpack config specified by a plugin.
if (currentConfig.webpackHook) {
diff --git a/src/cli_plugin/install/cleanup.js b/src/cli_plugin/install/cleanup.js
index fa4bdcf4f69669..eaa25962ef0e44 100644
--- a/src/cli_plugin/install/cleanup.js
+++ b/src/cli_plugin/install/cleanup.js
@@ -27,7 +27,7 @@ export function cleanPrevious(settings, logger) {
logger.log('Found previous install attempt. Deleting...');
try {
- del.sync(settings.workingPath);
+ del.sync(settings.workingPath, { force: true });
} catch (e) {
reject(e);
}
diff --git a/src/cli_plugin/install/install.js b/src/cli_plugin/install/install.js
index 5a341e67dc128a..92be2ac2503202 100644
--- a/src/cli_plugin/install/install.js
+++ b/src/cli_plugin/install/install.js
@@ -46,7 +46,7 @@ export default async function install(settings, logger) {
await extract(settings, logger);
- del.sync(settings.tempArchiveFile);
+ del.sync(settings.tempArchiveFile, { force: true });
existingInstall(settings, logger);
diff --git a/src/cli_plugin/remove/remove.js b/src/cli_plugin/remove/remove.js
index 8432d0f44836ba..353e592390ff40 100644
--- a/src/cli_plugin/remove/remove.js
+++ b/src/cli_plugin/remove/remove.js
@@ -37,7 +37,7 @@ export default function remove(settings, logger) {
}
logger.log(`Removing ${settings.plugin}...`);
- del.sync(settings.pluginPath);
+ del.sync(settings.pluginPath, { force: true });
logger.log('Plugin removal complete');
} catch (err) {
logger.error(`Unable to remove plugin because of error: "${err.message}"`);
diff --git a/src/core/public/application/capabilities/capabilities_service.tsx b/src/core/public/application/capabilities/capabilities_service.tsx
index 05d718e1073dfa..d602422c146348 100644
--- a/src/core/public/application/capabilities/capabilities_service.tsx
+++ b/src/core/public/application/capabilities/capabilities_service.tsx
@@ -37,8 +37,7 @@ export interface CapabilitiesStart {
*/
export class CapabilitiesService {
public async start({ appIds, http }: StartDeps): Promise {
- const route = http.anonymousPaths.isAnonymous(window.location.pathname) ? '/defaults' : '';
- const capabilities = await http.post(`/api/core/capabilities${route}`, {
+ const capabilities = await http.post('/api/core/capabilities', {
body: JSON.stringify({ applications: appIds }),
});
diff --git a/src/core/server/capabilities/capabilities_service.test.ts b/src/core/server/capabilities/capabilities_service.test.ts
index aace0b9debf9c0..7d2e7391aa8d4b 100644
--- a/src/core/server/capabilities/capabilities_service.test.ts
+++ b/src/core/server/capabilities/capabilities_service.test.ts
@@ -41,8 +41,8 @@ describe('CapabilitiesService', () => {
});
it('registers the capabilities routes', async () => {
- expect(http.createRouter).toHaveBeenCalledWith('/api/core/capabilities');
- expect(router.post).toHaveBeenCalledTimes(2);
+ expect(http.createRouter).toHaveBeenCalledWith('');
+ expect(router.post).toHaveBeenCalledTimes(1);
expect(router.post).toHaveBeenCalledWith(expect.any(Object), expect.any(Function));
});
diff --git a/src/core/server/capabilities/routes/index.ts b/src/core/server/capabilities/routes/index.ts
index ccaa4621d70035..74c485986a77b2 100644
--- a/src/core/server/capabilities/routes/index.ts
+++ b/src/core/server/capabilities/routes/index.ts
@@ -22,6 +22,6 @@ import { InternalHttpServiceSetup } from '../../http';
import { registerCapabilitiesRoutes } from './resolve_capabilities';
export function registerRoutes(http: InternalHttpServiceSetup, resolver: CapabilitiesResolver) {
- const router = http.createRouter('/api/core/capabilities');
+ const router = http.createRouter('');
registerCapabilitiesRoutes(router, resolver);
}
diff --git a/src/core/server/capabilities/routes/resolve_capabilities.ts b/src/core/server/capabilities/routes/resolve_capabilities.ts
index 5e1d49b4b1b7e8..3fb1bb3d13d0bf 100644
--- a/src/core/server/capabilities/routes/resolve_capabilities.ts
+++ b/src/core/server/capabilities/routes/resolve_capabilities.ts
@@ -22,30 +22,24 @@ import { IRouter } from '../../http';
import { CapabilitiesResolver } from '../resolve_capabilities';
export function registerCapabilitiesRoutes(router: IRouter, resolver: CapabilitiesResolver) {
- // Capabilities are fetched on both authenticated and anonymous routes.
- // However when `authRequired` is false, authentication is not performed
- // and only default capabilities are returned (all disabled), even for authenticated users.
- // So we need two endpoints to handle both scenarios.
- [true, false].forEach(authRequired => {
- router.post(
- {
- path: authRequired ? '' : '/defaults',
- options: {
- authRequired,
- },
- validate: {
- body: schema.object({
- applications: schema.arrayOf(schema.string()),
- }),
- },
+ router.post(
+ {
+ path: '/api/core/capabilities',
+ options: {
+ authRequired: 'optional',
},
- async (ctx, req, res) => {
- const { applications } = req.body;
- const capabilities = await resolver(req, applications);
- return res.ok({
- body: capabilities,
- });
- }
- );
- });
+ validate: {
+ body: schema.object({
+ applications: schema.arrayOf(schema.string()),
+ }),
+ },
+ },
+ async (ctx, req, res) => {
+ const { applications } = req.body;
+ const capabilities = await resolver(req, applications);
+ return res.ok({
+ body: capabilities,
+ });
+ }
+ );
}
diff --git a/src/core/server/http/__snapshots__/http_config.test.ts.snap b/src/core/server/http/__snapshots__/http_config.test.ts.snap
index 28933a035c8709..07c153a7a8a200 100644
--- a/src/core/server/http/__snapshots__/http_config.test.ts.snap
+++ b/src/core/server/http/__snapshots__/http_config.test.ts.snap
@@ -87,7 +87,7 @@ exports[`throws if basepath is missing prepended slash 1`] = `"[basePath]: must
exports[`throws if basepath is not specified, but rewriteBasePath is set 1`] = `"cannot use [rewriteBasePath] when [basePath] is not specified"`;
-exports[`throws if invalid hostname 1`] = `"[host]: value is [asdf$%^] but it must be a valid hostname (see RFC 1123)."`;
+exports[`throws if invalid hostname 1`] = `"[host]: value must be a valid hostname (see RFC 1123)."`;
exports[`with TLS throws if TLS is enabled but \`redirectHttpFromPort\` is equal to \`port\` 1`] = `"Kibana does not accept http traffic to [port] when ssl is enabled (only https is allowed), so [ssl.redirectHttpFromPort] cannot be configured to the same value. Both are [1234]."`;
@@ -100,7 +100,7 @@ Array [
]
`;
-exports[`with compression throws if invalid referrer whitelist 1`] = `"[compression.referrerWhitelist.0]: value is [asdf$%^] but it must be a valid hostname (see RFC 1123)."`;
+exports[`with compression throws if invalid referrer whitelist 1`] = `"[compression.referrerWhitelist.0]: value must be a valid hostname (see RFC 1123)."`;
exports[`with compression throws if invalid referrer whitelist 2`] = `"[compression.referrerWhitelist]: array size is [0], but cannot be smaller than [1]"`;
diff --git a/src/core/server/http/http_server.mocks.ts b/src/core/server/http/http_server.mocks.ts
index 741c723ca93652..bbef0a105c0896 100644
--- a/src/core/server/http/http_server.mocks.ts
+++ b/src/core/server/http/http_server.mocks.ts
@@ -36,6 +36,7 @@ import { OnPostAuthToolkit } from './lifecycle/on_post_auth';
import { OnPreAuthToolkit } from './lifecycle/on_pre_auth';
interface RequestFixtureOptions {
+ auth?: { isAuthenticated: boolean };
headers?: Record;
params?: Record;
body?: Record;
@@ -65,11 +66,13 @@ function createKibanaRequestMock({
routeAuthRequired,
validation = {},
kibanaRouteState = { xsrfRequired: true },
+ auth = { isAuthenticated: true },
}: RequestFixtureOptions
= {}) {
const queryString = stringify(query, { sort: false });
return KibanaRequest.from
(
createRawRequestMock({
+ auth,
headers,
params,
query,
@@ -113,6 +116,9 @@ function createRawRequestMock(customization: DeepPartial = {}) {
{},
{
app: { xsrfRequired: true } as any,
+ auth: {
+ isAuthenticated: true,
+ },
headers: {},
path: '/',
route: { settings: {} },
diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts
index cffdffab0d0cf7..f898ed0ea1a991 100644
--- a/src/core/server/http/http_server.ts
+++ b/src/core/server/http/http_server.ts
@@ -26,8 +26,7 @@ import { adoptToHapiAuthFormat, AuthenticationHandler } from './lifecycle/auth';
import { adoptToHapiOnPostAuthFormat, OnPostAuthHandler } from './lifecycle/on_post_auth';
import { adoptToHapiOnPreAuthFormat, OnPreAuthHandler } from './lifecycle/on_pre_auth';
import { adoptToHapiOnPreResponseFormat, OnPreResponseHandler } from './lifecycle/on_pre_response';
-
-import { IRouter, KibanaRouteState, isSafeMethod } from './router';
+import { IRouter, RouteConfigOptions, KibanaRouteState, isSafeMethod } from './router';
import {
SessionStorageCookieOptions,
createCookieSessionStorageFactory,
@@ -148,7 +147,7 @@ export class HttpServer {
this.log.debug(`registering route handler for [${route.path}]`);
// Hapi does not allow payload validation to be specified for 'head' or 'get' requests
const validate = isSafeMethod(route.method) ? undefined : { payload: true };
- const { authRequired = true, tags, body = {} } = route.options;
+ const { authRequired, tags, body = {} } = route.options;
const { accepts: allow, maxBytes, output, parse } = body;
const kibanaRouteState: KibanaRouteState = {
@@ -160,8 +159,7 @@ export class HttpServer {
method: route.method,
path: route.path,
options: {
- // Enforcing the comparison with true because plugins could overwrite the auth strategy by doing `options: { authRequired: authStrategy as any }`
- auth: authRequired === true ? undefined : false,
+ auth: this.getAuthOption(authRequired),
app: kibanaRouteState,
tags: tags ? Array.from(tags) : undefined,
// TODO: This 'validate' section can be removed once the legacy platform is completely removed.
@@ -196,6 +194,22 @@ export class HttpServer {
this.server = undefined;
}
+ private getAuthOption(
+ authRequired: RouteConfigOptions['authRequired'] = true
+ ): undefined | false | { mode: 'required' | 'optional' } {
+ if (this.authRegistered === false) return undefined;
+
+ if (authRequired === true) {
+ return { mode: 'required' };
+ }
+ if (authRequired === 'optional') {
+ return { mode: 'optional' };
+ }
+ if (authRequired === false) {
+ return false;
+ }
+ }
+
private setupBasePathRewrite(config: HttpConfig, basePathService: BasePath) {
if (config.basePath === undefined || !config.rewriteBasePath) {
return;
diff --git a/src/core/server/http/http_service.mock.ts b/src/core/server/http/http_service.mock.ts
index 30032ff5da7968..442bc93190d86d 100644
--- a/src/core/server/http/http_service.mock.ts
+++ b/src/core/server/http/http_service.mock.ts
@@ -115,6 +115,8 @@ const createOnPostAuthToolkitMock = (): jest.Mocked => ({
const createAuthToolkitMock = (): jest.Mocked => ({
authenticated: jest.fn(),
+ notHandled: jest.fn(),
+ redirected: jest.fn(),
});
const createOnPreResponseToolkitMock = (): jest.Mocked => ({
diff --git a/src/core/server/http/index.ts b/src/core/server/http/index.ts
index 8f4c02680f8a30..a75eb04fa01204 100644
--- a/src/core/server/http/index.ts
+++ b/src/core/server/http/index.ts
@@ -67,9 +67,12 @@ export {
AuthenticationHandler,
AuthHeaders,
AuthResultParams,
+ AuthRedirected,
+ AuthRedirectedParams,
AuthToolkit,
AuthResult,
Authenticated,
+ AuthNotHandled,
AuthResultType,
} from './lifecycle/auth';
export { OnPostAuthHandler, OnPostAuthToolkit } from './lifecycle/on_post_auth';
diff --git a/src/core/server/http/integration_tests/core_services.test.ts b/src/core/server/http/integration_tests/core_services.test.ts
index 425d8cac1893ea..7b1630a7de0be0 100644
--- a/src/core/server/http/integration_tests/core_services.test.ts
+++ b/src/core/server/http/integration_tests/core_services.test.ts
@@ -50,7 +50,7 @@ describe('http service', () => {
await root.shutdown();
});
describe('#isAuthenticated()', () => {
- it('returns true if has been authorized', async () => {
+ it('returns true if has been authenticated', async () => {
const { http } = await root.setup();
const { registerAuth, createRouter, auth } = http;
@@ -65,11 +65,11 @@ describe('http service', () => {
await kbnTestServer.request.get(root, '/is-auth').expect(200, { isAuthenticated: true });
});
- it('returns false if has not been authorized', async () => {
+ it('returns false if has not been authenticated', async () => {
const { http } = await root.setup();
const { registerAuth, createRouter, auth } = http;
- await registerAuth((req, res, toolkit) => toolkit.authenticated());
+ registerAuth((req, res, toolkit) => toolkit.authenticated());
const router = createRouter('');
router.get(
@@ -81,7 +81,7 @@ describe('http service', () => {
await kbnTestServer.request.get(root, '/is-auth').expect(200, { isAuthenticated: false });
});
- it('returns false if no authorization mechanism has been registered', async () => {
+ it('returns false if no authentication mechanism has been registered', async () => {
const { http } = await root.setup();
const { createRouter, auth } = http;
@@ -94,6 +94,37 @@ describe('http service', () => {
await root.start();
await kbnTestServer.request.get(root, '/is-auth').expect(200, { isAuthenticated: false });
});
+
+ it('returns true if authenticated on a route with "optional" auth', async () => {
+ const { http } = await root.setup();
+ const { createRouter, auth, registerAuth } = http;
+
+ registerAuth((req, res, toolkit) => toolkit.authenticated());
+ const router = createRouter('');
+ router.get(
+ { path: '/is-auth', validate: false, options: { authRequired: 'optional' } },
+ (context, req, res) => res.ok({ body: { isAuthenticated: auth.isAuthenticated(req) } })
+ );
+
+ await root.start();
+ await kbnTestServer.request.get(root, '/is-auth').expect(200, { isAuthenticated: true });
+ });
+
+ it('returns false if not authenticated on a route with "optional" auth', async () => {
+ const { http } = await root.setup();
+ const { createRouter, auth, registerAuth } = http;
+
+ registerAuth((req, res, toolkit) => toolkit.notHandled());
+
+ const router = createRouter('');
+ router.get(
+ { path: '/is-auth', validate: false, options: { authRequired: 'optional' } },
+ (context, req, res) => res.ok({ body: { isAuthenticated: auth.isAuthenticated(req) } })
+ );
+
+ await root.start();
+ await kbnTestServer.request.get(root, '/is-auth').expect(200, { isAuthenticated: false });
+ });
});
describe('#get()', () => {
it('returns authenticated status and allow associate auth state with request', async () => {
diff --git a/src/core/server/http/integration_tests/lifecycle.test.ts b/src/core/server/http/integration_tests/lifecycle.test.ts
index 6dc7ece1359df7..0f0d54e88daca3 100644
--- a/src/core/server/http/integration_tests/lifecycle.test.ts
+++ b/src/core/server/http/integration_tests/lifecycle.test.ts
@@ -57,7 +57,7 @@ interface StorageData {
}
describe('OnPreAuth', () => {
- it('supports registering request inceptors', async () => {
+ it('supports registering a request interceptor', async () => {
const { registerOnPreAuth, server: innerServer, createRouter } = await server.setup(setupDeps);
const router = createRouter('/');
@@ -415,6 +415,23 @@ describe('Auth', () => {
.expect(200, { content: 'ok' });
});
+ it('blocks access to a resource if credentials are not provided', async () => {
+ const { registerAuth, server: innerServer, createRouter } = await server.setup(setupDeps);
+ const router = createRouter('/');
+
+ router.get({ path: '/', validate: false }, (context, req, res) =>
+ res.ok({ body: { content: 'ok' } })
+ );
+ registerAuth((req, res, t) => t.notHandled());
+ await server.start();
+
+ const result = await supertest(innerServer.listener)
+ .get('/')
+ .expect(401);
+
+ expect(result.body.message).toBe('Unauthorized');
+ });
+
it('enables auth for a route by default if registerAuth has been called', async () => {
const { registerAuth, server: innerServer, createRouter } = await server.setup(setupDeps);
const router = createRouter('/');
@@ -492,11 +509,9 @@ describe('Auth', () => {
router.get({ path: '/', validate: false }, (context, req, res) => res.ok());
const redirectTo = '/redirect-url';
- registerAuth((req, res) =>
- res.redirected({
- headers: {
- location: redirectTo,
- },
+ registerAuth((req, res, t) =>
+ t.redirected({
+ location: redirectTo,
})
);
await server.start();
@@ -507,6 +522,19 @@ describe('Auth', () => {
expect(response.header.location).toBe(redirectTo);
});
+ it('throws if redirection url is not provided', async () => {
+ const { registerAuth, server: innerServer, createRouter } = await server.setup(setupDeps);
+ const router = createRouter('/');
+
+ router.get({ path: '/', validate: false }, (context, req, res) => res.ok());
+ registerAuth((req, res, t) => t.redirected({} as any));
+ await server.start();
+
+ await supertest(innerServer.listener)
+ .get('/')
+ .expect(500);
+ });
+
it(`doesn't expose internal error details`, async () => {
const { registerAuth, server: innerServer, createRouter } = await server.setup(setupDeps);
const router = createRouter('/');
@@ -865,7 +893,7 @@ describe('Auth', () => {
]
`);
});
- // eslint-disable-next-line
+
it(`doesn't share request object between interceptors`, async () => {
const { registerOnPostAuth, server: innerServer, createRouter } = await server.setup(setupDeps);
const router = createRouter('/');
diff --git a/src/core/server/http/integration_tests/request.test.ts b/src/core/server/http/integration_tests/request.test.ts
index bc1bbc881315ab..85270174fbc048 100644
--- a/src/core/server/http/integration_tests/request.test.ts
+++ b/src/core/server/http/integration_tests/request.test.ts
@@ -45,6 +45,89 @@ afterEach(async () => {
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
describe('KibanaRequest', () => {
+ describe('auth', () => {
+ describe('isAuthenticated', () => {
+ it('returns false if no auth interceptor was registered', async () => {
+ const { server: innerServer, createRouter } = await server.setup(setupDeps);
+ const router = createRouter('/');
+ router.get(
+ { path: '/', validate: false, options: { authRequired: true } },
+ (context, req, res) => res.ok({ body: { isAuthenticated: req.auth.isAuthenticated } })
+ );
+ await server.start();
+
+ await supertest(innerServer.listener)
+ .get('/')
+ .expect(200, {
+ isAuthenticated: false,
+ });
+ });
+ it('returns false if not authenticated on a route with authRequired: "optional"', async () => {
+ const { server: innerServer, createRouter, registerAuth } = await server.setup(setupDeps);
+ const router = createRouter('/');
+ registerAuth((req, res, toolkit) => toolkit.notHandled());
+ router.get(
+ { path: '/', validate: false, options: { authRequired: 'optional' } },
+ (context, req, res) => res.ok({ body: { isAuthenticated: req.auth.isAuthenticated } })
+ );
+ await server.start();
+
+ await supertest(innerServer.listener)
+ .get('/')
+ .expect(200, {
+ isAuthenticated: false,
+ });
+ });
+ it('returns false if redirected on a route with authRequired: "optional"', async () => {
+ const { server: innerServer, createRouter, registerAuth } = await server.setup(setupDeps);
+ const router = createRouter('/');
+ registerAuth((req, res, toolkit) => toolkit.redirected({ location: '/any' }));
+ router.get(
+ { path: '/', validate: false, options: { authRequired: 'optional' } },
+ (context, req, res) => res.ok({ body: { isAuthenticated: req.auth.isAuthenticated } })
+ );
+ await server.start();
+
+ await supertest(innerServer.listener)
+ .get('/')
+ .expect(200, {
+ isAuthenticated: false,
+ });
+ });
+ it('returns true if authenticated on a route with authRequired: "optional"', async () => {
+ const { server: innerServer, createRouter, registerAuth } = await server.setup(setupDeps);
+ const router = createRouter('/');
+ registerAuth((req, res, toolkit) => toolkit.authenticated());
+ router.get(
+ { path: '/', validate: false, options: { authRequired: 'optional' } },
+ (context, req, res) => res.ok({ body: { isAuthenticated: req.auth.isAuthenticated } })
+ );
+ await server.start();
+
+ await supertest(innerServer.listener)
+ .get('/')
+ .expect(200, {
+ isAuthenticated: true,
+ });
+ });
+ it('returns true if authenticated', async () => {
+ const { server: innerServer, createRouter, registerAuth } = await server.setup(setupDeps);
+ const router = createRouter('/');
+ registerAuth((req, res, toolkit) => toolkit.authenticated());
+ router.get(
+ { path: '/', validate: false, options: { authRequired: true } },
+ (context, req, res) => res.ok({ body: { isAuthenticated: req.auth.isAuthenticated } })
+ );
+ await server.start();
+
+ await supertest(innerServer.listener)
+ .get('/')
+ .expect(200, {
+ isAuthenticated: true,
+ });
+ });
+ });
+ });
describe('events', () => {
describe('aborted$', () => {
it('emits once and completes when request aborted', async done => {
diff --git a/src/core/server/http/integration_tests/router.test.ts b/src/core/server/http/integration_tests/router.test.ts
index a1523781010d47..ee5b0c50acafb1 100644
--- a/src/core/server/http/integration_tests/router.test.ts
+++ b/src/core/server/http/integration_tests/router.test.ts
@@ -46,6 +46,286 @@ afterEach(async () => {
await server.stop();
});
+describe('Options', () => {
+ describe('authRequired', () => {
+ describe('optional', () => {
+ it('User has access to a route if auth mechanism not registered', async () => {
+ const { server: innerServer, createRouter, auth } = await server.setup(setupDeps);
+ const router = createRouter('/');
+
+ router.get(
+ { path: '/', validate: false, options: { authRequired: 'optional' } },
+ (context, req, res) =>
+ res.ok({
+ body: {
+ httpAuthIsAuthenticated: auth.isAuthenticated(req),
+ requestIsAuthenticated: req.auth.isAuthenticated,
+ },
+ })
+ );
+ await server.start();
+
+ await supertest(innerServer.listener)
+ .get('/')
+ .expect(200, {
+ httpAuthIsAuthenticated: false,
+ requestIsAuthenticated: false,
+ });
+ });
+
+ it('Authenticated user has access to a route', async () => {
+ const { server: innerServer, createRouter, registerAuth, auth } = await server.setup(
+ setupDeps
+ );
+ const router = createRouter('/');
+
+ registerAuth((req, res, toolkit) => {
+ return toolkit.authenticated();
+ });
+ router.get(
+ { path: '/', validate: false, options: { authRequired: 'optional' } },
+ (context, req, res) =>
+ res.ok({
+ body: {
+ httpAuthIsAuthenticated: auth.isAuthenticated(req),
+ requestIsAuthenticated: req.auth.isAuthenticated,
+ },
+ })
+ );
+ await server.start();
+
+ await supertest(innerServer.listener)
+ .get('/')
+ .expect(200, {
+ httpAuthIsAuthenticated: true,
+ requestIsAuthenticated: true,
+ });
+ });
+
+ it('User with no credentials can access a route', async () => {
+ const { server: innerServer, createRouter, registerAuth, auth } = await server.setup(
+ setupDeps
+ );
+ const router = createRouter('/');
+
+ registerAuth((req, res, toolkit) => toolkit.notHandled());
+
+ router.get(
+ { path: '/', validate: false, options: { authRequired: 'optional' } },
+ (context, req, res) =>
+ res.ok({
+ body: {
+ httpAuthIsAuthenticated: auth.isAuthenticated(req),
+ requestIsAuthenticated: req.auth.isAuthenticated,
+ },
+ })
+ );
+ await server.start();
+
+ await supertest(innerServer.listener)
+ .get('/')
+ .expect(200, {
+ httpAuthIsAuthenticated: false,
+ requestIsAuthenticated: false,
+ });
+ });
+
+ it('User with invalid credentials cannot access a route', async () => {
+ const { server: innerServer, createRouter, registerAuth } = await server.setup(setupDeps);
+ const router = createRouter('/');
+
+ registerAuth((req, res, toolkit) => res.unauthorized());
+
+ router.get(
+ { path: '/', validate: false, options: { authRequired: 'optional' } },
+ (context, req, res) => res.ok({ body: 'ok' })
+ );
+ await server.start();
+
+ await supertest(innerServer.listener)
+ .get('/')
+ .expect(401);
+ });
+
+ it('does not redirect user and allows access to a resource', async () => {
+ const { server: innerServer, createRouter, registerAuth, auth } = await server.setup(
+ setupDeps
+ );
+ const router = createRouter('/');
+
+ registerAuth((req, res, toolkit) =>
+ toolkit.redirected({
+ location: '/redirect-to',
+ })
+ );
+
+ router.get(
+ { path: '/', validate: false, options: { authRequired: 'optional' } },
+ (context, req, res) =>
+ res.ok({
+ body: {
+ httpAuthIsAuthenticated: auth.isAuthenticated(req),
+ requestIsAuthenticated: req.auth.isAuthenticated,
+ },
+ })
+ );
+ await server.start();
+
+ await supertest(innerServer.listener)
+ .get('/')
+ .expect(200, {
+ httpAuthIsAuthenticated: false,
+ requestIsAuthenticated: false,
+ });
+ });
+ });
+
+ describe('true', () => {
+ it('User has access to a route if auth interceptor is not registered', async () => {
+ const { server: innerServer, createRouter, auth } = await server.setup(setupDeps);
+ const router = createRouter('/');
+
+ router.get(
+ { path: '/', validate: false, options: { authRequired: true } },
+ (context, req, res) =>
+ res.ok({
+ body: {
+ httpAuthIsAuthenticated: auth.isAuthenticated(req),
+ requestIsAuthenticated: req.auth.isAuthenticated,
+ },
+ })
+ );
+ await server.start();
+
+ await supertest(innerServer.listener)
+ .get('/')
+ .expect(200, {
+ httpAuthIsAuthenticated: false,
+ requestIsAuthenticated: false,
+ });
+ });
+
+ it('Authenticated user has access to a route', async () => {
+ const { server: innerServer, createRouter, registerAuth, auth } = await server.setup(
+ setupDeps
+ );
+ const router = createRouter('/');
+
+ registerAuth((req, res, toolkit) => {
+ return toolkit.authenticated();
+ });
+ router.get(
+ { path: '/', validate: false, options: { authRequired: true } },
+ (context, req, res) =>
+ res.ok({
+ body: {
+ httpAuthIsAuthenticated: auth.isAuthenticated(req),
+ requestIsAuthenticated: req.auth.isAuthenticated,
+ },
+ })
+ );
+ await server.start();
+
+ await supertest(innerServer.listener)
+ .get('/')
+ .expect(200, {
+ httpAuthIsAuthenticated: true,
+ requestIsAuthenticated: true,
+ });
+ });
+
+ it('User with no credentials cannot access a route', async () => {
+ const { server: innerServer, createRouter, registerAuth } = await server.setup(setupDeps);
+ const router = createRouter('/');
+
+ registerAuth((req, res, toolkit) => toolkit.notHandled());
+ router.get(
+ { path: '/', validate: false, options: { authRequired: true } },
+ (context, req, res) => res.ok({ body: 'ok' })
+ );
+ await server.start();
+
+ await supertest(innerServer.listener)
+ .get('/')
+ .expect(401);
+ });
+
+ it('User with invalid credentials cannot access a route', async () => {
+ const { server: innerServer, createRouter, registerAuth } = await server.setup(setupDeps);
+ const router = createRouter('/');
+
+ registerAuth((req, res, toolkit) => res.unauthorized());
+
+ router.get(
+ { path: '/', validate: false, options: { authRequired: true } },
+ (context, req, res) => res.ok({ body: 'ok' })
+ );
+ await server.start();
+
+ await supertest(innerServer.listener)
+ .get('/')
+ .expect(401);
+ });
+
+ it('allows redirecting an user', async () => {
+ const { server: innerServer, createRouter, registerAuth } = await server.setup(setupDeps);
+ const router = createRouter('/');
+ const redirectUrl = '/redirect-to';
+
+ registerAuth((req, res, toolkit) =>
+ toolkit.redirected({
+ location: redirectUrl,
+ })
+ );
+
+ router.get(
+ { path: '/', validate: false, options: { authRequired: true } },
+ (context, req, res) => res.ok({ body: 'ok' })
+ );
+ await server.start();
+
+ const result = await supertest(innerServer.listener)
+ .get('/')
+ .expect(302);
+
+ expect(result.header.location).toBe(redirectUrl);
+ });
+ });
+
+ describe('false', () => {
+ it('does not try to authenticate a user', async () => {
+ const { server: innerServer, createRouter, registerAuth, auth } = await server.setup(
+ setupDeps
+ );
+ const router = createRouter('/');
+
+ const authHook = jest.fn();
+ registerAuth(authHook);
+ router.get(
+ { path: '/', validate: false, options: { authRequired: false } },
+ (context, req, res) =>
+ res.ok({
+ body: {
+ httpAuthIsAuthenticated: auth.isAuthenticated(req),
+ requestIsAuthenticated: req.auth.isAuthenticated,
+ },
+ })
+ );
+ await server.start();
+
+ await supertest(innerServer.listener)
+ .get('/')
+ .expect(200, {
+ httpAuthIsAuthenticated: false,
+ requestIsAuthenticated: false,
+ });
+
+ expect(authHook).toHaveBeenCalledTimes(0);
+ });
+ });
+ });
+});
+
describe('Handler', () => {
it("Doesn't expose error details if handler throws", async () => {
const { server: innerServer, createRouter } = await server.setup(setupDeps);
diff --git a/src/core/server/http/lifecycle/auth.ts b/src/core/server/http/lifecycle/auth.ts
index 036ab0211c2ff5..2eaf7e0f6fbfed 100644
--- a/src/core/server/http/lifecycle/auth.ts
+++ b/src/core/server/http/lifecycle/auth.ts
@@ -25,11 +25,14 @@ import {
lifecycleResponseFactory,
LifecycleResponseFactory,
isKibanaResponse,
+ ResponseHeaders,
} from '../router';
/** @public */
export enum AuthResultType {
authenticated = 'authenticated',
+ notHandled = 'notHandled',
+ redirected = 'redirected',
}
/** @public */
@@ -38,10 +41,20 @@ export interface Authenticated extends AuthResultParams {
}
/** @public */
-export type AuthResult = Authenticated;
+export interface AuthNotHandled {
+ type: AuthResultType.notHandled;
+}
+
+/** @public */
+export interface AuthRedirected extends AuthRedirectedParams {
+ type: AuthResultType.redirected;
+}
+
+/** @public */
+export type AuthResult = Authenticated | AuthNotHandled | AuthRedirected;
const authResult = {
- authenticated(data: Partial = {}): AuthResult {
+ authenticated(data: AuthResultParams = {}): AuthResult {
return {
type: AuthResultType.authenticated,
state: data.state,
@@ -49,8 +62,25 @@ const authResult = {
responseHeaders: data.responseHeaders,
};
},
+ notHandled(): AuthResult {
+ return {
+ type: AuthResultType.notHandled,
+ };
+ },
+ redirected(headers: { location: string } & ResponseHeaders): AuthResult {
+ return {
+ type: AuthResultType.redirected,
+ headers,
+ };
+ },
isAuthenticated(result: AuthResult): result is Authenticated {
- return result && result.type === AuthResultType.authenticated;
+ return result?.type === AuthResultType.authenticated;
+ },
+ isNotHandled(result: AuthResult): result is AuthNotHandled {
+ return result?.type === AuthResultType.notHandled;
+ },
+ isRedirected(result: AuthResult): result is AuthRedirected {
+ return result?.type === AuthResultType.redirected;
},
};
@@ -62,7 +92,7 @@ const authResult = {
export type AuthHeaders = Record;
/**
- * Result of an incoming request authentication.
+ * Result of successful authentication.
* @public
*/
export interface AuthResultParams {
@@ -82,6 +112,18 @@ export interface AuthResultParams {
responseHeaders?: AuthHeaders;
}
+/**
+ * Result of auth redirection.
+ * @public
+ */
+export interface AuthRedirectedParams {
+ /**
+ * Headers to attach for auth redirect.
+ * Must include "location" header
+ */
+ headers: { location: string } & ResponseHeaders;
+}
+
/**
* @public
* A tool set defining an outcome of Auth interceptor for incoming request.
@@ -89,10 +131,23 @@ export interface AuthResultParams {
export interface AuthToolkit {
/** Authentication is successful with given credentials, allow request to pass through */
authenticated: (data?: AuthResultParams) => AuthResult;
+ /**
+ * User has no credentials.
+ * Allows user to access a resource when authRequired: 'optional'
+ * Rejects a request when authRequired: true
+ * */
+ notHandled: () => AuthResult;
+ /**
+ * Redirects user to another location to complete authentication when authRequired: true
+ * Allows user to access a resource without redirection when authRequired: 'optional'
+ * */
+ redirected: (headers: { location: string } & ResponseHeaders) => AuthResult;
}
const toolkit: AuthToolkit = {
authenticated: authResult.authenticated,
+ notHandled: authResult.notHandled,
+ redirected: authResult.redirected,
};
/**
@@ -109,30 +164,51 @@ export type AuthenticationHandler = (
export function adoptToHapiAuthFormat(
fn: AuthenticationHandler,
log: Logger,
- onSuccess: (req: Request, data: AuthResultParams) => void = () => undefined
+ onAuth: (request: Request, data: AuthResultParams) => void = () => undefined
) {
return async function interceptAuth(
request: Request,
responseToolkit: ResponseToolkit
): Promise {
const hapiResponseAdapter = new HapiResponseAdapter(responseToolkit);
+ const kibanaRequest = KibanaRequest.from(request, undefined, false);
+
try {
- const result = await fn(
- KibanaRequest.from(request, undefined, false),
- lifecycleResponseFactory,
- toolkit
- );
+ const result = await fn(kibanaRequest, lifecycleResponseFactory, toolkit);
+
if (isKibanaResponse(result)) {
return hapiResponseAdapter.handle(result);
}
+
if (authResult.isAuthenticated(result)) {
- onSuccess(request, {
+ onAuth(request, {
state: result.state,
requestHeaders: result.requestHeaders,
responseHeaders: result.responseHeaders,
});
return responseToolkit.authenticated({ credentials: result.state || {} });
}
+
+ if (authResult.isRedirected(result)) {
+ // we cannot redirect a user when resources with optional auth requested
+ if (kibanaRequest.route.options.authRequired === 'optional') {
+ return responseToolkit.continue;
+ }
+
+ return hapiResponseAdapter.handle(
+ lifecycleResponseFactory.redirected({
+ // hapi doesn't accept string[] as a valid header
+ headers: result.headers as any,
+ })
+ );
+ }
+
+ if (authResult.isNotHandled(result)) {
+ if (kibanaRequest.route.options.authRequired === 'optional') {
+ return responseToolkit.continue;
+ }
+ return hapiResponseAdapter.handle(lifecycleResponseFactory.unauthorized());
+ }
throw new Error(
`Unexpected result from Authenticate. Expected AuthResult or KibanaResponse, but given: ${result}.`
);
diff --git a/src/core/server/http/router/request.test.ts b/src/core/server/http/router/request.test.ts
index 032027c2344858..fb999dc60e39c5 100644
--- a/src/core/server/http/router/request.test.ts
+++ b/src/core/server/http/router/request.test.ts
@@ -16,6 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
+import { RouteOptions } from 'hapi';
import { KibanaRequest } from './request';
import { httpServerMock } from '../http_server.mocks';
import { schema } from '@kbn/config-schema';
@@ -117,6 +118,106 @@ describe('KibanaRequest', () => {
});
});
+ describe('route.options.authRequired property', () => {
+ it('handles required auth: undefined', () => {
+ const auth: RouteOptions['auth'] = undefined;
+ const request = httpServerMock.createRawRequest({
+ route: {
+ settings: {
+ auth,
+ },
+ },
+ });
+ const kibanaRequest = KibanaRequest.from(request);
+
+ expect(kibanaRequest.route.options.authRequired).toBe(true);
+ });
+ it('handles required auth: false', () => {
+ const auth: RouteOptions['auth'] = false;
+ const request = httpServerMock.createRawRequest({
+ route: {
+ settings: {
+ auth,
+ },
+ },
+ });
+ const kibanaRequest = KibanaRequest.from(request);
+
+ expect(kibanaRequest.route.options.authRequired).toBe(false);
+ });
+ it('handles required auth: { mode: "required" }', () => {
+ const auth: RouteOptions['auth'] = { mode: 'required' };
+ const request = httpServerMock.createRawRequest({
+ route: {
+ settings: {
+ auth,
+ },
+ },
+ });
+ const kibanaRequest = KibanaRequest.from(request);
+
+ expect(kibanaRequest.route.options.authRequired).toBe(true);
+ });
+
+ it('handles required auth: { mode: "optional" }', () => {
+ const auth: RouteOptions['auth'] = { mode: 'optional' };
+ const request = httpServerMock.createRawRequest({
+ route: {
+ settings: {
+ auth,
+ },
+ },
+ });
+ const kibanaRequest = KibanaRequest.from(request);
+
+ expect(kibanaRequest.route.options.authRequired).toBe('optional');
+ });
+
+ it('handles required auth: { mode: "try" } as "optional"', () => {
+ const auth: RouteOptions['auth'] = { mode: 'try' };
+ const request = httpServerMock.createRawRequest({
+ route: {
+ settings: {
+ auth,
+ },
+ },
+ });
+ const kibanaRequest = KibanaRequest.from(request);
+
+ expect(kibanaRequest.route.options.authRequired).toBe('optional');
+ });
+
+ it('throws on auth: strategy name', () => {
+ const auth: RouteOptions['auth'] = 'session';
+ const request = httpServerMock.createRawRequest({
+ route: {
+ settings: {
+ auth,
+ },
+ },
+ });
+
+ expect(() => KibanaRequest.from(request)).toThrowErrorMatchingInlineSnapshot(
+ `"unexpected authentication options: \\"session\\" for route: /"`
+ );
+ });
+
+ it('throws on auth: { mode: unexpected mode }', () => {
+ const auth: RouteOptions['auth'] = { mode: undefined };
+ const request = httpServerMock.createRawRequest({
+ route: {
+ settings: {
+ auth,
+ },
+ },
+ });
+
+ expect(() => KibanaRequest.from(request)).toThrowErrorMatchingInlineSnapshot(
+ `"unexpected authentication options: {} for route: /"`
+ );
+ });
+ });
+
describe('RouteSchema type inferring', () => {
it('should work with config-schema', () => {
const body = Buffer.from('body!');
diff --git a/src/core/server/http/router/request.ts b/src/core/server/http/router/request.ts
index bb2db6367f701f..f266677c1a1727 100644
--- a/src/core/server/http/router/request.ts
+++ b/src/core/server/http/router/request.ts
@@ -143,6 +143,10 @@ export class KibanaRequest<
public readonly socket: IKibanaSocket;
/** Request events {@link KibanaRequestEvents} */
public readonly events: KibanaRequestEvents;
+ public readonly auth: {
+ /* true if the request has been successfully authenticated, otherwise false. */
+ isAuthenticated: boolean;
+ };
/** @internal */
protected readonly [requestSymbol]: Request;
@@ -172,6 +176,11 @@ export class KibanaRequest<
this.route = deepFreeze(this.getRouteInfo(request));
this.socket = new KibanaSocket(request.raw.req.socket);
this.events = this.getEvents(request);
+
+ this.auth = {
+ // missing in fakeRequests, so we cast to false
+ isAuthenticated: Boolean(request.auth?.isAuthenticated),
+ };
}
private getEvents(request: Request): KibanaRequestEvents {
@@ -189,7 +198,7 @@ export class KibanaRequest<
const { parse, maxBytes, allow, output } = request.route.settings.payload || {};
const options = ({
- authRequired: request.route.settings.auth !== false,
+ authRequired: this.getAuthRequired(request),
// some places in LP call KibanaRequest.from(request) manually. remove fallback to true before v8
xsrfRequired: (request.route.settings.app as KibanaRouteState)?.xsrfRequired ?? true,
tags: request.route.settings.tags || [],
@@ -209,6 +218,31 @@ export class KibanaRequest<
options,
};
}
+
+ private getAuthRequired(request: Request): boolean | 'optional' {
+ const authOptions = request.route.settings.auth;
+ if (typeof authOptions === 'object') {
+ // 'try' is used in the legacy platform
+ if (authOptions.mode === 'optional' || authOptions.mode === 'try') {
+ return 'optional';
+ }
+ if (authOptions.mode === 'required') {
+ return true;
+ }
+ }
+
+ // legacy platform routes
+ if (authOptions === undefined) {
+ return true;
+ }
+
+ if (authOptions === false) return false;
+ throw new Error(
+ `unexpected authentication options: ${JSON.stringify(authOptions)} for route: ${
+ this.url.href
+ }`
+ );
+ }
}
/**
diff --git a/src/core/server/http/router/route.ts b/src/core/server/http/router/route.ts
index d1458ef4ad0632..bb0a8616e72222 100644
--- a/src/core/server/http/router/route.ts
+++ b/src/core/server/http/router/route.ts
@@ -116,13 +116,15 @@ export interface RouteConfigOptionsBody {
*/
export interface RouteConfigOptions {
/**
- * A flag shows that authentication for a route:
- * `enabled` when true
- * `disabled` when false
+ * Defines authentication mode for a route:
+ * - true. A user has to have valid credentials to access a resource
+ * - false. A user can access a resource without any credentials.
+ * - 'optional'. A user can access a resource if has valid credentials or no credentials at all.
+ * Can be useful when we grant access to a resource but want to identify a user if possible.
*
- * Enabled by default.
+ * Defaults to `true` if an auth mechanism is registered.
*/
- authRequired?: boolean;
+ authRequired?: boolean | 'optional';
/**
* Defines xsrf protection requirements for a route:
diff --git a/src/core/server/http/ssl_config.test.ts b/src/core/server/http/ssl_config.test.ts
index 738f86f7a69eb8..3980b9c247fa33 100644
--- a/src/core/server/http/ssl_config.test.ts
+++ b/src/core/server/http/ssl_config.test.ts
@@ -293,16 +293,16 @@ describe('#sslSchema', () => {
expect(() => sslSchema.validate(singleUnknownProtocol)).toThrowErrorMatchingInlineSnapshot(`
"[supportedProtocols.0]: types that failed validation:
-- [supportedProtocols.0.0]: expected value to equal [TLSv1] but got [SOMEv100500]
-- [supportedProtocols.0.1]: expected value to equal [TLSv1.1] but got [SOMEv100500]
-- [supportedProtocols.0.2]: expected value to equal [TLSv1.2] but got [SOMEv100500]"
+- [supportedProtocols.0.0]: expected value to equal [TLSv1]
+- [supportedProtocols.0.1]: expected value to equal [TLSv1.1]
+- [supportedProtocols.0.2]: expected value to equal [TLSv1.2]"
`);
expect(() => sslSchema.validate(allKnownWithOneUnknownProtocols))
.toThrowErrorMatchingInlineSnapshot(`
"[supportedProtocols.3]: types that failed validation:
-- [supportedProtocols.3.0]: expected value to equal [TLSv1] but got [SOMEv100500]
-- [supportedProtocols.3.1]: expected value to equal [TLSv1.1] but got [SOMEv100500]
-- [supportedProtocols.3.2]: expected value to equal [TLSv1.2] but got [SOMEv100500]"
+- [supportedProtocols.3.0]: expected value to equal [TLSv1]
+- [supportedProtocols.3.1]: expected value to equal [TLSv1.1]
+- [supportedProtocols.3.2]: expected value to equal [TLSv1.2]"
`);
});
});
diff --git a/src/core/server/index.ts b/src/core/server/index.ts
index 8e481171116faf..80eabe778ece33 100644
--- a/src/core/server/index.ts
+++ b/src/core/server/index.ts
@@ -100,9 +100,12 @@ export {
AuthResultParams,
AuthStatus,
AuthToolkit,
+ AuthRedirected,
+ AuthRedirectedParams,
AuthResult,
AuthResultType,
Authenticated,
+ AuthNotHandled,
BasePath,
IBasePath,
CustomHttpResponseOptions,
diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md
index 30695df33345ad..f7afe7a6a290aa 100644
--- a/src/core/server/server.api.md
+++ b/src/core/server/server.api.md
@@ -419,7 +419,26 @@ export type AuthenticationHandler = (request: KibanaRequest, response: Lifecycle
export type AuthHeaders = Record;
// @public (undocumented)
-export type AuthResult = Authenticated;
+export interface AuthNotHandled {
+ // (undocumented)
+ type: AuthResultType.notHandled;
+}
+
+// @public (undocumented)
+export interface AuthRedirected extends AuthRedirectedParams {
+ // (undocumented)
+ type: AuthResultType.redirected;
+}
+
+// @public
+export interface AuthRedirectedParams {
+ headers: {
+ location: string;
+ } & ResponseHeaders;
+}
+
+// @public (undocumented)
+export type AuthResult = Authenticated | AuthNotHandled | AuthRedirected;
// @public
export interface AuthResultParams {
@@ -431,7 +450,11 @@ export interface AuthResultParams {
// @public (undocumented)
export enum AuthResultType {
// (undocumented)
- authenticated = "authenticated"
+ authenticated = "authenticated",
+ // (undocumented)
+ notHandled = "notHandled",
+ // (undocumented)
+ redirected = "redirected"
}
// @public
@@ -444,6 +467,10 @@ export enum AuthStatus {
// @public
export interface AuthToolkit {
authenticated: (data?: AuthResultParams) => AuthResult;
+ notHandled: () => AuthResult;
+ redirected: (headers: {
+ location: string;
+ } & ResponseHeaders) => AuthResult;
}
// @public
@@ -970,6 +997,10 @@ export class KibanaRequest {
// @public
export interface RouteConfigOptions {
- authRequired?: boolean;
+ authRequired?: boolean | 'optional';
body?: Method extends 'get' | 'options' ? undefined : RouteConfigOptionsBody;
tags?: readonly string[];
xsrfRequired?: Method extends 'get' ? never : boolean;
diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts
index 35ac4e27f9c8b3..8ed64f004c9be3 100644
--- a/src/dev/storybook/aliases.ts
+++ b/src/dev/storybook/aliases.ts
@@ -25,4 +25,5 @@ export const storybookAliases = {
embeddable: 'src/plugins/embeddable/scripts/storybook.js',
infra: 'x-pack/legacy/plugins/infra/scripts/storybook.js',
siem: 'x-pack/legacy/plugins/siem/scripts/storybook.js',
+ ui_actions: 'x-pack/plugins/advanced_ui_actions/scripts/storybook.js',
};
diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.test.tsx b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.test.tsx
index e09f26311e4e32..2278b243ecc143 100644
--- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.test.tsx
+++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.test.tsx
@@ -45,7 +45,10 @@ beforeEach(() => {
jest.clearAllMocks();
});
-export const waitForPromises = () => new Promise(resolve => setTimeout(resolve, 0));
+const waitForPromises = async () =>
+ act(async () => {
+ await new Promise(resolve => setTimeout(resolve));
+ });
/**
* this works but logs ugly error messages until we're using React 16.9
diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/create_index_pattern_wizard/lib/get_indices.test.ts b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/create_index_pattern_wizard/lib/get_indices.test.ts
index cd7c8278adcc7c..5a8460fcb51bae 100644
--- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/create_index_pattern_wizard/lib/get_indices.test.ts
+++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/create_index_pattern_wizard/lib/get_indices.test.ts
@@ -19,7 +19,8 @@
import { getIndices } from './get_indices';
import { IndexPatternCreationConfig } from './../../../../../../../management/public';
-import { LegacyApiCaller } from '../../../../../../../../../plugins/data/public';
+// eslint-disable-next-line @kbn/eslint/no-restricted-paths
+import { LegacyApiCaller } from '../../../../../../../../../plugins/data/public/search';
export const successfulResponse = {
hits: {
diff --git a/src/legacy/core_plugins/timelion/index.ts b/src/legacy/core_plugins/timelion/index.ts
index 42d4e04184a257..9e2bfd4023bd96 100644
--- a/src/legacy/core_plugins/timelion/index.ts
+++ b/src/legacy/core_plugins/timelion/index.ts
@@ -62,14 +62,6 @@ const timelionPluginInitializer: LegacyPluginInitializer = ({ Plugin }: LegacyPl
},
styleSheetPaths: resolve(__dirname, 'public/index.scss'),
hacks: [resolve(__dirname, 'public/legacy')],
- injectDefaultVars(server) {
- const config = server.config();
-
- return {
- timelionUiEnabled: config.get('timelion.ui.enabled'),
- kbnIndex: config.get('kibana.index'),
- };
- },
mappings: require('./mappings.json'),
uiSettingDefaults: {
'timelion:showTutorial': {
diff --git a/src/legacy/core_plugins/timelion/public/legacy.ts b/src/legacy/core_plugins/timelion/public/legacy.ts
index 63030fcbce3873..acb95e80fe18c0 100644
--- a/src/legacy/core_plugins/timelion/public/legacy.ts
+++ b/src/legacy/core_plugins/timelion/public/legacy.ts
@@ -18,7 +18,7 @@
*/
import { PluginInitializerContext } from 'kibana/public';
-import { npSetup, npStart } from 'ui/new_platform';
+import { npSetup } from 'ui/new_platform';
import { plugin } from '.';
import { TimelionPluginSetupDependencies } from './plugin';
import { LegacyDependenciesPlugin } from './shim';
@@ -32,4 +32,4 @@ const setupPlugins: Readonly = {
const pluginInstance = plugin({} as PluginInitializerContext);
export const setup = pluginInstance.setup(npSetup.core, setupPlugins);
-export const start = pluginInstance.start(npStart.core);
+export const start = pluginInstance.start();
diff --git a/src/legacy/core_plugins/timelion/public/plugin.ts b/src/legacy/core_plugins/timelion/public/plugin.ts
index 636b8bf8e128aa..8b021cda4bfb0b 100644
--- a/src/legacy/core_plugins/timelion/public/plugin.ts
+++ b/src/legacy/core_plugins/timelion/public/plugin.ts
@@ -16,13 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import {
- CoreSetup,
- CoreStart,
- Plugin,
- PluginInitializerContext,
- IUiSettingsClient,
-} from 'kibana/public';
+import { CoreSetup, Plugin, PluginInitializerContext, IUiSettingsClient } from 'kibana/public';
import { getTimeChart } from './panels/timechart/timechart';
import { Panel } from './panels/panel';
import { LegacyDependenciesPlugin, LegacyDependenciesPluginSetup } from './shim';
@@ -65,13 +59,7 @@ export class TimelionPlugin implements Plugin, void> {
dependencies.timelionPanels.set(timeChartPanel.name, timeChartPanel);
}
- public start(core: CoreStart) {
- const timelionUiEnabled = core.injectedMetadata.getInjectedVar('timelionUiEnabled');
-
- if (timelionUiEnabled === false) {
- core.chrome.navLinks.update('timelion', { hidden: true });
- }
- }
+ public start() {}
public stop(): void {}
}
diff --git a/src/plugins/data/common/es_query/filters/get_display_value.ts b/src/plugins/data/common/es_query/filters/get_display_value.ts
index 4bf7e1c9c6ba70..03167f3080419a 100644
--- a/src/plugins/data/common/es_query/filters/get_display_value.ts
+++ b/src/plugins/data/common/es_query/filters/get_display_value.ts
@@ -18,6 +18,7 @@
*/
import { get } from 'lodash';
+import { i18n } from '@kbn/i18n';
import { IIndexPattern, IFieldType } from '../..';
import { getIndexPatternFromFilter } from './get_index_pattern_from_filter';
import { Filter } from '../filters';
@@ -27,7 +28,16 @@ function getValueFormatter(indexPattern?: IIndexPattern, key?: string) {
let format = get(indexPattern, ['fields', 'byName', key, 'format']);
if (!format && (indexPattern.fields as any).getByName) {
// TODO: Why is indexPatterns sometimes a map and sometimes an array?
- format = ((indexPattern.fields as any).getByName(key) as IFieldType).format;
+ const field: IFieldType = (indexPattern.fields as any).getByName(key);
+ if (!field) {
+ throw new Error(
+ i18n.translate('data.filter.filterBar.fieldNotFound', {
+ defaultMessage: 'Field {key} not found in index pattern {indexPattern}',
+ values: { key, indexPattern: indexPattern.title },
+ })
+ );
+ }
+ format = field.format;
}
return format;
}
diff --git a/src/plugins/data/common/index.ts b/src/plugins/data/common/index.ts
index e02045de24e8f5..7fa6e88b427a95 100644
--- a/src/plugins/data/common/index.ts
+++ b/src/plugins/data/common/index.ts
@@ -24,4 +24,5 @@ export * from './index_patterns';
export * from './es_query';
export * from './utils';
export * from './types';
+export * from './search';
export * from './constants';
diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts
index a5f4ce2ce3c581..86cc0cca85e0b1 100644
--- a/src/plugins/data/public/index.ts
+++ b/src/plugins/data/public/index.ts
@@ -283,8 +283,39 @@ export {
* Search:
*/
-export { IRequestTypesMap, IResponseTypesMap } from './search';
-export * from './search';
+export {
+ ES_SEARCH_STRATEGY,
+ SYNC_SEARCH_STRATEGY,
+ defaultSearchStrategy,
+ esSearchStrategyProvider,
+ getEsPreference,
+ addSearchStrategy,
+ hasSearchStategyForIndexPattern,
+ getSearchErrorType,
+ ISearchContext,
+ TSearchStrategyProvider,
+ ISearchStrategy,
+ ISearch,
+ ISearchOptions,
+ IRequestTypesMap,
+ IResponseTypesMap,
+ ISearchGeneric,
+ IEsSearchResponse,
+ IEsSearchRequest,
+ ISyncSearchRequest,
+ IKibanaSearchResponse,
+ IKibanaSearchRequest,
+ SearchRequest,
+ SearchResponse,
+ SearchError,
+ SearchStrategyProvider,
+ ISearchSource,
+ SearchSource,
+ SearchSourceFields,
+ EsQuerySortValue,
+ SortDirection,
+ FetchOptions,
+} from './search';
/*
* UI components
diff --git a/src/plugins/data/public/search/es_search/es_search_strategy.ts b/src/plugins/data/public/search/es_search/es_search_strategy.ts
index 5382a59123e780..a61428c998157e 100644
--- a/src/plugins/data/public/search/es_search/es_search_strategy.ts
+++ b/src/plugins/data/public/search/es_search/es_search_strategy.ts
@@ -30,11 +30,10 @@ export const esSearchStrategyProvider: TSearchStrategyProvider {
- if (typeof request.params.preference === 'undefined') {
- const setPreference = context.core.uiSettings.get('courier:setRequestPreference');
- const customPreference = context.core.uiSettings.get('courier:customRequestPreference');
- request.params.preference = getEsPreference(setPreference, customPreference);
- }
+ request.params = {
+ preference: getEsPreference(context.core.uiSettings),
+ ...request.params,
+ };
return search({ ...request, serverStrategy: ES_SEARCH_STRATEGY }, options) as Observable<
IEsSearchResponse
>;
diff --git a/src/plugins/data/public/search/es_search/get_es_preference.test.ts b/src/plugins/data/public/search/es_search/get_es_preference.test.ts
index 27e6f9b48bbdd5..8b8156b4519d64 100644
--- a/src/plugins/data/public/search/es_search/get_es_preference.test.ts
+++ b/src/plugins/data/public/search/es_search/get_es_preference.test.ts
@@ -18,29 +18,40 @@
*/
import { getEsPreference } from './get_es_preference';
-
-jest.useFakeTimers();
+import { CoreStart } from '../../../../../core/public';
+import { coreMock } from '../../../../../core/public/mocks';
describe('Get ES preference', () => {
+ let mockCoreStart: MockedKeys;
+
+ beforeEach(() => {
+ mockCoreStart = coreMock.createStart();
+ });
+
test('returns the session ID if set to sessionId', () => {
- const setPreference = 'sessionId';
- const customPreference = 'foobar';
- const sessionId = 'my_session_id';
- const preference = getEsPreference(setPreference, customPreference, sessionId);
- expect(preference).toBe(sessionId);
+ mockCoreStart.uiSettings.get.mockImplementation((key: string) => {
+ if (key === 'courier:setRequestPreference') return 'sessionId';
+ if (key === 'courier:customRequestPreference') return 'foobar';
+ });
+ const preference = getEsPreference(mockCoreStart.uiSettings, 'my_session_id');
+ expect(preference).toBe('my_session_id');
});
test('returns the custom preference if set to custom', () => {
- const setPreference = 'custom';
- const customPreference = 'foobar';
- const preference = getEsPreference(setPreference, customPreference);
- expect(preference).toBe(customPreference);
+ mockCoreStart.uiSettings.get.mockImplementation((key: string) => {
+ if (key === 'courier:setRequestPreference') return 'custom';
+ if (key === 'courier:customRequestPreference') return 'foobar';
+ });
+ const preference = getEsPreference(mockCoreStart.uiSettings);
+ expect(preference).toBe('foobar');
});
test('returns undefined if set to none', () => {
- const setPreference = 'none';
- const customPreference = 'foobar';
- const preference = getEsPreference(setPreference, customPreference);
+ mockCoreStart.uiSettings.get.mockImplementation((key: string) => {
+ if (key === 'courier:setRequestPreference') return 'none';
+ if (key === 'courier:customRequestPreference') return 'foobar';
+ });
+ const preference = getEsPreference(mockCoreStart.uiSettings);
expect(preference).toBe(undefined);
});
});
diff --git a/src/plugins/data/public/search/es_search/get_es_preference.ts b/src/plugins/data/public/search/es_search/get_es_preference.ts
index 200e5bacb7f182..3f1c2b9b3b7369 100644
--- a/src/plugins/data/public/search/es_search/get_es_preference.ts
+++ b/src/plugins/data/public/search/es_search/get_es_preference.ts
@@ -17,13 +17,13 @@
* under the License.
*/
+import { IUiSettingsClient } from '../../../../../core/public';
+
const defaultSessionId = `${Date.now()}`;
-export function getEsPreference(
- setRequestPreference: string,
- customRequestPreference?: string,
- sessionId: string = defaultSessionId
-) {
- if (setRequestPreference === 'sessionId') return `${sessionId}`;
- return setRequestPreference === 'custom' ? customRequestPreference : undefined;
+export function getEsPreference(uiSettings: IUiSettingsClient, sessionId = defaultSessionId) {
+ const setPreference = uiSettings.get('courier:setRequestPreference');
+ if (setPreference === 'sessionId') return `${sessionId}`;
+ const customPreference = uiSettings.get('courier:customRequestPreference');
+ return setPreference === 'custom' ? customPreference : undefined;
}
diff --git a/src/plugins/data/public/search/es_search/index.ts b/src/plugins/data/public/search/es_search/index.ts
new file mode 100644
index 00000000000000..41c6ec388bfafe
--- /dev/null
+++ b/src/plugins/data/public/search/es_search/index.ts
@@ -0,0 +1,21 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export { esSearchStrategyProvider } from './es_search_strategy';
+export { getEsPreference } from './get_es_preference';
diff --git a/src/plugins/data/public/search/fetch/types.ts b/src/plugins/data/public/search/fetch/types.ts
index 62eb965703c3a4..e8de0576b8a72f 100644
--- a/src/plugins/data/public/search/fetch/types.ts
+++ b/src/plugins/data/public/search/fetch/types.ts
@@ -17,8 +17,8 @@
* under the License.
*/
-import { ISearchStart } from 'src/plugins/data/public';
import { IUiSettingsClient } from '../../../../../core/public';
+import { ISearchStart } from '../types';
export interface FetchOptions {
abortSignal?: AbortSignal;
diff --git a/src/plugins/data/public/search/index.ts b/src/plugins/data/public/search/index.ts
index 853dbd09e1f939..2a54cfe2be7857 100644
--- a/src/plugins/data/public/search/index.ts
+++ b/src/plugins/data/public/search/index.ts
@@ -36,6 +36,7 @@ export {
export { IEsSearchResponse, IEsSearchRequest, ES_SEARCH_STRATEGY } from '../../common/search';
export { ISyncSearchRequest, SYNC_SEARCH_STRATEGY } from './sync_search_strategy';
+export { esSearchStrategyProvider, getEsPreference } from './es_search';
export { IKibanaSearchResponse, IKibanaSearchRequest } from '../../common/search';
diff --git a/src/plugins/data/public/ui/filter_bar/_global_filter_item.scss b/src/plugins/data/public/ui/filter_bar/_global_filter_item.scss
index 51204e2a611688..24adf0093af952 100644
--- a/src/plugins/data/public/ui/filter_bar/_global_filter_item.scss
+++ b/src/plugins/data/public/ui/filter_bar/_global_filter_item.scss
@@ -32,6 +32,15 @@
font-style: italic;
}
+.globalFilterItem-isInvalid {
+ text-decoration: none;
+
+ .globalFilterLabel__value {
+ color: $euiColorDanger;
+ font-weight: $euiFontWeightBold;
+ }
+}
+
.globalFilterItem-isPinned {
position: relative;
diff --git a/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_label.tsx b/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_label.tsx
index ee6d178b25c22e..070631354d8b80 100644
--- a/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_label.tsx
+++ b/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_label.tsx
@@ -41,6 +41,10 @@ export function FilterLabel({ filter, valueLabel }: Props) {
prefixText
);
+ const getValue = (text?: string) => {
+ return {text} ;
+ };
+
if (filter.meta.alias !== null) {
return (
@@ -55,35 +59,35 @@ export function FilterLabel({ filter, valueLabel }: Props) {
return (
{prefix}
- {filter.meta.key} {existsOperator.message}
+ {filter.meta.key}: {getValue(`${existsOperator.message}`)}
);
case FILTERS.GEO_BOUNDING_BOX:
return (
{prefix}
- {filter.meta.key}: {valueLabel}
+ {filter.meta.key}: {getValue(valueLabel)}
);
case FILTERS.GEO_POLYGON:
return (
{prefix}
- {filter.meta.key}: {valueLabel}
+ {filter.meta.key}: {getValue(valueLabel)}
);
case FILTERS.PHRASES:
return (
{prefix}
- {filter.meta.key} {isOneOfOperator.message} {valueLabel}
+ {filter.meta.key}: {getValue(`${isOneOfOperator.message} ${valueLabel}`)}
);
case FILTERS.QUERY_STRING:
return (
{prefix}
- {valueLabel}
+ {getValue(`${valueLabel}`)}
);
case FILTERS.PHRASE:
@@ -91,14 +95,14 @@ export function FilterLabel({ filter, valueLabel }: Props) {
return (
{prefix}
- {filter.meta.key}: {valueLabel}
+ {filter.meta.key}: {getValue(valueLabel)}
);
default:
return (
{prefix}
- {JSON.stringify(filter.query) || filter.meta.value}
+ {getValue(`${JSON.stringify(filter.query) || filter.meta.value}`)}
);
}
diff --git a/src/plugins/data/public/ui/filter_bar/filter_item.tsx b/src/plugins/data/public/ui/filter_bar/filter_item.tsx
index 0febfe807a946d..6b5fd41dc06eab 100644
--- a/src/plugins/data/public/ui/filter_bar/filter_item.tsx
+++ b/src/plugins/data/public/ui/filter_bar/filter_item.tsx
@@ -33,6 +33,7 @@ import {
toggleFilterPinned,
toggleFilterDisabled,
} from '../../../common';
+import { getNotifications } from '../../services';
interface Props {
id: string;
@@ -64,24 +65,41 @@ class FilterItemUI extends Component {
public render() {
const { filter, id } = this.props;
const { negate, disabled } = filter.meta;
+ let hasError: boolean = false;
+
+ let valueLabel;
+ try {
+ valueLabel = getDisplayValueFromFilter(filter, this.props.indexPatterns);
+ } catch (e) {
+ getNotifications().toasts.addError(e, {
+ title: this.props.intl.formatMessage({
+ id: 'data.filter.filterBar.labelErrorMessage',
+ defaultMessage: 'Failed to display filter',
+ }),
+ });
+ valueLabel = this.props.intl.formatMessage({
+ id: 'data.filter.filterBar.labelErrorText',
+ defaultMessage: 'Error',
+ });
+ hasError = true;
+ }
+ const dataTestSubjKey = filter.meta.key ? `filter-key-${filter.meta.key}` : '';
+ const dataTestSubjValue = filter.meta.value ? `filter-value-${valueLabel}` : '';
+ const dataTestSubjDisabled = `filter-${
+ this.props.filter.meta.disabled ? 'disabled' : 'enabled'
+ }`;
const classes = classNames(
'globalFilterItem',
{
- 'globalFilterItem-isDisabled': disabled,
+ 'globalFilterItem-isDisabled': disabled || hasError,
+ 'globalFilterItem-isInvalid': hasError,
'globalFilterItem-isPinned': isFilterPinned(filter),
'globalFilterItem-isExcluded': negate,
},
this.props.className
);
- const valueLabel = getDisplayValueFromFilter(filter, this.props.indexPatterns);
- const dataTestSubjKey = filter.meta.key ? `filter-key-${filter.meta.key}` : '';
- const dataTestSubjValue = filter.meta.value ? `filter-value-${valueLabel}` : '';
- const dataTestSubjDisabled = `filter-${
- this.props.filter.meta.disabled ? 'disabled' : 'enabled'
- }`;
-
const badge = (
;
-
const mockDefaultSearch = jest.fn(() => Promise.resolve({ total: 100, loaded: 0 }));
const mockDefaultSearchStrategyProvider = jest.fn(() =>
Promise.resolve({
@@ -59,4 +57,15 @@ describe('createApi', () => {
`"No strategy found for noneByThisName"`
);
});
+
+ it('logs the response if `debug` is set to `true`', async () => {
+ const spy = jest.spyOn(console, 'log');
+ await api.search({ params: {} });
+
+ expect(spy).not.toBeCalled();
+
+ await api.search({ debug: true, params: {} });
+
+ expect(spy).toBeCalled();
+ });
});
diff --git a/src/plugins/data/server/search/create_api.ts b/src/plugins/data/server/search/create_api.ts
index 798a4b82caaefa..00665b21f2ba75 100644
--- a/src/plugins/data/server/search/create_api.ts
+++ b/src/plugins/data/server/search/create_api.ts
@@ -31,6 +31,10 @@ export function createApi({
}) {
const api: IRouteHandlerSearchContext = {
search: async (request, options, strategyName) => {
+ if (request.debug) {
+ // eslint-disable-next-line
+ console.log(JSON.stringify(request, null, 2));
+ }
const name = strategyName ?? DEFAULT_SEARCH_STRATEGY;
const strategyProvider = searchStrategies[name];
if (!strategyProvider) {
diff --git a/src/plugins/data/server/search/es_search/es_search_strategy.test.ts b/src/plugins/data/server/search/es_search/es_search_strategy.test.ts
index 99ccb4dcbebabf..c4b8119f9e0958 100644
--- a/src/plugins/data/server/search/es_search/es_search_strategy.test.ts
+++ b/src/plugins/data/server/search/es_search/es_search_strategy.test.ts
@@ -51,24 +51,6 @@ describe('ES search strategy', () => {
expect(typeof esSearch.search).toBe('function');
});
- it('logs the response if `debug` is set to `true`', async () => {
- const spy = jest.spyOn(console, 'log');
- const esSearch = esSearchStrategyProvider(
- {
- core: mockCoreSetup,
- config$: mockConfig$,
- },
- mockApiCaller,
- mockSearch
- );
-
- expect(spy).not.toBeCalled();
-
- await esSearch.search({ params: {}, debug: true });
-
- expect(spy).toBeCalled();
- });
-
it('calls the API caller with the params with defaults', async () => {
const params = { index: 'logstash-*' };
const esSearch = esSearchStrategyProvider(
diff --git a/src/plugins/data/server/search/es_search/es_search_strategy.ts b/src/plugins/data/server/search/es_search/es_search_strategy.ts
index 20bc964effc02c..26055a3ae41f71 100644
--- a/src/plugins/data/server/search/es_search/es_search_strategy.ts
+++ b/src/plugins/data/server/search/es_search/es_search_strategy.ts
@@ -21,7 +21,7 @@ import { APICaller } from 'kibana/server';
import { SearchResponse } from 'elasticsearch';
import { ES_SEARCH_STRATEGY } from '../../../common/search';
import { ISearchStrategy, TSearchStrategyProvider } from '../i_search_strategy';
-import { ISearchContext } from '..';
+import { getDefaultSearchParams, ISearchContext } from '..';
export const esSearchStrategyProvider: TSearchStrategyProvider = (
context: ISearchContext,
@@ -30,28 +30,18 @@ export const esSearchStrategyProvider: TSearchStrategyProvider {
const config = await context.config$.pipe(first()).toPromise();
+ const defaultParams = getDefaultSearchParams(config);
const params = {
- timeout: `${config.elasticsearch.shardTimeout.asMilliseconds()}ms`,
- ignoreUnavailable: true, // Don't fail if the index/indices don't exist
- restTotalHitsAsInt: true, // Get the number of hits as an int rather than a range
+ ...defaultParams,
...request.params,
};
- if (request.debug) {
- // eslint-disable-next-line
- console.log(JSON.stringify(params, null, 2));
- }
- const esSearchResponse = (await caller('search', params, options)) as SearchResponse;
+ const rawResponse = (await caller('search', params, options)) as SearchResponse;
// The above query will either complete or timeout and throw an error.
// There is no progress indication on this api.
- return {
- total: esSearchResponse._shards.total,
- loaded:
- esSearchResponse._shards.failed +
- esSearchResponse._shards.skipped +
- esSearchResponse._shards.successful,
- rawResponse: esSearchResponse,
- };
+ const { total, failed, successful } = rawResponse._shards;
+ const loaded = failed + successful;
+ return { total, loaded, rawResponse };
},
};
};
diff --git a/src/plugins/data/server/search/es_search/get_default_search_params.ts b/src/plugins/data/server/search/es_search/get_default_search_params.ts
new file mode 100644
index 00000000000000..b2341ccc0f3c86
--- /dev/null
+++ b/src/plugins/data/server/search/es_search/get_default_search_params.ts
@@ -0,0 +1,28 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { SharedGlobalConfig } from '../../../../../core/server';
+
+export function getDefaultSearchParams(config: SharedGlobalConfig) {
+ return {
+ timeout: `${config.elasticsearch.shardTimeout.asMilliseconds()}ms`,
+ ignoreUnavailable: true, // Don't fail if the index/indices don't exist
+ restTotalHitsAsInt: true, // Get the number of hits as an int rather than a range
+ };
+}
diff --git a/src/plugins/data/server/search/es_search/index.ts b/src/plugins/data/server/search/es_search/index.ts
index e5dcb0c97d7c9c..5a8b3bc94c679c 100644
--- a/src/plugins/data/server/search/es_search/index.ts
+++ b/src/plugins/data/server/search/es_search/index.ts
@@ -17,6 +17,6 @@
* under the License.
*/
-export { esSearchStrategyProvider } from './es_search_strategy';
-
export { ES_SEARCH_STRATEGY, IEsSearchRequest, IEsSearchResponse } from '../../../common/search';
+export { esSearchStrategyProvider } from './es_search_strategy';
+export { getDefaultSearchParams } from './get_default_search_params';
diff --git a/src/plugins/data/server/search/index.ts b/src/plugins/data/server/search/index.ts
index 298a665fd5b2c9..385e96ee803b64 100644
--- a/src/plugins/data/server/search/index.ts
+++ b/src/plugins/data/server/search/index.ts
@@ -21,8 +21,10 @@ export { ISearchSetup } from './i_search_setup';
export { ISearchContext } from './i_search_context';
-export { IRequestTypesMap, IResponseTypesMap } from './i_search';
+export { ISearch, ICancel, ISearchOptions, IRequestTypesMap, IResponseTypesMap } from './i_search';
export { TStrategyTypes } from './strategy_types';
export { TSearchStrategyProvider } from './i_search_strategy';
+
+export { getDefaultSearchParams } from './es_search';
diff --git a/src/plugins/embeddable/public/index.ts b/src/plugins/embeddable/public/index.ts
index 34abd57eeacdda..0b5fd8184deb10 100644
--- a/src/plugins/embeddable/public/index.ts
+++ b/src/plugins/embeddable/public/index.ts
@@ -39,6 +39,7 @@ export {
Embeddable,
EmbeddableChildPanel,
EmbeddableChildPanelProps,
+ EmbeddableContext,
EmbeddableFactory,
EmbeddableFactoryNotFoundError,
EmbeddableFactoryRenderer,
diff --git a/src/plugins/timelion/server/config.ts b/src/plugins/timelion/config.ts
similarity index 87%
rename from src/plugins/timelion/server/config.ts
rename to src/plugins/timelion/config.ts
index e76c878c0c6b1f..561fb4de9f58db 100644
--- a/src/plugins/timelion/server/config.ts
+++ b/src/plugins/timelion/config.ts
@@ -17,9 +17,9 @@
* under the License.
*/
-import { schema } from '@kbn/config-schema';
+import { schema, TypeOf } from '@kbn/config-schema';
-export const ConfigSchema = schema.object(
+export const configSchema = schema.object(
{
ui: schema.object({ enabled: schema.boolean({ defaultValue: false }) }),
graphiteUrls: schema.maybe(schema.arrayOf(schema.string())),
@@ -27,3 +27,5 @@ export const ConfigSchema = schema.object(
// This option should be removed as soon as we entirely migrate config from legacy Timelion plugin.
{ allowUnknowns: true }
);
+
+export type ConfigSchema = TypeOf;
diff --git a/src/plugins/timelion/kibana.json b/src/plugins/timelion/kibana.json
index fe6e425f76c05d..dddfd6c67e6550 100644
--- a/src/plugins/timelion/kibana.json
+++ b/src/plugins/timelion/kibana.json
@@ -4,5 +4,5 @@
"kibanaVersion": "kibana",
"configPath": ["timelion"],
"server": true,
- "ui": false
+ "ui": true
}
diff --git a/src/plugins/timelion/public/index.ts b/src/plugins/timelion/public/index.ts
new file mode 100644
index 00000000000000..b05c4f8a30b226
--- /dev/null
+++ b/src/plugins/timelion/public/index.ts
@@ -0,0 +1,30 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { CoreStart, PluginInitializerContext } from 'kibana/public';
+import { ConfigSchema } from '../config';
+
+export const plugin = (initializerContext: PluginInitializerContext) => ({
+ setup() {},
+ start(core: CoreStart) {
+ if (initializerContext.config.get().ui.enabled === false) {
+ core.chrome.navLinks.update('timelion', { hidden: true });
+ }
+ },
+});
diff --git a/src/plugins/timelion/server/index.ts b/src/plugins/timelion/server/index.ts
index 690544f0b9f5ca..5d420327f961e2 100644
--- a/src/plugins/timelion/server/index.ts
+++ b/src/plugins/timelion/server/index.ts
@@ -18,11 +18,18 @@
*/
import { PluginInitializerContext } from '../../../../src/core/server';
-import { ConfigSchema } from './config';
+import { configSchema } from '../config';
import { Plugin } from './plugin';
export { PluginSetupContract } from './plugin';
-export const config = { schema: ConfigSchema };
+export const config = {
+ schema: configSchema,
+ exposeToBrowser: {
+ ui: {
+ enabled: true,
+ },
+ },
+};
export const plugin = (initializerContext: PluginInitializerContext) =>
new Plugin(initializerContext);
diff --git a/src/plugins/timelion/server/lib/config_manager.ts b/src/plugins/timelion/server/lib/config_manager.ts
index 60d89f34a4c085..17471ca34f5ba8 100644
--- a/src/plugins/timelion/server/lib/config_manager.ts
+++ b/src/plugins/timelion/server/lib/config_manager.ts
@@ -19,14 +19,14 @@
import { PluginInitializerContext } from 'kibana/server';
import { TypeOf } from '@kbn/config-schema';
-import { ConfigSchema } from '../config';
+import { configSchema } from '../../config';
export class ConfigManager {
private esShardTimeout: number = 0;
private graphiteUrls: string[] = [];
constructor(config: PluginInitializerContext['config']) {
- config.create>().subscribe(configUpdate => {
+ config.create>().subscribe(configUpdate => {
this.graphiteUrls = configUpdate.graphiteUrls || [];
});
diff --git a/src/plugins/timelion/server/plugin.ts b/src/plugins/timelion/server/plugin.ts
index 4330bc0ffb357c..40e89008e75620 100644
--- a/src/plugins/timelion/server/plugin.ts
+++ b/src/plugins/timelion/server/plugin.ts
@@ -26,7 +26,7 @@ import {
RecursiveReadonly,
} from '../../../../src/core/server';
import { deepFreeze } from '../../../../src/core/utils';
-import { ConfigSchema } from './config';
+import { configSchema } from '../config';
import loadFunctions from './lib/load_functions';
import { functionsRoute } from './routes/functions';
import { validateEsRoute } from './routes/validate_es';
@@ -48,7 +48,7 @@ export class Plugin {
public async setup(core: CoreSetup): Promise> {
const config = await this.initializerContext.config
- .create>()
+ .create>()
.pipe(first())
.toPromise();
diff --git a/test/api_integration/apis/index_patterns/fields_for_time_pattern_route/query_params.js b/test/api_integration/apis/index_patterns/fields_for_time_pattern_route/query_params.js
index 10a5de2c2aa770..2b687a70a64613 100644
--- a/test/api_integration/apis/index_patterns/fields_for_time_pattern_route/query_params.js
+++ b/test/api_integration/apis/index_patterns/fields_for_time_pattern_route/query_params.js
@@ -93,7 +93,7 @@ export default function({ getService }) {
.expect(400)
.then(resp => {
expect(resp.body.message).to.contain(
- '[request query.look_back]: Value is [0] but it must be equal to or greater than [1].'
+ '[request query.look_back]: Value must be equal to or greater than [1].'
);
}));
});
diff --git a/test/functional/apps/discover/_discover.js b/test/functional/apps/discover/_discover.js
index 432e83891aa928..2011b9bc274f58 100644
--- a/test/functional/apps/discover/_discover.js
+++ b/test/functional/apps/discover/_discover.js
@@ -23,7 +23,6 @@ export default function({ getService, getPageObjects }) {
const log = getService('log');
const retry = getService('retry');
const esArchiver = getService('esArchiver');
- const browser = getService('browser');
const kibanaServer = getService('kibanaServer');
const queryBar = getService('queryBar');
const PageObjects = getPageObjects(['common', 'discover', 'header', 'timePicker']);
@@ -188,7 +187,7 @@ export default function({ getService, getPageObjects }) {
describe('time zone switch', () => {
it('should show bars in the correct time zone after switching', async function() {
await kibanaServer.uiSettings.replace({ 'dateFormat:tz': 'America/Phoenix' });
- await browser.refresh();
+ await PageObjects.common.navigateToApp('discover');
await PageObjects.header.awaitKibanaChrome();
await PageObjects.timePicker.setDefaultAbsoluteRange();
diff --git a/test/scripts/jenkins_xpack.sh b/test/scripts/jenkins_xpack.sh
index b629e064b39b5e..5055997df642a0 100755
--- a/test/scripts/jenkins_xpack.sh
+++ b/test/scripts/jenkins_xpack.sh
@@ -11,7 +11,7 @@ if [[ -z "$CODE_COVERAGE" ]] ; then
echo " -> Running jest tests"
cd "$XPACK_DIR"
- checks-reporter-with-killswitch "X-Pack Jest" node scripts/jest --ci --verbose --detectOpenHandles
+ checks-reporter-with-killswitch "X-Pack Jest" node --max-old-space-size=6144 scripts/jest --ci --verbose --detectOpenHandles
echo ""
echo ""
diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json
index f2af61df73d206..53628ea970fb6f 100644
--- a/x-pack/.i18nrc.json
+++ b/x-pack/.i18nrc.json
@@ -39,7 +39,7 @@
"xpack.snapshotRestore": "plugins/snapshot_restore",
"xpack.spaces": ["legacy/plugins/spaces", "plugins/spaces"],
"xpack.taskManager": "legacy/plugins/task_manager",
- "xpack.transform": ["legacy/plugins/transform", "plugins/transform"],
+ "xpack.transform": "plugins/transform",
"xpack.triggersActionsUI": "plugins/triggers_actions_ui",
"xpack.upgradeAssistant": "plugins/upgrade_assistant",
"xpack.uptime": "legacy/plugins/uptime",
diff --git a/x-pack/legacy/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap b/x-pack/legacy/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap
index 7809734dbf2adb..d5764001a7f180 100644
--- a/x-pack/legacy/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap
+++ b/x-pack/legacy/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap
@@ -22,6 +22,7 @@ exports[`Home component should render services 1`] = `
},
"notifications": Object {
"toasts": Object {
+ "addDanger": [Function],
"addWarning": [Function],
},
},
@@ -61,6 +62,7 @@ exports[`Home component should render traces 1`] = `
},
"notifications": Object {
"toasts": Object {
+ "addDanger": [Function],
"addWarning": [Function],
},
},
diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/EmptyBanner.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/EmptyBanner.tsx
new file mode 100644
index 00000000000000..418430e37b21e6
--- /dev/null
+++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/EmptyBanner.tsx
@@ -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 { EuiCallOut } from '@elastic/eui';
+import lightTheme from '@elastic/eui/dist/eui_theme_light.json';
+import { i18n } from '@kbn/i18n';
+import React from 'react';
+import styled from 'styled-components';
+import { ElasticDocsLink } from '../../shared/Links/ElasticDocsLink';
+
+const EmptyBannerCallOut = styled(EuiCallOut)`
+ margin: ${lightTheme.gutterTypes.gutterSmall};
+ /* Add some extra margin so it displays to the right of the controls. */
+ margin-left: calc(
+ ${lightTheme.gutterTypes.gutterLarge} +
+ ${lightTheme.gutterTypes.gutterExtraLarge}
+ );
+ position: absolute;
+ z-index: 1;
+`;
+
+export function EmptyBanner() {
+ return (
+
+ {i18n.translate('xpack.apm.serviceMap.emptyBanner.message', {
+ defaultMessage:
+ "We will map out connected services and external requests if we can detect them. Please make sure you're running the latest version of the APM agent."
+ })}{' '}
+
+ {i18n.translate('xpack.apm.serviceMap.emptyBanner.docsLink', {
+ defaultMessage: 'Learn more in the docs'
+ })}
+
+
+ );
+}
diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.test.tsx
new file mode 100644
index 00000000000000..926f53954e7c6b
--- /dev/null
+++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.test.tsx
@@ -0,0 +1,45 @@
+/*
+ * 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 { render } from '@testing-library/react';
+import React, { FunctionComponent } from 'react';
+import { License } from '../../../../../../../plugins/licensing/common/license';
+import { LicenseContext } from '../../../context/LicenseContext';
+import { MockApmPluginContextWrapper } from '../../../utils/testHelpers';
+import { ServiceMap } from './';
+
+const expiredLicense = new License({
+ signature: 'test signature',
+ license: {
+ expiryDateInMillis: 0,
+ mode: 'platinum',
+ status: 'expired',
+ type: 'platinum',
+ uid: '1'
+ }
+});
+
+const Wrapper: FunctionComponent = ({ children }) => {
+ return (
+
+ {children}
+
+ );
+};
+
+describe('ServiceMap', () => {
+ describe('with an inactive license', () => {
+ it('renders the license banner', async () => {
+ expect(
+ (
+ await render( , {
+ wrapper: Wrapper
+ }).findAllByText(/Platinum/)
+ ).length
+ ).toBeGreaterThan(0);
+ });
+ });
+});
diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx
index d5f0728a7ff128..2942ce64729e7d 100644
--- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx
+++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx
@@ -21,14 +21,15 @@ import { isValidPlatinumLicense } from '../../../../../../../plugins/apm/common/
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { ServiceMapAPIResponse } from '../../../../../../../plugins/apm/server/lib/service_map/get_service_map';
import { useApmPluginContext } from '../../../hooks/useApmPluginContext';
-import { useCallApmApi } from '../../../hooks/useCallApmApi';
import { useDeepObjectIdentity } from '../../../hooks/useDeepObjectIdentity';
import { useLicense } from '../../../hooks/useLicense';
import { useLoadingIndicator } from '../../../hooks/useLoadingIndicator';
import { useLocation } from '../../../hooks/useLocation';
import { useUrlParams } from '../../../hooks/useUrlParams';
+import { callApmApi } from '../../../services/rest/createCallApmApi';
import { Controls } from './Controls';
import { Cytoscape } from './Cytoscape';
+import { EmptyBanner } from './EmptyBanner';
import { getCytoscapeElements } from './get_cytoscape_elements';
import { PlatinumLicensePrompt } from './PlatinumLicensePrompt';
import { Popover } from './Popover';
@@ -61,7 +62,6 @@ ${theme.euiColorLightShade}`,
const MAX_REQUESTS = 5;
export function ServiceMap({ serviceName }: ServiceMapProps) {
- const callApmApi = useCallApmApi();
const license = useLicense();
const { search } = useLocation();
const { urlParams, uiFilters } = useUrlParams();
@@ -137,7 +137,7 @@ export function ServiceMap({ serviceName }: ServiceMapProps) {
}
}
},
- [params, setIsLoading, callApmApi, responses.length, notifications.toasts]
+ [params, setIsLoading, responses.length, notifications.toasts]
);
useEffect(() => {
@@ -215,6 +215,9 @@ export function ServiceMap({ serviceName }: ServiceMapProps) {
style={cytoscapeDivStyle}
>
+ {serviceName && renderedElements.current.length === 1 && (
+
+ )}
diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/DeleteButton.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/DeleteButton.tsx
index 1564f1ae746a99..997df371b51ed2 100644
--- a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/DeleteButton.tsx
+++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/DeleteButton.tsx
@@ -8,10 +8,9 @@ import React, { useState } from 'react';
import { EuiButtonEmpty } from '@elastic/eui';
import { NotificationsStart } from 'kibana/public';
import { i18n } from '@kbn/i18n';
-import { useCallApmApi } from '../../../../../hooks/useCallApmApi';
import { Config } from '../index';
import { getOptionLabel } from '../../../../../../../../../plugins/apm/common/agent_configuration_constants';
-import { APMClient } from '../../../../../services/rest/createCallApmApi';
+import { callApmApi } from '../../../../../services/rest/createCallApmApi';
import { useApmPluginContext } from '../../../../../hooks/useApmPluginContext';
interface Props {
@@ -22,7 +21,6 @@ interface Props {
export function DeleteButton({ onDeleted, selectedConfig }: Props) {
const [isDeleting, setIsDeleting] = useState(false);
const { toasts } = useApmPluginContext().core.notifications;
- const callApmApi = useCallApmApi();
return (
{
setIsDeleting(true);
- await deleteConfig(callApmApi, selectedConfig, toasts);
+ await deleteConfig(selectedConfig, toasts);
setIsDeleting(false);
onDeleted();
}}
@@ -45,7 +43,6 @@ export function DeleteButton({ onDeleted, selectedConfig }: Props) {
}
async function deleteConfig(
- callApmApi: APMClient,
selectedConfig: Config,
toasts: NotificationsStart['toasts']
) {
diff --git a/x-pack/legacy/plugins/apm/public/components/shared/ServiceForm/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/ServiceSection.tsx
similarity index 82%
rename from x-pack/legacy/plugins/apm/public/components/shared/ServiceForm/index.tsx
rename to x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/ServiceSection.tsx
index ab3accec90d1db..537bdace50e249 100644
--- a/x-pack/legacy/plugins/apm/public/components/shared/ServiceForm/index.tsx
+++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/ServiceSection.tsx
@@ -10,12 +10,12 @@ import { i18n } from '@kbn/i18n';
import {
omitAllOption,
getOptionLabel
-} from '../../../../../../../plugins/apm/common/agent_configuration_constants';
-import { useFetcher } from '../../../hooks/useFetcher';
-import { SelectWithPlaceholder } from '../SelectWithPlaceholder';
+} from '../../../../../../../../../plugins/apm/common/agent_configuration_constants';
+import { useFetcher } from '../../../../../hooks/useFetcher';
+import { SelectWithPlaceholder } from '../../../../shared/SelectWithPlaceholder';
const SELECT_PLACEHOLDER_LABEL = `- ${i18n.translate(
- 'xpack.apm.settings.agentConf.flyOut.serviceForm.selectPlaceholder',
+ 'xpack.apm.settings.agentConf.flyOut.serviceSection.selectPlaceholder',
{ defaultMessage: 'Select' }
)} -`;
@@ -27,7 +27,7 @@ interface Props {
onEnvironmentChange: (env: string) => void;
}
-export function ServiceForm({
+export function ServiceSection({
isReadOnly,
serviceName,
onServiceNameChange,
@@ -60,7 +60,7 @@ export function ServiceForm({
);
const ALREADY_CONFIGURED_TRANSLATED = i18n.translate(
- 'xpack.apm.settings.agentConf.flyOut.serviceForm.alreadyConfiguredOption',
+ 'xpack.apm.settings.agentConf.flyOut.serviceSection.alreadyConfiguredOption',
{ defaultMessage: 'already configured' }
);
@@ -83,7 +83,7 @@ export function ServiceForm({
{i18n.translate(
- 'xpack.apm.settings.agentConf.flyOut.serviceForm.title',
+ 'xpack.apm.settings.agentConf.flyOut.serviceSection.title',
{ defaultMessage: 'Service' }
)}
@@ -93,13 +93,13 @@ export function ServiceForm({
- ;
}) {
await callApmApi({
@@ -94,11 +91,11 @@ export function ApmIndices() {
const [apmIndices, setApmIndices] = useState>({});
const [isSaving, setIsSaving] = useState(false);
- const callApmApiFromHook = useCallApmApi();
-
const { data = INITIAL_STATE, status, refetch } = useFetcher(
- callApmApi =>
- callApmApi({ pathname: `/api/apm/settings/apm-index-settings` }),
+ _callApmApi =>
+ _callApmApi({
+ pathname: `/api/apm/settings/apm-index-settings`
+ }),
[]
);
@@ -122,10 +119,7 @@ export function ApmIndices() {
event.preventDefault();
setIsSaving(true);
try {
- await saveApmIndices({
- callApmApi: callApmApiFromHook,
- apmIndices
- });
+ await saveApmIndices({ apmIndices });
toasts.addSuccess({
title: i18n.translate(
'xpack.apm.settings.apmIndices.applyChanges.succeeded.title',
diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/CustomActionsFlyout/SettingsSection.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/CustomActionsFlyout/SettingsSection.tsx
deleted file mode 100644
index 8cb604d3675497..00000000000000
--- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/CustomActionsFlyout/SettingsSection.tsx
+++ /dev/null
@@ -1,81 +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 { EuiFieldText, EuiFormRow, EuiSpacer, EuiTitle } from '@elastic/eui';
-import { i18n } from '@kbn/i18n';
-import React from 'react';
-
-interface Props {
- label: string;
- onLabelChange: (label: string) => void;
- url: string;
- onURLChange: (url: string) => void;
-}
-
-export const SettingsSection = ({
- label,
- onLabelChange,
- url,
- onURLChange
-}: Props) => {
- return (
- <>
-
-
- {i18n.translate(
- 'xpack.apm.settings.customizeUI.customActions.flyout.settingsSection.title',
- { defaultMessage: 'Action' }
- )}
-
-
-
-
- {
- onLabelChange(e.target.value);
- }}
- />
-
-
- {
- onURLChange(e.target.value);
- }}
- />
-
- >
- );
-};
diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/CustomActionsFlyout/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/CustomActionsFlyout/index.tsx
deleted file mode 100644
index d04cdd62c303ba..00000000000000
--- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/CustomActionsFlyout/index.tsx
+++ /dev/null
@@ -1,109 +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 {
- EuiButton,
- EuiButtonEmpty,
- EuiFlexGroup,
- EuiFlexItem,
- EuiFlyout,
- EuiFlyoutBody,
- EuiFlyoutFooter,
- EuiFlyoutHeader,
- EuiPortal,
- EuiSpacer,
- EuiText,
- EuiTitle
-} from '@elastic/eui';
-import { i18n } from '@kbn/i18n';
-import React, { useState } from 'react';
-import { SettingsSection } from './SettingsSection';
-import { ServiceForm } from '../../../../../shared/ServiceForm';
-
-interface Props {
- onClose: () => void;
-}
-
-export const CustomActionsFlyout = ({ onClose }: Props) => {
- const [serviceName, setServiceName] = useState('');
- const [environment, setEnvironment] = useState('');
- const [label, setLabel] = useState('');
- const [url, setURL] = useState('');
- return (
-
-
-
-
-
- {i18n.translate(
- 'xpack.apm.settings.customizeUI.customActions.flyout.title',
- {
- defaultMessage: 'Create custom action'
- }
- )}
-
-
-
-
-
-
- {i18n.translate(
- 'xpack.apm.settings.customizeUI.customActions.flyout.label',
- {
- defaultMessage:
- "This action will be shown in the 'Actions' context menu for the trace and error detail components. You can specify any number of links, but only the first three will be shown, in alphabetical order."
- }
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {i18n.translate(
- 'xpack.apm.settings.customizeUI.customActions.flyout.close',
- {
- defaultMessage: 'Close'
- }
- )}
-
-
-
-
- {i18n.translate(
- 'xpack.apm.settings.customizeUI.customActions.flyout.save',
- {
- defaultMessage: 'Save'
- }
- )}
-
-
-
-
-
-
- );
-};
diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/EmptyPrompt.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/EmptyPrompt.tsx
deleted file mode 100644
index f39e4b307b24cc..00000000000000
--- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/EmptyPrompt.tsx
+++ /dev/null
@@ -1,52 +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 { EuiButton, EuiEmptyPrompt } from '@elastic/eui';
-import { i18n } from '@kbn/i18n';
-import React from 'react';
-
-export const EmptyPrompt = ({
- onCreateCustomActionClick
-}: {
- onCreateCustomActionClick: () => void;
-}) => {
- return (
-
- {i18n.translate(
- 'xpack.apm.settings.customizeUI.customActions.emptyPromptTitle',
- {
- defaultMessage: 'No actions found.'
- }
- )}
-
- }
- body={
- <>
-
- {i18n.translate(
- 'xpack.apm.settings.customizeUI.customActions.emptyPromptText',
- {
- defaultMessage:
- "Let's change that! You can add custom actions to the Actions context menu by the trace and error details for each service. This could be linking to a Kibana dashboard or going to your organization's support portal"
- }
- )}
-
- >
- }
- actions={
-
- {i18n.translate(
- 'xpack.apm.settings.customizeUI.customActions.createCustomAction',
- { defaultMessage: 'Create custom action' }
- )}
-
- }
- />
- );
-};
diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/__test__/CustomActions.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/__test__/CustomActions.test.tsx
deleted file mode 100644
index 970de66c64a9aa..00000000000000
--- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/__test__/CustomActions.test.tsx
+++ /dev/null
@@ -1,33 +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 React from 'react';
-import { fireEvent, render } from '@testing-library/react';
-import { CustomActionsOverview } from '../';
-import { expectTextsInDocument } from '../../../../../../utils/testHelpers';
-import * as hooks from '../../../../../../hooks/useFetcher';
-
-describe('CustomActions', () => {
- afterEach(() => jest.restoreAllMocks());
-
- describe('empty prompt', () => {
- it('shows when any actions are available', () => {
- // TODO: mock return items
- const component = render( );
- expectTextsInDocument(component, ['No actions found.']);
- });
- it('opens flyout when click to create new action', () => {
- spyOn(hooks, 'useFetcher').and.returnValue({
- data: [],
- status: 'success'
- });
- const { queryByText, getByText } = render( );
- expect(queryByText('Service')).not.toBeInTheDocument();
- fireEvent.click(getByText('Create custom action'));
- expect(queryByText('Service')).toBeInTheDocument();
- });
- });
-});
diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/index.tsx
deleted file mode 100644
index ae2972f251fc28..00000000000000
--- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/index.tsx
+++ /dev/null
@@ -1,75 +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 { EuiPanel, EuiSpacer } from '@elastic/eui';
-import { isEmpty } from 'lodash';
-import React, { useState } from 'react';
-import { ManagedTable } from '../../../../shared/ManagedTable';
-import { Title } from './Title';
-import { EmptyPrompt } from './EmptyPrompt';
-import { CustomActionsFlyout } from './CustomActionsFlyout';
-
-export const CustomActionsOverview = () => {
- const [isFlyoutOpen, setIsFlyoutOpen] = useState(false);
-
- // TODO: change it to correct fields fetched from ES
- const columns = [
- {
- field: 'actionName',
- name: 'Action Name',
- truncateText: true
- },
- {
- field: 'serviceName',
- name: 'Service Name'
- },
- {
- field: 'environment',
- name: 'Environment'
- },
- {
- field: 'lastUpdate',
- name: 'Last update'
- },
- {
- field: 'actions',
- name: 'Actions'
- }
- ];
-
- // TODO: change to items fetched from ES.
- const items: object[] = [];
-
- const onCloseFlyout = () => {
- setIsFlyoutOpen(false);
- };
-
- const onCreateCustomActionClick = () => {
- setIsFlyoutOpen(true);
- };
-
- return (
- <>
-
-
-
- {isFlyoutOpen && }
- {isEmpty(items) ? (
-
- ) : (
-
- )}
-
- >
- );
-};
diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateCustomLinkButton.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateCustomLinkButton.tsx
new file mode 100644
index 00000000000000..415d2557c23c33
--- /dev/null
+++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateCustomLinkButton.tsx
@@ -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.
+ */
+import React from 'react';
+import { EuiButton } from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+
+export const CreateCustomLinkButton = ({
+ onClick
+}: {
+ onClick: () => void;
+}) => (
+
+ {i18n.translate(
+ 'xpack.apm.settings.customizeUI.customLink.createCustomLink',
+ { defaultMessage: 'Create custom link' }
+ )}
+
+);
diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/DeleteButton.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/DeleteButton.tsx
new file mode 100644
index 00000000000000..2b3a5cbe87992c
--- /dev/null
+++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/DeleteButton.tsx
@@ -0,0 +1,70 @@
+/*
+ * 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 { EuiButtonEmpty } from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+import { NotificationsStart } from 'kibana/public';
+import React, { useState } from 'react';
+import { callApmApi } from '../../../../../../services/rest/createCallApmApi';
+import { useApmPluginContext } from '../../../../../../hooks/useApmPluginContext';
+
+interface Props {
+ onDelete: () => void;
+ customLinkId: string;
+}
+
+export function DeleteButton({ onDelete, customLinkId }: Props) {
+ const [isDeleting, setIsDeleting] = useState(false);
+ const { toasts } = useApmPluginContext().core.notifications;
+
+ return (
+ {
+ setIsDeleting(true);
+ await deleteConfig(customLinkId, toasts);
+ setIsDeleting(false);
+ onDelete();
+ }}
+ >
+ {i18n.translate('xpack.apm.settings.customizeUI.customLink.delete', {
+ defaultMessage: 'Delete'
+ })}
+
+ );
+}
+
+async function deleteConfig(
+ customLinkId: string,
+ toasts: NotificationsStart['toasts']
+) {
+ try {
+ await callApmApi({
+ pathname: '/api/apm/settings/custom_links/{id}',
+ method: 'DELETE',
+ params: {
+ path: { id: customLinkId }
+ }
+ });
+ toasts.addSuccess({
+ iconType: 'trash',
+ title: i18n.translate(
+ 'xpack.apm.settings.customizeUI.customLink.delete.successed',
+ { defaultMessage: 'Deleted custom link.' }
+ )
+ });
+ } catch (error) {
+ toasts.addDanger({
+ iconType: 'cross',
+ title: i18n.translate(
+ 'xpack.apm.settings.customizeUI.customLink.delete.failed',
+ { defaultMessage: 'Custom link could not be deleted' }
+ )
+ });
+ }
+}
diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FiltersSection.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FiltersSection.tsx
new file mode 100644
index 00000000000000..69fecf25f51437
--- /dev/null
+++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FiltersSection.tsx
@@ -0,0 +1,167 @@
+/*
+ * 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 {
+ EuiButtonEmpty,
+ EuiFieldText,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiSelect,
+ EuiSpacer,
+ EuiText,
+ EuiTitle
+} from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+import { isEmpty } from 'lodash';
+import React from 'react';
+// eslint-disable-next-line @kbn/eslint/no-restricted-paths
+import { FilterOptions } from '../../../../../../../../../../plugins/apm/server/routes/settings/custom_link';
+import {
+ DEFAULT_OPTION,
+ Filters,
+ filterSelectOptions,
+ getSelectOptions
+} from './helper';
+
+export const FiltersSection = ({
+ filters,
+ onChangeFilters
+}: {
+ filters: Filters;
+ onChangeFilters: (filters: Filters) => void;
+}) => {
+ const onChangeFilter = (filter: Filters[0], idx: number) => {
+ const newFilters = [...filters];
+ newFilters[idx] = filter;
+ onChangeFilters(newFilters);
+ };
+
+ const onRemoveFilter = (idx: number) => {
+ // remove without mutating original array
+ const newFilters = [...filters].splice(idx, 1);
+
+ // if there is only one item left it should not be removed
+ // but reset to empty
+ if (isEmpty(newFilters)) {
+ onChangeFilters([['', '']]);
+ } else {
+ onChangeFilters(newFilters);
+ }
+ };
+
+ const handleAddFilter = () => {
+ onChangeFilters([...filters, ['', '']]);
+ };
+
+ return (
+ <>
+
+
+ {i18n.translate(
+ 'xpack.apm.settings.customizeUI.customLink.flyout.filters.title',
+ {
+ defaultMessage: 'Filters'
+ }
+ )}
+
+
+
+
+ {i18n.translate(
+ 'xpack.apm.settings.customizeUI.customLink.flyout.filters.subtitle',
+ {
+ defaultMessage:
+ 'Add additional values within the same field by comma separating values.'
+ }
+ )}
+
+
+
+
+ {filters.map((filter, idx) => {
+ const [key, value] = filter;
+ const filterId = `filter-${idx}`;
+ const selectOptions = getSelectOptions(filters, idx);
+ return (
+
+
+
+ onChangeFilter(
+ [e.target.value as keyof FilterOptions, value],
+ idx
+ )
+ }
+ isInvalid={
+ !isEmpty(value) &&
+ (isEmpty(key) || key === DEFAULT_OPTION.value)
+ }
+ />
+
+
+ onChangeFilter([key, e.target.value], idx)}
+ value={value}
+ isInvalid={!isEmpty(key) && isEmpty(value)}
+ />
+
+
+ onRemoveFilter(idx)}
+ disabled={!key && filters.length === 1}
+ />
+
+
+ );
+ })}
+
+
+
+
+ >
+ );
+};
+
+const AddFilterButton = ({
+ onClick,
+ isDisabled
+}: {
+ onClick: () => void;
+ isDisabled: boolean;
+}) => (
+
+ {i18n.translate(
+ 'xpack.apm.settings.customizeUI.customLink.flyout.filters.addAnotherFilter',
+ {
+ defaultMessage: 'Add another filter'
+ }
+ )}
+
+);
diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FlyoutFooter.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FlyoutFooter.tsx
new file mode 100644
index 00000000000000..cb272213098127
--- /dev/null
+++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FlyoutFooter.tsx
@@ -0,0 +1,70 @@
+/*
+ * 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 {
+ EuiFlyoutFooter,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiButtonEmpty,
+ EuiButton
+} from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+import { DeleteButton } from './DeleteButton';
+
+export const FlyoutFooter = ({
+ onClose,
+ isSaving,
+ onDelete,
+ customLinkId,
+ isSaveButtonEnabled
+}: {
+ onClose: () => void;
+ isSaving: boolean;
+ onDelete: () => void;
+ customLinkId?: string;
+ isSaveButtonEnabled: boolean;
+}) => {
+ return (
+
+
+
+
+ {i18n.translate(
+ 'xpack.apm.settings.customizeUI.customLink.flyout.close',
+ {
+ defaultMessage: 'Close'
+ }
+ )}
+
+
+
+
+ {customLinkId && (
+
+
+
+ )}
+
+
+ {i18n.translate(
+ 'xpack.apm.settings.customizeUI.customLink.flyout.save',
+ {
+ defaultMessage: 'Save'
+ }
+ )}
+
+
+
+
+
+
+ );
+};
diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkSection.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkSection.tsx
new file mode 100644
index 00000000000000..89f55a6c682ca8
--- /dev/null
+++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkSection.tsx
@@ -0,0 +1,135 @@
+/*
+ * 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 {
+ EuiFieldText,
+ EuiFormRow,
+ EuiSpacer,
+ EuiText,
+ EuiTitle
+} from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+import React from 'react';
+import { CustomLink } from '../../../../../../../../../../plugins/apm/server/lib/settings/custom_link/custom_link_types';
+
+interface InputField {
+ name: keyof CustomLink;
+ label: string;
+ helpText: string;
+ placeholder: string;
+ onChange: (value: string) => void;
+ value?: string;
+}
+
+interface Props {
+ label?: string;
+ onChangeLabel: (label: string) => void;
+ url?: string;
+ onChangeUrl: (url: string) => void;
+}
+
+export const LinkSection = ({
+ label,
+ onChangeLabel,
+ url,
+ onChangeUrl
+}: Props) => {
+ const inputFields: InputField[] = [
+ {
+ name: 'label',
+ label: i18n.translate(
+ 'xpack.apm.settings.customizeUI.customLink.flyout.link.label',
+ {
+ defaultMessage: 'Label'
+ }
+ ),
+ helpText: i18n.translate(
+ 'xpack.apm.settings.customizeUI.customLink.flyout.link.label.helpText',
+ {
+ defaultMessage:
+ 'This is the label shown in the actions context menu. Keep it as short as possible.'
+ }
+ ),
+ placeholder: i18n.translate(
+ 'xpack.apm.settings.customizeUI.customLink.flyout.link.label.placeholder',
+ {
+ defaultMessage: 'e.g. Support tickets'
+ }
+ ),
+ value: label,
+ onChange: onChangeLabel
+ },
+ {
+ name: 'url',
+ label: i18n.translate(
+ 'xpack.apm.settings.customizeUI.customLink.flyout.link.url',
+ {
+ defaultMessage: 'URL'
+ }
+ ),
+ helpText: i18n.translate(
+ 'xpack.apm.settings.customizeUI.customLink.flyout.link.url.helpText',
+ {
+ defaultMessage:
+ 'Add fieldname variables to your URL to apply values e.g. {sample}. TODO: Learn more in the docs.',
+ values: { sample: '{{trace.id}}' }
+ }
+ ),
+ placeholder: i18n.translate(
+ 'xpack.apm.settings.customizeUI.customLink.flyout.link.url.placeholder',
+ {
+ defaultMessage: 'e.g. https://www.elastic.co/'
+ }
+ ),
+ value: url,
+ onChange: onChangeUrl
+ }
+ ];
+
+ return (
+ <>
+
+
+ {i18n.translate(
+ 'xpack.apm.settings.customizeUI.customLink.flyout.action.title',
+ {
+ defaultMessage: 'Link'
+ }
+ )}
+
+
+
+ {inputFields.map(field => {
+ return (
+
+ {i18n.translate(
+ 'xpack.apm.settings.customizeUI.customLink.flyout.required',
+ {
+ defaultMessage: 'Required'
+ }
+ )}
+
+ }
+ >
+ field.onChange(e.target.value)}
+ aria-label={field.name}
+ />
+
+ );
+ })}
+ >
+ );
+};
diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.ts b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.ts
new file mode 100644
index 00000000000000..bb86a251594abf
--- /dev/null
+++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.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 { i18n } from '@kbn/i18n';
+import { isEmpty, pick } from 'lodash';
+import {
+ FilterOptions,
+ filterOptions
+ // eslint-disable-next-line @kbn/eslint/no-restricted-paths
+} from '../../../../../../../../../../plugins/apm/server/routes/settings/custom_link';
+import { CustomLink } from '../../../../../../../../../../plugins/apm/server/lib/settings/custom_link/custom_link_types';
+
+export type Filters = Array<[keyof FilterOptions | '', string]>;
+
+interface FilterSelectOption {
+ value: 'DEFAULT' | keyof FilterOptions;
+ text: string;
+}
+
+/**
+ * Converts available filters from the Custom Link to Array of filters.
+ * e.g.
+ * customLink = {
+ * id: '1',
+ * label: 'foo',
+ * url: 'http://www.elastic.co',
+ * service.name: 'opbeans-java',
+ * transaction.type: 'request'
+ * }
+ *
+ * results: [['service.name', 'opbeans-java'],['transaction.type', 'request']]
+ * @param customLink
+ */
+export const convertFiltersToArray = (customLink?: CustomLink): Filters => {
+ if (customLink) {
+ const filters = Object.entries(pick(customLink, filterOptions)) as Filters;
+ if (!isEmpty(filters)) {
+ return filters;
+ }
+ }
+ return [['', '']];
+};
+
+/**
+ * Converts array of filters into object.
+ * e.g.
+ * filters: [['service.name', 'opbeans-java'],['transaction.type', 'request']]
+ *
+ * results: {
+ * 'service.name': 'opbeans-java',
+ * 'transaction.type': 'request'
+ * }
+ * @param filters
+ */
+export const convertFiltersToObject = (filters: Filters) => {
+ const convertedFilters = Object.fromEntries(
+ filters.filter(([key, value]) => !isEmpty(key) && !isEmpty(value))
+ );
+ if (!isEmpty(convertedFilters)) {
+ return convertedFilters;
+ }
+};
+
+export const DEFAULT_OPTION: FilterSelectOption = {
+ value: 'DEFAULT',
+ text: i18n.translate(
+ 'xpack.apm.settings.customizeUI.customLink.flyOut.filters.defaultOption',
+ { defaultMessage: 'Select field...' }
+ )
+};
+
+export const filterSelectOptions: FilterSelectOption[] = [
+ DEFAULT_OPTION,
+ ...filterOptions.map(filter => ({
+ value: filter as keyof FilterOptions,
+ text: filter
+ }))
+];
+
+/**
+ * Returns the options available, removing filters already added, but keeping the selected filter.
+ *
+ * @param filters
+ * @param idx
+ */
+export const getSelectOptions = (filters: Filters, idx: number) => {
+ return filterSelectOptions.filter(option => {
+ const indexUsedFilter = filters.findIndex(
+ filter => filter[0] === option.value
+ );
+ // Filter out all items already added, besides the one selected in the current filter.
+ return indexUsedFilter === -1 || idx === indexUsedFilter;
+ });
+};
diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/index.tsx
new file mode 100644
index 00000000000000..88358c888160b4
--- /dev/null
+++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/index.tsx
@@ -0,0 +1,121 @@
+/*
+ * 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 {
+ EuiFlyout,
+ EuiFlyoutBody,
+ EuiFlyoutHeader,
+ EuiPortal,
+ EuiSpacer,
+ EuiText,
+ EuiTitle
+} from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+import React, { useState } from 'react';
+import { CustomLink } from '../../../../../../../../../../plugins/apm/server/lib/settings/custom_link/custom_link_types';
+import { useApmPluginContext } from '../../../../../../hooks/useApmPluginContext';
+import { FiltersSection } from './FiltersSection';
+import { FlyoutFooter } from './FlyoutFooter';
+import { LinkSection } from './LinkSection';
+import { saveCustomLink } from './saveCustomLink';
+import { convertFiltersToArray, convertFiltersToObject } from './helper';
+
+interface Props {
+ onClose: () => void;
+ customLinkSelected?: CustomLink;
+ onSave: () => void;
+ onDelete: () => void;
+}
+
+export const CustomLinkFlyout = ({
+ onClose,
+ customLinkSelected,
+ onSave,
+ onDelete
+}: Props) => {
+ const { toasts } = useApmPluginContext().core.notifications;
+ const [isSaving, setIsSaving] = useState(false);
+
+ const [label, setLabel] = useState(customLinkSelected?.label || '');
+ const [url, setUrl] = useState(customLinkSelected?.url || '');
+ const [filters, setFilters] = useState(
+ convertFiltersToArray(customLinkSelected)
+ );
+
+ const isFormValid = !!label && !!url;
+
+ const onSubmit = async (
+ event:
+ | React.FormEvent
+ | React.MouseEvent
+ ) => {
+ event.preventDefault();
+ setIsSaving(true);
+ await saveCustomLink({
+ id: customLinkSelected?.id,
+ label,
+ url,
+ filters: convertFiltersToObject(filters),
+ toasts
+ });
+ setIsSaving(false);
+ onSave();
+ };
+
+ return (
+
+
+
+ );
+};
diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/saveCustomLink.ts b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/saveCustomLink.ts
new file mode 100644
index 00000000000000..f255840e1d7349
--- /dev/null
+++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/saveCustomLink.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 { i18n } from '@kbn/i18n';
+import { NotificationsStart } from 'kibana/public';
+import { callApmApi } from '../../../../../../services/rest/createCallApmApi';
+
+export async function saveCustomLink({
+ id,
+ label,
+ url,
+ filters,
+ toasts
+}: {
+ id?: string;
+ label: string;
+ url: string;
+ filters?: { [key: string]: string };
+ toasts: NotificationsStart['toasts'];
+}) {
+ try {
+ const customLink = {
+ label,
+ url,
+ ...filters
+ };
+ if (id) {
+ await callApmApi({
+ pathname: '/api/apm/settings/custom_links/{id}',
+ method: 'PUT',
+ params: {
+ path: { id },
+ body: customLink
+ }
+ });
+ } else {
+ await callApmApi({
+ pathname: '/api/apm/settings/custom_links',
+ method: 'POST',
+ params: {
+ body: customLink
+ }
+ });
+ }
+ toasts.addSuccess({
+ iconType: 'check',
+ title: i18n.translate(
+ 'xpack.apm.settings.customizeUI.customLink.create.successed',
+ { defaultMessage: 'Link saved!' }
+ )
+ });
+ } catch (error) {
+ toasts.addDanger({
+ title: i18n.translate(
+ 'xpack.apm.settings.customizeUI.customLink.create.failed',
+ { defaultMessage: 'Link could not be saved!' }
+ ),
+ text: i18n.translate(
+ 'xpack.apm.settings.customizeUI.customLink.create.failed.message',
+ {
+ defaultMessage:
+ 'Something went wrong when saving the link. Error: "{errorMessage}"',
+ values: {
+ errorMessage: error.message
+ }
+ }
+ )
+ });
+ }
+}
diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkTable.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkTable.tsx
new file mode 100644
index 00000000000000..f7d8c4baa71e9d
--- /dev/null
+++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkTable.tsx
@@ -0,0 +1,140 @@
+/*
+ * 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 {
+ EuiFieldSearch,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiText,
+ EuiSpacer
+} from '@elastic/eui';
+import { isEmpty } from 'lodash';
+import { units, px } from '../../../../../style/variables';
+import { CustomLink } from '../../../../../../../../../plugins/apm/server/lib/settings/custom_link/custom_link_types';
+import { ManagedTable } from '../../../../shared/ManagedTable';
+import { TimestampTooltip } from '../../../../shared/TimestampTooltip';
+import { LoadingStatePrompt } from '../../../../shared/LoadingStatePrompt';
+
+interface Props {
+ items: CustomLink[];
+ onCustomLinkSelected: (customLink: CustomLink) => void;
+}
+
+export const CustomLinkTable = ({
+ items = [],
+ onCustomLinkSelected
+}: Props) => {
+ const [searchTerm, setSearchTerm] = useState('');
+
+ const columns = [
+ {
+ field: 'label',
+ name: i18n.translate(
+ 'xpack.apm.settings.customizeUI.customLink.table.name',
+ { defaultMessage: 'Name' }
+ ),
+ truncateText: true
+ },
+ {
+ field: 'url',
+ name: i18n.translate(
+ 'xpack.apm.settings.customizeUI.customLink.table.url',
+ { defaultMessage: 'URL' }
+ ),
+ truncateText: true
+ },
+ {
+ width: px(160),
+ align: 'right',
+ field: '@timestamp',
+ name: i18n.translate(
+ 'xpack.apm.settings.customizeUI.customLink.table.lastUpdated',
+ { defaultMessage: 'Last updated' }
+ ),
+ sortable: true,
+ render: (value: number) => (
+
+ )
+ },
+ {
+ width: px(units.triple),
+ name: '',
+ actions: [
+ {
+ name: i18n.translate(
+ 'xpack.apm.settings.customizeUI.customLink.table.editButtonLabel',
+ { defaultMessage: 'Edit' }
+ ),
+ description: i18n.translate(
+ 'xpack.apm.settings.customizeUI.customLink.table.editButtonDescription',
+ { defaultMessage: 'Edit this custom link' }
+ ),
+ icon: 'pencil',
+ color: 'primary',
+ type: 'icon',
+ onClick: (customLink: CustomLink) => {
+ onCustomLinkSelected(customLink);
+ }
+ }
+ ]
+ }
+ ];
+
+ const filteredItems = items.filter(({ label, url }) => {
+ return (
+ label.toLowerCase().includes(searchTerm) ||
+ url.toLowerCase().includes(searchTerm)
+ );
+ });
+
+ return (
+ <>
+
+ setSearchTerm(e.target.value)}
+ placeholder={i18n.translate(
+ 'xpack.apm.settings.customizeUI.customLink.searchInput.filter',
+ {
+ defaultMessage: 'Filter links by Name and URL...'
+ }
+ )}
+ />
+
+
+ ) : (
+
+ )
+ }
+ items={filteredItems}
+ columns={columns}
+ initialPageSize={10}
+ initialSortField="@timestamp"
+ initialSortDirection="desc"
+ />
+ >
+ );
+};
+
+const NoResultFound = ({ value }: { value: string }) => (
+
+
+
+ {i18n.translate(
+ 'xpack.apm.settings.customizeUI.customLink.table.noResultFound',
+ {
+ defaultMessage: `No results for "{value}".`,
+ values: { value }
+ }
+ )}
+
+
+
+);
diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/EmptyPrompt.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/EmptyPrompt.tsx
new file mode 100644
index 00000000000000..e75004918f430d
--- /dev/null
+++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/EmptyPrompt.tsx
@@ -0,0 +1,46 @@
+/*
+ * 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 { EuiEmptyPrompt } from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+import React from 'react';
+import { CreateCustomLinkButton } from './CreateCustomLinkButton';
+
+export const EmptyPrompt = ({
+ onCreateCustomLinkClick
+}: {
+ onCreateCustomLinkClick: () => void;
+}) => {
+ return (
+
+ {i18n.translate(
+ 'xpack.apm.settings.customizeUI.customLink.emptyPromptTitle',
+ {
+ defaultMessage: 'No links found.'
+ }
+ )}
+
+ }
+ body={
+ <>
+
+ {i18n.translate(
+ 'xpack.apm.settings.customizeUI.customLink.emptyPromptText',
+ {
+ defaultMessage:
+ "Let's change that! You can add custom links to the Actions context menu by the transaction details for each service. Create a helpful link to your company's support portal or open a new bug report. Learn more about it in our docs."
+ }
+ )}
+
+ >
+ }
+ actions={ }
+ />
+ );
+};
diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/Title.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/Title.tsx
similarity index 81%
rename from x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/Title.tsx
rename to x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/Title.tsx
index d7f90e0919733f..17ec42b3e20161 100644
--- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomActionsOverview/Title.tsx
+++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/Title.tsx
@@ -14,8 +14,8 @@ export const Title = () => (
- {i18n.translate('xpack.apm.settings.customizeUI.customActions', {
- defaultMessage: 'Custom actions'
+ {i18n.translate('xpack.apm.settings.customizeUI.customLink', {
+ defaultMessage: 'Custom Links'
})}
@@ -25,10 +25,10 @@ export const Title = () => (
type="iInCircle"
position="top"
content={i18n.translate(
- 'xpack.apm.settings.customizeUI.customActions.info',
+ 'xpack.apm.settings.customizeUI.customLink.info',
{
defaultMessage:
- "These actions will be shown in the 'Actions' context menu for the trace and error detail components."
+ "These links will be shown in the 'Actions' context menu for the transaction detail."
}
)}
/>
diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/__test__/CustomLink.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/__test__/CustomLink.test.tsx
new file mode 100644
index 00000000000000..f02cc2be8268d0
--- /dev/null
+++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/__test__/CustomLink.test.tsx
@@ -0,0 +1,251 @@
+/*
+ * 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 { fireEvent, render, wait } from '@testing-library/react';
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { CustomLinkOverview } from '../';
+import * as hooks from '../../../../../../hooks/useFetcher';
+import {
+ expectTextsInDocument,
+ MockApmPluginContextWrapper
+} from '../../../../../../utils/testHelpers';
+import * as saveCustomLink from '../CustomLinkFlyout/saveCustomLink';
+import * as apmApi from '../../../../../../services/rest/createCallApmApi';
+
+const data = [
+ {
+ id: '1',
+ label: 'label 1',
+ url: 'url 1',
+ 'service.name': 'opbeans-java'
+ },
+ {
+ id: '2',
+ label: 'label 2',
+ url: 'url 2',
+ 'transaction.type': 'request'
+ }
+];
+
+describe('CustomLink', () => {
+ describe('empty prompt', () => {
+ beforeAll(() => {
+ spyOn(hooks, 'useFetcher').and.returnValue({
+ data: [],
+ status: 'success'
+ });
+ });
+
+ afterAll(() => {
+ jest.clearAllMocks();
+ });
+ it('shows when no link is available', () => {
+ const component = render( );
+ expectTextsInDocument(component, ['No links found.']);
+ });
+ it('opens flyout when click to create new link', () => {
+ const { queryByText, getByText } = render(
+
+
+
+ );
+ expect(queryByText('Create link')).not.toBeInTheDocument();
+ act(() => {
+ fireEvent.click(getByText('Create custom link'));
+ });
+ expect(queryByText('Create link')).toBeInTheDocument();
+ });
+ });
+
+ describe('overview', () => {
+ beforeAll(() => {
+ spyOn(hooks, 'useFetcher').and.returnValue({
+ data,
+ status: 'success'
+ });
+ });
+
+ afterAll(() => {
+ jest.clearAllMocks();
+ });
+
+ it('shows a table with all custom link', () => {
+ const component = render(
+
+
+
+ );
+ expectTextsInDocument(component, [
+ 'label 1',
+ 'url 1',
+ 'label 2',
+ 'url 2'
+ ]);
+ });
+
+ it('checks if create custom link button is available and working', () => {
+ const { queryByText, getByText } = render(
+
+
+
+ );
+ expect(queryByText('Create link')).not.toBeInTheDocument();
+ act(() => {
+ fireEvent.click(getByText('Create custom link'));
+ });
+ expect(queryByText('Create link')).toBeInTheDocument();
+ });
+ });
+
+ describe('Flyout', () => {
+ const refetch = jest.fn();
+ let callApmApiSpy: Function;
+ let saveCustomLinkSpy: Function;
+ beforeAll(() => {
+ callApmApiSpy = spyOn(apmApi, 'callApmApi');
+ saveCustomLinkSpy = spyOn(saveCustomLink, 'saveCustomLink');
+ spyOn(hooks, 'useFetcher').and.returnValue({
+ data,
+ status: 'success',
+ refetch
+ });
+ });
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ const openFlyout = () => {
+ const component = render(
+
+
+
+ );
+ expect(component.queryByText('Create link')).not.toBeInTheDocument();
+ act(() => {
+ fireEvent.click(component.getByText('Create custom link'));
+ });
+ expect(component.queryByText('Create link')).toBeInTheDocument();
+ return component;
+ };
+
+ it('creates a custom link', async () => {
+ const component = openFlyout();
+ const labelInput = component.getByLabelText('label');
+ act(() => {
+ fireEvent.change(labelInput, {
+ target: { value: 'foo' }
+ });
+ });
+ const urlInput = component.getByLabelText('url');
+ act(() => {
+ fireEvent.change(urlInput, {
+ target: { value: 'bar' }
+ });
+ });
+ await act(async () => {
+ await wait(() => fireEvent.submit(component.getByText('Save')));
+ });
+ expect(saveCustomLinkSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('deletes a custom link', async () => {
+ const component = render(
+
+
+
+ );
+ expect(component.queryByText('Create link')).not.toBeInTheDocument();
+ const editButtons = component.getAllByLabelText('Edit');
+ expect(editButtons.length).toEqual(2);
+ act(() => {
+ fireEvent.click(editButtons[0]);
+ });
+ expect(component.queryByText('Create link')).toBeInTheDocument();
+ await act(async () => {
+ await wait(() => fireEvent.click(component.getByText('Delete')));
+ });
+ expect(callApmApiSpy).toHaveBeenCalled();
+ expect(refetch).toHaveBeenCalled();
+ });
+
+ describe('Filters', () => {
+ const addFilterField = (
+ component: ReturnType,
+ amount: number
+ ) => {
+ for (let i = 1; i <= amount; i++) {
+ fireEvent.click(component.getByText('Add another filter'));
+ }
+ };
+ it('checks if add filter button is disabled after all elements have been added', () => {
+ const component = openFlyout();
+ expect(component.getAllByText('service.name').length).toEqual(1);
+ addFilterField(component, 1);
+ expect(component.getAllByText('service.name').length).toEqual(2);
+ addFilterField(component, 2);
+ expect(component.getAllByText('service.name').length).toEqual(4);
+ // After 4 items, the button is disabled
+ addFilterField(component, 2);
+ expect(component.getAllByText('service.name').length).toEqual(4);
+ });
+ it('removes items already selected', () => {
+ const component = openFlyout();
+
+ const addFieldAndCheck = (
+ fieldName: string,
+ selectValue: string,
+ addNewFilter: boolean,
+ optionsExpected: string[]
+ ) => {
+ if (addNewFilter) {
+ addFilterField(component, 1);
+ }
+ const field = component.getByLabelText(
+ fieldName
+ ) as HTMLSelectElement;
+ const optionsAvailable = Object.values(field)
+ .map(option => (option as HTMLOptionElement).text)
+ .filter(option => option);
+
+ act(() => {
+ fireEvent.change(field, {
+ target: { value: selectValue }
+ });
+ });
+ expect(field.value).toEqual(selectValue);
+ expect(optionsAvailable).toEqual(optionsExpected);
+ };
+
+ addFieldAndCheck('filter-0', 'transaction.name', false, [
+ 'Select field...',
+ 'service.name',
+ 'service.environment',
+ 'transaction.type',
+ 'transaction.name'
+ ]);
+
+ addFieldAndCheck('filter-1', 'service.name', true, [
+ 'Select field...',
+ 'service.name',
+ 'service.environment',
+ 'transaction.type'
+ ]);
+
+ addFieldAndCheck('filter-2', 'transaction.type', true, [
+ 'Select field...',
+ 'service.environment',
+ 'transaction.type'
+ ]);
+
+ addFieldAndCheck('filter-3', 'service.environment', true, [
+ 'Select field...',
+ 'service.environment'
+ ]);
+ });
+ });
+ });
+});
diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx
new file mode 100644
index 00000000000000..bc1882c8c27852
--- /dev/null
+++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx
@@ -0,0 +1,92 @@
+/*
+ * 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 { EuiPanel, EuiSpacer, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
+import { isEmpty } from 'lodash';
+import React, { useEffect, useState } from 'react';
+import { CustomLink } from '../../../../../../../../../plugins/apm/server/lib/settings/custom_link/custom_link_types';
+import { useFetcher, FETCH_STATUS } from '../../../../../hooks/useFetcher';
+import { CustomLinkFlyout } from './CustomLinkFlyout';
+import { CustomLinkTable } from './CustomLinkTable';
+import { EmptyPrompt } from './EmptyPrompt';
+import { Title } from './Title';
+import { CreateCustomLinkButton } from './CreateCustomLinkButton';
+
+export const CustomLinkOverview = () => {
+ const [isFlyoutOpen, setIsFlyoutOpen] = useState(false);
+ const [customLinkSelected, setCustomLinkSelected] = useState<
+ CustomLink | undefined
+ >();
+
+ const { data: customLinks, status, refetch } = useFetcher(
+ callApmApi => callApmApi({ pathname: '/api/apm/settings/custom_links' }),
+ []
+ );
+
+ useEffect(() => {
+ if (customLinkSelected) {
+ setIsFlyoutOpen(true);
+ }
+ }, [customLinkSelected]);
+
+ const onCloseFlyout = () => {
+ setCustomLinkSelected(undefined);
+ setIsFlyoutOpen(false);
+ };
+
+ const onCreateCustomLinkClick = () => {
+ setIsFlyoutOpen(true);
+ };
+
+ const showEmptyPrompt =
+ status === FETCH_STATUS.SUCCESS && isEmpty(customLinks);
+
+ return (
+ <>
+ {isFlyoutOpen && (
+ {
+ onCloseFlyout();
+ refetch();
+ }}
+ onDelete={() => {
+ onCloseFlyout();
+ refetch();
+ }}
+ />
+ )}
+
+
+
+
+
+ {!showEmptyPrompt && (
+
+
+
+
+
+
+
+ )}
+
+
+
+
+ {showEmptyPrompt ? (
+
+ ) : (
+
+ )}
+
+ >
+ );
+};
diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/index.tsx
index 17a4b2f8476795..1cd1298fdd5492 100644
--- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/index.tsx
+++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/index.tsx
@@ -7,7 +7,7 @@
import React from 'react';
import { EuiTitle, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
-import { CustomActionsOverview } from './CustomActionsOverview';
+import { CustomLinkOverview } from './CustomLink';
export const CustomizeUI = () => {
return (
@@ -20,7 +20,7 @@ export const CustomizeUI = () => {
-
+
>
);
};
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 7645162ab26559..0e0c318ad32996 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
@@ -23,7 +23,7 @@ export function ElasticDocsLink({ section, path, children, ...rest }: Props) {
children(href)
) : (
- children
+ {children}
);
}
diff --git a/x-pack/legacy/plugins/apm/public/hooks/useCallApmApi.ts b/x-pack/legacy/plugins/apm/public/hooks/useCallApmApi.ts
deleted file mode 100644
index b28b295d8189e4..00000000000000
--- a/x-pack/legacy/plugins/apm/public/hooks/useCallApmApi.ts
+++ /dev/null
@@ -1,17 +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 { useMemo } from 'react';
-import { createCallApmApi } from '../services/rest/createCallApmApi';
-import { useApmPluginContext } from './useApmPluginContext';
-
-export function useCallApmApi() {
- const { http } = useApmPluginContext().core;
-
- return useMemo(() => {
- return createCallApmApi(http);
- }, [http]);
-}
diff --git a/x-pack/legacy/plugins/apm/public/hooks/useFetcher.tsx b/x-pack/legacy/plugins/apm/public/hooks/useFetcher.tsx
index d2202fff996b13..c2530d6982c3b3 100644
--- a/x-pack/legacy/plugins/apm/public/hooks/useFetcher.tsx
+++ b/x-pack/legacy/plugins/apm/public/hooks/useFetcher.tsx
@@ -9,8 +9,7 @@ import { i18n } from '@kbn/i18n';
import { IHttpFetchError } from 'src/core/public';
import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public';
import { LoadingIndicatorContext } from '../context/LoadingIndicatorContext';
-import { APMClient } from '../services/rest/createCallApmApi';
-import { useCallApmApi } from './useCallApmApi';
+import { APMClient, callApmApi } from '../services/rest/createCallApmApi';
import { useApmPluginContext } from './useApmPluginContext';
import { useLoadingIndicator } from './useLoadingIndicator';
@@ -46,8 +45,6 @@ export function useFetcher(
const { preservePreviousData = true } = options;
const { setIsLoading } = useLoadingIndicator();
- const callApmApi = useCallApmApi();
-
const { dispatchStatus } = useContext(LoadingIndicatorContext);
const [result, setResult] = useState>>({
data: undefined,
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 0054f963ba8f2f..0103dd72a3feac 100644
--- a/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx
+++ b/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx
@@ -39,6 +39,7 @@ import { toggleAppLinkInNav } from './toggleAppLinkInNav';
import { setReadonlyBadge } from './updateBadge';
import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public';
import { APMIndicesPermission } from '../components/app/APMIndicesPermission';
+import { createCallApmApi } from '../services/rest/createCallApmApi';
export const REACT_APP_ROOT_ID = 'react-apm-root';
@@ -104,6 +105,7 @@ export class ApmPlugin
public start(core: CoreStart) {
const i18nCore = core.i18n;
const plugins = this.setupPlugins;
+ createCallApmApi(core.http);
// Once we're actually an NP plugin we'll get the config from the
// initializerContext like:
@@ -157,7 +159,7 @@ export class ApmPlugin
);
// create static index pattern and store as saved object. Not needed by APM UI but for legacy reasons in Discover, Dashboard etc.
- createStaticIndexPattern(core.http).catch(e => {
+ createStaticIndexPattern().catch(e => {
// eslint-disable-next-line no-console
console.log('Error fetching static index pattern', e);
});
diff --git a/x-pack/legacy/plugins/apm/public/services/__test__/callApmApi.test.ts b/x-pack/legacy/plugins/apm/public/services/__test__/callApmApi.test.ts
index 9cca9469bba0ed..2d4fd830031799 100644
--- a/x-pack/legacy/plugins/apm/public/services/__test__/callApmApi.test.ts
+++ b/x-pack/legacy/plugins/apm/public/services/__test__/callApmApi.test.ts
@@ -5,7 +5,7 @@
*/
import * as callApiExports from '../rest/callApi';
-import { createCallApmApi, APMClient } from '../rest/createCallApmApi';
+import { createCallApmApi, callApmApi } from '../rest/createCallApmApi';
import { HttpSetup } from 'kibana/public';
const callApi = jest
@@ -13,9 +13,8 @@ const callApi = jest
.mockImplementation(() => Promise.resolve(null));
describe('callApmApi', () => {
- let callApmApi: APMClient;
beforeEach(() => {
- callApmApi = createCallApmApi({} as HttpSetup);
+ createCallApmApi({} as HttpSetup);
});
afterEach(() => {
diff --git a/x-pack/legacy/plugins/apm/public/services/rest/createCallApmApi.ts b/x-pack/legacy/plugins/apm/public/services/rest/createCallApmApi.ts
index 220320216788a5..2fffb40d353fc7 100644
--- a/x-pack/legacy/plugins/apm/public/services/rest/createCallApmApi.ts
+++ b/x-pack/legacy/plugins/apm/public/services/rest/createCallApmApi.ts
@@ -19,8 +19,14 @@ export type APMClientOptions = Omit & {
};
};
-export const createCallApmApi = (http: HttpSetup) =>
- ((options: APMClientOptions) => {
+export let callApmApi: APMClient = () => {
+ throw new Error(
+ 'callApmApi has to be initialized before used. Call createCallApmApi first.'
+ );
+};
+
+export function createCallApmApi(http: HttpSetup) {
+ callApmApi = ((options: APMClientOptions) => {
const { pathname, params = {}, ...opts } = options;
const path = (params.path || {}) as Record;
@@ -36,3 +42,4 @@ export const createCallApmApi = (http: HttpSetup) =>
query: params.query
});
}) as APMClient;
+}
diff --git a/x-pack/legacy/plugins/apm/public/services/rest/index_pattern.ts b/x-pack/legacy/plugins/apm/public/services/rest/index_pattern.ts
index 8e1234dd55e69b..1efcc98bbbd665 100644
--- a/x-pack/legacy/plugins/apm/public/services/rest/index_pattern.ts
+++ b/x-pack/legacy/plugins/apm/public/services/rest/index_pattern.ts
@@ -4,11 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { HttpSetup } from 'kibana/public';
-import { createCallApmApi } from './createCallApmApi';
+import { callApmApi } from './createCallApmApi';
-export const createStaticIndexPattern = async (http: HttpSetup) => {
- const callApmApi = createCallApmApi(http);
+export const createStaticIndexPattern = async () => {
return await callApmApi({
method: 'POST',
pathname: '/api/apm/index_pattern/static'
diff --git a/x-pack/legacy/plugins/apm/public/services/rest/ml.ts b/x-pack/legacy/plugins/apm/public/services/rest/ml.ts
index 5e64d7e1ce716a..1c618098b36e3e 100644
--- a/x-pack/legacy/plugins/apm/public/services/rest/ml.ts
+++ b/x-pack/legacy/plugins/apm/public/services/rest/ml.ts
@@ -16,7 +16,7 @@ import {
} from '../../../../../../plugins/apm/common/ml_job_constants';
import { callApi } from './callApi';
import { ESFilter } from '../../../../../../plugins/apm/typings/elasticsearch';
-import { createCallApmApi, APMClient } from './createCallApmApi';
+import { callApmApi } from './createCallApmApi';
interface MlResponseItem {
id: string;
@@ -36,7 +36,6 @@ interface StartedMLJobApiResponse {
}
async function getTransactionIndices(http: HttpSetup) {
- const callApmApi: APMClient = createCallApmApi(http);
const indices = await callApmApi({
method: 'GET',
pathname: `/api/apm/settings/apm-indices`
diff --git a/x-pack/legacy/plugins/apm/public/utils/testHelpers.tsx b/x-pack/legacy/plugins/apm/public/utils/testHelpers.tsx
index dec2257746e50f..4ee45f7b3330bf 100644
--- a/x-pack/legacy/plugins/apm/public/utils/testHelpers.tsx
+++ b/x-pack/legacy/plugins/apm/public/utils/testHelpers.tsx
@@ -29,6 +29,7 @@ import {
ApmPluginContextValue
} from '../context/ApmPluginContext';
import { ConfigSchema } from '../new-platform/plugin';
+import { createCallApmApi } from '../services/rest/createCallApmApi';
export function toJson(wrapper: ReactWrapper) {
return enzymeToJson(wrapper, {
@@ -118,6 +119,7 @@ interface MockSetup {
'apm_oss.transactionIndices': string;
'apm_oss.metricsIndices': string;
apmAgentConfigurationIndex: string;
+ apmCustomLinkIndex: string;
};
}
@@ -162,7 +164,8 @@ export async function inspectSearchParams(
'apm_oss.spanIndices': 'myIndex',
'apm_oss.transactionIndices': 'myIndex',
'apm_oss.metricsIndices': 'myIndex',
- apmAgentConfigurationIndex: 'myIndex'
+ apmAgentConfigurationIndex: 'myIndex',
+ apmCustomLinkIndex: 'myIndex'
},
dynamicIndexPattern: null as any
};
@@ -195,7 +198,8 @@ const mockCore = {
},
notifications: {
toasts: {
- addWarning: () => {}
+ addWarning: () => {},
+ addDanger: () => {}
}
}
};
@@ -222,6 +226,9 @@ export function MockApmPluginContextWrapper({
children?: ReactNode;
value?: ApmPluginContextValue;
}) {
+ if (value.core?.http) {
+ createCallApmApi(value.core?.http);
+ }
return (
{
ReactDOM.render(
-
+
diff --git a/x-pack/legacy/plugins/canvas/public/lib/build_bool_array.js b/x-pack/legacy/plugins/canvas/public/lib/build_bool_array.js
new file mode 100644
index 00000000000000..2dc64477535263
--- /dev/null
+++ b/x-pack/legacy/plugins/canvas/public/lib/build_bool_array.js
@@ -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.
+ */
+
+import { getESFilter } from './get_es_filter';
+
+const compact = arr => (Array.isArray(arr) ? arr.filter(val => Boolean(val)) : []);
+
+export function buildBoolArray(canvasQueryFilterArray) {
+ return compact(
+ canvasQueryFilterArray.map(clause => {
+ try {
+ return getESFilter(clause);
+ } catch (e) {
+ return;
+ }
+ })
+ );
+}
diff --git a/x-pack/legacy/plugins/canvas/server/lib/build_embeddable_filters.test.ts b/x-pack/legacy/plugins/canvas/public/lib/build_embeddable_filters.test.ts
similarity index 100%
rename from x-pack/legacy/plugins/canvas/server/lib/build_embeddable_filters.test.ts
rename to x-pack/legacy/plugins/canvas/public/lib/build_embeddable_filters.test.ts
diff --git a/x-pack/legacy/plugins/canvas/server/lib/build_embeddable_filters.ts b/x-pack/legacy/plugins/canvas/public/lib/build_embeddable_filters.ts
similarity index 73%
rename from x-pack/legacy/plugins/canvas/server/lib/build_embeddable_filters.ts
rename to x-pack/legacy/plugins/canvas/public/lib/build_embeddable_filters.ts
index 05d4c6570bcfbf..1a5d2119a94b67 100644
--- a/x-pack/legacy/plugins/canvas/server/lib/build_embeddable_filters.ts
+++ b/x-pack/legacy/plugins/canvas/public/lib/build_embeddable_filters.ts
@@ -7,17 +7,11 @@
import { Filter } from '../../types';
// @ts-ignore Untyped Local
import { buildBoolArray } from './build_bool_array';
-
-// TODO: We should be importing from `data/server` below instead of `data/common`, but
-// need to keep `data/common` since the contents of this file are currently imported
-// by the browser. This file should probably be refactored so that the pieces required
-// on the client live in a `public` directory instead. See kibana/issues/52343
-// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import {
TimeRange,
esFilters,
Filter as DataFilter,
-} from '../../../../../../src/plugins/data/server';
+} from '../../../../../../src/plugins/data/public';
export interface EmbeddableFilterInput {
filters: DataFilter[];
diff --git a/x-pack/legacy/plugins/canvas/public/lib/filters.js b/x-pack/legacy/plugins/canvas/public/lib/filters.js
new file mode 100644
index 00000000000000..afa58c7ee30c2d
--- /dev/null
+++ b/x-pack/legacy/plugins/canvas/public/lib/filters.js
@@ -0,0 +1,38 @@
+/*
+ * 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.
+ */
+
+/*
+ TODO: This could be pluggable
+*/
+
+export function time(filter) {
+ if (!filter.column) {
+ throw new Error('column is required for Elasticsearch range filters');
+ }
+ return {
+ range: {
+ [filter.column]: { gte: filter.from, lte: filter.to },
+ },
+ };
+}
+
+export function luceneQueryString(filter) {
+ return {
+ query_string: {
+ query: filter.query || '*',
+ },
+ };
+}
+
+export function exactly(filter) {
+ return {
+ term: {
+ [filter.column]: {
+ value: filter.value,
+ },
+ },
+ };
+}
diff --git a/x-pack/legacy/plugins/canvas/public/lib/get_es_filter.js b/x-pack/legacy/plugins/canvas/public/lib/get_es_filter.js
new file mode 100644
index 00000000000000..e8a4d704118e8b
--- /dev/null
+++ b/x-pack/legacy/plugins/canvas/public/lib/get_es_filter.js
@@ -0,0 +1,26 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+/*
+ boolArray is the array of bool filter clauses to push filters into. Usually this would be
+ the value of must, should or must_not.
+ filter is the abstracted canvas filter.
+*/
+
+/*eslint import/namespace: ['error', { allowComputed: true }]*/
+import * as filters from './filters';
+
+export function getESFilter(filter) {
+ if (!filters[filter.type]) {
+ throw new Error(`Unknown filter type: ${filter.type}`);
+ }
+
+ try {
+ return filters[filter.type](filter);
+ } catch (e) {
+ throw new Error(`Could not create elasticsearch filter from ${filter.type}`);
+ }
+}
diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx
index 0e256d0ab181b5..4736dd75831e46 100644
--- a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx
+++ b/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx
@@ -24,11 +24,10 @@ import { FrameLayout } from './frame_layout';
// calling this function will wait for all pending Promises from mock
// datasources to be processed by its callers.
-async function waitForPromises(n = 3) {
- for (let i = 0; i < n; ++i) {
- await Promise.resolve();
- }
-}
+const waitForPromises = async () =>
+ act(async () => {
+ await new Promise(resolve => setTimeout(resolve));
+ });
function generateSuggestion(state = {}): DatasourceSuggestion {
return {
@@ -102,7 +101,7 @@ describe('editor_frame', () => {
});
describe('initialization', () => {
- it('should initialize initial datasource', () => {
+ it('should initialize initial datasource', async () => {
act(() => {
mount(
{
/>
);
});
+ await waitForPromises();
expect(mockDatasource.initialize).toHaveBeenCalled();
});
@@ -145,7 +145,7 @@ describe('editor_frame', () => {
expect(mockDatasource.initialize).not.toHaveBeenCalled();
});
- it('should initialize all datasources with state from doc', () => {
+ it('should initialize all datasources with state from doc', async () => {
const mockDatasource3 = createMockDatasource();
const datasource1State = { datasource1: '' };
const datasource2State = { datasource2: '' };
@@ -185,13 +185,13 @@ describe('editor_frame', () => {
/>
);
});
-
+ await waitForPromises();
expect(mockDatasource.initialize).toHaveBeenCalledWith(datasource1State);
expect(mockDatasource2.initialize).toHaveBeenCalledWith(datasource2State);
expect(mockDatasource3.initialize).not.toHaveBeenCalled();
});
- it('should not render something before all datasources are initialized', () => {
+ it('should not render something before all datasources are initialized', async () => {
act(() => {
mount(
{
expect(mockVisualization.renderLayerConfigPanel).not.toHaveBeenCalled();
expect(mockDatasource.renderDataPanel).not.toHaveBeenCalled();
+ await waitForPromises();
});
it('should not initialize visualization before datasource is initialized', async () => {
@@ -294,7 +295,9 @@ describe('editor_frame', () => {
await waitForPromises();
- mockVisualization.initialize.mock.calls[0][0].addNewLayer();
+ act(() => {
+ mockVisualization.initialize.mock.calls[0][0].addNewLayer();
+ });
expect(mockDatasource2.insertLayer).toHaveBeenCalledWith(initialState, expect.anything());
});
@@ -325,7 +328,9 @@ describe('editor_frame', () => {
await waitForPromises();
- mockVisualization.initialize.mock.calls[0][0].removeLayers(['abc', 'def']);
+ act(() => {
+ mockVisualization.initialize.mock.calls[0][0].removeLayers(['abc', 'def']);
+ });
expect(mockDatasource2.removeLayer).toHaveBeenCalledWith(initialState, 'abc');
expect(mockDatasource2.removeLayer).toHaveBeenCalledWith({ removed: true }, 'def');
@@ -989,6 +994,7 @@ describe('editor_frame', () => {
'[data-test-subj="datasource-switch-testDatasource2"]'
) as HTMLButtonElement).click();
});
+ await waitForPromises();
expect(mockDatasource2.initialize).toHaveBeenCalled();
});
diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.test.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.test.tsx
index 929b4667aeb667..92a14963ff0b61 100644
--- a/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.test.tsx
+++ b/x-pack/legacy/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.test.tsx
@@ -5,6 +5,7 @@
*/
import React from 'react';
+import { act } from 'react-dom/test-utils';
import { ReactExpressionRendererProps } from '../../../../../../../src/plugins/expressions/public';
import { FramePublicAPI, TableSuggestion, Visualization } from '../../types';
import {
@@ -22,7 +23,10 @@ import { Ast } from '@kbn/interpreter/common';
import { coreMock } from 'src/core/public/mocks';
import { esFilters, IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/public';
-const waitForPromises = () => new Promise(resolve => setTimeout(resolve));
+const waitForPromises = async () =>
+ act(async () => {
+ await new Promise(resolve => setTimeout(resolve));
+ });
describe('workspace_panel', () => {
let mockVisualization: jest.Mocked;
diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/field_item.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/field_item.test.tsx
index 46a8304cc395e9..1a38ffa44f6f2f 100644
--- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/field_item.test.tsx
+++ b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/field_item.test.tsx
@@ -16,7 +16,10 @@ import { IndexPattern } from './types';
jest.mock('ui/new_platform');
-const waitForPromises = () => new Promise(resolve => setTimeout(resolve));
+const waitForPromises = async () =>
+ act(async () => {
+ await new Promise(resolve => setTimeout(resolve));
+ });
describe('IndexPattern Field Item', () => {
let defaultProps: FieldItemProps;
diff --git a/x-pack/legacy/plugins/maps/common/constants.ts b/x-pack/legacy/plugins/maps/common/constants.ts
index 4f1b3223967a58..53289fbbc9005a 100644
--- a/x-pack/legacy/plugins/maps/common/constants.ts
+++ b/x-pack/legacy/plugins/maps/common/constants.ts
@@ -55,10 +55,10 @@ export const ES_SEARCH = 'ES_SEARCH';
export const ES_PEW_PEW = 'ES_PEW_PEW';
export const EMS_XYZ = 'EMS_XYZ'; // identifies a custom TMS source. Name is a little unfortunate.
-export const FIELD_ORIGIN = {
- SOURCE: 'source',
- JOIN: 'join',
-};
+export enum FIELD_ORIGIN {
+ SOURCE = 'source',
+ JOIN = 'join',
+}
export const SOURCE_DATA_ID_ORIGIN = 'source';
export const META_ID_ORIGIN_SUFFIX = 'meta';
@@ -139,6 +139,8 @@ export enum GRID_RESOLUTION {
MOST_FINE = 'MOST_FINE',
}
+export const TOP_TERM_PERCENTAGE_SUFFIX = '__percentage';
+
export const COUNT_PROP_LABEL = i18n.translate('xpack.maps.aggs.defaultCountLabel', {
defaultMessage: 'count',
});
diff --git a/x-pack/legacy/plugins/maps/common/descriptor_types.d.ts b/x-pack/legacy/plugins/maps/common/descriptor_types.d.ts
index f342260c3e7a41..f03f828200bbd9 100644
--- a/x-pack/legacy/plugins/maps/common/descriptor_types.d.ts
+++ b/x-pack/legacy/plugins/maps/common/descriptor_types.d.ts
@@ -40,8 +40,8 @@ export type AbstractESAggDescriptor = AbstractESSourceDescriptor & {
};
export type ESGeoGridSourceDescriptor = AbstractESAggDescriptor & {
- requestType: RENDER_AS;
- resolution: GRID_RESOLUTION;
+ requestType?: RENDER_AS;
+ resolution?: GRID_RESOLUTION;
};
export type ESSearchSourceDescriptor = AbstractESSourceDescriptor & {
diff --git a/x-pack/legacy/plugins/maps/public/layers/fields/es_agg_field.js b/x-pack/legacy/plugins/maps/public/layers/fields/es_agg_field.js
deleted file mode 100644
index 27ab8fc5bfb3ae..00000000000000
--- a/x-pack/legacy/plugins/maps/public/layers/fields/es_agg_field.js
+++ /dev/null
@@ -1,96 +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 { AbstractField } from './field';
-import { AGG_TYPE } from '../../../common/constants';
-import { isMetricCountable } from '../util/is_metric_countable';
-import { ESAggMetricTooltipProperty } from '../tooltips/es_aggmetric_tooltip_property';
-import { getField, addFieldToDSL } from '../util/es_agg_utils';
-
-export class ESAggMetricField extends AbstractField {
- static type = 'ES_AGG';
-
- constructor({ label, source, aggType, esDocField, origin }) {
- super({ source, origin });
- this._label = label;
- this._aggType = aggType;
- this._esDocField = esDocField;
- }
-
- getName() {
- return this._source.getAggKey(this.getAggType(), this.getRootName());
- }
-
- getRootName() {
- return this._getESDocFieldName();
- }
-
- async getLabel() {
- return this._label
- ? this._label
- : this._source.getAggLabel(this.getAggType(), this.getRootName());
- }
-
- getAggType() {
- return this._aggType;
- }
-
- isValid() {
- return this.getAggType() === AGG_TYPE.COUNT ? true : !!this._esDocField;
- }
-
- async getDataType() {
- return this.getAggType() === AGG_TYPE.TERMS ? 'string' : 'number';
- }
-
- _getESDocFieldName() {
- return this._esDocField ? this._esDocField.getName() : '';
- }
-
- getRequestDescription() {
- return this.getAggType() !== AGG_TYPE.COUNT
- ? `${this.getAggType()} ${this.getRootName()}`
- : AGG_TYPE.COUNT;
- }
-
- async createTooltipProperty(value) {
- const indexPattern = await this._source.getIndexPattern();
- return new ESAggMetricTooltipProperty(
- this.getName(),
- await this.getLabel(),
- value,
- indexPattern,
- this
- );
- }
-
- getValueAggDsl(indexPattern) {
- const field = getField(indexPattern, this.getRootName());
- const aggType = this.getAggType();
- const aggBody = aggType === AGG_TYPE.TERMS ? { size: 1, shard_size: 1 } : {};
- return {
- [aggType]: addFieldToDSL(aggBody, field),
- };
- }
-
- supportsFieldMeta() {
- // count and sum aggregations are not within field bounds so they do not support field meta.
- return !isMetricCountable(this.getAggType());
- }
-
- canValueBeFormatted() {
- // Do not use field formatters for counting metrics
- return ![AGG_TYPE.COUNT, AGG_TYPE.UNIQUE_COUNT].includes(this.getAggType());
- }
-
- async getOrdinalFieldMetaRequest(config) {
- return this._esDocField.getOrdinalFieldMetaRequest(config);
- }
-
- async getCategoricalFieldMetaRequest() {
- return this._esDocField.getCategoricalFieldMetaRequest();
- }
-}
diff --git a/x-pack/legacy/plugins/maps/public/layers/fields/es_agg_field.test.js b/x-pack/legacy/plugins/maps/public/layers/fields/es_agg_field.test.js
deleted file mode 100644
index aeeffd63607eeb..00000000000000
--- a/x-pack/legacy/plugins/maps/public/layers/fields/es_agg_field.test.js
+++ /dev/null
@@ -1,28 +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 { ESAggMetricField } from './es_agg_field';
-import { AGG_TYPE } from '../../../common/constants';
-
-describe('supportsFieldMeta', () => {
- test('Non-counting aggregations should support field meta', () => {
- const avgMetric = new ESAggMetricField({ aggType: AGG_TYPE.AVG });
- expect(avgMetric.supportsFieldMeta()).toBe(true);
- const maxMetric = new ESAggMetricField({ aggType: AGG_TYPE.MAX });
- expect(maxMetric.supportsFieldMeta()).toBe(true);
- const minMetric = new ESAggMetricField({ aggType: AGG_TYPE.MIN });
- expect(minMetric.supportsFieldMeta()).toBe(true);
- });
-
- test('Counting aggregations should not support field meta', () => {
- const countMetric = new ESAggMetricField({ aggType: AGG_TYPE.COUNT });
- expect(countMetric.supportsFieldMeta()).toBe(false);
- const sumMetric = new ESAggMetricField({ aggType: AGG_TYPE.SUM });
- expect(sumMetric.supportsFieldMeta()).toBe(false);
- const uniqueCountMetric = new ESAggMetricField({ aggType: AGG_TYPE.UNIQUE_COUNT });
- expect(uniqueCountMetric.supportsFieldMeta()).toBe(false);
- });
-});
diff --git a/x-pack/legacy/plugins/maps/public/layers/fields/es_agg_field.test.ts b/x-pack/legacy/plugins/maps/public/layers/fields/es_agg_field.test.ts
new file mode 100644
index 00000000000000..7a65b5f9f6b46e
--- /dev/null
+++ b/x-pack/legacy/plugins/maps/public/layers/fields/es_agg_field.test.ts
@@ -0,0 +1,80 @@
+/*
+ * 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 { ESAggField, esAggFieldsFactory } from './es_agg_field';
+import { AGG_TYPE, FIELD_ORIGIN } from '../../../common/constants';
+import { IESAggSource } from '../sources/es_agg_source';
+import { IIndexPattern } from 'src/plugins/data/public';
+
+const mockIndexPattern = {
+ title: 'wildIndex',
+ fields: [
+ {
+ name: 'foo*',
+ },
+ ],
+} as IIndexPattern;
+
+const mockEsAggSource = {
+ getAggKey: (aggType: AGG_TYPE, fieldName: string) => {
+ return 'agg_key';
+ },
+ getAggLabel: (aggType: AGG_TYPE, fieldName: string) => {
+ return 'agg_label';
+ },
+ getIndexPattern: async () => {
+ return mockIndexPattern;
+ },
+} as IESAggSource;
+
+const defaultParams = {
+ label: 'my agg field',
+ source: mockEsAggSource,
+ aggType: AGG_TYPE.COUNT,
+ origin: FIELD_ORIGIN.SOURCE,
+};
+
+describe('supportsFieldMeta', () => {
+ test('Non-counting aggregations should support field meta', () => {
+ const avgMetric = new ESAggField({ ...defaultParams, aggType: AGG_TYPE.AVG });
+ expect(avgMetric.supportsFieldMeta()).toBe(true);
+ const maxMetric = new ESAggField({ ...defaultParams, aggType: AGG_TYPE.MAX });
+ expect(maxMetric.supportsFieldMeta()).toBe(true);
+ const minMetric = new ESAggField({ ...defaultParams, aggType: AGG_TYPE.MIN });
+ expect(minMetric.supportsFieldMeta()).toBe(true);
+ const termsMetric = new ESAggField({ ...defaultParams, aggType: AGG_TYPE.TERMS });
+ expect(termsMetric.supportsFieldMeta()).toBe(true);
+ });
+
+ test('Counting aggregations should not support field meta', () => {
+ const countMetric = new ESAggField({ ...defaultParams, aggType: AGG_TYPE.COUNT });
+ expect(countMetric.supportsFieldMeta()).toBe(false);
+ const sumMetric = new ESAggField({ ...defaultParams, aggType: AGG_TYPE.SUM });
+ expect(sumMetric.supportsFieldMeta()).toBe(false);
+ const uniqueCountMetric = new ESAggField({ ...defaultParams, aggType: AGG_TYPE.UNIQUE_COUNT });
+ expect(uniqueCountMetric.supportsFieldMeta()).toBe(false);
+ });
+});
+
+describe('esAggFieldsFactory', () => {
+ test('Should only create top terms field when term field is not provided', () => {
+ const fields = esAggFieldsFactory(
+ { type: AGG_TYPE.TERMS },
+ mockEsAggSource,
+ FIELD_ORIGIN.SOURCE
+ );
+ expect(fields.length).toBe(1);
+ });
+
+ test('Should create top terms and top terms percentage fields', () => {
+ const fields = esAggFieldsFactory(
+ { type: AGG_TYPE.TERMS, field: 'myField' },
+ mockEsAggSource,
+ FIELD_ORIGIN.SOURCE
+ );
+ expect(fields.length).toBe(2);
+ });
+});
diff --git a/x-pack/legacy/plugins/maps/public/layers/fields/es_agg_field.ts b/x-pack/legacy/plugins/maps/public/layers/fields/es_agg_field.ts
new file mode 100644
index 00000000000000..9f08200442fea4
--- /dev/null
+++ b/x-pack/legacy/plugins/maps/public/layers/fields/es_agg_field.ts
@@ -0,0 +1,169 @@
+/*
+ * 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 { IndexPattern } from 'src/plugins/data/public';
+import { IField } from './field';
+import { AggDescriptor } from '../../../common/descriptor_types';
+import { IESAggSource } from '../sources/es_agg_source';
+import { IVectorSource } from '../sources/vector_source';
+// @ts-ignore
+import { ESDocField } from './es_doc_field';
+import { AGG_TYPE, FIELD_ORIGIN } from '../../../common/constants';
+import { isMetricCountable } from '../util/is_metric_countable';
+// @ts-ignore
+import { ESAggMetricTooltipProperty } from '../tooltips/es_aggmetric_tooltip_property';
+import { getField, addFieldToDSL } from '../util/es_agg_utils';
+import { TopTermPercentageField } from './top_term_percentage_field';
+
+export interface IESAggField extends IField {
+ getValueAggDsl(indexPattern: IndexPattern): unknown | null;
+ getBucketCount(): number;
+}
+
+export class ESAggField implements IESAggField {
+ static type = 'ES_AGG';
+
+ private _source: IESAggSource;
+ private _origin: FIELD_ORIGIN;
+ private _label?: string;
+ private _aggType: AGG_TYPE;
+ private _esDocField?: unknown;
+
+ constructor({
+ label,
+ source,
+ aggType,
+ esDocField,
+ origin,
+ }: {
+ label?: string;
+ source: IESAggSource;
+ aggType: AGG_TYPE;
+ esDocField?: unknown;
+ origin: FIELD_ORIGIN;
+ }) {
+ this._source = source;
+ this._origin = origin;
+ this._label = label;
+ this._aggType = aggType;
+ this._esDocField = esDocField;
+ }
+
+ getSource(): IVectorSource {
+ return this._source;
+ }
+
+ getOrigin(): FIELD_ORIGIN {
+ return this._origin;
+ }
+
+ getName(): string {
+ return this._source.getAggKey(this.getAggType(), this.getRootName());
+ }
+
+ getRootName(): string {
+ return this._getESDocFieldName();
+ }
+
+ async getLabel(): Promise {
+ return this._label
+ ? this._label
+ : this._source.getAggLabel(this.getAggType(), this.getRootName());
+ }
+
+ getAggType(): AGG_TYPE {
+ return this._aggType;
+ }
+
+ isValid(): boolean {
+ return this.getAggType() === AGG_TYPE.COUNT ? true : !!this._esDocField;
+ }
+
+ async getDataType(): Promise {
+ return this.getAggType() === AGG_TYPE.TERMS ? 'string' : 'number';
+ }
+
+ _getESDocFieldName(): string {
+ // TODO remove when esDocField is typed
+ // @ts-ignore
+ return this._esDocField ? this._esDocField.getName() : '';
+ }
+
+ async createTooltipProperty(value: number | string): Promise {
+ const indexPattern = await this._source.getIndexPattern();
+ return new ESAggMetricTooltipProperty(
+ this.getName(),
+ await this.getLabel(),
+ value,
+ indexPattern,
+ this
+ );
+ }
+
+ getValueAggDsl(indexPattern: IndexPattern): unknown | null {
+ if (this.getAggType() === AGG_TYPE.COUNT) {
+ return null;
+ }
+
+ const field = getField(indexPattern, this.getRootName());
+ const aggType = this.getAggType();
+ const aggBody = aggType === AGG_TYPE.TERMS ? { size: 1, shard_size: 1 } : {};
+ return {
+ [aggType]: addFieldToDSL(aggBody, field),
+ };
+ }
+
+ getBucketCount(): number {
+ // terms aggregation increases the overall number of buckets per split bucket
+ return this.getAggType() === AGG_TYPE.TERMS ? 1 : 0;
+ }
+
+ supportsFieldMeta(): boolean {
+ // count and sum aggregations are not within field bounds so they do not support field meta.
+ return !isMetricCountable(this.getAggType());
+ }
+
+ canValueBeFormatted(): boolean {
+ // Do not use field formatters for counting metrics
+ return ![AGG_TYPE.COUNT, AGG_TYPE.UNIQUE_COUNT].includes(this.getAggType());
+ }
+
+ async getOrdinalFieldMetaRequest(): Promise {
+ // TODO remove when esDocField is typed
+ // @ts-ignore
+ return this._esDocField.getOrdinalFieldMetaRequest();
+ }
+
+ async getCategoricalFieldMetaRequest(): Promise {
+ // TODO remove when esDocField is typed
+ // @ts-ignore
+ return this._esDocField.getCategoricalFieldMetaRequest();
+ }
+}
+
+export function esAggFieldsFactory(
+ aggDescriptor: AggDescriptor,
+ source: IESAggSource,
+ origin: FIELD_ORIGIN
+): IESAggField[] {
+ const aggField = new ESAggField({
+ label: aggDescriptor.label,
+ esDocField: aggDescriptor.field
+ ? new ESDocField({ fieldName: aggDescriptor.field, source })
+ : null,
+ aggType: aggDescriptor.type,
+ source,
+ origin,
+ });
+
+ const aggFields: IESAggField[] = [aggField];
+
+ if (aggDescriptor.field && aggDescriptor.type === AGG_TYPE.TERMS) {
+ aggFields.push(new TopTermPercentageField(aggField));
+ }
+
+ return aggFields;
+}
diff --git a/x-pack/legacy/plugins/maps/public/layers/fields/field.ts b/x-pack/legacy/plugins/maps/public/layers/fields/field.ts
index 57a916e93ffe0a..f7c27fec1c6c7e 100644
--- a/x-pack/legacy/plugins/maps/public/layers/fields/field.ts
+++ b/x-pack/legacy/plugins/maps/public/layers/fields/field.ts
@@ -13,12 +13,15 @@ export interface IField {
canValueBeFormatted(): boolean;
getLabel(): Promise;
getDataType(): Promise;
+ getSource(): IVectorSource;
+ getOrigin(): FIELD_ORIGIN;
+ isValid(): boolean;
}
export class AbstractField implements IField {
private _fieldName: string;
private _source: IVectorSource;
- private _origin: string;
+ private _origin: FIELD_ORIGIN;
constructor({
fieldName,
@@ -27,7 +30,7 @@ export class AbstractField implements IField {
}: {
fieldName: string;
source: IVectorSource;
- origin: string;
+ origin: FIELD_ORIGIN;
}) {
this._fieldName = fieldName;
this._source = source;
@@ -66,7 +69,7 @@ export class AbstractField implements IField {
throw new Error('must implement Field#createTooltipProperty');
}
- getOrigin(): string {
+ getOrigin(): FIELD_ORIGIN {
return this._origin;
}
@@ -74,7 +77,7 @@ export class AbstractField implements IField {
return false;
}
- async getOrdinalFieldMetaRequest(/* config */): Promise {
+ async getOrdinalFieldMetaRequest(): Promise {
return null;
}
diff --git a/x-pack/legacy/plugins/maps/public/layers/fields/top_term_percentage_field.ts b/x-pack/legacy/plugins/maps/public/layers/fields/top_term_percentage_field.ts
new file mode 100644
index 00000000000000..cadf325652370c
--- /dev/null
+++ b/x-pack/legacy/plugins/maps/public/layers/fields/top_term_percentage_field.ts
@@ -0,0 +1,70 @@
+/*
+ * 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 { IESAggField } from './es_agg_field';
+import { IVectorSource } from '../sources/vector_source';
+// @ts-ignore
+import { TooltipProperty } from '../tooltips/tooltip_property';
+import { TOP_TERM_PERCENTAGE_SUFFIX } from '../../../common/constants';
+import { FIELD_ORIGIN } from '../../../common/constants';
+
+export class TopTermPercentageField implements IESAggField {
+ private _topTermAggField: IESAggField;
+
+ constructor(topTermAggField: IESAggField) {
+ this._topTermAggField = topTermAggField;
+ }
+
+ getSource(): IVectorSource {
+ return this._topTermAggField.getSource();
+ }
+
+ getOrigin(): FIELD_ORIGIN {
+ return this._topTermAggField.getOrigin();
+ }
+
+ getName(): string {
+ return `${this._topTermAggField.getName()}${TOP_TERM_PERCENTAGE_SUFFIX}`;
+ }
+
+ getRootName(): string {
+ // top term percentage is a derived value so it has no root field
+ return '';
+ }
+
+ async getLabel(): Promise {
+ const baseLabel = await this._topTermAggField.getLabel();
+ return `${baseLabel}%`;
+ }
+
+ isValid(): boolean {
+ return this._topTermAggField.isValid();
+ }
+
+ async getDataType(): Promise {
+ return 'number';
+ }
+
+ async createTooltipProperty(value: unknown): Promise {
+ return new TooltipProperty(this.getName(), await this.getLabel(), value);
+ }
+
+ getValueAggDsl(): null {
+ return null;
+ }
+
+ getBucketCount(): number {
+ return 0;
+ }
+
+ supportsFieldMeta(): boolean {
+ return false;
+ }
+
+ canValueBeFormatted(): boolean {
+ return false;
+ }
+}
diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_agg_source.d.ts b/x-pack/legacy/plugins/maps/public/layers/sources/es_agg_source.d.ts
new file mode 100644
index 00000000000000..a91bb4a8bb1a7b
--- /dev/null
+++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_agg_source.d.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.
+ */
+
+import { IESSource } from './es_source';
+import { AbstractESSource } from './es_source';
+import { AGG_TYPE } from '../../../common/constants';
+
+export interface IESAggSource extends IESSource {
+ getAggKey(aggType: AGG_TYPE, fieldName: string): string;
+ getAggLabel(aggType: AGG_TYPE, fieldName: string): string;
+}
+
+export class AbstractESAggSource extends AbstractESSource implements IESAggSource {
+ getAggKey(aggType: AGG_TYPE, fieldName: string): string;
+ getAggLabel(aggType: AGG_TYPE, fieldName: string): string;
+}
diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_agg_source.js b/x-pack/legacy/plugins/maps/public/layers/sources/es_agg_source.js
index 775535d9e2299c..62f3369ceb3a36 100644
--- a/x-pack/legacy/plugins/maps/public/layers/sources/es_agg_source.js
+++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_agg_source.js
@@ -6,8 +6,8 @@
import { i18n } from '@kbn/i18n';
import { AbstractESSource } from './es_source';
-import { ESAggMetricField } from '../fields/es_agg_field';
-import { ESDocField } from '../fields/es_doc_field';
+import { esAggFieldsFactory } from '../fields/es_agg_field';
+
import {
AGG_TYPE,
COUNT_PROP_LABEL,
@@ -20,20 +20,14 @@ export const AGG_DELIMITER = '_of_';
export class AbstractESAggSource extends AbstractESSource {
constructor(descriptor, inspectorAdapters) {
super(descriptor, inspectorAdapters);
- this._metricFields = this._descriptor.metrics
- ? this._descriptor.metrics.map(metric => {
- const esDocField = metric.field
- ? new ESDocField({ fieldName: metric.field, source: this })
- : null;
- return new ESAggMetricField({
- label: metric.label,
- esDocField: esDocField,
- aggType: metric.type,
- source: this,
- origin: this.getOriginForField(),
- });
- })
- : [];
+ this._metricFields = [];
+ if (this._descriptor.metrics) {
+ this._descriptor.metrics.forEach(aggDescriptor => {
+ this._metricFields.push(
+ ...esAggFieldsFactory(aggDescriptor, this, this.getOriginForField())
+ );
+ });
+ }
}
getFieldByName(name) {
@@ -61,16 +55,9 @@ export class AbstractESAggSource extends AbstractESSource {
getMetricFields() {
const metrics = this._metricFields.filter(esAggField => esAggField.isValid());
- if (metrics.length === 0) {
- metrics.push(
- new ESAggMetricField({
- aggType: AGG_TYPE.COUNT,
- source: this,
- origin: this.getOriginForField(),
- })
- );
- }
- return metrics;
+ return metrics.length === 0
+ ? esAggFieldsFactory({ type: AGG_TYPE.COUNT }, this, this.getOriginForField())
+ : metrics;
}
getAggKey(aggType, fieldName) {
@@ -93,13 +80,12 @@ export class AbstractESAggSource extends AbstractESSource {
getValueAggsDsl(indexPattern) {
const valueAggsDsl = {};
- this.getMetricFields()
- .filter(esAggMetric => {
- return esAggMetric.getAggType() !== AGG_TYPE.COUNT;
- })
- .forEach(esAggMetric => {
+ this.getMetricFields().forEach(esAggMetric => {
+ const aggDsl = esAggMetric.getValueAggDsl(indexPattern);
+ if (aggDsl) {
valueAggsDsl[esAggMetric.getName()] = esAggMetric.getValueAggDsl(indexPattern);
- });
+ }
+ });
return valueAggsDsl;
}
diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/convert_to_geojson.test.ts b/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/convert_to_geojson.test.ts
index a8223c36df349a..e79d8e09fce9b6 100644
--- a/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/convert_to_geojson.test.ts
+++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/convert_to_geojson.test.ts
@@ -53,6 +53,7 @@ describe('convertCompositeRespToGeoJson', () => {
avg_of_bytes: 5359.2307692307695,
doc_count: 65,
'terms_of_machine.os.keyword': 'win xp',
+ 'terms_of_machine.os.keyword__percentage': 25,
},
type: 'Feature',
});
@@ -79,6 +80,7 @@ describe('convertCompositeRespToGeoJson', () => {
avg_of_bytes: 5359.2307692307695,
doc_count: 65,
'terms_of_machine.os.keyword': 'win xp',
+ 'terms_of_machine.os.keyword__percentage': 25,
},
type: 'Feature',
});
@@ -125,6 +127,7 @@ describe('convertRegularRespToGeoJson', () => {
avg_of_bytes: 5359.2307692307695,
doc_count: 65,
'terms_of_machine.os.keyword': 'win xp',
+ 'terms_of_machine.os.keyword__percentage': 25,
},
type: 'Feature',
});
@@ -151,6 +154,7 @@ describe('convertRegularRespToGeoJson', () => {
avg_of_bytes: 5359.2307692307695,
doc_count: 65,
'terms_of_machine.os.keyword': 'win xp',
+ 'terms_of_machine.os.keyword__percentage': 25,
},
type: 'Feature',
});
diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.d.ts b/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.d.ts
new file mode 100644
index 00000000000000..652409b61fd722
--- /dev/null
+++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.d.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 { AbstractESAggSource } from '../es_agg_source';
+import { ESGeoGridSourceDescriptor } from '../../../../common/descriptor_types';
+
+export class ESGeoGridSource extends AbstractESAggSource {
+ constructor(sourceDescriptor: ESGeoGridSourceDescriptor, inspectorAdapters: unknown);
+}
diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.js b/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.js
index b2463275dad0a7..4987d052b8ab70 100644
--- a/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.js
+++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.js
@@ -20,7 +20,6 @@ import { COLOR_GRADIENTS } from '../../styles/color_utils';
import { CreateSourceEditor } from './create_source_editor';
import { UpdateSourceEditor } from './update_source_editor';
import {
- AGG_TYPE,
DEFAULT_MAX_BUCKETS_LIMIT,
SOURCE_DATA_ID_ORIGIN,
ES_GEO_GRID,
@@ -297,10 +296,7 @@ export class ESGeoGridSource extends AbstractESAggSource {
let bucketsPerGrid = 1;
this.getMetricFields().forEach(metricField => {
- if (metricField.getAggType() === AGG_TYPE.TERMS) {
- // each terms aggregation increases the overall number of buckets per grid
- bucketsPerGrid++;
- }
+ bucketsPerGrid += metricField.getBucketCount();
});
const features =
diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_pew_pew_source/convert_to_lines.test.ts b/x-pack/legacy/plugins/maps/public/layers/sources/es_pew_pew_source/convert_to_lines.test.ts
index 5fbd5a3ad20c0e..14c62aa0207feb 100644
--- a/x-pack/legacy/plugins/maps/public/layers/sources/es_pew_pew_source/convert_to_lines.test.ts
+++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_pew_pew_source/convert_to_lines.test.ts
@@ -62,6 +62,7 @@ it('Should convert elasticsearch aggregation response into feature collection of
avg_of_FlightDelayMin: 3,
doc_count: 1,
terms_of_Carrier: 'ES-Air',
+ terms_of_Carrier__percentage: 100,
},
type: 'Feature',
});
diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_source.d.ts b/x-pack/legacy/plugins/maps/public/layers/sources/es_source.d.ts
index 2aaaad15d6321e..25c4fae89f0247 100644
--- a/x-pack/legacy/plugins/maps/public/layers/sources/es_source.d.ts
+++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_source.d.ts
@@ -5,5 +5,13 @@
*/
import { AbstractVectorSource } from './vector_source';
+import { IVectorSource } from './vector_source';
+import { IndexPattern } from '../../../../../../../src/plugins/data/public';
-export class AbstractESSource extends AbstractVectorSource {}
+export interface IESSource extends IVectorSource {
+ getIndexPattern(): Promise;
+}
+
+export class AbstractESSource extends AbstractVectorSource implements IESSource {
+ getIndexPattern(): Promise;
+}
diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_term_source.js b/x-pack/legacy/plugins/maps/public/layers/sources/es_term_source.js
index 30f60f543d38df..c12b4befc0684d 100644
--- a/x-pack/legacy/plugins/maps/public/layers/sources/es_term_source.js
+++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_term_source.js
@@ -105,7 +105,13 @@ export class ESTermSource extends AbstractESAggSource {
requestName: `${this._descriptor.indexPatternTitle}.${this._termField.getName()}`,
searchSource,
registerCancelCallback,
- requestDescription: this._getRequestDescription(leftSourceName, leftFieldName),
+ requestDescription: i18n.translate('xpack.maps.source.esJoin.joinDescription', {
+ defaultMessage: `Elasticsearch terms aggregation request, left source: {leftSource}, right source: {rightSource}`,
+ values: {
+ leftSource: `${leftSourceName}:${leftFieldName}`,
+ rightSource: `${this._descriptor.indexPatternTitle}:${this._termField.getName()}`,
+ },
+ }),
});
const countPropertyName = this.getAggKey(AGG_TYPE.COUNT);
@@ -118,30 +124,6 @@ export class ESTermSource extends AbstractESAggSource {
return false;
}
- _getRequestDescription(leftSourceName, leftFieldName) {
- const metrics = this.getMetricFields().map(esAggMetric => esAggMetric.getRequestDescription());
- const joinStatement = [];
- joinStatement.push(
- i18n.translate('xpack.maps.source.esJoin.joinLeftDescription', {
- defaultMessage: `Join {leftSourceName}:{leftFieldName} with`,
- values: { leftSourceName, leftFieldName },
- })
- );
- joinStatement.push(`${this._descriptor.indexPatternTitle}:${this._termField.getName()}`);
- joinStatement.push(
- i18n.translate('xpack.maps.source.esJoin.joinMetricsDescription', {
- defaultMessage: `for metrics {metrics}`,
- values: { metrics: metrics.join(',') },
- })
- );
- return i18n.translate('xpack.maps.source.esJoin.joinDescription', {
- defaultMessage: `Elasticsearch terms aggregation request for {description}`,
- values: {
- description: joinStatement.join(' '),
- },
- });
- }
-
async getDisplayName() {
//no need to localize. this is never rendered.
return `es_table ${this._descriptor.indexPatternId}`;
diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/vector_source.js b/x-pack/legacy/plugins/maps/public/layers/sources/vector_source.js
index 3952aacf03b334..8369ca562e14b3 100644
--- a/x-pack/legacy/plugins/maps/public/layers/sources/vector_source.js
+++ b/x-pack/legacy/plugins/maps/public/layers/sources/vector_source.js
@@ -54,7 +54,7 @@ export class AbstractVectorSource extends AbstractSource {
* factory function creating a new field-instance
* @param fieldName
* @param label
- * @returns {ESAggMetricField}
+ * @returns {IField}
*/
createField() {
throw new Error(`Should implemement ${this.constructor.type} ${this}`);
@@ -64,7 +64,7 @@ export class AbstractVectorSource extends AbstractSource {
* Retrieves a field. This may be an existing instance.
* @param fieldName
* @param label
- * @returns {ESAggMetricField}
+ * @returns {IField}
*/
getFieldByName(name) {
return this.createField({ fieldName: name });
diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js
index 19e80f330378b9..7b94e58f0e7d48 100644
--- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js
+++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js
@@ -152,10 +152,7 @@ export class DynamicStyleProperty extends AbstractStyleProperty {
async getFieldMetaRequest() {
if (this.isOrdinal()) {
- const fieldMetaOptions = this.getFieldMetaOptions();
- return this._field.getOrdinalFieldMetaRequest({
- sigma: _.get(fieldMetaOptions, 'sigma', DEFAULT_SIGMA),
- });
+ return this._field.getOrdinalFieldMetaRequest();
} else if (this.isCategorical()) {
return this._field.getCategoricalFieldMetaRequest();
} else {
diff --git a/x-pack/legacy/plugins/maps/public/layers/util/es_agg_utils.test.ts b/x-pack/legacy/plugins/maps/public/layers/util/es_agg_utils.test.ts
index 201d6907981a2b..445a7621194b7f 100644
--- a/x-pack/legacy/plugins/maps/public/layers/util/es_agg_utils.test.ts
+++ b/x-pack/legacy/plugins/maps/public/layers/util/es_agg_utils.test.ts
@@ -19,19 +19,34 @@ describe('extractPropertiesFromBucket', () => {
});
});
- test('Should extract bucket aggregation values', () => {
+ test('Should extract top bucket aggregation value and percentage', () => {
const properties = extractPropertiesFromBucket({
+ doc_count: 3,
'terms_of_machine.os.keyword': {
buckets: [
{
key: 'win xp',
- doc_count: 16,
+ doc_count: 1,
},
],
},
});
expect(properties).toEqual({
+ doc_count: 3,
'terms_of_machine.os.keyword': 'win xp',
+ 'terms_of_machine.os.keyword__percentage': 33,
+ });
+ });
+
+ test('Should handle empty top bucket aggregation', () => {
+ const properties = extractPropertiesFromBucket({
+ doc_count: 3,
+ 'terms_of_machine.os.keyword': {
+ buckets: [],
+ },
+ });
+ expect(properties).toEqual({
+ doc_count: 3,
});
});
});
diff --git a/x-pack/legacy/plugins/maps/public/layers/util/es_agg_utils.ts b/x-pack/legacy/plugins/maps/public/layers/util/es_agg_utils.ts
index 7af176acfaf467..9d4f24f80d6cd1 100644
--- a/x-pack/legacy/plugins/maps/public/layers/util/es_agg_utils.ts
+++ b/x-pack/legacy/plugins/maps/public/layers/util/es_agg_utils.ts
@@ -6,6 +6,7 @@
import { i18n } from '@kbn/i18n';
import _ from 'lodash';
import { IndexPattern, IFieldType } from '../../../../../../../src/plugins/data/public';
+import { TOP_TERM_PERCENTAGE_SUFFIX } from '../../../common/constants';
export function getField(indexPattern: IndexPattern, fieldName: string) {
const field = indexPattern.fields.getByName(fieldName);
@@ -42,7 +43,19 @@ export function extractPropertiesFromBucket(bucket: any, ignoreKeys: string[] =
if (_.has(bucket[key], 'value')) {
properties[key] = bucket[key].value;
} else if (_.has(bucket[key], 'buckets')) {
+ if (bucket[key].buckets.length === 0) {
+ // No top term
+ continue;
+ }
+
properties[key] = _.get(bucket[key], 'buckets[0].key');
+ const topBucketCount = bucket[key].buckets[0].doc_count;
+ const totalCount = bucket.doc_count;
+ if (totalCount && topBucketCount) {
+ properties[`${key}${TOP_TERM_PERCENTAGE_SUFFIX}`] = Math.round(
+ (topBucketCount / totalCount) * 100
+ );
+ }
} else {
properties[key] = bucket[key];
}
diff --git a/x-pack/legacy/plugins/maps/public/layers/util/is_metric_countable.js b/x-pack/legacy/plugins/maps/public/layers/util/is_metric_countable.ts
similarity index 85%
rename from x-pack/legacy/plugins/maps/public/layers/util/is_metric_countable.js
rename to x-pack/legacy/plugins/maps/public/layers/util/is_metric_countable.ts
index 69ccb8890d10cc..37916e53d6c45e 100644
--- a/x-pack/legacy/plugins/maps/public/layers/util/is_metric_countable.js
+++ b/x-pack/legacy/plugins/maps/public/layers/util/is_metric_countable.ts
@@ -6,6 +6,6 @@
import { AGG_TYPE } from '../../../common/constants';
-export function isMetricCountable(aggType) {
+export function isMetricCountable(aggType: AGG_TYPE): boolean {
return [AGG_TYPE.COUNT, AGG_TYPE.SUM, AGG_TYPE.UNIQUE_COUNT].includes(aggType);
}
diff --git a/x-pack/legacy/plugins/ml/public/application/app.tsx b/x-pack/legacy/plugins/ml/public/application/app.tsx
index 4c956bfabecc91..18545f31f03c73 100644
--- a/x-pack/legacy/plugins/ml/public/application/app.tsx
+++ b/x-pack/legacy/plugins/ml/public/application/app.tsx
@@ -25,9 +25,6 @@ export interface MlDependencies extends AppMountParameters {
data: DataPublicPluginStart;
security: SecurityPluginSetup;
licensing: LicensingPluginSetup;
- __LEGACY: {
- XSRF: string;
- };
}
interface AppProps {
@@ -49,7 +46,6 @@ const App: FC = ({ coreStart, deps }) => {
recentlyAccessed: coreStart.chrome!.recentlyAccessed,
basePath: coreStart.http.basePath,
savedObjectsClient: coreStart.savedObjects.client,
- XSRF: deps.__LEGACY.XSRF,
application: coreStart.application,
http: coreStart.http,
security: deps.security,
diff --git a/x-pack/legacy/plugins/ml/public/application/components/anomalies_table/anomalies_table_columns.js b/x-pack/legacy/plugins/ml/public/application/components/anomalies_table/anomalies_table_columns.js
index 23a40d9ecf295a..0c6c959927140a 100644
--- a/x-pack/legacy/plugins/ml/public/application/components/anomalies_table/anomalies_table_columns.js
+++ b/x-pack/legacy/plugins/ml/public/application/components/anomalies_table/anomalies_table_columns.js
@@ -281,7 +281,7 @@ export function getColumns(
defaultMessage: 'actions',
}),
render: item => {
- if (showLinksMenuForItem(item) === true) {
+ if (showLinksMenuForItem(item, showViewSeriesLink) === true) {
return (
{
- if (options && options.url) {
- let url = '';
- url = url + (options.url || '');
- const headers = getResultHeaders(options.headers ?? {});
-
- const allHeaders =
- options.headers === undefined ? headers : { ...options.headers, ...headers };
- const body = options.data === undefined ? null : JSON.stringify(options.data);
-
- const payload: RequestInit = {
- method: options.method || 'GET',
- headers: allHeaders,
- credentials: 'same-origin',
- };
-
- if (body !== null) {
- payload.body = body;
- }
+interface HttpOptions {
+ url: string;
+ method: string;
+ headers?: any;
+ data?: any;
+}
- fetch(url, payload)
- .then(resp => {
- resp
- .json()
- .then(resp.ok === true ? resolve : reject)
- .catch(resp.ok === true ? resolve : reject);
- })
- .catch(resp => {
- reject(resp);
- });
- } else {
- reject();
+/**
+ * Function for making HTTP requests to Kibana's backend.
+ * Wrapper for Kibana's HttpHandler.
+ */
+export async function http(options: HttpOptions) {
+ if (!options?.url) {
+ throw new Error('URL is missing');
+ }
+
+ try {
+ let url = '';
+ url = url + (options.url || '');
+ const headers = getResultHeaders(options.headers ?? {});
+
+ const allHeaders = options.headers === undefined ? headers : { ...options.headers, ...headers };
+ const body = options.data === undefined ? null : JSON.stringify(options.data);
+
+ const payload: RequestInit = {
+ method: options.method || 'GET',
+ headers: allHeaders,
+ credentials: 'same-origin',
+ };
+
+ if (body !== null) {
+ payload.body = body;
}
- });
+
+ return await getHttp().fetch(url, payload);
+ } catch (e) {
+ throw new Error(e);
+ }
}
interface RequestOptions extends RequestInit {
body: BodyInit | any;
}
+/**
+ * Function for making HTTP requests to Kibana's backend which returns an Observable
+ * with request cancellation support.
+ */
export function http$(url: string, options: RequestOptions): Observable {
const requestInit: RequestInit = {
...options,
@@ -75,13 +73,56 @@ export function http$(url: string, options: RequestOptions): Observable {
headers: getResultHeaders(options.headers ?? {}),
};
- return fromFetch(url, requestInit).pipe(
- switchMap(response => {
- if (response.ok) {
- return from(response.json() as Promise);
+ return fromHttpHandler(url, requestInit);
+}
+
+/**
+ * Creates an Observable from Kibana's HttpHandler.
+ */
+export function fromHttpHandler(input: string, init?: RequestInit): Observable {
+ return new Observable(subscriber => {
+ const controller = new AbortController();
+ const signal = controller.signal;
+
+ let abortable = true;
+ let unsubscribed = false;
+
+ if (init?.signal) {
+ if (init.signal.aborted) {
+ controller.abort();
} else {
- throw new Error(String(response.status));
+ init.signal.addEventListener('abort', () => {
+ if (!signal.aborted) {
+ controller.abort();
+ }
+ });
}
- })
- );
+ }
+
+ const perSubscriberInit: RequestInit = {
+ ...(init ? init : {}),
+ signal,
+ };
+
+ getHttp()
+ .fetch(input, perSubscriberInit)
+ .then(response => {
+ abortable = false;
+ subscriber.next(response);
+ subscriber.complete();
+ })
+ .catch(err => {
+ abortable = false;
+ if (!unsubscribed) {
+ subscriber.error(err);
+ }
+ });
+
+ return () => {
+ unsubscribed = true;
+ if (abortable) {
+ controller.abort();
+ }
+ };
+ });
}
diff --git a/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/index.js b/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/index.js
index 6fdc76d7244d3c..688abd1383ecb4 100644
--- a/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/index.js
+++ b/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/index.js
@@ -13,10 +13,9 @@ import { filters } from './filters';
import { results } from './results';
import { jobs } from './jobs';
import { fileDatavisualizer } from './datavisualizer';
-import { getBasePath } from '../../util/dependency_cache';
export function basePath() {
- return getBasePath().prepend('/api/ml');
+ return '/api/ml';
}
export const ml = {
@@ -452,7 +451,7 @@ export const ml = {
},
getIndices() {
- const tempBasePath = getBasePath().prepend('/api');
+ const tempBasePath = '/api';
return http({
url: `${tempBasePath}/index_management/indices`,
method: 'GET',
diff --git a/x-pack/legacy/plugins/ml/public/application/util/dependency_cache.ts b/x-pack/legacy/plugins/ml/public/application/util/dependency_cache.ts
index c167d7e7c3d42b..2a1ffe79d033cd 100644
--- a/x-pack/legacy/plugins/ml/public/application/util/dependency_cache.ts
+++ b/x-pack/legacy/plugins/ml/public/application/util/dependency_cache.ts
@@ -35,7 +35,6 @@ export interface DependencyCache {
autocomplete: DataPublicPluginStart['autocomplete'] | null;
basePath: IBasePath | null;
savedObjectsClient: SavedObjectsClientContract | null;
- XSRF: string | null;
application: ApplicationStart | null;
http: HttpStart | null;
security: SecurityPluginSetup | null;
@@ -54,7 +53,6 @@ const cache: DependencyCache = {
autocomplete: null,
basePath: null,
savedObjectsClient: null,
- XSRF: null,
application: null,
http: null,
security: null,
@@ -73,7 +71,6 @@ export function setDependencyCache(deps: Partial) {
cache.autocomplete = deps.autocomplete || null;
cache.basePath = deps.basePath || null;
cache.savedObjectsClient = deps.savedObjectsClient || null;
- cache.XSRF = deps.XSRF || null;
cache.application = deps.application || null;
cache.http = deps.http || null;
cache.security = deps.security || null;
@@ -162,13 +159,6 @@ export function getSavedObjectsClient() {
return cache.savedObjectsClient;
}
-export function getXSRF() {
- if (cache.XSRF === null) {
- throw new Error("xsrf hasn't been initialized");
- }
- return cache.XSRF;
-}
-
export function getApplication() {
if (cache.application === null) {
throw new Error("application hasn't been initialized");
diff --git a/x-pack/legacy/plugins/ml/public/legacy.ts b/x-pack/legacy/plugins/ml/public/legacy.ts
index 0c6c0bd8dd29e2..9fb53e78d9454a 100644
--- a/x-pack/legacy/plugins/ml/public/legacy.ts
+++ b/x-pack/legacy/plugins/ml/public/legacy.ts
@@ -4,7 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import chrome from 'ui/chrome';
import { npSetup, npStart } from 'ui/new_platform';
import { PluginInitializerContext } from 'src/core/public';
import { SecurityPluginSetup } from '../../../../plugins/security/public';
@@ -26,8 +25,5 @@ export const setup = pluginInstance.setup(npSetup.core, {
data: npStart.plugins.data,
security: setupDependencies.security,
licensing: setupDependencies.licensing,
- __LEGACY: {
- XSRF: chrome.getXsrfToken(),
- },
});
export const start = pluginInstance.start(npStart.core, npStart.plugins);
diff --git a/x-pack/legacy/plugins/ml/public/plugin.ts b/x-pack/legacy/plugins/ml/public/plugin.ts
index c0369a74c070ac..7b3a5f6fadfaca 100644
--- a/x-pack/legacy/plugins/ml/public/plugin.ts
+++ b/x-pack/legacy/plugins/ml/public/plugin.ts
@@ -8,7 +8,7 @@ import { Plugin, CoreStart, CoreSetup } from 'src/core/public';
import { MlDependencies } from './application/app';
export class MlPlugin implements Plugin {
- setup(core: CoreSetup, { data, security, licensing, __LEGACY }: MlDependencies) {
+ setup(core: CoreSetup, { data, security, licensing }: MlDependencies) {
core.application.register({
id: 'ml',
title: 'Machine learning',
@@ -21,7 +21,6 @@ export class MlPlugin implements Plugin {
onAppLeave: params.onAppLeave,
history: params.history,
data,
- __LEGACY,
security,
licensing,
});
diff --git a/x-pack/legacy/plugins/reporting/__snapshots__/index.test.js.snap b/x-pack/legacy/plugins/reporting/__snapshots__/index.test.js.snap
index 469f5e6e7b3c6e..757677f1d4f826 100644
--- a/x-pack/legacy/plugins/reporting/__snapshots__/index.test.js.snap
+++ b/x-pack/legacy/plugins/reporting/__snapshots__/index.test.js.snap
@@ -47,6 +47,11 @@ Object {
},
"settleTime": 1000,
"timeout": 20000,
+ "timeouts": Object {
+ "openUrl": 30000,
+ "renderComplete": 30000,
+ "waitForElements": 30000,
+ },
"viewport": Object {
"height": 1200,
"width": 1950,
@@ -138,6 +143,11 @@ Object {
},
"settleTime": 1000,
"timeout": 20000,
+ "timeouts": Object {
+ "openUrl": 30000,
+ "renderComplete": 30000,
+ "waitForElements": 30000,
+ },
"viewport": Object {
"height": 1200,
"width": 1950,
@@ -228,6 +238,11 @@ Object {
},
"settleTime": 1000,
"timeout": 20000,
+ "timeouts": Object {
+ "openUrl": 30000,
+ "renderComplete": 30000,
+ "waitForElements": 30000,
+ },
"viewport": Object {
"height": 1200,
"width": 1950,
@@ -319,6 +334,11 @@ Object {
},
"settleTime": 1000,
"timeout": 20000,
+ "timeouts": Object {
+ "openUrl": 30000,
+ "renderComplete": 30000,
+ "waitForElements": 30000,
+ },
"viewport": Object {
"height": 1200,
"width": 1950,
diff --git a/x-pack/legacy/plugins/reporting/config.ts b/x-pack/legacy/plugins/reporting/config.ts
index 34fc1f452fbc06..211fa70301bbf0 100644
--- a/x-pack/legacy/plugins/reporting/config.ts
+++ b/x-pack/legacy/plugins/reporting/config.ts
@@ -31,6 +31,17 @@ export async function config(Joi: any) {
.default(120000),
}).default(),
capture: Joi.object({
+ timeouts: Joi.object({
+ openUrl: Joi.number()
+ .integer()
+ .default(30000),
+ waitForElements: Joi.number()
+ .integer()
+ .default(30000),
+ renderComplete: Joi.number()
+ .integer()
+ .default(30000),
+ }).default(),
networkPolicy: Joi.object({
enabled: Joi.boolean().default(true),
rules: Joi.array()
diff --git a/x-pack/legacy/plugins/reporting/export_types/common/constants.ts b/x-pack/legacy/plugins/reporting/export_types/common/constants.ts
index 02a3e787da750b..254cfbaa878bdf 100644
--- a/x-pack/legacy/plugins/reporting/export_types/common/constants.ts
+++ b/x-pack/legacy/plugins/reporting/export_types/common/constants.ts
@@ -9,4 +9,4 @@ export const LayoutTypes = {
PRINT: 'print',
};
-export const WAITFOR_SELECTOR = '.application';
+export const PAGELOAD_SELECTOR = '.application';
diff --git a/x-pack/legacy/plugins/reporting/export_types/common/layouts/layout.ts b/x-pack/legacy/plugins/reporting/export_types/common/layouts/layout.ts
index 54fae60a0773c0..2c43517dbcaa91 100644
--- a/x-pack/legacy/plugins/reporting/export_types/common/layouts/layout.ts
+++ b/x-pack/legacy/plugins/reporting/export_types/common/layouts/layout.ts
@@ -27,7 +27,6 @@ export interface LayoutSelectorDictionary {
renderComplete: string;
itemsCountAttribute: string;
timefilterDurationAttribute: string;
- toastHeader: string;
}
export interface PdfImageSize {
@@ -40,7 +39,6 @@ export const getDefaultLayoutSelectors = (): LayoutSelectorDictionary => ({
renderComplete: '[data-shared-item]',
itemsCountAttribute: 'data-shared-items-count',
timefilterDurationAttribute: 'data-shared-timefilter-duration',
- toastHeader: '[data-test-subj="euiToastHeader"]',
});
export abstract class Layout {
@@ -75,9 +73,11 @@ export interface LayoutParams {
dimensions: Size;
}
-export type LayoutInstance = Layout & {
+interface LayoutSelectors {
// Fields that are not part of Layout: the instances
// independently implement these fields on their own
selectors: LayoutSelectorDictionary;
positionElements?: (browser: HeadlessChromiumDriver, logger: LevelLogger) => Promise;
-};
+}
+
+export type LayoutInstance = Layout & LayoutSelectors & Size;
diff --git a/x-pack/legacy/plugins/reporting/export_types/common/layouts/preserve_layout.ts b/x-pack/legacy/plugins/reporting/export_types/common/layouts/preserve_layout.ts
index cfa421b6f66abf..07dbba7d25883c 100644
--- a/x-pack/legacy/plugins/reporting/export_types/common/layouts/preserve_layout.ts
+++ b/x-pack/legacy/plugins/reporting/export_types/common/layouts/preserve_layout.ts
@@ -19,8 +19,8 @@ const ZOOM: number = 2;
export class PreserveLayout extends Layout {
public readonly selectors: LayoutSelectorDictionary = getDefaultLayoutSelectors();
public readonly groupCount = 1;
- private readonly height: number;
- private readonly width: number;
+ public readonly height: number;
+ public readonly width: number;
private readonly scaledHeight: number;
private readonly scaledWidth: number;
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
deleted file mode 100644
index c888870bd2bc35..00000000000000
--- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/check_for_toast.ts
+++ /dev/null
@@ -1,58 +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 { i18n } from '@kbn/i18n';
-import { ElementHandle } from 'puppeteer';
-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,
- layout: LayoutInstance,
- logger: LevelLogger
-): Promise> => {
- return await browser
- .waitForSelector(layout.selectors.toastHeader, { silent: true }, logger)
- .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;
- },
- args: [layout.selectors.toastHeader],
- },
- { context: CONTEXT_CHECKFORTOASTMESSAGE },
- logger
- );
-
- // Log an error to track the event in kibana server logs
- logger.error(
- i18n.translate(
- 'xpack.reporting.exportTypes.printablePdf.screenshots.unexpectedErrorMessage',
- {
- defaultMessage: 'Encountered an unexpected message on the page: {toastHeaderText}',
- values: { toastHeaderText },
- }
- )
- );
-
- // Throw an error to fail the screenshot job with a message
- throw new Error(
- i18n.translate(
- 'xpack.reporting.exportTypes.printablePdf.screenshots.unexpectedErrorMessage',
- {
- defaultMessage: 'Encountered an unexpected message on the page: {toastHeaderText}',
- values: { toastHeaderText },
- }
- )
- );
- });
-};
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
index bbc97ca57940c9..a3faf9337524ee 100644
--- 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
@@ -9,6 +9,6 @@ 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';
+export const CONTEXT_READMETADATA = 'ReadVisualizationsMetadata';
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 4302f4c631e3cd..2f93765165e50d 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,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
+import { i18n } from '@kbn/i18n';
import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers';
import { LayoutInstance } from '../../layouts/layout';
import { AttributesMap, ElementsPositionAndAttribute } from './types';
@@ -14,50 +15,58 @@ export const getElementPositionAndAttributes = async (
browser: HeadlessBrowser,
layout: LayoutInstance,
logger: Logger
-): Promise => {
- const elementsPositionAndAttributes: ElementsPositionAndAttribute[] = await browser.evaluate(
- {
- fn: (selector: string, attributes: any) => {
- const elements: NodeListOf = document.querySelectorAll(selector);
+): Promise => {
+ const { screenshot: screenshotSelector } = layout.selectors; // data-shared-items-container
+ let elementsPositionAndAttributes: ElementsPositionAndAttribute[] | null;
+ try {
+ elementsPositionAndAttributes = await browser.evaluate(
+ {
+ fn: (selector, attributes) => {
+ 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 as any)[key] = element.getAttribute(attribute);
- return result;
- }, {} as AttributesMap),
- });
- }
- 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: [screenshotSelector, { 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(
- `No shared items containers were found on the page! Reporting requires a container element with the '${layout.selectors.screenshot}' attribute on the page.`
+ { context: CONTEXT_ELEMENTATTRIBUTES },
+ logger
);
+
+ if (!elementsPositionAndAttributes || elementsPositionAndAttributes.length === 0) {
+ throw new Error(
+ i18n.translate('xpack.reporting.screencapture.noElements', {
+ defaultMessage: `An error occurred while reading the page for visualization panels: no panels were found.`,
+ })
+ );
+ }
+ } catch (err) {
+ elementsPositionAndAttributes = null;
}
return elementsPositionAndAttributes;
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 1beae719cd6b00..16eb433e8a75e3 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,38 +4,72 @@
* you may not use this file except in compliance with the Elastic License.
*/
+import { i18n } from '@kbn/i18n';
import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers';
import { LevelLogger } from '../../../../server/lib';
+import { ServerFacade } from '../../../../types';
import { LayoutInstance } from '../../layouts/layout';
-import { CONTEXT_GETNUMBEROFITEMS } from './constants';
+import { CONTEXT_GETNUMBEROFITEMS, CONTEXT_READMETADATA } from './constants';
export const getNumberOfItems = async (
+ server: ServerFacade,
browser: HeadlessBrowser,
layout: LayoutInstance,
logger: LevelLogger
): Promise => {
- logger.debug('determining how many rendered items to wait for');
+ const config = server.config();
+ const { renderComplete: renderCompleteSelector, itemsCountAttribute } = layout.selectors;
+ let itemsCount: number;
- // 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);
+ logger.debug(
+ i18n.translate('xpack.reporting.screencapture.logWaitingForElements', {
+ defaultMessage: 'waiting for elements or items count attribute; or not found to interrupt',
+ })
+ );
+
+ try {
+ // the dashboard is using the `itemsCountAttribute` attribute to let us
+ // know how many items to expect since gridster incrementally adds panels
+ // we have to use this hint to wait for all of them
+ await browser.waitForSelector(
+ `${renderCompleteSelector},[${itemsCountAttribute}]`,
+ { timeout: config.get('xpack.reporting.capture.timeouts.waitForElements') },
+ { context: CONTEXT_READMETADATA },
+ logger
+ );
+
+ // returns the value of the `itemsCountAttribute` if it's there, otherwise
+ // we just count the number of `itemSelector`: the number of items already rendered
+ itemsCount = 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: [renderCompleteSelector, itemsCountAttribute],
},
- args: [layout.selectors.renderComplete, layout.selectors.itemsCountAttribute],
- },
- { context: CONTEXT_GETNUMBEROFITEMS },
- logger
- );
+ { context: CONTEXT_GETNUMBEROFITEMS },
+ logger
+ );
+ } catch (err) {
+ throw new Error(
+ i18n.translate('xpack.reporting.screencapture.readVisualizationsError', {
+ defaultMessage: `An error occurred when trying to read the page for visualization panel info. You may need to increase '{configKey}'. {error}`,
+ values: {
+ error: err,
+ configKey: 'xpack.reporting.capture.timeouts.waitForElements',
+ },
+ })
+ );
+ itemsCount = 1;
+ }
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 b21d1e752ba3f5..d50ac64743f078 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,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
+import { i18n } from '@kbn/i18n';
import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers';
import { LevelLogger } from '../../../../server/lib';
import { Screenshot, ElementsPositionAndAttribute } from './types';
@@ -12,21 +13,29 @@ const getAsyncDurationLogger = (logger: LevelLogger) => {
return async (description: string, promise: Promise) => {
const start = Date.now();
const result = await promise;
- logger.debug(`${description} took ${Date.now() - start}ms`);
+ logger.debug(
+ i18n.translate('xpack.reporting.screencapture.asyncTook', {
+ defaultMessage: '{description} took {took}ms',
+ values: {
+ description,
+ took: Date.now() - start,
+ },
+ })
+ );
return result;
};
};
-export const getScreenshots = async ({
- browser,
- elementsPositionAndAttributes,
- logger,
-}: {
- logger: LevelLogger;
- browser: HeadlessBrowser;
- elementsPositionAndAttributes: ElementsPositionAndAttribute[];
-}): Promise => {
- logger.info(`taking screenshots`);
+export const getScreenshots = async (
+ browser: HeadlessBrowser,
+ elementsPositionAndAttributes: ElementsPositionAndAttribute[],
+ logger: LevelLogger
+): Promise => {
+ logger.info(
+ i18n.translate('xpack.reporting.screencapture.takingScreenshots', {
+ defaultMessage: `taking screenshots`,
+ })
+ );
const asyncDurationLogger = getAsyncDurationLogger(logger);
const screenshots: Screenshot[] = [];
@@ -45,7 +54,14 @@ export const getScreenshots = async ({
});
}
- logger.info(`screenshots taken: ${screenshots.length}`);
+ logger.info(
+ i18n.translate('xpack.reporting.screencapture.screenshotsTaken', {
+ defaultMessage: `screenshots taken: {numScreenhots}`,
+ values: {
+ numScreenhots: screenshots.length,
+ },
+ })
+ );
return screenshots;
};
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 40204804a276f6..cb2673e85186ba 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
@@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
+import { i18n } from '@kbn/i18n';
import fs from 'fs';
import { promisify } from 'util';
import { LevelLogger } from '../../../../server/lib';
@@ -18,21 +19,34 @@ export const injectCustomCss = async (
layout: Layout,
logger: LevelLogger
): Promise => {
- logger.debug('injecting custom css');
+ logger.debug(
+ i18n.translate('xpack.reporting.screencapture.injectingCss', {
+ defaultMessage: 'injecting custom css',
+ })
+ );
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);
+ try {
+ 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
- );
+ { context: CONTEXT_INJECTCSS },
+ logger
+ );
+ } catch (err) {
+ throw new Error(
+ i18n.translate('xpack.reporting.screencapture.injectCss', {
+ defaultMessage: `An error occurred when trying to update Kibana CSS for reporting. {error}`,
+ values: { error: err },
+ })
+ );
+ }
};
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
index 9f8e218f4f614a..13d07bcdd6baf7 100644
--- 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
@@ -23,7 +23,6 @@ import {
createMockBrowserDriverFactory,
createMockLayoutInstance,
createMockServer,
- mockSelectors,
} from '../../../../test_helpers';
import { ConditionalHeaders, HeadlessChromiumDriver } from '../../../../types';
import { screenshotsObservableFactory } from './observable';
@@ -61,6 +60,7 @@ describe('Screenshot Observable Pipeline', () => {
expect(result).toMatchInlineSnapshot(`
Array [
Object {
+ "error": undefined,
"screenshots": Array [
Object {
"base64EncodedData": "allyourBase64 of boundingClientRect,scroll",
@@ -98,6 +98,7 @@ describe('Screenshot Observable Pipeline', () => {
expect(result).toMatchInlineSnapshot(`
Array [
Object {
+ "error": undefined,
"screenshots": Array [
Object {
"base64EncodedData": "allyourBase64 screenshots",
@@ -108,6 +109,7 @@ describe('Screenshot Observable Pipeline', () => {
"timeRange": "Default GetTimeRange Result",
},
Object {
+ "error": undefined,
"screenshots": Array [
Object {
"base64EncodedData": "allyourBase64 screenshots",
@@ -122,15 +124,10 @@ describe('Screenshot Observable Pipeline', () => {
});
describe('error handling', () => {
- it('fails if error toast message is found', async () => {
+ it('recovers if waitForSelector fails', 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();
+ throw new Error('Mock error!');
});
// mocks
@@ -153,12 +150,35 @@ describe('Screenshot Observable Pipeline', () => {
}).toPromise();
};
- await expect(getScreenshot()).rejects.toMatchInlineSnapshot(
- `[Error: Encountered an unexpected message on the page: Toast Message]`
- );
+ await expect(getScreenshot()).resolves.toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "error": [Error: An error occurred when trying to read the page for visualization panel info. You may need to increase 'xpack.reporting.capture.timeouts.waitForElements'. Error: Mock error!],
+ "screenshots": Array [
+ Object {
+ "base64EncodedData": "allyourBase64 of boundingClientRect,scroll",
+ "description": undefined,
+ "title": undefined,
+ },
+ ],
+ "timeRange": null,
+ },
+ Object {
+ "error": [Error: An error occurred when trying to read the page for visualization panel info. You may need to increase 'xpack.reporting.capture.timeouts.waitForElements'. Error: Mock error!],
+ "screenshots": Array [
+ Object {
+ "base64EncodedData": "allyourBase64 of boundingClientRect,scroll",
+ "description": undefined,
+ "title": undefined,
+ },
+ ],
+ "timeRange": null,
+ },
+ ]
+ `);
});
- it('fails if exit$ fires a timeout or error signal', async () => {
+ it('recovers if exit$ fires a timeout signal', async () => {
// mocks
const mockGetCreatePage = (driver: HeadlessChromiumDriver) =>
jest
@@ -188,7 +208,21 @@ describe('Screenshot Observable Pipeline', () => {
}).toPromise();
};
- await expect(getScreenshot()).rejects.toMatchInlineSnapshot(`"Instant timeout has fired!"`);
+ await expect(getScreenshot()).resolves.toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "error": "Instant timeout has fired!",
+ "screenshots": Array [
+ Object {
+ "base64EncodedData": "allyourBase64 of boundingClientRect,scroll",
+ "description": undefined,
+ "title": undefined,
+ },
+ ],
+ "timeRange": null,
+ },
+ ]
+ `);
});
});
});
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
index d429931602951b..878a9d3b873932 100644
--- 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
@@ -5,19 +5,18 @@
*/
import * as Rx from 'rxjs';
-import { concatMap, first, mergeMap, take, toArray } from 'rxjs/operators';
+import { catchError, concatMap, first, mergeMap, take, takeUntil, 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';
+import { ScreenSetupData, ScreenshotObservableOpts, ScreenshotResults } from './types';
+import { waitForRenderComplete } from './wait_for_render';
+import { waitForVisualizations } from './wait_for_visualizations';
+import { injectCustomCss } from './inject_css';
export function screenshotsObservableFactory(
server: ServerFacade,
@@ -41,16 +40,16 @@ export function screenshotsObservableFactory(
concatMap(url => {
return create$.pipe(
mergeMap(({ driver, exit$ }) => {
- const screenshot$ = Rx.of(1).pipe(
- mergeMap(() => openUrl(driver, url, conditionalHeaders, logger)),
+ const setup$: Rx.Observable = Rx.of(1).pipe(
+ takeUntil(exit$),
+ mergeMap(() => openUrl(server, driver, url, conditionalHeaders, logger)),
mergeMap(() => skipTelemetry(driver, logger)),
- mergeMap(() => scanPage(driver, layout, logger)),
- mergeMap(() => getNumberOfItems(driver, layout, logger)),
+ mergeMap(() => getNumberOfItems(server, driver, layout, logger)),
mergeMap(async itemsCount => {
const viewport = layout.getViewport(itemsCount);
await Promise.all([
driver.setViewport(viewport, logger),
- waitForElementsToBeInDOM(driver, itemsCount, layout, logger),
+ waitForVisualizations(server, driver, itemsCount, layout, logger),
]);
}),
mergeMap(async () => {
@@ -63,28 +62,35 @@ export function screenshotsObservableFactory(
await layout.positionElements(driver, logger);
}
- await waitForRenderComplete(captureConfig, driver, layout, logger);
+ await waitForRenderComplete(driver, layout, captureConfig, 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,
- });
+ mergeMap(async () => {
+ return await Promise.all([
+ getTimeRange(driver, layout, logger),
+ getElementPositionAndAttributes(driver, layout, logger),
+ ]).then(([timeRange, elementsPositionAndAttributes]) => ({
+ elementsPositionAndAttributes,
+ timeRange,
+ }));
+ }),
+ catchError(err => {
+ logger.error(err);
+ return Rx.of({ elementsPositionAndAttributes: null, timeRange: null, error: err });
+ })
+ );
- return { timeRange, screenshots };
+ return setup$.pipe(
+ mergeMap(
+ async (data: ScreenSetupData): Promise => {
+ const elements = data.elementsPositionAndAttributes
+ ? data.elementsPositionAndAttributes
+ : getDefaultElementPosition(layout.getViewport(1));
+ const screenshots = await getScreenshots(driver, elements, logger);
+ const { timeRange, error: setupError } = data;
+ return { timeRange, screenshots, error: setupError };
}
)
);
-
- return Rx.race(screenshot$, exit$);
}),
first()
);
@@ -94,3 +100,18 @@ export function screenshotsObservableFactory(
);
};
}
+
+/*
+ * If an error happens setting up the page, we don't know if there actually
+ * are any visualizations showing. These defaults should help capture the page
+ * enough for the user to see the error themselves
+ */
+const getDefaultElementPosition = ({ height, width }: { height: number; width: number }) => [
+ {
+ position: {
+ boundingClientRect: { top: 0, left: 0, height, width },
+ scroll: { x: 0, y: 0 },
+ },
+ attributes: {},
+ },
+];
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 e465499f839f91..fbae1f91a7a6a7 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
@@ -4,23 +4,40 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { ConditionalHeaders } from '../../../../types';
+import { i18n } from '@kbn/i18n';
+import { ConditionalHeaders, ServerFacade } from '../../../../types';
import { LevelLogger } from '../../../../server/lib';
import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers';
-import { WAITFOR_SELECTOR } from '../../constants';
+import { PAGELOAD_SELECTOR } from '../../constants';
export const openUrl = async (
+ server: ServerFacade,
browser: HeadlessBrowser,
url: string,
conditionalHeaders: ConditionalHeaders,
logger: LevelLogger
): Promise => {
- await browser.open(
- url,
- {
- conditionalHeaders,
- waitForSelector: WAITFOR_SELECTOR,
- },
- logger
- );
+ const config = server.config();
+
+ try {
+ await browser.open(
+ url,
+ {
+ conditionalHeaders,
+ waitForSelector: PAGELOAD_SELECTOR,
+ timeout: config.get('xpack.reporting.capture.timeouts.openUrl'),
+ },
+ logger
+ );
+ } catch (err) {
+ throw new Error(
+ i18n.translate('xpack.reporting.screencapture.couldntLoadKibana', {
+ defaultMessage: `An error occurred when trying to open the Kibana URL. You may need to increase '{configKey}'. {error}`,
+ values: {
+ configKey: 'xpack.reporting.capture.timeouts.openUrl',
+ error: err,
+ },
+ })
+ );
+ }
};
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
deleted file mode 100644
index 010ffe8f23afce..00000000000000
--- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/scan_page.ts
+++ /dev/null
@@ -1,30 +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 * as Rx from 'rxjs';
-import { HeadlessChromiumDriver } from '../../../../server/browsers';
-import { LevelLogger } from '../../../../server/lib';
-import { LayoutInstance } from '../../layouts/layout';
-import { checkForToastMessage } from './check_for_toast';
-
-export function scanPage(
- browser: HeadlessChromiumDriver,
- layout: LayoutInstance,
- logger: LevelLogger
-) {
- logger.debug('waiting for elements or items count attribute; or not found to interrupt');
-
- // the dashboard is using the `itemsCountAttribute` attribute to let us
- // know how many items to expect since gridster incrementally adds panels
- // we have to use this hint to wait for all of them
- const renderSuccess = browser.waitForSelector(
- `${layout.selectors.renderComplete},[${layout.selectors.itemsCountAttribute}]`,
- {},
- logger
- );
- const renderError = checkForToastMessage(browser, layout, logger);
- return Rx.race(Rx.from(renderSuccess), Rx.from(renderError));
-}
diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/types.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/types.ts
index 78cd42f0cae2fd..ab81a952f345ce 100644
--- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/types.ts
+++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/types.ts
@@ -35,7 +35,14 @@ export interface Screenshot {
description: string;
}
+export interface ScreenSetupData {
+ elementsPositionAndAttributes: ElementsPositionAndAttribute[] | null;
+ timeRange: TimeRange | null;
+ error?: Error;
+}
+
export interface ScreenshotResults {
timeRange: TimeRange | null;
screenshots: Screenshot[];
+ error?: Error;
}
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
deleted file mode 100644
index c958585f78e0df..00000000000000
--- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_dom_elements.ts
+++ /dev/null
@@ -1,34 +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 { 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,
- itemsCount: number,
- layout: LayoutInstance,
- logger: LevelLogger
-): Promise => {
- logger.debug(`waiting for ${itemsCount} rendered elements to be in the DOM`);
-
- await browser.waitFor(
- {
- fn: selector => {
- return document.querySelectorAll(selector).length;
- },
- 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 632f008ca63bcc..2f6dc2829dfd8d 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
@@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
+import { i18n } from '@kbn/i18n';
import { CaptureConfig } from '../../../../types';
import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers';
import { LevelLogger } from '../../../../server/lib';
@@ -11,12 +12,16 @@ import { LayoutInstance } from '../../layouts/layout';
import { CONTEXT_WAITFORRENDER } from './constants';
export const waitForRenderComplete = async (
- captureConfig: CaptureConfig,
browser: HeadlessBrowser,
layout: LayoutInstance,
+ captureConfig: CaptureConfig,
logger: LevelLogger
) => {
- logger.debug('waiting for rendering to complete');
+ logger.debug(
+ i18n.translate('xpack.reporting.screencapture.waitingForRenderComplete', {
+ defaultMessage: 'waiting for rendering to complete',
+ })
+ );
return await browser
.evaluate(
@@ -66,6 +71,10 @@ export const waitForRenderComplete = async (
logger
)
.then(() => {
- logger.debug('rendering is complete');
+ logger.debug(
+ i18n.translate('xpack.reporting.screencapture.renderIsComplete', {
+ defaultMessage: 'rendering is complete',
+ })
+ );
});
};
diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_visualizations.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_visualizations.ts
new file mode 100644
index 00000000000000..93ad40026dff81
--- /dev/null
+++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_visualizations.ts
@@ -0,0 +1,67 @@
+/*
+ * 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 { i18n } from '@kbn/i18n';
+import { ServerFacade } from '../../../../types';
+import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers';
+import { LevelLogger } from '../../../../server/lib';
+import { LayoutInstance } from '../../layouts/layout';
+import { CONTEXT_WAITFORELEMENTSTOBEINDOM } from './constants';
+
+type SelectorArgs = Record;
+
+const getCompletedItemsCount = ({ renderCompleteSelector }: SelectorArgs) => {
+ return document.querySelectorAll(renderCompleteSelector).length;
+};
+
+/*
+ * 1. Wait for the visualization metadata to be found in the DOM
+ * 2. Read the metadata for the number of visualization items
+ * 3. Wait for the render complete event to be fired once for each item
+ */
+export const waitForVisualizations = async (
+ server: ServerFacade,
+ browser: HeadlessBrowser,
+ itemsCount: number,
+ layout: LayoutInstance,
+ logger: LevelLogger
+): Promise => {
+ const config = server.config();
+ const { renderComplete: renderCompleteSelector } = layout.selectors;
+
+ logger.debug(
+ i18n.translate('xpack.reporting.screencapture.waitingForRenderedElements', {
+ defaultMessage: `waiting for {itemsCount} rendered elements to be in the DOM`,
+ values: { itemsCount },
+ })
+ );
+
+ try {
+ await browser.waitFor(
+ {
+ fn: getCompletedItemsCount,
+ args: [{ renderCompleteSelector }],
+ toEqual: itemsCount,
+ timeout: config.get('xpack.reporting.capture.timeouts.renderComplete'),
+ },
+ { context: CONTEXT_WAITFORELEMENTSTOBEINDOM },
+ logger
+ );
+
+ logger.debug(`found ${itemsCount} rendered elements in the DOM`);
+ } catch (err) {
+ throw new Error(
+ i18n.translate('xpack.reporting.screencapture.couldntFinishRendering', {
+ defaultMessage: `An error occurred when trying to wait for {count} visualizations to finish rendering. You may need to increase '{configKey}'. {error}`,
+ values: {
+ count: itemsCount,
+ configKey: 'xpack.reporting.capture.timeouts.renderComplete',
+ error: err,
+ },
+ })
+ );
+ }
+};
diff --git a/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.test.js b/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.test.js
index c0c21119e1d53c..e2e6ba1b890963 100644
--- a/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.test.js
+++ b/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.test.js
@@ -114,7 +114,7 @@ test(`returns content of generatePng getBuffer base64 encoded`, async () => {
const testContent = 'test content';
const generatePngObservable = generatePngObservableFactory();
- generatePngObservable.mockReturnValue(Rx.of(Buffer.from(testContent)));
+ generatePngObservable.mockReturnValue(Rx.of({ buffer: Buffer.from(testContent) }));
const executeJob = await executeJobFactory(
mockReporting,
diff --git a/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.ts b/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.ts
index 5cde2450809149..8670f0027af89e 100644
--- a/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.ts
+++ b/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.ts
@@ -4,17 +4,23 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import * as Rx from 'rxjs';
import { ElasticsearchServiceSetup } from 'kibana/server';
+import * as Rx from 'rxjs';
import { catchError, map, mergeMap, takeUntil } from 'rxjs/operators';
-import { ReportingCore } from '../../../../server';
import { PNG_JOB_TYPE } from '../../../../common/constants';
-import { ServerFacade, ExecuteJobFactory, ESQueueWorkerExecuteFn, Logger } from '../../../../types';
+import { ReportingCore } from '../../../../server';
+import {
+ ESQueueWorkerExecuteFn,
+ ExecuteJobFactory,
+ JobDocOutput,
+ Logger,
+ ServerFacade,
+} from '../../../../types';
import {
decryptJobHeaders,
- omitBlacklistedHeaders,
getConditionalHeaders,
getFullUrls,
+ omitBlacklistedHeaders,
} from '../../../common/execute_job/';
import { JobDocPayloadPNG } from '../../types';
import { generatePngObservableFactory } from '../lib/generate_png';
@@ -33,7 +39,7 @@ export const executeJobFactory: QueuedPngExecutorFactory = async function execut
return function executeJob(jobId: string, job: JobDocPayloadPNG, cancellationToken: any) {
const jobLogger = logger.clone([jobId]);
- const process$ = Rx.of(1).pipe(
+ const process$: Rx.Observable = Rx.of(1).pipe(
mergeMap(() => decryptJobHeaders({ server, job, logger })),
map(decryptedHeaders => omitBlacklistedHeaders({ job, decryptedHeaders })),
map(filteredHeaders => getConditionalHeaders({ server, job, filteredHeaders })),
@@ -48,11 +54,12 @@ export const executeJobFactory: QueuedPngExecutorFactory = async function execut
job.layout
);
}),
- map((buffer: Buffer) => {
+ map(({ buffer, warnings }) => {
return {
content_type: 'image/png',
content: buffer.toString('base64'),
size: buffer.byteLength,
+ warnings,
};
}),
catchError(err => {
diff --git a/x-pack/legacy/plugins/reporting/export_types/png/server/lib/generate_png.ts b/x-pack/legacy/plugins/reporting/export_types/png/server/lib/generate_png.ts
index 600762c451a791..88e91982adc632 100644
--- a/x-pack/legacy/plugins/reporting/export_types/png/server/lib/generate_png.ts
+++ b/x-pack/legacy/plugins/reporting/export_types/png/server/lib/generate_png.ts
@@ -7,10 +7,11 @@
import * as Rx from 'rxjs';
import { map } from 'rxjs/operators';
import { LevelLogger } from '../../../../server/lib';
-import { ServerFacade, HeadlessChromiumDriverFactory, ConditionalHeaders } from '../../../../types';
-import { screenshotsObservableFactory } from '../../../common/lib/screenshots';
-import { PreserveLayout } from '../../../common/layouts/preserve_layout';
+import { ConditionalHeaders, HeadlessChromiumDriverFactory, ServerFacade } from '../../../../types';
import { LayoutParams } from '../../../common/layouts/layout';
+import { PreserveLayout } from '../../../common/layouts/preserve_layout';
+import { screenshotsObservableFactory } from '../../../common/lib/screenshots';
+import { ScreenshotResults } from '../../../common/lib/screenshots/types';
export function generatePngObservableFactory(
server: ServerFacade,
@@ -24,7 +25,7 @@ export function generatePngObservableFactory(
browserTimezone: string,
conditionalHeaders: ConditionalHeaders,
layoutParams: LayoutParams
- ): Rx.Observable {
+ ): Rx.Observable<{ buffer: Buffer; warnings: string[] }> {
if (!layoutParams || !layoutParams.dimensions) {
throw new Error(`LayoutParams.Dimensions is undefined.`);
}
@@ -37,12 +38,16 @@ export function generatePngObservableFactory(
layout,
browserTimezone,
}).pipe(
- map(([{ screenshots }]) => {
- if (screenshots.length !== 1) {
- throw new Error(`Expected there to be 1 screenshot, but there are ${screenshots.length}`);
- }
-
- return screenshots[0].base64EncodedData;
+ map((results: ScreenshotResults[]) => {
+ return {
+ buffer: results[0].screenshots[0].base64EncodedData,
+ warnings: results.reduce((found, current) => {
+ if (current.error) {
+ found.push(current.error.message);
+ }
+ return found;
+ }, [] as string[]),
+ };
})
);
diff --git a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.test.js b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.test.js
index cc6b298bebdc54..484842ba18f2ad 100644
--- a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.test.js
+++ b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.test.js
@@ -82,7 +82,7 @@ test(`returns content of generatePdf getBuffer base64 encoded`, async () => {
const testContent = 'test content';
const generatePdfObservable = generatePdfObservableFactory();
- generatePdfObservable.mockReturnValue(Rx.of(Buffer.from(testContent)));
+ generatePdfObservable.mockReturnValue(Rx.of({ buffer: Buffer.from(testContent) }));
const executeJob = await executeJobFactory(
mockReporting,
diff --git a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.ts b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.ts
index e8461862bee823..535c2dcd439a7a 100644
--- a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.ts
+++ b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.ts
@@ -4,21 +4,27 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import * as Rx from 'rxjs';
import { ElasticsearchServiceSetup } from 'kibana/server';
+import * as Rx from 'rxjs';
import { catchError, map, mergeMap, takeUntil } from 'rxjs/operators';
-import { ReportingCore } from '../../../../server';
-import { ServerFacade, ExecuteJobFactory, ESQueueWorkerExecuteFn, Logger } from '../../../../types';
-import { JobDocPayloadPDF } from '../../types';
import { PDF_JOB_TYPE } from '../../../../common/constants';
-import { generatePdfObservableFactory } from '../lib/generate_pdf';
+import { ReportingCore } from '../../../../server';
+import {
+ ESQueueWorkerExecuteFn,
+ ExecuteJobFactory,
+ JobDocOutput,
+ Logger,
+ ServerFacade,
+} from '../../../../types';
import {
decryptJobHeaders,
- omitBlacklistedHeaders,
getConditionalHeaders,
- getFullUrls,
getCustomLogo,
+ getFullUrls,
+ omitBlacklistedHeaders,
} from '../../../common/execute_job/';
+import { JobDocPayloadPDF } from '../../types';
+import { generatePdfObservableFactory } from '../lib/generate_pdf';
type QueuedPdfExecutorFactory = ExecuteJobFactory>;
@@ -34,8 +40,7 @@ export const executeJobFactory: QueuedPdfExecutorFactory = async function execut
return function executeJob(jobId: string, job: JobDocPayloadPDF, cancellationToken: any) {
const jobLogger = logger.clone([jobId]);
-
- const process$ = Rx.of(1).pipe(
+ const process$: Rx.Observable = Rx.of(1).pipe(
mergeMap(() => decryptJobHeaders({ server, job, logger })),
map(decryptedHeaders => omitBlacklistedHeaders({ job, decryptedHeaders })),
map(filteredHeaders => getConditionalHeaders({ server, job, filteredHeaders })),
@@ -54,10 +59,11 @@ export const executeJobFactory: QueuedPdfExecutorFactory = async function execut
logo
);
}),
- map((buffer: Buffer) => ({
+ map(({ buffer, warnings }) => ({
content_type: 'application/pdf',
content: buffer.toString('base64'),
size: buffer.byteLength,
+ warnings,
})),
catchError(err => {
jobLogger.error(err);
diff --git a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/lib/generate_pdf.ts b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/lib/generate_pdf.ts
index 9a8db308bea794..d78effaa1fc2f9 100644
--- a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/lib/generate_pdf.ts
+++ b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/lib/generate_pdf.ts
@@ -4,17 +4,17 @@
* you may not use this file except in compliance with the Elastic License.
*/
+import { groupBy } from 'lodash';
import * as Rx from 'rxjs';
import { mergeMap } from 'rxjs/operators';
-import { groupBy } from 'lodash';
import { LevelLogger } from '../../../../server/lib';
-import { ServerFacade, HeadlessChromiumDriverFactory, ConditionalHeaders } from '../../../../types';
-// @ts-ignore untyped module
-import { pdf } from './pdf';
-import { screenshotsObservableFactory } from '../../../common/lib/screenshots';
+import { ConditionalHeaders, HeadlessChromiumDriverFactory, ServerFacade } from '../../../../types';
import { createLayout } from '../../../common/layouts';
-import { ScreenshotResults } from '../../../common/lib/screenshots/types';
import { LayoutInstance, LayoutParams } from '../../../common/layouts/layout';
+import { screenshotsObservableFactory } from '../../../common/lib/screenshots';
+import { ScreenshotResults } from '../../../common/lib/screenshots/types';
+// @ts-ignore untyped module
+import { pdf } from './pdf';
const getTimeRange = (urlScreenshots: ScreenshotResults[]) => {
const grouped = groupBy(urlScreenshots.map(u => u.timeRange));
@@ -40,7 +40,7 @@ export function generatePdfObservableFactory(
conditionalHeaders: ConditionalHeaders,
layoutParams: LayoutParams,
logo?: string
- ): Rx.Observable {
+ ): Rx.Observable<{ buffer: Buffer; warnings: string[] }> {
const layout = createLayout(server, layoutParams) as LayoutInstance;
const screenshots$ = screenshotsObservable({
logger,
@@ -49,17 +49,17 @@ export function generatePdfObservableFactory(
layout,
browserTimezone,
}).pipe(
- mergeMap(async urlScreenshots => {
+ mergeMap(async (results: ScreenshotResults[]) => {
const pdfOutput = pdf.create(layout, logo);
if (title) {
- const timeRange = getTimeRange(urlScreenshots);
+ const timeRange = getTimeRange(results);
title += timeRange ? ` - ${timeRange.duration}` : '';
pdfOutput.setTitle(title);
}
- urlScreenshots.forEach(({ screenshots }) => {
- screenshots.forEach(screenshot => {
+ results.forEach(r => {
+ r.screenshots.forEach(screenshot => {
pdfOutput.addImage(screenshot.base64EncodedData, {
title: screenshot.title,
description: screenshot.description,
@@ -68,7 +68,16 @@ export function generatePdfObservableFactory(
});
pdfOutput.generate();
- return await pdfOutput.getBuffer();
+
+ return {
+ buffer: await pdfOutput.getBuffer(),
+ warnings: results.reduce((found, current) => {
+ if (current.error) {
+ found.push(current.error.message);
+ }
+ return found;
+ }, [] as string[]),
+ };
})
);
diff --git a/x-pack/legacy/plugins/reporting/public/components/report_info_button.tsx b/x-pack/legacy/plugins/reporting/public/components/report_info_button.tsx
index 77869c40d35778..7f5d070948e50a 100644
--- a/x-pack/legacy/plugins/reporting/public/components/report_info_button.tsx
+++ b/x-pack/legacy/plugins/reporting/public/components/report_info_button.tsx
@@ -86,95 +86,102 @@ export class ReportInfoButton extends Component {
const maxAttempts = info.max_attempts ? info.max_attempts.toString() : NA;
const priority = info.priority ? info.priority.toString() : NA;
const timeout = info.timeout ? info.timeout.toString() : NA;
+ const warnings = info.output && info.output.warnings ? info.output.warnings.join(',') : null;
+
+ const jobInfoDateTimes: JobInfo[] = [
+ {
+ title: 'Created By',
+ description: info.created_by || NA,
+ },
+ {
+ title: 'Created At',
+ description: info.created_at || NA,
+ },
+ {
+ title: 'Started At',
+ description: info.started_at || NA,
+ },
+ {
+ title: 'Completed At',
+ description: info.completed_at || NA,
+ },
+ {
+ title: 'Processed By',
+ description:
+ info.kibana_name && info.kibana_id ? `${info.kibana_name} (${info.kibana_id})` : UNKNOWN,
+ },
+ {
+ title: 'Browser Timezone',
+ description: get(info, 'payload.browserTimezone') || NA,
+ },
+ ];
+ const jobInfoPayload: JobInfo[] = [
+ {
+ title: 'Title',
+ description: get(info, 'payload.title') || NA,
+ },
+ {
+ title: 'Type',
+ description: get(info, 'payload.type') || NA,
+ },
+ {
+ title: 'Layout',
+ description: get(info, 'meta.layout') || NA,
+ },
+ {
+ title: 'Dimensions',
+ description: getDimensions(info),
+ },
+ {
+ title: 'Job Type',
+ description: jobType,
+ },
+ {
+ title: 'Content Type',
+ description: get(info, 'output.content_type') || NA,
+ },
+ {
+ title: 'Size in Bytes',
+ description: get(info, 'output.size') || NA,
+ },
+ ];
+ const jobInfoStatus: JobInfo[] = [
+ {
+ title: 'Attempts',
+ description: attempts,
+ },
+ {
+ title: 'Max Attempts',
+ description: maxAttempts,
+ },
+ {
+ title: 'Priority',
+ description: priority,
+ },
+ {
+ title: 'Timeout',
+ description: timeout,
+ },
+ {
+ title: 'Status',
+ description: info.status || NA,
+ },
+ {
+ title: 'Browser Type',
+ description: USES_HEADLESS_JOB_TYPES.includes(jobType) ? info.browser_type || UNKNOWN : NA,
+ },
+ ];
+ if (warnings) {
+ jobInfoStatus.push({
+ title: 'Errors',
+ description: warnings,
+ });
+ }
const jobInfoParts: JobInfoMap = {
- datetimes: [
- {
- title: 'Created By',
- description: info.created_by || NA,
- },
- {
- title: 'Created At',
- description: info.created_at || NA,
- },
- {
- title: 'Started At',
- description: info.started_at || NA,
- },
- {
- title: 'Completed At',
- description: info.completed_at || NA,
- },
- {
- title: 'Processed By',
- description:
- info.kibana_name && info.kibana_id
- ? `${info.kibana_name} (${info.kibana_id})`
- : UNKNOWN,
- },
- {
- title: 'Browser Timezone',
- description: get(info, 'payload.browserTimezone') || NA,
- },
- ],
- payload: [
- {
- title: 'Title',
- description: get(info, 'payload.title') || NA,
- },
- {
- title: 'Type',
- description: get(info, 'payload.type') || NA,
- },
- {
- title: 'Layout',
- description: get(info, 'meta.layout') || NA,
- },
- {
- title: 'Dimensions',
- description: getDimensions(info),
- },
- {
- title: 'Job Type',
- description: jobType,
- },
- {
- title: 'Content Type',
- description: get(info, 'output.content_type') || NA,
- },
- {
- title: 'Size in Bytes',
- description: get(info, 'output.size') || NA,
- },
- ],
- status: [
- {
- title: 'Attempts',
- description: attempts,
- },
- {
- title: 'Max Attempts',
- description: maxAttempts,
- },
- {
- title: 'Priority',
- description: priority,
- },
- {
- title: 'Timeout',
- description: timeout,
- },
- {
- title: 'Status',
- description: info.status || NA,
- },
- {
- title: 'Browser Type',
- description: USES_HEADLESS_JOB_TYPES.includes(jobType)
- ? info.browser_type || UNKNOWN
- : NA,
- },
- ],
+ datetimes: jobInfoDateTimes,
+ payload: jobInfoPayload,
+ status: jobInfoStatus,
};
return (
diff --git a/x-pack/legacy/plugins/reporting/public/components/report_listing.tsx b/x-pack/legacy/plugins/reporting/public/components/report_listing.tsx
index 320f6220aa9968..54061eda94dce2 100644
--- a/x-pack/legacy/plugins/reporting/public/components/report_listing.tsx
+++ b/x-pack/legacy/plugins/reporting/public/components/report_listing.tsx
@@ -43,6 +43,7 @@ interface Job {
attempts: number;
max_attempts: number;
csv_contains_formulas: boolean;
+ warnings: string[];
}
interface Props {
@@ -203,7 +204,7 @@ class ReportListingUi extends Component {
return (
@@ -215,13 +216,27 @@ class ReportListingUi extends Component {
maxSizeReached = (
);
}
+ let warnings;
+ if (record.warnings) {
+ warnings = (
+
+
+
+
+
+ );
+ }
+
let statusTimestamp;
if (status === JobStatuses.PROCESSING && record.started_at) {
statusTimestamp = this.formatDate(record.started_at);
@@ -242,7 +257,7 @@ class ReportListingUi extends Component {
return (
{
}}
/>
{maxSizeReached}
+ {warnings}
);
}
@@ -259,6 +275,7 @@ class ReportListingUi extends Component {
{statusLabel}
{maxSizeReached}
+ {warnings}
);
},
@@ -437,6 +454,7 @@ class ReportListingUi extends Component {
attempts: source.attempts,
max_attempts: source.max_attempts,
csv_contains_formulas: get(source, 'output.csv_contains_formulas'),
+ warnings: source.output ? source.output.warnings : undefined,
};
}
),
diff --git a/x-pack/legacy/plugins/reporting/public/lib/job_queue_client.ts b/x-pack/legacy/plugins/reporting/public/lib/job_queue_client.ts
index 281a2e1cdf9a51..87d4174168b7f8 100644
--- a/x-pack/legacy/plugins/reporting/public/lib/job_queue_client.ts
+++ b/x-pack/legacy/plugins/reporting/public/lib/job_queue_client.ts
@@ -31,6 +31,7 @@ export interface JobInfo {
output: {
content_type: string;
size: number;
+ warnings: string[];
};
process_expiration: string;
completed_at: string;
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 0592124b9897b9..60799e3e918b82 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
@@ -4,13 +4,13 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { trunc, map } from 'lodash';
+import { i18n } from '@kbn/i18n';
+import { map, trunc } from 'lodash';
import open from 'opn';
+import { ElementHandle, EvaluateFn, Page, SerializableOrJSHandle } from 'puppeteer';
import { parse as parseUrl } from 'url';
-import { Page, SerializableOrJSHandle, EvaluateFn } from 'puppeteer';
import { ViewZoomWidthHeight } from '../../../../export_types/common/layouts/layout';
import { LevelLogger } from '../../../../server/lib';
-import { allowRequest } from '../../network_policy';
import {
ConditionalHeaders,
ConditionalHeadersConditions,
@@ -18,6 +18,7 @@ import {
InterceptedRequest,
NetworkPolicy,
} from '../../../../types';
+import { allowRequest } from '../../network_policy';
export interface ChromiumDriverOptions {
inspect: boolean;
@@ -25,7 +26,7 @@ export interface ChromiumDriverOptions {
}
interface WaitForSelectorOpts {
- silent?: boolean;
+ timeout: number;
}
interface EvaluateOpts {
@@ -65,10 +66,15 @@ export class HeadlessChromiumDriver {
url: string,
{
conditionalHeaders,
- waitForSelector,
- }: { conditionalHeaders: ConditionalHeaders; waitForSelector: string },
+ waitForSelector: pageLoadSelector,
+ timeout,
+ }: {
+ conditionalHeaders: ConditionalHeaders;
+ waitForSelector: string;
+ timeout: number;
+ },
logger: LevelLogger
- ) {
+ ): Promise {
logger.info(`opening url ${url}`);
// @ts-ignore
const client = this.page._client;
@@ -81,7 +87,7 @@ export class HeadlessChromiumDriver {
// https://github.com/puppeteer/puppeteer/issues/5003
// Docs on this client/protocol can be found here:
// https://chromedevtools.github.io/devtools-protocol/tot/Fetch
- client.on('Fetch.requestPaused', (interceptedRequest: InterceptedRequest) => {
+ client.on('Fetch.requestPaused', async (interceptedRequest: InterceptedRequest) => {
const {
requestId,
request: { url: interceptedUrl },
@@ -92,12 +98,17 @@ export class HeadlessChromiumDriver {
// We should never ever let file protocol requests go through
if (!allowed || !this.allowRequest(interceptedUrl)) {
logger.error(`Got bad URL: "${interceptedUrl}", closing browser.`);
- client.send('Fetch.failRequest', {
+ await client.send('Fetch.failRequest', {
errorReason: 'Aborted',
requestId,
});
this.page.browser().close();
- throw new Error(`Received disallowed outgoing URL: "${interceptedUrl}", exiting`);
+ throw new Error(
+ i18n.translate('xpack.reporting.chromiumDriver.disallowedOutgoingUrl', {
+ defaultMessage: `Received disallowed outgoing URL: "{interceptedUrl}", exiting`,
+ values: { interceptedUrl },
+ })
+ );
}
if (this._shouldUseCustomHeaders(conditionalHeaders.conditions, interceptedUrl)) {
@@ -112,14 +123,33 @@ export class HeadlessChromiumDriver {
value,
})
);
- client.send('Fetch.continueRequest', {
- requestId,
- headers,
- });
+
+ try {
+ await client.send('Fetch.continueRequest', {
+ requestId,
+ headers,
+ });
+ } catch (err) {
+ logger.error(
+ i18n.translate('xpack.reporting.chromiumDriver.failedToCompleteRequestUsingHeaders', {
+ defaultMessage: 'Failed to complete a request using headers: {error}',
+ values: { error: err },
+ })
+ );
+ }
} else {
const loggedUrl = isData ? this.truncateUrl(interceptedUrl) : interceptedUrl;
logger.debug(`No custom headers for ${loggedUrl}`);
- client.send('Fetch.continueRequest', { requestId });
+ try {
+ await client.send('Fetch.continueRequest', { requestId });
+ } catch (err) {
+ logger.error(
+ i18n.translate('xpack.reporting.chromiumDriver.failedToCompleteRequest', {
+ defaultMessage: 'Failed to complete a request: {error}',
+ values: { error: err },
+ })
+ );
+ }
}
interceptedCount = interceptedCount + (isData ? 0 : 1);
});
@@ -144,11 +174,16 @@ export class HeadlessChromiumDriver {
await this.launchDebugger();
}
- await this.waitForSelector(waitForSelector, {}, logger);
+ await this.waitForSelector(
+ pageLoadSelector,
+ { timeout },
+ { context: 'waiting for page load selector' },
+ logger
+ );
logger.info(`handled ${interceptedCount} page requests`);
}
- public async screenshot(elementPosition: ElementPosition) {
+ public async screenshot(elementPosition: ElementPosition): Promise {
let clip;
if (elementPosition) {
const { boundingClientRect, scroll = { x: 0, y: 0 } } = elementPosition;
@@ -176,63 +211,56 @@ export class HeadlessChromiumDriver {
const result = await this.page.evaluate(fn, ...args);
return result;
}
+
public async waitForSelector(
selector: string,
- opts: WaitForSelectorOpts = {},
+ opts: WaitForSelectorOpts,
+ context: EvaluateMetaOpts,
logger: LevelLogger
- ) {
- const { silent = false } = opts;
+ ): Promise> {
+ const { timeout } = opts;
logger.debug(`waitForSelector ${selector}`);
-
- let resp;
- try {
- resp = await this.page.waitFor(selector);
- } catch (err) {
- if (!silent) {
- // 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: [],
- },
- { context: `waitForSelector${selector}` },
- logger
- );
- logger.debug(`Page plain text: ${pageText.replace(/\n/g, '\\n')}`); // replace newline with escaped for single log line
- }
- throw err;
- }
-
+ const resp = await this.page.waitFor(selector, { timeout }); // override default 30000ms
logger.debug(`waitForSelector ${selector} resolved`);
return resp;
}
- public async waitFor(
+ public async waitFor(
{
fn,
args,
toEqual,
+ timeout,
}: {
fn: EvaluateFn;
args: SerializableOrJSHandle[];
- toEqual: T;
+ toEqual: number;
+ timeout: number;
},
context: EvaluateMetaOpts,
logger: LevelLogger
- ) {
+ ): Promise {
+ const startTime = Date.now();
+
while (true) {
const result = await this.evaluate({ fn, args }, context, logger);
if (result === toEqual) {
return;
}
+ if (Date.now() - startTime > timeout) {
+ throw new Error(
+ `Timed out waiting for the items selected to equal ${toEqual}. Found: ${result}. Context: ${context.context}`
+ );
+ }
await new Promise(r => setTimeout(r, WAIT_FOR_DELAY_MS));
}
}
- public async setViewport({ width, height, zoom }: ViewZoomWidthHeight, logger: LevelLogger) {
+ public async setViewport(
+ { width, height, zoom }: ViewZoomWidthHeight,
+ logger: LevelLogger
+ ): Promise {
logger.debug(`Setting viewport to width: ${width}, height: ${height}, zoom: ${zoom}`);
await this.page.setViewport({
diff --git a/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/index.ts b/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/index.ts
index 6fa46b893de8ce..f90f2c7aee395b 100644
--- a/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/index.ts
+++ b/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/index.ts
@@ -3,54 +3,52 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
+
+import { i18n } from '@kbn/i18n';
+import del from 'del';
import fs from 'fs';
import os from 'os';
import path from 'path';
import {
Browser,
- Page,
- LaunchOptions,
ConsoleMessage,
+ LaunchOptions,
+ Page,
Request as PuppeteerRequest,
} from 'puppeteer';
-import del from 'del';
import * as Rx from 'rxjs';
-import { ignoreElements, map, mergeMap, tap } from 'rxjs/operators';
import { InnerSubscriber } from 'rxjs/internal/InnerSubscriber';
-
-import { BrowserConfig, NetworkPolicy } from '../../../../types';
+import { ignoreElements, map, mergeMap, tap } from 'rxjs/operators';
+import { BrowserConfig, CaptureConfig } from '../../../../types';
import { LevelLogger as Logger } from '../../../lib/level_logger';
-import { HeadlessChromiumDriver } from '../driver';
import { safeChildProcess } from '../../safe_child_process';
-import { puppeteerLaunch } from '../puppeteer';
+import { HeadlessChromiumDriver } from '../driver';
import { getChromeLogLocation } from '../paths';
+import { puppeteerLaunch } from '../puppeteer';
import { args } from './args';
type binaryPath = string;
-type queueTimeout = number;
+type ViewportConfig = BrowserConfig['viewport'];
export class HeadlessChromiumDriverFactory {
private binaryPath: binaryPath;
+ private captureConfig: CaptureConfig;
private browserConfig: BrowserConfig;
- private queueTimeout: queueTimeout;
- private networkPolicy: NetworkPolicy;
private userDataDir: string;
- private getChromiumArgs: (viewport: BrowserConfig['viewport']) => string[];
+ private getChromiumArgs: (viewport: ViewportConfig) => string[];
constructor(
binaryPath: binaryPath,
logger: Logger,
browserConfig: BrowserConfig,
- queueTimeout: queueTimeout,
- networkPolicy: NetworkPolicy
+ captureConfig: CaptureConfig
) {
this.binaryPath = binaryPath;
this.browserConfig = browserConfig;
- this.queueTimeout = queueTimeout;
- this.networkPolicy = networkPolicy;
+ this.captureConfig = captureConfig;
this.userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chromium-'));
- this.getChromiumArgs = (viewport: BrowserConfig['viewport']) =>
+ this.getChromiumArgs = (viewport: ViewportConfig) =>
args({
userDataDir: this.userDataDir,
viewport,
@@ -88,7 +86,7 @@ export class HeadlessChromiumDriverFactory {
* Return an observable to objects which will drive screenshot capture for a page
*/
createPage(
- { viewport, browserTimezone }: { viewport: BrowserConfig['viewport']; browserTimezone: string },
+ { viewport, browserTimezone }: { viewport: ViewportConfig; browserTimezone: string },
pLogger: Logger
): Rx.Observable<{ driver: HeadlessChromiumDriver; exit$: Rx.Observable }> {
return Rx.Observable.create(async (observer: InnerSubscriber) => {
@@ -113,11 +111,9 @@ export class HeadlessChromiumDriverFactory {
page = await browser.newPage();
- // All navigation/waitFor methods default to 30 seconds,
- // which can cause the job to fail even if we bump timeouts in
- // the config. Help alleviate errors like
- // "TimeoutError: waiting for selector ".application" failed: timeout 30000ms exceeded"
- page.setDefaultTimeout(this.queueTimeout);
+ // Set the default timeout for all navigation methods to the openUrl timeout (30 seconds)
+ // All waitFor methods have their own timeout config passed in to them
+ page.setDefaultTimeout(this.captureConfig.timeouts.openUrl);
logger.debug(`Browser page driver created`);
} catch (err) {
@@ -158,7 +154,7 @@ export class HeadlessChromiumDriverFactory {
// HeadlessChromiumDriver: object to "drive" a browser page
const driver = new HeadlessChromiumDriver(page, {
inspect: this.browserConfig.inspect,
- networkPolicy: this.networkPolicy,
+ networkPolicy: this.captureConfig.networkPolicy,
});
// Rx.Observable: stream to interrupt page capture
@@ -172,7 +168,7 @@ export class HeadlessChromiumDriverFactory {
logger.debug(`deleting chromium user data directory at [${userDataDir}]`);
// the unsubscribe function isn't `async` so we're going to make our best effort at
// deleting the userDataDir and if it fails log an error.
- del(userDataDir).catch(error => {
+ del(userDataDir, { force: true }).catch(error => {
logger.error(`error deleting user data directory at [${userDataDir}]: [${error}]`);
});
});
@@ -221,17 +217,35 @@ export class HeadlessChromiumDriverFactory {
}
getPageExit(browser: Browser, page: Page) {
- const pageError$ = Rx.fromEvent(page, 'error').pipe(mergeMap(err => Rx.throwError(err)));
+ const pageError$ = Rx.fromEvent(page, 'error').pipe(
+ mergeMap(err => {
+ return Rx.throwError(
+ i18n.translate('xpack.reporting.browsers.chromium.errorDetected', {
+ defaultMessage: 'Reporting detected an error: {err}',
+ values: { err: err.toString() },
+ })
+ );
+ })
+ );
const uncaughtExceptionPageError$ = Rx.fromEvent(page, 'pageerror').pipe(
- mergeMap(err => Rx.throwError(err))
+ mergeMap(err => {
+ return Rx.throwError(
+ i18n.translate('xpack.reporting.browsers.chromium.pageErrorDetected', {
+ defaultMessage: `Reporting detected an error on the page: {err}`,
+ values: { err: err.toString() },
+ })
+ );
+ })
);
const browserDisconnect$ = Rx.fromEvent(browser, 'disconnected').pipe(
mergeMap(() =>
Rx.throwError(
new Error(
- `Puppeteer was disconnected from the Chromium instance! Chromium has closed or crashed.`
+ i18n.translate('xpack.reporting.browsers.chromium.chromiumClosed', {
+ defaultMessage: `Reporting detected that Chromium has closed.`,
+ })
)
)
)
diff --git a/x-pack/legacy/plugins/reporting/server/browsers/chromium/index.ts b/x-pack/legacy/plugins/reporting/server/browsers/chromium/index.ts
index d5f7027e025d49..d32338ae3e311e 100644
--- a/x-pack/legacy/plugins/reporting/server/browsers/chromium/index.ts
+++ b/x-pack/legacy/plugins/reporting/server/browsers/chromium/index.ts
@@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { BrowserConfig, NetworkPolicy } from '../../../types';
+import { BrowserConfig, CaptureConfig } from '../../../types';
import { LevelLogger } from '../../lib';
import { HeadlessChromiumDriverFactory } from './driver_factory';
@@ -14,14 +14,7 @@ export async function createDriverFactory(
binaryPath: string,
logger: LevelLogger,
browserConfig: BrowserConfig,
- queueTimeout: number,
- networkPolicy: NetworkPolicy
+ captureConfig: CaptureConfig
): Promise {
- return new HeadlessChromiumDriverFactory(
- binaryPath,
- logger,
- browserConfig,
- queueTimeout,
- networkPolicy
- );
+ return new HeadlessChromiumDriverFactory(binaryPath, logger, browserConfig, captureConfig);
}
diff --git a/x-pack/legacy/plugins/reporting/server/browsers/create_browser_driver_factory.ts b/x-pack/legacy/plugins/reporting/server/browsers/create_browser_driver_factory.ts
index 128df4d318c764..49c6222c9f276f 100644
--- a/x-pack/legacy/plugins/reporting/server/browsers/create_browser_driver_factory.ts
+++ b/x-pack/legacy/plugins/reporting/server/browsers/create_browser_driver_factory.ts
@@ -22,8 +22,6 @@ export async function createBrowserDriverFactory(
const browserType = captureConfig.browser.type;
const browserAutoDownload = captureConfig.browser.autoDownload;
const browserConfig = captureConfig.browser[BROWSER_TYPE];
- const networkPolicy = captureConfig.networkPolicy;
- const reportingTimeout: number = config.get('xpack.reporting.queue.timeout');
if (browserConfig.disableSandbox) {
logger.warning(`Enabling the Chromium sandbox provides an additional layer of protection.`);
@@ -34,13 +32,7 @@ export async function createBrowserDriverFactory(
try {
const { binaryPath } = await installBrowser(logger, chromium, dataDir);
- return chromium.createDriverFactory(
- binaryPath,
- logger,
- browserConfig,
- reportingTimeout,
- networkPolicy
- );
+ return chromium.createDriverFactory(binaryPath, logger, browserConfig, captureConfig);
} catch (error) {
if (error.cause && ['EACCES', 'EEXIST'].includes(error.cause.code)) {
logger.error(
diff --git a/x-pack/legacy/plugins/reporting/server/browsers/download/clean.ts b/x-pack/legacy/plugins/reporting/server/browsers/download/clean.ts
index 4355a6a0a17732..a2d1fc7f91a290 100644
--- a/x-pack/legacy/plugins/reporting/server/browsers/download/clean.ts
+++ b/x-pack/legacy/plugins/reporting/server/browsers/download/clean.ts
@@ -31,7 +31,7 @@ export async function clean(dir: string, expectedPaths: string[]) {
const path = resolvePath(dir, filename);
if (!expectedPaths.includes(path)) {
log(`Deleting unexpected file ${path}`);
- await del(path);
+ await del(path, { force: true });
}
});
}
diff --git a/x-pack/legacy/plugins/reporting/server/lib/esqueue/worker.js b/x-pack/legacy/plugins/reporting/server/lib/esqueue/worker.js
index 43735979422783..113059fa2fa47e 100644
--- a/x-pack/legacy/plugins/reporting/server/lib/esqueue/worker.js
+++ b/x-pack/legacy/plugins/reporting/server/lib/esqueue/worker.js
@@ -226,8 +226,10 @@ export class Worker extends events.EventEmitter {
docOutput.content = output.content;
docOutput.content_type = output.content_type || unknownMime;
docOutput.max_size_reached = output.max_size_reached;
- docOutput.size = output.size;
docOutput.csv_contains_formulas = output.csv_contains_formulas;
+ docOutput.size = output.size;
+ docOutput.warnings =
+ output.warnings && output.warnings.length > 0 ? output.warnings : undefined;
} else {
docOutput.content = output || defaultOutput;
docOutput.content_type = unknownMime;
@@ -248,7 +250,11 @@ export class Worker extends events.EventEmitter {
Promise.resolve(this.workerFn.call(null, job, jobSource.payload, cancellationToken))
.then(res => {
// job execution was successful
- this.info(`Job execution completed successfully`);
+ if (res && res.warnings && res.warnings.length > 0) {
+ this.warn(`Job execution completed with warnings`);
+ } else {
+ this.info(`Job execution completed successfully`);
+ }
isResolved = true;
resolve(res);
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
index 6d9ae2153255fe..883276d43e27e0 100644
--- a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts
+++ b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts
@@ -10,7 +10,7 @@ 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';
+import { BrowserConfig, CaptureConfig, Logger } from '../types';
interface CreateMockBrowserDriverFactoryOpts {
evaluate: jest.Mock, any[]>;
@@ -19,7 +19,7 @@ interface CreateMockBrowserDriverFactoryOpts {
getCreatePage: (driver: HeadlessChromiumDriver) => jest.Mock;
}
-export const mockSelectors = {
+const mockSelectors = {
renderComplete: 'renderedSelector',
itemsCountAttribute: 'itemsSelector',
screenshot: 'screenshotSelector',
@@ -73,9 +73,6 @@ mockBrowserEvaluate.mockImplementation(() => {
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();
@@ -105,19 +102,20 @@ export const createMockBrowserDriverFactory = async (
} as BrowserConfig;
const binaryPath = '/usr/local/share/common/secure/';
- const queueTimeout = 55;
- const networkPolicy = {} as NetworkPolicy;
+ const captureConfig = { networkPolicy: {}, timeouts: {} } as CaptureConfig;
const mockBrowserDriverFactory = await createDriverFactory(
binaryPath,
logger,
browserConfig,
- queueTimeout,
- networkPolicy
+ captureConfig
);
const mockPage = {} as Page;
- const mockBrowserDriver = new HeadlessChromiumDriver(mockPage, { inspect: true, networkPolicy });
+ const mockBrowserDriver = new HeadlessChromiumDriver(mockPage, {
+ inspect: true,
+ networkPolicy: captureConfig.networkPolicy,
+ });
// mock the driver methods as either default mocks or passed-in
mockBrowserDriver.waitForSelector = opts.waitForSelector ? opts.waitForSelector : defaultOpts.waitForSelector; // prettier-ignore
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
index a2eb03c3fe300f..0250e6c0a9afdb 100644
--- a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_layoutinstance.ts
+++ b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_layoutinstance.ts
@@ -19,7 +19,6 @@ export const createMockLayoutInstance = (__LEGACY: ServerFacade) => {
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 91c348ba1db3d0..491d390c370b98 100644
--- a/x-pack/legacy/plugins/reporting/test_helpers/index.ts
+++ b/x-pack/legacy/plugins/reporting/test_helpers/index.ts
@@ -6,5 +6,5 @@
export { createMockServer } from './create_mock_server';
export { createMockReportingCore } from './create_mock_reportingplugin';
-export { createMockBrowserDriverFactory, mockSelectors } from './create_mock_browserdriverfactory';
+export { createMockBrowserDriverFactory } 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 38406186c81738..b4d49fd21f230b 100644
--- a/x-pack/legacy/plugins/reporting/types.d.ts
+++ b/x-pack/legacy/plugins/reporting/types.d.ts
@@ -122,6 +122,11 @@ export interface CaptureConfig {
maxAttempts: number;
networkPolicy: NetworkPolicy;
loadDelay: number;
+ timeouts: {
+ openUrl: number;
+ waitForElements: number;
+ renderComplet: number;
+ };
}
export interface BrowserConfig {
@@ -219,8 +224,9 @@ export interface JobSource {
export interface JobDocOutput {
content_type: string;
content: string | null;
- max_size_reached: boolean;
size: number;
+ max_size_reached?: boolean;
+ warnings?: string[];
}
export interface ESQueueWorker {
diff --git a/x-pack/legacy/plugins/siem/cypress/integration/navigation.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/navigation.spec.ts
index 2c5a0e5eeea8a0..bebd5f7d679cfe 100644
--- a/x-pack/legacy/plugins/siem/cypress/integration/navigation.spec.ts
+++ b/x-pack/legacy/plugins/siem/cypress/integration/navigation.spec.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 { HOSTS, NETWORK, OVERVIEW, TIMELINES } from '../screens/siem_header';
+import { DETECTIONS, HOSTS, NETWORK, OVERVIEW, TIMELINES } from '../screens/siem_header';
import { loginAndWaitForPage } from '../tasks/login';
import { navigateFromHeaderTo } from '../tasks/siem_header';
@@ -29,6 +29,11 @@ describe('top-level navigation common to all pages in the SIEM app', () => {
cy.url().should('include', '/siem#/network');
});
+ it('navigates to the Detections page', () => {
+ navigateFromHeaderTo(DETECTIONS);
+ cy.url().should('include', '/siem#/detections');
+ });
+
it('navigates to the Timelines page', () => {
navigateFromHeaderTo(TIMELINES);
cy.url().should('include', '/siem#/timelines');
diff --git a/x-pack/legacy/plugins/siem/cypress/integration/signal_detection_rules.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/signal_detection_rules.spec.ts
new file mode 100644
index 00000000000000..f2ed9d48daaf63
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/cypress/integration/signal_detection_rules.spec.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 { ELASTIC_RULES_BTN, RULES_TABLE, RULES_ROW } from '../screens/signal_detection_rules';
+
+import {
+ changeToThreeHundredRowsPerPage,
+ loadPrebuiltDetectionRules,
+ waitForLoadElasticPrebuiltDetectionRulesTableToBeLoaded,
+ waitForPrebuiltDetectionRulesToBeLoaded,
+ waitForRulesToBeLoaded,
+} from '../tasks/signal_detection_rules';
+import {
+ goToManageSignalDetectionRules,
+ waitForSignalsIndexToBeCreated,
+ waitForSignalsPanelToBeLoaded,
+} from '../tasks/detections';
+import { loginAndWaitForPageWithoutDateRange } from '../tasks/login';
+
+import { DETECTIONS } from '../urls/navigation';
+
+describe('Signal detection rules', () => {
+ before(() => {
+ loginAndWaitForPageWithoutDateRange(DETECTIONS);
+ });
+ it('Loads prebuilt rules', () => {
+ waitForSignalsPanelToBeLoaded();
+ waitForSignalsIndexToBeCreated();
+ goToManageSignalDetectionRules();
+ waitForLoadElasticPrebuiltDetectionRulesTableToBeLoaded();
+ loadPrebuiltDetectionRules();
+ waitForPrebuiltDetectionRulesToBeLoaded();
+
+ const expectedElasticRulesBtnText = 'Elastic rules (92)';
+ cy.get(ELASTIC_RULES_BTN)
+ .invoke('text')
+ .should('eql', expectedElasticRulesBtnText);
+
+ changeToThreeHundredRowsPerPage();
+ waitForRulesToBeLoaded();
+
+ const expectedNumberOfRules = 92;
+ cy.get(RULES_TABLE).then($table => {
+ cy.wrap($table.find(RULES_ROW).length).should('eql', expectedNumberOfRules);
+ });
+ });
+});
diff --git a/x-pack/legacy/plugins/siem/cypress/screens/detections.ts b/x-pack/legacy/plugins/siem/cypress/screens/detections.ts
new file mode 100644
index 00000000000000..8089b028a10d48
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/cypress/screens/detections.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 const LOADING_SIGNALS_PANEL = '[data-test-subj="loading-signals-panel"]';
+
+export const MANAGE_SIGNAL_DETECTION_RULES_BTN = '[data-test-subj="manage-signal-detection-rules"]';
diff --git a/x-pack/legacy/plugins/siem/cypress/screens/siem_header.ts b/x-pack/legacy/plugins/siem/cypress/screens/siem_header.ts
index cf1059269393a0..c2dab051793c1a 100644
--- a/x-pack/legacy/plugins/siem/cypress/screens/siem_header.ts
+++ b/x-pack/legacy/plugins/siem/cypress/screens/siem_header.ts
@@ -6,6 +6,8 @@
export const BREADCRUMBS = '[data-test-subj="breadcrumbs"] a';
+export const DETECTIONS = '[data-test-subj="navigation-detections"]';
+
export const HOSTS = '[data-test-subj="navigation-hosts"]';
export const KQL_INPUT = '[data-test-subj="queryInput"]';
diff --git a/x-pack/legacy/plugins/siem/cypress/screens/signal_detection_rules.ts b/x-pack/legacy/plugins/siem/cypress/screens/signal_detection_rules.ts
new file mode 100644
index 00000000000000..bfaa86e83f301c
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/cypress/screens/signal_detection_rules.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 const ELASTIC_RULES_BTN = '[data-test-subj="show-elastic-rules-filter-button"]';
+
+export const LOAD_PREBUILT_RULES_BTN = '[data-test-subj="load-prebuilt-rules"]';
+
+export const LOADING_INITIAL_PREBUILT_RULES_TABLE =
+ '[data-test-subj="initialLoadingPanelAllRulesTable"]';
+
+export const LOADING_SPINNER = '[data-test-subj="loading-spinner"]';
+
+export const PAGINATION_POPOVER_BTN = '[data-test-subj="tablePaginationPopoverButton"]';
+
+export const RULES_TABLE = '[data-test-subj="rules-table"]';
+
+export const RULES_ROW = '.euiTableRow';
+
+export const THREE_HUNDRED_ROWS = '[data-test-subj="tablePagination-300-rows"]';
diff --git a/x-pack/legacy/plugins/siem/cypress/tasks/detections.ts b/x-pack/legacy/plugins/siem/cypress/tasks/detections.ts
new file mode 100644
index 00000000000000..4a0a565a74e27d
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/cypress/tasks/detections.ts
@@ -0,0 +1,28 @@
+/*
+ * 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 { LOADING_SIGNALS_PANEL, MANAGE_SIGNAL_DETECTION_RULES_BTN } from '../screens/detections';
+
+export const goToManageSignalDetectionRules = () => {
+ cy.get(MANAGE_SIGNAL_DETECTION_RULES_BTN)
+ .should('exist')
+ .click({ force: true });
+};
+
+export const waitForSignalsIndexToBeCreated = () => {
+ cy.request({ url: '/api/detection_engine/index', retryOnStatusCodeFailure: true }).then(
+ response => {
+ if (response.status !== 200) {
+ cy.wait(7500);
+ }
+ }
+ );
+};
+
+export const waitForSignalsPanelToBeLoaded = () => {
+ cy.get(LOADING_SIGNALS_PANEL).should('exist');
+ cy.get(LOADING_SIGNALS_PANEL).should('not.exist');
+};
diff --git a/x-pack/legacy/plugins/siem/cypress/tasks/signal_detection_rules.ts b/x-pack/legacy/plugins/siem/cypress/tasks/signal_detection_rules.ts
new file mode 100644
index 00000000000000..cc0e4bce1035af
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/cypress/tasks/signal_detection_rules.ts
@@ -0,0 +1,40 @@
+/*
+ * 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 {
+ LOAD_PREBUILT_RULES_BTN,
+ LOADING_INITIAL_PREBUILT_RULES_TABLE,
+ LOADING_SPINNER,
+ PAGINATION_POPOVER_BTN,
+ RULES_TABLE,
+ THREE_HUNDRED_ROWS,
+} from '../screens/signal_detection_rules';
+
+export const changeToThreeHundredRowsPerPage = () => {
+ cy.get(PAGINATION_POPOVER_BTN).click({ force: true });
+ cy.get(THREE_HUNDRED_ROWS).click();
+};
+
+export const loadPrebuiltDetectionRules = () => {
+ cy.get(LOAD_PREBUILT_RULES_BTN)
+ .should('exist')
+ .click({ force: true });
+};
+
+export const waitForLoadElasticPrebuiltDetectionRulesTableToBeLoaded = () => {
+ cy.get(LOADING_INITIAL_PREBUILT_RULES_TABLE).should('exist');
+ cy.get(LOADING_INITIAL_PREBUILT_RULES_TABLE).should('not.exist');
+};
+
+export const waitForPrebuiltDetectionRulesToBeLoaded = () => {
+ cy.get(LOAD_PREBUILT_RULES_BTN).should('not.exist');
+ cy.get(RULES_TABLE).should('exist');
+};
+
+export const waitForRulesToBeLoaded = () => {
+ cy.get(LOADING_SPINNER).should('exist');
+ cy.get(LOADING_SPINNER).should('not.exist');
+};
diff --git a/x-pack/legacy/plugins/siem/cypress/urls/navigation.ts b/x-pack/legacy/plugins/siem/cypress/urls/navigation.ts
index 8fdc939e7ee51d..5e65e5aa34c186 100644
--- a/x-pack/legacy/plugins/siem/cypress/urls/navigation.ts
+++ b/x-pack/legacy/plugins/siem/cypress/urls/navigation.ts
@@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
+export const DETECTIONS = 'app/siem#/detections';
export const HOSTS_PAGE = '/app/siem#/hosts/allHosts';
export const HOSTS_PAGE_TAB_URLS = {
allHosts: '/app/siem#/hosts/allHosts',
diff --git a/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper.tsx b/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper.tsx
index 4b80b9fff2740e..b7d368639ed92c 100644
--- a/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper.tsx
+++ b/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper.tsx
@@ -30,6 +30,24 @@ DragEffects.displayName = 'DragEffects';
export const DraggablePortalContext = createContext(false);
export const useDraggablePortalContext = () => useContext(DraggablePortalContext);
+/**
+ * Wraps the `react-beautiful-dnd` error boundary. See also:
+ * https://github.com/atlassian/react-beautiful-dnd/blob/v12.0.0/docs/guides/setup-problem-detection-and-error-recovery.md
+ *
+ * NOTE: This extends from `PureComponent` because, at the time of this
+ * writing, there's no hook equivalent for `componentDidCatch`, per
+ * https://reactjs.org/docs/hooks-faq.html#do-hooks-cover-all-use-cases-for-classes
+ */
+class DragDropErrorBoundary extends React.PureComponent {
+ componentDidCatch() {
+ this.forceUpdate(); // required for recovery
+ }
+
+ render() {
+ return this.props.children;
+ }
+}
+
const Wrapper = styled.div`
display: inline-block;
max-width: 100%;
@@ -94,50 +112,52 @@ export const DraggableWrapper = React.memo(
return (
-
- {droppableProvided => (
-
-
- {(provided, snapshot) => (
-
-
+
+ {droppableProvided => (
+
+
+ {(provided, snapshot) => (
+
- {truncate && !snapshot.isDragging ? (
-
- {render(dataProvider, provided, snapshot)}
-
- ) : (
-
- {render(dataProvider, provided, snapshot)}
-
- )}
-
-
- )}
-
- {droppableProvided.placeholder}
-
- )}
-
+
+ {truncate && !snapshot.isDragging ? (
+
+ {render(dataProvider, provided, snapshot)}
+
+ ) : (
+
+ {render(dataProvider, provided, snapshot)}
+
+ )}
+
+
+ )}
+
+ {droppableProvided.placeholder}
+
+ )}
+
+
);
},
diff --git a/x-pack/legacy/plugins/siem/public/components/flyout/pane/index.tsx b/x-pack/legacy/plugins/siem/public/components/flyout/pane/index.tsx
index fb977417ffbbfd..38ec4a4b6f1f38 100644
--- a/x-pack/legacy/plugins/siem/public/components/flyout/pane/index.tsx
+++ b/x-pack/legacy/plugins/siem/public/components/flyout/pane/index.tsx
@@ -49,7 +49,7 @@ const EuiFlyoutContainer = styled.div<{ headerHeight: number }>`
.timeline-flyout-body {
overflow-y: hidden;
padding: 0;
- .euiFlyoutBody__overflow {
+ .euiFlyoutBody__overflowContent {
padding: 0;
}
}
diff --git a/x-pack/legacy/plugins/siem/public/components/loader/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/loader/__snapshots__/index.test.tsx.snap
index 0885f15b1efba3..ad2d57b948ba00 100644
--- a/x-pack/legacy/plugins/siem/public/components/loader/__snapshots__/index.test.tsx.snap
+++ b/x-pack/legacy/plugins/siem/public/components/loader/__snapshots__/index.test.tsx.snap
@@ -16,6 +16,7 @@ exports[`rendering renders correctly 1`] = `
grow={false}
>
diff --git a/x-pack/legacy/plugins/siem/public/components/loader/index.tsx b/x-pack/legacy/plugins/siem/public/components/loader/index.tsx
index be2ce3dde951cd..e78f148418588c 100644
--- a/x-pack/legacy/plugins/siem/public/components/loader/index.tsx
+++ b/x-pack/legacy/plugins/siem/public/components/loader/index.tsx
@@ -62,7 +62,7 @@ export const Loader = React.memo(({ children, overlay, overlayBackg
-
+
{children && (
diff --git a/x-pack/legacy/plugins/siem/public/components/ml/api/error_to_toaster.test.ts b/x-pack/legacy/plugins/siem/public/components/ml/api/error_to_toaster.test.ts
index 507d6cf98ed085..d4f38d817bd6be 100644
--- a/x-pack/legacy/plugins/siem/public/components/ml/api/error_to_toaster.test.ts
+++ b/x-pack/legacy/plugins/siem/public/components/ml/api/error_to_toaster.test.ts
@@ -5,7 +5,7 @@
*/
import { isAnError, isToasterError, errorToToaster } from './error_to_toaster';
-import { ToasterErrors } from './throw_if_not_ok';
+import { ToasterErrors } from '../../../hooks/api/throw_if_not_ok';
describe('error_to_toaster', () => {
let dispatchToaster = jest.fn();
diff --git a/x-pack/legacy/plugins/siem/public/components/ml/api/error_to_toaster.ts b/x-pack/legacy/plugins/siem/public/components/ml/api/error_to_toaster.ts
index 779befaa0cd8ef..b341016fff6efa 100644
--- a/x-pack/legacy/plugins/siem/public/components/ml/api/error_to_toaster.ts
+++ b/x-pack/legacy/plugins/siem/public/components/ml/api/error_to_toaster.ts
@@ -7,7 +7,7 @@
import { isError } from 'lodash/fp';
import uuid from 'uuid';
import { ActionToaster, AppToast } from '../../toasters';
-import { ToasterErrorsType, ToasterErrors } from './throw_if_not_ok';
+import { ToasterErrorsType, ToasterErrors } from '../../../hooks/api/throw_if_not_ok';
export type ErrorToToasterArgs = Partial & {
error: unknown;
diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/api.tsx b/x-pack/legacy/plugins/siem/public/components/ml_popover/api.tsx
index 120fd8c404ffde..1ab996f88515ba 100644
--- a/x-pack/legacy/plugins/siem/public/components/ml_popover/api.tsx
+++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/api.tsx
@@ -17,7 +17,7 @@ import {
StartDatafeedResponse,
StopDatafeedResponse,
} from './types';
-import { throwIfErrorAttached, throwIfErrorAttachedToSetup } from '../ml/api/throw_if_not_ok';
+import { throwIfErrorAttached, throwIfErrorAttachedToSetup } from '../../hooks/api/throw_if_not_ok';
import { throwIfNotOk } from '../../hooks/api/api';
import { KibanaServices } from '../../lib/kibana';
diff --git a/x-pack/legacy/plugins/siem/public/containers/case/api.ts b/x-pack/legacy/plugins/siem/public/containers/case/api.ts
index ff03a3799018c1..81f8f83217e111 100644
--- a/x-pack/legacy/plugins/siem/public/containers/case/api.ts
+++ b/x-pack/legacy/plugins/siem/public/containers/case/api.ts
@@ -4,24 +4,29 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { KibanaServices } from '../../lib/kibana';
import {
- AllCases,
- Case,
- CaseSnake,
- Comment,
- CommentSnake,
- FetchCasesProps,
- NewCase,
- NewComment,
- SortFieldCase,
-} from './types';
+ CaseResponse,
+ CasesResponse,
+ CaseRequest,
+ CommentRequest,
+ CommentResponse,
+} from '../../../../../../plugins/case/common/api';
+import { KibanaServices } from '../../lib/kibana';
+import { AllCases, Case, Comment, FetchCasesProps, SortFieldCase } from './types';
import { throwIfNotOk } from '../../hooks/api/api';
import { CASES_URL } from './constants';
-import { convertToCamelCase, convertAllCasesToCamel } from './utils';
+import {
+ convertToCamelCase,
+ convertAllCasesToCamel,
+ decodeCaseResponse,
+ decodeCasesResponse,
+ decodeCommentResponse,
+} from './utils';
+
+const CaseSavedObjectType = 'cases';
export const getCase = async (caseId: string, includeComments: boolean = true): Promise => {
- const response = await KibanaServices.get().http.fetch(`${CASES_URL}/${caseId}`, {
+ const response = await KibanaServices.get().http.fetch(`${CASES_URL}/${caseId}`, {
method: 'GET',
asResponse: true,
query: {
@@ -29,7 +34,16 @@ export const getCase = async (caseId: string, includeComments: boolean = true):
},
});
await throwIfNotOk(response.response);
- return convertToCamelCase(response.body!);
+ return convertToCamelCase(decodeCaseResponse(response.body));
+};
+
+export const getTags = async (): Promise => {
+ const response = await KibanaServices.get().http.fetch(`${CASES_URL}/tags`, {
+ method: 'GET',
+ asResponse: true,
+ });
+ await throwIfNotOk(response.response);
+ return response.body ?? [];
};
export const getCases = async ({
@@ -45,70 +59,74 @@ export const getCases = async ({
sortOrder: 'desc',
},
}: FetchCasesProps): Promise => {
- const stateFilter = `case-workflow.attributes.state: ${filterOptions.state}`;
+ const stateFilter = `${CaseSavedObjectType}.attributes.state: ${filterOptions.state}`;
const tags = [
- ...(filterOptions.tags?.reduce((acc, t) => [...acc, `case-workflow.attributes.tags: ${t}`], [
- stateFilter,
- ]) ?? [stateFilter]),
+ ...(filterOptions.tags?.reduce(
+ (acc, t) => [...acc, `${CaseSavedObjectType}.attributes.tags: ${t}`],
+ [stateFilter]
+ ) ?? [stateFilter]),
];
const query = {
...queryParams,
- filter: tags.join(' AND '),
- search: filterOptions.search,
+ ...(tags.length > 0 ? { filter: tags.join(' AND ') } : {}),
+ ...(filterOptions.search.length > 0 ? { search: filterOptions.search } : {}),
};
- const response = await KibanaServices.get().http.fetch(`${CASES_URL}`, {
+ const response = await KibanaServices.get().http.fetch(`${CASES_URL}/_find`, {
method: 'GET',
query,
asResponse: true,
});
await throwIfNotOk(response.response);
- return convertAllCasesToCamel(response.body!);
+ return convertAllCasesToCamel(decodeCasesResponse(response.body));
};
-export const createCase = async (newCase: NewCase): Promise => {
- const response = await KibanaServices.get().http.fetch(`${CASES_URL}`, {
+export const postCase = async (newCase: CaseRequest): Promise => {
+ const response = await KibanaServices.get().http.fetch(`${CASES_URL}`, {
method: 'POST',
asResponse: true,
body: JSON.stringify(newCase),
});
await throwIfNotOk(response.response);
- return convertToCamelCase(response.body!);
+ return convertToCamelCase(decodeCaseResponse(response.body));
};
-export const updateCaseProperty = async (
+export const patchCase = async (
caseId: string,
- updatedCase: Partial,
+ updatedCase: Partial,
version: string
-): Promise> => {
- const response = await KibanaServices.get().http.fetch(`${CASES_URL}/${caseId}`, {
+): Promise => {
+ const response = await KibanaServices.get().http.fetch(`${CASES_URL}`, {
method: 'PATCH',
asResponse: true,
- body: JSON.stringify({ case: updatedCase, version }),
+ body: JSON.stringify({ ...updatedCase, id: caseId, version }),
});
await throwIfNotOk(response.response);
- return convertToCamelCase, Partial>(response.body!);
+ return convertToCamelCase(decodeCaseResponse(response.body));
};
-export const createComment = async (newComment: NewComment, caseId: string): Promise => {
- const response = await KibanaServices.get().http.fetch(`${CASES_URL}/${caseId}/comment`, {
- method: 'POST',
- asResponse: true,
- body: JSON.stringify(newComment),
- });
+export const postComment = async (newComment: CommentRequest, caseId: string): Promise => {
+ const response = await KibanaServices.get().http.fetch(
+ `${CASES_URL}/${caseId}/comments`,
+ {
+ method: 'POST',
+ asResponse: true,
+ body: JSON.stringify(newComment),
+ }
+ );
await throwIfNotOk(response.response);
- return convertToCamelCase(response.body!);
+ return convertToCamelCase(decodeCommentResponse(response.body));
};
-export const updateComment = async (
+export const patchComment = async (
commentId: string,
commentUpdate: string,
version: string
): Promise> => {
- const response = await KibanaServices.get().http.fetch(`${CASES_URL}/comment/${commentId}`, {
+ const response = await KibanaServices.get().http.fetch(`${CASES_URL}/comments`, {
method: 'PATCH',
asResponse: true,
- body: JSON.stringify({ comment: commentUpdate, version }),
+ body: JSON.stringify({ comment: commentUpdate, id: commentId, version }),
});
await throwIfNotOk(response.response);
- return convertToCamelCase, Partial>(response.body!);
+ return convertToCamelCase(decodeCommentResponse(response.body));
};
diff --git a/x-pack/legacy/plugins/siem/public/containers/case/types.ts b/x-pack/legacy/plugins/siem/public/containers/case/types.ts
index 9cc9f519f3a62c..d479abdbd44891 100644
--- a/x-pack/legacy/plugins/siem/public/containers/case/types.ts
+++ b/x-pack/legacy/plugins/siem/public/containers/case/types.ts
@@ -4,31 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
-interface FormData {
- isNew?: boolean;
-}
-
-export interface NewCase extends FormData {
- description: string;
- tags: string[];
- title: string;
-}
-
-export interface NewComment extends FormData {
- comment: string;
-}
-
-export interface CommentSnake {
- comment_id: string;
- created_at: string;
- created_by: ElasticUserSnake;
- comment: string;
- updated_at: string;
- version: string;
-}
-
export interface Comment {
- commentId: string;
+ id: string;
createdAt: string;
createdBy: ElasticUser;
comment: string;
@@ -36,21 +13,8 @@ export interface Comment {
version: string;
}
-export interface CaseSnake {
- case_id: string;
- comments: CommentSnake[];
- created_at: string;
- created_by: ElasticUserSnake;
- description: string;
- state: string;
- tags: string[];
- title: string;
- updated_at: string;
- version: string;
-}
-
export interface Case {
- caseId: string;
+ id: string;
comments: Comment[];
createdAt: string;
createdBy: ElasticUser;
@@ -75,29 +39,18 @@ export interface FilterOptions {
tags: string[];
}
-export interface AllCasesSnake {
- cases: CaseSnake[];
- page: number;
- per_page: number;
- total: number;
-}
-
export interface AllCases {
cases: Case[];
page: number;
perPage: number;
total: number;
}
+
export enum SortFieldCase {
createdAt = 'createdAt',
updatedAt = 'updatedAt',
}
-export interface ElasticUserSnake {
- readonly username: string;
- readonly full_name?: string | null;
-}
-
export interface ElasticUser {
readonly username: string;
readonly fullName?: string | null;
diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx
index ce71c26078db94..5f1dc96735d32b 100644
--- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx
+++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx
@@ -50,7 +50,7 @@ const dataFetchReducer = (state: CaseState, action: Action): CaseState => {
}
};
const initialData: Case = {
- caseId: '',
+ id: '',
createdAt: '',
comments: [],
createdBy: {
@@ -83,7 +83,11 @@ export const useGetCase = (caseId: string): [CaseState] => {
}
} catch (error) {
if (!didCancel) {
- errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster });
+ errorToToaster({
+ title: i18n.ERROR_TITLE,
+ error: error.body && error.body.message ? new Error(error.body.message) : error,
+ dispatchToaster,
+ });
dispatch({ type: FETCH_FAILURE });
}
}
diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx
index e73b251477bf36..76e9b5c138269b 100644
--- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx
+++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx
@@ -4,15 +4,14 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { Dispatch, SetStateAction, useCallback, useEffect, useReducer, useState } from 'react';
-import { isEqual } from 'lodash/fp';
+import { useCallback, useEffect, useReducer } from 'react';
import { DEFAULT_TABLE_ACTIVE_PAGE, DEFAULT_TABLE_LIMIT } from './constants';
import { AllCases, SortFieldCase, FilterOptions, QueryParams, Case } from './types';
import { errorToToaster } from '../../components/ml/api/error_to_toaster';
import { useStateToaster } from '../../components/toasters';
import * as i18n from './translations';
import { UpdateByKey } from './use_update_case';
-import { getCases, updateCaseProperty } from './api';
+import { getCases, patchCase } from './api';
export interface UseGetCasesState {
caseCount: CaseCount;
@@ -109,11 +108,11 @@ const initialData: AllCases = {
total: 0,
};
interface UseGetCases extends UseGetCasesState {
- dispatchUpdateCaseProperty: Dispatch;
- getCaseCount: Dispatch;
- setFilters: Dispatch>;
- setQueryParams: Dispatch>>;
- setSelectedCases: Dispatch;
+ dispatchUpdateCaseProperty: ({ updateKey, updateValue, caseId, version }: UpdateCase) => void;
+ getCaseCount: (caseState: keyof CaseCount) => void;
+ setFilters: (filters: FilterOptions) => void;
+ setQueryParams: (queryParams: QueryParams) => void;
+ setSelectedCases: (mySelectedCases: Case[]) => void;
}
export const useGetCases = (): UseGetCases => {
const [state, dispatch] = useReducer(dataFetchReducer, {
@@ -138,33 +137,27 @@ export const useGetCases = (): UseGetCases => {
selectedCases: [],
});
const [, dispatchToaster] = useStateToaster();
- const [filterQuery, setFilters] = useState(state.filterOptions);
- const [queryParams, setQueryParams] = useState>(state.queryParams);
const setSelectedCases = useCallback((mySelectedCases: Case[]) => {
dispatch({ type: 'UPDATE_TABLE_SELECTIONS', payload: mySelectedCases });
}, []);
- useEffect(() => {
- if (!isEqual(queryParams, state.queryParams)) {
- dispatch({ type: 'UPDATE_QUERY_PARAMS', payload: queryParams });
- }
- }, [queryParams, state.queryParams]);
+ const setQueryParams = useCallback((newQueryParams: QueryParams) => {
+ dispatch({ type: 'UPDATE_QUERY_PARAMS', payload: newQueryParams });
+ }, []);
- useEffect(() => {
- if (!isEqual(filterQuery, state.filterOptions)) {
- dispatch({ type: 'UPDATE_FILTER_OPTIONS', payload: filterQuery });
- }
- }, [filterQuery, state.filterOptions]);
+ const setFilters = useCallback((newFilters: FilterOptions) => {
+ dispatch({ type: 'UPDATE_FILTER_OPTIONS', payload: newFilters });
+ }, []);
- const fetchCases = useCallback(() => {
+ const fetchCases = useCallback((filterOptions: FilterOptions, queryParams: QueryParams) => {
let didCancel = false;
const fetchData = async () => {
dispatch({ type: 'FETCH_INIT', payload: 'cases' });
try {
const response = await getCases({
- filterOptions: state.filterOptions,
- queryParams: state.queryParams,
+ filterOptions,
+ queryParams,
});
if (!didCancel) {
dispatch({
@@ -174,7 +167,11 @@ export const useGetCases = (): UseGetCases => {
}
} catch (error) {
if (!didCancel) {
- errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster });
+ errorToToaster({
+ title: i18n.ERROR_TITLE,
+ error: error.body && error.body.message ? new Error(error.body.message) : error,
+ dispatchToaster,
+ });
dispatch({ type: 'FETCH_FAILURE', payload: 'cases' });
}
}
@@ -183,8 +180,12 @@ export const useGetCases = (): UseGetCases => {
return () => {
didCancel = true;
};
- }, [state.queryParams, state.filterOptions]);
- useEffect(() => fetchCases(), [state.queryParams, state.filterOptions]);
+ }, []);
+
+ useEffect(() => fetchCases(state.filterOptions, state.queryParams), [
+ state.queryParams,
+ state.filterOptions,
+ ]);
const getCaseCount = useCallback((caseState: keyof CaseCount) => {
let didCancel = false;
@@ -219,14 +220,14 @@ export const useGetCases = (): UseGetCases => {
const fetchData = async () => {
dispatch({ type: 'FETCH_INIT', payload: 'caseUpdate' });
try {
- await updateCaseProperty(
+ await patchCase(
caseId,
{ [updateKey]: updateValue },
version ?? '' // saved object versions are typed as string | undefined, hope that's not true
);
if (!didCancel) {
dispatch({ type: 'FETCH_UPDATE_CASE_SUCCESS' });
- fetchCases();
+ fetchCases(state.filterOptions, state.queryParams);
getCaseCount('open');
getCaseCount('closed');
}
@@ -242,7 +243,7 @@ export const useGetCases = (): UseGetCases => {
didCancel = true;
};
},
- [filterQuery, state.filterOptions]
+ [state.filterOptions, state.queryParams]
);
return {
diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_tags.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_tags.tsx
index f796ae550c9ec3..7d3e00a4f2be4c 100644
--- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_tags.tsx
+++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_tags.tsx
@@ -5,12 +5,12 @@
*/
import { useEffect, useReducer } from 'react';
-import chrome from 'ui/chrome';
import { useStateToaster } from '../../components/toasters';
import { errorToToaster } from '../../components/ml/api/error_to_toaster';
-import * as i18n from './translations';
+
+import { getTags } from './api';
import { FETCH_FAILURE, FETCH_INIT, FETCH_SUCCESS } from './constants';
-import { throwIfNotOk } from '../../hooks/api/api';
+import * as i18n from './translations';
interface TagsState {
data: string[];
@@ -63,22 +63,17 @@ export const useGetTags = (): [TagsState] => {
const fetchData = async () => {
dispatch({ type: FETCH_INIT });
try {
- const response = await fetch(`${chrome.getBasePath()}/api/cases/tags`, {
- method: 'GET',
- credentials: 'same-origin',
- headers: {
- 'content-type': 'application/json',
- 'kbn-system-api': 'true',
- },
- });
+ const response = await getTags();
if (!didCancel) {
- await throwIfNotOk(response);
- const responseJson = await response.json();
- dispatch({ type: FETCH_SUCCESS, payload: responseJson });
+ dispatch({ type: FETCH_SUCCESS, payload: response });
}
} catch (error) {
if (!didCancel) {
- errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster });
+ errorToToaster({
+ title: i18n.ERROR_TITLE,
+ error: error.body && error.body.message ? new Error(error.body.message) : error,
+ dispatchToaster,
+ });
dispatch({ type: FETCH_FAILURE });
}
}
diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_post_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_post_case.tsx
index 0fcc8a3a1abecb..7497b30395155d 100644
--- a/x-pack/legacy/plugins/siem/public/containers/case/use_post_case.tsx
+++ b/x-pack/legacy/plugins/siem/public/containers/case/use_post_case.tsx
@@ -4,24 +4,25 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { Dispatch, SetStateAction, useEffect, useReducer, useState } from 'react';
+import { useReducer, useCallback } from 'react';
+
+import { CaseRequest } from '../../../../../../plugins/case/common/api';
import { useStateToaster } from '../../components/toasters';
import { errorToToaster } from '../../components/ml/api/error_to_toaster';
+
+import { postCase } from './api';
+import { FETCH_FAILURE, FETCH_INIT, FETCH_SUCCESS } from './constants';
import * as i18n from './translations';
-import { FETCH_FAILURE, FETCH_INIT, FETCH_SUCCESS, POST_NEW_CASE } from './constants';
-import { Case, NewCase } from './types';
-import { createCase } from './api';
-import { getTypedPayload } from './utils';
+import { Case } from './types';
interface NewCaseState {
- data: NewCase;
- newCase?: Case;
+ caseData: Case | null;
isLoading: boolean;
isError: boolean;
}
interface Action {
type: string;
- payload?: NewCase | Case;
+ payload?: Case;
}
const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => {
@@ -32,19 +33,12 @@ const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState =>
isLoading: true,
isError: false,
};
- case POST_NEW_CASE:
- return {
- ...state,
- isLoading: false,
- isError: false,
- data: getTypedPayload(action.payload),
- };
case FETCH_SUCCESS:
return {
...state,
isLoading: false,
isError: false,
- newCase: getTypedPayload(action.payload),
+ caseData: action.payload ?? null,
};
case FETCH_FAILURE:
return {
@@ -56,41 +50,43 @@ const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState =>
throw new Error();
}
};
-const initialData: NewCase = {
- description: '',
- isNew: false,
- tags: [],
- title: '',
-};
-export const usePostCase = (): [NewCaseState, Dispatch>] => {
+interface UsePostCase extends NewCaseState {
+ postCase: (data: CaseRequest) => void;
+}
+export const usePostCase = (): UsePostCase => {
const [state, dispatch] = useReducer(dataFetchReducer, {
isLoading: false,
isError: false,
- data: initialData,
+ caseData: null,
});
- const [formData, setFormData] = useState(initialData);
const [, dispatchToaster] = useStateToaster();
- useEffect(() => {
- dispatch({ type: POST_NEW_CASE, payload: formData });
- }, [formData]);
-
- useEffect(() => {
- const postCase = async () => {
+ const postMyCase = useCallback(async (data: CaseRequest) => {
+ let cancel = false;
+ try {
dispatch({ type: FETCH_INIT });
- try {
- const { isNew, ...dataWithoutIsNew } = state.data;
- const response = await createCase(dataWithoutIsNew);
- dispatch({ type: FETCH_SUCCESS, payload: response });
- } catch (error) {
- errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster });
+ const response = await postCase({ ...data, state: 'open' });
+ if (!cancel) {
+ dispatch({
+ type: FETCH_SUCCESS,
+ payload: response,
+ });
+ }
+ } catch (error) {
+ if (!cancel) {
+ errorToToaster({
+ title: i18n.ERROR_TITLE,
+ error: error.body && error.body.message ? new Error(error.body.message) : error,
+ dispatchToaster,
+ });
dispatch({ type: FETCH_FAILURE });
}
- };
- if (state.data.isNew) {
- postCase();
}
- }, [state.data.isNew]);
- return [state, setFormData];
+ return () => {
+ cancel = true;
+ };
+ }, []);
+
+ return { ...state, postCase: postMyCase };
};
diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_post_comment.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_post_comment.tsx
index d8abda25af286b..63d24e2935c2a7 100644
--- a/x-pack/legacy/plugins/siem/public/containers/case/use_post_comment.tsx
+++ b/x-pack/legacy/plugins/siem/public/containers/case/use_post_comment.tsx
@@ -4,25 +4,26 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { Dispatch, SetStateAction, useEffect, useReducer, useState } from 'react';
+import { useReducer, useCallback } from 'react';
+
+import { CommentRequest } from '../../../../../../plugins/case/common/api';
import { useStateToaster } from '../../components/toasters';
import { errorToToaster } from '../../components/ml/api/error_to_toaster';
+
+import { postComment } from './api';
+import { FETCH_FAILURE, FETCH_INIT, FETCH_SUCCESS } from './constants';
import * as i18n from './translations';
-import { FETCH_FAILURE, FETCH_INIT, FETCH_SUCCESS, POST_NEW_COMMENT } from './constants';
-import { Comment, NewComment } from './types';
-import { createComment } from './api';
-import { getTypedPayload } from './utils';
+import { Comment } from './types';
interface NewCommentState {
- data: NewComment;
- newComment?: Comment;
+ commentData: Comment | null;
isLoading: boolean;
isError: boolean;
caseId: string;
}
interface Action {
type: string;
- payload?: NewComment | Comment;
+ payload?: Comment;
}
const dataFetchReducer = (state: NewCommentState, action: Action): NewCommentState => {
@@ -33,19 +34,12 @@ const dataFetchReducer = (state: NewCommentState, action: Action): NewCommentSta
isLoading: true,
isError: false,
};
- case POST_NEW_COMMENT:
- return {
- ...state,
- isLoading: false,
- isError: false,
- data: getTypedPayload(action.payload),
- };
case FETCH_SUCCESS:
return {
...state,
isLoading: false,
isError: false,
- newComment: getTypedPayload(action.payload),
+ commentData: action.payload ?? null,
};
case FETCH_FAILURE:
return {
@@ -57,41 +51,42 @@ const dataFetchReducer = (state: NewCommentState, action: Action): NewCommentSta
throw new Error();
}
};
-const initialData: NewComment = {
- comment: '',
-};
-export const usePostComment = (
- caseId: string
-): [NewCommentState, Dispatch>] => {
+interface UsePostComment extends NewCommentState {
+ postComment: (data: CommentRequest) => void;
+}
+
+export const usePostComment = (caseId: string): UsePostComment => {
const [state, dispatch] = useReducer(dataFetchReducer, {
+ commentData: null,
isLoading: false,
isError: false,
caseId,
- data: initialData,
});
- const [formData, setFormData] = useState(initialData);
const [, dispatchToaster] = useStateToaster();
- useEffect(() => {
- dispatch({ type: POST_NEW_COMMENT, payload: formData });
- }, [formData]);
-
- useEffect(() => {
- const postComment = async () => {
+ const postMyComment = useCallback(async (data: CommentRequest) => {
+ let cancel = false;
+ try {
dispatch({ type: FETCH_INIT });
- try {
- const { isNew, ...dataWithoutIsNew } = state.data;
- const response = await createComment(dataWithoutIsNew, state.caseId);
+ const response = await postComment(data, state.caseId);
+ if (!cancel) {
dispatch({ type: FETCH_SUCCESS, payload: response });
- } catch (error) {
- errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster });
+ }
+ } catch (error) {
+ if (!cancel) {
+ errorToToaster({
+ title: i18n.ERROR_TITLE,
+ error: error.body && error.body.message ? new Error(error.body.message) : error,
+ dispatchToaster,
+ });
dispatch({ type: FETCH_FAILURE });
}
- };
- if (state.data.isNew) {
- postComment();
}
- }, [state.data.isNew]);
- return [state, setFormData];
+ return () => {
+ cancel = true;
+ };
+ }, []);
+
+ return { ...state, postComment: postMyComment };
};
diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx
index f23be526fbeb7e..21c8fb5dc7032f 100644
--- a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx
+++ b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx
@@ -4,19 +4,22 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { useReducer } from 'react';
+import { useReducer, useCallback } from 'react';
+
+import { CaseRequest } from '../../../../../../plugins/case/common/api';
import { useStateToaster } from '../../components/toasters';
import { errorToToaster } from '../../components/ml/api/error_to_toaster';
-import * as i18n from './translations';
+
+import { patchCase } from './api';
import { FETCH_FAILURE, FETCH_INIT, FETCH_SUCCESS } from './constants';
+import * as i18n from './translations';
import { Case } from './types';
-import { updateCaseProperty } from './api';
import { getTypedPayload } from './utils';
-type UpdateKey = keyof Case;
+type UpdateKey = keyof CaseRequest;
interface NewCaseState {
- data: Case;
+ caseData: Case;
isLoading: boolean;
isError: boolean;
updateKey: UpdateKey | null;
@@ -24,12 +27,12 @@ interface NewCaseState {
export interface UpdateByKey {
updateKey: UpdateKey;
- updateValue: Case[UpdateKey];
+ updateValue: CaseRequest[UpdateKey];
}
interface Action {
type: string;
- payload?: Partial | UpdateKey;
+ payload?: Case | UpdateKey;
}
const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => {
@@ -47,10 +50,7 @@ const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState =>
...state,
isLoading: false,
isError: false,
- data: {
- ...state.data,
- ...getTypedPayload(action.payload),
- },
+ caseData: getTypedPayload(action.payload),
updateKey: null,
};
case FETCH_FAILURE:
@@ -65,32 +65,47 @@ const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState =>
}
};
-export const useUpdateCase = (
- caseId: string,
- initialData: Case
-): [NewCaseState, (updates: UpdateByKey) => void] => {
+interface UseUpdateCase extends NewCaseState {
+ updateCaseProperty: (updates: UpdateByKey) => void;
+}
+export const useUpdateCase = (caseId: string, initialData: Case): UseUpdateCase => {
const [state, dispatch] = useReducer(dataFetchReducer, {
isLoading: false,
isError: false,
- data: initialData,
+ caseData: initialData,
updateKey: null,
});
const [, dispatchToaster] = useStateToaster();
- const dispatchUpdateCaseProperty = async ({ updateKey, updateValue }: UpdateByKey) => {
- dispatch({ type: FETCH_INIT, payload: updateKey });
- try {
- const response = await updateCaseProperty(
- caseId,
- { [updateKey]: updateValue },
- state.data.version ?? '' // saved object versions are typed as string | undefined, hope that's not true
- );
- dispatch({ type: FETCH_SUCCESS, payload: response });
- } catch (error) {
- errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster });
- dispatch({ type: FETCH_FAILURE });
- }
- };
+ const dispatchUpdateCaseProperty = useCallback(
+ async ({ updateKey, updateValue }: UpdateByKey) => {
+ let cancel = false;
+ try {
+ dispatch({ type: FETCH_INIT, payload: updateKey });
+ const response = await patchCase(
+ caseId,
+ { [updateKey]: updateValue },
+ state.caseData.version
+ );
+ if (!cancel) {
+ dispatch({ type: FETCH_SUCCESS, payload: response });
+ }
+ } catch (error) {
+ if (!cancel) {
+ errorToToaster({
+ title: i18n.ERROR_TITLE,
+ error: error.body && error.body.message ? new Error(error.body.message) : error,
+ dispatchToaster,
+ });
+ dispatch({ type: FETCH_FAILURE });
+ }
+ }
+ return () => {
+ cancel = true;
+ };
+ },
+ [state]
+ );
- return [state, dispatchUpdateCaseProperty];
+ return { ...state, updateCaseProperty: dispatchUpdateCaseProperty };
};
diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx
index bc8369117433a2..d7649cb7d8fdb4 100644
--- a/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx
+++ b/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx
@@ -4,17 +4,19 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { useReducer, useRef } from 'react';
+import { useReducer, useCallback } from 'react';
+
import { useStateToaster } from '../../components/toasters';
import { errorToToaster } from '../../components/ml/api/error_to_toaster';
-import * as i18n from './translations';
+
+import { patchComment } from './api';
import { FETCH_FAILURE, FETCH_INIT, FETCH_SUCCESS } from './constants';
+import * as i18n from './translations';
import { Comment } from './types';
-import { updateComment } from './api';
import { getTypedPayload } from './utils';
-interface CommetUpdateState {
- data: Comment[];
+interface CommentUpdateState {
+ comments: Comment[];
isLoadingIds: string[];
isError: boolean;
}
@@ -29,7 +31,7 @@ interface Action {
payload?: CommentUpdate | string;
}
-const dataFetchReducer = (state: CommetUpdateState, action: Action): CommetUpdateState => {
+const dataFetchReducer = (state: CommentUpdateState, action: Action): CommentUpdateState => {
switch (action.type) {
case FETCH_INIT:
return {
@@ -40,15 +42,19 @@ const dataFetchReducer = (state: CommetUpdateState, action: Action): CommetUpdat
case FETCH_SUCCESS:
const updatePayload = getTypedPayload(action.payload);
- const foundIndex = state.data.findIndex(
- comment => comment.commentId === updatePayload.commentId
+ const foundIndex = state.comments.findIndex(
+ comment => comment.id === updatePayload.commentId
);
- state.data[foundIndex] = { ...state.data[foundIndex], ...updatePayload.update };
+ const newComments = state.comments;
+ if (foundIndex !== -1) {
+ newComments[foundIndex] = { ...state.comments[foundIndex], ...updatePayload.update };
+ }
+
return {
...state,
isLoadingIds: state.isLoadingIds.filter(id => updatePayload.commentId !== id),
isError: false,
- data: [...state.data],
+ comments: newComments,
};
case FETCH_FAILURE:
return {
@@ -63,30 +69,46 @@ const dataFetchReducer = (state: CommetUpdateState, action: Action): CommetUpdat
}
};
-export const useUpdateComment = (
- comments: Comment[]
-): [CommetUpdateState, (commentId: string, commentUpdate: string) => void] => {
+interface UseUpdateComment extends CommentUpdateState {
+ updateComment: (commentId: string, commentUpdate: string) => void;
+}
+
+export const useUpdateComment = (comments: Comment[]): UseUpdateComment => {
const [state, dispatch] = useReducer(dataFetchReducer, {
isLoadingIds: [],
isError: false,
- data: comments,
+ comments,
});
- const dispatchUpdateComment = useRef<(commentId: string, commentUpdate: string) => void>();
const [, dispatchToaster] = useStateToaster();
- dispatchUpdateComment.current = async (commentId: string, commentUpdate: string) => {
- dispatch({ type: FETCH_INIT, payload: commentId });
- try {
- const currentComment = state.data.find(comment => comment.commentId === commentId) ?? {
- version: '',
+ const dispatchUpdateComment = useCallback(
+ async (commentId: string, commentUpdate: string) => {
+ let cancel = false;
+ try {
+ dispatch({ type: FETCH_INIT, payload: commentId });
+ const currentComment = state.comments.find(comment => comment.id === commentId) ?? {
+ version: '',
+ };
+ const response = await patchComment(commentId, commentUpdate, currentComment.version);
+ if (!cancel) {
+ dispatch({ type: FETCH_SUCCESS, payload: { update: response, commentId } });
+ }
+ } catch (error) {
+ if (!cancel) {
+ errorToToaster({
+ title: i18n.ERROR_TITLE,
+ error: error.body && error.body.message ? new Error(error.body.message) : error,
+ dispatchToaster,
+ });
+ dispatch({ type: FETCH_FAILURE, payload: commentId });
+ }
+ }
+ return () => {
+ cancel = true;
};
- const response = await updateComment(commentId, commentUpdate, currentComment.version);
- dispatch({ type: FETCH_SUCCESS, payload: { update: response, commentId } });
- } catch (error) {
- errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster });
- dispatch({ type: FETCH_FAILURE, payload: commentId });
- }
- };
+ },
+ [state]
+ );
- return [state, dispatchUpdateComment.current];
+ return { ...state, updateComment: dispatchUpdateComment };
};
diff --git a/x-pack/legacy/plugins/siem/public/containers/case/utils.ts b/x-pack/legacy/plugins/siem/public/containers/case/utils.ts
index 14a3819bdfdad6..a377c496fe7267 100644
--- a/x-pack/legacy/plugins/siem/public/containers/case/utils.ts
+++ b/x-pack/legacy/plugins/siem/public/containers/case/utils.ts
@@ -5,7 +5,21 @@
*/
import { camelCase, isArray, isObject, set } from 'lodash';
-import { AllCases, AllCasesSnake, Case, CaseSnake } from './types';
+import { fold } from 'fp-ts/lib/Either';
+import { identity } from 'fp-ts/lib/function';
+import { pipe } from 'fp-ts/lib/pipeable';
+
+import {
+ CaseResponse,
+ CaseResponseRt,
+ CasesResponse,
+ CasesResponseRt,
+ throwErrors,
+ CommentResponse,
+ CommentResponseRt,
+} from '../../../../../../plugins/case/common/api';
+import { ToasterErrors } from '../../hooks/api/throw_if_not_ok';
+import { AllCases, Case } from './types';
export const getTypedPayload = (a: unknown): T => a as T;
@@ -32,9 +46,20 @@ export const convertToCamelCase = (snakeCase: T): U =>
return acc;
}, {} as U);
-export const convertAllCasesToCamel = (snakeCases: AllCasesSnake): AllCases => ({
- cases: snakeCases.cases.map(snakeCase => convertToCamelCase(snakeCase)),
+export const convertAllCasesToCamel = (snakeCases: CasesResponse): AllCases => ({
+ cases: snakeCases.cases.map(snakeCase => convertToCamelCase(snakeCase)),
page: snakeCases.page,
perPage: snakeCases.per_page,
total: snakeCases.total,
});
+
+export const createToasterPlainError = (message: string) => new ToasterErrors([message]);
+
+export const decodeCaseResponse = (respCase?: CaseResponse) =>
+ pipe(CaseResponseRt.decode(respCase), fold(throwErrors(createToasterPlainError), identity));
+
+export const decodeCasesResponse = (respCases?: CasesResponse) =>
+ pipe(CasesResponseRt.decode(respCases), fold(throwErrors(createToasterPlainError), identity));
+
+export const decodeCommentResponse = (respComment?: CommentResponse) =>
+ pipe(CommentResponseRt.decode(respComment), fold(throwErrors(createToasterPlainError), identity));
diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts
index b348678e789f88..05446577a0fa05 100644
--- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts
+++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts
@@ -20,7 +20,7 @@ import {
getPrePackagedRulesStatus,
} from './api';
import { ruleMock, rulesMock } from './mock';
-import { ToasterErrors } from '../../../components/ml/api/throw_if_not_ok';
+import { ToasterErrors } from '../../../hooks/api/throw_if_not_ok';
const abortCtrl = new AbortController();
const mockKibanaServices = KibanaServices.get as jest.Mock;
diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/errors_types/get_index_error.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/errors_types/get_index_error.ts
index 4f45b480772f2b..79dae5b8acb875 100644
--- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/errors_types/get_index_error.ts
+++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/errors_types/get_index_error.ts
@@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { MessageBody } from '../../../../components/ml/api/throw_if_not_ok';
+import { MessageBody } from '../../../../hooks/api/throw_if_not_ok';
export class SignalIndexError extends Error {
message: string = '';
diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/errors_types/post_index_error.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/errors_types/post_index_error.ts
index d6d8cccfb45401..227699af71b421 100644
--- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/errors_types/post_index_error.ts
+++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/errors_types/post_index_error.ts
@@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { MessageBody } from '../../../../components/ml/api/throw_if_not_ok';
+import { MessageBody } from '../../../../hooks/api/throw_if_not_ok';
export class PostSignalError extends Error {
message: string = '';
diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/errors_types/privilege_user_error.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/errors_types/privilege_user_error.ts
index 5cd458a7fe9aa3..19915e898bbebd 100644
--- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/errors_types/privilege_user_error.ts
+++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/errors_types/privilege_user_error.ts
@@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { MessageBody } from '../../../../components/ml/api/throw_if_not_ok';
+import { MessageBody } from '../../../../hooks/api/throw_if_not_ok';
export class PrivilegeUserError extends Error {
message: string = '';
diff --git a/x-pack/legacy/plugins/siem/public/hooks/api/api.tsx b/x-pack/legacy/plugins/siem/public/hooks/api/api.tsx
index 69848c08fa3f88..1dfd6416531ee1 100644
--- a/x-pack/legacy/plugins/siem/public/hooks/api/api.tsx
+++ b/x-pack/legacy/plugins/siem/public/hooks/api/api.tsx
@@ -6,7 +6,7 @@
import * as i18n from '../translations';
import { StartServices } from '../../plugin';
-import { parseJsonFromBody, ToasterErrors } from '../../components/ml/api/throw_if_not_ok';
+import { parseJsonFromBody, ToasterErrors } from './throw_if_not_ok';
import { IndexPatternSavedObject, IndexPatternSavedObjectAttributes } from '../types';
/**
diff --git a/x-pack/legacy/plugins/siem/public/components/ml/api/throw_if_not_ok.test.ts b/x-pack/legacy/plugins/siem/public/hooks/api/throw_if_not_ok.test.ts
similarity index 99%
rename from x-pack/legacy/plugins/siem/public/components/ml/api/throw_if_not_ok.test.ts
rename to x-pack/legacy/plugins/siem/public/hooks/api/throw_if_not_ok.test.ts
index 9fd00105352031..bc0c765d6f2dfa 100644
--- a/x-pack/legacy/plugins/siem/public/components/ml/api/throw_if_not_ok.test.ts
+++ b/x-pack/legacy/plugins/siem/public/hooks/api/throw_if_not_ok.test.ts
@@ -14,7 +14,7 @@ import {
ToasterErrors,
tryParseResponse,
} from './throw_if_not_ok';
-import { SetupMlResponse } from '../../ml_popover/types';
+import { SetupMlResponse } from '../../components/ml_popover/types';
describe('throw_if_not_ok', () => {
afterEach(() => {
diff --git a/x-pack/legacy/plugins/siem/public/components/ml/api/throw_if_not_ok.ts b/x-pack/legacy/plugins/siem/public/hooks/api/throw_if_not_ok.ts
similarity index 91%
rename from x-pack/legacy/plugins/siem/public/components/ml/api/throw_if_not_ok.ts
rename to x-pack/legacy/plugins/siem/public/hooks/api/throw_if_not_ok.ts
index 6ca843207a15e1..7d70106b0e5627 100644
--- a/x-pack/legacy/plugins/siem/public/components/ml/api/throw_if_not_ok.ts
+++ b/x-pack/legacy/plugins/siem/public/hooks/api/throw_if_not_ok.ts
@@ -6,11 +6,11 @@
import { has } from 'lodash/fp';
-import * as i18n from './translations';
-import { MlError } from '../types';
-import { SetupMlResponse } from '../../ml_popover/types';
+import * as i18n from '../../components/ml/api/translations';
+import { MlError } from '../../components/ml/types';
+import { SetupMlResponse } from '../../components/ml_popover/types';
-export { MessageBody, parseJsonFromBody } from '../../../utils/api';
+export { MessageBody, parseJsonFromBody } from '../../utils/api';
export interface MlStartJobError {
error: MlError;
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx
index c8e0dafcf5742b..16c6101b80d40a 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx
@@ -3,15 +3,17 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
-import React, { useCallback } from 'react';
+
import { EuiButton, EuiLoadingSpinner } from '@elastic/eui';
+import React, { useCallback } from 'react';
import styled from 'styled-components';
-import { Form, useForm, UseField } from '../../../../shared_imports';
-import { NewComment } from '../../../../containers/case/types';
+
+import { CommentRequest } from '../../../../../../../../plugins/case/common/api';
import { usePostComment } from '../../../../containers/case/use_post_comment';
-import { schema } from './schema';
-import * as i18n from '../../translations';
import { MarkdownEditorForm } from '../../../../components/markdown_editor/form';
+import { Form, useForm, UseField } from '../../../../shared_imports';
+import * as i18n from '../../translations';
+import { schema } from './schema';
const MySpinner = styled(EuiLoadingSpinner)`
position: absolute;
@@ -19,24 +21,26 @@ const MySpinner = styled(EuiLoadingSpinner)`
left: 50%;
`;
+const initialCommentValue: CommentRequest = {
+ comment: '',
+};
+
export const AddComment = React.memo<{
caseId: string;
}>(({ caseId }) => {
- const [{ data, isLoading, newComment }, setFormData] = usePostComment(caseId);
- const { form } = useForm({
- defaultValue: data,
+ const { commentData, isLoading, postComment } = usePostComment(caseId);
+ const { form } = useForm({
+ defaultValue: initialCommentValue,
options: { stripEmptyFields: false },
schema,
});
const onSubmit = useCallback(async () => {
- const { isValid, data: newData } = await form.submit();
- if (isValid && newData.comment) {
- setFormData({ ...newData, isNew: true } as NewComment);
- } else if (isValid && data.comment) {
- setFormData({ ...data, ...newData, isNew: true } as NewComment);
+ const { isValid, data } = await form.submit();
+ if (isValid) {
+ await postComment(data);
}
- }, [form, data]);
+ }, [form]);
return (
<>
@@ -64,7 +68,7 @@ export const AddComment = React.memo<{
}}
/>
- {newComment &&
+ {commentData != null &&
'TO DO new comment got added but we didnt update the UI yet. Refresh the page to see your comment ;)'}
>
);
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/schema.tsx
index 5f30f59149d99b..c61874a8dabfc1 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/schema.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/schema.tsx
@@ -3,12 +3,14 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
+
+import { CommentRequest } from '../../../../../../../../plugins/case/common/api';
import { FIELD_TYPES, fieldValidators, FormSchema } from '../../../../shared_imports';
import * as i18n from '../../translations';
const { emptyField } = fieldValidators;
-export const schema: FormSchema = {
+export const schema: FormSchema = {
comment: {
type: FIELD_TYPES.TEXTAREA,
validations: [
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx
index a054d685399bcc..2e57e5f2f95d9f 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx
@@ -11,7 +11,7 @@ export const useGetCasesMockState: UseGetCasesState = {
data: {
cases: [
{
- caseId: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15',
+ id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15',
createdAt: '2020-02-13T19:44:23.627Z',
createdBy: { username: 'elastic' },
comments: [],
@@ -23,7 +23,7 @@ export const useGetCasesMockState: UseGetCasesState = {
version: 'WzQ3LDFd',
},
{
- caseId: '362a5c10-4e99-11ea-9290-35d05cb55c15',
+ id: '362a5c10-4e99-11ea-9290-35d05cb55c15',
createdAt: '2020-02-13T19:44:13.328Z',
createdBy: { username: 'elastic' },
comments: [],
@@ -35,7 +35,7 @@ export const useGetCasesMockState: UseGetCasesState = {
version: 'WzQ3LDFd',
},
{
- caseId: '34f8b9e0-4e99-11ea-9290-35d05cb55c15',
+ id: '34f8b9e0-4e99-11ea-9290-35d05cb55c15',
createdAt: '2020-02-13T19:44:11.328Z',
createdBy: { username: 'elastic' },
comments: [],
@@ -47,7 +47,7 @@ export const useGetCasesMockState: UseGetCasesState = {
version: 'WzQ3LDFd',
},
{
- caseId: '31890e90-4e99-11ea-9290-35d05cb55c15',
+ id: '31890e90-4e99-11ea-9290-35d05cb55c15',
createdAt: '2020-02-13T19:44:05.563Z',
createdBy: { username: 'elastic' },
comments: [],
@@ -59,7 +59,7 @@ export const useGetCasesMockState: UseGetCasesState = {
version: 'WzQ3LDFd',
},
{
- caseId: '2f5b3210-4e99-11ea-9290-35d05cb55c15',
+ id: '2f5b3210-4e99-11ea-9290-35d05cb55c15',
createdAt: '2020-02-13T19:44:01.901Z',
createdBy: { username: 'elastic' },
comments: [],
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/actions.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/actions.tsx
index 5dad19b1e54d30..0ec09f2b579182 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/actions.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/actions.tsx
@@ -24,7 +24,7 @@ export const getActions = ({
icon: 'trash',
name: i18n.DELETE,
// eslint-disable-next-line no-console
- onClick: ({ caseId }: Case) => console.log('TO DO Delete case', caseId),
+ onClick: ({ id }: Case) => console.log('TO DO Delete case', id),
type: 'icon',
'data-test-subj': 'action-delete',
},
@@ -37,7 +37,7 @@ export const getActions = ({
dispatchUpdate({
updateKey: 'state',
updateValue: 'closed',
- caseId: theCase.caseId,
+ caseId: theCase.id,
version: theCase.version,
}),
type: 'icon',
@@ -51,7 +51,7 @@ export const getActions = ({
dispatchUpdate({
updateKey: 'state',
updateValue: 'open',
- caseId: theCase.caseId,
+ caseId: theCase.id,
version: theCase.version,
}),
type: 'icon',
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx
index 41a2bdf52d5a17..f6ed2694fdc402 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx
@@ -42,9 +42,9 @@ export const getCasesColumns = (
{
name: i18n.NAME,
render: (theCase: Case) => {
- if (theCase.caseId != null && theCase.title != null) {
+ if (theCase.id != null && theCase.title != null) {
const caseDetailsLinkComponent = (
- {theCase.title}
+ {theCase.title}
);
return theCase.state === 'open' ? (
caseDetailsLinkComponent
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx
index dd584f3f716b62..40a76c636954ff 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx
@@ -41,7 +41,7 @@ describe('AllCases', () => {
.find(`a[data-test-subj="case-details-link"]`)
.first()
.prop('href')
- ).toEqual(`#/link-to/case/${useGetCasesMockState.data.cases[0].caseId}`);
+ ).toEqual(`#/link-to/case/${useGetCasesMockState.data.cases[0].id}`);
expect(
wrapper
.find(`a[data-test-subj="case-details-link"]`)
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx
index 89d321c6d106a5..c2d3cae6774b0f 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx
@@ -10,11 +10,11 @@ import { Case } from '../../../../../containers/case/types';
export const caseProps: CaseProps = {
caseId: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15',
initialData: {
- caseId: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15',
+ id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15',
comments: [
{
comment: 'Solve this fast!',
- commentId: 'a357c6a0-5435-11ea-b427-fb51a1fcb7b8',
+ id: 'a357c6a0-5435-11ea-b427-fb51a1fcb7b8',
createdAt: '2020-02-20T23:06:33.798Z',
createdBy: {
fullName: 'Steph Milovic',
@@ -36,11 +36,11 @@ export const caseProps: CaseProps = {
};
export const data: Case = {
- caseId: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15',
+ id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15',
comments: [
{
comment: 'Solve this fast!',
- commentId: 'a357c6a0-5435-11ea-b427-fb51a1fcb7b8',
+ id: 'a357c6a0-5435-11ea-b427-fb51a1fcb7b8',
createdAt: '2020-02-20T23:06:33.798Z',
createdBy: {
fullName: 'Steph Milovic',
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx
index 1539b3de5a0c13..e3bbfc0a83d718 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx
@@ -12,16 +12,17 @@ import { caseProps, data } from './__mock__';
import { TestProviders } from '../../../../mock';
describe('CaseView ', () => {
- const dispatchUpdateCaseProperty = jest.fn();
+ const updateCaseProperty = jest.fn();
beforeEach(() => {
jest.resetAllMocks();
- jest
- .spyOn(apiHook, 'useUpdateCase')
- .mockReturnValue([
- { data, isLoading: false, isError: false, updateKey: null },
- dispatchUpdateCaseProperty,
- ]);
+ jest.spyOn(apiHook, 'useUpdateCase').mockReturnValue({
+ caseData: data,
+ isLoading: false,
+ isError: false,
+ updateKey: null,
+ updateCaseProperty,
+ });
});
it('should render CaseComponent', () => {
@@ -79,7 +80,7 @@ describe('CaseView ', () => {
.find('input[data-test-subj="toggle-case-state"]')
.simulate('change', { target: { value: false } });
- expect(dispatchUpdateCaseProperty).toBeCalledWith({
+ expect(updateCaseProperty).toBeCalledWith({
updateKey: 'state',
updateValue: 'closed',
});
@@ -94,7 +95,7 @@ describe('CaseView ', () => {
expect(
wrapper
.find(
- `div[data-test-subj="user-action-${data.comments[0].commentId}-avatar"] [data-test-subj="user-action-avatar"]`
+ `div[data-test-subj="user-action-${data.comments[0].id}-avatar"] [data-test-subj="user-action-avatar"]`
)
.first()
.prop('name')
@@ -103,7 +104,7 @@ describe('CaseView ', () => {
expect(
wrapper
.find(
- `div[data-test-subj="user-action-${data.comments[0].commentId}"] [data-test-subj="user-action-title"] strong`
+ `div[data-test-subj="user-action-${data.comments[0].id}"] [data-test-subj="user-action-title"] strong`
)
.first()
.text()
@@ -112,7 +113,7 @@ describe('CaseView ', () => {
expect(
wrapper
.find(
- `div[data-test-subj="user-action-${data.comments[0].commentId}"] [data-test-subj="markdown"]`
+ `div[data-test-subj="user-action-${data.comments[0].id}"] [data-test-subj="markdown"]`
)
.first()
.prop('source')
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx
index 605f9e8fa17134..c917d27aebea35 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx
@@ -60,10 +60,7 @@ export interface CaseProps {
}
export const CaseComponent = React.memo(({ caseId, initialData }) => {
- const [{ data, isLoading, updateKey }, dispatchUpdateCaseProperty] = useUpdateCase(
- caseId,
- initialData
- );
+ const { caseData, isLoading, updateKey, updateCaseProperty } = useUpdateCase(caseId, initialData);
const onUpdateField = useCallback(
(newUpdateKey: keyof Case, updateValue: Case[keyof Case]) => {
@@ -71,7 +68,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) =>
case 'title':
const titleUpdate = getTypedPayload(updateValue);
if (titleUpdate.length > 0) {
- dispatchUpdateCaseProperty({
+ updateCaseProperty({
updateKey: 'title',
updateValue: titleUpdate,
});
@@ -80,7 +77,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) =>
case 'description':
const descriptionUpdate = getTypedPayload(updateValue);
if (descriptionUpdate.length > 0) {
- dispatchUpdateCaseProperty({
+ updateCaseProperty({
updateKey: 'description',
updateValue: descriptionUpdate,
});
@@ -88,15 +85,15 @@ export const CaseComponent = React.memo(({ caseId, initialData }) =>
break;
case 'tags':
const tagsUpdate = getTypedPayload(updateValue);
- dispatchUpdateCaseProperty({
+ updateCaseProperty({
updateKey: 'tags',
updateValue: tagsUpdate,
});
break;
case 'state':
const stateUpdate = getTypedPayload(updateValue);
- if (data.state !== updateValue) {
- dispatchUpdateCaseProperty({
+ if (caseData.state !== updateValue) {
+ updateCaseProperty({
updateKey: 'state',
updateValue: stateUpdate,
});
@@ -105,7 +102,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) =>
return null;
}
},
- [dispatchUpdateCaseProperty, data.state]
+ [updateCaseProperty, caseData.state]
);
// TO DO refactor each of these const's into their own components
@@ -146,11 +143,11 @@ export const CaseComponent = React.memo(({ caseId, initialData }) =>
titleNode={
}
- title={data.title}
+ title={caseData.title}
>
@@ -160,10 +157,10 @@ export const CaseComponent = React.memo(({ caseId, initialData }) =>
{i18n.STATUS}
- {data.state}
+ {caseData.state}
@@ -172,7 +169,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) =>
@@ -184,10 +181,10 @@ export const CaseComponent = React.memo(({ caseId, initialData }) =>
@@ -204,7 +201,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) =>
@@ -213,11 +210,11 @@ export const CaseComponent = React.memo(({ caseId, initialData }) =>
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx
index 65d7256fd6e20c..840792f510fc00 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx
@@ -14,8 +14,9 @@ import {
} from '@elastic/eui';
import styled, { css } from 'styled-components';
import { Redirect } from 'react-router-dom';
+
+import { CaseRequest } from '../../../../../../../../plugins/case/common/api';
import { Field, Form, getUseField, useForm, UseField } from '../../../../shared_imports';
-import { NewCase } from '../../../../containers/case/types';
import { usePostCase } from '../../../../containers/case/use_post_case';
import { schema } from './schema';
import * as i18n from '../../translations';
@@ -42,30 +43,37 @@ const MySpinner = styled(EuiLoadingSpinner)`
z-index: 99;
`;
+const initialCaseValue: CaseRequest = {
+ description: '',
+ state: 'open',
+ tags: [],
+ title: '',
+};
+
export const Create = React.memo(() => {
- const [{ data, isLoading, newCase }, setFormData] = usePostCase();
+ const { caseData, isLoading, postCase } = usePostCase();
const [isCancel, setIsCancel] = useState(false);
- const { form } = useForm({
- defaultValue: data,
+ const { form } = useForm({
+ defaultValue: initialCaseValue,
options: { stripEmptyFields: false },
schema,
});
const onSubmit = useCallback(async () => {
- const { isValid, data: newData } = await form.submit();
- if (isValid && newData.description) {
- setFormData({ ...newData, isNew: true } as NewCase);
- } else if (isValid && data.description) {
- setFormData({ ...data, ...newData, isNew: true } as NewCase);
+ const { isValid, data } = await form.submit();
+ if (isValid) {
+ await postCase(data);
}
- }, [form, data]);
+ }, [form]);
- if (newCase && newCase.caseId) {
- return ;
+ if (caseData != null && caseData.id) {
+ return ;
}
+
if (isCancel) {
return ;
}
+
return (
{isLoading && }
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/create/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/create/schema.tsx
index c81a31f0d4f3f2..91d3b77493b034 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/create/schema.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/create/schema.tsx
@@ -4,13 +4,21 @@
* you may not use this file except in compliance with the Elastic License.
*/
+import { CaseRequest } from '../../../../../../../../plugins/case/common/api';
import { FIELD_TYPES, fieldValidators, FormSchema } from '../../../../shared_imports';
-import { OptionalFieldLabel } from './optional_field_label';
import * as i18n from '../../translations';
+import { OptionalFieldLabel } from './optional_field_label';
const { emptyField } = fieldValidators;
-export const schema: FormSchema = {
+export const schemaTags = {
+ type: FIELD_TYPES.COMBO_BOX,
+ label: i18n.TAGS,
+ helpText: i18n.TAGS_HELP,
+ labelAppend: OptionalFieldLabel,
+};
+
+export const schema: FormSchema = {
title: {
type: FIELD_TYPES.TEXT,
label: i18n.NAME,
@@ -28,10 +36,5 @@ export const schema: FormSchema = {
},
],
},
- tags: {
- type: FIELD_TYPES.COMBO_BOX,
- label: i18n.TAGS,
- helpText: i18n.TAGS_HELP,
- labelAppend: OptionalFieldLabel,
- },
+ tags: schemaTags,
};
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/tag_list/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/tag_list/schema.tsx
index 26a89408069fbc..50ba114de528e0 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/tag_list/schema.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/tag_list/schema.tsx
@@ -4,8 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { FormSchema } from '../../../../shared_imports';
-import { schema as createSchema } from '../create/schema';
+import { schemaTags } from '../create/schema';
export const schema: FormSchema = {
- tags: createSchema.tags,
+ tags: schemaTags,
};
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx
index 63e0bbeb443c22..b68bfd73e50e9a 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx
@@ -23,10 +23,8 @@ const DescriptionId = 'description';
const NewId = 'newComent';
export const UserActionTree = React.memo(
- ({ data, onUpdateField, isLoadingDescription }: UserActionTreeProps) => {
- const [{ data: comments, isLoadingIds }, dispatchUpdateComment] = useUpdateComment(
- data.comments
- );
+ ({ data: caseData, onUpdateField, isLoadingDescription }: UserActionTreeProps) => {
+ const { comments, isLoadingIds, updateComment } = useUpdateComment(caseData.comments);
const [manageMarkdownEditIds, setManangeMardownEditIds] = useState([]);
@@ -44,16 +42,16 @@ export const UserActionTree = React.memo(
const handleSaveComment = useCallback(
(id: string, content: string) => {
handleManageMarkdownEditId(id);
- dispatchUpdateComment(id, content);
+ updateComment(id, content);
},
- [handleManageMarkdownEditId, dispatchUpdateComment]
+ [handleManageMarkdownEditId, updateComment]
);
const MarkdownDescription = useMemo(
() => (
{
handleManageMarkdownEditId(DescriptionId);
@@ -62,45 +60,45 @@ export const UserActionTree = React.memo(
onChangeEditable={handleManageMarkdownEditId}
/>
),
- [data.description, handleManageMarkdownEditId, manageMarkdownEditIds, onUpdateField]
+ [caseData.description, handleManageMarkdownEditId, manageMarkdownEditIds, onUpdateField]
);
- const MarkdownNewComment = useMemo(() => , [data.caseId]);
+ const MarkdownNewComment = useMemo(() => , [caseData.id]);
return (
<>
{comments.map(comment => (
}
- onEdit={handleManageMarkdownEditId.bind(null, comment.commentId)}
+ onEdit={handleManageMarkdownEditId.bind(null, comment.id)}
userName={comment.createdBy.username}
/>
))}
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 75f19218d9b387..afd325f5399666 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
@@ -290,7 +290,7 @@ const SignalsTableComponent: React.FC = ({
return (
-
+
);
}
diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx
index c3fb907ae83e1e..1bd7ab2c4f1ae0 100644
--- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx
@@ -129,7 +129,12 @@ const DetectionEnginePageComponent: React.FC = ({
}
title={i18n.PAGE_TITLE}
>
-
+
{i18n.BUTTON_MANAGE_RULES}
diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx
index e7d68164c4ef4c..bb718d80298172 100644
--- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx
@@ -317,6 +317,7 @@ export const AllRules = React.memo(
= (
isDisabled={userHasNoPermissions}
isLoading={loading}
onClick={handlePreBuiltCreation}
+ data-test-subj="load-prebuilt-rules"
>
{i18n.PRE_BUILT_ACTION}
diff --git a/x-pack/legacy/plugins/siem/server/lib/case/saved_object_mappings.ts b/x-pack/legacy/plugins/siem/server/lib/case/saved_object_mappings.ts
deleted file mode 100644
index 80cdb9e979a684..00000000000000
--- a/x-pack/legacy/plugins/siem/server/lib/case/saved_object_mappings.ts
+++ /dev/null
@@ -1,81 +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.
- */
-/* eslint-disable @typescript-eslint/no-empty-interface */
-/* eslint-disable @typescript-eslint/camelcase */
-import { CaseAttributes, CommentAttributes } from '../../../../../../../x-pack/plugins/case/server';
-import { ElasticsearchMappingOf } from '../../utils/typed_elasticsearch_mappings';
-
-// Temporary file to write mappings for case
-// while Saved Object Mappings API is programmed for the NP
-// See: https://github.com/elastic/kibana/issues/50309
-
-export const caseSavedObjectType = 'case-workflow';
-export const caseCommentSavedObjectType = 'case-workflow-comment';
-
-export const caseSavedObjectMappings: {
- [caseSavedObjectType]: ElasticsearchMappingOf;
-} = {
- [caseSavedObjectType]: {
- properties: {
- created_at: {
- type: 'date',
- },
- description: {
- type: 'text',
- },
- title: {
- type: 'keyword',
- },
- created_by: {
- properties: {
- username: {
- type: 'keyword',
- },
- full_name: {
- type: 'keyword',
- },
- },
- },
- state: {
- type: 'keyword',
- },
- tags: {
- type: 'keyword',
- },
- updated_at: {
- type: 'date',
- },
- },
- },
-};
-
-export const caseCommentSavedObjectMappings: {
- [caseCommentSavedObjectType]: ElasticsearchMappingOf;
-} = {
- [caseCommentSavedObjectType]: {
- properties: {
- comment: {
- type: 'text',
- },
- created_at: {
- type: 'date',
- },
- created_by: {
- properties: {
- full_name: {
- type: 'keyword',
- },
- username: {
- type: 'keyword',
- },
- },
- },
- updated_at: {
- type: 'date',
- },
- },
- },
-};
diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts
index 1d904b2b349ae7..5fdef59a72f04f 100644
--- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts
+++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts
@@ -43,6 +43,7 @@ export const patchRules = async ({
type,
references,
version,
+ throttle,
}: PatchRuleParams): Promise => {
const rule = await readRules({ alertsClient, ruleId, id });
if (rule == null) {
@@ -73,6 +74,7 @@ export const patchRules = async ({
type,
references,
version,
+ throttle,
});
const nextParams = defaults(
@@ -108,6 +110,7 @@ export const patchRules = async ({
id: rule.id,
data: {
tags: addTags(tags ?? rule.tags, rule.params.ruleId, immutable ?? rule.params.immutable),
+ throttle: throttle ?? rule.throttle ?? null,
name: calculateName({ updatedName: name, originalName: rule.name }),
schedule: {
interval: calculateInterval(interval, rule.schedule.interval),
diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_prepacked_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_prepacked_rules.ts
index c63237c93daf49..7889267a7267b5 100644
--- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_prepacked_rules.ts
+++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_prepacked_rules.ts
@@ -41,6 +41,7 @@ export const updatePrepackagedRules = async (
threat,
references,
version,
+ throttle,
} = rule;
// Note: we do not pass down enabled as we do not want to suddenly disable
@@ -73,6 +74,7 @@ export const updatePrepackagedRules = async (
threat,
references,
version,
+ throttle,
});
});
};
diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts
index 9ead8313b2c91c..3a10841b70d7e1 100644
--- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts
+++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts
@@ -42,6 +42,7 @@ export const updateRules = async ({
type,
references,
version,
+ throttle,
}: UpdateRuleParams): Promise => {
const rule = await readRules({ alertsClient, ruleId, id });
if (rule == null) {
@@ -72,6 +73,7 @@ export const updateRules = async ({
type,
references,
version,
+ throttle,
});
const update = await alertsClient.update({
@@ -81,6 +83,7 @@ export const updateRules = async ({
name,
schedule: { interval },
actions: rule.actions,
+ throttle: throttle ?? rule.throttle ?? null,
params: {
description,
ruleId: rule.params.ruleId,
diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts
index 77eefd3d1d855b..5e5ff157c92c65 100644
--- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts
+++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts
@@ -49,6 +49,7 @@ export interface RuleAlertParams {
threat: ThreatParams[] | undefined | null;
type: 'query' | 'saved_query';
version: number;
+ throttle?: string;
}
export type RuleTypeParams = Omit;
diff --git a/x-pack/legacy/plugins/siem/server/saved_objects.ts b/x-pack/legacy/plugins/siem/server/saved_objects.ts
index 58da333c7bc9a8..76d8837883b8b7 100644
--- a/x-pack/legacy/plugins/siem/server/saved_objects.ts
+++ b/x-pack/legacy/plugins/siem/server/saved_objects.ts
@@ -3,6 +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 { noteSavedObjectType, noteSavedObjectMappings } from './lib/note/saved_object_mappings';
import {
pinnedEventSavedObjectType,
@@ -16,10 +17,6 @@ import {
ruleStatusSavedObjectMappings,
ruleStatusSavedObjectType,
} from './lib/detection_engine/rules/saved_object_mappings';
-import {
- caseSavedObjectMappings,
- caseCommentSavedObjectMappings,
-} from './lib/case/saved_object_mappings';
export {
noteSavedObjectType,
@@ -31,8 +28,5 @@ export const savedObjectMappings = {
...timelineSavedObjectMappings,
...noteSavedObjectMappings,
...pinnedEventSavedObjectMappings,
- // TODO: Remove once while Saved Object Mappings API is programmed for the NP See: https://github.com/elastic/kibana/issues/50309
- ...caseSavedObjectMappings,
- ...caseCommentSavedObjectMappings,
...ruleStatusSavedObjectMappings,
};
diff --git a/x-pack/legacy/plugins/transform/index.ts b/x-pack/legacy/plugins/transform/index.ts
index 10f4732152c43a..a4b980c0bf8f3a 100644
--- a/x-pack/legacy/plugins/transform/index.ts
+++ b/x-pack/legacy/plugins/transform/index.ts
@@ -4,19 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { resolve } from 'path';
-
-import { PLUGIN } from './common/constants';
-
export function transform(kibana: any) {
return new kibana.Plugin({
- id: PLUGIN.ID,
+ id: 'transform',
configPrefix: 'xpack.transform',
- publicDir: resolve(__dirname, 'public'),
- require: ['kibana', 'elasticsearch', 'xpack_main'],
- uiExports: {
- styleSheetPaths: resolve(__dirname, 'public/app/index.scss'),
- managementSections: ['plugins/transform'],
- },
});
}
diff --git a/x-pack/legacy/plugins/transform/public/app/hooks/use_api.ts b/x-pack/legacy/plugins/transform/public/app/hooks/use_api.ts
deleted file mode 100644
index 802599aaedd4f8..00000000000000
--- a/x-pack/legacy/plugins/transform/public/app/hooks/use_api.ts
+++ /dev/null
@@ -1,103 +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 { useAppDependencies } from '../app_dependencies';
-import { PreviewRequestBody, TransformId } from '../common';
-import { httpFactory, Http } from '../services/http_service';
-
-import { EsIndex, TransformEndpointRequest, TransformEndpointResult } from './use_api_types';
-
-const apiFactory = (basePath: string, indicesBasePath: string, http: Http) => ({
- getTransforms(transformId?: TransformId): Promise {
- const transformIdString = transformId !== undefined ? `/${transformId}` : '';
- return http({
- url: `${basePath}/transforms${transformIdString}`,
- method: 'GET',
- });
- },
- getTransformsStats(transformId?: TransformId): Promise {
- if (transformId !== undefined) {
- return http({
- url: `${basePath}/transforms/${transformId}/_stats`,
- method: 'GET',
- });
- }
-
- return http({
- url: `${basePath}/transforms/_stats`,
- method: 'GET',
- });
- },
- createTransform(transformId: TransformId, transformConfig: any): Promise {
- return http({
- url: `${basePath}/transforms/${transformId}`,
- method: 'PUT',
- data: transformConfig,
- });
- },
- deleteTransforms(transformsInfo: TransformEndpointRequest[]) {
- return http({
- url: `${basePath}/delete_transforms`,
- method: 'POST',
- data: transformsInfo,
- }) as Promise;
- },
- getTransformsPreview(obj: PreviewRequestBody): Promise {
- return http({
- url: `${basePath}/transforms/_preview`,
- method: 'POST',
- data: obj,
- });
- },
- startTransforms(transformsInfo: TransformEndpointRequest[]) {
- return http({
- url: `${basePath}/start_transforms`,
- method: 'POST',
- data: {
- transformsInfo,
- },
- }) as Promise;
- },
- stopTransforms(transformsInfo: TransformEndpointRequest[]) {
- return http({
- url: `${basePath}/stop_transforms`,
- method: 'POST',
- data: {
- transformsInfo,
- },
- }) as Promise;
- },
- getTransformAuditMessages(transformId: TransformId): Promise {
- return http({
- url: `${basePath}/transforms/${transformId}/messages`,
- method: 'GET',
- });
- },
- esSearch(payload: any) {
- return http({
- url: `${basePath}/es_search`,
- method: 'POST',
- data: payload,
- }) as Promise;
- },
- getIndices() {
- return http({
- url: `${indicesBasePath}/index_management/indices`,
- method: 'GET',
- }) as Promise;
- },
-});
-
-export const useApi = () => {
- const appDeps = useAppDependencies();
-
- const basePath = appDeps.core.http.basePath.prepend('/api/transform');
- const indicesBasePath = appDeps.core.http.basePath.prepend('/api');
- const xsrfToken = appDeps.plugins.xsrfToken;
- const http = httpFactory(xsrfToken);
-
- return apiFactory(basePath, indicesBasePath, http);
-};
diff --git a/x-pack/legacy/plugins/transform/public/app/hooks/use_api_types.ts b/x-pack/legacy/plugins/transform/public/app/hooks/use_api_types.ts
deleted file mode 100644
index d0f81a058b7b3c..00000000000000
--- a/x-pack/legacy/plugins/transform/public/app/hooks/use_api_types.ts
+++ /dev/null
@@ -1,25 +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 { TransformId, TRANSFORM_STATE } from '../common';
-
-export interface EsIndex {
- name: string;
-}
-
-export interface TransformEndpointRequest {
- id: TransformId;
- state?: TRANSFORM_STATE;
-}
-
-export interface ResultData {
- success: boolean;
- error?: any;
-}
-
-export interface TransformEndpointResult {
- [key: string]: ResultData;
-}
diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/use_source_index_data.test.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/use_source_index_data.test.tsx
deleted file mode 100644
index 715573e3a6f67d..00000000000000
--- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/use_source_index_data.test.tsx
+++ /dev/null
@@ -1,66 +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 React, { FC } from 'react';
-import ReactDOM from 'react-dom';
-import { act } from 'react-dom/test-utils';
-
-import { SimpleQuery } from '../../../../common';
-import {
- SOURCE_INDEX_STATUS,
- useSourceIndexData,
- UseSourceIndexDataReturnType,
-} from './use_source_index_data';
-
-jest.mock('../../../../hooks/use_api');
-
-type Callback = () => void;
-interface TestHookProps {
- callback: Callback;
-}
-
-const TestHook: FC